diff --git a/backend/routes/supplyRoutes.js b/backend/routes/supplyRoutes.js index 45554f5..c97b278 100644 --- a/backend/routes/supplyRoutes.js +++ b/backend/routes/supplyRoutes.js @@ -1,7 +1,9 @@ const express = require('express'); const { verifyToken } = require('../auth'); const { + adjustInventoryLot, approveReceipt, + consumeLotForProduction, createFabricPlan, createReceipt, deleteFabricPlan, @@ -65,6 +67,22 @@ router.get('/supply/lots', verifyToken, async (req, res, next) => { } }); +router.post('/supply/lots/:id/inventory-adjustment', verifyToken, async (req, res, next) => { + try { + res.json(await adjustInventoryLot(req.params.id, req.body || {})); + } catch (error) { + next(error); + } +}); + +router.post('/supply/lots/:id/production-exit', verifyToken, async (req, res, next) => { + try { + res.json(await consumeLotForProduction(req.params.id, req.body || {})); + } catch (error) { + next(error); + } +}); + router.get('/supply/movements', verifyToken, async (req, res, next) => { try { res.json(await listMovements()); diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 3b5c54c..7d1e7d6 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -8,6 +8,12 @@ const normalizeNumber = (value) => { return Number.isFinite(number) && number > 0 ? number : null; }; +const normalizeNonNegativeNumber = (value) => { + if (value === '' || value === null || value === undefined) return null; + const number = Number(String(value).replace(',', '.')); + return Number.isFinite(number) && number >= 0 ? number : null; +}; + const normalizeKey = (value) => normalizeText(value) .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') @@ -377,8 +383,131 @@ const deleteFabricPlan = async (id) => { `, [id]); }; +const updateLotQuantity = async (client, lotId, quantity) => { + const status = quantity > 0 ? 'active' : 'depleted'; + const result = await client.query(` + UPDATE supply_stock_lots + SET quantity = $2, + status = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at + `, [lotId, quantity, status]); + + return mapLot(result.rows[0]); +}; + +const adjustInventoryLot = async (id, payload) => { + const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity); + const reason = normalizeText(payload.reason); + + if (countedQuantity === null) throw createValidationError('Quantidade contada deve ser zero ou maior.'); + if (!reason) throw createValidationError('Justificativa do ajuste é obrigatória.'); + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + const lotResult = await client.query(` + SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at + FROM supply_stock_lots + WHERE id = $1 + FOR UPDATE + `, [id]); + + if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.'); + + const lot = lotResult.rows[0]; + const currentQuantity = Number(lot.quantity); + const difference = countedQuantity - currentQuantity; + const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity); + + await client.query(` + INSERT INTO supply_movements ( + lot_id, type, category, product, quantity, unit, reason + ) + VALUES ($1, 'inventory_adjustment', $2, $3, $4, $5, $6) + `, [ + lot.id, + lot.category, + lot.product, + difference, + lot.unit, + `${reason} · sistema ${currentQuantity} ${lot.unit} · contado ${countedQuantity} ${lot.unit}` + ]); + + await client.query('COMMIT'); + return updatedLot; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +const consumeLotForProduction = async (id, payload) => { + const quantity = normalizeNumber(payload.quantity); + const reason = normalizeText(payload.reason); + const productionOrderNumber = normalizeText(payload.productionOrderNumber); + + if (!quantity) throw createValidationError('Quantidade de saída deve ser maior que zero.'); + if (!productionOrderNumber && !reason) throw createValidationError('Informe a OP ou uma justificativa para a saída.'); + + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + const lotResult = await client.query(` + SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at + FROM supply_stock_lots + WHERE id = $1 + FOR UPDATE + `, [id]); + + if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.'); + + const lot = lotResult.rows[0]; + const currentQuantity = Number(lot.quantity); + 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.'); + + const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity); + const movementReason = [ + productionOrderNumber ? `OP ${productionOrderNumber}` : '', + reason + ].filter(Boolean).join(' · '); + + await client.query(` + INSERT INTO supply_movements ( + lot_id, type, category, product, quantity, unit, reason + ) + VALUES ($1, 'production_exit', $2, $3, $4, $5, $6) + `, [ + lot.id, + lot.category, + lot.product, + -quantity, + lot.unit, + movementReason || 'Saída para produção' + ]); + + await client.query('COMMIT'); + return updatedLot; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + module.exports = { + adjustInventoryLot, approveReceipt, + consumeLotForProduction, createFabricPlan, createReceipt, deleteFabricPlan, diff --git a/src/dataService.ts b/src/dataService.ts index 05dc078..be05375 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, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -399,6 +399,36 @@ export const fetchSupplyPurchaseNeeds = async (): Promise } }; +export const adjustSupplyLotInventory = async (lotId: number, payload: SupplyInventoryAdjustmentPayload): Promise => { + const response = await authFetch(`/supply/lots/${lotId}/inventory-adjustment`, { + 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 ajustar o inventário.'); + } + + return data as SupplyLot; +}; + +export const consumeSupplyLotForProduction = async (lotId: number, payload: SupplyProductionExitPayload): Promise => { + const response = await authFetch(`/supply/lots/${lotId}/production-exit`, { + 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 registrar a saída para produção.'); + } + + return data as SupplyLot; +}; + export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise => { const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`; return getCachedAnalytics(path, async () => { diff --git a/src/pages/ProductionOrders.tsx b/src/pages/ProductionOrders.tsx index 2af1822..94afe73 100644 --- a/src/pages/ProductionOrders.tsx +++ b/src/pages/ProductionOrders.tsx @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import RefreshStatus from '../components/RefreshStatus'; -import { exportToCSV, fetchProductionOrders } from '../dataService'; -import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary } from '../types'; +import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary } from '../dataService'; +import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types'; type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled'; @@ -117,9 +117,18 @@ const ProductionOrders = () => { const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [summary, setSummary] = useState(emptySummary); + const [supplyLots, setSupplyLots] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isSupplyBusy, setIsSupplyBusy] = useState(false); + const [supplyMessage, setSupplyMessage] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(20); + const [exitForm, setExitForm] = useState({ + orderId: '', + lotId: '', + quantity: '', + reason: '', + }); const loadProductionOrders = useCallback(async (options?: { force?: boolean }) => { setIsLoading(true); @@ -133,9 +142,13 @@ const ProductionOrders = () => { const load = async () => { setIsLoading(true); - const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }); + const [nextSummary, nextSupplySummary] = await Promise.all([ + fetchProductionOrders(dateRange, { search: searchTerm }), + fetchSupplySummary() + ]); if (isMounted) { setSummary(nextSummary); + setSupplyLots(nextSupplySummary.lots); setIsLoading(false); } }; @@ -177,6 +190,38 @@ const ProductionOrders = () => { void loadProductionOrders({ force: true }); }; + const refreshSupplyLots = async () => { + const supplySummary = await fetchSupplySummary(); + setSupplyLots(supplySummary.lots); + }; + + const selectedOrder = summary.orders.find(order => `${order.id}` === exitForm.orderId); + const selectedLot = supplyLots.find(lot => `${lot.id}` === exitForm.lotId); + + const handleProductionExit = async (event: FormEvent) => { + event.preventDefault(); + const lotId = Number(exitForm.lotId); + const quantity = Number(exitForm.quantity.replace(',', '.')); + if (!lotId || !selectedOrder || !Number.isFinite(quantity) || quantity <= 0) return; + + setIsSupplyBusy(true); + setSupplyMessage(''); + try { + await consumeSupplyLotForProduction(lotId, { + quantity, + productionOrderNumber: selectedOrder.number || `${selectedOrder.id}`, + reason: exitForm.reason.trim() || selectedOrder.productDescription, + }); + await refreshSupplyLots(); + setExitForm({ orderId: '', lotId: '', quantity: '', reason: '' }); + setSupplyMessage('Saída de material registrada no estoque.'); + } catch (error) { + setSupplyMessage(error instanceof Error ? error.message : 'Não foi possível baixar o material.'); + } finally { + setIsSupplyBusy(false); + } + }; + const handleExport = () => { const exportData = filteredOrders.map(order => ({ 'Numero': order.number, @@ -274,6 +319,84 @@ const ProductionOrders = () => { +
+
+
+

Baixa de material da OP

+

Consome um lote do estoque e registra a saída nas movimentações.

+
+ {selectedLot && ( + + Saldo lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit} + + )} +
+
+ + + + + +
+ {supplyMessage && ( +
+ {supplyMessage} +
+ )} +
+
diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index 4e8fe93..9d4e2a0 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -21,7 +21,7 @@ import { Trash2, Warehouse, } from 'lucide-react'; -import { approveSupplyReceipt, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService'; +import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService'; import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types'; type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements'; @@ -547,13 +547,33 @@ const ReceiptsTab = ({ ); }; -const InventoryCountTab = ({ lots, onRefresh }: { lots: SupplyLot[]; onRefresh: () => void }) => { +const InventoryCountTab = ({ + lots, + onAdjust, + onRefresh, +}: { + lots: SupplyLot[]; + onAdjust: (lotId: number, countedQuantity: number, reason: string) => Promise; + onRefresh: () => void; +}) => { const [search, setSearch] = useState(''); + const [adjustments, setAdjustments] = useState>({}); const normalizedSearch = normalizeSearch(search); const visibleLots = lots.filter(lot => ( !normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lot.supplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch) )); + const updateAdjustment = (lotId: number, patch: Partial<{ countedQuantity: string; reason: string }>) => { + setAdjustments(current => ({ + ...current, + [lotId]: { + countedQuantity: current[lotId]?.countedQuantity ?? '', + reason: current[lotId]?.reason ?? '', + ...patch, + }, + })); + }; + return (
+
))}
@@ -604,10 +660,91 @@ const InventoryCountTab = ({ lots, onRefresh }: { lots: SupplyLot[]; onRefresh: const movementLabels: Record = { receipt: 'Entrada por recebimento', inventory_adjustment: 'Ajuste de inventário', + production_exit: 'Saída para produção', reversal: 'Estorno', }; -const MovementsTab = ({ movements }: { movements: SupplyMovement[] }) => { +const ProductionExitPanel = ({ + lots, + onConsume, +}: { + lots: SupplyLot[]; + onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise; +}) => { + const [form, setForm] = useState({ + lotId: '', + quantity: '', + productionOrderNumber: '', + reason: '', + }); + + const selectedLot = lots.find(lot => `${lot.id}` === form.lotId); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const lotId = Number(form.lotId); + const quantity = parseDecimal(form.quantity); + if (!lotId || quantity <= 0) return; + + await onConsume(lotId, quantity, form.productionOrderNumber.trim(), form.reason.trim()); + setForm({ lotId: '', quantity: '', productionOrderNumber: '', reason: '' }); + }; + + return ( +
+
+
+

Saída para produção

+

Baixa material de um lote e registra movimentação ligada à OP.

+
+ {selectedLot && ( + + Saldo: {formatNumber(selectedLot.quantity)} {selectedLot.unit} + + )} +
+
+ + + + + +
+
+ ); +}; + +const MovementsTab = ({ + lots, + movements, + onConsume, +}: { + lots: SupplyLot[]; + movements: SupplyMovement[]; + onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise; +}) => { const [search, setSearch] = useState(''); const [type, setType] = useState('all'); const movementTypes = Array.from(new Set(movements.map(movement => movement.type))).sort(); @@ -620,6 +757,7 @@ const MovementsTab = ({ movements }: { movements: SupplyMovement[] }) => { return (
+
diff --git a/src/types.ts b/src/types.ts index 84d3b38..265f043 100644 --- a/src/types.ts +++ b/src/types.ts @@ -270,6 +270,17 @@ export type SupplyFabricPlanPayload = { priority?: string; }; +export type SupplyInventoryAdjustmentPayload = { + countedQuantity: number | string; + reason: string; +}; + +export type SupplyProductionExitPayload = { + quantity: number | string; + productionOrderNumber?: string; + reason?: string; +}; + export interface DateRange { start: Date; end: Date;