From adc8d357c9294bd32c801fbc80a507b3c659990d Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 10 Jul 2026 12:30:49 +0200 Subject: [PATCH] fix: dedupe cube_pa rows by artnoid before full refresh Cube_PA is a heap in SQL Server with no primary key, while the PostgreSQL cube_pa table uses artnoid as PRIMARY KEY. The cube normally emits one row per article, but for 3 articles supplied by two vendors it emitted both vendor prices, so fullRefresh hit: duplicate key value violates unique constraint "cube_pa_pkey" These are not exact duplicates: the two rows carry different PA values, so the choice changes the article's purchase price. Resolve by keeping the price whose last change is the most recent, read from the dated history packed into ARTFOU2.PRIXACHAT ("[01/01/1901,0.990:16/05/2018,1.010]"). SUIVIDATEMODIF cannot be used alone: it is not touched when only the price changes (S028 shows modif 2017 but a price change in 2025). It only breaks ties. The lookup runs solely when duplicates are present, so the nightly sync is unaffected in the normal case. Verified against the live 427 752-row Cube_PA: 427 749 rows out, all artnoid distinct, no fallback triggered. Parsing PRIXACHAT over 20 000 ARTFOU2 rows: 19 999 parsed, 1 empty, 0 failures. Co-Authored-By: Claude Opus 4.8 --- sync/tables/stock.js | 74 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/sync/tables/stock.js b/sync/tables/stock.js index 2da8a96..6714322 100644 --- a/sync/tables/stock.js +++ b/sync/tables/stock.js @@ -11,6 +11,48 @@ const STOCK_COLS = [ const PA_COLS = ['artnoid','pa']; const PV_COLS = ['artnoid','site','pv']; +// ARTFOU2.PRIXACHAT empile l'historique des prix d'un fournisseur sous forme +// "[01/01/1901,0.990:16/05/2018,1.010]". Le dernier couple porte le prix courant +// et la date à laquelle il a été appliqué. +function lastPriceChange(s) { + if (!s) return null; + const body = String(s).trim().replace(/^\[/, '').replace(/\]$/, ''); + if (!body) return null; + const pairs = body.split(':'); + const [d, p] = pairs[pairs.length - 1].split(','); + const m = d && d.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + const price = parseFloat(p); + if (!m || Number.isNaN(price)) return null; + return { date: Date.UTC(+m[3], +m[2] - 1, +m[1]), price }; +} + +// Pour chaque artnoid en doublon, retrouve le prix dont le changement est le plus +// récent. Égalité de date de prix → on départage sur SUIVIDATEMODIF de la ligne. +async function resolveDuplicatePa(ms, artnoids) { + const ids = artnoids.map(Number).filter(Number.isFinite); + if (!ids.length) return new Map(); + + const res = await ms.request().query(` + SELECT f1.ART_NO_ID, f2.PRIXACHAT, f2.SUIVIDATEMODIF + FROM ARTFOU1 f1 + JOIN ARTFOU2 f2 ON f2.IDARTFOU1 = f1.NO_ID + WHERE f1.ART_NO_ID IN (${ids.join(',')}) + `); + + const best = new Map(); + for (const r of res.recordset) { + const lp = lastPriceChange(r.PRIXACHAT); + if (!lp) continue; + const key = String(r.ART_NO_ID); + const modif = r.SUIVIDATEMODIF ? new Date(r.SUIVIDATEMODIF).getTime() : 0; + const prev = best.get(key); + if (!prev || lp.date > prev.date || (lp.date === prev.date && modif > prev.modif)) { + best.set(key, { date: lp.date, price: lp.price, modif }); + } + } + return best; +} + async function syncStock(force) { const ms = await getMssql(); const pg = getPg(); @@ -57,7 +99,37 @@ async function syncStock(force) { // === Cube_PA (full refresh) === try { const res = await ms.request().query(`SELECT ArtNoId, PA FROM Cube_PA`); - const rows = res.recordset.map(r => ({ artnoid: r.ArtNoId, pa: r.PA })); + const raw = res.recordset.map(r => ({ artnoid: r.ArtNoId, pa: r.PA })); + + // artnoid est PK côté PostgreSQL, mais le cube peut sortir deux lignes pour + // un article livré par deux fournisseurs → l'INSERT violerait cube_pa_pkey. + const groups = new Map(); + for (const r of raw) { + const key = String(r.artnoid); + const g = groups.get(key); + if (g) g.push(r); else groups.set(key, [r]); + } + const dupIds = [...groups].filter(([, g]) => g.length > 1).map(([id]) => id); + + let rows = raw; + if (dupIds.length) { + const best = await resolveDuplicatePa(ms, dupIds); + rows = []; + for (const [key, g] of groups) { + if (g.length === 1) { rows.push(g[0]); continue; } + const b = best.get(key); + let chosen = b && g.find(x => Math.abs(parseFloat(x.pa) - b.price) < 0.005); + if (!chosen) { + // Pas de prix fournisseur exploitable : on retient le PA le plus élevé, + // qui sous-estime la marge plutôt que de l'inventer. + chosen = g.reduce((a, x) => (parseFloat(x.pa) > parseFloat(a.pa) ? x : a)); + console.warn(`[cube_pa] artnoid ${key} non résolu → PA le plus élevé retenu (${chosen.pa})`); + } + rows.push(chosen); + } + console.log(`[cube_pa] ${dupIds.length} artnoid dédupliqués`); + } + const count = await fullRefresh(pg, 'cube_pa', rows, PA_COLS); await logSync(pg, 'cube_pa', count, 'ok'); console.log(`[cube_pa] ${count} lignes refresh`);