merge: intègre main — main prioritaire, abandon de la page en doublon

main a beaucoup avancé (53 commits) pendant que cette branche construisait
l'API. La fusion a révélé que /produits, sur main, fait la même chose que la
page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités,
URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits).

Résolution, main prioritaire :
- sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son
  travail sur la tendance et Qlik est postérieur et plus complet.
- get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel
  upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir.
- settings : les deux côtés (ServerLogs de main + les sections API d'ici).

Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et
composants. searchNetworkProductsPlaywright disparaît avec la version main de
qlik-playwright ; rien d'autre ne l'utilisait.

api-enrich se branche désormais sur le module de tendance de main
(features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure
est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue
« vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi
exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et
nouveau.

Apport conservé de la branche : l'API /api/v1, absente de main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
Claude committed 2026-08-06 11:13:44 +00:00
commit 3fa92e891b
50 files changed
+8488 -2017

No files matched your search

+28 -10
View File
@@ -22,17 +22,26 @@ import "server-only";
import type { ProductRow } from "@/types/grid";
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
import { pgGetGammesByCodeins } from "@/lib/pg-ff-client";
import { computeNetworkTrend, trendLabel, type TrendDirection } from "@/lib/network-trend";
// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans
// l'application, pas une seconde implémentation qui divergerait.
import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend";
/** Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille. */
export interface ProductTrend {
/** "up" | "down" | "flat" — seuil à ±8 % de variation modélisée. */
direction: TrendDirection;
/** Variation modélisée sur la période (0.23 = +23 %). `null` si non calculable. */
direction: "up" | "down" | "flat";
/** Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base. */
pct: number | null;
/** Libellé prêt à afficher : « Forte hausse », « Stable »… */
/** Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »… */
label: string;
/** Nombre de mois effectivement utilisés (12 au maximum). */
/**
* `true` = tendance calculée sur la quantité **par magasin vendeur**, ce qui
* neutralise l'effet d'un simple élargissement de la distribution. `false` =
* repli sur les quantités brutes, faute de nombre de magasins sur 12 mois.
*/
surQteParMagasin: boolean;
/** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */
nouveau: boolean;
/** Nombre de mois de la série (12 quand elle est complète). */
monthsUsed: number;
}
@@ -110,15 +119,24 @@ export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow
enriched.networkFetchedAt = metrics.fetchedAt ?? undefined;
}
// Tendance calculée sur la série finale (celle du cache si elle existe,
// sinon celle de l'instantané) — même entrée que la colonne « Tendance ».
// Tendance : mêmes entrées que la colonne « Tendance » de la Grille, à
// savoir la série des quantités **et** celle du nombre de magasins vendeurs
// — sans la seconde, le calcul se rabat sur les quantités brutes et
// confond « vend mieux » avec « est distribué plus largement ».
const serie = enriched.qteReseauByMonth;
const t = computeNetworkTrend(serie);
const nbMagByMonth = metrics?.metricsByMonth
? Object.fromEntries(
Object.entries(metrics.metricsByMonth).map(([mois, m]) => [mois, Number(m?.nbMag) || 0]),
)
: null;
const t = computeNetworkTrend(serie, nbMagByMonth);
if (t.hasData) {
enriched.trend = {
direction: t.direction,
pct: t.pct,
label: t.pct != null ? trendLabel(t.pct) : "Indéterminée",
label: trendLabel(t.pct, t.nouveau),
surQteParMagasin: t.surQteParMagasin,
nouveau: t.nouveau,
monthsUsed: t.values.length,
};
}
+229
View File
@@ -0,0 +1,229 @@
/**
* CollectFlow — Capture des logs serveur d'un job, pour téléchargement.
*
* POURQUOI. Les extractions Qlik produisent plusieurs milliers de lignes
* (`[qlik-pw][page] …`, timings, diagnostics du modèle). Or ces lignes ne
* vivent que dans la sortie standard du conteneur : un terminal les tronque,
* et un copier-coller ne rapporte que la fin — c'est-à-dire jamais l'entête,
* là où se trouvent la carte du modèle Qlik et le choix des champs. Sans elle,
* un diagnostic se fait à l'aveugle.
*
* COMMENT. `console.log/warn/error` est instrumenté UNE fois au chargement du
* module. Tant qu'au moins une capture est ouverte, chaque ligne est recopiée
* dans les captures ouvertes, puis transmise telle quelle à la console
* d'origine (rien n'est retenu, rien ne change dans les logs du conteneur).
*
* ⚠️ Deux jobs simultanés partagent la sortie standard du process : leurs
* lignes ne portent pas d'identifiant permettant de les démêler. Chaque
* capture ouverte reçoit donc TOUTES les lignes de la période — mieux vaut un
* peu de bruit qu'une ligne manquante dans un journal de diagnostic.
*
* Les captures sont écrites sur disque (répertoire temporaire) au fil de l'eau :
* un job qui fait tomber le process laisse quand même son journal.
*/
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Répertoire des journaux. Volontairement hors du projet : ce sont des temporaires. */
const LOG_DIR = join(tmpdir(), "collectflow-logs");
/** Au-delà, on arrête d'accumuler : un job en boucle ne doit pas manger la RAM. */
const MAX_LIGNES = 200_000;
/** Une ligne pathologique (dump d'hypercube) est tronquée plutôt que gardée entière. */
const MAX_LONGUEUR_LIGNE = 8_000;
/** Nombre de journaux conservés sur disque. */
const MAX_FICHIERS = 20;
/** Écriture disque au plus toutes les N ms (le journal complet est réécrit). */
const FLUSH_MS = 2_000;
interface Capture {
id: string;
lignes: string[];
tronque: boolean;
debut: number;
flushEnCours: boolean;
flushDemande: boolean;
dernierFlush: number;
}
const captures = new Map<string, Capture>();
/** Nom de fichier sûr : l'id vient d'un job interne, on le verrouille quand même. */
function fichierPour(id: string): string {
return join(LOG_DIR, `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`);
}
function horodatage(): string {
return new Date().toISOString();
}
/** Rend une ligne à partir des arguments variadiques de `console.*`. */
function formater(args: unknown[]): string {
return args
.map((a) => {
if (typeof a === "string") return a;
if (a instanceof Error) return `${a.message}\n${a.stack ?? ""}`;
try {
return JSON.stringify(a);
} catch {
return String(a);
}
})
.join(" ");
}
async function flush(capture: Capture): Promise<void> {
if (capture.flushEnCours) {
capture.flushDemande = true;
return;
}
capture.flushEnCours = true;
try {
await mkdir(LOG_DIR, { recursive: true });
await writeFile(fichierPour(capture.id), capture.lignes.join("\n") + "\n", "utf8");
capture.dernierFlush = Date.now();
} catch {
// Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe.
} finally {
capture.flushEnCours = false;
if (capture.flushDemande) {
capture.flushDemande = false;
void flush(capture);
}
}
}
function ajouter(ligne: string): void {
if (captures.size === 0) return;
const texte = ligne.length > MAX_LONGUEUR_LIGNE
? ligne.slice(0, MAX_LONGUEUR_LIGNE) + " …[ligne tronquée]"
: ligne;
const prefixe = `[${horodatage()}] `;
for (const capture of captures.values()) {
if (capture.lignes.length >= MAX_LIGNES) {
if (!capture.tronque) {
capture.tronque = true;
capture.lignes.push(`${prefixe}[log-capture] limite de ${MAX_LIGNES} lignes atteinte — suite non enregistrée`);
}
continue;
}
capture.lignes.push(prefixe + texte);
if (Date.now() - capture.dernierFlush >= FLUSH_MS) void flush(capture);
}
}
/**
* Instrumente `console` une seule fois, même en développement où le module peut
* être réévalué à chaud (le drapeau est porté par `globalThis`).
*/
function instrumenterConsole(): void {
const g = globalThis as typeof globalThis & { __cfLogCaptureInstalle?: boolean };
if (g.__cfLogCaptureInstalle) return;
g.__cfLogCaptureInstalle = true;
const methodes = ["log", "warn", "error", "info"] as const;
for (const methode of methodes) {
const origine = console[methode].bind(console);
console[methode] = (...args: unknown[]) => {
try {
ajouter(formater(args));
} catch {
// idem : jamais au détriment du log réel
}
origine(...args);
};
}
}
instrumenterConsole();
/** Supprime les journaux les plus anciens au-delà de `MAX_FICHIERS`. */
async function purger(): Promise<void> {
try {
const noms = (await readdir(LOG_DIR)).filter((n) => n.endsWith(".log"));
if (noms.length <= MAX_FICHIERS) return;
const avecDate = await Promise.all(
noms.map(async (nom) => {
const s = await stat(join(LOG_DIR, nom)).catch(() => null);
return { nom, mtime: s?.mtimeMs ?? 0 };
}),
);
avecDate.sort((a, b) => b.mtime - a.mtime);
for (const { nom } of avecDate.slice(MAX_FICHIERS)) {
await unlink(join(LOG_DIR, nom)).catch(() => undefined);
}
} catch {
// purge best-effort
}
}
/**
* Ouvre une capture. Toutes les lignes écrites sur la console à partir de
* maintenant (par n'importe quel code du process, cf. avertissement en tête)
* y sont recopiées jusqu'à `stopCapture`.
*/
export function startCapture(id: string, entete?: string): void {
const capture: Capture = {
id,
lignes: [],
tronque: false,
debut: Date.now(),
flushEnCours: false,
flushDemande: false,
dernierFlush: 0,
};
captures.set(id, capture);
capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`);
if (entete) capture.lignes.push(`[${horodatage()}] [log-capture] ${entete}`);
void flush(capture);
void purger();
}
/** Ferme la capture et écrit son état final sur disque. */
export async function stopCapture(id: string, pied?: string): Promise<void> {
const capture = captures.get(id);
if (!capture) return;
const duree = Math.round((Date.now() - capture.debut) / 1000);
capture.lignes.push(`[${horodatage()}] [log-capture] fin de capture ${id} — ${capture.lignes.length} lignes, ${duree} s`);
if (pied) capture.lignes.push(`[${horodatage()}] [log-capture] ${pied}`);
captures.delete(id);
await flush(capture);
}
/**
* Contenu d'un journal : la capture en cours si elle est ouverte, sinon le
* fichier laissé sur disque. `null` si aucun des deux n'existe.
*/
export async function readCapture(id: string): Promise<string | null> {
const capture = captures.get(id);
if (capture) return capture.lignes.join("\n") + "\n";
try {
return await readFile(fichierPour(id), "utf8");
} catch {
return null;
}
}
/** Journaux disponibles, du plus récent au plus ancien. */
export async function listCaptures(): Promise<Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }>> {
const resultats: Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }> = [];
try {
for (const nom of await readdir(LOG_DIR)) {
if (!nom.endsWith(".log")) continue;
const s = await stat(join(LOG_DIR, nom)).catch(() => null);
if (!s) continue;
const id = nom.slice(0, -4);
resultats.push({
id,
octets: s.size,
modifieLe: new Date(s.mtimeMs).toISOString(),
enCours: captures.has(id),
});
}
} catch {
// répertoire pas encore créé
}
return resultats.sort((a, b) => b.modifieLe.localeCompare(a.modifieLe));
}
-67
View File
@@ -1,67 +0,0 @@
/**
* CollectFlow — Tendance réseau (calcul pur, sans React).
*
* Isolé du composant d'affichage (`features/network/components/network-trend.tsx`)
* pour être utilisable **côté serveur** : l'API `/api/v1` expose ainsi la même
* tendance que celle affichée dans la Grille, au lieu de laisser chaque appelant
* la recalculer — et diverger.
*
* Source : `qteByMonth` ({ "YYYY-MM": quantité }) produit par la synchronisation Qlik.
*/
export type TrendDirection = "up" | "down" | "flat";
export type NetworkTrend = {
values: number[]; // quantités mensuelles, ordre chronologique
labels: string[]; // "YYYY-MM"
direction: TrendDirection;
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
hasData: boolean;
};
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse. */
export const TREND_STRONG = 0.25;
/**
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
* Direction : hausse >+8%, stable, baisse <−8%.
*/
export function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty;
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (labels.length === 0) return empty;
labels = labels.slice(-12); // 12 derniers mois
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length;
let pct: number | null = null;
let direction: TrendDirection = "flat";
if (n >= 3) {
const mx = (n - 1) / 2;
const my = values.reduce((s, v) => s + v, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
const slope = den ? num / den : 0;
if (my > 0) {
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
} else if (slope > 0) {
direction = "up";
}
} else if (n === 2 && values[0] > 0) {
pct = (values[1] - values[0]) / values[0];
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
}
return { values, labels, direction, pct, hasData: true };
}
/** Libellé français de la tendance (« Forte hausse », « Stable »…). */
export function trendLabel(pct: number): string {
if (pct > TREND_STRONG) return "Forte hausse";
if (pct > 0.08) return "Hausse";
if (pct < -TREND_STRONG) return "Forte baisse";
if (pct < -0.08) return "Baisse";
return "Stable";
}
+683 -19
View File
@@ -355,26 +355,37 @@ export interface PgNomRow {
* Colonnes confirmées : nomenclature.code, nomenclature.libelle
* Colonne parent : découverte dynamiquement via information_schema
*/
/**
* Découvre (une seule fois par process) le nom de la colonne "parent" de la
* table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur.
*
* On ne peut pas la coder en dur : le nom varie selon les installations
* ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK),
* d'où le filtre explicite sur `data_type` entier.
*/
async function getNomenclatureParentCol(): Promise<string | null> {
if (_nomenclatureParentCol !== undefined) return _nomenclatureParentCol;
const metaResult = await db.execute(sql`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
`);
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
// "chemin_pere" = chemin vers le père en français
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
const parentRow = nomCols.find(r =>
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
&& /int|serial/i.test(r.data_type)
);
_nomenclatureParentCol = parentRow?.column_name ?? null;
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
return _nomenclatureParentCol;
}
export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<Map<string, PgNomRow>> {
// Découverte de la colonne parent : mise en cache module-level (1 seule fois par process)
if (_nomenclatureParentCol === undefined) {
const metaResult = await db.execute(sql`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
`);
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
// "chemin_pere" = chemin vers le père en français
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
const parentRow = nomCols.find(r =>
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
&& /int|serial/i.test(r.data_type)
);
_nomenclatureParentCol = parentRow?.column_name ?? null;
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
}
const parentCol = _nomenclatureParentCol;
const parentCol = await getNomenclatureParentCol();
let result;
if (parentCol) {
@@ -1308,3 +1319,656 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
return map;
}
}
// ---------------------------------------------------------------------------
// 13. Recherche & fiche produit (page /produits)
// ---------------------------------------------------------------------------
/**
* Une ligne de résultat de recherche produit. Volontairement **légère** :
* aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût).
* Le détail lourd n'est calculé qu'à l'ouverture de la fiche.
*/
export interface PgProduitSearchRow {
no_id: number;
codein: string;
code_centrale: string;
libelle1: string;
/** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */
fournisseur: string;
codefou: string;
nomenclature_code: string;
nomenclature: string;
stock_total: number;
/** true si le produit a déjà des métriques réseau Qlik en cache. */
has_reseau: boolean;
qte_reseau: number;
nb_magasins_reseau: number;
}
/**
* Caractères accentués et leur équivalent non accentué, pour `translate()`.
* Les deux chaînes DOIVENT rester de même longueur, caractère par caractère.
* Postgres n'a pas `unaccent()` sans l'extension : on ne peut pas compter dessus
* sur la base FF, d'où cette table de correspondance explicite.
*/
const SQL_ACCENTS_FROM = "àâäãáåéèêëíìîïóòôöõúùûüçñýÿ";
const SQL_ACCENTS_TO = "aaaaaaeeeeiiiiooooouuuucnyy";
/**
* Normalise un terme de recherche côté JS, de la même façon que
* `normalizedLibelleSql()` le fait côté SQL : minuscules, accents retirés,
* toute ponctuation ramenée à un espace.
*/
function normalizeSearchTerm(term: string): string {
return term
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // retire les diacritiques
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
/** Expression SQL renvoyant le libellé normalisé (minuscules, sans accent ni ponctuation). */
function normalizedLibelleSql() {
return sql`regexp_replace(translate(lower(COALESCE(a.libelle1, '')), ${SQL_ACCENTS_FROM}, ${SQL_ACCENTS_TO}), '[^a-z0-9]+', ' ', 'g')`;
}
/**
* Recherche un produit par **libellé** ou par **code centrale** (préfixe).
* Si le terme est entièrement numérique, on tente aussi une correspondance
* exacte sur le `codein`.
*
* La recherche par libellé est **insensible aux accents et à la ponctuation**,
* et fonctionne **mot à mot** : « tapis anti-poussière » retrouve
* « TAPIS ANTI POUSSIERE », et « tapis poussiere » aussi (tous les mots doivent
* être présents, dans n'importe quel ordre). Sans cette normalisation, un
* `ILIKE` brut exigeait l'accent ET le tiret exacts et ne remontait rien.
*
* ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée
* chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc
* un seq-scan avec normalisation par ligne : l'appelant impose un minimum de
* 3 caractères et une soumission explicite, jamais une recherche à la frappe.
*/
export async function pgSearchProduits(term: string, limit = 50): Promise<PgProduitSearchRow[]> {
const cleaned = term.trim();
if (cleaned.length < 3) return [];
const prefix = `${cleaned}%`;
// Mots de la recherche, normalisés. Tous doivent être présents (AND).
const tokens = normalizeSearchTerm(cleaned).split(" ").filter(Boolean);
const normLibelle = normalizedLibelleSql();
const libelleClause = tokens.length > 0
? sql`(${sql.join(tokens.map((t) => sql`${normLibelle} LIKE ${`%${t}%`}`), sql` AND `)})`
: sql`FALSE`;
// Un terme purement numérique peut être un codein : on l'ajoute au filtre
// (correspondance exacte, la colonne est space-padded d'où le TRIM).
const codeinClause = /^\d+$/.test(cleaned)
? sql`OR TRIM(a.codein::text) = ${cleaned}`
: sql``;
try {
const result = await pgNoParallel(sql`
WITH raw AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
a.no_id,
TRIM(a.codein::text) AS codein,
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1,
a.nom_no_id
FROM articles a
WHERE a.codein IS NOT NULL
AND (
${libelleClause}
OR TRIM(a.artcentrale::text) ILIKE ${prefix}
${codeinClause}
)
ORDER BY TRIM(a.codein::text), a.no_id DESC
),
matched AS (
SELECT * FROM raw ORDER BY libelle1 LIMIT ${limit}
)
SELECT
m.no_id,
m.codein,
m.code_centrale,
m.libelle1,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(af.code, '')::text AS codefou,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, '')::text AS nomenclature,
COALESCE(st.stock_total, 0)::float AS stock_total,
(q.code_centrale IS NOT NULL) AS has_reseau,
COALESCE(q.qte_reseau, 0)::float AS qte_reseau,
COALESCE(q.nb_magasins_reseau, 0) AS nb_magasins_reseau
FROM matched m
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
LEFT JOIN LATERAL (
SELECT SUM(cs.qte)::float AS stock_total
FROM cube_stock cs
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
) st ON TRUE
LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale
ORDER BY m.libelle1
`);
console.log(`[pg-ff] pgSearchProduits("${cleaned}"): ${result.rows.length} résultats`);
return result.rows as unknown as PgProduitSearchRow[];
} catch (e) {
console.error("[pg-ff] pgSearchProduits error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/**
* Rapproche une liste de **codes centraux** (issus de Qlik) du catalogue FF Nancy.
*
* C'est l'étape 2 de la recherche produit : Qlik dit quels articles le réseau
* travaille, cette requête dit lesquels nous référençons — et avec quel
* `codein`, quel fournisseur et quel stock. Un code absent du résultat est un
* produit réseau que Nancy ne référence pas (cas normal et intéressant).
*
* Les codeins sont renvoyés indexés par code centrale ; un même code centrale
* peut porter plusieurs codeins (ré-créations d'article), on ne garde que le
* `no_id` le plus récent.
*/
export async function pgGetProduitsByCodeCentrale(
codesCentraux: string[],
): Promise<Map<string, PgProduitSearchRow>> {
const out = new Map<string, PgProduitSearchRow>();
const codes = [...new Set(codesCentraux.map((c) => c.trim()).filter(Boolean))];
if (codes.length === 0) return out;
try {
const result = await pgNoParallel(sql`
WITH matched AS (
SELECT DISTINCT ON (TRIM(a.artcentrale::text))
a.no_id,
TRIM(a.codein::text) AS codein,
TRIM(a.artcentrale::text) AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1,
a.nom_no_id
FROM articles a
WHERE TRIM(a.artcentrale::text) IN (${sql.join(codes.map((c) => sql`${c}`), sql`, `)})
AND a.codein IS NOT NULL
ORDER BY TRIM(a.artcentrale::text), a.no_id DESC
)
SELECT
m.no_id,
m.codein,
m.code_centrale,
m.libelle1,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
COALESCE(af.code, '')::text AS codefou,
COALESCE(n.code, '')::text AS nomenclature_code,
COALESCE(n.libelle, '')::text AS nomenclature,
COALESCE(st.stock_total, 0)::float AS stock_total,
TRUE AS has_reseau,
0::float AS qte_reseau,
0 AS nb_magasins_reseau
FROM matched m
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
LEFT JOIN LATERAL (
SELECT SUM(cs.qte)::float AS stock_total
FROM cube_stock cs
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
) st ON TRUE
`);
for (const row of result.rows as unknown as PgProduitSearchRow[]) {
out.set(row.code_centrale, row);
}
console.log(`[pg-ff] pgGetProduitsByCodeCentrale: ${codes.length} codes Qlik → ${out.size} trouvés dans le catalogue Nancy`);
return out;
} catch (e) {
console.error("[pg-ff] pgGetProduitsByCodeCentrale error:", (e as Error).message?.slice(0, 250));
return out;
}
}
/**
* `codein` de l'article portant ce code centrale, ou `null` si Nancy ne le
* référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik
* (URL `?cc=…`), qui n'a que le code centrale.
*/
export async function pgGetCodeinByCodeCentrale(codeCentrale: string): Promise<string | null> {
const cleaned = codeCentrale.trim();
if (!cleaned) return null;
try {
const result = await pgNoParallel(sql`
SELECT TRIM(a.codein::text) AS codein
FROM articles a
WHERE TRIM(a.artcentrale::text) = ${cleaned}
AND a.codein IS NOT NULL
ORDER BY a.no_id DESC
LIMIT 1
`);
const row = (result.rows as unknown as { codein: string }[])[0];
return row?.codein ?? null;
} catch (e) {
console.error("[pg-ff] pgGetCodeinByCodeCentrale error:", (e as Error).message?.slice(0, 250));
return null;
}
}
/** Identité, prix et nomenclature d'un produit (une seule ligne). */
export interface PgProduitDetailRow {
no_id: number;
codein: string;
code_centrale: string;
libelle1: string;
nom_no_id: number | null;
gtin: string;
reference: string;
pcb: number | null;
/** article_infosup.prix_vente_mini */
pv_central: number | null;
/** cube_pa.pa */
pa: number | null;
code3: string | null;
libelle3: string | null;
code2: string | null;
libelle2: string | null;
code1: string | null;
libelle1nom: string | null;
}
/**
* Fiche d'identité complète d'un produit à partir de son `codein`.
* Renvoie `null` si le codein est inconnu.
*/
export async function pgGetProduitDetail(codein: string): Promise<PgProduitDetailRow | null> {
const parentCol = await getNomenclatureParentCol();
// Hiérarchie nomenclature — deux stratégies :
//
// 1. Colonne parent entière si elle existe (FK vers le no_id du niveau au-dessus).
// 2. Sinon, remontée par **préfixe de code** : la nomenclature FF est encodée
// hiérarchiquement, secteur = 2 chiffres, famille = 4, sous-famille = 6
// (ex "330702" → "3307" → "33"). En production `nomenclature` n'expose que
// no_id / code / libelle / niveau / chemin_pere : aucune FK entière, et
// `chemin_pere` est un chemin texte. Sans ce repli, Secteur et Famille
// restaient vides sur la fiche.
const hierarchy = parentCol
? sql`
LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)}
LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)}
`
: sql`
LEFT JOIN LATERAL (
SELECT nx.code, nx.libelle
FROM nomenclature nx
WHERE LENGTH(TRIM(n3.code)) >= 4
AND TRIM(nx.code) = LEFT(TRIM(n3.code), 4)
LIMIT 1
) n2 ON TRUE
LEFT JOIN LATERAL (
SELECT nx.code, nx.libelle
FROM nomenclature nx
WHERE LENGTH(TRIM(n3.code)) >= 2
AND TRIM(nx.code) = LEFT(TRIM(n3.code), 2)
LIMIT 1
) n1 ON TRUE
`;
// Les colonnes sont identiques dans les deux stratégies (alias n1 / n2).
const hierarchyCols = sql`,
n2.code AS code2,
n2.libelle AS libelle2,
n1.code AS code1,
n1.libelle AS libelle1nom`;
try {
const result = await pgNoParallel(sql`
SELECT
a.no_id,
TRIM(a.codein::text) AS codein,
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1,
a.nom_no_id,
COALESCE(ag.gtin, af.ean13, '')::text AS gtin,
COALESCE(af.reference, '')::text AS reference,
af.pcb::float AS pcb,
ai.prix_vente_mini::float AS pv_central,
pa.pa::float AS pa,
n3.code AS code3,
n3.libelle AS libelle3
${hierarchyCols}
FROM articles a
LEFT JOIN LATERAL (
SELECT af1.code, af1.pcb, af1.reference, af1.ean13
FROM artfou1 af1
WHERE af1.art_no_id = a.no_id
ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id
${hierarchy}
WHERE TRIM(a.codein::text) = ${codein}
ORDER BY a.no_id DESC
LIMIT 1
`);
const row = (result.rows as unknown as PgProduitDetailRow[])[0] ?? null;
console.log(`[pg-ff] pgGetProduitDetail(${codein}): ${row ? "trouvé" : "introuvable"}`);
return row;
} catch (e) {
console.error("[pg-ff] pgGetProduitDetail error:", (e as Error).message?.slice(0, 250));
return null;
}
}
/** Un fournisseur référencé pour l'article. */
export interface PgProduitFournisseurRow {
codefou: string;
nomfou: string;
reference: string;
pcb: number | null;
/** true si artfou1.preference = 1 */
principal: boolean;
}
/** Tous les fournisseurs référencés pour un article (le principal en premier). */
export async function pgGetFournisseursByCodein(codein: string): Promise<PgProduitFournisseurRow[]> {
try {
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (af.code)
af.code::text AS codefou,
COALESCE(fi.nom, af.code)::text AS nomfou,
COALESCE(af.reference, '')::text AS reference,
af.pcb::float AS pcb,
(af.preference = 1) AS principal
FROM articles a
JOIN artfou1 af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
WHERE TRIM(a.codein::text) = ${codein}
ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC
`);
const rows = result.rows as unknown as PgProduitFournisseurRow[];
// Le principal remonte en tête (le DISTINCT ON impose un tri par code).
rows.sort((x, y) => Number(y.principal) - Number(x.principal) || x.nomfou.localeCompare(y.nomfou, "fr"));
return rows;
} catch (e) {
console.error("[pg-ff] pgGetFournisseursByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Stock temps réel par site pour un article. */
export interface PgProduitStockRow {
site: string;
stockdispo: number;
qte: number;
valstock: number;
prmp: number;
dernierevente: string | null;
dernierereception: string | null;
}
export async function pgGetStockByCodein(codein: string): Promise<PgProduitStockRow[]> {
try {
const result = await pgNoParallel(sql`
SELECT
cs.site,
COALESCE(cs.stockdispo, 0)::float AS stockdispo,
COALESCE(cs.qte, 0)::float AS qte,
COALESCE(cs.valstock, 0)::float AS valstock,
COALESCE(cs.prmp, 0)::float AS prmp,
cs.dernierevente::text AS dernierevente,
cs.dernierereception::text AS dernierereception
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
WHERE TRIM(a.codein::text) = ${codein}
AND cs.site IN ('292', '579')
ORDER BY cs.site
`);
return result.rows as unknown as PgProduitStockRow[];
} catch (e) {
console.error("[pg-ff] pgGetStockByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Quantité totale en commande fournisseur pour un article. */
export async function pgGetCommandesByCodein(codein: string): Promise<number> {
try {
const result = await pgNoParallel(sql`
SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde
FROM cdefou_vivant cv
WHERE TRIM(cv.articles_codein::text) = ${codein}
`);
const row = (result.rows as unknown as { qtecde: number }[])[0];
return Number(row?.qtecde) || 0;
} catch (e) {
console.error("[pg-ff] pgGetCommandesByCodein error:", (e as Error).message?.slice(0, 250));
return 0;
}
}
/** Une gamme affectée à l'article pour une saison donnée. */
export interface PgGammeHistoryRow {
saison_no_id: number;
saison_code: string | null;
saison_libelle: string | null;
gamme_code: string;
gamme_libelle: string | null;
}
/**
* Historique complet des gammes de l'article, saison par saison (la plus
* récente en premier). Le reste de l'application ne lit que la saison active
* (`pgGetGammesByFournisseur`) ; ici on veut la trajectoire d'arbitrage.
*/
export async function pgGetGammeHistoryByCodein(codein: string): Promise<PgGammeHistoryRow[]> {
try {
const result = await pgNoParallel(sql`
SELECT
s.no_id AS saison_no_id,
s.code::text AS saison_code,
s.libelle::text AS saison_libelle,
g.code::text AS gamme_code,
g.libelle::text AS gamme_libelle
FROM art_gamme_saison ags
JOIN articles a ON a.no_id = ags.artnoid
JOIN gammes g ON g.no_id = ags.idgamme
JOIN saisons s ON s.no_id = ags.idsaison
WHERE TRIM(a.codein::text) = ${codein}
ORDER BY s.no_id DESC
`);
return result.rows as unknown as PgGammeHistoryRow[];
} catch (e) {
console.error("[pg-ff] pgGetGammeHistoryByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/**
* Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions)
* d'UN article, par site et par mois.
*
* Reprend **exactement** le calcul canonique de `pgGetMensuelByFournisseur`
* (la source de vérité de la Grille) : seule la sélection change — filtre sur
* le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la
* fiche produit sont donc identiques à ceux de la Grille, par construction.
*/
export async function pgGetMensuelByCodein(
codein: string,
dateDebut: string,
dateFin: string
): Promise<PgMensuelRow[]> {
try {
const result = await pgNoParallel(sql`
WITH base AS (
SELECT
TRIM(a.codein::text) AS codein,
m.site,
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
m.qtemvt,
m.mntmvtttc,
m.margemvt,
m.genremvt,
m.qtestock,
ROW_NUMBER() OVER (
PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
ORDER BY m.datmvt DESC
) AS rn_last
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
WHERE TRIM(a.codein::text) = ${codein}
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
AND m.site IN ('292', '579')
)
SELECT
codein,
site,
mois,
SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue,
SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht,
SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge,
MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois,
SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue
FROM base
GROUP BY codein, site, mois
ORDER BY site, mois
`);
console.log(`[pg-ff] pgGetMensuelByCodein(${codein}): ${result.rows.length} lignes`);
return result.rows as unknown as PgMensuelRow[];
} catch (e) {
console.error("[pg-ff] pgGetMensuelByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Un produit de la même famille, comparé local vs réseau. */
export interface PgOpportuniteRow {
codein: string;
libelle1: string;
code_centrale: string;
fournisseur: string;
qte_reseau: number;
nb_magasins_reseau: number;
ca_reseau: number;
ca_par_magasin_reseau: number;
qte_locale: number;
nb_sites_locaux: number;
qte_reseau_par_magasin: number;
qte_locale_par_magasin: number;
/** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */
ecart_par_magasin: number;
}
/**
* Produits de la même nomenclature, classés par écart entre la performance
* réseau et la performance locale (quantités **par magasin**, pour neutraliser
* la différence d'échelle entre ~270 magasins et nos 2 sites).
*
* Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà
* des métriques réseau en cache : la dimension Qlik interrogée est
* `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas.
*/
export async function pgGetOpportunitesFamille(
nomNoId: number,
dateDebut: string,
dateFin: string,
limit = 30
): Promise<PgOpportuniteRow[]> {
try {
const result = await pgNoParallel(sql`
WITH famille AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
a.no_id,
TRIM(a.codein::text) AS codein,
TRIM(a.artcentrale::text) AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1
FROM articles a
WHERE a.nom_no_id = ${nomNoId}
AND a.codein IS NOT NULL
AND a.artcentrale IS NOT NULL
AND TRIM(a.artcentrale::text) <> ''
ORDER BY TRIM(a.codein::text), a.no_id DESC
),
avec_reseau AS (
SELECT
f.no_id,
f.codein,
f.code_centrale,
f.libelle1,
q.qte_reseau::float AS qte_reseau,
q.nb_magasins_reseau AS nb_magasins_reseau,
COALESCE(q.ca_reseau, 0)::float AS ca_reseau,
COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau
FROM famille f
JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale
WHERE q.nb_magasins_reseau > 0
AND q.qte_reseau > 0
),
ventes AS (
SELECT
TRIM(a.codein::text) AS codein,
SUM(-m.qtemvt)::float AS qte_locale,
COUNT(DISTINCT m.site) AS nb_sites_locaux
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
WHERE m.genremvt = 3
AND m.site IN ('292', '579')
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau)
GROUP BY TRIM(a.codein::text)
)
SELECT
r.codein,
r.libelle1,
r.code_centrale,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
r.qte_reseau,
r.nb_magasins_reseau,
r.ca_reseau,
r.ca_par_magasin_reseau,
COALESCE(v.qte_locale, 0)::float AS qte_locale,
COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux,
(r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin,
(COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin,
(
(r.qte_reseau / r.nb_magasins_reseau)
- (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))
)::float AS ecart_par_magasin
FROM avec_reseau r
LEFT JOIN ventes v ON v.codein = r.codein
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = r.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
ORDER BY ecart_par_magasin DESC
LIMIT ${limit}
`);
console.log(`[pg-ff] pgGetOpportunitesFamille(nom=${nomNoId}): ${result.rows.length} lignes`);
return result.rows as unknown as PgOpportuniteRow[];
} catch (e) {
console.error("[pg-ff] pgGetOpportunitesFamille error:", (e as Error).message?.slice(0, 250));
return [];
}
}
+30
View File
@@ -91,6 +91,36 @@ export interface NetworkMetric {
periode?: string;
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */
qteByMonth?: Record<string, number>;
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
*
* Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois).
* Lors d'une synchronisation datée validée, les 12 mois complets sont
* toujours présents ; un mois sans fait est explicitement représenté à 0.
*/
metricsByMonth?: Record<string, QlikMonthMetrics>;
/**
* Libellé de l'article côté Qlik. Renseigné uniquement par la recherche
* produit (`qlik-search.ts`), qui interroge la dimension libellé ; la sync
* fournisseur ne le connaît pas et le laisse `undefined`.
*/
libelleReseau?: string;
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau?: string;
}
/** Les mesures réseau Qlik pour un produit sur un mois donné. */
export interface QlikMonthMetrics {
/** Quantité vendue réseau — toujours présente. */
qte: number;
/** CA réseau du mois. */
ca?: number;
/** Nombre de magasins ayant vendu le produit ce mois-là. */
nbMag?: number;
/** CA moyen par magasin sur le mois (mesure Qlik brute). */
caMag?: number;
/** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */
margePct?: number;
}
/**
+8 -2
View File
@@ -144,8 +144,14 @@ export function buildGridNetworkQlikDateFilter(
// Début : `months` mois avant le mois courant (1er du mois)
const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1);
const dateDebut = startMonth.toISOString().slice(0, 10);
const dateFin = endMonth.toISOString().slice(0, 10);
// Ne jamais utiliser `toISOString()` ici : minuit local en Europe/Paris
// devient la veille en UTC et transformait par exemple 2025-07-01 en
// 2025-06-30, soit une extraction de 13 mois. Les bornes sont des dates
// calendaires Qlik, on les formate donc avec les composantes locales.
const localIso = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const dateDebut = localIso(startMonth);
const dateFin = localIso(endMonth);
const qStart = isoDateToQlikSerial(dateDebut);
const qEnd = isoDateToQlikSerial(dateFin);
+29 -2
View File
@@ -9,7 +9,7 @@ import "server-only";
import { db } from "@/db";
import { qlikNetworkMetrics } from "@/db/schema";
import { inArray, sql } from "drizzle-orm";
import type { NetworkMetric } from "@/lib/qlik-client";
import type { NetworkMetric, QlikMonthMetrics } from "@/lib/qlik-client";
export interface NetworkMetricCached {
caReseau: number;
@@ -19,6 +19,18 @@ export interface NetworkMetricCached {
margePctReseau: number;
/** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */
qteByMonth: Record<string, number> | null;
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* null pour les lignes synchronisées avant l'ajout de la colonne — l'UI
* retombe alors sur `qteByMonth`.
*/
metricsByMonth: Record<string, QlikMonthMetrics> | null;
/** Période couverte par l'extraction (ex "2025-03_2026-02"). */
periode: string | null;
/** Libellé de l'article côté Qlik — `null` tant qu'aucune recherche produit ne l'a renseigné. */
libelleReseau: string | null;
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau: string | null;
fetchedAt: string | null;
}
@@ -43,6 +55,10 @@ export async function getNetworkMetricsByCodeCentrale(
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
metricsByMonth: (r.metricsByMonth as Record<string, QlikMonthMetrics> | null) ?? null,
periode: r.periode ?? null,
libelleReseau: r.libelleReseau ?? null,
fournisseurReseau: r.fournisseurReseau ?? null,
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
});
}
@@ -62,6 +78,9 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
margePctReseau: String(m.margePctReseau),
periode: m.periode ?? null,
qteByMonth: m.qteByMonth ?? null,
metricsByMonth: m.metricsByMonth ?? null,
libelleReseau: m.libelleReseau ?? null,
fournisseurReseau: m.fournisseurReseau ?? null,
fetchedAt: now,
}));
@@ -82,7 +101,15 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
margePctReseau: sql`excluded.marge_pct_reseau`,
periode: sql`excluded.periode`,
qteByMonth: sql`excluded.qte_by_month`,
// Le détail mensuel n'est produit que par l'extraction complète
// (dimension Mois). La recherche produit, elle, n'a que les
// agrégats : elle ne doit pas effacer un mensuel déjà extrait.
qteByMonth: sql`COALESCE(excluded.qte_by_month, ${qlikNetworkMetrics.qteByMonth})`,
metricsByMonth: sql`COALESCE(excluded.metrics_by_month, ${qlikNetworkMetrics.metricsByMonth})`,
// Seule la recherche produit connaît le libellé / le fournisseur
// réseau : une sync fournisseur ne doit pas les effacer.
libelleReseau: sql`COALESCE(excluded.libelle_reseau, ${qlikNetworkMetrics.libelleReseau})`,
fournisseurReseau: sql`COALESCE(excluded.fournisseur_reseau, ${qlikNetworkMetrics.fournisseurReseau})`,
fetchedAt: sql`excluded.fetched_at`,
},
});
+1698 -577
View File
File diff suppressed because it is too large. Load diff
+664
View File
@@ -0,0 +1,664 @@
/**
* CollectFlow — Recherche produit **dans Qlik Sense** (source primaire).
*
* La recherche produit part du réseau, pas du catalogue Nancy : on interroge
* d'abord l'app Qlik « Magasins Vision Consolidée » pour trouver les articles
* qui correspondent au terme saisi (libellé ou code centrale), puis l'appelant
* rapproche les codes centraux trouvés de la base FF Nancy.
*
* Pourquoi Qlik d'abord : le réseau (~270 magasins) référence beaucoup plus de
* produits que Nancy. Chercher dans la base locale d'abord masquait tous les
* produits que le réseau travaille et que nous ne référençons pas — c'est
* précisément ce qu'on veut voir.
*
* Technique : même plomberie que `qlik-playwright.ts` (Chromium headless +
* websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse
* un websocket « raw » côté serveur.
*
* ## Comment la sélection est construite
*
* La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU**
* entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont
* l'immense majorité ne contient que « anti ». Sélectionner ces 17 696 valeurs
* faisait ensuite exploser le cube (`code 15 — Request aborted`).
*
* On procède donc en trois temps :
* 1. une recherche par mot, pour retenir le **plus sélectif** (celui qui
* ramène le moins de valeurs) ;
* 2. lecture de ces valeurs et filtrage **ET** côté client (tous les mots
* présents, insensible casse/accents) ;
* 3. sélection des seules valeurs retenues, **par numéro d'élément**
* (`SelectListObjectValues`) — exact, sans ré-appariement de texte.
*
* Le résultat final est revérifié une dernière fois avant d'être renvoyé : une
* liste sans rapport avec la recherche est pire que pas de résultat du tout.
*/
import "server-only";
import { chromium, type Browser } from "playwright-core";
import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client";
import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
/**
* Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**.
*
* Les mesures sortent du même cube que l'identité : une seule session Qlik pour
* toute la recherche. Les extraire dans une seconde session concurrente faisait
* abandonner le moteur (`code 15 — Request aborted`).
*/
export interface QlikArticleMatch {
/** Valeur de la dimension « Article Code » — clé de jointure de la Grille. */
codeCentrale: string;
/**
* Champ `article_no_centrale` quand l'app l'expose : second candidat pour la
* jointure avec `articles.artcentrale`, les deux ne portant pas toujours le
* même format.
*/
codeCentraleAlt: string;
/** Libellé réseau, si l'app expose un champ libellé article. */
libelle: string;
/** Fournisseur réseau, si l'app expose un champ fournisseur. */
fournisseur: string;
/** CA réseau sur la fenêtre 12 mois glissants. */
caReseau: number;
qteReseau: number;
nbMagasinsReseau: number;
caParMagasinReseau: number;
/** Taux de marge réseau, ratio brut Qlik (0.32 = 32 %). */
margePctReseau: number;
}
export interface QlikSearchOutcome {
matches: QlikArticleMatch[];
/** Champ Qlik effectivement interrogé (diagnostic UI). */
champUtilise: string | null;
/** true si Qlik a renvoyé plus de résultats que la limite demandée. */
tronque: boolean;
/**
* Message de diagnostic quand la recherche aboutit à zéro article alors que
* Qlik a bien répondu (filtre non appliqué, terme trop large…). `null` en
* fonctionnement nominal.
*/
avertissement: string | null;
/** Fenêtre couverte par les mesures (`YYYY-MM_YYYY-MM`). */
periode: string | null;
}
/**
* Nombre maximum d'articles renvoyés par une recherche.
*
* Chaque code trouvé est ensuite ré-extrait avec son détail mensuel : au-delà
* de quelques dizaines, la seconde passe devient longue et le résultat
* illisible. L'utilisateur affine son terme.
*/
export const QLIK_SEARCH_MAX_RESULTS = 40;
/**
* Champs candidats pour le libellé article, par ordre de préférence.
*
* Sur l'app FF, `Article` est le libellé métier et `article_libelle_ticket` le
* libellé (tronqué) imprimé sur le ticket. On essaie le premier qui donne des
* résultats : si un candidat ne matche rien, on passe au suivant.
*/
const CHAMPS_LIBELLE_PREFERES = ["Article", "article_libelle_ticket"];
/** Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). */
const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"];
/**
* Champ portant le code centrale « brut » de l'article, quand il existe.
* Second candidat de jointure avec `articles.artcentrale` : sur l'app FF, la
* dimension « Article Code » ne renvoie pas le même format.
*/
const CHAMP_CODE_CENTRALE_ALT = "article_no_centrale";
let browserPromise: Promise<Browser> | null = null;
async function getBrowser(): Promise<Browser> {
if (!browserPromise) {
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
browserPromise = chromium.launch({
headless: true,
executablePath: execPath || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
}).catch((e) => { browserPromise = null; throw e; });
}
return browserPromise;
}
interface InPageSearchResult {
ok: boolean;
error?: string;
/** [code, codeAlt, libellé, fournisseur, ca, qte, nbMag, caMag, margePct] */
rows?: Array<[string, string, string, string, number, number, number, number, number]>;
champUtilise?: string | null;
/** Nombre d'articles uniques correspondant au terme (avant plafonnement). */
total?: number;
/** Lignes brutes lues dans le cube (avant garde-fou). */
lues?: number;
/** Lignes écartées parce qu'elles ne correspondaient pas au terme. */
rejetees?: number;
/** true si le terme ramenait trop de valeurs pour être traité intégralement. */
tropLarge?: boolean;
/** Journal compact des étapes, remonté dans les logs serveur. */
trace?: string[];
}
/**
* Cherche dans Qlik les articles correspondant à `term`.
*
* Ne fait **aucune** mesure : on veut juste la liste des codes centraux (plus
* libellé / fournisseur si l'app les expose). Les métriques réseau sont
* ensuite extraites par `fetchNetworkMetricsPlaywright`, qui sait déjà produire
* le détail mensuel sur 12 mois glissants.
*/
export async function searchQlikArticles(
term: string,
limit: number = QLIK_SEARCH_MAX_RESULTS,
cfg: QlikConfig = getQlikConfig(),
): Promise<QlikSearchOutcome> {
const cleaned = term.trim();
if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null, periode: null };
if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant");
if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants");
const started = Date.now();
// Latence laissée à l'Engine après chaque mutation de sélection. Même réglage
// que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après
// est calculé sur l'état de sélection précédent.
const settleMs = Math.min(2000, Math.max(0, Number(process.env.QLIK_SETTLE_MS ?? "250") || 250));
// Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu.
const dateFilter = buildGridNetworkQlikDateFilter(
new Date(),
QLIK_MONTHS_BACK_DEFAULT,
);
const sess = await qlikNtlmSession(cfg);
const [cookieName, ...rest] = sess.cookie.split("=");
const cookieValue = rest.join("=");
const browser = await getBrowser();
const ctx = await browser.newContext({
httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
page.on("console", (msg) => {
// Le client Qlik est très bavard au chargement (thèmes, extensions) :
// on ne relaie que ce qui vient de notre script.
const t = msg.text();
if (t.startsWith("[cf]")) console.log(`[qlik-search][page] ${t}`);
});
let csrfToken: string | null = null;
let resolveToken: (() => void) | null = null;
const tokenReady = new Promise<void>((r) => { resolveToken = r; });
page.on("websocket", (ws) => {
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); }
});
await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs })
.catch(() => { /* le client Qlik ouvre son ws juste après */ });
await Promise.race([tokenReady, page.waitForTimeout(15000)]);
if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)");
const result = (await page.evaluate(
({ app, dim, token, terme, limite, settleMs, serialsDate, mCa, mQte, mNbMag, mCaMag, mMarge,
champCodeAlt, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: {
app: string; dim: string; token: string; terme: string; limite: number; settleMs: number;
serialsDate: number[];
mCa: string; mQte: string; mNbMag: string; mCaMag: string; mMarge: string;
champCodeAlt: string;
champsLibellePreferes: string[]; champsFournisseurPreferes: string[];
champLibelleForce: string; champFournisseurForce: string;
}) =>
new Promise<InPageSearchResult>((resolve) => {
/** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */
const MAX_VALEURS_LUES = 10000;
/**
* Valeurs sélectionnées au maximum.
*
* Mesuré en production : sélectionner les 13 446 libellés contenant
* « tapis » a mis 81 s puis 486 s, et faisait abandonner le cube
* (code 15). Le cube étant trié par quantité réseau décroissante et
* l'affichage plafonné à quelques dizaines de lignes, 300 libellés
* suffisent largement.
*/
const MAX_VALEURS_SELECTION = 300;
const PAGE_VALEURS = 5000;
const loc = (window as unknown as { location: Location }).location;
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
const ws = new WebSocket(url);
const trace: string[] = [];
let id = 0;
const pend = new Map<number, { res: (v: { [k: string]: unknown }) => void; rej: (e: unknown) => void }>();
const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
const ready = new Promise<void>((res, rej) => {
ws.onmessage = (ev: MessageEvent) => {
const m = JSON.parse(ev.data as string);
if (m.method === "OnConnected") return res();
if (m.id && pend.has(m.id)) {
const x = pend.get(m.id)!;
pend.delete(m.id);
if (m.error) x.rej(new Error(JSON.stringify(m.error)));
else x.res(m.result);
}
};
ws.onerror = () => rej(new Error("ws error (403 ?)"));
setTimeout(() => rej(new Error("ws timeout")), 30000);
});
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** Qlik Engine code 15 = « Request aborted » : requête entrée en collision avec une évaluation. */
const estCode15 = (e: unknown) => /"code"\s*:\s*15\b/.test(String((e as Error)?.message ?? e));
const rpcRetry = async (method: string, params: unknown, handle: number, tentatives = 3) => {
let derniere: unknown;
for (let i = 1; i <= tentatives; i++) {
try {
return await rpc(method, params, handle);
} catch (e) {
derniere = e;
if (!estCode15(e) || i === tentatives) throw e;
trace.push("code15 sur " + method + ", tentative " + i + "/" + tentatives);
await sleep(settleMs * (i + 1));
}
}
throw derniere;
};
const normaliser = (s: string) => s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
const mots = normaliser(terme).split(" ").filter(Boolean);
(async () => {
try {
await ready;
const open = await rpc("OpenDoc", { qDocName: app });
const doc = (open.qReturn as { qHandle: number }).qHandle;
// ─── 1. Inventaire des champs ────────────────────────────
const fl = await rpc("CreateSessionObject", {
qProp: {
qInfo: { qType: "FieldList" },
qFieldListDef: { qShowSystem: false, qShowHidden: false, qShowSemantic: true, qShowSrcTables: true },
},
}, doc);
const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle);
const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? [])
.map((f) => f.qName));
const has = (nom: string, re: RegExp) => re.test(nom);
const champCode = champs.find((c) => c === "Article Code")
?? champs.find((c) => has(c, /article/i) && has(c, /code/i))
?? "Article Code";
// Candidats libellé : surcharge d'env, puis noms connus de
// l'app FF, puis heuristique. On essaiera chacun dans l'ordre
// jusqu'à en trouver un qui ramène des valeurs.
const candidatsLibelle = [
champLibelleForce,
...champsLibellePreferes,
...champs.filter((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)),
...champs.filter((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)),
].filter((c, i, arr) => c && champs.includes(c) && arr.indexOf(c) === i);
const champFournisseur = [
champFournisseurForce,
...champsFournisseurPreferes,
...champs.filter((c) => has(c, /fournisseur/i)),
...champs.filter((c) => has(c, /(fabricant|supplier)/i)),
].find((c) => c && champs.includes(c)) ?? null;
trace.push("code=" + champCode + " libellé candidats=" + JSON.stringify(candidatsLibelle) + " fournisseur=" + champFournisseur);
// ─── 2. Recherche + sélection sur le champ le plus adapté ─
//
// `SearchListObjectFor` applique un OU entre les mots. On
// cherche donc mot par mot pour retenir le plus sélectif,
// puis on filtre en ET côté client avant de sélectionner.
const chercherEtSelectionner = async (champ: string): Promise<{ retenues: number; brut: number; tropLarge: boolean }> => {
const lo = await rpcRetry("CreateSessionObject", {
qProp: {
qInfo: { qType: "cf-search-lb" },
qListObjectDef: {
qDef: { qFieldDefs: [champ] },
qInitialDataFetch: [],
},
},
}, doc);
const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const loId = String(ret.qGenericId ?? ret.qId ?? "");
const lh = ret.qHandle;
try {
// 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur).
let meilleurMot = terme;
let meilleurNb = Number.POSITIVE_INFINITY;
for (const mot of (mots.length > 1 ? mots : [terme])) {
await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh);
const lay = await rpcRetry("GetLayout", {}, lh);
const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0;
trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)");
if (nb > 0 && nb < meilleurNb) { meilleurNb = nb; meilleurMot = mot; }
}
if (!Number.isFinite(meilleurNb) || meilleurNb === 0) return { retenues: 0, brut: 0, tropLarge: false };
// 2b. Relecture des valeurs du mot retenu. On relance la
// recherche systématiquement : la boucle ci-dessus a pu
// laisser le list object sur un autre mot.
await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: meilleurMot }, lh);
const aLire = Math.min(meilleurNb, MAX_VALEURS_LUES);
const elems: number[] = [];
let lues = 0;
for (let top = 0; top < aLire; top += PAGE_VALEURS) {
const d = await rpcRetry("GetListObjectData", {
qPath: "/qListObjectDef",
qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(PAGE_VALEURS, aLire - top) }],
}, lh);
const matrix = ((d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qElemNumber?: number }>> }>)?.[0]?.qMatrix) ?? [];
if (matrix.length === 0) break;
lues += matrix.length;
for (const ligne of matrix) {
const cell = ligne[0];
const texte = String(cell?.qText ?? "");
const elem = cell?.qElemNumber;
if (elem == null || elem < 0) continue;
// Filtre ET : tous les mots du terme présents.
const n = normaliser(texte);
if (!mots.every((m) => n.includes(m))) continue;
elems.push(elem);
if (elems.length >= MAX_VALEURS_SELECTION) break;
}
if (elems.length >= MAX_VALEURS_SELECTION) break;
}
trace.push(" mot retenu « " + meilleurMot + " » : " + lues + " valeur(s) lues → " + elems.length + " après filtre ET");
if (elems.length === 0) {
return { retenues: 0, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES };
}
// 2c. Sélection par numéro d'élément : exact, aucun
// ré-appariement de texte possible.
await rpcRetry("SelectListObjectValues", {
qPath: "/qListObjectDef",
qValues: elems,
qToggleMode: false,
qSoftLock: true,
}, lh);
await sleep(settleMs);
return { retenues: elems.length, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES };
} finally {
if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } }
}
};
await rpc("ClearAll", { qLockedAlso: false }, doc);
await sleep(settleMs);
let champChoisi: string | null = null;
let tropLarge = false;
for (const candidat of candidatsLibelle) {
const r = await chercherEtSelectionner(candidat);
tropLarge = tropLarge || r.tropLarge;
if (r.retenues > 0) { champChoisi = candidat; break; }
await rpc("ClearAll", { qLockedAlso: false }, doc);
await sleep(settleMs);
}
if (!champChoisi) {
// Repli code centrale (terme saisi = un code).
const r = await chercherEtSelectionner(champCode);
if (r.retenues > 0) champChoisi = champCode;
tropLarge = tropLarge || r.tropLarge;
}
if (!champChoisi) {
ws.close();
return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, tropLarge, trace });
}
trace.push("champ retenu = " + champChoisi);
// Trace des sélections réellement actives : si elle est vide,
// la sélection n'a pas pris côté Engine.
try {
const cs = await rpc("CreateSessionObject", {
qProp: { qInfo: { qType: "cf-search-sel" }, qSelectionObjectDef: {} },
}, doc);
const csl = await rpc("GetLayout", {}, (cs.qReturn as { qHandle: number }).qHandle);
const sel = (((csl.qLayout as { qSelectionObject?: { qSelections?: Array<{ qField?: string; qSelectedCount?: number; qTotal?: number }> } })
.qSelectionObject?.qSelections) ?? [])
.map((s) => `${s.qField}:${s.qSelectedCount}/${s.qTotal}`);
trace.push("sélections actives=" + JSON.stringify(sel));
} catch (e) {
trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")");
}
// ─── 3. Fenêtre 12 mois glissants ────────────────────────
// Sélectionnée APRÈS les libellés : restreindre les dates avant
// la recherche masquerait les articles non vendus sur la période.
if (serialsDate.length > 0) {
try {
const gd = await rpc("GetField", { qFieldName: "Date" }, doc);
const dh = (gd.qReturn as { qHandle: number }).qHandle;
for (let i = 0; i < serialsDate.length; i += 500) {
const lot = serialsDate.slice(i, i + 500);
await rpcRetry("SelectValues", {
qFieldValues: lot.map((v) => ({ qNum: v, qText: String(v) })),
qToggleMode: i > 0,
qSoftLock: true,
}, dh);
}
await sleep(settleMs);
trace.push("fenêtre Date sélectionnée (" + serialsDate.length + " jours)");
} catch (e) {
trace.push("sélection Date impossible, mesures sur toute la période : " + String((e as Error)?.message || e));
}
}
// ─── 4. Identité + mesures réseau, dans UN SEUL cube ─────
//
// Les mesures sont dans le même cube que l'identité, et donc
// dans la même session Qlik : une seconde session concurrente
// pour les extraire faisait abandonner le moteur (code 15).
//
// Les master measures sont résolues par TITRE : le qLibraryId
// fiable est `qInfo.qId`, qui n'est pas le GUID du QRS — même
// approche que l'extraction de la Grille.
const norm = (x: string) => x.replace(/\s+/g, " ").trim().toLowerCase();
const ml = await rpcRetry("CreateSessionObject", {
qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title" } } },
}, doc);
const mll = await rpcRetry("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle);
const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qMeasureList?.qItems) ?? []);
const parTitre = new Map<string, string>();
for (const it of items) {
const t = it.qMeta?.title ?? it.qData?.title ?? "";
if (t) parTitre.set(norm(t), it.qInfo.qId);
}
const mesure = (titre: string, repli: string) => parTitre.get(norm(titre)) ?? repli;
const idCa = mesure("CA N", mCa);
const idQte = mesure("Quantité N", mQte);
const idNbMag = mesure("Magasin Ventes Nb N", mNbMag);
const idCaMag = mesure("CA par Magasin N", mCaMag);
const idMarge = mesure("Marge % N", mMarge);
const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi;
const champAlt = champs.includes(champCodeAlt) ? champCodeAlt : null;
const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }];
const idxAlt = champAlt ? dims.push({ qDef: { qFieldDefs: [champAlt] } }) - 1 : -1;
const idxLibelle = champLibelleAffiche ? dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }) - 1 : -1;
const idxFournisseur = champFournisseur ? dims.push({ qDef: { qFieldDefs: [champFournisseur] } }) - 1 : -1;
const nbDims = dims.length;
// Colonnes mesures, dans l'ordre : CA, Qté, NbMag, CA/mag, Marge %.
const idxQteCol = nbDims + 1;
const largeur = nbDims + 5;
// Cube trié par quantité réseau décroissante : la première page
// contient donc directement les meilleures ventes du réseau, et
// on n'a pas besoin de lire tout l'ensemble sélectionné.
const hauteur = Math.max(50, Math.min(400, Math.floor(9000 / largeur)));
const defCube = (trie: boolean) => ({
qProp: {
qInfo: { qType: "cf-search" },
qHyperCubeDef: {
qDimensions: dims.map((d) => ({ ...d, qNullSuppression: true })),
qMeasures: [
{ qLibraryId: idCa },
trie ? { qLibraryId: idQte, qSortBy: { qSortByNumeric: -1 } } : { qLibraryId: idQte },
{ qLibraryId: idNbMag },
{ qLibraryId: idCaMag },
{ qLibraryId: idMarge },
],
...(trie ? { qInterColumnSortOrder: [idxQteCol, 0] } : {}),
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: hauteur }],
qSuppressZero: false,
qSuppressMissing: true,
},
},
});
let cube: { [k: string]: unknown };
try {
cube = await rpcRetry("CreateSessionObject", defCube(true), doc);
} catch (e) {
// Le tri par mesure est un confort (meilleures ventes en
// tête) : si l'Engine refuse cette forme, on repart sans.
if (estCode15(e)) throw e;
trace.push("tri par quantité refusé, cube non trié : " + String((e as Error)?.message || e));
cube = await rpcRetry("CreateSessionObject", defCube(false), doc);
}
const ch = (cube.qReturn as { qHandle: number }).qHandle;
const layout = await rpcRetry("GetLayout", {}, ch);
const hc = (layout.qLayout as {
qHyperCube: {
qSize: { qcy: number };
qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>;
};
}).qHyperCube;
const matrix = hc.qDataPages?.[0]?.qMatrix ?? [];
// ─── 4. Garde-fou : le résultat DOIT correspondre au terme ──
const termeCode = terme.trim().toLowerCase();
const correspond = (code: string, libelle: string): boolean => {
if (mots.length === 0) return true;
const c = code.toLowerCase();
if (c === termeCode || c.startsWith(termeCode)) return true;
if (!libelle) return champChoisi === champCode;
const l = normaliser(libelle);
return mots.every((m) => l.includes(m));
};
type Ligne = [string, string, string, string, number, number, number, number, number];
const texte = (r: Array<{ qText?: string }>, i: number) => {
if (i < 0) return "";
const v = String(r[i]?.qText ?? "").trim();
return v === "-" ? "" : v;
};
const parCode = new Map<string, Ligne>();
let rejetees = 0;
for (const r of matrix) {
const code = String(r[0]?.qText ?? "").trim();
if (!code || code === "-") continue;
const codeAlt = texte(r, idxAlt);
const libelle = texte(r, idxLibelle);
const fournisseur = texte(r, idxFournisseur);
if (!correspond(code, libelle)) { rejetees++; continue; }
const nombre = (i: number) => Number(r[nbDims + i]?.qNum) || 0;
const ligne: Ligne = [
code, codeAlt, libelle, fournisseur,
nombre(0), nombre(1), nombre(2), nombre(3), nombre(4),
];
const existant = parCode.get(code);
if (!existant) { parCode.set(code, ligne); continue; }
// Même article chez plusieurs fournisseurs : une seule ligne.
// Les mesures sont identiques (elles ne dépendent pas de la
// dimension fournisseur), on complète juste les libellés.
if (!existant[1] && codeAlt) existant[1] = codeAlt;
if (!existant[2] && libelle) existant[2] = libelle;
if (!existant[3] && fournisseur) existant[3] = fournisseur;
}
const uniques = [...parCode.values()];
const rows = uniques.slice(0, limite);
trace.push(
"cube=" + hc.qSize.qcy + " lignes lues=" + matrix.length +
" rejetées(hors terme)=" + rejetees +
" articles uniques=" + uniques.length + " retenus=" + rows.length,
);
if (rows.length > 0) {
trace.push("échantillon=" + JSON.stringify(rows.slice(0, 3)));
}
ws.close();
resolve({
ok: true, rows, champUtilise: champChoisi, total: uniques.length,
lues: matrix.length, rejetees, tropLarge, trace,
});
} catch (e) {
try { ws.close(); } catch { /* noop */ }
resolve({ ok: false, error: String((e as Error)?.message || e), trace });
}
})();
}),
{
app: cfg.appNetwork,
dim: cfg.dimCodeArticleId,
token: csrfToken,
terme: cleaned,
limite: limit,
settleMs,
serialsDate: dateFilter.dailySerials,
mCa: cfg.measCaId,
mQte: cfg.measQteId,
mNbMag: cfg.measNbMagId,
mCaMag: cfg.measCaMagId,
mMarge: cfg.measMargePctId,
champCodeAlt: CHAMP_CODE_CENTRALE_ALT,
champsLibellePreferes: CHAMPS_LIBELLE_PREFERES,
champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES,
champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(),
champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(),
},
)) as InPageSearchResult;
for (const t of result.trace ?? []) console.log(`[qlik-search] ${t}`);
if (!result.ok) throw new Error(`[qlik-search] ${result.error}`);
const matches: QlikArticleMatch[] = (result.rows ?? []).map(
([codeCentrale, codeCentraleAlt, libelle, fournisseur, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau]) => ({
codeCentrale, codeCentraleAlt, libelle, fournisseur,
caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, margePctReseau,
}),
);
console.log(
`[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`,
);
// Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que
// d'afficher un « aucun résultat » qui laisserait croire à une absence
// réelle dans le réseau.
let avertissement: string | null = null;
if (matches.length === 0 && (result.rejetees ?? 0) > 0) {
avertissement = "Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search].";
} else if (matches.length === 0 && result.tropLarge) {
avertissement = "Le terme est trop large pour Qlik (plus de 10 000 libellés correspondants). Ajoutez un mot plus précis.";
}
if (avertissement) console.warn(`[qlik-search] ${avertissement}`);
return {
matches,
champUtilise: result.champUtilise ?? null,
tronque: (result.total ?? 0) > matches.length,
avertissement,
periode: dateFilter.label,
};
} finally {
await ctx.close();
}
}
+26
View File
@@ -0,0 +1,26 @@
/**
* CollectFlow — Détection des Server Actions périmées après un déploiement.
*
* Next.js identifie chaque Server Action par un hash calculé **à la compilation**.
* Après un nouveau build (déploiement, redémarrage du conteneur), les hashs
* changent : un onglet resté ouvert continue d'envoyer les anciens identifiants,
* et le serveur répond
*
* Server Action "4010b9…" was not found on the server.
*
* Ce n'est pas une panne applicative : c'est le client qui est périmé. La seule
* issue est de recharger la page pour récupérer le bundle courant.
*
* Les brouillons de la Grille sont persistés dans le store zustand
* (localStorage) : recharger ne perd pas le travail en cours.
*/
/** Message affiché à l'utilisateur quand son onglet est périmé. */
export const STALE_ACTION_MESSAGE =
"L'application a été mise à jour depuis l'ouverture de cet onglet. Rechargez la page pour continuer — vos modifications en cours sont conservées.";
/** true si l'erreur vient d'une Server Action absente du serveur (bundle périmé). */
export function isStaleServerActionError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err ?? "");
return /server action/i.test(message) && /not\s+be?\s*found|was not found|failed-to-find-server-action/i.test(message);
}