mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-13 02:07:37 +02:00
feat: Introduce product scoring based on weighted sales metrics and store participation, integrated into the product grid API, and add a performance test script.
This commit is contained in:
1 parent
2a76745f29
commit
3ce22defa7
3 files changed
+185
-141
No files matched your search
@@ -0,0 +1,39 @@
|
||||
const { computeProductScores } = require('./src/lib/score-engine');
|
||||
|
||||
// Simulate 50,000 rows
|
||||
const largeDataset = Array.from({ length: 50000 }, (_, i) => ({
|
||||
codein: `item-${i}`,
|
||||
codeFournisseur: 'A032',
|
||||
nomFournisseur: 'ACCESS DECO',
|
||||
libelle1: `Product ${i}`,
|
||||
gtin: '123456789',
|
||||
reference: 'REF',
|
||||
code1: '20',
|
||||
libelleNiveau1: 'LIQUIDES',
|
||||
code2: '2001',
|
||||
libelleNiveau2: 'EAUX',
|
||||
code3: '200101',
|
||||
libelle3: 'EAU SOURCE',
|
||||
codeGamme: 'A',
|
||||
codeGammeInit: 'A',
|
||||
codeGammeDraft: null,
|
||||
sales12m: {},
|
||||
totalQuantite: Math.random() * 1000,
|
||||
totalCa: Math.random() * 5000,
|
||||
totalMarge: Math.random() * 1000,
|
||||
tauxMarge: 20,
|
||||
score: 0,
|
||||
workingStores: ['MAG1', 'MAG2']
|
||||
}));
|
||||
|
||||
console.log(`Starting test with ${largeDataset.length} rows...`);
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const result = computeProductScores(largeDataset);
|
||||
const end = Date.now();
|
||||
console.log(`Success! Processed ${result.length} rows in ${end - start}ms.`);
|
||||
} catch (error) {
|
||||
console.error('Failed!', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -17,145 +17,150 @@ interface GetProductRowsInput {
|
||||
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
|
||||
const { codeFournisseur, magasin = "TOTAL", filters } = input;
|
||||
|
||||
// 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);
|
||||
// We continue anyway, the mapping fallback will handle it for the UI
|
||||
}
|
||||
product.workingStores = workingStores.sort();
|
||||
}
|
||||
}
|
||||
|
||||
return computeProductScores(Array.from(productMap.values()));
|
||||
// ... (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 >= 3) {
|
||||
workingStores.push(magasin);
|
||||
}
|
||||
}
|
||||
product.workingStores = workingStores.sort();
|
||||
}
|
||||
}
|
||||
|
||||
return computeProductScores(Array.from(productMap.values()));
|
||||
} catch (error) {
|
||||
console.error(`[getProductRows] Error for supplier ${codeFournisseur}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,9 @@ export function computeProductScores(
|
||||
};
|
||||
});
|
||||
|
||||
const maxQty = Math.max(...weightedData.map((d) => d.wQty));
|
||||
const maxCa = Math.max(...weightedData.map((d) => d.wCa));
|
||||
const maxMarge = Math.max(...weightedData.map((d) => d.wMarge));
|
||||
const maxQty = weightedData.reduce((max, d) => Math.max(max, d.wQty), 0);
|
||||
const maxCa = weightedData.reduce((max, d) => Math.max(max, d.wCa), 0);
|
||||
const maxMarge = weightedData.reduce((max, d) => Math.max(max, d.wMarge), 0);
|
||||
|
||||
// 2. Calculer les moyennes et totaux globaux
|
||||
const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0);
|
||||
|
||||
Reference in new issue
Block a user