mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: implement product row data retrieval and processing logic for the grid.
This commit is contained in:
1 parent
65f455b4e2
commit
58d467a7ea
2 files changed
+420
-169
No files matched your search
@@ -2,14 +2,15 @@
|
||||
|
||||
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
|
||||
import { computeProductScores } from "@/lib/score-engine";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
import {
|
||||
getArticlesByFournisseur,
|
||||
getMensuelByArticles,
|
||||
getReferentielByArticles,
|
||||
getCommandesByFournisseur,
|
||||
getRankingByArticles,
|
||||
buildLast12MonthsRange,
|
||||
} from "@/lib/api-ff-client";
|
||||
pgGetArticlesByFournisseur,
|
||||
pgGetMensuelByFournisseur,
|
||||
pgGetGammesByFournisseur,
|
||||
pgGetStockByFournisseur,
|
||||
pgGetRankingByFournisseur,
|
||||
pgGetCommandesByFournisseur,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
@@ -25,141 +26,112 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
|
||||
|
||||
try {
|
||||
// ─── Phase 1 : Articles du fournisseur ───────────────────────────────
|
||||
const articles = await getArticlesByFournisseur(codeFournisseur);
|
||||
console.log(`[getProductRows] ${articles.length} articles for ${codeFournisseur}`);
|
||||
|
||||
// ─── Phase 2A : Mensuel + Commandes (en parallèle) ──────────────────
|
||||
// Mensuel d'abord pour identifier quels articles ont des ventes.
|
||||
// Referentiel et Ranking ensuite, seulement pour ces articles.
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const [mensuelMap, commandesMap] = await Promise.all([
|
||||
getMensuelByArticles(articles, dateDebut, dateFin),
|
||||
getCommandesByFournisseur(codeFournisseur),
|
||||
]);
|
||||
console.log(`[getProductRows] mensuel data for ${mensuelMap.size}/${articles.length} articles`);
|
||||
|
||||
// ─── Phase 2B : Referentiel + Ranking uniquement pour articles avec ventes ──
|
||||
// Réduit drastiquement les appels API pour les gros fournisseurs.
|
||||
const articlesWithData = articles.filter(a => mensuelMap.has(a.codein));
|
||||
console.log(`[getProductRows] articles avec ventes: ${articlesWithData.length}/${articles.length}`);
|
||||
const [referentielMap, rankingResult] = await Promise.all([
|
||||
getReferentielByArticles(articlesWithData),
|
||||
getRankingByArticles(articlesWithData, codeFournisseur),
|
||||
// ─── Phase 1 : 6 requêtes SQL en parallèle ────────────────────────────
|
||||
// Remplace des centaines/milliers d'appels HTTP per-article.
|
||||
const [
|
||||
articles,
|
||||
mensuelRows,
|
||||
gammeMap,
|
||||
stockMap,
|
||||
rankingResult,
|
||||
commandesMap,
|
||||
] = await Promise.all([
|
||||
pgGetArticlesByFournisseur(codeFournisseur),
|
||||
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin),
|
||||
pgGetGammesByFournisseur(codeFournisseur),
|
||||
pgGetStockByFournisseur(codeFournisseur),
|
||||
pgGetRankingByFournisseur(codeFournisseur),
|
||||
pgGetCommandesByFournisseur(codeFournisseur),
|
||||
]);
|
||||
|
||||
const { rankings: rankingMap, totalRankedProducts } = rankingResult;
|
||||
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes, ${rankingMap.size} rankings`);
|
||||
|
||||
// ─── Phase 3 : Fenêtre temporelle (12 mois complets) ─────────────────
|
||||
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
|
||||
const now = new Date();
|
||||
const allowedPeriods = new Set<string>();
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
const sortedPeriods = [...allowedPeriods].sort(); // "202503" … "202602"
|
||||
const sortedPeriods = [...allowedPeriods].sort();
|
||||
|
||||
// ─── Phase 4 : Seed productMap depuis articles ────────────────────────
|
||||
// ─── Phase 3 : Seed productMap depuis articles ────────────────────────
|
||||
const productMap = new Map<string, ProductRow>();
|
||||
for (const art of articles) {
|
||||
if (!productMap.has(art.codein)) {
|
||||
productMap.set(art.codein, {
|
||||
codein: art.codein,
|
||||
codeFournisseur: art.codefou ?? codeFournisseur,
|
||||
nomFournisseur: art.nomfou ?? "",
|
||||
libelle1: art.libelle1 ?? "",
|
||||
gtin: art.gtin ?? "",
|
||||
reference: art.reference ?? "",
|
||||
code1: "", libelleNiveau1: "",
|
||||
code2: "", libelleNiveau2: "",
|
||||
code3: "", libelle3: "",
|
||||
codeGamme: null,
|
||||
codeGammeInit: null,
|
||||
codeGammeDraft: null,
|
||||
sales12m: {},
|
||||
stock12m: {},
|
||||
receptions12m: {},
|
||||
totalQuantite: 0,
|
||||
totalCa: 0,
|
||||
totalMarge: 0,
|
||||
tauxMarge: 0,
|
||||
score: 0,
|
||||
workingStores: [],
|
||||
aiRecommendation: null,
|
||||
noid: art.noid ? Number(art.noid) : undefined,
|
||||
pcb: art.pcb ? Number(art.pcb) : undefined,
|
||||
prixVente: art.pv_central ? Number(art.pv_central) : undefined,
|
||||
});
|
||||
}
|
||||
if (!art.codein || productMap.has(art.codein)) continue;
|
||||
productMap.set(art.codein, {
|
||||
codein: art.codein,
|
||||
codeFournisseur: art.codefou ?? codeFournisseur,
|
||||
nomFournisseur: art.nomfou ?? "",
|
||||
libelle1: art.libelle1 ?? "",
|
||||
gtin: art.gtin ?? "",
|
||||
reference: art.reference ?? "",
|
||||
code1: "", libelleNiveau1: "",
|
||||
code2: "", libelleNiveau2: "",
|
||||
code3: "", libelle3: "",
|
||||
codeGamme: null,
|
||||
codeGammeInit: null,
|
||||
codeGammeDraft: null,
|
||||
sales12m: {},
|
||||
stock12m: {},
|
||||
receptions12m: {},
|
||||
totalQuantite: 0,
|
||||
totalCa: 0,
|
||||
totalMarge: 0,
|
||||
tauxMarge: 0,
|
||||
score: 0,
|
||||
workingStores: [],
|
||||
aiRecommendation: null,
|
||||
noid: art.no_id ? Number(art.no_id) : undefined,
|
||||
pcb: art.pcb ? Number(art.pcb) : undefined,
|
||||
prixVente: art.pv_central ? Number(art.pv_central) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Phase 5 : Agréger données mensuelles → sales12m + stock12m ──────
|
||||
// ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ─────────────
|
||||
// SQL a déjà fait l'agrégation par (codein, site, mois).
|
||||
// On somme ici les 2 sites (292 + 579) par période.
|
||||
const filterSite = magasin !== "TOTAL" ? magasin : null;
|
||||
|
||||
// Log de TOUS les sites distincts sur l'ensemble des articles (diagnostic double-comptage)
|
||||
const allSites = new Set<string>();
|
||||
for (const entries of mensuelMap.values()) {
|
||||
for (const e of entries as import("@/lib/api-ff-client").FfMensuelEntry[]) allSites.add(e.site);
|
||||
}
|
||||
console.log(`[getProductRows] Tous les sites distincts dans mensuel: ${JSON.stringify([...allSites])}`);
|
||||
// Compter combien d'entrées par site (pour détecter la centrale)
|
||||
const siteCount: Record<string, number> = {};
|
||||
for (const entries of mensuelMap.values()) {
|
||||
for (const e of entries as import("@/lib/api-ff-client").FfMensuelEntry[]) {
|
||||
siteCount[e.site] = (siteCount[e.site] ?? 0) + 1;
|
||||
type PeriodData = { qty: number; ca: number; marge: number; stock: number; receptions: number };
|
||||
const mensuelByCodein = new Map<string, Map<string, PeriodData>>();
|
||||
const storeMonthsByCodein = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
for (const row of mensuelRows) {
|
||||
if (filterSite && row.site !== filterSite) continue;
|
||||
|
||||
const periode = row.mois.replace("-", ""); // "2026-02" → "202602"
|
||||
if (!allowedPeriods.has(periode)) continue;
|
||||
|
||||
if (!mensuelByCodein.has(row.codein)) mensuelByCodein.set(row.codein, new Map());
|
||||
const byPeriod = mensuelByCodein.get(row.codein)!;
|
||||
|
||||
if (!byPeriod.has(periode)) byPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 });
|
||||
const p = byPeriod.get(periode)!;
|
||||
p.stock += Number(row.stock_fin_mois) || 0;
|
||||
p.qty += Number(row.qte_vendue) || 0;
|
||||
p.ca += Number(row.ca_ht) || 0;
|
||||
p.marge += Number(row.marge) || 0;
|
||||
p.receptions += Number(row.qte_recue) || 0;
|
||||
|
||||
// Suivi magasins actifs (pour workingStores)
|
||||
if (Number(row.qte_vendue) > 0) {
|
||||
if (!storeMonthsByCodein.has(row.codein)) storeMonthsByCodein.set(row.codein, new Map());
|
||||
const storeMonths = storeMonthsByCodein.get(row.codein)!;
|
||||
if (!storeMonths.has(row.site)) storeMonths.set(row.site, new Set());
|
||||
storeMonths.get(row.site)!.add(periode);
|
||||
}
|
||||
}
|
||||
console.log(`[getProductRows] Entrées par site: ${JSON.stringify(siteCount)}`);
|
||||
|
||||
for (const [codein, entries] of mensuelMap.entries()) {
|
||||
// ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ─────────────
|
||||
for (const [codein, byPeriod] of mensuelByCodein.entries()) {
|
||||
const product = productMap.get(codein);
|
||||
if (!product) continue;
|
||||
|
||||
// Agréger par période (toutes les entrées site × mois → 1 entrée par mois)
|
||||
const byPeriod = new Map<string, { qty: number; ca: number; marge: number; stock: number; pa: number; receptions: number }>();
|
||||
const storeMonths = new Map<string, Set<string>>(); // site → Set<YYYYMM>
|
||||
|
||||
for (const entry of entries) {
|
||||
// Garder uniquement les sites physiques (292, 579) — exclure la centrale (agrégat)
|
||||
if (filterSite) {
|
||||
if (entry.site !== filterSite) continue;
|
||||
} else {
|
||||
if (entry.site !== "292" && entry.site !== "579") continue;
|
||||
}
|
||||
|
||||
const periode = entry.mois.replace("-", ""); // "2026-02" → "202602"
|
||||
if (!allowedPeriods.has(periode)) continue;
|
||||
|
||||
if (!byPeriod.has(periode)) {
|
||||
byPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, pa: 0, receptions: 0 });
|
||||
}
|
||||
const p = byPeriod.get(periode)!;
|
||||
|
||||
// Stock fin de mois : somme tous sites (ou site filtré)
|
||||
p.stock += Math.abs(parseFloat(entry.stock_fin_mois ?? "0") || 0);
|
||||
if (!p.pa && entry.prmp_fin_mois) p.pa = parseFloat(entry.prmp_fin_mois) || 0;
|
||||
|
||||
// Réceptions
|
||||
if (entry.receptions) {
|
||||
p.receptions += Math.abs(parseFloat(entry.receptions.qte_recue ?? "0") || 0);
|
||||
}
|
||||
|
||||
// Ventes
|
||||
if (entry.ventes) {
|
||||
p.qty += Math.abs(parseFloat(entry.ventes.qte_vendue ?? "0") || 0);
|
||||
p.ca += Math.abs(parseFloat(entry.ventes.ca_ht ?? "0") || 0);
|
||||
p.marge += parseFloat(entry.ventes.marge ?? "0") || 0;
|
||||
|
||||
// Suivi magasins actifs (pour workingStores)
|
||||
if (entry.site && entry.site !== "TOTAL") {
|
||||
if (!storeMonths.has(entry.site)) storeMonths.set(entry.site, new Set());
|
||||
storeMonths.get(entry.site)!.add(periode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remplir sales12m / stock12m avec carry-forward sur les mois sans données
|
||||
let lastStock = 0;
|
||||
let lastPa = 0;
|
||||
for (const periode of sortedPeriods) {
|
||||
const p = byPeriod.get(periode);
|
||||
if (p) {
|
||||
@@ -170,7 +142,6 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
product.totalCa += p.ca;
|
||||
product.totalMarge += p.marge;
|
||||
lastStock = p.stock;
|
||||
if (!lastPa && p.pa) lastPa = p.pa;
|
||||
} else {
|
||||
product.sales12m[periode] = 0;
|
||||
product.stock12m[periode] = lastStock; // carry-forward
|
||||
@@ -178,72 +149,70 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
}
|
||||
}
|
||||
|
||||
product.tauxMarge = product.totalCa > 0 ? (product.totalMarge / product.totalCa) * 100 : 0;
|
||||
product.pa = lastPa > 0 ? lastPa : undefined;
|
||||
product.prixAchat = lastPa > 0 ? lastPa : undefined;
|
||||
product.stockActuel = product.stock12m[sortedPeriods[sortedPeriods.length - 1]] ?? 0;
|
||||
product.tauxMarge = product.totalCa > 0 ? (product.totalMarge / product.totalCa) * 100 : 0;
|
||||
|
||||
// workingStores : sites avec ventes sur >= 3 mois distincts
|
||||
product.workingStores = [...storeMonths.entries()]
|
||||
.filter(([, periods]) => periods.size >= 3)
|
||||
.map(([site]) => site)
|
||||
.sort();
|
||||
// workingStores : sites avec ventes sur ≥ 3 mois distincts
|
||||
const storeMonths = storeMonthsByCodein.get(codein);
|
||||
if (storeMonths) {
|
||||
product.workingStores = [...storeMonths.entries()]
|
||||
.filter(([, periods]) => periods.size >= 3)
|
||||
.map(([site]) => site)
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Commandes en cours
|
||||
const cmdQty = commandesMap.get(codein);
|
||||
if (cmdQty) product.commandesEnCours = cmdQty;
|
||||
}
|
||||
|
||||
// ─── Phase 6 : Classification depuis referentiel API (nomenclature + gamme) ─
|
||||
let refCount = 0;
|
||||
for (const [codein, ref] of referentielMap.entries()) {
|
||||
// ─── Phase 6 : Gammes (saison active) ────────────────────────────────
|
||||
for (const [codein, gammeCode] of gammeMap.entries()) {
|
||||
const product = productMap.get(codein);
|
||||
if (!product) continue;
|
||||
product.codeGammeInit = gammeCode as GammeCode;
|
||||
product.codeGamme = gammeCode as GammeCode;
|
||||
}
|
||||
|
||||
// ─── Phase 7 : Stock temps réel (cube_stock) ─────────────────────────
|
||||
for (const [codein, stocks] of stockMap.entries()) {
|
||||
const product = productMap.get(codein);
|
||||
if (!product) continue;
|
||||
|
||||
// Nomenclature
|
||||
product.code3 = ref.article.nom_code ?? "";
|
||||
product.libelle3 = ref.article.nom_libelle ?? "";
|
||||
const sitesStock = filterSite
|
||||
? stocks.filter(s => s.site === filterSite)
|
||||
: stocks;
|
||||
|
||||
// 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;
|
||||
product.stockActuel = sitesStock.reduce((s, r) => s + (Number(r.stockdispo) || 0), 0);
|
||||
product.stockTotal = sitesStock.reduce((s, r) => s + (Number(r.qte) || 0), 0);
|
||||
product.stockValeur = sitesStock.reduce((s, r) => s + (Number(r.valstock) || 0), 0);
|
||||
|
||||
// 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; }
|
||||
const withPrmp = sitesStock.find(r => Number(r.prmp) > 0);
|
||||
if (withPrmp) {
|
||||
product.pa = Number(withPrmp.prmp);
|
||||
product.prixAchat = product.pa;
|
||||
}
|
||||
|
||||
// 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;
|
||||
const dernVente = sitesStock.reduce<string>((best, r) =>
|
||||
r.dernierevente && r.dernierevente > best ? r.dernierevente : best, "");
|
||||
if (dernVente) product.derniereVente = dernVente;
|
||||
|
||||
refCount++;
|
||||
const dernLiv = sitesStock.reduce<string>((best, r) =>
|
||||
r.dernierereception && r.dernierereception > best ? r.dernierereception : best, "");
|
||||
if (dernLiv) product.derniereLivraison = dernLiv;
|
||||
}
|
||||
console.log(`[getProductRows] Referentiel: ${refCount}/${articles.length} articles enrichis`);
|
||||
|
||||
// ─── Phase 6b : Ranking réseau + magasin ────────────────────────────
|
||||
// ─── Phase 8 : Ranking réseau ────────────────────────────────────────
|
||||
for (const [codein, ranking] of rankingMap.entries()) {
|
||||
const product = productMap.get(codein);
|
||||
if (!product) continue;
|
||||
product.rankingCa = ranking.ranking_ca;
|
||||
product.rankingQte = ranking.ranking_qte;
|
||||
product.rankingMagCa = ranking.ranking_mag_ca;
|
||||
product.rankingMagQte = ranking.ranking_mag_qte;
|
||||
product.rankingCa = ranking.ranking_ca ? Number(ranking.ranking_ca) : undefined;
|
||||
product.rankingQte = ranking.ranking_qte ? Number(ranking.ranking_qte) : undefined;
|
||||
product.rankingMagCa = ranking.ranking_mag_ca ? Number(ranking.ranking_mag_ca) : undefined;
|
||||
product.rankingMagQte = ranking.ranking_mag_qte ? Number(ranking.ranking_mag_qte) : undefined;
|
||||
product.totalRankedProducts = totalRankedProducts;
|
||||
}
|
||||
|
||||
// ─── Phase 7 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
try {
|
||||
const snaps = await db
|
||||
.select()
|
||||
@@ -257,7 +226,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
for (const [codein, change] of Object.entries(changes)) {
|
||||
const product = productMap.get(codein);
|
||||
if (product && change.after) {
|
||||
// codeGammeInit reste figé (valeur d'origine depuis ventesProduits)
|
||||
// codeGammeInit reste figé — seulement codeGamme est overridé
|
||||
product.codeGamme = change.after as GammeCode;
|
||||
}
|
||||
}
|
||||
@@ -267,15 +236,12 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
console.error("[getProductRows] Snapshot restore error:", snapErr);
|
||||
}
|
||||
|
||||
// ─── Phase 8 : Filtrer les produits Y sans ventes ────────────────────
|
||||
// Les produits en gamme Y sans aucune vente sur 12 mois sont exclus de la grille
|
||||
// pour alléger le chargement. Exception : produits Y avec ventes conservés.
|
||||
// ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ─────────
|
||||
const allRows = Array.from(productMap.values());
|
||||
const rows = allRows.filter(p =>
|
||||
p.codeGamme !== "Y" || p.totalQuantite > 0
|
||||
);
|
||||
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
|
||||
const excludedY = allRows.length - rows.length;
|
||||
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelMap.size} avec données`);
|
||||
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
|
||||
|
||||
return computeProductScores(rows);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* CollectFlow — Client PostgreSQL FF Nancy
|
||||
*
|
||||
* Remplace les appels HTTP per-article vers api.ffnancy.fr par des requêtes
|
||||
* SQL bulk directes sur la base PostgreSQL FF Nancy.
|
||||
*
|
||||
* Chaque fonction fait 1 seule requête filtrée par codefou — O(1) au lieu de O(N).
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types résultats SQL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PgArticle {
|
||||
no_id: number;
|
||||
codein: string;
|
||||
codefou: string;
|
||||
nomfou?: string;
|
||||
libelle1?: string;
|
||||
pcb?: number;
|
||||
reference?: string;
|
||||
ean13?: string;
|
||||
gtin?: string; // art_gtin.gtin (préférentiel)
|
||||
pv_central?: number; // article_infosup.prix_vente_mini
|
||||
pa?: number; // cube_pa.pa
|
||||
}
|
||||
|
||||
export interface PgMensuelRow {
|
||||
codein: string;
|
||||
site: string;
|
||||
mois: string; // "YYYY-MM"
|
||||
qte_vendue: number;
|
||||
ca_ht: number; // mntmvtttc (à vérifier si HT ou TTC)
|
||||
marge: number;
|
||||
stock_fin_mois: number; // MAX(qtestock) sur le mois
|
||||
qte_recue: number;
|
||||
}
|
||||
|
||||
export interface PgStockRow {
|
||||
codein: string;
|
||||
site: string;
|
||||
stockdispo: number;
|
||||
qte: number;
|
||||
valstock: number;
|
||||
prmp: number;
|
||||
dernierevente?: string;
|
||||
dernierereception?: string;
|
||||
}
|
||||
|
||||
export interface PgRankingRow {
|
||||
codein: string;
|
||||
site: string;
|
||||
ranking_ca?: number;
|
||||
ranking_qte?: number;
|
||||
ranking_mag_ca?: number;
|
||||
ranking_mag_qte?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Articles du fournisseur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retourne tous les articles d'un fournisseur avec leurs métadonnées.
|
||||
* 1 seule requête SQL — remplace getArticlesByFournisseur() + per-article fetches.
|
||||
*/
|
||||
export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArticle[]> {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
a.no_id,
|
||||
a.codein,
|
||||
af.code AS codefou,
|
||||
fa.raisonsociale AS nomfou,
|
||||
a.libelle1,
|
||||
af.pcb,
|
||||
af.reference,
|
||||
af.ean13,
|
||||
COALESCE(ag.gtin, af.ean13) AS gtin,
|
||||
ai.prix_vente_mini AS pv_central,
|
||||
pa.pa
|
||||
FROM artfou1 af
|
||||
JOIN articles a
|
||||
ON a.no_id = af.art_no_id
|
||||
LEFT JOIN fouadr1 fa
|
||||
ON fa.code = af.code AND fa.sit_code = '000'
|
||||
LEFT JOIN article_infosup ai
|
||||
ON ai.artnoid = a.no_id
|
||||
LEFT JOIN cube_pa pa
|
||||
ON pa.artnoid = a.no_id
|
||||
LEFT JOIN art_gtin ag
|
||||
ON ag.idarticle = a.no_id AND ag.preferentiel = 1
|
||||
WHERE af.code = ${codefou}
|
||||
AND a.codein IS NOT NULL
|
||||
ORDER BY a.codein
|
||||
`);
|
||||
|
||||
return (result.rows as unknown as PgArticle[]).filter(r => r.codein);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Données mensuelles (ventes + stock + réceptions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retourne les données mensuelles agrégées par (codein, site, mois).
|
||||
* 1 seule requête SQL — remplace getMensuelByArticles() (N appels HTTP).
|
||||
*
|
||||
* genremvt = 3 → ventes clients
|
||||
* genremvt 1/2 → réceptions (à confirmer selon nomenclature FF Nancy)
|
||||
* qtestock → stock après mouvement (MAX du mois ≈ stock fin de mois)
|
||||
*/
|
||||
export async function pgGetMensuelByFournisseur(
|
||||
codefou: string,
|
||||
dateDebut: string,
|
||||
dateFin: string
|
||||
): Promise<PgMensuelRow[]> {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
m.site,
|
||||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||||
SUM(CASE WHEN m.genremvt = 3 THEN ABS(m.qtemvt) ELSE 0 END)::float AS qte_vendue,
|
||||
SUM(CASE WHEN m.genremvt = 3 THEN ABS(m.mntmvtttc) ELSE 0 END)::float AS ca_ht,
|
||||
SUM(CASE WHEN m.genremvt = 3 THEN m.margemvt ELSE 0 END)::float AS marge,
|
||||
MAX(m.qtestock)::float AS stock_fin_mois,
|
||||
SUM(CASE WHEN m.genremvt IN (1, 2) THEN ABS(m.qtemvt) ELSE 0 END)::float AS qte_recue
|
||||
FROM mvtart m
|
||||
JOIN articles a
|
||||
ON a.no_id = m.artnoid
|
||||
JOIN artfou1 af
|
||||
ON af.art_no_id = a.no_id AND af.code = ${codefou}
|
||||
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||||
AND m.site IN ('292', '579')
|
||||
GROUP BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||||
ORDER BY a.codein, m.site, mois
|
||||
`);
|
||||
|
||||
return result.rows as unknown as PgMensuelRow[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Gammes (saison active)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retourne la gamme courante (saison active) pour chaque article du fournisseur.
|
||||
* 1 requête SQL — remplace getReferentielByArticles() pour la partie gammes.
|
||||
*/
|
||||
export async function pgGetGammesByFournisseur(codefou: string): Promise<Map<string, string>> {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
g.code AS gamme_code
|
||||
FROM art_gamme_saison ags
|
||||
JOIN articles a
|
||||
ON a.no_id = ags.artnoid
|
||||
JOIN artfou1 af
|
||||
ON af.art_no_id = a.no_id AND af.code = ${codefou}
|
||||
JOIN gammes g
|
||||
ON g.no_id = ags.idgamme
|
||||
JOIN saisons s
|
||||
ON s.no_id = ags.idsaison
|
||||
WHERE s.actif = true
|
||||
`);
|
||||
|
||||
const map = new Map<string, string>();
|
||||
for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) {
|
||||
if (row.codein && row.gamme_code) {
|
||||
map.set(row.codein, row.gamme_code);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Stock temps réel (cube_stock)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retourne le stock actuel par site pour chaque article du fournisseur.
|
||||
* 1 requête SQL — remplace la partie stock du référentiel.
|
||||
*/
|
||||
export async function pgGetStockByFournisseur(codefou: string): Promise<Map<string, PgStockRow[]>> {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
cs.site,
|
||||
cs.stockdispo::float,
|
||||
cs.qte::float,
|
||||
cs.valstock::float,
|
||||
cs.prmp::float,
|
||||
cs.dernierevente::text,
|
||||
cs.dernierereception::text
|
||||
FROM cube_stock cs
|
||||
JOIN articles a
|
||||
ON a.no_id = cs.artnoid
|
||||
JOIN artfou1 af
|
||||
ON af.art_no_id = a.no_id AND af.code = ${codefou}
|
||||
`);
|
||||
|
||||
const map = new Map<string, PgStockRow[]>();
|
||||
for (const row of result.rows as unknown as PgStockRow[]) {
|
||||
if (!map.has(row.codein)) map.set(row.codein, []);
|
||||
map.get(row.codein)!.push(row);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Ranking réseau
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retourne le classement réseau pour chaque article du fournisseur.
|
||||
* 1 requête SQL — remplace getRankingByArticles() (N appels HTTP per-article,
|
||||
* capés à 500 par l'API).
|
||||
*/
|
||||
export async function pgGetRankingByFournisseur(codefou: string): Promise<{
|
||||
rankings: Map<string, PgRankingRow>;
|
||||
totalRankedProducts: number;
|
||||
}> {
|
||||
const [rankResult, totalResult] = await Promise.all([
|
||||
db.execute(sql`
|
||||
SELECT DISTINCT ON (a.codein)
|
||||
a.codein,
|
||||
r.site,
|
||||
r.ranking_ca,
|
||||
r.ranking_qte,
|
||||
r.ranking_mag_ca,
|
||||
r.ranking_mag_qte
|
||||
FROM ranking r
|
||||
JOIN art_gtin ag
|
||||
ON ag.gtin = r.gencod
|
||||
JOIN articles a
|
||||
ON a.no_id = ag.idarticle
|
||||
JOIN artfou1 af
|
||||
ON af.art_no_id = a.no_id AND af.code = ${codefou}
|
||||
ORDER BY a.codein, r.site
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(DISTINCT gencod)::int AS total FROM ranking
|
||||
`),
|
||||
]);
|
||||
|
||||
const rankings = new Map<string, PgRankingRow>();
|
||||
for (const row of rankResult.rows as unknown as PgRankingRow[]) {
|
||||
if (row.codein) rankings.set(row.codein, row);
|
||||
}
|
||||
|
||||
const totalRankedProducts = Number((totalResult.rows[0] as unknown as { total: number })?.total ?? 0);
|
||||
console.log(`[pg-ff] Ranking: ${rankings.size}/${codefou} articles classés (réseau: ${totalRankedProducts})`);
|
||||
|
||||
return { rankings, totalRankedProducts };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Commandes en cours
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retourne les quantités commandées en cours pour chaque article du fournisseur.
|
||||
* 1 requête SQL — remplace getCommandesByFournisseur().
|
||||
*/
|
||||
export async function pgGetCommandesByFournisseur(codefou: string): Promise<Map<string, number>> {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
a.codein,
|
||||
SUM(cv.qtecde)::float AS qtecde
|
||||
FROM cdefou_vivant cv
|
||||
JOIN artfou1 af
|
||||
ON af.no_id = cv.artfou1_no_id AND af.code = ${codefou}
|
||||
JOIN articles a
|
||||
ON a.no_id = af.art_no_id
|
||||
GROUP BY a.codein
|
||||
`);
|
||||
|
||||
const map = new Map<string, number>();
|
||||
for (const row of result.rows as unknown as { codein: string; qtecde: number }[]) {
|
||||
if (row.codein) map.set(row.codein, Number(row.qtecde) || 0);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
Reference in new issue
Block a user