Add native cutting plan page
This commit is contained in:
199
src/analytics/cutting.ts
Normal file
199
src/analytics/cutting.ts
Normal 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
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user