panels-origin/api/pdf.ts
Cursor Agent 31a7c1cbf5
fix: gafete usa la empresa del tenant y corrige fn_badge_job_get
El ORDER BY del listado de personas refería la tabla w fuera del
subquery (42P01). El frente del gafete ya no imprime Construcciones
Arctec/Zendala: usa el nombre comercial de la empresa del proyecto.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 20:52:35 +00:00

414 lines
11 KiB
TypeScript

import QRCode from "qrcode";
import { PDFDocument, StandardFonts, rgb, degrees, type PDFPage, type PDFFont, type PDFImage } from "pdf-lib";
import { config } from "./config.ts";
import { decryptBytes } from "./docs_crypto.ts";
import { fullName, frontName } from "./mx.ts";
import type { Db } from "./db.ts";
import { badgeJobPdfKey, getObject, putObject, workerDocKey } from "./storage.ts";
const CM = 28.346456692913385;
const CARD_W = 6.7 * CM;
const CARD_H = 10.5 * CM;
const PAGE_W = 792;
const PAGE_H = 612;
type WorkerRow = {
id: number;
first_name: string;
middle_name: string | null;
last_name_p: string;
last_name_m: string;
curp: string;
nss: string;
blood_type: string | null;
position: string;
risk_color: string;
risk_text: string;
photo_storage?: string | null;
photo_iv?: string | null;
};
export function badgeVcardUrl(curp: string) {
const token = btoa(curp).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
return `${config.vcardBase}${token}`;
}
export async function badgeQrPng(curp: string): Promise<Uint8Array> {
return await QRCode.toBuffer(badgeVcardUrl(curp), {
errorCorrectionLevel: "H",
width: 280,
margin: 1,
}) as Uint8Array;
}
function hexRgb(hex: string) {
const h = hex.replace("#", "");
return rgb(
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
);
}
export async function loadCurrentPhoto(db: Db, workerId: number): Promise<Uint8Array | null> {
const doc = await db.prepare(
`SELECT storage_name, iv FROM documents
WHERE worker_id = ? AND type_code = 'foto' AND is_current = true
ORDER BY id DESC LIMIT 1`,
).get(workerId) as { storage_name: string; iv: string } | undefined;
if (!doc) return null;
try {
const enc = await getObject(workerDocKey(workerId, doc.storage_name));
return await decryptBytes(doc.iv, enc);
} catch {
return null;
}
}
async function embedMaybe(
pdf: PDFDocument,
bytes: Uint8Array | null,
): Promise<PDFImage | null> {
if (!bytes) return null;
try {
return await pdf.embedJpg(bytes);
} catch {
try {
return await pdf.embedPng(bytes);
} catch {
return null;
}
}
}
function drawCentered(
page: PDFPage,
font: PDFFont,
text: string,
x: number,
y: number,
w: number,
size: number,
color = rgb(0, 0, 0),
) {
const tw = font.widthOfTextAtSize(text, size);
page.drawText(text, { x: x + Math.max(0, (w - tw) / 2), y, size, font, color });
}
export async function generateBadgePdf(
db: Db,
projectId: number,
workerIds: number[],
): Promise<Uint8Array> {
const project = await db.prepare(
`SELECT p.name, p.code, p.theme_id, p.logo_left_path, p.logo_right_path, p.tenant_id,
COALESCE(NULLIF(c.nombre_comercial, ''), NULLIF(c.name, '')) AS company_name
FROM projects p
LEFT JOIN companies c ON c.id = p.company_id
WHERE p.id = ?`,
).get(projectId) as {
name: string;
code: string;
theme_id: string;
logo_left_path: string | null;
logo_right_path: string | null;
tenant_id?: number | null;
company_name?: string | null;
} | undefined;
if (!project) throw new Error("Proyecto no encontrado");
let companyName = String(project.company_name || "").trim();
if (!companyName && project.tenant_id) {
const principal = await db.prepare(
`SELECT COALESCE(NULLIF(nombre_comercial, ''), name) AS company_name
FROM companies
WHERE tenant_id = ? AND kind = 'principal'
ORDER BY id
LIMIT 1`,
).get(project.tenant_id) as { company_name?: string } | undefined;
companyName = String(principal?.company_name || "").trim();
}
const workers = await db.prepare(
`SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m,
w.curp, w.nss, w.blood_type, w.position,
r.color AS risk_color, r.text_color AS risk_text
FROM workers w
JOIN risk_levels r ON r.code = w.risk_code
WHERE w.id = ANY(?)`,
).all(workerIds) as WorkerRow[];
const byId = new Map(workers.map((w) => [w.id, w]));
const ordered = workerIds.map((id) => byId.get(id)).filter(Boolean) as WorkerRow[];
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
let logoL: PDFImage | null = null;
let logoR: PDFImage | null = null;
if (project.logo_left_path) {
try {
logoL = await embedMaybe(pdf, await getObject(project.logo_left_path));
} catch { /* text fallback */ }
}
if (project.logo_right_path) {
try {
logoR = await embedMaybe(pdf, await getObject(project.logo_right_path));
} catch { /* text fallback */ }
}
const gap = 4;
const totalW = 4 * CARD_W + 3 * gap;
const originX = (PAGE_W - totalW) / 2;
const topY = PAGE_H - 8 - CARD_H;
const botY = 8;
const chunk = 4;
for (let i = 0; i < ordered.length; i += chunk) {
const group = ordered.slice(i, i + chunk);
const page = pdf.addPage([PAGE_W, PAGE_H]);
for (let c = 0; c < group.length; c++) {
const w = group[c];
const x = originX + c * (CARD_W + gap);
const photo = await embedMaybe(pdf, await loadCurrentPhoto(db, w.id));
const qrPng = await badgeQrPng(w.curp);
const qrImg = await pdf.embedPng(qrPng);
drawFront(page, font, fontBold, x, topY, w, photo, logoL, logoR, companyName);
drawBack(page, font, fontBold, x, botY, w, qrImg, project.name, project.code);
}
}
return await pdf.save();
}
function drawFittedLeft(
page: PDFPage,
font: PDFFont,
text: string,
x: number,
y: number,
maxWidth: number,
size: number,
color = rgb(0, 0, 0),
) {
if (!text) return;
let s = size;
while (s > 5 && font.widthOfTextAtSize(text, s) > maxWidth) s -= 0.4;
page.drawText(text, { x, y, size: s, font, color });
}
function drawFront(
page: PDFPage,
font: PDFFont,
fontBold: PDFFont,
x: number,
y: number,
w: WorkerRow,
photo: PDFImage | null,
logoL: PDFImage | null,
logoR: PDFImage | null,
companyName: string,
) {
page.drawRectangle({
x,
y,
width: CARD_W,
height: CARD_H,
borderColor: rgb(0, 0, 0),
borderWidth: 1.5,
});
const logoH = CARD_H * 0.16;
if (logoL) {
const scale = Math.min((CARD_W / 2 - 8) / logoL.width, (logoH - 4) / logoL.height);
page.drawImage(logoL, {
x: x + 6,
y: y + CARD_H - logoH - 4,
width: logoL.width * scale,
height: logoL.height * scale,
});
} else {
drawFittedLeft(
page,
fontBold,
companyName.toUpperCase(),
x + 6,
y + CARD_H - 22,
CARD_W / 2 - 10,
7,
);
}
if (logoR) {
const scale = Math.min((CARD_W / 2 - 8) / logoR.width, (logoH - 4) / logoR.height);
page.drawImage(logoR, {
x: x + CARD_W / 2 + 4,
y: y + CARD_H - logoH - 4,
width: logoR.width * scale,
height: logoR.height * scale,
});
}
const photoW = 3.5 * CM;
const photoH = 4.5 * CM;
const px = x + (CARD_W - photoW) / 2;
const py = y + CARD_H - logoH - photoH - 18;
page.drawRectangle({
x: px,
y: py,
width: photoW,
height: photoH,
borderColor: rgb(0, 0, 0),
borderWidth: 2,
});
if (photo) {
page.drawImage(photo, { x: px + 1, y: py + 1, width: photoW - 2, height: photoH - 2 });
}
drawCentered(page, fontBold, w.first_name.toUpperCase(), x, py - 22, CARD_W, 13);
drawCentered(page, fontBold, w.last_name_p.toUpperCase(), x, py - 38, CARD_W, 13);
const barH = 28;
const barY = y + 10;
page.drawRectangle({
x: x + 1.5,
y: barY,
width: CARD_W - 3,
height: barH,
color: hexRgb(w.risk_color),
});
drawCentered(
page,
fontBold,
w.position.toUpperCase(),
x,
barY + 8,
CARD_W,
11,
hexRgb(w.risk_text || "#FFFFFF"),
);
}
function drawBack(
page: PDFPage,
font: PDFFont,
fontBold: PDFFont,
x: number,
y: number,
w: WorkerRow,
qr: PDFImage,
projectName: string,
projectCode: string,
) {
page.drawRectangle({
x,
y,
width: CARD_W,
height: CARD_H,
borderColor: rgb(0, 0, 0),
borderWidth: 1.5,
rotate: degrees(180),
});
page.drawRectangle({
x: x + CARD_W,
y: y + CARD_H,
width: CARD_W,
height: CARD_H,
borderColor: rgb(0, 0, 0),
borderWidth: 1.5,
rotate: degrees(180),
});
const qrSize = CARD_W * 0.5;
page.drawImage(qr, {
x: x + CARD_W - (CARD_W - qrSize) / 2 - qrSize,
y: y + CARD_H - 16 - qrSize,
width: qrSize,
height: qrSize,
rotate: degrees(180),
});
const lines = [
`GRUPO SANGUINEO: ${w.blood_type ?? ""}`,
`CURP: ${w.curp}`,
`NSS: ${w.nss}`,
`NOMBRE: ${fullName(w)}`,
];
let ly = y + CARD_H - qrSize - 36;
for (const line of lines) {
page.drawText(line, {
x: x + CARD_W - 12,
y: ly,
size: 7,
font,
rotate: degrees(180),
maxWidth: CARD_W - 20,
});
ly -= 12;
}
page.drawText((projectCode || "").toUpperCase(), {
x: x + CARD_W - 20,
y: y + 36,
size: 10,
font: fontBold,
rotate: degrees(180),
});
page.drawText(projectName.toUpperCase(), {
x: x + CARD_W - 20,
y: y + 22,
size: 10,
font: fontBold,
rotate: degrees(180),
});
}
export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise<string> {
const key = badgeJobPdfKey(jobId);
await putObject(key, bytes);
return key;
}
function wrap(text: string, font: PDFFont, size: number, max: number): string[] {
const words = text.split(/\s+/);
const lines: string[] = [];
let cur = "";
for (const word of words) {
const next = cur ? `${cur} ${word}` : word;
if (font.widthOfTextAtSize(next, size) > max && cur) {
lines.push(cur);
cur = word;
} else {
cur = next;
}
}
if (cur) lines.push(cur);
return lines;
}
export async function generateLoanReceiptPdf(opts: {
title: string;
subtitle: string;
person: string;
lines: { label: string; value: string }[];
}): Promise<Uint8Array> {
const pdf = await PDFDocument.create();
const page = pdf.addPage([612, 792]);
const font = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
page.drawText("ARCTEC / PANEL OBRA", { x: 56, y: 740, size: 11, font: bold, color: rgb(0.12, 0.18, 0.28) });
page.drawText(opts.title, { x: 56, y: 710, size: 18, font: bold });
page.drawText(opts.subtitle, { x: 56, y: 688, size: 10, font, color: rgb(0.3, 0.3, 0.3) });
page.drawText(opts.person, { x: 56, y: 650, size: 14, font: bold });
let y = 610;
for (const row of opts.lines) {
page.drawText(row.label, { x: 56, y, size: 10, font, color: rgb(0.35, 0.35, 0.35) });
const valueLines = wrap(row.value, bold, 11, 280);
let vy = y;
for (const line of valueLines) {
page.drawText(line, { x: 280, y: vy, size: 11, font: bold });
vy -= 14;
}
y = Math.min(y, vy) - 18;
}
page.drawText("Recibo interno. No es un CFDI.", { x: 56, y: 80, size: 9, font, color: rgb(0.4, 0.4, 0.4) });
return await pdf.save();
}