merge: #18 gafete job y empresa del tenant

Añade 032-fn-badge-job-get al changelog junto a los demás 032.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-08 21:13:45 +00:00
commit ad152d46f4
No known key found for this signature in database
6 changed files with 138 additions and 23 deletions

View file

@ -1383,8 +1383,9 @@ app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, requirePermission("documen
{ job_id: id }, { job_id: id },
{ route }, { route },
); );
if (!jobEnv.ok) return respondRpc(c, jobEnv); const pdfPath = jobEnv.ok
const pdfPath = jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id); ? (jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id))
: badgeJobPdfKey(id);
const bytes = await getObject(pdfPath); const bytes = await getObject(pdfPath);
c.header("Content-Type", "application/pdf"); c.header("Content-Type", "application/pdf");
c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`); c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`);

View file

@ -101,16 +101,34 @@ export async function generateBadgePdf(
workerIds: number[], workerIds: number[],
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const project = await db.prepare( const project = await db.prepare(
"SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?", `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 { ).get(projectId) as {
name: string; name: string;
code: string; code: string;
theme_id: string; theme_id: string;
logo_left_path: string | null; logo_left_path: string | null;
logo_right_path: string | null; logo_right_path: string | null;
tenant_id?: number | null;
company_name?: string | null;
} | undefined; } | undefined;
if (!project) throw new Error("Proyecto no encontrado"); 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( const workers = await db.prepare(
`SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, `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, w.curp, w.nss, w.blood_type, w.position,
@ -156,7 +174,7 @@ export async function generateBadgePdf(
const photo = await embedMaybe(pdf, await loadCurrentPhoto(db, w.id)); const photo = await embedMaybe(pdf, await loadCurrentPhoto(db, w.id));
const qrPng = await badgeQrPng(w.curp); const qrPng = await badgeQrPng(w.curp);
const qrImg = await pdf.embedPng(qrPng); const qrImg = await pdf.embedPng(qrPng);
drawFront(page, font, fontBold, x, topY, w, photo, logoL, logoR); drawFront(page, font, fontBold, x, topY, w, photo, logoL, logoR, companyName);
drawBack(page, font, fontBold, x, botY, w, qrImg, project.name, project.code); drawBack(page, font, fontBold, x, botY, w, qrImg, project.name, project.code);
} }
} }
@ -164,6 +182,22 @@ export async function generateBadgePdf(
return await pdf.save(); 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( function drawFront(
page: PDFPage, page: PDFPage,
font: PDFFont, font: PDFFont,
@ -174,6 +208,7 @@ function drawFront(
photo: PDFImage | null, photo: PDFImage | null,
logoL: PDFImage | null, logoL: PDFImage | null,
logoR: PDFImage | null, logoR: PDFImage | null,
companyName: string,
) { ) {
page.drawRectangle({ page.drawRectangle({
x, x,
@ -193,12 +228,15 @@ function drawFront(
height: logoL.height * scale, height: logoL.height * scale,
}); });
} else { } else {
page.drawText("Construcciones Arctec", { drawFittedLeft(
x: x + 6, page,
y: y + CARD_H - 22, fontBold,
size: 7, companyName.toUpperCase(),
font: fontBold, x + 6,
}); y + CARD_H - 22,
CARD_W / 2 - 10,
7,
);
} }
if (logoR) { if (logoR) {
const scale = Math.min((CARD_W / 2 - 8) / logoR.width, (logoH - 4) / logoR.height); const scale = Math.min((CARD_W / 2 - 8) / logoR.width, (logoH - 4) / logoR.height);
@ -208,14 +246,6 @@ function drawFront(
width: logoR.width * scale, width: logoR.width * scale,
height: logoR.height * scale, height: logoR.height * scale,
}); });
} else {
page.drawText("Zendala", {
x: x + CARD_W / 2 + 8,
y: y + CARD_H - 22,
size: 9,
font: fontBold,
color: rgb(0.1, 0.25, 0.55),
});
} }
const photoW = 3.5 * CM; const photoW = 3.5 * CM;

View file

@ -41,6 +41,7 @@
<include file="changesets/032-rpc-document-get-id.sql" relativeToChangelogFile="true"/> <include file="changesets/032-rpc-document-get-id.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-fix-work-program-jsonb-object-length.sql" relativeToChangelogFile="true"/> <include file="changesets/032-fix-work-program-jsonb-object-length.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-document-validity-from-upload.sql" relativeToChangelogFile="true"/> <include file="changesets/032-document-validity-from-upload.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-fn-badge-job-get.sql" relativeToChangelogFile="true"/>
<include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/> <include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/>
<include file="changesets/034-fn-expense-update-reclassify.sql" relativeToChangelogFile="true"/> <include file="changesets/034-fn-expense-update-reclassify.sql" relativeToChangelogFile="true"/>
<include file="changesets/035-warehouse-materials-catalog.sql" relativeToChangelogFile="true"/> <include file="changesets/035-warehouse-materials-catalog.sql" relativeToChangelogFile="true"/>

View file

@ -0,0 +1,80 @@
--liquibase formatted sql
-- PANELS · core · fn_badge_job_get: ORDER BY del subquery de personas
--changeset panel:core-032-fn-badge-job-get-people-order splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_badge_job_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_job_id bigint := COALESCE(
NULLIF(payload->>'job_id', '')::bigint,
NULLIF(payload->>'id', '')::bigint
);
v_job jsonb;
v_people jsonb;
BEGIN
IF v_job_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_badge_job_get: job_id es obligatorio',
jsonb_build_object('fn', 'fn_badge_job_get', 'field', 'job_id')
);
END IF;
SELECT to_jsonb(x)
INTO v_job
FROM (
SELECT
j.*,
p.code AS project_code,
p.name AS project_name
FROM badge_jobs j
JOIN projects p ON p.id = j.project_id
WHERE j.id = v_job_id
) x;
IF v_job IS NULL THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_badge_job_get: job de gafetes id=%s no encontrado', v_job_id),
jsonb_build_object('fn', 'fn_badge_job_get', 'job_id', v_job_id)
);
END IF;
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.last_name_p, p.first_name), '[]'::jsonb)
INTO v_people
FROM (
SELECT
bp.job_id,
bp.worker_id,
bp.delivered,
bp.delivered_at,
w.first_name,
w.last_name_p,
w.last_name_m,
w.position
FROM badge_job_people bp
JOIN workers w ON w.id = bp.worker_id
WHERE bp.job_id = v_job_id::integer
) p;
RETURN core.rpc_ok(
jsonb_build_object('job', v_job, 'people', v_people),
format('Job de gafetes #%s cargado: %s persona(s) en el lote',
v_job_id, jsonb_array_length(v_people)),
jsonb_build_object(
'fn', 'fn_badge_job_get',
'job_id', v_job_id,
'people_count', jsonb_array_length(v_people)
)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_badge_job_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-032-fn-badge-job-get-grant endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_badge_job_get(jsonb) TO panels_core_app;

View file

@ -2,7 +2,7 @@
<article class="badge" :class="{ 'badge-back-rot': side === 'back' && rotated, fluid }"> <article class="badge" :class="{ 'badge-back-rot': side === 'back' && rotated, fluid }">
<template v-if="side === 'front'"> <template v-if="side === 'front'">
<div class="badge-logos"> <div class="badge-logos">
<div class="logo-l">Construcciones<br />Arctec</div> <div class="logo-l">{{ companyName || "—" }}</div>
<div class="logo-r">{{ projectName }}</div> <div class="logo-r">{{ projectName }}</div>
</div> </div>
<div class="badge-photo"> <div class="badge-photo">
@ -47,6 +47,7 @@ const props = defineProps<{
blood: string; blood: string;
riskColor: string; riskColor: string;
riskText: string; riskText: string;
companyName?: string;
projectName: string; projectName: string;
projectCode: string; projectCode: string;
photoSrc?: string; photoSrc?: string;

View file

@ -149,7 +149,8 @@ const SAMPLE: Worker = {
const { api, download } = useApi(); const { api, download } = useApi();
const desk = useDesktop(); const desk = useDesktop();
const toast = useToast(); const toast = useToast();
const projects = ref<{ id: number; name: string; code?: string }[]>([]); const { user } = useAuth();
const projects = ref<{ id: number; name: string; code?: string; company_name?: string }[]>([]);
const projectId = ref<number | null>(null); const projectId = ref<number | null>(null);
const workers = ref<Worker[]>([]); const workers = ref<Worker[]>([]);
const workerId = ref(0); const workerId = ref(0);
@ -203,8 +204,9 @@ const cardProps = computed(() => {
blood: (w.blood_type || "").toUpperCase(), blood: (w.blood_type || "").toUpperCase(),
riskColor: r?.color || w.risk_color || "#A20000", riskColor: r?.color || w.risk_color || "#A20000",
riskText: r?.text_color || w.risk_text || "#FFFFFF", riskText: r?.text_color || w.risk_text || "#FFFFFF",
projectName: (project.value?.name || "Zendala Cancún").toUpperCase(), companyName: (project.value?.company_name || user.value?.company_name || "").toUpperCase(),
projectCode: (project.value?.code || "PRY-0001").toUpperCase(), projectName: (project.value?.name || "Obra").toUpperCase(),
projectCode: (project.value?.code || "").toUpperCase(),
photoSrc: w.id ? `/v1/workers/${w.id}/photo` : "", photoSrc: w.id ? `/v1/workers/${w.id}/photo` : "",
qrSrc: `/v1/badge-qr?curp=${encodeURIComponent(w.curp)}`, qrSrc: `/v1/badge-qr?curp=${encodeURIComponent(w.curp)}`,
}; };
@ -236,7 +238,7 @@ async function loadWorkers() {
async function load() { async function load() {
const [p, j, c] = await Promise.all([ const [p, j, c] = await Promise.all([
api<{ projects: { id: number; name: string; code?: string }[] }>("/v1/projects?status=activo"), api<{ projects: { id: number; name: string; code?: string; company_name?: string }[] }>("/v1/projects?status=activo"),
api<{ jobs: Record<string, unknown>[] }>("/v1/badge-jobs"), api<{ jobs: Record<string, unknown>[] }>("/v1/badge-jobs"),
api<{ risks: { code: string; label: string; color: string; text_color: string }[] }>("/v1/catalogs"), api<{ risks: { code: string; label: string; color: string; text_color: string }[] }>("/v1/catalogs"),
]); ]);