Use Tiny compositions for purchase planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s

This commit is contained in:
Cauê Faleiros
2026-08-03 10:22:39 -03:00
parent 1a3fed3dda
commit e993bfdaf3
11 changed files with 544 additions and 40 deletions

View File

@@ -7,8 +7,9 @@ import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus';
import { buildCuttingSkuConfigPath } from '../catalogLinks';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types';
import { getPlanningStock } from '../planningStock';
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
@@ -17,6 +18,7 @@ type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
type CorrectionIssueFilter = CutProductIssue | 'all';
type SettingsSection = 'rules' | 'corrections';
type CuttingView = 'plan' | 'families' | 'issues';
type MaterialReadiness = { status: 'ready' | 'blocked' | 'missing'; blockers: string[] };
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
const coverageTargetOptions = [7, 15, 30, 60];
@@ -66,6 +68,13 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const normalizeMaterialKey = (value: string) => value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toUpperCase();
const formatDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -128,6 +137,8 @@ const Cutting = () => {
const [searchParams] = useSearchParams();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [compositions, setCompositions] = useState<ProductComposition[]>([]);
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
@@ -181,13 +192,17 @@ const Cutting = () => {
const loadProducts = async () => {
setIsLoading(true);
const [productData, productionOrderData] = await Promise.all([
const [productData, productionOrderData, compositionData, stockData] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange)
fetchProductionOrders(allProductionOrdersRange),
fetchProductCompositions(),
fetchStock()
]);
if (isMounted) {
setProducts(productData);
setProductionOrders(productionOrderData.orders);
setCompositions(compositionData);
setMaterialStock(stockData);
setIsLoading(false);
}
};
@@ -209,6 +224,35 @@ const Cutting = () => {
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
);
const materialReadinessByProductId = useMemo(() => {
const compositionsById = new Map<string, ProductComposition>();
compositions.forEach(composition => {
[composition.finishedTinyProductId, composition.finishedProductSku]
.map(normalizeMaterialKey)
.filter(Boolean)
.forEach(key => compositionsById.set(key, composition));
});
const readinessByProductId = new Map<string, MaterialReadiness>();
cutPlan.rows.forEach(row => {
const composition = compositionsById.get(normalizeMaterialKey(row.id));
if (!composition?.components.length) {
readinessByProductId.set(row.id, { status: 'missing', blockers: [] });
return;
}
const blockers = composition.components.flatMap(component => {
const stockKeys = new Set([component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean));
const match = materialStock.find(stock => stockKeys.has(normalizeMaterialKey(stock.produto_id)) || stockKeys.has(normalizeMaterialKey(stock.nome)));
const available = match ? getPlanningStock(match.saldo) : null;
const required = row.suggestedCutQuantity * component.quantityPerUnit;
return available === null || available < required
? [`${component.componentName}${available === null ? ' (sem estoque vinculado)' : ` (${formatNumber(available)} / ${formatNumber(required)} ${component.unit || ''})`}`]
: [];
});
readinessByProductId.set(row.id, { status: blockers.length ? 'blocked' : 'ready', blockers });
});
return readinessByProductId;
}, [compositions, cutPlan.rows, materialStock]);
const issueSummaries = useMemo(() => {
const counts = new Map<CutIssue, number>();
cutPlan.needRows.forEach(row => {
@@ -254,8 +298,8 @@ const Cutting = () => {
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
const rowsAvailableForOrder = useMemo(() => (
filteredRows.filter(row => row.suggestedCutQuantity > 0)
), [filteredRows]);
filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready')
), [filteredRows, materialReadinessByProductId]);
const orderPreviewTotalUnits = useMemo(() => (
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0)
@@ -420,13 +464,15 @@ const Cutting = () => {
'Rendimento un/rolo': row.family.unitsPerRoll || '',
'Rolos estimados': row.estimatedRolls || '',
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
'Pronto para cortar': materialReadinessByProductId.get(row.id)?.status === 'ready' ? 'Sim' : materialReadinessByProductId.get(row.id)?.status === 'blocked' ? 'Não - falta material' : 'Sem composição',
'Materiais bloqueando': materialReadinessByProductId.get(row.id)?.blockers.join(' | ') || '',
'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ')
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
};
const openProductionOrderPreview = () => {
if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há necessidade de corte no filtro atual.');
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
return;
}
@@ -436,7 +482,7 @@ const Cutting = () => {
const generateProductionOrders = async () => {
if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há necessidade de corte no filtro atual.');
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
return;
}
@@ -459,7 +505,7 @@ const Cutting = () => {
markers: [
{ label: row.family.materialLabel, color: '#38bdf8' },
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
{ label: 'Material pendente', color: '#facc15' },
{ label: 'Material confirmado', color: '#52DFA0' },
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
],
metadata: {
@@ -509,6 +555,13 @@ const Cutting = () => {
);
};
const renderMaterialReadiness = (row: CutPlanSkuRow) => {
const readiness = materialReadinessByProductId.get(row.id);
if (readiness?.status === 'ready') return <span className="text-xs font-bold text-emerald-300">Pode cortar</span>;
if (readiness?.status === 'missing') return <span className="text-xs font-bold text-amber-300">Sem composição</span>;
return <span className="inline-flex max-w-[170px] truncate rounded-full border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}>Falta: {readiness?.blockers[0] || 'material'}</span>;
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
@@ -1209,7 +1262,7 @@ const Cutting = () => {
) : (
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
<div className="overflow-x-auto">
<table className="w-full min-w-[1180px] table-fixed text-left text-sm">
<table className="w-full min-w-[1320px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[105px]" />
<col className="w-[320px]" />
@@ -1220,6 +1273,7 @@ const Cutting = () => {
<col className="w-[140px]" />
<col className="w-[80px]" />
<col className="w-[130px]" />
<col className="w-[170px]" />
<col className="w-[110px]" />
</colgroup>
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
@@ -1232,6 +1286,7 @@ const Cutting = () => {
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Materiais</th>
<th
className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider"
title="Dados que ainda faltam para fechar o plano de corte do SKU."
@@ -1280,6 +1335,7 @@ const Cutting = () => {
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
</td>
<td className="px-4 py-2.5">{renderMaterialReadiness(row)}</td>
<td className="px-4 py-2.5">{renderIssueBadge(row)}</td>
<td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2">