Files
graphs/src/productClassification.ts
Cauê Faleiros 9af5f296a7
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
Improve planning data normalization
2026-07-20 11:24:47 -03:00

178 lines
7.1 KiB
TypeScript

import { normalizeProductText } from './productParsing.ts';
export type ProductTypeKey =
| 'finished_apparel'
| 'finished_accessory'
| 'raw_material'
| 'packaging'
| 'dtf_input'
| 'dtf_service'
| 'kit_bundle'
| 'service'
| 'machine_part'
| 'equipment'
| 'ignore_from_planning'
| 'unknown';
export type ProductPlanningMode = 'cutting' | 'unit_replenishment' | 'material' | 'service' | 'ignore' | 'review';
export type ProductTypeConfig = {
key: ProductTypeKey;
label: string;
description: string;
planningMode: ProductPlanningMode;
badgeClassName: string;
};
export const productTypeConfigs: Record<ProductTypeKey, ProductTypeConfig> = {
finished_apparel: {
key: 'finished_apparel',
label: 'Vestuário',
description: 'Produto acabado que pode entrar em corte/reposição por SKU.',
planningMode: 'cutting',
badgeClassName: 'border-sky-400/30 bg-sky-400/10 text-sky-300'
},
finished_accessory: {
key: 'finished_accessory',
label: 'Acessório',
description: 'Produto acabado sem regra de malha principal.',
planningMode: 'unit_replenishment',
badgeClassName: 'border-cyan-400/30 bg-cyan-400/10 text-cyan-300'
},
raw_material: {
key: 'raw_material',
label: 'Matéria-prima',
description: 'Entrada de produção, como malha, ribana, tecido, fio ou resíduo.',
planningMode: 'material',
badgeClassName: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
},
packaging: {
key: 'packaging',
label: 'Embalagem',
description: 'Insumo de embalagem controlado por unidade/milheiro.',
planningMode: 'unit_replenishment',
badgeClassName: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
},
dtf_input: {
key: 'dtf_input',
label: 'Insumo DTF',
description: 'Insumo para DTF, como tinta, filme, poliamida ou fluido.',
planningMode: 'material',
badgeClassName: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-300'
},
dtf_service: {
key: 'dtf_service',
label: 'Serviço DTF',
description: 'Impressão, estampa ou personalização vendida como serviço/produto customizado.',
planningMode: 'service',
badgeClassName: 'border-purple-400/30 bg-purple-400/10 text-purple-300'
},
kit_bundle: {
key: 'kit_bundle',
label: 'Kit',
description: 'Bundle marketplace composto por outros SKUs.',
planningMode: 'review',
badgeClassName: 'border-indigo-400/30 bg-indigo-400/10 text-indigo-300'
},
service: {
key: 'service',
label: 'Serviço',
description: 'Frete, transporte, tecelagem, tinturaria ou serviço técnico.',
planningMode: 'service',
badgeClassName: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-300'
},
machine_part: {
key: 'machine_part',
label: 'Peça máquina',
description: 'Peça, limpeza ou manutenção de máquina.',
planningMode: 'unit_replenishment',
badgeClassName: 'border-orange-400/30 bg-orange-400/10 text-orange-300'
},
equipment: {
key: 'equipment',
label: 'Equipamento',
description: 'Máquina ou equipamento permanente.',
planningMode: 'ignore',
badgeClassName: 'border-slate-400/30 bg-slate-400/10 text-slate-300'
},
ignore_from_planning: {
key: 'ignore_from_planning',
label: 'Ignorar',
description: 'Item que não deve dirigir corte, compra ou reposição.',
planningMode: 'ignore',
badgeClassName: 'border-dark-border bg-dark-input text-dark-muted'
},
unknown: {
key: 'unknown',
label: 'Revisar',
description: 'Tipo não identificado automaticamente.',
planningMode: 'review',
badgeClassName: 'border-red-400/30 bg-red-400/10 text-red-300'
}
};
const has = (value: string, pattern: RegExp) => pattern.test(value);
export const classifyProductType = (name: string): ProductTypeKey => {
const normalizedName = normalizeProductText(name)
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toUpperCase();
if (!normalizedName) return 'unknown';
if (has(normalizedName, /\bSALDO ESTOQUE\b/)) return 'ignore_from_planning';
if (has(normalizedName, /^(?:\d+\s+)?(?:MALHA|RIBANA)\b/)) return 'raw_material';
if (has(normalizedName, /\b(?:RETALHO|RESIDUO)\s+(?:DE\s+)?(?:MALHA|MOLETOM)\b/)) return 'raw_material';
if (has(normalizedName, /\bFIO\b.*\bMALHARIA\b/)) return 'raw_material';
if (has(normalizedName, /\b(?:LINHA|FIO) PARA COSTURA\b/)) return 'raw_material';
if (has(normalizedName, /\bATACADOR\b/)) return 'raw_material';
if (has(normalizedName, /\bILHOS\b/)) return 'raw_material';
if (has(normalizedName, /ETIQUETA|TAG|FITA/)) return 'raw_material';
if (has(normalizedName, /\bCREDITO\b/)) return 'ignore_from_planning';
if (has(normalizedName, /\bKIT\b/)) return 'kit_bundle';
if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging';
if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA/)) return 'equipment';
if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA|MAO DE OBRA/)) return 'service';
if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|CABECA DE IMPRESSAO|SENSOR INFRAVERMELHO|CAPSULA FILTRO|PECA DE MAQUINA/)) return 'machine_part';
if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF|BOMBA DE TINTA/)) return 'dtf_input';
if (has(normalizedName, /IMPRESSAO DTF|IMPRESSAO UV|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service';
if (has(normalizedName, /MALHA|RIBANA|FIO|TECIDO|RESIDUO|RETALHO|PIMA|ALGODAO/)) {
if (!has(normalizedName, /CAMISETA|MOLETOM|REGATA|BONE|CHINELO|OVERSIZE|OVER SIZE/)) {
return 'raw_material';
}
}
if (has(normalizedName, /BONE|TRUCKER|\bCAP\b|CHINELO/)) return 'finished_accessory';
if (has(normalizedName, /CAMISETA|MOLETOM|CANGURU|REGATA|INFANTIL|OVER SIZE|OVERSIZE|PROMOCIONAL|VESTUARIO/)) return 'finished_apparel';
if (has(normalizedName, /ETIQUETA|TAG|FITA|LINHA PARA COSTURA|COSTURA/)) return 'raw_material';
return 'unknown';
};
export const getProductTypeConfig = (type: ProductTypeKey) => productTypeConfigs[type] || productTypeConfigs.unknown;
export const productTypeOptions = Object.values(productTypeConfigs)
.filter(config => config.key !== 'ignore_from_planning')
.map(config => ({ value: config.key, label: config.label }));
export const editableProductTypeOptions = Object.values(productTypeConfigs)
.map(config => ({ value: config.key, label: config.label, description: config.description }));
export const resolveProductType = (
name: string,
override?: { productType?: ProductTypeKey | '' } | null
): ProductTypeKey => {
return override?.productType || classifyProductType(name);
};
export const getDominantProductType = <T extends { productType: ProductTypeKey; quantitySold: number }>(rows: T[]) => {
if (!rows.length) return 'unknown';
const totals = rows.reduce<Record<string, number>>((acc, row) => {
acc[row.productType] = (acc[row.productType] || 0) + Math.max(row.quantitySold, 1);
return acc;
}, {});
return (Object.entries(totals).sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown') as ProductTypeKey;
};