require('../connector'); const express = require('express'); const router = express.Router(); const TIPOS_OS = new Set([ 'CORRETIVA', 'PREVENTIVA', 'REFORMA', 'DESENVOLVIMENTO', 'FABRICACAO', 'GARANTIA', 'CORTESIA', 'INTERNA', 'OUTRO' ]); const STATUS_OS = new Set([ 'RASCUNHO', 'ABERTA', 'EM_DIAGNOSTICO', 'AGUARDANDO_APROVACAO', 'AGUARDANDO_PECA', 'EM_EXECUCAO', 'EM_TESTES', 'FINALIZADA', 'RETIRADA', 'CANCELADA' ]); const STATUS_PAGAMENTO = new Set([ 'NAO_APLICAVEL', 'PENDENTE', 'PARCIAL', 'RECEBIDO', 'CORTESIA' ]); const PRIORIDADES = new Set(['BAIXA', 'NORMAL', 'ALTA', 'URGENTE']); const PAPEIS_TECNICO = new Set(['RESPONSAVEL', 'EXECUTOR', 'APOIO']); const FORMAS_PAGAMENTO = new Set([ 'DINHEIRO', 'PIX', 'TRANSFERENCIA', 'BOLETO', 'CARTAO', 'OUTRO' ]); const TIPOS_ODOMETRO = new Set([ 'CADASTRO_INICIAL', 'ENTRADA_MANUTENCAO', 'SAIDA_MANUTENCAO', 'ATUALIZACAO_MANUAL', 'TELEMETRIA' ]); function getPool() { if (!global.ConexaoMySQL_Oriontard) { throw new Error('Pool do banco Oriontard não foi inicializado.'); } return global.ConexaoMySQL_Oriontard; } function dbQuery(sql, params = [], connection = null) { const db = connection || getPool(); return new Promise((resolve, reject) => { db.query(sql, params, (error, results, fields) => { if (error) { reject(error); return; } resolve({ results, fields }); }); }); } function getConnection() { return new Promise((resolve, reject) => { getPool().getConnection((error, connection) => { if (error) { reject(error); return; } resolve(connection); }); }); } function beginTransaction(connection) { return new Promise((resolve, reject) => { connection.beginTransaction(error => { if (error) { reject(error); return; } resolve(); }); }); } function commit(connection) { return new Promise((resolve, reject) => { connection.commit(error => { if (error) { reject(error); return; } resolve(); }); }); } function rollback(connection) { return new Promise(resolve => { connection.rollback(() => resolve()); }); } async function withTransaction(handler) { const connection = await getConnection(); try { await beginTransaction(connection); const result = await handler(connection); await commit(connection); return result; } catch (error) { await rollback(connection); throw error; } finally { connection.release(); } } function sendSuccess(res, response, status = 200) { return res.status(status).json({ status, error: null, response }); } function sendError(req, res, error, status = 500, message = 'Erro interno do servidor.') { console.error( `${new Date().toUTCString()} - ${req.originalUrl} ${status} error`, error ); return res.status(status).json({ status, error: { code: error && error.code ? error.code : null, message }, response: null }); } function createHttpError(status, message) { const error = new Error(message); error.httpStatus = status; return error; } function handleRouteError(req, res, error) { const status = error.httpStatus || 500; const message = status >= 500 ? 'Erro interno ao processar a operação.' : error.message; return sendError(req, res, error, status, message); } function positiveInteger(value, fieldName, nullable = false) { if ((value === null || value === undefined || value === '') && nullable) { return null; } const number = Number(value); if (!Number.isInteger(number) || number <= 0) { throw createHttpError(400, `${fieldName} inválido.`); } return number; } function nonNegativeNumber(value, fieldName, defaultValue = 0) { if (value === null || value === undefined || value === '') { return defaultValue; } const number = Number(value); if (!Number.isFinite(number) || number < 0) { throw createHttpError(400, `${fieldName} deve ser um número maior ou igual a zero.`); } return number; } function nonNegativeInteger(value, fieldName, defaultValue = 0) { const number = nonNegativeNumber(value, fieldName, defaultValue); if (!Number.isInteger(number)) { throw createHttpError(400, `${fieldName} deve ser informado em segundos inteiros.`); } return number; } function optionalText(value) { if (value === null || value === undefined) { return null; } const text = String(value).trim(); return text || null; } function requiredText(value, fieldName) { const text = optionalText(value); if (!text) { throw createHttpError(400, `${fieldName} é obrigatório.`); } return text; } function enumValue(value, allowedValues, fieldName, defaultValue = null) { const finalValue = value || defaultValue; if (!allowedValues.has(finalValue)) { throw createHttpError(400, `${fieldName} inválido.`); } return finalValue; } function booleanToTinyInt(value, defaultValue = true) { if (value === undefined || value === null) { return defaultValue ? 1 : 0; } return value === true || value === 1 || value === '1' ? 1 : 0; } function normalizeNullableDate(value) { return value === null || value === undefined || value === '' ? null : value; } function sanitizePagination(query) { const page = Math.max(1, Number.parseInt(query.page, 10) || 1); const pageSize = Math.min(200, Math.max(1, Number.parseInt(query.pageSize, 10) || 50)); return { page, pageSize, offset: (page - 1) * pageSize }; } async function getEquipmentSnapshot(connection, equipamentoId, clienteIdInformado = null) { const { results: equipamentos } = await dbQuery( ` SELECT e.id, e.cliente_id, e.nome, e.numero_serie, e.versao, e.odometro_total_segundos, e.odometro_conectado_segundos, e.odometro_movimento_segundos, e.odometro_parcial_segundos, m.nome AS modelo_nome FROM manut_equipamentos e LEFT JOIN manut_equipamento_modelos m ON m.id = e.modelo_id WHERE e.id = ? AND e.ativo = 1 LIMIT 1; `, [equipamentoId], connection ); if (!equipamentos.length) { throw createHttpError(404, 'Equipamento não encontrado ou inativo.'); } const equipamento = equipamentos[0]; const clienteId = clienteIdInformado || equipamento.cliente_id || null; let clienteNome = null; if (clienteId) { const { results: clientes } = await dbQuery( ` SELECT id, nome_fantasia FROM manut_clientes WHERE id = ? AND ativo = 1 LIMIT 1; `, [clienteId], connection ); if (!clientes.length) { throw createHttpError(404, 'Cliente não encontrado ou inativo.'); } clienteNome = clientes[0].nome_fantasia; } return { equipamento, clienteId, clienteNome }; } function parseOdometer(payload) { if (!payload) { return null; } return { total: nonNegativeInteger( payload.odometro_total_segundos ?? payload.total_segundos, 'Odômetro total' ), conectado: nonNegativeInteger( payload.odometro_conectado_segundos ?? payload.conectado_segundos, 'Odômetro conectado' ), movimento: nonNegativeInteger( payload.odometro_movimento_segundos ?? payload.movimento_segundos, 'Odômetro de movimento' ), parcial: nonNegativeInteger( payload.odometro_parcial_segundos ?? payload.parcial_segundos, 'Odômetro parcial' ), leituraEm: normalizeNullableDate(payload.leitura_em), observacoes: optionalText(payload.observacoes) }; } function validateCumulativeOdometer(current, incoming) { const comparisons = [ ['total', Number(current.odometro_total_segundos || 0), incoming.total], ['conectado', Number(current.odometro_conectado_segundos || 0), incoming.conectado], ['movimento', Number(current.odometro_movimento_segundos || 0), incoming.movimento] ]; for (const [name, oldValue, newValue] of comparisons) { if (newValue < oldValue) { throw createHttpError( 409, `O odômetro ${name} não pode diminuir. Atual: ${oldValue}; informado: ${newValue}.` ); } } } async function insertOdometerReading( connection, { equipamento, ordemServicoId, odometro, tipoLeitura, usuarioId, origem = 'SISTEMA' } ) { validateCumulativeOdometer(equipamento, odometro); const leituraEm = odometro.leituraEm || new Date(); await dbQuery( ` INSERT INTO manut_equipamento_odometro_leituras ( equipamento_id, ordem_servico_id, tipo_leitura, origem, leitura_em, odometro_total_segundos, odometro_conectado_segundos, odometro_movimento_segundos, odometro_parcial_segundos, observacoes, registrado_por_usuario_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); `, [ equipamento.id, ordemServicoId, tipoLeitura, origem, leituraEm, odometro.total, odometro.conectado, odometro.movimento, odometro.parcial, odometro.observacoes, usuarioId ], connection ); await dbQuery( ` UPDATE manut_equipamentos SET odometro_total_segundos = ?, odometro_conectado_segundos = ?, odometro_movimento_segundos = ?, odometro_parcial_segundos = ?, odometro_atualizado_em = ?, atualizado_por_usuario_id = ? WHERE id = ?; `, [ odometro.total, odometro.conectado, odometro.movimento, odometro.parcial, leituraEm, usuarioId, equipamento.id ], connection ); } async function recalculateOrderTotals(connection, ordemServicoId) { const { results: rows } = await dbQuery( ` SELECT os.valor_desconto, os.valor_acrescimo, os.status_pagamento, COALESCE(a.total_horas_expediente, 0) AS total_horas_expediente, COALESCE(a.total_horas_extras, 0) AS total_horas_extras, COALESCE(a.valor_mao_obra, 0) AS valor_mao_obra, COALESCE(m.custo_insumos, 0) AS custo_insumos, COALESCE(m.valor_insumos, 0) AS valor_insumos, COALESCE(p.valor_pago, 0) AS valor_pago FROM manut_ordens_servico os LEFT JOIN ( SELECT ordem_servico_id, SUM(horas_expediente) AS total_horas_expediente, SUM(horas_extras) AS total_horas_extras, SUM(valor_mao_obra) AS valor_mao_obra FROM manut_ordem_servico_atividades WHERE ordem_servico_id = ? GROUP BY ordem_servico_id ) a ON a.ordem_servico_id = os.id LEFT JOIN ( SELECT atv.ordem_servico_id, SUM(mat.valor_custo_total) AS custo_insumos, SUM(mat.valor_cobrado_total) AS valor_insumos FROM manut_ordem_servico_atividades atv INNER JOIN manut_ordem_servico_atividade_materiais mat ON mat.atividade_id = atv.id WHERE atv.ordem_servico_id = ? GROUP BY atv.ordem_servico_id ) m ON m.ordem_servico_id = os.id LEFT JOIN ( SELECT ordem_servico_id, SUM(valor) AS valor_pago FROM manut_ordem_servico_pagamentos WHERE ordem_servico_id = ? AND cancelado_em IS NULL GROUP BY ordem_servico_id ) p ON p.ordem_servico_id = os.id WHERE os.id = ? AND os.excluido_em IS NULL LIMIT 1; `, [ordemServicoId, ordemServicoId, ordemServicoId, ordemServicoId], connection ); if (!rows.length) { throw createHttpError(404, 'Ordem de serviço não encontrada.'); } const data = rows[0]; const valorTotal = Math.max( 0, Number(data.valor_mao_obra) + Number(data.valor_insumos) + Number(data.valor_acrescimo) - Number(data.valor_desconto) ); const valorPago = Number(data.valor_pago); let statusPagamento = data.status_pagamento; if (!['CORTESIA', 'NAO_APLICAVEL'].includes(statusPagamento)) { if (valorPago <= 0) { statusPagamento = 'PENDENTE'; } else if (valorPago + 0.005 < valorTotal) { statusPagamento = 'PARCIAL'; } else { statusPagamento = 'RECEBIDO'; } } await dbQuery( ` UPDATE manut_ordens_servico SET total_horas_expediente = ?, total_horas_extras = ?, valor_mao_obra = ?, custo_insumos = ?, valor_insumos = ?, valor_total = ?, valor_pago = ?, status_pagamento = ? WHERE id = ?; `, [ data.total_horas_expediente, data.total_horas_extras, data.valor_mao_obra, data.custo_insumos, data.valor_insumos, valorTotal, valorPago, statusPagamento, ordemServicoId ], connection ); return { total_horas_expediente: Number(data.total_horas_expediente), total_horas_extras: Number(data.total_horas_extras), valor_mao_obra: Number(data.valor_mao_obra), custo_insumos: Number(data.custo_insumos), valor_insumos: Number(data.valor_insumos), valor_total: valorTotal, valor_pago: valorPago, status_pagamento: statusPagamento }; } async function insertActivityTechnicians(connection, atividadeId, tecnicos) { if (!Array.isArray(tecnicos) || !tecnicos.length) { return; } const uniqueTechnicians = new Map(); for (const item of tecnicos) { const tecnicoId = positiveInteger(item.tecnico_id, 'Técnico'); uniqueTechnicians.set(tecnicoId, { tecnicoId, papel: enumValue(item.papel, PAPEIS_TECNICO, 'Papel do técnico', 'EXECUTOR'), horasExpediente: item.horas_trabalhadas_expediente === null || item.horas_trabalhadas_expediente === undefined ? null : nonNegativeNumber(item.horas_trabalhadas_expediente, 'Horas trabalhadas em expediente'), horasExtras: item.horas_trabalhadas_extras === null || item.horas_trabalhadas_extras === undefined ? null : nonNegativeNumber(item.horas_trabalhadas_extras, 'Horas trabalhadas extras') }); } for (const item of uniqueTechnicians.values()) { await dbQuery( ` INSERT INTO manut_ordem_servico_atividade_tecnicos ( atividade_id, tecnico_id, papel, horas_trabalhadas_expediente, horas_trabalhadas_extras ) VALUES (?, ?, ?, ?, ?); `, [ atividadeId, item.tecnicoId, item.papel, item.horasExpediente, item.horasExtras ], connection ); } } async function resolveMaterialSnapshot(connection, material) { const materialId = material.material_id ? positiveInteger(material.material_id, 'Material') : null; let catalogMaterial = null; if (materialId) { const { results } = await dbQuery( ` SELECT id, descricao, unidade, custo_padrao, preco_venda_padrao FROM manut_materiais WHERE id = ? AND ativo = 1 LIMIT 1; `, [materialId], connection ); if (!results.length) { throw createHttpError(404, `Material ${materialId} não encontrado ou inativo.`); } catalogMaterial = results[0]; } return { materialId, descricao: optionalText(material.descricao_snapshot) || (catalogMaterial ? catalogMaterial.descricao : null), unidade: optionalText(material.unidade_snapshot) || (catalogMaterial ? catalogMaterial.unidade : 'UN'), quantidade: nonNegativeNumber(material.quantidade, 'Quantidade', 1), custoUnitario: nonNegativeNumber( material.valor_custo_unitario, 'Valor de custo unitário', catalogMaterial ? Number(catalogMaterial.custo_padrao) : 0 ), cobradoUnitario: nonNegativeNumber( material.valor_cobrado_unitario, 'Valor cobrado unitário', catalogMaterial ? Number(catalogMaterial.preco_venda_padrao) : 0 ), cobravel: booleanToTinyInt(material.cobravel, true), observacoes: optionalText(material.observacoes) }; } async function insertActivityMaterials(connection, atividadeId, materiais) { if (!Array.isArray(materiais) || !materiais.length) { return; } for (const material of materiais) { const item = await resolveMaterialSnapshot(connection, material); if (!item.descricao) { throw createHttpError(400, 'A descrição do material é obrigatória.'); } if (item.quantidade <= 0) { throw createHttpError(400, 'A quantidade do material deve ser maior que zero.'); } await dbQuery( ` INSERT INTO manut_ordem_servico_atividade_materiais ( atividade_id, material_id, descricao_snapshot, unidade_snapshot, quantidade, valor_custo_unitario, valor_cobrado_unitario, cobravel, observacoes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); `, [ atividadeId, item.materialId, item.descricao, item.unidade, item.quantidade, item.custoUnitario, item.cobradoUnitario, item.cobravel, item.observacoes ], connection ); } } async function getOrderIdByActivity(connection, atividadeId) { const { results } = await dbQuery( ` SELECT ordem_servico_id FROM manut_ordem_servico_atividades WHERE id = ? LIMIT 1; `, [atividadeId], connection ); if (!results.length) { throw createHttpError(404, 'Atividade não encontrada.'); } return results[0].ordem_servico_id; } var routes = function () { // ========================================================== // LOGS EXISTENTES // ========================================================== router.get('/getAllLogs', async function (req, res) { try { const { results } = await dbQuery(` SELECT * FROM vw_oriontard_logs WHERE dataLog > DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 3 MONTH) ORDER BY dataLog DESC; `); return sendSuccess(res, results); } catch (error) { return handleRouteError(req, res, error); } }); router.post('/insertLog', async function (req, res) { try { const cpf = optionalText(req.body.cpf); const perfil = optionalText(req.body.perfil); const log = requiredText(req.body.log, 'Log'); // Mantém a compatibilidade histórica com a view atual. const latitude = req.body.longitude ?? null; const longitude = req.body.latitude ?? null; const { results } = await dbQuery( ` INSERT INTO orionTardProLogs ( cpf, perfil, latitude, longitude, log ) VALUES (?, ?, ?, ?, ?); `, [cpf, perfil, latitude, longitude, log] ); return sendSuccess(res, { id: results.insertId }, 201); } catch (error) { return handleRouteError(req, res, error); } }); // ========================================================== // CONSULTAS AUXILIARES PARA COMBOS DO ANGULAR // Cadastro continua sendo feito diretamente no banco por agora. // ========================================================== router.get('/getClientesManutencao', async function (req, res) { try { const { results } = await dbQuery(` SELECT id, nome_fantasia, razao_social, documento FROM manut_clientes WHERE ativo = 1 ORDER BY nome_fantasia; `); return sendSuccess(res, results); } catch (error) { return handleRouteError(req, res, error); } }); router.get('/getEquipamentosManutencao', async function (req, res) { try { const { results } = await dbQuery(` SELECT e.id, e.cliente_id, e.modelo_id, e.codigo_interno, e.nome, e.numero_serie, e.versao, e.odometro_total_segundos, e.odometro_conectado_segundos, e.odometro_movimento_segundos, e.odometro_parcial_segundos, e.odometro_atualizado_em, m.nome AS modelo_nome, c.nome_fantasia AS cliente_nome FROM manut_equipamentos e LEFT JOIN manut_equipamento_modelos m ON m.id = e.modelo_id LEFT JOIN manut_clientes c ON c.id = e.cliente_id WHERE e.ativo = 1 ORDER BY e.nome, e.numero_serie; `); return sendSuccess(res, results); } catch (error) { return handleRouteError(req, res, error); } }); router.get('/getTecnicosManutencao', async function (req, res) { try { const { results } = await dbQuery(` SELECT id, usuario_sistema_id, nome, especialidade, valor_hora_expediente_padrao, valor_hora_extra_padrao FROM manut_tecnicos WHERE ativo = 1 ORDER BY nome; `); return sendSuccess(res, results); } catch (error) { return handleRouteError(req, res, error); } }); router.get('/getMateriaisManutencao', async function (req, res) { try { const { results } = await dbQuery(` SELECT id, codigo, descricao, tipo, unidade, custo_padrao, preco_venda_padrao FROM manut_materiais WHERE ativo = 1 ORDER BY descricao; `); return sendSuccess(res, results); } catch (error) { return handleRouteError(req, res, error); } }); // ========================================================== // ORDENS DE SERVICO // ========================================================== router.get('/getOrdensServico', async function (req, res) { try { const { page, pageSize, offset } = sanitizePagination(req.query); const where = ['os.excluido_em IS NULL']; const params = []; if (req.query.status) { where.push('os.status = ?'); params.push(enumValue(req.query.status, STATUS_OS, 'Status')); } if (req.query.status_pagamento) { where.push('os.status_pagamento = ?'); params.push(enumValue( req.query.status_pagamento, STATUS_PAGAMENTO, 'Status do pagamento' )); } if (req.query.tipo) { where.push('os.tipo = ?'); params.push(enumValue(req.query.tipo, TIPOS_OS, 'Tipo da OS')); } if (req.query.equipamento_id) { where.push('os.equipamento_id = ?'); params.push(positiveInteger(req.query.equipamento_id, 'Equipamento')); } if (req.query.cliente_id) { where.push('os.cliente_id = ?'); params.push(positiveInteger(req.query.cliente_id, 'Cliente')); } if (req.query.data_inicio) { where.push('os.data_entrada >= ?'); params.push(req.query.data_inicio); } if (req.query.data_fim) { where.push('os.data_entrada < DATE_ADD(?, INTERVAL 1 DAY)'); params.push(req.query.data_fim); } if (req.query.busca) { const search = `%${String(req.query.busca).trim()}%`; where.push(`( CAST(os.id AS CHAR) LIKE ? OR os.equipamento_nome_snapshot LIKE ? OR os.numero_serie_snapshot LIKE ? OR os.problema_relatado LIKE ? OR os.cliente_nome_snapshot LIKE ? )`); params.push(search, search, search, search, search); } const whereSql = where.join(' AND '); const { results: countRows } = await dbQuery( ` SELECT COUNT(*) AS total FROM manut_ordens_servico os WHERE ${whereSql}; `, params ); const listParams = [...params, pageSize, offset]; const { results } = await dbQuery( ` SELECT os.id, os.cliente_id, os.equipamento_id, os.tecnico_responsavel_id, os.cliente_nome_snapshot, os.equipamento_nome_snapshot, os.numero_serie_snapshot, os.modelo_snapshot, os.versao_snapshot, os.tipo, os.status, os.status_pagamento, os.prioridade, os.data_entrada, os.data_previsao, os.data_finalizacao, os.data_retirada, os.problema_relatado, os.total_horas_expediente, os.total_horas_extras, os.valor_mao_obra, os.valor_insumos, os.valor_desconto, os.valor_acrescimo, os.valor_total, os.valor_pago, GREATEST(os.valor_total - os.valor_pago, 0) AS valor_pendente, t.nome AS tecnico_responsavel_nome, os.created_at, os.updated_at FROM manut_ordens_servico os LEFT JOIN manut_tecnicos t ON t.id = os.tecnico_responsavel_id WHERE ${whereSql} ORDER BY os.data_entrada DESC, os.id DESC LIMIT ? OFFSET ?; `, listParams ); return sendSuccess(res, { items: results, pagination: { page, pageSize, total: Number(countRows[0].total), totalPages: Math.ceil(Number(countRows[0].total) / pageSize) } }); } catch (error) { return handleRouteError(req, res, error); } }); router.get('/getOrdemServico/:id', async function (req, res) { try { const id = positiveInteger(req.params.id, 'Ordem de serviço'); const [ { results: orders }, { results: activities }, { results: technicians }, { results: materials }, { results: odometers }, { results: payments }, { results: statusHistory } ] = await Promise.all([ dbQuery( ` SELECT os.*, t.nome AS tecnico_responsavel_nome, GREATEST(os.valor_total - os.valor_pago, 0) AS valor_pendente FROM manut_ordens_servico os LEFT JOIN manut_tecnicos t ON t.id = os.tecnico_responsavel_id WHERE os.id = ? AND os.excluido_em IS NULL LIMIT 1; `, [id] ), dbQuery( ` SELECT * FROM manut_ordem_servico_atividades WHERE ordem_servico_id = ? ORDER BY data_atividade, ordem_exibicao, id; `, [id] ), dbQuery( ` SELECT at.atividade_id, at.tecnico_id, at.papel, at.horas_trabalhadas_expediente, at.horas_trabalhadas_extras, t.nome AS tecnico_nome FROM manut_ordem_servico_atividade_tecnicos at INNER JOIN manut_tecnicos t ON t.id = at.tecnico_id INNER JOIN manut_ordem_servico_atividades a ON a.id = at.atividade_id WHERE a.ordem_servico_id = ? ORDER BY t.nome; `, [id] ), dbQuery( ` SELECT mat.* FROM manut_ordem_servico_atividade_materiais mat INNER JOIN manut_ordem_servico_atividades a ON a.id = mat.atividade_id WHERE a.ordem_servico_id = ? ORDER BY mat.id; `, [id] ), dbQuery( ` SELECT l.*, LAG(l.odometro_total_segundos) OVER ( PARTITION BY l.equipamento_id ORDER BY l.leitura_em, l.id ) AS odometro_total_anterior, LAG(l.odometro_conectado_segundos) OVER ( PARTITION BY l.equipamento_id ORDER BY l.leitura_em, l.id ) AS odometro_conectado_anterior, LAG(l.odometro_movimento_segundos) OVER ( PARTITION BY l.equipamento_id ORDER BY l.leitura_em, l.id ) AS odometro_movimento_anterior FROM manut_equipamento_odometro_leituras l WHERE l.equipamento_id = ( SELECT equipamento_id FROM manut_ordens_servico WHERE id = ? ) ORDER BY l.leitura_em, l.id; `, [id] ), dbQuery( ` SELECT * FROM manut_ordem_servico_pagamentos WHERE ordem_servico_id = ? ORDER BY data_pagamento, id; `, [id] ), dbQuery( ` SELECT * FROM manut_ordem_servico_historico_status WHERE ordem_servico_id = ? ORDER BY created_at, id; `, [id] ) ]); if (!orders.length) { throw createHttpError(404, 'Ordem de serviço não encontrada.'); } const techniciansByActivity = new Map(); for (const item of technicians) { if (!techniciansByActivity.has(item.atividade_id)) { techniciansByActivity.set(item.atividade_id, []); } techniciansByActivity.get(item.atividade_id).push(item); } const materialsByActivity = new Map(); for (const item of materials) { if (!materialsByActivity.has(item.atividade_id)) { materialsByActivity.set(item.atividade_id, []); } materialsByActivity.get(item.atividade_id).push(item); } const nestedActivities = activities.map(activity => ({ ...activity, tecnicos: techniciansByActivity.get(activity.id) || [], materiais: materialsByActivity.get(activity.id) || [] })); const orderOdometers = odometers.filter(item => Number(item.ordem_servico_id) === id); const entryOdometer = orderOdometers.find(item => item.tipo_leitura === 'ENTRADA_MANUTENCAO') || null; let intervalSincePreviousMaintenance = null; if (entryOdometer) { intervalSincePreviousMaintenance = { total_segundos: entryOdometer.odometro_total_anterior === null ? null : Number(entryOdometer.odometro_total_segundos) - Number(entryOdometer.odometro_total_anterior), conectado_segundos: entryOdometer.odometro_conectado_anterior === null ? null : Number(entryOdometer.odometro_conectado_segundos) - Number(entryOdometer.odometro_conectado_anterior), movimento_segundos: entryOdometer.odometro_movimento_anterior === null ? null : Number(entryOdometer.odometro_movimento_segundos) - Number(entryOdometer.odometro_movimento_anterior) }; } return sendSuccess(res, { ...orders[0], atividades: nestedActivities, odometros_da_os: orderOdometers, intervalo_desde_leitura_anterior: intervalSincePreviousMaintenance, pagamentos: payments, historico_status: statusHistory }); } catch (error) { return handleRouteError(req, res, error); } }); router.post('/insertOrdemServico', async function (req, res) { try { const result = await withTransaction(async connection => { const body = req.body || {}; const status = enumValue(body.status, STATUS_OS, 'Status', 'ABERTA'); const tipo = enumValue(body.tipo, TIPOS_OS, 'Tipo', 'CORRETIVA'); const prioridade = enumValue(body.prioridade, PRIORIDADES, 'Prioridade', 'NORMAL'); let statusPagamentoPadrao = 'PENDENTE'; if (tipo === 'CORTESIA') { statusPagamentoPadrao = 'CORTESIA'; } else if (tipo === 'GARANTIA') { statusPagamentoPadrao = 'NAO_APLICAVEL'; } const statusPagamento = enumValue( body.status_pagamento, STATUS_PAGAMENTO, 'Status do pagamento', statusPagamentoPadrao ); const equipamentoId = body.equipamento_id ? positiveInteger(body.equipamento_id, 'Equipamento') : null; const clienteIdInformado = body.cliente_id ? positiveInteger(body.cliente_id, 'Cliente') : null; const tecnicoResponsavelId = body.tecnico_responsavel_id ? positiveInteger(body.tecnico_responsavel_id, 'Técnico responsável') : null; const usuarioId = body.usuario_id ? positiveInteger(body.usuario_id, 'Usuário') : null; const problemaRelatado = status === 'RASCUNHO' ? optionalText(body.problema_relatado) : requiredText(body.problema_relatado, 'Problema relatado'); let snapshot = { clienteId: clienteIdInformado, clienteNome: null, equipamento: null }; if (equipamentoId) { snapshot = await getEquipmentSnapshot( connection, equipamentoId, clienteIdInformado ); } else if (clienteIdInformado) { const { results: clientes } = await dbQuery( ` SELECT id, nome_fantasia FROM manut_clientes WHERE id = ? AND ativo = 1 LIMIT 1; `, [clienteIdInformado], connection ); if (!clientes.length) { throw createHttpError(404, 'Cliente não encontrado ou inativo.'); } snapshot.clienteNome = clientes[0].nome_fantasia; } const odometroEntrada = parseOdometer(body.odometro_entrada); if (equipamentoId && status !== 'RASCUNHO' && !odometroEntrada) { throw createHttpError( 400, 'Informe o odômetro de entrada do equipamento.' ); } if (!equipamentoId && odometroEntrada) { throw createHttpError( 400, 'Não é possível registrar odômetro sem selecionar um equipamento.' ); } const { results: insertResult } = await dbQuery( ` INSERT INTO manut_ordens_servico ( cliente_id, equipamento_id, tecnico_responsavel_id, cliente_nome_snapshot, equipamento_nome_snapshot, numero_serie_snapshot, modelo_snapshot, versao_snapshot, tipo, status, status_pagamento, prioridade, data_entrada, data_previsao, data_finalizacao, data_retirada, problema_relatado, diagnostico, solucao_resumo, responsavel_entrega, responsavel_retirada, observacoes_internas, observacoes_cliente, garantia_dias, garantia_ate, valor_desconto, valor_acrescimo, justificativa_ajuste_valor, origem, criado_por_usuario_id, atualizado_por_usuario_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'SISTEMA', ?, ?); `, [ snapshot.clienteId, equipamentoId, tecnicoResponsavelId, snapshot.clienteNome, snapshot.equipamento ? snapshot.equipamento.nome : optionalText(body.equipamento_nome), snapshot.equipamento ? snapshot.equipamento.numero_serie : optionalText(body.numero_serie), snapshot.equipamento ? snapshot.equipamento.modelo_nome : optionalText(body.modelo), snapshot.equipamento ? snapshot.equipamento.versao : optionalText(body.versao), tipo, status, statusPagamento, prioridade, normalizeNullableDate(body.data_entrada), normalizeNullableDate(body.data_previsao), normalizeNullableDate(body.data_finalizacao), normalizeNullableDate(body.data_retirada), problemaRelatado, optionalText(body.diagnostico), optionalText(body.solucao_resumo), optionalText(body.responsavel_entrega), optionalText(body.responsavel_retirada), optionalText(body.observacoes_internas), optionalText(body.observacoes_cliente), body.garantia_dias === null || body.garantia_dias === undefined ? null : nonNegativeInteger(body.garantia_dias, 'Garantia em dias'), normalizeNullableDate(body.garantia_ate), nonNegativeNumber(body.valor_desconto, 'Valor de desconto'), nonNegativeNumber(body.valor_acrescimo, 'Valor de acréscimo'), optionalText(body.justificativa_ajuste_valor), usuarioId, usuarioId ], connection ); const ordemServicoId = insertResult.insertId; await dbQuery( ` INSERT INTO manut_ordem_servico_historico_status ( ordem_servico_id, status_anterior, status_novo, observacoes, alterado_por_usuario_id ) VALUES (?, NULL, ?, 'Ordem de serviço criada.', ?); `, [ordemServicoId, status, usuarioId], connection ); if (snapshot.equipamento && odometroEntrada) { await insertOdometerReading(connection, { equipamento: snapshot.equipamento, ordemServicoId, odometro: odometroEntrada, tipoLeitura: 'ENTRADA_MANUTENCAO', usuarioId }); } const totals = await recalculateOrderTotals(connection, ordemServicoId); return { id: ordemServicoId, ...totals }; }); return sendSuccess(res, result, 201); } catch (error) { return handleRouteError(req, res, error); } }); router.put('/updateOrdemServico/:id', async function (req, res) { try { const id = positiveInteger(req.params.id, 'Ordem de serviço'); const result = await withTransaction(async connection => { const { results: currentRows } = await dbQuery( ` SELECT * FROM manut_ordens_servico WHERE id = ? AND excluido_em IS NULL FOR UPDATE; `, [id], connection ); if (!currentRows.length) { throw createHttpError(404, 'Ordem de serviço não encontrada.'); } const current = currentRows[0]; const body = req.body || {}; if ( body.equipamento_id !== undefined && Number(body.equipamento_id) !== Number(current.equipamento_id) ) { throw createHttpError( 409, 'O equipamento da OS não pode ser alterado nesta versão. Exclua a OS em rascunho e crie outra.' ); } const fields = []; const params = []; function setField(column, value) { fields.push(`${column} = ?`); params.push(value); } if (body.tecnico_responsavel_id !== undefined) { setField( 'tecnico_responsavel_id', body.tecnico_responsavel_id ? positiveInteger(body.tecnico_responsavel_id, 'Técnico responsável') : null ); } if (body.tipo !== undefined) { setField('tipo', enumValue(body.tipo, TIPOS_OS, 'Tipo')); } let newStatus = current.status; if (body.status !== undefined) { newStatus = enumValue(body.status, STATUS_OS, 'Status'); setField('status', newStatus); } if (body.status_pagamento !== undefined) { setField( 'status_pagamento', enumValue(body.status_pagamento, STATUS_PAGAMENTO, 'Status do pagamento') ); } if (body.prioridade !== undefined) { setField('prioridade', enumValue(body.prioridade, PRIORIDADES, 'Prioridade')); } const plainFields = [ ['data_entrada', 'data_entrada', normalizeNullableDate], ['data_previsao', 'data_previsao', normalizeNullableDate], ['data_finalizacao', 'data_finalizacao', normalizeNullableDate], ['data_retirada', 'data_retirada', normalizeNullableDate], ['problema_relatado', 'problema_relatado', optionalText], ['diagnostico', 'diagnostico', optionalText], ['solucao_resumo', 'solucao_resumo', optionalText], ['responsavel_entrega', 'responsavel_entrega', optionalText], ['responsavel_retirada', 'responsavel_retirada', optionalText], ['observacoes_internas', 'observacoes_internas', optionalText], ['observacoes_cliente', 'observacoes_cliente', optionalText], ['garantia_ate', 'garantia_ate', normalizeNullableDate], ['justificativa_ajuste_valor', 'justificativa_ajuste_valor', optionalText] ]; for (const [bodyField, column, transformer] of plainFields) { if (body[bodyField] !== undefined) { setField(column, transformer(body[bodyField])); } } if (body.garantia_dias !== undefined) { setField( 'garantia_dias', body.garantia_dias === null ? null : nonNegativeInteger(body.garantia_dias, 'Garantia em dias') ); } if (body.valor_desconto !== undefined) { setField( 'valor_desconto', nonNegativeNumber(body.valor_desconto, 'Valor de desconto') ); } if (body.valor_acrescimo !== undefined) { setField( 'valor_acrescimo', nonNegativeNumber(body.valor_acrescimo, 'Valor de acréscimo') ); } const usuarioId = body.usuario_id ? positiveInteger(body.usuario_id, 'Usuário') : null; if (newStatus !== 'RASCUNHO') { const finalProblem = body.problema_relatado !== undefined ? optionalText(body.problema_relatado) : optionalText(current.problema_relatado); if (!finalProblem) { throw createHttpError(400, 'Problema relatado é obrigatório fora de rascunho.'); } } if (newStatus === 'FINALIZADA' && !body.data_finalizacao && !current.data_finalizacao) { fields.push('data_finalizacao = CURRENT_TIMESTAMP'); } if (newStatus === 'RETIRADA' && !body.data_retirada && !current.data_retirada) { fields.push('data_retirada = CURRENT_TIMESTAMP'); } if (fields.length) { setField('atualizado_por_usuario_id', usuarioId); params.push(id); await dbQuery( ` UPDATE manut_ordens_servico SET ${fields.join(', ')} WHERE id = ?; `, params, connection ); } if (newStatus !== current.status) { await dbQuery( ` INSERT INTO manut_ordem_servico_historico_status ( ordem_servico_id, status_anterior, status_novo, observacoes, alterado_por_usuario_id ) VALUES (?, ?, ?, ?, ?); `, [ id, current.status, newStatus, optionalText(body.observacao_status), usuarioId ], connection ); } const totals = await recalculateOrderTotals(connection, id); return { id, ...totals }; }); return sendSuccess(res, result); } catch (error) { return handleRouteError(req, res, error); } }); router.delete('/deleteOrdemServico/:id', async function (req, res) { try { const id = positiveInteger(req.params.id, 'Ordem de serviço'); const usuarioId = req.body && req.body.usuario_id ? positiveInteger(req.body.usuario_id, 'Usuário') : null; const { results } = await dbQuery( ` UPDATE manut_ordens_servico SET excluido_em = CURRENT_TIMESTAMP, excluido_por_usuario_id = ?, atualizado_por_usuario_id = ? WHERE id = ? AND excluido_em IS NULL; `, [usuarioId, usuarioId, id] ); if (!results.affectedRows) { throw createHttpError(404, 'Ordem de serviço não encontrada.'); } return sendSuccess(res, { id, excluida: true }); } catch (error) { return handleRouteError(req, res, error); } }); // ========================================================== // ATIVIDADES DA OS // ========================================================== router.post('/insertAtividadeOrdemServico/:ordemServicoId', async function (req, res) { try { const ordemServicoId = positiveInteger( req.params.ordemServicoId, 'Ordem de serviço' ); const result = await withTransaction(async connection => { const { results: orders } = await dbQuery( ` SELECT id FROM manut_ordens_servico WHERE id = ? AND excluido_em IS NULL LIMIT 1; `, [ordemServicoId], connection ); if (!orders.length) { throw createHttpError(404, 'Ordem de serviço não encontrada.'); } const body = req.body || {}; const { results: orderRows } = await dbQuery( ` SELECT COALESCE(MAX(ordem_exibicao), 0) + 1 AS proxima_ordem FROM manut_ordem_servico_atividades WHERE ordem_servico_id = ?; `, [ordemServicoId], connection ); const usuarioId = body.usuario_id ? positiveInteger(body.usuario_id, 'Usuário') : null; const { results: insertResult } = await dbQuery( ` INSERT INTO manut_ordem_servico_atividades ( ordem_servico_id, data_atividade, descricao, ordem_exibicao, horas_expediente, horas_extras, valor_hora_expediente, valor_hora_extra, cobravel, observacoes_internas, observacoes_cliente, criado_por_usuario_id, atualizado_por_usuario_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); `, [ ordemServicoId, body.data_atividade || new Date(), requiredText(body.descricao, 'Descrição da atividade'), body.ordem_exibicao === undefined ? orderRows[0].proxima_ordem : nonNegativeInteger(body.ordem_exibicao, 'Ordem de exibição'), nonNegativeNumber(body.horas_expediente, 'Horas de expediente'), nonNegativeNumber(body.horas_extras, 'Horas extras'), nonNegativeNumber(body.valor_hora_expediente, 'Valor da hora de expediente'), nonNegativeNumber(body.valor_hora_extra, 'Valor da hora extra'), booleanToTinyInt(body.cobravel, true), optionalText(body.observacoes_internas), optionalText(body.observacoes_cliente), usuarioId, usuarioId ], connection ); const atividadeId = insertResult.insertId; await insertActivityTechnicians(connection, atividadeId, body.tecnicos); await insertActivityMaterials(connection, atividadeId, body.materiais); const totals = await recalculateOrderTotals(connection, ordemServicoId); return { id: atividadeId, ordem_servico_id: ordemServicoId, totais_os: totals }; }); return sendSuccess(res, result, 201); } catch (error) { return handleRouteError(req, res, error); } }); router.put('/updateAtividadeOrdemServico/:atividadeId', async function (req, res) { try { const atividadeId = positiveInteger(req.params.atividadeId, 'Atividade'); const result = await withTransaction(async connection => { const ordemServicoId = await getOrderIdByActivity(connection, atividadeId); const body = req.body || {}; const fields = []; const params = []; function setField(column, value) { fields.push(`${column} = ?`); params.push(value); } const simpleFields = [ ['data_atividade', 'data_atividade', value => value], ['descricao', 'descricao', value => requiredText(value, 'Descrição da atividade')], ['observacoes_internas', 'observacoes_internas', optionalText], ['observacoes_cliente', 'observacoes_cliente', optionalText] ]; for (const [bodyField, column, transformer] of simpleFields) { if (body[bodyField] !== undefined) { setField(column, transformer(body[bodyField])); } } const numericFields = [ ['ordem_exibicao', 'ordem_exibicao', true], ['horas_expediente', 'horas_expediente', false], ['horas_extras', 'horas_extras', false], ['valor_hora_expediente', 'valor_hora_expediente', false], ['valor_hora_extra', 'valor_hora_extra', false] ]; for (const [bodyField, column, integer] of numericFields) { if (body[bodyField] !== undefined) { setField( column, integer ? nonNegativeInteger(body[bodyField], bodyField) : nonNegativeNumber(body[bodyField], bodyField) ); } } if (body.cobravel !== undefined) { setField('cobravel', booleanToTinyInt(body.cobravel, true)); } if (body.usuario_id !== undefined) { setField( 'atualizado_por_usuario_id', body.usuario_id ? positiveInteger(body.usuario_id, 'Usuário') : null ); } if (fields.length) { params.push(atividadeId); await dbQuery( ` UPDATE manut_ordem_servico_atividades SET ${fields.join(', ')} WHERE id = ?; `, params, connection ); } if (Array.isArray(body.tecnicos)) { await dbQuery( `DELETE FROM manut_ordem_servico_atividade_tecnicos WHERE atividade_id = ?;`, [atividadeId], connection ); await insertActivityTechnicians(connection, atividadeId, body.tecnicos); } if (Array.isArray(body.materiais)) { await dbQuery( `DELETE FROM manut_ordem_servico_atividade_materiais WHERE atividade_id = ?;`, [atividadeId], connection ); await insertActivityMaterials(connection, atividadeId, body.materiais); } const totals = await recalculateOrderTotals(connection, ordemServicoId); return { id: atividadeId, ordem_servico_id: ordemServicoId, totais_os: totals }; }); return sendSuccess(res, result); } catch (error) { return handleRouteError(req, res, error); } }); router.delete('/deleteAtividadeOrdemServico/:atividadeId', async function (req, res) { try { const atividadeId = positiveInteger(req.params.atividadeId, 'Atividade'); const result = await withTransaction(async connection => { const ordemServicoId = await getOrderIdByActivity(connection, atividadeId); await dbQuery( `DELETE FROM manut_ordem_servico_atividades WHERE id = ?;`, [atividadeId], connection ); const totals = await recalculateOrderTotals(connection, ordemServicoId); return { id: atividadeId, excluida: true, ordem_servico_id: ordemServicoId, totais_os: totals }; }); return sendSuccess(res, result); } catch (error) { return handleRouteError(req, res, error); } }); // ========================================================== // ODOMETROS // ========================================================== router.post('/insertOdometroOrdemServico/:ordemServicoId', async function (req, res) { try { const ordemServicoId = positiveInteger( req.params.ordemServicoId, 'Ordem de serviço' ); const result = await withTransaction(async connection => { const { results: orders } = await dbQuery( ` SELECT equipamento_id FROM manut_ordens_servico WHERE id = ? AND excluido_em IS NULL LIMIT 1; `, [ordemServicoId], connection ); if (!orders.length) { throw createHttpError(404, 'Ordem de serviço não encontrada.'); } if (!orders[0].equipamento_id) { throw createHttpError(409, 'A OS não possui equipamento associado.'); } const { equipamento } = await getEquipmentSnapshot( connection, orders[0].equipamento_id ); const odometro = parseOdometer(req.body); if (!odometro) { throw createHttpError(400, 'Informe os dados do odômetro.'); } const tipoLeitura = enumValue( req.body.tipo_leitura, TIPOS_ODOMETRO, 'Tipo de leitura', 'SAIDA_MANUTENCAO' ); const usuarioId = req.body.usuario_id ? positiveInteger(req.body.usuario_id, 'Usuário') : null; await insertOdometerReading(connection, { equipamento, ordemServicoId, odometro, tipoLeitura, usuarioId }); return { ordem_servico_id: ordemServicoId, equipamento_id: equipamento.id, tipo_leitura: tipoLeitura }; }); return sendSuccess(res, result, 201); } catch (error) { return handleRouteError(req, res, error); } }); // ========================================================== // PAGAMENTOS // ========================================================== router.post('/insertPagamentoOrdemServico/:ordemServicoId', async function (req, res) { try { const ordemServicoId = positiveInteger( req.params.ordemServicoId, 'Ordem de serviço' ); const result = await withTransaction(async connection => { const valor = nonNegativeNumber(req.body.valor, 'Valor do pagamento'); if (valor <= 0) { throw createHttpError(400, 'O valor do pagamento deve ser maior que zero.'); } const formaPagamento = enumValue( req.body.forma_pagamento, FORMAS_PAGAMENTO, 'Forma de pagamento', 'PIX' ); const usuarioId = req.body.usuario_id ? positiveInteger(req.body.usuario_id, 'Usuário') : null; const { results: insertResult } = await dbQuery( ` INSERT INTO manut_ordem_servico_pagamentos ( ordem_servico_id, data_pagamento, valor, forma_pagamento, referencia, observacoes, registrado_por_usuario_id ) VALUES (?, COALESCE(?, CURRENT_TIMESTAMP), ?, ?, ?, ?, ?); `, [ ordemServicoId, normalizeNullableDate(req.body.data_pagamento), valor, formaPagamento, optionalText(req.body.referencia), optionalText(req.body.observacoes), usuarioId ], connection ); const totals = await recalculateOrderTotals(connection, ordemServicoId); return { id: insertResult.insertId, ordem_servico_id: ordemServicoId, totais_os: totals }; }); return sendSuccess(res, result, 201); } catch (error) { return handleRouteError(req, res, error); } }); router.delete('/cancelPagamentoOrdemServico/:pagamentoId', async function (req, res) { try { const pagamentoId = positiveInteger(req.params.pagamentoId, 'Pagamento'); const result = await withTransaction(async connection => { const { results: paymentRows } = await dbQuery( ` SELECT ordem_servico_id FROM manut_ordem_servico_pagamentos WHERE id = ? AND cancelado_em IS NULL LIMIT 1; `, [pagamentoId], connection ); if (!paymentRows.length) { throw createHttpError(404, 'Pagamento não encontrado ou já cancelado.'); } const ordemServicoId = paymentRows[0].ordem_servico_id; const usuarioId = req.body && req.body.usuario_id ? positiveInteger(req.body.usuario_id, 'Usuário') : null; await dbQuery( ` UPDATE manut_ordem_servico_pagamentos SET cancelado_em = CURRENT_TIMESTAMP, cancelado_por_usuario_id = ? WHERE id = ?; `, [usuarioId, pagamentoId], connection ); const totals = await recalculateOrderTotals(connection, ordemServicoId); return { id: pagamentoId, cancelado: true, ordem_servico_id: ordemServicoId, totais_os: totals }; }); return sendSuccess(res, result); } catch (error) { return handleRouteError(req, res, error); } }); return router; }; module.exports = routes;