commit d565ed9f10adf3c87412bde95dd8dd2e0d003195 Author: Michael SCHAL Date: Wed Mar 18 10:10:47 2026 +0100 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 diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..568d226 --- /dev/null +++ b/.env.docker.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc36d5d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cddea41 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6d8ee65 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frpc.toml.example b/frpc.toml.example new file mode 100644 index 0000000..6a5d71c --- /dev/null +++ b/frpc.toml.example @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..a652cfa --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postgres/init.sql b/postgres/init.sql new file mode 100644 index 0000000..66ae679 --- /dev/null +++ b/postgres/init.sql @@ -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); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..1e72a4e --- /dev/null +++ b/src/app.js @@ -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'); +}); diff --git a/src/config/database.js b/src/config/database.js new file mode 100644 index 0000000..9f7a761 --- /dev/null +++ b/src/config/database.js @@ -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 }; diff --git a/src/routes/articles.js b/src/routes/articles.js new file mode 100644 index 0000000..8c3ec58 --- /dev/null +++ b/src/routes/articles.js @@ -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; diff --git a/src/routes/commandes.js b/src/routes/commandes.js new file mode 100644 index 0000000..c7f0174 --- /dev/null +++ b/src/routes/commandes.js @@ -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; diff --git a/src/routes/fournisseurs.js b/src/routes/fournisseurs.js new file mode 100644 index 0000000..62698ff --- /dev/null +++ b/src/routes/fournisseurs.js @@ -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; diff --git a/src/routes/mouvements.js b/src/routes/mouvements.js new file mode 100644 index 0000000..49009dc --- /dev/null +++ b/src/routes/mouvements.js @@ -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; diff --git a/src/routes/performance.js b/src/routes/performance.js new file mode 100644 index 0000000..b897506 --- /dev/null +++ b/src/routes/performance.js @@ -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; diff --git a/src/routes/schema.js b/src/routes/schema.js new file mode 100644 index 0000000..a671148 --- /dev/null +++ b/src/routes/schema.js @@ -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; diff --git a/src/routes/stock.js b/src/routes/stock.js new file mode 100644 index 0000000..33ca6a0 --- /dev/null +++ b/src/routes/stock.js @@ -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; diff --git a/src/routes/sync.js b/src/routes/sync.js new file mode 100644 index 0000000..1f75095 --- /dev/null +++ b/src/routes/sync.js @@ -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; diff --git a/sync/Dockerfile b/sync/Dockerfile new file mode 100644 index 0000000..c8038f0 --- /dev/null +++ b/sync/Dockerfile @@ -0,0 +1,6 @@ +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +CMD ["node", "index.js"] diff --git a/sync/db.js b/sync/db.js new file mode 100644 index 0000000..14891e0 --- /dev/null +++ b/sync/db.js @@ -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 }; diff --git a/sync/index.js b/sync/index.js new file mode 100644 index 0000000..dacff52 --- /dev/null +++ b/sync/index.js @@ -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)); + }); +} diff --git a/sync/package.json b/sync/package.json new file mode 100644 index 0000000..4ea8c9e --- /dev/null +++ b/sync/package.json @@ -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" + } +} diff --git a/sync/tables/articles.js b/sync/tables/articles.js new file mode 100644 index 0000000..c5b5944 --- /dev/null +++ b/sync/tables/articles.js @@ -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 }; diff --git a/sync/tables/commandes.js b/sync/tables/commandes.js new file mode 100644 index 0000000..7a47fef --- /dev/null +++ b/sync/tables/commandes.js @@ -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 }; diff --git a/sync/tables/fournisseurs.js b/sync/tables/fournisseurs.js new file mode 100644 index 0000000..8d47753 --- /dev/null +++ b/sync/tables/fournisseurs.js @@ -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 }; diff --git a/sync/tables/mouvements.js b/sync/tables/mouvements.js new file mode 100644 index 0000000..4440030 --- /dev/null +++ b/sync/tables/mouvements.js @@ -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 }; diff --git a/sync/tables/referentiel.js b/sync/tables/referentiel.js new file mode 100644 index 0000000..cf4904d --- /dev/null +++ b/sync/tables/referentiel.js @@ -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 }; diff --git a/sync/tables/stock.js b/sync/tables/stock.js new file mode 100644 index 0000000..0183f77 --- /dev/null +++ b/sync/tables/stock.js @@ -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 }; diff --git a/sync/utils.js b/sync/utils.js new file mode 100644 index 0000000..c4a9197 --- /dev/null +++ b/sync/utils.js @@ -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 };