From 21d996014529888763406773c4acadd5524b7f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 25 Aug 2026 11:10:36 -0300 Subject: [PATCH] Add supply purchase order workflow --- backend/db.js | 27 ++++++++++ backend/routes/supplyRoutes.js | 27 ++++++++++ backend/services/supplyService.js | 83 ++++++++++++++++++++++++++++++- src/dataService.ts | 30 ++++++++++- src/pages/Supplies.tsx | 30 ++++++++++- src/types.ts | 30 +++++++++++ 6 files changed, 223 insertions(+), 4 deletions(-) diff --git a/backend/db.js b/backend/db.js index 1d47519..aefc80f 100644 --- a/backend/db.js +++ b/backend/db.js @@ -490,6 +490,33 @@ const initDB = async () => { ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS supply_purchase_orders ( + id SERIAL PRIMARY KEY, + number VARCHAR(40) UNIQUE, + supplier TEXT NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'draft', + expected_date DATE, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS supply_purchase_order_items ( + id SERIAL PRIMARY KEY, + purchase_order_id INTEGER NOT NULL REFERENCES supply_purchase_orders(id) ON DELETE CASCADE, + material TEXT NOT NULL, + quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0), + unit VARCHAR(30) NOT NULL DEFAULT 'kg', + unit_cost NUMERIC(14, 4), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + + await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_purchase_orders_status ON supply_purchase_orders (status, created_at DESC);`); + await pool.query(` ALTER TABLE production_orders ADD COLUMN IF NOT EXISTS notes TEXT, diff --git a/backend/routes/supplyRoutes.js b/backend/routes/supplyRoutes.js index a1471e1..a448303 100644 --- a/backend/routes/supplyRoutes.js +++ b/backend/routes/supplyRoutes.js @@ -5,13 +5,16 @@ const { approveReceipt, consumeLotForProduction, createFabricPlan, + createPurchaseOrder, createReceipt, deleteFabricPlan, deleteReceipt, getSupplySummary, listFabricPlans, listKnownSuppliers, + listPurchaseOrders, saveMaterialDetails, + updatePurchaseOrderStatus, listLots, listMovements, listPurchaseNeeds, @@ -36,6 +39,30 @@ router.get('/supply/suppliers', verifyToken, async (req, res, next) => { } }); +router.get('/supply/purchase-orders', verifyToken, async (req, res, next) => { + try { + res.json(await listPurchaseOrders()); + } catch (error) { + next(error); + } +}); + +router.post('/supply/purchase-orders', verifyToken, async (req, res, next) => { + try { + res.status(201).json(await createPurchaseOrder(req.body || {})); + } catch (error) { + next(error); + } +}); + +router.patch('/supply/purchase-orders/:id/status', verifyToken, async (req, res, next) => { + try { + res.json(await updatePurchaseOrderStatus(req.params.id, req.body?.status)); + } catch (error) { + next(error); + } +}); + router.get('/supply/receipts', verifyToken, async (req, res, next) => { try { res.json(await listReceipts()); diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 0c91a8b..854b2cf 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -101,6 +101,27 @@ const mapMaterialDetails = (row) => ({ updatedAt: row.updated_at }); +const PURCHASE_ORDER_STATUSES = new Set(['draft', 'sent', 'approved', 'received', 'canceled']); + +const mapPurchaseOrder = (row) => ({ + id: row.id, + number: row.number || '', + supplier: row.supplier || '', + status: row.status || 'draft', + expectedDate: row.expected_date ? String(row.expected_date).slice(0, 10) : null, + notes: row.notes || '', + total: Number(row.total || 0), + items: Array.isArray(row.items) ? row.items.map(item => ({ + id: item.id, + material: item.material || '', + quantity: Number(item.quantity || 0), + unit: item.unit || 'kg', + unitCost: item.unit_cost === null || item.unit_cost === undefined ? null : Number(item.unit_cost) + })) : [], + createdAt: row.created_at, + updatedAt: row.updated_at +}); + const createValidationError = (message) => { const error = new Error(message); error.statusCode = 400; @@ -189,6 +210,63 @@ const listKnownSuppliers = async () => { return result.rows.map(row => row.supplier); }; +const listPurchaseOrders = async () => { + const result = await pool.query(` + SELECT + purchase_order.id, purchase_order.number, purchase_order.supplier, purchase_order.status, + purchase_order.expected_date, purchase_order.notes, purchase_order.created_at, purchase_order.updated_at, + COALESCE((SELECT SUM(item.quantity * COALESCE(item.unit_cost, 0)) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), 0) AS total, + COALESCE((SELECT JSON_AGG(JSON_BUILD_OBJECT('id', item.id, 'material', item.material, 'quantity', item.quantity, 'unit', item.unit, 'unit_cost', item.unit_cost) ORDER BY item.id) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), '[]'::json) AS items + FROM supply_purchase_orders purchase_order + ORDER BY purchase_order.created_at DESC, purchase_order.id DESC; + `); + return result.rows.map(mapPurchaseOrder); +}; + +const createPurchaseOrder = async (payload = {}) => { + const supplier = normalizeText(payload.supplier); + const expectedDate = /^\d{4}-\d{2}-\d{2}$/.test(normalizeText(payload.expectedDate)) ? normalizeText(payload.expectedDate) : null; + const notes = normalizeText(payload.notes); + const items = (Array.isArray(payload.items) ? payload.items : []).map(item => ({ + material: normalizeText(item?.material), + quantity: normalizeNumber(item?.quantity), + unit: normalizeText(item?.unit) || 'kg', + unitCost: normalizeNonNegativeNumber(item?.unitCost) + })).filter(item => item.material && item.quantity); + if (!supplier) throw createValidationError('Fornecedor é obrigatório para a ordem de compra.'); + if (!items.length) throw createValidationError('Inclua pelo menos um material na ordem de compra.'); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const orderResult = await client.query(`INSERT INTO supply_purchase_orders (supplier, status, expected_date, notes, updated_at) VALUES ($1, 'draft', $2, NULLIF($3, ''), CURRENT_TIMESTAMP) RETURNING id;`, [supplier, expectedDate, notes]); + const id = orderResult.rows[0].id; + await client.query(`UPDATE supply_purchase_orders SET number = $1 WHERE id = $2;`, [`OC-${String(id).padStart(5, '0')}`, id]); + for (const item of items) { + await client.query(`INSERT INTO supply_purchase_order_items (purchase_order_id, material, quantity, unit, unit_cost) VALUES ($1, $2, $3, $4, $5);`, [id, item.material, item.quantity, item.unit, item.unitCost]); + } + await client.query('COMMIT'); + return (await listPurchaseOrders()).find(order => order.id === id); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +const updatePurchaseOrderStatus = async (id, status) => { + const normalizedStatus = normalizeText(status).toLowerCase(); + if (!PURCHASE_ORDER_STATUSES.has(normalizedStatus)) throw createValidationError('Status de ordem de compra inválido.'); + const result = await pool.query(`UPDATE supply_purchase_orders SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id;`, [normalizedStatus, id]); + if (!result.rowCount) { + const error = new Error('Ordem de compra não encontrada.'); + error.statusCode = 404; + throw error; + } + return (await listPurchaseOrders()).find(order => order.id === Number(id)); +}; + const saveMaterialDetails = async (materialKey, payload) => { const normalizedKey = normalizeKey(materialKey); if (!normalizedKey) throw createValidationError('Material inválido para edição.'); @@ -1161,6 +1239,7 @@ module.exports = { approveReceipt, consumeLotForProduction, createFabricPlan, + createPurchaseOrder, createReceipt, deleteFabricPlan, deleteReceipt, @@ -1170,9 +1249,11 @@ module.exports = { listFabricPlans, listMaterialDetails, listKnownSuppliers, + listPurchaseOrders, listLots, listMovements, listPurchaseNeeds, listReceipts, - saveMaterialDetails + saveMaterialDetails, + updatePurchaseOrderStatus }; diff --git a/src/dataService.ts b/src/dataService.ts index a21eb29..8988580 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, 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, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -544,6 +544,34 @@ export const fetchSupplySuppliers = async (): Promise => { return response.json() as Promise; }; +export const fetchSupplyPurchaseOrders = async (): Promise => { + const response = await authFetch('/supply/purchase-orders'); + if (!response.ok) throw new Error('Não foi possível carregar as ordens de compra.'); + return response.json() as Promise; +}; + +export const createSupplyPurchaseOrder = async (payload: SupplyPurchaseOrderPayload): Promise => { + const response = await authFetch('/supply/purchase-orders', { + 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 criar a ordem de compra.'); + return data as SupplyPurchaseOrder; +}; + +export const updateSupplyPurchaseOrderStatus = async (id: number, status: SupplyPurchaseOrderStatus): Promise => { + const response = await authFetch(`/supply/purchase-orders/${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 ordem de compra.'); + return data as SupplyPurchaseOrder; +}; + export const saveSupplyMaterialDetails = async ( materialKey: string, payload: SupplyMaterialDetailsPayload diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index 0eee476..ea443b6 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -31,11 +31,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, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, fetchSupplySuppliers, isSuperAdmin, saveSupplyMaterialDetails } from '../dataService'; +import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyPurchaseOrder, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplyPurchaseOrders, fetchSupplySummary, fetchSupplySuppliers, isSuperAdmin, saveSupplyMaterialDetails, 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, SupplyReceipt, SupplySummary } from '../types'; +import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderStatus, SupplyReceipt, SupplySummary } from '../types'; type InventoryTab = 'dashboard' | 'stock' | 'balance' | 'receipts' | 'inventory' | 'movements'; type ReceiptView = 'new' | 'pending' | 'history'; @@ -411,6 +411,7 @@ const SuppliesHub = () => {
+ @@ -2656,6 +2657,30 @@ const PurchaseNeedsScreen = () => { ); }; +const PurchaseOrdersScreen = () => { + const [orders, setOrders] = useState([]); + const [suppliers, setSuppliers] = useState([]); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const [form, setForm] = useState({ supplier: '', material: '', quantity: '', unit: 'kg', unitCost: '', expectedDate: '', notes: '' }); + const load = async () => { + const [nextOrders, nextSuppliers] = await Promise.all([fetchSupplyPurchaseOrders(), fetchSupplySuppliers()]); + setOrders(nextOrders); setSuppliers(nextSuppliers); + }; + useEffect(() => { void load().catch(error => setMessage(error instanceof Error ? error.message : 'Não foi possível carregar as OCs.')); }, []); + const submit = async (event: FormEvent) => { + event.preventDefault(); setBusy(true); setMessage(''); + try { + await createSupplyPurchaseOrder({ supplier: form.supplier, expectedDate: form.expectedDate || null, notes: form.notes, items: [{ material: form.material, quantity: parseDecimal(form.quantity), unit: form.unit, unitCost: form.unitCost ? parseDecimal(form.unitCost) : null }] }); + setForm({ supplier: '', material: '', quantity: '', unit: 'kg', unitCost: '', expectedDate: '', notes: '' }); await load(); setMessage('Ordem de compra criada. Envie ao fornecedor e atualize o status conforme o andamento.'); + } catch (error) { setMessage(error instanceof Error ? error.message : 'Não foi possível criar a OC.'); } finally { setBusy(false); } + }; + const changeStatus = async (id: number, status: SupplyPurchaseOrderStatus) => { setBusy(true); try { await updateSupplyPurchaseOrderStatus(id, status); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar a OC.'); } finally { setBusy(false); } }; + return
+ {message &&
{message}
} +

Nova ordem de compra

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

{suppliers.map(supplier =>