2026-09-15 13:44:07 +08:00
|
|
|
|
const fs = require("fs");
|
|
|
|
|
|
const fsp = require("fs/promises");
|
|
|
|
|
|
const path = require("path");
|
|
|
|
|
|
const crypto = require("crypto");
|
|
|
|
|
|
const { ObjectId } = require("mongodb");
|
|
|
|
|
|
const dayjs = require("dayjs");
|
|
|
|
|
|
const axios = require("axios");
|
|
|
|
|
|
const puppeteer = require("puppeteer-core");
|
|
|
|
|
|
const { decryptDiagnosticRecordFields } = require("../diagnostic-record/format");
|
|
|
|
|
|
|
|
|
|
|
|
let browserPromise;
|
|
|
|
|
|
let activeJobs = 0;
|
|
|
|
|
|
const waiters = [];
|
|
|
|
|
|
const cachedPdfJobs = new Map();
|
2026-10-12 16:55:21 +08:00
|
|
|
|
const PDF_TEMPLATE_VERSION = "v11";
|
2026-09-15 13:44:07 +08:00
|
|
|
|
const MAX_SIGNATURE_IMAGE_BYTES = 1024 * 1024;
|
|
|
|
|
|
const SIGNATURE_DOWNLOAD_TIMEOUT_MS = 5000;
|
2026-10-12 16:55:21 +08:00
|
|
|
|
const MAX_PRESCRIPTION_PDF_BYTES = 100 * 1024;
|
|
|
|
|
|
const RASTER_PAGE_SIZES = {
|
|
|
|
|
|
// A5 (148mm) 减去左右 8mm 页边距后的实际打印宽度为 132mm。
|
|
|
|
|
|
A5: { viewport: { width: 500, height: 733, deviceScaleFactor: 1 }, contentWidth: "132mm", contentHeight: "194mm" },
|
|
|
|
|
|
A4: { viewport: { width: 733, height: 1062, deviceScaleFactor: 1 }, contentWidth: "194mm", contentHeight: "281mm" },
|
|
|
|
|
|
};
|
2026-09-24 18:22:05 +08:00
|
|
|
|
const PRESCRIPTION_SEAL_IMAGE_URL = process.env.CONFIG_PRESCRIPTION_SEAL_IMAGE_URL
|
|
|
|
|
|
|| "https://hlwyy.hnkhhospital.com/upload-files/other/stamp.png";
|
2026-09-15 13:44:07 +08:00
|
|
|
|
|
|
|
|
|
|
const signatureMimeTypes = {
|
|
|
|
|
|
".png": "image/png",
|
|
|
|
|
|
".jpg": "image/jpeg",
|
|
|
|
|
|
".jpeg": "image/jpeg",
|
|
|
|
|
|
".webp": "image/webp",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function resolveDefaultPdfRoot(
|
|
|
|
|
|
moduleDirectory = __dirname,
|
|
|
|
|
|
isBuilt = process.env.IS_BUILDED === "YES"
|
|
|
|
|
|
) {
|
|
|
|
|
|
const serviceRoot = isBuilt
|
|
|
|
|
|
? moduleDirectory
|
|
|
|
|
|
: path.resolve(moduleDirectory, "../..");
|
|
|
|
|
|
return path.resolve(serviceRoot, "../private-files/prescriptions");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getPdfRoot() {
|
|
|
|
|
|
return path.resolve(
|
|
|
|
|
|
process.env.CONFIG_PRESCRIPTION_PDF_ROOT || resolveDefaultPdfRoot()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function initializeStorage() {
|
|
|
|
|
|
const pdfRoot = getPdfRoot();
|
|
|
|
|
|
await fsp.mkdir(pdfRoot, { recursive: true });
|
|
|
|
|
|
await fsp.access(pdfRoot, fs.constants.R_OK | fs.constants.W_OK);
|
|
|
|
|
|
return pdfRoot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function maxConcurrency() {
|
|
|
|
|
|
const value = Number.parseInt(process.env.PRESCRIPTION_PDF_MAX_CONCURRENCY, 10);
|
|
|
|
|
|
return Number.isInteger(value) && value > 0 ? value : 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function acquireSlot() {
|
|
|
|
|
|
if (activeJobs < maxConcurrency()) {
|
|
|
|
|
|
activeJobs += 1;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await new Promise((resolve) => waiters.push(resolve));
|
|
|
|
|
|
activeJobs += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function releaseSlot() {
|
|
|
|
|
|
activeJobs -= 1;
|
|
|
|
|
|
const next = waiters.shift();
|
|
|
|
|
|
if (next) next();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getExecutablePath() {
|
|
|
|
|
|
const platformCandidates = process.platform === "linux"
|
|
|
|
|
|
? [
|
|
|
|
|
|
"/usr/bin/google-chrome",
|
|
|
|
|
|
"/usr/bin/google-chrome-stable",
|
|
|
|
|
|
"/usr/bin/chromium",
|
|
|
|
|
|
"/usr/bin/chromium-browser",
|
|
|
|
|
|
]
|
|
|
|
|
|
: [
|
|
|
|
|
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
|
|
|
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
|
|
|
|
];
|
|
|
|
|
|
const candidates = [process.env.PUPPETEER_EXECUTABLE_PATH, ...platformCandidates]
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
const executablePath = candidates.find((candidate) => fs.existsSync(candidate));
|
|
|
|
|
|
if (!executablePath) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`未找到 Chrome/Chromium,请配置 PUPPETEER_EXECUTABLE_PATH。已检查:${candidates.join(", ")}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return executablePath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getBrowser() {
|
|
|
|
|
|
if (!browserPromise) {
|
|
|
|
|
|
browserPromise = puppeteer.launch({
|
|
|
|
|
|
executablePath: getExecutablePath(),
|
|
|
|
|
|
headless: true,
|
2026-10-12 16:55:21 +08:00
|
|
|
|
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
|
2026-09-15 13:44:07 +08:00
|
|
|
|
});
|
|
|
|
|
|
const browser = await browserPromise;
|
|
|
|
|
|
browser.on("disconnected", () => {
|
|
|
|
|
|
browserPromise = undefined;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return browserPromise;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(value) {
|
|
|
|
|
|
return String(value ?? "")
|
|
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
|
|
.replace(/\"/g, """)
|
|
|
|
|
|
.replace(/'/g, "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function safeDataImage(value) {
|
|
|
|
|
|
if (typeof value !== "string" || value.length > 1024 * 1024) return "";
|
|
|
|
|
|
return /^data:image\/(png|jpeg|webp);base64,[a-z0-9+/=\s]+$/i.test(value)
|
|
|
|
|
|
? value
|
|
|
|
|
|
: "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getUploadRoot() {
|
2026-10-13 10:11:45 +08:00
|
|
|
|
// 源码运行时:<repo>/hn-hlw-service/hlw/prescription-pdf -> <repo>/upload-files。
|
|
|
|
|
|
// esbuild 后 __dirname 为 /opt/apps(bundle_pro.js 所在目录),上传目录则为 /opt/apps/upload-files。
|
|
|
|
|
|
return process.env.IS_BUILDED === "YES"
|
|
|
|
|
|
? path.resolve(__dirname, "../upload-files")
|
|
|
|
|
|
: path.resolve(__dirname, "../../../upload-files");
|
2026-09-15 13:44:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getImageMimeType(buffer, contentType = "") {
|
|
|
|
|
|
const normalizedContentType = String(contentType).split(";", 1)[0].trim().toLowerCase();
|
|
|
|
|
|
if (["image/png", "image/jpeg", "image/webp"].includes(normalizedContentType)) {
|
|
|
|
|
|
return normalizedContentType;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) return "image/png";
|
|
|
|
|
|
if (buffer.length >= 3 && buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg";
|
|
|
|
|
|
if (buffer.length >= 12 && buffer.subarray(0, 4).toString() === "RIFF" && buffer.subarray(8, 12).toString() === "WEBP") return "image/webp";
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getLocalSignaturePath(value) {
|
|
|
|
|
|
if (typeof value !== "string") return "";
|
|
|
|
|
|
let pathname = value;
|
|
|
|
|
|
if (/^https?:\/\//i.test(value)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
pathname = new URL(value).pathname;
|
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!pathname.startsWith("/upload-files/")) return "";
|
|
|
|
|
|
|
|
|
|
|
|
const uploadRoot = getUploadRoot();
|
|
|
|
|
|
let relativePath;
|
|
|
|
|
|
try {
|
|
|
|
|
|
relativePath = decodeURIComponent(pathname.slice("/upload-files/".length));
|
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
const filePath = path.resolve(uploadRoot, relativePath);
|
|
|
|
|
|
return filePath.startsWith(`${uploadRoot}${path.sep}`) ? filePath : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadLocalSignatureDataImage(value) {
|
|
|
|
|
|
const filePath = getLocalSignaturePath(value);
|
|
|
|
|
|
if (!filePath) return "";
|
|
|
|
|
|
const mimeType = signatureMimeTypes[path.extname(filePath).toLowerCase()];
|
|
|
|
|
|
if (!mimeType) return "";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const file = await fsp.readFile(filePath);
|
|
|
|
|
|
if (file.length === 0 || file.length > MAX_SIGNATURE_IMAGE_BYTES) return "";
|
|
|
|
|
|
return `data:${mimeType};base64,${file.toString("base64")}`;
|
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function downloadSignatureDataImage(value) {
|
|
|
|
|
|
if (typeof value !== "string" || !/^https?:\/\//i.test(value)) return "";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(value);
|
|
|
|
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
|
|
|
|
|
const response = await axios.get(url.toString(), {
|
|
|
|
|
|
responseType: "arraybuffer",
|
|
|
|
|
|
timeout: SIGNATURE_DOWNLOAD_TIMEOUT_MS,
|
|
|
|
|
|
maxContentLength: MAX_SIGNATURE_IMAGE_BYTES,
|
|
|
|
|
|
maxBodyLength: MAX_SIGNATURE_IMAGE_BYTES,
|
|
|
|
|
|
maxRedirects: 2,
|
|
|
|
|
|
validateStatus: (status) => status >= 200 && status < 300,
|
|
|
|
|
|
});
|
|
|
|
|
|
const image = Buffer.from(response.data);
|
|
|
|
|
|
if (image.length === 0 || image.length > MAX_SIGNATURE_IMAGE_BYTES) return "";
|
|
|
|
|
|
const mimeType = getImageMimeType(image, response.headers["content-type"]);
|
|
|
|
|
|
return mimeType ? `data:${mimeType};base64,${image.toString("base64")}` : "";
|
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSignatureDataImage(value) {
|
|
|
|
|
|
const embeddedImage = safeDataImage(value);
|
|
|
|
|
|
if (embeddedImage) return embeddedImage;
|
|
|
|
|
|
const localImage = await loadLocalSignatureDataImage(value);
|
|
|
|
|
|
if (localImage) return localImage;
|
|
|
|
|
|
return downloadSignatureDataImage(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getAge(record) {
|
|
|
|
|
|
if (Number.isFinite(Number(record.age)) && Number(record.age) >= 0) {
|
|
|
|
|
|
return `${Number(record.age)}岁`;
|
|
|
|
|
|
}
|
|
|
|
|
|
const idCard = String(record.idCard || "");
|
|
|
|
|
|
if (!/^\d{17}[\dXx]$/.test(idCard)) return "";
|
|
|
|
|
|
const birth = dayjs(`${idCard.slice(6, 10)}-${idCard.slice(10, 12)}-${idCard.slice(12, 14)}`);
|
|
|
|
|
|
const reference = dayjs(record.prescriptionEndTime || record.createTime);
|
|
|
|
|
|
return birth.isValid() && reference.isValid() ? `${reference.diff(birth, "year")}岁` : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getSex(record) {
|
|
|
|
|
|
if (record.sex === "男" || record.sex === "女") return record.sex;
|
|
|
|
|
|
const idCard = String(record.idCard || "");
|
|
|
|
|
|
return /^\d{17}[\dXx]$/.test(idCard) ? (Number(idCard[16]) % 2 ? "男" : "女") : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function medicineUsage(drug) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
drug.usageName,
|
|
|
|
|
|
drug.frequencyName,
|
|
|
|
|
|
drug.dosage ? `每次${drug.dosage}${drug.dosage_unit || ""}` : "",
|
|
|
|
|
|
].filter(Boolean).join(" ");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function signature(value, fallback) {
|
|
|
|
|
|
const image = safeDataImage(value);
|
|
|
|
|
|
return image
|
|
|
|
|
|
? `<img class="signature-image" src="${image}" alt="签名" />`
|
|
|
|
|
|
: `<span>${escapeHtml(fallback)}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 18:22:05 +08:00
|
|
|
|
async function addDrugStoreName(db, record) {
|
|
|
|
|
|
if (typeof record.drugStoreName === "string" || typeof record.orderId !== "string" || !record.orderId.trim()) {
|
|
|
|
|
|
return record;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const order = await db.collection("consult-order").findOne(
|
|
|
|
|
|
{ orderId: record.orderId },
|
|
|
|
|
|
{ projection: { drugStoreName: 1 } }
|
|
|
|
|
|
);
|
|
|
|
|
|
return { ...record, drugStoreName: order?.drugStoreName || "" };
|
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
|
// 门店名称仅用于处方提示语;查询异常不能阻止 PDF 的生成。
|
|
|
|
|
|
return record;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 13:44:07 +08:00
|
|
|
|
function buildPrescriptionHtml(record, paperSize = "A5", signatureImages = {}) {
|
|
|
|
|
|
const size = paperSize === "A4" ? "A4" : "A5";
|
|
|
|
|
|
const diagnosis = Array.isArray(record.diagnosisList) ? record.diagnosisList : [];
|
|
|
|
|
|
const drugs = Array.isArray(record.drugs) ? record.drugs : [];
|
|
|
|
|
|
const prescriptionTime = dayjs(record.prescriptionEndTime || record.createTime);
|
2026-09-24 18:22:05 +08:00
|
|
|
|
const diagnosisText = diagnosis.map((item) => item?.name).filter(Boolean).join("+");
|
2026-09-15 13:44:07 +08:00
|
|
|
|
const drugHtml = drugs.length
|
|
|
|
|
|
? drugs.map((drug, index) => `
|
|
|
|
|
|
<section class="medicine-item">
|
|
|
|
|
|
<div class="medicine-name">${index + 1}. ${escapeHtml(drug.drugName)} x ${escapeHtml(drug.quantity)} ${escapeHtml(drug.unit)}</div>
|
|
|
|
|
|
<div class="medicine-detail">规格:${escapeHtml(drug.specification)}</div>
|
|
|
|
|
|
<div class="medicine-detail">用法:${escapeHtml(medicineUsage(drug))}</div>
|
|
|
|
|
|
</section>`).join("")
|
|
|
|
|
|
: "<section class=\"medicine-item\">暂无药品</section>";
|
|
|
|
|
|
|
|
|
|
|
|
return `<!doctype html>
|
|
|
|
|
|
<html lang="zh-CN"><head><meta charset="utf-8" />
|
|
|
|
|
|
<style>
|
2026-09-24 18:22:05 +08:00
|
|
|
|
@page { size: ${size} portrait; margin: 8mm; }
|
2026-09-15 13:44:07 +08:00
|
|
|
|
* { box-sizing: border-box; }
|
2026-10-12 16:55:21 +08:00
|
|
|
|
html, body { margin: 0; padding: 0; color: #111; background: #fff; font-family: "Noto Sans CJK SC", "Noto Sans SC", SimSun, "Songti SC", serif; font-size: 14px; }
|
2026-09-15 13:44:07 +08:00
|
|
|
|
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
2026-10-12 16:55:21 +08:00
|
|
|
|
.prescription { width: calc(100% - 1px); min-height: ${size === "A5" ? "194mm" : "281mm"}; display: flex; flex-direction: column; line-height: 1.28; overflow: hidden; }
|
2026-09-24 18:22:05 +08:00
|
|
|
|
.header { position: relative; border-bottom: 1px solid #111; padding: 3px 0 4px; }
|
|
|
|
|
|
.title { font-weight: 700; font-size: 17px; text-align: center; line-height: 1.4; }
|
2026-10-12 16:55:21 +08:00
|
|
|
|
.rx-type { position: absolute; top: 0; right: 1px; width: 40px; border: 1px solid #333; text-align: center; color: #666; font-size: 12px; line-height: 1.3; }
|
2026-09-24 18:22:05 +08:00
|
|
|
|
.rx-number { margin-top: 5px; }
|
2026-09-15 13:44:07 +08:00
|
|
|
|
.patient-info { padding-top: 5px; }
|
2026-09-24 18:22:05 +08:00
|
|
|
|
.info-row { display: flex; padding: 2px; }
|
2026-09-15 13:44:07 +08:00
|
|
|
|
.info-cell { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
|
|
|
|
.info-half { width: 50%; overflow-wrap: anywhere; }
|
|
|
|
|
|
.diagnosis { margin-top: 5px; }
|
|
|
|
|
|
.diagnosis-head { display: flex; justify-content: space-between; gap: 8px; }
|
2026-09-24 18:22:05 +08:00
|
|
|
|
.diagnosis-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
|
|
|
|
.date { flex: none; white-space: nowrap; }
|
|
|
|
|
|
.medicine { margin-top: 5px; }
|
|
|
|
|
|
.rp-line { position: relative; display: flex; align-items: center; min-height: 30px; margin-bottom: 6px; }
|
|
|
|
|
|
.rp-line::after { content: ""; flex: 1; margin-left: 8px; border-top: 1px dashed #6b7280; }
|
|
|
|
|
|
.rp { font-size: 20px; font-weight: 700; }
|
|
|
|
|
|
.seal-image { position: absolute; z-index: 1; top: -4px; right: 0; width: 120px; height: 120px; object-fit: contain; }
|
|
|
|
|
|
.medicine-item { break-inside: avoid; margin: 0 0 6px; }
|
|
|
|
|
|
.medicine-name { font-weight: 700; }
|
|
|
|
|
|
.medicine-detail { color: #666; margin-top: 1px; }
|
|
|
|
|
|
.footer { margin-top: auto; border-top: 1px dashed #999; padding-top: 5px; break-inside: avoid; }
|
|
|
|
|
|
.signature-row, .action-row { display: flex; padding-top: 4px; }
|
|
|
|
|
|
.signature-cell, .action-cell { width: 33.333%; min-height: 24px; }
|
|
|
|
|
|
.signature-image { display: inline-block; width: 76px; height: 30px; object-fit: contain; vertical-align: middle; }
|
|
|
|
|
|
.notice { margin-top: 0; font-size: 12px; line-height: 1.25; white-space: nowrap; }
|
|
|
|
|
|
.notice-first { margin-top: 7px; }
|
2026-09-15 13:44:07 +08:00
|
|
|
|
@media print { .medicine-item, .footer { page-break-inside: avoid; } }
|
|
|
|
|
|
</style></head><body>
|
|
|
|
|
|
<main class="prescription">
|
|
|
|
|
|
<header class="header">
|
|
|
|
|
|
<div class="rx-type">普通<br>处方</div>
|
|
|
|
|
|
<div class="title">海宁康华医院互联网医院<br>处方笺</div>
|
|
|
|
|
|
<div class="rx-number">NO:${escapeHtml(record.medOrgOrderNo || record._id)}</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<section class="patient-info">
|
2026-09-24 18:22:05 +08:00
|
|
|
|
<div class="info-row"><div class="info-cell">姓名:${escapeHtml(record.name)}</div><div class="info-cell">性别:${escapeHtml(getSex(record))}</div><div class="info-cell">年龄:${escapeHtml(getAge(record))}</div><div class="info-cell">费别:${record.feeType === "YB" ? "医保" : "自费"}</div></div>
|
2026-09-15 13:44:07 +08:00
|
|
|
|
<div class="info-row"><div class="info-half">门诊病历号:${escapeHtml(record.blhno)}</div><div class="info-half">科别:${escapeHtml(record.deptName)}</div></div>
|
|
|
|
|
|
<div class="info-row"><div class="info-cell">地址:${escapeHtml(record.address)}</div></div>
|
|
|
|
|
|
<div class="info-row"><div class="info-cell">电话号:${escapeHtml(record.mobile)}</div></div>
|
|
|
|
|
|
</section>
|
2026-09-24 18:22:05 +08:00
|
|
|
|
<section class="diagnosis"><div class="diagnosis-head"><div class="diagnosis-text">诊断:${escapeHtml(diagnosisText)}</div><div class="date">开方日期:${prescriptionTime.isValid() ? prescriptionTime.format("YYYY-MM-DD HH:mm") : ""}</div></div></section>
|
|
|
|
|
|
<section class="medicine"><div class="rp-line"><div class="rp">Rp</div>${signatureImages.seal ? `<img class="seal-image" src="${signatureImages.seal}" alt="处方印章" />` : ""}</div>${drugHtml}</section>
|
|
|
|
|
|
<footer class="footer"><div class="signature-row"><div class="signature-cell">医生:${signature(signatureImages.doctor || record.doctorSignImg, record.doctorName)}</div><div class="signature-cell">审方药师:${signature(signatureImages.pharmacist || record.pharmacistSignImg, record.pharmacist)}</div><div class="signature-cell"></div></div><div class="action-row"><div class="action-cell">审核:</div><div class="action-cell">调配:</div><div class="action-cell">核对/发药:</div></div><div class="notice notice-first">1.根据《处方管理办法》规定,本处方开具三天有效;</div><div class="notice">2.本处方仅供患者${escapeHtml(record.drugStoreName)}使用,本处方金额以实际药店成交金额为准;</div><div class="notice">3.请仔细阅读药品使用说明,服药谨遵医嘱,如有不适请立即停药并前往医院就诊。</div></footer>
|
2026-09-15 13:44:07 +08:00
|
|
|
|
</main>
|
|
|
|
|
|
</body></html>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 16:55:21 +08:00
|
|
|
|
function buildRasterPdfHtml(image, paperSize) {
|
|
|
|
|
|
const size = RASTER_PAGE_SIZES[paperSize];
|
|
|
|
|
|
return `<!doctype html><html><head><meta charset="utf-8" /><style>
|
|
|
|
|
|
@page { size: ${paperSize} portrait; margin: 8mm; }
|
|
|
|
|
|
html, body { margin: 0; padding: 0; }
|
|
|
|
|
|
img { display: block; width: ${size.contentWidth}; height: ${size.contentHeight}; }
|
|
|
|
|
|
</style></head><body><img src="${image}" alt="处方笺" /></body></html>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function generateCompactPrescriptionPdf(html, paperSize) {
|
|
|
|
|
|
// Chrome 会为中文文本嵌入字体子集;即使没有图片,单页也约 280KB。
|
|
|
|
|
|
// 将实际打印区域以 96dpi 截图并转为 JPEG,可保留处方可读性且避免字体嵌入。
|
|
|
|
|
|
const browser = await getBrowser();
|
|
|
|
|
|
const size = RASTER_PAGE_SIZES[paperSize];
|
|
|
|
|
|
const rasterPage = await browser.newPage();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await rasterPage.setViewport(size.viewport);
|
|
|
|
|
|
await rasterPage.setContent(html, { waitUntil: "load" });
|
|
|
|
|
|
await rasterPage.evaluate(() => document.fonts && document.fonts.ready);
|
|
|
|
|
|
const screenshot = await rasterPage.screenshot({
|
|
|
|
|
|
type: "jpeg",
|
|
|
|
|
|
quality: 72,
|
|
|
|
|
|
clip: { x: 0, y: 0, width: size.viewport.width, height: size.viewport.height },
|
|
|
|
|
|
});
|
|
|
|
|
|
const compactPage = await browser.newPage();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await compactPage.setContent(
|
|
|
|
|
|
buildRasterPdfHtml(`data:image/jpeg;base64,${Buffer.from(screenshot).toString("base64")}`, paperSize),
|
|
|
|
|
|
{ waitUntil: "load" }
|
|
|
|
|
|
);
|
|
|
|
|
|
const pdf = Buffer.from(await compactPage.pdf({
|
|
|
|
|
|
format: paperSize,
|
|
|
|
|
|
preferCSSPageSize: true,
|
|
|
|
|
|
printBackground: true,
|
|
|
|
|
|
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
|
|
|
|
}));
|
|
|
|
|
|
if (pdf.length > MAX_PRESCRIPTION_PDF_BYTES) {
|
|
|
|
|
|
throw new Error(`处方 PDF 压缩后仍超过 ${MAX_PRESCRIPTION_PDF_BYTES} 字节`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return pdf;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await compactPage.close().catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
await rasterPage.close().catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 13:44:07 +08:00
|
|
|
|
async function generatePrescriptionPdf(record, options = {}) {
|
|
|
|
|
|
await acquireSlot();
|
|
|
|
|
|
try {
|
2026-09-24 18:22:05 +08:00
|
|
|
|
const [doctor, pharmacist, seal] = await Promise.all([
|
2026-09-15 13:44:07 +08:00
|
|
|
|
loadSignatureDataImage(record.doctorSignImg),
|
|
|
|
|
|
loadSignatureDataImage(record.pharmacistSignImg),
|
2026-09-24 18:22:05 +08:00
|
|
|
|
loadSignatureDataImage(PRESCRIPTION_SEAL_IMAGE_URL),
|
2026-09-15 13:44:07 +08:00
|
|
|
|
]);
|
2026-10-12 16:55:21 +08:00
|
|
|
|
const html = buildPrescriptionHtml(record, options.paperSize, { doctor, pharmacist, seal });
|
|
|
|
|
|
const paperSize = options.paperSize === "A4" ? "A4" : "A5";
|
|
|
|
|
|
return generateCompactPrescriptionPdf(html, paperSize);
|
2026-09-15 13:44:07 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
releaseSlot();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function createPrescriptionPdf(db, id, options) {
|
|
|
|
|
|
if (typeof id !== "string" || !ObjectId.isValid(id)) {
|
|
|
|
|
|
const error = new Error("处方 id 格式错误");
|
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
const record = await db.collection("diagnostic-record").findOne({ _id: new ObjectId(id) });
|
|
|
|
|
|
if (!record) {
|
|
|
|
|
|
const error = new Error("处方不存在");
|
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
2026-09-24 18:22:05 +08:00
|
|
|
|
return generatePrescriptionPdf(
|
|
|
|
|
|
await addDrugStoreName(db, decryptDiagnosticRecordFields(record)),
|
|
|
|
|
|
options
|
|
|
|
|
|
);
|
2026-09-15 13:44:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getPdfPath(id, paperSize) {
|
|
|
|
|
|
const size = paperSize === "A4" ? "A4" : "A5";
|
|
|
|
|
|
// MongoDB ObjectId 的前四字节是创建时间戳;用 UTC 日期分目录,避免因服务器时区不同写入不同路径。
|
|
|
|
|
|
const createdAt = new ObjectId(id).getTimestamp();
|
|
|
|
|
|
const year = String(createdAt.getUTCFullYear());
|
|
|
|
|
|
const month = String(createdAt.getUTCMonth() + 1).padStart(2, "0");
|
|
|
|
|
|
const day = String(createdAt.getUTCDate()).padStart(2, "0");
|
|
|
|
|
|
return path.join(
|
|
|
|
|
|
getPdfRoot(),
|
|
|
|
|
|
year,
|
|
|
|
|
|
month,
|
|
|
|
|
|
day,
|
|
|
|
|
|
`${id}-${size}-${PDF_TEMPLATE_VERSION}.pdf`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getOrCreatePrescriptionPdf(db, id, options = {}) {
|
|
|
|
|
|
if (typeof id !== "string" || !ObjectId.isValid(id)) {
|
|
|
|
|
|
const error = new Error("处方 id 格式错误");
|
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
const paperSize = options.paperSize === "A4" ? "A4" : "A5";
|
|
|
|
|
|
const pdfPath = getPdfPath(id, paperSize);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fsp.access(pdfPath, fs.constants.R_OK);
|
|
|
|
|
|
return { pdfPath, cached: true };
|
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
|
// 文件尚未生成,继续生成;不要将 access 错误泄漏给客户端。
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const key = `${id}:${paperSize}`;
|
|
|
|
|
|
if (!cachedPdfJobs.has(key)) {
|
|
|
|
|
|
cachedPdfJobs.set(key, (async () => {
|
|
|
|
|
|
await initializeStorage();
|
|
|
|
|
|
await fsp.mkdir(path.dirname(pdfPath), { recursive: true });
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fsp.access(pdfPath, fs.constants.R_OK);
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
|
// 仍不存在,当前请求负责生成。
|
|
|
|
|
|
}
|
|
|
|
|
|
const record = await db.collection("diagnostic-record").findOne({ _id: new ObjectId(id) });
|
|
|
|
|
|
if (!record) {
|
|
|
|
|
|
const error = new Error("处方不存在");
|
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
2026-09-24 18:22:05 +08:00
|
|
|
|
const pdf = await generatePrescriptionPdf(
|
|
|
|
|
|
await addDrugStoreName(db, decryptDiagnosticRecordFields(record)),
|
|
|
|
|
|
{ paperSize }
|
|
|
|
|
|
);
|
2026-09-15 13:44:07 +08:00
|
|
|
|
const temporaryPath = `${pdfPath}.${crypto.randomUUID()}.tmp`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fsp.writeFile(temporaryPath, pdf, { flag: "wx" });
|
|
|
|
|
|
await fsp.rename(temporaryPath, pdfPath);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
await fsp.unlink(temporaryPath).catch(() => {});
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
})().finally(() => cachedPdfJobs.delete(key)));
|
|
|
|
|
|
}
|
|
|
|
|
|
await cachedPdfJobs.get(key);
|
|
|
|
|
|
return { pdfPath, cached: false };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function closeBrowser() {
|
|
|
|
|
|
if (!browserPromise) return;
|
|
|
|
|
|
const browser = await browserPromise;
|
|
|
|
|
|
browserPromise = undefined;
|
|
|
|
|
|
await browser.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
|
buildPrescriptionHtml,
|
|
|
|
|
|
createPrescriptionPdf,
|
|
|
|
|
|
generatePrescriptionPdf,
|
|
|
|
|
|
getOrCreatePrescriptionPdf,
|
|
|
|
|
|
initializeStorage,
|
|
|
|
|
|
loadSignatureDataImage,
|
|
|
|
|
|
loadLocalSignatureDataImage,
|
|
|
|
|
|
downloadSignatureDataImage,
|
|
|
|
|
|
getPdfPath,
|
|
|
|
|
|
closeBrowser,
|
|
|
|
|
|
};
|