diff --git a/backend/db.js b/backend/db.js index 44ac5b2..1d47519 100644 --- a/backend/db.js +++ b/backend/db.js @@ -443,6 +443,24 @@ const initDB = async () => { ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS cut_plan_lot_reservations ( + id SERIAL PRIMARY KEY, + cut_plan_reference TEXT NOT NULL, + supply_lot_id INTEGER NOT NULL REFERENCES supply_stock_lots(id) ON DELETE RESTRICT, + material_key VARCHAR(255) NOT NULL, + quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0), + consumed_quantity NUMERIC(14, 4) NOT NULL DEFAULT 0 CHECK (consumed_quantity >= 0), + status VARCHAR(30) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + released_at TIMESTAMPTZ + ); + `); + + await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_lot_status ON cut_plan_lot_reservations (supply_lot_id, status);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_reference ON cut_plan_lot_reservations (cut_plan_reference, status);`); + await pool.query(` CREATE TABLE IF NOT EXISTS supply_movements ( id SERIAL PRIMARY KEY, diff --git a/backend/routes/productionOrderRoutes.js b/backend/routes/productionOrderRoutes.js index 5308ef1..a29b4b7 100644 --- a/backend/routes/productionOrderRoutes.js +++ b/backend/routes/productionOrderRoutes.js @@ -16,7 +16,8 @@ router.get('/production-orders', verifyToken, async (req, res) => { router.post('/production-orders', verifyToken, async (req, res) => { try { const orders = Array.isArray(req.body?.orders) ? req.body.orders : []; - res.status(201).json(await createProductionOrders(orders)); + const lotReservations = Array.isArray(req.body?.lotReservations) ? req.body.lotReservations : []; + res.status(201).json(await createProductionOrders(orders, lotReservations)); } catch (error) { console.error('Error creating production orders:', error); res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' }); diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index 27bebd9..179e963 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -255,6 +255,18 @@ const mapStep = (step) => ({ color: step.color || '' }); +const mapLotReservation = (reservation) => ({ + id: reservation.id, + lotId: Number(reservation.supply_lot_id), + materialKey: reservation.material_key || '', + quantity: Number(reservation.quantity || 0), + consumedQuantity: Number(reservation.consumed_quantity || 0), + status: reservation.status || '', + lotProduct: reservation.lot_product || '', + lotUnit: reservation.lot_unit || '', + lotSupplier: reservation.lot_supplier || '' +}); + const mapProductionOrderRow = (row) => { const status = normalizeStatus(row.status); @@ -284,6 +296,7 @@ const mapProductionOrderRow = (row) => { rejectedQuantity: Number(row.rejected_quantity || 0), finishedQuantity: row.finished_quantity === null ? null : Number(row.finished_quantity), qualityNotes: row.quality_notes || '', + lotReservations: Array.isArray(row.lot_reservations) ? row.lot_reservations.map(mapLotReservation) : [], markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [], components: Array.isArray(row.components) ? row.components.map(mapComponent) : [], steps: Array.isArray(row.steps) ? row.steps.map(mapStep) : [], @@ -354,6 +367,29 @@ const getOrderById = async (id, client = pool) => { ), '[]'::json ) as components, + COALESCE( + ( + SELECT JSON_AGG( + JSON_BUILD_OBJECT( + 'id', reservation.id, + 'supply_lot_id', reservation.supply_lot_id, + 'material_key', reservation.material_key, + 'quantity', reservation.quantity, + 'consumed_quantity', reservation.consumed_quantity, + 'status', reservation.status, + 'lot_product', lot.product, + 'lot_unit', lot.unit, + 'lot_supplier', lot.supplier + ) + ORDER BY reservation.created_at, reservation.id + ) + FROM cut_plan_lot_reservations reservation + JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id + WHERE reservation.cut_plan_reference = po.order_reference + AND reservation.status IN ('active', 'consumed') + ), + '[]'::json + ) as lot_reservations, COALESCE( ( SELECT JSON_AGG( @@ -441,6 +477,29 @@ const baseProductionOrderSelect = ` ), '[]'::json ) as components, + COALESCE( + ( + SELECT JSON_AGG( + JSON_BUILD_OBJECT( + 'id', reservation.id, + 'supply_lot_id', reservation.supply_lot_id, + 'material_key', reservation.material_key, + 'quantity', reservation.quantity, + 'consumed_quantity', reservation.consumed_quantity, + 'status', reservation.status, + 'lot_product', lot.product, + 'lot_unit', lot.unit, + 'lot_supplier', lot.supplier + ) + ORDER BY reservation.created_at, reservation.id + ) + FROM cut_plan_lot_reservations reservation + JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id + WHERE reservation.cut_plan_reference = po.order_reference + AND reservation.status IN ('active', 'consumed') + ), + '[]'::json + ) as lot_reservations, COALESCE( ( SELECT JSON_AGG( @@ -517,7 +576,7 @@ const listProductionOrders = async (filters = {}) => { return { orders, counts }; }; -const createProductionOrders = async (orders = []) => { +const createProductionOrders = async (orders = [], lotReservations = []) => { const client = await pool.connect(); try { @@ -530,6 +589,7 @@ const createProductionOrders = async (orders = []) => { const productSku = normalizeText(order.productSku); const orderReference = normalizeText(order.orderReference); const quantity = normalizeQuantity(order.quantity); + const components = normalizeComponents(order.components, quantity); if (!productDescription || !quantity) { skipped.push({ productSku, productDescription, reason: 'invalid_payload' }); @@ -611,10 +671,76 @@ const createProductionOrders = async (orders = []) => { `, [orderId, label, normalizeText(marker.color) || null]); } + for (const component of components) { + await client.query(` + INSERT INTO production_order_components ( + production_order_id, component_tiny_id, component_sku, component_name, + quantity_per_unit, total_quantity, unit + ) + VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), $4, $5, $6, NULLIF($7, '')); + `, [ + orderId, + component.componentTinyId, + component.componentSku, + component.componentName, + component.quantityPerUnit, + component.totalQuantity, + component.unit + ]); + } + const createdOrder = await getOrderById(orderId, client); if (createdOrder) created.push(createdOrder); } + if (created.length && Array.isArray(lotReservations)) { + for (const reservation of lotReservations) { + const lotId = Number(reservation?.lotId); + const quantity = normalizeQuantity(reservation?.quantity); + const materialKey = normalizeText(reservation?.materialKey); + const cutPlanReference = normalizeText(reservation?.cutPlanReference) || normalizeText(created[0]?.orderReference); + if (!Number.isInteger(lotId) || !quantity || !materialKey || !cutPlanReference) { + const error = new Error('Reserva de lote inválida.'); + error.statusCode = 400; + throw error; + } + + const lotResult = await client.query(` + SELECT id, quantity, unit, status + FROM supply_stock_lots + WHERE id = $1 + FOR UPDATE; + `, [lotId]); + if (!lotResult.rowCount || lotResult.rows[0].status !== 'active') { + const error = new Error('O lote selecionado não está disponível para reserva.'); + error.statusCode = 400; + throw error; + } + const activeReservations = await client.query(` + SELECT quantity, consumed_quantity + FROM cut_plan_lot_reservations + WHERE supply_lot_id = $1 + AND status = 'active' + FOR UPDATE; + `, [lotId]); + const alreadyReserved = activeReservations.rows.reduce((total, row) => ( + total + Number(row.quantity || 0) - Number(row.consumed_quantity || 0) + ), 0); + const availableQuantity = Math.max(0, Number(lotResult.rows[0].quantity || 0) - alreadyReserved); + if (quantity > availableQuantity) { + const error = new Error(`O lote #${lotId} possui apenas ${availableQuantity} ${lotResult.rows[0].unit} disponível para nova reserva.`); + error.statusCode = 400; + throw error; + } + await client.query(` + INSERT INTO cut_plan_lot_reservations ( + cut_plan_reference, supply_lot_id, material_key, quantity, consumed_quantity, status, updated_at + ) + VALUES ($1, $2, $3, $4, 0, 'active', CURRENT_TIMESTAMP); + `, [cutPlanReference, lotId, materialKey, quantity]); + } + } + await client.query('COMMIT'); return { created, skipped }; } catch (error) { diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 856f886..0c91a8b 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -61,6 +61,8 @@ const mapLot = (row) => ({ supplier: row.supplier || '', invoice: row.invoice || '', status: row.status, + reservedQuantity: Number(row.reserved_quantity || 0), + availableQuantity: Number(row.available_quantity === undefined ? row.quantity : row.available_quantity), createdAt: row.created_at, updatedAt: row.updated_at }); @@ -116,10 +118,19 @@ const listReceipts = async () => { const listLots = async () => { const result = await pool.query(` - SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at - FROM supply_stock_lots - WHERE status = 'active' - ORDER BY created_at DESC, id DESC + SELECT + lot.id, lot.receipt_id, lot.category, lot.product, lot.quantity, lot.unit, lot.supplier, lot.invoice, lot.status, lot.created_at, lot.updated_at, + COALESCE(reservations.reserved_quantity, 0) AS reserved_quantity, + GREATEST(0, lot.quantity - COALESCE(reservations.reserved_quantity, 0)) AS available_quantity + FROM supply_stock_lots lot + LEFT JOIN LATERAL ( + SELECT COALESCE(SUM(quantity - consumed_quantity), 0) AS reserved_quantity + FROM cut_plan_lot_reservations reservation + WHERE reservation.supply_lot_id = lot.id + AND reservation.status = 'active' + ) reservations ON TRUE + WHERE lot.status = 'active' + ORDER BY lot.created_at DESC, lot.id DESC `); return result.rows.map(mapLot); }; @@ -233,7 +244,7 @@ const buildPurchaseNeeds = (plans, lots, receipts) => { lots.forEach(lot => { if (lot.unit !== 'kg') return; const need = needsByMaterial.get(normalizeKey(lot.product)); - if (need) need.stockKg += lot.quantity; + if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity); }); receipts.forEach(receipt => { @@ -624,7 +635,7 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { lots.forEach(lot => { const need = getNeedForInventory(lot.unit, '', lot.product) || needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`); - if (need) need.stockKg += lot.quantity; + if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity); }); receipts.forEach(receipt => { @@ -962,6 +973,19 @@ const updateLotQuantity = async (client, lotId, quantity) => { return mapLot(result.rows[0]); }; +const getActiveReservationQuantity = async (client, lotId) => { + const result = await client.query(` + SELECT quantity, consumed_quantity + FROM cut_plan_lot_reservations + WHERE supply_lot_id = $1 + AND status = 'active' + FOR UPDATE; + `, [lotId]); + return result.rows.reduce((total, reservation) => ( + total + Number(reservation.quantity || 0) - Number(reservation.consumed_quantity || 0) + ), 0); +}; + const adjustInventoryLot = async (id, payload) => { const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity); const reason = normalizeText(payload.reason); @@ -985,6 +1009,10 @@ const adjustInventoryLot = async (id, payload) => { const lot = lotResult.rows[0]; const currentQuantity = Number(lot.quantity); + const reservedQuantity = await getActiveReservationQuantity(client, lot.id); + if (countedQuantity < reservedQuantity) { + throw createValidationError(`A contagem não pode ficar abaixo de ${reservedQuantity} ${lot.unit} já reservados para corte.`); + } const difference = countedQuantity - currentQuantity; const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity); @@ -1039,6 +1067,35 @@ const consumeLotForProduction = async (id, payload) => { if (lot.status !== 'active' || currentQuantity <= 0) throw createValidationError('Lote sem saldo disponível.'); if (quantity > currentQuantity) throw createValidationError('Quantidade de saída maior que o saldo do lote.'); + let reference = ''; + if (productionOrderNumber) { + const orderResult = await client.query(` + SELECT order_reference + FROM production_orders + WHERE number = $1 + LIMIT 1; + `, [productionOrderNumber]); + reference = normalizeText(orderResult.rows[0]?.order_reference); + } + const allReservationsResult = await client.query(` + SELECT id, cut_plan_reference, quantity, consumed_quantity + FROM cut_plan_lot_reservations + WHERE supply_lot_id = $1 + AND status = 'active' + ORDER BY created_at, id + FOR UPDATE; + `, [lot.id]); + const totalReserved = allReservationsResult.rows.reduce((total, reservation) => ( + total + Number(reservation.quantity) - Number(reservation.consumed_quantity) + ), 0); + const reservedByThisPlan = allReservationsResult.rows + .filter(reservation => reference && reservation.cut_plan_reference === reference) + .reduce((total, reservation) => total + Number(reservation.quantity) - Number(reservation.consumed_quantity), 0); + const permittedQuantity = Math.max(0, currentQuantity - totalReserved + reservedByThisPlan); + if (quantity > permittedQuantity) { + throw createValidationError(`O lote possui ${Math.max(0, currentQuantity - totalReserved)} ${lot.unit} livres e ${reservedByThisPlan} ${lot.unit} reservados para esta OP.`); + } + const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity); const movementReason = [ productionOrderNumber ? `OP ${productionOrderNumber}` : '', @@ -1059,6 +1116,36 @@ const consumeLotForProduction = async (id, payload) => { movementReason || 'Saída para produção' ]); + if (productionOrderNumber) { + if (reference) { + const reservationsResult = await client.query(` + SELECT id, quantity, consumed_quantity + FROM cut_plan_lot_reservations + WHERE supply_lot_id = $1 + AND cut_plan_reference = $2 + AND status = 'active' + ORDER BY created_at, id + FOR UPDATE; + `, [lot.id, reference]); + let remainingToApply = quantity; + for (const reservation of reservationsResult.rows) { + if (remainingToApply <= 0) break; + const reservedRemaining = Number(reservation.quantity) - Number(reservation.consumed_quantity); + const consumed = Math.min(reservedRemaining, remainingToApply); + if (consumed <= 0) continue; + const nextConsumedQuantity = Number(reservation.consumed_quantity) + consumed; + await client.query(` + UPDATE cut_plan_lot_reservations + SET consumed_quantity = $1, + status = CASE WHEN $1 >= quantity THEN 'consumed' ELSE 'active' END, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2; + `, [nextConsumedQuantity, reservation.id]); + remainingToApply -= consumed; + } + } + } + await client.query('COMMIT'); return updatedLot; } catch (error) { diff --git a/src/dataService.ts b/src/dataService.ts index 7a9e0ab..a21eb29 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -239,11 +239,11 @@ export const fetchProductionOrders = async ( }, options); }; -export const createProductionOrders = async (orders: ProductionOrderPayload[]): Promise => { +export const createProductionOrders = async (orders: ProductionOrderPayload[], lotReservations: CutPlanLotReservationPayload[] = []): Promise => { const response = await authFetch('/production-orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orders }) + body: JSON.stringify({ orders, lotReservations }) }); if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.'); diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 653800d..6380c3d 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -6,8 +6,8 @@ import PaginationControls from '../components/PaginationControls'; import ProductColorBadge from '../components/ProductColorBadge'; import RefreshStatus from '../components/RefreshStatus'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; -import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, saveCuttingSettings } from '../dataService'; -import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData } from '../types'; +import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService'; +import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData, SupplyLot } from '../types'; import { getPlanningStock } from '../planningStock'; type CutFilter = 'need' | 'all' | 'issues' | 'covered'; @@ -19,6 +19,7 @@ type CorrectionIssueFilter = CutProductIssue | 'all'; type SettingsSection = 'rules' | 'corrections'; type CuttingView = 'plan' | 'families' | 'issues'; type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] }; +type CutMaterialRequirement = { key: string; label: string; unit: string; quantity: number }; const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings'; const coverageTargetOptions = [7, 15, 30, 60]; @@ -142,6 +143,7 @@ const Cutting = () => { const [productionOrders, setProductionOrders] = useState([]); const [compositions, setCompositions] = useState([]); const [materialStock, setMaterialStock] = useState([]); + const [supplyLots, setSupplyLots] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [targetCoverageDays, setTargetCoverageDays] = useState(30); @@ -164,6 +166,7 @@ const Cutting = () => { const [previewPage, setPreviewPage] = useState(1); const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10); const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState([]); + const [lotSelections, setLotSelections] = useState>({}); const [editingCutRow, setEditingCutRow] = useState(null); const [quickCutEdit, setQuickCutEdit] = useState({}); const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false); @@ -213,17 +216,19 @@ const Cutting = () => { const loadProducts = async () => { setIsLoading(true); - const [productData, productionOrderData, compositionData, stockData] = await Promise.all([ + const [productData, productionOrderData, compositionData, stockData, supplySummary] = await Promise.all([ fetchProductAnalytics(dateRange), fetchProductionOrders(allProductionOrdersRange), fetchProductCompositions(), - fetchStock() + fetchStock(), + fetchSupplySummary() ]); if (isMounted) { setProducts(productData); setProductionOrders(productionOrderData.orders); setCompositions(compositionData); setMaterialStock(stockData); + setSupplyLots(supplySummary.lots); setIsLoading(false); } }; @@ -245,7 +250,7 @@ const Cutting = () => { [cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays] ); - const materialReadinessByProductId = useMemo(() => { + const compositionsByProductId = useMemo(() => { const compositionsById = new Map(); compositions.forEach(composition => { [composition.finishedTinyProductId, composition.finishedProductSku] @@ -253,6 +258,10 @@ const Cutting = () => { .filter(Boolean) .forEach(key => compositionsById.set(key, composition)); }); + return compositionsById; + }, [compositions]); + + const materialReadinessByProductId = useMemo(() => { const stockIndexByKey = new Map(); materialStock.forEach((stock, index) => { [stock.produto_id, stock.nome].map(normalizeMaterialKey).filter(Boolean).forEach(key => stockIndexByKey.set(key, index)); @@ -262,7 +271,7 @@ const Cutting = () => { // Shared stock is allocated to the largest current requirement first. This // prevents every SKU from claiming the same material balance independently. [...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => { - const composition = compositionsById.get(normalizeMaterialKey(row.id)); + const composition = compositionsByProductId.get(normalizeMaterialKey(row.id)); if (!composition?.components.length) { // Products without an imported composition remain eligible for cutting. // They are excluded from material-demand calculations instead of @@ -299,7 +308,7 @@ const Cutting = () => { readinessByProductId.set(row.id, { status: 'ready', blockers: [] }); }); return readinessByProductId; - }, [compositions, cutPlan.rows, materialStock]); + }, [compositionsByProductId, cutPlan.rows, materialStock]); const issueSummaries = useMemo(() => { const counts = new Map(); @@ -383,6 +392,43 @@ const Cutting = () => { selectedPreviewRows.reduce((total, row) => total + row.suggestedCutQuantity, 0) ), [selectedPreviewRows]); + const cutPlanReference = useMemo(() => ( + `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias` + ), [dateRange.end, dateRange.start, targetCoverageDays]); + + const selectedMaterialRequirements = useMemo(() => { + const requirements = new Map(); + selectedPreviewRows.forEach(row => { + const composition = compositionsByProductId.get(normalizeMaterialKey(row.id)); + composition?.components + .filter(component => !isServiceComponent(component.componentName) && component.quantityPerUnit > 0) + .forEach(component => { + const key = normalizeMaterialKey(component.componentSku || component.componentTinyId || component.componentName); + if (!key) return; + const current = requirements.get(key) || { + key, + label: component.componentName, + unit: component.unit || 'UN', + quantity: 0 + }; + current.quantity += row.suggestedCutQuantity * component.quantityPerUnit; + requirements.set(key, current); + }); + }); + return Array.from(requirements.values()).sort((a, b) => a.label.localeCompare(b.label, 'pt-BR')); + }, [compositionsByProductId, selectedPreviewRows]); + + const selectedLotReservations = useMemo(() => ( + selectedMaterialRequirements.flatMap(requirement => { + const selection = lotSelections[requirement.key]; + const lotId = Number(selection?.lotId); + const quantity = Number((selection?.quantity || '').replace(',', '.')); + return Number.isInteger(lotId) && Number.isFinite(quantity) && quantity > 0 + ? [{ lotId, materialKey: requirement.key, quantity, cutPlanReference }] + : []; + }) + ), [cutPlanReference, lotSelections, selectedMaterialRequirements]); + const previewRowsWithIssues = useMemo(() => ( selectedPreviewRows.filter(row => row.issues.length).length ), [selectedPreviewRows]); @@ -619,6 +665,7 @@ const Cutting = () => { setGenerationMessage(''); setPreviewPage(1); setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id)); + setLotSelections({}); setIsOrderPreviewOpen(true); }; @@ -657,7 +704,7 @@ const Cutting = () => { setGenerationMessage(''); const issueDate = formatDateKey(new Date()); - const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`; + const orderReference = cutPlanReference; try { const result = await createProductionOrders(selectedPreviewRows.map(row => ({ @@ -669,6 +716,16 @@ const Cutting = () => { quantity: row.suggestedCutQuantity, unit: 'UN', integrationStatus: 'Local', + components: (compositionsByProductId.get(normalizeMaterialKey(row.id))?.components || []) + .filter(component => component.quantityPerUnit > 0) + .map(component => ({ + componentTinyId: component.componentTinyId, + componentSku: component.componentSku, + componentName: component.componentName, + quantityPerUnit: component.quantityPerUnit, + totalQuantity: row.suggestedCutQuantity * component.quantityPerUnit, + unit: component.unit + })), markers: [ { label: row.family.materialLabel, color: '#38bdf8' }, ...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []), @@ -689,7 +746,7 @@ const Cutting = () => { availableQuantity: row.availableQuantity, issues: row.issues } - }))); + })), selectedLotReservations); const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true }); setProductionOrders(refreshedOrders.orders); @@ -898,6 +955,17 @@ const Cutting = () => { +
+
+
+

Reservar lotes para este corte

+

A reserva é opcional quando o saldo vem do Tiny. Ao selecionar um lote, ela bloqueia somente o saldo disponível para outros planos; a baixa física continua na OP.

+
+ {formatNumber(selectedLotReservations.length)} lote(s) reservado(s) +
+ {selectedMaterialRequirements.length ?
{selectedMaterialRequirements.map(requirement => { const selection = lotSelections[requirement.key] || { lotId: '', quantity: '' }; const lots = supplyLots.filter(lot => { const lotKey = normalizeMaterialKey(lot.product); const materialMatch = lotKey === requirement.key || lotKey === normalizeMaterialKey(requirement.label); return lot.availableQuantity > 0 && materialMatch && lot.unit.toLowerCase() === requirement.unit.toLowerCase(); }); return ; })}
Material necessárioNecessárioLote reservadoQuantidade reservada

{requirement.label}

{requirement.key}

{formatNumber(requirement.quantity, 4)} {requirement.unit} setLotSelections(current => ({ ...current, [requirement.key]: { lotId: current[requirement.key]?.lotId || '', quantity: event.target.value } }))} disabled={!selection.lotId} className="h-9 w-32 rounded-lg border border-dark-border bg-dark-input px-2 text-right text-xs font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50" placeholder={formatNumber(requirement.quantity, 4)} />
:

Os SKUs selecionados não possuem componentes de material sincronizados. Eles podem seguir para OP, mas não geram reserva.

} +
+ { {order.components.length ?
{order.components.map(component => )}
MaterialPor unidadeTotal

{component.componentName}

{component.componentSku || 'Sem SKU'}

{formatQuantity(component.quantityPerUnit)} {component.unit}{formatQuantity(component.totalQuantity)} {component.unit}
:

Sem composição sincronizada.

} + {order.lotReservations.length > 0 &&
+

Lotes reservados pelo plano

+
{order.lotReservations.map(reservation => )}
LoteMaterialReservadoConsumido

#{reservation.lotId}

{reservation.lotSupplier || 'Sem fornecedor'}

{reservation.lotProduct}

{reservation.materialKey}

{formatQuantity(reservation.quantity)} {reservation.lotUnit}{formatQuantity(reservation.consumedQuantity)} {reservation.lotUnit}
+
} +

Baixa de material

{order.status === 'cutting' ?

Registre uma saída somente quando o material for realmente consumido pela OP.

{selectedLot &&

Saldo do lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}

}{supplyMessage &&

{supplyMessage}

}
:
Altere a OP para Em corte antes de registrar a baixa de materiais.
} diff --git a/src/types.ts b/src/types.ts index b0e24bb..ffa3a59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,18 @@ export interface ProductionOrderStep { color: string; } +export interface ProductionOrderLotReservation { + id: number; + lotId: number; + materialKey: string; + quantity: number; + consumedQuantity: number; + status: string; + lotProduct: string; + lotUnit: string; + lotSupplier: string; +} + export interface ProductionOrderItem { id: number; tinyId: string; @@ -104,6 +116,7 @@ export interface ProductionOrderItem { rejectedQuantity: number; finishedQuantity: number | null; qualityNotes: string; + lotReservations: ProductionOrderLotReservation[]; markers: ProductionOrderMarker[]; components: ProductionOrderComponent[]; steps: ProductionOrderStep[]; @@ -143,10 +156,25 @@ export type ProductionOrderPayload = { quantity: number; unit?: string; integrationStatus?: string; + components?: Array<{ + componentTinyId?: string; + componentSku?: string; + componentName: string; + quantityPerUnit: number; + totalQuantity?: number; + unit?: string; + }>; markers?: ProductionOrderMarkerPayload[]; metadata?: Record; }; +export type CutPlanLotReservationPayload = { + lotId: number; + materialKey: string; + quantity: number; + cutPlanReference?: string; +}; + export type ProductionOrderExecutionPayload = { actualCutQuantity?: number | null; rejectedQuantity?: number | null; @@ -395,6 +423,8 @@ export interface SupplyLot { supplier: string; invoice: string; status: string; + reservedQuantity: number; + availableQuantity: number; createdAt: string; updatedAt: string; }