From e7edb5c483a78e4a75d776460853fdb3f46d970f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 12 Aug 2026 11:41:37 -0300 Subject: [PATCH] Export production orders with missing data --- backend/routes/olistRoutes.js | 10 +++- .../productionOrderCsvImportService.js | 58 ++++++++++++++++++- .../productionOrderCsvImportService.test.js | 13 +++++ src/dataService.ts | 18 ++++++ src/pages/OlistSync.tsx | 23 +++++++- 5 files changed, 117 insertions(+), 5 deletions(-) diff --git a/backend/routes/olistRoutes.js b/backend/routes/olistRoutes.js index 791a0cd..ccd5bc9 100644 --- a/backend/routes/olistRoutes.js +++ b/backend/routes/olistRoutes.js @@ -9,7 +9,7 @@ const { requestOlistSyncStop, startOlistSync } = require('../services/olistService'); -const { importFinalizedProductionOrderCsv, listProductionOrderImportData } = require('../services/productionOrderCsvImportService'); +const { exportMissingProductionOrderDataCsv, importFinalizedProductionOrderCsv, listProductionOrderImportData } = require('../services/productionOrderCsvImportService'); const router = express.Router(); @@ -57,6 +57,14 @@ router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res, } }); +router.get('/olist/production-order-imports/pending-csv', verifySuperAdmin, async (req, res, next) => { + try { + res.json(await exportMissingProductionOrderDataCsv()); + } catch (error) { + next(error); + } +}); + router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => { try { res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {})); diff --git a/backend/services/productionOrderCsvImportService.js b/backend/services/productionOrderCsvImportService.js index 6e85dce..19db628 100644 --- a/backend/services/productionOrderCsvImportService.js +++ b/backend/services/productionOrderCsvImportService.js @@ -31,6 +31,11 @@ const normalizeHeader = (header) => String(header || '') .toLowerCase() .replace(/\s+/g, '_'); +const csvCell = (value) => { + const normalized = value === null || value === undefined ? '' : String(value); + return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized; +}; + const parseCsv = (csv) => { const rows = []; let row = []; @@ -129,6 +134,12 @@ const parseFinalizedProductionOrderCsv = (csv) => { const familyKey = productSku.split('.')[0]; const extracted = extractObservationFields(notes); + const supplier = normalizeText(value('fornecedor')) || extracted.supplier; + const lotCode = normalizeText(value('lote')) || extracted.lotCode; + const rollQuantity = normalizeNumber(value('quantidade_rolos')) || extracted.rollQuantity; + const fabricKg = normalizeNumber(value('quilos_malha')) || extracted.fabricKg; + const ribKg = normalizeNumber(value('quilos_ribana')) || extracted.ribKg; + const yieldPiecesPerKg = normalizeNumber(value('rendimento')) || extracted.yieldPiecesPerKg; records.push({ tinyId, number, @@ -140,7 +151,12 @@ const parseFinalizedProductionOrderCsv = (csv) => { orderReference: normalizeText(value('pedido_relacionado')), notes, familyKey: FAMILY_KEYS.has(familyKey) ? familyKey : null, - ...extracted + supplier, + lotCode, + rollQuantity, + fabricKg, + ribKg, + yieldPiecesPerKg }); }); @@ -195,6 +211,45 @@ const listProductionOrderImportData = async () => { }; }; +const exportMissingProductionOrderDataCsv = async () => { + const result = await pool.query(` + SELECT tiny_id, number, product_sku, product_description, quantity, issue_date, completed_date, notes, + order_reference, supplier, lot_code, roll_quantity, fabric_kg, rib_kg, yield_pieces_per_kg + FROM production_orders + WHERE status = 'finished' + AND ( + supplier IS NULL OR lot_code IS NULL OR roll_quantity IS NULL + OR fabric_kg IS NULL OR yield_pieces_per_kg IS NULL + ) + ORDER BY completed_date DESC NULLS LAST, id DESC; + `); + const headers = [ + 'id_op', 'numero_op', 'sku_produto', 'descricao_produto', 'quantidade_produzida', 'data_emissao', + 'data_conclusao', 'situacao', 'observacoes', 'pedido_relacionado', 'fornecedor', 'lote', + 'quantidade_rolos', 'quilos_malha', 'quilos_ribana', 'rendimento', 'campos_pendentes' + ]; + const rows = result.rows.map((row) => { + const missing = [ + !row.supplier && 'fornecedor', + !row.lot_code && 'lote', + !row.roll_quantity && 'quantidade_rolos', + !row.fabric_kg && 'quilos_malha', + !row.yield_pieces_per_kg && 'rendimento' + ].filter(Boolean).join(', '); + return [ + row.tiny_id, row.number, row.product_sku, row.product_description, row.quantity, row.issue_date, + row.completed_date, 'Finalizada', row.notes, row.order_reference, row.supplier, row.lot_code, + row.roll_quantity, row.fabric_kg, row.rib_kg, row.yield_pieces_per_kg, missing + ]; + }); + + return { + filename: `ops-finalizadas-pendentes-${new Date().toISOString().slice(0, 10)}.csv`, + totalRows: rows.length, + csv: [headers, ...rows].map(row => row.map(csvCell).join(',')).join('\r\n') + }; +}; + const importFinalizedProductionOrderCsv = async ({ filename, csv }) => { const parsed = parseFinalizedProductionOrderCsv(csv); const client = await pool.connect(); @@ -260,6 +315,7 @@ const importFinalizedProductionOrderCsv = async ({ filename, csv }) => { }; module.exports = { + exportMissingProductionOrderDataCsv, extractObservationFields, importFinalizedProductionOrderCsv, listProductionOrderImportData, diff --git a/backend/test/productionOrderCsvImportService.test.js b/backend/test/productionOrderCsvImportService.test.js index 59adc56..2028549 100644 --- a/backend/test/productionOrderCsvImportService.test.js +++ b/backend/test/productionOrderCsvImportService.test.js @@ -28,3 +28,16 @@ test('parseFinalizedProductionOrderCsv supports multiline observations and skips assert.equal(result.records[0].fabricKg, 577.9); assert.equal(result.records[0].yieldPiecesPerKg, 5.44); }); + +test('parseFinalizedProductionOrderCsv accepts structured production fields from a completed template', () => { + const csv = 'id_op,numero_op,sku_produto,descricao_produto,quantidade_produzida,data_emissao,data_conclusao,situacao,observacoes,fornecedor,lote,quantidade_rolos,quilos_malha,quilos_ribana,rendimento\n1,10,BLCS.PTO.G,Camiseta preta G,100,2026-08-01,2026-08-02,Finalizada,,TECEBEM,0245163,30,"577,90","28,89","5,44"'; + const result = parseFinalizedProductionOrderCsv(csv); + + assert.equal(result.records.length, 1); + assert.equal(result.records[0].supplier, 'TECEBEM'); + assert.equal(result.records[0].lotCode, '0245163'); + assert.equal(result.records[0].rollQuantity, 30); + assert.equal(result.records[0].fabricKg, 577.9); + assert.equal(result.records[0].ribKg, 28.89); + assert.equal(result.records[0].yieldPiecesPerKg, 5.44); +}); diff --git a/src/dataService.ts b/src/dataService.ts index 071b90f..386084d 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -321,6 +321,24 @@ export const fetchProductionOrderImportData = async (): Promise => { + const response = await authFetch('/olist/production-order-imports/pending-csv', { cache: 'no-store' }); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.csv || !data?.filename) { + throw new Error(data?.error || 'Não foi possível exportar as pendências das OPs.'); + } + + const url = URL.createObjectURL(new Blob([data.csv], { type: 'text/csv;charset=utf-8' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = data.filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + return Number(data.totalRows) || 0; +}; + export const importFinalizedProductionOrderCsv = async (filename: string, csv: string): Promise }> => { const response = await authFetch('/olist/production-order-imports', { method: 'POST', diff --git a/src/pages/OlistSync.tsx b/src/pages/OlistSync.tsx index 5b0fc67..28f6cf9 100644 --- a/src/pages/OlistSync.tsx +++ b/src/pages/OlistSync.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { AlertTriangle, CheckCircle2, CloudDownload, Database, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react'; -import { fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService'; +import { AlertTriangle, CheckCircle2, CloudDownload, Database, Download, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react'; +import { exportMissingProductionOrderDataCsv, fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService'; import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types'; const formatDateTime = (value: string | null) => { @@ -50,6 +50,7 @@ const OlistSync = () => { const [isDetailsLoading, setIsDetailsLoading] = useState(false); const [importData, setImportData] = useState(null); const [isImportingOrders, setIsImportingOrders] = useState(false); + const [isExportingPendingOrders, setIsExportingPendingOrders] = useState(false); const [importMessage, setImportMessage] = useState(''); const loadStatus = useCallback(async (initial = false) => { @@ -167,6 +168,19 @@ const OlistSync = () => { } }; + const exportPendingOrders = async () => { + setIsExportingPendingOrders(true); + setImportMessage(''); + try { + const totalRows = await exportMissingProductionOrderDataCsv(); + setImportMessage(totalRows ? `${totalRows.toLocaleString('pt-BR')} OPs com pendências exportadas para completar.` : 'Nenhuma OP finalizada possui pendências de dados.'); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : 'Não foi possível exportar as pendências das OPs.'); + } finally { + setIsExportingPendingOrders(false); + } + }; + const openRunDetails = (runId: number) => { const initialPages = { events: 1, products: 1 }; setDetailsPages(initialPages); @@ -189,6 +203,9 @@ const OlistSync = () => { +