Site/server/services/orionPdfService.js

1465 lines
32 KiB
JavaScript

'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
};