Files
CollectFlow/src/db/schema.ts
T
Claude 7da4c6d5fd perf: lot 1 — chargements plus rapides et corrections, sans changement visuel
Grille (navigateur)
- « Rafraîchir » ne passe plus par l'URL : le paramètre _refresh restait
  mémorisé et chaque retour par le menu forçait un recalcul serveur complet
  (migration du store pour nettoyer les valeurs déjà enregistrées).
- Lignes reçues regroupées toutes les 300 ms au lieu de reconstruire tout le
  tableau à chaque paquet de 150 (chargement quadratique).
- Sélection indexée par code article (getRowId) : après un filtre, l'action
  groupée visait d'autres produits. Seules les lignes affichées comptent.
- Recherche : un passage par ligne au lieu d'un par colonne ; formateurs de
  nombres partagés (lib/format.ts) ; colonnes mensuelles mémorisées ;
  plus de transition-all sur les lignes positionnées par transform.
- exceljs, jspdf et xlsx chargés au clic seulement.

Grille (serveur)
- Requêtes Qlik et snapshot lancées en parallèle de la phase SQL.
- Cache mémoire borné (LRU) ; calcul en cours réutilisé même en forcé ;
  flux interrompu quand le client part.
- Après un enregistrement, les gammes sont reportées dans le cache et dans
  grid_rows au lieu d'invalider (recalcul de ~40 s évité).

Données et API
- lib/ff-cache.ts : cache mémoire 30 min des lectures FF (stock, hit-parade,
  CA mensuel, fournisseurs, dernière réception, publicités), vidé en fin de
  synchro nocturne ; erreurs jamais gardées.
- Pool PostgreSQL unique (globalThis), réglage sans workers parallèles posé
  une fois par connexion au lieu d'une transaction autour de chaque requête.
- Délai maximal sur tous les appels à l'API FF.
- Index session_snapshots ; commandes : plus de double rechargement ;
  synchro : saisies regroupées ; paramètres : configuration lue une fois ;
  journal de capture en ajout seul, rien de formaté sans capture ouverte.

Corrections
- Historique : l'échec de chargement s'affiche (au lieu de « Aucun snapshot »).
- Sessions et validations enregistrent le magasin affiché, plus « TOTAL ».
- Publicités : libellé du statut « passées ».

Plan complet : docs/plan-optimisation-webui.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
2026-10-03 20:28:14 +00:00

