mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 18:27:40 +02:00
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:
50 files changed
+8488
-2017
No files matched your search
+28
-10
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user