mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 04:36:21 +02:00
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:
16 files changed
+1550
-329
No files matched your search
@@ -23,6 +23,24 @@ export type NetworkTrend = {
|
||||
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
|
||||
export const TREND_STRONG = 0.25;
|
||||
|
||||
/**
|
||||
* Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au
|
||||
* plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer
|
||||
* écraserait systématiquement la tendance vers le bas.
|
||||
*
|
||||
* Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que
|
||||
* `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent
|
||||
* rester alignés.
|
||||
*/
|
||||
export function buildRolling12QlikMonths(now: Date = new Date()): string[] {
|
||||
const months: string[] = [];
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
/** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */
|
||||
export const TREND_FLAT = 0.08;
|
||||
|
||||
@@ -33,17 +51,42 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
|
||||
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les **12 mois
|
||||
* complets glissants, mois en cours exclu**.
|
||||
*
|
||||
* La fenêtre est reconstruite à partir de la date du jour (et non des clés
|
||||
* présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance
|
||||
* réellement glissante. Deux conséquences voulues :
|
||||
* - un mois en cours (partiel) ou un mois plus vieux que 12 mois présent dans
|
||||
* le cache est **ignoré** — sinon la pente est faussée par un mois tronqué ;
|
||||
* - un mois de la fenêtre **absent** du cache vaut 0 : côté Qlik, un produit
|
||||
* non vendu sur un mois ne produit tout simplement pas de ligne.
|
||||
*
|
||||
* Seule exception au remplissage par zéro : les mois **antérieurs au premier
|
||||
* mois réellement extrait**. Ils traduisent une extraction plus courte que 12
|
||||
* mois (`QLIK_SYNC_MONTHS_BACK`), pas une absence de ventes — les inventer à 0
|
||||
* simulerait une croissance qui n'existe pas.
|
||||
*
|
||||
* 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 : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
|
||||
*/
|
||||
export function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
|
||||
export function computeNetworkTrend(
|
||||
qteByMonth?: Record<string, number> | null,
|
||||
now: Date = new Date(),
|
||||
): 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 presents = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
|
||||
if (presents.length === 0) return empty;
|
||||
|
||||
const fenetre = buildRolling12QlikMonths(now);
|
||||
// Aucun mois extrait ne tombe dans la fenêtre glissante (cache périmé) :
|
||||
// rien de fiable à tracer.
|
||||
const premierMoisExtrait = presents[0];
|
||||
const labels = fenetre.filter((m) => m >= premierMoisExtrait);
|
||||
if (labels.length === 0 || !labels.some((m) => qteByMonth[m] != null)) return empty;
|
||||
|
||||
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
|
||||
const n = values.length;
|
||||
let pct: number | null = null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import "server-only";
|
||||
|
||||
import {
|
||||
pgGetProduitDetail,
|
||||
pgGetCodeinByCodeCentrale,
|
||||
pgGetFournisseursByCodein,
|
||||
pgGetStockByCodein,
|
||||
pgGetCommandesByCodein,
|
||||
@@ -34,6 +35,55 @@ function totauxFrom(rows: ProduitMois[]): ProduitTotaux {
|
||||
return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble la fiche d'un produit **à partir de son code centrale** : c'est la
|
||||
* clé du réseau Qlik, seule identité disponible pour un produit que le réseau
|
||||
* travaille mais que Nancy ne référence pas.
|
||||
*
|
||||
* Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie
|
||||
* locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes).
|
||||
* Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal
|
||||
* depuis la recherche Qlik.
|
||||
*
|
||||
* Renvoie `null` uniquement si ni Qlik ni le catalogue ne connaissent le code.
|
||||
*/
|
||||
export async function getProduitFicheByCodeCentrale(codeCentrale: string): Promise<ProduitFiche | null> {
|
||||
const cleaned = codeCentrale.trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
const codein = await pgGetCodeinByCodeCentrale(cleaned);
|
||||
if (codein) return getProduitFiche(codein);
|
||||
|
||||
// Produit réseau pur : aucune donnée locale à assembler.
|
||||
const map = await getNetworkMetricsByCodeCentrale([cleaned]);
|
||||
const reseau = map.get(cleaned) ?? null;
|
||||
if (!reseau) return null;
|
||||
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const months = buildMonthKeys();
|
||||
const mensuelTotal: Record<string, ProduitMois> = {};
|
||||
for (const m of months) mensuelTotal[m] = emptyMois(m);
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
codeCentrale: cleaned,
|
||||
libelleReseau: reseau.libelleReseau ?? "",
|
||||
fournisseurReseau: reseau.fournisseurReseau ?? "",
|
||||
fournisseurs: [],
|
||||
stock: [],
|
||||
commandesEnCours: 0,
|
||||
gammes: [],
|
||||
months,
|
||||
mensuelTotal,
|
||||
mensuelParSite: {},
|
||||
totaux: totauxFrom(months.map((m) => mensuelTotal[m])),
|
||||
totauxParSite: {},
|
||||
sitesActifs: [],
|
||||
reseau,
|
||||
periode: { dateDebut, dateFin },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble la fiche complète d'un produit : identité, fournisseurs, stock,
|
||||
* gammes, 12 mois glissants de ventes (total et par magasin) et métriques
|
||||
@@ -138,6 +188,9 @@ export async function getProduitFiche(codein: string): Promise<ProduitFiche | nu
|
||||
|
||||
return {
|
||||
detail,
|
||||
codeCentrale: detail.code_centrale ?? "",
|
||||
libelleReseau: reseau?.libelleReseau ?? "",
|
||||
fournisseurReseau: reseau?.fournisseurReseau ?? "",
|
||||
fournisseurs,
|
||||
stock,
|
||||
commandesEnCours,
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import "server-only";
|
||||
|
||||
import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search";
|
||||
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { normalizeMargePct } from "@/features/produits/lib/compare-reseau";
|
||||
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
|
||||
|
||||
/**
|
||||
* CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite.
|
||||
*
|
||||
* Déroulé :
|
||||
* 1. Qlik Sense : quels articles du réseau correspondent au terme ?
|
||||
* (`searchQlikArticles` — libellé ou code centrale)
|
||||
* 2. Qlik Sense : métriques réseau 12 mois glissants de ces articles
|
||||
* (`fetchNetworkMetricsPlaywright` — CA, qté, nb magasins, marge, mensuel)
|
||||
* 3. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`)
|
||||
*
|
||||
* Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne
|
||||
* pas laisser l'utilisateur sans résultat — la réponse le signale explicitement
|
||||
* (`source: "db"`).
|
||||
*/
|
||||
|
||||
/** Durée de validité d'un résultat de recherche en cache mémoire. */
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik
|
||||
* (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur
|
||||
* ou un rafraîchissement relancerait toute l'extraction.
|
||||
*/
|
||||
const cache = new Map<string, { at: number; result: ProduitRechercheResultat }>();
|
||||
|
||||
/** Recherches en cours, pour ne pas lancer deux extractions identiques en parallèle. */
|
||||
const enCours = new Map<string, Promise<ProduitRechercheResultat>>();
|
||||
|
||||
function cacheKey(term: string): string {
|
||||
return term.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */
|
||||
function purgeCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of cache) {
|
||||
if (now - v.at > CACHE_TTL_MS) cache.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rechercherProduits(
|
||||
term: string,
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<ProduitRechercheResultat> {
|
||||
const cleaned = term.trim();
|
||||
if (cleaned.length < 3) {
|
||||
return {
|
||||
rows: [], source: "qlik", qlikError: null, tronque: false,
|
||||
champUtilise: null, locauxHorsReseau: [], dureeMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const key = cacheKey(cleaned);
|
||||
purgeCache();
|
||||
if (!options.force) {
|
||||
const hit = cache.get(key);
|
||||
if (hit) {
|
||||
console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`);
|
||||
return hit.result;
|
||||
}
|
||||
const running = enCours.get(key);
|
||||
if (running) return running;
|
||||
}
|
||||
|
||||
const p = executerRecherche(cleaned).finally(() => enCours.delete(key));
|
||||
enCours.set(key, p);
|
||||
const result = await p;
|
||||
cache.set(key, { at: Date.now(), result });
|
||||
return result;
|
||||
}
|
||||
|
||||
async function executerRecherche(term: string): Promise<ProduitRechercheResultat> {
|
||||
const started = Date.now();
|
||||
|
||||
// La recherche catalogue local tourne en parallèle : elle sert à la fois de
|
||||
// repli si Qlik échoue, et de complément (produits Nancy sans code centrale,
|
||||
// donc invisibles côté réseau).
|
||||
const localPromise = pgSearchProduits(term).catch((e) => {
|
||||
console.error("[produits/search] recherche locale échouée:", (e as Error).message);
|
||||
return [] as PgProduitSearchRow[];
|
||||
});
|
||||
|
||||
let qlik;
|
||||
try {
|
||||
qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.error("[produits/search] Qlik indisponible, repli catalogue local:", message);
|
||||
const locaux = await localPromise;
|
||||
return {
|
||||
rows: locaux.map(rowDepuisCatalogueSeul),
|
||||
source: "db",
|
||||
qlikError: messageQlikLisible(message),
|
||||
tronque: locaux.length >= 50,
|
||||
champUtilise: null,
|
||||
locauxHorsReseau: [],
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const codes = qlik.matches.map((m) => m.codeCentrale);
|
||||
if (codes.length === 0) {
|
||||
const locaux = await localPromise;
|
||||
return {
|
||||
rows: [],
|
||||
source: "qlik",
|
||||
qlikError: null,
|
||||
tronque: false,
|
||||
champUtilise: qlik.champUtilise,
|
||||
locauxHorsReseau: locaux,
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
// Métriques réseau + détail mensuel sur la fenêtre 12 mois glissants (mois
|
||||
// courant exclu) — même extracteur que la sync fournisseur, donc mêmes
|
||||
// chiffres que la Grille.
|
||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
||||
let metrics = new Map<string, NetworkMetric>();
|
||||
let metricsError: string | null = null;
|
||||
try {
|
||||
metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
||||
// Le libellé / fournisseur ne sortent que de la recherche : on les
|
||||
// attache ici pour qu'ils soient persistés avec les mesures.
|
||||
for (const m of qlik.matches) {
|
||||
const metric = metrics.get(m.codeCentrale);
|
||||
if (!metric) continue;
|
||||
if (m.libelle) metric.libelleReseau = m.libelle;
|
||||
if (m.fournisseur) metric.fournisseurReseau = m.fournisseur;
|
||||
}
|
||||
// On alimente le cache `qlik_network_metrics` au passage : la fiche
|
||||
// produit ouverte depuis un résultat affichera les données réseau tout
|
||||
// de suite, sans re-extraction.
|
||||
if (metrics.size > 0) {
|
||||
const n = await upsertNetworkMetrics([...metrics.values()]);
|
||||
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Les identités trouvées restent affichables sans les mesures : mieux
|
||||
// vaut une liste sans chiffres réseau qu'une page d'erreur.
|
||||
metricsError = e instanceof Error ? e.message : String(e);
|
||||
console.error("[produits/search] extraction des métriques réseau échouée:", metricsError);
|
||||
}
|
||||
|
||||
const catalogue = await pgGetProduitsByCodeCentrale(codes);
|
||||
const locaux = await localPromise;
|
||||
|
||||
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
|
||||
const net = metrics.get(m.codeCentrale);
|
||||
const local = catalogue.get(m.codeCentrale);
|
||||
const qteReseau = net?.qteReseau ?? 0;
|
||||
const caReseau = net?.caReseau ?? 0;
|
||||
const nbMag = net?.nbMagasinsReseau ?? 0;
|
||||
return {
|
||||
codeCentrale: m.codeCentrale,
|
||||
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
|
||||
libelleReseau: m.libelle,
|
||||
fournisseur: local?.fournisseur || m.fournisseur || "",
|
||||
codein: local?.codein ?? null,
|
||||
nomenclature: local?.nomenclature ?? "",
|
||||
stockLocal: local ? Number(local.stock_total) || 0 : null,
|
||||
qteReseau,
|
||||
caReseau,
|
||||
nbMagasinsReseau: nbMag,
|
||||
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
||||
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
|
||||
caParMagasinReseau: net?.caParMagasinReseau ?? 0,
|
||||
prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
|
||||
margePctReseau: normalizeMargePct(net?.margePctReseau),
|
||||
qteByMonth: net?.qteByMonth ?? null,
|
||||
periode: net?.periode ?? dateFilter.label,
|
||||
};
|
||||
});
|
||||
|
||||
// Les plus vendus du réseau d'abord : c'est l'information utile pour
|
||||
// arbitrer, un produit que le réseau ne vend pas n'intéresse personne.
|
||||
rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr"));
|
||||
|
||||
const codesTrouves = new Set(codes);
|
||||
const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale));
|
||||
|
||||
return {
|
||||
rows,
|
||||
source: "qlik",
|
||||
qlikError: metricsError ? messageQlikLisible(metricsError) : null,
|
||||
tronque: qlik.tronque,
|
||||
champUtilise: qlik.champUtilise,
|
||||
locauxHorsReseau,
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ligne de repli : catalogue Nancy seul, sans données réseau fiables. */
|
||||
function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow {
|
||||
const qteReseau = Number(r.qte_reseau) || 0;
|
||||
const nbMag = Number(r.nb_magasins_reseau) || 0;
|
||||
return {
|
||||
codeCentrale: r.code_centrale,
|
||||
libelle: r.libelle1 || r.codein,
|
||||
libelleReseau: "",
|
||||
fournisseur: r.fournisseur,
|
||||
codein: r.codein,
|
||||
nomenclature: r.nomenclature,
|
||||
stockLocal: Number(r.stock_total) || 0,
|
||||
qteReseau,
|
||||
caReseau: 0,
|
||||
nbMagasinsReseau: nbMag,
|
||||
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
||||
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
|
||||
caParMagasinReseau: 0,
|
||||
prixMoyenReseau: null,
|
||||
margePctReseau: null,
|
||||
qteByMonth: null,
|
||||
periode: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduit les pannes Qlik récurrentes en message actionnable. Les autres
|
||||
* erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables.
|
||||
*/
|
||||
function messageQlikLisible(message: string): string {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) {
|
||||
return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard.";
|
||||
}
|
||||
if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) {
|
||||
return "Identifiants Qlik non configurés (Paramètres → Qlik).";
|
||||
}
|
||||
if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("enotfound")) {
|
||||
return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN).";
|
||||
}
|
||||
return message.slice(0, 300);
|
||||
}
|
||||
@@ -5,11 +5,71 @@
|
||||
import type {
|
||||
PgProduitDetailRow,
|
||||
PgProduitFournisseurRow,
|
||||
PgProduitSearchRow,
|
||||
PgProduitStockRow,
|
||||
PgGammeHistoryRow,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import type { NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
|
||||
/**
|
||||
* Une ligne de résultat de recherche produit.
|
||||
*
|
||||
* La recherche part de **Qlik** : chaque ligne est d'abord un produit du réseau
|
||||
* (identifié par son code centrale), éventuellement rapproché du catalogue FF
|
||||
* Nancy. `codein === null` = le réseau travaille ce produit, nous ne le
|
||||
* référençons pas.
|
||||
*/
|
||||
export interface ProduitRechercheRow {
|
||||
/** Dimension Qlik « Article Code » = `articles.artcentrale`. */
|
||||
codeCentrale: string;
|
||||
/** Libellé affiché : catalogue Nancy si référencé, sinon libellé réseau. */
|
||||
libelle: string;
|
||||
/** Libellé tel que Qlik le renvoie (vide si l'app n'expose pas de champ libellé). */
|
||||
libelleReseau: string;
|
||||
/** Fournisseur : catalogue Nancy prioritaire (nom réel), sinon champ Qlik. */
|
||||
fournisseur: string;
|
||||
/** `null` si le produit n'est pas au catalogue Nancy. */
|
||||
codein: string | null;
|
||||
nomenclature: string;
|
||||
/** Stock Nancy tous sites, `null` si produit non référencé. */
|
||||
stockLocal: number | null;
|
||||
|
||||
// ─── Réseau Qlik, sur 12 mois glissants ─────────────────────────────────
|
||||
qteReseau: number;
|
||||
caReseau: number;
|
||||
nbMagasinsReseau: number;
|
||||
/** Part des magasins du réseau travaillant le produit (0..1). */
|
||||
tauxPresence: number;
|
||||
/** Quantité vendue par magasin qui travaille le produit. */
|
||||
qteParMagasinReseau: number;
|
||||
caParMagasinReseau: number;
|
||||
/** CA réseau / quantité réseau — panier moyen unitaire, `null` si pas de vente. */
|
||||
prixMoyenReseau: number | null;
|
||||
/** Taux de marge réseau en points de % (déjà normalisé), `null` si absent. */
|
||||
margePctReseau: number | null;
|
||||
/** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */
|
||||
qteByMonth: Record<string, number> | null;
|
||||
periode: string | null;
|
||||
}
|
||||
|
||||
export interface ProduitRechercheResultat {
|
||||
rows: ProduitRechercheRow[];
|
||||
/** `qlik` = recherche nominale ; `db` = repli catalogue local (Qlik indisponible). */
|
||||
source: "qlik" | "db";
|
||||
/** Message d'erreur Qlik quand on a dû se rabattre sur la base locale. */
|
||||
qlikError: string | null;
|
||||
/** true si Qlik a renvoyé plus d'articles que la limite affichée. */
|
||||
tronque: boolean;
|
||||
/** Champ Qlik réellement interrogé (diagnostic). */
|
||||
champUtilise: string | null;
|
||||
/**
|
||||
* Produits du catalogue Nancy qui matchent le terme mais qu'aucun article
|
||||
* Qlik ne couvre (pas de code centrale, ou non vendus par le réseau).
|
||||
*/
|
||||
locauxHorsReseau: PgProduitSearchRow[];
|
||||
dureeMs: number;
|
||||
}
|
||||
|
||||
/** Les métriques d'un mois, pour un périmètre donné (total ou un site). */
|
||||
export interface ProduitMois {
|
||||
/** Clé de mois FF, format "YYYYMM". */
|
||||
@@ -31,7 +91,17 @@ export interface ProduitTotaux {
|
||||
}
|
||||
|
||||
export interface ProduitFiche {
|
||||
detail: PgProduitDetailRow;
|
||||
/**
|
||||
* Identité catalogue FF Nancy — `null` pour un produit que le réseau
|
||||
* travaille mais que Nancy ne référence pas (fiche 100 % réseau).
|
||||
*/
|
||||
detail: PgProduitDetailRow | null;
|
||||
/** Code centrale = clé Qlik. Toujours renseigné pour une fiche ouverte depuis la recherche. */
|
||||
codeCentrale: string;
|
||||
/** Libellé de l'article côté Qlik (peut être vide selon l'app Qlik). */
|
||||
libelleReseau: string;
|
||||
/** Fournisseur de l'article côté Qlik (peut être vide selon l'app Qlik). */
|
||||
fournisseurReseau: string;
|
||||
fournisseurs: PgProduitFournisseurRow[];
|
||||
stock: PgProduitStockRow[];
|
||||
commandesEnCours: number;
|
||||
|
||||
Reference in new issue
Block a user