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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-07-10 12:30:49 +02:00
1 parent 950539472f
commit adc8d357c9
1 file changed
+73 -1
+73 -1
View File
@@ -11,6 +11,48 @@ const STOCK_COLS = [
const PA_COLS = ['artnoid','pa']; const PA_COLS = ['artnoid','pa'];
const PV_COLS = ['artnoid','site','pv']; 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) { async function syncStock(force) {
const ms = await getMssql(); const ms = await getMssql();
const pg = getPg(); const pg = getPg();
@@ -57,7 +99,37 @@ async function syncStock(force) {
// === Cube_PA (full refresh) === // === Cube_PA (full refresh) ===
try { try {
const res = await ms.request().query(`SELECT ArtNoId, PA FROM Cube_PA`); 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); const count = await fullRefresh(pg, 'cube_pa', rows, PA_COLS);
await logSync(pg, 'cube_pa', count, 'ok'); await logSync(pg, 'cube_pa', count, 'ok');
console.log(`[cube_pa] ${count} lignes refresh`); console.log(`[cube_pa] ${count} lignes refresh`);