Add native cutting plan page

This commit is contained in:
Cauê Faleiros
2026-07-07 15:03:24 -03:00
parent fedb1c88a8
commit f451c3df63
5 changed files with 742 additions and 1 deletions

View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildCutPlan, classifyCutFamily } from './cutting.ts';
import type { DateRange, ProductAnalyticsItem } from '../types.ts';
const range: DateRange = {
start: new Date(2026, 6, 1),
end: new Date(2026, 6, 7)
};
const product = (overrides: Partial<ProductAnalyticsItem>): ProductAnalyticsItem => ({
id: 'SKU-1',
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - M',
quantitySold: 70,
revenue: 700,
orderLineCount: 10,
lastPrice: 10,
stock: 20,
firstSaleDate: '2026-07-01',
lastSaleDate: '2026-07-07',
...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');
assert.equal(classifyCutFamily('BASE LISA MOLETOM CANGURU').key, 'BLPM');
assert.equal(classifyCutFamily('BONÉ').key, 'OUTROS');
});
test('buildCutPlan calculates projected cut need from sales pace and stock', () => {
const plan = buildCutPlan([product({})], range, 14);
const [row] = plan.needRows;
assert.equal(row.dailySales, 10);
assert.equal(row.projectedDemand, 140);
assert.equal(row.availableQuantity, 20);
assert.equal(row.suggestedCutQuantity, 120);
assert.equal(plan.summary.suggestedCutQuantity, 120);
});
test('buildCutPlan subtracts open production quantity from suggested cut need', () => {
const plan = buildCutPlan([product({ id: 'SKU-1' })], range, 14, { 'SKU-1': 100 });
const [row] = plan.needRows;
assert.equal(row.availableQuantity, 120);
assert.equal(row.suggestedCutQuantity, 20);
});
test('buildCutPlan marks products that cannot be planned cleanly for cutting', () => {
const plan = buildCutPlan([
product({
id: 'SKU-2',
name: 'BONÉ PRETO',
quantitySold: 70,
stock: 0
})
], range, 7);
assert.equal(plan.needRows[0].family.key, 'OUTROS');
assert.deepEqual(plan.needRows[0].issues, ['missing_family_rule', 'missing_color', 'missing_size']);
assert.equal(plan.summary.rowsWithIssues, 1);
});

199
src/analytics/cutting.ts Normal file
View File

@@ -0,0 +1,199 @@
import type { DateRange, ProductAnalyticsItem } from '../types';
import { parseProductName, sortProductSizes } from '../productParsing.ts';
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
export type CutIssue = 'missing_family_rule' | 'missing_color' | 'missing_size';
export interface CutFamilyRule {
key: CutFamilyKey;
label: string;
materialLabel: string;
keywords: string[];
}
export interface CutPlanSkuRow extends ProductAnalyticsItem {
family: CutFamilyRule;
baseName: string;
color: string;
size: string;
dailySales: number;
projectedDemand: number;
openProductionQuantity: number;
availableQuantity: number;
suggestedCutQuantity: number;
daysOfCover: number | null;
issues: CutIssue[];
}
export interface CutPlanFamilySummary {
family: CutFamilyRule;
skuCount: number;
colorCount: number;
sizeCount: number;
quantitySold: number;
stock: number;
projectedDemand: number;
suggestedCutQuantity: number;
}
export interface CutPlanSummary {
skuCount: number;
familiesWithNeed: number;
colorsWithNeed: number;
sizesWithNeed: number;
totalSold: number;
totalStock: number;
projectedDemand: number;
suggestedCutQuantity: number;
rowsWithIssues: 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']
},
{
key: 'BLOS',
label: 'Camiseta over',
materialLabel: 'BLOS',
keywords: ['OVER']
},
{
key: 'BLMC',
label: 'Camiseta infantil',
materialLabel: 'BLMC',
keywords: ['INFANTIL', 'KIDS']
},
{
key: 'BLCS',
label: 'Camiseta regular',
materialLabel: 'BLCS',
keywords: ['CAMISETA']
}
];
export const OUTROS_RULE: CutFamilyRule = {
key: 'OUTROS',
label: 'Sem regra',
materialLabel: 'Pendente',
keywords: []
};
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;
};
export const buildCutPlan = (
products: ProductAnalyticsItem[],
dateRange: DateRange,
targetCoverageDays: number,
openProductionByProductId: Record<string, number> = {}
): CutPlan => {
const rangeDays = getRangeDays(dateRange);
const rows = products.map<CutPlanSkuRow>(product => {
const metadata = parseProductName(product.name);
const family = classifyCutFamily(metadata.baseName);
const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * targetCoverageDays;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity;
const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
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');
return {
...product,
family,
baseName: metadata.baseName,
color: metadata.color,
size: metadata.size,
dailySales,
projectedDemand,
openProductionQuantity,
availableQuantity,
suggestedCutQuantity,
daysOfCover,
issues
};
});
const needRows = rows.filter(row => row.suggestedCutQuantity > 0);
const familyGroups = new Map<CutFamilyKey, CutPlanSkuRow[]>();
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<CutPlanFamilySummary>(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)
};
})
.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),
rowsWithIssues: needRows.filter(row => row.issues.length > 0).length
}
};
};