From 9675a243b70b3086c376e114ae3f07061db91fef Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 21 Mar 2026 10:01:02 +0100 Subject: [PATCH] feat: Implement initial API backend with a comprehensive PostgreSQL schema, Express application, and data synchronization for articles and ranking. --- postgres/init.sql | 13 +++++ src/routes/performance.js | 99 +++++++++++++++++++++++++++++++++++++++ sync/index.js | 3 +- sync/tables/mouvements.js | 52 +++++++++++++++++++- 4 files changed, 165 insertions(+), 2 deletions(-) diff --git a/postgres/init.sql b/postgres/init.sql index 0e2e60e..1432761 100644 --- a/postgres/init.sql +++ b/postgres/init.sql @@ -310,3 +310,16 @@ CREATE TABLE IF NOT EXISTS cdefou_receplig ( ); 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); + +-- ============================================================ +-- CA journalier + trafic (StatOpCAJour SQL Server) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS statopca ( + site TEXT NOT NULL, + datmvt DATE NOT NULL, + mnt NUMERIC(14,4), + nbticket INTEGER, + PRIMARY KEY (site, datmvt) +); +CREATE INDEX IF NOT EXISTS idx_statopca_datmvt ON statopca (datmvt); diff --git a/src/routes/performance.js b/src/routes/performance.js index c173991..bd0451d 100644 --- a/src/routes/performance.js +++ b/src/routes/performance.js @@ -153,4 +153,103 @@ router.get('/ca/gamme', async (req, res) => { } }); +// GET /api/performance/dashboard?date=2026-03-20&site= +// CA + trafic du jour vs N-1 + top 10 CA / qté / marge +router.get('/dashboard', async (req, res) => { + try { + const pool = getPool(); + const { date, site = '' } = req.query; + + const targetDate = date || new Date(Date.now() - 86400000).toISOString().slice(0, 10); + const n1Date = new Date(targetDate); + n1Date.setFullYear(n1Date.getFullYear() - 1); + const n1DateStr = n1Date.toISOString().slice(0, 10); + + const siteFilter = site ? `AND site = $3` : ''; + const siteParams = site ? [targetDate, n1DateStr, site] : [targetDate, n1DateStr]; + + const [caRows, top10CA, top10Qte, top10Marge] = await Promise.all([ + // CA + trafic N et N-1 + pool.query(` + SELECT site, datmvt::DATE AS date, mnt AS ca_ttc, nbticket AS trafic + FROM statopca + WHERE datmvt IN ($1, $2) ${siteFilter} + ORDER BY site, datmvt + `, siteParams), + + // Top 10 CA + pool.query(` + SELECT a.codein, a.libelle1, m.site, + ABS(SUM(m.mntmvtttc)) AS ca_ttc, + ABS(SUM(m.qtemvt)) AS qte_vendue, + SUM(m.margemvt) AS marge + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.datmvt::DATE = $1 AND m.genremvt = 3 ${site ? 'AND m.site = $2' : ''} + GROUP BY a.codein, a.libelle1, m.site + ORDER BY ca_ttc DESC LIMIT 10 + `, site ? [targetDate, site] : [targetDate]), + + // Top 10 Qté + pool.query(` + SELECT a.codein, a.libelle1, m.site, + ABS(SUM(m.mntmvtttc)) AS ca_ttc, + ABS(SUM(m.qtemvt)) AS qte_vendue, + SUM(m.margemvt) AS marge + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.datmvt::DATE = $1 AND m.genremvt = 3 ${site ? 'AND m.site = $2' : ''} + GROUP BY a.codein, a.libelle1, m.site + ORDER BY qte_vendue DESC LIMIT 10 + `, site ? [targetDate, site] : [targetDate]), + + // Top 10 Marge + pool.query(` + SELECT a.codein, a.libelle1, m.site, + ABS(SUM(m.mntmvtttc)) AS ca_ttc, + ABS(SUM(m.qtemvt)) AS qte_vendue, + SUM(m.margemvt) AS marge + FROM mvtart m + JOIN articles a ON a.no_id = m.artnoid + WHERE m.datmvt::DATE = $1 AND m.genremvt = 3 ${site ? 'AND m.site = $2' : ''} + GROUP BY a.codein, a.libelle1, m.site + ORDER BY marge DESC LIMIT 10 + `, site ? [targetDate, site] : [targetDate]), + ]); + + // Construire la comparaison N vs N-1 par site + const byDate = {}; + for (const row of caRows.rows) { + const d = row.date instanceof Date ? row.date.toISOString().slice(0, 10) : String(row.date).slice(0, 10); + if (!byDate[row.site]) byDate[row.site] = {}; + byDate[row.site][d] = { ca_ttc: parseFloat(row.ca_ttc) || 0, trafic: row.trafic || 0 }; + } + + const sites = Object.keys(byDate).sort().map(s => { + const n = byDate[s]?.[targetDate] || { ca_ttc: 0, trafic: 0 }; + const n1 = byDate[s]?.[n1DateStr] || { ca_ttc: 0, trafic: 0 }; + const evolCa = n1.ca_ttc > 0 ? Math.round((n.ca_ttc / n1.ca_ttc - 1) * 1000) / 10 : null; + const evolTrafic = n1.trafic > 0 ? Math.round((n.trafic / n1.trafic - 1) * 1000) / 10 : null; + return { + site: s, + ca_ttc: n.ca_ttc, ca_ttc_n1: n1.ca_ttc, + evol_ca: evolCa !== null ? `${evolCa > 0 ? '+' : ''}${evolCa}%` : null, + trafic: n.trafic, trafic_n1: n1.trafic, + evol_trafic: evolTrafic !== null ? `${evolTrafic > 0 ? '+' : ''}${evolTrafic}%` : null, + }; + }); + + res.json({ + date: targetDate, + date_n1: n1DateStr, + sites, + top10_ca: top10CA.rows, + top10_qte: top10Qte.rows, + top10_marge: top10Marge.rows, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + module.exports = router; diff --git a/sync/index.js b/sync/index.js index 9b82a04..116dbd6 100644 --- a/sync/index.js +++ b/sync/index.js @@ -4,7 +4,7 @@ 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 { syncMouvements, syncStatOpCA } = require('./tables/mouvements'); const { syncCommandes } = require('./tables/commandes'); const { syncReferentiel } = require('./tables/referentiel'); const { syncRanking } = require('./tables/ranking'); @@ -24,6 +24,7 @@ const STEPS = [ { name: 'fournisseurs', fn: syncFournisseurs }, { name: 'stock', fn: syncStock }, { name: 'mouvements', fn: syncMouvements }, + { name: 'statopca', fn: syncStatOpCA }, { name: 'commandes', fn: syncCommandes }, { name: 'ranking', fn: syncRanking }, ]; diff --git a/sync/tables/mouvements.js b/sync/tables/mouvements.js index 306018d..ddcbf79 100644 --- a/sync/tables/mouvements.js +++ b/sync/tables/mouvements.js @@ -1,6 +1,8 @@ const { getMssql, getPg } = require('../db'); const { logSync } = require('../utils'); +const STATOPCA_COLS = ['site', 'datmvt', 'mnt', 'nbticket']; + const MVTART_COLS = [ 'artnoid','datmvt','site','libmvt','genremvt', 'qtemvt','valmvt','mntmvtht','mntmvtttc','margemvt', @@ -135,4 +137,52 @@ async function syncMouvements(force) { } } -module.exports = { syncMouvements }; +// === StatOpCAJour (upsert par site+date) === +async function syncStatOpCA(force) { + const ms = await getMssql(); + const pg = getPg(); + + try { + const maxRes = await pg.query(`SELECT MAX(datmvt) AS last FROM statopca`); + 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 SITE, DatMvt, mnt, nbTicket FROM StatOpCAJour WHERE DatMvt > @lastDate ORDER BY DatMvt`); + + const rows = res.recordset.map(r => ({ + site: r.SITE, + datmvt: r.DatMvt, + mnt: r.mnt, + nbticket: r.nbTicket, + })); + + for (let i = 0; i < rows.length; i += CHUNK) { + const chunk = rows.slice(i, i + CHUNK); + const values = []; + const placeholders = chunk.map((row, ri) => { + STATOPCA_COLS.forEach(col => values.push(row[col] ?? null)); + const ph = STATOPCA_COLS.map((_, ci) => `$${ri * STATOPCA_COLS.length + ci + 1}`); + return `(${ph.join(', ')})`; + }); + await pg.query( + `INSERT INTO statopca (${STATOPCA_COLS.join(', ')}) VALUES ${placeholders.join(', ')} + ON CONFLICT (site, datmvt) DO UPDATE SET mnt=EXCLUDED.mnt, nbticket=EXCLUDED.nbticket`, + values + ); + } + + await logSync(pg, 'statopca', rows.length, 'ok'); + console.log(`[statopca] ${rows.length} lignes`); + } catch (err) { + await logSync(pg, 'statopca', 0, 'error', err.message); + console.error(`[statopca] ERREUR: ${err.message}`); + } +} + +module.exports = { syncMouvements, syncStatOpCA };