mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Articles: - noid: r.no_id (was r.noid) - codefou: r.codefou_principal (was r.codefou) - nomfou: r.nom_fou_principal (was r.nomfou) - pcb: r.pcb_principal (was r.pcb) Mouvements: - datemvt: r.datmvt (was r.datemvt) — CRITICAL - qte: r.qtemvt (was r.qte) — CRITICAL - montant: r.mntmvtht (was r.montant) — CRITICAL - marge: r.margemvt (was r.marge) — CRITICAL - URL now uses dateDebut/dateFin params (confirmed working vs datedeb/datefin which ignores filter) Sites fallback: use real codes 292/579 (Frouard/Houdemont) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
382 lines
16 KiB
TypeScript
382 lines
16 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
// 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<T>(
|
|
buildUrl: (page: number) => string,
|
|
_extractItems: (data: unknown) => T[], // conservé pour compatibilité, mais on utilise extractList
|
|
pageSize = 500
|
|
): Promise<T[]> {
|
|
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<FfArticle[]> {
|
|
const raw = await fetchAllPages<unknown>(
|
|
(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<FfMouvement[]> {
|
|
const raw = await fetchAllPages<unknown>(
|
|
(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<Map<string, AggregatedStock>> {
|
|
const result = new Map<string, AggregatedStock>();
|
|
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<Map<string, number>> {
|
|
const result = new Map<string, number>();
|
|
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<FfSyncStatus | null> {
|
|
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" },
|
|
];
|
|
}
|
|
}
|