239 lines
7.9 KiB
JavaScript
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}`);
|
|
});
|