All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
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<Record<CutFamilyKey, number>>;
|
|
productOverrides?: Record<string, CutProductOverride>;
|
|
}
|
|
|
|
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<CutFamilyKey, CutFamilyRule>([
|
|
...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<Record<CutFamilyKey, number>>): 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<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,
|
|
targetCoverageDays: number,
|
|
openProductionByProductId: Record<string, number> = {},
|
|
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<CutPlanSkuRow>(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<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),
|
|
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)
|
|
}
|
|
};
|
|
};
|