Add OP-aware cutting planning
This commit is contained in:
@@ -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)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user