feat: create API client for FF Nancy REST API to synchronize supplier, article, and movement data

This commit is contained in:
Michael committed 2026-04-24 10:22:05 +02:00
1 parent 5a3ac3ed7f
commit a06d7cd7e8
2 files changed
+30 -75

No files matched your search

+4
View File
@@ -43,6 +43,8 @@ export interface FfMouvement {
marge?: number; // marge (si disponible) marge?: number; // marge (si disponible)
qtestock: number; // stock APRÈS ce mouvement — clé pour stock12m qtestock: number; // stock APRÈS ce mouvement — clé pour stock12m
site: string; // code magasin / site site: string; // code magasin / site
codefou_reel?: string; // code fournisseur réel (dernière entrée avant cette vente, ou code de l'entrée)
nom_fournisseur_reel?: string; // nom résolu du fournisseur réel
} }
export interface FfStock { export interface FfStock {
@@ -286,6 +288,8 @@ export async function getMouvementsByFournisseur(
marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 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), qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0),
site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "", site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "",
codefou_reel: r.codefou_reel ?? undefined,
nom_fournisseur_reel: r.nom_fournisseur_reel ?? undefined,
})).filter(m => m.codein && m.datemvt); })).filter(m => m.codein && m.datemvt);
} }
+26 -75
View File
@@ -530,6 +530,8 @@ interface ApiPerfTopItem {
codein: string; codein: string;
libelle1: string; libelle1: string;
site: string; site: string;
codefou_reel?: string;
nom_fournisseur_reel?: string;
ca_ttc: number; ca_ttc: number;
qte_vendue: number; qte_vendue: number;
marge: number; marge: number;
@@ -601,100 +603,46 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
]; ];
const allCodeins = [...new Set(allItems.map(i => i.codein).filter(Boolean))]; const allCodeins = [...new Set(allItems.map(i => i.codein).filter(Boolean))];
// Enrichissement : stock et fournisseur via PostgreSQL (optionnel — le dashboard // Enrichissement stock uniquement — le fournisseur réel vient maintenant directement
// s'affiche même si la DB n'est pas configurée). // des champs codefou_reel / nom_fournisseur_reel retournés par l'API mouvements.
let stockMap = new Map<string, { stock292: number; stock579: number; stockTotal: number }>(); let stockMap = new Map<string, { stock292: number; stock579: number; stockTotal: number }>();
let fouMap = new Map<string, string>();
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
if (allCodeins.length > 0) { if (allCodeins.length > 0) {
try { try {
// Batch the API requests to avoid overwhelming the server (e.g., batch of 5)
for (let i = 0; i < allCodeins.length; i += 5) { for (let i = 0; i < allCodeins.length; i += 5) {
const batch = allCodeins.slice(i, i + 5); const batch = allCodeins.slice(i, i + 5);
await Promise.all(batch.map(async (codein) => { await Promise.all(batch.map(async (codein) => {
try { try {
// 1. Fetch article search by codein to get no_id
const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`); const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`);
if (!artRes.ok) return; if (!artRes.ok) return;
const artData = await artRes.json(); const artData = await artRes.json();
if (!artData.articles || artData.articles.length === 0) return; if (!artData.articles || artData.articles.length === 0) return;
const article = artData.articles[0];
const noId = article.no_id;
// 2. Fetch referentiel using no_id — provides stock + real supplier (preference=1) const noId = artData.articles[0].no_id;
if (noId) { if (!noId) return;
const refRes = await fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`);
if (!refRes.ok) { const refRes = await fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`);
// Fallback: use nom_fou_principal from article list if (!refRes.ok) return;
fouMap.set(codein, article.nom_fou_principal || article.codefou_principal || 'Sans fournisseur'); const refData = await refRes.json();
return;
let s292 = 0, s579 = 0, sTot = 0;
if (Array.isArray(refData.stock)) {
for (const s of refData.stock) {
const qte = Number(s.qte) || 0;
if (s.site === '292') s292 += qte;
else if (s.site === '579') s579 += qte;
sTot += qte;
} }
const refData = await refRes.json();
// --- Supplier: pick the last fournisseur who entered stock ---
// Business rule: attribute the article to the most recently active supplier
// = non-depot fournisseur with suspendu=null (currently active delivery relationship)
type FouEntry = { codefou: string; nom_fou: string | null; preference: number | null; suspendu: string | null };
let fou = '—';
// Known depot/platform codes that are NOT real commercial suppliers
const DEPOT_CODES = new Set(['D005', 'B0071', 'D005 ', 'B0071 ']);
if (refData.fournisseurs && Array.isArray(refData.fournisseurs)) {
const fous = refData.fournisseurs as FouEntry[];
const pickLabel = (f: FouEntry): string => f.nom_fou?.trim() || f.codefou.trim();
// Truly active = suspendu is null or the 1901 epoch sentinel (never suspended)
const isTrulyActive = (f: FouEntry): boolean =>
!f.suspendu || f.suspendu.startsWith('1901');
// Non-depot fournisseurs only (exclude known platform/depot codes)
const realFous = fous.filter(f => !DEPOT_CODES.has(f.codefou.trim()));
if (realFous.length > 0) {
// Priority 1: currently active (suspendu=null) non-depot — most likely to have made last reception
const activeNow = realFous.find(f => isTrulyActive(f));
// Priority 2: preference=1 (declared main supplier, even if suspended)
const p1 = realFous.find(f => f.preference === 1);
// Priority 3: first non-depot available (last resort)
const first = realFous[0];
const best = activeNow ?? p1 ?? first;
fou = pickLabel(best);
} else {
// Only depot codes found → article sourced exclusively through depot
fou = 'Dépôt';
}
}
fouMap.set(codein, fou);
// --- Stock: sum qte per site ---
let s292 = 0;
let s579 = 0;
let sTot = 0;
if (refData.stock && Array.isArray(refData.stock)) {
for (const s of refData.stock) {
const qte = Number(s.qte) || 0;
if (s.site === '292') s292 += qte;
else if (s.site === '579') s579 += qte;
sTot += qte;
}
}
stockMap.set(codein, { stock292: s292, stock579: s579, stockTotal: sTot });
} else {
// no_id missing: fallback supplier from article list
fouMap.set(codein, article.nom_fou_principal || article.codefou_principal || 'Sans fournisseur');
} }
stockMap.set(codein, { stock292: s292, stock579: s579, stockTotal: sTot });
} catch (err) { } catch (err) {
console.error(`[Dashboard API enrichment] Error for codein ${codein}:`, err); console.error(`[Dashboard stock enrichment] Error for codein ${codein}:`, err);
} }
})); }));
} }
} catch (enrichErr: any) { } catch (enrichErr: unknown) {
console.warn("[Dashboard] API enrichment unavailable:", enrichErr); console.warn("[Dashboard] Stock enrichment unavailable:", enrichErr);
} }
} }
@@ -707,6 +655,9 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
const stock = i.site === "292" ? (stockEntry?.stock292 ?? 0) const stock = i.site === "292" ? (stockEntry?.stock292 ?? 0)
: i.site === "579" ? (stockEntry?.stock579 ?? 0) : i.site === "579" ? (stockEntry?.stock579 ?? 0)
: (stockEntry?.stockTotal ?? 0); : (stockEntry?.stockTotal ?? 0);
const fournisseur = i.nom_fournisseur_reel?.trim()
|| i.codefou_reel?.trim()
|| "—";
return { return {
codein: i.codein, codein: i.codein,
libelle1: i.libelle1, libelle1: i.libelle1,
@@ -714,7 +665,7 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
qte: Number(i.qte_vendue) || 0, qte: Number(i.qte_vendue) || 0,
marge: Number(i.marge) || 0, marge: Number(i.marge) || 0,
stock, stock,
fournisseur: fouMap.get(i.codein) ?? "—", fournisseur,
}; };
}) })
.sort((a, b) => b[sortKey] - a[sortKey]) .sort((a, b) => b[sortKey] - a[sortKey])