Reserve material lots from cut plans

This commit is contained in:
Cauê Faleiros
2026-08-25 10:55:54 -03:00
parent a226e6d9f4
commit 91f3e5e00c
8 changed files with 355 additions and 20 deletions

View File

@@ -443,6 +443,24 @@ const initDB = async () => {
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS cut_plan_lot_reservations (
id SERIAL PRIMARY KEY,
cut_plan_reference TEXT NOT NULL,
supply_lot_id INTEGER NOT NULL REFERENCES supply_stock_lots(id) ON DELETE RESTRICT,
material_key VARCHAR(255) NOT NULL,
quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0),
consumed_quantity NUMERIC(14, 4) NOT NULL DEFAULT 0 CHECK (consumed_quantity >= 0),
status VARCHAR(30) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
released_at TIMESTAMPTZ
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_lot_status ON cut_plan_lot_reservations (supply_lot_id, status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_reference ON cut_plan_lot_reservations (cut_plan_reference, status);`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_movements (
id SERIAL PRIMARY KEY,

View File

@@ -16,7 +16,8 @@ router.get('/production-orders', verifyToken, async (req, res) => {
router.post('/production-orders', verifyToken, async (req, res) => {
try {
const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
res.status(201).json(await createProductionOrders(orders));
const lotReservations = Array.isArray(req.body?.lotReservations) ? req.body.lotReservations : [];
res.status(201).json(await createProductionOrders(orders, lotReservations));
} catch (error) {
console.error('Error creating production orders:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });

View File

@@ -255,6 +255,18 @@ const mapStep = (step) => ({
color: step.color || ''
});
const mapLotReservation = (reservation) => ({
id: reservation.id,
lotId: Number(reservation.supply_lot_id),
materialKey: reservation.material_key || '',
quantity: Number(reservation.quantity || 0),
consumedQuantity: Number(reservation.consumed_quantity || 0),
status: reservation.status || '',
lotProduct: reservation.lot_product || '',
lotUnit: reservation.lot_unit || '',
lotSupplier: reservation.lot_supplier || ''
});
const mapProductionOrderRow = (row) => {
const status = normalizeStatus(row.status);
@@ -284,6 +296,7 @@ const mapProductionOrderRow = (row) => {
rejectedQuantity: Number(row.rejected_quantity || 0),
finishedQuantity: row.finished_quantity === null ? null : Number(row.finished_quantity),
qualityNotes: row.quality_notes || '',
lotReservations: Array.isArray(row.lot_reservations) ? row.lot_reservations.map(mapLotReservation) : [],
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
components: Array.isArray(row.components) ? row.components.map(mapComponent) : [],
steps: Array.isArray(row.steps) ? row.steps.map(mapStep) : [],
@@ -354,6 +367,29 @@ const getOrderById = async (id, client = pool) => {
),
'[]'::json
) as components,
COALESCE(
(
SELECT JSON_AGG(
JSON_BUILD_OBJECT(
'id', reservation.id,
'supply_lot_id', reservation.supply_lot_id,
'material_key', reservation.material_key,
'quantity', reservation.quantity,
'consumed_quantity', reservation.consumed_quantity,
'status', reservation.status,
'lot_product', lot.product,
'lot_unit', lot.unit,
'lot_supplier', lot.supplier
)
ORDER BY reservation.created_at, reservation.id
)
FROM cut_plan_lot_reservations reservation
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id
WHERE reservation.cut_plan_reference = po.order_reference
AND reservation.status IN ('active', 'consumed')
),
'[]'::json
) as lot_reservations,
COALESCE(
(
SELECT JSON_AGG(
@@ -441,6 +477,29 @@ const baseProductionOrderSelect = `
),
'[]'::json
) as components,
COALESCE(
(
SELECT JSON_AGG(
JSON_BUILD_OBJECT(
'id', reservation.id,
'supply_lot_id', reservation.supply_lot_id,
'material_key', reservation.material_key,
'quantity', reservation.quantity,
'consumed_quantity', reservation.consumed_quantity,
'status', reservation.status,
'lot_product', lot.product,
'lot_unit', lot.unit,
'lot_supplier', lot.supplier
)
ORDER BY reservation.created_at, reservation.id
)
FROM cut_plan_lot_reservations reservation
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id
WHERE reservation.cut_plan_reference = po.order_reference
AND reservation.status IN ('active', 'consumed')
),
'[]'::json
) as lot_reservations,
COALESCE(
(
SELECT JSON_AGG(
@@ -517,7 +576,7 @@ const listProductionOrders = async (filters = {}) => {
return { orders, counts };
};
const createProductionOrders = async (orders = []) => {
const createProductionOrders = async (orders = [], lotReservations = []) => {
const client = await pool.connect();
try {
@@ -530,6 +589,7 @@ const createProductionOrders = async (orders = []) => {
const productSku = normalizeText(order.productSku);
const orderReference = normalizeText(order.orderReference);
const quantity = normalizeQuantity(order.quantity);
const components = normalizeComponents(order.components, quantity);
if (!productDescription || !quantity) {
skipped.push({ productSku, productDescription, reason: 'invalid_payload' });
@@ -611,10 +671,76 @@ const createProductionOrders = async (orders = []) => {
`, [orderId, label, normalizeText(marker.color) || null]);
}
for (const component of components) {
await client.query(`
INSERT INTO production_order_components (
production_order_id, component_tiny_id, component_sku, component_name,
quantity_per_unit, total_quantity, unit
)
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), $4, $5, $6, NULLIF($7, ''));
`, [
orderId,
component.componentTinyId,
component.componentSku,
component.componentName,
component.quantityPerUnit,
component.totalQuantity,
component.unit
]);
}
const createdOrder = await getOrderById(orderId, client);
if (createdOrder) created.push(createdOrder);
}
if (created.length && Array.isArray(lotReservations)) {
for (const reservation of lotReservations) {
const lotId = Number(reservation?.lotId);
const quantity = normalizeQuantity(reservation?.quantity);
const materialKey = normalizeText(reservation?.materialKey);
const cutPlanReference = normalizeText(reservation?.cutPlanReference) || normalizeText(created[0]?.orderReference);
if (!Number.isInteger(lotId) || !quantity || !materialKey || !cutPlanReference) {
const error = new Error('Reserva de lote inválida.');
error.statusCode = 400;
throw error;
}
const lotResult = await client.query(`
SELECT id, quantity, unit, status
FROM supply_stock_lots
WHERE id = $1
FOR UPDATE;
`, [lotId]);
if (!lotResult.rowCount || lotResult.rows[0].status !== 'active') {
const error = new Error('O lote selecionado não está disponível para reserva.');
error.statusCode = 400;
throw error;
}
const activeReservations = await client.query(`
SELECT quantity, consumed_quantity
FROM cut_plan_lot_reservations
WHERE supply_lot_id = $1
AND status = 'active'
FOR UPDATE;
`, [lotId]);
const alreadyReserved = activeReservations.rows.reduce((total, row) => (
total + Number(row.quantity || 0) - Number(row.consumed_quantity || 0)
), 0);
const availableQuantity = Math.max(0, Number(lotResult.rows[0].quantity || 0) - alreadyReserved);
if (quantity > availableQuantity) {
const error = new Error(`O lote #${lotId} possui apenas ${availableQuantity} ${lotResult.rows[0].unit} disponível para nova reserva.`);
error.statusCode = 400;
throw error;
}
await client.query(`
INSERT INTO cut_plan_lot_reservations (
cut_plan_reference, supply_lot_id, material_key, quantity, consumed_quantity, status, updated_at
)
VALUES ($1, $2, $3, $4, 0, 'active', CURRENT_TIMESTAMP);
`, [cutPlanReference, lotId, materialKey, quantity]);
}
}
await client.query('COMMIT');
return { created, skipped };
} catch (error) {

View File

@@ -61,6 +61,8 @@ const mapLot = (row) => ({
supplier: row.supplier || '',
invoice: row.invoice || '',
status: row.status,
reservedQuantity: Number(row.reserved_quantity || 0),
availableQuantity: Number(row.available_quantity === undefined ? row.quantity : row.available_quantity),
createdAt: row.created_at,
updatedAt: row.updated_at
});
@@ -116,10 +118,19 @@ const listReceipts = async () => {
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
SELECT
lot.id, lot.receipt_id, lot.category, lot.product, lot.quantity, lot.unit, lot.supplier, lot.invoice, lot.status, lot.created_at, lot.updated_at,
COALESCE(reservations.reserved_quantity, 0) AS reserved_quantity,
GREATEST(0, lot.quantity - COALESCE(reservations.reserved_quantity, 0)) AS available_quantity
FROM supply_stock_lots lot
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(quantity - consumed_quantity), 0) AS reserved_quantity
FROM cut_plan_lot_reservations reservation
WHERE reservation.supply_lot_id = lot.id
AND reservation.status = 'active'
) reservations ON TRUE
WHERE lot.status = 'active'
ORDER BY lot.created_at DESC, lot.id DESC
`);
return result.rows.map(mapLot);
};
@@ -233,7 +244,7 @@ const buildPurchaseNeeds = (plans, lots, receipts) => {
lots.forEach(lot => {
if (lot.unit !== 'kg') return;
const need = needsByMaterial.get(normalizeKey(lot.product));
if (need) need.stockKg += lot.quantity;
if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity);
});
receipts.forEach(receipt => {
@@ -624,7 +635,7 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
lots.forEach(lot => {
const need = getNeedForInventory(lot.unit, '', lot.product)
|| needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
if (need) need.stockKg += lot.quantity;
if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity);
});
receipts.forEach(receipt => {
@@ -962,6 +973,19 @@ const updateLotQuantity = async (client, lotId, quantity) => {
return mapLot(result.rows[0]);
};
const getActiveReservationQuantity = async (client, lotId) => {
const result = await client.query(`
SELECT quantity, consumed_quantity
FROM cut_plan_lot_reservations
WHERE supply_lot_id = $1
AND status = 'active'
FOR UPDATE;
`, [lotId]);
return result.rows.reduce((total, reservation) => (
total + Number(reservation.quantity || 0) - Number(reservation.consumed_quantity || 0)
), 0);
};
const adjustInventoryLot = async (id, payload) => {
const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity);
const reason = normalizeText(payload.reason);
@@ -985,6 +1009,10 @@ const adjustInventoryLot = async (id, payload) => {
const lot = lotResult.rows[0];
const currentQuantity = Number(lot.quantity);
const reservedQuantity = await getActiveReservationQuantity(client, lot.id);
if (countedQuantity < reservedQuantity) {
throw createValidationError(`A contagem não pode ficar abaixo de ${reservedQuantity} ${lot.unit} já reservados para corte.`);
}
const difference = countedQuantity - currentQuantity;
const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity);
@@ -1039,6 +1067,35 @@ const consumeLotForProduction = async (id, payload) => {
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.');
let reference = '';
if (productionOrderNumber) {
const orderResult = await client.query(`
SELECT order_reference
FROM production_orders
WHERE number = $1
LIMIT 1;
`, [productionOrderNumber]);
reference = normalizeText(orderResult.rows[0]?.order_reference);
}
const allReservationsResult = await client.query(`
SELECT id, cut_plan_reference, quantity, consumed_quantity
FROM cut_plan_lot_reservations
WHERE supply_lot_id = $1
AND status = 'active'
ORDER BY created_at, id
FOR UPDATE;
`, [lot.id]);
const totalReserved = allReservationsResult.rows.reduce((total, reservation) => (
total + Number(reservation.quantity) - Number(reservation.consumed_quantity)
), 0);
const reservedByThisPlan = allReservationsResult.rows
.filter(reservation => reference && reservation.cut_plan_reference === reference)
.reduce((total, reservation) => total + Number(reservation.quantity) - Number(reservation.consumed_quantity), 0);
const permittedQuantity = Math.max(0, currentQuantity - totalReserved + reservedByThisPlan);
if (quantity > permittedQuantity) {
throw createValidationError(`O lote possui ${Math.max(0, currentQuantity - totalReserved)} ${lot.unit} livres e ${reservedByThisPlan} ${lot.unit} reservados para esta OP.`);
}
const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity);
const movementReason = [
productionOrderNumber ? `OP ${productionOrderNumber}` : '',
@@ -1059,6 +1116,36 @@ const consumeLotForProduction = async (id, payload) => {
movementReason || 'Saída para produção'
]);
if (productionOrderNumber) {
if (reference) {
const reservationsResult = await client.query(`
SELECT id, quantity, consumed_quantity
FROM cut_plan_lot_reservations
WHERE supply_lot_id = $1
AND cut_plan_reference = $2
AND status = 'active'
ORDER BY created_at, id
FOR UPDATE;
`, [lot.id, reference]);
let remainingToApply = quantity;
for (const reservation of reservationsResult.rows) {
if (remainingToApply <= 0) break;
const reservedRemaining = Number(reservation.quantity) - Number(reservation.consumed_quantity);
const consumed = Math.min(reservedRemaining, remainingToApply);
if (consumed <= 0) continue;
const nextConsumedQuantity = Number(reservation.consumed_quantity) + consumed;
await client.query(`
UPDATE cut_plan_lot_reservations
SET consumed_quantity = $1,
status = CASE WHEN $1 >= quantity THEN 'consumed' ELSE 'active' END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;
`, [nextConsumedQuantity, reservation.id]);
remainingToApply -= consumed;
}
}
}
await client.query('COMMIT');
return updatedLot;
} catch (error) {