mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 05:06:56 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf65968431 | ||
|
|
efd6c00cf0 |
No files matched your search
@@ -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'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.")}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } }));
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user