From 800eb976ab20c8a13ac5998ece5982b306178a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 23 Jul 2026 11:53:18 -0300 Subject: [PATCH] Derive consumption references from Tiny OP sync --- backend/db.js | 23 +++ backend/services/catalogService.js | 20 ++- backend/services/databaseDiagnosticService.js | 2 +- backend/services/productionOrderService.js | 157 +++++++++++++++++- backend/services/supplyService.js | 118 ++++++++----- src/pages/Registrations.tsx | 32 +++- src/types.ts | 8 + 7 files changed, 310 insertions(+), 50 deletions(-) diff --git a/backend/db.js b/backend/db.js index 039b7be..282a245 100644 --- a/backend/db.js +++ b/backend/db.js @@ -245,6 +245,10 @@ const initDB = async () => { efficiency_percent NUMERIC(7, 3), rib_g_per_piece NUMERIC(14, 4), material_cost_per_kg NUMERIC(14, 4), + consumption_quantity NUMERIC(14, 4), + consumption_unit VARCHAR(30), + source VARCHAR(60) DEFAULT 'manual', + last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); @@ -381,6 +385,14 @@ const initDB = async () => { ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; `).catch(() => {}); + await pool.query(` + ALTER TABLE consumption_references + ADD COLUMN IF NOT EXISTS consumption_quantity NUMERIC(14, 4), + ADD COLUMN IF NOT EXISTS consumption_unit VARCHAR(30), + ADD COLUMN IF NOT EXISTS source VARCHAR(60) DEFAULT 'manual', + ADD COLUMN IF NOT EXISTS last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL; + `).catch(() => {}); + await pool.query(` ALTER TABLE consumption_references ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', @@ -497,6 +509,17 @@ 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 UNIQUE INDEX IF NOT EXISTS unique_consumption_reference_source + ON consumption_references ( + product_id, + COALESCE(material_product_id, 0), + COALESCE(color, ''), + COALESCE(source, 'manual') + ); + `).catch(err => { + console.error('Notice: Could not create unique consumption reference source index:', err.message); + }); 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);`); diff --git a/backend/services/catalogService.js b/backend/services/catalogService.js index 13eda16..65d5e83 100644 --- a/backend/services/catalogService.js +++ b/backend/services/catalogService.js @@ -73,6 +73,10 @@ const mapConsumptionReference = (row) => ({ efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent), ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece), materialCostPerKg: row.material_cost_per_kg === null ? null : Number(row.material_cost_per_kg), + consumptionQuantity: row.consumption_quantity === null ? null : Number(row.consumption_quantity), + consumptionUnit: row.consumption_unit || '', + source: row.source || 'manual', + lastProductionOrderId: row.last_production_order_id === null ? null : Number(row.last_production_order_id), createdAt: row.created_at, updatedAt: row.updated_at }); @@ -185,7 +189,8 @@ const listConsumptionReferences = async () => { r.material_product_id, m.sku AS material_sku, m.name AS material_name, r.color, r.general_yield, r.size_yields, r.size_areas, r.gramature, r.efficiency_percent, r.rib_g_per_piece, - r.material_cost_per_kg, r.created_at, r.updated_at + r.material_cost_per_kg, r.consumption_quantity, r.consumption_unit, + r.source, r.last_production_order_id, r.created_at, r.updated_at 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 @@ -208,9 +213,10 @@ const createConsumptionReference = async (payload) => { const calculatedYield = Object.values(sizeYields).length ? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length : null; + const consumptionQuantity = normalizeNumber(payload.consumptionQuantity); - if (!generalYield && !calculatedYield) { - const error = new Error('Informe o rendimento geral ou por tamanho.'); + if (!generalYield && !calculatedYield && !consumptionQuantity) { + const error = new Error('Informe o rendimento ou consumo por peça.'); error.statusCode = 400; throw error; } @@ -219,9 +225,9 @@ const createConsumptionReference = async (payload) => { INSERT INTO consumption_references ( product_id, material_product_id, color, general_yield, size_yields, size_areas, gramature, efficiency_percent, rib_g_per_piece, - material_cost_per_kg, updated_at + material_cost_per_kg, consumption_quantity, consumption_unit, source, updated_at ) - VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, CURRENT_TIMESTAMP) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12, 'manual', CURRENT_TIMESTAMP) RETURNING id `, [ productId, @@ -233,7 +239,9 @@ const createConsumptionReference = async (payload) => { normalizeNumber(payload.gramature), normalizeNumber(payload.efficiencyPercent), normalizeNumber(payload.ribGPerPiece), - normalizeNumber(payload.materialCostPerKg) + normalizeNumber(payload.materialCostPerKg), + consumptionQuantity, + normalizeText(payload.consumptionUnit) || null ]); const references = await listConsumptionReferences(); diff --git a/backend/services/databaseDiagnosticService.js b/backend/services/databaseDiagnosticService.js index 87b1a04..5ed3cd3 100644 --- a/backend/services/databaseDiagnosticService.js +++ b/backend/services/databaseDiagnosticService.js @@ -12,7 +12,7 @@ const sampleSpecs = { orderBy: ['updated_at', 'created_at', 'id'] }, consumption_references: { - columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'created_at', 'updated_at'], + columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'consumption_quantity', 'consumption_unit', 'source', 'last_production_order_id', 'created_at', 'updated_at'], orderBy: ['updated_at', 'created_at', 'id'] }, cutting_family_rules: { diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index 92e1567..7164770 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -65,6 +65,157 @@ const normalizeInteger = (value) => { return Number.isInteger(number) ? number : null; }; +const normalizeSku = (value) => normalizeText(value).toUpperCase(); + +const normalizeUnit = (value) => normalizeText(value).toLowerCase(); + +const normalizeCategoryName = (name) => normalizeText(name) + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toUpperCase(); + +const classifyCatalogCategoryName = (name, type) => { + const normalizedName = normalizeCategoryName(name); + if (type === 'finished_product') { + if (/\bDTF\b/.test(normalizedName)) return 'DTF'; + if (/\b(?:MOLETOM|CANGURU)\b/.test(normalizedName)) return 'Moletom'; + if (/\b(?:OVERSIZE|OVERSIZED)\b/.test(normalizedName)) return 'Oversized'; + if (/\bINFANTIL\b/.test(normalizedName)) return 'Camiseta infantil'; + if (/\b(?:BONE|ACESSORIO|ACESSORIOS)\b/.test(normalizedName)) return 'Acessórios'; + return 'Camiseta regular'; + } + if (/\b(?:MALHA|TECIDO|RIBANA|FIO)\b/.test(normalizedName)) return 'Malha'; + if (/\b(?:ETIQUETA|TAG|EMBALAGEM|SACO|SACOLA)\b/.test(normalizedName)) return 'Embalagens'; + if (/\b(?:ATACADOR|ILHOS|LINHA|FITA)\b/.test(normalizedName)) return 'Aviamentos'; + if (/\bDTF\b/.test(normalizedName)) return 'DTF'; + return 'Insumos gerais'; +}; + +const ensureCategoryId = async (client, name) => { + const result = await client.query(` + INSERT INTO catalog_categories (name, description, updated_at) + VALUES ($1, $2, CURRENT_TIMESTAMP) + ON CONFLICT (name) DO UPDATE + SET updated_at = catalog_categories.updated_at + RETURNING id; + `, [name, 'Categoria criada automaticamente pela sincronização de produção.']); + + return result.rows[0].id; +}; + +const upsertCatalogProductFromSync = async (client, { sku, name, type, categoryName, notes }) => { + const normalizedSku = normalizeSku(sku); + const normalizedName = normalizeText(name); + if (!normalizedSku || !normalizedName) return null; + + const categoryId = await ensureCategoryId(client, categoryName || classifyCatalogCategoryName(normalizedName, type)); + const result = await client.query(` + INSERT INTO catalog_products ( + type, sku, name, category_id, notes, updated_at + ) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) + ON CONFLICT (sku) DO UPDATE + SET type = EXCLUDED.type, + name = EXCLUDED.name, + category_id = COALESCE(catalog_products.category_id, EXCLUDED.category_id), + notes = COALESCE(NULLIF(catalog_products.notes, ''), EXCLUDED.notes), + updated_at = CURRENT_TIMESTAMP + RETURNING id; + `, [ + type, + normalizedSku, + normalizedName, + categoryId, + normalizeText(notes) || null + ]); + + return result.rows[0].id; +}; + +const upsertConsumptionReferencesFromComponents = async (client, orderId, order, components) => { + if (!order.productSku || !components.length) { + return { referenceCount: 0, skippedReferenceCount: components.length }; + } + + const productId = await upsertCatalogProductFromSync(client, { + sku: order.productSku, + name: order.productDescription, + type: 'finished_product', + categoryName: classifyCatalogCategoryName(order.productDescription, 'finished_product'), + notes: `Sincronizado da OP ${order.number || order.tinyId || orderId}.` + }); + + if (!productId) return { referenceCount: 0, skippedReferenceCount: components.length }; + + let referenceCount = 0; + let skippedReferenceCount = 0; + + for (const component of components) { + const materialId = await upsertCatalogProductFromSync(client, { + sku: component.componentSku, + name: component.componentName, + type: 'raw_material', + categoryName: classifyCatalogCategoryName(component.componentName, 'raw_material'), + notes: `Sincronizado da composição da OP ${order.number || order.tinyId || orderId}.` + }); + + if (!materialId || !component.quantityPerUnit) { + skippedReferenceCount += 1; + continue; + } + + const unit = normalizeUnit(component.unit); + const generalYield = unit === 'kg' ? 1 / component.quantityPerUnit : null; + + const existingReferenceResult = await client.query(` + SELECT id + FROM consumption_references + WHERE product_id = $1 + AND COALESCE(material_product_id, 0) = $2 + AND COALESCE(color, '') = '' + AND COALESCE(source, 'manual') = 'tiny_op' + LIMIT 1; + `, [productId, materialId]); + + if (existingReferenceResult.rows.length) { + await client.query(` + UPDATE consumption_references + SET general_yield = $1, + consumption_quantity = $2, + consumption_unit = $3, + last_production_order_id = $4, + updated_at = CURRENT_TIMESTAMP + WHERE id = $5; + `, [ + generalYield, + component.quantityPerUnit, + component.unit || null, + orderId, + existingReferenceResult.rows[0].id + ]); + } else { + await client.query(` + INSERT INTO consumption_references ( + product_id, material_product_id, general_yield, consumption_quantity, + consumption_unit, source, last_production_order_id, updated_at + ) + VALUES ($1, $2, $3, $4, $5, 'tiny_op', $6, CURRENT_TIMESTAMP); + `, [ + productId, + materialId, + generalYield, + component.quantityPerUnit, + component.unit || null, + orderId + ]); + } + + referenceCount += 1; + } + + return { referenceCount, skippedReferenceCount }; +}; + const mapComponent = (component) => ({ id: component.id, componentTinyId: component.component_tiny_id || '', @@ -640,11 +791,15 @@ const upsertTinyProductionOrderDetail = async (payload = {}) => { ]); } + const referenceSync = await upsertConsumptionReferencesFromComponents(client, orderId, order, components); + await client.query('COMMIT'); return { order: await getOrderById(orderId), componentCount: components.length, - stepCount: steps.length + stepCount: steps.length, + referenceCount: referenceSync.referenceCount, + skippedReferenceCount: referenceSync.skippedReferenceCount }; } catch (error) { await client.query('ROLLBACK'); diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index fdbfe87..67d4b5d 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -24,6 +24,13 @@ const normalizeKey = (value) => normalizeText(value) 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, @@ -249,7 +256,10 @@ const listConsumptionReferenceRows = async () => { m.name AS material_name, r.color, r.general_yield, - r.size_yields + 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 @@ -296,7 +306,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { listProjectDemandRows(), listConsumptionReferenceRows() ]); - const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference])); + 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(); demandRows.forEach(row => { @@ -309,8 +324,8 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0); if (suggestedQuantity <= 0) return; - const reference = referencesBySku.get(productId); - if (!reference) { + const references = referencesBySku.get(productId) || []; + if (!references.length) { mergeNeedLine(needsByMaterial, `missing:${productId}`, { material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`, plannedKg: suggestedQuantity, @@ -329,56 +344,81 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { return; } - const yieldPerKg = getReferenceYield(reference); - if (!yieldPerKg) { - mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, { - material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`, - plannedKg: suggestedQuantity, - priority: 'Crítico', - unit: 'un.', - source: 'project_demand', - missingReference: true, + 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 + stockQuantity, + yieldPerKg } }); - return; - } - - const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro'; - mergeNeedLine(needsByMaterial, normalizeKey(materialName), { - material: materialName, - plannedKg: suggestedQuantity / yieldPerKg, - priority: 'Atenção', - unit: 'kg', - source: 'project_demand', - color: reference.color, - product: { - productId, - name: normalizeText(row.product_name), - suggestedQuantity, - quantitySold, - stockQuantity, - yieldPerKg - } }); }); lots.forEach(lot => { - if (lot.unit !== 'kg') return; - const need = needsByMaterial.get(normalizeKey(lot.product)); - if (need && need.unit === 'kg') need.stockKg += lot.quantity; + const need = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${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.unit === 'kg') need.pendingKg += receipt.quantity; + if (receipt.status !== 'pending') return; + const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`); + if (need) need.pendingKg += receipt.quantity; }); return Array.from(needsByMaterial.values()).map(need => { diff --git a/src/pages/Registrations.tsx b/src/pages/Registrations.tsx index ea61e91..2edaa7b 100644 --- a/src/pages/Registrations.tsx +++ b/src/pages/Registrations.tsx @@ -92,6 +92,16 @@ const getAverageYield = (reference: ConsumptionReference) => { return reference.generalYield; }; +const getReferenceSourceLabel = (reference: ConsumptionReference) => ( + reference.source === 'tiny_op' ? 'Tiny OP' : 'Manual' +); + +const formatConsumptionPerPiece = (reference: ConsumptionReference) => { + if (!reference.consumptionQuantity) return ''; + const unit = reference.consumptionUnit || 'un.'; + return `${formatNumber(reference.consumptionQuantity, 4)} ${unit}/peça`; +}; + const Registrations = () => { const [searchParams] = useSearchParams(); const [catalog, setCatalog] = useState(emptyCatalog); @@ -663,6 +673,13 @@ const Registrations = () => { {Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'} + + {getReferenceSourceLabel(reference)} +

{reference.productName}

@@ -680,10 +697,19 @@ const Registrations = () => {

-
{formatNumber(getAverageYield(reference), 3)} pç/kg
-
- {Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'} +
+ {formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
+
+ {reference.consumptionQuantity + ? 'consumo' + : Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'} +
+ {reference.consumptionQuantity && getAverageYield(reference) ? ( +
+ {formatNumber(getAverageYield(reference), 3)} pç/kg +
+ ) : null}