From e993bfdaf36de3a11780a4539f626a0f59e43730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 3 Aug 2026 10:22:39 -0300 Subject: [PATCH] Use Tiny compositions for purchase planning --- backend/routes/analyticsRoutes.js | 10 ++ backend/services/dataHealthService.js | 85 ++++++++++++++ backend/services/supplyService.js | 154 ++++++++++++++++++++++++-- src/App.tsx | 2 + src/components/Layout.tsx | 3 +- src/dataService.ts | 25 ++++- src/pages/Cutting.tsx | 76 +++++++++++-- src/pages/DataHealth.tsx | 107 ++++++++++++++++++ src/pages/ProductDetails.tsx | 77 +++++++++++-- src/pages/Supplies.tsx | 23 +++- src/types.ts | 22 ++++ 11 files changed, 544 insertions(+), 40 deletions(-) create mode 100644 backend/services/dataHealthService.js create mode 100644 src/pages/DataHealth.tsx diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index 5cbfe47..3c0f3cd 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -11,6 +11,7 @@ const { getRfmAnalytics } = require('../services/analyticsService'); const { getProductComposition, listProductCompositions } = require('../services/productionOrderService'); +const { getDataHealthSummary } = require('../services/dataHealthService'); const router = express.Router(); @@ -78,6 +79,15 @@ router.get('/analytics/product-compositions', verifyToken, async (req, res) => { } }); +router.get('/analytics/data-health', verifyToken, async (req, res) => { + try { + res.json(await getDataHealthSummary()); + } catch (error) { + console.error('Error fetching data health:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + router.get('/analytics/clients', verifyToken, async (req, res) => { try { res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query))); diff --git a/backend/services/dataHealthService.js b/backend/services/dataHealthService.js new file mode 100644 index 0000000..07dfbd5 --- /dev/null +++ b/backend/services/dataHealthService.js @@ -0,0 +1,85 @@ +const { pool } = require('../db'); + +const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); +const normalizeSku = (value) => normalizeText(value).toUpperCase(); +const isRawOrService = (value) => /\b(?:MALHA|RIBANA|FIO|TECIDO|SERVICO|SERVIÇO|TINTURARIA|TECELAGEM|FRETE)\b/i.test(value); + +const getDataHealthSummary = async () => { + const [productsResult, compositionsResult, componentsResult, stockResult] = await Promise.all([ + pool.query(` + SELECT produto_id AS id, MAX(NULLIF(produto_descricao, '')) AS name FROM orders GROUP BY produto_id + UNION + SELECT produto_id AS id, MAX(NULLIF(nome, '')) AS name FROM stock GROUP BY produto_id; + `), + pool.query(` + SELECT id, finished_tiny_product_id, finished_product_sku, finished_product_description, finished_product_unit + FROM product_compositions WHERE source = 'tiny_olist_v3'; + `), + pool.query(` + SELECT product_composition_id, component_tiny_id, component_sku, component_name, quantity_per_unit, unit + FROM product_composition_components; + `), + pool.query(`SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo FROM stock;`) + ]); + + const products = Array.from(new Map(productsResult.rows.map(row => [normalizeSku(row.id), row])).values()); + const compositions = compositionsResult.rows; + const components = componentsResult.rows; + const stock = stockResult.rows; + const stockKeys = new Set(stock.flatMap(item => [normalizeSku(item.produto_id), normalizeSku(item.nome)]).filter(Boolean)); + const compositionKeys = new Set(compositions.flatMap(item => [normalizeSku(item.finished_tiny_product_id), normalizeSku(item.finished_product_sku)]).filter(Boolean)); + const productKeys = new Set(products.map(item => normalizeSku(item.id)).filter(Boolean)); + const linkedComponentKeys = new Set([...stockKeys, ...productKeys]); + const materialKeys = new Set(components.map(item => normalizeSku(item.component_tiny_id) || normalizeSku(item.component_sku) || normalizeSku(item.component_name)).filter(Boolean)); + const materialStockKeys = new Set([...materialKeys].filter(key => stockKeys.has(key))); + const rowsByComposition = new Map(compositions.map(item => [Number(item.id), item])); + const issues = []; + const addIssue = (type, severity, title, detail, productSku = '', component = '') => { + issues.push({ type, severity, title, detail, productSku, component }); + }; + + compositions.filter(row => !normalizeText(row.finished_product_sku)).forEach(row => { + addIssue('missing_product_sku', 'critical', 'Produto sem SKU', normalizeText(row.finished_product_description) || 'Composição sem descrição', '', ''); + }); + components.filter(row => { + const key = normalizeSku(row.component_tiny_id) || normalizeSku(row.component_sku) || normalizeSku(row.component_name); + return !key || !linkedComponentKeys.has(key); + }).forEach(row => { + const parent = rowsByComposition.get(Number(row.product_composition_id)); + addIssue('component_without_stock_link', 'attention', 'Componente sem vínculo de estoque', normalizeText(row.component_name) || 'Componente sem nome', normalizeText(parent?.finished_product_sku), normalizeText(row.component_sku || row.component_tiny_id)); + }); + components.filter(row => { + const quantity = Number(row.quantity_per_unit || 0); + return !Number.isFinite(quantity) || quantity <= 0 || quantity > 100 || !normalizeText(row.unit); + }).forEach(row => { + const parent = rowsByComposition.get(Number(row.product_composition_id)); + addIssue('suspicious_quantity', 'attention', 'Quantidade suspeita', `${normalizeText(row.component_name)} · ${row.quantity_per_unit || 0} ${normalizeText(row.unit) || '(sem unidade)'}`, normalizeText(parent?.finished_product_sku), normalizeText(row.component_sku || row.component_tiny_id)); + }); + compositions.filter(row => isRawOrService(row.finished_product_description) || normalizeText(row.finished_product_unit).toLowerCase() !== 'un').forEach(row => { + addIssue('raw_or_service_structure', 'info', 'Estrutura de matéria-prima/serviço', normalizeText(row.finished_product_description), normalizeText(row.finished_product_sku), ''); + }); + products.filter(row => !compositionKeys.has(normalizeSku(row.id))).forEach(row => { + addIssue('product_without_composition', 'attention', 'Produto sem composição', normalizeText(row.name) || normalizeText(row.id), normalizeText(row.id), ''); + }); + + const productsWithComposition = products.filter(row => compositionKeys.has(normalizeSku(row.id))).length; + const productsWithStockLink = products.filter(row => stockKeys.has(normalizeSku(row.id))).length; + + return { + totals: { + products: productKeys.size, + productsWithComposition, + productsWithStockLink, + materials: materialKeys.size, + materialsWithStock: materialStockKeys.size, + compositions: compositions.length, + components: components.length + }, + issues: issues.sort((a, b) => { + const severityOrder = { critical: 0, attention: 1, info: 2 }; + return severityOrder[a.severity] - severityOrder[b.severity] || a.title.localeCompare(b.title); + }) + }; +}; + +module.exports = { getDataHealthSummary }; diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 67d4b5d..70ced53 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -269,6 +269,37 @@ const listConsumptionReferenceRows = async () => { 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 = 'tiny_olist_v3' + ORDER BY composition.id, component.id; + `); + return result.rows; +}; + +const listTinyStockRows = async () => { + const result = await pool.query(` + SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo + FROM stock + WHERE COALESCE(produto_id, '') <> '' OR COALESCE(nome, '') <> ''; + `); + return result.rows; +}; + const mergeNeedLine = (needsByMaterial, key, patch) => { const current = needsByMaterial.get(key) || { material: patch.material, @@ -302,9 +333,11 @@ const mergeNeedLine = (needsByMaterial, key, patch) => { }; const buildProjectPurchaseNeeds = async (lots, receipts) => { - const [demandRows, referenceRows] = await Promise.all([ + const [demandRows, referenceRows, compositionRows, tinyStockRows] = await Promise.all([ listProjectDemandRows(), - listConsumptionReferenceRows() + listConsumptionReferenceRows(), + listCompositionRows(), + listTinyStockRows() ]); const referencesBySku = referenceRows.reduce((references, reference) => { const sku = normalizeSku(reference.product_sku); @@ -314,6 +347,70 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { }, 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, + 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; @@ -324,6 +421,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { 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}`, { @@ -410,14 +513,32 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { }); }); + 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 = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`); + const need = getNeedForInventory(lot.unit, '', lot.product) + || needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`); if (need) need.stockKg += lot.quantity; }); receipts.forEach(receipt => { if (receipt.status !== 'pending') return; - const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`); + const need = getNeedForInventory(receipt.unit, '', receipt.product) + || needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`); if (need) need.pendingKg += receipt.quantity; }); @@ -462,9 +583,11 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => { } current.plannedKg += need.plannedKg; - current.stockKg += need.stockKg; - current.pendingKg += need.pendingKg; - current.purchaseKg += need.purchaseKg; + // 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' @@ -483,11 +606,18 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => { }); return Array.from(mergedByKey.values()) - .map(need => ({ - ...need, - suppliers: Array.from(need.suppliers), - colors: Array.from(need.colors) - })) + .map(need => { + const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0); + return { + ...need, + purchaseKg, + status: purchaseKg > 0 + ? (need.priority === 'Crítico' || need.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); diff --git a/src/App.tsx b/src/App.tsx index a160230..fb55b89 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails const Replenishment = React.lazy(() => import('./pages/Replenishment')); const Cutting = React.lazy(() => import('./pages/Cutting')); const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues')); +const DataHealth = React.lazy(() => import('./pages/DataHealth')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const Supplies = React.lazy(() => import('./pages/Supplies')); const Clients = React.lazy(() => import('./pages/Clients')); @@ -56,6 +57,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index ddde5d5..9efee90 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList } from 'lucide-react'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Database } from 'lucide-react'; import type { DateRange, OrderData } from '../types'; import { isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; @@ -74,6 +74,7 @@ const Layout = () => { items: [ { name: 'Produtos', href: '/products', icon: Package }, { name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList }, + { name: 'Saúde dos Dados', href: '/data-health', icon: Database }, { name: 'Cadastros', href: '/registrations', icon: Tags }, { name: 'Suprimentos', href: '/supplies', icon: Boxes }, ], diff --git a/src/dataService.ts b/src/dataService.ts index 8631139..b867004 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DataHealthSummary, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -567,6 +567,29 @@ export const fetchProductComposition = async (productId: string): Promise => { + try { + const response = await authFetch('/analytics/product-compositions'); + if (!response.ok) return []; + const data = await response.json() as { compositions?: ProductComposition[] }; + return data.compositions || []; + } catch (error) { + console.error('Fetch product compositions failed', error); + return []; + } +}; + +export const fetchDataHealth = async (): Promise => { + try { + const response = await authFetch('/analytics/data-health'); + if (!response.ok) return null; + return await response.json() as DataHealthSummary; + } catch (error) { + console.error('Fetch data health failed', error); + return null; + } +}; + export const exportProductCompositions = async (): Promise => { const response = await authFetch('/analytics/product-compositions'); const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null; diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 25073fa..02dd124 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -7,8 +7,9 @@ import ProductColorBadge from '../components/ProductColorBadge'; import RefreshStatus from '../components/RefreshStatus'; import { buildCuttingSkuConfigPath } from '../catalogLinks'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; -import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService'; -import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; +import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService'; +import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types'; +import { getPlanningStock } from '../planningStock'; type CutFilter = 'need' | 'all' | 'issues' | 'covered'; type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc'; @@ -17,6 +18,7 @@ type CutProductIssue = Exclude; type CorrectionIssueFilter = CutProductIssue | 'all'; type SettingsSection = 'rules' | 'corrections'; type CuttingView = 'plan' | 'families' | 'issues'; +type MaterialReadiness = { status: 'ready' | 'blocked' | 'missing'; blockers: string[] }; const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings'; const coverageTargetOptions = [7, 15, 30, 60]; @@ -66,6 +68,13 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => ( new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value) ); +const normalizeMaterialKey = (value: string) => value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, ' ') + .trim() + .toUpperCase(); + const formatDateKey = (date: Date) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); @@ -128,6 +137,8 @@ const Cutting = () => { const [searchParams] = useSearchParams(); const [products, setProducts] = useState([]); const [productionOrders, setProductionOrders] = useState([]); + const [compositions, setCompositions] = useState([]); + const [materialStock, setMaterialStock] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [targetCoverageDays, setTargetCoverageDays] = useState(30); @@ -181,13 +192,17 @@ const Cutting = () => { const loadProducts = async () => { setIsLoading(true); - const [productData, productionOrderData] = await Promise.all([ + const [productData, productionOrderData, compositionData, stockData] = await Promise.all([ fetchProductAnalytics(dateRange), - fetchProductionOrders(allProductionOrdersRange) + fetchProductionOrders(allProductionOrdersRange), + fetchProductCompositions(), + fetchStock() ]); if (isMounted) { setProducts(productData); setProductionOrders(productionOrderData.orders); + setCompositions(compositionData); + setMaterialStock(stockData); setIsLoading(false); } }; @@ -209,6 +224,35 @@ const Cutting = () => { [cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays] ); + const materialReadinessByProductId = useMemo(() => { + const compositionsById = new Map(); + compositions.forEach(composition => { + [composition.finishedTinyProductId, composition.finishedProductSku] + .map(normalizeMaterialKey) + .filter(Boolean) + .forEach(key => compositionsById.set(key, composition)); + }); + const readinessByProductId = new Map(); + cutPlan.rows.forEach(row => { + const composition = compositionsById.get(normalizeMaterialKey(row.id)); + if (!composition?.components.length) { + readinessByProductId.set(row.id, { status: 'missing', blockers: [] }); + return; + } + const blockers = composition.components.flatMap(component => { + const stockKeys = new Set([component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean)); + const match = materialStock.find(stock => stockKeys.has(normalizeMaterialKey(stock.produto_id)) || stockKeys.has(normalizeMaterialKey(stock.nome))); + const available = match ? getPlanningStock(match.saldo) : null; + const required = row.suggestedCutQuantity * component.quantityPerUnit; + return available === null || available < required + ? [`${component.componentName}${available === null ? ' (sem estoque vinculado)' : ` (${formatNumber(available)} / ${formatNumber(required)} ${component.unit || ''})`}`] + : []; + }); + readinessByProductId.set(row.id, { status: blockers.length ? 'blocked' : 'ready', blockers }); + }); + return readinessByProductId; + }, [compositions, cutPlan.rows, materialStock]); + const issueSummaries = useMemo(() => { const counts = new Map(); cutPlan.needRows.forEach(row => { @@ -254,8 +298,8 @@ const Cutting = () => { }, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]); const rowsAvailableForOrder = useMemo(() => ( - filteredRows.filter(row => row.suggestedCutQuantity > 0) - ), [filteredRows]); + filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready') + ), [filteredRows, materialReadinessByProductId]); const orderPreviewTotalUnits = useMemo(() => ( rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0) @@ -420,13 +464,15 @@ const Cutting = () => { 'Rendimento un/rolo': row.family.unitsPerRoll || '', 'Rolos estimados': row.estimatedRolls || '', 'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','), + 'Pronto para cortar': materialReadinessByProductId.get(row.id)?.status === 'ready' ? 'Sim' : materialReadinessByProductId.get(row.id)?.status === 'blocked' ? 'Não - falta material' : 'Sem composição', + 'Materiais bloqueando': materialReadinessByProductId.get(row.id)?.blockers.join(' | ') || '', 'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ') })), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`); }; const openProductionOrderPreview = () => { if (!rowsAvailableForOrder.length) { - setGenerationMessage('Não há necessidade de corte no filtro atual.'); + setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.'); return; } @@ -436,7 +482,7 @@ const Cutting = () => { const generateProductionOrders = async () => { if (!rowsAvailableForOrder.length) { - setGenerationMessage('Não há necessidade de corte no filtro atual.'); + setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.'); return; } @@ -459,7 +505,7 @@ const Cutting = () => { markers: [ { label: row.family.materialLabel, color: '#38bdf8' }, ...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []), - { label: 'Material pendente', color: '#facc15' }, + { label: 'Material confirmado', color: '#52DFA0' }, ...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : []) ], metadata: { @@ -509,6 +555,13 @@ const Cutting = () => { ); }; + const renderMaterialReadiness = (row: CutPlanSkuRow) => { + const readiness = materialReadinessByProductId.get(row.id); + if (readiness?.status === 'ready') return Pode cortar; + if (readiness?.status === 'missing') return Sem composição; + return Falta: {readiness?.blockers[0] || 'material'}; + }; + return (
@@ -1209,7 +1262,7 @@ const Cutting = () => { ) : (
- +
@@ -1220,6 +1273,7 @@ const Cutting = () => { + @@ -1232,6 +1286,7 @@ const Cutting = () => { + +
Estoque Necessidade RolosMateriais { {row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)} {renderMaterialReadiness(row)} {renderIssueBadge(row)}
diff --git a/src/pages/DataHealth.tsx b/src/pages/DataHealth.tsx new file mode 100644 index 0000000..b7d1e99 --- /dev/null +++ b/src/pages/DataHealth.tsx @@ -0,0 +1,107 @@ +import { useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, CheckCircle2, Database, PackageSearch, RefreshCw } from 'lucide-react'; +import PaginationControls from '../components/PaginationControls'; +import { fetchDataHealth } from '../dataService'; +import type { DataHealthIssue, DataHealthSummary } from '../types'; + +const emptySummary: DataHealthSummary = { + totals: { products: 0, productsWithComposition: 0, productsWithStockLink: 0, materials: 0, materialsWithStock: 0, compositions: 0, components: 0 }, + issues: [], +}; + +const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR').format(value); +const percent = (part: number, total: number) => total ? Math.round((part / total) * 100) : 0; +const severityStyle: Record = { + critical: 'border-red-400/30 bg-red-400/10 text-red-300', + attention: 'border-amber-400/30 bg-amber-400/10 text-amber-300', + info: 'border-sky-400/30 bg-sky-400/10 text-sky-300', +}; + +const DataHealth = () => { + const [summary, setSummary] = useState(emptySummary); + const [isLoading, setIsLoading] = useState(true); + const [filter, setFilter] = useState<'all' | DataHealthIssue['type']>('all'); + const [search, setSearch] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(20); + + const load = async () => { + setIsLoading(true); + const next = await fetchDataHealth(); + if (next) setSummary(next); + setIsLoading(false); + }; + + useEffect(() => { + const timer = window.setTimeout(() => { void load(); }, 0); + return () => window.clearTimeout(timer); + }, []); + + const filteredIssues = useMemo(() => { + const query = search.trim().toLowerCase(); + return summary.issues.filter(issue => ( + (filter === 'all' || issue.type === filter) && + (!query || `${issue.title} ${issue.detail} ${issue.productSku} ${issue.component}`.toLowerCase().includes(query)) + )); + }, [filter, search, summary.issues]); + const totalPages = Math.ceil(filteredIssues.length / itemsPerPage); + const safePage = Math.min(currentPage, totalPages || 1); + const startIndex = (safePage - 1) * itemsPerPage; + const visibleIssues = filteredIssues.slice(startIndex, startIndex + itemsPerPage); + const count = (type: DataHealthIssue['type']) => summary.issues.filter(issue => issue.type === type).length; + const metrics = [ + { label: 'Produtos com composição', value: percent(summary.totals.productsWithComposition, summary.totals.products), detail: `${formatNumber(summary.totals.productsWithComposition)} de ${formatNumber(summary.totals.products)}` }, + { label: 'Produtos com estoque', value: percent(summary.totals.productsWithStockLink, summary.totals.products), detail: `${formatNumber(summary.totals.productsWithStockLink)} de ${formatNumber(summary.totals.products)}` }, + { label: 'Materiais com estoque', value: percent(summary.totals.materialsWithStock, summary.totals.materials), detail: `${formatNumber(summary.totals.materialsWithStock)} de ${formatNumber(summary.totals.materials)}` }, + ]; + + return ( +
+
+
+

Saúde dos Dados

+

Confira se composição, vínculo de estoque e consumo estão prontos para orientar compra e corte.

+
+ +
+ +
+ {metrics.map(metric => ( +
+

{metric.label}

+

{metric.value}%

{metric.detail}

+
+
+ ))} +
+ +
+ {[ + ['missing_product_sku', 'Sem SKU'], ['component_without_stock_link', 'Sem vínculo'], ['suspicious_quantity', 'Qtd. suspeita'], ['raw_or_service_structure', 'Matéria-prima/serviço'], ['product_without_composition', 'Sem composição'], + ].map(([type, label]) => ( + + ))} +
+ +
+
+ { setSearch(event.target.value); setCurrentPage(1); }} placeholder="Buscar produto, SKU ou componente..." className="h-10 flex-1 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" /> + +
+ {isLoading ?
Verificando dados…
: !visibleIssues.length ?
Nenhuma pendência neste filtro.
: ( + <>
{visibleIssues.map((issue, index) =>

{issue.title}

{issue.severity === 'critical' ? 'Crítico' : issue.severity === 'attention' ? 'Revisar' : 'Informativo'}

{issue.detail}

{(issue.productSku || issue.component) &&

{[issue.productSku && `SKU ${issue.productSku}`, issue.component && `Comp. ${issue.component}`].filter(Boolean).join(' · ')}

}
)}
+ { setItemsPerPage(size); setCurrentPage(1); }} className="border-t border-dark-border px-4 py-3" /> + )} +
+
+ ); +}; + +export default DataHealth; diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index f7b46f1..301db40 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -7,8 +7,8 @@ import DateRangePicker from '../components/DateRangePicker'; import SkuPlanningModal from '../components/SkuPlanningModal'; import ProductTypeBadge from '../components/ProductTypeBadge'; import RefreshStatus from '../components/RefreshStatus'; -import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types'; -import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService'; +import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics, StockData } from '../types'; +import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, fetchStock, saveCuttingSettings } from '../dataService'; import { parseProductName } from '../productParsing'; import { formatColorLabel } from '../displayFormatters'; import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils'; @@ -132,6 +132,7 @@ const ProductDetails = () => { }>(); const [details, setDetails] = useState(null); const [composition, setComposition] = useState(null); + const [materialStock, setMaterialStock] = useState([]); const [isCompositionLoading, setIsCompositionLoading] = useState(true); const [isLoading, setIsLoading] = useState(true); const [chartMetric, setChartMetric] = useState('quantity'); @@ -171,14 +172,16 @@ const ProductDetails = () => { setIsLoading(true); setIsCompositionLoading(true); - const [productDetails, productComposition] = await Promise.all([ + const [productDetails, productComposition, stock] = await Promise.all([ fetchProductDetailsAnalytics(id, dateRange), - fetchProductComposition(id) + fetchProductComposition(id), + fetchStock() ]); if (isMounted) { setDetails(productDetails); setComposition(productComposition); + setMaterialStock(stock); setIsLoading(false); setIsCompositionLoading(false); } @@ -331,6 +334,35 @@ const ProductDetails = () => { : formatDateBucketLongLabel(selectedProductPoint.date, dateBucket) : ''; const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0); + const normalizeMaterialKey = (value: string) => value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, ' ') + .trim() + .toUpperCase(); + const materialPlan = (composition?.components || []).map(component => { + const candidates = new Set([ + component.componentTinyId, + component.componentSku, + component.productId || '', + component.componentName + ].map(normalizeMaterialKey).filter(Boolean)); + const stockMatch = materialStock.find(item => ( + candidates.has(normalizeMaterialKey(item.produto_id)) || candidates.has(normalizeMaterialKey(item.nome)) + )); + const availableStock = stockMatch ? getPlanningStock(stockMatch.saldo) : null; + const requiredForPeriod = totalSold * component.quantityPerUnit; + const productionCapacity = availableStock === null || component.quantityPerUnit <= 0 + ? null + : Math.floor(availableStock / component.quantityPerUnit); + const periodCoverage = requiredForPeriod > 0 && availableStock !== null + ? (availableStock / requiredForPeriod) * periodDays + : null; + return { component, availableStock, requiredForPeriod, productionCapacity, periodCoverage }; + }); + const blockingMaterial = materialPlan + .filter(item => item.productionCapacity !== null) + .sort((a, b) => (a.productionCapacity || 0) - (b.productionCapacity || 0))[0]; return (
@@ -414,12 +446,23 @@ const ProductDetails = () => {
-
-

Composição

- {composition && ( -

- Para produzir 1 UN de {composition.finishedProductSku || productInfo.id} -

+
+
+

Composição

+ {composition && ( +

+ Necessidade para as {formatNumber(totalSold)} un. vendidas no período e saldo atual do Tiny. +

+ )} +
+ {blockingMaterial && ( +
+ Limitante: {blockingMaterial.component.componentName} · {formatNumber(blockingMaterial.productionCapacity || 0)} un. possíveis +
)}
{isCompositionLoading ? ( @@ -434,17 +477,20 @@ const ProductDetails = () => {
) : (
- +
+ + + - {composition.components.map(component => ( + {materialPlan.map(({ component, requiredForPeriod, availableStock, productionCapacity, periodCoverage }) => ( + + + ))} diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index 5e5a2cb..d7c8754 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -1997,8 +1997,9 @@ const PurchaseNeedsScreen = () => {
Produto / insumo SKU Quantidade por unidade UnidadeNecessário no períodoEstoque TinyCobertura
{component.productId ? ( @@ -456,6 +502,13 @@ const ProductDetails = () => { {component.componentSku || '—'} {formatNumber(component.quantityPerUnit)} {component.unit || '—'}{formatNumber(requiredForPeriod)} {component.unit || ''}{availableStock === null ? 'Sem vínculo' : `${formatNumber(availableStock)} ${component.unit || ''}`} + {availableStock === null + ? 'Revisar link' + : `${formatNumber(productionCapacity || 0)} un. · ${periodCoverage === null ? '-' : `${formatNumber(periodCoverage)} dias`}`} +
) : ( - +
+ @@ -2008,9 +2009,10 @@ const PurchaseNeedsScreen = () => { - - - + + + + @@ -2021,6 +2023,19 @@ const PurchaseNeedsScreen = () => { const referenceProduct = need.products?.[0]; return ( +
MaterialPlanejadoEstoqueDemanda de produtoMaterial necessárioNecessárioEstoque atual Pendente Comprar Cobertura
+ {need.products?.length ? ( +
+ {need.products.slice(0, 2).map(product => ( +
+

{product.name || product.productId}

+

SKU {product.productId} · demanda {formatNumber(product.suggestedQuantity)} un.

+
+ ))} + {need.products.length > 2 &&

+{need.products.length - 2} SKUs impactados

} +
+ ) : Plano manual} +

{getNeedDisplayName(need)}

diff --git a/src/types.ts b/src/types.ts index 296ef0f..62a1139 100644 --- a/src/types.ts +++ b/src/types.ts @@ -517,6 +517,28 @@ export interface ProductCompositionImportSummary { }>; } +export interface DataHealthIssue { + type: 'missing_product_sku' | 'component_without_stock_link' | 'suspicious_quantity' | 'raw_or_service_structure' | 'product_without_composition'; + severity: 'critical' | 'attention' | 'info'; + title: string; + detail: string; + productSku: string; + component: string; +} + +export interface DataHealthSummary { + totals: { + products: number; + productsWithComposition: number; + productsWithStockLink: number; + materials: number; + materialsWithStock: number; + compositions: number; + components: number; + }; + issues: DataHealthIssue[]; +} + export interface ClientAnalyticsItem { customerKey: string; clientToken: string;