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>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-07-09 09:09:25 +02:00
1 parent 62249e6c13
commit f00b9c4b14
3 files changed
+30 -5

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);