diff --git a/README.md b/README.md index f0d3e05..c75a711 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,11 @@ npm run dev Default local URLs: ```text -Frontend: http://127.0.0.1:3001 +Frontend: http://127.0.0.1:3002 Backend: http://127.0.0.1:3004 ``` -Vite may choose a different frontend port if `3001` is already in use. +Vite may choose a different frontend port if `3002` is already in use. ## Environment diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 7d1e7d6..fdbfe87 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -1,5 +1,8 @@ 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) => { @@ -19,6 +22,8 @@ const normalizeKey = (value) => normalizeText(value) .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); +const normalizeSku = (value) => normalizeText(value).toUpperCase(); + const mapReceipt = (row) => ({ id: row.id, category: row.category, @@ -187,6 +192,268 @@ const buildPurchaseNeeds = (plans, lots, receipts) => { }); }; +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(stock.saldo, 0)::numeric AS stock_quantity + FROM period_orders + FULL OUTER JOIN stock ON stock.produto_id = period_orders.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 + 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; +}; + +const mergeNeedLine = (needsByMaterial, key, patch) => { + const current = needsByMaterial.get(key) || { + material: 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] = await Promise.all([ + listProjectDemandRows(), + listConsumptionReferenceRows() + ]); + const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference])); + const needsByMaterial = new Map(); + + 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 reference = referencesBySku.get(productId); + if (!reference) { + 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; + } + + 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, + color: reference.color, + product: { + productId, + name: normalizeText(row.product_name), + suggestedQuantity, + quantitySold, + stockQuantity + } + }); + 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; + }); + + 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; + }); + + 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), + 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; + current.stockKg += need.stockKg; + current.pendingKg += need.pendingKg; + current.purchaseKg += need.purchaseKg; + 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 => ({ + ...need, + 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 @@ -209,7 +476,9 @@ const getSupplySummary = async () => { listMovements(), listFabricPlans() ]); - const purchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts); + const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts); + const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts); + const purchaseNeeds = mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds); return { receipts, @@ -228,7 +497,10 @@ const listPurchaseNeeds = async () => { listReceipts() ]); - return buildPurchaseNeeds(plans, lots, receipts); + return mergePurchaseNeeds( + buildPurchaseNeeds(plans, lots, receipts), + await buildProjectPurchaseNeeds(lots, receipts) + ); }; const createReceipt = async (payload) => { @@ -513,6 +785,8 @@ module.exports = { deleteFabricPlan, deleteReceipt, getSupplySummary, + buildProjectPurchaseNeeds, + buildPurchaseNeeds, listFabricPlans, listLots, listMovements, diff --git a/docker-compose.yml b/docker-compose.yml index 18057a2..7a42097 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest container_name: graph_frontend ports: - - "3005:80" + - "3002:80" depends_on: - backend restart: unless-stopped diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index 9d4e2a0..5c074b6 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -1124,6 +1124,24 @@ const purchaseStatusLabels: Record = { ok: 'Coberto', }; +const getNeedUnit = (need: SupplyPurchaseNeed) => need.unit || 'kg'; + +const formatNeedQuantity = (need: SupplyPurchaseNeed, value: number) => ( + `${formatNumber(value)} ${getNeedUnit(need)}` +); + +const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => { + const totalsByUnit = needs.reduce>((totals, need) => { + if (need.purchaseKg <= 0) return totals; + const unit = getNeedUnit(need); + totals[unit] = (totals[unit] || 0) + need.purchaseKg; + return totals; + }, {}); + + const summaries = Object.entries(totalsByUnit).map(([unit, total]) => `${formatNumber(total)} ${unit}`); + return summaries.length ? summaries.join(' + ') : '0 kg'; +}; + const PurchaseNeedsScreen = () => { const [summary, setSummary] = useState(emptySupplySummary); const [isLoading, setIsLoading] = useState(true); @@ -1159,17 +1177,19 @@ const PurchaseNeedsScreen = () => { !normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch) )); const purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length; - const suggestedPurchaseKg = summary.purchaseNeeds.reduce((total, need) => total + need.purchaseKg, 0); + const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds); const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size; + const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length; return (
-
+
{[ { label: 'Itens a comprar', value: `${purchaseItemCount}` }, - { label: 'Compra sugerida', value: `${formatNumber(suggestedPurchaseKg)} kg` }, - { label: 'Fornecedores envolvidos', value: `${pendingSupplierCount}` }, + { label: 'Compra sugerida', value: suggestedPurchaseSummary }, + { label: 'Fornecedores', value: `${pendingSupplierCount}` }, + { label: 'Sem referência', value: `${missingReferenceCount}` }, ].map(stat => (

{stat.label}

@@ -1193,10 +1213,12 @@ const PurchaseNeedsScreen = () => { estoque_kg: need.stockKg, pendente_kg: need.pendingKg, comprar_kg: need.purchaseKg, + unidade: getNeedUnit(need), prioridade: need.priority, - cobertura: purchaseStatusLabels[need.status], + cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status], fornecedores: need.suppliers.join(' | '), cores: need.colors.join(' | '), + produtos: (need.products || []).map(product => `${product.productId} ${product.name}`).join(' | '), })))} className={buttonClassName} > @@ -1229,21 +1251,31 @@ const PurchaseNeedsScreen = () => {

{need.material}

- {(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · {(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')} + {need.missingReference + ? 'Cadastre produto/material em Cadastros > Referência de Consumo' + : `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`}

+ {need.products?.length ? ( +

+ {need.products.slice(0, 2).map(product => product.productId).join(', ')} + {need.products.length > 2 ? ` +${need.products.length - 2}` : ''} +

+ ) : null}
-

{formatNumber(need.plannedKg)} kg

-

{formatNumber(need.stockKg)} kg

-

{formatNumber(need.pendingKg)} kg

-

0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNumber(need.purchaseKg)} kg

+

{formatNeedQuantity(need, need.plannedKg)}

+

{formatNeedQuantity(need, need.stockKg)}

+

{formatNeedQuantity(need, need.pendingKg)}

+

0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}

- {purchaseStatusLabels[need.status]} + {need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
))} diff --git a/src/types.ts b/src/types.ts index 265f043..8137095 100644 --- a/src/types.ts +++ b/src/types.ts @@ -232,6 +232,17 @@ export interface SupplyPurchaseNeed { status: 'critical' | 'attention' | 'ok'; suppliers: string[]; colors: string[]; + unit?: string; + source?: string; + missingReference?: boolean; + products?: Array<{ + productId: string; + name: string; + suggestedQuantity: number; + quantitySold: number; + stockQuantity: number; + yieldPerKg?: number; + }>; } export interface SupplyStats {