'use strict'; const fs = require('fs'); const path = require('path'); const PDFDocument = require('pdfkit'); const CORES = { azul: '#175CD3', azulEscuro: '#1849A9', azulClaro: '#EFF8FF', texto: '#344054', textoForte: '#101828', textoSuave: '#667085', borda: '#D0D5DD', bordaClara: '#EAECF0', fundo: '#F8FAFC', sucesso: '#027A48', sucessoFundo: '#ECFDF3', alerta: '#B54708', alertaFundo: '#FFFAEB', erro: '#B42318', erroFundo: '#FEF3F2', branco: '#FFFFFF' }; const MARGEM = 42; const TOPO_CONTEUDO = 102; const RODAPE_Y = 806; const LARGURA_A4 = 595.28; const ALTURA_A4 = 841.89; const LARGURA_CONTEUDO = LARGURA_A4 - (MARGEM * 2); const STATUS_OS_LABEL = { RASCUNHO: 'Rascunho', ABERTA: 'Aberta', EM_DIAGNOSTICO: 'Em diagnóstico', AGUARDANDO_APROVACAO: 'Aguardando aprovação', AGUARDANDO_PECA: 'Aguardando peça', EM_EXECUCAO: 'Em execução', EM_TESTES: 'Em testes', FINALIZADA: 'Finalizada', AGUARDANDO_RETIRADA: 'Aguardando retirada', RETIRADA: 'Retirada', CANCELADA: 'Cancelada' }; const STATUS_PAGAMENTO_LABEL = { NAO_APLICAVEL: 'Não aplicável', PENDENTE: 'Pendente', PARCIAL: 'Parcial', RECEBIDO: 'Recebido', CORTESIA: 'Cortesia' }; const TIPO_OS_LABEL = { CORRETIVA: 'Corretiva', PREVENTIVA: 'Preventiva', REFORMA: 'Reforma', DESENVOLVIMENTO: 'Desenvolvimento', FABRICACAO: 'Fabricação', GARANTIA: 'Garantia', CORTESIA: 'Cortesia', INTERNA: 'Interna', OUTRO: 'Outro' }; const TIPO_ODOMETRO_LABEL = { CADASTRO_INICIAL: 'Cadastro inicial', ENTRADA_MANUTENCAO: 'Entrada na manutenção', SAIDA_MANUTENCAO: 'Saída da manutenção', ATUALIZACAO_MANUAL: 'Atualização manual', TELEMETRIA: 'Telemetria' }; function empresaConfig() { return { nome: process.env.ORION_PDF_EMPRESA_NOME || 'Zendion INC', documento: process.env.ORION_PDF_EMPRESA_DOCUMENTO || '', endereco: process.env.ORION_PDF_EMPRESA_ENDERECO || 'Rua Custódio Pereira, 690, Leme, SP', contato: process.env.ORION_PDF_EMPRESA_CONTATO || '+55 (19) 99871-7560', site: process.env.ORION_PDF_EMPRESA_SITE || 'https://zendioninc.com.br', logo: process.env.ORION_PDF_LOGO || '' }; } function texto(valor, fallback = '-') { if (valor === null || valor === undefined || valor === '') { return fallback; } return String(valor); } function numero(valor) { const convertido = Number(valor); return Number.isFinite(convertido) ? convertido : 0; } function moeda(valor) { return numero(valor).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); } function decimal(valor, casas = 2) { return numero(valor).toLocaleString('pt-BR', { minimumFractionDigits: casas, maximumFractionDigits: casas }); } function dataBR(valor) { if (!valor) { return '-'; } const data = valor instanceof Date ? valor : new Date(valor); if (Number.isNaN(data.getTime())) { return texto(valor); } return data.toLocaleDateString('pt-BR'); } function dataHoraBR(valor) { if (!valor) { return '-'; } const data = valor instanceof Date ? valor : new Date(valor); if (Number.isNaN(data.getTime())) { return texto(valor); } return data.toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }); } function segundosParaTempo(valor) { let total = Math.max(0, Math.floor(numero(valor))); const horas = Math.floor(total / 3600); total %= 3600; const minutos = Math.floor(total / 60); const segundos = total % 60; return [ String(horas).padStart(2, '0'), String(minutos).padStart(2, '0'), String(segundos).padStart(2, '0') ].join(':'); } function nomeArquivoSeguro(valor) { return texto(valor, 'documento') .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9._-]+/g, '_') .replace(/_+/g, '_') .replace(/^_|_$/g, ''); } function statusCor(status) { if (status === 'RECEBIDO' || status === 'RETIRADA' || status === 'FINALIZADA') { return { fundo: CORES.sucessoFundo, texto: CORES.sucesso }; } if ( status === 'PENDENTE' || status === 'CANCELADA' ) { return { fundo: CORES.erroFundo, texto: CORES.erro }; } if ( status === 'PARCIAL' || status === 'AGUARDANDO_PECA' || status === 'AGUARDANDO_APROVACAO' || status === 'AGUARDANDO_RETIRADA' ) { return { fundo: CORES.alertaFundo, texto: CORES.alerta }; } return { fundo: CORES.azulClaro, texto: CORES.azulEscuro }; } function configurarRespostaPdf(res, nomeArquivo) { res.status(200); res.setHeader('Content-Type', 'application/pdf'); res.setHeader( 'Content-Disposition', `attachment; filename="${nomeArquivoSeguro(nomeArquivo)}"` ); res.setHeader('Cache-Control', 'no-store'); } function criarDocumento(res, nomeArquivo, titulo, subtitulo) { configurarRespostaPdf(res, nomeArquivo); const doc = new PDFDocument({ size: 'A4', margins: { top: TOPO_CONTEUDO, right: MARGEM, bottom: 58, left: MARGEM }, bufferPages: true, info: { Title: titulo, Subject: subtitulo, Author: empresaConfig().nome, Creator: 'Oriontard' } }); doc.pipe(res); const contexto = { titulo, subtitulo, empresa: empresaConfig() }; doc.on('pageAdded', () => { desenharCabecalho(doc, contexto); }); desenharCabecalho(doc, contexto); return doc; } function desenharCabecalho(doc, contexto) { const empresa = contexto.empresa; const y = 28; doc.save(); if (empresa.logo && fs.existsSync(empresa.logo)) { try { doc.image(empresa.logo, MARGEM, y, { fit: [52, 40], align: 'left', valign: 'center' }); } catch (error) { desenharMarcaPadrao(doc, y); } } else { desenharMarcaPadrao(doc, y); } doc .font('Helvetica-Bold') .fontSize(14) .fillColor(CORES.textoForte) .text(empresa.nome, MARGEM + 64, y + 1, { width: 220 }); const linhaEmpresa = [ empresa.documento, empresa.contato, empresa.site ].filter(Boolean).join(' · '); if (linhaEmpresa) { doc .font('Helvetica') .fontSize(7.5) .fillColor(CORES.textoSuave) .text(linhaEmpresa, MARGEM + 64, y + 20, { width: 250 }); } doc .font('Helvetica-Bold') .fontSize(11) .fillColor(CORES.azulEscuro) .text(contexto.titulo, 320, y + 1, { width: LARGURA_A4 - MARGEM - 320, align: 'right' }); doc .font('Helvetica') .fontSize(7.5) .fillColor(CORES.textoSuave) .text(contexto.subtitulo, 310, y + 20, { width: LARGURA_A4 - MARGEM - 310, align: 'right' }); doc .moveTo(MARGEM, 78) .lineTo(LARGURA_A4 - MARGEM, 78) .lineWidth(1) .strokeColor(CORES.bordaClara) .stroke(); doc.restore(); doc.x = MARGEM; doc.y = TOPO_CONTEUDO; } function desenharMarcaPadrao(doc, y) { doc .roundedRect(MARGEM, y, 48, 40, 8) .fill(CORES.azulEscuro); doc .font('Helvetica-Bold') .fontSize(19) .fillColor(CORES.branco) .text('Z', MARGEM, y + 9, { width: 48, align: 'center' }); } function garantirEspaco(doc, alturaNecessaria) { if (doc.y + alturaNecessaria > RODAPE_Y - 18) { doc.addPage(); } } function tituloSecao(doc, titulo, subtitulo = '') { garantirEspaco(doc, subtitulo ? 50 : 34); doc .font('Helvetica-Bold') .fontSize(11) .fillColor(CORES.textoForte) .text(titulo, MARGEM, doc.y, { width: LARGURA_CONTEUDO }); doc.y += 4; doc .moveTo(MARGEM, doc.y) .lineTo(MARGEM + LARGURA_CONTEUDO, doc.y) .lineWidth(1.4) .strokeColor(CORES.azul) .stroke(); doc.y += 8; if (subtitulo) { doc .font('Helvetica') .fontSize(8) .fillColor(CORES.textoSuave) .text(subtitulo, { width: LARGURA_CONTEUDO }); doc.y += 7; } } function badge(doc, x, y, label, status, largura = 112) { const cor = statusCor(status); const cursorX = doc.x; const cursorY = doc.y; doc .roundedRect(x, y, largura, 22, 11) .fill(cor.fundo); doc .font('Helvetica-Bold') .fontSize(7.5) .fillColor(cor.texto) .text(label, x + 8, y + 7, { width: largura - 16, height: 10, align: 'center', ellipsis: true }); // Desenhos absolutos não podem alterar o fluxo vertical do documento. doc.x = cursorX; doc.y = cursorY; } function cardInfo(doc, x, y, largura, rotulo, valor, opcoes = {}) { const altura = opcoes.altura || 48; const cursorX = doc.x; const cursorY = doc.y; doc .roundedRect(x, y, largura, altura, 7) .lineWidth(0.8) .fillAndStroke(CORES.fundo, CORES.bordaClara); doc .font('Helvetica-Bold') .fontSize(6.5) .fillColor(CORES.textoSuave) .text(rotulo.toUpperCase(), x + 10, y + 9, { width: largura - 20, height: 9, ellipsis: true }); doc .font(opcoes.negrito === false ? 'Helvetica' : 'Helvetica-Bold') .fontSize(opcoes.fonte || 9.2) .fillColor(CORES.textoForte) .text(texto(valor), x + 10, y + 23, { width: largura - 20, height: altura - 28, ellipsis: true }); // doc.text() move doc.y mesmo quando usamos coordenadas absolutas. // Restaurar o cursor mantém todos os cards da mesma linha alinhados. doc.x = cursorX; doc.y = cursorY; } function blocoTexto(doc, rotulo, conteudo) { const valor = texto(conteudo, ''); if (!valor) { return; } const alturaTexto = doc .font('Helvetica') .fontSize(8.5) .heightOfString(valor, { width: LARGURA_CONTEUDO - 22, lineGap: 2 }); const altura = Math.max(52, alturaTexto + 31); garantirEspaco(doc, altura + 8); const y = doc.y; doc .roundedRect(MARGEM, y, LARGURA_CONTEUDO, altura, 7) .lineWidth(0.8) .fillAndStroke(CORES.fundo, CORES.bordaClara); doc .font('Helvetica-Bold') .fontSize(6.5) .fillColor(CORES.textoSuave) .text(rotulo.toUpperCase(), MARGEM + 11, y + 9, { width: LARGURA_CONTEUDO - 22 }); doc .font('Helvetica') .fontSize(8.5) .fillColor(CORES.texto) .text(valor, MARGEM + 11, y + 23, { width: LARGURA_CONTEUDO - 22, lineGap: 2 }); doc.y = y + altura + 8; } function nomesTecnicos(atividade) { const nomes = (atividade.tecnicos || []) .map(item => item.tecnico_nome) .filter(Boolean); return nomes.length ? nomes.join(', ') : '-'; } function desenharMateriaisAtividade(doc, materiais, interno) { if (!materiais || !materiais.length) { return; } garantirEspaco(doc, 46); const x = MARGEM + 12; const largura = LARGURA_CONTEUDO - 24; const colunas = interno ? [230, 48, 55, 78, 78] : [270, 55, 70, 90]; const cabecalhos = interno ? ['Material / insumo', 'Qtd.', 'Un.', 'Custo', 'Cobrado'] : ['Material / insumo', 'Qtd.', 'Un.', 'Valor']; function cabecalhoTabela() { garantirEspaco(doc, 30); const y = doc.y; doc .rect(x, y, largura, 22) .fill(CORES.azulClaro); let cx = x; cabecalhos.forEach((cabecalho, indice) => { doc .font('Helvetica-Bold') .fontSize(6.5) .fillColor(CORES.azulEscuro) .text(cabecalho, cx + 5, y + 7, { width: colunas[indice] - 10, align: indice === 0 ? 'left' : 'right' }); cx += colunas[indice]; }); doc.y = y + 22; } cabecalhoTabela(); materiais.forEach((material, indice) => { garantirEspaco(doc, 28); if (doc.y < TOPO_CONTEUDO + 5) { cabecalhoTabela(); } const y = doc.y; const alturaDescricao = doc .font('Helvetica') .fontSize(7) .heightOfString(texto(material.descricao_snapshot), { width: colunas[0] - 10 }); const altura = Math.max(24, alturaDescricao + 12); if (doc.y + altura > RODAPE_Y - 18) { doc.addPage(); cabecalhoTabela(); } if (indice % 2 === 1) { doc .rect(x, doc.y, largura, altura) .fill(CORES.fundo); } let cx = x; const valores = interno ? [ texto(material.descricao_snapshot), decimal(material.quantidade, 3), texto(material.unidade_snapshot), moeda(material.valor_custo_total), material.cobravel ? moeda(material.valor_cobrado_total) : 'Não cobrado' ] : [ texto(material.descricao_snapshot), decimal(material.quantidade, 3), texto(material.unidade_snapshot), material.cobravel ? moeda(material.valor_cobrado_total) : 'Não cobrado' ]; valores.forEach((valor, coluna) => { doc .font(coluna === 0 ? 'Helvetica' : 'Helvetica') .fontSize(7) .fillColor(CORES.texto) .text(valor, cx + 5, doc.y + 6, { width: colunas[coluna] - 10, height: altura - 8, align: coluna === 0 ? 'left' : 'right', ellipsis: true }); cx += colunas[coluna]; }); doc .moveTo(x, doc.y + altura) .lineTo(x + largura, doc.y + altura) .lineWidth(0.5) .strokeColor(CORES.bordaClara) .stroke(); doc.y += altura; }); doc.y += 6; } function desenharAtividade(doc, atividade, indice, interno) { garantirEspaco(doc, 108); const yInicial = doc.y; doc .roundedRect(MARGEM, yInicial, LARGURA_CONTEUDO, 30, 7) .fill(CORES.azulClaro); doc .font('Helvetica-Bold') .fontSize(8.5) .fillColor(CORES.azulEscuro) .text( `${String(indice + 1).padStart(2, '0')} · ${dataBR(atividade.data_atividade)}`, MARGEM + 11, yInicial + 10, { width: 135 } ); const horasTotais = numero(atividade.horas_expediente) + numero(atividade.horas_extras); doc .font('Helvetica') .fontSize(7.2) .fillColor(CORES.textoSuave) .text( `Técnicos: ${nomesTecnicos(atividade)}`, MARGEM + 145, yInicial + 10, { width: 235, ellipsis: true } ); doc .font('Helvetica-Bold') .fontSize(7.2) .fillColor(CORES.azulEscuro) .text( `${decimal(horasTotais)} h`, MARGEM + 395, yInicial + 10, { width: 62, align: 'right' } ); doc .font('Helvetica-Bold') .fontSize(7.2) .fillColor(CORES.azulEscuro) .text( atividade.cobravel ? moeda(atividade.valor_mao_obra) : 'Não cobrada', MARGEM + 462, yInicial + 10, { width: 38, align: 'right' } ); doc.y = yInicial + 38; const descricao = texto(atividade.descricao, ''); doc .font('Helvetica') .fontSize(8.5) .fillColor(CORES.texto) .text(descricao, MARGEM + 8, doc.y, { width: LARGURA_CONTEUDO - 16, lineGap: 2 }); doc.y += 7; const observacao = interno ? atividade.observacoes_internas : atividade.observacoes_cliente; if (observacao) { doc .font('Helvetica-Oblique') .fontSize(7.5) .fillColor(CORES.textoSuave) .text(`Observação: ${observacao}`, MARGEM + 8, doc.y, { width: LARGURA_CONTEUDO - 16 }); doc.y += 7; } desenharMateriaisAtividade(doc, atividade.materiais || [], interno); doc .moveTo(MARGEM, doc.y) .lineTo(MARGEM + LARGURA_CONTEUDO, doc.y) .lineWidth(0.7) .strokeColor(CORES.bordaClara) .stroke(); doc.y += 12; } function desenharResumoFinanceiro(doc, ordem, interno) { // Mantém o título e os primeiros cards juntos na mesma página. garantirEspaco(doc, interno ? 178 : 154); tituloSecao( doc, interno ? 'Resumo financeiro e custos' : 'Resumo financeiro', interno ? 'Valores cobrados do cliente e custos internos registrados.' : 'Valores consolidados desta ordem de serviço.' ); const y = doc.y; const largura = interno ? 120 : 150; const gap = 10; const itens = interno ? [ ['Mão de obra', moeda(ordem.valor_mao_obra)], ['Insumos cobrados', moeda(ordem.valor_insumos)], ['Custo de insumos', moeda(ordem.custo_insumos)], ['Desconto', moeda(ordem.valor_desconto)] ] : [ ['Mão de obra', moeda(ordem.valor_mao_obra)], ['Materiais e insumos', moeda(ordem.valor_insumos)], ['Desconto', moeda(ordem.valor_desconto)] ]; itens.forEach((item, indice) => { cardInfo( doc, MARGEM + ((largura + gap) * indice), y, largura, item[0], item[1], { altura: 48, fonte: 10 } ); }); doc.y = y + 59; const totalLargura = 160; cardInfo( doc, MARGEM, doc.y, totalLargura, 'Valor total', moeda(ordem.valor_total), { altura: 51, fonte: 12 } ); cardInfo( doc, MARGEM + totalLargura + gap, doc.y, totalLargura, 'Valor pago', moeda(ordem.valor_pago), { altura: 51, fonte: 12 } ); cardInfo( doc, MARGEM + ((totalLargura + gap) * 2), doc.y, totalLargura, 'Valor pendente', moeda(ordem.valor_pendente), { altura: 51, fonte: 12 } ); doc.y += 62; const labelPagamento = STATUS_PAGAMENTO_LABEL[ordem.status_pagamento] || texto(ordem.status_pagamento); badge( doc, MARGEM, doc.y, `Pagamento: ${labelPagamento}`, ordem.status_pagamento, 155 ); if (ordem.tipo === 'GARANTIA') { badge(doc, MARGEM + 165, doc.y, 'Serviço em garantia', 'PARCIAL', 135); } else if (ordem.tipo === 'CORTESIA') { badge(doc, MARGEM + 165, doc.y, 'Serviço em cortesia', 'PARCIAL', 135); } doc.y += 34; } function desenharOdometros(doc, odometros) { if (!odometros || !odometros.length) { return; } // Evita deixar o título da seção sozinho no fim da página. garantirEspaco(doc, 118); tituloSecao( doc, 'Leituras de odômetro', 'Leituras vinculadas à entrada, aos testes ou à saída da manutenção.' ); odometros.forEach(leitura => { garantirEspaco(doc, 76); const y = doc.y; doc .font('Helvetica-Bold') .fontSize(8) .fillColor(CORES.textoForte) .text( TIPO_ODOMETRO_LABEL[leitura.tipo_leitura] || texto(leitura.tipo_leitura), MARGEM, y, { width: 230 } ); doc .font('Helvetica') .fontSize(7) .fillColor(CORES.textoSuave) .text(dataHoraBR(leitura.leitura_em), MARGEM + 250, y, { width: 260, align: 'right' }); const valores = [ ['Total', segundosParaTempo(leitura.odometro_total_segundos)], ['Conectado', segundosParaTempo(leitura.odometro_conectado_segundos)], ['Movimento', segundosParaTempo(leitura.odometro_movimento_segundos)], ['Parcial', segundosParaTempo(leitura.odometro_parcial_segundos)] ]; valores.forEach((item, indice) => { cardInfo( doc, MARGEM + (indice * 126), y + 18, 116, item[0], item[1], { altura: 44, fonte: 9 } ); }); doc.y = y + 72; }); } function desenharAssinaturas(doc, ordem) { garantirEspaco(doc, 112); doc.y += 10; const y = doc.y + 44; const largura = 220; doc .moveTo(MARGEM, y) .lineTo(MARGEM + largura, y) .lineWidth(0.7) .strokeColor(CORES.textoSuave) .stroke(); doc .moveTo(LARGURA_A4 - MARGEM - largura, y) .lineTo(LARGURA_A4 - MARGEM, y) .stroke(); doc .font('Helvetica') .fontSize(7) .fillColor(CORES.textoSuave) .text( 'Responsável técnico', MARGEM, y + 6, { width: largura, align: 'center' } ); doc .text( 'Cliente / responsável pela retirada', LARGURA_A4 - MARGEM - largura, y + 6, { width: largura, align: 'center' } ); if (ordem.tecnico_responsavel_nome) { doc .font('Helvetica-Bold') .fontSize(7.5) .fillColor(CORES.texto) .text( ordem.tecnico_responsavel_nome, MARGEM, y + 19, { width: largura, align: 'center' } ); } if (ordem.responsavel_retirada) { doc .font('Helvetica-Bold') .fontSize(7.5) .fillColor(CORES.texto) .text( ordem.responsavel_retirada, LARGURA_A4 - MARGEM - largura, y + 19, { width: largura, align: 'center' } ); } doc.y = y + 42; } function adicionarRodapes(doc, identificador) { const paginas = doc.bufferedPageRange(); const ultimaPaginaOriginal = paginas.start + paginas.count - 1; for (let i = paginas.start; i <= ultimaPaginaOriginal; i += 1) { doc.switchToPage(i); const cursorX = doc.x; const cursorY = doc.y; const margemInferiorOriginal = doc.page.margins.bottom; // O rodapé fica fisicamente dentro do A4, mas abaixo da margem de fluxo. // Sem isto, o PDFKit cria uma página nova para cada doc.text() do rodapé. doc.page.margins.bottom = 0; doc .moveTo(MARGEM, RODAPE_Y) .lineTo(LARGURA_A4 - MARGEM, RODAPE_Y) .lineWidth(0.6) .strokeColor(CORES.bordaClara) .stroke(); doc .font('Helvetica') .fontSize(6.5) .fillColor(CORES.textoSuave) .text( `${identificador} · Gerado em ${dataHoraBR(new Date())}`, MARGEM, RODAPE_Y + 8, { width: 350, height: 9, lineBreak: false, ellipsis: true } ); doc .text( `Página ${i - paginas.start + 1} de ${paginas.count}`, 420, RODAPE_Y + 8, { width: LARGURA_A4 - MARGEM - 420, height: 9, align: 'right', lineBreak: false } ); doc.page.margins.bottom = margemInferiorOriginal; doc.x = cursorX; doc.y = cursorY; } // Termina posicionado na última página real, sem criar páginas vazias. doc.switchToPage(ultimaPaginaOriginal); } function gerarOrdemServicoPdf(res, ordem, opcoes = {}) { const interno = opcoes.interno === true; const equipamento = ordem.equipamento_nome_snapshot || ordem.modelo_snapshot || 'Equipamento'; const nomeArquivo = `OS_${ordem.id}_${equipamento}` + (ordem.numero_serie_snapshot ? `_NS_${ordem.numero_serie_snapshot}` : '') + (interno ? '_INTERNA' : '_CLIENTE') + '.pdf'; const doc = criarDocumento( res, nomeArquivo, `Ordem de Serviço #${ordem.id}`, interno ? 'Relatório técnico interno de manutenção' : 'Relatório técnico de manutenção' ); const y = doc.y; doc .font('Helvetica-Bold') .fontSize(18) .fillColor(CORES.textoForte) .text(`Ordem de Serviço #${ordem.id}`, MARGEM, y, { width: 260 }); doc .font('Helvetica') .fontSize(8) .fillColor(CORES.textoSuave) .text( `${TIPO_OS_LABEL[ordem.tipo] || texto(ordem.tipo)} · ` + `${texto(ordem.prioridade, 'Normal')}`, MARGEM, y + 23, { width: 250 } ); badge( doc, 340, y, STATUS_OS_LABEL[ordem.status] || texto(ordem.status), ordem.status, 150 ); doc.y = y + 46; const cardW = (LARGURA_CONTEUDO - 20) / 3; cardInfo( doc, MARGEM, doc.y, cardW, 'Equipamento', equipamento, { altura: 53 } ); cardInfo( doc, MARGEM + cardW + 10, doc.y, cardW, 'Número de série', ordem.numero_serie_snapshot || '-', { altura: 53 } ); cardInfo( doc, MARGEM + ((cardW + 10) * 2), doc.y, cardW, 'Cliente', ordem.cliente_nome_snapshot || '-', { altura: 53 } ); doc.y += 64; cardInfo( doc, MARGEM, doc.y, cardW, 'Entrada', dataHoraBR(ordem.data_entrada), { altura: 48 } ); cardInfo( doc, MARGEM + cardW + 10, doc.y, cardW, 'Finalização', dataHoraBR(ordem.data_finalizacao), { altura: 48 } ); cardInfo( doc, MARGEM + ((cardW + 10) * 2), doc.y, cardW, 'Retirada', dataHoraBR(ordem.data_retirada), { altura: 48 } ); doc.y += 60; blocoTexto(doc, 'Problema relatado', ordem.problema_relatado); blocoTexto(doc, 'Diagnóstico técnico', ordem.diagnostico); blocoTexto(doc, 'Solução executada', ordem.solucao_resumo); if (!interno && ordem.observacoes_cliente) { blocoTexto(doc, 'Observações ao cliente', ordem.observacoes_cliente); } if (interno && ordem.observacoes_internas) { blocoTexto(doc, 'Observações internas', ordem.observacoes_internas); } tituloSecao( doc, 'Atividades realizadas', 'Histórico cronológico das etapas executadas nesta manutenção.' ); if (!ordem.atividades || !ordem.atividades.length) { blocoTexto(doc, 'Atividades', 'Nenhuma atividade registrada.'); } else { ordem.atividades.forEach((atividade, indice) => { desenharAtividade(doc, atividade, indice, interno); }); } desenharOdometros(doc, ordem.odometros_da_os || []); desenharResumoFinanceiro(doc, ordem, interno); if (ordem.garantia_dias || ordem.garantia_ate) { blocoTexto( doc, 'Garantia do serviço', [ ordem.garantia_dias ? `${ordem.garantia_dias} dia(s)` : null, ordem.garantia_ate ? `válida até ${dataBR(ordem.garantia_ate)}` : null ].filter(Boolean).join(', ') ); } desenharAssinaturas(doc, ordem); adicionarRodapes(doc, `OS #${ordem.id}`); doc.end(); } function cabecalhoTabelaRelatorio(doc, colunas, y) { doc .rect(MARGEM, y, LARGURA_CONTEUDO, 24) .fill(CORES.azulEscuro); let x = MARGEM; colunas.forEach(coluna => { doc .font('Helvetica-Bold') .fontSize(6.2) .fillColor(CORES.branco) .text(coluna.titulo, x + 4, y + 8, { width: coluna.largura - 8, align: coluna.alinhamento || 'left' }); x += coluna.largura; }); doc.y = y + 24; } function linhaTabelaRelatorio(doc, colunas, valores, indice) { const alturas = valores.map((valor, coluna) => { return doc .font('Helvetica') .fontSize(6.5) .heightOfString(texto(valor), { width: colunas[coluna].largura - 8 }); }); const altura = Math.max(27, Math.min(44, Math.max(...alturas) + 10)); if (doc.y + altura > RODAPE_Y - 18) { doc.addPage(); cabecalhoTabelaRelatorio(doc, colunas, doc.y); } const y = doc.y; if (indice % 2 === 1) { doc .rect(MARGEM, y, LARGURA_CONTEUDO, altura) .fill(CORES.fundo); } let x = MARGEM; valores.forEach((valor, coluna) => { doc .font('Helvetica') .fontSize(6.5) .fillColor(CORES.texto) .text(texto(valor), x + 4, y + 6, { width: colunas[coluna].largura - 8, height: altura - 8, ellipsis: true, align: colunas[coluna].alinhamento || 'left' }); x += colunas[coluna].largura; }); doc .moveTo(MARGEM, y + altura) .lineTo(MARGEM + LARGURA_CONTEUDO, y + altura) .lineWidth(0.45) .strokeColor(CORES.bordaClara) .stroke(); doc.y = y + altura; } function gerarRelatorioOrdensServicoPdf(res, ordens, filtros = {}) { const inicio = filtros.dataInicio; const fim = filtros.dataFim; const periodo = `${dataBR(inicio)} a ${dataBR(fim)}`; const nomeArquivo = `Relatorio_OS_${nomeArquivoSeguro( dataBR(inicio) )}_${nomeArquivoSeguro(dataBR(fim))}.pdf`; const doc = criarDocumento( res, nomeArquivo, 'Relatório de Serviços e Cobranças', `Período: ${periodo}` ); const totalHoras = ordens.reduce( (soma, ordem) => soma + numero(ordem.total_horas_expediente) + numero(ordem.total_horas_extras), 0 ); const totalServicos = ordens.reduce( (soma, ordem) => soma + numero(ordem.valor_mao_obra), 0 ); const totalInsumos = ordens.reduce( (soma, ordem) => soma + numero(ordem.valor_insumos), 0 ); const totalGeral = ordens.reduce( (soma, ordem) => soma + numero(ordem.valor_total), 0 ); const totalPago = ordens.reduce( (soma, ordem) => soma + numero(ordem.valor_pago), 0 ); const totalPendente = ordens.reduce( (soma, ordem) => soma + numero(ordem.valor_pendente), 0 ); doc .font('Helvetica-Bold') .fontSize(18) .fillColor(CORES.textoForte) .text('Relatório de Serviços e Cobranças'); doc .font('Helvetica') .fontSize(8) .fillColor(CORES.textoSuave) .text( `Período: ${periodo} · Critério: ${texto(filtros.criterioLabel)}`, { width: LARGURA_CONTEUDO } ); doc.y += 16; const resumo = [ ['Ordens', ordens.length], ['Horas', `${decimal(totalHoras)} h`], ['Mão de obra', moeda(totalServicos)], ['Insumos', moeda(totalInsumos)] ]; const cardW = (LARGURA_CONTEUDO - 30) / 4; resumo.forEach((item, indice) => { cardInfo( doc, MARGEM + ((cardW + 10) * indice), doc.y, cardW, item[0], item[1], { altura: 50, fonte: 10 } ); }); doc.y += 62; const resumoFinanceiro = [ ['Total faturado', moeda(totalGeral)], ['Total recebido', moeda(totalPago)], ['Total pendente', moeda(totalPendente)] ]; const cardFinanceiroW = (LARGURA_CONTEUDO - 20) / 3; resumoFinanceiro.forEach((item, indice) => { cardInfo( doc, MARGEM + ((cardFinanceiroW + 10) * indice), doc.y, cardFinanceiroW, item[0], item[1], { altura: 53, fonte: 11.5 } ); }); doc.y += 72; tituloSecao( doc, 'Ordens incluídas', filtros.clienteNome ? `Cliente: ${filtros.clienteNome}` : 'Relação consolidada das ordens que atendem aos filtros informados.' ); if (!ordens.length) { blocoTexto(doc, 'Resultado', 'Nenhuma ordem de serviço encontrada para o período.'); } else { const colunas = [ { titulo: 'OS', largura: 31, alinhamento: 'center' }, { titulo: 'Equipamento', largura: 104 }, { titulo: 'Finalização', largura: 58, alinhamento: 'center' }, { titulo: 'Horas', largura: 45, alinhamento: 'right' }, { titulo: 'Mão de obra', largura: 65, alinhamento: 'right' }, { titulo: 'Insumos', largura: 58, alinhamento: 'right' }, { titulo: 'Total', largura: 67, alinhamento: 'right' }, { titulo: 'Pendente', largura: 83, alinhamento: 'right' } ]; cabecalhoTabelaRelatorio(doc, colunas, doc.y); ordens.forEach((ordem, indice) => { linhaTabelaRelatorio( doc, colunas, [ `#${ordem.id}`, `${texto(ordem.equipamento_nome_snapshot)}${ ordem.numero_serie_snapshot ? ` · NS ${ordem.numero_serie_snapshot}` : '' }`, dataBR( ordem.data_finalizacao || ordem.data_retirada || ordem.data_entrada ), decimal( numero(ordem.total_horas_expediente) + numero(ordem.total_horas_extras) ), moeda(ordem.valor_mao_obra), moeda(ordem.valor_insumos), moeda(ordem.valor_total), moeda(ordem.valor_pendente) ], indice ); }); doc.y += 12; } tituloSecao(doc, 'Consolidação financeira'); garantirEspaco(doc, 98); const linhas = [ ['Mão de obra', totalServicos], ['Materiais e insumos', totalInsumos], ['Valor total das OS', totalGeral], ['Valor recebido', totalPago], ['Valor pendente', totalPendente] ]; linhas.forEach((linha, indice) => { const y = doc.y; if (indice % 2 === 0) { doc .rect(MARGEM, y, LARGURA_CONTEUDO, 24) .fill(CORES.fundo); } doc .font(indice >= 2 ? 'Helvetica-Bold' : 'Helvetica') .fontSize(8) .fillColor(CORES.texto) .text(linha[0], MARGEM + 8, y + 8, { width: 300 }); doc .font('Helvetica-Bold') .text(moeda(linha[1]), MARGEM + 320, y + 8, { width: LARGURA_CONTEUDO - 328, align: 'right' }); doc.y = y + 24; }); doc.y += 18; blocoTexto( doc, 'Observação', 'Este relatório consolida os valores registrados nas ordens de serviço. ' + 'Pagamentos cancelados não compõem o valor recebido.' ); adicionarRodapes(doc, 'Relatório de serviços'); doc.end(); } module.exports = { gerarOrdemServicoPdf, gerarRelatorioOrdensServicoPdf, nomeArquivoSeguro };