const { pool } = require('../db'); const STATUS_LABELS = { open: 'Em aberto', in_progress: 'Em andamento', finished: 'Finalizada', canceled: 'Cancelada' }; const normalizeStatus = (status) => { const normalizedStatus = String(status || 'open').trim().toLowerCase(); if (['open', 'em_aberto', 'em aberto', 'aberta'].includes(normalizedStatus)) return 'open'; if (['in_progress', 'andamento', 'em andamento'].includes(normalizedStatus)) return 'in_progress'; if (['finished', 'finalizada', 'finalizado'].includes(normalizedStatus)) return 'finished'; if (['canceled', 'cancelada', 'cancelado', 'cancelled'].includes(normalizedStatus)) return 'canceled'; return normalizedStatus || 'open'; }; const normalizeDateParam = (value) => { if (!value) return null; const normalizedValue = String(value).trim(); const isoMatch = normalizedValue.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/); if (isoMatch) { const [, year, month, day] = isoMatch; const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T00:00:00`); return Number.isNaN(date.getTime()) ? null : `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; } const brMatch = normalizedValue.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})/); if (brMatch) { const [, day, month, year] = brMatch; const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T00:00:00`); return Number.isNaN(date.getTime()) ? null : `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; } return null; }; const formatDate = (value) => { if (!value) return null; if (value instanceof Date && !Number.isNaN(value.getTime())) { return value.toISOString().slice(0, 10); } return String(value).slice(0, 10); }; const normalizeText = (value) => String(value || '').trim(); const normalizeQuantity = (value) => { const normalizedValue = typeof value === 'string' && value.includes(',') ? value.replace(/\./g, '').replace(',', '.') : value; const quantity = Number(normalizedValue); if (!Number.isFinite(quantity) || quantity <= 0) return 0; return quantity; }; const normalizeNullableQuantity = (value) => { const quantity = normalizeQuantity(value); return quantity || null; }; const normalizeInteger = (value) => { const number = Number(value); return Number.isInteger(number) ? number : null; }; const mapComponent = (component) => ({ id: component.id, componentTinyId: component.component_tiny_id || '', componentSku: component.component_sku || '', componentName: component.component_name || '', quantityPerUnit: Number(component.quantity_per_unit || 0), totalQuantity: Number(component.total_quantity || 0), unit: component.unit || '' }); const mapStep = (step) => ({ id: step.id, stepNumber: step.step_number === null ? null : Number(step.step_number), name: step.name || '', startDate: formatDate(step.start_date), endDate: formatDate(step.end_date), status: step.status || '', color: step.color || '' }); const mapProductionOrderRow = (row) => { const status = normalizeStatus(row.status); return { id: row.id, tinyId: row.tiny_id || '', number: row.number || '', status, statusLabel: STATUS_LABELS[status] || row.status || 'Em aberto', orderReference: row.order_reference || '', issueDate: formatDate(row.issue_date), expectedDate: formatDate(row.expected_date), productSku: row.product_sku || '', productDescription: row.product_description || '', quantity: Number(row.quantity || 0), unit: row.unit || 'UN', integrationStatus: row.integration_status || '', notes: row.notes || '', supplier: row.supplier || '', lotCode: row.lot_code || '', rollQuantity: row.roll_quantity === null ? null : Number(row.roll_quantity), fabricKg: row.fabric_kg === null ? null : Number(row.fabric_kg), ribKg: row.rib_kg === null ? null : Number(row.rib_kg), yieldPiecesPerKg: row.yield_pieces_per_kg === null ? null : Number(row.yield_pieces_per_kg), markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [], components: Array.isArray(row.components) ? row.components.map(mapComponent) : [], steps: Array.isArray(row.steps) ? row.steps.map(mapStep) : [], createdAt: row.created_at || null, updatedAt: row.updated_at || null }; }; 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.notes, po.supplier, po.lot_code, po.roll_quantity, po.fabric_kg, po.rib_kg, po.yield_pieces_per_kg, po.created_at, po.updated_at, COALESCE( ( SELECT JSON_AGG( JSON_BUILD_OBJECT( 'label', marker.label, 'color', marker.color ) ORDER BY marker.label ) FROM production_order_markers marker WHERE marker.production_order_id = po.id ), '[]'::json ) as markers, COALESCE( ( SELECT JSON_AGG( JSON_BUILD_OBJECT( 'id', component.id, 'component_tiny_id', component.component_tiny_id, 'component_sku', component.component_sku, 'component_name', component.component_name, 'quantity_per_unit', component.quantity_per_unit, 'total_quantity', component.total_quantity, 'unit', component.unit ) ORDER BY component.id ) FROM production_order_components component WHERE component.production_order_id = po.id ), '[]'::json ) as components, COALESCE( ( SELECT JSON_AGG( JSON_BUILD_OBJECT( 'id', step.id, 'step_number', step.step_number, 'name', step.name, 'start_date', step.start_date, 'end_date', step.end_date, 'status', step.status, 'color', step.color ) ORDER BY step.step_number NULLS LAST, step.id ) FROM production_order_steps step WHERE step.production_order_id = po.id ), '[]'::json ) as steps FROM production_orders po WHERE po.id = $1; `, [id]); return result.rows[0] ? mapProductionOrderRow(result.rows[0]) : null; }; const baseProductionOrderSelect = ` 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.notes, po.supplier, po.lot_code, po.roll_quantity, po.fabric_kg, po.rib_kg, po.yield_pieces_per_kg, po.created_at, po.updated_at, COALESCE( ( SELECT JSON_AGG( JSON_BUILD_OBJECT( 'label', marker.label, 'color', marker.color ) ORDER BY marker.label ) FROM production_order_markers marker WHERE marker.production_order_id = po.id ), '[]'::json ) as markers, COALESCE( ( SELECT JSON_AGG( JSON_BUILD_OBJECT( 'id', component.id, 'component_tiny_id', component.component_tiny_id, 'component_sku', component.component_sku, 'component_name', component.component_name, 'quantity_per_unit', component.quantity_per_unit, 'total_quantity', component.total_quantity, 'unit', component.unit ) ORDER BY component.id ) FROM production_order_components component WHERE component.production_order_id = po.id ), '[]'::json ) as components, COALESCE( ( SELECT JSON_AGG( JSON_BUILD_OBJECT( 'id', step.id, 'step_number', step.step_number, 'name', step.name, 'start_date', step.start_date, 'end_date', step.end_date, 'status', step.status, 'color', step.color ) ORDER BY step.step_number NULLS LAST, step.id ) FROM production_order_steps step WHERE step.production_order_id = po.id ), '[]'::json ) as steps FROM production_orders po `; const listProductionOrders = async (filters = {}) => { const params = []; const where = []; const normalizedStart = normalizeDateParam(filters.start); const normalizedEnd = normalizeDateParam(filters.end); const normalizedSearch = String(filters.search || '').trim(); if (normalizedStart) { params.push(normalizedStart); where.push(`COALESCE(po.issue_date, po.expected_date, po.created_at::date) >= $${params.length}::date`); } if (normalizedEnd) { params.push(normalizedEnd); where.push(`COALESCE(po.issue_date, po.expected_date, po.created_at::date) <= $${params.length}::date`); } if (normalizedSearch) { params.push(`%${normalizedSearch}%`); where.push(`( po.number ILIKE $${params.length} OR po.order_reference ILIKE $${params.length} OR po.product_sku ILIKE $${params.length} OR po.product_description ILIKE $${params.length} )`); } const result = await pool.query(` ${baseProductionOrderSelect} ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY COALESCE(po.issue_date, po.created_at::date) DESC, CASE WHEN po.number ~ '^\\d+$' THEN po.number::bigint ELSE NULL END DESC NULLS LAST, po.id DESC; `, params); const orders = result.rows.map(mapProductionOrderRow); const counts = orders.reduce((nextCounts, order) => { nextCounts.all += 1; nextCounts[order.status] = (nextCounts[order.status] || 0) + 1; return nextCounts; }, { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 }); return { orders, counts }; }; 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 resolveTinyOrderPayload = (payload = {}) => { const order = payload.order && typeof payload.order === 'object' ? payload.order : payload; const quantity = normalizeQuantity(order.quantity); const productDescription = normalizeText(order.productDescription || order.productName || order.produto || order.descricao); if (!productDescription || !quantity) { const error = new Error('Produto e quantidade da OP são obrigatórios.'); error.statusCode = 400; throw error; } return { tinyId: normalizeText(order.tinyId || order.tiny_id || order.idTiny || order.id), number: normalizeText(order.number || order.numero || order.numeroOp || order.productionOrderNumber), status: normalizeStatus(order.status || order.situacao || 'in_progress'), orderReference: normalizeText(order.orderReference || order.reference || order.plano || order.pedidos), issueDate: normalizeDateParam(order.issueDate || order.date || order.data), expectedDate: normalizeDateParam(order.expectedDate || order.dataPrevista), productSku: normalizeText(order.productSku || order.sku || order.codigo || order.codigoSku), productDescription, quantity, unit: normalizeText(order.unit || order.unidade) || 'UN', integrationStatus: normalizeText(order.integrationStatus) || 'Tiny', notes: normalizeText(order.notes || order.observations || order.observacoes), supplier: normalizeText(order.supplier || order.fornecedor), lotCode: normalizeText(order.lotCode || order.lote), rollQuantity: normalizeNullableQuantity(order.rollQuantity || order.quantidadeRolos), fabricKg: normalizeNullableQuantity(order.fabricKg || order.quilosMalha), ribKg: normalizeNullableQuantity(order.ribKg || order.quilosRibana), yieldPiecesPerKg: normalizeNullableQuantity(order.yieldPiecesPerKg || order.rendimento), rawPayload: payload }; }; const normalizeComponents = (components = [], orderQuantity = 0) => ( Array.isArray(components) ? components : [] ).map(component => { const quantityPerUnit = normalizeQuantity(component.quantityPerUnit || component.quantity || component.quantidade); const totalQuantity = normalizeQuantity(component.totalQuantity || component.quantidadeTotal) || quantityPerUnit * orderQuantity; return { componentTinyId: normalizeText(component.componentTinyId || component.idComponente || component.id_componente || component.id), componentSku: normalizeText(component.componentSku || component.sku || component.codigo || component.code), componentName: normalizeText(component.componentName || component.name || component.nome || component.produto), quantityPerUnit, totalQuantity, unit: normalizeText(component.unit || component.unidade) }; }).filter(component => component.componentName); const normalizeSteps = (steps = []) => ( Array.isArray(steps) ? steps : [] ).map(step => ({ stepNumber: normalizeInteger(step.stepNumber || step.number || step.nro || step.numero), name: normalizeText(step.name || step.etapa || step.posto || step.description), startDate: normalizeDateParam(step.startDate || step.dataInicio), endDate: normalizeDateParam(step.endDate || step.dataFim), status: normalizeText(step.status || step.situacao), color: normalizeText(step.color || step.cor) })).filter(step => step.name); const upsertTinyProductionOrderDetail = async (payload = {}) => { const order = resolveTinyOrderPayload(payload); const components = normalizeComponents(payload.components || payload.composition || payload.composicao, order.quantity); const steps = normalizeSteps(payload.steps || payload.etapas); const client = await pool.connect(); try { await client.query('BEGIN'); const existingResult = await client.query(` SELECT id FROM production_orders WHERE ($1 <> '' AND tiny_id = $1) OR ($2 <> '' AND number = $2) ORDER BY CASE WHEN tiny_id = $1 THEN 0 ELSE 1 END LIMIT 1; `, [order.tinyId, order.number]); let orderId = existingResult.rows[0]?.id; if (orderId) { await client.query(` UPDATE production_orders SET tiny_id = COALESCE(NULLIF($1, ''), tiny_id), number = COALESCE(NULLIF($2, ''), number), status = $3, order_reference = NULLIF($4, ''), issue_date = $5, expected_date = $6, product_sku = NULLIF($7, ''), product_description = $8, quantity = $9, unit = $10, integration_status = $11, notes = NULLIF($12, ''), supplier = NULLIF($13, ''), lot_code = NULLIF($14, ''), roll_quantity = $15, fabric_kg = $16, rib_kg = $17, yield_pieces_per_kg = $18, tiny_payload = $19::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = $20; `, [ order.tinyId, order.number, order.status, order.orderReference, order.issueDate, order.expectedDate, order.productSku, order.productDescription, order.quantity, order.unit, order.integrationStatus, order.notes, order.supplier, order.lotCode, order.rollQuantity, order.fabricKg, order.ribKg, order.yieldPiecesPerKg, JSON.stringify(order.rawPayload), orderId ]); } else { 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, notes, supplier, lot_code, roll_quantity, fabric_kg, rib_kg, yield_pieces_per_kg, tiny_payload ) VALUES ( NULLIF($1, ''), NULLIF($2, ''), $3, NULLIF($4, ''), $5, $6, NULLIF($7, ''), $8, $9, $10, $11, NULLIF($12, ''), NULLIF($13, ''), NULLIF($14, ''), $15, $16, $17, $18, $19::jsonb ) RETURNING id; `, [ order.tinyId, order.number, order.status, order.orderReference, order.issueDate, order.expectedDate, order.productSku, order.productDescription, order.quantity, order.unit, order.integrationStatus, order.notes, order.supplier, order.lotCode, order.rollQuantity, order.fabricKg, order.ribKg, order.yieldPiecesPerKg, JSON.stringify(order.rawPayload) ]); orderId = insertResult.rows[0].id; } await client.query('DELETE FROM production_order_components WHERE production_order_id = $1;', [orderId]); for (const component of components) { await client.query(` INSERT INTO production_order_components ( production_order_id, component_tiny_id, component_sku, component_name, quantity_per_unit, total_quantity, unit ) VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), $4, $5, $6, NULLIF($7, '')); `, [ orderId, component.componentTinyId, component.componentSku, component.componentName, component.quantityPerUnit, component.totalQuantity, component.unit ]); } await client.query('DELETE FROM production_order_steps WHERE production_order_id = $1;', [orderId]); for (const step of steps) { await client.query(` INSERT INTO production_order_steps ( production_order_id, step_number, name, start_date, end_date, status, color ) VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), NULLIF($7, '')); `, [ orderId, step.stepNumber, step.name, step.startDate, step.endDate, step.status, step.color ]); } await client.query('COMMIT'); return { order: await getOrderById(orderId), componentCount: components.length, stepCount: steps.length }; } 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, upsertTinyProductionOrderDetail, updateProductionOrderStatus };