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 }; 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 CutPlanOptions { familyYields?: Partial>; productOverrides?: Record; } export interface CutPlanSkuRow extends ProductAnalyticsItem { family: CutFamilyRule; baseName: string; color: string; size: string; dailySales: number; projectedDemand: number; openProductionQuantity: number; availableQuantity: number; suggestedCutQuantity: number; estimatedRolls: number | null; daysOfCover: number | null; issues: CutIssue[]; } export interface CutPlanFamilySummary { family: CutFamilyRule; skuCount: number; colorCount: number; sizeCount: number; quantitySold: number; stock: number; projectedDemand: number; suggestedCutQuantity: number; estimatedRolls: number | null; } export interface CutPlanSummary { skuCount: number; familiesWithNeed: number; colorsWithNeed: number; sizesWithNeed: number; totalSold: number; totalStock: number; projectedDemand: number; suggestedCutQuantity: number; estimatedRolls: number | null; rowsWithIssues: number; openProductionQuantity: number; } export interface CutPlan { rows: CutPlanSkuRow[]; needRows: CutPlanSkuRow[]; familySummaries: CutPlanFamilySummary[]; summary: CutPlanSummary; } export const CUT_FAMILY_RULES: CutFamilyRule[] = [ { key: 'BLPM', label: 'Moletom', materialLabel: 'BLPM', keywords: ['MOLETOM'], unitsPerRoll: null }, { key: 'BLOS', label: 'Camiseta over', materialLabel: 'BLOS', keywords: ['OVER'], unitsPerRoll: null }, { key: 'BLMC', label: 'Camiseta infantil', materialLabel: 'BLMC', keywords: ['INFANTIL', 'KIDS'], unitsPerRoll: null }, { key: 'BLCS', label: 'Camiseta regular', materialLabel: 'BLCS', keywords: ['CAMISETA'], unitsPerRoll: null } ]; export const OUTROS_RULE: CutFamilyRule = { key: 'OUTROS', label: 'Sem regra', materialLabel: 'Pendente', keywords: [], unitsPerRoll: null }; const FAMILY_RULES_BY_KEY = new Map([ ...CUT_FAMILY_RULES.map(rule => [rule.key, rule] as const), [OUTROS_RULE.key, OUTROS_RULE] ]); export const getRangeDays = (range: DateRange) => { const start = new Date(range.start); const end = new Date(range.end); start.setHours(0, 0, 0, 0); end.setHours(0, 0, 0, 0); return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1); }; const normalizeRuleText = (value: string) => ( value .normalize('NFD') .replace(/\p{Diacritic}/gu, '') .toUpperCase() ); export const classifyCutFamily = (baseName: string): CutFamilyRule => { const normalizedName = normalizeRuleText(baseName); return CUT_FAMILY_RULES.find(rule => ( rule.keywords.some(keyword => normalizedName.includes(normalizeRuleText(keyword))) )) || OUTROS_RULE; }; const ruleWithConfiguredYield = (rule: CutFamilyRule, familyYields?: Partial>): CutFamilyRule => { const configuredYield = familyYields?.[rule.key]; return { ...rule, unitsPerRoll: configuredYield && configuredYield > 0 ? configuredYield : rule.unitsPerRoll }; }; 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, targetCoverageDays: number, openProductionByProductId: Record = {}, options: CutPlanOptions = {} ): CutPlan => { const rangeDays = getRangeDays(dateRange); const cuttableProducts = products.filter(product => ( resolveProductType(product.name, options.productOverrides?.[product.id]) === 'finished_apparel' )); const rows = cuttableProducts.map(product => { const metadata = parseProductName(product.name); const override = options.productOverrides?.[product.id]; const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined; const family = ruleWithConfiguredYield(overrideRule || classifyCutFamily(metadata.baseName), options.familyYields); const color = normalizeProductText(override?.color || metadata.color); const size = normalizeProductText(override?.size || metadata.size).toUpperCase(); const dailySales = product.quantitySold / rangeDays; const projectedDemand = dailySales * targetCoverageDays; const openProductionQuantity = openProductionByProductId[product.id] || 0; 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) : null; const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null; const issues: CutIssue[] = []; if (family.key === 'OUTROS') issues.push('missing_family_rule'); if (!color) issues.push('missing_color'); if (!size) issues.push('missing_size'); if (suggestedCutQuantity > 0 && family.key !== 'OUTROS' && !family.unitsPerRoll) issues.push('missing_yield_rule'); return { ...product, family, baseName: metadata.baseName, color, size, dailySales, projectedDemand, openProductionQuantity, availableQuantity, suggestedCutQuantity, estimatedRolls, daysOfCover, issues }; }); const needRows = rows.filter(row => row.suggestedCutQuantity > 0); const familyGroups = new Map(); needRows.forEach(row => { const group = familyGroups.get(row.family.key) || []; group.push(row); familyGroups.set(row.family.key, group); }); const familySummaries = Array.from(familyGroups.values()) .map(group => { const first = group[0]; const colors = new Set(group.map(row => row.color).filter(Boolean)); const sizes = sortProductSizes(Array.from(new Set(group.map(row => row.size).filter(Boolean)))); return { family: first.family, skuCount: group.length, colorCount: colors.size, sizeCount: sizes.length, 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), estimatedRolls: group.some(row => row.estimatedRolls === null) ? null : group.reduce((total, row) => total + (row.estimatedRolls || 0), 0) }; }) .sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity); const colorsWithNeed = new Set(needRows.map(row => row.color).filter(Boolean)); const sizesWithNeed = new Set(needRows.map(row => row.size).filter(Boolean)); return { rows, needRows, familySummaries, summary: { skuCount: needRows.length, familiesWithNeed: familySummaries.length, colorsWithNeed: colorsWithNeed.size, sizesWithNeed: sizesWithNeed.size, totalSold: needRows.reduce((total, row) => total + row.quantitySold, 0), 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), 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) } }; };