From d1220fdd3f40da6a6cd3101da80def3d85c66346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 7 Jul 2026 15:11:42 -0300 Subject: [PATCH] Add OP-aware cutting planning --- src/analytics/cutting.test.ts | 40 ++++++++++++- src/analytics/cutting.ts | 95 ++++++++++++++++++++++++++---- src/pages/Cutting.tsx | 106 +++++++++++++++++++++++++++++----- src/pages/Replenishment.tsx | 56 ++++++++++++++---- 4 files changed, 261 insertions(+), 36 deletions(-) diff --git a/src/analytics/cutting.test.ts b/src/analytics/cutting.test.ts index fabf705..165ff9d 100644 --- a/src/analytics/cutting.test.ts +++ b/src/analytics/cutting.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildCutPlan, classifyCutFamily } from './cutting.ts'; -import type { DateRange, ProductAnalyticsItem } from '../types.ts'; +import { buildCutPlan, buildOpenProductionByProductId, classifyCutFamily } from './cutting.ts'; +import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types.ts'; const range: DateRange = { start: new Date(2026, 6, 1), @@ -22,6 +22,26 @@ const product = (overrides: Partial): ProductAnalyticsItem ...overrides }); +const productionOrder = (overrides: Partial): ProductionOrderItem => ({ + id: 1, + tinyId: '', + number: 'OP-1', + status: 'in_progress', + statusLabel: 'Em andamento', + orderReference: '', + issueDate: '2026-07-01', + expectedDate: '2026-07-15', + productSku: '', + productDescription: 'BASE LISA CAMISETA COR PRETO TAMANHO - M', + quantity: 40, + unit: 'UN', + integrationStatus: '', + markers: [], + createdAt: null, + updatedAt: null, + ...overrides +}); + test('classifyCutFamily maps known product bases to internal cut families', () => { assert.equal(classifyCutFamily('BASE LISA CAMISETA').key, 'BLCS'); assert.equal(classifyCutFamily('BASE LISA CAMISETA OVER').key, 'BLOS'); @@ -37,6 +57,8 @@ test('buildCutPlan calculates projected cut need from sales pace and stock', () assert.equal(row.projectedDemand, 140); assert.equal(row.availableQuantity, 20); assert.equal(row.suggestedCutQuantity, 120); + assert.equal(row.estimatedRolls, null); + assert.deepEqual(row.issues, ['missing_yield_rule']); assert.equal(plan.summary.suggestedCutQuantity, 120); }); @@ -48,6 +70,20 @@ test('buildCutPlan subtracts open production quantity from suggested cut need', assert.equal(row.suggestedCutQuantity, 20); }); +test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => { + const products = [ + product({ id: 'SKU-1' }), + product({ id: 'SKU-2', name: 'BASE LISA CAMISETA COR BRANCO TAMANHO - G' }) + ]; + const totals = buildOpenProductionByProductId(products, [ + productionOrder({ productSku: 'SKU-1', productDescription: '', quantity: 10 }), + productionOrder({ productSku: '', productDescription: 'Base Lisa Camiseta Cor Branco Tamanho - G', quantity: 20 }), + productionOrder({ productSku: 'SKU-1', status: 'finished', quantity: 999 }) + ]); + + assert.deepEqual(totals, { 'SKU-1': 10, 'SKU-2': 20 }); +}); + test('buildCutPlan marks products that cannot be planned cleanly for cutting', () => { const plan = buildCutPlan([ product({ diff --git a/src/analytics/cutting.ts b/src/analytics/cutting.ts index 7b7447a..f8f8064 100644 --- a/src/analytics/cutting.ts +++ b/src/analytics/cutting.ts @@ -1,14 +1,15 @@ -import type { DateRange, ProductAnalyticsItem } from '../types'; -import { parseProductName, sortProductSizes } from '../productParsing.ts'; +import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; +import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts'; export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS'; -export type CutIssue = 'missing_family_rule' | 'missing_color' | 'missing_size'; +export type CutIssue = 'missing_family_rule' | 'missing_color' | 'missing_size' | 'missing_yield_rule'; export interface CutFamilyRule { key: CutFamilyKey; label: string; materialLabel: string; keywords: string[]; + unitsPerRoll: number | null; } export interface CutPlanSkuRow extends ProductAnalyticsItem { @@ -21,6 +22,7 @@ export interface CutPlanSkuRow extends ProductAnalyticsItem { openProductionQuantity: number; availableQuantity: number; suggestedCutQuantity: number; + estimatedRolls: number | null; daysOfCover: number | null; issues: CutIssue[]; } @@ -34,6 +36,7 @@ export interface CutPlanFamilySummary { stock: number; projectedDemand: number; suggestedCutQuantity: number; + estimatedRolls: number | null; } export interface CutPlanSummary { @@ -45,7 +48,9 @@ export interface CutPlanSummary { totalStock: number; projectedDemand: number; suggestedCutQuantity: number; + estimatedRolls: number | null; rowsWithIssues: number; + openProductionQuantity: number; } export interface CutPlan { @@ -60,25 +65,29 @@ export const CUT_FAMILY_RULES: CutFamilyRule[] = [ key: 'BLPM', label: 'Moletom', materialLabel: 'BLPM', - keywords: ['MOLETOM'] + keywords: ['MOLETOM'], + unitsPerRoll: null }, { key: 'BLOS', label: 'Camiseta over', materialLabel: 'BLOS', - keywords: ['OVER'] + keywords: ['OVER'], + unitsPerRoll: null }, { key: 'BLMC', label: 'Camiseta infantil', materialLabel: 'BLMC', - keywords: ['INFANTIL', 'KIDS'] + keywords: ['INFANTIL', 'KIDS'], + unitsPerRoll: null }, { key: 'BLCS', label: 'Camiseta regular', materialLabel: 'BLCS', - keywords: ['CAMISETA'] + keywords: ['CAMISETA'], + unitsPerRoll: null } ]; @@ -86,7 +95,8 @@ export const OUTROS_RULE: CutFamilyRule = { key: 'OUTROS', label: 'Sem regra', materialLabel: 'Pendente', - keywords: [] + keywords: [], + unitsPerRoll: null }; export const getRangeDays = (range: DateRange) => { @@ -111,6 +121,59 @@ export const classifyCutFamily = (baseName: string): CutFamilyRule => { )) || OUTROS_RULE; }; +const normalizeMatchText = (value: string) => normalizeProductText(value) + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase(); + +const isOpenProductionOrder = (order: ProductionOrderItem) => ( + order.status === 'open' || + order.status === 'in_progress' || + ['em aberto', 'aberta', 'andamento', 'em andamento'].includes(normalizeMatchText(order.status)) +); + +export const buildOpenProductionByProductId = ( + products: ProductAnalyticsItem[], + productionOrders: ProductionOrderItem[] +): Record => { + const productById = new Map(products.map(product => [product.id, product])); + const productByName = new Map(products.map(product => [normalizeMatchText(product.name), product])); + const productsByVariantKey = new Map(); + + products.forEach(product => { + const metadata = parseProductName(product.name); + const key = [ + normalizeMatchText(metadata.baseName), + normalizeMatchText(metadata.color), + normalizeMatchText(metadata.size) + ].join('::'); + productsByVariantKey.set(key, product); + }); + + return productionOrders.reduce>((totals, order) => { + if (!isOpenProductionOrder(order)) return totals; + + const skuMatch = order.productSku ? productById.get(order.productSku) : undefined; + const nameMatch = order.productDescription ? productByName.get(normalizeMatchText(order.productDescription)) : undefined; + let product = skuMatch || nameMatch; + + if (!product && order.productDescription) { + const metadata = parseProductName(order.productDescription); + const key = [ + normalizeMatchText(metadata.baseName), + normalizeMatchText(metadata.color), + normalizeMatchText(metadata.size) + ].join('::'); + product = productsByVariantKey.get(key); + } + + if (!product) return totals; + + totals[product.id] = (totals[product.id] || 0) + order.quantity; + return totals; + }, {}); +}; + export const buildCutPlan = ( products: ProductAnalyticsItem[], dateRange: DateRange, @@ -127,12 +190,16 @@ export const buildCutPlan = ( const openProductionQuantity = openProductionByProductId[product.id] || 0; const availableQuantity = product.stock + openProductionQuantity; const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity)); + const estimatedRolls = family.unitsPerRoll && suggestedCutQuantity > 0 + ? Math.ceil(suggestedCutQuantity / family.unitsPerRoll) + : null; const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null; const issues: CutIssue[] = []; if (family.key === 'OUTROS') issues.push('missing_family_rule'); if (!metadata.color) issues.push('missing_color'); if (!metadata.size) issues.push('missing_size'); + if (suggestedCutQuantity > 0 && family.key !== 'OUTROS' && !family.unitsPerRoll) issues.push('missing_yield_rule'); return { ...product, @@ -145,6 +212,7 @@ export const buildCutPlan = ( openProductionQuantity, availableQuantity, suggestedCutQuantity, + estimatedRolls, daysOfCover, issues }; @@ -172,7 +240,10 @@ export const buildCutPlan = ( quantitySold: group.reduce((total, row) => total + row.quantitySold, 0), stock: group.reduce((total, row) => total + row.stock, 0), projectedDemand: group.reduce((total, row) => total + row.projectedDemand, 0), - suggestedCutQuantity: group.reduce((total, row) => total + row.suggestedCutQuantity, 0) + suggestedCutQuantity: group.reduce((total, row) => total + row.suggestedCutQuantity, 0), + estimatedRolls: group.some(row => row.estimatedRolls === null) + ? null + : group.reduce((total, row) => total + (row.estimatedRolls || 0), 0) }; }) .sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity); @@ -193,7 +264,11 @@ export const buildCutPlan = ( totalStock: needRows.reduce((total, row) => total + row.stock, 0), projectedDemand: needRows.reduce((total, row) => total + row.projectedDemand, 0), suggestedCutQuantity: needRows.reduce((total, row) => total + row.suggestedCutQuantity, 0), - rowsWithIssues: needRows.filter(row => row.issues.length > 0).length + estimatedRolls: needRows.some(row => row.estimatedRolls === null) + ? null + : needRows.reduce((total, row) => total + (row.estimatedRolls || 0), 0), + rowsWithIssues: needRows.filter(row => row.issues.length > 0).length, + openProductionQuantity: needRows.reduce((total, row) => total + row.openProductionQuantity, 0) } }; }; diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index df165ae..a51a197 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -1,12 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; -import { AlertTriangle, Download, Layers3, Package, Palette, Ruler, Scissors, Search } from 'lucide-react'; +import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, Ruler, Scissors, Search } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import RefreshStatus from '../components/RefreshStatus'; -import { CUT_FAMILY_RULES, buildCutPlan, type CutFamilyKey, type CutIssue, type CutPlanSkuRow } from '../analytics/cutting'; -import { exportToCSV, fetchProductAnalytics } from '../dataService'; -import type { DateRange, ProductAnalyticsItem } from '../types'; +import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow } from '../analytics/cutting'; +import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService'; +import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; type CutFilter = 'need' | 'all' | 'issues' | 'covered'; type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc'; @@ -28,7 +28,15 @@ const filterOptions: Array<{ value: CutFilter; label: string }> = [ const issueLabels: Record = { missing_family_rule: 'Sem família', missing_color: 'Sem cor', - missing_size: 'Sem tamanho' + missing_size: 'Sem tamanho', + missing_yield_rule: 'Sem rendimento' +}; + +const issueHelp: Record = { + missing_family_rule: 'Produto não caiu em BLCS, BLOS, BLMC ou BLPM.', + missing_color: 'Nome do produto não tem uma cor clara para montar matriz de corte.', + missing_size: 'Nome do produto não tem tamanho claro para montar matriz de corte.', + missing_yield_rule: 'Família reconhecida, mas ainda falta cadastrar unidades por rolo.' }; const familyStyles: Record = { @@ -49,6 +57,11 @@ const formatDays = (value: number | null) => { return `${formatNumber(value, value < 10 ? 1 : 0)} dias`; }; +const allProductionOrdersRange = { + start: new Date(2000, 0, 1), + end: new Date(2100, 11, 31) +}; + const CuttingSkeleton = () => (
@@ -72,6 +85,7 @@ const Cutting = () => { setDateRange: (range: DateRange) => void }>(); const [products, setProducts] = useState([]); + const [productionOrders, setProductionOrders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [targetCoverageDays, setTargetCoverageDays] = useState(30); @@ -86,9 +100,13 @@ const Cutting = () => { const loadProducts = async () => { setIsLoading(true); - const data = await fetchProductAnalytics(dateRange); + const [productData, productionOrderData] = await Promise.all([ + fetchProductAnalytics(dateRange), + fetchProductionOrders(allProductionOrdersRange) + ]); if (isMounted) { - setProducts(data); + setProducts(productData); + setProductionOrders(productionOrderData.orders); setIsLoading(false); } }; @@ -100,7 +118,23 @@ const Cutting = () => { }; }, [dateRange]); - const cutPlan = useMemo(() => buildCutPlan(products, dateRange, targetCoverageDays), [dateRange, products, targetCoverageDays]); + const openProductionByProductId = useMemo( + () => buildOpenProductionByProductId(products, productionOrders), + [products, productionOrders] + ); + + const cutPlan = useMemo( + () => buildCutPlan(products, dateRange, targetCoverageDays, openProductionByProductId), + [dateRange, openProductionByProductId, products, targetCoverageDays] + ); + + const issueSummaries = useMemo(() => { + const counts = new Map(); + cutPlan.needRows.forEach(row => { + row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1)); + }); + return Array.from(counts.entries()).map(([issue, count]) => ({ issue, count })); + }, [cutPlan.needRows]); const filteredRows = useMemo(() => { const normalizedSearch = searchTerm.trim().toLowerCase(); @@ -159,6 +193,8 @@ const Cutting = () => { 'OP aberta': row.openProductionQuantity, 'Disponível': row.availableQuantity, 'Necessidade corte': row.suggestedCutQuantity, + 'Rendimento un/rolo': row.family.unitsPerRoll || '', + 'Rolos estimados': row.estimatedRolls || '', 'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','), 'Pendências': row.issues.map(issue => issueLabels[issue]).join(' | ') })), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`); @@ -229,7 +265,9 @@ const Cutting = () => {

Famílias

{formatNumber(cutPlan.summary.familiesWithNeed)}

-

Com necessidade no período

+

+ {cutPlan.summary.estimatedRolls === null ? 'Rolos pendentes de rendimento' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`} +

@@ -255,17 +293,52 @@ const Cutting = () => {
-

Pendências

-

{formatNumber(cutPlan.summary.rowsWithIssues)}

-

Sem família, cor ou tamanho

+

OP aberta

+

{formatNumber(cutPlan.summary.openProductionQuantity)}

+

Unidades abatidas quando há match

- +
+ {!!issueSummaries.length && ( +
+
+
+

Pendências para fechar o Corte

+

+ Estes pontos substituem as correções manuais que antes ficavam espalhadas no Excel. +

+
+ + {formatNumber(cutPlan.summary.rowsWithIssues)} SKUs + +
+
+ {issueSummaries.map(summary => ( + + ))} +
+
+ )} + {!!cutPlan.familySummaries.length && (
{cutPlan.familySummaries.map(summary => ( @@ -281,7 +354,7 @@ const Cutting = () => {

{formatNumber(summary.suggestedCutQuantity)} un.

- {formatNumber(summary.skuCount)} SKUs · {formatNumber(summary.colorCount)} cores · {formatNumber(summary.sizeCount)} tamanhos + {formatNumber(summary.skuCount)} SKUs · {formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`}

))} @@ -370,6 +443,7 @@ const Cutting = () => { + @@ -384,6 +458,7 @@ const Cutting = () => { Estoque Disponível Necessidade + Rolos Pendências Ações @@ -426,6 +501,9 @@ const Cutting = () => { {formatNumber(row.suggestedCutQuantity)} un. + + {row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)} + {renderIssueBadge(row)} { return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1); }; +const allProductionOrdersRange = { + start: new Date(2000, 0, 1), + end: new Date(2100, 11, 31) +}; + const ReplenishmentSkeleton = () => (
@@ -115,6 +123,7 @@ const Replenishment = () => { }>(); const [searchParams] = useSearchParams(); const [products, setProducts] = useState([]); + const [productionOrders, setProductionOrders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(() => { @@ -132,10 +141,14 @@ const Replenishment = () => { const loadProducts = async () => { setIsLoading(true); - const data = await fetchProductAnalytics(dateRange); + const [productData, productionOrderData] = await Promise.all([ + fetchProductAnalytics(dateRange), + fetchProductionOrders(allProductionOrdersRange) + ]); if (isMounted) { - setProducts(data); + setProducts(productData); + setProductionOrders(productionOrderData.orders); setIsLoading(false); } }; @@ -147,16 +160,23 @@ const Replenishment = () => { }; }, [dateRange]); + const openProductionByProductId = useMemo( + () => buildOpenProductionByProductId(products, productionOrders), + [products, productionOrders] + ); + const allRows = useMemo(() => { const rangeDays = getRangeDays(dateRange); return products.map(product => { const dailySales = product.quantitySold / rangeDays; const projectedDemand = dailySales * targetCoverageDays; - const rawNeed = projectedDemand - product.stock; + const openProductionQuantity = openProductionByProductId[product.id] || 0; + const availableQuantity = product.stock + openProductionQuantity; + const rawNeed = projectedDemand - availableQuantity; const suggestedQuantity = Math.max(0, Math.ceil(rawNeed)); - const daysOfCover = dailySales > 0 ? product.stock / dailySales : null; - const status: ReplenishmentStatus = product.stock <= 0 + const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null; + const status: ReplenishmentStatus = availableQuantity <= 0 ? 'no_stock' : dailySales <= 0 ? 'no_sales' @@ -171,6 +191,8 @@ const Replenishment = () => { dailySales, projectedDemand, suggestedQuantity, + openProductionQuantity, + availableQuantity, daysOfCover, status, statusLabel: statusStyles[status].label, @@ -182,7 +204,7 @@ const Replenishment = () => { sizes: metadata.size ? [metadata.size] : [] }; }); - }, [dateRange, products, targetCoverageDays]); + }, [dateRange, openProductionByProductId, products, targetCoverageDays]); const groupedRows = useMemo(() => { const groups = new Map(); @@ -199,12 +221,14 @@ const Replenishment = () => { const quantitySold = group.reduce((total, row) => total + row.quantitySold, 0); const revenue = group.reduce((total, row) => total + row.revenue, 0); const stock = group.reduce((total, row) => total + row.stock, 0); + const openProductionQuantity = group.reduce((total, row) => total + row.openProductionQuantity, 0); + const availableQuantity = group.reduce((total, row) => total + row.availableQuantity, 0); const dailySales = group.reduce((total, row) => total + row.dailySales, 0); const projectedDemand = group.reduce((total, row) => total + row.projectedDemand, 0); const suggestedQuantity = group.reduce((total, row) => total + row.suggestedQuantity, 0); const orderLineCount = group.reduce((total, row) => total + row.orderLineCount, 0); - const daysOfCover = dailySales > 0 ? stock / dailySales : null; - const status: ReplenishmentStatus = stock <= 0 + const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null; + const status: ReplenishmentStatus = availableQuantity <= 0 ? 'no_stock' : dailySales <= 0 ? 'no_sales' @@ -222,6 +246,8 @@ const Replenishment = () => { quantitySold, revenue, stock, + openProductionQuantity, + availableQuantity, orderLineCount, dailySales, projectedDemand, @@ -317,6 +343,8 @@ const Replenishment = () => { 'Media Diaria': row.dailySales.toFixed(2).replace('.', ','), 'Demanda Projetada': row.projectedDemand.toFixed(2).replace('.', ','), 'Estoque Atual': row.stock, + 'OP Aberta': row.openProductionQuantity, + 'Disponivel': row.availableQuantity, 'Cobertura Atual': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','), 'Sugestao Reposicao': row.suggestedQuantity })); @@ -481,6 +509,7 @@ const Replenishment = () => { + @@ -495,6 +524,7 @@ const Replenishment = () => { Status Demanda proj. Estoque + Disponível Sugestão reposição Cobertura Ações @@ -524,6 +554,12 @@ const Replenishment = () => { {formatNumber(row.projectedDemand, 1)} un. {formatNumber(row.stock)} un. + + {formatNumber(row.availableQuantity)} un. + {!!row.openProductionQuantity && ( + OP {formatNumber(row.openProductionQuantity)} + )} + 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}> {formatNumber(row.suggestedQuantity)} un.