/** * CollectFlow — API FF Nancy Client * * Client HTTP centralisé pour l'API REST https://api.ffnancy.fr * Sync nuit SQL Server → données fraîches J-1 * * Stratégie mouvements : appeler SANS filtre genremvt (tous types) * - genremvt = 3 → alimenter sales12m (ventes) * - Tous types → lire qtestock pour reconstruire stock12m */ const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; // --------------------------------------------------------------------------- // Types API (shapes des réponses brutes) // --------------------------------------------------------------------------- export interface FfFournisseur { codefou: string; nomfou: string; } export interface FfArticle { codein: string; libelle1: string; codefou: string; nomfou?: string; pcb?: number; // conditionnement pv_central?: number; // prix de vente central noid?: number; // SQL Server internal ID } export interface FfMouvement { codein: string; genremvt: number; // 3 = vente client datemvt: string; // ISO date (YYYY-MM-DD) qte: number; // quantité du mouvement montant: number; // montant HT marge?: number; // marge (si disponible) qtestock: number; // stock APRÈS ce mouvement — clé pour stock12m site: string; // code magasin / site } export interface FfStock { codein: string; site?: string; stockdispo: number; qte: number; valstock: number; prmp?: number; // PRMP (prix de revient moyen pondéré) pv?: number; // prix de vente dernieresventes?: string; dernierereception?: string; nbjoursdernsventes?: number; } export interface FfCommande { codein: string; qtecde: number; } export interface FfSyncStatus { lastSync: string; tables: { nom: string; derniereSync: string; nbLignes?: number }[]; } /** Stock agrégé sur tous les sites pour un codein */ export interface AggregatedStock { stockActuel: number; // sum stockdispo stockTotal: number; // sum qte stockValeur: number; // sum valstock pa: number; // prmp (premier site non-zéro) prixVente: number; // pv premier site derniereVente: string; // max date tous sites derniereLivraison: string; // max dernierereception tous sites nbJoursDerniereVente: number; // min tous sites } // --------------------------------------------------------------------------- // Pagination générique // --------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractList(data: unknown): any[] { if (Array.isArray(data)) return data; const d = data as Record; // Essaie les wrappers courants for (const key of ["items", "data", "result", "results", "records", "list", "mouvements", "articles", "fournisseurs", "ventes", "stocks", "commandes"]) { if (Array.isArray(d[key])) return d[key] as unknown[]; } // Cherche la première propriété qui est un tableau for (const val of Object.values(d)) { if (Array.isArray(val)) return val as unknown[]; } return []; } async function fetchAllPages( buildUrl: (page: number) => string, _extractItems: (data: unknown) => T[], // conservé pour compatibilité, mais on utilise extractList pageSize = 500 ): Promise { const all: T[] = []; let page = 1; while (true) { const url = buildUrl(page); console.log(`[api-ff] GET ${url}`); const res = await fetch(url, { cache: "no-store" }); if (!res.ok) { console.error(`[api-ff] HTTP ${res.status} — ${url}`); break; } const data = await res.json(); const items = extractList(data) as T[]; if (page === 1) { console.log(`[api-ff] page 1: ${items.length} items, keys: ${items[0] ? Object.keys(items[0] as object).join(",") : "empty"}`); } all.push(...items); if (items.length < pageSize) break; page++; } return all; } // --------------------------------------------------------------------------- // Helpers date // --------------------------------------------------------------------------- /** Retourne la fenêtre des 12 mois complets précédant le mois en cours. * En mars 2026 → { dateDebut: "2025-03-01", dateFin: "2026-02-28" } */ export function buildLast12MonthsRange(): { dateDebut: string; dateFin: string } { const now = new Date(); // Fin : dernier jour du mois précédent le mois courant const endMonth = new Date(now.getFullYear(), now.getMonth(), 0); // Début : 12 mois avant le mois courant const startMonth = new Date(now.getFullYear(), now.getMonth() - 12, 1); return { dateDebut: startMonth.toISOString().slice(0, 10), dateFin: endMonth.toISOString().slice(0, 10), }; } /** "2025-03-15" → "202503" */ export function dateToYYYYMM(isoDate: string): string { return isoDate.slice(0, 7).replace("-", ""); } // --------------------------------------------------------------------------- // Fournisseurs // --------------------------------------------------------------------------- /** Extrait le code fournisseur depuis n'importe quelle forme de réponse API */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractFouCode(f: any): string { return f.codefou ?? f.CodeFou ?? f.code_fournisseur ?? f.codeFournisseur ?? f.code ?? String(f.id ?? ""); } // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractFouNom(f: any): string { return f.nomfou ?? f.NomFou ?? f.nom_fournisseur ?? f.nomFournisseur ?? f.nom ?? f.libelle ?? f.name ?? "Inconnu"; } export async function getFournisseursFromApi( search?: string ): Promise<{ code: string; nom: string }[]> { try { const url = search ? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500` : `${FF_API_BASE}/api/fournisseurs?limit=500`; console.log(`[api-ff] GET ${url}`); const res = await fetch(url, { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`); const data = await res.json(); // Log premier item pour diagnostic const rawList = extractList(data); console.log(`[api-ff] fournisseurs: ${rawList.length} items, sample keys:`, rawList[0] ? Object.keys(rawList[0] as object) : "empty"); return rawList .map((f: unknown) => ({ code: extractFouCode(f), nom: extractFouNom(f) })) .filter((f: { code: string; nom: string }) => f.code); } catch (err) { console.error("[api-ff] getFournisseursFromApi error:", err); return []; } } // --------------------------------------------------------------------------- // Articles // --------------------------------------------------------------------------- export async function getArticlesByFournisseur( codefou: string ): Promise { const raw = await fetchAllPages( (page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`, extractList, 500 ); // eslint-disable-next-line @typescript-eslint/no-explicit-any return raw.map((r: any): FfArticle => ({ codein: r.codein ?? r.Codein ?? r.code_article ?? r.codeArticle ?? String(r.id ?? ""), libelle1: r.libelle1 ?? r.Libelle1 ?? r.libelle ?? r.designation ?? r.nom ?? "", codefou: r.codefou_principal ?? r.codefou ?? r.Codefou ?? r.code_fournisseur ?? r.codeFournisseur ?? codefou, nomfou: r.nom_fou_principal ?? r.nomfou ?? r.Nomfou ?? r.nom_fournisseur ?? r.nomFournisseur ?? "", pcb: r.pcb_principal ?? r.pcb ?? r.Pcb ?? r.conditionnement ?? r.colisage, pv_central: r.pv_central ?? r.PvCentral ?? r.pv ?? r.prixVente, noid: r.no_id ?? r.noid ?? r.NoId, })).filter(a => a.codein); } // --------------------------------------------------------------------------- // Mouvements (ventes + stock12m) // --------------------------------------------------------------------------- export async function getMouvementsByFournisseur( codefou: string, dateDebut: string, dateFin: string ): Promise { const raw = await fetchAllPages( (page) => `${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`, extractList, 1000 ); // eslint-disable-next-line @typescript-eslint/no-explicit-any return raw.map((r: any): FfMouvement => ({ codein: r.codein ?? r.Codein ?? r.code_article ?? "", genremvt: Number(r.genremvt ?? r.GenreMvt ?? r.genre_mvt ?? r.type ?? 0), datemvt: r.datmvt ?? r.datemvt ?? r.DateMvt ?? r.date_mvt ?? r.date ?? "", qte: Number(r.qtemvt ?? r.qte ?? r.Qte ?? r.quantite ?? 0), montant: Number(r.mntmvtht ?? r.montant ?? r.Montant ?? r.montant_mvt ?? r.ca ?? 0), marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 0), qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0), site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "", })).filter(m => m.codein && m.datemvt); } // --------------------------------------------------------------------------- // Stock (agrégé tous sites) // --------------------------------------------------------------------------- export async function getStockByFournisseur( articles: FfArticle[] ): Promise> { const result = new Map(); const BATCH_SIZE = 10; for (let i = 0; i < articles.length; i += BATCH_SIZE) { const batch = articles.slice(i, i + BATCH_SIZE); await Promise.all( batch.map(async (art) => { try { const res = await fetch( `${FF_API_BASE}/api/stocks/articles?codein=${encodeURIComponent(art.codein)}`, { cache: "no-store" } ); if (!res.ok) return; const data = await res.json(); const rawSites = extractList(data); if (rawSites.length === 0) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const sites: FfStock[] = rawSites.map((s: any) => ({ codein: s.codein ?? s.Codein ?? art.codein, site: s.site ?? s.Site ?? s.codesite ?? s.magasin ?? "", stockdispo: Number(s.stockdispo ?? s.StockDispo ?? s.stock_dispo ?? s.stock ?? 0), qte: Number(s.qte ?? s.Qte ?? s.quantite ?? 0), valstock: Number(s.valstock ?? s.ValStock ?? s.val_stock ?? s.valeur ?? 0), prmp: Number(s.prmp ?? s.Prmp ?? s.pa ?? s.prix_achat ?? 0) || undefined, pv: Number(s.pv ?? s.Pv ?? s.prix_vente ?? s.pv_central ?? 0) || undefined, dernieresventes: s.dernieresventes ?? s.DernVentes ?? s.derniere_vente ?? s.last_sale ?? "", dernierereception: s.dernierereception ?? s.DernReception ?? s.derniere_reception ?? s.last_reception ?? "", nbjoursdernsventes: Number(s.nbjoursdernsventes ?? s.NbJoursDernVentes ?? s.nb_jours ?? 0) || undefined, })); const agg: AggregatedStock = { stockActuel: 0, stockTotal: 0, stockValeur: 0, pa: 0, prixVente: 0, derniereVente: "", derniereLivraison: "", nbJoursDerniereVente: 9999, }; for (const s of sites) { agg.stockActuel += s.stockdispo ?? 0; agg.stockTotal += s.qte ?? 0; agg.stockValeur += s.valstock ?? 0; if (!agg.pa && s.prmp) agg.pa = s.prmp; if (!agg.prixVente && s.pv) agg.prixVente = s.pv; if (s.dernieresventes && s.dernieresventes > agg.derniereVente) { agg.derniereVente = s.dernieresventes; } if (s.dernierereception && s.dernierereception > agg.derniereLivraison) { agg.derniereLivraison = s.dernierereception; } if (s.nbjoursdernsventes !== undefined && s.nbjoursdernsventes < agg.nbJoursDerniereVente) { agg.nbJoursDerniereVente = s.nbjoursdernsventes; } } if (agg.nbJoursDerniereVente === 9999) agg.nbJoursDerniereVente = 0; result.set(art.codein, agg); } catch (err) { console.error(`[api-ff] getStock error for ${art.codein}:`, err); } }) ); } return result; } // --------------------------------------------------------------------------- // Commandes en cours // --------------------------------------------------------------------------- export async function getCommandesByFournisseur( codefou: string ): Promise> { const result = new Map(); try { const res = await fetch( `${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`, { cache: "no-store" } ); if (!res.ok) return result; const data = await res.json(); const list: FfCommande[] = Array.isArray(data) ? data : ((data as any).items ?? (data as any).data ?? []); for (const c of list) { result.set(c.codein, (result.get(c.codein) ?? 0) + (c.qtecde ?? 0)); } } catch (err) { console.error("[api-ff] getCommandesByFournisseur error:", err); } return result; } // --------------------------------------------------------------------------- // Statut de synchronisation // --------------------------------------------------------------------------- export async function getSyncStatus(): Promise { try { const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" }); if (!res.ok) return null; return await res.json(); } catch (err) { console.error("[api-ff] getSyncStatus error:", err); return null; } } // --------------------------------------------------------------------------- // Sites (magasins) // --------------------------------------------------------------------------- export async function getSitesFromApi(): Promise<{ code: string; nom: string }[]> { try { const res = await fetch(`${FF_API_BASE}/api/sites`, { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const list = Array.isArray(data) ? data : ((data as any).items ?? (data as any).data ?? []); return list.map((s: any) => ({ code: s.code ?? s.codesite ?? s.site, nom: s.nom ?? s.libelle ?? s.code })); } catch (err) { console.error("[api-ff] getSitesFromApi error:", err); // Fallback : sites connus de FF Nancy return [ { code: "292", nom: "Frouard (Nancy)" }, { code: "579", nom: "Houdemont" }, ]; } }