This commit is contained in:
Michael committed 2026-03-11 14:45:49 +01:00
commit 12809b9924
23 files changed
+617 -193

No files matched your search

+48
View File
@@ -1,4 +1,15 @@
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.
@@ -26,3 +37,40 @@ export function verifyPassword(password: string, storedHash: string): boolean {
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);
}
}
+22 -7
View File
@@ -1,6 +1,6 @@
import { db } from "@/db";
import { users } from "@/db/schema";
import { hashPassword } from "./auth-logic";
import { hashPassword, getFallbackUsers, saveFallbackUsers } from "./auth-logic";
import { count, eq, sql } from "drizzle-orm";
/**
@@ -9,10 +9,25 @@ import { count, eq, sql } from "drizzle-orm";
* Also ensures the table exists (auto-repair).
*/
export async function ensureAdminExists() {
// 1. Always ensure local JSON fallback has at least one admin
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"
}]);
}
try {
// 1. Auto-repair: Ensure table exists
console.log("[AUTH] Testing database connection for seeding...");
await db.execute(sql`SELECT 1`);
// 2. Auto-repair: Ensure table exists
await db.execute(sql`
CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS "users" (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
@@ -21,14 +36,14 @@ export async function ensureAdminExists() {
);
`);
// 2. Check if admin specifically exists
// 3. Sync admin to DB
const adminUser = await db.select()
.from(users)
.where(eq(users.username, "admin"))
.limit(1);
if (adminUser.length === 0) {
console.log("BMAD: Default admin account not found. Initializing (admin/admin)...");
console.log("[AUTH] Seeding admin to DB...");
await db.insert(users).values({
username: "admin",
passwordHash: hashPassword("admin"),
@@ -36,8 +51,8 @@ export async function ensureAdminExists() {
});
return true;
}
} catch (err) {
console.error("BMAD: CRITICAL Error checking/seeding users table.", err);
} catch (err: any) {
console.warn("[AUTH] Database seeding failed (expected if DB not configured yet):", err.message);
}
return false;
}
+139 -138
View File
@@ -5,6 +5,8 @@ import { ventesProduits } from "@/db/schema";
import { eq, and, ne, like, sql } from "drizzle-orm";
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
import { computeProductScores } from "@/lib/score-engine";
import fs from "fs";
import path from "path";
let isSchemaInitialized = false;
@@ -16,146 +18,145 @@ interface GetProductRowsInput {
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const { codeFournisseur, magasin = "TOTAL", filters } = input;
console.log(`\n>>> [getProductRows] RECEIVED CALL for supplier: ${codeFournisseur}, magasin: ${magasin}`);
// Self-healing: Ensure codeGammeInit column exists and is populated
if (!isSchemaInitialized) {
try {
await db.execute(sql`
ALTER TABLE ventes_produits
ADD COLUMN IF NOT EXISTS code_gamme_init VARCHAR(20)
`);
await db.execute(sql`
UPDATE ventes_produits
SET code_gamme_init = code_gamme
WHERE code_gamme_init IS NULL
`);
isSchemaInitialized = true;
console.log("[Schema] codeGammeInit initialized successfully.");
} catch (err) {
console.error("[Schema] Failed to initialize codeGammeInit:", err);
// We continue anyway, the mapping fallback will handle it for the UI
}
}
// ... (allowedPeriods calculation remains the same)
const now = new Date();
const allowedPeriods = new Set<string>();
for (let i = 12; i >= 0; i--) { // i >= 0 au lieu de 1 pour inclure le mois en cours
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
// Build conditions
const conditions = [
eq(ventesProduits.codeFournisseur, codeFournisseur),
ne(ventesProduits.periode, "TOTAL"),
magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"),
];
// Hierarchical filtering
if (filters?.code3) {
conditions.push(eq(ventesProduits.code3, filters.code3));
} else if (filters?.code2) {
conditions.push(like(ventesProduits.code3, `${filters.code2}%`));
} else if (filters?.code1) {
conditions.push(like(ventesProduits.code3, `${filters.code1}%`));
}
// Get all rows for this supplier
const rows = await db
.select()
.from(ventesProduits)
.where(and(...conditions));
// Group by codein and aggregate monthly sales + track store participation
const productMap = new Map<string, ProductRow>();
const storeParticipationMap = new Map<string, Map<string, Set<string>>>(); // codein -> magasin -> Set of periods
for (const row of rows) {
// Ignorer les données qui ne font pas partie des 12 mois complets
if (!allowedPeriods.has(row.periode)) continue;
const { codein, magasin, periode } = row;
// Suivi de la participation par magasin
if (!storeParticipationMap.has(codein)) storeParticipationMap.set(codein, new Map());
const productStores = storeParticipationMap.get(codein)!;
if (!productStores.has(magasin)) productStores.set(magasin, new Set());
productStores.get(magasin)!.add(periode);
const existing = productMap.get(codein);
const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0"));
const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0"));
const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0"));
if (existing) {
existing.sales12m[periode] = (existing.sales12m[periode] ?? 0) + qty;
existing.totalQuantite += qty;
existing.totalCa += ca;
existing.totalMarge += marge;
existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0;
} else {
const c3 = row.code3 || "";
const c1 = c3.slice(0, 2);
const c2 = c3.slice(0, 4);
const NOMENCLATURE_LABELS: Record<string, string> = {
"20": "LIQUIDES",
"30": "ÉPICERIE",
"40": "DPH",
"50": "BAZAR",
"60": "TEXTILE",
"70": "FRAIS",
// Rayons
"2001": "EAUX", "2002": "JUS DE FRUITS", "2003": "SODAS / BOUILLONS", "2004": "BIERES", "2005": "VINS", "2006": "CIDRES", "2007": "APERITIFS", "2008": "ALCOOLS",
"3001": "ÉPICERIE SALÉE", "3002": "ÉPICERIE SUCRÉE", "3003": "PETIT DÉJEUNER", "3004": "CONSERVES", "3005": "PLAT CUISINES",
"4001": "HYGIÈNE", "4002": "ENTRETIEN", "4003": "PARFUMERIE",
"5001": "BRICOLAGE", "5002": "MAISON", "5003": "LOISIRS", "5004": "JARDIN",
"7001": "FRUITS ET LEGUMES", "7002": "CREMERIE", "7003": "VOLAILLE", "7004": "CHARCUTERIE", "7005": "TRAITEUR", "7006": "BOULANGERIE",
};
productMap.set(codein, {
codein,
codeFournisseur: row.codeFournisseur ?? "",
nomFournisseur: row.nomFournisseur ?? "",
libelle1: row.libelle1 ?? "",
gtin: row.gtin ?? "",
reference: row.reference ?? "",
code1: c1,
libelleNiveau1: NOMENCLATURE_LABELS[c1] || `Secteur ${c1}`,
code2: c2,
libelleNiveau2: NOMENCLATURE_LABELS[c2] || `Rayon ${c2}`,
code3: c3,
libelle3: row.libelle3 ?? "",
codeGamme: (row.codeGamme as GammeCode | null),
codeGammeInit: (row.codeGammeInit as GammeCode | null) ?? (row.codeGamme as GammeCode | null),
codeGammeDraft: null,
sales12m: { [periode]: qty },
totalQuantite: qty,
totalCa: ca,
totalMarge: marge,
tauxMarge: ca > 0 ? (marge / ca) * 100 : 0,
score: 0,
workingStores: [],
aiRecommendation: null,
});
}
}
// Finaliser la liste des magasins "actifs" (travaillant le produit : >= 3 mois de vente)
for (const [codein, product] of productMap.entries()) {
const productStores = storeParticipationMap.get(codein);
if (productStores) {
const workingStores: string[] = [];
for (const [magasin, periods] of productStores.entries()) {
if (magasin === "TOTAL") continue;
if (periods.size >= 1) {
workingStores.push(magasin);
}
try {
// Self-healing: Ensure codeGammeInit column exists and is populated
if (!isSchemaInitialized) {
try {
await db.execute(sql`
ALTER TABLE ventes_produits
ADD COLUMN IF NOT EXISTS code_gamme_init VARCHAR(20)
`);
await db.execute(sql`
UPDATE ventes_produits
SET code_gamme_init = code_gamme
WHERE code_gamme_init IS NULL
`);
isSchemaInitialized = true;
console.log("[Schema] codeGammeInit initialized successfully.");
} catch (err) {
console.error("[Schema] Failed to initialize codeGammeInit:", err);
}
product.workingStores = workingStores.sort();
}
}
return computeProductScores(Array.from(productMap.values()));
const now = new Date();
const allowedPeriods = new Set<string>();
for (let i = 12; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
const conditions = [
eq(ventesProduits.codeFournisseur, codeFournisseur),
ne(ventesProduits.periode, "TOTAL"),
magasin !== "TOTAL" ? eq(ventesProduits.magasin, magasin) : ne(ventesProduits.magasin, "TOTAL"),
];
if (filters?.code3) {
conditions.push(eq(ventesProduits.code3, filters.code3));
} else if (filters?.code2) {
conditions.push(like(ventesProduits.code3, `${filters.code2}%`));
} else if (filters?.code1) {
conditions.push(like(ventesProduits.code3, `${filters.code1}%`));
}
const rows = await db
.select()
.from(ventesProduits)
.where(and(...conditions));
console.log(`[getProductRows] Fetching for ${codeFournisseur} (${magasin}). Found ${rows.length} rows.`);
const productMap = new Map<string, ProductRow>();
const storeParticipationMap = new Map<string, Map<string, Set<string>>>(); // codein -> magasin -> Set of periods
for (const row of rows) {
const { codein, magasin, periode } = row;
const isInPeriod = allowedPeriods.has(periode);
if (isInPeriod && magasin !== "TOTAL") {
if (!storeParticipationMap.has(codein)) storeParticipationMap.set(codein, new Map());
const productStores = storeParticipationMap.get(codein)!;
if (!productStores.has(magasin)) productStores.set(magasin, new Set());
productStores.get(magasin)!.add(periode);
}
let existing = productMap.get(codein);
if (!existing) {
const c3 = row.code3 || "";
const c1 = c3.slice(0, 2);
const c2 = c3.slice(0, 4);
const NOMENCLATURE_LABELS: Record<string, string> = {
"20": "LIQUIDES", "30": "ÉPICERIE", "40": "DPH", "50": "BAZAR", "60": "TEXTILE", "70": "FRAIS",
"2001": "EAUX", "2002": "JUS DE FRUITS", "2003": "SODAS / BOUILLONS", "2004": "BIERES", "2005": "VINS", "2006": "CIDRES", "2007": "APERITIFS", "2008": "ALCOOLS",
"3001": "ÉPICERIE SALÉE", "3002": "ÉPICERIE SUCRÉE", "3003": "PETIT DÉJEUNER", "3004": "CONSERVES", "3005": "PLAT CUISINES",
"4001": "HYGIÈNE", "4002": "ENTRETIEN", "4003": "PARFUMERIE",
"5001": "BRICOLAGE", "5002": "MAISON", "5003": "LOISIRS", "5004": "JARDIN",
"7001": "FRUITS ET LEGUMES", "7002": "CREMERIE", "7003": "VOLAILLE", "7004": "CHARCUTERIE", "7005": "TRAITEUR", "7006": "BOULANGERIE",
};
existing = {
codein,
codeFournisseur: row.codeFournisseur ?? "",
nomFournisseur: row.nomFournisseur ?? "",
libelle1: row.libelle1 ?? "",
gtin: row.gtin ?? "",
reference: row.reference ?? "",
code1: c1,
libelleNiveau1: NOMENCLATURE_LABELS[c1] || `Secteur ${c1}`,
code2: c2,
libelleNiveau2: NOMENCLATURE_LABELS[c2] || `Rayon ${c2}`,
code3: c3,
libelle3: row.libelle3 ?? "",
codeGamme: (row.codeGamme as GammeCode | null),
codeGammeInit: (row.codeGammeInit as GammeCode | null) ?? (row.codeGamme as GammeCode | null),
codeGammeDraft: null,
sales12m: {},
totalQuantite: 0,
totalCa: 0,
totalMarge: 0,
tauxMarge: 0,
score: 0,
workingStores: [],
aiRecommendation: null,
};
productMap.set(codein, existing);
}
if (isInPeriod) {
const qty = Math.abs(parseFloat(row.quantite?.toString() ?? "0"));
const ca = Math.abs(parseFloat(row.montantMvt?.toString() ?? "0"));
const marge = Math.abs(parseFloat(row.margeMvt?.toString() ?? "0"));
existing.sales12m[periode] = (existing.sales12m[periode] ?? 0) + qty;
existing.totalQuantite += qty;
existing.totalCa += ca;
existing.totalMarge += marge;
existing.tauxMarge = existing.totalCa > 0 ? (existing.totalMarge / existing.totalCa) * 100 : 0;
}
}
for (const [codein, product] of productMap.entries()) {
const productStores = storeParticipationMap.get(codein);
if (productStores) {
const workingStores: string[] = [];
for (const [magasin, periods] of productStores.entries()) {
if (magasin === "TOTAL") continue;
if (periods.size >= 3) {
workingStores.push(magasin);
}
}
product.workingStores = workingStores.sort();
}
}
console.log(`- Final ProductMap Size: ${productMap.size}`);
return computeProductScores(Array.from(productMap.values()));
} catch (error) {
console.error(`[getProductRows] Error for supplier ${codeFournisseur}:`, error);
return [];
}
}
@@ -145,6 +145,10 @@ export function FloatingSummaryBar() {
</div>
<div className="flex space-x-12 items-center">
<Stat
label="Nombre de Produits"
value={summary.totalProducts.toLocaleString("fr-FR")}
/>
<Stat
label="Volume Total Vendu"
value={Math.round(summary.totalQuantite).toLocaleString("fr-FR")}
@@ -15,7 +15,7 @@ function getHeatmapClass(value: number | null): string {
export const HeatmapCell = React.memo(({ value }: HeatmapCellProps) => {
const heatClass = getHeatmapClass(value);
const display = value === null || value === 0 ? "—" : Math.round(Math.abs(value)).toString();
const display = value === null || value === 0 ? "0" : Math.round(Math.abs(value)).toString();
return (
<div
@@ -42,6 +42,7 @@ function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>):
const totalCa = active.reduce((s, r) => s + (r.totalCa ?? 0), 0);
const totalMarge = active.reduce((s, r) => s + (r.totalMarge ?? 0), 0);
return {
totalProducts: rows.length,
totalRows: active.length,
totalQuantite: active.reduce((s, r) => s + (r.totalQuantite ?? 0), 0),
totalCa,
@@ -65,6 +66,7 @@ export const useGridStore = create<GridState>()(
search: "",
},
summary: {
totalProducts: 0,
totalRows: 0,
totalQuantite: 0,
totalCa: 0,
+22 -10
View File
@@ -2,23 +2,35 @@
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { and, eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import { auth } from "@/lib/auth";
export async function deleteSnapshot(id: number) {
const session = await auth();
const userId = session?.user ? Number((session.user as any).id) : null;
const rawUserId = (session?.user as any)?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
if (!userId) {
return { success: false, error: "Non autorisé" };
}
console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`);
try {
await db.delete(sessionSnapshots)
.where(and(eq(sessionSnapshots.id, id), eq(sessionSnapshots.userId, userId)));
// 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) {
console.error(err);
return { success: false, error: "Delete failed" };
} catch (err: any) {
console.error("[deleteSnapshot] DB Error:", err);
return { success: false, error: err.message || "Delete failed" };
}
}
+14 -10
View File
@@ -3,23 +3,27 @@
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { auth } from "@/lib/auth";
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, isNull } from "drizzle-orm";
export async function getSnapshots(type?: "snapshot" | "export") {
try {
const session = await auth();
const userId = session?.user ? Number((session.user as any).id) : null;
const rawUserId = (session?.user as any)?.id;
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
let query = db.select().from(sessionSnapshots);
const conditions = [];
if (userId) conditions.push(eq(sessionSnapshots.userId, userId));
// 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));
if (conditions.length > 0) {
return query.where(and(...conditions)).orderBy(desc(sessionSnapshots.createdAt));
}
return query.orderBy(desc(sessionSnapshots.createdAt));
return await db.select()
.from(sessionSnapshots)
.where(and(...conditions))
.orderBy(desc(sessionSnapshots.createdAt));
} catch (err: any) {
const msg = (err?.message || String(err)).split("\n")[0];
console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err);
+12 -4
View File
@@ -5,6 +5,7 @@ import { sessionSnapshots } from "@/db/schema";
import { z } from "zod";
import { sql, and, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
const SaveSnapshotSchema = z.object({
codeFournisseur: z.string(),
@@ -33,13 +34,18 @@ export async function saveSnapshot(raw: unknown) {
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
const session = await auth();
const userId = session?.user ? Number((session.user as any).id) : null;
const rawUserId = (session?.user as any)?.id;
const userId = rawUserId ? parseInt(String(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,
userId: finalUserId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
@@ -49,7 +55,9 @@ export async function saveSnapshot(raw: unknown) {
type: type ?? "snapshot",
})
.returning({ id: sessionSnapshots.id });
revalidatePath("/snapshots");
revalidatePath("/exports");
return { success: true, snapshotId: created?.id };
} catch (err: any) {
console.error("Initial snapshot save failed, attempting auto-repair...", err);
@@ -83,7 +91,7 @@ export async function saveSnapshot(raw: unknown) {
const [retryCreated] = await db
.insert(sessionSnapshots)
.values({
userId,
userId: finalUserId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,