mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
feat(api): API CollectFlow /api/v1 — lecture de la grille et recherche
Les données de la grille (ventes 12 mois, stock, marges, gammes, métriques réseau Qlik) n'étaient accessibles par aucun moyen programmatique, et getProductRows() exige un fournisseur : chercher un produit sans le connaître était impossible. Contrainte de conception : l'API ne recalcule jamais rien. La grille était reconstruite en direct et gardée seulement 10 min en mémoire ; une API qui appellerait getProductRows() serait lente et imprévisible. On persiste donc le résultat d'un calcul qui a déjà lieu, et on le sert. - Table grid_rows : colonnes scalaires (filtre/tri/recherche en SQL) + payload jsonb du ProductRow complet. Remplie en effet de bord NON bloquant par getProductRows(), purge des articles disparus via computed_at. Survit aux redémarrages, contrairement au cache mémoire. - Endpoints /api/v1 : fournisseurs, grid, products/search (transversale, tous fournisseurs), products/:codein, network/:codeCentrale, openapi.json. Pagination, tri sur liste blanche, projection de champs, validation zod. 202 not_ready si un fournisseur n'a pas encore d'instantané. - Authentification double : clé d'API (X-API-Key ou Bearer, SHA-256 en base, révocable) ou session existante. Le middleware exempte /api/v1 — sans quoi un script recevait une redirection 302 vers /login au lieu d'un 401 JSON. - Gestion des clés dans /settings (server actions, clé affichée une seule fois). Vérifié contre une base PostgreSQL locale : 401 JSON sans clé, 401 sur clé révoquée, recherche renvoyant plusieurs fournisseurs, upsert + purge, et 24 appels /api/v1 sans déclencher un seul recalcul (l'ancienne route /api/grid/rows en déclenche un à chaque appel). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
17 files changed
+1509
No files matched your search
@@ -129,6 +129,82 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
|
||||
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"),
|
||||
});
|
||||
|
||||
/** AI Context rules per supplier (Epic: AI Context) */
|
||||
export const aiSupplierContext = pgTable("ai_supplier_context", {
|
||||
/** Supplier code serving as the primary key */
|
||||
|
||||
Reference in new issue
Block a user