Author SHA1 Message Date
MichaelandClaude Opus 4.8 adc8d357c9 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>
2026-07-10 12:30:49 +02:00
Michael 950539472f Merge remote-tracking branch 'origin/claude/gallant-edison-i5bg90' 2026-07-09 09:13:35 +02:00
LogiFlow e01048fe3b Merge pull request #2 from R0m1k3/feat/artcentrale-api
feat(articles): expose and filter code centrale (artcentrale)
2026-07-09 09:10:28 +02:00
MichaelandClaude Opus 4.8 f00b9c4b14 feat(articles): expose and filter code centrale (artcentrale)
The code centrale is LaFoir'Fouille's 11-digit product identifier
(prefix 1000x). Only products referenced by the central purchasing
office have one: ~355 800 of 427 700 articles.

The column was already synced from SQL Server and returned by the
detail route via `a.*`, but was absent from the list route and could
not be filtered on.

- list route: select artcentrale, add `artcentrale` (partial match)
  and `has_artcentrale=1|0` query params
- filter matches on prefix AND length 11, so malformed values with a
  valid prefix ('10000', '10000192289P') are excluded
- COALESCE in the predicate: without it, NOT (NULL LIKE '1000%') is
  NULL and the 61 442 NULL rows would vanish from has_artcentrale=0
- normalise '' to null in both routes so they agree
- sync: use safeStr so empty values are stored as NULL, not ''

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:09:25 +02:00
4 changed files with 103 additions and 6 deletions

No files matched your search

+10 -1
View File
@@ -32,9 +32,17 @@ GET /api/articles
| `ean` | string | EAN / GTIN | — |
| `codefou` | string | Code fournisseur (partiel) | — |
| `actif` | `1`/`0`| `1` = actif, `0` = suspendu | — |
| `artcentrale` | string | Code centrale (partiel) | — |
| `has_artcentrale` | `1`/`0` | `1` = référencé centrale, `0` = non référencé | — |
| `page` | int | Numéro de page | 1 |
| `limit` | int | Lignes par page (max 500) | 50 |
> **Code centrale** (`artcentrale`) : identifiant LaFoir'Fouille sur 11 chiffres, préfixe `1000x`
> (ex. `10000137005`). Seuls les produits référencés par la centrale en possèdent un —
> environ 355 800 articles sur 427 700. Pour les autres, le champ vaut `null`.
> `has_artcentrale=1` ne retient que les codes conformes : quelques valeurs parasites
> (`10000`, `10000192289P`) ont le bon préfixe mais pas la bonne longueur, et sont exclues.
**Réponse** :
```json
{
@@ -54,6 +62,7 @@ GET /api/articles
"suspendu": null,
"suividatecreation": "2020-01-15T00:00:00.000Z",
"suividatemodif": "2024-06-01T00:00:00.000Z",
"artcentrale": "10000137005",
"prix_vente_mini": 15.00,
"prix_vente_maxi": 25.00,
"eco_ttc": 0.10,
@@ -85,7 +94,7 @@ GET /api/articles/:id
**Réponse** :
```json
{
"article": { "no_id": 12345, "codein": "...", "libelle1": "...", "pa": 10.50, "..." : "..." },
"article": { "no_id": 12345, "codein": "...", "libelle1": "...", "artcentrale": "10000137005", "pa": 10.50, "..." : "..." },
"gtins": [
{ "gtin": "3760000000001", "preferentiel": 1 }
],
+19 -3
View File
@@ -2,11 +2,12 @@ const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// GET /api/articles?search=&codein=&ean=&actif=&codefou=&page=&limit=
// GET /api/articles?search=&codein=&ean=&actif=&codefou=&artcentrale=&has_artcentrale=&page=&limit=
router.get('/', async (req, res) => {
try {
const pool = getPool();
const { search = '', codein = '', ean = '', actif = '', codefou = '', page = 1, limit = 50 } = req.query;
const { search = '', codein = '', ean = '', actif = '', codefou = '',
artcentrale = '', has_artcentrale = '', page = 1, limit = 50 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.max(1, Math.min(parseInt(limit) || 50, 500));
const offsetNum = (pageNum - 1) * limitNum;
@@ -16,6 +17,7 @@ router.get('/', async (req, res) => {
a.NO_ID, a.CODEIN, a.LIBELLE1, a.LIBELLE2, a.LIB_TICKET,
a.TAX_CODE, a.ACH_CODE, a.UTILISABLE, a.ACTIF, a.SUSPENDU,
a.SUIVIDATECREATION, a.SUIVIDATEMODIF,
NULLIF(TRIM(a.ARTCENTRALE), '') AS ARTCENTRALE,
ai.PRIX_VENTE_MINI, ai.PRIX_VENTE_MAXI, ai.ECO_TTC,
ai.ON_WEB, ai.INTERDIT_REMISE, ai.NOMPHOTO,
ai.DATEDEBVENTE, ai.DATEFINVENTE,
@@ -50,9 +52,22 @@ router.get('/', async (req, res) => {
AND ($4 = '%%' OR EXISTS (
SELECT 1 FROM ARTFOU1 f WHERE f.ART_NO_ID = a.NO_ID AND f.CODE LIKE $4
))
AND ($6 = '%%' OR TRIM(a.ARTCENTRALE) LIKE $6)
-- code centrale LaFoir'Fouille : préfixe 1000x sur 11 chiffres.
-- COALESCE car les articles non référencés valent '' ou NULL.
AND ($7 = '' OR (
$7 = '1' AND TRIM(COALESCE(a.ARTCENTRALE, '')) LIKE '1000%'
AND LENGTH(TRIM(COALESCE(a.ARTCENTRALE, ''))) = 11
) OR (
$7 = '0' AND NOT (
TRIM(COALESCE(a.ARTCENTRALE, '')) LIKE '1000%'
AND LENGTH(TRIM(COALESCE(a.ARTCENTRALE, ''))) = 11
)
))
ORDER BY a.LIBELLE1
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [`%${search}%`, `%${codein}%`, `%${ean}%`, `%${codefou}%`, actif]);
`, [`%${search}%`, `%${codein}%`, `%${ean}%`, `%${codefou}%`, actif,
`%${artcentrale}%`, has_artcentrale]);
const articles = result.rows.map(a => {
const photoCode = a.nomphoto ? a.nomphoto.replace(/\.[^.]+$/, '') : null;
@@ -123,6 +138,7 @@ router.get('/:id', async (req, res) => {
res.json({
article: {
...art,
artcentrale: art.artcentrale?.trim() || null,
photo_url: photoCode ? `/api/articles/${req.params.id}/photo` : null,
photo_url_large: photoCode ? `/api/articles/${req.params.id}/photo?size=large` : null,
},
+1 -1
View File
@@ -44,7 +44,7 @@ async function syncArticles(force) {
suividatecreation: r.SUIVIDATECREATION,
suividatemodif: r.SUIVIDATEMODIF,
nom_no_id: r.NOM_NO_ID,
artcentrale: r.ARTCENTRALE ?? null,
artcentrale: safeStr(r.ARTCENTRALE),
}));
const count = await batchUpsert(pg, 'articles', rows, ['no_id'], ARTICLES_COLS);
+73 -1
View File
@@ -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`);