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', 'AGUARDANDO_RETIRADA', '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 parsePagination(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 }; } 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 { // A dashboard precisa enxergar o registro bruto independentemente da // versão do JSON. A view histórica continua sendo usada somente para // expor dataLog/filtro temporal; o conteúdo do campo log é normalizado // abaixo para manter compatibilidade entre os schemas antigos e o v2. const mesesSolicitados = Number.parseInt(req.query.meses, 10); const meses = Number.isFinite(mesesSolicitados) ? Math.min(12, Math.max(1, mesesSolicitados)) : 3; // dataLog não é coluna física de orionTardProLogs. Ela é exposta // pela view histórica vw_oriontard_logs. Mantemos a view somente como // fonte SQL para paginação/filtro temporal e fazemos toda a // compatibilidade de schema 1/2 abaixo, a partir do campo log bruto. const { results } = await dbQuery(` SELECT * FROM vw_oriontard_logs WHERE dataLog > DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ${meses} MONTH) ORDER BY dataLog DESC; `); const normalizados = results.map(row => { let parsed = null; let jsonValido = false; try { parsed = typeof row.log === 'string' ? JSON.parse(row.log) : row.log; if (typeof parsed === 'string') { parsed = JSON.parse(parsed); } jsonValido = !!parsed && typeof parsed === 'object'; } catch (error) { parsed = null; } const latitudeJson = parsed ? Number(parsed.latitude) : NaN; const longitudeJson = parsed ? Number(parsed.longitude) : NaN; // Os campos latitude/longitude da tabela possuem inversão histórica // por compatibilidade. Para JSON válido, o próprio payload é a fonte // de verdade. Para legado inválido, preservamos o fallback invertido. const latitude = Number.isFinite(latitudeJson) ? latitudeJson : Number(row.longitude); const longitude = Number.isFinite(longitudeJson) ? longitudeJson : Number(row.latitude); const dataOperacao = parsed ? (parsed.periodoFim || parsed.data || row.dataLog) : row.dataLog; return { ...row, nome: (parsed && (parsed.usuario || parsed.cpf)) || row.cpf || 'Não identificado', perfil: (parsed && parsed.perfil) || row.perfil || 'Sem perfil', coordenadas: Number.isFinite(latitude) && Number.isFinite(longitude) ? `${latitude},${longitude}` : '', dataOperacao, schemaVersao: parsed && parsed.schemaVersao !== undefined ? Number(parsed.schemaVersao) : null, tipoEvento: parsed ? (parsed.tipoEvento || null) : null, periodoInicio: parsed ? (parsed.periodoInicio || null) : null, periodoFim: parsed ? (parsed.periodoFim || null) : null, jsonValido }; }); // dataLog representa a inserção no banco. Para logs reenviados após // ficar offline, a data real da operação pode ser anterior. Ordenamos // pela data do payload quando ela existe e usamos dataLog como fallback. normalizados.sort((a, b) => { const dataA = new Date(a.dataOperacao || a.dataLog).getTime(); const dataB = new Date(b.dataOperacao || b.dataLog).getTime(); const valorA = Number.isFinite(dataA) ? dataA : 0; const valorB = Number.isFinite(dataB) ? dataB : 0; return valorB - valorA; }); return sendSuccess(res, normalizados); } 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); } }); // ========================================================== // PRONTUARIO DOS EQUIPAMENTOS // ========================================================== router.get('/getEquipamentosHistorico', async function (req, res) { try { const { page, pageSize, offset } = parsePagination(req.query); const where = ['e.ativo = 1']; const params = []; if (req.query.busca) { const busca = `%${String(req.query.busca).trim()}%`; where.push(`( e.numero_serie LIKE ? OR e.codigo_interno LIKE ? OR e.nome LIKE ? OR e.versao LIKE ? OR m.nome LIKE ? OR c.nome_fantasia LIKE ? )`); params.push(busca, busca, busca, busca, busca, busca); } if (req.query.modelo_id) { where.push('e.modelo_id = ?'); params.push(positiveInteger(req.query.modelo_id, 'Modelo')); } if (req.query.modelo) { where.push('m.nome LIKE ?'); params.push(`%${String(req.query.modelo).trim()}%`); } if (req.query.cliente_id) { where.push('e.cliente_id = ?'); params.push(positiveInteger(req.query.cliente_id, 'Cliente')); } if (req.query.versao) { where.push('e.versao LIKE ?'); params.push(`%${String(req.query.versao).trim()}%`); } if (req.query.possui_os_aberta !== undefined) { const possuiOsAberta = String(req.query.possui_os_aberta).toLowerCase(); if (!['true', 'false', '1', '0'].includes(possuiOsAberta)) { throw createHttpError(400, 'Filtro de OS aberta invalido.'); } where.push(`${['true', '1'].includes(possuiOsAberta) ? '' : 'NOT '}EXISTS ( SELECT 1 FROM manut_ordens_servico os_aberta WHERE os_aberta.equipamento_id = e.id AND os_aberta.excluido_em IS NULL AND os_aberta.status NOT IN ('RASCUNHO', 'RETIRADA', 'CANCELADA') )`); } if (req.query.status_ultima_os) { const statusUltimaOs = enumValue( req.query.status_ultima_os, STATUS_OS, 'Status da ultima OS' ); where.push(`( SELECT os_status.status FROM manut_ordens_servico os_status WHERE os_status.equipamento_id = e.id AND os_status.excluido_em IS NULL ORDER BY os_status.data_entrada DESC, os_status.id DESC LIMIT 1 ) = ?`); params.push(statusUltimaOs); } const whereSql = where.join('\n AND '); const { results: countRows } = await dbQuery( ` SELECT COUNT(*) AS total 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 ${whereSql}; `, params ); const listParams = [...params, pageSize, offset]; 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, COALESCE(resumo.quantidade_os, 0) AS quantidade_os, COALESCE(resumo.quantidade_os_abertas, 0) AS quantidade_os_abertas, COALESCE(resumo.valor_mao_obra, 0) AS valor_mao_obra_historico, COALESCE(resumo.valor_insumos, 0) AS valor_insumos_historico, COALESCE(resumo.valor_total, 0) AS valor_total_historico, COALESCE(resumo.valor_pago, 0) AS valor_pago_historico, COALESCE(resumo.valor_pendente, 0) AS valor_pendente_historico, ultima_os.id AS ultima_os_id, ultima_os.status AS ultima_os_status, ultima_os.tipo AS ultima_os_tipo, ultima_os.data_entrada AS ultima_os_data_entrada, ultima_os.data_finalizacao AS ultima_os_data_finalizacao, ultima_os.data_retirada AS ultima_os_data_retirada 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 LEFT JOIN ( SELECT equipamento_id, COUNT(*) AS quantidade_os, SUM(status NOT IN ('RASCUNHO', 'RETIRADA', 'CANCELADA')) AS quantidade_os_abertas, SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_mao_obra ELSE 0 END) AS valor_mao_obra, SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_insumos ELSE 0 END) AS valor_insumos, SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_total ELSE 0 END) AS valor_total, SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_pago ELSE 0 END) AS valor_pago, SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN GREATEST(valor_total - valor_pago, 0) ELSE 0 END) AS valor_pendente FROM manut_ordens_servico WHERE excluido_em IS NULL GROUP BY equipamento_id ) resumo ON resumo.equipamento_id = e.id LEFT JOIN manut_ordens_servico ultima_os ON ultima_os.id = ( SELECT os_ultima.id FROM manut_ordens_servico os_ultima WHERE os_ultima.equipamento_id = e.id AND os_ultima.excluido_em IS NULL ORDER BY os_ultima.data_entrada DESC, os_ultima.id DESC LIMIT 1 ) WHERE ${whereSql} ORDER BY e.nome, e.numero_serie 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('/getEquipamentoProntuario/:equipamentoId', async function (req, res) { try { const equipamentoId = positiveInteger(req.params.equipamentoId, 'Equipamento'); const [ { results: equipamentos }, { results: totaisRows }, { results: ultimaOsRows }, { results: leiturasManutencao } ] = await Promise.all([ dbQuery( ` SELECT e.*, m.nome AS modelo_nome, c.nome_fantasia AS cliente_nome, c.razao_social AS cliente_razao_social 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.id = ? AND e.ativo = 1 LIMIT 1; `, [equipamentoId] ), dbQuery( ` SELECT COUNT(*) AS quantidade_os, SUM(status NOT IN ('RASCUNHO', 'RETIRADA', 'CANCELADA')) AS quantidade_os_abertas, SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN 1 ELSE 0 END) AS quantidade_os_efetivas, SUM(CASE WHEN tipo = 'CORRETIVA' AND status NOT IN ('RASCUNHO', 'CANCELADA') THEN 1 ELSE 0 END) AS quantidade_corretivas, SUM(CASE WHEN tipo = 'PREVENTIVA' AND status NOT IN ('RASCUNHO', 'CANCELADA') THEN 1 ELSE 0 END) AS quantidade_preventivas, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN total_horas_expediente ELSE 0 END), 0) AS total_horas_expediente, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN total_horas_extras ELSE 0 END), 0) AS total_horas_extras, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_mao_obra ELSE 0 END), 0) AS valor_mao_obra, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN custo_insumos ELSE 0 END), 0) AS custo_insumos, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_insumos ELSE 0 END), 0) AS valor_insumos, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_desconto ELSE 0 END), 0) AS valor_desconto, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_acrescimo ELSE 0 END), 0) AS valor_acrescimo, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_total ELSE 0 END), 0) AS valor_total, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_pago ELSE 0 END), 0) AS valor_pago, COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN GREATEST(valor_total - valor_pago, 0) ELSE 0 END), 0) AS valor_pendente, MIN(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN data_entrada END) AS primeira_manutencao_em, MAX(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN data_entrada END) AS ultima_manutencao_em FROM manut_ordens_servico WHERE equipamento_id = ? AND excluido_em IS NULL; `, [equipamentoId] ), dbQuery( ` SELECT id, tipo, status, status_pagamento, prioridade, data_entrada, data_finalizacao, data_retirada, problema_relatado, diagnostico, solucao_resumo, valor_mao_obra, valor_insumos, valor_total FROM manut_ordens_servico WHERE equipamento_id = ? AND excluido_em IS NULL ORDER BY data_entrada DESC, id DESC LIMIT 1; `, [equipamentoId] ), dbQuery( ` SELECT id, ordem_servico_id, tipo_leitura, leitura_em, odometro_total_segundos, odometro_conectado_segundos, odometro_movimento_segundos, odometro_parcial_segundos, observacoes FROM manut_equipamento_odometro_leituras WHERE equipamento_id = ? AND tipo_leitura IN ('ENTRADA_MANUTENCAO', 'SAIDA_MANUTENCAO') ORDER BY leitura_em, id; `, [equipamentoId] ) ]); if (!equipamentos.length) { throw createHttpError(404, 'Equipamento nao encontrado ou inativo.'); } const equipamento = equipamentos[0]; const totais = totaisRows[0]; const entradas = leiturasManutencao.filter( leitura => leitura.tipo_leitura === 'ENTRADA_MANUTENCAO' ); const intervalosMovimento = []; for (let index = 1; index < entradas.length; index += 1) { intervalosMovimento.push( Number(entradas[index].odometro_movimento_segundos) - Number(entradas[index - 1].odometro_movimento_segundos) ); } const ultimaSaida = [...leiturasManutencao] .reverse() .find(leitura => leitura.tipo_leitura === 'SAIDA_MANUTENCAO') || null; const movimentoAtual = Number(equipamento.odometro_movimento_segundos || 0); const horasMovimento = movimentoAtual / 3600; const valorTotal = Number(totais.valor_total || 0); return sendSuccess(res, { equipamento, indicadores: { ...totais, horas_movimento: horasMovimento, horas_conectado: Number(equipamento.odometro_conectado_segundos || 0) / 3600, horas_total: Number(equipamento.odometro_total_segundos || 0) / 3600, horas_parcial: Number(equipamento.odometro_parcial_segundos || 0) / 3600, movimento_desde_ultima_saida_segundos: ultimaSaida ? Math.max(0, movimentoAtual - Number(ultimaSaida.odometro_movimento_segundos)) : null, media_movimento_entre_manutencoes_segundos: intervalosMovimento.length ? intervalosMovimento.reduce((total, valor) => total + valor, 0) / intervalosMovimento.length : null, custo_medio_por_os: Number(totais.quantidade_os_efetivas || 0) ? valorTotal / Number(totais.quantidade_os_efetivas) : 0, custo_por_hora_movimento: horasMovimento > 0 ? valorTotal / horasMovimento : null }, ultima_os: ultimaOsRows[0] || null, ultima_saida_manutencao: ultimaSaida, leituras_manutencao: leiturasManutencao }); } catch (error) { return handleRouteError(req, res, error); } }); router.get('/getEquipamentoTimeline/:equipamentoId', async function (req, res) { try { const equipamentoId = positiveInteger(req.params.equipamentoId, 'Equipamento'); const { page, pageSize, offset } = parsePagination(req.query); const where = [ 'os.equipamento_id = ?', 'os.excluido_em IS NULL' ]; const params = [equipamentoId]; if (req.query.tipo) { where.push('os.tipo = ?'); params.push(enumValue(req.query.tipo, TIPOS_OS, 'Tipo')); } if (req.query.status) { where.push('os.status = ?'); params.push(enumValue(req.query.status, STATUS_OS, 'Status')); } if (req.query.data_inicio) { where.push('os.data_entrada >= ?'); params.push(normalizeNullableDate(req.query.data_inicio)); } if (req.query.data_fim) { where.push('os.data_entrada < DATE_ADD(?, INTERVAL 1 DAY)'); params.push(normalizeNullableDate(req.query.data_fim)); } if (req.query.somente_com_materiais === 'true' || req.query.somente_com_materiais === '1') { where.push(`EXISTS ( SELECT 1 FROM manut_ordem_servico_atividades atv_mat INNER JOIN manut_ordem_servico_atividade_materiais mat_busca ON mat_busca.atividade_id = atv_mat.id WHERE atv_mat.ordem_servico_id = os.id )`); } if (req.query.busca) { const busca = `%${String(req.query.busca).trim()}%`; where.push(`( CAST(os.id AS CHAR) LIKE ? OR os.problema_relatado LIKE ? OR os.diagnostico LIKE ? OR os.solucao_resumo LIKE ? OR os.observacoes_internas LIKE ? OR os.observacoes_cliente LIKE ? OR EXISTS ( SELECT 1 FROM manut_ordem_servico_atividades atv_busca WHERE atv_busca.ordem_servico_id = os.id AND ( atv_busca.descricao LIKE ? OR atv_busca.observacoes_internas LIKE ? OR atv_busca.observacoes_cliente LIKE ? ) ) OR EXISTS ( SELECT 1 FROM manut_ordem_servico_atividades atv_busca_mat INNER JOIN manut_ordem_servico_atividade_materiais mat_busca_texto ON mat_busca_texto.atividade_id = atv_busca_mat.id WHERE atv_busca_mat.ordem_servico_id = os.id AND ( mat_busca_texto.descricao_snapshot LIKE ? OR mat_busca_texto.observacoes LIKE ? ) ) OR EXISTS ( SELECT 1 FROM manut_ordem_servico_historico_status hs_busca WHERE hs_busca.ordem_servico_id = os.id AND hs_busca.observacoes LIKE ? ) )`); params.push( busca, busca, busca, busca, busca, busca, busca, busca, busca, busca, busca, busca ); } const whereSql = where.join('\n AND '); const { results: countRows } = await dbQuery( `SELECT COUNT(*) AS total FROM manut_ordens_servico os WHERE ${whereSql};`, params ); const { results: orders } = await 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 ${whereSql} ORDER BY os.data_entrada DESC, os.id DESC LIMIT ? OFFSET ?; `, [...params, pageSize, offset] ); if (!orders.length) { return sendSuccess(res, { items: [], pagination: { page, pageSize, total: Number(countRows[0].total), totalPages: Math.ceil(Number(countRows[0].total) / pageSize) } }); } const orderIds = orders.map(order => Number(order.id)); const placeholders = orderIds.map(() => '?').join(', '); const [ { results: activities }, { results: technicians }, { results: materials }, { results: odometers }, { results: statusHistory } ] = await Promise.all([ dbQuery( `SELECT * FROM manut_ordem_servico_atividades WHERE ordem_servico_id IN (${placeholders}) ORDER BY data_atividade, ordem_exibicao, id;`, orderIds ), dbQuery( ` SELECT a.ordem_servico_id, 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 IN (${placeholders}) ORDER BY t.nome; `, orderIds ), dbQuery( ` SELECT a.ordem_servico_id, 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 IN (${placeholders}) ORDER BY mat.id; `, orderIds ), dbQuery( ` SELECT * FROM manut_equipamento_odometro_leituras WHERE equipamento_id = ? AND ordem_servico_id IN (${placeholders}) ORDER BY leitura_em, id; `, [equipamentoId, ...orderIds] ), dbQuery( ` SELECT * FROM manut_ordem_servico_historico_status WHERE ordem_servico_id IN (${placeholders}) ORDER BY created_at, id; `, orderIds ) ]); const techniciansByActivity = new Map(); for (const technician of technicians) { if (!techniciansByActivity.has(technician.atividade_id)) { techniciansByActivity.set(technician.atividade_id, []); } techniciansByActivity.get(technician.atividade_id).push(technician); } const materialsByActivity = new Map(); for (const material of materials) { if (!materialsByActivity.has(material.atividade_id)) { materialsByActivity.set(material.atividade_id, []); } materialsByActivity.get(material.atividade_id).push(material); } const activitiesByOrder = new Map(); for (const activity of activities) { if (!activitiesByOrder.has(activity.ordem_servico_id)) { activitiesByOrder.set(activity.ordem_servico_id, []); } activitiesByOrder.get(activity.ordem_servico_id).push({ ...activity, tecnicos: techniciansByActivity.get(activity.id) || [], materiais: materialsByActivity.get(activity.id) || [] }); } const groupByOrder = rows => { const map = new Map(); for (const row of rows) { if (!map.has(row.ordem_servico_id)) { map.set(row.ordem_servico_id, []); } map.get(row.ordem_servico_id).push(row); } return map; }; const odometersByOrder = groupByOrder(odometers); const statusByOrder = groupByOrder(statusHistory); const items = orders.map(order => ({ ...order, atividades: activitiesByOrder.get(order.id) || [], odometros: odometersByOrder.get(order.id) || [], historico_status: statusByOrder.get(order.id) || [] })); return sendSuccess(res, { items, 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('/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;