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

+6
View File
@@ -0,0 +1,6 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "index.js"]
+48
View File
@@ -0,0 +1,48 @@
require('dotenv').config();
const sql = require('mssql');
const { Pool } = require('pg');
const mssqlConfig = {
server: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT) || 1433,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
options: {
encrypt: false,
trustServerCertificate: true,
readOnlyIntent: true,
},
pool: { max: 5, min: 0, idleTimeoutMillis: 30000 },
};
let mssqlPool = null;
let pgPool = null;
async function getMssql() {
if (!mssqlPool) {
mssqlPool = await sql.connect(mssqlConfig);
}
return mssqlPool;
}
function getPg() {
if (!pgPool) {
pgPool = new Pool({
host: process.env.PG_HOST || 'postgres',
port: parseInt(process.env.PG_PORT) || 5432,
database: process.env.PG_DB,
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
max: 5,
});
}
return pgPool;
}
async function closeAll() {
if (mssqlPool) { await mssqlPool.close(); mssqlPool = null; }
if (pgPool) { await pgPool.end(); pgPool = null; }
}
module.exports = { getMssql, getPg, closeAll, sql };
+46
View File
@@ -0,0 +1,46 @@
require('dotenv').config();
const cron = require('node-cron');
const { syncArticles } = require('./tables/articles');
const { syncFournisseurs } = require('./tables/fournisseurs');
const { syncStock } = require('./tables/stock');
const { syncMouvements } = require('./tables/mouvements');
const { syncCommandes } = require('./tables/commandes');
const { syncReferentiel } = require('./tables/referentiel');
const { closeAll } = require('./db');
const forceMode = process.argv.includes('--force');
async function syncAll(force = false) {
const start = Date.now();
console.log(`\n=== SYNC START ${new Date().toISOString()} (force=${force}) ===`);
// Ordre important : référentiel avant articles, articles avant stock/mouvements
await syncReferentiel(force);
await syncArticles(force);
await syncFournisseurs(force);
await syncStock(force);
await syncMouvements(force);
await syncCommandes(force);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`=== SYNC DONE en ${elapsed}s ===\n`);
}
if (forceMode) {
// Lancement manuel : node index.js --force
console.log('Mode force : sync complète en cours...');
syncAll(true)
.then(() => closeAll())
.then(() => process.exit(0))
.catch(err => {
console.error('Erreur fatale sync:', err.message);
process.exit(1);
});
} else {
// Mode daemon : sync nightly à 3h00
console.log('Service sync démarré — cron: 0 3 * * * (3h00 chaque nuit)');
cron.schedule('0 3 * * *', () => {
syncAll(false).catch(err => console.error('Erreur cron sync:', err.message));
});
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "foirfouille-sync",
"version": "1.0.0",
"description": "Sync nightly SQL Server → PostgreSQL",
"main": "index.js",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"mssql": "^12.2.0",
"node-cron": "^3.0.3",
"pg": "^8.13.3"
}
}
+118
View File
@@ -0,0 +1,118 @@
const { getMssql, getPg } = require('../db');
const { batchUpsert, getLastSync, logSync } = require('../utils');
const ARTICLES_COLS = [
'no_id','codein','libelle1','libelle2','lib_ticket',
'tax_code','ach_code','utilisable','actif','suspendu',
'suividatecreation','suividatemodif','nom_no_id',
];
const INFOSUP_COLS = [
'artnoid','prix_vente_mini','prix_vente_maxi','eco_ht','eco_ttc',
'on_web','interdit_remise','nomphoto','datedebvente','datefinvente',
'datevaliditeachat','commentaire','pv_conseille','fraislogistic',
'transit','distribution',
];
const GTIN_COLS = ['idarticle','gtin','preferentiel'];
async function syncArticles(force) {
const ms = await getMssql();
const pg = getPg();
// === ARTICLES ===
try {
const lastSync = force ? null : await getLastSync(pg, 'articles');
const where = lastSync ? `WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}'` : '';
const res = await ms.request().query(`
SELECT NO_ID, CODEIN, LIBELLE1, LIBELLE2, LIB_TICKET,
TAX_CODE, ACH_CODE, UTILISABLE, ACTIF, SUSPENDU,
SUIVIDATECREATION, SUIVIDATEMODIF, NOM_NO_ID
FROM ARTICLES ${where}
`);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
codein: r.CODEIN,
libelle1: r.LIBELLE1,
libelle2: r.LIBELLE2,
lib_ticket: r.LIB_TICKET,
tax_code: r.TAX_CODE,
ach_code: r.ACH_CODE,
utilisable: r.UTILISABLE,
actif: r.ACTIF,
suspendu: r.SUSPENDU,
suividatecreation: r.SUIVIDATECREATION,
suividatemodif: r.SUIVIDATEMODIF,
nom_no_id: r.NOM_NO_ID,
}));
const count = await batchUpsert(pg, 'articles', rows, ['no_id'], ARTICLES_COLS);
await logSync(pg, 'articles', count, 'ok');
console.log(`[articles] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'articles', 0, 'error', err.message);
console.error(`[articles] ERREUR: ${err.message}`);
}
// === ARTICLE_INFOSUP ===
try {
const lastSync = force ? null : await getLastSync(pg, 'article_infosup');
const where = lastSync
? `WHERE ai.ARTNOID IN (SELECT NO_ID FROM ARTICLES WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}')`
: '';
const res = await ms.request().query(`
SELECT ARTNOID, PRIX_VENTE_MINI, PRIX_VENTE_MAXI, ECO_HT, ECO_TTC,
ON_WEB, INTERDIT_REMISE, NOMPHOTO, DATEDEBVENTE, DATEFINVENTE,
DATEVALIDITEACHAT, COMMENTAIRE, PV_CONSEILLE, FRAISLOGISTIC,
TRANSIT, DISTRIBUTION
FROM ARTICLE_INFOSUP ai ${where}
`);
const rows = res.recordset.map(r => ({
artnoid: r.ARTNOID,
prix_vente_mini: r.PRIX_VENTE_MINI,
prix_vente_maxi: r.PRIX_VENTE_MAXI,
eco_ht: r.ECO_HT,
eco_ttc: r.ECO_TTC,
on_web: r.ON_WEB,
interdit_remise: r.INTERDIT_REMISE,
nomphoto: r.NOMPHOTO,
datedebvente: r.DATEDEBVENTE,
datefinvente: r.DATEFINVENTE,
datevaliditeachat: r.DATEVALIDITEACHAT,
commentaire: r.COMMENTAIRE,
pv_conseille: r.PV_CONSEILLE,
fraislogistic: r.FRAISLOGISTIC,
transit: r.TRANSIT,
distribution: r.DISTRIBUTION,
}));
const count = await batchUpsert(pg, 'article_infosup', rows, ['artnoid'], INFOSUP_COLS);
await logSync(pg, 'article_infosup', count, 'ok');
console.log(`[article_infosup] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'article_infosup', 0, 'error', err.message);
console.error(`[article_infosup] ERREUR: ${err.message}`);
}
// === ART_GTIN (full refresh — petite table) ===
try {
const res = await ms.request().query(
`SELECT IDARTICLE, GTIN, PREFERENTIEL FROM ART_GTIN`
);
const rows = res.recordset.map(r => ({
idarticle: r.IDARTICLE,
gtin: r.GTIN,
preferentiel: r.PREFERENTIEL,
}));
const count = await batchUpsert(pg, 'art_gtin', rows, ['idarticle','gtin'], GTIN_COLS);
await logSync(pg, 'art_gtin', count, 'ok');
console.log(`[art_gtin] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'art_gtin', 0, 'error', err.message);
console.error(`[art_gtin] ERREUR: ${err.message}`);
}
}
module.exports = { syncArticles };
+148
View File
@@ -0,0 +1,148 @@
const { getMssql, getPg } = require('../db');
const { fullRefresh, batchUpsert, getLastSync, logSync } = require('../utils');
const VIVANT_COLS = [
'cdefou_ligne_com_no_id','artfou1_no_id','articles_codein','articles_libelle1',
'articles_libelle2','artfou1_reference','artfou1_ean13','artfou1_itf',
'cdefou_ligne_qtecde','cdefou_ligne_prixbrut','cdefou_ligne_remise',
'cdefou_ligne_remise2','cdefou_ligne_remise3','cdefou_ligne_prixnet',
'cdefou_ligne_montant','cdefou_ligne_prixvente','cdefou_ligne_gratuit',
'cdefou_ligne_qteacc','cdefou_ligne_qteann','cdefou_ligne_qteatt',
'cdefou_ligne_qterel','cdefou_ligne_cdeligtard','cdefou_ligne_cdeligtot',
'cotisation_logistique','fraislogistic','transit','distribution','taxe',
'commentaire','suividatecreation',
];
const RECEP_COLS = ['no_id','suividatecreation','suividatemodif'];
const RECEPLIG_COLS = [
'no_id','cdefou_reception_no_id','artfou1_no_id',
'qtebl','qterec','qteacc','qteref','qteaff',
'pribrut','remise','prirec','mntrec',
'motifrefus','recpb','recpbok',
'suividatecreation','suividatemodif',
];
async function syncCommandes(force) {
const ms = await getMssql();
const pg = getPg();
// === CDEFOU_VIVANT (full refresh — commandes en cours uniquement) ===
try {
const res = await ms.request().query(`
SELECT
CDEFOU_LIGNE_COM_NO_ID, ARTFOU1_NO_ID,
ARTICLES_CODEIN, ARTICLES_LIBELLE1, ARTICLES_LIBELLE2,
ARTFOU1_REFERENCE, ARTFOU1_EAN13, ARTFOU1_ITF,
CDEFOU_LIGNE_QTECDE, CDEFOU_LIGNE_PRIXBRUT, CDEFOU_LIGNE_REMISE,
CDEFOU_LIGNE_REMISE2, CDEFOU_LIGNE_REMISE3, CDEFOU_LIGNE_PRIXNET,
CDEFOU_LIGNE_MONTANT, CDEFOU_LIGNE_PRIXVENTE, CDEFOU_LIGNE_GRATUIT,
CDEFOU_LIGNE_QTEACC, CDEFOU_LIGNE_QTEANN, CDEFOU_LIGNE_QTEATT,
CDEFOU_LIGNE_QTEREL, CDEFOU_LIGNE_CDELIGTARD, CDEFOU_LIGNE_CDELIGTOT,
COTISATION_LOGISTIQUE, FRAISLOGISTIC, TRANSIT, DISTRIBUTION, TAXE,
COMMENTAIRE, SUIVIDATECREATION
FROM CDEFOU_VIVANT
`);
const rows = res.recordset.map(r => ({
cdefou_ligne_com_no_id: r.CDEFOU_LIGNE_COM_NO_ID,
artfou1_no_id: r.ARTFOU1_NO_ID,
articles_codein: r.ARTICLES_CODEIN,
articles_libelle1: r.ARTICLES_LIBELLE1,
articles_libelle2: r.ARTICLES_LIBELLE2,
artfou1_reference: r.ARTFOU1_REFERENCE,
artfou1_ean13: r.ARTFOU1_EAN13,
artfou1_itf: r.ARTFOU1_ITF,
cdefou_ligne_qtecde: r.CDEFOU_LIGNE_QTECDE,
cdefou_ligne_prixbrut: r.CDEFOU_LIGNE_PRIXBRUT,
cdefou_ligne_remise: r.CDEFOU_LIGNE_REMISE,
cdefou_ligne_remise2: r.CDEFOU_LIGNE_REMISE2,
cdefou_ligne_remise3: r.CDEFOU_LIGNE_REMISE3,
cdefou_ligne_prixnet: r.CDEFOU_LIGNE_PRIXNET,
cdefou_ligne_montant: r.CDEFOU_LIGNE_MONTANT,
cdefou_ligne_prixvente: r.CDEFOU_LIGNE_PRIXVENTE,
cdefou_ligne_gratuit: r.CDEFOU_LIGNE_GRATUIT,
cdefou_ligne_qteacc: r.CDEFOU_LIGNE_QTEACC,
cdefou_ligne_qteann: r.CDEFOU_LIGNE_QTEANN,
cdefou_ligne_qteatt: r.CDEFOU_LIGNE_QTEATT,
cdefou_ligne_qterel: r.CDEFOU_LIGNE_QTEREL,
cdefou_ligne_cdeligtard: r.CDEFOU_LIGNE_CDELIGTARD,
cdefou_ligne_cdeligtot: r.CDEFOU_LIGNE_CDELIGTOT,
cotisation_logistique: r.COTISATION_LOGISTIQUE,
fraislogistic: r.FRAISLOGISTIC,
transit: r.TRANSIT,
distribution: r.DISTRIBUTION,
taxe: r.TAXE,
commentaire: r.COMMENTAIRE,
suividatecreation: r.SUIVIDATECREATION,
}));
const count = await fullRefresh(pg, 'cdefou_vivant', rows, VIVANT_COLS);
await logSync(pg, 'cdefou_vivant', count, 'ok');
console.log(`[cdefou_vivant] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'cdefou_vivant', 0, 'error', err.message);
console.error(`[cdefou_vivant] ERREUR: ${err.message}`);
}
// === CDEFOU_RECEPTION (upsert delta) ===
try {
const lastSync = force ? null : await getLastSync(pg, 'cdefou_reception');
const where = lastSync ? `WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}'` : '';
const res = await ms.request().query(
`SELECT NO_ID, SUIVIDATECREATION, SUIVIDATEMODIF FROM CDEFOU_RECEPTION ${where}`
);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
suividatecreation: r.SUIVIDATECREATION,
suividatemodif: r.SUIVIDATEMODIF,
}));
const count = await batchUpsert(pg, 'cdefou_reception', rows, ['no_id'], RECEP_COLS);
await logSync(pg, 'cdefou_reception', count, 'ok');
console.log(`[cdefou_reception] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'cdefou_reception', 0, 'error', err.message);
console.error(`[cdefou_reception] ERREUR: ${err.message}`);
}
// === CDEFOU_RECEPLIG (upsert delta) ===
try {
const lastSync = force ? null : await getLastSync(pg, 'cdefou_receplig');
const where = lastSync ? `WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}'` : '';
const res = await ms.request().query(`
SELECT NO_ID, CDEFOU_RECEPTION_NO_ID, ARTFOU1_NO_ID,
QTEBL, QTEREC, QTEACC, QTEREF, QTEAFF,
PRIBRUT, REMISE, PRIREC, MNTREC,
MOTIFREFUS, RECPB, RECPBOK,
SUIVIDATECREATION, SUIVIDATEMODIF
FROM CDEFOU_RECEPLIG ${where}
`);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
cdefou_reception_no_id: r.CDEFOU_RECEPTION_NO_ID,
artfou1_no_id: r.ARTFOU1_NO_ID,
qtebl: r.QTEBL,
qterec: r.QTEREC,
qteacc: r.QTEACC,
qteref: r.QTEREF,
qteaff: r.QTEAFF,
pribrut: r.PRIBRUT,
remise: r.REMISE,
prirec: r.PRIREC,
mntrec: r.MNTREC,
motifrefus: r.MOTIFREFUS,
recpb: r.RECPB,
recpbok: r.RECPBOK,
suividatecreation: r.SUIVIDATECREATION,
suividatemodif: r.SUIVIDATEMODIF,
}));
const count = await batchUpsert(pg, 'cdefou_receplig', rows, ['no_id'], RECEPLIG_COLS);
await logSync(pg, 'cdefou_receplig', count, 'ok');
console.log(`[cdefou_receplig] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'cdefou_receplig', 0, 'error', err.message);
console.error(`[cdefou_receplig] ERREUR: ${err.message}`);
}
}
module.exports = { syncCommandes };
+94
View File
@@ -0,0 +1,94 @@
const { getMssql, getPg } = require('../db');
const { batchUpsert, fullRefresh, getLastSync, logSync } = require('../utils');
const ARTFOU1_COLS = [
'no_id','art_no_id','code','reference','ean13','itf',
'qteua','pcb','spcb','delai','securite','preference','suspendu',
'suividatecreation','suividatemodif',
];
const ARTFOU2_COLS = ['idartfou1','prixachat','remise_promotion'];
const FOUADR1_COLS = ['code','sit_code','raisonsociale','adrligne1','telephone','email'];
async function syncFournisseurs(force) {
const ms = await getMssql();
const pg = getPg();
// === ARTFOU1 (delta par SUIVIDATEMODIF) ===
try {
const lastSync = force ? null : await getLastSync(pg, 'artfou1');
const where = lastSync ? `WHERE SUIVIDATEMODIF > '${lastSync.toISOString()}'` : '';
const res = await ms.request().query(`
SELECT NO_ID, ART_NO_ID, CODE, REFERENCE, EAN13, ITF,
QTEUA, PCB, SPCB, DELAI, SECURITE, PREFERENCE, SUSPENDU,
SUIVIDATECREATION, SUIVIDATEMODIF
FROM ARTFOU1 ${where}
`);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
art_no_id: r.ART_NO_ID,
code: r.CODE,
reference: r.REFERENCE,
ean13: r.EAN13,
itf: r.ITF,
qteua: r.QTEUA,
pcb: r.PCB,
spcb: r.SPCB,
delai: r.DELAI,
securite: r.SECURITE,
preference: r.PREFERENCE,
suspendu: r.SUSPENDU,
suividatecreation: r.SUIVIDATECREATION,
suividatemodif: r.SUIVIDATEMODIF,
}));
const count = await batchUpsert(pg, 'artfou1', rows, ['no_id'], ARTFOU1_COLS);
await logSync(pg, 'artfou1', count, 'ok');
console.log(`[artfou1] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'artfou1', 0, 'error', err.message);
console.error(`[artfou1] ERREUR: ${err.message}`);
}
// === ARTFOU2 (full refresh — liée à ARTFOU1) ===
try {
const res = await ms.request().query(
`SELECT IDARTFOU1, PRIXACHAT, REMISE_PROMOTION FROM ARTFOU2`
);
const rows = res.recordset.map(r => ({
idartfou1: r.IDARTFOU1,
prixachat: r.PRIXACHAT,
remise_promotion: r.REMISE_PROMOTION,
}));
const count = await batchUpsert(pg, 'artfou2', rows, ['idartfou1'], ARTFOU2_COLS);
await logSync(pg, 'artfou2', count, 'ok');
console.log(`[artfou2] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'artfou2', 0, 'error', err.message);
console.error(`[artfou2] ERREUR: ${err.message}`);
}
// === FOUADR1 (full refresh — ~500 lignes) ===
try {
const res = await ms.request().query(
`SELECT CODE, SIT_CODE, RAISONSOCIALE, ADRLIGNE1, TELEPHONE, EMAIL FROM FOUADR1`
);
const rows = res.recordset.map(r => ({
code: r.CODE,
sit_code: r.SIT_CODE,
raisonsociale: r.RAISONSOCIALE,
adrligne1: r.ADRLIGNE1,
telephone: r.TELEPHONE,
email: r.EMAIL,
}));
const count = await fullRefresh(pg, 'fouadr1', rows, FOUADR1_COLS);
await logSync(pg, 'fouadr1', count, 'ok');
console.log(`[fouadr1] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'fouadr1', 0, 'error', err.message);
console.error(`[fouadr1] ERREUR: ${err.message}`);
}
}
module.exports = { syncFournisseurs };
+138
View File
@@ -0,0 +1,138 @@
const { getMssql, getPg } = require('../db');
const { logSync } = require('../utils');
const MVTART_COLS = [
'artnoid','datmvt','site','libmvt','genremvt',
'qtemvt','valmvt','mntmvtht','mntmvtttc','margemvt',
'qtestock','prmp','valstock','codefou',
];
const MVTREG_COLS = [
'datmvt','codtick','codcartecli','coddev',
'mntreg','mntregdev','clientnom','echeance',
'reference','typereg','suividatecreation',
];
const CHUNK = 2000;
async function insertChunk(pg, table, rows, cols) {
if (!rows.length) return;
const values = [];
const placeholders = rows.map((row, ri) => {
const ph = cols.map((col, ci) => {
values.push(row[col] ?? null);
return `$${ri * cols.length + ci + 1}`;
});
return `(${ph.join(', ')})`;
});
await pg.query(
`INSERT INTO ${table} (${cols.join(', ')}) VALUES ${placeholders.join(', ')}`,
values
);
}
async function syncMouvements(force) {
const ms = await getMssql();
const pg = getPg();
// === MvtArt (insert-only par date croissante) ===
try {
// Date pivot : max existant en pg, ou 2 ans en arrière pour sync initiale
const maxRes = await pg.query(`SELECT MAX(datmvt) AS last FROM mvtart`);
let lastDate = maxRes.rows[0]?.last;
if (!lastDate || force) {
const twoYearsAgo = new Date();
twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
lastDate = twoYearsAgo;
}
const res = await ms.request()
.input('lastDate', lastDate)
.query(`
SELECT ArtNoId, DatMvt, Site, LibMvt, GenreMvt,
QteMvt, ValMvt, MntMvtHt, MntMvtTTC, MargeMvt,
QteStock, Prmp, ValStock, CODEFOU
FROM MvtArt
WHERE DatMvt > @lastDate
ORDER BY DatMvt
`);
const allRows = res.recordset.map(r => ({
artnoid: r.ArtNoId,
datmvt: r.DatMvt,
site: r.Site,
libmvt: r.LibMvt,
genremvt: r.GenreMvt,
qtemvt: r.QteMvt,
valmvt: r.ValMvt,
mntmvtht: r.MntMvtHt,
mntmvtttc: r.MntMvtTTC,
margemvt: r.MargeMvt,
qtestock: r.QteStock,
prmp: r.Prmp,
valstock: r.ValStock,
codefou: r.CODEFOU,
}));
let inserted = 0;
for (let i = 0; i < allRows.length; i += CHUNK) {
await insertChunk(pg, 'mvtart', allRows.slice(i, i + CHUNK), MVTART_COLS);
inserted += Math.min(CHUNK, allRows.length - i);
}
await logSync(pg, 'mvtart', inserted, 'ok');
console.log(`[mvtart] ${inserted} nouvelles lignes`);
} catch (err) {
await logSync(pg, 'mvtart', 0, 'error', err.message);
console.error(`[mvtart] ERREUR: ${err.message}`);
}
// === MvtReg (insert-only par date) ===
try {
const maxRes = await pg.query(`SELECT MAX(datmvt) AS last FROM mvtreg`);
let lastDate = maxRes.rows[0]?.last;
if (!lastDate || force) {
const twoYearsAgo = new Date();
twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
lastDate = twoYearsAgo;
}
const res = await ms.request()
.input('lastDate', lastDate)
.query(`
SELECT DatMvt, CodTick, CodCarteCli, CodDev,
MntReg, MntRegDev, ClientNom, Echeance,
REFERENCE, TYPEREG, SuiviDateCreation
FROM MvtReg
WHERE DatMvt > @lastDate
ORDER BY DatMvt
`);
const allRows = res.recordset.map(r => ({
datmvt: r.DatMvt,
codtick: r.CodTick,
codcartecli: r.CodCarteCli,
coddev: r.CodDev,
mntreg: r.MntReg,
mntregdev: r.MntRegDev,
clientnom: r.ClientNom,
echeance: r.Echeance,
reference: r.REFERENCE,
typereg: r.TYPEREG,
suividatecreation: r.SuiviDateCreation,
}));
let inserted = 0;
for (let i = 0; i < allRows.length; i += CHUNK) {
await insertChunk(pg, 'mvtreg', allRows.slice(i, i + CHUNK), MVTREG_COLS);
inserted += Math.min(CHUNK, allRows.length - i);
}
await logSync(pg, 'mvtreg', inserted, 'ok');
console.log(`[mvtreg] ${inserted} nouvelles lignes`);
} catch (err) {
await logSync(pg, 'mvtreg', 0, 'error', err.message);
console.error(`[mvtreg] ERREUR: ${err.message}`);
}
}
module.exports = { syncMouvements };
+88
View File
@@ -0,0 +1,88 @@
const { getMssql, getPg } = require('../db');
const { fullRefresh, batchUpsert, logSync } = require('../utils');
const NOMEN_COLS = ['no_id','code','libelle','niveau','chemin_pere'];
const GAMME_COLS = ['no_id','code','libelle'];
const SAISON_COLS = ['no_id','code','libelle'];
const AGS_COLS = ['artnoid','idgamme','idsaison'];
async function syncReferentiel(force) {
const ms = await getMssql();
const pg = getPg();
// === NOMENCLATURE (full refresh) ===
try {
const res = await ms.request().query(
`SELECT NO_ID, CODE, LIBELLE, NIVEAU, CHEMIN_PERE FROM NOMENCLATURE`
);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
code: r.CODE,
libelle: r.LIBELLE,
niveau: r.NIVEAU,
chemin_pere: r.CHEMIN_PERE,
}));
const count = await batchUpsert(pg, 'nomenclature', rows, ['no_id'], NOMEN_COLS);
await logSync(pg, 'nomenclature', count, 'ok');
console.log(`[nomenclature] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'nomenclature', 0, 'error', err.message);
console.error(`[nomenclature] ERREUR: ${err.message}`);
}
// === GAMMES (full refresh) ===
try {
const res = await ms.request().query(
`SELECT NO_ID, CODE, LIBELLE FROM GAMMES`
);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
code: r.CODE,
libelle: r.LIBELLE,
}));
const count = await batchUpsert(pg, 'gammes', rows, ['no_id'], GAMME_COLS);
await logSync(pg, 'gammes', count, 'ok');
console.log(`[gammes] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'gammes', 0, 'error', err.message);
console.error(`[gammes] ERREUR: ${err.message}`);
}
// === SAISONS (full refresh) ===
try {
const res = await ms.request().query(
`SELECT NO_ID, CODE, LIBELLE FROM SAISONS`
);
const rows = res.recordset.map(r => ({
no_id: r.NO_ID,
code: r.CODE,
libelle: r.LIBELLE,
}));
const count = await batchUpsert(pg, 'saisons', rows, ['no_id'], SAISON_COLS);
await logSync(pg, 'saisons', count, 'ok');
console.log(`[saisons] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'saisons', 0, 'error', err.message);
console.error(`[saisons] ERREUR: ${err.message}`);
}
// === ART_GAMME_SAISON (full refresh) ===
try {
const res = await ms.request().query(
`SELECT ARTNOID, IDGAMME, IDSAISON FROM ART_GAMME_SAISON`
);
const rows = res.recordset.map(r => ({
artnoid: r.ARTNOID,
idgamme: r.IDGAMME,
idsaison: r.IDSAISON,
}));
const count = await fullRefresh(pg, 'art_gamme_saison', rows, AGS_COLS);
await logSync(pg, 'art_gamme_saison', count, 'ok');
console.log(`[art_gamme_saison] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'art_gamme_saison', 0, 'error', err.message);
console.error(`[art_gamme_saison] ERREUR: ${err.message}`);
}
}
module.exports = { syncReferentiel };
+84
View File
@@ -0,0 +1,84 @@
const { getMssql, getPg } = require('../db');
const { fullRefresh, batchUpsert, logSync } = require('../utils');
const STOCK_COLS = [
'artnoid','site','qte','prmp','valstock','pv',
'stockdispo','stockmort','stockcolis',
'dernierevente','dernierereception','premierevente',
'nbjoursdernierMouvement','nbjoursdernierevente','nbjoursdernierrereception',
'interditachat','codefou',
];
const PA_COLS = ['artnoid','pa'];
const PV_COLS = ['artnoid','site','pv'];
async function syncStock(force) {
const ms = await getMssql();
const pg = getPg();
// === Cube_Stock (full refresh — recalculé chaque nuit) ===
try {
const res = await ms.request().query(`
SELECT ArtNoId, Site, QTE, Prmp, ValStock, PV,
StockDispo, StockMort, StockColis,
DerniereVente, DerniereReception, PremiereVente,
NbJoursDernierMouvement, NbJoursDerniereVente,
NbJoursDerniereReception, InterditAchat, CODEFOU
FROM Cube_Stock
`);
const rows = res.recordset.map(r => ({
artnoid: r.ArtNoId,
site: r.Site,
qte: r.QTE,
prmp: r.Prmp,
valstock: r.ValStock,
pv: r.PV,
stockdispo: r.StockDispo,
stockmort: r.StockMort,
stockcolis: r.StockColis,
dernierevente: r.DerniereVente,
dernierereception: r.DerniereReception,
premierevente: r.PremiereVente,
nbjoursdernierMouvement: r.NbJoursDernierMouvement,
nbjoursdernierevente: r.NbJoursDerniereVente,
nbjoursdernierrereception: r.NbJoursDerniereReception,
interditachat: r.InterditAchat,
codefou: r.CODEFOU,
}));
const count = await fullRefresh(pg, 'cube_stock', rows, STOCK_COLS);
await logSync(pg, 'cube_stock', count, 'ok');
console.log(`[cube_stock] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'cube_stock', 0, 'error', err.message);
console.error(`[cube_stock] ERREUR: ${err.message}`);
}
// === Cube_PA (full refresh) ===
try {
const res = await ms.request().query(`SELECT ArtNoId, PA FROM Cube_PA`);
const rows = res.recordset.map(r => ({ artnoid: r.ArtNoId, pa: r.PA }));
const count = await fullRefresh(pg, 'cube_pa', rows, PA_COLS);
await logSync(pg, 'cube_pa', count, 'ok');
console.log(`[cube_pa] ${count} lignes refresh`);
} catch (err) {
await logSync(pg, 'cube_pa', 0, 'error', err.message);
console.error(`[cube_pa] ERREUR: ${err.message}`);
}
// === Cube_PV (full refresh) ===
try {
const res = await ms.request().query(`SELECT ArtNoId, Site, PV FROM Cube_PV`);
const rows = res.recordset.map(r => ({
artnoid: r.ArtNoId, site: r.Site, pv: r.PV,
}));
const count = await batchUpsert(pg, 'cube_pv', rows, ['artnoid','site'], PV_COLS);
await logSync(pg, 'cube_pv', count, 'ok');
console.log(`[cube_pv] ${count} lignes upsert`);
} catch (err) {
await logSync(pg, 'cube_pv', 0, 'error', err.message);
console.error(`[cube_pv] ERREUR: ${err.message}`);
}
}
module.exports = { syncStock };
+91
View File
@@ -0,0 +1,91 @@
/**
* Upsert en masse dans PostgreSQL (chunks de 500 lignes).
* @param {import('pg').Pool} pg
* @param {string} table - nom de la table PostgreSQL (lowercase)
* @param {object[]} rows - lignes avec les valeurs à insérer
* @param {string[]} pk - colonnes formant la clé primaire
* @param {string[]} cols - toutes les colonnes à upsert
* @returns {number} total de lignes traitées
*/
async function batchUpsert(pg, table, rows, pk, cols) {
if (!rows.length) return 0;
const CHUNK = 500;
let total = 0;
for (let i = 0; i < rows.length; i += CHUNK) {
const chunk = rows.slice(i, i + CHUNK);
const values = [];
const placeholders = chunk.map((row, ri) => {
const ph = cols.map((col, ci) => {
values.push(row[col] ?? null);
return `$${ri * cols.length + ci + 1}`;
});
return `(${ph.join(', ')})`;
});
const updateCols = cols.filter(c => !pk.includes(c));
const updateSet = updateCols.map(c => `${c} = EXCLUDED.${c}`).join(', ');
const sql = `
INSERT INTO ${table} (${cols.join(', ')})
VALUES ${placeholders.join(', ')}
ON CONFLICT (${pk.join(', ')}) DO UPDATE SET ${updateSet}
`;
await pg.query(sql, values);
total += chunk.length;
}
return total;
}
/**
* Full refresh : TRUNCATE puis INSERT en masse.
*/
async function fullRefresh(pg, table, rows, cols) {
if (!rows.length) {
await pg.query(`TRUNCATE TABLE ${table}`);
return 0;
}
const CHUNK = 500;
await pg.query(`TRUNCATE TABLE ${table}`);
let total = 0;
for (let i = 0; i < rows.length; i += CHUNK) {
const chunk = rows.slice(i, i + CHUNK);
const values = [];
const placeholders = chunk.map((row, ri) => {
const ph = cols.map((col, ci) => {
values.push(row[col] ?? null);
return `$${ri * cols.length + ci + 1}`;
});
return `(${ph.join(', ')})`;
});
const sql = `INSERT INTO ${table} (${cols.join(', ')}) VALUES ${placeholders.join(', ')}`;
await pg.query(sql, values);
total += chunk.length;
}
return total;
}
/**
* Lit la date du dernier sync réussi pour une table.
*/
async function getLastSync(pg, tableName) {
const res = await pg.query(
`SELECT last_sync FROM sync_log WHERE table_name = $1 AND status = 'ok'`,
[tableName]
);
return res.rows[0]?.last_sync || null;
}
/**
* Enregistre le résultat d'un sync dans sync_log.
*/
async function logSync(pg, tableName, rowsSynced, status, errorMsg = null) {
await pg.query(`
INSERT INTO sync_log (table_name, last_sync, rows_synced, status, error_msg)
VALUES ($1, NOW(), $2, $3, $4)
ON CONFLICT (table_name) DO UPDATE SET
last_sync = NOW(),
rows_synced = $2,
status = $3,
error_msg = $4
`, [tableName, rowsSynced, status, errorMsg]);
}
module.exports = { batchUpsert, fullRefresh, getLastSync, logSync };