mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 04:36:21 +02:00
perf(stock): onglets instantanés, pagination et fournisseur de dernière entrée
Le changement d'onglet déclenchait un router.push : la page serveur était rejouée et les 3 requêtes SQL relancées à chaque clic, alors que les données étaient déjà côté client. Sans aucun retour visuel, l'onglet semblait bloqué. Interface (stock-negatif/client.tsx) - Changement d'onglet purement client (useTransition + history.replaceState) : plus aucune requête SQL rejouée, l'URL reste partageable. - Modal de chargement pendant le changement de magasin (seule action qui interroge vraiment le serveur), pendant un changement d'onglet lent et pendant la génération de l'export Excel. Affichage différé de 120 ms pour éviter tout clignotement. - Pagination 50 lignes : l'onglet « Sans vente 6 mois » rendait jusqu'à 17 000 lignes d'un coup, ce qui figeait le navigateur. L'export Excel continue de reprendre l'intégralité des lignes filtrées. - Tri : ordre de la base conservé tant qu'aucune colonne n'est cliquée (le tri alphabétique sur codein écrasait le classement métier), collateur Intl réutilisé au lieu d'un localeCompare par comparaison, tri numérique robuste aux valeurs nulles. - Les 3 onglets partagent désormais un composant de tableau unique piloté par une configuration de colonnes, au lieu de 3 copies quasi identiques. - Select magasin et onglets désactivés pendant le chargement, aria-busy posé. Données (pg-ff-client.ts) - Un article n'est plus rattaché à son fournisseur principal mais au fournisseur de sa dernière entrée en stock : s'il est rentré chez un autre fournisseur, il n'apparaît plus sous le précédent. La colonne de mvtart qui porte ce lien est détectée une fois par process parmi une liste blanche (même principe que getNomenclatureParentCol), avec repli documenté sur artfou1.preference = 1 si la base ne porte pas l'information. - La jointure latérale sur la dernière entrée remplace la sous-requête corrélée MAX(datmvt) : même coût qu'avant pour une information de plus. - pgGetStockSansVente dédoublonne artfou1 via DISTINCT ON comme ses deux requêtes sœurs, et exclut les stocks nuls en SQL (HAVING) au lieu de les filtrer côté client : le compteur d'onglet correspond enfin aux lignes réellement transmises. Diagnostic - GET /api/diag/stock-fournisseur : colonne détectée, colonnes réelles de mvtart et échantillon comparant fournisseur principal et fournisseur de dernière entrée. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174nm1nWPaypWBNHioSTJ8X
This commit is contained in:
3 files changed
+638
-423
No files matched your search
+157
-23
@@ -1030,6 +1030,143 @@ export async function pgGetStockForCodeins(codeins: string[]): Promise<Map<strin
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1044,10 +1181,6 @@ export interface PgStockNegatifRow {
|
||||
derniereentree: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne tous les articles avec un stock disponible négatif.
|
||||
* Si `site` est fourni, filtre sur ce magasin uniquement.
|
||||
*/
|
||||
export interface PgStockSansVenteRow {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
@@ -1060,22 +1193,30 @@ export interface PgStockSansVenteRow {
|
||||
/**
|
||||
* 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,
|
||||
COALESCE(f.nom, af.code) AS fournisseur,
|
||||
${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 artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
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 date_trunc('month', m.datmvt) < date_trunc('month', CURRENT_DATE)
|
||||
@@ -1086,29 +1227,25 @@ export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVen
|
||||
AND mv2.site = m.site
|
||||
AND mv2.genremvt = 3
|
||||
)
|
||||
GROUP BY a.codein, a.libelle1, f.nom, af.code, m.site, cs.qte
|
||||
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,
|
||||
COALESCE(f.nom, af.code) AS fournisseur,
|
||||
${fournisseurExpr(link)} AS fournisseur,
|
||||
cs.site,
|
||||
cs.qte::float AS stockdispo,
|
||||
cs.dernierevente::text,
|
||||
(
|
||||
SELECT MAX(m.datmvt)::text
|
||||
FROM mvtart m
|
||||
WHERE m.artnoid = cs.artnoid
|
||||
AND m.site = cs.site
|
||||
AND m.genremvt IN (1, 2)
|
||||
) AS derniereentree
|
||||
lastentry.datmvt::text AS derniereentree
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
JOIN (
|
||||
@@ -1118,6 +1255,7 @@ export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRo
|
||||
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}
|
||||
@@ -1147,12 +1285,13 @@ export interface PgSansVente6MoisRow {
|
||||
* 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,
|
||||
COALESCE(f.nom, af.code) AS fournisseur,
|
||||
${fournisseurExpr(link)} AS fournisseur,
|
||||
cs.site,
|
||||
cs.qte::float AS stock_actuel,
|
||||
cs.dernierevente::text AS derniere_vente,
|
||||
@@ -1160,13 +1299,7 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
|
||||
WHEN cs.dernierevente IS NULL THEN NULL
|
||||
ELSE (CURRENT_DATE - cs.dernierevente::date)
|
||||
END AS jours_sans_vente,
|
||||
(
|
||||
SELECT MAX(m.datmvt)::text
|
||||
FROM mvtart m
|
||||
WHERE m.artnoid = cs.artnoid
|
||||
AND m.site = cs.site
|
||||
AND m.genremvt IN (1, 2)
|
||||
) AS derniere_entree
|
||||
lastentry.datmvt::text AS derniere_entree
|
||||
FROM cube_stock cs
|
||||
JOIN articles a ON a.no_id = cs.artnoid
|
||||
JOIN (
|
||||
@@ -1176,6 +1309,7 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
|
||||
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 (
|
||||
|
||||
Reference in new issue
Block a user