Add OP-aware cutting planning

This commit is contained in:
Cauê Faleiros
2026-07-07 15:11:42 -03:00
parent f451c3df63
commit d1220fdd3f
4 changed files with 261 additions and 36 deletions

View File

@@ -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>): ProductAnalyticsItem
...overrides
});
const productionOrder = (overrides: Partial<ProductionOrderItem>): 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({

View File

@@ -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<string, number> => {
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<string, ProductAnalyticsItem>();
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<Record<string, number>>((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)
}
};
};