1341 lines
53 KiB
JavaScript
1341 lines
53 KiB
JavaScript
const { pool } = require('../db');
|
|
|
|
const DEFAULT_SUPPLY_LOOKBACK_DAYS = 30;
|
|
const DEFAULT_SUPPLY_COVERAGE_DAYS = 30;
|
|
|
|
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;
|
|
};
|
|
|
|
// Tiny may temporarily report a negative balance while its own adjustments are
|
|
// being reconciled. Negative inventory must not inflate the material demand:
|
|
// for planning, it represents no available stock, never an extra shortage.
|
|
const getAvailablePlanningStock = (value) => Math.max(0, Number(value) || 0);
|
|
|
|
const normalizeKey = (value) => normalizeText(value)
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase();
|
|
|
|
const normalizeSku = (value) => normalizeText(value).toUpperCase();
|
|
|
|
const normalizeUnit = (value) => {
|
|
const unit = normalizeText(value).toLowerCase();
|
|
if (['kg', 'quilo', 'quilos'].includes(unit)) return 'kg';
|
|
if (['un', 'und', 'un.', 'unidade', 'unidades'].includes(unit)) return 'un.';
|
|
return unit || 'un.';
|
|
};
|
|
|
|
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,
|
|
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
|
|
});
|
|
|
|
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 mapMaterialTransformation = (row) => ({
|
|
id: row.id,
|
|
batchCode: row.batch_code || '',
|
|
stage: row.stage,
|
|
inputMaterial: row.input_material,
|
|
inputQuantityKg: Number(row.input_quantity_kg),
|
|
outputMaterial: row.output_material,
|
|
outputColor: row.output_color || '',
|
|
expectedLossPercent: Number(row.expected_loss_percent || 0),
|
|
expectedOutputKg: Number(row.expected_output_kg || 0),
|
|
supplier: row.supplier || '',
|
|
priority: row.priority || 'Normal',
|
|
status: row.status || 'planned',
|
|
notes: row.notes || '',
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
});
|
|
|
|
const mapMaterialDetails = (row) => ({
|
|
materialKey: row.material_key,
|
|
displayName: row.display_name || '',
|
|
preferredSupplier: row.preferred_supplier || '',
|
|
notes: row.notes || '',
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
});
|
|
|
|
const PURCHASE_ORDER_STATUSES = new Set(['draft', 'sent', 'approved', 'received', 'canceled']);
|
|
|
|
const mapPurchaseOrder = (row) => ({
|
|
id: row.id,
|
|
number: row.number || '',
|
|
supplier: row.supplier || '',
|
|
status: row.status || 'draft',
|
|
expectedDate: row.expected_date ? String(row.expected_date).slice(0, 10) : null,
|
|
notes: row.notes || '',
|
|
total: Number(row.total || 0),
|
|
items: Array.isArray(row.items) ? row.items.map(item => ({
|
|
id: item.id,
|
|
material: item.material || '',
|
|
quantity: Number(item.quantity || 0),
|
|
unit: item.unit || 'kg',
|
|
unitCost: item.unit_cost === null || item.unit_cost === undefined ? null : Number(item.unit_cost)
|
|
})) : [],
|
|
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
|
|
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);
|
|
};
|
|
|
|
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 listMaterialTransformations = async () => {
|
|
const result = await pool.query(`
|
|
SELECT id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
|
|
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
|
|
FROM supply_material_transformations
|
|
ORDER BY
|
|
CASE status WHEN 'in_progress' THEN 1 WHEN 'planned' THEN 2 WHEN 'completed' THEN 3 ELSE 4 END,
|
|
CASE priority WHEN 'Crítico' THEN 1 WHEN 'Atenção' THEN 2 ELSE 3 END,
|
|
created_at DESC, id DESC
|
|
`);
|
|
return result.rows.map(mapMaterialTransformation);
|
|
};
|
|
|
|
const listMaterialDetails = async () => {
|
|
const result = await pool.query(`
|
|
SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at
|
|
FROM supply_material_details
|
|
`);
|
|
return result.rows.map(mapMaterialDetails);
|
|
};
|
|
|
|
const listKnownSuppliers = async () => {
|
|
const result = await pool.query(`
|
|
SELECT DISTINCT supplier
|
|
FROM (
|
|
SELECT preferred_supplier AS supplier FROM supply_material_details
|
|
UNION ALL
|
|
SELECT supplier FROM supply_stock_lots
|
|
UNION ALL
|
|
SELECT supplier FROM supply_receipts
|
|
UNION ALL
|
|
SELECT supplier FROM supply_fabric_plans
|
|
UNION ALL
|
|
SELECT supplier FROM supply_purchase_orders
|
|
UNION ALL
|
|
SELECT supplier FROM production_orders
|
|
UNION ALL
|
|
SELECT supplier FROM production_yield_supplier_recommendations
|
|
) sources
|
|
WHERE NULLIF(BTRIM(supplier), '') IS NOT NULL
|
|
ORDER BY supplier;
|
|
`);
|
|
return result.rows.map(row => row.supplier);
|
|
};
|
|
|
|
const listPurchaseOrders = async () => {
|
|
const result = await pool.query(`
|
|
SELECT
|
|
purchase_order.id, purchase_order.number, purchase_order.supplier, purchase_order.status,
|
|
purchase_order.expected_date, purchase_order.notes, purchase_order.created_at, purchase_order.updated_at,
|
|
COALESCE((SELECT SUM(item.quantity * COALESCE(item.unit_cost, 0)) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), 0) AS total,
|
|
COALESCE((SELECT JSON_AGG(JSON_BUILD_OBJECT('id', item.id, 'material', item.material, 'quantity', item.quantity, 'unit', item.unit, 'unit_cost', item.unit_cost) ORDER BY item.id) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), '[]'::json) AS items
|
|
FROM supply_purchase_orders purchase_order
|
|
ORDER BY purchase_order.created_at DESC, purchase_order.id DESC;
|
|
`);
|
|
return result.rows.map(mapPurchaseOrder);
|
|
};
|
|
|
|
const createPurchaseOrder = async (payload = {}) => {
|
|
const supplier = normalizeText(payload.supplier);
|
|
const expectedDate = /^\d{4}-\d{2}-\d{2}$/.test(normalizeText(payload.expectedDate)) ? normalizeText(payload.expectedDate) : null;
|
|
const notes = normalizeText(payload.notes);
|
|
const items = (Array.isArray(payload.items) ? payload.items : []).map(item => ({
|
|
material: normalizeText(item?.material),
|
|
quantity: normalizeNumber(item?.quantity),
|
|
unit: normalizeText(item?.unit) || 'kg',
|
|
unitCost: normalizeNonNegativeNumber(item?.unitCost)
|
|
})).filter(item => item.material && item.quantity);
|
|
if (!supplier) throw createValidationError('Fornecedor é obrigatório para a ordem de compra.');
|
|
if (!items.length) throw createValidationError('Inclua pelo menos um material na ordem de compra.');
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const orderResult = await client.query(`INSERT INTO supply_purchase_orders (supplier, status, expected_date, notes, updated_at) VALUES ($1, 'draft', $2, NULLIF($3, ''), CURRENT_TIMESTAMP) RETURNING id;`, [supplier, expectedDate, notes]);
|
|
const id = orderResult.rows[0].id;
|
|
await client.query(`UPDATE supply_purchase_orders SET number = $1 WHERE id = $2;`, [`OC-${String(id).padStart(5, '0')}`, id]);
|
|
for (const item of items) {
|
|
await client.query(`INSERT INTO supply_purchase_order_items (purchase_order_id, material, quantity, unit, unit_cost) VALUES ($1, $2, $3, $4, $5);`, [id, item.material, item.quantity, item.unit, item.unitCost]);
|
|
}
|
|
await client.query('COMMIT');
|
|
return (await listPurchaseOrders()).find(order => order.id === id);
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
};
|
|
|
|
const updatePurchaseOrderStatus = async (id, status) => {
|
|
const normalizedStatus = normalizeText(status).toLowerCase();
|
|
if (!PURCHASE_ORDER_STATUSES.has(normalizedStatus)) throw createValidationError('Status de ordem de compra inválido.');
|
|
const result = await pool.query(`UPDATE supply_purchase_orders SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id;`, [normalizedStatus, id]);
|
|
if (!result.rowCount) {
|
|
const error = new Error('Ordem de compra não encontrada.');
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
return (await listPurchaseOrders()).find(order => order.id === Number(id));
|
|
};
|
|
|
|
const saveMaterialDetails = async (materialKey, payload) => {
|
|
const normalizedKey = normalizeKey(materialKey);
|
|
if (!normalizedKey) throw createValidationError('Material inválido para edição.');
|
|
|
|
const result = await pool.query(`
|
|
INSERT INTO supply_material_details (
|
|
material_key, display_name, preferred_supplier, notes, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
|
ON CONFLICT (material_key) DO UPDATE
|
|
SET display_name = EXCLUDED.display_name,
|
|
preferred_supplier = EXCLUDED.preferred_supplier,
|
|
notes = EXCLUDED.notes,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
RETURNING material_key, display_name, preferred_supplier, notes, created_at, updated_at
|
|
`, [
|
|
normalizedKey,
|
|
normalizeText(payload.displayName) || null,
|
|
normalizeText(payload.preferredSupplier) || null,
|
|
normalizeText(payload.notes) || null
|
|
]);
|
|
|
|
return mapMaterialDetails(result.rows[0]);
|
|
};
|
|
|
|
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,
|
|
materialKey: normalizeKey(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 += Number(lot.availableQuantity ?? 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 stockKg = getAvailablePlanningStock(need.stockKg);
|
|
const purchaseKg = Math.max(need.plannedKg - stockKg - need.pendingKg, 0);
|
|
let status = 'ok';
|
|
if (purchaseKg > 0 && (need.priority === 'Crítico' || stockKg === 0)) status = 'critical';
|
|
else if (purchaseKg > 0) status = 'attention';
|
|
|
|
return {
|
|
material: need.material,
|
|
materialKey: need.materialKey,
|
|
plannedKg: need.plannedKg,
|
|
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 getReferenceYield = (reference) => {
|
|
if (reference.general_yield !== null && Number(reference.general_yield) > 0) {
|
|
return Number(reference.general_yield);
|
|
}
|
|
|
|
const sizeYields = reference.size_yields && typeof reference.size_yields === 'object'
|
|
? Object.values(reference.size_yields).map(Number).filter(value => Number.isFinite(value) && value > 0)
|
|
: [];
|
|
|
|
if (!sizeYields.length) return null;
|
|
return sizeYields.reduce((total, value) => total + value, 0) / sizeYields.length;
|
|
};
|
|
|
|
const listProjectDemandRows = async () => {
|
|
const result = await pool.query(`
|
|
WITH bounds AS (
|
|
SELECT MAX(data_pedido_date) AS end_date
|
|
FROM orders
|
|
WHERE data_pedido_date IS NOT NULL
|
|
),
|
|
period_orders AS (
|
|
SELECT
|
|
produto_id,
|
|
MAX(produto_descricao) AS product_name,
|
|
SUM(quantidade)::numeric AS quantity_sold
|
|
FROM orders, bounds
|
|
WHERE data_pedido_date IS NOT NULL
|
|
AND bounds.end_date IS NOT NULL
|
|
AND data_pedido_date >= (bounds.end_date - ($1::int - 1) * INTERVAL '1 day')::date
|
|
AND data_pedido_date <= bounds.end_date
|
|
GROUP BY produto_id
|
|
)
|
|
SELECT
|
|
COALESCE(period_orders.produto_id, stock.produto_id) AS product_id,
|
|
COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name,
|
|
COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold,
|
|
COALESCE(graphs.balance, stock.saldo, 0)::numeric AS stock_quantity
|
|
FROM period_orders
|
|
FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id
|
|
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
|
|
WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> ''
|
|
ORDER BY quantity_sold DESC, product_name;
|
|
`, [DEFAULT_SUPPLY_LOOKBACK_DAYS]);
|
|
|
|
return result.rows;
|
|
};
|
|
|
|
const listConsumptionReferenceRows = async () => {
|
|
const result = await pool.query(`
|
|
SELECT
|
|
r.product_id,
|
|
p.sku AS product_sku,
|
|
p.name AS product_name,
|
|
r.material_product_id,
|
|
m.sku AS material_sku,
|
|
m.name AS material_name,
|
|
r.color,
|
|
r.general_yield,
|
|
r.size_yields,
|
|
r.consumption_quantity,
|
|
r.consumption_unit,
|
|
r.source
|
|
FROM consumption_references r
|
|
JOIN catalog_products p ON p.id = r.product_id
|
|
LEFT JOIN catalog_products m ON m.id = r.material_product_id
|
|
ORDER BY p.sku, r.color NULLS FIRST;
|
|
`);
|
|
|
|
return result.rows;
|
|
};
|
|
|
|
// The Tiny structure is the source of truth when it exists. Consumption
|
|
// references remain useful as a fallback for legacy/manual products, but they
|
|
// must not make an imported structure invisible to purchase planning.
|
|
const listCompositionRows = async () => {
|
|
const result = await pool.query(`
|
|
SELECT
|
|
composition.finished_tiny_product_id,
|
|
composition.finished_product_sku,
|
|
component.component_tiny_id,
|
|
component.component_sku,
|
|
component.component_name,
|
|
component.quantity_per_unit,
|
|
component.unit
|
|
FROM product_compositions composition
|
|
JOIN product_composition_components component
|
|
ON component.product_composition_id = composition.id
|
|
WHERE composition.source IN ('tiny_olist_v3', 'manual')
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM product_compositions manual_composition
|
|
WHERE manual_composition.source = 'manual'
|
|
AND composition.source = 'tiny_olist_v3'
|
|
AND (
|
|
manual_composition.finished_tiny_product_id = composition.finished_tiny_product_id
|
|
OR (
|
|
COALESCE(manual_composition.finished_tiny_product_id, '') = ''
|
|
AND manual_composition.finished_product_sku = composition.finished_product_sku
|
|
)
|
|
)
|
|
)
|
|
ORDER BY composition.id, component.id;
|
|
`);
|
|
return result.rows;
|
|
};
|
|
|
|
const listTinyStockRows = async () => {
|
|
const result = await pool.query(`
|
|
SELECT
|
|
stock.produto_id,
|
|
stock.nome,
|
|
COALESCE(stock.saldo, 0)::numeric AS tiny_saldo,
|
|
graphs.balance::numeric AS graphs_balance,
|
|
COALESCE(graphs.balance, stock.saldo, 0)::numeric AS saldo
|
|
FROM stock
|
|
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
|
|
WHERE COALESCE(stock.produto_id, '') <> '' OR COALESCE(stock.nome, '') <> '';
|
|
`);
|
|
return result.rows;
|
|
};
|
|
|
|
const mergeNeedLine = (needsByMaterial, key, patch) => {
|
|
const current = needsByMaterial.get(key) || {
|
|
material: patch.material,
|
|
materialKey: patch.materialKey || normalizeKey(patch.material),
|
|
plannedKg: 0,
|
|
stockKg: 0,
|
|
pendingKg: 0,
|
|
purchaseKg: 0,
|
|
priority: patch.priority || 'Normal',
|
|
suppliers: new Set(),
|
|
colors: new Set(),
|
|
unit: patch.unit || 'kg',
|
|
source: patch.source || 'manual_plan',
|
|
missingReference: Boolean(patch.missingReference),
|
|
products: []
|
|
};
|
|
|
|
current.plannedKg += patch.plannedKg || 0;
|
|
current.priority = patch.priority === 'Crítico' || current.priority === 'Crítico'
|
|
? 'Crítico'
|
|
: patch.priority === 'Atenção' || current.priority === 'Atenção'
|
|
? 'Atenção'
|
|
: current.priority;
|
|
current.missingReference = current.missingReference || Boolean(patch.missingReference);
|
|
current.source = current.source === patch.source ? current.source : 'mixed';
|
|
if (patch.supplier) current.suppliers.add(patch.supplier);
|
|
if (patch.color) current.colors.add(patch.color);
|
|
if (patch.product) current.products.push(patch.product);
|
|
|
|
needsByMaterial.set(key, current);
|
|
return current;
|
|
};
|
|
|
|
const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
|
const [demandRows, referenceRows, compositionRows, tinyStockRows] = await Promise.all([
|
|
listProjectDemandRows(),
|
|
listConsumptionReferenceRows(),
|
|
listCompositionRows(),
|
|
listTinyStockRows()
|
|
]);
|
|
const referencesBySku = referenceRows.reduce((references, reference) => {
|
|
const sku = normalizeSku(reference.product_sku);
|
|
if (!sku) return references;
|
|
references.set(sku, [...(references.get(sku) || []), reference]);
|
|
return references;
|
|
}, new Map());
|
|
const needsByMaterial = new Map();
|
|
|
|
const compositionsByProduct = compositionRows.reduce((compositions, component) => {
|
|
[component.finished_tiny_product_id, component.finished_product_sku]
|
|
.map(normalizeSku)
|
|
.filter(Boolean)
|
|
.forEach(identity => {
|
|
compositions.set(identity, [...(compositions.get(identity) || []), component]);
|
|
});
|
|
return compositions;
|
|
}, new Map());
|
|
|
|
// A need is keyed by the imported Tiny component identity whenever
|
|
// possible. Names are retained for display and for matching manual lots.
|
|
const componentNeedKeys = new Map();
|
|
const registerNeedKey = (identity, key) => {
|
|
const normalizedIdentity = normalizeSku(identity);
|
|
if (normalizedIdentity) componentNeedKeys.set(normalizedIdentity, key);
|
|
};
|
|
const addCompositionNeed = (component, row, productId, suggestedQuantity, quantitySold, stockQuantity) => {
|
|
const unit = normalizeUnit(component.unit);
|
|
const material = normalizeText(component.component_name)
|
|
|| normalizeText(component.component_sku)
|
|
|| normalizeText(component.component_tiny_id)
|
|
|| 'Material sem cadastro';
|
|
const quantityPerUnit = Number(component.quantity_per_unit || 0);
|
|
const tinyId = normalizeText(component.component_tiny_id);
|
|
const sku = normalizeSku(component.component_sku);
|
|
const identity = tinyId || sku || normalizeKey(material);
|
|
const key = `${unit}:${normalizeSku(identity) || normalizeKey(material)}`;
|
|
|
|
if (!Number.isFinite(quantityPerUnit) || quantityPerUnit <= 0) {
|
|
mergeNeedLine(needsByMaterial, `missing-quantity:${productId}:${identity}`, {
|
|
material: `Revisar quantidade: ${normalizeText(row.product_name) || productId}`,
|
|
plannedKg: suggestedQuantity,
|
|
priority: 'Crítico',
|
|
unit: 'un.',
|
|
source: 'tiny_composition',
|
|
missingReference: true,
|
|
product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity }
|
|
});
|
|
return;
|
|
}
|
|
|
|
mergeNeedLine(needsByMaterial, key, {
|
|
material,
|
|
materialKey: normalizeKey(material),
|
|
plannedKg: suggestedQuantity * quantityPerUnit,
|
|
priority: 'Atenção',
|
|
unit,
|
|
source: 'tiny_composition',
|
|
product: {
|
|
productId,
|
|
name: normalizeText(row.product_name),
|
|
suggestedQuantity,
|
|
quantitySold,
|
|
stockQuantity,
|
|
consumptionQuantity: quantityPerUnit,
|
|
consumptionUnit: unit
|
|
}
|
|
});
|
|
registerNeedKey(tinyId, key);
|
|
registerNeedKey(sku, key);
|
|
// Lots are normally registered by material name, not Tiny ID.
|
|
registerNeedKey(material, key);
|
|
};
|
|
|
|
demandRows.forEach(row => {
|
|
const productId = normalizeSku(row.product_id);
|
|
if (!productId) return;
|
|
|
|
const quantitySold = Number(row.quantity_sold || 0);
|
|
const stockQuantity = Number(row.stock_quantity || 0);
|
|
const projectedDemand = quantitySold * (DEFAULT_SUPPLY_COVERAGE_DAYS / DEFAULT_SUPPLY_LOOKBACK_DAYS);
|
|
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
|
|
if (suggestedQuantity <= 0) return;
|
|
|
|
const composition = compositionsByProduct.get(productId) || [];
|
|
if (composition.length) {
|
|
composition.forEach(component => addCompositionNeed(component, row, productId, suggestedQuantity, quantitySold, stockQuantity));
|
|
return;
|
|
}
|
|
|
|
const references = referencesBySku.get(productId) || [];
|
|
if (!references.length) {
|
|
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
|
|
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
|
|
plannedKg: suggestedQuantity,
|
|
priority: 'Crítico',
|
|
unit: 'un.',
|
|
source: 'project_demand',
|
|
missingReference: true,
|
|
product: {
|
|
productId,
|
|
name: normalizeText(row.product_name),
|
|
suggestedQuantity,
|
|
quantitySold,
|
|
stockQuantity
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
references.forEach(reference => {
|
|
const consumptionQuantity = Number(reference.consumption_quantity || 0);
|
|
const consumptionUnit = normalizeUnit(reference.consumption_unit);
|
|
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
|
|
|
|
if (consumptionQuantity > 0) {
|
|
mergeNeedLine(needsByMaterial, `${consumptionUnit}:${normalizeKey(materialName)}`, {
|
|
material: materialName,
|
|
plannedKg: suggestedQuantity * consumptionQuantity,
|
|
priority: 'Atenção',
|
|
unit: consumptionUnit,
|
|
source: reference.source || 'project_demand',
|
|
color: reference.color,
|
|
product: {
|
|
productId,
|
|
name: normalizeText(row.product_name),
|
|
suggestedQuantity,
|
|
quantitySold,
|
|
stockQuantity,
|
|
consumptionQuantity,
|
|
consumptionUnit
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
const yieldPerKg = getReferenceYield(reference);
|
|
if (!yieldPerKg) {
|
|
mergeNeedLine(needsByMaterial, `missing-yield:${productId}:${reference.material_product_id || 'material'}`, {
|
|
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
|
|
plannedKg: suggestedQuantity,
|
|
priority: 'Crítico',
|
|
unit: 'un.',
|
|
source: 'project_demand',
|
|
missingReference: true,
|
|
color: reference.color,
|
|
product: {
|
|
productId,
|
|
name: normalizeText(row.product_name),
|
|
suggestedQuantity,
|
|
quantitySold,
|
|
stockQuantity
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, {
|
|
material: materialName,
|
|
plannedKg: suggestedQuantity / yieldPerKg,
|
|
priority: 'Atenção',
|
|
unit: 'kg',
|
|
source: reference.source || 'project_demand',
|
|
color: reference.color,
|
|
product: {
|
|
productId,
|
|
name: normalizeText(row.product_name),
|
|
suggestedQuantity,
|
|
quantitySold,
|
|
stockQuantity,
|
|
yieldPerKg
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
const getNeedForInventory = (unit, id, name) => {
|
|
const normalizedUnit = normalizeUnit(unit);
|
|
const key = componentNeedKeys.get(normalizeSku(id)) || componentNeedKeys.get(normalizeSku(name));
|
|
return key && key.startsWith(`${normalizedUnit}:`) ? needsByMaterial.get(key) : undefined;
|
|
};
|
|
|
|
// Tiny saldo is material stock too. It is matched using the component ID
|
|
// first, then SKU/name, which lets "MALHA PRETA" stock directly reduce the
|
|
// material purchase suggestion imported from the product structure.
|
|
tinyStockRows.forEach(stock => {
|
|
const need = getNeedForInventory('un.', stock.produto_id, stock.nome)
|
|
|| getNeedForInventory('kg', stock.produto_id, stock.nome)
|
|
|| getNeedForInventory('', stock.produto_id, stock.nome);
|
|
if (need) need.stockKg += Number(stock.saldo || 0);
|
|
});
|
|
|
|
lots.forEach(lot => {
|
|
const need = getNeedForInventory(lot.unit, '', lot.product)
|
|
|| needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
|
|
if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity);
|
|
});
|
|
|
|
receipts.forEach(receipt => {
|
|
if (receipt.status !== 'pending') return;
|
|
const need = getNeedForInventory(receipt.unit, '', receipt.product)
|
|
|| needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
|
|
if (need) need.pendingKg += receipt.quantity;
|
|
});
|
|
|
|
return Array.from(needsByMaterial.values()).map(need => {
|
|
const stockKg = getAvailablePlanningStock(need.stockKg);
|
|
const purchaseKg = Math.max(need.plannedKg - stockKg - need.pendingKg, 0);
|
|
let status = 'ok';
|
|
if (purchaseKg > 0 && (need.priority === 'Crítico' || stockKg === 0)) status = 'critical';
|
|
else if (purchaseKg > 0) status = 'attention';
|
|
|
|
return {
|
|
material: need.material,
|
|
materialKey: need.materialKey,
|
|
plannedKg: need.plannedKg,
|
|
stockKg,
|
|
pendingKg: need.pendingKg,
|
|
purchaseKg,
|
|
priority: need.priority,
|
|
status,
|
|
suppliers: Array.from(need.suppliers),
|
|
colors: Array.from(need.colors),
|
|
unit: need.unit,
|
|
source: need.source,
|
|
missingReference: need.missingReference,
|
|
products: need.products
|
|
};
|
|
});
|
|
};
|
|
|
|
const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
|
|
const mergedByKey = new Map();
|
|
|
|
[...manualNeeds, ...projectNeeds].forEach(need => {
|
|
const key = `${need.unit || 'kg'}:${normalizeKey(need.material)}:${need.missingReference ? 'missing' : 'mapped'}`;
|
|
const current = mergedByKey.get(key);
|
|
if (!current) {
|
|
mergedByKey.set(key, {
|
|
...need,
|
|
suppliers: new Set(need.suppliers || []),
|
|
colors: new Set(need.colors || []),
|
|
products: [...(need.products || [])]
|
|
});
|
|
return;
|
|
}
|
|
|
|
current.plannedKg += need.plannedKg;
|
|
// Stock/receipts are the same physical inventory for a manual plan and
|
|
// an imported-composition need. They are alternatives views of the
|
|
// balance, never amounts to add together.
|
|
current.stockKg = Math.max(current.stockKg, need.stockKg);
|
|
current.pendingKg = Math.max(current.pendingKg, need.pendingKg);
|
|
current.priority = need.priority === 'Crítico' || current.priority === 'Crítico'
|
|
? 'Crítico'
|
|
: need.priority === 'Atenção' || current.priority === 'Atenção'
|
|
? 'Atenção'
|
|
: current.priority;
|
|
current.status = current.status === 'critical' || need.status === 'critical'
|
|
? 'critical'
|
|
: current.status === 'attention' || need.status === 'attention'
|
|
? 'attention'
|
|
: 'ok';
|
|
current.source = current.source === need.source ? current.source : 'mixed';
|
|
current.missingReference = current.missingReference || Boolean(need.missingReference);
|
|
(need.suppliers || []).forEach(supplier => current.suppliers.add(supplier));
|
|
(need.colors || []).forEach(color => current.colors.add(color));
|
|
current.products.push(...(need.products || []));
|
|
});
|
|
|
|
return Array.from(mergedByKey.values())
|
|
.map(need => {
|
|
const stockKg = getAvailablePlanningStock(need.stockKg);
|
|
const purchaseKg = Math.max(need.plannedKg - stockKg - need.pendingKg, 0);
|
|
return {
|
|
...need,
|
|
stockKg,
|
|
purchaseKg,
|
|
status: purchaseKg > 0
|
|
? (need.priority === 'Crítico' || stockKg === 0 ? 'critical' : 'attention')
|
|
: 'ok',
|
|
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 applyMaterialDetails = (purchaseNeeds, materialDetails) => {
|
|
const detailsByKey = new Map(materialDetails.map(detail => [detail.materialKey, detail]));
|
|
|
|
return purchaseNeeds.map(need => {
|
|
const detail = detailsByKey.get(normalizeKey(need.materialKey || need.material));
|
|
if (!detail) return { ...need, preferredSupplier: '', notes: '' };
|
|
|
|
return {
|
|
...need,
|
|
material: detail.displayName || need.material,
|
|
suppliers: detail.preferredSupplier
|
|
? [detail.preferredSupplier, ...need.suppliers.filter(supplier => supplier !== detail.preferredSupplier)]
|
|
: need.suppliers,
|
|
preferredSupplier: detail.preferredSupplier,
|
|
notes: detail.notes
|
|
};
|
|
});
|
|
};
|
|
|
|
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, materialDetails] = await Promise.all([
|
|
listReceipts(),
|
|
listLots(),
|
|
listMovements(),
|
|
listFabricPlans(),
|
|
listMaterialDetails()
|
|
]);
|
|
const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
|
|
const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts);
|
|
const purchaseNeeds = applyMaterialDetails(
|
|
mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds),
|
|
materialDetails
|
|
);
|
|
|
|
return {
|
|
receipts,
|
|
lots,
|
|
movements,
|
|
fabricPlans,
|
|
purchaseNeeds,
|
|
stats: buildStats(receipts, lots, purchaseNeeds)
|
|
};
|
|
};
|
|
|
|
const listPurchaseNeeds = async () => {
|
|
const [plans, lots, receipts, materialDetails] = await Promise.all([
|
|
listFabricPlans(),
|
|
listLots(),
|
|
listReceipts(),
|
|
listMaterialDetails()
|
|
]);
|
|
|
|
return applyMaterialDetails(
|
|
mergePurchaseNeeds(
|
|
buildPurchaseNeeds(plans, lots, receipts),
|
|
await buildProjectPurchaseNeeds(lots, receipts)
|
|
),
|
|
materialDetails
|
|
);
|
|
};
|
|
|
|
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 TRANSFORMATION_STAGES = new Set(['knitting', 'dyeing', 'finishing']);
|
|
const TRANSFORMATION_STATUSES = new Set(['planned', 'in_progress', 'completed', 'canceled']);
|
|
|
|
const createMaterialTransformation = async (payload = {}) => {
|
|
const stage = normalizeText(payload.stage).toLowerCase();
|
|
const inputMaterial = normalizeText(payload.inputMaterial);
|
|
const outputMaterial = normalizeText(payload.outputMaterial);
|
|
const inputQuantityKg = normalizeNumber(payload.inputQuantityKg);
|
|
const expectedLossPercent = normalizeNonNegativeNumber(payload.expectedLossPercent) ?? 0;
|
|
if (!TRANSFORMATION_STAGES.has(stage)) throw createValidationError('Etapa de transformação inválida.');
|
|
if (!inputMaterial || !outputMaterial) throw createValidationError('Material de entrada e saída são obrigatórios.');
|
|
if (!inputQuantityKg) throw createValidationError('Quantidade de entrada deve ser maior que zero.');
|
|
if (expectedLossPercent >= 100) throw createValidationError('A perda esperada deve ser menor que 100%.');
|
|
const expectedOutputKg = Number((inputQuantityKg * (1 - (expectedLossPercent / 100))).toFixed(4));
|
|
const result = await pool.query(`
|
|
INSERT INTO supply_material_transformations (
|
|
batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
|
|
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'planned', $11, CURRENT_TIMESTAMP)
|
|
RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
|
|
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
|
|
`, [
|
|
normalizeText(payload.batchCode) || null, stage, inputMaterial, inputQuantityKg, outputMaterial,
|
|
normalizeText(payload.outputColor) || null, expectedLossPercent, expectedOutputKg,
|
|
normalizeText(payload.supplier) || null, normalizeText(payload.priority) || 'Normal', normalizeText(payload.notes) || null
|
|
]);
|
|
return mapMaterialTransformation(result.rows[0]);
|
|
};
|
|
|
|
const updateMaterialTransformationStatus = async (id, status) => {
|
|
const normalizedStatus = normalizeText(status).toLowerCase();
|
|
if (!TRANSFORMATION_STATUSES.has(normalizedStatus)) throw createValidationError('Status da transformação inválido.');
|
|
const result = await pool.query(`
|
|
UPDATE supply_material_transformations
|
|
SET status = $2, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
|
|
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
|
|
`, [id, normalizedStatus]);
|
|
if (!result.rows[0]) throw createValidationError('Transformação não encontrada.');
|
|
return mapMaterialTransformation(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 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);
|
|
|
|
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 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);
|
|
|
|
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.');
|
|
|
|
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}` : '',
|
|
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'
|
|
]);
|
|
|
|
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) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
adjustInventoryLot,
|
|
approveReceipt,
|
|
consumeLotForProduction,
|
|
createFabricPlan,
|
|
createMaterialTransformation,
|
|
createPurchaseOrder,
|
|
createReceipt,
|
|
deleteFabricPlan,
|
|
deleteReceipt,
|
|
getSupplySummary,
|
|
buildProjectPurchaseNeeds,
|
|
buildPurchaseNeeds,
|
|
listFabricPlans,
|
|
listMaterialTransformations,
|
|
listMaterialDetails,
|
|
listKnownSuppliers,
|
|
listPurchaseOrders,
|
|
listLots,
|
|
listMovements,
|
|
listPurchaseNeeds,
|
|
listReceipts,
|
|
saveMaterialDetails,
|
|
updateMaterialTransformationStatus,
|
|
updatePurchaseOrderStatus
|
|
};
|