feat(produits): recherche Qlik d'abord + fiche réseau complète, tendance 12 mois glissants

Recherche produit
- La page /produits interroge désormais Qlik Sense EN PREMIER
  (src/lib/qlik-search.ts) : recherche globale Engine sur les champs article
  (code + libellé détectés dynamiquement via FieldList), sélection des valeurs
  trouvées, puis liste des Article Code correspondants.
- Les métriques réseau des articles trouvés sont extraites sur 12 mois
  glissants et mises en cache (qlik_network_metrics), puis rapprochées du
  catalogue FF Nancy par code centrale (pgGetProduitsByCodeCentrale).
- Repli propre sur la recherche catalogue local si Qlik est injoignable, signalé
  dans l'UI. Résultats mis en cache mémoire 10 min, requête dédupliquée.
- Nouvelle route GET /api/produits/search (runtime nodejs, 300 s) ; la liste est
  chargée côté client avec état de chargement, l'extraction Qlik prenant
  plusieurs secondes.

Informations affichées
- Résultats : magasins vendeurs (+ % du réseau), quantité réseau, quantité par
  magasin, prix moyen réseau, CA par magasin, marge %, sparkline de tendance,
  fournisseur, et présence ou non au catalogue Nancy.
- Fiche : carte « Performance réseau · 12 mois glissants » en tête (8 indicateurs
  + détail mensuel qté / magasins / qté par magasin / CA / prix moyen / marge %),
  puis carte « Fournisseur » dédiée, puis identité, stock, nos ventes 12 mois.
- La fiche s'ouvre aussi par code centrale (?cc=) pour les produits que le réseau
  travaille et que Nancy ne référence pas — le libellé et le fournisseur Qlik
  sont persistés dans le cache réseau pour ce cas.

Tendance réseau
- computeNetworkTrend reconstruit sa fenêtre depuis la date du jour : 12 mois
  complets, mois en cours EXCLU (il est partiel et écrasait la pente). Les mois
  absents du cache valent 0 ; ceux antérieurs à la première extraction réelle
  sont écartés au lieu d'être inventés à 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
Claude committed 2026-07-29 05:04:34 +00:00
1 parent abc482c370
commit 203c96f6c9
16 files changed
+1550 -329

No files matched your search

+99
View File
@@ -1387,6 +1387,105 @@ export async function pgSearchProduits(term: string, limit = 50): Promise<PgProd
}
}
/**
* 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;