diff --git a/src/analytics/cutting.ts b/src/analytics/cutting.ts index 830d192..4bf6410 100644 --- a/src/analytics/cutting.ts +++ b/src/analytics/cutting.ts @@ -1,6 +1,7 @@ import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts'; import { resolveProductType } from '../productClassification.ts'; +import { getPlanningStock } from '../planningStock.ts'; export type { CutFamilyKey, CutProductOverride }; @@ -216,7 +217,7 @@ export const buildCutPlan = ( const dailySales = product.quantitySold / rangeDays; const projectedDemand = dailySales * targetCoverageDays; const openProductionQuantity = openProductionByProductId[product.id] || 0; - const availableQuantity = product.stock + openProductionQuantity; + const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity; const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity)); const estimatedRolls = family.unitsPerRoll && suggestedCutQuantity > 0 ? Math.ceil(suggestedCutQuantity / family.unitsPerRoll) diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index 801ba1b..1cd1df1 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -13,6 +13,7 @@ import { parseProductName } from '../productParsing'; import { formatColorLabel } from '../displayFormatters'; import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils'; import { getProductTypeConfig, resolveProductType } from '../productClassification'; +import { getPlanningStock } from '../planningStock'; const CHART_GRID_COLOR = 'var(--chart-grid)'; const CHART_AXIS_COLOR = 'var(--chart-axis)'; @@ -229,7 +230,7 @@ const ProductDetails = () => { const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange); const periodDays = getRangeDayCount(dateRange); const dailyAverageSold = totalSold / periodDays; - const projectedStockDays = dailyAverageSold > 0 ? productInfo.stock / dailyAverageSold : null; + const projectedStockDays = dailyAverageSold > 0 ? getPlanningStock(productInfo.stock) / dailyAverageSold : null; const stockActionLabel = projectedStockDays === null ? 'Sem venda no perĂ­odo' : projectedStockDays <= 7 diff --git a/src/pages/ProductGroupDetails.tsx b/src/pages/ProductGroupDetails.tsx index 2546a70..3a6a005 100644 --- a/src/pages/ProductGroupDetails.tsx +++ b/src/pages/ProductGroupDetails.tsx @@ -14,6 +14,7 @@ import { decodeProductGroupKey, normalizeProductText, parseProductName } from '. import { formatColorLabel } from '../displayFormatters'; import { getProductColor } from '../productColors'; import { getDominantProductType, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification'; +import { getPlanningStock } from '../planningStock'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; type VariantRow = ProductAnalyticsItem & { @@ -263,7 +264,7 @@ const ProductGroupDetails = () => { const dailySales = product.quantitySold / rangeDays; const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS; const openProductionQuantity = openProductionByProductId[product.id] || 0; - const availableQuantity = product.stock + openProductionQuantity; + const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity; const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity)); return { diff --git a/src/pages/Products.tsx b/src/pages/Products.tsx index f281120..240fbdb 100644 --- a/src/pages/Products.tsx +++ b/src/pages/Products.tsx @@ -11,6 +11,7 @@ import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSe import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { formatColorLabel } from '../displayFormatters'; import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification'; +import { getPlanningStock } from '../planningStock'; type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales'; type StockStatusFilter = 'all' | StockRisk; @@ -262,7 +263,8 @@ const Products = () => { const normalizedSearch = searchTerm.trim().toLowerCase(); const skuRows = productAnalytics.map(product => { const dailySales = product.quantitySold / days; - const risk = classifyStockRisk(product.stock, dailySales); + const planningStock = getPlanningStock(product.stock); + const risk = classifyStockRisk(planningStock, dailySales); const style = riskStyles[risk]; const metadata = parseProductName(product.name); const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]); @@ -270,7 +272,7 @@ const Products = () => { return { ...product, dailySales, - daysOfCover: dailySales > 0 ? product.stock / dailySales : null, + daysOfCover: dailySales > 0 ? planningStock / dailySales : null, risk, riskLabel: style.label, baseName: metadata.baseName, @@ -302,10 +304,11 @@ const Products = () => { const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0); const revenue = group.reduce((total, product) => total + product.revenue, 0); const stock = group.reduce((total, product) => total + product.stock, 0); + const planningStock = group.reduce((total, product) => total + getPlanningStock(product.stock), 0); const orderLineCount = group.reduce((total, product) => total + product.orderLineCount, 0); const dailySales = quantitySold / days; - const daysOfCover = dailySales > 0 ? stock / dailySales : null; - const risk = classifyStockRisk(stock, dailySales); + const daysOfCover = dailySales > 0 ? planningStock / dailySales : null; + const risk = classifyStockRisk(planningStock, dailySales); const style = riskStyles[risk]; const colorTotals = new Map(); const sizeTotals = new Map(); diff --git a/src/pages/Replenishment.tsx b/src/pages/Replenishment.tsx index bfc0ea6..f32dbb8 100644 --- a/src/pages/Replenishment.tsx +++ b/src/pages/Replenishment.tsx @@ -10,6 +10,7 @@ import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../ty import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService'; import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { formatColorLabel } from '../displayFormatters'; +import { getPlanningStock } from '../planningStock'; type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock'; type ReplenishmentFilter = 'all' | ReplenishmentStatus; @@ -174,7 +175,7 @@ const Replenishment = () => { const dailySales = product.quantitySold / rangeDays; const projectedDemand = dailySales * targetCoverageDays; const openProductionQuantity = openProductionByProductId[product.id] || 0; - const availableQuantity = product.stock + openProductionQuantity; + const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity; const rawNeed = projectedDemand - availableQuantity; const suggestedQuantity = Math.max(0, Math.ceil(rawNeed)); const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null; diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index f9cdc64..18421f4 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -31,6 +31,7 @@ import { classifyCutFamily } from '../analytics/cutting'; import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchSupplySummary } from '../dataService'; import { parseProductName } from '../productParsing'; import { resolveProductType, type ProductTypeKey } from '../productClassification'; +import { getPlanningStock } from '../planningStock'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types'; type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements'; @@ -230,9 +231,10 @@ const buildDemandRow = ( const color = override?.color || metadata.color; const size = (override?.size || metadata.size).toUpperCase(); const dailySales = product.quantitySold / rangeDays; - const daysOfCover = dailySales > 0 ? product.stock / dailySales : null; + const planningStock = getPlanningStock(product.stock); + const daysOfCover = dailySales > 0 ? planningStock / dailySales : null; const targetDemand = dailySales * targetCoverageDays; - const suggestedUnits = Math.max(0, Math.ceil(targetDemand - product.stock)); + const suggestedUnits = Math.max(0, Math.ceil(targetDemand - planningStock)); const missingData: string[] = []; if (planningReviewTypes.has(productType)) missingData.push('tipo'); diff --git a/src/planningStock.ts b/src/planningStock.ts new file mode 100644 index 0000000..2d25ae5 --- /dev/null +++ b/src/planningStock.ts @@ -0,0 +1 @@ +export const getPlanningStock = (stock: number) => Math.max(0, stock); diff --git a/src/productClassification.ts b/src/productClassification.ts index b1f759e..234eb18 100644 --- a/src/productClassification.ts +++ b/src/productClassification.ts @@ -123,17 +123,20 @@ export const classifyProductType = (name: string): ProductTypeKey => { if (has(normalizedName, /\bSALDO ESTOQUE\b/)) return 'ignore_from_planning'; if (has(normalizedName, /^(?:\d+\s+)?(?:MALHA|RIBANA)\b/)) return 'raw_material'; - if (has(normalizedName, /\b(?:RETALHO|RESIDUO)\s+(?:DE\s+)?MALHA\b/)) return 'raw_material'; + if (has(normalizedName, /\b(?:RETALHO|RESIDUO)\s+(?:DE\s+)?(?:MALHA|MOLETOM)\b/)) return 'raw_material'; if (has(normalizedName, /\bFIO\b.*\bMALHARIA\b/)) return 'raw_material'; if (has(normalizedName, /\b(?:LINHA|FIO) PARA COSTURA\b/)) return 'raw_material'; + if (has(normalizedName, /\bATACADOR\b/)) return 'raw_material'; + if (has(normalizedName, /\bILHOS\b/)) return 'raw_material'; if (has(normalizedName, /ETIQUETA|TAG|FITA/)) return 'raw_material'; + if (has(normalizedName, /\bCREDITO\b/)) return 'ignore_from_planning'; if (has(normalizedName, /\bKIT\b/)) return 'kit_bundle'; if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging'; - if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK/)) return 'equipment'; - if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA/)) return 'service'; - if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|PECA DE MAQUINA/)) return 'machine_part'; - if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF/)) return 'dtf_input'; - if (has(normalizedName, /IMPRESSAO DTF|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service'; + if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA/)) return 'equipment'; + if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA|MAO DE OBRA/)) return 'service'; + if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|CABECA DE IMPRESSAO|SENSOR INFRAVERMELHO|CAPSULA FILTRO|PECA DE MAQUINA/)) return 'machine_part'; + if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF|BOMBA DE TINTA/)) return 'dtf_input'; + if (has(normalizedName, /IMPRESSAO DTF|IMPRESSAO UV|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service'; if (has(normalizedName, /MALHA|RIBANA|FIO|TECIDO|RESIDUO|RETALHO|PIMA|ALGODAO/)) { if (!has(normalizedName, /CAMISETA|MOLETOM|REGATA|BONE|CHINELO|OVERSIZE|OVER SIZE/)) { return 'raw_material'; diff --git a/src/productParsing.ts b/src/productParsing.ts index 65713b0..986844f 100644 --- a/src/productParsing.ts +++ b/src/productParsing.ts @@ -1,8 +1,54 @@ const sizeOrder = ['2', '4', '6', '8', '10', '12', '14', '16', 'PP', 'P', 'M', 'G', 'GG', 'XG', 'G1', 'G2', 'G3', 'G4', 'G5']; const sizeSet = new Set(sizeOrder); +const knownColors = [ + 'BRANCO + PRETO', + 'CINZA/PRETO', + 'VERDE BANDEIRA', + 'VERDE MILITAR', + 'AZUL MARINHO', + 'CINZA GRAFITE', + 'AMARELO', + 'BRANCO', + 'GRAFITE', + 'MARINHO', + 'VERMELHO', + 'BORDO', + 'CAFE', + 'CAQUI', + 'CHUMBO', + 'CINZA', + 'MARROM', + 'PEROLA', + 'PRETO', + 'ROYAL', + 'ROSA', + 'ROXO', + 'VERDE', + 'AZUL', + 'BEGE' +].sort((a, b) => b.length - a.length); export const normalizeProductText = (value: string) => value.replace(/\s+/g, ' ').trim(); +const normalizeForMatch = (value: string) => normalizeProductText(value) + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toUpperCase(); + +const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const toFlexiblePattern = (value: string) => escapeRegex(value).replace(/\s+/g, '\\s+'); + +const findLeadingColor = (value: string) => { + const normalizedValue = normalizeForMatch(value); + return knownColors.find(color => normalizedValue === color || normalizedValue.startsWith(`${color} `)) || ''; +}; + +const findTrailingColor = (value: string) => { + const normalizedValue = normalizeForMatch(value); + return knownColors.find(color => normalizedValue === color || normalizedValue.endsWith(` ${color}`) || normalizedValue.endsWith(`- ${color}`)) || ''; +}; + export const encodeProductGroupKey = (value: string) => { const normalizedValue = normalizeProductText(value); const bytes = new TextEncoder().encode(normalizedValue); @@ -34,16 +80,51 @@ export const parseProductName = (name: string) => { const explicitSizeMatch = cleanName.match(/\bTAMANHO\s*-?\s*([A-Z0-9]+)\b/i); const trailingTokenMatch = cleanName.match(/(?:\s+-\s+|\s)([A-Z0-9]+)$/i); const trailingToken = trailingTokenMatch?.[1]?.toUpperCase() || ''; - const size = (explicitSizeMatch?.[1] || (sizeSet.has(trailingToken) ? trailingToken : '')).toUpperCase(); + let size = (explicitSizeMatch?.[1] || (sizeSet.has(trailingToken) ? trailingToken : '')).toUpperCase(); const colorMatch = cleanName.match(/\bCOR\s+(.+?)(?:\s+TAMANHO|\s+-\s+[A-Z0-9]+$|$)/i); - const color = normalizeProductText(colorMatch?.[1] || ''); + let color = normalizeProductText(findLeadingColor(colorMatch?.[1] || '') || colorMatch?.[1] || ''); + + if (!color && size) { + const nameBeforeSize = cleanName.replace(new RegExp(`(?:\\s+-\\s+|\\s)${escapeRegex(size)}$`, 'i'), ''); + color = findTrailingColor(nameBeforeSize); + } + + if (!color) { + const trailingColor = findTrailingColor(cleanName); + if (trailingColor) { + const normalizedName = normalizeForMatch(cleanName); + const normalizedPrefix = normalizeProductText(normalizedName.replace(new RegExp(`${escapeRegex(trailingColor)}$`), '')); + const tokenBeforeColor = normalizedPrefix.match(/([A-Z0-9]+)$/)?.[1] || ''; + if (sizeSet.has(tokenBeforeColor)) { + color = trailingColor; + size = tokenBeforeColor; + } + } + } let baseName = cleanName .replace(/\bCOR\s+.+?(?:\s+TAMANHO\s*-?\s*[A-Z0-9]+|\s+-\s+[A-Z0-9]+$|$)/i, '') .replace(/\bTAMANHO\s*-?\s*[A-Z0-9]+\b/i, '') .replace(/\s+-\s*[A-Z0-9]+$/i, ''); + if (color && size) { + const colorPattern = toFlexiblePattern(color); + const sizePattern = escapeRegex(size); + baseName = baseName + .replace(new RegExp(`\\s+-\\s*${colorPattern}\\s+${sizePattern}$`, 'i'), '') + .replace(new RegExp(`\\s+${colorPattern}\\s+-\\s*${sizePattern}$`, 'i'), '') + .replace(new RegExp(`\\s+${sizePattern}\\s+${colorPattern}$`, 'i'), '') + .replace(new RegExp(`\\s+${colorPattern}\\s+${sizePattern}$`, 'i'), ''); + } + + if (color) { + const colorPattern = toFlexiblePattern(color); + baseName = baseName + .replace(new RegExp(`\\s+-\\s*${colorPattern}$`, 'i'), '') + .replace(new RegExp(`\\s+${colorPattern}$`, 'i'), ''); + } + baseName = normalizeProductText(baseName.replace(/\s+-\s*$/g, '')); return {