diff --git a/backend/db.js b/backend/db.js index e130c89..f309c50 100644 --- a/backend/db.js +++ b/backend/db.js @@ -559,6 +559,31 @@ const initDB = async () => { ); `); + // A transformation is a planning/execution record, not a stock movement. + // Physical stock remains controlled only by approved receipts and inventory + // adjustments, so a planned dyeing run can never inflate availability. + await pool.query(` + CREATE TABLE IF NOT EXISTS supply_material_transformations ( + id SERIAL PRIMARY KEY, + batch_code VARCHAR(120), + stage VARCHAR(40) NOT NULL, + input_material TEXT NOT NULL, + input_quantity_kg NUMERIC(14, 4) NOT NULL CHECK (input_quantity_kg > 0), + output_material TEXT NOT NULL, + output_color VARCHAR(120), + expected_loss_percent NUMERIC(7, 4) NOT NULL DEFAULT 0 CHECK (expected_loss_percent >= 0 AND expected_loss_percent < 100), + expected_output_kg NUMERIC(14, 4) NOT NULL CHECK (expected_output_kg >= 0), + supplier TEXT, + priority VARCHAR(40) NOT NULL DEFAULT 'Normal', + status VARCHAR(30) NOT NULL DEFAULT 'planned', + notes TEXT, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_status ON supply_material_transformations (status, created_at DESC);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_batch ON supply_material_transformations (batch_code, created_at DESC);`); + await pool.query(` CREATE TABLE IF NOT EXISTS supply_purchase_orders ( id SERIAL PRIMARY KEY, diff --git a/backend/routes/supplyRoutes.js b/backend/routes/supplyRoutes.js index a448303..88323ba 100644 --- a/backend/routes/supplyRoutes.js +++ b/backend/routes/supplyRoutes.js @@ -5,15 +5,18 @@ const { approveReceipt, consumeLotForProduction, createFabricPlan, + createMaterialTransformation, createPurchaseOrder, createReceipt, deleteFabricPlan, deleteReceipt, getSupplySummary, listFabricPlans, + listMaterialTransformations, listKnownSuppliers, listPurchaseOrders, saveMaterialDetails, + updateMaterialTransformationStatus, updatePurchaseOrderStatus, listLots, listMovements, @@ -153,6 +156,30 @@ router.delete('/supply/fabric-plans/:id', verifyToken, async (req, res, next) => } }); +router.get('/supply/material-transformations', verifyToken, async (req, res, next) => { + try { + res.json(await listMaterialTransformations()); + } catch (error) { + next(error); + } +}); + +router.post('/supply/material-transformations', verifyToken, async (req, res, next) => { + try { + res.status(201).json(await createMaterialTransformation(req.body || {})); + } catch (error) { + next(error); + } +}); + +router.patch('/supply/material-transformations/:id/status', verifyToken, async (req, res, next) => { + try { + res.json(await updateMaterialTransformationStatus(req.params.id, req.body?.status)); + } catch (error) { + next(error); + } +}); + router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => { try { res.json(await listPurchaseNeeds()); diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 972a635..39055e1 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -92,6 +92,24 @@ const mapFabricPlan = (row) => ({ updatedAt: row.updated_at }); +const mapMaterialTransformation = (row) => ({ + id: row.id, + batchCode: row.batch_code || '', + stage: row.stage, + inputMaterial: row.input_material, + inputQuantityKg: Number(row.input_quantity_kg), + outputMaterial: row.output_material, + outputColor: row.output_color || '', + expectedLossPercent: Number(row.expected_loss_percent || 0), + expectedOutputKg: Number(row.expected_output_kg || 0), + supplier: row.supplier || '', + priority: row.priority || 'Normal', + status: row.status || 'planned', + notes: row.notes || '', + createdAt: row.created_at, + updatedAt: row.updated_at +}); + const mapMaterialDetails = (row) => ({ materialKey: row.material_key, displayName: row.display_name || '', @@ -182,6 +200,19 @@ const listFabricPlans = async () => { return result.rows.map(mapFabricPlan); }; +const listMaterialTransformations = async () => { + const result = await pool.query(` + SELECT id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color, + expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at + FROM supply_material_transformations + ORDER BY + CASE status WHEN 'in_progress' THEN 1 WHEN 'planned' THEN 2 WHEN 'completed' THEN 3 ELSE 4 END, + CASE priority WHEN 'Crítico' THEN 1 WHEN 'Atenção' THEN 2 ELSE 3 END, + created_at DESC, id DESC + `); + return result.rows.map(mapMaterialTransformation); +}; + const listMaterialDetails = async () => { const result = await pool.query(` SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at @@ -1032,6 +1063,49 @@ const createFabricPlan = async (payload) => { return mapFabricPlan(result.rows[0]); }; +const TRANSFORMATION_STAGES = new Set(['knitting', 'dyeing', 'finishing']); +const TRANSFORMATION_STATUSES = new Set(['planned', 'in_progress', 'completed', 'canceled']); + +const createMaterialTransformation = async (payload = {}) => { + const stage = normalizeText(payload.stage).toLowerCase(); + const inputMaterial = normalizeText(payload.inputMaterial); + const outputMaterial = normalizeText(payload.outputMaterial); + const inputQuantityKg = normalizeNumber(payload.inputQuantityKg); + const expectedLossPercent = normalizeNonNegativeNumber(payload.expectedLossPercent) ?? 0; + if (!TRANSFORMATION_STAGES.has(stage)) throw createValidationError('Etapa de transformação inválida.'); + if (!inputMaterial || !outputMaterial) throw createValidationError('Material de entrada e saída são obrigatórios.'); + if (!inputQuantityKg) throw createValidationError('Quantidade de entrada deve ser maior que zero.'); + if (expectedLossPercent >= 100) throw createValidationError('A perda esperada deve ser menor que 100%.'); + const expectedOutputKg = Number((inputQuantityKg * (1 - (expectedLossPercent / 100))).toFixed(4)); + const result = await pool.query(` + INSERT INTO supply_material_transformations ( + batch_code, stage, input_material, input_quantity_kg, output_material, output_color, + expected_loss_percent, expected_output_kg, supplier, priority, status, notes, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'planned', $11, CURRENT_TIMESTAMP) + RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color, + expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at + `, [ + normalizeText(payload.batchCode) || null, stage, inputMaterial, inputQuantityKg, outputMaterial, + normalizeText(payload.outputColor) || null, expectedLossPercent, expectedOutputKg, + normalizeText(payload.supplier) || null, normalizeText(payload.priority) || 'Normal', normalizeText(payload.notes) || null + ]); + return mapMaterialTransformation(result.rows[0]); +}; + +const updateMaterialTransformationStatus = async (id, status) => { + const normalizedStatus = normalizeText(status).toLowerCase(); + if (!TRANSFORMATION_STATUSES.has(normalizedStatus)) throw createValidationError('Status da transformação inválido.'); + const result = await pool.query(` + UPDATE supply_material_transformations + SET status = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color, + expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at + `, [id, normalizedStatus]); + if (!result.rows[0]) throw createValidationError('Transformação não encontrada.'); + return mapMaterialTransformation(result.rows[0]); +}; + const deleteFabricPlan = async (id) => { await pool.query(` UPDATE supply_fabric_plans @@ -1243,6 +1317,7 @@ module.exports = { approveReceipt, consumeLotForProduction, createFabricPlan, + createMaterialTransformation, createPurchaseOrder, createReceipt, deleteFabricPlan, @@ -1251,6 +1326,7 @@ module.exports = { buildProjectPurchaseNeeds, buildPurchaseNeeds, listFabricPlans, + listMaterialTransformations, listMaterialDetails, listKnownSuppliers, listPurchaseOrders, @@ -1259,5 +1335,6 @@ module.exports = { listPurchaseNeeds, listReceipts, saveMaterialDetails, + updateMaterialTransformationStatus, updatePurchaseOrderStatus }; diff --git a/src/dataService.ts b/src/dataService.ts index 012fab5..52083a2 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, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, 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, MaterialTransformationStatus, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyMaterialTransformation, SupplyMaterialTransformationPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -732,6 +732,35 @@ export const deleteSupplyFabricPlan = async (id: number): Promise => { } }; +export const fetchSupplyMaterialTransformations = async (): Promise => { + try { + const response = await authFetch('/supply/material-transformations'); + if (!response.ok) return []; + return await response.json(); + } catch (error) { + console.error('Fetch material transformations failed', error); + return []; + } +}; + +export const createSupplyMaterialTransformation = async (payload: SupplyMaterialTransformationPayload): Promise => { + const response = await authFetch('/supply/material-transformations', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível salvar a transformação.'); + return data as SupplyMaterialTransformation; +}; + +export const updateSupplyMaterialTransformationStatus = async (id: number, status: MaterialTransformationStatus): Promise => { + const response = await authFetch(`/supply/material-transformations/${id}/status`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar a transformação.'); + return data as SupplyMaterialTransformation; +}; + export const fetchSupplyPurchaseNeeds = async (): Promise => { try { const response = await authFetch('/supply/purchase-needs'); diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index b2d717f..595b05d 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -10,6 +10,8 @@ import { ClipboardList, Download, Eye, + Factory, + GitBranch, Link as LinkIcon, Package, PackageSearch, @@ -31,11 +33,11 @@ import PaginationControls from '../components/PaginationControls'; import ProductTypeBadge from '../components/ProductTypeBadge'; import StockGraphsBalanceModal from '../components/StockGraphsBalanceModal'; import { classifyCutFamily } from '../analytics/cutting'; -import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyPurchaseOrder, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplyPurchaseOrders, fetchSupplySummary, fetchSupplySuppliers, isSuperAdmin, saveSupplyMaterialDetails, updateSupplyPurchaseOrderStatus } from '../dataService'; +import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyMaterialTransformation, createSupplyPurchaseOrder, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplyMaterialTransformations, fetchSupplyPurchaseOrders, fetchSupplySummary, fetchSupplySuppliers, isSuperAdmin, saveSupplyMaterialDetails, updateSupplyMaterialTransformationStatus, updateSupplyPurchaseOrderStatus } from '../dataService'; import { parseProductName } from '../productParsing'; import { resolveProductType, type ProductTypeKey } from '../productClassification'; import { getPlanningStock } from '../planningStock'; -import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderStatus, SupplyReceipt, SupplySummary } from '../types'; +import type { CuttingSettings, DateRange, MaterialTransformationStage, MaterialTransformationStatus, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMaterialTransformation, SupplyMovement, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderStatus, SupplyReceipt, SupplySummary } from '../types'; type InventoryTab = 'dashboard' | 'stock' | 'balance' | 'receipts' | 'inventory' | 'movements'; type ReceiptView = 'new' | 'pending' | 'history'; @@ -413,6 +415,7 @@ const SuppliesHub = () => { + @@ -2686,6 +2689,132 @@ const PurchaseOrdersScreen = () => {

Nova ordem de compra

Uma OC começa com um material. Use uma OC por fornecedor para manter o recebimento rastreável.

{isAddingSupplier && }