From 88089343a8941e7d2d0a9e0b75600f8010b920fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 20 Jul 2026 12:57:28 -0300 Subject: [PATCH] Add local production order workflow --- backend/routes/productionOrderRoutes.js | 21 ++- backend/services/productionOrderService.js | 185 ++++++++++++++++++++- src/dataService.ts | 26 ++- src/pages/Cutting.tsx | 86 +++++++++- src/pages/ProductionOrders.tsx | 50 +++++- src/types.ts | 29 ++++ 6 files changed, 384 insertions(+), 13 deletions(-) diff --git a/backend/routes/productionOrderRoutes.js b/backend/routes/productionOrderRoutes.js index 3b94dd5..b066a9a 100644 --- a/backend/routes/productionOrderRoutes.js +++ b/backend/routes/productionOrderRoutes.js @@ -1,6 +1,6 @@ const express = require('express'); const { verifyToken } = require('../auth'); -const { listProductionOrders } = require('../services/productionOrderService'); +const { createProductionOrders, listProductionOrders, updateProductionOrderStatus } = require('../services/productionOrderService'); const router = express.Router(); @@ -13,4 +13,23 @@ 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)); + } catch (error) { + console.error('Error creating production orders:', error); + res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' }); + } +}); + +router.patch('/production-orders/:id/status', verifyToken, async (req, res) => { + try { + res.json(await updateProductionOrderStatus(req.params.id, req.body?.status)); + } catch (error) { + console.error('Error updating production order status:', error); + res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' }); + } +}); + module.exports = router; diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index db21de4..4089814 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -31,6 +31,14 @@ const formatDate = (value) => { return String(value).slice(0, 10); }; +const normalizeText = (value) => String(value || '').trim(); + +const normalizeQuantity = (value) => { + const quantity = Number(value); + if (!Number.isFinite(quantity) || quantity <= 0) return 0; + return quantity; +}; + const mapProductionOrderRow = (row) => { const status = normalizeStatus(row.status); @@ -54,6 +62,42 @@ const mapProductionOrderRow = (row) => { }; }; +const getOrderById = async (id, client = pool) => { + const result = await client.query(` + SELECT + po.id, + po.tiny_id, + po.number, + po.status, + po.order_reference, + po.issue_date, + po.expected_date, + po.product_sku, + po.product_description, + po.quantity, + po.unit, + po.integration_status, + po.created_at, + po.updated_at, + COALESCE( + JSON_AGG( + JSON_BUILD_OBJECT( + 'label', pom.label, + 'color', pom.color + ) + ORDER BY pom.label + ) FILTER (WHERE pom.id IS NOT NULL), + '[]'::json + ) as markers + FROM production_orders po + LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id + WHERE po.id = $1 + GROUP BY po.id; + `, [id]); + + return result.rows[0] ? mapProductionOrderRow(result.rows[0]) : null; +}; + const listProductionOrders = async (filters = {}) => { const params = []; const where = []; @@ -133,7 +177,142 @@ const listProductionOrders = async (filters = {}) => { return { orders, counts }; }; -module.exports = { - listProductionOrders, - normalizeStatus +const createProductionOrders = async (orders = []) => { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + const created = []; + const skipped = []; + + for (const order of orders) { + const productDescription = normalizeText(order.productDescription); + const productSku = normalizeText(order.productSku); + const orderReference = normalizeText(order.orderReference); + const quantity = normalizeQuantity(order.quantity); + + if (!productDescription || !quantity) { + skipped.push({ productSku, productDescription, reason: 'invalid_payload' }); + continue; + } + + const duplicateResult = await client.query(` + SELECT id + FROM production_orders + WHERE COALESCE(order_reference, '') = $1 + AND COALESCE(product_sku, '') = $2 + AND status IN ('open', 'in_progress') + LIMIT 1; + `, [orderReference, productSku]); + + if (duplicateResult.rows.length) { + skipped.push({ productSku, productDescription, reason: 'duplicate_open_order' }); + continue; + } + + const insertResult = await client.query(` + INSERT INTO production_orders ( + tiny_id, + number, + status, + order_reference, + issue_date, + expected_date, + product_sku, + product_description, + quantity, + unit, + integration_status, + tiny_payload + ) + VALUES ( + NULL, + NULL, + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10::jsonb + ) + RETURNING id; + `, [ + normalizeStatus(order.status || 'open'), + orderReference, + normalizeDateParam(order.issueDate) || formatDate(new Date()), + normalizeDateParam(order.expectedDate), + productSku, + productDescription, + quantity, + normalizeText(order.unit) || 'UN', + normalizeText(order.integrationStatus) || 'Local', + JSON.stringify(order.metadata || {}) + ]); + + const orderId = insertResult.rows[0].id; + await client.query(` + UPDATE production_orders + SET number = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2; + `, [normalizeText(order.number) || `OP-${String(orderId).padStart(5, '0')}`, orderId]); + + const markers = Array.isArray(order.markers) ? order.markers : []; + for (const marker of markers) { + const label = normalizeText(marker.label); + if (!label) continue; + await client.query(` + INSERT INTO production_order_markers (production_order_id, label, color) + VALUES ($1, $2, $3) + ON CONFLICT (production_order_id, label) DO UPDATE SET color = EXCLUDED.color; + `, [orderId, label, normalizeText(marker.color) || null]); + } + + const createdOrder = await getOrderById(orderId, client); + if (createdOrder) created.push(createdOrder); + } + + await client.query('COMMIT'); + return { created, skipped }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +const updateProductionOrderStatus = async (id, status) => { + const normalizedStatus = normalizeStatus(status); + + if (!STATUS_LABELS[normalizedStatus]) { + const error = new Error('Invalid production order status'); + error.statusCode = 400; + throw error; + } + + const result = await pool.query(` + UPDATE production_orders + SET status = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + RETURNING id; + `, [normalizedStatus, id]); + + if (!result.rows.length) { + const error = new Error('Production order not found'); + error.statusCode = 404; + throw error; + } + + return getOrderById(id); +}; + +module.exports = { + createProductionOrders, + listProductionOrders, + normalizeStatus, + updateProductionOrderStatus }; diff --git a/src/dataService.ts b/src/dataService.ts index be05375..e02a2d5 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, SupplyInventoryAdjustmentPayload, SupplyLot, 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, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, 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'; @@ -183,6 +183,30 @@ export const fetchProductionOrders = async ( }, options); }; +export const createProductionOrders = async (orders: ProductionOrderPayload[]): Promise => { + const response = await authFetch('/production-orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orders }) + }); + + if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.'); + analyticsCache.clear(); + return await response.json(); +}; + +export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise => { + const response = await authFetch(`/production-orders/${id}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }) + }); + + if (!response.ok) throw new Error('Não foi possível atualizar o status da ordem.'); + analyticsCache.clear(); + return await response.json(); +}; + export const fetchCuttingSettings = async (): Promise => { try { const response = await authFetch('/cutting-settings'); diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 5a2c815..2e1bd31 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -7,7 +7,7 @@ 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 { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService'; +import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; type CutFilter = 'need' | 'all' | 'issues' | 'covered'; @@ -65,6 +65,13 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => ( new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value) ); +const formatDateKey = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + const formatDays = (value: number | null) => { if (value === null) return '-'; if (value > 999) return '999+ dias'; @@ -135,6 +142,8 @@ const Cutting = () => { const [correctionPage, setCorrectionPage] = useState(1); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); + const [isGeneratingOrders, setIsGeneratingOrders] = useState(false); + const [generationMessage, setGenerationMessage] = useState(''); const cutConfigSection = searchParams.get('config'); const targetCorrectionSku = (searchParams.get('sku') || '').trim(); @@ -241,6 +250,10 @@ const Cutting = () => { }); }, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]); + const rowsAvailableForOrder = useMemo(() => ( + filteredRows.filter(row => row.suggestedCutQuantity > 0) + ), [filteredRows]); + const totalPages = Math.ceil(filteredRows.length / itemsPerPage); const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * itemsPerPage; @@ -400,6 +413,60 @@ const Cutting = () => { })), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`); }; + const generateProductionOrders = async () => { + if (!rowsAvailableForOrder.length) { + setGenerationMessage('Não há necessidade de corte no filtro atual.'); + return; + } + + setIsGeneratingOrders(true); + setGenerationMessage(''); + + const issueDate = formatDateKey(new Date()); + const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`; + + try { + const result = await createProductionOrders(rowsAvailableForOrder.map(row => ({ + status: 'open', + orderReference, + issueDate, + productSku: row.id, + productDescription: row.name, + quantity: row.suggestedCutQuantity, + unit: 'UN', + integrationStatus: 'Local', + markers: [ + { label: row.family.materialLabel, color: '#38bdf8' }, + ...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []), + { label: 'Material pendente', color: '#facc15' }, + ...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : []) + ], + metadata: { + source: 'cut_plan', + familyKey: row.family.key, + familyLabel: row.family.label, + materialLabel: row.family.materialLabel, + color: row.color, + size: row.size, + targetCoverageDays, + projectedDemand: row.projectedDemand, + stock: row.stock, + openProductionQuantity: row.openProductionQuantity, + availableQuantity: row.availableQuantity, + issues: row.issues + } + }))); + + const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true }); + setProductionOrders(refreshedOrders.orders); + setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`); + } catch (error) { + setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.'); + } finally { + setIsGeneratingOrders(false); + } + }; + const renderIssueBadge = (row: CutPlanSkuRow) => { if (!row.issues.length) { return OK; @@ -444,6 +511,17 @@ const Cutting = () => { }} /> + +