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

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