From 6d3a1d6b1a11debf05153cc32755449f25cffb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 12 Aug 2026 10:57:18 -0300 Subject: [PATCH] Import finalized Olist production orders --- backend/db.js | 37 +++ backend/routes/olistRoutes.js | 17 ++ backend/server.js | 2 +- .../productionOrderCsvImportService.js | 268 ++++++++++++++++++ backend/services/productionOrderService.js | 3 + .../productionOrderCsvImportService.test.js | 30 ++ src/dataService.ts | 20 +- src/pages/Cutting.tsx | 19 +- src/pages/OlistSync.tsx | 73 ++++- src/types.ts | 32 +++ 10 files changed, 494 insertions(+), 7 deletions(-) create mode 100644 backend/services/productionOrderCsvImportService.js create mode 100644 backend/test/productionOrderCsvImportService.test.js diff --git a/backend/db.js b/backend/db.js index 67473ec..2e9b117 100644 --- a/backend/db.js +++ b/backend/db.js @@ -454,6 +454,41 @@ const initDB = async () => { ADD COLUMN IF NOT EXISTS yield_pieces_per_kg NUMERIC(14, 4); `).catch(() => {}); + await pool.query(` + ALTER TABLE production_orders + ADD COLUMN IF NOT EXISTS completed_date DATE; + `).catch(() => {}); + + await pool.query(` + CREATE TABLE IF NOT EXISTS production_order_import_runs ( + id SERIAL PRIMARY KEY, + filename TEXT NOT NULL, + status VARCHAR(40) NOT NULL DEFAULT 'running', + total_rows INTEGER NOT NULL DEFAULT 0, + imported_count INTEGER NOT NULL DEFAULT 0, + updated_count INTEGER NOT NULL DEFAULT 0, + skipped_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMPTZ + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS production_yield_recommendations ( + family_key VARCHAR(20) PRIMARY KEY, + sample_count INTEGER NOT NULL DEFAULT 0, + yield_sample_count INTEGER NOT NULL DEFAULT 0, + roll_sample_count INTEGER NOT NULL DEFAULT 0, + avg_yield_pieces_per_kg NUMERIC(14, 4), + avg_kg_per_roll NUMERIC(14, 4), + recommended_units_per_roll NUMERIC(14, 4), + last_completed_date DATE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + await pool.query(` ALTER TABLE production_orders ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', @@ -627,6 +662,8 @@ const initDB = async () => { await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_status ON production_orders (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_completed_date ON production_orders (completed_date DESC);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_import_runs_started_at ON production_order_import_runs (started_at DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_order_id ON production_order_components (production_order_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_sku ON production_order_components (component_sku);`); diff --git a/backend/routes/olistRoutes.js b/backend/routes/olistRoutes.js index fb12b3f..791a0cd 100644 --- a/backend/routes/olistRoutes.js +++ b/backend/routes/olistRoutes.js @@ -9,6 +9,7 @@ const { requestOlistSyncStop, startOlistSync } = require('../services/olistService'); +const { importFinalizedProductionOrderCsv, listProductionOrderImportData } = require('../services/productionOrderCsvImportService'); const router = express.Router(); @@ -48,6 +49,22 @@ router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => { } }); +router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => { + try { + res.json(await listProductionOrderImportData()); + } catch (error) { + next(error); + } +}); + +router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => { + try { + res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {})); + } catch (error) { + next(error); + } +}); + router.get('/olist/runs/:runId', verifySuperAdmin, async (req, res, next) => { try { res.json(await getOlistRunDetails(req.params.runId, { diff --git a/backend/server.js b/backend/server.js index c2c22d4..1822953 100644 --- a/backend/server.js +++ b/backend/server.js @@ -19,7 +19,7 @@ const createApp = () => { const app = express(); app.use(cors()); - app.use(bodyParser.json({ limit: '10mb' })); + app.use(bodyParser.json({ limit: '25mb' })); app.use('/api', authRoutes); app.use('/api', dataRoutes); diff --git a/backend/services/productionOrderCsvImportService.js b/backend/services/productionOrderCsvImportService.js new file mode 100644 index 0000000..6e85dce --- /dev/null +++ b/backend/services/productionOrderCsvImportService.js @@ -0,0 +1,268 @@ +const { pool } = require('../db'); + +const FAMILY_KEYS = new Set(['BLCS', 'BLOS', 'BLMC', 'BLPM']); +const REQUIRED_HEADERS = ['id_op', 'numero_op', 'sku_produto', 'descricao_produto', 'quantidade_produzida', 'data_emissao', 'data_conclusao', 'situacao', 'observacoes']; + +const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + +const normalizeNumber = (value) => { + const normalized = String(value || '').trim().replace(/\s/g, ''); + if (!normalized) return null; + const decimal = normalized.includes(',') + ? normalized.replace(/\./g, '').replace(',', '.') + : normalized; + const number = Number(decimal); + return Number.isFinite(number) && number > 0 ? number : null; +}; + +const normalizeDate = (value) => { + const raw = String(value || '').trim(); + const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; + const br = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); + return br ? `${br[3]}-${br[2].padStart(2, '0')}-${br[1].padStart(2, '0')}` : null; +}; + +const normalizeHeader = (header) => String(header || '') + .replace(/^\uFEFF/, '') + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .trim() + .toLowerCase() + .replace(/\s+/g, '_'); + +const parseCsv = (csv) => { + const rows = []; + let row = []; + let field = ''; + let quoted = false; + + for (let index = 0; index < csv.length; index += 1) { + const character = csv[index]; + const next = csv[index + 1]; + if (character === '"') { + if (quoted && next === '"') { + field += '"'; + index += 1; + } else { + quoted = !quoted; + } + } else if (character === ',' && !quoted) { + row.push(field); + field = ''; + } else if ((character === '\n' || character === '\r') && !quoted) { + if (character === '\r' && next === '\n') index += 1; + row.push(field); + if (row.some(value => value.length > 0)) rows.push(row); + row = []; + field = ''; + } else { + field += character; + } + } + + row.push(field); + if (row.some(value => value.length > 0)) rows.push(row); + if (quoted) throw new Error('CSV possui aspas não finalizadas.'); + return rows; +}; + +const extractObservationFields = (notes) => { + const raw = String(notes || ''); + const lineValue = (pattern) => raw.match(pattern)?.[1]?.trim() || ''; + const supplier = lineValue(/(?:^|\n)\s*FORNECEDOR\s*:\s*([^\r\n]+)/i); + const lotCode = lineValue(/(?:^|\n)\s*LOTE\s*:\s*([^\r\n]+)/i); + const yieldPiecesPerKg = normalizeNumber(raw.match(/RENDIMENTO\s*:\s*([\d.,]+)/i)?.[1]); + const explicitRolls = normalizeNumber(raw.match(/(?:QUANTIDADE\s+DE\s+ROLOS|ROLOS|BOBINAS)\s*:\s*([\d.,]+)/i)?.[1]); + const fabricKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+MALHA|QUILOS?\s+MALHA)\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]) + || normalizeNumber(raw.match(/(?:ROLOS|BOBINAS)\s*:\s*[\d.,]+\s*[-–]\s*([\d.,]+)\s*KG/i)?.[1]); + const ribKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+(?:GOLA|RIBANA|VIEZ(?:\s*\([^)]*\))?))\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]); + + return { supplier, lotCode, yieldPiecesPerKg, rollQuantity: explicitRolls, fabricKg, ribKg }; +}; + +const parseFinalizedProductionOrderCsv = (csv) => { + if (typeof csv !== 'string' || !csv.trim()) { + const error = new Error('Selecione um arquivo CSV de OPs finalizadas.'); + error.statusCode = 400; + throw error; + } + + const rows = parseCsv(csv); + if (rows.length < 2) { + const error = new Error('O CSV não possui OPs para importar.'); + error.statusCode = 400; + throw error; + } + + const headers = rows[0].map(normalizeHeader); + const missingHeaders = REQUIRED_HEADERS.filter(header => !headers.includes(header)); + if (missingHeaders.length) { + const error = new Error(`CSV sem colunas obrigatórias: ${missingHeaders.join(', ')}.`); + error.statusCode = 400; + throw error; + } + + const headerIndex = Object.fromEntries(headers.map((header, index) => [header, index])); + const records = []; + const issues = []; + + rows.slice(1).forEach((row, rowOffset) => { + const line = rowOffset + 2; + const value = (header) => row[headerIndex[header]] || ''; + const tinyId = normalizeText(value('id_op')); + const number = normalizeText(value('numero_op')); + const productSku = normalizeText(value('sku_produto')).toUpperCase(); + const productDescription = normalizeText(value('descricao_produto')); + const quantity = normalizeNumber(value('quantidade_produzida')); + const status = normalizeText(value('situacao')).toLowerCase(); + const notes = String(value('observacoes') || '').trim(); + + if (!tinyId || !number || !productSku || !productDescription || !quantity) { + issues.push({ line, message: 'OP sem identificação, SKU, produto ou quantidade válida.' }); + return; + } + if (!/^finalizad[ao]$/.test(status)) { + issues.push({ line, number, message: 'Ignorada: a OP não está Finalizada.' }); + return; + } + + const familyKey = productSku.split('.')[0]; + const extracted = extractObservationFields(notes); + records.push({ + tinyId, + number, + productSku, + productDescription, + quantity, + issueDate: normalizeDate(value('data_emissao')), + completedDate: normalizeDate(value('data_conclusao')), + orderReference: normalizeText(value('pedido_relacionado')), + notes, + familyKey: FAMILY_KEYS.has(familyKey) ? familyKey : null, + ...extracted + }); + }); + + return { records, issues, totalRows: rows.length - 1 }; +}; + +const refreshYieldRecommendations = async (client) => { + await client.query('DELETE FROM production_yield_recommendations;'); + await client.query(` + INSERT INTO production_yield_recommendations ( + family_key, sample_count, yield_sample_count, roll_sample_count, + avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, + last_completed_date, updated_at + ) + SELECT + split_part(product_sku, '.', 1) AS family_key, + COUNT(*)::int, + COUNT(yield_pieces_per_kg)::int, + COUNT(*) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0)::int, + AVG(yield_pieces_per_kg), + AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0), + AVG(yield_pieces_per_kg) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0) + * AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0), + MAX(completed_date), + CURRENT_TIMESTAMP + FROM production_orders + WHERE status = 'finished' + AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM') + GROUP BY split_part(product_sku, '.', 1); + `); +}; + +const listProductionOrderImportData = async () => { + const [runs, recommendations] = await Promise.all([ + pool.query(`SELECT id, filename, status, total_rows, imported_count, updated_count, skipped_count, failed_count, error_message, started_at, completed_at FROM production_order_import_runs ORDER BY started_at DESC LIMIT 10;`), + pool.query(`SELECT family_key, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_recommendations ORDER BY family_key;`) + ]); + return { + runs: runs.rows.map(row => ({ + id: row.id, filename: row.filename, status: row.status, totalRows: row.total_rows, + imported: row.imported_count, updated: row.updated_count, skipped: row.skipped_count, + failed: row.failed_count, errorMessage: row.error_message || '', startedAt: row.started_at, + completedAt: row.completed_at + })), + recommendations: recommendations.rows.map(row => ({ + familyKey: row.family_key, sampleCount: Number(row.sample_count), yieldSampleCount: Number(row.yield_sample_count), + rollSampleCount: Number(row.roll_sample_count), avgYieldPiecesPerKg: row.avg_yield_pieces_per_kg === null ? null : Number(row.avg_yield_pieces_per_kg), + avgKgPerRoll: row.avg_kg_per_roll === null ? null : Number(row.avg_kg_per_roll), + recommendedUnitsPerRoll: row.recommended_units_per_roll === null ? null : Number(row.recommended_units_per_roll), + lastCompletedDate: row.last_completed_date || null, updatedAt: row.updated_at + })) + }; +}; + +const importFinalizedProductionOrderCsv = async ({ filename, csv }) => { + const parsed = parseFinalizedProductionOrderCsv(csv); + const client = await pool.connect(); + const safeFilename = normalizeText(filename) || 'ops-finalizadas.csv'; + let runId; + let imported = 0; + let updated = 0; + let failed = 0; + + try { + await client.query('BEGIN'); + const run = await client.query(` + INSERT INTO production_order_import_runs (filename, status, total_rows, skipped_count) + VALUES ($1, 'running', $2, $3) + RETURNING id; + `, [safeFilename, parsed.totalRows, parsed.issues.length]); + runId = run.rows[0].id; + + for (const record of parsed.records) { + try { + await client.query('SAVEPOINT production_order_import_record;'); + const existing = await client.query('SELECT id FROM production_orders WHERE tiny_id = $1 OR number = $2 LIMIT 1;', [record.tinyId, record.number]); + const payload = JSON.stringify({ source: 'olist_finalized_op_csv', importedAt: new Date().toISOString(), record }); + if (existing.rows[0]) { + await client.query(` + UPDATE production_orders SET tiny_id = $1, number = $2, status = 'finished', order_reference = NULLIF($3, ''), issue_date = $4, + completed_date = $5, product_sku = $6, product_description = $7, quantity = $8, unit = 'UN', integration_status = 'Olist CSV', + notes = NULLIF($9, ''), supplier = NULLIF($10, ''), lot_code = NULLIF($11, ''), roll_quantity = $12, fabric_kg = $13, rib_kg = $14, + yield_pieces_per_kg = $15, tiny_payload = $16::jsonb, updated_at = CURRENT_TIMESTAMP + WHERE id = $17; + `, [record.tinyId, record.number, record.orderReference, record.issueDate, record.completedDate, record.productSku, record.productDescription, record.quantity, record.notes, record.supplier, record.lotCode, record.rollQuantity, record.fabricKg, record.ribKg, record.yieldPiecesPerKg, payload, existing.rows[0].id]); + updated += 1; + } else { + await client.query(` + INSERT INTO production_orders (tiny_id, number, status, order_reference, issue_date, completed_date, product_sku, product_description, quantity, unit, integration_status, notes, supplier, lot_code, roll_quantity, fabric_kg, rib_kg, yield_pieces_per_kg, tiny_payload) + VALUES ($1, $2, 'finished', NULLIF($3, ''), $4, $5, $6, $7, $8, 'UN', 'Olist CSV', NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), $12, $13, $14, $15, $16::jsonb); + `, [record.tinyId, record.number, record.orderReference, record.issueDate, record.completedDate, record.productSku, record.productDescription, record.quantity, record.notes, record.supplier, record.lotCode, record.rollQuantity, record.fabricKg, record.ribKg, record.yieldPiecesPerKg, payload]); + imported += 1; + } + await client.query('RELEASE SAVEPOINT production_order_import_record;'); + } catch (error) { + await client.query('ROLLBACK TO SAVEPOINT production_order_import_record;'); + failed += 1; + parsed.issues.push({ number: record.number, message: error.message }); + } + } + + await refreshYieldRecommendations(client); + await client.query(` + UPDATE production_order_import_runs + SET status = $1, imported_count = $2, updated_count = $3, skipped_count = $4, failed_count = $5, error_message = $6, completed_at = CURRENT_TIMESTAMP + WHERE id = $7; + `, [failed ? 'completed_with_errors' : 'completed', imported, updated, parsed.issues.length, failed, parsed.issues.slice(0, 20).map(issue => `OP ${issue.number || `linha ${issue.line}`}: ${issue.message}`).join('\n') || null, runId]); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + + return { runId, totalRows: parsed.totalRows, imported, updated, skipped: parsed.issues.length, failed, issues: parsed.issues.slice(0, 20), ...(await listProductionOrderImportData()) }; +}; + +module.exports = { + extractObservationFields, + importFinalizedProductionOrderCsv, + listProductionOrderImportData, + parseCsv, + parseFinalizedProductionOrderCsv +}; diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index 6948cee..b2b4637 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -254,6 +254,7 @@ const mapProductionOrderRow = (row) => { orderReference: row.order_reference || '', issueDate: formatDate(row.issue_date), expectedDate: formatDate(row.expected_date), + completedDate: formatDate(row.completed_date), productSku: row.product_sku || '', productDescription: row.product_description || '', quantity: Number(row.quantity || 0), @@ -284,6 +285,7 @@ const getOrderById = async (id, client = pool) => { po.order_reference, po.issue_date, po.expected_date, + po.completed_date, po.product_sku, po.product_description, po.quantity, @@ -366,6 +368,7 @@ const baseProductionOrderSelect = ` po.order_reference, po.issue_date, po.expected_date, + po.completed_date, po.product_sku, po.product_description, po.quantity, diff --git a/backend/test/productionOrderCsvImportService.test.js b/backend/test/productionOrderCsvImportService.test.js new file mode 100644 index 0000000..59adc56 --- /dev/null +++ b/backend/test/productionOrderCsvImportService.test.js @@ -0,0 +1,30 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { extractObservationFields, parseFinalizedProductionOrderCsv } = require('../services/productionOrderCsvImportService'); + +test('extractObservationFields reads Brazilian production notes without using the OP quantity as rolls', () => { + const result = extractObservationFields(`MALHA\nFORNECEDOR: TECEBEM\nBOBINAS: 10 - 209,94KG\nRENDIMENTO: 3,6\nLOTE: 0245163`); + + assert.deepEqual(result, { + supplier: 'TECEBEM', + lotCode: '0245163', + yieldPiecesPerKg: 3.6, + rollQuantity: 10, + fabricKg: 209.94, + ribKg: null + }); +}); + +test('parseFinalizedProductionOrderCsv supports multiline observations and skips non-finalized OPs', () => { + const csv = `id_op,numero_op,sku_produto,descricao_produto,quantidade_produzida,data_emissao,data_conclusao,situacao,observacoes,pedido_relacionado\n1,10,BLCS.PTO.G,Camiseta preta G,100,2026-08-01T00:00:00.000Z,2026-08-02T00:00:00.000Z,Finalizada,"FORNECEDOR: NEXSTAR\nRENDIMENTO: 5,44\nQUANTIDADE DE ROLOS: 30\nQUILOS DE MALHA: 577,90",PLANO-1\n2,11,BLCS.PTO.M,Camiseta preta M,100,2026-08-01,2026-08-02,Em andamento,Teste,`; + const result = parseFinalizedProductionOrderCsv(csv); + + assert.equal(result.totalRows, 2); + assert.equal(result.records.length, 1); + assert.equal(result.issues.length, 1); + assert.equal(result.records[0].familyKey, 'BLCS'); + assert.equal(result.records[0].rollQuantity, 30); + assert.equal(result.records[0].fabricKg, 577.9); + assert.equal(result.records[0].yieldPiecesPerKg, 5.44); +}); diff --git a/src/dataService.ts b/src/dataService.ts index 9fa3b36..071b90f 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, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, 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, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -314,6 +314,24 @@ export const fetchOlistRunDetails = async (runId: number, eventsPage = 1, produc return data as OlistRunDetails; }; +export const fetchProductionOrderImportData = async (): Promise => { + const response = await authFetch('/olist/production-order-imports', { cache: 'no-store' }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar as importações de OP.'); + return data as ProductionOrderImportData; +}; + +export const importFinalizedProductionOrderCsv = async (filename: string, csv: string): Promise }> => { + const response = await authFetch('/olist/production-order-imports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename, csv }) + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível importar as OPs finalizadas.'); + return data; +}; + export const saveCatalogCategory = async (payload: CatalogCategoryPayload): Promise => { const response = await authFetch('/catalog/categories', { method: 'POST', diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index edc8375..1e9e72a 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -6,8 +6,8 @@ import PaginationControls from '../components/PaginationControls'; import ProductColorBadge from '../components/ProductColorBadge'; import RefreshStatus from '../components/RefreshStatus'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; -import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService'; -import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types'; +import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, saveCuttingSettings } from '../dataService'; +import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData } from '../types'; import { getPlanningStock } from '../planningStock'; type CutFilter = 'need' | 'all' | 'issues' | 'covered'; @@ -151,6 +151,7 @@ const Cutting = () => { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState('rules'); const [cuttingSettings, setCuttingSettings] = useState(loadCuttingSettings); + const [yieldRecommendations, setYieldRecommendations] = useState([]); const [saveStatus, setSaveStatus] = useState('idle'); const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); const [correctionIssueFilter, setCorrectionIssueFilter] = useState('all'); @@ -195,6 +196,16 @@ const Cutting = () => { }; }, []); + useEffect(() => { + let isMounted = true; + void fetchProductionOrderImportData().then(data => { + if (isMounted) setYieldRecommendations(data.recommendations); + }).catch(() => { + if (isMounted) setYieldRecommendations([]); + }); + return () => { isMounted = false; }; + }, []); + useEffect(() => { let isMounted = true; @@ -1010,6 +1021,10 @@ const Cutting = () => { placeholder="un./rolo" className="h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary" /> + {yieldRecommendations.find(item => item.familyKey === rule.key)?.recommendedUnitsPerRoll && (() => { + const recommendation = yieldRecommendations.find(item => item.familyKey === rule.key)!; + return
{Math.round(recommendation.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo · {recommendation.sampleCount} OPs
; + })()} ))} diff --git a/src/pages/OlistSync.tsx b/src/pages/OlistSync.tsx index 1f6867b..b2f2fdd 100644 --- a/src/pages/OlistSync.tsx +++ b/src/pages/OlistSync.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { AlertTriangle, CheckCircle2, CloudDownload, Database, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react'; -import { fetchOlistRunDetails, fetchOlistStatus, getOlistAuthorizationUrl, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService'; -import type { OlistRunDetails, OlistStatus, OlistSyncRun } from '../types'; +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 type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types'; const formatDateTime = (value: string | null) => { if (!value) return '—'; @@ -48,6 +48,9 @@ const OlistSync = () => { const [runDetails, setRunDetails] = useState(null); const [detailsPages, setDetailsPages] = useState({ events: 1, products: 1 }); const [isDetailsLoading, setIsDetailsLoading] = useState(false); + const [importData, setImportData] = useState(null); + const [isImportingOrders, setIsImportingOrders] = useState(false); + const [importMessage, setImportMessage] = useState(''); const loadStatus = useCallback(async (initial = false) => { if (initial) setIsLoading(true); @@ -83,6 +86,19 @@ const OlistSync = () => { } }, []); + const loadImportData = useCallback(async () => { + try { + setImportData(await fetchProductionOrderImportData()); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar as importações de OP.'); + } + }, []); + + useEffect(() => { + const timeoutId = window.setTimeout(() => void loadImportData(), 0); + return () => window.clearTimeout(timeoutId); + }, [loadImportData]); + useEffect(() => { if (!selectedRunId || runDetails?.run.status !== 'running') return; const intervalId = window.setInterval(() => void loadRunDetails(selectedRunId, detailsPages), 5_000); @@ -131,6 +147,25 @@ const OlistSync = () => { } }; + const importOrders = async (file: File | null) => { + if (!file) return; + if (!file.name.toLowerCase().endsWith('.csv')) { + setError('Selecione um arquivo CSV de OPs finalizadas.'); + return; + } + setIsImportingOrders(true); + setImportMessage(''); + try { + const result = await importFinalizedProductionOrderCsv(file.name, await file.text()); + setImportData({ runs: result.runs, recommendations: result.recommendations }); + setImportMessage(`${result.imported.toLocaleString('pt-BR')} novas · ${result.updated.toLocaleString('pt-BR')} atualizadas · ${result.skipped.toLocaleString('pt-BR')} ignoradas.`); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : 'Não foi possível importar as OPs finalizadas.'); + } finally { + setIsImportingOrders(false); + } + }; + const openRunDetails = (runId: number) => { const initialPages = { events: 1, products: 1 }; setDetailsPages(initialPages); @@ -153,6 +188,10 @@ const OlistSync = () => { + {!status?.connected ? ( + +
+ {(['BLCS', 'BLOS', 'BLMC', 'BLPM'] as const).map(familyKey => { + const recommendation = importData?.recommendations.find(item => item.familyKey === familyKey); + return
+

{familyKey}

+

{recommendation?.recommendedUnitsPerRoll ? `${Math.round(recommendation.recommendedUnitsPerRoll).toLocaleString('pt-BR')} un./rolo` : 'Sem sugestão'}

+

{recommendation ? `${recommendation.sampleCount.toLocaleString('pt-BR')} OPs · ${recommendation.yieldSampleCount.toLocaleString('pt-BR')} c/ rendimento` : 'Nenhuma OP importada'}

+
; + })} +
+
+ {!importData?.runs.length &&

Nenhum CSV de OPs finalizadas importado.

} + {importData?.runs.map(run =>
+

{run.filename}

{formatDateTime(run.startedAt)} · {run.totalRows.toLocaleString('pt-BR')} linhas

{run.errorMessage &&

{run.errorMessage}

}
+
{run.imported} novas{run.updated} atualizadas{run.skipped} ignoradas{run.failed} falhas
+
)} +
+ +

Histórico de sincronizações

diff --git a/src/types.ts b/src/types.ts index efe7109..2128ac3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,7 @@ export interface ProductionOrderItem { orderReference: string; issueDate: string | null; expectedDate: string | null; + completedDate: string | null; productSku: string; productDescription: string; quantity: number; @@ -256,6 +257,37 @@ export interface OlistStatus { runs: OlistSyncRun[]; } +export interface ProductionOrderImportRun { + id: number; + filename: string; + status: 'running' | 'completed' | 'completed_with_errors' | 'failed'; + totalRows: number; + imported: number; + updated: number; + skipped: number; + failed: number; + errorMessage: string; + startedAt: string; + completedAt: string | null; +} + +export interface ProductionYieldRecommendation { + familyKey: 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM'; + sampleCount: number; + yieldSampleCount: number; + rollSampleCount: number; + avgYieldPiecesPerKg: number | null; + avgKgPerRoll: number | null; + recommendedUnitsPerRoll: number | null; + lastCompletedDate: string | null; + updatedAt: string; +} + +export interface ProductionOrderImportData { + runs: ProductionOrderImportRun[]; + recommendations: ProductionYieldRecommendation[]; +} + export type CatalogProductPayload = { type: CatalogProductType; sku: string;