mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
- API /api/v1 : PUT /products/:codein/gamme et POST /gammes déposent une proposition (table gammes_a_valider) au lieu d'enregistrer. La Grille la charge comme modification non enregistrée (badge « API ») : Enregistrer la valide, Annuler la rejette. Lecture : champ gammeAValider, statut a_valider dans la réponse ; OpenAPI mise à jour. - Meilleures ventes, Ventes par mois, Publicités : plus de largeur max centrée, les pages occupent toute la largeur. - Meilleures ventes : colonne Référence (référence fournisseur), recherche et export Excel compris. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FaniLFvegAZNXFrFFSStqa
2160 lines
90 KiB
TypeScript
2160 lines
90 KiB
TypeScript
/**
|
||
* 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, type SQL } from "drizzle-orm";
|
||
import { ffFetchInit } from "@/lib/api-ff-client";
|
||
|
||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||
|
||
|
||
// Cache module-level pour éviter les requêtes information_schema répétées
|
||
let _nomenclatureParentCol: string | null | undefined = undefined; // undefined = pas encore chargé
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Utilitaire : exécuter une requête sans workers parallèles PostgreSQL.
|
||
// Les workers parallèles consomment /dev/shm (mémoire partagée Docker) et font
|
||
// échouer les requêtes si le conteneur a un shm_size insuffisant.
|
||
// Le réglage est désormais posé une fois par connexion du pool (src/db/index.ts) :
|
||
// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait
|
||
// trois allers-retours de plus à chacune.
|
||
// ---------------------------------------------------------------------------
|
||
/**
|
||
* `fn` sur chaque élément, au plus `simultanes` à la fois, résultats dans l'ordre
|
||
* (même forme que Promise.allSettled). Évite de lancer des centaines d'appels à
|
||
* l'API FF d'un coup, qui la saturent et finissent en délais dépassés.
|
||
*/
|
||
async function parLots<T, R>(items: T[], simultanes: number, fn: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
|
||
const resultats: PromiseSettledResult<R>[] = new Array(items.length);
|
||
let suivant = 0;
|
||
const ouvrier = async () => {
|
||
while (suivant < items.length) {
|
||
const i = suivant++;
|
||
try {
|
||
resultats[i] = { status: "fulfilled", value: await fn(items[i]) };
|
||
} catch (reason) {
|
||
resultats[i] = { status: "rejected", reason };
|
||
}
|
||
}
|
||
};
|
||
await Promise.all(Array.from({ length: Math.min(simultanes, items.length) }, ouvrier));
|
||
return resultats;
|
||
}
|
||
|
||
async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> {
|
||
const r = await db.execute(query);
|
||
return { rows: r.rows };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types résultats SQL
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface PgArticle {
|
||
no_id: number;
|
||
codein: string;
|
||
/** Code centrale = articles.artcentrale (format 10000XXXXXX) — clé jointure Qlik "Article Code". Vide pour les articles non référencés centralement. */
|
||
codeCentrale?: 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
|
||
codefou_principal?: string; // artfou1.code où preference=1
|
||
nomfou_principal?: string; // nom du fournisseur principal
|
||
}
|
||
|
||
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;
|
||
/** Prix de vente du site — cube_stock.pv, filet de secours si cube_pv est vide. */
|
||
pv?: number;
|
||
dernierevente?: string;
|
||
dernierereception?: string;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 0. Liste des fournisseurs
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Retourne la liste des fournisseurs depuis fouident (vrais noms) + artfou1.
|
||
*/
|
||
export async function pgGetFournisseurs(search?: string): Promise<{ code: string; nom: string }[]> {
|
||
try {
|
||
const result = await db.execute(sql`
|
||
SELECT DISTINCT fi.code, fi.nom
|
||
FROM fouident fi
|
||
INNER JOIN artfou1 af ON af.code = fi.code
|
||
WHERE fi.nom IS NOT NULL
|
||
AND fi.nom != ''
|
||
${search ? sql`AND (fi.nom ILIKE ${'%' + search + '%'} OR fi.code ILIKE ${'%' + search + '%'})` : sql``}
|
||
ORDER BY fi.nom
|
||
`);
|
||
return (result.rows as unknown as { code: string; nom: string }[]).filter(r => r.code && r.nom);
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetFournisseurs error:", (e as Error).message?.slice(0, 200));
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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[]> {
|
||
// DISTINCT ON (a.no_id) car artfou1 peut avoir plusieurs lignes par article/fournisseur.
|
||
// Pas de filtre "actif" : on retourne tous les articles pour ne pas exclure les articles
|
||
// d'une session précédente (snapshot) qui n'ont pas de gamme DB ni de ventes récentes.
|
||
// Le filtrage gamme-Y sans ventes est fait en aval dans getProductRows (Phase 10).
|
||
// afpref : fournisseur préférentiel (preference=1) de l'article — peut différer du fournisseur consulté.
|
||
const result = await pgNoParallel(sql`
|
||
SELECT DISTINCT ON (a.no_id)
|
||
a.no_id,
|
||
a.codein,
|
||
a.artcentrale AS "codeCentrale",
|
||
af.code AS codefou,
|
||
fi.nom 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,
|
||
last_fournisseur.codefou_dernier AS codefou_principal,
|
||
last_fournisseur.nomfou_dernier AS nomfou_principal
|
||
FROM artfou1 af
|
||
JOIN articles a ON a.no_id = af.art_no_id
|
||
LEFT JOIN fouident fi ON fi.code = af.code
|
||
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
|
||
LEFT JOIN LATERAL (
|
||
SELECT af2.code AS codefou_dernier, fi2.nom AS nomfou_dernier
|
||
FROM artfou1 af2
|
||
LEFT JOIN fouident fi2 ON fi2.code = af2.code
|
||
WHERE af2.art_no_id = a.no_id
|
||
ORDER BY af2.no_id DESC
|
||
LIMIT 1
|
||
) last_fournisseur ON true
|
||
WHERE af.code = ${codefou}
|
||
AND a.codein IS NOT NULL
|
||
AND (af.suspendu IS NULL OR af.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on'))
|
||
AND (a.suspendu IS NULL OR a.suspendu::text NOT IN ('1', 'true', 't', 'yes', 'y', 'on'))
|
||
ORDER BY a.no_id, af.no_id
|
||
`);
|
||
|
||
console.log(`[pg-ff] Articles: ${result.rows.length} pour ${codefou}`);
|
||
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[]> {
|
||
// IMPORTANT : utiliser DISTINCT sur artfou1 pour éviter les doublons de mouvements.
|
||
// Un article peut avoir plusieurs lignes artfou1 pour le même fournisseur (pcb/ref différents),
|
||
// ce qui multiplierait chaque mouvement et gonflerait les totaux.
|
||
//
|
||
// stock_fin_mois = qtestock du DERNIER mouvement du mois (tri par datmvt DESC).
|
||
// MAX(qtestock) donnait le stock de début de mois (avant les premières ventes), ce qui était faux.
|
||
const result = await pgNoParallel(sql`
|
||
WITH base AS (
|
||
SELECT
|
||
a.codein,
|
||
m.site,
|
||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||
m.qtemvt,
|
||
m.mntmvtttc,
|
||
m.margemvt,
|
||
m.genremvt,
|
||
m.qtestock,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY a.codein, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||
ORDER BY m.datmvt DESC
|
||
) AS rn_last
|
||
FROM mvtart m
|
||
JOIN articles a ON a.no_id = m.artnoid
|
||
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af
|
||
ON af.art_no_id = a.no_id
|
||
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||
AND m.site IN ('292', '579')
|
||
)
|
||
SELECT
|
||
codein,
|
||
site,
|
||
mois,
|
||
SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue,
|
||
SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht,
|
||
SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge,
|
||
MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois,
|
||
SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue
|
||
FROM base
|
||
GROUP BY codein, site, mois
|
||
ORDER BY codein, 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>> {
|
||
// DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier
|
||
// saisons n'a PAS de colonne "actif" → pas de filtre actif
|
||
const result = await pgNoParallel(sql`
|
||
SELECT DISTINCT ON (a.codein)
|
||
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
|
||
ORDER BY a.codein, s.no_id DESC
|
||
`);
|
||
|
||
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, String(row.gamme_code).trim());
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* Gammes **serveur** pour une liste de codein, tous fournisseurs confondus.
|
||
*
|
||
* Variante de `pgGetGammesByFournisseur` sans la jointure `artfou1` : la recherche
|
||
* de l'API `/api/v1` est transversale, on ne connaît donc pas le fournisseur.
|
||
* C'est la gamme telle qu'elle existe en base — non modifiée, indépendante des
|
||
* snapshots de session qui peuvent surcharger `codeGamme` dans la Grille.
|
||
*/
|
||
export async function pgGetGammesByCodeins(codeins: string[]): Promise<Map<string, string>> {
|
||
const unique = [...new Set(codeins.map(c => String(c ?? "").trim()).filter(Boolean))];
|
||
if (unique.length === 0) return new Map();
|
||
|
||
// Découpage obligatoire : chaque codein devient un paramètre lié, or PostgreSQL
|
||
// en accepte 65535 au maximum. Un gros fournisseur dépasse 130 000 articles —
|
||
// la requête échouait alors purement et simplement.
|
||
const CHUNK = 2000;
|
||
const map = new Map<string, string>();
|
||
|
||
for (let i = 0; i < unique.length; i += CHUNK) {
|
||
const batch = unique.slice(i, i + CHUNK);
|
||
// DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier.
|
||
const result = await pgNoParallel(sql`
|
||
SELECT DISTINCT ON (a.codein)
|
||
TRIM(a.codein::text) AS codein,
|
||
g.code AS gamme_code
|
||
FROM art_gamme_saison ags
|
||
JOIN articles a ON a.no_id = ags.artnoid
|
||
JOIN gammes g ON g.no_id = ags.idgamme
|
||
JOIN saisons s ON s.no_id = ags.idsaison
|
||
WHERE TRIM(a.codein::text) IN (${sql.join(batch.map(c => sql`${c}`), sql`, `)})
|
||
ORDER BY a.codein, s.no_id DESC
|
||
`);
|
||
for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) {
|
||
if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim());
|
||
}
|
||
}
|
||
console.log(`[pg-ff] pgGetGammesByCodeins: ${map.size}/${unique.length} articles avec gamme`);
|
||
return map;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. Nomenclature (famille / sous-famille)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface PgNomRow {
|
||
codein: string;
|
||
code3: string; // code feuille (ex: "360504")
|
||
libelle3: string; // libellé feuille
|
||
code2?: string; // famille (4 chiffres)
|
||
libelle2?: string;
|
||
code1?: string; // secteur (2 chiffres)
|
||
libelle1nom?: string;
|
||
}
|
||
|
||
/**
|
||
* Retourne la nomenclature (famille/sous-famille) pour chaque article du fournisseur.
|
||
* FK confirmée : articles.nom_no_id → nomenclature.no_id
|
||
* Colonnes confirmées : nomenclature.code, nomenclature.libelle
|
||
* Colonne parent : découverte dynamiquement via information_schema
|
||
*/
|
||
/**
|
||
* Découvre (une seule fois par process) le nom de la colonne "parent" de la
|
||
* table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur.
|
||
*
|
||
* On ne peut pas la coder en dur : le nom varie selon les installations
|
||
* ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK),
|
||
* d'où le filtre explicite sur `data_type` entier.
|
||
*/
|
||
async function getNomenclatureParentCol(): Promise<string | null> {
|
||
if (_nomenclatureParentCol !== undefined) return _nomenclatureParentCol;
|
||
|
||
const metaResult = await db.execute(sql`
|
||
SELECT column_name, data_type FROM information_schema.columns
|
||
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
|
||
`);
|
||
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
|
||
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
|
||
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
|
||
// "chemin_pere" = chemin vers le père en français
|
||
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
|
||
const parentRow = nomCols.find(r =>
|
||
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
|
||
&& /int|serial/i.test(r.data_type)
|
||
);
|
||
_nomenclatureParentCol = parentRow?.column_name ?? null;
|
||
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
|
||
return _nomenclatureParentCol;
|
||
}
|
||
|
||
export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<Map<string, PgNomRow>> {
|
||
const parentCol = await getNomenclatureParentCol();
|
||
|
||
let result;
|
||
if (parentCol) {
|
||
result = await pgNoParallel(sql`
|
||
SELECT
|
||
a.codein,
|
||
n3.code AS code3,
|
||
n3.libelle AS libelle3,
|
||
n2.code AS code2,
|
||
n2.libelle AS libelle2,
|
||
n1.code AS code1,
|
||
n1.libelle AS libelle1nom
|
||
FROM artfou1 af
|
||
JOIN articles a ON a.no_id = af.art_no_id AND af.code = ${codefou}
|
||
JOIN nomenclature n3 ON n3.no_id = a.nom_no_id
|
||
LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)}
|
||
LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)}
|
||
WHERE a.codein IS NOT NULL
|
||
`);
|
||
} else {
|
||
// Sans hiérarchie : juste le niveau feuille
|
||
result = await pgNoParallel(sql`
|
||
SELECT
|
||
a.codein,
|
||
n3.code AS code3,
|
||
n3.libelle AS libelle3
|
||
FROM artfou1 af
|
||
JOIN articles a ON a.no_id = af.art_no_id AND af.code = ${codefou}
|
||
JOIN nomenclature n3 ON n3.no_id = a.nom_no_id
|
||
WHERE a.codein IS NOT NULL
|
||
`);
|
||
}
|
||
|
||
console.log(`[pg-ff] Nomenclature: ${result.rows.length} articles, parentCol="${parentCol ?? "none"}"`);
|
||
return buildNomMap(result.rows as unknown as PgNomRow[]);
|
||
}
|
||
|
||
function buildNomMap(rows: PgNomRow[]): Map<string, PgNomRow> {
|
||
const map = new Map<string, PgNomRow>();
|
||
for (const r of rows) {
|
||
if (r.codein) map.set(r.codein, r);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 5. 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[]>> {
|
||
// `site` est élagué pour la même raison que dans pgGetPrixVenteByFournisseur :
|
||
// il sert de clé de recherche (derniereLivraisonByStore, prix de repli).
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
a.codein,
|
||
TRIM(cs.site::text) AS site,
|
||
cs.stockdispo::float,
|
||
cs.qte::float,
|
||
cs.valstock::float,
|
||
cs.prmp::float,
|
||
cs.pv::float,
|
||
cs.dernierevente::text,
|
||
cs.dernierereception::text
|
||
FROM cube_stock cs
|
||
JOIN articles a ON a.no_id = cs.artnoid
|
||
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af
|
||
ON af.art_no_id = a.no_id
|
||
`);
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Prix de vente par article et par site (`cube_pv`).
|
||
*
|
||
* Pendant exact de `cube_pa` pour l'achat, rafraîchi chaque nuit depuis le cube
|
||
* MSSQL `Cube_PV`. C'est LA source du prix de vente : `article_infosup`, seule
|
||
* table lue jusqu'ici pour cette information, ne porte qu'un prix plancher de
|
||
* paramétrage (`prix_vente_mini`) qui reste vide en pratique.
|
||
*
|
||
* Retour : codein → { site → PV }. Les prix nuls sont écartés — un article non
|
||
* tarifé n'est pas un article à 0 €.
|
||
*/
|
||
export async function pgGetPrixVenteByFournisseur(codefou: string): Promise<Map<string, Record<string, number>>> {
|
||
// Le site sert de CLÉ de recherche côté Grille (prixVenteByStore["292"]), d'où
|
||
// l'élagage : la synchronisation Apiflow écrit `cube_pv.site` brut, sans passer
|
||
// par le `safeStr` qu'elle applique à `cube_stock`. Une colonne MSSQL de largeur
|
||
// fixe remonterait complétée d'espaces, et la recherche échouerait en silence —
|
||
// le prix resterait visible en « tous magasins » et vide magasin par magasin.
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
a.codein,
|
||
TRIM(cpv.site::text) AS site,
|
||
cpv.pv::float AS pv
|
||
FROM cube_pv cpv
|
||
JOIN articles a ON a.no_id = cpv.artnoid
|
||
JOIN (SELECT DISTINCT art_no_id FROM artfou1 WHERE code = ${codefou}) af
|
||
ON af.art_no_id = a.no_id
|
||
WHERE cpv.pv IS NOT NULL AND cpv.pv > 0
|
||
`);
|
||
|
||
const map = new Map<string, Record<string, number>>();
|
||
for (const row of result.rows as { codein: string; site: string; pv: number }[]) {
|
||
if (!row.codein || !row.site) continue;
|
||
const parSite = map.get(row.codein) ?? {};
|
||
parSite[String(row.site)] = Number(row.pv);
|
||
map.set(row.codein, parSite);
|
||
}
|
||
console.log(`[pg-ff] Prix de vente : ${map.size} articles tarifés pour ${codefou}`);
|
||
return map;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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>> {
|
||
// cdefou_vivant est une vue dénormalisée : articles_codein et cdefou_ligne_qtecde disponibles directement
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
cv.articles_codein AS codein,
|
||
SUM(cv.cdefou_ligne_qtecde)::float AS qtecde
|
||
FROM cdefou_vivant cv
|
||
JOIN artfou1 af ON af.no_id = cv.artfou1_no_id AND af.code = ${codefou}
|
||
WHERE cv.articles_codein IS NOT NULL
|
||
GROUP BY cv.articles_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;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 7. Dashboard quotidien (hit parade + évolution N/N-1)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface DashboardSiteStats {
|
||
site: string;
|
||
ca_hier: number;
|
||
ca_n1: number;
|
||
tickets_hier: number;
|
||
tickets_n1: number;
|
||
qte_hier: number;
|
||
marge_hier: number;
|
||
lignes_hier: number;
|
||
}
|
||
|
||
export interface DashboardTopItem {
|
||
codein: string;
|
||
libelle1: string;
|
||
ca: number;
|
||
qte: number;
|
||
marge: number;
|
||
stock: number;
|
||
fournisseur: string;
|
||
}
|
||
|
||
export interface DashboardSiteTop10 {
|
||
site: string;
|
||
ca: DashboardTopItem[];
|
||
qte: DashboardTopItem[];
|
||
marge: DashboardTopItem[];
|
||
}
|
||
|
||
export interface DashboardData {
|
||
dateHier: string;
|
||
dateN1: string;
|
||
sites: DashboardSiteStats[];
|
||
top10BySite: DashboardSiteTop10[];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types internes API performance
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface ApiPerfSite {
|
||
site: string;
|
||
ca_ttc: number;
|
||
ca_ttc_n1: number;
|
||
trafic: number;
|
||
trafic_n1: number;
|
||
}
|
||
|
||
interface ApiPerfTopItem {
|
||
codein: string;
|
||
libelle1: string;
|
||
site: string;
|
||
codefou_reel?: string;
|
||
nom_fournisseur_reel?: string;
|
||
ca_ttc: number;
|
||
qte_vendue: number;
|
||
marge: number;
|
||
}
|
||
|
||
interface ApiPerfDashboard {
|
||
date: string;
|
||
date_n1: string;
|
||
sites: ApiPerfSite[];
|
||
top10_ca: ApiPerfTopItem[];
|
||
top10_qte: ApiPerfTopItem[];
|
||
top10_marge: ApiPerfTopItem[];
|
||
}
|
||
|
||
/**
|
||
* Retourne toutes les données nécessaires pour le dashboard quotidien.
|
||
* 100% via l'API REST api.ffnancy.fr — aucune requête PostgreSQL directe.
|
||
* N-1 : même jour de semaine (52 semaines = 364 jours avant hier).
|
||
*/
|
||
export async function pgGetDashboardData(): Promise<DashboardData> {
|
||
const yesterday = new Date();
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
const dateHier = yesterday.toISOString().split("T")[0];
|
||
|
||
// N-1 : même jour de semaine, même semaine N-1 → 364 jours = 52 semaines exactes
|
||
const n1Date = new Date(yesterday);
|
||
n1Date.setDate(n1Date.getDate() - 364);
|
||
const dateN1Param = n1Date.toISOString().split("T")[0];
|
||
|
||
const apiBase = `${FF_API_BASE}/api/performance`;
|
||
|
||
|
||
// Deux appels parallèles : données hier + données même jour N-1
|
||
const [apiResult, apiN1Result] = await Promise.all([
|
||
fetch(`${apiBase}/dashboard?date=${dateHier}`, ffFetchInit())
|
||
.then(r => { if (!r.ok) throw new Error(`API dashboard ${r.status}: ${r.statusText}`); return r.json() as Promise<ApiPerfDashboard>; }),
|
||
fetch(`${apiBase}/dashboard?date=${dateN1Param}`, ffFetchInit())
|
||
.then(r => r.ok ? r.json() as Promise<ApiPerfDashboard> : null)
|
||
.catch(() => null),
|
||
]);
|
||
|
||
// Construire map N-1 par site depuis l'appel séparé
|
||
const n1BySite = new Map<string, { ca: number; trafic: number }>();
|
||
for (const s of apiN1Result?.sites ?? []) {
|
||
n1BySite.set(s.site, { ca: Number(s.ca_ttc) || 0, trafic: Number(s.trafic) || 0 });
|
||
}
|
||
|
||
const sites: DashboardSiteStats[] = (apiResult.sites ?? []).map(s => {
|
||
const n1 = n1BySite.get(s.site);
|
||
// Priorité : même jour de semaine N-1 (si > 0), sinon fallback sur n1 calendaire de l'API
|
||
const caN1 = (n1?.ca && n1.ca > 0) ? n1.ca : (Number(s.ca_ttc_n1) || 0);
|
||
const trafficN1 = (n1?.trafic && n1.trafic > 0) ? n1.trafic : (Number(s.trafic_n1) || 0);
|
||
return {
|
||
site: s.site,
|
||
ca_hier: Number(s.ca_ttc) || 0,
|
||
ca_n1: caN1,
|
||
tickets_hier: Number(s.trafic) || 0,
|
||
tickets_n1: trafficN1,
|
||
qte_hier: 0,
|
||
marge_hier: 0,
|
||
lignes_hier: 0,
|
||
};
|
||
});
|
||
|
||
// Top 10 par magasin : grouper les items par site, trier, garder top 10
|
||
const allItems = [
|
||
...(apiResult.top10_ca ?? []),
|
||
...(apiResult.top10_qte ?? []),
|
||
...(apiResult.top10_marge ?? []),
|
||
];
|
||
const allCodeins = [...new Set(allItems.map(i => i.codein).filter(Boolean))];
|
||
|
||
// Fournisseur réel : un seul appel sur les mouvements du jour (codefou_reel / nom_fournisseur_reel).
|
||
// Enrichissement : fournisseur réel via /dernier-fournisseur (primaire) + /referentiel (stock + fallback fournisseur).
|
||
// Les deux appels sont faits en parallèle par article.
|
||
const fouMap = new Map<string, string>(); // codein → nom_fournisseur résolu
|
||
const stockMap = new Map<string, { stock292: number; stock579: number; stockTotal: number }>();
|
||
|
||
|
||
if (allCodeins.length > 0) {
|
||
try {
|
||
// Vagues de 12 articles au lieu de 5 : chaque article coûte deux allers-
|
||
// retours successifs, et la page d'accueil attendait une douzaine de vagues.
|
||
const VAGUE = 12;
|
||
for (let i = 0; i < allCodeins.length; i += VAGUE) {
|
||
const batch = allCodeins.slice(i, i + VAGUE);
|
||
await Promise.all(batch.map(async (codein) => {
|
||
try {
|
||
const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`, ffFetchInit());
|
||
if (!artRes.ok) return;
|
||
const artData = await artRes.json();
|
||
if (!artData.articles || artData.articles.length === 0) return;
|
||
|
||
const noId = artData.articles[0].no_id;
|
||
if (!noId) return;
|
||
|
||
const [refRes, dernierRes] = await Promise.all([
|
||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`, ffFetchInit()),
|
||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`, ffFetchInit()),
|
||
]);
|
||
|
||
// Stock (toujours depuis referentiel)
|
||
let refFournisseurFallback = "";
|
||
if (refRes.ok) {
|
||
const refData = await refRes.json();
|
||
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;
|
||
}
|
||
}
|
||
stockMap.set(codein, { stock292: s292, stock579: s579, stockTotal: sTot });
|
||
|
||
// Fallback fournisseur (pas d'entrée en stock) :
|
||
// - 1 seul fournisseur → on l'utilise directement
|
||
// - plusieurs → celui avec preference = 1
|
||
if (Array.isArray(refData.fournisseurs) && refData.fournisseurs.length > 0) {
|
||
type RefFou = { codefou: string; nom_fou: string | null; preference: number | null };
|
||
const fous = refData.fournisseurs as RefFou[];
|
||
const pick = fous.length === 1
|
||
? fous[0]
|
||
: (fous.find(f => f.preference === 1) ?? fous[0]);
|
||
refFournisseurFallback = pick.nom_fou?.trim() || pick.codefou?.trim() || "";
|
||
}
|
||
}
|
||
|
||
// Fournisseur réel : /dernier-fournisseur (dernière entrée en stock par site)
|
||
// Fallback : premier non-dépôt du référentiel
|
||
if (dernierRes.ok) {
|
||
const dernierData = await dernierRes.json();
|
||
const entries: Array<{ site: string; nom_fournisseur: string }> =
|
||
dernierData.fournisseurs_par_site ?? [];
|
||
// Prendre le fournisseur du premier site disponible
|
||
const first = entries.find(e => e.nom_fournisseur);
|
||
if (first) {
|
||
fouMap.set(codein, String(first.nom_fournisseur).trim());
|
||
} else if (refFournisseurFallback) {
|
||
fouMap.set(codein, refFournisseurFallback);
|
||
}
|
||
} else if (refFournisseurFallback) {
|
||
fouMap.set(codein, refFournisseurFallback);
|
||
}
|
||
} catch (err) {
|
||
console.error(`[Dashboard enrichment] Error for codein ${codein}:`, err);
|
||
}
|
||
}));
|
||
}
|
||
} catch (enrichErr: unknown) {
|
||
console.warn("[Dashboard] Enrichment unavailable:", enrichErr);
|
||
}
|
||
}
|
||
|
||
const siteSet = [...new Set(allItems.map(i => i.site).filter(Boolean))].sort();
|
||
|
||
const buildTop10 = (items: ApiPerfTopItem[], sortKey: "ca" | "qte" | "marge"): DashboardTopItem[] =>
|
||
items
|
||
.map(i => {
|
||
const stockEntry = stockMap.get(i.codein);
|
||
const stock = i.site === "292" ? (stockEntry?.stock292 ?? 0)
|
||
: i.site === "579" ? (stockEntry?.stock579 ?? 0)
|
||
: (stockEntry?.stockTotal ?? 0);
|
||
const fournisseur = fouMap.get(i.codein) ?? "—";
|
||
return {
|
||
codein: i.codein,
|
||
libelle1: i.libelle1,
|
||
ca: Number(i.ca_ttc) || 0,
|
||
qte: Number(i.qte_vendue) || 0,
|
||
marge: Number(i.marge) || 0,
|
||
stock,
|
||
fournisseur,
|
||
};
|
||
})
|
||
.sort((a, b) => b[sortKey] - a[sortKey])
|
||
.slice(0, 10);
|
||
|
||
const top10BySite: DashboardSiteTop10[] = siteSet.map(site => {
|
||
const byS = (arr: ApiPerfTopItem[]) => arr.filter(i => i.site === site);
|
||
return {
|
||
site,
|
||
ca: buildTop10(byS(apiResult.top10_ca ?? []), "ca"),
|
||
qte: buildTop10(byS(apiResult.top10_qte ?? []), "qte"),
|
||
marge: buildTop10(byS(apiResult.top10_marge ?? []), "marge"),
|
||
};
|
||
});
|
||
|
||
console.log(`[pg-ff] Dashboard: hier=${dateHier} n1=${dateN1Param} sites=${sites.length} n1_found=${n1BySite.size}`);
|
||
|
||
return {
|
||
dateHier: apiResult.date ?? dateHier,
|
||
dateN1: apiN1Result?.date ?? dateN1Param,
|
||
sites,
|
||
top10BySite,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Analytics — CA TTC par fournisseur et nomenclature
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface CaByFournisseurRow {
|
||
code: string;
|
||
nom: string;
|
||
site: string;
|
||
mois: string;
|
||
ca_ttc: number;
|
||
}
|
||
|
||
export interface CaByNomenclatureRow {
|
||
code: string;
|
||
libelle: string;
|
||
site: string;
|
||
mois: string;
|
||
ca_ttc: number;
|
||
}
|
||
|
||
/**
|
||
* Retourne le CA TTC par fournisseur et site pour deux mois donnés (mois et mois N-1).
|
||
* Attribution via artfou1.preference = true (fournisseur principal de l'article).
|
||
* CA NET : SUM(-mntmvtttc) — les retours clients sont déduits (même convention
|
||
* que pgGetMensuelByFournisseur / pgGetHitParade). Fournisseur préféré résolu
|
||
* via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par
|
||
* article, aucune duplication possible des mouvements dans la somme.
|
||
*/
|
||
/**
|
||
* Bornes d'un mois « YYYY-MM » : [premier jour, premier jour du mois suivant[.
|
||
*
|
||
* Filtrer `datmvt` sur ces bornes plutôt que sur `TO_CHAR(datmvt, 'YYYY-MM')`
|
||
* donne exactement les mêmes lignes, mais sans formater en texte la date de
|
||
* chaque mouvement de l'historique (et reste utilisable par un index sur datmvt).
|
||
*/
|
||
function bornesMois(mois: string): { debut: string; fin: string } {
|
||
const [annee, m] = mois.split("-").map(Number);
|
||
const suivant = m === 12 ? `${annee + 1}-01` : `${annee}-${String(m + 1).padStart(2, "0")}`;
|
||
return { debut: `${mois}-01`, fin: `${suivant}-01` };
|
||
}
|
||
|
||
/** Mouvements du mois OU du mois N-1 (cf. `bornesMois`). */
|
||
function filtreDeuxMois(mois: string, moisN1: string): SQL {
|
||
const a = bornesMois(mois);
|
||
const b = bornesMois(moisN1);
|
||
return sql`((m.datmvt >= ${a.debut}::date AND m.datmvt < ${a.fin}::date)
|
||
OR (m.datmvt >= ${b.debut}::date AND m.datmvt < ${b.fin}::date))`;
|
||
}
|
||
|
||
export async function pgGetCaByFournisseur(
|
||
mois: string,
|
||
moisN1: string
|
||
): Promise<CaByFournisseurRow[]> {
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
COALESCE(af.code, 'SANS_FOURNISSEUR')::text AS code,
|
||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS nom,
|
||
m.site,
|
||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||
SUM(-m.mntmvtttc)::float AS ca_ttc
|
||
FROM mvtart m
|
||
JOIN articles a ON a.no_id = m.artnoid
|
||
LEFT JOIN (
|
||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||
FROM artfou1
|
||
WHERE preference = 1
|
||
ORDER BY art_no_id, code
|
||
) af ON af.art_no_id = a.no_id
|
||
LEFT JOIN fouident fi ON fi.code = af.code
|
||
WHERE ${filtreDeuxMois(mois, moisN1)}
|
||
AND m.site IN ('292', '579')
|
||
AND m.genremvt = 3
|
||
GROUP BY af.code, fi.nom, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||
ORDER BY af.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||
`);
|
||
|
||
console.log(`[pg-ff] CaByFournisseur: ${result.rows.length} lignes pour ${mois}/${moisN1}`);
|
||
return result.rows as unknown as CaByFournisseurRow[];
|
||
}
|
||
|
||
/**
|
||
* Retourne le CA TTC par nomenclature et site pour deux mois donnés.
|
||
* CA NET : SUM(-mntmvtttc) — retours clients déduits (convention commune).
|
||
*/
|
||
export async function pgGetCaByNomenclature(
|
||
mois: string,
|
||
moisN1: string
|
||
): Promise<CaByNomenclatureRow[]> {
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
COALESCE(n.code, 'SANS_NOM')::text AS code,
|
||
COALESCE(n.libelle, 'Sans nomenclature')::text AS libelle,
|
||
m.site,
|
||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||
SUM(-m.mntmvtttc)::float AS ca_ttc
|
||
FROM mvtart m
|
||
JOIN articles a ON a.no_id = m.artnoid
|
||
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
|
||
WHERE ${filtreDeuxMois(mois, moisN1)}
|
||
AND m.site IN ('292', '579')
|
||
AND m.genremvt = 3
|
||
GROUP BY n.no_id, n.code, n.libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||
ORDER BY n.code, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||
`);
|
||
|
||
console.log(`[pg-ff] CaByNomenclature: ${result.rows.length} lignes pour ${mois}/${moisN1}`);
|
||
return result.rows as unknown as CaByNomenclatureRow[];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Hit Parade — produits les plus vendus sur un mois donné
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface HitParadeRow {
|
||
codein: string;
|
||
libelle: string;
|
||
/** Référence fournisseur de l'article (artfou1.reference du fournisseur principal). */
|
||
reference: string;
|
||
fournisseur: string;
|
||
nomenclature_code: string;
|
||
nomenclature: string;
|
||
site: string;
|
||
qte_vendue: number;
|
||
ca_ttc: number;
|
||
marge: number;
|
||
stock292: number;
|
||
stock579: number;
|
||
stockTotal: number;
|
||
}
|
||
|
||
/**
|
||
* Retourne les ventes produit sur une période, avec fournisseur, qté, CA TTC, marge et stock par site.
|
||
*
|
||
* L'agrégation des ventes se fait UNIQUEMENT sur mvtart × articles (jointure 1:1),
|
||
* exactement comme pgGetMensuelByFournisseur (le calcul canonique de la Grille) :
|
||
* qte = SUM(-qtemvt), ca = SUM(-mntmvtttc), marge = SUM(margemvt) pour genremvt = 3
|
||
* (ventes stockées en négatif — vérifié sur les mouvements réels de l'API).
|
||
* Les attributs (libellé, fournisseur préféré, nomenclature) et le stock sont
|
||
* joints APRÈS l'agrégation, par codein : aucune jointure ne peut dupliquer les
|
||
* mouvements et fausser les sommes, et le LATERAL fournisseur ne s'exécute que
|
||
* par article vendu (et non par ligne de mouvement).
|
||
*
|
||
* Cas vérifié (codein 487673, site 579, jan→juil 2026) : 31 ventes brutes,
|
||
* 7 retours clients → 24 net. L'ancien SUM(ABS(qtemvt)) affichait 38 (retours
|
||
* additionnés) ; SUM(-qtemvt) donne 24, identique à l'API mensuel officielle.
|
||
*/
|
||
export async function pgGetHitParade(dateDebut: string, dateFin: string): Promise<HitParadeRow[]> {
|
||
const result = await pgNoParallel(sql`
|
||
WITH ventes AS (
|
||
SELECT
|
||
TRIM(a.codein::text) AS codein,
|
||
m.site,
|
||
SUM(-m.qtemvt)::float AS qte_vendue,
|
||
SUM(-m.mntmvtttc)::float AS ca_ttc,
|
||
SUM(m.margemvt)::float AS marge
|
||
FROM mvtart m
|
||
JOIN articles a ON a.no_id = m.artnoid
|
||
WHERE m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||
AND m.site IN ('292', '579')
|
||
AND m.genremvt = 3
|
||
AND a.codein IS NOT NULL
|
||
GROUP BY TRIM(a.codein::text), m.site
|
||
),
|
||
attrs AS (
|
||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||
TRIM(a.codein::text) AS codein,
|
||
a.libelle1::text AS libelle,
|
||
COALESCE(TRIM(af.reference::text), '') AS reference,
|
||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||
COALESCE(n.libelle, 'Sans nomenclature')::text AS nomenclature
|
||
FROM articles a
|
||
LEFT JOIN LATERAL (
|
||
SELECT af1.code, af1.reference
|
||
FROM artfou1 af1
|
||
WHERE af1.art_no_id = a.no_id AND af1.preference = 1
|
||
ORDER BY af1.code
|
||
LIMIT 1
|
||
) af ON TRUE
|
||
LEFT JOIN fouident fi ON fi.code = af.code
|
||
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
|
||
WHERE TRIM(a.codein::text) IN (SELECT codein FROM ventes)
|
||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||
),
|
||
stock_agg AS (
|
||
SELECT
|
||
TRIM(a2.codein::text) AS codein,
|
||
SUM(CASE WHEN cs.site = '292' THEN cs.qte ELSE 0 END)::float AS stock292,
|
||
SUM(CASE WHEN cs.site = '579' THEN cs.qte ELSE 0 END)::float AS stock579,
|
||
SUM(cs.qte)::float AS stockTotal
|
||
FROM cube_stock cs
|
||
JOIN articles a2 ON a2.no_id = cs.artnoid
|
||
WHERE TRIM(a2.codein::text) IN (SELECT codein FROM ventes)
|
||
GROUP BY TRIM(a2.codein::text)
|
||
)
|
||
SELECT
|
||
v.codein,
|
||
COALESCE(ar.libelle, '') AS libelle,
|
||
COALESCE(ar.reference, '') AS reference,
|
||
COALESCE(ar.fournisseur, 'Sans fournisseur') AS fournisseur,
|
||
COALESCE(ar.nomenclature_code, '') AS nomenclature_code,
|
||
COALESCE(ar.nomenclature, 'Sans nomenclature') AS nomenclature,
|
||
v.site,
|
||
v.qte_vendue,
|
||
v.ca_ttc,
|
||
v.marge,
|
||
COALESCE(s.stock292, 0) AS stock292,
|
||
COALESCE(s.stock579, 0) AS stock579,
|
||
COALESCE(s.stockTotal, 0) AS "stockTotal"
|
||
FROM ventes v
|
||
LEFT JOIN attrs ar ON ar.codein = v.codein
|
||
LEFT JOIN stock_agg s ON s.codein = v.codein
|
||
ORDER BY v.ca_ttc DESC
|
||
`);
|
||
|
||
console.log(`[pg-ff] HitParade: ${result.rows.length} lignes pour ${dateDebut}→${dateFin}`);
|
||
return result.rows as unknown as HitParadeRow[];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stock par liste de codeins (pour Hit Parade)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface StockBySite {
|
||
stock292: number;
|
||
stock579: number;
|
||
stockTotal: number;
|
||
}
|
||
|
||
/**
|
||
* Retourne le stock actuel (stockdispo) par codein pour une liste de codeins.
|
||
* 1 seule requête bulk — utilisé pour enrichir le Hit Parade.
|
||
*
|
||
* On passe les codeins comme une SEULE chaîne CSV ($1) et on utilise
|
||
* string_to_array côté PG — cela évite l'expansion Drizzle en $1,$2,...$N
|
||
* qui déclencherait le PG error 54011 (ROW > 1664 entries).
|
||
*/
|
||
export async function pgGetStockForCodeins(codeins: string[]): Promise<Map<string, StockBySite>> {
|
||
if (codeins.length === 0) return new Map();
|
||
|
||
const result = await pgNoParallel(
|
||
sql`SELECT TRIM(a.codein::text) AS codein, cs.site, COALESCE(cs.qte, 0)::float AS stockdispo
|
||
FROM cube_stock cs
|
||
JOIN articles a ON a.no_id = cs.artnoid
|
||
WHERE TRIM(a.codein::text) IN (${sql.join(codeins.map(c => sql`${c}`), sql`, `)})`
|
||
);
|
||
const rows = result.rows as { codein: string; site: string; stockdispo: number }[];
|
||
|
||
const map = new Map<string, StockBySite>();
|
||
for (const r of rows) {
|
||
if (!map.has(r.codein)) map.set(r.codein, { stock292: 0, stock579: 0, stockTotal: 0 });
|
||
const entry = map.get(r.codein)!;
|
||
if (r.site === "292") entry.stock292 += r.stockdispo;
|
||
else if (r.site === "579") entry.stock579 += r.stockdispo;
|
||
entry.stockTotal += r.stockdispo;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stock — fournisseur de la dernière entrée
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Colonnes candidates de `mvtart` reliant un mouvement de stock à un fournisseur.
|
||
* `mvtart` provient d'une réplication FF : le nom exact n'est pas garanti, on le
|
||
* détecte une fois par process (même principe que `getNomenclatureParentCol`).
|
||
* - target "artfou1" : la colonne est une FK vers `artfou1.no_id`
|
||
* - target "code" : la colonne contient directement le code fournisseur (`fouident.code`)
|
||
*/
|
||
const MVTART_FOU_CANDIDATES: { column: string; target: "artfou1" | "code" }[] = [
|
||
{ column: "artfou1_no_id", target: "artfou1" },
|
||
{ column: "artfou_no_id", target: "artfou1" },
|
||
{ column: "afou_no_id", target: "artfou1" },
|
||
{ column: "codefou", target: "code" },
|
||
{ column: "code_fou", target: "code" },
|
||
{ column: "fou_code", target: "code" },
|
||
{ column: "fournisseur", target: "code" },
|
||
];
|
||
|
||
let _mvtartFouLink: { column: string; target: "artfou1" | "code" } | null | undefined = undefined;
|
||
|
||
async function getMvtartFouLink() {
|
||
if (_mvtartFouLink !== undefined) return _mvtartFouLink;
|
||
try {
|
||
const meta = await db.execute(sql`
|
||
SELECT column_name FROM information_schema.columns
|
||
WHERE table_name = 'mvtart'
|
||
`);
|
||
const cols = new Set(
|
||
(meta.rows as { column_name: string }[]).map(r => r.column_name.toLowerCase())
|
||
);
|
||
// Le nom vient d'information_schema ET doit figurer dans la liste blanche
|
||
// ci-dessus : aucune valeur utilisateur n'atteint le SQL.
|
||
_mvtartFouLink = MVTART_FOU_CANDIDATES.find(c => cols.has(c.column)) ?? null;
|
||
} catch (e) {
|
||
console.error("[pg-ff] Détection mvtart.fournisseur impossible:", e);
|
||
_mvtartFouLink = null;
|
||
}
|
||
console.log("[pg-ff] mvtart → fournisseur:", _mvtartFouLink ?? "aucune colonne (repli artfou1.preference=1)");
|
||
return _mvtartFouLink;
|
||
}
|
||
|
||
/**
|
||
* Jointure latérale sur la DERNIÈRE entrée en stock (`genremvt IN (1,2)`) du
|
||
* couple article/site : elle expose `lastentry.datmvt` (date) et
|
||
* `lastentry.code` (code fournisseur, `NULL` si la base ne porte pas
|
||
* l'information).
|
||
*
|
||
* Règle métier : un article n'est rattaché qu'au fournisseur de sa dernière
|
||
* entrée. S'il est rentré en stock chez un autre fournisseur, il ne doit plus
|
||
* apparaître sous le précédent.
|
||
*
|
||
* La `LEFT JOIN artfou1` (et non `JOIN`) garantit qu'on retient bien la vraie
|
||
* dernière entrée, même si son fournisseur est inconnu.
|
||
*/
|
||
function lastEntryJoin(
|
||
link: { column: string; target: "artfou1" | "code" } | null,
|
||
artnoidExpr: SQL,
|
||
siteExpr: SQL,
|
||
): SQL {
|
||
const codeExpr = link
|
||
? (link.target === "artfou1" ? sql`afe.code` : sql.raw(`me.${link.column}`))
|
||
: sql`NULL::text`;
|
||
const joinArtfou = link && link.target === "artfou1"
|
||
? sql`LEFT JOIN artfou1 afe ON afe.no_id = ${sql.raw(`me.${link.column}`)}`
|
||
: sql``;
|
||
return sql`
|
||
LEFT JOIN LATERAL (
|
||
SELECT me.datmvt, ${codeExpr} AS code
|
||
FROM mvtart me
|
||
${joinArtfou}
|
||
WHERE me.artnoid = ${artnoidExpr}
|
||
AND me.site = ${siteExpr}
|
||
AND me.genremvt IN (1, 2)
|
||
ORDER BY me.datmvt DESC, me.no_id DESC
|
||
LIMIT 1
|
||
) lastentry ON TRUE
|
||
${link ? sql`LEFT JOIN fouident flast ON flast.code = lastentry.code` : sql``}
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Libellé fournisseur affiché : celui de la dernière entrée quand la base le
|
||
* permet, sinon repli documenté sur `artfou1.preference = 1`.
|
||
*/
|
||
function fournisseurExpr(link: { column: string; target: "artfou1" | "code" } | null): SQL {
|
||
return link
|
||
? sql`COALESCE(flast.nom, lastentry.code, f.nom, af.code)`
|
||
: sql`COALESCE(f.nom, af.code)`;
|
||
}
|
||
|
||
/**
|
||
* Diagnostic (`/api/diag/stock-fournisseur`) : expose la colonne détectée, les
|
||
* colonnes réellement présentes sur `mvtart` et un échantillon comparant le
|
||
* fournisseur principal au fournisseur de la dernière entrée.
|
||
*/
|
||
export async function pgDiagFournisseurDerniereEntree() {
|
||
const link = await getMvtartFouLink();
|
||
const meta = await db.execute(sql`
|
||
SELECT column_name, data_type FROM information_schema.columns
|
||
WHERE table_name = 'mvtart' ORDER BY ordinal_position
|
||
`);
|
||
let echantillon: unknown[] = [];
|
||
if (link) {
|
||
const r = await pgNoParallel(sql`
|
||
SELECT
|
||
a.codein,
|
||
cs.site,
|
||
COALESCE(f.nom, af.code) AS fournisseur_preference,
|
||
COALESCE(flast.nom, lastentry.code) AS fournisseur_derniere_entree,
|
||
lastentry.datmvt::text AS derniere_entree
|
||
FROM cube_stock cs
|
||
JOIN articles a ON a.no_id = cs.artnoid
|
||
JOIN (
|
||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||
FROM artfou1
|
||
WHERE preference = 1
|
||
ORDER BY art_no_id, code
|
||
) af ON af.art_no_id = a.no_id
|
||
LEFT JOIN fouident f ON f.code = af.code
|
||
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
|
||
WHERE cs.qte < 0
|
||
AND cs.site IN ('292', '579')
|
||
LIMIT 20
|
||
`);
|
||
echantillon = r.rows;
|
||
}
|
||
return {
|
||
colonneDetectee: link,
|
||
candidatsTestes: MVTART_FOU_CANDIDATES.map(c => c.column),
|
||
colonnesMvtart: meta.rows,
|
||
echantillon,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stock négatif — tous magasins ou par site
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface PgStockNegatifRow {
|
||
codein: string;
|
||
libelle1: string;
|
||
fournisseur: string;
|
||
site: string;
|
||
stockdispo: number;
|
||
dernierevente: string | null;
|
||
derniereentree: string | null;
|
||
}
|
||
|
||
export interface PgStockSansVenteRow {
|
||
codein: string;
|
||
libelle1: string;
|
||
fournisseur: string;
|
||
site: string;
|
||
stock_actuel: number;
|
||
derniere_entree: string | null;
|
||
}
|
||
|
||
/**
|
||
* Retourne les articles ayant des entrées en stock (hors mois courant)
|
||
* mais aucune vente enregistrée sur ce site.
|
||
* Les articles dont le stock actuel est nul sont exclus (rien à régulariser).
|
||
*/
|
||
export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVenteRow[]> {
|
||
const link = await getMvtartFouLink();
|
||
const siteFilter = site ? sql`AND m.site = ${site}` : sql``;
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
a.codein,
|
||
COALESCE(a.libelle1, '') AS libelle1,
|
||
${fournisseurExpr(link)} AS fournisseur,
|
||
m.site,
|
||
COALESCE(cs.qte::float, 0) AS stock_actuel,
|
||
MAX(m.datmvt)::text AS derniere_entree
|
||
FROM mvtart m
|
||
JOIN articles a ON a.no_id = m.artnoid
|
||
JOIN (
|
||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||
FROM artfou1
|
||
WHERE preference = 1
|
||
ORDER BY art_no_id, code
|
||
) af ON af.art_no_id = a.no_id
|
||
JOIN fouident f ON f.code = af.code
|
||
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
|
||
${lastEntryJoin(link, sql`a.no_id`, sql`m.site`)}
|
||
WHERE m.genremvt IN (1, 2)
|
||
AND m.site IN ('292', '579')
|
||
AND m.datmvt < date_trunc('month', CURRENT_DATE)
|
||
${siteFilter}
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM mvtart mv2
|
||
WHERE mv2.artnoid = m.artnoid
|
||
AND mv2.site = m.site
|
||
AND mv2.genremvt = 3
|
||
)
|
||
GROUP BY a.codein, a.libelle1, ${fournisseurExpr(link)}, m.site, cs.qte
|
||
HAVING COALESCE(cs.qte::float, 0) <> 0
|
||
ORDER BY MAX(m.datmvt) DESC
|
||
`);
|
||
return result.rows as PgStockSansVenteRow[];
|
||
}
|
||
|
||
export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRow[]> {
|
||
const link = await getMvtartFouLink();
|
||
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
a.codein,
|
||
COALESCE(a.libelle1, '') AS libelle1,
|
||
${fournisseurExpr(link)} AS fournisseur,
|
||
cs.site,
|
||
cs.qte::float AS stockdispo,
|
||
cs.dernierevente::text,
|
||
lastentry.datmvt::text AS derniereentree
|
||
FROM cube_stock cs
|
||
JOIN articles a ON a.no_id = cs.artnoid
|
||
JOIN (
|
||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||
FROM artfou1
|
||
WHERE preference = 1
|
||
ORDER BY art_no_id, code
|
||
) af ON af.art_no_id = a.no_id
|
||
JOIN fouident f ON f.code = af.code
|
||
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
|
||
WHERE cs.qte < 0
|
||
AND cs.site IN ('292', '579')
|
||
${siteFilter}
|
||
ORDER BY cs.qte ASC
|
||
`);
|
||
return result.rows as PgStockNegatifRow[];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stock sans vente depuis 6 mois
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface PgSansVente6MoisRow {
|
||
codein: string;
|
||
libelle1: string;
|
||
fournisseur: string;
|
||
site: string;
|
||
stock_actuel: number;
|
||
derniere_vente: string | null;
|
||
derniere_entree: string | null;
|
||
jours_sans_vente: number | null;
|
||
}
|
||
|
||
/**
|
||
* Retourne les articles ayant du stock disponible (qte > 0)
|
||
* dont la dernière vente remonte à plus de 6 mois (ou jamais vendus)
|
||
* ET dont la dernière entrée en stock remonte aussi à plus de 6 mois.
|
||
*/
|
||
export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6MoisRow[]> {
|
||
const link = await getMvtartFouLink();
|
||
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
a.codein,
|
||
COALESCE(a.libelle1, '') AS libelle1,
|
||
${fournisseurExpr(link)} AS fournisseur,
|
||
cs.site,
|
||
cs.qte::float AS stock_actuel,
|
||
cs.dernierevente::text AS derniere_vente,
|
||
CASE
|
||
WHEN cs.dernierevente IS NULL THEN NULL
|
||
ELSE (CURRENT_DATE - cs.dernierevente::date)
|
||
END AS jours_sans_vente,
|
||
lastentry.datmvt::text AS derniere_entree
|
||
FROM cube_stock cs
|
||
JOIN articles a ON a.no_id = cs.artnoid
|
||
JOIN (
|
||
SELECT DISTINCT ON (art_no_id) art_no_id, code
|
||
FROM artfou1
|
||
WHERE preference = 1
|
||
ORDER BY art_no_id, code
|
||
) af ON af.art_no_id = a.no_id
|
||
JOIN fouident f ON f.code = af.code
|
||
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
|
||
WHERE cs.site IN ('292', '579')
|
||
AND cs.qte > 0
|
||
AND (
|
||
cs.dernierevente IS NULL
|
||
OR cs.dernierevente < CURRENT_DATE - INTERVAL '6 months'
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM mvtart m2
|
||
WHERE m2.artnoid = cs.artnoid
|
||
AND m2.site = cs.site
|
||
AND m2.genremvt IN (1, 2)
|
||
AND m2.datmvt >= CURRENT_DATE - INTERVAL '6 months'
|
||
)
|
||
${siteFilter}
|
||
ORDER BY cs.dernierevente ASC NULLS FIRST
|
||
`);
|
||
return result.rows as PgSansVente6MoisRow[];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Commandes automatiques — par site + fournisseur avec franco
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface PgCommandeAutoRow {
|
||
site: string;
|
||
codefou: string;
|
||
nomfou: string;
|
||
franco: number;
|
||
nb_articles: number;
|
||
montant_cde: number;
|
||
franco_atteint: boolean | null;
|
||
ecart_franco: number;
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
function mapCommandeAutoRow(r: any): PgCommandeAutoRow {
|
||
return {
|
||
site: String(r.site ?? ""),
|
||
codefou: String(r.codefou ?? ""),
|
||
nomfou: String(r.nom_fou ?? r.nomfou ?? ""),
|
||
franco: Number(r.franco_ht ?? r.franco ?? 0),
|
||
nb_articles: Number(r.nb_articles ?? 0),
|
||
montant_cde: Number(r.montant_propo_ht ?? r.montant_cde ?? 0),
|
||
franco_atteint: r.franco_atteint === "OUI" ? true
|
||
: r.franco_atteint === "NON" ? false
|
||
: r.franco_atteint === "N/A" ? null
|
||
: r.franco_atteint != null ? Boolean(r.franco_atteint)
|
||
: null,
|
||
ecart_franco: Number(r.ecart_franco ?? 0),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Retourne la liste des fournisseurs en commande automatique via l'API Hostinger.
|
||
* GET https://api.ffnancy.fr/api/commandes-auto
|
||
*
|
||
* Pour les fournisseurs dont le franco est absent de la réponse globale (franco_ht null),
|
||
* on enrichit via le detail endpoint /api/commandes-auto/:codefou?site= qui contient
|
||
* toujours le résumé franco du fournisseur.
|
||
*/
|
||
export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||
|
||
try {
|
||
const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, ffFetchInit());
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json();
|
||
// L'API retourne { count: N, propositions: [...] } ou un tableau direct
|
||
const rows: unknown[] = Array.isArray(data)
|
||
? data
|
||
: (data?.propositions ?? data?.data ?? data?.items ?? data?.results ?? []);
|
||
|
||
// Diagnostic : log les clés du premier row pour détecter les changements de format
|
||
if (rows.length > 0) {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const sample = rows[0] as any;
|
||
console.log(`[pg-ff] pgGetCommandesAuto sample keys:`, Object.keys(sample));
|
||
console.log(`[pg-ff] pgGetCommandesAuto sample franco fields: franco_ht=${sample.franco_ht} franco=${sample.franco} franco_atteint=${sample.franco_atteint}`);
|
||
}
|
||
|
||
console.log(`[pg-ff] pgGetCommandesAuto: ${rows.length} lignes (API Hostinger)`);
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const mapped = (rows as any[]).map(mapCommandeAutoRow);
|
||
|
||
// Enrichissement : pour les fournisseurs sans franco dans la réponse globale,
|
||
// on appelle GET /api/commandes-auto/{codefou} (sans filtre site — franco est une
|
||
// propriété du fournisseur, pas du site) et on lit resume[0].franco_ht.
|
||
const missingFranco = mapped.filter(r => r.franco === 0 && r.codefou);
|
||
if (missingFranco.length > 0) {
|
||
console.log(`[pg-ff] pgGetCommandesAuto: ${missingFranco.length} lignes sans franco → enrichissement`);
|
||
// Dédupliquer par codefou (franco identique quel que soit le site)
|
||
const uniqueCodeFous = [...new Set(missingFranco.map(r => r.codefou))];
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const extractFranco = (detail: any): number => {
|
||
if (!detail || detail.error) return 0;
|
||
const resumeRaw = detail?.resume ?? detail?.summary ?? detail;
|
||
const resumeItem = Array.isArray(resumeRaw) ? resumeRaw[0] : resumeRaw;
|
||
return Number(resumeItem?.franco_ht ?? resumeItem?.franco ?? 0);
|
||
};
|
||
|
||
// 6 fournisseurs à la fois : chacun peut coûter deux appels successifs.
|
||
const details = await parLots(uniqueCodeFous, 6, async (codefou) => {
|
||
// 1. Essai sans filtre site
|
||
try {
|
||
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
|
||
if (res2.ok) {
|
||
const detail = await res2.json();
|
||
const f = extractFranco(detail);
|
||
if (f > 0) return { codefou, franco: f };
|
||
}
|
||
} catch { /* ignore */ }
|
||
// 2. Fallback site=000
|
||
try {
|
||
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
|
||
if (res3.ok) {
|
||
const detail = await res3.json();
|
||
const f = extractFranco(detail);
|
||
if (f > 0) return { codefou, franco: f };
|
||
}
|
||
} catch { /* ignore */ }
|
||
return { codefou, franco: 0 };
|
||
});
|
||
|
||
// Map codefou → franco enrichi
|
||
const francoMap = new Map<string, number>();
|
||
for (const result of details) {
|
||
if (result.status === "fulfilled" && result.value.franco > 0) {
|
||
francoMap.set(result.value.codefou, result.value.franco);
|
||
}
|
||
}
|
||
console.log(`[pg-ff] pgGetCommandesAuto: francos enrichis:`, Object.fromEntries(francoMap));
|
||
|
||
return mapped.map(r => {
|
||
if (r.franco === 0) {
|
||
const enriched = francoMap.get(r.codefou);
|
||
if (enriched && enriched > 0) return { ...r, franco: enriched };
|
||
}
|
||
return r;
|
||
});
|
||
}
|
||
|
||
return mapped;
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetCommandesAuto error:", (e as Error).message?.slice(0, 300));
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 12. Dernière réception par fournisseur ET par site (cadencier)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Retourne la date de la dernière réception (entrée de marchandise) par
|
||
* fournisseur et par site, pour piloter le cadencier de commande.
|
||
*
|
||
* Source : mvtart (genremvt IN (1, 2) = réceptions, voir pgGetMensuelByFournisseur).
|
||
* La réception est rattachée à l'article ; on l'attribue au fournisseur
|
||
* principal de l'article (artfou1.preference = 1), comme partout ailleurs.
|
||
*
|
||
* Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur
|
||
* (l'UI affichera alors « échéance inconnue »).
|
||
*/
|
||
export async function pgGetDerniereReceptionParFournisseur(codes?: string[]): Promise<Map<string, string>> {
|
||
const map = new Map<string, string>();
|
||
// Liste fournie (fournisseurs du cadencier) : on n'agrège que leurs articles,
|
||
// au lieu de tout l'historique de réceptions de tous les fournisseurs.
|
||
if (codes && codes.length === 0) return map;
|
||
const filtreFournisseurs = codes
|
||
? sql`AND TRIM(af.code) IN (${sql.join(codes.map((c) => sql`${c.trim()}`), sql`, `)})`
|
||
: sql``;
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
af.code AS codefou,
|
||
m.site AS site,
|
||
MAX(m.datmvt) AS derniere
|
||
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.preference = 1
|
||
WHERE m.genremvt IN (1, 2)
|
||
AND m.site IN ('292', '579')
|
||
AND m.datmvt IS NOT NULL
|
||
${filtreFournisseurs}
|
||
GROUP BY af.code, m.site
|
||
`);
|
||
|
||
for (const row of result.rows as { codefou: string; site: string; derniere: string | Date }[]) {
|
||
if (!row.codefou || !row.derniere) continue;
|
||
const iso = row.derniere instanceof Date
|
||
? row.derniere.toISOString()
|
||
: new Date(row.derniere).toISOString();
|
||
map.set(`${String(row.codefou).trim()}|${String(row.site ?? "").trim()}`, iso);
|
||
}
|
||
console.log(`[pg-ff] pgGetDerniereReceptionParFournisseur: ${map.size} entrées (mvtart)`);
|
||
return map;
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetDerniereReceptionParFournisseur error:", (e as Error).message?.slice(0, 250));
|
||
return map;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 13. Recherche & fiche produit (page /produits)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Une ligne de résultat de recherche produit. Volontairement **légère** :
|
||
* aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût).
|
||
* Le détail lourd n'est calculé qu'à l'ouverture de la fiche.
|
||
*/
|
||
export interface PgProduitSearchRow {
|
||
no_id: number;
|
||
codein: string;
|
||
code_centrale: string;
|
||
libelle1: string;
|
||
/** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */
|
||
fournisseur: string;
|
||
codefou: string;
|
||
nomenclature_code: string;
|
||
nomenclature: string;
|
||
stock_total: number;
|
||
/** true si le produit a déjà des métriques réseau Qlik en cache. */
|
||
has_reseau: boolean;
|
||
qte_reseau: number;
|
||
nb_magasins_reseau: number;
|
||
}
|
||
|
||
/**
|
||
* Caractères accentués et leur équivalent non accentué, pour `translate()`.
|
||
* Les deux chaînes DOIVENT rester de même longueur, caractère par caractère.
|
||
* Postgres n'a pas `unaccent()` sans l'extension : on ne peut pas compter dessus
|
||
* sur la base FF, d'où cette table de correspondance explicite.
|
||
*/
|
||
const SQL_ACCENTS_FROM = "àâäãáåéèêëíìîïóòôöõúùûüçñýÿ";
|
||
const SQL_ACCENTS_TO = "aaaaaaeeeeiiiiooooouuuucnyy";
|
||
|
||
/**
|
||
* Normalise un terme de recherche côté JS, de la même façon que
|
||
* `normalizedLibelleSql()` le fait côté SQL : minuscules, accents retirés,
|
||
* toute ponctuation ramenée à un espace.
|
||
*/
|
||
function normalizeSearchTerm(term: string): string {
|
||
return term
|
||
.toLowerCase()
|
||
.normalize("NFD")
|
||
.replace(/[\u0300-\u036f]/g, "") // retire les diacritiques
|
||
.replace(/[^a-z0-9]+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
/** Expression SQL renvoyant le libellé normalisé (minuscules, sans accent ni ponctuation). */
|
||
function normalizedLibelleSql() {
|
||
return sql`regexp_replace(translate(lower(COALESCE(a.libelle1, '')), ${SQL_ACCENTS_FROM}, ${SQL_ACCENTS_TO}), '[^a-z0-9]+', ' ', 'g')`;
|
||
}
|
||
|
||
/**
|
||
* Recherche un produit par **libellé** ou par **code centrale** (préfixe).
|
||
* Si le terme est entièrement numérique, on tente aussi une correspondance
|
||
* exacte sur le `codein`.
|
||
*
|
||
* La recherche par libellé est **insensible aux accents et à la ponctuation**,
|
||
* et fonctionne **mot à mot** : « tapis anti-poussière » retrouve
|
||
* « TAPIS ANTI POUSSIERE », et « tapis poussiere » aussi (tous les mots doivent
|
||
* être présents, dans n'importe quel ordre). Sans cette normalisation, un
|
||
* `ILIKE` brut exigeait l'accent ET le tiret exacts et ne remontait rien.
|
||
*
|
||
* ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée
|
||
* chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc
|
||
* un seq-scan avec normalisation par ligne : l'appelant impose un minimum de
|
||
* 3 caractères et une soumission explicite, jamais une recherche à la frappe.
|
||
*/
|
||
export async function pgSearchProduits(term: string, limit = 50): Promise<PgProduitSearchRow[]> {
|
||
const cleaned = term.trim();
|
||
if (cleaned.length < 3) return [];
|
||
|
||
const prefix = `${cleaned}%`;
|
||
// Mots de la recherche, normalisés. Tous doivent être présents (AND).
|
||
const tokens = normalizeSearchTerm(cleaned).split(" ").filter(Boolean);
|
||
const normLibelle = normalizedLibelleSql();
|
||
const libelleClause = tokens.length > 0
|
||
? sql`(${sql.join(tokens.map((t) => sql`${normLibelle} LIKE ${`%${t}%`}`), sql` AND `)})`
|
||
: sql`FALSE`;
|
||
|
||
// Un terme purement numérique peut être un codein : on l'ajoute au filtre
|
||
// (correspondance exacte, la colonne est space-padded d'où le TRIM).
|
||
const codeinClause = /^\d+$/.test(cleaned)
|
||
? sql`OR TRIM(a.codein::text) = ${cleaned}`
|
||
: sql``;
|
||
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
WITH raw AS (
|
||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||
a.no_id,
|
||
TRIM(a.codein::text) AS codein,
|
||
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
|
||
COALESCE(a.libelle1, '') AS libelle1,
|
||
a.nom_no_id
|
||
FROM articles a
|
||
WHERE a.codein IS NOT NULL
|
||
AND (
|
||
${libelleClause}
|
||
OR TRIM(a.artcentrale::text) ILIKE ${prefix}
|
||
${codeinClause}
|
||
)
|
||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||
),
|
||
matched AS (
|
||
SELECT * FROM raw ORDER BY libelle1 LIMIT ${limit}
|
||
)
|
||
SELECT
|
||
m.no_id,
|
||
m.codein,
|
||
m.code_centrale,
|
||
m.libelle1,
|
||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||
COALESCE(af.code, '')::text AS codefou,
|
||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||
COALESCE(n.libelle, '')::text AS nomenclature,
|
||
COALESCE(st.stock_total, 0)::float AS stock_total,
|
||
(q.code_centrale IS NOT NULL) AS has_reseau,
|
||
COALESCE(q.qte_reseau, 0)::float AS qte_reseau,
|
||
COALESCE(q.nb_magasins_reseau, 0) AS nb_magasins_reseau
|
||
FROM matched m
|
||
LEFT JOIN LATERAL (
|
||
SELECT af1.code
|
||
FROM artfou1 af1
|
||
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
|
||
ORDER BY af1.code
|
||
LIMIT 1
|
||
) af ON TRUE
|
||
LEFT JOIN fouident fi ON fi.code = af.code
|
||
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT SUM(cs.qte)::float AS stock_total
|
||
FROM cube_stock cs
|
||
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
|
||
) st ON TRUE
|
||
LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale
|
||
ORDER BY m.libelle1
|
||
`);
|
||
|
||
console.log(`[pg-ff] pgSearchProduits("${cleaned}"): ${result.rows.length} résultats`);
|
||
return result.rows as unknown as PgProduitSearchRow[];
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgSearchProduits error:", (e as Error).message?.slice(0, 250));
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rapproche une liste de **codes centraux** (issus de Qlik) du catalogue FF Nancy.
|
||
*
|
||
* C'est l'étape 2 de la recherche produit : Qlik dit quels articles le réseau
|
||
* travaille, cette requête dit lesquels nous référençons — et avec quel
|
||
* `codein`, quel fournisseur et quel stock. Un code absent du résultat est un
|
||
* produit réseau que Nancy ne référence pas (cas normal et intéressant).
|
||
*
|
||
* Les codeins sont renvoyés indexés par code centrale ; un même code centrale
|
||
* peut porter plusieurs codeins (ré-créations d'article), on ne garde que le
|
||
* `no_id` le plus récent.
|
||
*/
|
||
export async function pgGetProduitsByCodeCentrale(
|
||
codesCentraux: string[],
|
||
): Promise<Map<string, PgProduitSearchRow>> {
|
||
const out = new Map<string, PgProduitSearchRow>();
|
||
const codes = [...new Set(codesCentraux.map((c) => c.trim()).filter(Boolean))];
|
||
if (codes.length === 0) return out;
|
||
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
WITH matched AS (
|
||
SELECT DISTINCT ON (TRIM(a.artcentrale::text))
|
||
a.no_id,
|
||
TRIM(a.codein::text) AS codein,
|
||
TRIM(a.artcentrale::text) AS code_centrale,
|
||
COALESCE(a.libelle1, '') AS libelle1,
|
||
a.nom_no_id
|
||
FROM articles a
|
||
WHERE TRIM(a.artcentrale::text) IN (${sql.join(codes.map((c) => sql`${c}`), sql`, `)})
|
||
AND a.codein IS NOT NULL
|
||
ORDER BY TRIM(a.artcentrale::text), a.no_id DESC
|
||
)
|
||
SELECT
|
||
m.no_id,
|
||
m.codein,
|
||
m.code_centrale,
|
||
m.libelle1,
|
||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||
COALESCE(af.code, '')::text AS codefou,
|
||
COALESCE(n.code, '')::text AS nomenclature_code,
|
||
COALESCE(n.libelle, '')::text AS nomenclature,
|
||
COALESCE(st.stock_total, 0)::float AS stock_total,
|
||
TRUE AS has_reseau,
|
||
0::float AS qte_reseau,
|
||
0 AS nb_magasins_reseau
|
||
FROM matched m
|
||
LEFT JOIN LATERAL (
|
||
SELECT af1.code
|
||
FROM artfou1 af1
|
||
WHERE af1.art_no_id = m.no_id AND af1.preference = 1
|
||
ORDER BY af1.code
|
||
LIMIT 1
|
||
) af ON TRUE
|
||
LEFT JOIN fouident fi ON fi.code = af.code
|
||
LEFT JOIN nomenclature n ON n.no_id = m.nom_no_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT SUM(cs.qte)::float AS stock_total
|
||
FROM cube_stock cs
|
||
WHERE cs.artnoid = m.no_id AND cs.site IN ('292', '579')
|
||
) st ON TRUE
|
||
`);
|
||
|
||
for (const row of result.rows as unknown as PgProduitSearchRow[]) {
|
||
out.set(row.code_centrale, row);
|
||
}
|
||
console.log(`[pg-ff] pgGetProduitsByCodeCentrale: ${codes.length} codes Qlik → ${out.size} trouvés dans le catalogue Nancy`);
|
||
return out;
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetProduitsByCodeCentrale error:", (e as Error).message?.slice(0, 250));
|
||
return out;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* `codein` de l'article portant ce code centrale, ou `null` si Nancy ne le
|
||
* référence pas. Utilisé par la fiche produit ouverte depuis un résultat Qlik
|
||
* (URL `?cc=…`), qui n'a que le code centrale.
|
||
*/
|
||
export async function pgGetCodeinByCodeCentrale(codeCentrale: string): Promise<string | null> {
|
||
const cleaned = codeCentrale.trim();
|
||
if (!cleaned) return null;
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
SELECT TRIM(a.codein::text) AS codein
|
||
FROM articles a
|
||
WHERE TRIM(a.artcentrale::text) = ${cleaned}
|
||
AND a.codein IS NOT NULL
|
||
ORDER BY a.no_id DESC
|
||
LIMIT 1
|
||
`);
|
||
const row = (result.rows as unknown as { codein: string }[])[0];
|
||
return row?.codein ?? null;
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetCodeinByCodeCentrale error:", (e as Error).message?.slice(0, 250));
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Identité, prix et nomenclature d'un produit (une seule ligne). */
|
||
export interface PgProduitDetailRow {
|
||
no_id: number;
|
||
codein: string;
|
||
code_centrale: string;
|
||
libelle1: string;
|
||
nom_no_id: number | null;
|
||
gtin: string;
|
||
reference: string;
|
||
pcb: number | null;
|
||
/** article_infosup.prix_vente_mini */
|
||
pv_central: number | null;
|
||
/** cube_pa.pa */
|
||
pa: number | null;
|
||
code3: string | null;
|
||
libelle3: string | null;
|
||
code2: string | null;
|
||
libelle2: string | null;
|
||
code1: string | null;
|
||
libelle1nom: string | null;
|
||
}
|
||
|
||
/**
|
||
* Fiche d'identité complète d'un produit à partir de son `codein`.
|
||
* Renvoie `null` si le codein est inconnu.
|
||
*/
|
||
export async function pgGetProduitDetail(codein: string): Promise<PgProduitDetailRow | null> {
|
||
const parentCol = await getNomenclatureParentCol();
|
||
// Hiérarchie nomenclature — deux stratégies :
|
||
//
|
||
// 1. Colonne parent entière si elle existe (FK vers le no_id du niveau au-dessus).
|
||
// 2. Sinon, remontée par **préfixe de code** : la nomenclature FF est encodée
|
||
// hiérarchiquement, secteur = 2 chiffres, famille = 4, sous-famille = 6
|
||
// (ex "330702" → "3307" → "33"). En production `nomenclature` n'expose que
|
||
// no_id / code / libelle / niveau / chemin_pere : aucune FK entière, et
|
||
// `chemin_pere` est un chemin texte. Sans ce repli, Secteur et Famille
|
||
// restaient vides sur la fiche.
|
||
const hierarchy = parentCol
|
||
? sql`
|
||
LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)}
|
||
LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)}
|
||
`
|
||
: sql`
|
||
LEFT JOIN LATERAL (
|
||
SELECT nx.code, nx.libelle
|
||
FROM nomenclature nx
|
||
WHERE LENGTH(TRIM(n3.code)) >= 4
|
||
AND TRIM(nx.code) = LEFT(TRIM(n3.code), 4)
|
||
LIMIT 1
|
||
) n2 ON TRUE
|
||
LEFT JOIN LATERAL (
|
||
SELECT nx.code, nx.libelle
|
||
FROM nomenclature nx
|
||
WHERE LENGTH(TRIM(n3.code)) >= 2
|
||
AND TRIM(nx.code) = LEFT(TRIM(n3.code), 2)
|
||
LIMIT 1
|
||
) n1 ON TRUE
|
||
`;
|
||
// Les colonnes sont identiques dans les deux stratégies (alias n1 / n2).
|
||
const hierarchyCols = sql`,
|
||
n2.code AS code2,
|
||
n2.libelle AS libelle2,
|
||
n1.code AS code1,
|
||
n1.libelle AS libelle1nom`;
|
||
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
a.no_id,
|
||
TRIM(a.codein::text) AS codein,
|
||
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
|
||
COALESCE(a.libelle1, '') AS libelle1,
|
||
a.nom_no_id,
|
||
COALESCE(ag.gtin, af.ean13, '')::text AS gtin,
|
||
COALESCE(af.reference, '')::text AS reference,
|
||
af.pcb::float AS pcb,
|
||
ai.prix_vente_mini::float AS pv_central,
|
||
pa.pa::float AS pa,
|
||
n3.code AS code3,
|
||
n3.libelle AS libelle3
|
||
${hierarchyCols}
|
||
FROM articles a
|
||
LEFT JOIN LATERAL (
|
||
SELECT af1.code, af1.pcb, af1.reference, af1.ean13
|
||
FROM artfou1 af1
|
||
WHERE af1.art_no_id = a.no_id
|
||
ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC
|
||
LIMIT 1
|
||
) af ON TRUE
|
||
LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1
|
||
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
|
||
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
|
||
LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id
|
||
${hierarchy}
|
||
WHERE TRIM(a.codein::text) = ${codein}
|
||
ORDER BY a.no_id DESC
|
||
LIMIT 1
|
||
`);
|
||
|
||
const row = (result.rows as unknown as PgProduitDetailRow[])[0] ?? null;
|
||
console.log(`[pg-ff] pgGetProduitDetail(${codein}): ${row ? "trouvé" : "introuvable"}`);
|
||
return row;
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetProduitDetail error:", (e as Error).message?.slice(0, 250));
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Un fournisseur référencé pour l'article. */
|
||
export interface PgProduitFournisseurRow {
|
||
codefou: string;
|
||
nomfou: string;
|
||
reference: string;
|
||
pcb: number | null;
|
||
/** true si artfou1.preference = 1 */
|
||
principal: boolean;
|
||
}
|
||
|
||
/** Tous les fournisseurs référencés pour un article (le principal en premier). */
|
||
export async function pgGetFournisseursByCodein(codein: string): Promise<PgProduitFournisseurRow[]> {
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
SELECT DISTINCT ON (af.code)
|
||
af.code::text AS codefou,
|
||
COALESCE(fi.nom, af.code)::text AS nomfou,
|
||
COALESCE(af.reference, '')::text AS reference,
|
||
af.pcb::float AS pcb,
|
||
(af.preference = 1) AS principal
|
||
FROM articles a
|
||
JOIN artfou1 af ON af.art_no_id = a.no_id
|
||
LEFT JOIN fouident fi ON fi.code = af.code
|
||
WHERE TRIM(a.codein::text) = ${codein}
|
||
ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC
|
||
`);
|
||
const rows = result.rows as unknown as PgProduitFournisseurRow[];
|
||
// Le principal remonte en tête (le DISTINCT ON impose un tri par code).
|
||
rows.sort((x, y) => Number(y.principal) - Number(x.principal) || x.nomfou.localeCompare(y.nomfou, "fr"));
|
||
return rows;
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetFournisseursByCodein error:", (e as Error).message?.slice(0, 250));
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** Stock temps réel par site pour un article. */
|
||
export interface PgProduitStockRow {
|
||
site: string;
|
||
stockdispo: number;
|
||
qte: number;
|
||
valstock: number;
|
||
prmp: number;
|
||
dernierevente: string | null;
|
||
dernierereception: string | null;
|
||
}
|
||
|
||
export async function pgGetStockByCodein(codein: string): Promise<PgProduitStockRow[]> {
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
cs.site,
|
||
COALESCE(cs.stockdispo, 0)::float AS stockdispo,
|
||
COALESCE(cs.qte, 0)::float AS qte,
|
||
COALESCE(cs.valstock, 0)::float AS valstock,
|
||
COALESCE(cs.prmp, 0)::float AS prmp,
|
||
cs.dernierevente::text AS dernierevente,
|
||
cs.dernierereception::text AS dernierereception
|
||
FROM cube_stock cs
|
||
JOIN articles a ON a.no_id = cs.artnoid
|
||
WHERE TRIM(a.codein::text) = ${codein}
|
||
AND cs.site IN ('292', '579')
|
||
ORDER BY cs.site
|
||
`);
|
||
return result.rows as unknown as PgProduitStockRow[];
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetStockByCodein error:", (e as Error).message?.slice(0, 250));
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** Quantité totale en commande fournisseur pour un article. */
|
||
export async function pgGetCommandesByCodein(codein: string): Promise<number> {
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde
|
||
FROM cdefou_vivant cv
|
||
WHERE TRIM(cv.articles_codein::text) = ${codein}
|
||
`);
|
||
const row = (result.rows as unknown as { qtecde: number }[])[0];
|
||
return Number(row?.qtecde) || 0;
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetCommandesByCodein error:", (e as Error).message?.slice(0, 250));
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/** Une gamme affectée à l'article pour une saison donnée. */
|
||
export interface PgGammeHistoryRow {
|
||
saison_no_id: number;
|
||
saison_code: string | null;
|
||
saison_libelle: string | null;
|
||
gamme_code: string;
|
||
gamme_libelle: string | null;
|
||
}
|
||
|
||
/**
|
||
* Historique complet des gammes de l'article, saison par saison (la plus
|
||
* récente en premier). Le reste de l'application ne lit que la saison active
|
||
* (`pgGetGammesByFournisseur`) ; ici on veut la trajectoire d'arbitrage.
|
||
*/
|
||
export async function pgGetGammeHistoryByCodein(codein: string): Promise<PgGammeHistoryRow[]> {
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
SELECT
|
||
s.no_id AS saison_no_id,
|
||
s.code::text AS saison_code,
|
||
s.libelle::text AS saison_libelle,
|
||
g.code::text AS gamme_code,
|
||
g.libelle::text AS gamme_libelle
|
||
FROM art_gamme_saison ags
|
||
JOIN articles a ON a.no_id = ags.artnoid
|
||
JOIN gammes g ON g.no_id = ags.idgamme
|
||
JOIN saisons s ON s.no_id = ags.idsaison
|
||
WHERE TRIM(a.codein::text) = ${codein}
|
||
ORDER BY s.no_id DESC
|
||
`);
|
||
return result.rows as unknown as PgGammeHistoryRow[];
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetGammeHistoryByCodein error:", (e as Error).message?.slice(0, 250));
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions)
|
||
* d'UN article, par site et par mois.
|
||
*
|
||
* Reprend **exactement** le calcul canonique de `pgGetMensuelByFournisseur`
|
||
* (la source de vérité de la Grille) : seule la sélection change — filtre sur
|
||
* le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la
|
||
* fiche produit sont donc identiques à ceux de la Grille, par construction.
|
||
*/
|
||
export async function pgGetMensuelByCodein(
|
||
codein: string,
|
||
dateDebut: string,
|
||
dateFin: string
|
||
): Promise<PgMensuelRow[]> {
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
WITH base AS (
|
||
SELECT
|
||
TRIM(a.codein::text) AS codein,
|
||
m.site,
|
||
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
|
||
m.qtemvt,
|
||
m.mntmvtttc,
|
||
m.margemvt,
|
||
m.genremvt,
|
||
m.qtestock,
|
||
ROW_NUMBER() OVER (
|
||
PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
|
||
ORDER BY m.datmvt DESC
|
||
) AS rn_last
|
||
FROM mvtart m
|
||
JOIN articles a ON a.no_id = m.artnoid
|
||
WHERE TRIM(a.codein::text) = ${codein}
|
||
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||
AND m.site IN ('292', '579')
|
||
)
|
||
SELECT
|
||
codein,
|
||
site,
|
||
mois,
|
||
SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue,
|
||
SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht,
|
||
SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge,
|
||
MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois,
|
||
SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue
|
||
FROM base
|
||
GROUP BY codein, site, mois
|
||
ORDER BY site, mois
|
||
`);
|
||
|
||
console.log(`[pg-ff] pgGetMensuelByCodein(${codein}): ${result.rows.length} lignes`);
|
||
return result.rows as unknown as PgMensuelRow[];
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetMensuelByCodein error:", (e as Error).message?.slice(0, 250));
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** Un produit de la même famille, comparé local vs réseau. */
|
||
export interface PgOpportuniteRow {
|
||
codein: string;
|
||
libelle1: string;
|
||
code_centrale: string;
|
||
fournisseur: string;
|
||
qte_reseau: number;
|
||
nb_magasins_reseau: number;
|
||
ca_reseau: number;
|
||
ca_par_magasin_reseau: number;
|
||
qte_locale: number;
|
||
nb_sites_locaux: number;
|
||
qte_reseau_par_magasin: number;
|
||
qte_locale_par_magasin: number;
|
||
/** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */
|
||
ecart_par_magasin: number;
|
||
}
|
||
|
||
/**
|
||
* Produits de la même nomenclature, classés par écart entre la performance
|
||
* réseau et la performance locale (quantités **par magasin**, pour neutraliser
|
||
* la différence d'échelle entre ~270 magasins et nos 2 sites).
|
||
*
|
||
* Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà
|
||
* des métriques réseau en cache : la dimension Qlik interrogée est
|
||
* `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas.
|
||
*/
|
||
export async function pgGetOpportunitesFamille(
|
||
nomNoId: number,
|
||
dateDebut: string,
|
||
dateFin: string,
|
||
limit = 30
|
||
): Promise<PgOpportuniteRow[]> {
|
||
try {
|
||
const result = await pgNoParallel(sql`
|
||
WITH famille AS (
|
||
SELECT DISTINCT ON (TRIM(a.codein::text))
|
||
a.no_id,
|
||
TRIM(a.codein::text) AS codein,
|
||
TRIM(a.artcentrale::text) AS code_centrale,
|
||
COALESCE(a.libelle1, '') AS libelle1
|
||
FROM articles a
|
||
WHERE a.nom_no_id = ${nomNoId}
|
||
AND a.codein IS NOT NULL
|
||
AND a.artcentrale IS NOT NULL
|
||
AND TRIM(a.artcentrale::text) <> ''
|
||
ORDER BY TRIM(a.codein::text), a.no_id DESC
|
||
),
|
||
avec_reseau AS (
|
||
SELECT
|
||
f.no_id,
|
||
f.codein,
|
||
f.code_centrale,
|
||
f.libelle1,
|
||
q.qte_reseau::float AS qte_reseau,
|
||
q.nb_magasins_reseau AS nb_magasins_reseau,
|
||
COALESCE(q.ca_reseau, 0)::float AS ca_reseau,
|
||
COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau
|
||
FROM famille f
|
||
JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale
|
||
WHERE q.nb_magasins_reseau > 0
|
||
AND q.qte_reseau > 0
|
||
),
|
||
ventes AS (
|
||
SELECT
|
||
TRIM(a.codein::text) AS codein,
|
||
SUM(-m.qtemvt)::float AS qte_locale,
|
||
COUNT(DISTINCT m.site) AS nb_sites_locaux
|
||
FROM mvtart m
|
||
JOIN articles a ON a.no_id = m.artnoid
|
||
WHERE m.genremvt = 3
|
||
AND m.site IN ('292', '579')
|
||
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
|
||
AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau)
|
||
GROUP BY TRIM(a.codein::text)
|
||
)
|
||
SELECT
|
||
r.codein,
|
||
r.libelle1,
|
||
r.code_centrale,
|
||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
|
||
r.qte_reseau,
|
||
r.nb_magasins_reseau,
|
||
r.ca_reseau,
|
||
r.ca_par_magasin_reseau,
|
||
COALESCE(v.qte_locale, 0)::float AS qte_locale,
|
||
COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux,
|
||
(r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin,
|
||
(COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin,
|
||
(
|
||
(r.qte_reseau / r.nb_magasins_reseau)
|
||
- (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))
|
||
)::float AS ecart_par_magasin
|
||
FROM avec_reseau r
|
||
LEFT JOIN ventes v ON v.codein = r.codein
|
||
LEFT JOIN LATERAL (
|
||
SELECT af1.code
|
||
FROM artfou1 af1
|
||
WHERE af1.art_no_id = r.no_id AND af1.preference = 1
|
||
ORDER BY af1.code
|
||
LIMIT 1
|
||
) af ON TRUE
|
||
LEFT JOIN fouident fi ON fi.code = af.code
|
||
ORDER BY ecart_par_magasin DESC
|
||
LIMIT ${limit}
|
||
`);
|
||
|
||
console.log(`[pg-ff] pgGetOpportunitesFamille(nom=${nomNoId}): ${result.rows.length} lignes`);
|
||
return result.rows as unknown as PgOpportuniteRow[];
|
||
} catch (e) {
|
||
console.error("[pg-ff] pgGetOpportunitesFamille error:", (e as Error).message?.slice(0, 250));
|
||
return [];
|
||
}
|
||
}
|