Initial commit : API Node.js + PostgreSQL + sync service Docker

- API Express/pg connectée à PostgreSQL (converti depuis mssql)
- 8 routes : articles, fournisseurs, commandes, stock, mouvements, performance, schema, sync
- Service sync nightly (mssql → pg, cron 3h00)
- Docker Compose : postgres + api + sync + frpc (tunnel sortant)
- Schéma PostgreSQL 17 tables dans postgres/init.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-03-18 10:10:47 +01:00
commit d565ed9f10
28 files changed
+2423

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
# Copier en .env.docker et remplir les valeurs
# .env.docker est exclu du git (contient les mots de passe)
DB_HOST=192.168.184.254
DB_PORT=1433
DB_NAME=foirfouille
DB_USER=sa
DB_PASSWORD=CHANGE_ME
PG_HOST=postgres
PG_PORT=5432
PG_DB=foirfouille
PG_USER=ff_api
PG_PASSWORD=CHANGE_ME_STRONG_PASSWORD
API_PORT=3000
+15
View File
@@ -0,0 +1,15 @@
node_modules/
sync/node_modules/
# Fichiers secrets — ne jamais committer
.env
.env.docker
frpc.toml
# Scripts et docs d'exploration (phase de développement, pas nécessaires en prod)
scripts/
docs/
# Fichiers de déploiement locaux
*.log
package-lock.json
+7
View File
@@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/app.js"]
+65
View File
@@ -0,0 +1,65 @@
services:
postgres:
image: postgres:16-alpine
container_name: ff_postgres
environment:
POSTGRES_DB: ${PG_DB}
POSTGRES_USER: ${PG_USER}
POSTGRES_PASSWORD: ${PG_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
interval: 10s
timeout: 5s
retries: 10
api:
build: .
container_name: ff_api
environment:
PG_HOST: postgres
PG_PORT: 5432
PG_DB: ${PG_DB}
PG_USER: ${PG_USER}
PG_PASSWORD: ${PG_PASSWORD}
API_PORT: ${API_PORT:-3000}
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
sync:
build: ./sync
container_name: ff_sync
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT:-1433}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
PG_HOST: postgres
PG_PORT: 5432
PG_DB: ${PG_DB}
PG_USER: ${PG_USER}
PG_PASSWORD: ${PG_PASSWORD}
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
# Tunnel sortant vers le serveur Hostinger (aucun port entrant requis)
frpc:
image: snowdreamtech/frpc:latest
container_name: ff_frpc
volumes:
- ./frpc.toml:/etc/frp/frpc.toml:ro
depends_on:
- api
restart: unless-stopped
volumes:
postgres_data:
+17
View File
@@ -0,0 +1,17 @@
# Copier en frpc.toml et remplir les valeurs
# frpc.toml est exclu du git (contient le token secret)
serverAddr = "api.ffnancy.fr"
serverPort = 7000
auth.method = "token"
auth.token = "CHANGE_ME_FRP_SECRET_TOKEN"
log.level = "info"
[[proxies]]
name = "foirfouille-api"
type = "tcp"
localIP = "api"
localPort = 3000
remotePort = 13000
+19
View File
@@ -0,0 +1,19 @@
{
"name": "foirfouille-api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node src/app.js",
"dev": "node --watch src/app.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"dotenv": "^17.3.1",
"express": "^5.2.1",
"pg": "^8.13.3"
}
}
+283
View File
@@ -0,0 +1,283 @@
-- ============================================================
-- Schéma PostgreSQL — foirfouille API
-- Tables sélectionnées depuis SQL Server (copie nightly via sync)
-- ============================================================
-- Contrôle de synchronisation
CREATE TABLE IF NOT EXISTS sync_log (
table_name TEXT PRIMARY KEY,
last_sync TIMESTAMP,
rows_synced INTEGER,
status TEXT,
error_msg TEXT
);
-- ============================================================
-- Référentiel articles
-- ============================================================
CREATE TABLE IF NOT EXISTS articles (
no_id BIGINT PRIMARY KEY,
codein TEXT,
libelle1 TEXT,
libelle2 TEXT,
lib_ticket TEXT,
tax_code TEXT,
ach_code TEXT,
utilisable TEXT,
actif TEXT,
suspendu TEXT,
suividatecreation TIMESTAMP,
suividatemodif TIMESTAMP,
nom_no_id BIGINT
);
CREATE INDEX IF NOT EXISTS idx_articles_codein ON articles (codein);
CREATE INDEX IF NOT EXISTS idx_articles_libelle1 ON articles (libelle1);
CREATE INDEX IF NOT EXISTS idx_articles_nom_no_id ON articles (nom_no_id);
CREATE TABLE IF NOT EXISTS article_infosup (
artnoid BIGINT PRIMARY KEY,
prix_vente_mini NUMERIC(12,4),
prix_vente_maxi NUMERIC(12,4),
eco_ht NUMERIC(12,4),
eco_ttc NUMERIC(12,4),
on_web TEXT,
interdit_remise TEXT,
nomphoto TEXT,
datedebvente TIMESTAMP,
datefinvente TIMESTAMP,
datevaliditeachat TIMESTAMP,
commentaire TEXT,
pv_conseille NUMERIC(12,4),
fraislogistic NUMERIC(12,4),
transit NUMERIC(12,4),
distribution NUMERIC(12,4)
);
CREATE TABLE IF NOT EXISTS art_gtin (
idarticle BIGINT,
gtin TEXT,
preferentiel SMALLINT,
PRIMARY KEY (idarticle, gtin)
);
CREATE INDEX IF NOT EXISTS idx_art_gtin_article ON art_gtin (idarticle);
-- ============================================================
-- Nomenclature & Gammes
-- ============================================================
CREATE TABLE IF NOT EXISTS nomenclature (
no_id BIGINT PRIMARY KEY,
code TEXT,
libelle TEXT,
niveau INTEGER,
chemin_pere TEXT
);
CREATE INDEX IF NOT EXISTS idx_nomenclature_niveau ON nomenclature (niveau);
CREATE TABLE IF NOT EXISTS gammes (
no_id BIGINT PRIMARY KEY,
code TEXT,
libelle TEXT
);
CREATE TABLE IF NOT EXISTS saisons (
no_id BIGINT PRIMARY KEY,
code TEXT,
libelle TEXT
);
CREATE TABLE IF NOT EXISTS art_gamme_saison (
artnoid BIGINT,
idgamme BIGINT,
idsaison BIGINT,
PRIMARY KEY (artnoid, idgamme, idsaison)
);
CREATE INDEX IF NOT EXISTS idx_ags_artnoid ON art_gamme_saison (artnoid);
-- ============================================================
-- Fournisseurs
-- ============================================================
CREATE TABLE IF NOT EXISTS artfou1 (
no_id BIGINT PRIMARY KEY,
art_no_id BIGINT,
code TEXT,
reference TEXT,
ean13 TEXT,
itf TEXT,
qteua NUMERIC(10,3),
pcb NUMERIC(10,3),
spcb NUMERIC(10,3),
delai INTEGER,
securite NUMERIC(10,3),
preference INTEGER,
suspendu TEXT,
suividatecreation TIMESTAMP,
suividatemodif TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_artfou1_art_no_id ON artfou1 (art_no_id);
CREATE INDEX IF NOT EXISTS idx_artfou1_code ON artfou1 (code);
CREATE TABLE IF NOT EXISTS artfou2 (
idartfou1 BIGINT PRIMARY KEY,
prixachat NUMERIC(12,4),
remise_promotion NUMERIC(10,4)
);
CREATE TABLE IF NOT EXISTS fouadr1 (
code TEXT,
sit_code TEXT,
raisonsociale TEXT,
adrligne1 TEXT,
telephone TEXT,
email TEXT,
PRIMARY KEY (code, sit_code)
);
-- ============================================================
-- Cubes de stock et prix
-- ============================================================
CREATE TABLE IF NOT EXISTS cube_stock (
artnoid BIGINT,
site TEXT,
qte NUMERIC(12,3),
prmp NUMERIC(12,4),
valstock NUMERIC(14,4),
pv NUMERIC(12,4),
stockdispo NUMERIC(12,3),
stockmort NUMERIC(12,3),
stockcolis NUMERIC(12,3),
dernierevente TIMESTAMP,
dernierereception TIMESTAMP,
premierevente TIMESTAMP,
nbjoursdernierMouvement INTEGER,
nbjoursdernierevente INTEGER,
nbjoursdernierrereception INTEGER,
interditachat TEXT,
codefou TEXT,
PRIMARY KEY (artnoid, site)
);
CREATE INDEX IF NOT EXISTS idx_cube_stock_site ON cube_stock (site);
CREATE TABLE IF NOT EXISTS cube_pa (
artnoid BIGINT PRIMARY KEY,
pa NUMERIC(12,4)
);
CREATE TABLE IF NOT EXISTS cube_pv (
artnoid BIGINT,
site TEXT,
pv NUMERIC(12,4),
PRIMARY KEY (artnoid, site)
);
CREATE INDEX IF NOT EXISTS idx_cube_pv_site ON cube_pv (site);
-- ============================================================
-- Mouvements
-- ============================================================
CREATE TABLE IF NOT EXISTS mvtart (
artnoid BIGINT,
datmvt TIMESTAMP,
site TEXT,
libmvt TEXT,
genremvt INTEGER,
qtemvt NUMERIC(12,3),
valmvt NUMERIC(14,4),
mntmvtht NUMERIC(14,4),
mntmvtttc NUMERIC(14,4),
margemvt NUMERIC(14,4),
qtestock NUMERIC(12,3),
prmp NUMERIC(12,4),
valstock NUMERIC(14,4),
codefou TEXT
);
CREATE INDEX IF NOT EXISTS idx_mvtart_artnoid ON mvtart (artnoid);
CREATE INDEX IF NOT EXISTS idx_mvtart_datmvt ON mvtart (datmvt);
CREATE INDEX IF NOT EXISTS idx_mvtart_site ON mvtart (site);
CREATE INDEX IF NOT EXISTS idx_mvtart_genremvt ON mvtart (genremvt);
CREATE TABLE IF NOT EXISTS mvtreg (
datmvt TIMESTAMP,
codtick TEXT,
codcartecli TEXT,
coddev TEXT,
mntreg NUMERIC(14,4),
mntregdev NUMERIC(14,4),
clientnom TEXT,
echeance TIMESTAMP,
reference TEXT,
typereg TEXT,
suividatecreation TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mvtreg_datmvt ON mvtreg (datmvt);
-- ============================================================
-- Commandes fournisseurs
-- ============================================================
CREATE TABLE IF NOT EXISTS cdefou_vivant (
cdefou_ligne_com_no_id BIGINT,
artfou1_no_id BIGINT,
articles_codein TEXT,
articles_libelle1 TEXT,
articles_libelle2 TEXT,
artfou1_reference TEXT,
artfou1_ean13 TEXT,
artfou1_itf TEXT,
cdefou_ligne_qtecde NUMERIC(12,3),
cdefou_ligne_prixbrut NUMERIC(12,4),
cdefou_ligne_remise NUMERIC(10,4),
cdefou_ligne_remise2 NUMERIC(10,4),
cdefou_ligne_remise3 NUMERIC(10,4),
cdefou_ligne_prixnet NUMERIC(12,4),
cdefou_ligne_montant NUMERIC(14,4),
cdefou_ligne_prixvente NUMERIC(12,4),
cdefou_ligne_gratuit NUMERIC(12,3),
cdefou_ligne_qteacc NUMERIC(12,3),
cdefou_ligne_qteann NUMERIC(12,3),
cdefou_ligne_qteatt NUMERIC(12,3),
cdefou_ligne_qterel NUMERIC(12,3),
cdefou_ligne_cdeligtard TIMESTAMP,
cdefou_ligne_cdeligtot TIMESTAMP,
cotisation_logistique NUMERIC(14,4),
fraislogistic NUMERIC(12,4),
transit NUMERIC(12,4),
distribution NUMERIC(12,4),
taxe NUMERIC(12,4),
commentaire TEXT,
suividatecreation TIMESTAMP,
PRIMARY KEY (cdefou_ligne_com_no_id, artfou1_no_id)
);
CREATE INDEX IF NOT EXISTS idx_cdefou_vivant_artfou1 ON cdefou_vivant (artfou1_no_id);
CREATE INDEX IF NOT EXISTS idx_cdefou_vivant_date ON cdefou_vivant (suividatecreation);
CREATE TABLE IF NOT EXISTS cdefou_reception (
no_id BIGINT PRIMARY KEY,
suividatecreation TIMESTAMP,
suividatemodif TIMESTAMP
);
CREATE TABLE IF NOT EXISTS cdefou_receplig (
no_id BIGINT PRIMARY KEY,
cdefou_reception_no_id BIGINT,
artfou1_no_id BIGINT,
qtebl NUMERIC(12,3),
qterec NUMERIC(12,3),
qteacc NUMERIC(12,3),
qteref NUMERIC(12,3),
qteaff NUMERIC(12,3),
pribrut NUMERIC(12,4),
remise NUMERIC(10,4),
prirec NUMERIC(12,4),
mntrec NUMERIC(14,4),
motifrefus TEXT,
recpb SMALLINT,
recpbok SMALLINT,
suividatecreation TIMESTAMP,
suividatemodif TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_receplig_artfou1 ON cdefou_receplig (artfou1_no_id);
CREATE INDEX IF NOT EXISTS idx_receplig_date ON cdefou_receplig (suividatecreation);
+74
View File
@@ -0,0 +1,74 @@
require('dotenv').config();
const express = require('express');
const { getPool } = require('./config/database');
const schemaRouter = require('./routes/schema');
const articlesRouter = require('./routes/articles');
const fournisseursRouter = require('./routes/fournisseurs');
const commandesRouter = require('./routes/commandes');
const stockRouter = require('./routes/stock');
const mouvementsRouter = require('./routes/mouvements');
const performanceRouter = require('./routes/performance');
const syncRouter = require('./routes/sync');
const app = express();
app.use(express.json());
// Health check
app.get('/api/health', async (req, res) => {
try {
const pool = getPool();
await pool.query('SELECT 1 AS ok');
res.json({ status: 'ok', database: process.env.PG_DB, server: process.env.PG_HOST });
} catch (err) {
res.status(500).json({ status: 'error', detail: err.message });
}
});
// Routes
app.use('/api/schema', schemaRouter);
app.use('/api/articles', articlesRouter);
app.use('/api/fournisseurs', fournisseursRouter);
app.use('/api/commandes', commandesRouter);
app.use('/api/stock', stockRouter);
app.use('/api/mouvements', mouvementsRouter);
app.use('/api/performance', performanceRouter);
app.use('/api/sync', syncRouter);
// 404
app.use((req, res) => {
res.status(404).json({ error: 'Route introuvable' });
});
const PORT = process.env.API_PORT || 3000;
app.listen(PORT, () => {
console.log(`\nAPI Foirfouille démarrée sur http://localhost:${PORT}`);
console.log(`Base : ${process.env.PG_HOST}/${process.env.PG_DB} (PostgreSQL)\n`);
console.log('Endpoints disponibles :');
console.log(' GET /api/health');
console.log(' GET /api/schema/tables[?search=]');
console.log(' GET /api/schema/top[?limit=]');
console.log(' GET /api/schema/tables/:nom');
console.log(' GET /api/articles[?search=&codein=&ean=&codefou=&actif=&page=&limit=]');
console.log(' GET /api/articles/:id');
console.log(' GET /api/articles/:id/referentiel');
console.log(' GET /api/articles/:id/mouvements');
console.log(' GET /api/fournisseurs[?search=]');
console.log(' GET /api/fournisseurs/:code/articles');
console.log(' GET /api/fournisseurs/:code/commandes');
console.log(' GET /api/commandes[?dateDebut=&dateFin=&codefou=]');
console.log(' GET /api/commandes/:noCommande');
console.log(' GET /api/commandes/receptions/liste');
console.log(' GET /api/stock[?site=]');
console.log(' GET /api/stock/article/:id');
console.log(' GET /api/stock/site/:site');
console.log(' GET /api/stock/valorisation');
console.log(' GET /api/mouvements/articles[?dateDebut=&dateFin=&site=]');
console.log(' GET /api/mouvements/reglements[?dateDebut=&dateFin=]');
console.log(' GET /api/mouvements/synthese[?dateDebut=&dateFin=&site=]');
console.log(' GET /api/performance/ca[?dateDebut=&dateFin=&site=&groupBy=jour|mois]');
console.log(' GET /api/performance/hitparade[?dateDebut=&dateFin=&site=&limit=&groupBy=qte|ca|marge]');
console.log(' GET /api/performance/ca/nomenclature[?dateDebut=&dateFin=&site=&niveau=]');
console.log(' GET /api/performance/ca/gamme[?dateDebut=&dateFin=&site=]');
console.log(' GET /api/sync/status');
});
+24
View File
@@ -0,0 +1,24 @@
require('dotenv').config();
const { Pool } = require('pg');
let pool = null;
function getPool() {
if (!pool) {
pool = new Pool({
host: process.env.PG_HOST || 'postgres',
port: parseInt(process.env.PG_PORT) || 5432,
database: process.env.PG_DB || 'foirfouille',
user: process.env.PG_USER || 'ff_api',
password: process.env.PG_PASSWORD,
max: 10,
idleTimeoutMillis: 30000,
});
pool.on('error', (err) => {
console.error('PostgreSQL pool error:', err.message);
});
}
return pool;
}
module.exports = { getPool };
+270
View File
@@ -0,0 +1,270 @@
const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// 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 = '', 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;
const result = await pool.query(`
SELECT
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,
ai.PRIX_VENTE_MINI, ai.PRIX_VENTE_MAXI, ai.ECO_TTC,
ai.ON_WEB, ai.INTERDIT_REMISE, ai.NOMPHOTO,
ai.DATEDEBVENTE, ai.DATEFINVENTE,
pa.PA,
g.GTIN,
fpref.CODE AS CODEFOU_PRINCIPAL,
fpref.NOM_FOU AS NOM_FOU_PRINCIPAL,
fpref.REF AS REF_FOU_PRINCIPALE,
fpref.PCB AS PCB_PRINCIPAL
FROM ARTICLES a
LEFT JOIN ARTICLE_INFOSUP ai ON ai.ARTNOID = a.NO_ID
LEFT JOIN Cube_PA pa ON pa.ArtNoId = a.NO_ID
LEFT JOIN ART_GTIN g ON g.IDARTICLE = a.NO_ID AND g.PREFERENTIEL = 1
LEFT JOIN LATERAL (
SELECT f.CODE, f.REFERENCE AS REF, f.QTEUA AS PCB,
fa.RAISONSOCIALE AS NOM_FOU
FROM ARTFOU1 f
LEFT JOIN FOUADR1 fa ON fa.CODE = f.CODE AND fa.SIT_CODE = '000'
WHERE f.ART_NO_ID = a.NO_ID
ORDER BY f.PREFERENCE
LIMIT 1
) fpref ON true
WHERE
($1 = '%%' OR a.LIBELLE1 LIKE $1 OR a.LIBELLE2 LIKE $1)
AND ($2 = '%%' OR a.CODEIN LIKE $2)
AND ($3 = '%%' OR g.GTIN LIKE $3)
AND ($5 = '' OR (
$5 = '1' AND a.ACTIF IS NOT NULL AND a.SUSPENDU IS NULL
) OR (
$5 = '0' AND a.SUSPENDU IS NOT NULL
))
AND ($4 = '%%' OR EXISTS (
SELECT 1 FROM ARTFOU1 f WHERE f.ART_NO_ID = a.NO_ID AND f.CODE LIKE $4
))
ORDER BY a.LIBELLE1
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [`%${search}%`, `%${codein}%`, `%${ean}%`, `%${codefou}%`, actif]);
res.json({ page: pageNum, limit: limitNum, articles: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/articles/:id - Détail complet d'un article
router.get('/:id', async (req, res) => {
try {
const pool = getPool();
const article = await pool.query(`
SELECT
a.*, ai.PRIX_VENTE_MINI, ai.PRIX_VENTE_MAXI,
ai.ECO_HT, ai.ECO_TTC, ai.ON_WEB, ai.INTERDIT_REMISE,
ai.NOMPHOTO, ai.DATEDEBVENTE, ai.DATEFINVENTE,
ai.DATEVALIDITEACHAT, ai.COMMENTAIRE, ai.PV_CONSEILLE,
ai.FRAISLOGISTIC, ai.TRANSIT, ai.DISTRIBUTION,
pa.PA
FROM ARTICLES a
LEFT JOIN ARTICLE_INFOSUP ai ON ai.ARTNOID = a.NO_ID
LEFT JOIN Cube_PA pa ON pa.ArtNoId = a.NO_ID
WHERE a.NO_ID = $1
`, [req.params.id]);
if (!article.rows.length)
return res.status(404).json({ error: 'Article introuvable' });
const gtins = await pool.query(
`SELECT GTIN, PREFERENTIEL FROM ART_GTIN WHERE IDARTICLE = $1 ORDER BY PREFERENTIEL DESC`,
[req.params.id]
);
const stock = await pool.query(`
SELECT Site, QTE, Prmp, ValStock, PV, StockDispo,
DerniereVente, DerniereReception, StockMort
FROM Cube_Stock WHERE ArtNoId = $1 ORDER BY Site
`, [req.params.id]);
const prix = await pool.query(
`SELECT Site, PV FROM Cube_PV WHERE ArtNoId = $1 ORDER BY Site`,
[req.params.id]
);
const fournisseurs = await pool.query(`
SELECT f.CODE AS CODEFOU, fa.RAISONSOCIALE AS NOM_FOU,
f.REFERENCE, f.EAN13, f.ITF,
f.QTEUA, f.PCB, f.SPCB, f.DELAI, f.SECURITE, f.PREFERENCE, f.SUSPENDU,
t.PRIXACHAT, t.REMISE_PROMOTION
FROM ARTFOU1 f
LEFT JOIN FOUADR1 fa ON fa.CODE = f.CODE AND fa.SIT_CODE = '000'
LEFT JOIN ARTFOU2 t ON t.IDARTFOU1 = f.NO_ID
WHERE f.ART_NO_ID = $1
ORDER BY f.PREFERENCE
`, [req.params.id]);
res.json({
article: article.rows[0],
gtins: gtins.rows,
stock: stock.rows,
prix: prix.rows,
fournisseurs: fournisseurs.rows,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/articles/:id/referentiel - Fiche complète
router.get('/:id/referentiel', async (req, res) => {
try {
const pool = getPool();
const id = req.params.id;
const article = await pool.query(`
SELECT
a.NO_ID, a.CODEIN, a.LIBELLE1, a.LIBELLE2, a.LIB_TICKET,
a.TAX_CODE, a.ACTIF, a.SUSPENDU, a.UTILISABLE,
a.SUIVIDATECREATION, a.SUIVIDATEMODIF,
ai.PRIX_VENTE_MINI, ai.PRIX_VENTE_MAXI,
ai.ECO_HT, ai.ECO_TTC, ai.ON_WEB,
ai.INTERDIT_REMISE, ai.NOMPHOTO,
ai.DATEDEBVENTE, ai.DATEFINVENTE,
ai.PV_CONSEILLE, ai.FRAISLOGISTIC,
ai.COMMENTAIRE,
n.CODE AS NOM_CODE, n.LIBELLE AS NOM_LIBELLE, n.NIVEAU AS NOM_NIVEAU,
n.CHEMIN_PERE AS NOM_CHEMIN_PERE
FROM ARTICLES a
LEFT JOIN ARTICLE_INFOSUP ai ON ai.ARTNOID = a.NO_ID
LEFT JOIN NOMENCLATURE n ON n.NO_ID = a.NOM_NO_ID
WHERE a.NO_ID = $1
`, [id]);
if (!article.rows.length)
return res.status(404).json({ error: 'Article introuvable' });
const gtins = await pool.query(
`SELECT GTIN, PREFERENTIEL FROM ART_GTIN WHERE IDARTICLE = $1 ORDER BY PREFERENTIEL DESC`,
[id]
);
const gammes = await pool.query(`
SELECT g.CODE AS GAMME_CODE, g.LIBELLE AS GAMME_LIBELLE,
s.CODE AS SAISON_CODE, s.LIBELLE AS SAISON_LIBELLE,
ags.IDGAMME, ags.IDSAISON
FROM ART_GAMME_SAISON ags
JOIN GAMMES g ON g.NO_ID = ags.IDGAMME
LEFT JOIN SAISONS s ON s.NO_ID = ags.IDSAISON
WHERE ags.ARTNOID = $1
`, [id]);
const stock = await pool.query(`
SELECT Site, QTE, Prmp, ValStock, PV, StockDispo,
DerniereVente, DerniereReception, StockMort
FROM Cube_Stock WHERE ArtNoId = $1 ORDER BY Site
`, [id]);
const prixAchat = await pool.query(
`SELECT ArtNoId, PA FROM Cube_PA WHERE ArtNoId = $1`,
[id]
);
const prixVente = await pool.query(
`SELECT Site, PV FROM Cube_PV WHERE ArtNoId = $1 ORDER BY Site`,
[id]
);
const fournisseurs = await pool.query(`
SELECT f.CODE AS CODEFOU, fa.RAISONSOCIALE AS NOM_FOU,
f.REFERENCE AS REF_FOU, f.EAN13, f.ITF,
f.QTEUA AS PCB, f.SPCB, f.DELAI, f.SECURITE,
f.PREFERENCE, f.SUSPENDU,
t.PRIXACHAT, t.REMISE_PROMOTION
FROM ARTFOU1 f
LEFT JOIN FOUADR1 fa ON fa.CODE = f.CODE AND fa.SIT_CODE = '000'
LEFT JOIN ARTFOU2 t ON t.IDARTFOU1 = f.NO_ID
WHERE f.ART_NO_ID = $1
ORDER BY f.PREFERENCE
`, [id]);
const mvtAgreges = await pool.query(`
SELECT
MAX(CASE WHEN GenreMvt = 1 THEN DatMvt END) AS DERNIERE_ENTREE,
MAX(CASE WHEN GenreMvt = 3 THEN DatMvt END) AS DERNIERE_VENTE,
SUM(CASE WHEN GenreMvt = 3 THEN QteMvt ELSE 0 END) AS QTE_TOTALE_VENDUE,
SUM(CASE WHEN GenreMvt = 3 THEN MntMvtTTC ELSE 0 END) AS CA_TTC_TOTAL,
SUM(CASE WHEN GenreMvt = 3 THEN MargeMvt ELSE 0 END) AS MARGE_TOTALE
FROM MvtArt WHERE ArtNoId = $1
`, [id]);
let stockRemballe = [];
try {
const remb = await pool.query(
`SELECT Site, QTE AS QTE_REMBALLE, DateSaisie FROM InvRemballe WHERE ArtNoId = $1 ORDER BY Site`,
[id]
);
stockRemballe = remb.rows;
} catch (e) { /* Table optionnelle */ }
let notes = [];
try {
const notesRes = await pool.query(
`SELECT Note, DateSaisie, Auteur FROM NoteArticles WHERE ArtNoId = $1 ORDER BY DateSaisie DESC`,
[id]
);
notes = notesRes.rows;
} catch (e) { /* Table optionnelle */ }
res.json({
article: article.rows[0],
gtins: gtins.rows,
gammes: gammes.rows,
stock: stock.rows,
stock_remballe: stockRemballe,
prix: {
achat: prixAchat.rows[0]?.pa ?? null,
vente_par_site: prixVente.rows,
},
fournisseurs: fournisseurs.rows,
performance: mvtAgreges.rows[0],
notes,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/articles/:id/mouvements?dateDebut=&dateFin=&site=&page=&limit=
router.get('/:id/mouvements', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '', dateFin = '', site = '', page = 1, limit = 100 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.max(1, Math.min(parseInt(limit) || 100, 1000));
const offsetNum = (pageNum - 1) * limitNum;
const result = await pool.query(`
SELECT DatMvt, Site, LibMvt, GenreMvt, QteMvt, ValMvt,
MntMvtHt, MntMvtTTC, MargeMvt, QteStock, Prmp, CODEFOU
FROM MvtArt
WHERE ArtNoId = $1
AND DatMvt BETWEEN $2 AND $3
AND Site LIKE $4
ORDER BY DatMvt DESC
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [req.params.id, dateDebut || '2000-01-01', dateFin || '2099-12-31', `%${site}%`]);
res.json({ page: pageNum, limit: limitNum, mouvements: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+137
View File
@@ -0,0 +1,137 @@
const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// GET /api/commandes?dateDebut=&dateFin=&codefou=&page=&limit=
router.get('/', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2020-01-01', dateFin = '2099-12-31', codefou = '', page = 1, limit = 100 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.max(1, Math.min(parseInt(limit) || 100, 1000));
const offsetNum = (pageNum - 1) * limitNum;
const result = await pool.query(`
SELECT
cv.CDEFOU_LIGNE_COM_NO_ID AS NO_COMMANDE,
f.CODE AS CODEFOU,
cv.ARTICLES_CODEIN AS CODEIN,
cv.ARTICLES_LIBELLE1 AS LIBELLE,
cv.ARTFOU1_REFERENCE AS REF_FOU,
cv.ARTFOU1_EAN13 AS EAN13,
cv.ARTFOU1_ITF AS ITF,
cv.CDEFOU_LIGNE_QTECDE AS QTE_CDE,
cv.CDEFOU_LIGNE_PRIXBRUT AS PRIX_BRUT,
cv.CDEFOU_LIGNE_REMISE AS REMISE,
cv.CDEFOU_LIGNE_PRIXNET AS PRIX_NET,
cv.CDEFOU_LIGNE_MONTANT AS MONTANT,
cv.CDEFOU_LIGNE_PRIXVENTE AS PRIX_VENTE,
cv.CDEFOU_LIGNE_QTEACC AS QTE_ACCEPTEE,
cv.CDEFOU_LIGNE_QTEANN AS QTE_ANNULEE,
cv.CDEFOU_LIGNE_QTEATT AS QTE_ATTENTE,
cv.CDEFOU_LIGNE_QTEREL AS QTE_RELIQUAT,
cv.CDEFOU_LIGNE_CDELIGTARD AS DATE_LIVRAISON_CIBLE,
cv.CDEFOU_LIGNE_CDELIGTOT AS DATE_LIVRAISON_TOT,
cv.COMMENTAIRE,
cv.SUIVIDATECREATION AS DATE_COMMANDE
FROM CDEFOU_VIVANT cv
JOIN ARTFOU1 f ON f.NO_ID = cv.ARTFOU1_NO_ID
WHERE cv.SUIVIDATECREATION BETWEEN $1 AND $2
AND f.CODE LIKE $3
ORDER BY cv.SUIVIDATECREATION DESC
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [dateDebut, dateFin, `%${codefou}%`]);
res.json({ page: pageNum, limit: limitNum, commandes: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/commandes/:noCommande - Détail d'une commande
router.get('/:noCommande', async (req, res) => {
try {
const pool = getPool();
const result = await pool.query(`
SELECT
cv.CDEFOU_LIGNE_COM_NO_ID AS NO_COMMANDE,
f.CODE AS CODEFOU,
cv.ARTICLES_CODEIN AS CODEIN,
cv.ARTICLES_LIBELLE1 AS LIBELLE,
cv.ARTICLES_LIBELLE2 AS LIBELLE2,
cv.ARTFOU1_REFERENCE AS REF_FOU,
cv.ARTFOU1_EAN13 AS EAN13,
cv.ARTFOU1_ITF AS ITF,
cv.CDEFOU_LIGNE_QTECDE AS QTE_CDE,
cv.CDEFOU_LIGNE_PRIXBRUT AS PRIX_BRUT,
cv.CDEFOU_LIGNE_REMISE AS REMISE,
cv.CDEFOU_LIGNE_REMISE2 AS REMISE2,
cv.CDEFOU_LIGNE_REMISE3 AS REMISE3,
cv.CDEFOU_LIGNE_PRIXNET AS PRIX_NET,
cv.CDEFOU_LIGNE_MONTANT AS MONTANT,
cv.CDEFOU_LIGNE_PRIXVENTE AS PRIX_VENTE,
cv.CDEFOU_LIGNE_GRATUIT AS QTE_GRATUITE,
cv.CDEFOU_LIGNE_QTEACC AS QTE_ACCEPTEE,
cv.CDEFOU_LIGNE_QTEANN AS QTE_ANNULEE,
cv.CDEFOU_LIGNE_QTEATT AS QTE_ATTENTE,
cv.CDEFOU_LIGNE_QTEREL AS QTE_RELIQUAT,
cv.CDEFOU_LIGNE_CDELIGTARD AS DATE_LIVRAISON_CIBLE,
cv.COTISATION_LOGISTIQUE,
cv.FRAISLOGISTIC, cv.TRANSIT, cv.DISTRIBUTION, cv.TAXE,
cv.COMMENTAIRE,
cv.SUIVIDATECREATION AS DATE_COMMANDE
FROM CDEFOU_VIVANT cv
JOIN ARTFOU1 f ON f.NO_ID = cv.ARTFOU1_NO_ID
WHERE cv.CDEFOU_LIGNE_COM_NO_ID = $1
ORDER BY cv.ARTICLES_LIBELLE1
`, [req.params.noCommande]);
if (!result.rows.length)
return res.status(404).json({ error: 'Commande introuvable' });
res.json({
noCommande: req.params.noCommande,
nbLignes: result.rows.length,
montantTotal: result.rows.reduce((s, l) => s + (l.montant || 0), 0),
lignes: result.rows,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/commandes/receptions/liste?dateDebut=&dateFin=&page=&limit=
router.get('/receptions/liste', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2020-01-01', dateFin = '2099-12-31', page = 1, limit = 100 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.max(1, Math.min(parseInt(limit) || 100, 1000));
const offsetNum = (pageNum - 1) * limitNum;
const result = await pool.query(`
SELECT
rl.NO_ID, rl.CDEFOU_RECEPTION_NO_ID,
rl.QTEBL, rl.QTEREC, rl.QTEACC, rl.QTEREF, rl.QTEAFF,
rl.PRIBRUT, rl.REMISE, rl.PRIREC, rl.MNTREC,
rl.MOTIFREFUS, rl.RECPB, rl.RECPBOK,
f.CODE AS CODEFOU,
f.REFERENCE AS REF_FOU, f.EAN13,
a.CODEIN, a.LIBELLE1,
rl.SUIVIDATECREATION AS DATE_RECEPTION
FROM CDEFOU_RECEPLIG rl
JOIN ARTFOU1 f ON f.NO_ID = rl.ARTFOU1_NO_ID
JOIN ARTICLES a ON a.NO_ID = f.ART_NO_ID
WHERE rl.SUIVIDATECREATION BETWEEN $1 AND $2
ORDER BY rl.SUIVIDATECREATION DESC
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [dateDebut, dateFin]);
res.json({ page: pageNum, limit: limitNum, receptions: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+109
View File
@@ -0,0 +1,109 @@
const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// GET /api/fournisseurs - Liste des fournisseurs distincts
router.get('/', async (req, res) => {
try {
const pool = getPool();
const { search = '' } = req.query;
const result = await pool.query(`
SELECT
f.CODE AS CODEFOU,
MAX(fa.RAISONSOCIALE) AS NOM,
MAX(fa.ADRLIGNE1) AS ADRESSE,
MAX(fa.TELEPHONE) AS TELEPHONE,
MAX(fa.EMAIL) AS EMAIL,
COUNT(DISTINCT f.ART_NO_ID) AS NB_ARTICLES,
MIN(f.SUIVIDATECREATION) AS PREMIERE_ENTREE,
MAX(f.SUIVIDATEMODIF) AS DERNIERE_MODIF,
SUM(CASE WHEN f.SUSPENDU IS NULL THEN 1 ELSE 0 END) AS NB_ACTIFS,
SUM(CASE WHEN f.SUSPENDU IS NOT NULL THEN 1 ELSE 0 END) AS NB_SUSPENDUS
FROM ARTFOU1 f
LEFT JOIN FOUADR1 fa ON fa.CODE = f.CODE AND fa.SIT_CODE = '000'
WHERE f.CODE LIKE $1
GROUP BY f.CODE
ORDER BY NB_ARTICLES DESC
`, [`%${search}%`]);
res.json({ count: result.rows.length, fournisseurs: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/fournisseurs/:code/articles?page=&limit=&suspendu=
router.get('/:code/articles', async (req, res) => {
try {
const pool = getPool();
const { 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;
const result = await pool.query(`
SELECT
a.NO_ID, a.CODEIN, a.LIBELLE1, a.LIBELLE2,
f.REFERENCE AS REF_FOU, f.EAN13, f.ITF,
f.PCB, f.SPCB, f.DELAI, f.SECURITE, f.SUSPENDU, f.PREFERENCE,
t2.PRIXACHAT, t2.REMISE_PROMOTION,
pa.PA,
pv.PV AS PV_CENTRAL
FROM ARTFOU1 f
JOIN ARTICLES a ON a.NO_ID = f.ART_NO_ID
LEFT JOIN ARTFOU2 t2 ON t2.IDARTFOU1 = f.NO_ID
LEFT JOIN Cube_PA pa ON pa.ArtNoId = a.NO_ID
LEFT JOIN Cube_PV pv ON pv.ArtNoId = a.NO_ID AND pv.Site = '000'
WHERE f.CODE = $1
ORDER BY a.LIBELLE1
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [req.params.code]);
res.json({ code: req.params.code, page: pageNum, limit: limitNum, articles: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/fournisseurs/:code/commandes?dateDebut=&dateFin=&page=&limit=
router.get('/:code/commandes', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2020-01-01', dateFin = '2099-12-31', page = 1, limit = 100 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.max(1, Math.min(parseInt(limit) || 100, 1000));
const offsetNum = (pageNum - 1) * limitNum;
const result = await pool.query(`
SELECT
cv.CDEFOU_LIGNE_COM_NO_ID AS NO_COMMANDE,
cv.ARTICLES_CODEIN AS CODEIN,
cv.ARTICLES_LIBELLE1 AS LIBELLE,
cv.ARTFOU1_REFERENCE AS REF_FOU,
cv.ARTFOU1_EAN13 AS EAN13,
cv.CDEFOU_LIGNE_QTECDE AS QTE_CDE,
cv.CDEFOU_LIGNE_PRIXBRUT AS PRIX_BRUT,
cv.CDEFOU_LIGNE_REMISE AS REMISE,
cv.CDEFOU_LIGNE_PRIXNET AS PRIX_NET,
cv.CDEFOU_LIGNE_MONTANT AS MONTANT,
cv.CDEFOU_LIGNE_QTEACC AS QTE_ACCEPTEE,
cv.CDEFOU_LIGNE_QTEANN AS QTE_ANNULEE,
cv.CDEFOU_LIGNE_QTEATT AS QTE_ATTENTE,
cv.CDEFOU_LIGNE_CDELIGTARD AS DATE_LIVRAISON,
cv.SUIVIDATECREATION AS DATE_COMMANDE
FROM CDEFOU_VIVANT cv
JOIN ARTFOU1 f ON f.NO_ID = cv.ARTFOU1_NO_ID
WHERE f.CODE = $1
AND cv.SUIVIDATECREATION BETWEEN $2 AND $3
ORDER BY cv.SUIVIDATECREATION DESC
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [req.params.code, dateDebut, dateFin]);
res.json({ code: req.params.code, page: pageNum, limit: limitNum, commandes: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+91
View File
@@ -0,0 +1,91 @@
const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// GET /api/mouvements/articles?dateDebut=&dateFin=&site=&codefou=&page=&limit=
router.get('/articles', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2024-01-01', dateFin = '2099-12-31', site = '', codefou = '', page = 1, limit = 100 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.max(1, Math.min(parseInt(limit) || 100, 1000));
const offsetNum = (pageNum - 1) * limitNum;
const result = await pool.query(`
SELECT
m.DatMvt, m.Site,
a.CODEIN, a.LIBELLE1,
m.LibMvt, m.GenreMvt, m.QteMvt, m.ValMvt,
m.MntMvtHt, m.MntMvtTTC, m.MargeMvt,
m.QteStock, m.Prmp, m.ValStock, m.CODEFOU
FROM MvtArt m
JOIN ARTICLES a ON a.NO_ID = m.ArtNoId
WHERE m.DatMvt BETWEEN $1 AND $2
AND m.Site LIKE $3
AND m.CODEFOU LIKE $4
ORDER BY m.DatMvt DESC
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [dateDebut, dateFin, `%${site}%`, `%${codefou}%`]);
res.json({ page: pageNum, limit: limitNum, mouvements: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/mouvements/reglements?dateDebut=&dateFin=&page=&limit=
router.get('/reglements', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2024-01-01', dateFin = '2099-12-31', page = 1, limit = 100 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.max(1, Math.min(parseInt(limit) || 100, 1000));
const offsetNum = (pageNum - 1) * limitNum;
const result = await pool.query(`
SELECT
DatMvt, CodTick, CodCarteCli, CodDev,
MntReg, MntRegDev, ClientNom, Echeance,
REFERENCE, TYPEREG, SuiviDateCreation
FROM MvtReg
WHERE DatMvt BETWEEN $1 AND $2
ORDER BY DatMvt DESC
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [dateDebut, dateFin]);
res.json({ page: pageNum, limit: limitNum, reglements: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/mouvements/synthese?dateDebut=&dateFin=&site= - Synthèse par jour/site
router.get('/synthese', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2024-01-01', dateFin = '2099-12-31', site = '' } = req.query;
const result = await pool.query(`
SELECT
DatMvt::DATE AS Jour,
Site,
GenreMvt,
COUNT(*) AS NB_MVT,
SUM(QteMvt) AS QTE_TOTALE,
SUM(MntMvtHt) AS MNT_HT,
SUM(MntMvtTTC) AS MNT_TTC,
SUM(MargeMvt) AS MARGE
FROM MvtArt
WHERE DatMvt BETWEEN $1 AND $2
AND Site LIKE $3
GROUP BY DatMvt::DATE, Site, GenreMvt
ORDER BY Jour DESC, Site
`, [dateDebut, dateFin, `%${site}%`]);
res.json({ synthese: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+156
View File
@@ -0,0 +1,156 @@
const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// GET /api/performance/ca?dateDebut=&dateFin=&site=&groupBy=jour|mois
router.get('/ca', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2024-01-01', dateFin = '2099-12-31', site = '', groupBy = 'jour' } = req.query;
const groupExpr = groupBy === 'mois'
? "TO_CHAR(DatMvt, 'YYYY-MM')"
: "DatMvt::DATE";
const result = await pool.query(`
SELECT
${groupExpr} AS Periode,
Site,
COUNT(*) AS NB_VENTES,
SUM(QteMvt) AS QTE_VENDUE,
SUM(MntMvtHt) AS CA_HT,
SUM(MntMvtTTC) AS CA_TTC,
SUM(MargeMvt) AS MARGE,
CASE WHEN SUM(MntMvtTTC) > 0
THEN ROUND(SUM(MargeMvt) / SUM(MntMvtTTC) * 100, 2)
ELSE 0 END AS TAUX_MARGE
FROM MvtArt
WHERE GenreMvt = 3
AND DatMvt BETWEEN $1 AND $2
AND Site LIKE $3
GROUP BY ${groupExpr}, Site
ORDER BY Periode DESC, Site
`, [dateDebut, dateFin, `%${site}%`]);
res.json({
groupBy,
dateDebut,
dateFin,
site: site || 'tous',
ca: result.rows,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/performance/hitparade?dateDebut=&dateFin=&site=&limit=&groupBy=qte|ca|marge
router.get('/hitparade', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2024-01-01', dateFin = '2099-12-31', site = '', limit = 50, groupBy = 'ca' } = req.query;
const limitNum = Math.max(1, Math.min(parseInt(limit) || 50, 500));
const orderCol = groupBy === 'qte' ? 'QTE_VENDUE' : groupBy === 'marge' ? 'MARGE' : 'CA_TTC';
const result = await pool.query(`
SELECT
a.CODEIN,
a.LIBELLE1,
m.Site,
COUNT(*) AS NB_PASSAGES,
SUM(m.QteMvt) AS QTE_VENDUE,
SUM(m.MntMvtHt) AS CA_HT,
SUM(m.MntMvtTTC) AS CA_TTC,
SUM(m.MargeMvt) AS MARGE,
CASE WHEN SUM(m.MntMvtTTC) > 0
THEN ROUND(SUM(m.MargeMvt) / SUM(m.MntMvtTTC) * 100, 2)
ELSE 0 END AS TAUX_MARGE,
MAX(m.DatMvt) AS DERNIERE_VENTE
FROM MvtArt m
JOIN ARTICLES a ON a.NO_ID = m.ArtNoId
WHERE m.GenreMvt = 3
AND m.DatMvt BETWEEN $1 AND $2
AND m.Site LIKE $3
GROUP BY a.CODEIN, a.LIBELLE1, m.Site
ORDER BY ${orderCol} DESC
LIMIT ${limitNum}
`, [dateDebut, dateFin, `%${site}%`]);
res.json({
dateDebut,
dateFin,
site: site || 'tous',
classementPar: groupBy,
hitparade: result.rows,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/performance/ca/nomenclature?dateDebut=&dateFin=&site=&niveau=
router.get('/ca/nomenclature', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2024-01-01', dateFin = '2099-12-31', site = '', niveau = 1 } = req.query;
const result = await pool.query(`
SELECT
n.CODE AS CODE_NOMEN,
n.LIBELLE,
n.NIVEAU,
COUNT(DISTINCT m.ArtNoId) AS NB_ARTICLES,
SUM(m.QteMvt) AS QTE_VENDUE,
SUM(m.MntMvtHt) AS CA_HT,
SUM(m.MntMvtTTC) AS CA_TTC,
SUM(m.MargeMvt) AS MARGE
FROM MvtArt m
JOIN ARTICLES a ON a.NO_ID = m.ArtNoId
JOIN NOMENCLATURE n ON n.NO_ID = a.NOM_NO_ID
WHERE m.GenreMvt = 3
AND m.DatMvt BETWEEN $1 AND $2
AND m.Site LIKE $3
AND n.NIVEAU = $4
GROUP BY n.CODE, n.LIBELLE, n.NIVEAU
ORDER BY CA_TTC DESC
`, [dateDebut, dateFin, `%${site}%`, parseInt(niveau)]);
res.json({ dateDebut, dateFin, niveau: parseInt(niveau), ca_par_nomenclature: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/performance/ca/gamme?dateDebut=&dateFin=&site=
router.get('/ca/gamme', async (req, res) => {
try {
const pool = getPool();
const { dateDebut = '2024-01-01', dateFin = '2099-12-31', site = '' } = req.query;
const result = await pool.query(`
SELECT
g.CODE AS GAMME,
g.LIBELLE AS LIBELLE_GAMME,
COUNT(DISTINCT m.ArtNoId) AS NB_ARTICLES,
SUM(m.QteMvt) AS QTE_VENDUE,
SUM(m.MntMvtHt) AS CA_HT,
SUM(m.MntMvtTTC) AS CA_TTC,
SUM(m.MargeMvt) AS MARGE
FROM MvtArt m
JOIN ART_GAMME_SAISON ags ON ags.ARTNOID = m.ArtNoId
JOIN GAMMES g ON g.NO_ID = ags.IDGAMME
WHERE m.GenreMvt = 3
AND m.DatMvt BETWEEN $1 AND $2
AND m.Site LIKE $3
GROUP BY g.CODE, g.LIBELLE
ORDER BY CA_TTC DESC
`, [dateDebut, dateFin, `%${site}%`]);
res.json({ dateDebut, dateFin, ca_par_gamme: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+136
View File
@@ -0,0 +1,136 @@
const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// GET /api/schema - Structure complète de la base de données
router.get('/', async (req, res) => {
try {
const pool = getPool();
const result = await pool.query(`
SELECT
table_schema AS TABLE_SCHEMA,
table_name AS TABLE_NAME,
table_type AS TABLE_TYPE,
column_name AS COLUMN_NAME,
ordinal_position AS ORDINAL_POSITION,
data_type AS DATA_TYPE,
character_maximum_length AS CHARACTER_MAXIMUM_LENGTH,
numeric_precision AS NUMERIC_PRECISION,
numeric_scale AS NUMERIC_SCALE,
is_nullable AS IS_NULLABLE,
column_default AS COLUMN_DEFAULT
FROM information_schema.tables
JOIN information_schema.columns USING (table_schema, table_name)
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position
`);
const schema = {};
for (const row of result.rows) {
const key = `${row.table_schema}.${row.table_name}`;
if (!schema[key]) {
schema[key] = {
schema: row.table_schema,
table: row.table_name,
type: row.table_type,
columns: [],
};
}
schema[key].columns.push({
name: row.column_name,
position: row.ordinal_position,
type: row.data_type,
maxLength: row.character_maximum_length,
precision: row.numeric_precision,
scale: row.numeric_scale,
nullable: row.is_nullable === 'YES',
default: row.column_default,
});
}
const tables = Object.values(schema);
res.json({
database: process.env.PG_DB,
server: process.env.PG_HOST,
tableCount: tables.filter(t => t.type === 'BASE TABLE').length,
viewCount: tables.filter(t => t.type === 'VIEW').length,
tables,
});
} catch (err) {
res.status(500).json({ error: 'Erreur connexion base de données', detail: err.message });
}
});
// GET /api/schema/tables?search= - Liste des tables avec nombre de lignes (estimé)
router.get('/tables', async (req, res) => {
try {
const pool = getPool();
const search = req.query.search || '';
const result = await pool.query(`
SELECT
schemaname AS TABLE_SCHEMA,
tablename AS TABLE_NAME,
'BASE TABLE' AS TABLE_TYPE,
n_live_tup AS ROW_COUNT
FROM pg_stat_user_tables
WHERE tablename LIKE $1
ORDER BY n_live_tup DESC, tablename
`, [`%${search}%`]);
res.json({ count: result.rows.length, tables: result.rows });
} catch (err) {
res.status(500).json({ error: 'Erreur connexion base de données', detail: err.message });
}
});
// GET /api/schema/top?limit= - Top tables par nombre de lignes
router.get('/top', async (req, res) => {
try {
const pool = getPool();
const limitNum = Math.max(1, Math.min(parseInt(req.query.limit) || 50, 200));
const result = await pool.query(`
SELECT tablename AS TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE, n_live_tup AS ROW_COUNT
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC
LIMIT $1
`, [limitNum]);
res.json({ tables: result.rows });
} catch (err) {
res.status(500).json({ error: 'Erreur connexion base de données', detail: err.message });
}
});
// GET /api/schema/tables/:tableName - Colonnes d'une table
router.get('/tables/:tableName', async (req, res) => {
try {
const pool = getPool();
const result = await pool.query(`
SELECT
column_name AS COLUMN_NAME,
ordinal_position AS ORDINAL_POSITION,
data_type AS DATA_TYPE,
character_maximum_length AS CHARACTER_MAXIMUM_LENGTH,
numeric_precision AS NUMERIC_PRECISION,
numeric_scale AS NUMERIC_SCALE,
is_nullable AS IS_NULLABLE,
column_default AS COLUMN_DEFAULT
FROM information_schema.columns
WHERE table_name = $1 AND table_schema = 'public'
ORDER BY ordinal_position
`, [req.params.tableName]);
if (!result.rows.length)
return res.status(404).json({ error: `Table '${req.params.tableName}' introuvable` });
res.json({ table: req.params.tableName, columns: result.rows });
} catch (err) {
res.status(500).json({ error: 'Erreur connexion base de données', detail: err.message });
}
});
module.exports = router;
+111
View File
@@ -0,0 +1,111 @@
const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// GET /api/stock?site=&page=&limit= - Stock global
router.get('/', async (req, res) => {
try {
const pool = getPool();
const { site = '', page = 1, limit = 100 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.max(1, Math.min(parseInt(limit) || 100, 1000));
const offsetNum = (pageNum - 1) * limitNum;
const result = await pool.query(`
SELECT
a.CODEIN, a.LIBELLE1,
s.Site, s.QTE, s.Prmp, s.ValStock, s.PV,
s.StockDispo, s.StockMort, s.StockColis,
s.DerniereVente, s.DerniereReception, s.PremiereVente,
s.NbJoursDernierMouvement, s.NbJoursDerniereVente,
s.NbJoursDerniereReception, s.InterditAchat
FROM Cube_Stock s
JOIN ARTICLES a ON a.NO_ID = s.ArtNoId
WHERE s.Site LIKE $1
ORDER BY a.LIBELLE1, s.Site
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [`%${site}%`]);
res.json({ page: pageNum, limit: limitNum, stock: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/stock/article/:id - Stock d'un article par site
router.get('/article/:id', async (req, res) => {
try {
const pool = getPool();
const result = await pool.query(`
SELECT
s.Site, s.QTE, s.Prmp, s.ValStock, s.PV,
s.StockDispo, s.StockMort, s.StockColis,
s.DerniereVente, s.DerniereReception, s.PremiereVente,
s.NbJoursDernierMouvement, s.NbJoursDerniereVente,
s.NbJoursDerniereReception, s.InterditAchat, s.CODEFOU
FROM Cube_Stock s
WHERE s.ArtNoId = $1
ORDER BY s.Site
`, [req.params.id]);
res.json({ artNoId: req.params.id, stock: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/stock/site/:site - Tous les articles d'un site
router.get('/site/:site', async (req, res) => {
try {
const pool = getPool();
const { page = 1, limit = 100 } = req.query;
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.max(1, Math.min(parseInt(limit) || 100, 1000));
const offsetNum = (pageNum - 1) * limitNum;
const result = await pool.query(`
SELECT
a.NO_ID AS ART_NO_ID, a.CODEIN, a.LIBELLE1,
s.QTE, s.Prmp, s.ValStock, s.PV,
s.StockDispo, s.StockMort, s.DerniereVente, s.DerniereReception,
s.NbJoursDerniereVente, s.NbJoursDerniereReception
FROM Cube_Stock s
JOIN ARTICLES a ON a.NO_ID = s.ArtNoId
WHERE s.Site = $1
ORDER BY s.QTE DESC
LIMIT ${limitNum} OFFSET ${offsetNum}
`, [req.params.site]);
res.json({ site: req.params.site, page: pageNum, limit: limitNum, stock: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/stock/valorisation - Valorisation du stock par site
router.get('/valorisation', async (req, res) => {
try {
const pool = getPool();
const result = await pool.query(`
SELECT
Site,
COUNT(*) AS NB_ARTICLES,
SUM(QTE) AS QTE_TOTALE,
SUM(ValStock) AS VAL_STOCK,
SUM(QTE * PV) AS VAL_PV_THEORIQUE,
AVG(Prmp) AS PRMP_MOYEN
FROM Cube_Stock
WHERE QTE > 0
GROUP BY Site
ORDER BY VAL_STOCK DESC
`);
res.json({ valorisation: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+19
View File
@@ -0,0 +1,19 @@
const express = require('express');
const router = express.Router();
const { getPool } = require('../config/database');
// GET /api/sync/status - Dernier état de chaque table synchronisée
router.get('/status', async (req, res) => {
try {
const pool = getPool();
const result = await pool.query(
`SELECT table_name, last_sync, rows_synced, status, error_msg
FROM sync_log ORDER BY last_sync DESC NULLS LAST`
);
res.json({ sync: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+6
View File
@@ -0,0 +1,6 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "index.js"]
+48
View File
@@ -0,0 +1,48 @@
require('dotenv').config();
const sql = require('mssql');
const { Pool } = require('pg');
const mssqlConfig = {
server: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT) || 1433,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
options: {
encrypt: false,
trustServerCertificate: true,
readOnlyIntent: true,
},
pool: { max: 5, min: 0, idleTimeoutMillis: 30000 },
};
let mssqlPool = null;
let pgPool = null;
async function getMssql() {
if (!mssqlPool) {
mssqlPool = await sql.connect(mssqlConfig);
}
return mssqlPool;
}
function getPg() {
if (!pgPool) {
pgPool = new Pool({
host: process.env.PG_HOST || 'postgres',
port: parseInt(process.env.PG_PORT) || 5432,
database: process.env.PG_DB,
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
max: 5,
});
}
return pgPool;
}
async function closeAll() {
if (mssqlPool) { await mssqlPool.close(); mssqlPool = null; }
if (pgPool) { await pgPool.end(); pgPool = null; }
}
module.exports = { getMssql, getPg, closeAll, sql };
+46
View File
@@ -0,0 +1,46 @@
require('dotenv').config();
const cron = require('node-cron');
const { syncArticles } = require('./tables/articles');
const { syncFournisseurs } = require('./tables/fournisseurs');
const { syncStock } = require('./tables/stock');
const { syncMouvements } = require('./tables/mouvements');
const { syncCommandes } = require('./tables/commandes');
const { syncReferentiel } = require('./tables/referentiel');
const { closeAll } = require('./db');
const forceMode = process.argv.includes('--force');
async function syncAll(force = false) {
const start = Date.now();
console.log(`\n=== SYNC START ${new Date().toISOString()} (force=${force}) ===`);
// Ordre important : référentiel avant articles, articles avant stock/mouvements
await syncReferentiel(force);
await syncArticles(force);
await syncFournisseurs(force);
await syncStock(force);
await syncMouvements(force);
await syncCommandes(force);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`=== SYNC DONE en ${elapsed}s ===\n`);
}
if (forceMode) {
// Lancement manuel : node index.js --force
console.log('Mode force : sync complète en cours...');
syncAll(true)
.then(() => closeAll())
.then(() => process.exit(0))
.catch(err => {
console.error('Erreur fatale sync:', err.message);
process.exit(1);
});
} else {
// Mode daemon : sync nightly à 3h00
console.log('Service sync démarré — cron: 0 3 * * * (3h00 chaque nuit)');
cron.schedule('0 3 * * *', () => {
syncAll(false).catch(err => console.error('Erreur cron sync:', err.message));
});
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "foirfouille-sync",
"version": "1.0.0",
"description": "Sync nightly SQL Server → PostgreSQL",
"main": "index.js",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"mssql": "^12.2.0",
"node-cron": "^3.0.3",
"pg": "^8.13.3"
}
}
+118
View File
@@ -0,0 +1,118 @@
const { getMssql, getPg } = require('../db');
const { batchUpsert, getLastSync, logSync } = require('../utils');
const ARTICLES_COLS = [
'no_id','codein','libelle1','libelle2','lib_ticket',
'tax_code','ach_code','utilisable','actif','suspendu',
'suividatecreation','suividatemodif','nom_no_id',
];
const INFOSUP_COLS = [
'artnoid','prix_vente_mini','prix_vente_maxi','eco_ht','eco_ttc',
'on_web','interdit_remise','nomphoto','datedebvente','datefinvente',
'datevaliditeachat','commentaire','pv_conseille','fraislogistic',
'transit','distribution',
];
const GTIN_COLS = ['idarticle','gtin','preferentiel'];
async function syncArticles(force) {
const ms = await getMssql();
const pg = getPg();
// === ARTICLES ===
try {
const lastSync = force ? null : await getLastSync(pg, 'articles');
const where = lastSync ? `WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}'` : '';
const res = await ms.request().query(`
SELECT NO_ID, CODEIN, LIBELLE1, LIBELLE2, LIB_TICKET,
TAX_CODE, ACH_CODE, UTILISABLE, ACTIF, SUSPENDU,
SUIVIDATECREATION, SUIVIDATEMODIF, NOM_NO_ID
FROM ARTICLES ${where}
`);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
codein: r.CODEIN,
libelle1: r.LIBELLE1,
libelle2: r.LIBELLE2,
lib_ticket: r.LIB_TICKET,
tax_code: r.TAX_CODE,
ach_code: r.ACH_CODE,
utilisable: r.UTILISABLE,
actif: r.ACTIF,
suspendu: r.SUSPENDU,
suividatecreation: r.SUIVIDATECREATION,
suividatemodif: r.SUIVIDATEMODIF,
nom_no_id: r.NOM_NO_ID,
}));
const count = await batchUpsert(pg, 'articles', rows, ['no_id'], ARTICLES_COLS);
await logSync(pg, 'articles', count, 'ok');
console.log(`[articles] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'articles', 0, 'error', err.message);
console.error(`[articles] ERREUR: ${err.message}`);
}
// === ARTICLE_INFOSUP ===
try {
const lastSync = force ? null : await getLastSync(pg, 'article_infosup');
const where = lastSync
? `WHERE ai.ARTNOID IN (SELECT NO_ID FROM ARTICLES WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}')`
: '';
const res = await ms.request().query(`
SELECT ARTNOID, PRIX_VENTE_MINI, PRIX_VENTE_MAXI, ECO_HT, ECO_TTC,
ON_WEB, INTERDIT_REMISE, NOMPHOTO, DATEDEBVENTE, DATEFINVENTE,
DATEVALIDITEACHAT, COMMENTAIRE, PV_CONSEILLE, FRAISLOGISTIC,
TRANSIT, DISTRIBUTION
FROM ARTICLE_INFOSUP ai ${where}
`);
const rows = res.recordset.map(r => ({
artnoid: r.ARTNOID,
prix_vente_mini: r.PRIX_VENTE_MINI,
prix_vente_maxi: r.PRIX_VENTE_MAXI,
eco_ht: r.ECO_HT,
eco_ttc: r.ECO_TTC,
on_web: r.ON_WEB,
interdit_remise: r.INTERDIT_REMISE,
nomphoto: r.NOMPHOTO,
datedebvente: r.DATEDEBVENTE,
datefinvente: r.DATEFINVENTE,
datevaliditeachat: r.DATEVALIDITEACHAT,
commentaire: r.COMMENTAIRE,
pv_conseille: r.PV_CONSEILLE,
fraislogistic: r.FRAISLOGISTIC,
transit: r.TRANSIT,
distribution: r.DISTRIBUTION,
}));
const count = await batchUpsert(pg, 'article_infosup', rows, ['artnoid'], INFOSUP_COLS);
await logSync(pg, 'article_infosup', count, 'ok');
console.log(`[article_infosup] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'article_infosup', 0, 'error', err.message);
console.error(`[article_infosup] ERREUR: ${err.message}`);
}
// === ART_GTIN (full refresh — petite table) ===
try {
const res = await ms.request().query(
`SELECT IDARTICLE, GTIN, PREFERENTIEL FROM ART_GTIN`
);
const rows = res.recordset.map(r => ({
idarticle: r.IDARTICLE,
gtin: r.GTIN,
preferentiel: r.PREFERENTIEL,
}));
const count = await batchUpsert(pg, 'art_gtin', rows, ['idarticle','gtin'], GTIN_COLS);
await logSync(pg, 'art_gtin', count, 'ok');
console.log(`[art_gtin] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'art_gtin', 0, 'error', err.message);
console.error(`[art_gtin] ERREUR: ${err.message}`);
}
}
module.exports = { syncArticles };
+148
View File
@@ -0,0 +1,148 @@
const { getMssql, getPg } = require('../db');
const { fullRefresh, batchUpsert, getLastSync, logSync } = require('../utils');
const VIVANT_COLS = [
'cdefou_ligne_com_no_id','artfou1_no_id','articles_codein','articles_libelle1',
'articles_libelle2','artfou1_reference','artfou1_ean13','artfou1_itf',
'cdefou_ligne_qtecde','cdefou_ligne_prixbrut','cdefou_ligne_remise',
'cdefou_ligne_remise2','cdefou_ligne_remise3','cdefou_ligne_prixnet',
'cdefou_ligne_montant','cdefou_ligne_prixvente','cdefou_ligne_gratuit',
'cdefou_ligne_qteacc','cdefou_ligne_qteann','cdefou_ligne_qteatt',
'cdefou_ligne_qterel','cdefou_ligne_cdeligtard','cdefou_ligne_cdeligtot',
'cotisation_logistique','fraislogistic','transit','distribution','taxe',
'commentaire','suividatecreation',
];
const RECEP_COLS = ['no_id','suividatecreation','suividatemodif'];
const RECEPLIG_COLS = [
'no_id','cdefou_reception_no_id','artfou1_no_id',
'qtebl','qterec','qteacc','qteref','qteaff',
'pribrut','remise','prirec','mntrec',
'motifrefus','recpb','recpbok',
'suividatecreation','suividatemodif',
];
async function syncCommandes(force) {
const ms = await getMssql();
const pg = getPg();
// === CDEFOU_VIVANT (full refresh — commandes en cours uniquement) ===
try {
const res = await ms.request().query(`
SELECT
CDEFOU_LIGNE_COM_NO_ID, ARTFOU1_NO_ID,
ARTICLES_CODEIN, ARTICLES_LIBELLE1, ARTICLES_LIBELLE2,
ARTFOU1_REFERENCE, ARTFOU1_EAN13, ARTFOU1_ITF,
CDEFOU_LIGNE_QTECDE, CDEFOU_LIGNE_PRIXBRUT, CDEFOU_LIGNE_REMISE,
CDEFOU_LIGNE_REMISE2, CDEFOU_LIGNE_REMISE3, CDEFOU_LIGNE_PRIXNET,
CDEFOU_LIGNE_MONTANT, CDEFOU_LIGNE_PRIXVENTE, CDEFOU_LIGNE_GRATUIT,
CDEFOU_LIGNE_QTEACC, CDEFOU_LIGNE_QTEANN, CDEFOU_LIGNE_QTEATT,
CDEFOU_LIGNE_QTEREL, CDEFOU_LIGNE_CDELIGTARD, CDEFOU_LIGNE_CDELIGTOT,
COTISATION_LOGISTIQUE, FRAISLOGISTIC, TRANSIT, DISTRIBUTION, TAXE,
COMMENTAIRE, SUIVIDATECREATION
FROM CDEFOU_VIVANT
`);
const rows = res.recordset.map(r => ({
cdefou_ligne_com_no_id: r.CDEFOU_LIGNE_COM_NO_ID,
artfou1_no_id: r.ARTFOU1_NO_ID,
articles_codein: r.ARTICLES_CODEIN,
articles_libelle1: r.ARTICLES_LIBELLE1,
articles_libelle2: r.ARTICLES_LIBELLE2,
artfou1_reference: r.ARTFOU1_REFERENCE,
artfou1_ean13: r.ARTFOU1_EAN13,
artfou1_itf: r.ARTFOU1_ITF,
cdefou_ligne_qtecde: r.CDEFOU_LIGNE_QTECDE,
cdefou_ligne_prixbrut: r.CDEFOU_LIGNE_PRIXBRUT,
cdefou_ligne_remise: r.CDEFOU_LIGNE_REMISE,
cdefou_ligne_remise2: r.CDEFOU_LIGNE_REMISE2,
cdefou_ligne_remise3: r.CDEFOU_LIGNE_REMISE3,
cdefou_ligne_prixnet: r.CDEFOU_LIGNE_PRIXNET,
cdefou_ligne_montant: r.CDEFOU_LIGNE_MONTANT,
cdefou_ligne_prixvente: r.CDEFOU_LIGNE_PRIXVENTE,
cdefou_ligne_gratuit: r.CDEFOU_LIGNE_GRATUIT,
cdefou_ligne_qteacc: r.CDEFOU_LIGNE_QTEACC,
cdefou_ligne_qteann: r.CDEFOU_LIGNE_QTEANN,
cdefou_ligne_qteatt: r.CDEFOU_LIGNE_QTEATT,
cdefou_ligne_qterel: r.CDEFOU_LIGNE_QTEREL,
cdefou_ligne_cdeligtard: r.CDEFOU_LIGNE_CDELIGTARD,
cdefou_ligne_cdeligtot: r.CDEFOU_LIGNE_CDELIGTOT,
cotisation_logistique: r.COTISATION_LOGISTIQUE,
fraislogistic: r.FRAISLOGISTIC,
transit: r.TRANSIT,
distribution: r.DISTRIBUTION,
taxe: r.TAXE,
commentaire: r.COMMENTAIRE,
suividatecreation: r.SUIVIDATECREATION,
}));
const count = await fullRefresh(pg, 'cdefou_vivant', rows, VIVANT_COLS);
await logSync(pg, 'cdefou_vivant', count, 'ok');
console.log(`[cdefou_vivant] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'cdefou_vivant', 0, 'error', err.message);
console.error(`[cdefou_vivant] ERREUR: ${err.message}`);
}
// === CDEFOU_RECEPTION (upsert delta) ===
try {
const lastSync = force ? null : await getLastSync(pg, 'cdefou_reception');
const where = lastSync ? `WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}'` : '';
const res = await ms.request().query(
`SELECT NO_ID, SUIVIDATECREATION, SUIVIDATEMODIF FROM CDEFOU_RECEPTION ${where}`
);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
suividatecreation: r.SUIVIDATECREATION,
suividatemodif: r.SUIVIDATEMODIF,
}));
const count = await batchUpsert(pg, 'cdefou_reception', rows, ['no_id'], RECEP_COLS);
await logSync(pg, 'cdefou_reception', count, 'ok');
console.log(`[cdefou_reception] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'cdefou_reception', 0, 'error', err.message);
console.error(`[cdefou_reception] ERREUR: ${err.message}`);
}
// === CDEFOU_RECEPLIG (upsert delta) ===
try {
const lastSync = force ? null : await getLastSync(pg, 'cdefou_receplig');
const where = lastSync ? `WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}'` : '';
const res = await ms.request().query(`
SELECT NO_ID, CDEFOU_RECEPTION_NO_ID, ARTFOU1_NO_ID,
QTEBL, QTEREC, QTEACC, QTEREF, QTEAFF,
PRIBRUT, REMISE, PRIREC, MNTREC,
MOTIFREFUS, RECPB, RECPBOK,
SUIVIDATECREATION, SUIVIDATEMODIF
FROM CDEFOU_RECEPLIG ${where}
`);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
cdefou_reception_no_id: r.CDEFOU_RECEPTION_NO_ID,
artfou1_no_id: r.ARTFOU1_NO_ID,
qtebl: r.QTEBL,
qterec: r.QTEREC,
qteacc: r.QTEACC,
qteref: r.QTEREF,
qteaff: r.QTEAFF,
pribrut: r.PRIBRUT,
remise: r.REMISE,
prirec: r.PRIREC,
mntrec: r.MNTREC,
motifrefus: r.MOTIFREFUS,
recpb: r.RECPB,
recpbok: r.RECPBOK,
suividatecreation: r.SUIVIDATECREATION,
suividatemodif: r.SUIVIDATEMODIF,
}));
const count = await batchUpsert(pg, 'cdefou_receplig', rows, ['no_id'], RECEPLIG_COLS);
await logSync(pg, 'cdefou_receplig', count, 'ok');
console.log(`[cdefou_receplig] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'cdefou_receplig', 0, 'error', err.message);
console.error(`[cdefou_receplig] ERREUR: ${err.message}`);
}
}
module.exports = { syncCommandes };
+94
View File
@@ -0,0 +1,94 @@
const { getMssql, getPg } = require('../db');
const { batchUpsert, fullRefresh, getLastSync, logSync } = require('../utils');
const ARTFOU1_COLS = [
'no_id','art_no_id','code','reference','ean13','itf',
'qteua','pcb','spcb','delai','securite','preference','suspendu',
'suividatecreation','suividatemodif',
];
const ARTFOU2_COLS = ['idartfou1','prixachat','remise_promotion'];
const FOUADR1_COLS = ['code','sit_code','raisonsociale','adrligne1','telephone','email'];
async function syncFournisseurs(force) {
const ms = await getMssql();
const pg = getPg();
// === ARTFOU1 (delta par SUIVIDATEMODIF) ===
try {
const lastSync = force ? null : await getLastSync(pg, 'artfou1');
const where = lastSync ? `WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}'` : '';
const res = await ms.request().query(`
SELECT NO_ID, ART_NO_ID, CODE, REFERENCE, EAN13, ITF,
QTEUA, PCB, SPCB, DELAI, SECURITE, PREFERENCE, SUSPENDU,
SUIVIDATECREATION, SUIVIDATEMODIF
FROM ARTFOU1 ${where}
`);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
art_no_id: r.ART_NO_ID,
code: r.CODE,
reference: r.REFERENCE,
ean13: r.EAN13,
itf: r.ITF,
qteua: r.QTEUA,
pcb: r.PCB,
spcb: r.SPCB,
delai: r.DELAI,
securite: r.SECURITE,
preference: r.PREFERENCE,
suspendu: r.SUSPENDU,
suividatecreation: r.SUIVIDATECREATION,
suividatemodif: r.SUIVIDATEMODIF,
}));
const count = await batchUpsert(pg, 'artfou1', rows, ['no_id'], ARTFOU1_COLS);
await logSync(pg, 'artfou1', count, 'ok');
console.log(`[artfou1] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'artfou1', 0, 'error', err.message);
console.error(`[artfou1] ERREUR: ${err.message}`);
}
// === ARTFOU2 (full refresh — liée à ARTFOU1) ===
try {
const res = await ms.request().query(
`SELECT IDARTFOU1, PRIXACHAT, REMISE_PROMOTION FROM ARTFOU2`
);
const rows = res.recordset.map(r => ({
idartfou1: r.IDARTFOU1,
prixachat: r.PRIXACHAT,
remise_promotion: r.REMISE_PROMOTION,
}));
const count = await batchUpsert(pg, 'artfou2', rows, ['idartfou1'], ARTFOU2_COLS);
await logSync(pg, 'artfou2', count, 'ok');
console.log(`[artfou2] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'artfou2', 0, 'error', err.message);
console.error(`[artfou2] ERREUR: ${err.message}`);
}
// === FOUADR1 (full refresh — ~500 lignes) ===
try {
const res = await ms.request().query(
`SELECT CODE, SIT_CODE, RAISONSOCIALE, ADRLIGNE1, TELEPHONE, EMAIL FROM FOUADR1`
);
const rows = res.recordset.map(r => ({
code: r.CODE,
sit_code: r.SIT_CODE,
raisonsociale: r.RAISONSOCIALE,
adrligne1: r.ADRLIGNE1,
telephone: r.TELEPHONE,
email: r.EMAIL,
}));
const count = await fullRefresh(pg, 'fouadr1', rows, FOUADR1_COLS);
await logSync(pg, 'fouadr1', count, 'ok');
console.log(`[fouadr1] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'fouadr1', 0, 'error', err.message);
console.error(`[fouadr1] ERREUR: ${err.message}`);
}
}
module.exports = { syncFournisseurs };
+138
View File
@@ -0,0 +1,138 @@
const { getMssql, getPg } = require('../db');
const { logSync } = require('../utils');
const MVTART_COLS = [
'artnoid','datmvt','site','libmvt','genremvt',
'qtemvt','valmvt','mntmvtht','mntmvtttc','margemvt',
'qtestock','prmp','valstock','codefou',
];
const MVTREG_COLS = [
'datmvt','codtick','codcartecli','coddev',
'mntreg','mntregdev','clientnom','echeance',
'reference','typereg','suividatecreation',
];
const CHUNK = 2000;
async function insertChunk(pg, table, rows, cols) {
if (!rows.length) return;
const values = [];
const placeholders = rows.map((row, ri) => {
const ph = cols.map((col, ci) => {
values.push(row[col] ?? null);
return `$${ri * cols.length + ci + 1}`;
});
return `(${ph.join(', ')})`;
});
await pg.query(
`INSERT INTO ${table} (${cols.join(', ')}) VALUES ${placeholders.join(', ')}`,
values
);
}
async function syncMouvements(force) {
const ms = await getMssql();
const pg = getPg();
// === MvtArt (insert-only par date croissante) ===
try {
// Date pivot : max existant en pg, ou 2 ans en arrière pour sync initiale
const maxRes = await pg.query(`SELECT MAX(datmvt) AS last FROM mvtart`);
let lastDate = maxRes.rows[0]?.last;
if (!lastDate || force) {
const twoYearsAgo = new Date();
twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
lastDate = twoYearsAgo;
}
const res = await ms.request()
.input('lastDate', lastDate)
.query(`
SELECT ArtNoId, DatMvt, Site, LibMvt, GenreMvt,
QteMvt, ValMvt, MntMvtHt, MntMvtTTC, MargeMvt,
QteStock, Prmp, ValStock, CODEFOU
FROM MvtArt
WHERE DatMvt > @lastDate
ORDER BY DatMvt
`);
const allRows = res.recordset.map(r => ({
artnoid: r.ArtNoId,
datmvt: r.DatMvt,
site: r.Site,
libmvt: r.LibMvt,
genremvt: r.GenreMvt,
qtemvt: r.QteMvt,
valmvt: r.ValMvt,
mntmvtht: r.MntMvtHt,
mntmvtttc: r.MntMvtTTC,
margemvt: r.MargeMvt,
qtestock: r.QteStock,
prmp: r.Prmp,
valstock: r.ValStock,
codefou: r.CODEFOU,
}));
let inserted = 0;
for (let i = 0; i < allRows.length; i += CHUNK) {
await insertChunk(pg, 'mvtart', allRows.slice(i, i + CHUNK), MVTART_COLS);
inserted += Math.min(CHUNK, allRows.length - i);
}
await logSync(pg, 'mvtart', inserted, 'ok');
console.log(`[mvtart] ${inserted} nouvelles lignes`);
} catch (err) {
await logSync(pg, 'mvtart', 0, 'error', err.message);
console.error(`[mvtart] ERREUR: ${err.message}`);
}
// === MvtReg (insert-only par date) ===
try {
const maxRes = await pg.query(`SELECT MAX(datmvt) AS last FROM mvtreg`);
let lastDate = maxRes.rows[0]?.last;
if (!lastDate || force) {
const twoYearsAgo = new Date();
twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
lastDate = twoYearsAgo;
}
const res = await ms.request()
.input('lastDate', lastDate)
.query(`
SELECT DatMvt, CodTick, CodCarteCli, CodDev,
MntReg, MntRegDev, ClientNom, Echeance,
REFERENCE, TYPEREG, SuiviDateCreation
FROM MvtReg
WHERE DatMvt > @lastDate
ORDER BY DatMvt
`);
const allRows = res.recordset.map(r => ({
datmvt: r.DatMvt,
codtick: r.CodTick,
codcartecli: r.CodCarteCli,
coddev: r.CodDev,
mntreg: r.MntReg,
mntregdev: r.MntRegDev,
clientnom: r.ClientNom,
echeance: r.Echeance,
reference: r.REFERENCE,
typereg: r.TYPEREG,
suividatecreation: r.SuiviDateCreation,
}));
let inserted = 0;
for (let i = 0; i < allRows.length; i += CHUNK) {
await insertChunk(pg, 'mvtreg', allRows.slice(i, i + CHUNK), MVTREG_COLS);
inserted += Math.min(CHUNK, allRows.length - i);
}
await logSync(pg, 'mvtreg', inserted, 'ok');
console.log(`[mvtreg] ${inserted} nouvelles lignes`);
} catch (err) {
await logSync(pg, 'mvtreg', 0, 'error', err.message);
console.error(`[mvtreg] ERREUR: ${err.message}`);
}
}
module.exports = { syncMouvements };
+88
View File
@@ -0,0 +1,88 @@
const { getMssql, getPg } = require('../db');
const { fullRefresh, batchUpsert, logSync } = require('../utils');
const NOMEN_COLS = ['no_id','code','libelle','niveau','chemin_pere'];
const GAMME_COLS = ['no_id','code','libelle'];
const SAISON_COLS = ['no_id','code','libelle'];
const AGS_COLS = ['artnoid','idgamme','idsaison'];
async function syncReferentiel(force) {
const ms = await getMssql();
const pg = getPg();
// === NOMENCLATURE (full refresh) ===
try {
const res = await ms.request().query(
`SELECT NO_ID, CODE, LIBELLE, NIVEAU, CHEMIN_PERE FROM NOMENCLATURE`
);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
code: r.CODE,
libelle: r.LIBELLE,
niveau: r.NIVEAU,
chemin_pere: r.CHEMIN_PERE,
}));
const count = await batchUpsert(pg, 'nomenclature', rows, ['no_id'], NOMEN_COLS);
await logSync(pg, 'nomenclature', count, 'ok');
console.log(`[nomenclature] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'nomenclature', 0, 'error', err.message);
console.error(`[nomenclature] ERREUR: ${err.message}`);
}
// === GAMMES (full refresh) ===
try {
const res = await ms.request().query(
`SELECT NO_ID, CODE, LIBELLE FROM GAMMES`
);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
code: r.CODE,
libelle: r.LIBELLE,
}));
const count = await batchUpsert(pg, 'gammes', rows, ['no_id'], GAMME_COLS);
await logSync(pg, 'gammes', count, 'ok');
console.log(`[gammes] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'gammes', 0, 'error', err.message);
console.error(`[gammes] ERREUR: ${err.message}`);
}
// === SAISONS (full refresh) ===
try {
const res = await ms.request().query(
`SELECT NO_ID, CODE, LIBELLE FROM SAISONS`
);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
code: r.CODE,
libelle: r.LIBELLE,
}));
const count = await batchUpsert(pg, 'saisons', rows, ['no_id'], SAISON_COLS);
await logSync(pg, 'saisons', count, 'ok');
console.log(`[saisons] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'saisons', 0, 'error', err.message);
console.error(`[saisons] ERREUR: ${err.message}`);
}
// === ART_GAMME_SAISON (full refresh) ===
try {
const res = await ms.request().query(
`SELECT ARTNOID, IDGAMME, IDSAISON FROM ART_GAMME_SAISON`
);
const rows = res.recordset.map(r => ({
artnoid: r.ARTNOID,
idgamme: r.IDGAMME,
idsaison: r.IDSAISON,
}));
const count = await fullRefresh(pg, 'art_gamme_saison', rows, AGS_COLS);
await logSync(pg, 'art_gamme_saison', count, 'ok');
console.log(`[art_gamme_saison] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'art_gamme_saison', 0, 'error', err.message);
console.error(`[art_gamme_saison] ERREUR: ${err.message}`);
}
}
module.exports = { syncReferentiel };
+84
View File
@@ -0,0 +1,84 @@
const { getMssql, getPg } = require('../db');
const { fullRefresh, batchUpsert, logSync } = require('../utils');
const STOCK_COLS = [
'artnoid','site','qte','prmp','valstock','pv',
'stockdispo','stockmort','stockcolis',
'dernierevente','dernierereception','premierevente',
'nbjoursdernierMouvement','nbjoursdernierevente','nbjoursdernierrereception',
'interditachat','codefou',
];
const PA_COLS = ['artnoid','pa'];
const PV_COLS = ['artnoid','site','pv'];
async function syncStock(force) {
const ms = await getMssql();
const pg = getPg();
// === Cube_Stock (full refresh — recalculé chaque nuit) ===
try {
const res = await ms.request().query(`
SELECT ArtNoId, Site, QTE, Prmp, ValStock, PV,
StockDispo, StockMort, StockColis,
DerniereVente, DerniereReception, PremiereVente,
NbJoursDernierMouvement, NbJoursDerniereVente,
NbJoursDerniereReception, InterditAchat, CODEFOU
FROM Cube_Stock
`);
const rows = res.recordset.map(r => ({
artnoid: r.ArtNoId,
site: r.Site,
qte: r.QTE,
prmp: r.Prmp,
valstock: r.ValStock,
pv: r.PV,
stockdispo: r.StockDispo,
stockmort: r.StockMort,
stockcolis: r.StockColis,
dernierevente: r.DerniereVente,
dernierereception: r.DerniereReception,
premierevente: r.PremiereVente,
nbjoursdernierMouvement: r.NbJoursDernierMouvement,
nbjoursdernierevente: r.NbJoursDerniereVente,
nbjoursdernierrereception: r.NbJoursDerniereReception,
interditachat: r.InterditAchat,
codefou: r.CODEFOU,
}));
const count = await fullRefresh(pg, 'cube_stock', rows, STOCK_COLS);
await logSync(pg, 'cube_stock', count, 'ok');
console.log(`[cube_stock] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'cube_stock', 0, 'error', err.message);
console.error(`[cube_stock] ERREUR: ${err.message}`);
}
// === 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 count = await fullRefresh(pg, 'cube_pa', rows, PA_COLS);
await logSync(pg, 'cube_pa', count, 'ok');
console.log(`[cube_pa] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'cube_pa', 0, 'error', err.message);
console.error(`[cube_pa] ERREUR: ${err.message}`);
}
// === Cube_PV (full refresh) ===
try {
const res = await ms.request().query(`SELECT ArtNoId, Site, PV FROM Cube_PV`);
const rows = res.recordset.map(r => ({
artnoid: r.ArtNoId, site: r.Site, pv: r.PV,
}));
const count = await batchUpsert(pg, 'cube_pv', rows, ['artnoid','site'], PV_COLS);
await logSync(pg, 'cube_pv', count, 'ok');
console.log(`[cube_pv] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'cube_pv', 0, 'error', err.message);
console.error(`[cube_pv] ERREUR: ${err.message}`);
}
}
module.exports = { syncStock };
+91
View File
@@ -0,0 +1,91 @@
/**
* Upsert en masse dans PostgreSQL (chunks de 500 lignes).
* @param {import('pg').Pool} pg
* @param {string} table - nom de la table PostgreSQL (lowercase)
* @param {object[]} rows - lignes avec les valeurs à insérer
* @param {string[]} pk - colonnes formant la clé primaire
* @param {string[]} cols - toutes les colonnes à upsert
* @returns {number} total de lignes traitées
*/
async function batchUpsert(pg, table, rows, pk, cols) {
if (!rows.length) return 0;
const CHUNK = 500;
let total = 0;
for (let i = 0; i < rows.length; i += CHUNK) {
const chunk = rows.slice(i, i + CHUNK);
const values = [];
const placeholders = chunk.map((row, ri) => {
const ph = cols.map((col, ci) => {
values.push(row[col] ?? null);
return `$${ri * cols.length + ci + 1}`;
});
return `(${ph.join(', ')})`;
});
const updateCols = cols.filter(c => !pk.includes(c));
const updateSet = updateCols.map(c => `${c} = EXCLUDED.${c}`).join(', ');
const sql = `
INSERT INTO ${table} (${cols.join(', ')})
VALUES ${placeholders.join(', ')}
ON CONFLICT (${pk.join(', ')}) DO UPDATE SET ${updateSet}
`;
await pg.query(sql, values);
total += chunk.length;
}
return total;
}
/**
* Full refresh : TRUNCATE puis INSERT en masse.
*/
async function fullRefresh(pg, table, rows, cols) {
if (!rows.length) {
await pg.query(`TRUNCATE TABLE ${table}`);
return 0;
}
const CHUNK = 500;
await pg.query(`TRUNCATE TABLE ${table}`);
let total = 0;
for (let i = 0; i < rows.length; i += CHUNK) {
const chunk = rows.slice(i, i + CHUNK);
const values = [];
const placeholders = chunk.map((row, ri) => {
const ph = cols.map((col, ci) => {
values.push(row[col] ?? null);
return `$${ri * cols.length + ci + 1}`;
});
return `(${ph.join(', ')})`;
});
const sql = `INSERT INTO ${table} (${cols.join(', ')}) VALUES ${placeholders.join(', ')}`;
await pg.query(sql, values);
total += chunk.length;
}
return total;
}
/**
* Lit la date du dernier sync réussi pour une table.
*/
async function getLastSync(pg, tableName) {
const res = await pg.query(
`SELECT last_sync FROM sync_log WHERE table_name = $1 AND status = 'ok'`,
[tableName]
);
return res.rows[0]?.last_sync || null;
}
/**
* Enregistre le résultat d'un sync dans sync_log.
*/
async function logSync(pg, tableName, rowsSynced, status, errorMsg = null) {
await pg.query(`
INSERT INTO sync_log (table_name, last_sync, rows_synced, status, error_msg)
VALUES ($1, NOW(), $2, $3, $4)
ON CONFLICT (table_name) DO UPDATE SET
last_sync = NOW(),
rows_synced = $2,
status = $3,
error_msg = $4
`, [tableName, rowsSynced, status, errorMsg]);
}
module.exports = { batchUpsert, fullRefresh, getLastSync, logSync };