Files
gestion-stock/server.js
T
2026-05-28 09:35:43 +00:00

239 lines
7.9 KiB
JavaScript

const express = require('express');
const cookieParser = require('cookie-parser');
const https = require('https');
const http = require('http');
const app = express();
app.use(express.json());
app.use(cookieParser());
const PORT = process.env.PORT || 3000;
const PASSWORD = process.env.STOCK_PASSWORD || 'delvarre';
const NOCODB_URL = process.env.NOCODB_URL || 'https://nocodb.hub.delvarre.net';
const NOCODB_TOKEN = process.env.NOCODB_TOKEN || '';
const TABLE_CATALOGUE = process.env.TABLE_CATALOGUE || 'maybayq2akegbs1';
const TABLE_MOUVEMENTS = process.env.TABLE_MOUVEMENTS || 'm88endvsfj5am9n';
const SESSION_SECRET = process.env.SESSION_SECRET || 'stock-dc-2026';
function makeToken(password) {
const crypto = require('crypto');
return crypto.createHmac('sha256', SESSION_SECRET).update(password).digest('hex');
}
const VALID_TOKEN = makeToken(PASSWORD);
function requireAuth(req, res, next) {
if (req.cookies && req.cookies.stock_auth === VALID_TOKEN) return next();
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Non autorisé' });
return res.redirect('/login.html');
}
// Login endpoint
app.post('/auth/login', (req, res) => {
const { password } = req.body;
if (password === PASSWORD) {
res.cookie('stock_auth', VALID_TOKEN, { httpOnly: true, sameSite: 'lax', maxAge: 30 * 24 * 3600 * 1000 });
return res.json({ ok: true });
}
res.status(401).json({ error: 'Mot de passe incorrect' });
});
app.post('/auth/logout', (req, res) => {
res.clearCookie('stock_auth');
res.json({ ok: true });
});
// Serve login page without auth
app.get('/login.html', (req, res, next) => {
if (req.cookies && req.cookies.stock_auth === VALID_TOKEN) return res.redirect('/');
next();
});
// NocoDB proxy
function proxyNocoDB(method, path, body) {
return new Promise((resolve, reject) => {
const url = new URL(path, NOCODB_URL);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method,
headers: {
'xc-token': NOCODB_TOKEN,
'Content-Type': 'application/json',
},
};
const proto = url.protocol === 'https:' ? https : http;
const req = proto.request(options, (resp) => {
const chunks = [];
resp.on('data', (chunk) => chunks.push(chunk));
resp.on('end', () => {
const data = Buffer.concat(chunks).toString('utf8');
try { resolve(JSON.parse(data)); }
catch { resolve(data); }
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
// API: Get all products
app.get('/api/produits', requireAuth, async (req, res) => {
try {
const data = await proxyNocoDB('GET', `/api/v2/tables/${TABLE_CATALOGUE}/records?limit=50&fields=Id,SKU,Nom,Stock,Image,Statut,Ordre&where=(Statut,eq,Actif)&sort=Ordre`);
if (data && data.list) {
data.list.sort((a, b) => (a.Ordre ?? 9999) - (b.Ordre ?? 9999));
}
res.json(data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: Get movements (recent)
app.get('/api/mouvements', requireAuth, async (req, res) => {
try {
const limit = req.query.limit || 50;
const offset = req.query.offset || 0;
// Don't filter fields — we need the nested linked record data
let url = `/api/v2/tables/${TABLE_MOUVEMENTS}/records?limit=${limit}&offset=${offset}&sort=-Date`;
const data = await proxyNocoDB('GET', url);
// Extract SKU from nested linked field
if (data && data.list) {
for (const m of data.list) {
m._sku = '—';
for (const key of Object.keys(m)) {
if (key.startsWith('nc_') && Array.isArray(m[key]) && m[key].length > 0) {
const linked = m[key][0];
const catalogueKey = Object.keys(linked).find(k => k === 'Catalogue produits');
if (catalogueKey && linked[catalogueKey]) {
m._sku = linked[catalogueKey].SKU || linked[catalogueKey].Nom || '—';
}
}
}
}
}
res.json(data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: Create movement + update stock
app.post('/api/mouvement', requireAuth, async (req, res) => {
try {
const { productId, sku, type, quantite, source, reference } = req.body;
if (!productId || !type || !quantite || quantite <= 0) {
return res.status(400).json({ error: 'Données invalides' });
}
// 1. Get current stock
const product = await proxyNocoDB('GET', `/api/v2/tables/${TABLE_CATALOGUE}/records/${productId}?fields=Stock`);
const currentStock = product.Stock || 0;
const newStock = type === 'Entrée' ? currentStock + quantite : currentStock - quantite;
// 2. Create movement record (link to catalogue via SKU field)
const movement = await proxyNocoDB('POST', `/api/v2/tables/${TABLE_MOUVEMENTS}/records`, {
Date: new Date().toISOString(),
Type: type,
'Quantité': quantite,
Source: source || 'Manuel',
'Référence': reference || '',
SKU: [{ Id: productId }],
});
// Check movement was actually created
if (!movement || !movement.Id) {
const errMsg = (movement && movement.msg) || 'Mouvement non créé';
return res.status(400).json({ error: errMsg });
}
// 3. Update stock on product (only if movement succeeded)
await proxyNocoDB('PATCH', `/api/v2/tables/${TABLE_CATALOGUE}/records`, {
Id: productId,
Stock: newStock,
});
res.json({ ok: true, newStock, movement });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: Update movement note
app.patch('/api/mouvement/:id', requireAuth, async (req, res) => {
try {
const id = parseInt(req.params.id);
const { reference } = req.body;
if (!id) return res.status(400).json({ error: 'ID invalide' });
await proxyNocoDB('PATCH', `/api/v2/tables/${TABLE_MOUVEMENTS}/records`, {
Id: id,
'Référence': reference || '',
});
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: Bulk inventory (multiple products at once)
app.post('/api/inventaire', requireAuth, async (req, res) => {
try {
const { items } = req.body; // [{ productId, sku, realStock }]
const results = [];
for (const item of items) {
const product = await proxyNocoDB('GET', `/api/v2/tables/${TABLE_CATALOGUE}/records/${item.productId}?fields=Stock,SKU`);
const currentStock = product.Stock || 0;
const diff = item.realStock - currentStock;
if (diff === 0) {
results.push({ sku: item.sku, status: 'inchangé' });
continue;
}
// Create adjustment movement
const mvt = await proxyNocoDB('POST', `/api/v2/tables/${TABLE_MOUVEMENTS}/records`, {
Date: new Date().toISOString(),
Type: diff > 0 ? 'Entrée' : 'Sortie',
'Quantité': Math.abs(diff),
Source: 'Inventaire',
'Référence': `Inventaire du ${new Date().toLocaleDateString('fr-FR')}`,
SKU: [{ Id: item.productId }],
});
if (!mvt || !mvt.Id) {
results.push({ sku: item.sku, status: 'erreur', msg: mvt && mvt.msg });
continue;
}
// Update stock (only if movement succeeded)
await proxyNocoDB('PATCH', `/api/v2/tables/${TABLE_CATALOGUE}/records`, {
Id: item.productId,
Stock: item.realStock,
});
results.push({ sku: item.sku, ancien: currentStock, nouveau: item.realStock, diff });
}
res.json({ ok: true, results });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Static files (protected except login)
app.use('/login.html', express.static('public/login.html'));
app.use('/favicon.ico', express.static('public/favicon.ico'));
app.use(requireAuth, express.static('public'));
app.get('/', requireAuth, (req, res) => {
res.sendFile(__dirname + '/public/index.html');
});
app.listen(PORT, () => {
console.log(`Gestion de Stock running on port ${PORT}`);
});