Author SHA1 Message Date
Claude c5b0896bb4 fix: dedupe foucad rows by foucode before full refresh
The source MSSQL FOUCAD table can return several rows for the same
FOUCODE, but the PostgreSQL foucad table uses foucode as PRIMARY KEY.
The bulk INSERT in fullRefresh has no conflict handling, so duplicate
FOUCODE values triggered:

  duplicate key value violates unique constraint "foucad_pkey"

Deduplicate by foucode before insert, keeping the most recently
modified row (suividatemodif). Routes join foucad 1:1 on foucode, so
one config row per supplier code is the intended shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ad1qwhDEHNniSUDveTQHL
2026-06-29 06:30:00 +00:00
4 changed files with 18 additions and 31 deletions

No files matched your search

+1 -10
View File
@@ -32,17 +32,9 @@ 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
{
@@ -62,7 +54,6 @@ 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,
@@ -94,7 +85,7 @@ GET /api/articles/:id
**Réponse** :
```json
{
"article": { "no_id": 12345, "codein": "...", "libelle1": "...", "artcentrale": "10000137005", "pa": 10.50, "..." : "..." },
"article": { "no_id": 12345, "codein": "...", "libelle1": "...", "pa": 10.50, "..." : "..." },
"gtins": [
{ "gtin": "3760000000001", "preferentiel": 1 }
],
+3 -19
View File
@@ -2,12 +2,11 @@ const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// GET /api/articles?search=&codein=&ean=&actif=&codefou=&artcentrale=&has_artcentrale=&page=&limit=
// GET /api/articles?search=&codein=&ean=&actif=&codefou=&page=&limit=
router.get('/', async (req, res) => {
try {
const pool = getPool();
const { search = '', codein = '', ean = '', actif = '', codefou = '',
artcentrale = '', has_artcentrale = '', page = 1, limit = 50 } = req.query;
const { search = '', codein = '', ean = '', actif = '', codefou = '', 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;
@@ -17,7 +16,6 @@ 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,
@@ -52,22 +50,9 @@ 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,
`%${artcentrale}%`, has_artcentrale]);
`, [`%${search}%`, `%${codein}%`, `%${ean}%`, `%${codefou}%`, actif]);
const articles = result.rows.map(a => {
const photoCode = a.nomphoto ? a.nomphoto.replace(/\.[^.]+$/, '') : null;
@@ -138,7 +123,6 @@ 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,
},
+13 -1
View File
@@ -171,7 +171,19 @@ async function syncAppro(force) {
suividatemodif: r.SUIVIDATEMODIF,
}));
const count = await fullRefresh(pg, 'foucad', rows, FOUCAD_COLS);
// foucode est PK côté PostgreSQL mais la source FOUCAD peut renvoyer
// plusieurs lignes par FOUCODE → on déduplique en gardant la plus récente
// (suividatemodif), sinon l'INSERT viole foucad_pkey.
const byCode = new Map();
for (const row of rows) {
const prev = byCode.get(row.foucode);
if (!prev || (row.suividatemodif ?? 0) >= (prev.suividatemodif ?? 0)) {
byCode.set(row.foucode, row);
}
}
const dedupRows = [...byCode.values()];
const count = await fullRefresh(pg, 'foucad', dedupRows, FOUCAD_COLS);
await logSync(pg, 'foucad', count, 'ok');
console.log(`[foucad] ${count} lignes`);
} catch (err) {
+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: safeStr(r.ARTCENTRALE),
artcentrale: r.ARTCENTRALE ?? null,
}));
const count = await batchUpsert(pg, 'articles', rows, ['no_id'], ARTICLES_COLS);