mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 21:48:33 +02:00
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
286 lines
13 KiB
TypeScript
286 lines
13 KiB
TypeScript
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(),
|
||
});
|