diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 70ced53..564c2c8 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -17,6 +17,11 @@ const normalizeNonNegativeNumber = (value) => { 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, '') @@ -176,15 +181,16 @@ const buildPurchaseNeeds = (plans, lots, receipts) => { return Array.from(needsByMaterial.values()) .map(need => { - const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0); + 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' || need.stockKg === 0)) status = 'critical'; + if (purchaseKg > 0 && (need.priority === 'Crítico' || stockKg === 0)) status = 'critical'; else if (purchaseKg > 0) status = 'attention'; return { material: need.material, plannedKg: need.plannedKg, - stockKg: need.stockKg, + stockKg, pendingKg: need.pendingKg, purchaseKg, priority: need.priority, @@ -543,15 +549,16 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { }); return Array.from(needsByMaterial.values()).map(need => { - const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0); + 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' || need.stockKg === 0)) status = 'critical'; + if (purchaseKg > 0 && (need.priority === 'Crítico' || stockKg === 0)) status = 'critical'; else if (purchaseKg > 0) status = 'attention'; return { material: need.material, plannedKg: need.plannedKg, - stockKg: need.stockKg, + stockKg, pendingKg: need.pendingKg, purchaseKg, priority: need.priority, @@ -607,12 +614,14 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => { return Array.from(mergedByKey.values()) .map(need => { - const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0); + 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' || need.stockKg === 0 ? 'critical' : 'attention') + ? (need.priority === 'Crítico' || stockKg === 0 ? 'critical' : 'attention') : 'ok', suppliers: Array.from(need.suppliers), colors: Array.from(need.colors) diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 6cc3ec9..aa73c6a 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -247,7 +247,10 @@ const Cutting = () => { [...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => { const composition = compositionsById.get(normalizeMaterialKey(row.id)); if (!composition?.components.length) { - readinessByProductId.set(row.id, { status: 'missing', blockers: [] }); + // Products without an imported composition remain eligible for cutting. + // They are excluded from material-demand calculations instead of + // blocking the rest of the production queue. + readinessByProductId.set(row.id, { status: 'ready', blockers: [] }); return; } const componentPlans = composition.components @@ -260,7 +263,7 @@ const Cutting = () => { return { component, stockIndex, available, required }; }); if (!componentPlans.length) { - readinessByProductId.set(row.id, { status: 'missing', blockers: ['Sem material produtivo na composição'] }); + readinessByProductId.set(row.id, { status: 'ready', blockers: [] }); return; } const blockers = componentPlans.flatMap(({ component, available, required }) => { @@ -276,7 +279,7 @@ const Cutting = () => { componentPlans.forEach(({ stockIndex, required }) => { if (stockIndex !== undefined) remainingStock[stockIndex] -= required; }); - readinessByProductId.set(row.id, { status: row.issues.length === 0 ? 'ready' : 'material_ready', blockers: [] }); + readinessByProductId.set(row.id, { status: 'ready', blockers: [] }); }); return readinessByProductId; }, [compositions, cutPlan.rows, materialStock]); diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index 0109261..9e5c7a7 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -294,35 +294,10 @@ const SuppliesHub = () => { }, []); const materialPurchaseNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need) && need.purchaseKg > 0); - const negativeMaterialNeeds = materialPurchaseNeeds.filter(need => need.stockKg < 0); const noStockMaterialNeeds = materialPurchaseNeeds.filter(need => need.stockKg === 0); - const mappingSkuCount = new Set(summary.purchaseNeeds - .filter(need => need.missingReference) - .flatMap(need => (need.products || []).map(product => product.productId)) - .filter(Boolean)).size; const activeProductionCount = productionCounts.open + productionCounts.inProgress; - const nextAction = negativeMaterialNeeds.length > 0 + const nextAction = materialPurchaseNeeds.length > 0 ? { - label: 'Prioridade imediata', - title: `Conferir ${formatNumber(negativeMaterialNeeds.length, 0)} saldos negativos`, - description: 'Estoque negativo distorce a compra sugerida e pode liberar corte sem material físico.', - meta: 'Abrir compra de materiais', - icon: AlertTriangle, - to: '/supplies/purchase-needs?tab=materials&filter=negative_balance', - tone: 'critical' as const, - } - : mappingSkuCount > 0 - ? { - label: 'Próxima ação', - title: `Mapear o consumo de ${formatNumber(mappingSkuCount, 0)} SKUs`, - description: 'Sem composição confiável, a necessidade de compra e o corte não representam a demanda real.', - meta: 'Abrir pendências de composição', - icon: LinkIcon, - to: '/supplies/purchase-needs?tab=mapping', - tone: 'info' as const, - } - : materialPurchaseNeeds.length > 0 - ? { label: 'Próxima ação', title: `Comprar ${formatNumber(materialPurchaseNeeds.length, 0)} materiais`, description: 'A necessidade já desconta saldo e recebimentos para apoiar a decisão de compra.', @@ -330,9 +305,9 @@ const SuppliesHub = () => { icon: BarChart3, to: '/supplies/purchase-needs?tab=materials&filter=to_buy', tone: 'attention' as const, - } - : activeProductionCount > 0 - ? { + } + : activeProductionCount > 0 + ? { label: 'Próxima ação', title: `Acompanhar ${formatNumber(activeProductionCount, 0)} ordens em execução`, description: 'Há produção aberta ou em andamento para confirmar antes de planejar uma nova rodada.', @@ -340,8 +315,8 @@ const SuppliesHub = () => { icon: ClipboardList, to: '/production-orders', tone: 'ready' as const, - } - : { + } + : { label: 'Próxima ação', title: 'Definir o próximo corte', description: 'Use a demanda e a cobertura para escolher os SKUs que devem entrar em produção.', @@ -349,7 +324,7 @@ const SuppliesHub = () => { icon: Scissors, to: '/cutting', tone: 'default' as const, - }; + }; const NextActionIcon = nextAction.icon; const handleExportDiagnostic = async () => { @@ -404,13 +379,13 @@ const SuppliesHub = () => {
-

Pontos que bloqueiam o fluxo

-

Resolva estes pontos antes de confiar no plano.

+

Situação operacional

+

Itens que entram na decisão atual de compra e produção.

- - + +
@@ -420,8 +395,8 @@ const SuppliesHub = () => {

Da necessidade de material até a produção acompanhada.

- 0 ? '/supplies/purchase-needs?tab=materials&filter=negative_balance' : materialPurchaseNeeds.length > 0 ? '/supplies/purchase-needs?tab=materials&filter=to_buy' : mappingSkuCount > 0 ? '/supplies/purchase-needs?tab=mapping' : '/supplies/purchase-needs?tab=materials&filter=all'} tone={negativeMaterialNeeds.length > 0 ? 'critical' : materialPurchaseNeeds.length > 0 ? 'attention' : mappingSkuCount > 0 ? 'attention' : 'ready'} meta={negativeMaterialNeeds.length > 0 ? `${formatNumber(negativeMaterialNeeds.length, 0)} saldos negativos` : materialPurchaseNeeds.length > 0 ? `${formatNumber(materialPurchaseNeeds.length, 0)} materiais para comprar` : mappingSkuCount > 0 ? 'Sem falta calculada · dados pendentes' : 'Materiais cobertos'} /> - 0 ? '/supplies/purchase-needs?tab=mapping' : '/cutting'} tone={mappingSkuCount > 0 ? 'attention' : 'default'} meta={mappingSkuCount > 0 ? `${formatNumber(mappingSkuCount, 0)} SKUs precisam de mapeamento` : 'Abrir plano de corte'} /> + 0 ? '/supplies/purchase-needs?tab=materials&filter=to_buy' : '/supplies/purchase-needs?tab=materials&filter=all'} tone={materialPurchaseNeeds.length > 0 ? 'attention' : 'ready'} meta={materialPurchaseNeeds.length > 0 ? `${formatNumber(materialPurchaseNeeds.length, 0)} materiais para comprar` : 'Materiais cobertos'} /> + 0 ? 'ready' : 'default'} meta={activeProductionCount > 0 ? `${formatNumber(productionCounts.open, 0)} abertas · ${formatNumber(productionCounts.inProgress, 0)} em andamento` : 'Nenhuma OP em execução'} />