Persist supply receipts and stock
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s

This commit is contained in:
Cauê Faleiros
2026-07-13 11:31:41 -03:00
parent 1bf5e518de
commit 8320f2ae35
7 changed files with 948 additions and 123 deletions

View File

@@ -188,6 +188,54 @@ const initDB = async () => {
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_receipts (
id SERIAL PRIMARY KEY,
category VARCHAR(120) NOT NULL,
product TEXT NOT NULL,
quantity NUMERIC(14, 4) NOT NULL,
unit VARCHAR(30) NOT NULL DEFAULT 'kg',
supplier TEXT,
invoice VARCHAR(120),
notes TEXT,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
approved_at TIMESTAMPTZ
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_stock_lots (
id SERIAL PRIMARY KEY,
receipt_id INTEGER REFERENCES supply_receipts(id) ON DELETE SET NULL,
category VARCHAR(120) NOT NULL,
product TEXT NOT NULL,
quantity NUMERIC(14, 4) NOT NULL,
unit VARCHAR(30) NOT NULL DEFAULT 'kg',
supplier TEXT,
invoice VARCHAR(120),
status VARCHAR(30) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_movements (
id SERIAL PRIMARY KEY,
receipt_id INTEGER REFERENCES supply_receipts(id) ON DELETE SET NULL,
lot_id INTEGER REFERENCES supply_stock_lots(id) ON DELETE SET NULL,
type VARCHAR(40) NOT NULL,
category VARCHAR(120) NOT NULL,
product TEXT NOT NULL,
quantity NUMERIC(14, 4) NOT NULL,
unit VARCHAR(30) NOT NULL DEFAULT 'kg',
reason TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
ALTER TABLE production_orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
@@ -232,6 +280,29 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE supply_receipts
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN approved_at TYPE TIMESTAMPTZ USING approved_at AT TIME ZONE 'America/Sao_Paulo';
`).catch(() => {});
await pool.query(`
ALTER TABLE supply_stock_lots
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE supply_movements
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
CREATE TABLE IF NOT EXISTS app_users (
id SERIAL PRIMARY KEY,
@@ -304,6 +375,10 @@ const initDB = async () => {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_status ON supply_receipts (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_movements_created_at ON supply_movements (created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome

View File

@@ -0,0 +1,72 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
approveReceipt,
createReceipt,
deleteReceipt,
getSupplySummary,
listLots,
listMovements,
listReceipts
} = require('../services/supplyService');
const router = express.Router();
router.get('/supply', verifyToken, async (req, res, next) => {
try {
res.json(await getSupplySummary());
} catch (error) {
next(error);
}
});
router.get('/supply/receipts', verifyToken, async (req, res, next) => {
try {
res.json(await listReceipts());
} catch (error) {
next(error);
}
});
router.post('/supply/receipts', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createReceipt(req.body || {}));
} catch (error) {
next(error);
}
});
router.post('/supply/receipts/:id/approve', verifyToken, async (req, res, next) => {
try {
res.json(await approveReceipt(req.params.id));
} catch (error) {
next(error);
}
});
router.delete('/supply/receipts/:id', verifyToken, async (req, res, next) => {
try {
await deleteReceipt(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get('/supply/lots', verifyToken, async (req, res, next) => {
try {
res.json(await listLots());
} catch (error) {
next(error);
}
});
router.get('/supply/movements', verifyToken, async (req, res, next) => {
try {
res.json(await listMovements());
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -11,6 +11,7 @@ const userRoutes = require('./routes/userRoutes');
const productionOrderRoutes = require('./routes/productionOrderRoutes');
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
const catalogRoutes = require('./routes/catalogRoutes');
const supplyRoutes = require('./routes/supplyRoutes');
const createApp = () => {
const app = express();
@@ -25,6 +26,7 @@ const createApp = () => {
app.use('/api', productionOrderRoutes);
app.use('/api', cuttingSettingsRoutes);
app.use('/api', catalogRoutes);
app.use('/api', supplyRoutes);
app.use('/api', analyticsRoutes);
app.use('/api', userRoutes);
app.use('/api/internal', internalRoutes);

View File

@@ -0,0 +1,244 @@
const { pool } = require('../db');
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
const normalizeNumber = (value) => {
if (value === '' || value === null || value === undefined) return null;
const number = Number(String(value).replace(',', '.'));
return Number.isFinite(number) && number > 0 ? number : null;
};
const mapReceipt = (row) => ({
id: row.id,
category: row.category,
product: row.product,
quantity: Number(row.quantity),
unit: row.unit,
supplier: row.supplier || '',
invoice: row.invoice || '',
notes: row.notes || '',
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
approvedAt: row.approved_at
});
const mapLot = (row) => ({
id: row.id,
receiptId: row.receipt_id,
category: row.category,
product: row.product,
quantity: Number(row.quantity),
unit: row.unit,
supplier: row.supplier || '',
invoice: row.invoice || '',
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapMovement = (row) => ({
id: row.id,
receiptId: row.receipt_id,
lotId: row.lot_id,
type: row.type,
category: row.category,
product: row.product,
quantity: Number(row.quantity),
unit: row.unit,
reason: row.reason || '',
createdAt: row.created_at
});
const createValidationError = (message) => {
const error = new Error(message);
error.statusCode = 400;
return error;
};
const listReceipts = async () => {
const result = await pool.query(`
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
FROM supply_receipts
ORDER BY created_at DESC, id DESC
`);
return result.rows.map(mapReceipt);
};
const listLots = async () => {
const result = await pool.query(`
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
FROM supply_stock_lots
WHERE status = 'active'
ORDER BY created_at DESC, id DESC
`);
return result.rows.map(mapLot);
};
const listMovements = async () => {
const result = await pool.query(`
SELECT id, receipt_id, lot_id, type, category, product, quantity, unit, reason, created_at
FROM supply_movements
ORDER BY created_at DESC, id DESC
`);
return result.rows.map(mapMovement);
};
const buildStats = (receipts, lots) => {
const totalQuantityKg = lots.reduce((total, lot) => (
lot.unit === 'kg' ? total + lot.quantity : total
), 0);
return {
totalQuantityKg,
activeLots: lots.length,
rolls: lots.filter(lot => lot.unit === 'rolos').reduce((total, lot) => total + lot.quantity, 0),
alerts: 0,
pendingReceipts: receipts.filter(receipt => receipt.status === 'pending').length,
approvedReceipts: receipts.filter(receipt => receipt.status === 'approved').length
};
};
const getSupplySummary = async () => {
const [receipts, lots, movements] = await Promise.all([
listReceipts(),
listLots(),
listMovements()
]);
return {
receipts,
lots,
movements,
stats: buildStats(receipts, lots)
};
};
const createReceipt = async (payload) => {
const category = normalizeText(payload.category);
const product = normalizeText(payload.product);
const quantity = normalizeNumber(payload.quantity);
const unit = normalizeText(payload.unit) || 'kg';
if (!category) throw createValidationError('Categoria é obrigatória.');
if (!product) throw createValidationError('Produto ou material é obrigatório.');
if (!quantity) throw createValidationError('Quantidade deve ser maior que zero.');
const result = await pool.query(`
INSERT INTO supply_receipts (
category, product, quantity, unit, supplier, invoice, notes, status, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP)
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
`, [
category,
product,
quantity,
unit,
normalizeText(payload.supplier) || null,
normalizeText(payload.invoice) || null,
normalizeText(payload.notes) || null
]);
return mapReceipt(result.rows[0]);
};
const approveReceipt = async (id) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const receiptResult = await client.query(`
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
FROM supply_receipts
WHERE id = $1
FOR UPDATE
`, [id]);
if (!receiptResult.rowCount) {
throw createValidationError('Recebimento não encontrado.');
}
const receipt = receiptResult.rows[0];
if (receipt.status === 'approved') {
await client.query('COMMIT');
return mapReceipt(receipt);
}
const updatedReceiptResult = await client.query(`
UPDATE supply_receipts
SET status = 'approved',
approved_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
`, [id]);
const lotResult = await client.query(`
INSERT INTO supply_stock_lots (
receipt_id, category, product, quantity, unit, supplier, invoice, status, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', CURRENT_TIMESTAMP)
RETURNING id
`, [
receipt.id,
receipt.category,
receipt.product,
receipt.quantity,
receipt.unit,
receipt.supplier,
receipt.invoice
]);
await client.query(`
INSERT INTO supply_movements (
receipt_id, lot_id, type, category, product, quantity, unit, reason
)
VALUES ($1, $2, 'receipt', $3, $4, $5, $6, $7)
`, [
receipt.id,
lotResult.rows[0].id,
receipt.category,
receipt.product,
receipt.quantity,
receipt.unit,
`Recebimento aprovado${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}`
]);
await client.query('COMMIT');
return mapReceipt(updatedReceiptResult.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const deleteReceipt = async (id) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM supply_movements WHERE receipt_id = $1', [id]);
await client.query('DELETE FROM supply_stock_lots WHERE receipt_id = $1', [id]);
await client.query('DELETE FROM supply_receipts WHERE id = $1', [id]);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
module.exports = {
approveReceipt,
createReceipt,
deleteReceipt,
getSupplySummary,
listLots,
listMovements,
listReceipts
};