Add inferred product classification
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m36s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m36s
This commit is contained in:
@@ -80,11 +80,11 @@ test('buildCutPlan estimates rolls when a family yield is configured', () => {
|
||||
assert.equal(plan.summary.estimatedRolls, 3);
|
||||
});
|
||||
|
||||
test('buildCutPlan applies per-product planning overrides', () => {
|
||||
test('buildCutPlan applies per-product planning overrides to finished apparel', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
name: 'CAMISETA SEM PADRAO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
@@ -102,6 +102,26 @@ test('buildCutPlan applies per-product planning overrides', () => {
|
||||
assert.deepEqual(plan.needRows[0].issues, []);
|
||||
});
|
||||
|
||||
test('buildCutPlan excludes non-apparel products from cut planning', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
}),
|
||||
product({
|
||||
id: 'SKU-3',
|
||||
name: 'TRANSPARENTE PP MILHEIRO - 25x35',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
], range, 7);
|
||||
|
||||
assert.equal(plan.rows.length, 0);
|
||||
assert.equal(plan.summary.skuCount, 0);
|
||||
});
|
||||
|
||||
test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => {
|
||||
const products = [
|
||||
product({ id: 'SKU-1' }),
|
||||
@@ -116,11 +136,11 @@ test('buildOpenProductionByProductId matches open OPs by SKU and normalized prod
|
||||
assert.deepEqual(totals, { 'SKU-1': 10, 'SKU-2': 20 });
|
||||
});
|
||||
|
||||
test('buildCutPlan marks products that cannot be planned cleanly for cutting', () => {
|
||||
test('buildCutPlan marks apparel products that cannot be planned cleanly for cutting', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
name: 'VESTUARIO SEM PADRAO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
|
||||
import { classifyProductType } from '../productClassification.ts';
|
||||
|
||||
export type { CutFamilyKey, CutProductOverride };
|
||||
|
||||
@@ -201,8 +202,9 @@ export const buildCutPlan = (
|
||||
options: CutPlanOptions = {}
|
||||
): CutPlan => {
|
||||
const rangeDays = getRangeDays(dateRange);
|
||||
const cuttableProducts = products.filter(product => classifyProductType(product.name) === 'finished_apparel');
|
||||
|
||||
const rows = products.map<CutPlanSkuRow>(product => {
|
||||
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;
|
||||
|
||||
21
src/components/ProductTypeBadge.tsx
Normal file
21
src/components/ProductTypeBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getProductTypeConfig, type ProductTypeKey } from '../productClassification';
|
||||
|
||||
type ProductTypeBadgeProps = {
|
||||
type: ProductTypeKey;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ProductTypeBadge = ({ type, className = '' }: ProductTypeBadgeProps) => {
|
||||
const config = getProductTypeConfig(type);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex w-fit items-center whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] font-bold ${config.badgeClassName} ${className}`}
|
||||
title={config.description}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductTypeBadge;
|
||||
@@ -4,12 +4,14 @@ import { Package, DollarSign, ReceiptText, Warehouse } from 'lucide-react';
|
||||
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import BackButton from '../components/BackButton';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
||||
import { fetchProductDetailsAnalytics } from '../dataService';
|
||||
import { parseProductName } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
|
||||
import { classifyProductType, getProductTypeConfig } from '../productClassification';
|
||||
|
||||
const CHART_GRID_COLOR = 'var(--chart-grid)';
|
||||
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
||||
@@ -181,6 +183,8 @@ const ProductDetails = () => {
|
||||
}
|
||||
|
||||
const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details;
|
||||
const productType = classifyProductType(productInfo.name);
|
||||
const productTypeConfig = getProductTypeConfig(productType);
|
||||
const isRefreshing = isLoading && Boolean(details);
|
||||
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
|
||||
const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date));
|
||||
@@ -292,6 +296,10 @@ const ProductDetails = () => {
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{productInfo.name}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProductTypeBadge type={productType} />
|
||||
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import BackButton from '../components/BackButton';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||
@@ -12,6 +13,7 @@ import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { getProductColor } from '../productColors';
|
||||
import { classifyProductType, getDominantProductType, getProductTypeConfig, type ProductTypeKey } from '../productClassification';
|
||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
|
||||
type VariantRow = ProductAnalyticsItem & {
|
||||
@@ -23,6 +25,7 @@ type VariantRow = ProductAnalyticsItem & {
|
||||
availableQuantity: number;
|
||||
suggestedReplenishment: number;
|
||||
daysOfCover: number | null;
|
||||
productType: ProductTypeKey;
|
||||
};
|
||||
|
||||
type BreakdownRow = {
|
||||
@@ -240,6 +243,7 @@ const ProductGroupDetails = () => {
|
||||
return products
|
||||
.map(product => {
|
||||
const metadata = parseProductName(product.name);
|
||||
const productType = classifyProductType(product.name);
|
||||
const dailySales = product.quantitySold / rangeDays;
|
||||
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
|
||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||
@@ -256,7 +260,8 @@ const ProductGroupDetails = () => {
|
||||
openProductionQuantity,
|
||||
availableQuantity,
|
||||
suggestedReplenishment,
|
||||
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null
|
||||
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null,
|
||||
productType
|
||||
};
|
||||
})
|
||||
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
||||
@@ -275,6 +280,7 @@ const ProductGroupDetails = () => {
|
||||
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
|
||||
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
|
||||
const productType = getDominantProductType(groupRows);
|
||||
|
||||
return {
|
||||
totalSold,
|
||||
@@ -287,9 +293,11 @@ const ProductGroupDetails = () => {
|
||||
suggestedReplenishment,
|
||||
daysOfCover,
|
||||
colorCount: colors.size,
|
||||
sizeCount: sizes.size
|
||||
sizeCount: sizes.size,
|
||||
productType
|
||||
};
|
||||
}, [groupRows]);
|
||||
const productTypeConfig = getProductTypeConfig(totals.productType);
|
||||
|
||||
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
||||
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
|
||||
@@ -335,6 +343,10 @@ const ProductGroupDetails = () => {
|
||||
Grupo · {formatNumber(groupRows.length)} SKUs · {formatNumber(totals.colorCount)} cores · {formatNumber(totals.sizeCount)} tamanhos
|
||||
</p>
|
||||
<h1 className="truncate text-2xl font-bold text-zinc-900 dark:text-dark-text" title={groupName}>{groupName}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProductTypeBadge type={totals.productType} />
|
||||
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -522,7 +534,10 @@ const ProductGroupDetails = () => {
|
||||
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.id}</td>
|
||||
<td className="max-w-0 px-6 py-2.5">
|
||||
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
|
||||
<div className="text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2">
|
||||
<ProductTypeBadge type={row.productType} />
|
||||
<span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
|
||||
|
||||
@@ -4,17 +4,20 @@ import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingD
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
||||
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { classifyProductType, getDominantProductType, getProductTypeConfig, productTypeOptions, type ProductTypeKey } from '../productClassification';
|
||||
|
||||
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
|
||||
type StockStatusFilter = 'all' | StockRisk;
|
||||
type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high';
|
||||
type SalesFilter = 'all' | 'sold' | 'not_sold';
|
||||
type CoverageFilter = 'all' | 'up_to_7' | 'up_to_14' | 'up_to_30' | 'over_30' | 'none';
|
||||
type ProductTypeFilter = 'all' | ProductTypeKey;
|
||||
type ProductSortOption = 'sold_desc' | 'sold_asc' | 'stock_priority' | 'revenue_desc' | 'revenue_asc' | 'stock_asc' | 'stock_desc' | 'coverage_asc' | 'coverage_desc' | 'name_asc';
|
||||
type ProductViewMode = 'sku' | 'group';
|
||||
|
||||
@@ -33,6 +36,7 @@ type ProductRow = ProductAnalyticsItem & {
|
||||
topColor: string;
|
||||
topSize: string;
|
||||
groupKey: string;
|
||||
productType: ProductTypeKey;
|
||||
};
|
||||
|
||||
const riskStyles: Record<StockRisk, { label: string; className: string; dotClass: string }> = {
|
||||
@@ -163,6 +167,7 @@ const Products = () => {
|
||||
const [stockQuantityFilter, setStockQuantityFilter] = useState<StockQuantityFilter>('all');
|
||||
const [salesFilter, setSalesFilter] = useState<SalesFilter>('all');
|
||||
const [coverageFilter, setCoverageFilter] = useState<CoverageFilter>('all');
|
||||
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
|
||||
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
|
||||
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -223,6 +228,7 @@ const Products = () => {
|
||||
const risk = classifyStockRisk(product.stock, dailySales);
|
||||
const style = riskStyles[risk];
|
||||
const metadata = parseProductName(product.name);
|
||||
const productType = classifyProductType(product.name);
|
||||
|
||||
return {
|
||||
...product,
|
||||
@@ -239,7 +245,8 @@ const Products = () => {
|
||||
sizes: metadata.size ? [metadata.size] : [],
|
||||
topColor: metadata.color || '-',
|
||||
topSize: metadata.size || '-',
|
||||
groupKey: encodeProductGroupKey(metadata.baseName)
|
||||
groupKey: encodeProductGroupKey(metadata.baseName),
|
||||
productType
|
||||
};
|
||||
});
|
||||
|
||||
@@ -254,6 +261,7 @@ const Products = () => {
|
||||
|
||||
const groupedRows = Array.from(groups.values()).map(group => {
|
||||
const first = group[0];
|
||||
const productType = getDominantProductType(group);
|
||||
const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0);
|
||||
const revenue = group.reduce((total, product) => total + product.revenue, 0);
|
||||
const stock = group.reduce((total, product) => total + product.stock, 0);
|
||||
@@ -297,6 +305,7 @@ const Products = () => {
|
||||
sizes,
|
||||
topColor,
|
||||
topSize,
|
||||
productType,
|
||||
lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice
|
||||
};
|
||||
});
|
||||
@@ -309,7 +318,8 @@ const Products = () => {
|
||||
product.id.toLowerCase().includes(normalizedSearch) ||
|
||||
product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) ||
|
||||
product.colors.some(color => color.toLowerCase().includes(normalizedSearch)) ||
|
||||
product.sizes.some(size => size.toLowerCase().includes(normalizedSearch))
|
||||
product.sizes.some(size => size.toLowerCase().includes(normalizedSearch)) ||
|
||||
getProductTypeConfig(product.productType).label.toLowerCase().includes(normalizedSearch)
|
||||
)
|
||||
: activeRows;
|
||||
|
||||
@@ -325,6 +335,7 @@ const Products = () => {
|
||||
salesFilter === 'all' ||
|
||||
(salesFilter === 'sold' && product.quantitySold > 0) ||
|
||||
(salesFilter === 'not_sold' && product.quantitySold === 0);
|
||||
const matchesProductType = productTypeFilter === 'all' || product.productType === productTypeFilter;
|
||||
const matchesCoverage =
|
||||
coverageFilter === 'all' ||
|
||||
(coverageFilter === 'none' && product.daysOfCover === null) ||
|
||||
@@ -333,7 +344,7 @@ const Products = () => {
|
||||
(coverageFilter === 'up_to_30' && product.daysOfCover !== null && product.daysOfCover > 14 && product.daysOfCover <= 30) ||
|
||||
(coverageFilter === 'over_30' && product.daysOfCover !== null && product.daysOfCover > 30);
|
||||
|
||||
return matchesStatus && matchesStockQuantity && matchesSales && matchesCoverage;
|
||||
return matchesStatus && matchesStockQuantity && matchesSales && matchesProductType && matchesCoverage;
|
||||
});
|
||||
|
||||
return detailedFilteredProducts.sort((a, b) => {
|
||||
@@ -356,12 +367,13 @@ const Products = () => {
|
||||
return b.quantitySold - a.quantitySold;
|
||||
}
|
||||
});
|
||||
}, [coverageFilter, dateRange, productAnalytics, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
|
||||
}, [coverageFilter, dateRange, productAnalytics, productTypeFilter, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
|
||||
|
||||
const activeFilterCount =
|
||||
(stockStatusFilter === 'all' ? 0 : 1) +
|
||||
(stockQuantityFilter === 'all' ? 0 : 1) +
|
||||
(salesFilter === 'all' ? 0 : 1) +
|
||||
(productTypeFilter === 'all' ? 0 : 1) +
|
||||
(coverageFilter === 'all' ? 0 : 1);
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
|
||||
@@ -369,6 +381,7 @@ const Products = () => {
|
||||
setStockStatusFilter('all');
|
||||
setStockQuantityFilter('all');
|
||||
setSalesFilter('all');
|
||||
setProductTypeFilter('all');
|
||||
setCoverageFilter('all');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
@@ -409,6 +422,7 @@ const Products = () => {
|
||||
'SKUs': product.skuCount,
|
||||
'Cores': product.colors.join(' | '),
|
||||
'Tamanhos': product.sizes.join(' | '),
|
||||
'Tipo de produto': getProductTypeConfig(product.productType).label,
|
||||
'Cor principal': product.topColor,
|
||||
'Tamanho principal': product.topSize,
|
||||
'Status': product.riskLabel,
|
||||
@@ -504,6 +518,23 @@ const Products = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Tipo de produto
|
||||
<select
|
||||
value={productTypeFilter}
|
||||
onChange={(event) => {
|
||||
setProductTypeFilter(event.target.value as ProductTypeFilter);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
{productTypeOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Status
|
||||
<select
|
||||
@@ -657,10 +688,13 @@ const Products = () => {
|
||||
</td>
|
||||
<td className="max-w-0 px-6 py-2.5">
|
||||
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={product.name}>{product.name}</div>
|
||||
<div className="truncate text-[10px] text-zinc-400 dark:text-dark-muted font-medium">
|
||||
{viewMode === 'group'
|
||||
? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos`
|
||||
: `Preço Atual: ${formatCurrency(product.lastPrice)}`}
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2">
|
||||
<ProductTypeBadge type={product.productType} />
|
||||
<span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">
|
||||
{viewMode === 'group'
|
||||
? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos`
|
||||
: `Preço Atual: ${formatCurrency(product.lastPrice)}`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
|
||||
158
src/productClassification.ts
Normal file
158
src/productClassification.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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, /\bKIT\b/)) return 'kit_bundle';
|
||||
if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging';
|
||||
if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK/)) return 'equipment';
|
||||
if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA/)) return 'service';
|
||||
if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|PECA DE MAQUINA/)) return 'machine_part';
|
||||
if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF/)) return 'dtf_input';
|
||||
if (has(normalizedName, /IMPRESSAO DTF|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 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;
|
||||
};
|
||||
Reference in New Issue
Block a user