Persist supply receipts and stock
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
This commit is contained in:
@@ -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
|
||||
|
||||
72
backend/routes/supplyRoutes.js
Normal file
72
backend/routes/supplyRoutes.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
244
backend/services/supplyService.js
Normal file
244
backend/services/supplyService.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user