522 lines
17 KiB
JavaScript
522 lines
17 KiB
JavaScript
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 normalizeNonNegativeNumber = (value) => {
|
|
if (value === '' || value === null || value === undefined) return null;
|
|
const number = Number(String(value).replace(',', '.'));
|
|
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
};
|
|
|
|
const normalizeKey = (value) => normalizeText(value)
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase();
|
|
|
|
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 mapFabricPlan = (row) => ({
|
|
id: row.id,
|
|
material: row.material,
|
|
color: row.color || '',
|
|
quantityKg: Number(row.quantity_kg),
|
|
supplier: row.supplier || '',
|
|
priority: row.priority,
|
|
status: row.status,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_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 listFabricPlans = async () => {
|
|
const result = await pool.query(`
|
|
SELECT id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at
|
|
FROM supply_fabric_plans
|
|
WHERE status = 'active'
|
|
ORDER BY
|
|
CASE priority
|
|
WHEN 'Crítico' THEN 1
|
|
WHEN 'Atenção' THEN 2
|
|
ELSE 3
|
|
END,
|
|
created_at DESC,
|
|
id DESC
|
|
`);
|
|
return result.rows.map(mapFabricPlan);
|
|
};
|
|
|
|
const buildPurchaseNeeds = (plans, lots, receipts) => {
|
|
const needsByMaterial = new Map();
|
|
|
|
plans.forEach(plan => {
|
|
const key = normalizeKey(plan.material);
|
|
if (!key) return;
|
|
|
|
const current = needsByMaterial.get(key) || {
|
|
material: plan.material,
|
|
plannedKg: 0,
|
|
stockKg: 0,
|
|
pendingKg: 0,
|
|
purchaseKg: 0,
|
|
priority: 'Normal',
|
|
suppliers: new Set(),
|
|
colors: new Set()
|
|
};
|
|
|
|
current.plannedKg += plan.quantityKg;
|
|
if (plan.supplier) current.suppliers.add(plan.supplier);
|
|
if (plan.color) current.colors.add(plan.color);
|
|
if (plan.priority === 'Crítico') current.priority = 'Crítico';
|
|
if (plan.priority === 'Atenção' && current.priority !== 'Crítico') current.priority = 'Atenção';
|
|
needsByMaterial.set(key, current);
|
|
});
|
|
|
|
lots.forEach(lot => {
|
|
if (lot.unit !== 'kg') return;
|
|
const need = needsByMaterial.get(normalizeKey(lot.product));
|
|
if (need) need.stockKg += lot.quantity;
|
|
});
|
|
|
|
receipts.forEach(receipt => {
|
|
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
|
|
const need = needsByMaterial.get(normalizeKey(receipt.product));
|
|
if (need) need.pendingKg += receipt.quantity;
|
|
});
|
|
|
|
return Array.from(needsByMaterial.values())
|
|
.map(need => {
|
|
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
|
|
let status = 'ok';
|
|
if (purchaseKg > 0 && (need.priority === 'Crítico' || need.stockKg === 0)) status = 'critical';
|
|
else if (purchaseKg > 0) status = 'attention';
|
|
|
|
return {
|
|
material: need.material,
|
|
plannedKg: need.plannedKg,
|
|
stockKg: need.stockKg,
|
|
pendingKg: need.pendingKg,
|
|
purchaseKg,
|
|
priority: need.priority,
|
|
status,
|
|
suppliers: Array.from(need.suppliers),
|
|
colors: Array.from(need.colors)
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
const statusOrder = { critical: 1, attention: 2, ok: 3 };
|
|
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);
|
|
});
|
|
};
|
|
|
|
const buildStats = (receipts, lots, purchaseNeeds) => {
|
|
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: purchaseNeeds.filter(need => need.status !== 'ok').length,
|
|
pendingReceipts: receipts.filter(receipt => receipt.status === 'pending').length,
|
|
approvedReceipts: receipts.filter(receipt => receipt.status === 'approved').length
|
|
};
|
|
};
|
|
|
|
const getSupplySummary = async () => {
|
|
const [receipts, lots, movements, fabricPlans] = await Promise.all([
|
|
listReceipts(),
|
|
listLots(),
|
|
listMovements(),
|
|
listFabricPlans()
|
|
]);
|
|
const purchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
|
|
|
|
return {
|
|
receipts,
|
|
lots,
|
|
movements,
|
|
fabricPlans,
|
|
purchaseNeeds,
|
|
stats: buildStats(receipts, lots, purchaseNeeds)
|
|
};
|
|
};
|
|
|
|
const listPurchaseNeeds = async () => {
|
|
const [plans, lots, receipts] = await Promise.all([
|
|
listFabricPlans(),
|
|
listLots(),
|
|
listReceipts()
|
|
]);
|
|
|
|
return buildPurchaseNeeds(plans, lots, receipts);
|
|
};
|
|
|
|
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();
|
|
}
|
|
};
|
|
|
|
const createFabricPlan = async (payload) => {
|
|
const material = normalizeText(payload.material);
|
|
const quantityKg = normalizeNumber(payload.quantityKg);
|
|
|
|
if (!material) throw createValidationError('Malha ou tecido é obrigatório.');
|
|
if (!quantityKg) throw createValidationError('Quantidade deve ser maior que zero.');
|
|
|
|
const result = await pool.query(`
|
|
INSERT INTO supply_fabric_plans (
|
|
material, color, quantity_kg, supplier, priority, status, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, 'active', CURRENT_TIMESTAMP)
|
|
RETURNING id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at
|
|
`, [
|
|
material,
|
|
normalizeText(payload.color) || 'Todas as cores',
|
|
quantityKg,
|
|
normalizeText(payload.supplier) || null,
|
|
normalizeText(payload.priority) || 'Normal'
|
|
]);
|
|
|
|
return mapFabricPlan(result.rows[0]);
|
|
};
|
|
|
|
const deleteFabricPlan = async (id) => {
|
|
await pool.query(`
|
|
UPDATE supply_fabric_plans
|
|
SET status = 'removed',
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
`, [id]);
|
|
};
|
|
|
|
const updateLotQuantity = async (client, lotId, quantity) => {
|
|
const status = quantity > 0 ? 'active' : 'depleted';
|
|
const result = await client.query(`
|
|
UPDATE supply_stock_lots
|
|
SET quantity = $2,
|
|
status = $3,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
RETURNING id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
|
`, [lotId, quantity, status]);
|
|
|
|
return mapLot(result.rows[0]);
|
|
};
|
|
|
|
const adjustInventoryLot = async (id, payload) => {
|
|
const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity);
|
|
const reason = normalizeText(payload.reason);
|
|
|
|
if (countedQuantity === null) throw createValidationError('Quantidade contada deve ser zero ou maior.');
|
|
if (!reason) throw createValidationError('Justificativa do ajuste é obrigatória.');
|
|
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
const lotResult = await client.query(`
|
|
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
|
FROM supply_stock_lots
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`, [id]);
|
|
|
|
if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.');
|
|
|
|
const lot = lotResult.rows[0];
|
|
const currentQuantity = Number(lot.quantity);
|
|
const difference = countedQuantity - currentQuantity;
|
|
const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity);
|
|
|
|
await client.query(`
|
|
INSERT INTO supply_movements (
|
|
lot_id, type, category, product, quantity, unit, reason
|
|
)
|
|
VALUES ($1, 'inventory_adjustment', $2, $3, $4, $5, $6)
|
|
`, [
|
|
lot.id,
|
|
lot.category,
|
|
lot.product,
|
|
difference,
|
|
lot.unit,
|
|
`${reason} · sistema ${currentQuantity} ${lot.unit} · contado ${countedQuantity} ${lot.unit}`
|
|
]);
|
|
|
|
await client.query('COMMIT');
|
|
return updatedLot;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
};
|
|
|
|
const consumeLotForProduction = async (id, payload) => {
|
|
const quantity = normalizeNumber(payload.quantity);
|
|
const reason = normalizeText(payload.reason);
|
|
const productionOrderNumber = normalizeText(payload.productionOrderNumber);
|
|
|
|
if (!quantity) throw createValidationError('Quantidade de saída deve ser maior que zero.');
|
|
if (!productionOrderNumber && !reason) throw createValidationError('Informe a OP ou uma justificativa para a saída.');
|
|
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
const lotResult = await client.query(`
|
|
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
|
FROM supply_stock_lots
|
|
WHERE id = $1
|
|
FOR UPDATE
|
|
`, [id]);
|
|
|
|
if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.');
|
|
|
|
const lot = lotResult.rows[0];
|
|
const currentQuantity = Number(lot.quantity);
|
|
if (lot.status !== 'active' || currentQuantity <= 0) throw createValidationError('Lote sem saldo disponível.');
|
|
if (quantity > currentQuantity) throw createValidationError('Quantidade de saída maior que o saldo do lote.');
|
|
|
|
const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity);
|
|
const movementReason = [
|
|
productionOrderNumber ? `OP ${productionOrderNumber}` : '',
|
|
reason
|
|
].filter(Boolean).join(' · ');
|
|
|
|
await client.query(`
|
|
INSERT INTO supply_movements (
|
|
lot_id, type, category, product, quantity, unit, reason
|
|
)
|
|
VALUES ($1, 'production_exit', $2, $3, $4, $5, $6)
|
|
`, [
|
|
lot.id,
|
|
lot.category,
|
|
lot.product,
|
|
-quantity,
|
|
lot.unit,
|
|
movementReason || 'Saída para produção'
|
|
]);
|
|
|
|
await client.query('COMMIT');
|
|
return updatedLot;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
adjustInventoryLot,
|
|
approveReceipt,
|
|
consumeLotForProduction,
|
|
createFabricPlan,
|
|
createReceipt,
|
|
deleteFabricPlan,
|
|
deleteReceipt,
|
|
getSupplySummary,
|
|
listFabricPlans,
|
|
listLots,
|
|
listMovements,
|
|
listPurchaseNeeds,
|
|
listReceipts
|
|
};
|