mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 18:58:17 +02:00
Interface : passage de Next.js à une SPA React (Vite + React Router)
Le serveur étant réécrit en Rust, l'interface devient une application
React 19 côté navigateur, compilée par Vite dans dist/ et servie par le
serveur Rust (STATIC_DIR). Composants, styles et logique cliente
inchangés : seule la plomberie Next.js est remplacée.
- Outillage : Vite 8, @vitejs/plugin-react, Tailwind 4 via PostCSS,
React Router 7 (mode déclaratif), index.html, src/main.tsx, alias @/,
proxy /api → localhost:5643 en développement, ESLint sans plugin Next.
- Adaptations (src/shims) : next/link, next/navigation (useRouter avec
refresh, usePathname/useSearchParams suivant aussi les replaceState de
l'interface), next-auth/react (session lue sur /api/auth/session,
déconnexion par /api/auth/logout).
- Actions serveur → POST /api/actions/{nom} (src/lib/rpc.ts) : mêmes noms,
mêmes signatures, mêmes types. La connexion passe par /api/auth/login.
- Pages → table des routes (src/router.tsx) : données lues sur
/api/pages/{nom} avec Suspense (loading.tsx au premier chargement,
page conservée pendant une navigation ou un router.refresh()),
error.tsx en frontière d'erreur, not-found.tsx, titres d'onglet,
pages d'administration réservées aux administrateurs.
- Suppression du code serveur Next.js (routes /api, accès base, Qlik,
authentification, middleware…) ; types utiles à l'interface recopiés
dans src/types/ff.ts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
170 files changed
+2528
-24445
No files matched your search
@@ -1,19 +1,14 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* CollectFlow — Gestion des clés de l'API `/api/v1` (administrateurs).
|
||||
*
|
||||
* La clé en clair n'existe qu'une fois : elle est renvoyée par `createApiKey()` et
|
||||
* jamais restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
|
||||
* conserve sa ligne, pour garder la trace de son usage passé.
|
||||
*
|
||||
* Actions exécutées par le serveur Rust : `POST /api/actions/{nom}`.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { requireAdmin } from "@/lib/authz";
|
||||
import { generateApiKey } from "@/lib/api-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
|
||||
export interface ApiKeyRow {
|
||||
id: number;
|
||||
@@ -28,27 +23,7 @@ export interface ApiKeyRow {
|
||||
|
||||
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
|
||||
export async function getApiKeys(): Promise<ApiKeyRow[]> {
|
||||
await requireAdmin();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
keyPrefix: apiKeys.keyPrefix,
|
||||
role: apiKeys.role,
|
||||
createdBy: apiKeys.createdBy,
|
||||
createdAt: apiKeys.createdAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
revokedAt: apiKeys.revokedAt,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.orderBy(desc(apiKeys.createdAt));
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
createdAt: r.createdAt ? r.createdAt.toISOString() : null,
|
||||
lastUsedAt: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
|
||||
revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null,
|
||||
}));
|
||||
return callAction("getApiKeys", []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,51 +34,15 @@ export async function createApiKey(
|
||||
name: string,
|
||||
role: "admin" | "user" = "user",
|
||||
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
|
||||
const moi = await requireAdmin();
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
|
||||
if (trimmed.length > 100) return { success: false, error: "Nom trop long (100 caractères max)." };
|
||||
|
||||
try {
|
||||
const { key, keyHash, keyPrefix } = generateApiKey();
|
||||
await db.insert(apiKeys).values({
|
||||
name: trimmed,
|
||||
keyPrefix,
|
||||
keyHash,
|
||||
role,
|
||||
createdBy: moi.username || null,
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
return { success: true, key, keyPrefix };
|
||||
} catch (err) {
|
||||
console.error("[api-keys] création KO:", err);
|
||||
return { success: false, error: "Erreur technique lors de la création de la clé." };
|
||||
}
|
||||
return callAction("createApiKey", [name, role]);
|
||||
}
|
||||
|
||||
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
|
||||
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("[api-keys] révocation KO:", err);
|
||||
return { success: false, error: "Erreur lors de la révocation." };
|
||||
}
|
||||
return callAction("revokeApiKey", [id]);
|
||||
}
|
||||
|
||||
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
|
||||
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("[api-keys] suppression KO:", err);
|
||||
return { success: false, error: "Erreur lors de la suppression." };
|
||||
}
|
||||
return callAction("deleteApiKey", [id]);
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
/**
|
||||
* CollectFlow — Synchronisation nocturne des fournisseurs.
|
||||
*
|
||||
* Deux sources aux coûts très différents, donc deux files distinctes :
|
||||
* • **SQL** — l'instantané de grille (`getProductRows`), ~43 s sur le plus gros
|
||||
* fournisseur du catalogue. Alimente la Grille et `/api/v1`.
|
||||
* • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un
|
||||
* serveur externe qui abandonne quand on le sollicite trop. Ce sont des
|
||||
* agrégats mensuels : les rafraîchir chaque nuit n'apporterait rien.
|
||||
*
|
||||
* ORDRE DE PASSAGE — le point de conception central. La file est simplement
|
||||
* « le plus ancien d'abord », en ignorant ceux déjà traités depuis l'ouverture
|
||||
* de la fenêtre courante. Il en découle, sans curseur ni compteur de tour :
|
||||
* • la nuit 2 reprend là où la nuit 1 s'est arrêtée (les non-traités sont les
|
||||
* plus anciens) ;
|
||||
* • quand plus personne n'est en attente, le tour est fini ; la nuit suivante
|
||||
* tout le monde redevient éligible et le tour recommence ;
|
||||
* • un fournisseur en échec garde une date ancienne, donc il repasse en tête —
|
||||
* le rattrapage est automatique.
|
||||
*
|
||||
* La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie
|
||||
* « ne plus en **démarrer** après l'heure de fin ».
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, eq, isNull, lt, or, sql, type SQL } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { syncFournisseurs } from "@/db/schema";
|
||||
import { getProductRows } from "@/features/grid/api/get-product-rows";
|
||||
import { pgGetFournisseurs, pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings";
|
||||
import { invalidateFFCache } from "@/lib/ff-cache";
|
||||
|
||||
export type { SyncSettings };
|
||||
|
||||
// ─── État courant, en mémoire ────────────────────────────────────────────────
|
||||
|
||||
export interface SyncSchedulerState {
|
||||
enCours: boolean;
|
||||
/** "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin. */
|
||||
origine: "auto" | "manuel" | null;
|
||||
debutAt: string | null;
|
||||
finAt: string | null;
|
||||
fournisseurCourant: string | null;
|
||||
etape: "sql" | "qlik" | null;
|
||||
sqlFaits: number;
|
||||
sqlEchecs: number;
|
||||
qlikFaits: number;
|
||||
qlikEchecs: number;
|
||||
/** Renseigné quand la file s'est vidée : le tour est bouclé. */
|
||||
tourTermine: boolean;
|
||||
arretDemande: boolean;
|
||||
derniereErreur: string | null;
|
||||
}
|
||||
|
||||
const etat: SyncSchedulerState = {
|
||||
enCours: false, origine: null, debutAt: null, finAt: null,
|
||||
fournisseurCourant: null, etape: null,
|
||||
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
|
||||
tourTermine: false, arretDemande: false, derniereErreur: null,
|
||||
};
|
||||
|
||||
export function getSyncSchedulerState(): SyncSchedulerState {
|
||||
return { ...etat };
|
||||
}
|
||||
|
||||
export function demanderArret(): void {
|
||||
if (etat.enCours) etat.arretDemande = true;
|
||||
}
|
||||
|
||||
// ─── Fenêtre horaire ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Début de la fenêtre en cours, ou `null` si l'heure courante est en dehors.
|
||||
*
|
||||
* Gère le passage par minuit (ex. 22h → 5h) : dans ce cas, entre 0h et l'heure
|
||||
* de fin, la fenêtre a commencé **la veille**.
|
||||
*/
|
||||
export function debutFenetre(s: SyncSettings, maintenant = new Date()): Date | null {
|
||||
const h = maintenant.getHours();
|
||||
const debutDuJour = new Date(maintenant);
|
||||
debutDuJour.setHours(s.heureDebut, 0, 0, 0);
|
||||
|
||||
if (s.heureDebut < s.heureFin) {
|
||||
return h >= s.heureDebut && h < s.heureFin ? debutDuJour : null;
|
||||
}
|
||||
// Fenêtre à cheval sur minuit.
|
||||
if (h >= s.heureDebut) return debutDuJour;
|
||||
if (h < s.heureFin) {
|
||||
const veille = new Date(debutDuJour);
|
||||
veille.setDate(veille.getDate() - 1);
|
||||
return veille;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Référentiel des fournisseurs ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux
|
||||
* fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien —
|
||||
* l'historique de synchronisation d'un fournisseur disparu reste consultable.
|
||||
*/
|
||||
export async function seedSyncFournisseurs(): Promise<number> {
|
||||
const fournisseurs = await pgGetFournisseurs();
|
||||
if (fournisseurs.length === 0) return 0;
|
||||
|
||||
const CHUNK = 500;
|
||||
for (let i = 0; i < fournisseurs.length; i += CHUNK) {
|
||||
const lot = fournisseurs.slice(i, i + CHUNK);
|
||||
await db
|
||||
.insert(syncFournisseurs)
|
||||
.values(lot.map((f) => ({ codeFournisseur: f.code, nomFournisseur: f.nom })))
|
||||
.onConflictDoUpdate({
|
||||
target: syncFournisseurs.codeFournisseur,
|
||||
// Seul le nom est réaligné : les choix d'activation appartiennent à l'admin.
|
||||
set: { nomFournisseur: sql`excluded.nom_fournisseur` },
|
||||
});
|
||||
}
|
||||
return fournisseurs.length;
|
||||
}
|
||||
|
||||
// ─── Files d'attente ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre. */
|
||||
async function prochainSql(depuis: Date) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(syncFournisseurs)
|
||||
.where(and(
|
||||
eq(syncFournisseurs.actifSql, true),
|
||||
or(isNull(syncFournisseurs.dernierSqlAt), lt(syncFournisseurs.dernierSqlAt, depuis)) as SQL,
|
||||
))
|
||||
.orderBy(asc(sql`${syncFournisseurs.dernierSqlAt} nulls first`))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Délai avant de retenter un fournisseur Qlik en échec : la nuit suivante. */
|
||||
const QLIK_REPRISE_ECHEC_MS = 18 * 3600 * 1000;
|
||||
|
||||
/**
|
||||
* Le plus ancien fournisseur Qlik à rafraîchir. Il l'est si :
|
||||
* - ses données dépassent `qlikMinJours` (agrégats mensuels : inutile de
|
||||
* relancer chaque nuit une extraction de plusieurs minutes) ;
|
||||
* - ou elles datent d'avant le mois en cours : la fenêtre de 12 mois a glissé,
|
||||
* son dernier mois manque, sa tendance n'est plus à jour ;
|
||||
* - ou sa dernière extraction a échoué il y a plus de 18 h. Un échec attendait
|
||||
* jusqu'ici `qlikMinJours` (7 jours) avant un nouvel essai, alors que la
|
||||
* plupart sont passagers (serveur Qlik chargé, dernier mois pas encore
|
||||
* chargé). Le plus ancien passant en premier, un fournisseur qui échoue à
|
||||
* chaque fois repart en fin de file et ne bloque personne.
|
||||
*/
|
||||
async function prochainQlik(qlikMinJours: number, maintenant: Date) {
|
||||
const seuilAge = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000);
|
||||
const debutMois = new Date(maintenant.getFullYear(), maintenant.getMonth(), 1);
|
||||
const seuil = seuilAge > debutMois ? seuilAge : debutMois;
|
||||
const seuilEchec = new Date(maintenant.getTime() - QLIK_REPRISE_ECHEC_MS);
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(syncFournisseurs)
|
||||
.where(and(
|
||||
eq(syncFournisseurs.actifQlik, true),
|
||||
or(
|
||||
isNull(syncFournisseurs.dernierQlikAt),
|
||||
lt(syncFournisseurs.dernierQlikAt, seuil),
|
||||
and(eq(syncFournisseurs.dernierQlikStatut, "echec"), lt(syncFournisseurs.dernierQlikAt, seuilEchec)),
|
||||
) as SQL,
|
||||
))
|
||||
.orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// ─── Traitement d'un fournisseur ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Instantané SQL. `dernierSqlAt` est renseigné **même en cas d'échec** : sans
|
||||
* cela, un fournisseur qui échoue resterait éternellement le plus ancien et
|
||||
* bloquerait la file derrière lui.
|
||||
*/
|
||||
async function traiterSql(code: string, nom: string | null): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
|
||||
etat.etape = "sql";
|
||||
try {
|
||||
const rows = await getProductRows({ codeFournisseur: code, magasin: "TOTAL", forceRefresh: true });
|
||||
const vide = rows.length === 0;
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierSqlAt: new Date(),
|
||||
dernierSqlStatut: vide ? "vide" : "succes",
|
||||
dernierSqlLignes: rows.length,
|
||||
dernierSqlErreur: null,
|
||||
dernierSqlMs: Date.now() - t0,
|
||||
// Un fournisseur sans article n'a rien à donner : on le sort des deux
|
||||
// files plutôt que de le réessayer chaque nuit. L'admin voit le motif
|
||||
// et peut le réactiver.
|
||||
...(vide ? { actifSql: false, actifQlik: false, desactiveMotif: "Aucun article — désactivé automatiquement" } : {}),
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code));
|
||||
if (vide) console.log(`[sync-nuit] ${code} sans article — désactivé automatiquement`);
|
||||
else etat.sqlFaits++;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
etat.sqlEchecs++;
|
||||
etat.derniereErreur = `${code} (SQL) : ${msg}`;
|
||||
console.error(`[sync-nuit] SQL ${code} en échec :`, msg);
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierSqlAt: new Date(),
|
||||
dernierSqlStatut: "echec",
|
||||
dernierSqlErreur: msg.slice(0, 500),
|
||||
dernierSqlMs: Date.now() - t0,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* le suivi ne doit pas casser la boucle */ });
|
||||
}
|
||||
}
|
||||
|
||||
/** Format d'un code centrale plausible — même filtre que la synchro manuelle. */
|
||||
const CODE_CENTRALE = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i;
|
||||
|
||||
/** Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec. */
|
||||
async function traiterQlik(code: string, nom: string | null): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`;
|
||||
etat.etape = "qlik";
|
||||
try {
|
||||
const articles = await pgGetArticlesByFournisseur(code);
|
||||
const codes = [...new Set(
|
||||
articles
|
||||
.map((a) => String(a.codeCentrale ?? "").trim())
|
||||
.filter((c) => c && c !== "-" && CODE_CENTRALE.test(c)),
|
||||
)];
|
||||
|
||||
if (codes.length === 0) {
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierQlikAt: new Date(), dernierQlikStatut: "vide", dernierQlikCodes: 0,
|
||||
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code));
|
||||
return;
|
||||
}
|
||||
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT));
|
||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, code);
|
||||
const n = await upsertNetworkMetrics([...metrics.values()]);
|
||||
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierQlikAt: new Date(), dernierQlikStatut: "succes", dernierQlikCodes: n,
|
||||
dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code));
|
||||
etat.qlikFaits++;
|
||||
console.log(`[sync-nuit] Qlik ${code} — ${n} codes en ${Math.round((Date.now() - t0) / 1000)} s`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
etat.qlikEchecs++;
|
||||
etat.derniereErreur = `${code} (Qlik) : ${msg}`;
|
||||
console.error(`[sync-nuit] Qlik ${code} en échec :`, msg);
|
||||
await db.update(syncFournisseurs).set({
|
||||
dernierQlikAt: new Date(), dernierQlikStatut: "echec",
|
||||
dernierQlikErreur: msg.slice(0, 500), dernierQlikMs: Date.now() - t0, updatedAt: new Date(),
|
||||
}).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* idem */ });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Boucle de travail ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt.
|
||||
*
|
||||
* Les deux files avancent en parallèle : une extraction Qlik est intercalée
|
||||
* tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne
|
||||
* démarrerait jamais tant que la file SQL n'est pas vide.
|
||||
*/
|
||||
export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre = false): Promise<void> {
|
||||
if (etat.enCours) return;
|
||||
|
||||
const s = await readSyncSettings();
|
||||
const maintenant = new Date();
|
||||
const debut = debutFenetre(s, maintenant);
|
||||
if (!forcerHorsFenetre && !debut) return;
|
||||
// Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ».
|
||||
const depuis = debut ?? new Date(new Date(maintenant).setHours(0, 0, 0, 0));
|
||||
|
||||
Object.assign(etat, {
|
||||
enCours: true, origine, debutAt: maintenant.toISOString(), finAt: null,
|
||||
fournisseurCourant: null, etape: null,
|
||||
sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0,
|
||||
tourTermine: false, arretDemande: false, derniereErreur: null,
|
||||
});
|
||||
console.log(`[sync-nuit] round ${origine} démarré (fenêtre ${s.heureDebut}h-${s.heureFin}h, Qlik ≤ ${s.qlikParNuit})`);
|
||||
|
||||
try {
|
||||
await seedSyncFournisseurs();
|
||||
|
||||
// Garde anti-boucle. L'avancement de la file repose sur l'écriture de
|
||||
// `dernier*At`, et cette écriture est volontairement non bloquante en cas
|
||||
// d'erreur. Si elle échoue, le fournisseur resterait le plus ancien et
|
||||
// serait repris sans fin : on retient donc ce qui a déjà été tenté.
|
||||
const tentes = new Set<string>();
|
||||
|
||||
let depuisDernierQlik = 0;
|
||||
for (;;) {
|
||||
if (etat.arretDemande) break;
|
||||
if (!forcerHorsFenetre && !debutFenetre(s, new Date())) {
|
||||
console.log("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours");
|
||||
break;
|
||||
}
|
||||
|
||||
const qlikPossible = etat.qlikFaits + etat.qlikEchecs < s.qlikParNuit;
|
||||
|
||||
// Qlik prioritaire quand le quota d'entrelacement est atteint.
|
||||
if (qlikPossible && depuisDernierQlik >= s.sqlParQlik) {
|
||||
const cible = await prochainQlik(s.qlikMinJours, new Date());
|
||||
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
|
||||
tentes.add(`qlik:${cible.codeFournisseur}`);
|
||||
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
|
||||
depuisDernierQlik = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const cibleSql = await prochainSql(depuis);
|
||||
if (cibleSql) {
|
||||
if (tentes.has(`sql:${cibleSql.codeFournisseur}`)) {
|
||||
console.error(
|
||||
`[sync-nuit] ${cibleSql.codeFournisseur} ressort de la file après avoir été traité :`
|
||||
+ " le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
tentes.add(`sql:${cibleSql.codeFournisseur}`);
|
||||
await traiterSql(cibleSql.codeFournisseur, cibleSql.nomFournisseur);
|
||||
depuisDernierQlik++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// File SQL vide : on consacre le temps restant à Qlik.
|
||||
if (qlikPossible) {
|
||||
const cible = await prochainQlik(s.qlikMinJours, new Date());
|
||||
if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) {
|
||||
tentes.add(`qlik:${cible.codeFournisseur}`);
|
||||
await traiterQlik(cible.codeFournisseur, cible.nomFournisseur);
|
||||
depuisDernierQlik = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
etat.tourTermine = true;
|
||||
console.log("[sync-nuit] plus rien en attente — tour terminé");
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
etat.derniereErreur = e instanceof Error ? e.message : String(e);
|
||||
console.error("[sync-nuit] round interrompu :", etat.derniereErreur);
|
||||
} finally {
|
||||
// Les pages (stock, hit-parade, CA…) relisent la base fraîche au lieu
|
||||
// d'attendre la fin de vie de leur cache.
|
||||
invalidateFFCache();
|
||||
etat.enCours = false;
|
||||
etat.fournisseurCourant = null;
|
||||
etat.etape = null;
|
||||
etat.finAt = new Date().toISOString();
|
||||
console.log(
|
||||
`[sync-nuit] round terminé — SQL ${etat.sqlFaits} ok / ${etat.sqlEchecs} en échec,`
|
||||
+ ` Qlik ${etat.qlikFaits} ok / ${etat.qlikEchecs} en échec, tour bouclé=${etat.tourTermine}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais
|
||||
* deux rounds à la fois. Volontairement silencieux hors fenêtre — il est appelé
|
||||
* chaque minute.
|
||||
*/
|
||||
export async function tickScheduler(): Promise<void> {
|
||||
try {
|
||||
const s = await readSyncSettings();
|
||||
if (!s.actif || etat.enCours) return;
|
||||
if (!debutFenetre(s)) return;
|
||||
await lancerRound("auto");
|
||||
} catch (e) {
|
||||
console.error("[sync-nuit] tick en erreur :", e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* CollectFlow — Réglages de la synchronisation nocturne.
|
||||
*
|
||||
* Rangés dans `data/.db-config.json`, comme les autres réglages de
|
||||
* l'application (base, Qlik, API FF), pour qu'il n'y ait qu'un seul endroit à
|
||||
* sauvegarder et à monter dans le conteneur.
|
||||
*
|
||||
* Module **serveur**, pas fichier d'actions : il exporte aussi des valeurs
|
||||
* (les défauts, le type), ce que `"use server"` interdit.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
||||
|
||||
export interface SyncSettings {
|
||||
/**
|
||||
* Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
|
||||
* Qlik et la base la nuit suivante sans que personne l'ait décidé.
|
||||
*/
|
||||
actif: boolean;
|
||||
/** Heure d'ouverture de la fenêtre (0-23). */
|
||||
heureDebut: number;
|
||||
/** Heure de fermeture. Aucun fournisseur n'est **démarré** après. */
|
||||
heureFin: number;
|
||||
/**
|
||||
* Nombre maximum d'extractions Qlik par nuit. Chacune peut prendre 8 minutes
|
||||
* et le serveur abandonne quand il est trop sollicité : ce plafond est la
|
||||
* principale protection contre une nuit entièrement passée sur Qlik.
|
||||
*/
|
||||
qlikParNuit: number;
|
||||
/** Une extraction Qlik est intercalée tous les N fournisseurs SQL. */
|
||||
sqlParQlik: number;
|
||||
/**
|
||||
* Âge minimal des données Qlik avant de les refaire. Ce sont des agrégats
|
||||
* mensuels : les rafraîchir toutes les nuits coûterait cher pour rien.
|
||||
*/
|
||||
qlikMinJours: number;
|
||||
}
|
||||
|
||||
export const SYNC_SETTINGS_DEFAUT: SyncSettings = {
|
||||
actif: false,
|
||||
heureDebut: 0,
|
||||
heureFin: 5,
|
||||
qlikParNuit: 6,
|
||||
sqlParQlik: 20,
|
||||
qlikMinJours: 7,
|
||||
};
|
||||
|
||||
function borne(v: unknown, min: number, max: number, defaut: number): number {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return defaut;
|
||||
return Math.min(max, Math.max(min, Math.trunc(n)));
|
||||
}
|
||||
|
||||
/** Normalise ce qui vient du fichier : il est éditable à la main. */
|
||||
function normaliser(brut: Partial<SyncSettings> | undefined): SyncSettings {
|
||||
const d = SYNC_SETTINGS_DEFAUT;
|
||||
const s: SyncSettings = {
|
||||
actif: Boolean(brut?.actif),
|
||||
heureDebut: borne(brut?.heureDebut, 0, 23, d.heureDebut),
|
||||
heureFin: borne(brut?.heureFin, 0, 23, d.heureFin),
|
||||
qlikParNuit: borne(brut?.qlikParNuit, 0, 200, d.qlikParNuit),
|
||||
sqlParQlik: borne(brut?.sqlParQlik, 1, 500, d.sqlParQlik),
|
||||
qlikMinJours: borne(brut?.qlikMinJours, 0, 365, d.qlikMinJours),
|
||||
};
|
||||
// Une fenêtre de durée nulle ne s'ouvrirait jamais : on revient au défaut
|
||||
// plutôt que de laisser le planificateur muet sans explication.
|
||||
if (s.heureDebut === s.heureFin) {
|
||||
s.heureDebut = d.heureDebut;
|
||||
s.heureFin = d.heureFin;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function readSyncSettings(): Promise<SyncSettings> {
|
||||
try {
|
||||
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
const cfg = JSON.parse(raw) as { syncNuit?: Partial<SyncSettings> };
|
||||
return normaliser(cfg?.syncNuit);
|
||||
} catch {
|
||||
return { ...SYNC_SETTINGS_DEFAUT };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSyncSettings(patch: Partial<SyncSettings>): Promise<SyncSettings> {
|
||||
let existant: Record<string, unknown> = {};
|
||||
try {
|
||||
existant = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8"));
|
||||
} catch {
|
||||
// Pas encore de fichier : on le crée avec les seuls réglages de synchro.
|
||||
}
|
||||
const courant = normaliser((existant.syncNuit as Partial<SyncSettings>) ?? undefined);
|
||||
const suivant = normaliser({ ...courant, ...patch });
|
||||
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existant, syncNuit: suivant }, null, 2));
|
||||
console.log(`[sync-nuit] réglages enregistrés : ${JSON.stringify(suivant)}`);
|
||||
return suivant;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* CollectFlow — Minuteur de la synchronisation nocturne.
|
||||
*
|
||||
* Démarré une fois au boot par `src/instrumentation.ts`. Il bat chaque minute
|
||||
* et laisse `tickScheduler()` décider s'il y a quelque chose à faire : hors
|
||||
* fenêtre, ou si un round tourne déjà, le battement ne coûte rien.
|
||||
*
|
||||
* Conséquence assumée du choix « minuteur interne » plutôt que cron système :
|
||||
* rien ne tourne si l'application est arrêtée. En contrepartie, aucune
|
||||
* configuration côté hôte, et le minuteur se réarme tout seul au redémarrage.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { tickScheduler } from "@/features/admin/api/sync-scheduler";
|
||||
|
||||
const INTERVALLE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Garde au niveau du module : en développement, le rechargement à chaud peut
|
||||
* réévaluer `register()` plusieurs fois, et on se retrouverait avec autant de
|
||||
* minuteurs empilés.
|
||||
*/
|
||||
let minuteur: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startSyncTimer(): void {
|
||||
if (minuteur) return;
|
||||
|
||||
console.log(`[sync-nuit] minuteur démarré (battement ${INTERVALLE_MS / 1000} s)`);
|
||||
minuteur = setInterval(() => {
|
||||
// `tickScheduler` capture déjà ses propres erreurs ; ce `catch` ne couvre
|
||||
// qu'un rejet inattendu, pour qu'il ne devienne jamais une exception non
|
||||
// gérée capable d'abattre le processus.
|
||||
void tickScheduler().catch((e) => {
|
||||
console.error("[sync-nuit] battement en erreur :", e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
}, INTERVALLE_MS);
|
||||
|
||||
// Ne pas maintenir le processus en vie uniquement pour ce minuteur.
|
||||
minuteur.unref?.();
|
||||
}
|
||||
@@ -1,74 +1,46 @@
|
||||
"use server";
|
||||
/**
|
||||
* Gestion des comptes (administrateurs) — actions exécutées par le serveur
|
||||
* Rust : `POST /api/actions/{nom}`.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hashPassword } from "@/features/auth/logic/auth-logic";
|
||||
import { requireAdmin } from "@/lib/authz";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
|
||||
export interface UtilisateurListe {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
/** Date ISO de création. */
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les utilisateurs (Admin seulement).
|
||||
*/
|
||||
export async function getUsers() {
|
||||
await requireAdmin();
|
||||
return db.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt
|
||||
}).from(users);
|
||||
export async function getUsers(): Promise<UtilisateurListe[]> {
|
||||
return callAction("getUsers", []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un nouvel utilisateur.
|
||||
*/
|
||||
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
|
||||
await requireAdmin();
|
||||
|
||||
try {
|
||||
await db.insert(users).values({
|
||||
username,
|
||||
passwordHash: hashPassword(password),
|
||||
role,
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("CreateUser Error:", err);
|
||||
return { success: false, error: "L'utilisateur existe déjà ou une erreur technique est survenue." };
|
||||
}
|
||||
export async function createUser(
|
||||
username: string,
|
||||
password: string,
|
||||
role: "admin" | "user" = "user",
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return callAction("createUser", [username, password, role]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un utilisateur.
|
||||
*/
|
||||
export async function deleteUser(id: number) {
|
||||
const moi = await requireAdmin();
|
||||
if (Number(moi.id) === id) {
|
||||
return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." };
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
revalidatePath("/settings");
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Erreur lors de la suppression." };
|
||||
}
|
||||
export async function deleteUser(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
return callAction("deleteUser", [id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le mot de passe d'un utilisateur.
|
||||
*/
|
||||
export async function updatePassword(id: number, newPassword: string) {
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.update(users)
|
||||
.set({ passwordHash: hashPassword(newPassword) })
|
||||
.where(eq(users.id, id));
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Erreur lors de la mise à jour du mot de passe." };
|
||||
}
|
||||
export async function updatePassword(id: number, newPassword: string): Promise<{ success: boolean; error?: string }> {
|
||||
return callAction("updatePassword", [id, newPassword]);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Informations de connexion à l'API `/api/v1`.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Gestion des clés de l'API `/api/v1`.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Préchauffage des données exposées par l'API.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { KeyRound, Loader2, Shield, Trash2, User, UserPlus } from "lucide-react";
|
||||
import { getUsers, createUser, deleteUser, updatePassword } from "../api/user-actions";
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/lib/auth";
|
||||
import { AuthError } from "next-auth";
|
||||
|
||||
/**
|
||||
* Server action to handle user login.
|
||||
*/
|
||||
export async function loginAction(prevState: string | undefined, formData: FormData) {
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
username: formData.get("username"),
|
||||
password: formData.get("password"),
|
||||
redirectTo: "/dashboard"
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
switch (error.type) {
|
||||
case "CredentialsSignin":
|
||||
return "Utilisateur ou mot de passe incorrect.";
|
||||
default:
|
||||
return "Une erreur technique est survenue.";
|
||||
}
|
||||
}
|
||||
// Next.js redirect throws a special error that must be rethrown
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { loginAction } from "../api/login-action";
|
||||
import { AlertCircle, Loader2, Lock, ShieldCheck, User } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/form-controls";
|
||||
|
||||
const MESSAGE_IDENTIFIANTS = "Utilisateur ou mot de passe incorrect.";
|
||||
const MESSAGE_TECHNIQUE = "Une erreur technique est survenue.";
|
||||
|
||||
/**
|
||||
* Connexion : `POST /api/auth/login` (le serveur pose le cookie de session),
|
||||
* puis chargement complet du tableau de bord pour repartir avec la session.
|
||||
* Renvoie le message d'erreur à afficher, ou ne rend jamais la main en cas de
|
||||
* réussite (le bouton reste « Connexion… » jusqu'au changement de page).
|
||||
*/
|
||||
async function loginAction(_prevState: string | undefined, formData: FormData): Promise<string | undefined> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
username: String(formData.get("username") ?? ""),
|
||||
password: String(formData.get("password") ?? ""),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
return MESSAGE_TECHNIQUE;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: unknown } | null;
|
||||
if (typeof body?.error === "string" && body.error) return body.error;
|
||||
return res.status === 401 ? MESSAGE_IDENTIFIANTS : MESSAGE_TECHNIQUE;
|
||||
}
|
||||
await res.text().catch(() => "");
|
||||
window.location.assign("/dashboard");
|
||||
return new Promise<undefined>(() => {});
|
||||
}
|
||||
|
||||
export function LoginForm() {
|
||||
const [error, action, isPending] = useActionState(loginAction, undefined);
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { scryptSync, randomBytes, timingSafeEqual } from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const USERS_JSON_PATH = path.join(process.cwd(), "data", "users.json");
|
||||
|
||||
export interface UserJson {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes a password using scrypt.
|
||||
* Format: salt:hash
|
||||
*/
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(password, salt, 64).toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a password against a stored salt:hash string.
|
||||
*/
|
||||
export function verifyPassword(password: string, storedHash: string): boolean {
|
||||
try {
|
||||
const [salt, hash] = storedHash.split(":");
|
||||
if (!salt || !hash) return false;
|
||||
|
||||
const hashBuffer = scryptSync(password, salt, 64);
|
||||
const storedHashBuffer = Buffer.from(hash, "hex");
|
||||
|
||||
return timingSafeEqual(hashBuffer, storedHashBuffer);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads users from local JSON fallback.
|
||||
*/
|
||||
export function getFallbackUsers(): UserJson[] {
|
||||
try {
|
||||
if (!fs.existsSync(USERS_JSON_PATH)) {
|
||||
// Initial seed if file doesn't exist
|
||||
const defaultAdmin: UserJson = {
|
||||
id: "0",
|
||||
username: "admin",
|
||||
passwordHash: hashPassword("admin"),
|
||||
role: "admin"
|
||||
};
|
||||
saveFallbackUsers([defaultAdmin]);
|
||||
return [defaultAdmin];
|
||||
}
|
||||
const data = fs.readFileSync(USERS_JSON_PATH, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error("[AUTH] Error reading users.json:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves users to local JSON fallback.
|
||||
*/
|
||||
export function saveFallbackUsers(users: UserJson[]) {
|
||||
try {
|
||||
const dir = path.dirname(USERS_JSON_PATH);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(USERS_JSON_PATH, JSON.stringify(users, null, 2));
|
||||
} catch (e) {
|
||||
console.error("[AUTH] Error writing users.json:", e);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
/** Amorçage en cours ou réussi pour ce processus (remis à null en cas d'échec). */
|
||||
let amorcage: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* Prépare les comptes au premier démarrage, une seule fois par processus.
|
||||
*
|
||||
* - Base joignable : crée la table `users` si besoin (auto-réparation), puis le
|
||||
* compte admin/admin **uniquement si la table est vide**. Un admin supprimé ou
|
||||
* renommé n'est donc pas recréé à chaque connexion. Le fichier de secours
|
||||
* `data/users.json` n'est pas touché.
|
||||
* - Base injoignable (première installation, base pas encore configurée) : on
|
||||
* s'assure que le fichier de secours contient un administrateur, et on
|
||||
* retentera la base à la prochaine connexion.
|
||||
*
|
||||
* Renvoie true si le compte admin/admin vient d'être créé en base.
|
||||
*/
|
||||
export function ensureAdminExists(): Promise<boolean> {
|
||||
if (!amorcage) {
|
||||
amorcage = amorcer().catch((err: unknown) => {
|
||||
// Échec : on retentera à la prochaine connexion.
|
||||
amorcage = null;
|
||||
console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", (err as Error)?.message ?? err);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return amorcage;
|
||||
}
|
||||
|
||||
async function amorcer(): Promise<boolean> {
|
||||
console.log("[AUTH] Testing database connection for seeding...");
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
} catch (err) {
|
||||
assurerAdminDeSecours();
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Auto-repair: Ensure table exists
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
const [{ nb }] = await db.select({ nb: sql<number>`count(*)::int` }).from(users);
|
||||
if (nb > 0) return false;
|
||||
|
||||
console.log("[AUTH] Table users vide : création du compte admin/admin.");
|
||||
// Si un autre processus l'a créé entre-temps, l'unicité du nom fait échouer
|
||||
// l'insertion : on la laisse sans effet plutôt que de relancer l'amorçage.
|
||||
const crees = await db.insert(users).values({
|
||||
username: "admin",
|
||||
passwordHash: hashPassword("admin"),
|
||||
role: "admin",
|
||||
}).onConflictDoNothing().returning({ id: users.id });
|
||||
return crees.length > 0;
|
||||
}
|
||||
|
||||
/** Base injoignable : le fichier de secours doit contenir au moins un administrateur. */
|
||||
function assurerAdminDeSecours() {
|
||||
const fallbackUsers = getFallbackUsers();
|
||||
if (fallbackUsers.length === 0) {
|
||||
console.log("[AUTH] Initializing local JSON fallback with admin/admin...");
|
||||
saveFallbackUsers([{
|
||||
id: "0",
|
||||
username: "admin",
|
||||
passwordHash: hashPassword("admin"),
|
||||
role: "admin"
|
||||
}]);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
"use server";
|
||||
/**
|
||||
* Cadencier des commandes fournisseurs — actions exécutées par le serveur
|
||||
* Rust : `POST /api/actions/{nom}`.
|
||||
*/
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { commandeCadences } from "@/db/schema";
|
||||
import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache";
|
||||
import { requireSession, verifierSession } from "@/lib/authz";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
import { refreshPage } from "@/lib/page-data";
|
||||
|
||||
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
|
||||
|
||||
@@ -25,21 +24,18 @@ export interface CadenceView {
|
||||
statut: CadenceStatut;
|
||||
}
|
||||
|
||||
const MS_PAR_JOUR = 24 * 60 * 60 * 1000;
|
||||
/** Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander" */
|
||||
const SEUIL_BIENTOT_JOURS = 7;
|
||||
|
||||
function calcStatut(joursRestants: number | null): CadenceStatut {
|
||||
if (joursRestants === null) return "inconnu";
|
||||
if (joursRestants <= 0) return "a_commander";
|
||||
if (joursRestants <= SEUIL_BIENTOT_JOURS) return "bientot";
|
||||
return "ok";
|
||||
/**
|
||||
* Les actions Next.js de modification appelaient `revalidatePath("/commandes-auto")`,
|
||||
* dont la réponse rafraîchissait la page : même effet ici après une réussite.
|
||||
*/
|
||||
function rafraichirSiOk<R extends { ok: boolean }>(resultat: R): R {
|
||||
if (resultat.ok) refreshPage();
|
||||
return resultat;
|
||||
}
|
||||
|
||||
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
|
||||
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
|
||||
await requireSession();
|
||||
return getFournisseursCached();
|
||||
return callAction("getFournisseursPourCadence", []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,45 +43,7 @@ export async function getFournisseursPourCadence(): Promise<{ code: string; nom:
|
||||
* de la dernière réception (FF Nancy).
|
||||
*/
|
||||
export async function listCadences(): Promise<CadenceView[]> {
|
||||
await requireSession();
|
||||
let rows: typeof commandeCadences.$inferSelect[] = [];
|
||||
let receptions = new Map<string, string>();
|
||||
try {
|
||||
rows = await db.select().from(commandeCadences);
|
||||
// Réceptions des seuls fournisseurs du cadencier : la lecture sur tout
|
||||
// l'historique de tous les fournisseurs était la plus lourde de la page.
|
||||
receptions = await getDerniereReceptionCached(rows.map((r) => r.codeFournisseur));
|
||||
} catch (e) {
|
||||
console.error("[cadence] listCadences error:", (e as Error).message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return rows.map((r) => {
|
||||
const receptionIso = receptions.get(`${r.codeFournisseur}|${r.site}`) ?? null;
|
||||
|
||||
let echeanceIso: string | null = null;
|
||||
let joursRestants: number | null = null;
|
||||
if (receptionIso) {
|
||||
const echeance = new Date(receptionIso).getTime() + r.intervalleSemaines * 7 * MS_PAR_JOUR;
|
||||
echeanceIso = new Date(echeance).toISOString();
|
||||
joursRestants = Math.ceil((echeance - now) / MS_PAR_JOUR);
|
||||
}
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
codefou: r.codeFournisseur,
|
||||
nomfou: r.nomFournisseur ?? r.codeFournisseur,
|
||||
site: r.site,
|
||||
intervalleSemaines: r.intervalleSemaines,
|
||||
actif: r.actif,
|
||||
derniereReception: receptionIso,
|
||||
echeance: echeanceIso,
|
||||
joursRestants,
|
||||
statut: r.actif ? calcStatut(joursRestants) : "inconnu",
|
||||
} satisfies CadenceView;
|
||||
});
|
||||
return callAction("listCadences", []);
|
||||
}
|
||||
|
||||
export interface UpsertCadenceInput {
|
||||
@@ -98,76 +56,15 @@ export interface UpsertCadenceInput {
|
||||
|
||||
/** Crée ou met à jour la cadence d'un fournisseur sur un site. */
|
||||
export async function upsertCadence(input: UpsertCadenceInput): Promise<{ ok: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { ok: false, error: acces.message };
|
||||
|
||||
const codefou = input.codefou?.trim();
|
||||
const site = input.site?.trim();
|
||||
const intervalle = Math.round(Number(input.intervalleSemaines));
|
||||
|
||||
if (!codefou || !site) return { ok: false, error: "Fournisseur et magasin requis." };
|
||||
if (!Number.isFinite(intervalle) || intervalle < 1 || intervalle > 104) {
|
||||
return { ok: false, error: "L'intervalle doit être compris entre 1 et 104 semaines." };
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(commandeCadences)
|
||||
.values({
|
||||
codeFournisseur: codefou,
|
||||
nomFournisseur: input.nomfou ?? null,
|
||||
site,
|
||||
intervalleSemaines: intervalle,
|
||||
actif: input.actif ?? true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [commandeCadences.codeFournisseur, commandeCadences.site],
|
||||
set: {
|
||||
nomFournisseur: input.nomfou ?? null,
|
||||
intervalleSemaines: intervalle,
|
||||
actif: input.actif ?? true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
revalidatePath("/commandes-auto");
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
console.error("[cadence] upsertCadence error:", (e as Error).message);
|
||||
return { ok: false, error: "Échec de l'enregistrement." };
|
||||
}
|
||||
return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("upsertCadence", [input]));
|
||||
}
|
||||
|
||||
/** Active / désactive une cadence sans la supprimer. */
|
||||
export async function toggleCadence(codefou: string, site: string, actif: boolean): Promise<{ ok: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { ok: false, error: acces.message };
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(commandeCadences)
|
||||
.set({ actif, updatedAt: new Date() })
|
||||
.where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site)));
|
||||
revalidatePath("/commandes-auto");
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
console.error("[cadence] toggleCadence error:", (e as Error).message);
|
||||
return { ok: false };
|
||||
}
|
||||
return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("toggleCadence", [codefou, site, actif]));
|
||||
}
|
||||
|
||||
/** Supprime définitivement une cadence. */
|
||||
export async function removeCadence(codefou: string, site: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { ok: false, error: acces.message };
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(commandeCadences)
|
||||
.where(and(eq(commandeCadences.codeFournisseur, codefou), eq(commandeCadences.site, site)));
|
||||
revalidatePath("/commandes-auto");
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
console.error("[cadence] removeCadence error:", (e as Error).message);
|
||||
return { ok: false };
|
||||
}
|
||||
return rafraichirSiOk(await callAction<{ ok: boolean; error?: string }>("removeCadence", [codefou, site]));
|
||||
}
|
||||
@@ -1,23 +1,20 @@
|
||||
"use server";
|
||||
/**
|
||||
* Actions de la Grille (ex-« server actions » Next.js), exécutées par le
|
||||
* serveur Rust : `POST /api/actions/{nom}`.
|
||||
*/
|
||||
|
||||
import { getSitesFromApi } from "@/lib/api-ff-client";
|
||||
import { getFournisseursCached } from "@/lib/ff-cache";
|
||||
import { requireSession } from "@/lib/authz";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
|
||||
/**
|
||||
* Get the list of all suppliers from PostgreSQL (fouadr1).
|
||||
*/
|
||||
export async function getFournisseurs() {
|
||||
await requireSession();
|
||||
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
|
||||
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
|
||||
return getFournisseursCached();
|
||||
export async function getFournisseurs(): Promise<{ code: string; nom: string }[]> {
|
||||
return callAction("getFournisseurs", []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of all stores (sites) from the FF Nancy API.
|
||||
*/
|
||||
export async function getMagasins() {
|
||||
await requireSession();
|
||||
return getSitesFromApi();
|
||||
export async function getMagasins(): Promise<{ code: string; nom: string }[]> {
|
||||
return callAction("getMagasins", []);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { patchGridRowsCache } from "./get-product-rows";
|
||||
import { updateGridRowsGamme } from "@/lib/grid-store";
|
||||
|
||||
export interface ChangementGamme {
|
||||
codein: string;
|
||||
/** Gamme avant la modification (état serveur), conservée pour l'historique. */
|
||||
codeGammeBefore: string | null;
|
||||
codeGamme: string;
|
||||
}
|
||||
|
||||
export interface EnregistrementGammes {
|
||||
codeFournisseur: string;
|
||||
nomFournisseur?: string | null;
|
||||
magasin: string;
|
||||
changes: ChangementGamme[];
|
||||
userId: number | null;
|
||||
/** Libellé du snapshot, « Draft — date » par défaut. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1.
|
||||
*
|
||||
* Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur,
|
||||
* puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`.
|
||||
* Lève une erreur si l'écriture du snapshot échoue.
|
||||
*/
|
||||
export async function enregistrerGammes(input: EnregistrementGammes): Promise<number> {
|
||||
const { codeFournisseur, nomFournisseur, magasin, changes, userId } = input;
|
||||
if (changes.length === 0) return 0;
|
||||
|
||||
// Charger le dernier snapshot existant pour merger les changements
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(sessionSnapshots)
|
||||
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
|
||||
.orderBy(desc(sessionSnapshots.createdAt))
|
||||
.limit(1);
|
||||
|
||||
const prevChanges: Record<string, { before: string | null; after: string }> =
|
||||
existing.length > 0
|
||||
? (existing[0].changes as Record<string, { before: string | null; after: string }>)
|
||||
: {};
|
||||
|
||||
// Merger les nouveaux changements par-dessus l'existant
|
||||
const mergedChanges = { ...prevChanges };
|
||||
for (const c of changes) {
|
||||
mergedChanges[c.codein] = {
|
||||
before: c.codeGammeBefore,
|
||||
after: c.codeGamme,
|
||||
};
|
||||
}
|
||||
|
||||
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
|
||||
await db.insert(sessionSnapshots).values({
|
||||
userId,
|
||||
codeFournisseur,
|
||||
nomFournisseur: nomFournisseur ?? null,
|
||||
magasin,
|
||||
label: input.label ?? `Draft — ${new Date().toLocaleDateString("fr-FR")}`,
|
||||
changes: mergedChanges,
|
||||
summaryJson: null,
|
||||
type: "snapshot",
|
||||
});
|
||||
|
||||
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
|
||||
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
|
||||
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
|
||||
patchGridRowsCache(codeFournisseur, changes);
|
||||
void updateGridRowsGamme(codeFournisseur, changes).catch((e) =>
|
||||
console.error("[enregistrerGammes] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)),
|
||||
);
|
||||
|
||||
return changes.length;
|
||||
}
|
||||
@@ -1,37 +1,21 @@
|
||||
"use server";
|
||||
/**
|
||||
* Gammes proposées par l'API `/api/v1`, en attente de validation dans la
|
||||
* Grille — actions exécutées par le serveur Rust : `POST /api/actions/{nom}`.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { listerGammesAValider, supprimerGammesAValider, type GammeAValider } from "@/lib/gammes-a-valider";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
import type { GammeAValider } from "@/types/ff";
|
||||
|
||||
export type { GammeAValider };
|
||||
|
||||
/** Gammes proposées par l'API pour un fournisseur, en attente de validation. */
|
||||
export async function getGammesAValider(
|
||||
codeFournisseur: string,
|
||||
): Promise<{ success: boolean; propositions: GammeAValider[]; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, propositions: [], error: acces.message };
|
||||
try {
|
||||
return { success: true, propositions: await listerGammesAValider(String(codeFournisseur)) };
|
||||
} catch (err) {
|
||||
return { success: false, propositions: [], error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
return callAction("getGammesAValider", [codeFournisseur]);
|
||||
}
|
||||
|
||||
const RejetSchema = z.object({
|
||||
codeFournisseur: z.string().min(1),
|
||||
codeins: z.array(z.string()),
|
||||
});
|
||||
|
||||
/** Rejette des propositions de l'API (« Annuler les modifications » dans la Grille). */
|
||||
export async function rejeterGammesAValider(raw: unknown): Promise<{ success: boolean; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
const parsed = RejetSchema.safeParse(raw);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed: " + parsed.error.message };
|
||||
try {
|
||||
await supprimerGammesAValider(parsed.data.codeFournisseur, parsed.data.codeins);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
return callAction("rejeterGammesAValider", [raw]);
|
||||
}
|
||||
@@ -1,779 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
|
||||
import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client";
|
||||
import {
|
||||
pgGetArticlesByFournisseur,
|
||||
pgGetMensuelByFournisseur,
|
||||
pgGetGammesByFournisseur,
|
||||
pgGetNomenclatureByFournisseur,
|
||||
pgGetStockByFournisseur,
|
||||
pgGetCommandesByFournisseur,
|
||||
pgGetPrixVenteByFournisseur,
|
||||
type PgStockRow,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
|
||||
import { readGridSnapshot, upsertGridRows } from "@/lib/grid-store";
|
||||
import {
|
||||
applyStorePatchInPlace,
|
||||
buildStorePatchEntries,
|
||||
storePatchCandidates,
|
||||
type StorePatch,
|
||||
type StorePatchEntry,
|
||||
} from "@/features/grid/lib/store-patch";
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
magasin?: string;
|
||||
filters?: Partial<GridFilters>;
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
const GRID_ROWS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
/**
|
||||
* Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par
|
||||
* `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait
|
||||
* par garder toutes les grilles en mémoire (plusieurs centaines de Mo), ce qui
|
||||
* ralentissait toutes les requêtes (ramasse-miettes). Ordre d'insertion de la Map
|
||||
* = ordre d'usage : on évince les entrées les moins récemment servies.
|
||||
*/
|
||||
const GRID_ROWS_CACHE_MAX_ENTRIES = Number(process.env.GRID_ROWS_CACHE_MAX_ENTRIES ?? 30);
|
||||
const GRID_ROWS_CACHE_MAX_ROWS = Number(process.env.GRID_ROWS_CACHE_MAX_ROWS ?? 150_000);
|
||||
const gridRowsCache = new Map<string, { rows: ProductRow[]; createdAt: number }>();
|
||||
const gridRowsPending = new Map<string, Promise<ProductRow[]>>();
|
||||
/** Nombre d'enregistrements de gammes par fournisseur (cf. patchGridRowsCache). */
|
||||
const gammeSaveVersion = new Map<string, number>();
|
||||
|
||||
function cacheGridRows(key: string, rows: ProductRow[]): void {
|
||||
gridRowsCache.delete(key);
|
||||
gridRowsCache.set(key, { rows, createdAt: Date.now() });
|
||||
let totalRows = 0;
|
||||
for (const entry of gridRowsCache.values()) totalRows += entry.rows.length;
|
||||
for (const [oldKey, entry] of gridRowsCache) {
|
||||
if (gridRowsCache.size <= 1) break;
|
||||
if (gridRowsCache.size <= GRID_ROWS_CACHE_MAX_ENTRIES && totalRows <= GRID_ROWS_CACHE_MAX_ROWS) break;
|
||||
gridRowsCache.delete(oldKey);
|
||||
totalRows -= entry.rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
function gridRowsCacheKey(input: GetProductRowsInput): string {
|
||||
return `${input.codeFournisseur}:${input.magasin ?? "TOTAL"}`;
|
||||
}
|
||||
|
||||
export function invalidateGridRowsCache(codeFournisseur?: string) {
|
||||
if (codeFournisseur) {
|
||||
for (const key of gridRowsCache.keys()) {
|
||||
if (key.startsWith(`${codeFournisseur}:`)) gridRowsCache.delete(key);
|
||||
}
|
||||
for (const key of gridRowsPending.keys()) {
|
||||
if (key.startsWith(`${codeFournisseur}:`)) gridRowsPending.delete(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
gridRowsCache.clear();
|
||||
gridRowsPending.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter
|
||||
* le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros
|
||||
* fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné
|
||||
* ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9),
|
||||
* la colonne INIT reste l'état serveur.
|
||||
*/
|
||||
export function patchGridRowsCache(
|
||||
codeFournisseur: string,
|
||||
changes: ReadonlyArray<{ codein: string; codeGamme: string }>,
|
||||
): void {
|
||||
if (changes.length === 0) return;
|
||||
// Un calcul en cours pour ce fournisseur a lu les gammes avant cet
|
||||
// enregistrement : le compteur lui signale de les relire avant d'être caché.
|
||||
gammeSaveVersion.set(codeFournisseur, (gammeSaveVersion.get(codeFournisseur) ?? 0) + 1);
|
||||
const byCodein = new Map(changes.map((c) => [c.codein, c.codeGamme as GammeCode]));
|
||||
for (const [key, entry] of gridRowsCache) {
|
||||
if (!key.startsWith(`${codeFournisseur}:`)) continue;
|
||||
for (const row of entry.rows) {
|
||||
const gamme = byCodein.get(row.codein);
|
||||
if (gamme !== undefined) row.codeGamme = gamme;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
|
||||
* sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
|
||||
* touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT)
|
||||
* est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le
|
||||
* marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif.
|
||||
*/
|
||||
async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Promise<void> {
|
||||
try {
|
||||
const gammeMap = await pgGetGammesByFournisseur(codeFournisseur);
|
||||
if (gammeMap.size === 0) return;
|
||||
for (const row of rows) {
|
||||
const live = gammeMap.get(row.codein);
|
||||
if (live !== undefined) row.codeGammeInit = live as GammeCode;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[getProductRows] refreshGammeInit error:", (e as Error).message?.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`).
|
||||
*
|
||||
* Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations
|
||||
* par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes.
|
||||
* Toute erreur est avalée : la persistance ne doit jamais casser l'affichage.
|
||||
*/
|
||||
async function persistGridSnapshot(input: GetProductRowsInput, rows: ProductRow[]): Promise<void> {
|
||||
if ((input.magasin ?? "TOTAL") !== "TOTAL") return;
|
||||
try {
|
||||
const n = await upsertGridRows(input.codeFournisseur, rows);
|
||||
console.log(`[getProductRows] instantané persisté: ${n} lignes pour ${input.codeFournisseur}`);
|
||||
} catch (e) {
|
||||
console.error("[getProductRows] persistance grid_rows KO:", (e as Error).message?.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||
const cacheKey = gridRowsCacheKey(input);
|
||||
const cached = gridRowsCache.get(cacheKey);
|
||||
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
|
||||
// Remonte l'entrée en tête de l'ordre d'usage (éviction LRU).
|
||||
gridRowsCache.delete(cacheKey);
|
||||
gridRowsCache.set(cacheKey, cached);
|
||||
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
|
||||
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
|
||||
await refreshGammeInit(cached.rows, input.codeFournisseur);
|
||||
return cached.rows;
|
||||
}
|
||||
|
||||
// Un calcul déjà en cours est réutilisé même en rechargement forcé : il part
|
||||
// de données fraîches, et en lancer un second en parallèle doublait la charge
|
||||
// (double clic, onglet rouvert…) sans rien apporter.
|
||||
const pending = gridRowsPending.get(cacheKey);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi
|
||||
// en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement
|
||||
// forcé — c'est précisément ce que l'utilisateur demande d'éviter.
|
||||
if (!input.forceRefresh) {
|
||||
const servi = await serveFromSnapshot(input, cacheKey);
|
||||
if (servi) return servi;
|
||||
}
|
||||
|
||||
const versionAuDepart = gammeSaveVersion.get(input.codeFournisseur) ?? 0;
|
||||
const promise = buildProductRows(input).then(async (rows) => {
|
||||
if ((gammeSaveVersion.get(input.codeFournisseur) ?? 0) !== versionAuDepart) {
|
||||
// Gammes enregistrées pendant le calcul : on reprend le dernier snapshot.
|
||||
applySnapshotChanges(rows, await loadLatestSnapshotChanges(input.codeFournisseur));
|
||||
}
|
||||
cacheGridRows(cacheKey, rows);
|
||||
gridRowsPending.delete(cacheKey);
|
||||
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
|
||||
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
|
||||
// l'affichage de la Grille ne doit pas attendre l'écriture.
|
||||
void persistGridSnapshot(input, rows);
|
||||
return rows;
|
||||
}).catch((error) => {
|
||||
gridRowsPending.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
gridRowsPending.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan. */
|
||||
const SNAPSHOT_REFRESH_AFTER_MS = 20 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le
|
||||
* calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et
|
||||
* les dernières gammes enregistrées reportées dessus. Hors « tous magasins »,
|
||||
* le rattrapage du magasin choisi est appliqué comme après un calcul.
|
||||
*
|
||||
* `null` = pas d'instantané utilisable (absent, ancien format, autre mois) :
|
||||
* l'appelant recalcule.
|
||||
*/
|
||||
async function serveFromSnapshot(input: GetProductRowsInput, cacheKey: string): Promise<ProductRow[] | null> {
|
||||
const { codeFournisseur, magasin = "TOTAL" } = input;
|
||||
let snapshot: Awaited<ReturnType<typeof readGridSnapshot>>;
|
||||
try {
|
||||
snapshot = await readGridSnapshot(codeFournisseur);
|
||||
} catch (e) {
|
||||
console.error("[getProductRows] lecture grid_rows KO:", (e as Error).message?.slice(0, 200));
|
||||
return null;
|
||||
}
|
||||
if (!snapshot || snapshot.rows.length === 0) return null;
|
||||
|
||||
const { rows } = snapshot;
|
||||
// Les colonnes réseau de l'instantané datent de son calcul : on les relit dans
|
||||
// le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite
|
||||
// depuis (au changement de mois notamment) restait invisible jusqu'au
|
||||
// prochain recalcul, et la tendance réseau disparaissait.
|
||||
const [changes, network] = await Promise.all([
|
||||
loadLatestSnapshotChanges(codeFournisseur),
|
||||
fetchNetworkMetrics(rows.map((r) => r.codeCentrale ?? "")),
|
||||
refreshGammeInit(rows, codeFournisseur),
|
||||
]);
|
||||
applySnapshotChanges(rows, changes);
|
||||
enrichWithNetworkMetrics(rows, network);
|
||||
|
||||
if (magasin !== "TOTAL") {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, last12Periods());
|
||||
}
|
||||
|
||||
cacheGridRows(cacheKey, rows);
|
||||
|
||||
// Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans
|
||||
// faire attendre l'utilisateur (la prochaine ouverture en profitera).
|
||||
// Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané.
|
||||
if (Date.now() - snapshot.computedAt.getTime() > SNAPSHOT_REFRESH_AFTER_MS && !gridRowsPending.has(`${codeFournisseur}:TOTAL`)) {
|
||||
void getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true }).catch((e) =>
|
||||
console.error("[getProductRows] rafraîchissement en arrière-plan KO:", (e as Error).message?.slice(0, 200)),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul). */
|
||||
function last12Periods(): string[] {
|
||||
const now = new Date();
|
||||
const periods: string[] = [];
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
periods.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return periods;
|
||||
}
|
||||
|
||||
async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||
const { codeFournisseur, magasin = "TOTAL" } = input;
|
||||
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
|
||||
|
||||
try {
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
|
||||
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
|
||||
// Remplace des centaines/milliers d'appels HTTP per-article.
|
||||
const articlesPromise = pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; });
|
||||
// Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des
|
||||
// articles (codes centraux) ou le seul code fournisseur. Exécutées après la
|
||||
// Phase 1, elles ajoutaient deux allers-retours en série à chaque calcul.
|
||||
const networkPromise = articlesPromise.then((arts) =>
|
||||
fetchNetworkMetrics(arts.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))),
|
||||
);
|
||||
const snapshotPromise = loadLatestSnapshotChanges(codeFournisseur);
|
||||
|
||||
const [
|
||||
articles,
|
||||
mensuelRows,
|
||||
gammeMap,
|
||||
nomMap,
|
||||
stockMap,
|
||||
commandesMap,
|
||||
prixVenteMap,
|
||||
] = await Promise.all([
|
||||
articlesPromise,
|
||||
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }),
|
||||
pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map<string, string>(); }),
|
||||
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
|
||||
pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map<string, PgStockRow[]>(); }),
|
||||
pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map<string, number>(); }),
|
||||
pgGetPrixVenteByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetPrixVenteByFournisseur ERROR:", e); return new Map<string, Record<string, number>>(); }),
|
||||
]);
|
||||
|
||||
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
|
||||
|
||||
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
|
||||
const sortedPeriods = last12Periods();
|
||||
const allowedPeriods = new Set(sortedPeriods);
|
||||
|
||||
// ─── Phase 3 : Seed productMap depuis articles ────────────────────────
|
||||
const productMap = new Map<string, ProductRow>();
|
||||
for (const art of articles) {
|
||||
if (!art.codein || productMap.has(art.codein)) continue;
|
||||
productMap.set(art.codein, {
|
||||
codein: art.codein,
|
||||
codeFournisseur: art.codefou ?? codeFournisseur,
|
||||
nomFournisseur: art.nomfou ?? "",
|
||||
fournisseurPrincipalCode: art.codefou_principal ?? undefined,
|
||||
fournisseurPrincipalNom: art.nomfou_principal ?? undefined,
|
||||
libelle1: art.libelle1 ?? "",
|
||||
gtin: art.gtin ?? "",
|
||||
reference: art.reference ?? "",
|
||||
codeCentrale: art.codeCentrale ? String(art.codeCentrale).trim() : undefined,
|
||||
code1: "", libelleNiveau1: "",
|
||||
code2: "", libelleNiveau2: "",
|
||||
code3: "", libelle3: "",
|
||||
codeGamme: null,
|
||||
codeGammeInit: null,
|
||||
codeGammeDraft: null,
|
||||
sales12m: {},
|
||||
stock12m: {},
|
||||
receptions12m: {},
|
||||
totalQuantite: 0,
|
||||
totalCa: 0,
|
||||
totalMarge: 0,
|
||||
tauxMarge: 0,
|
||||
workingStores: [],
|
||||
noid: art.no_id ? Number(art.no_id) : undefined,
|
||||
pcb: art.pcb ? Number(art.pcb) : undefined,
|
||||
prixVente: art.pv_central ? Number(art.pv_central) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ─────────────
|
||||
// SQL a déjà fait l'agrégation par (codein, site, mois).
|
||||
// On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side.
|
||||
type PeriodData = { qty: number; ca: number; marge: number; stock: number; receptions: number };
|
||||
const mensuelByCodein = new Map<string, Map<string, PeriodData>>();
|
||||
const mensuelBySite = new Map<string, Map<string, Map<string, PeriodData>>>(); // codein → site → periode → data
|
||||
const storeMonthsByCodein = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
for (const row of mensuelRows) {
|
||||
const periode = row.mois.replace("-", ""); // "2026-02" → "202602"
|
||||
if (!allowedPeriods.has(periode)) continue;
|
||||
|
||||
// Accumulation TOTAL (toujours tous sites)
|
||||
if (!mensuelByCodein.has(row.codein)) mensuelByCodein.set(row.codein, new Map());
|
||||
const byPeriod = mensuelByCodein.get(row.codein)!;
|
||||
if (!byPeriod.has(periode)) byPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 });
|
||||
const p = byPeriod.get(periode)!;
|
||||
p.stock += Number(row.stock_fin_mois) || 0;
|
||||
p.qty += Number(row.qte_vendue) || 0;
|
||||
p.ca += Number(row.ca_ht) || 0;
|
||||
p.marge += Number(row.marge) || 0;
|
||||
p.receptions += Number(row.qte_recue) || 0;
|
||||
|
||||
// Accumulation par site (pour switch client-side)
|
||||
if (!mensuelBySite.has(row.codein)) mensuelBySite.set(row.codein, new Map());
|
||||
const bySite = mensuelBySite.get(row.codein)!;
|
||||
if (!bySite.has(row.site)) bySite.set(row.site, new Map());
|
||||
const siteByPeriod = bySite.get(row.site)!;
|
||||
if (!siteByPeriod.has(periode)) siteByPeriod.set(periode, { qty: 0, ca: 0, marge: 0, stock: 0, receptions: 0 });
|
||||
const sp = siteByPeriod.get(periode)!;
|
||||
sp.qty += Number(row.qte_vendue) || 0;
|
||||
sp.ca += Number(row.ca_ht) || 0;
|
||||
sp.marge += Number(row.marge) || 0;
|
||||
sp.stock = Number(row.stock_fin_mois) || 0; // SQL donne déjà le dernier mouvement
|
||||
sp.receptions += Number(row.qte_recue) || 0;
|
||||
|
||||
// Suivi magasins actifs (pour workingStores) : 1 vente suffit
|
||||
if (Number(row.qte_vendue) > 0) {
|
||||
if (!storeMonthsByCodein.has(row.codein)) storeMonthsByCodein.set(row.codein, new Map());
|
||||
storeMonthsByCodein.get(row.codein)!.set(row.site, new Set());
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ─────────────
|
||||
for (const [codein, byPeriod] of mensuelByCodein.entries()) {
|
||||
const product = productMap.get(codein);
|
||||
if (!product) continue;
|
||||
|
||||
let lastStock = 0;
|
||||
for (const periode of sortedPeriods) {
|
||||
const p = byPeriod.get(periode);
|
||||
if (p) {
|
||||
product.sales12m[periode] = p.qty;
|
||||
product.stock12m[periode] = p.stock;
|
||||
product.receptions12m[periode] = p.receptions;
|
||||
product.totalQuantite += p.qty;
|
||||
product.totalCa += p.ca;
|
||||
product.totalMarge += p.marge;
|
||||
lastStock = p.stock;
|
||||
} else {
|
||||
product.sales12m[periode] = 0;
|
||||
product.stock12m[periode] = lastStock; // carry-forward
|
||||
product.receptions12m[periode] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
product.tauxMarge = product.totalCa > 0 ? (product.totalMarge / product.totalCa) * 100 : 0;
|
||||
|
||||
// Breakdowns par site pour le switch client-side
|
||||
const siteMap = mensuelBySite.get(codein);
|
||||
if (siteMap) {
|
||||
product.sales12mByStore = {};
|
||||
product.stock12mByStore = {};
|
||||
product.receptions12mByStore = {};
|
||||
product.caByStore = {};
|
||||
product.quantiteByStore = {};
|
||||
product.margeByStore = {};
|
||||
for (const [site, siteByPeriod] of siteMap.entries()) {
|
||||
product.sales12mByStore[site] = {};
|
||||
product.stock12mByStore[site] = {};
|
||||
product.receptions12mByStore![site] = {};
|
||||
let sQty = 0, sCa = 0, sMarge = 0;
|
||||
let lastSiteStock = 0;
|
||||
for (const periode of sortedPeriods) {
|
||||
const sp = siteByPeriod.get(periode);
|
||||
product.sales12mByStore[site][periode] = sp?.qty ?? 0;
|
||||
product.receptions12mByStore![site][periode] = sp?.receptions ?? 0;
|
||||
if (sp) {
|
||||
product.stock12mByStore[site][periode] = sp.stock;
|
||||
lastSiteStock = sp.stock;
|
||||
} else {
|
||||
product.stock12mByStore[site][periode] = lastSiteStock; // carry-forward
|
||||
}
|
||||
sQty += sp?.qty ?? 0;
|
||||
sCa += sp?.ca ?? 0;
|
||||
sMarge += sp?.marge ?? 0;
|
||||
}
|
||||
product.quantiteByStore[site] = sQty;
|
||||
product.caByStore[site] = sCa;
|
||||
product.margeByStore[site] = sMarge;
|
||||
}
|
||||
|
||||
// Le stock TOTAL se recalcule ici, à partir des séries par site.
|
||||
//
|
||||
// Il était sommé plus haut depuis les lignes mensuelles brutes, ce
|
||||
// qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock
|
||||
// est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu
|
||||
// détient toujours sa marchandise. Sur un produit à faible rotation,
|
||||
// « tous magasins » n'affichait donc que le site actif, en pratique
|
||||
// Frouard. Les séries par site, elles, sont reportées d'un mois sur
|
||||
// l'autre : leur somme est la bonne.
|
||||
const sites = Object.keys(product.stock12mByStore);
|
||||
if (sites.length > 0) {
|
||||
for (const periode of sortedPeriods) {
|
||||
product.stock12m[periode] = sites.reduce(
|
||||
(total, site) => total + (product.stock12mByStore![site][periode] ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// workingStores : sites avec au moins 1 vente sur les 12 derniers mois
|
||||
const storeMonths = storeMonthsByCodein.get(codein);
|
||||
if (storeMonths) {
|
||||
product.workingStores = [...storeMonths.keys()].sort();
|
||||
}
|
||||
|
||||
// Commandes en cours
|
||||
const cmdQty = commandesMap.get(codein);
|
||||
if (cmdQty) product.commandesEnCours = cmdQty;
|
||||
}
|
||||
|
||||
// ─── Phase 6 : Gammes (saison active) ────────────────────────────────
|
||||
for (const [codein, gammeCode] of gammeMap.entries()) {
|
||||
const product = productMap.get(codein);
|
||||
if (!product) continue;
|
||||
product.codeGammeInit = gammeCode as GammeCode;
|
||||
product.codeGamme = gammeCode as GammeCode;
|
||||
}
|
||||
|
||||
// ─── Phase 6b : Nomenclature (famille / sous-famille) ────────────────
|
||||
for (const [codein, nom] of nomMap.entries()) {
|
||||
const product = productMap.get(codein);
|
||||
if (!product) continue;
|
||||
product.code3 = nom.code3 ?? "";
|
||||
product.libelle3 = nom.libelle3 ?? "";
|
||||
product.code2 = nom.code2 ?? "";
|
||||
product.libelleNiveau2 = nom.libelle2 ?? "";
|
||||
product.code1 = nom.code1 ?? "";
|
||||
product.libelleNiveau1 = nom.libelle1nom ?? "";
|
||||
}
|
||||
|
||||
// ─── Phase 7 : Stock temps réel (cube_stock) ─────────────────────────
|
||||
for (const [codein, stocks] of stockMap.entries()) {
|
||||
const product = productMap.get(codein);
|
||||
if (!product) continue;
|
||||
|
||||
const sitesStock = stocks;
|
||||
|
||||
product.stockActuel = sitesStock.reduce((s, r) => s + (Number(r.stockdispo) || 0), 0);
|
||||
product.stockTotal = sitesStock.reduce((s, r) => s + (Number(r.qte) || 0), 0);
|
||||
product.stockValeur = sitesStock.reduce((s, r) => s + (Number(r.valstock) || 0), 0);
|
||||
|
||||
const withPrmp = sitesStock.find(r => Number(r.prmp) > 0);
|
||||
if (withPrmp) {
|
||||
product.pa = Number(withPrmp.prmp);
|
||||
product.prixAchat = product.pa;
|
||||
}
|
||||
|
||||
const dernVente = sitesStock.reduce<string>((best, r) =>
|
||||
r.dernierevente && r.dernierevente > best ? r.dernierevente : best, "");
|
||||
if (dernVente) product.derniereVente = dernVente;
|
||||
|
||||
const dernLiv = sitesStock.reduce<string>((best, r) =>
|
||||
r.dernierereception && r.dernierereception > best ? r.dernierereception : best, "");
|
||||
if (dernLiv) product.derniereLivraison = dernLiv;
|
||||
|
||||
// Map store-specific delivery dates
|
||||
product.derniereLivraisonByStore = {};
|
||||
for (const r of sitesStock) {
|
||||
if (r.site && r.dernierereception) {
|
||||
product.derniereLivraisonByStore[r.site] = r.dernierereception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─
|
||||
//
|
||||
// `article_infosup.prix_vente_mini`, renseigné en Phase 3, n'est qu'une
|
||||
// borne de paramétrage : elle est vide en base et laissait la colonne PV
|
||||
// entièrement creuse. Le vrai prix vit dans `cube_pv`, par article ET par
|
||||
// site. On garde le détail par magasin — deux magasins peuvent ne pas
|
||||
// pratiquer le même prix, et l'afficher vaut mieux que le moyenner.
|
||||
for (const product of productMap.values()) {
|
||||
const parSite = prixVenteMap.get(product.codein);
|
||||
if (parSite && Object.keys(parSite).length > 0) {
|
||||
product.prixVenteByStore = parSite;
|
||||
} else {
|
||||
// Filet de secours : le cube de stock porte le même PV, mais
|
||||
// seulement pour les articles qui ont une ligne de stock.
|
||||
const depuisStock: Record<string, number> = {};
|
||||
for (const s of stockMap.get(product.codein) ?? []) {
|
||||
const pv = Number(s.pv);
|
||||
const site = String(s.site ?? "").trim();
|
||||
if (site && Number.isFinite(pv) && pv > 0) depuisStock[site] = pv;
|
||||
}
|
||||
if (Object.keys(depuisStock).length > 0) product.prixVenteByStore = depuisStock;
|
||||
}
|
||||
|
||||
const prix = Object.values(product.prixVenteByStore ?? {});
|
||||
// Le plus élevé quand les magasins divergent : une moyenne inventerait
|
||||
// un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran.
|
||||
if (prix.length > 0) product.prixVente = Math.max(...prix);
|
||||
}
|
||||
|
||||
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
|
||||
enrichWithNetworkMetrics(productMap.values(), await networkPromise);
|
||||
|
||||
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
|
||||
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
|
||||
// rechargé en Phase 6 — jamais écrasée ici.
|
||||
applySnapshotChanges(productMap.values(), await snapshotPromise);
|
||||
|
||||
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
|
||||
const allRows = Array.from(productMap.values());
|
||||
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
|
||||
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, sortedPeriods);
|
||||
const excludedY = allRows.length - rows.length;
|
||||
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
|
||||
|
||||
return rows;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
|
||||
// Ne PAS renvoyer [] : une liste vide est indiscernable d'un fournisseur
|
||||
// sans article. La Grille affichait une page blanche sans explication, et
|
||||
// la synchro nocturne prenait la panne pour un fournisseur vide — qu'elle
|
||||
// désactivait alors automatiquement. La remonter laisse chaque appelant la
|
||||
// traiter comme une erreur (bandeau rouge, statut « echec »).
|
||||
throw error instanceof Error
|
||||
? error
|
||||
: new Error(`Calcul de la grille impossible pour ${codeFournisseur} : ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Articles complétés par l'API FF, au plus, lors d'un changement de magasin. */
|
||||
function plafondComplement(): number {
|
||||
return Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
|
||||
}
|
||||
|
||||
/** Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). */
|
||||
async function fetchStorePatchEntries(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
periods: string[],
|
||||
): Promise<StorePatchEntry[]> {
|
||||
const candidates = storePatchCandidates(rows, magasin);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
|
||||
// les candidats se comptent par milliers et le changement de magasin se fige
|
||||
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
|
||||
// plutôt que de tronquer en silence.
|
||||
const PLAFOND = plafondComplement();
|
||||
const retenus = candidates.slice(0, PLAFOND);
|
||||
if (candidates.length > retenus.length) {
|
||||
console.warn(
|
||||
`[getProductRows] rattrapage magasin ${magasin} borné à ${retenus.length} articles`
|
||||
+ ` sur ${candidates.length} : les autres gardent la valeur SQL.`,
|
||||
);
|
||||
}
|
||||
|
||||
const mensuelMap = await getMensuelByArticles(
|
||||
retenus.map((row) => ({
|
||||
codein: row.codein,
|
||||
libelle1: row.libelle1,
|
||||
codefou: row.codeFournisseur,
|
||||
noid: row.noid,
|
||||
})),
|
||||
dateDebut,
|
||||
dateFin,
|
||||
25
|
||||
);
|
||||
return buildStorePatchEntries(retenus, magasin, periods, mensuelMap);
|
||||
}
|
||||
|
||||
async function reconcileSelectedStoreFromMensuelApi(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
sortedPeriods: string[]
|
||||
) {
|
||||
if (magasin === "TOTAL") return;
|
||||
try {
|
||||
const entries = await fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, sortedPeriods);
|
||||
const fixedRows = applyStorePatchInPlace(rows, magasin, sortedPeriods, entries);
|
||||
if (fixedRows > 0) {
|
||||
console.log(`[getProductRows] ${fixedRows} produits corrigés via API mensuelle pour magasin ${magasin}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[getProductRows] Mensuel API reconciliation error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compléments par magasin, rattachés au tableau de lignes « tous magasins » en
|
||||
* cache : ils vivent et meurent avec lui (expiration, « Actualiser », nouvel
|
||||
* enregistrement), sans autre invalidation à gérer.
|
||||
*/
|
||||
const storePatches = new WeakMap<ProductRow[], Map<string, Promise<StorePatch>>>();
|
||||
|
||||
/**
|
||||
* Complément de l'API FF pour un magasin, calculé sur les lignes « tous
|
||||
* magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
|
||||
* recharger les lignes. Les demandes simultanées partagent le même calcul, et
|
||||
* un échec n'est jamais gardé.
|
||||
*/
|
||||
export async function getStorePatch(codeFournisseur: string, magasin: string): Promise<StorePatch> {
|
||||
const enCache = gridRowsCache.get(`${codeFournisseur}:TOTAL`);
|
||||
const rows = enCache && Date.now() - enCache.createdAt < GRID_ROWS_CACHE_TTL_MS
|
||||
? enCache.rows
|
||||
: await getProductRows({ codeFournisseur, magasin: "TOTAL" });
|
||||
|
||||
let parMagasin = storePatches.get(rows);
|
||||
if (!parMagasin) {
|
||||
parMagasin = new Map();
|
||||
storePatches.set(rows, parMagasin);
|
||||
}
|
||||
let patch = parMagasin.get(magasin);
|
||||
if (!patch) {
|
||||
const periods = last12Periods();
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const enCours = fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, periods)
|
||||
.then((entries): StorePatch => ({ magasin, periods, entries }));
|
||||
const table = parMagasin;
|
||||
enCours.catch(() => {
|
||||
if (table.get(magasin) === enCours) table.delete(magasin);
|
||||
});
|
||||
parMagasin.set(magasin, enCours);
|
||||
patch = enCours;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
|
||||
*
|
||||
* `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la
|
||||
* deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide
|
||||
* pour que l'UI distingue « pas de donnée » de « zéro magasin ».
|
||||
*/
|
||||
function nbMagParMois(
|
||||
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
|
||||
): Record<string, number> | null {
|
||||
if (!metricsByMonth) return null;
|
||||
const parMois: Record<string, number> = {};
|
||||
for (const [mois] of Object.entries(metricsByMonth)) {
|
||||
// Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de
|
||||
// `nbMag`, c'est qu'aucun magasin n'a vendu, donc zéro. Le traiter comme
|
||||
// « manquant » amputait la série et faisait disparaître toute la courbe
|
||||
// (les anciens caches n'écrivaient `nbMag` que sur les mois avec vente).
|
||||
const nb = Number(metricsByMonth[mois]?.nbMag);
|
||||
parMois[mois] = Number.isFinite(nb) ? nb : 0;
|
||||
}
|
||||
return Object.keys(parMois).length > 0 ? parMois : null;
|
||||
}
|
||||
|
||||
type SnapshotChanges = Record<string, { before: string | null; after: string }>;
|
||||
|
||||
/**
|
||||
* Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ;
|
||||
* codeGammeInit reste figé (état serveur).
|
||||
*/
|
||||
function applySnapshotChanges(rows: Iterable<ProductRow>, changes: SnapshotChanges | null): void {
|
||||
if (!changes) return;
|
||||
for (const row of rows) {
|
||||
const change = changes[row.codein];
|
||||
if (change?.after) row.codeGamme = change.after as GammeCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `null`. */
|
||||
async function loadLatestSnapshotChanges(codeFournisseur: string): Promise<SnapshotChanges | null> {
|
||||
try {
|
||||
const snaps = await db
|
||||
.select({ changes: sessionSnapshots.changes })
|
||||
.from(sessionSnapshots)
|
||||
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
|
||||
.orderBy(desc(sessionSnapshots.createdAt))
|
||||
.limit(1);
|
||||
return snaps.length > 0 ? (snaps[0].changes as SnapshotChanges) : null;
|
||||
} catch (snapErr) {
|
||||
console.error("[getProductRows] Snapshot restore error:", snapErr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit les metriques reseau Qlik (cache qlik_network_metrics) des codes centraux
|
||||
* donnes. `null` en cas d'erreur : la grille s'affiche alors sans donnees reseau.
|
||||
*/
|
||||
async function fetchNetworkMetrics(
|
||||
codesCentrale: string[],
|
||||
): Promise<Map<string, NetworkMetricCached> | null> {
|
||||
try {
|
||||
const codes = [...new Set(codesCentrale.filter(Boolean))];
|
||||
if (codes.length === 0) return null;
|
||||
return await getNetworkMetricsByCodeCentrale(codes);
|
||||
} catch (error) {
|
||||
console.error("[getProductRows] enrichWithNetworkMetrics error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit les produits avec les metriques reseau Qlik, jointes par code centrale.
|
||||
* Degradation propre si la sync Qlik n'a jamais tourne ou si le code centrale
|
||||
* n'est pas encore disponible.
|
||||
*/
|
||||
function enrichWithNetworkMetrics(
|
||||
products: Iterable<ProductRow>,
|
||||
metrics: Map<string, NetworkMetricCached> | null,
|
||||
): void {
|
||||
if (!metrics || metrics.size === 0) return;
|
||||
for (const product of products) {
|
||||
const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined;
|
||||
if (!m) continue;
|
||||
product.caReseau = m.caReseau;
|
||||
product.qteReseau = m.qteReseau;
|
||||
product.nbMagasinsReseau = m.nbMagasinsReseau;
|
||||
product.caParMagasinReseau = m.caParMagasinReseau;
|
||||
product.margePctReseau = m.margePctReseau;
|
||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||
product.qteReseauByMonth = m.qteByMonth ?? null;
|
||||
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
|
||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,25 @@
|
||||
"use server";
|
||||
/**
|
||||
* Enregistrement des gammes modifiées dans la Grille — action exécutée par le
|
||||
* serveur Rust : `POST /api/actions/saveDraftChanges` (validation comprise).
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { enregistrerGammes } from "./enregistrer-gammes";
|
||||
import { supprimerGammesAValider } from "@/lib/gammes-a-valider";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
|
||||
const SaveDraftsSchema = z.object({
|
||||
codeFournisseur: z.string(),
|
||||
nomFournisseur: z.string().optional(),
|
||||
magasin: z.string(),
|
||||
changes: z.array(
|
||||
z.object({
|
||||
codein: z.string(),
|
||||
codeGammeBefore: z.string().nullable(),
|
||||
codeGamme: z.string(),
|
||||
})
|
||||
),
|
||||
/** Données attendues par le serveur (ex-schéma zod `SaveDraftsSchema`). */
|
||||
export interface SaveDraftsInput {
|
||||
codeFournisseur: string;
|
||||
nomFournisseur?: string;
|
||||
magasin: string;
|
||||
changes: { codein: string; codeGammeBefore: string | null; codeGamme: string }[];
|
||||
/**
|
||||
* Propositions de l'API examinées dans la Grille : validées (présentes dans
|
||||
* `changes`) ou écartées. Elles quittent la file « à valider ».
|
||||
*/
|
||||
gammesAValiderTraitees: z.array(z.string()).optional(),
|
||||
});
|
||||
gammesAValiderTraitees?: string[];
|
||||
}
|
||||
|
||||
export async function saveDraftChanges(
|
||||
raw: unknown
|
||||
): Promise<{ success: boolean; saved: number; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, saved: 0, error: acces.message };
|
||||
|
||||
const parsed = SaveDraftsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
|
||||
}
|
||||
|
||||
const { codeFournisseur, nomFournisseur, magasin, changes, gammesAValiderTraitees = [] } = parsed.data;
|
||||
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
const finalUserId = userId && !isNaN(userId) ? userId : null;
|
||||
|
||||
try {
|
||||
const saved = await enregistrerGammes({ codeFournisseur, nomFournisseur, magasin, changes, userId: finalUserId });
|
||||
await supprimerGammesAValider(codeFournisseur, gammesAValiderTraitees);
|
||||
return { success: true, saved };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
return { success: false, saved: 0, error: msg };
|
||||
}
|
||||
return callAction("saveDraftChanges", [raw]);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Trous d'assortiment : ce qu'un magasin ne travaille pas.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { GammeCode } from "@/types/grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { GammeCode } from "@/types/grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CLASSES_SANS_GAMME, GAMMES, trouverGamme } from "@/lib/gammes";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
||||
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts).
|
||||
*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Carte « tendance réseau », ouverte depuis la colonne Tendance.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — CA par nomenclature : nos magasins face au réseau.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Filter, Search, X } from "lucide-react";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CollectFlow — Détail mensuel d'un produit, ouvert depuis la case « Tot. 12m ».
|
||||
*
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Segmented } from "@/components/ui/tabs";
|
||||
import { CODE_TOUS_MAGASINS, LIBELLE_TOUS_MAGASINS, nomMagasin } from "@/lib/magasins";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { saveDraftChanges } from "@/features/grid/api/save-draft-changes";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { CODE_TOUS_MAGASINS } from "@/lib/magasins";
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import type { FfMensuelEntry } from "@/lib/api-ff-client";
|
||||
import type { FfMensuelEntry } from "@/types/ff";
|
||||
|
||||
/** Chiffres d'un article pour le magasin complété. */
|
||||
export interface StorePatchEntry {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { createThrottledJSONStorage } from "@/lib/throttled-json-storage";
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
pgGetProduitDetail,
|
||||
pgGetCodeinByCodeCentrale,
|
||||
pgGetFournisseursByCodein,
|
||||
pgGetStockByCodein,
|
||||
pgGetCommandesByCodein,
|
||||
pgGetGammeHistoryByCodein,
|
||||
pgGetMensuelByCodein,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
import type { ProduitFiche, ProduitMois, ProduitTotaux } from "@/features/produits/types";
|
||||
|
||||
/** Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. */
|
||||
function buildMonthKeys(): string[] {
|
||||
const months: string[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 12; i >= 1; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
function emptyMois(mois: string): ProduitMois {
|
||||
return { mois, qte: 0, ca: 0, marge: 0, stockFinMois: 0, qteRecue: 0 };
|
||||
}
|
||||
|
||||
function totauxFrom(rows: ProduitMois[]): ProduitTotaux {
|
||||
const qte = rows.reduce((s, r) => s + r.qte, 0);
|
||||
const ca = rows.reduce((s, r) => s + r.ca, 0);
|
||||
const marge = rows.reduce((s, r) => s + r.marge, 0);
|
||||
return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble la fiche d'un produit **à partir de son code centrale** : c'est la
|
||||
* clé du réseau Qlik, seule identité disponible pour un produit que le réseau
|
||||
* travaille mais que Nancy ne référence pas.
|
||||
*
|
||||
* Si le catalogue Nancy connaît ce code centrale, on rattache toute la partie
|
||||
* locale (identité, fournisseurs, stock, gammes, 12 mois glissants de ventes).
|
||||
* Sinon la fiche reste purement réseau, ce qui est un cas parfaitement normal
|
||||
* depuis la recherche Qlik.
|
||||
*
|
||||
* Renvoie `null` uniquement si ni Qlik ni le catalogue ne connaissent le code.
|
||||
*/
|
||||
export async function getProduitFicheByCodeCentrale(codeCentrale: string): Promise<ProduitFiche | null> {
|
||||
const cleaned = codeCentrale.trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
const codein = await pgGetCodeinByCodeCentrale(cleaned);
|
||||
if (codein) return getProduitFiche(codein);
|
||||
|
||||
// Produit réseau pur : aucune donnée locale à assembler.
|
||||
const map = await getNetworkMetricsByCodeCentrale([cleaned]);
|
||||
const reseau = map.get(cleaned) ?? null;
|
||||
if (!reseau) return null;
|
||||
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const months = buildMonthKeys();
|
||||
const mensuelTotal: Record<string, ProduitMois> = {};
|
||||
for (const m of months) mensuelTotal[m] = emptyMois(m);
|
||||
|
||||
return {
|
||||
detail: null,
|
||||
codeCentrale: cleaned,
|
||||
libelleReseau: reseau.libelleReseau ?? "",
|
||||
fournisseurReseau: reseau.fournisseurReseau ?? "",
|
||||
fournisseurs: [],
|
||||
stock: [],
|
||||
commandesEnCours: 0,
|
||||
gammes: [],
|
||||
months,
|
||||
mensuelTotal,
|
||||
mensuelParSite: {},
|
||||
totaux: totauxFrom(months.map((m) => mensuelTotal[m])),
|
||||
totauxParSite: {},
|
||||
sitesActifs: [],
|
||||
reseau,
|
||||
periode: { dateDebut, dateFin },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble la fiche complète d'un produit : identité, fournisseurs, stock,
|
||||
* gammes, 12 mois glissants de ventes (total et par magasin) et métriques
|
||||
* réseau Qlik.
|
||||
*
|
||||
* Renvoie `null` si le codein est inconnu du catalogue FF.
|
||||
*/
|
||||
export async function getProduitFiche(codein: string): Promise<ProduitFiche | null> {
|
||||
const cleaned = codein.trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
|
||||
// Tout part en même temps : l'identité du produit était lue seule d'abord,
|
||||
// puis les cinq autres lectures, puis les métriques réseau — trois attentes
|
||||
// successives. Les métriques réseau n'attendent que l'identité (code centrale).
|
||||
const detailPromise = pgGetProduitDetail(cleaned);
|
||||
const reseauPromise = detailPromise.then(async (d) => {
|
||||
if (!d?.code_centrale) return null;
|
||||
const map = await getNetworkMetricsByCodeCentrale([d.code_centrale]);
|
||||
return map.get(d.code_centrale) ?? null;
|
||||
});
|
||||
const [detail, fournisseurs, stock, commandesEnCours, gammes, mensuelRows, reseau] = await Promise.all([
|
||||
detailPromise,
|
||||
pgGetFournisseursByCodein(cleaned),
|
||||
pgGetStockByCodein(cleaned),
|
||||
pgGetCommandesByCodein(cleaned),
|
||||
pgGetGammeHistoryByCodein(cleaned),
|
||||
pgGetMensuelByCodein(cleaned, dateDebut, dateFin),
|
||||
reseauPromise,
|
||||
]);
|
||||
if (!detail) return null;
|
||||
|
||||
const months = buildMonthKeys();
|
||||
const allowed = new Set(months);
|
||||
|
||||
const mensuelTotal: Record<string, ProduitMois> = {};
|
||||
const mensuelParSite: Record<string, Record<string, ProduitMois>> = {};
|
||||
for (const m of months) mensuelTotal[m] = emptyMois(m);
|
||||
|
||||
// Mois réellement présents dans les mouvements. Indispensable pour distinguer
|
||||
// « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock
|
||||
// effectivement nul » — c'est la convention de la Grille.
|
||||
const moisAvecDonnees = new Set<string>();
|
||||
const moisAvecDonneesParSite = new Map<string, Set<string>>();
|
||||
|
||||
for (const row of mensuelRows) {
|
||||
// SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM".
|
||||
const mois = row.mois.replace("-", "");
|
||||
if (!allowed.has(mois)) continue;
|
||||
const site = String(row.site).trim();
|
||||
|
||||
const qte = Number(row.qte_vendue) || 0;
|
||||
const ca = Number(row.ca_ht) || 0;
|
||||
const marge = Number(row.marge) || 0;
|
||||
const stockFinMois = Number(row.stock_fin_mois) || 0;
|
||||
const qteRecue = Number(row.qte_recue) || 0;
|
||||
|
||||
moisAvecDonnees.add(mois);
|
||||
const tot = mensuelTotal[mois];
|
||||
tot.qte += qte;
|
||||
tot.ca += ca;
|
||||
tot.marge += marge;
|
||||
tot.stockFinMois += stockFinMois;
|
||||
tot.qteRecue += qteRecue;
|
||||
|
||||
mensuelParSite[site] ??= Object.fromEntries(months.map((m) => [m, emptyMois(m)]));
|
||||
if (!moisAvecDonneesParSite.has(site)) moisAvecDonneesParSite.set(site, new Set());
|
||||
moisAvecDonneesParSite.get(site)!.add(mois);
|
||||
const cell = mensuelParSite[site][mois];
|
||||
cell.qte += qte;
|
||||
cell.ca += ca;
|
||||
cell.marge += marge;
|
||||
// Le SQL donne déjà le stock du dernier mouvement du mois pour ce site.
|
||||
cell.stockFinMois = stockFinMois;
|
||||
cell.qteRecue += qteRecue;
|
||||
}
|
||||
|
||||
// Report du stock de fin de mois sur les mois SANS mouvement (carry-forward),
|
||||
// même convention que la Grille : un mois sans mouvement conserve le stock du
|
||||
// mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0.
|
||||
const carryForward = (serie: Record<string, ProduitMois>, avecDonnees: Set<string>) => {
|
||||
let last = 0;
|
||||
for (const m of months) {
|
||||
if (avecDonnees.has(m)) last = serie[m].stockFinMois;
|
||||
else serie[m].stockFinMois = last;
|
||||
}
|
||||
};
|
||||
carryForward(mensuelTotal, moisAvecDonnees);
|
||||
for (const [site, bySite] of Object.entries(mensuelParSite)) {
|
||||
carryForward(bySite, moisAvecDonneesParSite.get(site) ?? new Set());
|
||||
}
|
||||
|
||||
const totaux = totauxFrom(months.map((m) => mensuelTotal[m]));
|
||||
const totauxParSite: Record<string, ProduitTotaux> = {};
|
||||
for (const [site, bySite] of Object.entries(mensuelParSite)) {
|
||||
totauxParSite[site] = totauxFrom(months.map((m) => bySite[m]));
|
||||
}
|
||||
|
||||
const sitesActifs = Object.entries(totauxParSite)
|
||||
.filter(([, t]) => t.qte > 0)
|
||||
.map(([site]) => site)
|
||||
.sort();
|
||||
|
||||
|
||||
return {
|
||||
detail,
|
||||
codeCentrale: detail.code_centrale ?? "",
|
||||
libelleReseau: reseau?.libelleReseau ?? "",
|
||||
fournisseurReseau: reseau?.fournisseurReseau ?? "",
|
||||
fournisseurs,
|
||||
stock,
|
||||
commandesEnCours,
|
||||
gammes,
|
||||
months,
|
||||
mensuelTotal,
|
||||
mensuelParSite,
|
||||
totaux,
|
||||
totauxParSite,
|
||||
sitesActifs,
|
||||
reseau,
|
||||
periode: { dateDebut, dateFin },
|
||||
};
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
import "server-only";
|
||||
|
||||
import { searchQlikArticles, QLIK_SEARCH_MAX_RESULTS } from "@/lib/qlik-search";
|
||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
||||
import {
|
||||
getNetworkMetricsByCodeCentrale,
|
||||
upsertNetworkMetrics,
|
||||
type NetworkMetricCached,
|
||||
} from "@/lib/qlik-network-cache";
|
||||
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
|
||||
import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||
import { normalizeMargePct } from "@/features/produits/lib/compare-reseau";
|
||||
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
|
||||
|
||||
/**
|
||||
* CollectFlow — Recherche produit **Qlik d'abord**, base FF Nancy ensuite.
|
||||
*
|
||||
* Déroulé :
|
||||
* 1. Qlik Sense, **une seule session** : les articles correspondant au terme ET
|
||||
* leurs mesures réseau sur 12 mois glissants (`searchQlikArticles`).
|
||||
* 2. Base FF Nancy : lesquels référençons-nous ? (`pgGetProduitsByCodeCentrale`)
|
||||
* 3. Cache `qlik_network_metrics` : détail mensuel quand une sync l'a déjà
|
||||
* extrait (le cube de recherche est agrégé, il ne le fournit pas).
|
||||
*
|
||||
* Une seconde session Qlik pour les mesures faisait abandonner le moteur
|
||||
* (`code 15 — Request aborted`) : deux sessions concurrentes sur la même app,
|
||||
* dont une avec une grosse sélection, et l'Engine coupe les requêtes.
|
||||
*
|
||||
* Si Qlik est injoignable, on se rabat sur la recherche catalogue local pour ne
|
||||
* pas laisser l'utilisateur sans résultat — la réponse le signale explicitement
|
||||
* (`source: "db"`).
|
||||
*/
|
||||
|
||||
/** Durée de validité d'un résultat de recherche en cache mémoire. */
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Durée de conservation d'un job terminé (le client a le temps de le relire). */
|
||||
const JOB_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Série « magasins vendeurs » extraite du détail mensuel du cache.
|
||||
*
|
||||
* Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est
|
||||
* qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la
|
||||
* série et priverait la tendance de son dénominateur.
|
||||
*/
|
||||
function nbMagParMois(
|
||||
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
|
||||
): Record<string, number> | null {
|
||||
if (!metricsByMonth) return null;
|
||||
const parMois: Record<string, number> = {};
|
||||
for (const mois of Object.keys(metricsByMonth)) {
|
||||
const nb = Number(metricsByMonth[mois]?.nbMag);
|
||||
parMois[mois] = Number.isFinite(nb) ? nb : 0;
|
||||
}
|
||||
return Object.keys(parMois).length > 0 ? parMois : null;
|
||||
}
|
||||
|
||||
/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */
|
||||
function cacheReseauComplet(
|
||||
cache: NetworkMetricCached | undefined,
|
||||
periode: string | null,
|
||||
now: Date = new Date(),
|
||||
): cache is NetworkMetricCached {
|
||||
const parMois = cache?.qteByMonth;
|
||||
if (!parMois || !periode || cache.periode !== periode) return false;
|
||||
return buildRolling12QlikMonths(now).every((mois) =>
|
||||
Object.prototype.hasOwnProperty.call(parMois, mois) &&
|
||||
Number.isFinite(Number(parMois[mois])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nombre maximum de recherches Qlik simultanées.
|
||||
*
|
||||
* Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on
|
||||
* plafonne plutôt que de lui envoyer N extractions en parallèle.
|
||||
*/
|
||||
const MAX_RECHERCHES_SIMULTANEES = 1;
|
||||
|
||||
/**
|
||||
* Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik
|
||||
* (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur
|
||||
* ou un rafraîchissement relancerait toute l'extraction.
|
||||
*/
|
||||
const cache = new Map<string, { at: number; result: ProduitRechercheResultat }>();
|
||||
|
||||
/**
|
||||
* Jobs de recherche, indexés par terme normalisé.
|
||||
*
|
||||
* Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours
|
||||
* Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps
|
||||
* se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le
|
||||
* client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail
|
||||
* tourne donc en tâche de fond et le client interroge l'état.
|
||||
*/
|
||||
const jobs = new Map<string, RechercheJob>();
|
||||
|
||||
export interface RechercheJob {
|
||||
jobId: string;
|
||||
terme: string;
|
||||
status: "running" | "success" | "error";
|
||||
/** Étape en cours, affichée pendant l'attente. */
|
||||
etape: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
result?: ProduitRechercheResultat;
|
||||
}
|
||||
|
||||
/** État renvoyé au client : le job, ou `idle` si aucune recherche connue. */
|
||||
export type RechercheEtat = RechercheJob | { status: "idle"; terme: string };
|
||||
|
||||
function cacheKey(term: string): string {
|
||||
return term.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */
|
||||
function purge(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of cache) {
|
||||
if (now - v.at > CACHE_TTL_MS) cache.delete(k);
|
||||
}
|
||||
for (const [k, j] of jobs) {
|
||||
if (j.status !== "running" && j.finishedAt && now - Date.parse(j.finishedAt) > JOB_TTL_MS) jobs.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
function jobsEnCours(): number {
|
||||
let n = 0;
|
||||
for (const j of jobs.values()) if (j.status === "running") n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function jobTermine(terme: string, result: ProduitRechercheResultat): RechercheJob {
|
||||
return {
|
||||
jobId: `cache_${cacheKey(terme)}`,
|
||||
terme,
|
||||
status: "success",
|
||||
etape: "Terminé",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre (ou réutilise) une recherche et rend la main **immédiatement**.
|
||||
*
|
||||
* - résultat déjà en cache et pas de `force` → job `success` directement ;
|
||||
* - recherche déjà en cours sur le même terme → on renvoie ce job ;
|
||||
* - sinon on lance le travail en tâche de fond.
|
||||
*/
|
||||
export function demarrerRecherche(
|
||||
term: string,
|
||||
options: { force?: boolean } = {},
|
||||
): RechercheJob {
|
||||
const cleaned = term.trim();
|
||||
const key = cacheKey(cleaned);
|
||||
purge();
|
||||
|
||||
if (!options.force) {
|
||||
const hit = cache.get(key);
|
||||
if (hit) {
|
||||
console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`);
|
||||
return jobTermine(cleaned, hit.result);
|
||||
}
|
||||
}
|
||||
|
||||
const existant = jobs.get(key);
|
||||
if (existant && existant.status === "running") return existant;
|
||||
if (existant && !options.force && existant.status === "success" && existant.result) return existant;
|
||||
|
||||
if (jobsEnCours() >= MAX_RECHERCHES_SIMULTANEES) {
|
||||
return {
|
||||
jobId: `refus_${Date.now().toString(36)}`,
|
||||
terme: cleaned,
|
||||
status: "error",
|
||||
etape: "Refusé",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
error: "Trop de recherches Qlik en cours. Réessayez dans quelques instants.",
|
||||
};
|
||||
}
|
||||
|
||||
const job: RechercheJob = {
|
||||
jobId: `rech_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
terme: cleaned,
|
||||
status: "running",
|
||||
etape: "Recherche des articles dans Qlik…",
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(key, job);
|
||||
console.log(`[produits/search] job=${job.jobId} démarré pour "${cleaned}"`);
|
||||
|
||||
// Fire-and-forget : le client suivra l'avancement via `etatRecherche`.
|
||||
void executerRecherche(cleaned, (etape) => { job.etape = etape; })
|
||||
.then((result) => {
|
||||
job.result = result;
|
||||
job.status = "success";
|
||||
job.etape = "Terminé";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
// On ne met en cache qu'un résultat Qlik exploitable : mémoriser un
|
||||
// repli catalogue (panne Qlik passagère) le figerait 10 minutes
|
||||
// alors qu'un simple « Relancer » aurait suffi.
|
||||
if (result.source === "qlik" && !result.qlikError) {
|
||||
cache.set(key, { at: Date.now(), result });
|
||||
}
|
||||
console.log(`[produits/search] job=${job.jobId} terminé — ${result.rows.length} produit(s) en ${result.dureeMs} ms`);
|
||||
})
|
||||
.catch((e) => {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
job.status = "error";
|
||||
job.etape = "Échec";
|
||||
job.error = messageQlikLisible(message);
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.error(`[produits/search] job=${job.jobId} échec :`, message.slice(0, 300));
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/** État courant de la recherche pour ce terme (pour le polling client). */
|
||||
export function etatRecherche(term: string): RechercheEtat {
|
||||
const cleaned = term.trim();
|
||||
const key = cacheKey(cleaned);
|
||||
purge();
|
||||
const job = jobs.get(key);
|
||||
if (job) return job;
|
||||
const hit = cache.get(key);
|
||||
if (hit) return jobTermine(cleaned, hit.result);
|
||||
return { status: "idle", terme: cleaned };
|
||||
}
|
||||
|
||||
async function executerRecherche(
|
||||
term: string,
|
||||
onEtape: (etape: string) => void = () => {},
|
||||
): Promise<ProduitRechercheResultat> {
|
||||
const started = Date.now();
|
||||
|
||||
// La recherche catalogue local tourne en parallèle : elle sert à la fois de
|
||||
// repli si Qlik échoue, et de complément (produits Nancy sans code centrale,
|
||||
// donc invisibles côté réseau).
|
||||
const localPromise = pgSearchProduits(term).catch((e) => {
|
||||
console.error("[produits/search] recherche locale échouée:", (e as Error).message);
|
||||
return [] as PgProduitSearchRow[];
|
||||
});
|
||||
|
||||
let qlik;
|
||||
try {
|
||||
onEtape("Recherche des articles dans Qlik…");
|
||||
qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.error("[produits/search] Qlik indisponible, repli catalogue local:", message);
|
||||
const locaux = await localPromise;
|
||||
return {
|
||||
rows: locaux.map(rowDepuisCatalogueSeul),
|
||||
source: "db",
|
||||
qlikError: messageQlikLisible(message),
|
||||
tronque: locaux.length >= 50,
|
||||
champUtilise: null,
|
||||
locauxHorsReseau: [],
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const codes = qlik.matches.map((m) => m.codeCentrale);
|
||||
if (codes.length === 0) {
|
||||
const locaux = await localPromise;
|
||||
return {
|
||||
rows: [],
|
||||
source: "qlik",
|
||||
// `avertissement` n'est renseigné que si Qlik a répondu sans appliquer
|
||||
// le filtre : l'utilisateur doit savoir que ce n'est pas « pas de
|
||||
// produit » mais « recherche non appliquée ».
|
||||
qlikError: qlik.avertissement,
|
||||
tronque: false,
|
||||
champUtilise: qlik.champUtilise,
|
||||
locauxHorsReseau: locaux,
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
// Les mesures réseau sortent DÉJÀ du cube de recherche (même session Qlik).
|
||||
// Les extraire dans une seconde session concurrente faisait abandonner le
|
||||
// moteur (`code 15 — Request aborted`) : c'est ce qui faisait « planter » la
|
||||
// recherche alors que la sync de la Grille, elle seule sur le serveur,
|
||||
// fonctionnait.
|
||||
//
|
||||
// Reste à récupérer le détail mensuel — indisponible dans ce cube. On le lit
|
||||
// dans le cache alimenté par les syncs fournisseur ; s'il manque, la fiche
|
||||
// produit propose son bouton « Actualiser depuis Qlik ».
|
||||
onEtape("Rapprochement avec notre catalogue…");
|
||||
const [cacheReseau, catalogue, locaux] = await Promise.all([
|
||||
getNetworkMetricsByCodeCentrale(codes).catch((e) => {
|
||||
console.error("[produits/search] lecture du cache réseau échouée:", (e as Error).message);
|
||||
return new Map<string, NetworkMetricCached>();
|
||||
}),
|
||||
// Deux clés candidates : la dimension « Article Code » et le champ
|
||||
// `article_no_centrale`. Sur l'app FF elles n'ont pas le même format et
|
||||
// une seule joint avec `articles.artcentrale`.
|
||||
pgGetProduitsByCodeCentrale([...codes, ...qlik.matches.map((m) => m.codeCentraleAlt).filter(Boolean)]),
|
||||
localPromise,
|
||||
]);
|
||||
|
||||
// Le cube de recherche utilise encore les master measures annuelles pour
|
||||
// classer rapidement les résultats. Il ne doit jamais écraser une extraction
|
||||
// 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec
|
||||
// leur mensuel. Sans cache complet, les valeurs de découverte restent
|
||||
// temporaires jusqu'au bouton « Actualiser depuis Qlik ».
|
||||
try {
|
||||
const aPersister: NetworkMetric[] = qlik.matches.map((m) => {
|
||||
const cache = cacheReseau.get(m.codeCentrale);
|
||||
const fiable = cacheReseauComplet(cache, qlik.periode);
|
||||
return {
|
||||
codeCentrale: m.codeCentrale,
|
||||
caReseau: fiable ? cache.caReseau : m.caReseau,
|
||||
qteReseau: fiable ? cache.qteReseau : m.qteReseau,
|
||||
nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau,
|
||||
caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau,
|
||||
margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau,
|
||||
periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined,
|
||||
qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined,
|
||||
metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined,
|
||||
libelleReseau: m.libelle || undefined,
|
||||
fournisseurReseau: m.fournisseur || undefined,
|
||||
};
|
||||
});
|
||||
const n = await upsertNetworkMetrics(aPersister);
|
||||
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
|
||||
} catch (e) {
|
||||
console.error("[produits/search] mise en cache des mesures échouée:", (e as Error).message);
|
||||
}
|
||||
|
||||
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
|
||||
const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined);
|
||||
const cache = cacheReseau.get(m.codeCentrale);
|
||||
const fiable = cacheReseauComplet(cache, qlik.periode);
|
||||
const qteReseau = fiable ? cache.qteReseau : m.qteReseau;
|
||||
const caReseau = fiable ? cache.caReseau : m.caReseau;
|
||||
const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau;
|
||||
const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau;
|
||||
const margePct = fiable ? cache.margePctReseau : m.margePctReseau;
|
||||
return {
|
||||
codeCentrale: m.codeCentrale,
|
||||
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
|
||||
libelleReseau: m.libelle,
|
||||
fournisseur: local?.fournisseur || m.fournisseur || "",
|
||||
codein: local?.codein ?? null,
|
||||
nomenclature: local?.nomenclature ?? "",
|
||||
stockLocal: local ? Number(local.stock_total) || 0 : null,
|
||||
qteReseau,
|
||||
caReseau,
|
||||
nbMagasinsReseau: nbMag,
|
||||
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
||||
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
|
||||
caParMagasinReseau: caParMagasin,
|
||||
prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
|
||||
margePctReseau: normalizeMargePct(margePct),
|
||||
// Le mensuel ne vient que du cache : le cube de recherche est agrégé.
|
||||
qteByMonth: fiable ? cache.qteByMonth : null,
|
||||
nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null,
|
||||
periode: qlik.periode,
|
||||
};
|
||||
});
|
||||
|
||||
// Les plus vendus du réseau d'abord : c'est l'information utile pour
|
||||
// arbitrer, un produit que le réseau ne vend pas n'intéresse personne.
|
||||
rows.sort((a, b) => b.qteReseau - a.qteReseau || a.libelle.localeCompare(b.libelle, "fr"));
|
||||
|
||||
// Les produits Nancy déjà couverts par un résultat réseau ne doivent pas
|
||||
// réapparaître dans la liste secondaire. On compare sur les deux clés
|
||||
// candidates, la jointure pouvant se faire par l'une ou par l'autre.
|
||||
const codesTrouves = new Set<string>(codes);
|
||||
for (const m of qlik.matches) if (m.codeCentraleAlt) codesTrouves.add(m.codeCentraleAlt);
|
||||
const locauxHorsReseau = locaux.filter((l) => !l.code_centrale || !codesTrouves.has(l.code_centrale));
|
||||
|
||||
// Diagnostic de la jointure : 0 rapprochement sur 40 codes signale un format
|
||||
// de clé différent entre Qlik et `articles.artcentrale`.
|
||||
if (catalogue.size === 0 && codes.length > 0) {
|
||||
console.warn(
|
||||
`[produits/search] aucun des ${codes.length} codes Qlik ne joint avec articles.artcentrale — ` +
|
||||
`échantillon Article Code=${JSON.stringify(codes.slice(0, 3))} ` +
|
||||
`article_no_centrale=${JSON.stringify(qlik.matches.slice(0, 3).map((m) => m.codeCentraleAlt))}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
source: "qlik",
|
||||
qlikError: qlik.avertissement,
|
||||
tronque: qlik.tronque,
|
||||
champUtilise: qlik.champUtilise,
|
||||
locauxHorsReseau,
|
||||
dureeMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ligne de repli : catalogue Nancy seul, sans données réseau fiables. */
|
||||
function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow {
|
||||
const qteReseau = Number(r.qte_reseau) || 0;
|
||||
const nbMag = Number(r.nb_magasins_reseau) || 0;
|
||||
return {
|
||||
codeCentrale: r.code_centrale,
|
||||
libelle: r.libelle1 || r.codein,
|
||||
libelleReseau: "",
|
||||
fournisseur: r.fournisseur,
|
||||
codein: r.codein,
|
||||
nomenclature: r.nomenclature,
|
||||
stockLocal: Number(r.stock_total) || 0,
|
||||
qteReseau,
|
||||
caReseau: 0,
|
||||
nbMagasinsReseau: nbMag,
|
||||
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
|
||||
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
|
||||
caParMagasinReseau: 0,
|
||||
prixMoyenReseau: null,
|
||||
margePctReseau: null,
|
||||
qteByMonth: null,
|
||||
nbMagByMonth: null,
|
||||
periode: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduit les pannes Qlik récurrentes en message actionnable. Les autres
|
||||
* erreurs sont renvoyées telles quelles (tronquées) pour rester diagnosticables.
|
||||
*/
|
||||
function messageQlikLisible(message: string): string {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) {
|
||||
return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard.";
|
||||
}
|
||||
if (/"code"\s*:\s*15\b/.test(message) || lower.includes("request aborted")) {
|
||||
return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez.";
|
||||
}
|
||||
if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) {
|
||||
return "Identifiants Qlik non configurés (Paramètres → Qlik).";
|
||||
}
|
||||
if (lower.includes("timeout") || lower.includes("econnrefused") || lower.includes("enotfound")) {
|
||||
return "Serveur Qlik injoignable depuis CollectFlow (réseau ou VPN).";
|
||||
}
|
||||
return message.slice(0, 300);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
PgProduitSearchRow,
|
||||
PgProduitStockRow,
|
||||
PgGammeHistoryRow,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import type { NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
NetworkMetricCached,
|
||||
} from "@/types/ff";
|
||||
|
||||
/**
|
||||
* Une ligne de résultat de recherche produit.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** État serveur renvoyé par GET/POST /api/qlik/sync. */
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
"use server";
|
||||
|
||||
/**
|
||||
* Actions des Paramètres — réservées aux administrateurs.
|
||||
* Actions des Paramètres — réservées aux administrateurs, exécutées par le
|
||||
* serveur Rust : `POST /api/actions/{nom}`.
|
||||
*
|
||||
* Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) et
|
||||
* les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé vide
|
||||
* dans le formulaire signifie « conserver celui qui est enregistré ».
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import { requireAdmin, verifierAdmin } from "@/lib/authz";
|
||||
import { CONFIG_FILE, readConfig, writeConfig } from "./config-file";
|
||||
import { diagnostiquerApiFf } from "./ff-api-diagnostic";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
import type { FfSyncStatus } from "@/types/ff";
|
||||
|
||||
export interface ChampsBaseDeDonnees {
|
||||
host: string;
|
||||
@@ -37,181 +34,45 @@ export interface ParametresAffichables {
|
||||
ffApiBaseUrl: string;
|
||||
}
|
||||
|
||||
/** Décode un composant d'URL, ou le garde tel quel s'il n'est pas encodé proprement. */
|
||||
function decoder(valeur: string): string {
|
||||
try {
|
||||
return decodeURIComponent(valeur);
|
||||
} catch {
|
||||
return valeur;
|
||||
}
|
||||
export interface ResultatAction {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Découpe l'URL `postgres://` enregistrée en champs du formulaire. */
|
||||
function lireUrlPostgres(url: string) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return {
|
||||
host: u.hostname,
|
||||
port: u.port || "5432",
|
||||
database: decoder(u.pathname.slice(1)),
|
||||
user: decoder(u.username),
|
||||
password: decoder(u.password),
|
||||
ssl: url.includes("sslmode=require"),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("[Settings] URL de base enregistrée illisible :", (e as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function construireUrlPostgres(champs: ChampsBaseDeDonnees, motDePasse: string): string {
|
||||
const user = encodeURIComponent(champs.user.trim());
|
||||
const identifiants = motDePasse ? `${user}:${encodeURIComponent(motDePasse)}` : user;
|
||||
const port = champs.port.trim() || "5432";
|
||||
return `postgres://${identifiants}@${champs.host.trim()}:${port}/${champs.database.trim()}${champs.ssl ? "?sslmode=require" : ""}`;
|
||||
}
|
||||
|
||||
/** Mot de passe saisi, sinon celui de l'URL enregistrée. */
|
||||
async function motDePasseBase(champs: ChampsBaseDeDonnees): Promise<string> {
|
||||
if (champs.password) return champs.password;
|
||||
const { url } = await readConfig();
|
||||
return (url && lireUrlPostgres(url)?.password) || "";
|
||||
}
|
||||
/** Diagnostic de l'API FF Nancy (bouton « Tester »). */
|
||||
export type DiagnosticApiFf =
|
||||
| { success: true; url: string; ms: number; status: FfSyncStatus }
|
||||
| { success: false; url: string; error: string };
|
||||
|
||||
/** Réglages enregistrés, sans aucun secret. */
|
||||
export async function getParametresAffichables(): Promise<ParametresAffichables> {
|
||||
await requireAdmin();
|
||||
const config = await readConfig();
|
||||
const base = config.url ? lireUrlPostgres(config.url) : null;
|
||||
return {
|
||||
db: base
|
||||
? {
|
||||
host: base.host,
|
||||
port: base.port,
|
||||
database: base.database,
|
||||
user: base.user,
|
||||
ssl: base.ssl,
|
||||
motDePasseEnregistre: base.password !== "",
|
||||
}
|
||||
: null,
|
||||
qlik: {
|
||||
host: config.qlikHost ?? "",
|
||||
user: config.qlikUser ?? "",
|
||||
// Même ordre de priorité que getQlikConfig() : réglage enregistré, puis variable d'environnement.
|
||||
motDePasseEnregistre: Boolean(config.qlikPassword || process.env.QLIK_PWD),
|
||||
},
|
||||
ffApiBaseUrl: config.ffApiBaseUrl ?? "",
|
||||
};
|
||||
return callAction("getParametresAffichables", []);
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(champs: ChampsBaseDeDonnees) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
|
||||
console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@"));
|
||||
const pool = new Pool({
|
||||
connectionString: url,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
await client.query("SELECT 1");
|
||||
client.release();
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Database connection test failed:", errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
export async function testDatabaseConnection(champs: ChampsBaseDeDonnees): Promise<ResultatAction> {
|
||||
return callAction("testDatabaseConnection", [champs]);
|
||||
}
|
||||
|
||||
export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
const url = construireUrlPostgres(champs, await motDePasseBase(champs));
|
||||
// Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés.
|
||||
console.log(`[Settings] Saving config to ${CONFIG_FILE}`);
|
||||
await writeConfig({ url });
|
||||
console.log("[Settings] Database configuration saved successfully.");
|
||||
|
||||
// Refresh the shared DB instance immediately
|
||||
const { refreshDb } = await import("@/db");
|
||||
refreshDb();
|
||||
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to save database configuration:", errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees): Promise<ResultatAction> {
|
||||
return callAction("saveDatabaseSettings", [champs]);
|
||||
}
|
||||
|
||||
/** Mot de passe vide ou absent : celui qui est enregistré est conservé. */
|
||||
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
await writeConfig({ qlikHost, qlikUser, ...(qlikPassword ? { qlikPassword } : {}) });
|
||||
console.log("[Settings] Qlik configuration saved.");
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Settings] Failed to save Qlik config:", msg);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string): Promise<ResultatAction> {
|
||||
return callAction("saveQlikSettings", [qlikHost, qlikUser, qlikPassword]);
|
||||
}
|
||||
|
||||
/** Mot de passe vide ou absent : on teste avec celui qui est enregistré. */
|
||||
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
|
||||
const enregistre = getQlikConfig();
|
||||
const cfg = { ...enregistre, user: qlikUser, password: qlikPassword || enregistre.password };
|
||||
if (qlikHost) cfg.host = qlikHost;
|
||||
await qlikNtlmSession(cfg);
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string): Promise<ResultatAction> {
|
||||
return callAction("testQlikConnection", [qlikHost, qlikUser, qlikPassword]);
|
||||
}
|
||||
|
||||
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
|
||||
export async function saveFfApiSettings(ffApiBaseUrl: string) {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
try {
|
||||
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
|
||||
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
|
||||
return { success: false, error: "L'URL doit commencer par http:// ou https://" };
|
||||
}
|
||||
await writeConfig({ ffApiBaseUrl: cleaned || undefined });
|
||||
// Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde.
|
||||
const { resetFfApiBaseCache } = await import("@/lib/api-ff-client");
|
||||
resetFfApiBaseCache();
|
||||
console.log(`[Settings] FF API base URL saved: ${cleaned || "(défaut)"}`);
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[Settings] Failed to save FF API config:", msg);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
export async function saveFfApiSettings(ffApiBaseUrl: string): Promise<ResultatAction> {
|
||||
return callAction("saveFfApiSettings", [ffApiBaseUrl]);
|
||||
}
|
||||
|
||||
/** Teste l'API FF Nancy (voir `diagnostiquerApiFf`). */
|
||||
export async function testFfApiConnection(ffApiBaseUrl?: string): Promise<Awaited<ReturnType<typeof diagnostiquerApiFf>>> {
|
||||
const acces = await verifierAdmin();
|
||||
if (!acces.ok) return { success: false, url: "", error: acces.message };
|
||||
return diagnostiquerApiFf(ffApiBaseUrl);
|
||||
/** Teste l'API FF Nancy (diagnostic détaillé : DNS, refus, délai, code HTTP). */
|
||||
export async function testFfApiConnection(ffApiBaseUrl?: string): Promise<DiagnosticApiFf> {
|
||||
return callAction("testFfApiConnection", [ffApiBaseUrl]);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Download, Loader2, RefreshCw, FileText } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* CollectFlow — Fichier de configuration `data/.db-config.json`.
|
||||
*
|
||||
* Module **serveur**, pas fichier d'actions : ses fonctions ne doivent pas être
|
||||
* appelables depuis le navigateur (le fichier contient des mots de passe et des
|
||||
* clés d'API). Les actions de `./actions.ts` les utilisent après contrôle du rôle.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export const DATA_DIR = path.join(process.cwd(), "data");
|
||||
export const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
||||
|
||||
export interface DbConfig {
|
||||
url: string;
|
||||
openRouterKey?: string;
|
||||
openRouterModel?: string;
|
||||
aiProvider?: "openrouter" | "google";
|
||||
googleAiKey?: string;
|
||||
googleAiModel?: string;
|
||||
/** Qlik Sense — données réseau */
|
||||
qlikHost?: string;
|
||||
qlikUser?: string;
|
||||
qlikPassword?: string;
|
||||
/**
|
||||
* API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et
|
||||
* au rattrapage des ventes par magasin dans la Grille. Configurable ici pour
|
||||
* ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer
|
||||
* sans redéployer.
|
||||
*/
|
||||
ffApiBaseUrl?: string;
|
||||
}
|
||||
|
||||
/** Lit la config existante (ou {} si absente). */
|
||||
export async function readConfig(): Promise<Partial<DbConfig>> {
|
||||
try {
|
||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont
|
||||
* conservées telles quelles (une clé passée à `undefined` est retirée).
|
||||
*/
|
||||
export async function writeConfig(patch: Partial<DbConfig>): Promise<void> {
|
||||
const existing = await readConfig();
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existing, ...patch }, null, 2));
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* CollectFlow — Diagnostic de l'API FF Nancy.
|
||||
*
|
||||
* Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la
|
||||
* route `/api/ff-status` (tout utilisateur connecté). Module serveur et non
|
||||
* fichier d'actions : chaque appelant fait son propre contrôle d'accès.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { readConfig } from "./config-file";
|
||||
|
||||
/**
|
||||
* Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
|
||||
*
|
||||
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
|
||||
* si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
|
||||
* donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
|
||||
*/
|
||||
export async function diagnostiquerApiFf(ffApiBaseUrl?: string) {
|
||||
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
|
||||
const url = `${base}/api/sync/status`;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
const ms = Date.now() - started;
|
||||
if (!res.ok) {
|
||||
return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` };
|
||||
}
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` };
|
||||
}
|
||||
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
|
||||
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
|
||||
const { normalizeSyncStatus } = await import("@/lib/api-ff-client");
|
||||
const status = normalizeSyncStatus(body);
|
||||
if (!status) {
|
||||
return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` };
|
||||
}
|
||||
return { success: true, url, ms, status };
|
||||
} catch (error: unknown) {
|
||||
const ms = Date.now() - started;
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const name = error instanceof Error ? error.name : "";
|
||||
// fetch masque la cause réelle derrière « fetch failed » : on la déplie.
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })?.cause;
|
||||
let hint = raw;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`;
|
||||
} else if (cause?.code === "ENOTFOUND") {
|
||||
hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`;
|
||||
} else if (cause?.code === "ECONNREFUSED") {
|
||||
hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`;
|
||||
} else if (cause?.code) {
|
||||
hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`;
|
||||
}
|
||||
console.error(`[Settings] FF API test KO (${url}):`, raw);
|
||||
return { success: false, url, error: hint };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
|
||||
@@ -1,39 +1,10 @@
|
||||
"use server";
|
||||
/**
|
||||
* Suppression d'un enregistrement de l'historique — action exécutée par le
|
||||
* serveur Rust : `POST /api/actions/deleteSnapshot`.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
|
||||
export async function deleteSnapshot(id: number) {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
|
||||
console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`);
|
||||
|
||||
try {
|
||||
// Condition de sécurité : match ID ET match le propriétaire (ou anonyme)
|
||||
const userCondition = userId && !isNaN(userId)
|
||||
? eq(sessionSnapshots.userId, userId)
|
||||
: isNull(sessionSnapshots.userId);
|
||||
|
||||
const result = await db.delete(sessionSnapshots)
|
||||
.where(and(eq(sessionSnapshots.id, id), userCondition))
|
||||
.returning({ deletedId: sessionSnapshots.id });
|
||||
|
||||
if (result.length === 0) {
|
||||
console.warn(`[deleteSnapshot] No rows deleted for ID ${id}. Snapshot may not exist or belongs to another user.`);
|
||||
return { success: false, error: "Snapshot non trouvé ou non autorisé" };
|
||||
}
|
||||
|
||||
console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Le détail technique reste dans les journaux du serveur.
|
||||
console.error("[deleteSnapshot] DB Error:", err);
|
||||
return { success: false, error: "erreur technique sur le serveur" };
|
||||
}
|
||||
export async function deleteSnapshot(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
return callAction("deleteSnapshot", [id]);
|
||||
}
|
||||
@@ -1,34 +1,26 @@
|
||||
"use server";
|
||||
/**
|
||||
* Historique des sessions de révision et des exports — action exécutée par le
|
||||
* serveur Rust : `POST /api/actions/getSnapshots`.
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { requireSession } from "@/lib/authz";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
|
||||
export async function getSnapshots(type?: "snapshot" | "export") {
|
||||
const utilisateur = await requireSession();
|
||||
try {
|
||||
const rawUserId = utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
/** Une ligne de `session_snapshots`, telle que le serveur la renvoie. */
|
||||
export interface SnapshotRow {
|
||||
id: number;
|
||||
userId: number | null;
|
||||
codeFournisseur: string;
|
||||
nomFournisseur: string | null;
|
||||
magasin: string;
|
||||
/** codein → { before, after } */
|
||||
changes: unknown;
|
||||
summaryJson: unknown;
|
||||
label: string | null;
|
||||
type: string | null;
|
||||
/** Date ISO de création. */
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
// Si on a un userId, on filtre par celui-ci.
|
||||
// Sinon, on filtre par userId IS NULL (mode anonyme/local)
|
||||
const userCondition = userId && !isNaN(userId)
|
||||
? eq(sessionSnapshots.userId, userId)
|
||||
: isNull(sessionSnapshots.userId);
|
||||
|
||||
const conditions = [userCondition];
|
||||
if (type) conditions.push(eq(sessionSnapshots.type, type));
|
||||
|
||||
return await db.select()
|
||||
.from(sessionSnapshots)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(sessionSnapshots.createdAt));
|
||||
} catch (err) {
|
||||
const msg = ((err as Error)?.message || String(err)).split("\n")[0];
|
||||
console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err);
|
||||
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
|
||||
// au lieu de signaler que le chargement a échoué.
|
||||
throw new Error("Chargement de l'historique impossible.");
|
||||
}
|
||||
export async function getSnapshots(type?: "snapshot" | "export"): Promise<SnapshotRow[]> {
|
||||
return callAction("getSnapshots", [type]);
|
||||
}
|
||||
@@ -1,114 +1,27 @@
|
||||
"use server";
|
||||
/**
|
||||
* Enregistrement d'une session de révision ou d'un export — action exécutée par
|
||||
* le serveur Rust : `POST /api/actions/saveSnapshot` (validation des données
|
||||
* comprise).
|
||||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { z } from "zod";
|
||||
import { sql, and, eq } from "drizzle-orm";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { callAction } from "@/lib/rpc";
|
||||
|
||||
const SaveSnapshotSchema = z.object({
|
||||
codeFournisseur: z.string(),
|
||||
nomFournisseur: z.string().optional(),
|
||||
magasin: z.string(),
|
||||
label: z.string().optional(),
|
||||
changes: z.record(z.string(), z.object({
|
||||
before: z.string().nullable(),
|
||||
after: z.string(),
|
||||
})),
|
||||
type: z.enum(["snapshot", "export"]).optional(),
|
||||
summary: z.object({
|
||||
totalRows: z.number(),
|
||||
totalCa: z.number(),
|
||||
totalMarge: z.number(),
|
||||
tauxMargeGlobal: z.number(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export async function saveSnapshot(raw: unknown) {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, error: acces.message };
|
||||
|
||||
const parsed = SaveSnapshotSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
console.error("Snapshot validation failed:", parsed.error.format());
|
||||
return { success: false, error: "Validation failed: " + parsed.error.issues.map((i: any) => i.message).join(", ") };
|
||||
}
|
||||
|
||||
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
|
||||
console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`);
|
||||
|
||||
const finalUserId = (userId && !isNaN(userId)) ? userId : null;
|
||||
|
||||
try {
|
||||
const [created] = await db
|
||||
.insert(sessionSnapshots)
|
||||
.values({
|
||||
userId: finalUserId,
|
||||
codeFournisseur,
|
||||
nomFournisseur: nomFournisseur ?? null,
|
||||
magasin,
|
||||
label: label ?? `${type === 'export' ? 'Export' : 'Snapshot'} — ${new Date().toLocaleDateString("fr-FR")}`,
|
||||
changes,
|
||||
summaryJson: summary ?? null,
|
||||
type: type ?? "snapshot",
|
||||
})
|
||||
.returning({ id: sessionSnapshots.id });
|
||||
|
||||
revalidatePath("/snapshots");
|
||||
revalidatePath("/exports");
|
||||
return { success: true, snapshotId: created?.id };
|
||||
} catch (err) {
|
||||
console.error("Initial snapshot save failed, attempting auto-repair...", err);
|
||||
|
||||
try {
|
||||
// Tentative systématique de création de table (no-op si déjà là)
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS session_snapshots (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
code_fournisseur VARCHAR(20) NOT NULL,
|
||||
nom_fournisseur VARCHAR(255),
|
||||
magasin VARCHAR(20) NOT NULL,
|
||||
changes JSONB NOT NULL,
|
||||
summary_json JSONB,
|
||||
label TEXT,
|
||||
type VARCHAR(20) DEFAULT 'snapshot',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// Si la table existait déjà mais sans la colonne 'type' ou 'user_id', on les ajoute
|
||||
try {
|
||||
await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'`);
|
||||
await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS user_id INTEGER`);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Deuxième tentative d'insertion
|
||||
const [retryCreated] = await db
|
||||
.insert(sessionSnapshots)
|
||||
.values({
|
||||
userId: finalUserId,
|
||||
codeFournisseur,
|
||||
nomFournisseur: nomFournisseur ?? null,
|
||||
magasin,
|
||||
label: label ?? `${type === 'export' ? 'Export' : 'Snapshot'} — ${new Date().toLocaleDateString("fr-FR")}`,
|
||||
changes,
|
||||
summaryJson: summary ?? null,
|
||||
type: type ?? "snapshot",
|
||||
})
|
||||
.returning({ id: sessionSnapshots.id });
|
||||
|
||||
return { success: true, snapshotId: retryCreated?.id };
|
||||
} catch (repairErr) {
|
||||
// Le détail technique (les deux erreurs) reste dans les journaux du serveur.
|
||||
console.error("Snapshot auto-repair/retry failed:", repairErr);
|
||||
return { success: false, error: "erreur technique sur le serveur" };
|
||||
}
|
||||
}
|
||||
/** Données attendues par le serveur (ex-schéma zod `SaveSnapshotSchema`). */
|
||||
export interface SaveSnapshotInput {
|
||||
codeFournisseur: string;
|
||||
nomFournisseur?: string;
|
||||
magasin: string;
|
||||
label?: string;
|
||||
changes: Record<string, { before: string | null; after: string }>;
|
||||
type?: "snapshot" | "export";
|
||||
summary?: {
|
||||
totalRows: number;
|
||||
totalCa: number;
|
||||
totalMarge: number;
|
||||
tauxMargeGlobal: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveSnapshot(raw: unknown): Promise<{ success: boolean; snapshotId?: number; error?: string }> {
|
||||
return callAction("saveSnapshot", [raw]);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
|
||||
Reference in new issue
Block a user