From 8986f7d001c59572bba306549790cc2d595c0fcb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 18 Mar 2026 19:26:47 +0100 Subject: [PATCH] feat: integrate /referentiel endpoint for nomenclature, gamme, stock, PA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces ventesProduits DB query with API call to /articles/:no_id/referentiel. Called in parallel with mensuel and commandes. Extracts: - article.nom_code/nom_libelle → code3/libelle3 (nomenclature) - gammes[0].gamme_code → codeGamme + codeGammeInit (frozen initial) - prix.achat + fournisseurs[0].pcb → pa/prixAchat/pcb - stock[] → stockActuel/stockTotal/stockValeur (real-time) - performance.derniere_vente/derniere_entree → derniereVente/derniereLivraison Co-Authored-By: Claude Sonnet 4.6 --- src/features/grid/api/get-product-rows.ts | 73 ++++++++++---------- src/lib/api-ff-client.ts | 83 +++++++++++++++++++++++ 2 files changed, 122 insertions(+), 34 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index f9c5c8e..ae8577a 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -5,12 +5,13 @@ import { computeProductScores } from "@/lib/score-engine"; import { getArticlesByFournisseur, getMensuelByArticles, + getReferentielByArticles, getCommandesByFournisseur, buildLast12MonthsRange, } from "@/lib/api-ff-client"; import { db } from "@/db"; -import { sessionSnapshots, ventesProduits } from "@/db/schema"; -import { eq, desc, inArray } from "drizzle-orm"; +import { sessionSnapshots } from "@/db/schema"; +import { eq, desc } from "drizzle-orm"; interface GetProductRowsInput { codeFournisseur: string; @@ -29,8 +30,9 @@ export async function getProductRows(input: GetProductRowsInput): Promise 0) { - const classifRows = await db - .select({ - codein: ventesProduits.codein, - codeGamme: ventesProduits.codeGamme, - codeGammeInit: ventesProduits.codeGammeInit, - code3: ventesProduits.code3, - libelle3: ventesProduits.libelle3, - }) - .from(ventesProduits) - .where(inArray(ventesProduits.codein, codeins)); + // ─── Phase 6 : Classification depuis referentiel API (nomenclature + gamme) ─ + let refCount = 0; + for (const [codein, ref] of referentielMap.entries()) { + const product = productMap.get(codein); + if (!product) continue; - // Déduplique en JS : premier occurrence par codein - const seen = new Set(); - for (const row of classifRows) { - if (seen.has(row.codein)) continue; - seen.add(row.codein); - const product = productMap.get(row.codein); - if (product) { - product.codeGamme = row.codeGamme ?? null; - product.codeGammeInit = row.codeGammeInit ?? null; - product.code3 = row.code3 ?? ""; - product.libelle3 = row.libelle3 ?? ""; - } - } - console.log(`[getProductRows] Classification: ${seen.size} codeins trouvés sur ${codeins.length} articles`); + // Nomenclature + product.code3 = ref.article.nom_code ?? ""; + product.libelle3 = ref.article.nom_libelle ?? ""; + + // Gamme initiale (figée) + gamme courante (sera overridée par snapshot si besoin) + const gammeCode = ref.gammes?.[0]?.gamme_code ?? null; + product.codeGammeInit = gammeCode; + product.codeGamme = gammeCode; + + // PA et PCB depuis referentiel (plus précis que articles endpoint) + const pa = parseFloat(ref.prix?.achat ?? "0") || 0; + if (pa > 0) { product.pa = pa; product.prixAchat = pa; } + const pcb = parseFloat(ref.fournisseurs?.[0]?.pcb ?? "0") || 0; + if (pcb > 0) product.pcb = pcb; + + // Stock actuel agrégé tous sites (valeur temps réel) + if (ref.stock?.length) { + product.stockActuel = ref.stock.reduce((sum, s) => sum + (parseFloat(s.stockdispo ?? "0") || 0), 0); + product.stockTotal = ref.stock.reduce((sum, s) => sum + (parseFloat(s.qte ?? "0") || 0), 0); + product.stockValeur = ref.stock.reduce((sum, s) => sum + (parseFloat(s.valstock ?? "0") || 0), 0); + const firstSite = ref.stock.find(s => parseFloat(s.prmp ?? "0") > 0); + if (firstSite && !product.pa) { product.pa = parseFloat(firstSite.prmp); product.prixAchat = product.pa; } } - } catch (classifErr) { - console.warn("[getProductRows] ventesProduits classification non disponible:", classifErr); + + // Dernière vente depuis performance + if (ref.performance?.derniere_vente) product.derniereVente = ref.performance.derniere_vente; + if (ref.performance?.derniere_entree) product.derniereLivraison = ref.performance.derniere_entree; + + refCount++; } + console.log(`[getProductRows] Referentiel: ${refCount}/${articles.length} articles enrichis`); // ─── Phase 7 : Restaurer gammes depuis dernier snapshot ────────────── try { diff --git a/src/lib/api-ff-client.ts b/src/lib/api-ff-client.ts index e4c3381..7eec733 100644 --- a/src/lib/api-ff-client.ts +++ b/src/lib/api-ff-client.ts @@ -311,6 +311,89 @@ export async function getMensuelByArticles( return result; } +// --------------------------------------------------------------------------- +// Referentiel — article complet : nomenclature, gammes, stock, prix, fournisseurs +// GET /api/articles/:no_id/referentiel +// --------------------------------------------------------------------------- + +export interface FfReferentiel { + article: { + no_id: string; + codein: string; + nom_code: string | null; // code nomenclature (ex: "360504") + nom_libelle: string | null; // libellé nomenclature + nom_niveau: number | null; + nom_chemin_pere: string | null; + }; + gammes: { + gamme_code: string; + gamme_libelle: string; + saison_code: string; + saison_libelle: string; + }[]; + stock: { + site: string; + stockdispo: string; + qte: string; + prmp: string; + valstock: string; + pv: string; + dernierevente: string; + dernierereception: string; + stockmort: boolean; + }[]; + prix: { + achat: string | null; + vente_par_site: { site: string; pv: string }[]; + } | null; + fournisseurs: { + codefou: string; + ref_fou: string; + pcb: string; + prixachat: string; + }[]; + performance: { + derniere_vente: string | null; + derniere_entree: string | null; + qte_totale_vendue: string | null; + ca_ttc_total: string | null; + marge_totale: string | null; + } | null; +} + +/** + * Récupère le référentiel complet (nomenclature, gamme, stock, prix) pour un lot d'articles. + * Utilise no_id comme identifiant. + * Retourne Map + */ +export async function getReferentielByArticles( + articles: FfArticle[], + batchSize = 20 +): Promise> { + const result = new Map(); + + for (let i = 0; i < articles.length; i += batchSize) { + const batch = articles.slice(i, i + batchSize); + await Promise.all( + batch.map(async (art) => { + const noId = art.noid; + if (!noId) return; + try { + const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/referentiel`; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) return; + const data: FfReferentiel = await res.json(); + if (data?.article) result.set(art.codein, data); + } catch (err) { + console.error(`[api-ff] getReferentiel error for ${art.codein}:`, err); + } + }) + ); + } + + return result; +} + // --------------------------------------------------------------------------- // Commandes en cours // ---------------------------------------------------------------------------