286 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer, boolean } from "drizzle-orm/pg-core";
export const ventesProduits = pgTable("ventes_produits", {
id: serial("id").primaryKey(),
// Identification produit
codein: varchar("codein", { length: 20 }).notNull(),
codeFournisseur: varchar("code_fournisseur", { length: 20 }),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
libelle1: varchar("libelle1", { length: 500 }),
gtin: varchar("gtin", { length: 30 }),
reference: varchar("reference", { length: 100 }),
colisage: numeric("colisage", { precision: 10, scale: 5 }),
// Gamme & Nomenclature
codeGamme: varchar("code_gamme", { length: 20 }),
codeGammeInit: varchar("code_gamme_init", { length: 20 }),
code3: varchar("code3", { length: 20 }),
libelle3: varchar("libelle3", { length: 500 }),
// Dimension magasin & période
magasin: varchar("magasin", { length: 20 }).notNull(),
codeMagasin: varchar("code_magasin", { length: 10 }),
annee: smallint("annee"),
mois: smallint("mois"),
periode: varchar("periode", { length: 10 }).notNull(),
// Métriques
quantite: numeric("quantite", { precision: 12, scale: 2 }),
montantMvt: numeric("montant_mvt", { precision: 14, scale: 4 }),
margeMvt: numeric("marge_mvt", { precision: 14, scale: 4 }),
// Métadonnées
importedAt: timestamp("imported_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
uniqueIndex("uq_ventes_produit_magasin_periode").on(table.codein, table.magasin, table.periode),
index("idx_ventes_codein").on(table.codein),
index("idx_ventes_fournisseur").on(table.codeFournisseur),
index("idx_ventes_gamme").on(table.codeGamme),
index("idx_ventes_code3").on(table.code3),
index("idx_ventes_magasin").on(table.magasin),
index("idx_ventes_periode").on(table.periode),
index("idx_ventes_annee_mois").on(table.annee, table.mois),
index("idx_ventes_updated_at").on(table.updatedAt),
];
});
/** User management (Epic 6) */
export const users = pgTable("users", {
id: serial("id").primaryKey(),
username: varchar("username", { length: 50 }).notNull().unique(),
/** Hashed password */
passwordHash: text("password_hash").notNull(),
/** 'admin' or 'user' */
role: varchar("role", { length: 20 }).default("user").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
/** Snapshot of a complete arbitrage session (Epic 5) */
export const sessionSnapshots = pgTable("session_snapshots", {
id: serial("id").primaryKey(),
/** Link to user who created the snapshot */
userId: integer("user_id").references(() => users.id),
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
magasin: varchar("magasin", { length: 20 }).notNull(),
/** JSON map of codein → { before: GammeCode, after: GammeCode } */
changes: jsonb("changes").notNull(),
/** Summary stats at time of snapshot */
summaryJson: jsonb("summary_json"),
/** Label for the snapshot */
label: text("label"),
/** snapshot vs export */
type: varchar("type", { length: 20 }).default("snapshot"),
createdAt: timestamp("created_at").defaultNow(),
}, (table) => [
// Créés par scripts/db-init.js (la migration réellement jouée au démarrage).
index("idx_snapshots_fournisseur_date").on(table.codeFournisseur, table.createdAt.desc()),
index("idx_snapshots_user_type_date").on(table.userId, table.type, table.createdAt.desc()),
]);
/**
* Cadencier / alertes de commande par fournisseur ET par magasin.
* Un fournisseur peut être cadencé indépendamment sur chaque site (292/579)
* avec un intervalle propre (toutes les X semaines). L'échéance est calculée
* à partir de la dernière commande réelle (FF Nancy) + intervalle.
*/
export const commandeCadences = pgTable("commande_cadences", {
id: serial("id").primaryKey(),
/** Code fournisseur (fouident.code) */
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
/** Nom fournisseur dénormalisé pour l'affichage */
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
/** Site / magasin : "292" | "579" */
site: varchar("site", { length: 20 }).notNull(),
/** Fréquence de commande, en semaines */
intervalleSemaines: integer("intervalle_semaines").notNull(),
/** Cadence active ou non */
actif: boolean("actif").default(true).notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
uniqueIndex("uq_cadence_fou_site").on(table.codeFournisseur, table.site),
index("idx_cadence_site").on(table.site),
];
});
/**
* Cache des métriques réseau Qlik Sense (~270 magasins La Foir'Fouille).
* Clé = code centrale (format 10000XXXXXX). Rafraîchi par /api/qlik/sync.
*/
export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
/** Code centrale article (clé jointure Qlik ↔ FF) */
codeCentrale: varchar("code_centrale", { length: 20 }).primaryKey(),
/** CA réseau total du produit */
caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }),
/** Quantité vendue réseau */
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
/** Nombre de magasins travaillant le produit (sur ~270) */
nbMagasinsReseau: integer("nb_magasins_reseau"),
/** CA moyen par magasin (productivité normalisée présence) */
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
/** Taux de marge réseau (ratio brut Qlik) */
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
/** Période couverte (libre, ex "12m" ou "2025") */
periode: varchar("periode", { length: 20 }),
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
qteByMonth: jsonb("qte_by_month"),
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne
* « Tendance / Réseau » de la Grille.
*/
metricsByMonth: jsonb("metrics_by_month"),
/**
* Libellé de l'article tel que Qlik le renvoie. Renseigné par la recherche
* produit (qui interroge la dimension libellé) ; la sync fournisseur ne
* l'écrase pas. Permet d'afficher une fiche pour un produit réseau que le
* catalogue Nancy ne référence pas.
*/
libelleReseau: varchar("libelle_reseau", { length: 255 }),
/** Fournisseur de l'article côté Qlik, même origine que `libelleReseau`. */
fournisseurReseau: varchar("fournisseur_reseau", { length: 255 }),
/** Dernière synchro depuis Qlik */
fetchedAt: timestamp("fetched_at").defaultNow(),
});
/**
* Instantané persisté des lignes de grille (une ligne par fournisseur × article).
*
* Raison d'être : `getProductRows()` reconstruit la grille en direct et ne la garde
* qu'en mémoire 10 minutes — perdu à chaque redémarrage. L'API `/api/v1` ne doit
* jamais déclencher ce calcul, elle lit donc cette table, remplie en effet de bord
* quand quelqu'un ouvre la Grille. Aucun calcul supplémentaire n'est introduit : on
* arrête simplement de jeter le résultat.
*
* Seul `magasin = TOTAL` est persisté : `payload` embarque déjà les ventilations par
* magasin (sales12mByStore, caByStore…).
*/
export const gridRows = pgTable("grid_rows", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
codein: varchar("codein", { length: 20 }).notNull(),
// Colonnes scalaires : servent aux filtres, au tri et à la recherche en SQL.
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
libelle1: varchar("libelle1", { length: 500 }),
gtin: varchar("gtin", { length: 30 }),
reference: varchar("reference", { length: 100 }),
codeCentrale: varchar("code_centrale", { length: 20 }),
code1: varchar("code1", { length: 20 }),
code2: varchar("code2", { length: 20 }),
code3: varchar("code3", { length: 20 }),
codeGamme: varchar("code_gamme", { length: 20 }),
codeGammeInit: varchar("code_gamme_init", { length: 20 }),
totalCa: numeric("total_ca", { precision: 16, scale: 2 }),
totalQuantite: numeric("total_quantite", { precision: 14, scale: 2 }),
totalMarge: numeric("total_marge", { precision: 16, scale: 2 }),
tauxMarge: numeric("taux_marge", { precision: 8, scale: 4 }),
stockActuel: numeric("stock_actuel", { precision: 14, scale: 2 }),
caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }),
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
nbMagasinsReseau: integer("nb_magasins_reseau"),
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
/** ProductRow complet (séries mensuelles, ventilations par magasin, dates…). */
payload: jsonb("payload").notNull(),
/** Date du calcul ayant produit cette ligne — expose la fraîcheur au consommateur. */
computedAt: timestamp("computed_at").defaultNow().notNull(),
}, (table) => {
return [
uniqueIndex("uq_grid_rows_fou_codein").on(table.codeFournisseur, table.codein),
index("idx_grid_rows_fournisseur").on(table.codeFournisseur),
index("idx_grid_rows_codein").on(table.codein),
index("idx_grid_rows_gamme").on(table.codeGamme),
index("idx_grid_rows_code_centrale").on(table.codeCentrale),
index("idx_grid_rows_total_ca").on(table.totalCa),
index("idx_grid_rows_libelle").on(table.libelle1),
];
});
/**
* Clés d'API pour les appelants non-navigateur (scripts, outils externes).
*
* Seul le hachage SHA-256 est stocké : la clé en clair n'est affichée qu'une fois,
* à la création. Une clé révoquée conserve sa ligne (traçabilité) via `revokedAt`.
*/
export const apiKeys = pgTable("api_keys", {
id: serial("id").primaryKey(),
/** Nom lisible choisi à la création (ex "Export Excel comptabilité"). */
name: varchar("name", { length: 100 }).notNull(),
/** Préfixe en clair (ex "cf_a1b2c3") — sert à identifier la clé dans l'UI. */
keyPrefix: varchar("key_prefix", { length: 20 }).notNull(),
/** SHA-256 hexadécimal de la clé complète. */
keyHash: text("key_hash").notNull().unique(),
/** 'admin' ou 'user' — même sémantique que users.role. */
role: varchar("role", { length: 20 }).default("user").notNull(),
createdBy: varchar("created_by", { length: 50 }),
createdAt: timestamp("created_at").defaultNow(),
lastUsedAt: timestamp("last_used_at"),
revokedAt: timestamp("revoked_at"),
});
/**
* Paramétrage et suivi de la synchronisation nocturne, un enregistrement par
* fournisseur.
*
* Deux sources aux coûts très différents, donc deux cadences distinctes :
* l'instantané SQL prend une minute au pire, l'extraction Qlik jusqu'à huit —
* et le serveur Qlik abandonne quand on le sollicite trop. D'où `actifSql` et
* `actifQlik` séparés, et deux dates de dernier passage.
*
* L'ordre de passage se déduit de `dernier*At` (le plus ancien d'abord) : la
* nuit suivante reprend donc naturellement là où la précédente s'est arrêtée,
* et repart d'elle-même pour un nouveau tour quand tout a été traité. Aucun
* curseur ni compteur de tour à maintenir.
*/
export const syncFournisseurs = pgTable("sync_fournisseurs", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
/** Inclus dans la rotation de l'instantané SQL. */
actifSql: boolean("actif_sql").default(true).notNull(),
/** Inclus dans la rotation Qlik (beaucoup plus coûteuse). */
actifQlik: boolean("actif_qlik").default(true).notNull(),
/**
* Renseigné quand le planificateur a désactivé le fournisseur de lui-même —
* typiquement « aucun article ». Évite de réessayer chaque nuit un
* fournisseur vide, tout en gardant la raison visible pour le réactiver.
*/
desactiveMotif: text("desactive_motif"),
dernierSqlAt: timestamp("dernier_sql_at"),
/** 'succes' | 'echec' | 'vide' */
dernierSqlStatut: varchar("dernier_sql_statut", { length: 20 }),
dernierSqlLignes: integer("dernier_sql_lignes"),
dernierSqlErreur: text("dernier_sql_erreur"),
dernierSqlMs: integer("dernier_sql_ms"),
dernierQlikAt: timestamp("dernier_qlik_at"),
dernierQlikStatut: varchar("dernier_qlik_statut", { length: 20 }),
dernierQlikCodes: integer("dernier_qlik_codes"),
dernierQlikErreur: text("dernier_qlik_erreur"),
dernierQlikMs: integer("dernier_qlik_ms"),
updatedAt: timestamp("updated_at").defaultNow(),
}, (table) => {
return [
// Index de file d'attente : « le plus ancien d'abord », par source.
index("idx_sync_fou_sql").on(table.actifSql, table.dernierSqlAt),
index("idx_sync_fou_qlik").on(table.actifQlik, table.dernierQlikAt),
];
});
/** AI Context rules per supplier (Epic: AI Context) */
export const aiSupplierContext = pgTable("ai_supplier_context", {
/** Supplier code serving as the primary key */
codeFournisseur: varchar("code_fournisseur", { length: 20 }).primaryKey(),
/** The business rules text provided by the user */
context: text("context").notNull(),
/** When it was last updated */
updatedAt: timestamp("updated_at").defaultNow(),
});