Exports : reprendre les gammes changées par l'API avant d'exporter

Les exports et la copie dans l'Historique partaient des lignes chargées dans
le navigateur. Une gamme changée par l'API après le chargement n'y figurait
pas, et la copie (qui devient le dernier snapshot, celui que la Grille
réapplique) l'effaçait au rechargement suivant.

- Avant chaque export ou copie, l'écran relit les gammes enregistrées
  (dernier snapshot) et met à jour les lignes concernées.
- saveSnapshot reprend les gammes enregistrées depuis cet alignement au lieu
  de les écraser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011twCi6DCZ9xTpEpTACiBuN
This commit is contained in:
Claude committed 2026-10-05 13:35:10 +00:00
1 parent efd6c00cf0
commit cf65968431
6 files changed
+195 -34

No files matched your search

+20 -11
View File
@@ -13,6 +13,25 @@ export interface ChangementGamme {
codeGamme: string;
}
/** Gammes d'un snapshot : codein → gamme avant (état FF) / après. */
export type GammesSnapshot = Record<string, { before: string | null; after: string }>;
/**
* Dernier snapshot du fournisseur — celui que la Grille réapplique à chaque calcul
* (Phase 9). `null` s'il n'y en a aucun ; lève une erreur si la lecture échoue.
*/
export async function lireDernierSnapshot(
codeFournisseur: string,
): Promise<{ id: number; changes: GammesSnapshot } | null> {
const [dernier] = await db
.select({ id: sessionSnapshots.id, changes: sessionSnapshots.changes })
.from(sessionSnapshots)
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
.orderBy(desc(sessionSnapshots.createdAt))
.limit(1);
return dernier ? { id: dernier.id, changes: (dernier.changes ?? {}) as GammesSnapshot } : null;
}
export interface EnregistrementGammes {
codeFournisseur: string;
nomFournisseur?: string | null;
@@ -35,17 +54,7 @@ export async function enregistrerGammes(input: EnregistrementGammes): Promise<nu
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 }>)
: {};
const prevChanges = (await lireDernierSnapshot(codeFournisseur))?.changes ?? {};
// Merger les nouveaux changements par-dessus l'existant
const mergedChanges = { ...prevChanges };
@@ -0,0 +1,37 @@
"use server";
import { z } from "zod";
import { verifierSession } from "@/lib/authz";
import { lireDernierSnapshot } from "./enregistrer-gammes";
/**
* Gammes enregistrées pour un fournisseur (dernier snapshot), quelle qu'en soit
* l'origine : la Grille, ou l'API /api/v1 pendant que la Grille était ouverte.
*
* `snapshotId` identifie l'état lu : `saveSnapshot` s'en sert pour reprendre les
* gammes enregistrées APRÈS cette lecture au lieu de les écraser.
*/
export async function lireGammesEnregistrees(
raw: unknown,
): Promise<
| { success: true; snapshotId: number | null; gammes: Record<string, string> }
| { success: false; error: string }
> {
const acces = await verifierSession();
if (!acces.ok) return { success: false, error: acces.message };
const parsed = z.string().min(1).safeParse(raw);
if (!parsed.success) return { success: false, error: "Code fournisseur manquant." };
try {
const dernier = await lireDernierSnapshot(parsed.data);
const gammes: Record<string, string> = {};
for (const [codein, delta] of Object.entries(dernier?.changes ?? {})) {
if (delta?.after) gammes[codein] = delta.after;
}
return { success: true, snapshotId: dernier?.id ?? null, gammes };
} catch (err) {
console.error("[lireGammesEnregistrees]", err instanceof Error ? err.message : String(err));
return { success: false, error: "lecture des gammes enregistrées impossible" };
}
}
@@ -27,8 +27,11 @@ import {
exporterTousProduits,
imprimerPdf,
lignesModifiees,
synchroniserGammes,
} from "@/features/grid/lib/exports";
type Synchro = Awaited<ReturnType<typeof synchroniserGammes>>;
function Stat({ label, value, sub, subColor }: { label: string; value: string; sub?: string; subColor?: string }) {
return (
<div className="flex flex-col leading-tight">
@@ -101,33 +104,53 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
}
};
/**
* Gammes enregistrées depuis le chargement (API /api/v1, autre onglet) : l'écran
* les reprend avant tout export, sinon le fichier et l'Historique partaient de
* gammes périmées — et la copie dans l'Historique les effaçait.
*/
const alignerSurServeur = async (): Promise<Synchro> => {
if (!supplierCode) return { snapshotId: null, misesAJour: 0 };
const synchro = await synchroniserGammes(supplierCode);
if (synchro.misesAJour > 0) {
const n = synchro.misesAJour;
toast.info(`${n} gamme${n > 1 ? "s ont" : " a"} été modifiée${n > 1 ? "s" : ""} entre-temps côté serveur (API) : l'écran a été mis à jour.`);
}
return synchro;
};
/**
* Enregistre une copie de la session dans l'Historique.
* Renvoie l'identifiant créé, ou `null` en cas d'échec (déjà signalé).
* `synchro` : alignement déjà fait par l'appelant, sinon il est fait ici.
*/
const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot") => {
const handleSnapshot = async (labelOverride?: string, type: "snapshot" | "export" = "snapshot", synchro?: Synchro) => {
if (rows.length === 0) return null;
const { draftChanges } = useGridStore.getState();
// Tous les changements : brouillons + gammes déjà enregistrées (≠ FF)
const changes = Object.fromEntries(
lignesModifiees().map(r => [
r.codein,
{ before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string },
])
);
try {
const { snapshotId: baseSnapshotId } = synchro ?? await alignerSurServeur();
// Relu après l'alignement : les lignes et les totaux ont pu changer.
const { draftChanges, rows: lignes, summary: totaux } = useGridStore.getState();
if (lignes.length === 0) return null;
// Tous les changements : brouillons + gammes déjà enregistrées (≠ FF)
const changes = Object.fromEntries(
lignesModifiees().map(r => [
r.codein,
{ before: r.codeGammeInit, after: (draftChanges[r.codein] ?? r.codeGamme) as string },
])
);
const res = await saveSnapshot({
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
nomFournisseur: rows[0].nomFournisseur,
codeFournisseur: filterFournisseur || lignes[0].codeFournisseur,
nomFournisseur: lignes[0].nomFournisseur,
magasin: activeMagasin || "TOTAL",
label: labelOverride || `Session ${rows[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`,
label: labelOverride || `Session ${lignes[0].nomFournisseur} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`,
changes,
type,
baseSnapshotId,
summary: {
totalRows: summary.totalRows,
totalCa: summary.totalCa,
totalMarge: summary.totalMarge,
tauxMargeGlobal: summary.tauxMargeGlobal,
totalRows: totaux.totalRows,
totalCa: totaux.totalCa,
totalMarge: totaux.totalMarge,
tauxMargeGlobal: totaux.tauxMargeGlobal,
},
});
if (!res.success) throw new Error(res.error);
@@ -139,12 +162,12 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
}
};
/** Lance un export avec indicateur et messages d'erreur communs. */
const lancer = async (cle: string, action: () => Promise<boolean>, vide: string) => {
/** Lance un export (écran aligné sur le serveur) avec indicateur et messages d'erreur communs. */
const lancer = async (cle: string, action: (synchro: Synchro) => Promise<boolean>, vide: string) => {
if (enCours) return;
setEnCours(cle);
try {
const ok = await action();
const ok = await action(await alignerSurServeur());
if (!ok) toast.info(vide);
} catch (err) {
toast.erreur(`L'export a échoué : ${messageErreur(err)}`);
@@ -189,11 +212,11 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
<DropdownMenuLabel className="text-xs font-medium text-[var(--text-secondary)]">Fichier d&apos;import des gammes</DropdownMenuLabel>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => lancer("modifs", async () => {
onSelect={() => lancer("modifs", async (synchro) => {
if (lignesModifiees().length === 0) return false;
const nom = lignesModifiees()[0].nomFournisseur;
// Copie dans l'Historique, onglet « Exports »
await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export");
await handleSnapshot(`Export ${nom} — ${new Date().toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}`, "export", synchro);
return exporterFichierGammes(true);
}, "Aucune gamme n'a été modifiée par rapport à FF.")}
>
+17 -1
View File
@@ -3,10 +3,12 @@
*
* Il y avait deux menus d'export (en-tête et barre du bas) aux droits différents.
* Les bibliothèques lourdes (exceljs, jspdf) ne sont chargées qu'au clic.
* Les lignes sont lues dans le store au moment de l'export.
* Les lignes sont lues dans le store au moment de l'export, après
* `synchroniserGammes` : l'API peut avoir changé des gammes depuis le chargement.
*/
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { lireGammesEnregistrees } from "@/features/grid/api/lire-gammes-enregistrees";
import type { ProductRow } from "@/types/grid";
function telecharger(blob: Blob, nomFichier: string) {
@@ -25,6 +27,20 @@ function gammeEffective(r: ProductRow, drafts: Record<string, string>): string |
return (drafts[r.codein] ?? r.codeGamme) as string | null;
}
/**
* Aligne les lignes affichées sur les gammes enregistrées côté serveur, sans
* recharger toute la Grille. À appeler avant un export ou une copie dans
* l'Historique. `snapshotId` est à transmettre à `saveSnapshot`.
*/
export async function synchroniserGammes(
codeFournisseur: string,
): Promise<{ snapshotId: number | null; misesAJour: number }> {
const res = await lireGammesEnregistrees(codeFournisseur);
if (!res.success) throw new Error(res.error);
const misesAJour = useGridStore.getState().applyServerGammes(res.gammes);
return { snapshotId: res.snapshotId, misesAJour };
}
/** Lignes dont la gamme diffère de la gamme enregistrée dans FF. */
export function lignesModifiees(): ProductRow[] {
const { rows, draftChanges } = useGridStore.getState();
+29
View File
@@ -80,6 +80,8 @@ interface GridState {
clearDrafts: (codeins: string[]) => void;
/** Apply saved drafts onto row.codeGamme so change indicators persist after save */
applyDraftsToRows: (draftsToApply: Record<string, GammeCode>) => void;
/** Reporte les gammes enregistrées côté serveur ; renvoie le nombre de lignes changées. */
applyServerGammes: (gammes: Record<string, string>) => number;
setFilter: (key: Exclude<keyof GridFilters, "code3">, value: string | null) => void;
/** Nomenclatures retenues. `null` = aucun filtre, `[]` = rien de coché. */
setCode3Filter: (codes: string[] | null) => void;
@@ -240,6 +242,33 @@ export const useGridStore = create<GridState>()(
});
set({ rows, rowsByCodein: indexRows(rows), summary: computeSummary(rows, get().draftChanges, get().activeMagasin) });
},
/**
* Gammes enregistrées hors de cet écran (API /api/v1, autre onglet) : les
* lignes prennent la valeur qu'un rechargement leur donnerait. Une
* modification en cours qui tombe sur cette valeur n'a plus lieu d'être.
*/
applyServerGammes: (gammes) => {
const { rows, draftChanges, activeMagasin } = get();
const changees: string[] = [];
const next = rows.map((r) => {
const gamme = gammes[r.codein];
if (gamme === undefined || gamme === r.codeGamme) return r;
changees.push(r.codein);
return { ...r, codeGamme: gamme as GammeCode };
});
if (changees.length === 0) return 0;
const drafts = { ...draftChanges };
for (const codein of changees) {
if (drafts[codein] === gammes[codein]) delete drafts[codein];
}
set({
rows: next,
rowsByCodein: indexRows(next),
draftChanges: drafts,
summary: computeSummary(next, drafts, activeMagasin),
});
return changees.length;
},
setFilter: (key, value) => {
set((state) => ({ ...state, filters: { ...state.filters, [key]: value } }));
},
+48 -1
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { sql, and, eq } from "drizzle-orm";
import { verifierSession } from "@/lib/authz";
import { revalidatePath } from "next/cache";
import { lireDernierSnapshot, type GammesSnapshot } from "@/features/grid/api/enregistrer-gammes";
const SaveSnapshotSchema = z.object({
codeFournisseur: z.string(),
@@ -17,6 +18,11 @@ const SaveSnapshotSchema = z.object({
after: z.string(),
})),
type: z.enum(["snapshot", "export"]).optional(),
/**
* Snapshot sur lequel l'écran s'est aligné juste avant (cf. lireGammesEnregistrees),
* `null` s'il n'y en avait aucun. Absent : tout le dernier snapshot est repris.
*/
baseSnapshotId: z.number().int().nullable().optional(),
summary: z.object({
totalRows: z.number(),
totalCa: z.number(),
@@ -35,7 +41,7 @@ export async function saveSnapshot(raw: unknown) {
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 { codeFournisseur, nomFournisseur, magasin, label, summary, type, baseSnapshotId } = parsed.data;
const rawUserId = acces.utilisateur.id;
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
@@ -43,6 +49,16 @@ export async function saveSnapshot(raw: unknown) {
const finalUserId = (userId && !isNaN(userId)) ? userId : null;
// Ce snapshot devient le dernier, celui que la Grille réapplique : il ne doit
// pas effacer les gammes enregistrées depuis que l'écran s'est aligné (API).
let changes = parsed.data.changes;
try {
changes = await reprendreGammesRecentes(codeFournisseur, changes, baseSnapshotId);
} catch (err) {
// Table absente ou base indisponible : l'insertion ci-dessous le dira.
console.error("[saveSnapshot] lecture du dernier snapshot KO:", err);
}
try {
const [created] = await db
.insert(sessionSnapshots)
@@ -112,3 +128,34 @@ export async function saveSnapshot(raw: unknown) {
}
}
}
/**
* Ajoute aux gammes de l'écran celles enregistrées depuis le snapshot `baseSnapshotId`
* (sur lequel l'écran s'est aligné) : ce sont des écritures que l'écran n'a pas vues,
* typiquement par l'API. Les autres gammes du dernier snapshot, l'écran les
* connaissait : son état (retours à la gamme FF compris) fait foi.
*/
async function reprendreGammesRecentes(
codeFournisseur: string,
changes: GammesSnapshot,
baseSnapshotId: number | null | undefined,
): Promise<GammesSnapshot> {
const dernier = await lireDernierSnapshot(codeFournisseur);
if (!dernier || dernier.id === baseSnapshotId) return changes;
let base: GammesSnapshot = {};
if (baseSnapshotId != null) {
const [ligne] = await db
.select({ changes: sessionSnapshots.changes })
.from(sessionSnapshots)
.where(and(eq(sessionSnapshots.id, baseSnapshotId), eq(sessionSnapshots.codeFournisseur, codeFournisseur)))
.limit(1);
base = (ligne?.changes ?? {}) as GammesSnapshot;
}
const fusion = { ...changes };
for (const [codein, delta] of Object.entries(dernier.changes)) {
if (delta?.after && delta.after !== base[codein]?.after) fusion[codein] = delta;
}
return fusion;
}