Compare commits

...

7 Commits

Author SHA1 Message Date
huxuejian
6185f89f95 fix:问题修复 2026-10-13 10:11:45 +08:00
huxuejian
552dc546be fix: 问题修复
1. 编辑新增药品的信息同步his;
2. pdf调整(主要是压缩生成的pdf文件)
2026-10-12 16:55:21 +08:00
huxuejian
f1c1812516 Update index.js 2026-10-12 10:28:39 +08:00
huxuejian
ec816dfdd6 fix:接口调整 2026-09-24 18:22:05 +08:00
huxuejian
0165c2c064 feat: 接口调整 2026-09-21 17:04:40 +08:00
huxuejian
c8f4c72205 feat: 需求调整
1. 医保处方支持打印
2. 同步his接口开发
2026-09-20 17:34:12 +08:00
huxuejian
8956fb8c8a feat: 新增处方笺pdf 2026-09-15 13:44:07 +08:00
18 changed files with 2184 additions and 1055 deletions

View File

@ -18,3 +18,8 @@ CONFIG_ALIPAY_MINI_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBA
## 海宁his api
CONFIG_HN_HIS_BASE_URL=http://122.225.16.74:8026
##
PUPPETEER_EXECUTABLE_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe"
PRESCRIPTION_PDF_MAX_CONCURRENCY=2
CONFIG_PRESCRIPTION_PDF_BASE_URL=https://patient.youcan365.com/hn

View File

@ -19,4 +19,10 @@ CONFIG_ALIPAY_MINI_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBA
CONFIG_HN_HIS_BASE_URL=http://122.225.16.74:8026
## im图片存储服务端的位置
CONFIG_IM_IMAGE_ROOT=/opt/apps/private-files/im-chat
CONFIG_IM_IMAGE_ROOT=/opt/apps/private-files/im-chat
## 处方 PDF(Linux Chrome)
PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome
PRESCRIPTION_PDF_MAX_CONCURRENCY=2
CONFIG_PRESCRIPTION_PDF_ROOT=/opt/apps/private-files/prescriptions
CONFIG_PRESCRIPTION_PDF_BASE_URL=https://hlwyy.hnkhhospital.com

View File

@ -45,3 +45,23 @@ GET /im-chat/image/:msgId
当前接口按需求暂未启用 JWT,但仅允许通过 `MsgId` 查询数据库中归档成功的受控路径。
生产使用前建议补充问诊订单权限验证。
## 处方 PDF
处方笺下载接口为 `GET /hlw/prescriptions/:id/pdf`;默认输出 A5,传 `paperSize=A4` 可输出 A4。获取 PDF Base64 内容可调用 `GET /hlw/prescriptions/:id/base64`,响应中的 `data` 字段为 Base64 字符串,也支持 `paperSize=A4`。
服务运行环境需要预装 Chromium/Chrome,并配置其可执行文件路径:
```env
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
PRESCRIPTION_PDF_MAX_CONCURRENCY=2
# 推荐挂载到持久化卷;未设置时默认 ../private-files/prescriptions
CONFIG_PRESCRIPTION_PDF_ROOT=/data/hn-private-files/prescriptions
```
`PRESCRIPTION_PDF_MAX_CONCURRENCY` 默认为 2,用于限制 Chromium 同时渲染的页面数。首次访问会生成并原子写入持久化目录,之后同一处方和纸张规格直接读取已有文件。该链接当前不需要认证。医生和药师签名支持 `data:image/*;base64,...`、项目上传目录的 `/upload-files/*.png|jpg|jpeg|webp` 路径,或 HTTP(S) 图片 URL。按本地文件、网络下载、姓名文本的顺序回退;网络请求最长 5 秒、最大 1 MB。
PDF 响应使用 `Cache-Control: no-cache`,浏览器每次访问都会向服务端校验文件;已保存的 PDF 仍直接从磁盘读取,不会重复渲染。
PDF 按处方 ObjectId 的 UTC 创建日期分目录保存,例如 `/opt/apps/private-files/prescriptions/2026/09/15/{处方ID}-A5-v5.pdf`,避免大量文件堆积在同一个目录。
Linux 服务端需安装中文字体,否则 Chrome 无法渲染处方中的中文。TencentOS Server 4 可执行 `dnf install -y google-noto-sans-cjk-sc-fonts fontconfig && fc-cache -f`,模板会优先使用 `Noto Sans CJK SC`。

View File

@ -708,8 +708,8 @@ async function resolveDiagnosisList(db, order) {
}
};
const medInfo = order.medInfo || {};
add(medInfo.dise_codg, medInfo.dise_name);
// const medInfo = order.medInfo || {};
// add(medInfo.dise_codg, medInfo.dise_name);
const diseaseNames = Array.isArray(order.diseases)
? order.diseases.filter((item) => typeof item === "string" && item.trim())

View File

@ -18,6 +18,7 @@ enum DiagnosticStatus {
EXPIRED = 'EXPIRED', // 订单过期
PASS_INVALID = 'PASS_INVALID', // 通过的处方作废
UNPASS_INVALID = 'UNPASS_INVALID', // 未通过的处方作废
CANCELLED = 'CANCELLED', // 门店主动撤销
INVALID = 'INVALID', // 处方作废
}
@ -122,4 +123,5 @@ interface DiagnosticRecord {
needReUpload?: boolean // 门店处方 是否需要重新上传(可能存在的场景 调用his接口上传成功了 但是his未上传医保, 需要药师手动去his的上传处理页面上传)
printCount?: number; // 门店自费处方打印次数
lastPrintTime?: number; // 门店自费处方最近一次打印时间
cancelTime?: number; // 门店主动撤销时间
}

View File

@ -6,6 +6,18 @@ const Sm4Util = require("../../utils/sm4-util");
*/
const ENCRYPTED_FIELDS = ["name", "mobile", "idCard"];
function getPrescriptionPdfUrl(data) {
const id = data && (data._id || data.id);
if (id === undefined || id === null || String(id).trim() === "") {
return "";
}
const baseUrl = String(process.env.CONFIG_PRESCRIPTION_PDF_BASE_URL || "")
.trim()
.replace(/\/+$/, "");
if (!baseUrl) return "";
return `${baseUrl}/hlw/prescriptions/${encodeURIComponent(String(id))}/pdf`;
}
/**
* 加密诊断记录敏感字段
* @param {Object} data - 诊断记录数据
@ -78,8 +90,9 @@ function decryptDiagnosticRecordList(list) {
return list.map((item) => decryptDiagnosticRecordFields(item));
}
exports.formatUploadRxData = function (data) {
return {
exports.formatUploadRxData = function (data, opt) {
const result = {
// pdfUrl: getPrescriptionPdfUrl(data),
pay_type: data.feeType === 'YB' ? '1' : '0',
patientid: data.patientId,
orderno: data.medOrgOrderNo,
@ -128,6 +141,10 @@ exports.formatUploadRxData = function (data) {
icd_name: i.name,
})),
};
if (opt && opt.pdf) {
result.pdf = opt.pdf
}
return result
};
@ -266,3 +283,4 @@ exports.decryptDiagnosticRecordList = decryptDiagnosticRecordList;
exports.buildHybridSearchQuery = buildHybridSearchQuery;
exports.buildMultiFieldHybridSearch = buildMultiFieldHybridSearch;
exports.ENCRYPTED_FIELDS = ENCRYPTED_FIELDS;
exports.getPrescriptionPdfUrl = getPrescriptionPdfUrl;

View File

@ -1,5 +1,6 @@
let db = "";
const { ObjectId } = require("mongodb");
const fs = require("fs/promises");
const { getDatabase } = require("../../mongodb");
const dayjs = require("dayjs");
const { decryptOrderList } = require("../consult-order/format");
@ -20,6 +21,7 @@ const beijingCa = require("../beijing-ca");
const BigNumber = require("bignumber.js");
// const timeApi = require("../hlw-time-duration");
const types = require("./types");
const { getOrCreatePrescriptionPdf } = require("../prescription-pdf");
/**
* 性能优化建议:
@ -75,6 +77,7 @@ const status = {
passInvalid: "PASS_INVALID", //通过的处方作废 (目前就订单退费会导致处方作废)
unpassInvalid: "UNPASS_INVALID", //未通过的处方作废 (目前就订单退费会导致处方作废)
invalid: "INVALID", //处方作废 (目前就订单退费会导致处方作废)
cancelled: "CANCELLED", //门店主动撤销
};
module.exports = async (item, mongodb, user) => {
@ -90,6 +93,8 @@ module.exports = async (item, mongodb, user) => {
return await getStoreDiagnosisRecord(item, db, user);
case "markStoreRxPrinted":
return await markStoreRxPrinted(item, db, user);
case "cancelStoreDiagnosisRecord":
return await cancelStoreDiagnosisRecord(item, db, user);
case "getAuditDiagnosisList":
return await getAuditDiagnosisList(item, db);
case "getAuditedDiagnosisList":
@ -100,8 +105,6 @@ module.exports = async (item, mongodb, user) => {
return await getPatientPassDiagnosisRecord(item, db);
case "auditDiagnosis":
return await auditDiagnosis(item, db);
case "uploadDiagnosis":
return await uploadDiagnosis(item, db);
case "orderHasDiagnosis":
return await orderHasDiagnosis(item, db);
case "getLatestSubmitTime":
@ -140,11 +143,28 @@ module.exports = async (item, mongodb, user) => {
return await getPatientWineBuyCount(item, db);
case "getYbReviewResult":
return await getYbReviewResult(item, db);
case "getpdfbase64":
return {
success: true,
message: "查询成功",
data: await getpdfbase64(item, db),
};
}
};
let scheduleTaskIsRunning = false;
async function getpdfbase64(ctx = {}, db) {
const prescriptionId = String(ctx.id || ctx._id || "").trim();
if (!ObjectId.isValid(prescriptionId)) {
throw new Error("处方 id 格式错误");
}
const paperSize = ctx.paperSize === "A4" ? "A4" : "A5";
const { pdfPath } = await getOrCreatePrescriptionPdf(db, prescriptionId, { paperSize });
return (await fs.readFile(pdfPath)).toString("base64");
}
async function scheduleAutoPassAfterPrescription({
rxId,
orderId,
@ -554,7 +574,7 @@ async function getStoreDiagnosisRecord(item, db, user) {
}
const query = {
drugStoreNo: { $in: [...new Set(storeIds)] },
status: status.pass,
status: { $in: [status.pass, status.cancelled] },
};
const page = item.page > 0 && Number.isInteger(item.page) ? item.page : 1;
const pageSize =
@ -619,6 +639,56 @@ async function getStoreDiagnosisRecord(item, db, user) {
}
}
async function cancelStoreDiagnosisRecord(item, db, user) {
const storeIds = await resolveStoreIds(item, db, user);
if (
typeof item.id !== "string" ||
!ObjectId.isValid(item.id.trim()) ||
storeIds.length === 0
) {
return { success: false, message: "参数错误" };
}
try {
const record = await db.collection("diagnostic-record").findOne({
_id: new ObjectId(item.id.trim()),
drugStoreNo: { $in: [...new Set(storeIds)] },
status: status.pass,
});
if (!record) {
return { success: false, message: "处方不存在或当前状态不可撤销" };
}
// 医保处方必须先由 HIS 撤销成功,才允许修改本地状态。
if (record.feeType === "YB") {
const hisResult = await hnHis({
type: "cancelInsuranceRx",
patientId: record.patientId,
orderno: record.medOrgOrderNo,
});
if (!hisResult || !hisResult.success) {
return { success: false, message: (hisResult && hisResult.message) || "医保处方撤销失败" };
}
}
const cancelTime = Date.now();
const result = await db.collection("diagnostic-record").updateOne(
{
_id: record._id,
drugStoreNo: { $in: [...new Set(storeIds)] },
status: status.pass,
},
{ $set: { status: status.cancelled, cancelTime } }
);
if (result.modifiedCount !== 1) {
return { success: false, message: "处方状态已变更,请刷新后重试" };
}
return { success: true, message: "处方撤销成功", data: { cancelTime } };
} catch (e) {
return { success: false, message: e.message || "处方撤销失败" };
}
}
async function markStoreRxPrinted(item, db, user) {
const storeIds = await resolveStoreIds(item, db, user);
if (
@ -634,7 +704,7 @@ async function markStoreRxPrinted(item, db, user) {
_id: new ObjectId(item.id.trim()),
drugStoreNo: { $in: [...new Set(storeIds)] },
status: status.pass,
feeType: "ZF",
// feeType: "ZF",
},
{
$inc: { printCount: 1 },
@ -1051,6 +1121,15 @@ async function auditDiagnosis(item, db) {
if (passList.length) {
let successRxList = [];
const auditTime = Date.now();
// uploadRx2His 内部会按处方 id 从 MongoDB 重新读取记录并生成 PDF,
// 因此不能只在内存 rx 对象中补签名,必须先写入数据库。
await db.collection("diagnostic-record").updateMany(
{
_id: { $in: passList.map(i => i.rx._id) },
status: status.init,
},
{ $set: { pharmacistSignImg: pharmacistCAUserId } }
);
const uploadResult = await Promise.all(passList.map(i => uploadRx2His({ ...i.rx, auditTime }, i.order)));
uploadResult.forEach(i => {
if (i.success) {
@ -1143,7 +1222,9 @@ async function auditDiagnosis(item, db) {
async function uploadRx2His(rx, order) {
try {
const uploadData = formatUploadRxData(rx, order);
// 获取处方笺pdf
const base64 = await getpdfbase64({ id: rx._id.toString() }, db);
const uploadData = await formatUploadRxData(rx, { pdf: base64 });
const { success, message } = await hnHis({
type: 'hlwuploadprescription',
data: uploadData
@ -1281,52 +1362,6 @@ function getOriData(data) {
return `医生${doctorName}向患者${patientName}开具的诊断是${diagnosis},开具的药物是${drugs}。`;
}
async function uploadDiagnosis(ids) {
if (!Array.isArray(ids) || ids.length == 0)
return {
success: false,
message: "参数错误",
};
try {
const res = await db
.collection("diagnostic-record")
.find({ _id: { $in: ids }, status: status.pass })
.limit(ids.length)
.toArray();
const list = res.map((item) => ({
payload: formatUploadRxData(item),
_id: item._id,
}));
const res1 = await Promise.all(
list.map((item) =>
zytHis({
type: "hlwuploadprescription",
payload: item.payload,
_id: item._id,
})
)
);
const successIds = res1.filter((i) => i.success).map((i) => i._id);
await db
.collection("diagnostic-record")
.updateMany(
{ _id: { $in: successIds } },
{ $set: { uploadStatus: "uploaded" } }
);
const successCount = successIds.length;
return {
success: true,
message: `成功上传${successCount}条数据`,
};
} catch (err) {
console.log(err, err.message);
return {
success: false,
message: err.message || "上传失败",
};
}
}
/**
*
* orderStatus completed(已完成) processing(处理中) pending(待处理) cancelled(已取消) finished(已结束)
@ -1435,30 +1470,16 @@ async function orderHasDiagnosis(item) {
async function reUploadDiagnosticRecord({ _id }) {
try {
return
const data = await db
.collection("diagnostic-record")
.findOne({ _id: new ObjectId(_id) });
console.log(data)
if (!data) return { success: false, message: "处方不存在" };
if (data.prescriptionType !== "storeMedicinePurchase") {
return { success: false, message: "暂只支持门店处方重新上传" };
}
if (data && data.uploadStatus === "uploaded")
return { success: false, message: "处方已上传" };
const { uploaded } = await zytHis({
type: "hlwPrescriptionOrderStatus",
orderno: data.orderId,
patientId: data.patientId,
});
if (uploaded) {
await db
.collection("diagnostic-record")
.updateOne(
{ _id: new ObjectId(_id) },
{ $set: { uploadStatus: "uploaded", hisUploadStatus: "uploaded" } }
);
return { success: true, message: "处方上传成功" };
}
const payload = formatUploadRxData(data);
const base64 = await getpdfbase64({ id: data._id.toString() }, db);
const payload = formatUploadRxData(data, { pdf: base64 });
console.log(payload)
return { success: false, message: '打住' }
const { success, message } = await zytHis({
type: "hlwuploadprescription",
payload,

View File

@ -3,6 +3,7 @@ const { ObjectId } = require("mongodb");
const { getDrugList } = require('./verify');
const { validateApplicableDiagnosisCodes } = require('./applicable-diagnosis');
const hnHis = require("../hn-his");
let db = "";
function getConfigOptionNames(list) {
@ -57,6 +58,8 @@ module.exports = async (item, mongodb) => {
return await searchDrugs(item);
case "getDrugMatchedDisease":
return await getDrugMatchedDisease(item);
case "updateHisDrugInfo":
return await updateHisDrugInfo(item)
}
};
// 药品库信息
@ -258,6 +261,8 @@ async function addHlwDrugInfo(ctx) {
}
const diagnosisCodes = await validateApplicableDiagnosisCodes(params, db);
const { onSale, applicableDiagnosisCodes, ...data } = params;
// ERP ID is allocated by the service so clients cannot supply or predict it.
data.erpId = await generateDrugErpId(db);
let newDrug = {};
if (Object.keys(data).length) {
const [item] = getDrugList([data], true, peopleInfo, 'hlw', optionLists);
@ -281,6 +286,9 @@ async function addHlwDrugInfo(ctx) {
}
newDrug.createTime = Date.now();
const res = await db.collection("drug-info").insertOne(newDrug);
if (res && res.insertedId) {
updateHisDrugInfo({ id: res.insertedId.toString() })
}
return { success: true, message: "新增药品成功", id: res.insertedId }
} catch (e) {
return { success: false, message: e.message || '新增药品失败' }
@ -296,6 +304,9 @@ async function editHlwDrugInfo(ctx) {
const optionLists = await getHlwDrugOptionLists(ctx.corpId);
const diagnosisCodes = await validateApplicableDiagnosisCodes(params, db);
const { onSale, applicableDiagnosisCodes, ...data } = params;
// ERP ID is immutable after creation. Ignore it even when sent by an older
// client or a manually constructed request.
delete data.erpId;
const currentDrug = await db.collection("drug-info").findOne(
{ _id: new ObjectId(id) },
{ projection: { erpId: 1, product_id: 1, product_id_str: 1 } }
@ -303,13 +314,6 @@ async function editHlwDrugInfo(ctx) {
if (!currentDrug) {
return { success: false, message: "未找到对应药品" }
}
const legacyErpId = currentDrug.product_id_str || currentDrug.product_id;
const erpId = Object.prototype.hasOwnProperty.call(data, "erpId")
? data.erpId
: (currentDrug.erpId || (legacyErpId || legacyErpId === 0 ? String(legacyErpId) : ""));
if (typeof erpId !== "string" || !erpId.trim()) {
return { success: false, message: "ERP ID不能为空" }
}
let updateData = {};
if (Object.keys(data).length) {
const peopleInfo = {
@ -336,6 +340,7 @@ async function editHlwDrugInfo(ctx) {
if (res.matchedCount === 0) {
return { success: false, message: "未找到对应药品" }
}
updateHisDrugInfo({ id })
return { success: true, message: "更新成功" }
}
return { success: true, message: "更新成功" }
@ -346,6 +351,23 @@ async function editHlwDrugInfo(ctx) {
}
async function generateDrugErpId(db) {
const key = 'drug-erp-id';
const type = 'drug-erp-id';
const result = await db.collection('sequence-counter').findOneAndUpdate(
{ key, type },
{
$inc: { sequence: 1 },
$setOnInsert: { key, type }
},
{
upsert: true,
returnDocument: 'after'
}
);
return String(result.sequence).padStart(6, '0');
}
async function setHlwDrugOnSale(ctx) {
const { ids, onSale, updaterId, updater } = ctx;
const _ids = Array.isArray(ids) ? ids.filter(i => typeof i === 'string' && i.trim()).map(i => new ObjectId(i.trim())) : [ids];
@ -500,3 +522,42 @@ async function getDrugMatchedDisease(ctx) {
return { success: false, message: e.message }
}
}
async function updateHisDrugInfo(ctx) {
try {
const id = typeof ctx.id === 'string' ? ctx.id.trim() : '';
if (id === '' || !ObjectId.isValid(id)) {
return { success: false, message: '参数错误' }
}
const drug = await db.collection('drug-info').findOne({ _id: new ObjectId(id) });
if (!drug) {
return { success: false, message: '药品不存在' }
}
const data = {
code: '01',
erp_id: drug.erpId,
drugname: drug.name,
gjbm: drug.insurance_code,
specification: drug.specification,
pack_unit_name: drug.unit,
manu_name: drug.manufacturer,
py_code: drug.pinyin_code,
default_dosage: drug.dosage,
default_unit: drug.dosage_unit,
default_drug_win: drug.days,
default_total: drug.recommended_quantity,
default_usage_name: drug.administration_method,
default_supply_name: drug.frequency || drug.freq,
drug_dosform: drug.dosage_form,
zl: drug.package_amount,
kfxs: drug.package_amount,
kfdw: drug.unit,
jlxs: drug.dosage,
jldw: drug.dosage_unit
}
return await hnHis({ type: 'updateHisDrug', data: [data] })
// return { success: true, data }
} catch (e) {
return { success: false, message: e?.message }
}
}

View File

@ -558,6 +558,9 @@ async function getAllDoctorNos(ctx) {
try {
const jobs = Array.isArray(ctx.jobs) ? ctx.jobs : ['doctor'];
const query = ctx.showAll === true ? {} : { job: { $in: jobs } };
if (typeof ctx.corpId === 'string' && ctx.corpId.trim()) {
query.corpId = ctx.corpId.trim();
}
const res = await db
.collection("hlw-doctor")
.find(query, { projection: { doctorNo: 1, doctorName: 1, job: 1 } })

163
hlw/hlw-schedule/index.js Normal file
View File

@ -0,0 +1,163 @@
const dayjs = require('dayjs');
module.exports = async (item, db) => {
switch (item.type) {
case 'getHlwSchedule':
return getHlwSchedule(item, db);
case 'saveHlwSchedule':
return saveHlwSchedule(item, db);
case 'setTodayScheduledPeopleOnline':
return setTodayScheduledPeopleOnline(db);
}
};
function getDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return '';
const parsed = dayjs(value);
return parsed.isValid() && parsed.format('YYYY-MM-DD') === value ? value : '';
}
function getMonth(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}$/.test(value)) return '';
const parsed = dayjs(`${value}-01`);
return parsed.isValid() && parsed.format('YYYY-MM') === value ? value : '';
}
async function getHlwSchedule({ corpId, month }, db) {
const normalizedCorpId = typeof corpId === 'string' ? corpId.trim() : '';
const normalizedMonth = getMonth(month);
if (!normalizedCorpId || !normalizedMonth) return { success: false, message: '机构或月份参数错误' };
try {
const startDate = `${normalizedMonth}-01`;
const endDate = dayjs(startDate).add(1, 'month').format('YYYY-MM-DD');
const data = await db.collection('hlw-schedule')
.find({ corpId: normalizedCorpId, date: { $gte: startDate, $lt: endDate } }, { projection: { _id: 0 } })
.sort({ date: 1 })
.toArray();
return { success: true, message: '查询成功', data };
} catch (error) {
return { success: false, message: error.message || '查询排班失败' };
}
}
async function saveHlwSchedule({ corpId, date, doctorNos = [], pharmacistNos = [], operator, operatorId }, db) {
const normalizedCorpId = typeof corpId === 'string' ? corpId.trim() : '';
const normalizedDate = getDate(date);
if (!normalizedCorpId || !normalizedDate) return { success: false, message: '机构或日期参数错误' };
if (dayjs(normalizedDate).isBefore(dayjs(), 'day')) return { success: false, message: '过去日期的排班不可编辑' };
const normalizeNos = values => [...new Set((Array.isArray(values) ? values : [])
.filter(value => typeof value === 'string' && value.trim())
.map(value => value.trim()))];
const normalizedDoctorNos = normalizeNos(doctorNos);
const normalizedPharmacistNos = normalizeNos(pharmacistNos);
try {
const personnel = await db.collection('hlw-doctor').find(
{
corpId: normalizedCorpId,
doctorNo: { $in: [...normalizedDoctorNos, ...normalizedPharmacistNos] },
job: { $in: ['doctor', 'pharmacist'] }
},
{ projection: { doctorNo: 1, doctorName: 1, job: 1 } }
).toArray();
const peopleByNo = new Map(personnel.map(person => [person.doctorNo, person]));
const toPeople = (nos, job) => {
const people = nos.map(no => peopleByNo.get(no));
return people.length === nos.length && people.every(person => person && person.job === job)
? people.map(({ doctorNo, doctorName }) => ({ doctorNo, doctorName }))
: null;
};
const doctors = toPeople(normalizedDoctorNos, 'doctor');
const pharmacists = toPeople(normalizedPharmacistNos, 'pharmacist');
if (!doctors || !pharmacists) return { success: false, message: '排班人员不存在或岗位不匹配' };
await db.collection('hlw-schedule').updateOne(
{ corpId: normalizedCorpId, date: normalizedDate },
{
$set: {
doctors,
pharmacists,
updater: typeof operator === 'string' ? operator : '',
updaterId: typeof operatorId === 'string' ? operatorId : '',
updateTime: Date.now()
},
$setOnInsert: { corpId: normalizedCorpId, date: normalizedDate, createTime: Date.now() }
},
{ upsert: true }
);
if (normalizedDate === dayjs().format('YYYY-MM-DD')) {
const onlineResult = await setTodayScheduledPeopleOnline(db);
if (!onlineResult.success) {
return { success: false, message: `排班已保存,但设置当天排班人员在线失败:${onlineResult.message}` };
}
}
return { success: true, message: '排班保存成功' };
} catch (error) {
return { success: false, message: error.message || '保存排班失败' };
}
}
async function setTodayScheduledPeopleOnline(db) {
const date = dayjs().format('YYYY-MM-DD');
try {
const schedules = await db.collection('hlw-schedule')
.find({ date }, { projection: { corpId: 1, doctors: 1, pharmacists: 1 } })
.toArray();
const peopleByCorp = new Map();
const addPeople = (corpId, people, job) => {
if (typeof corpId !== 'string' || !corpId.trim()) return;
if (!peopleByCorp.has(corpId)) {
peopleByCorp.set(corpId, { doctors: new Set(), pharmacists: new Set() });
}
const target = job === 'doctor'
? peopleByCorp.get(corpId).doctors
: peopleByCorp.get(corpId).pharmacists;
(Array.isArray(people) ? people : []).forEach((person) => {
if (typeof person?.doctorNo === 'string' && person.doctorNo.trim()) {
target.add(person.doctorNo.trim());
}
});
};
schedules.forEach(({ corpId, doctors, pharmacists }) => {
addPeople(corpId, doctors, 'doctor');
addPeople(corpId, pharmacists, 'pharmacist');
});
const updateTime = Date.now();
const operations = [];
for (const [corpId, { doctors, pharmacists }] of peopleByCorp) {
if (doctors.size > 0) {
operations.push({
updateMany: {
filter: { corpId, doctorNo: { $in: [...doctors] }, job: 'doctor' },
update: { $set: { onlineStatus: 'online', offlineTime: null, updateTime } }
}
});
}
if (pharmacists.size > 0) {
operations.push({
updateMany: {
filter: { corpId, doctorNo: { $in: [...pharmacists] }, job: 'pharmacist' },
update: { $set: { onlineStatus: 'online', offlineTime: null, updateTime } }
}
});
}
}
if (operations.length === 0) {
return { success: true, message: '当天无排班人员', data: { date, matchedCount: 0, modifiedCount: 0 } };
}
const result = await db.collection('hlw-doctor').bulkWrite(operations);
return {
success: true,
message: '当天排班人员已上线',
data: { date, matchedCount: result.matchedCount, modifiedCount: result.modifiedCount }
};
} catch (error) {
return { success: false, message: error.message || '设置当天排班人员在线失败' };
}
}

View File

@ -13,60 +13,22 @@ const AppWin = {
module.exports = async (item) => {
switch (item.type) {
case "updateHisDrug":
return await updateHisDrug(item);
case "getHisCustomer":
return await getHisCustomer(item);
case "addHisCustomer":
return await addHisCustomer(item);
case "registration":
return await registration(item);
case "getPayStatus":
return await getPayStatus(item);
case "hlwRefund":
return await hlwRefund(item);
case "hlwuploadprescription":
return await hlwuploadprescription(item);
case "hlwPrescriptionOrderStatus":
return await hlwPrescriptionOrderStatus(item);
case "getHisPrescriptionUploadStatus":
return await getHisPrescriptionUploadStatus(item);
case "getHisStoreRegList":
return await getHisStoreRegList(item);
case "insurancePreSettle":
return await insurancePreSettle(item);
case "insuranceSettle":
return await insuranceSettle(item);
case "insuranceRefund":
return await insuranceRefund(item);
case "getRecent90daysDrugRecord":
return await getRecent90daysDrugRecord(item);
case "uploadMedicinePurchaseRx":
return await uploadMedicinePurchaseRx(item);
case "uploadWinePurchaseRx":
return await uploadWinePurchaseRx(item);
case "getHisDrugInfo":
return await getHisDrugInfo(item);
case "getHisMedicinePurchase":
return await getHisMedicinePurchase(item);
case "cancelMedicinePurchase":
return await cancelMedicinePurchase(item);
case "refundMedicinePurchase":
return await refundMedicinePurchase(item);
case "getHisInvoice":
return await getHisInvoice(item);
case "updateMedicinePurchaseDelivery":
return await updateMedicinePurchaseDelivery(item);
case 'syncHisChargeStatus':
return await syncHisChargeStatus(item);
case "hisTest":
return await hisTest(item);
case "getHisEmployeeInfo":
return await getHisEmployeeInfo(item);
case "syncHisRefundStatus":
return await syncHisRefundStatus(item);
case "getWineDrugStockInfo":
return await getWineDrugStockInfo(item);
case "ybReviewPrescription":
return await ybReviewPrescription(item);
case "cancelInsuranceRx":
return await cancelInsuranceRx(item);
}
};
@ -197,490 +159,6 @@ async function hlwuploadprescription(item) {
}
}
// 预约挂号
async function registration(item) {
const { patientId, doctorCode, appwin = 'line', unitCode, registerId, chargecode, reg_fee, shopid, shopname, op_id = '', channel } = item;
if (AppWin[appwin] == undefined) {
return {
success: false,
message: "appwin参数错误"
}
}
if (AppWin[appwin] == '线下挂号' && (typeof op_id !== 'string' || op_id.trim() === '')) {
return {
success: false,
message: "op_id参数错误"
}
}
try {
const url = `${baseUrl}/his/hlw/registration`;
// 根据dayjs 判断是上午 还是下午
const ampm = dayjs().hour() < 12 ? "a" : "p";
const requestday = dayjs().format("YYYY-MM-DD HH:mm:ss");
// registerId 为 H开头 15位随机数
const payload = {
patientId,
doctorCode,
unitCode,
requestday,
ampm,
registerId,
chargecode,
reg_fee,
shopid,
shopname,
chargetype: "普通",
appwin
};
if (AppWin[appwin] == '线下挂号') {
payload.op_id = op_id;
}
if (channel === 1) {
payload.channel = 1
}
const { data, status_code, message } = await request.main(
url,
payload,
"POST"
);
if (data && data.register && data.register.medorg_order_no) {
return {
success: true,
data,
message: message || "挂号成功",
};
} else {
return {
success: false,
message: message || "挂号失败",
};
}
} catch (err) {
return {
success: false,
message: err.message || "挂号失败"
};
}
}
//订单支付状态查询
async function getPayStatus(item) {
const { patientId, registerId, medorgOrderNo } = item;
try {
const url = `${baseUrl}/his/hlw/settlement`;
const { status, status_code, message, ...rest } = await request.main(
url,
{
patientid: patientId,
registerid: registerId,
medorg_order_no: medorgOrderNo,
},
"POST"
);
const settlement =
rest.data && rest.data.settlement ? rest.data.settlement : null;
//settlement.status 0 已收费 1 已退费 5 未收费 x 已作废
if (status_code == 200) {
return {
success: true,
status: settlement ? settlement.status : '',
settlement,
message: "订单查询成功",
};
} else {
return {
success: false,
message: message || "获取失败",
};
}
} catch (err) {
return {
success: false,
message: "获取失败",
};
}
}
//医保患者退费
async function hlwRefund(item) {
const { patientId, registerId, medorgOrderNo } = item;
try {
const url = `${baseUrl}/his/hlw/refund`;
const { data, status_code, message, ...rest } = await request.main(
url,
{
patientid: patientId,
registerid: registerId,
medorg_order_no: medorgOrderNo,
},
"POST"
);
if (status_code == 200 && !message) {
return {
success: true,
message: "退费成功",
};
} else {
return {
success: false,
message: message || "退费失败",
};
}
} catch (err) {
return {
success: false,
message: "退费失败",
};
}
}
// 外配处方状态查询
async function hlwPrescriptionOrderStatus(item) {
const { patientId, orderno } = item;
try {
const url = `${baseUrl}/his/hlw/orderstatus`;
const { status_code, message, data } = await request.main(
url,
{
patientid: patientId,
orderno,
},
"POST"
);
if (status_code == 200 && data && data.orderstatus) {
return {
success: true,
uploaded: data.orderstatus.status == '1',
status: data.orderstatus.status,
message: "查询成功",
};
} else {
return {
success: false,
message: message || "查询失败",
};
}
} catch (err) {
return {
success: false,
message: "查询失败",
};
}
}
async function getHisPrescriptionUploadStatus(item) {
const { patientId, orderno, _id } = item;
try {
const url = `${baseUrl}/his/hlw/orderstatus`;
const { status_code, message, data } = await request.main(
url,
{
patientid: patientId,
orderno,
},
"POST"
);
if (status_code == 200 && data && data.orderstatus && data.orderstatus.status == 1) {
return { orderno, hisUploadStatus: 'uploaded', _id }
}
if (status_code == 200 && data && data.orderstatus && data.orderstatus.status == -1) {
return { orderno, hisUploadStatus: 'error', _id }
}
if (status_code == 200 && data && data.orderstatus && data.orderstatus.status == 0) {
return { orderno, hisUploadStatus: 'notUploaded', _id }
}
return {
success: false,
message: message || "查询失败",
};
} catch (err) {
return {
success: false,
message: "查询失败",
};
}
}
async function getHisStoreRegList(item) {
const { storeId: shopid } = item;
if (typeof shopid !== 'string' || shopid.trim() === '') {
return {
success: false,
message: "店铺id无效",
};
}
try {
const url = `${baseUrl}/his/hlw/registerquery`;
const { status_code, message, data } = await request.main(
url,
{
shopid,
requestday: dayjs().format('YYYY-MM-DD HH:mm:ss'),
status: "0",
starttime: dayjs().startOf('day').format('YYYY-MM-DD HH:mm:ss'),
endtime: dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss'),
hospid: "108",
},
"POST"
);
if (status_code == 200 && Array.isArray(data)) {
return {
success: true,
list: data,
message: "查询成功",
};
} else {
return {
success: false,
message: message || "查询失败",
};
}
} catch (err) {
return {
success: false,
message: "查询失败",
};
}
}
async function insurancePreSettle(ctx) {
const {
medorg_order_no: orderno,
patientId: patientid,
mdtrtCertNo: mdtrt_cert_no,
mdtrtCertType: mdtrt_cert_type,
cardSn: card_sn,
operator_code,
operator_name,
shopid,
hospid = "108"
} = ctx;
try {
const url = `${baseUrl}/his/hlw/presettle`;
const payload = { orderno, patientid, mdtrt_cert_no, mdtrt_cert_type, card_sn, operator_code, operator_name, hospid, shopid };
const { data, status_code, message } = await request.main(url, payload, "POST");
if (status_code == 200 && !message) {
return { success: true, data, message: '预结算成功' }
}
return { success: false, message: message || '预结算失败' }
} catch (e) {
return { success: false, message: e.message || '预结算失败' }
}
}
async function insuranceSettle(ctx) {
const {
patientid,
orderno,
mdtrt_id,
mdtrt_cert_no,
outparm_1101,
outparm_2206A,
inparm_2207A,
outparm_2204,
totalamount,
selfpay,
operator_code,
operator_name
} = ctx;
const payload = {
patientid,
orderno,
settle_method: '7',
paymethod: '1', // 1: 现金 w: 支付宝 z: 微信
paysn: '',
payrequestsn: '',
mdtrt_id,
mdtrt_cert_no,
outparm_1101,
outparm_2206A,
inparm_2207A,
outparm_2204,
totalamount,
selfpay,
operator_code,
operator_name
}
try {
const url = `${baseUrl}/his/hlw/ybsettle`;
// const url = `${baseUrl}/his/hlw/presettle`;
const { data, status_code, message } = await request.main(url, payload, "POST");
if (status_code == 200 && !message) {
return { success: true, data, message: '结算成功' }
}
return { success: false, message: message || '结算失败' }
} catch (e) {
return { success: false, message: e.message || '结算失败' }
}
}
async function insuranceRefund(ctx) {
const { type, ...payload } = ctx;
try {
const url = `${baseUrl}/his/hlw/ybrefund`;
// const url = `${baseUrl}/his/hlw/presettle`;
const { data, status_code, message } = await request.main(url, payload, "POST");
if (status_code == 200 && !message) {
return { success: true, data, message: '退费成功' }
}
return { success: false, message: message || '退费失败' }
} catch (e) {
return { success: false, message: e.message || '退费失败' }
}
}
async function getRecent90daysDrugRecord(ctx) {
const patientid = typeof ctx.patientId === 'string' ? ctx.patientId.trim() : '';
if (patientid === '') {
return { success: false, message: '患者ID不能为空' }
}
try {
const url = `${baseUrl}/his/hlw/patientzj3100`;
const payload = { patientid, hospid: "108", doctorCode: '10021' };
if (typeof ctx.doctorcode === 'string' && ctx.doctorcode.trim() !== '') {
payload.doctorcode = ctx.doctorcode.trim();
}
const { data, status_code, message } = await request.main(url, payload, "POST");
if (status_code == 200 && !message && data && data.outparm_zj3100) {
// 解析医保用药数据列表
const decodedBuffer = Buffer.from(data.outparm_zj3100, 'base64');
const gbkDecodedStr = decode(decodedBuffer, 'gbk');
const res = JSON.parse(gbkDecodedStr);
const drugList = res && res.data && Array.isArray(res.data.lists) ? res.data.lists.map(i => ({
hiListName: i.hiListName,
insuranceCode: i.hilistCode,
mdtrtId: i.mdtrtId,
quantity: i.cnt,
spec: i.spec && i.spec !== 'null' ? i.spec : '',
unit: i.unit && i.unit !== 'null' ? i.unit : ''
})) : [];
// 解析医保记录
const decodedBuffer1 = Buffer.from(data.outparm_zj3101, 'base64');
const gbkDecodedStr1 = decode(decodedBuffer1, 'gbk');
const res1 = JSON.parse(gbkDecodedStr1);
const recordList = res1 && res1.data && Array.isArray(res1.data.lists) ? res1.data.lists.map(i => ({
mdtrtId: i.mdtrtId,
date: i.admDate,
medinsName: i.medinsName,
})) : [];
return { success: true, drugList, message: '查询成功', recordList }
}
return { success: false, message: message || '查询失败' }
} catch (e) {
return { success: false, message: e.message || '查询失败' }
}
}
async function getHisDrugInfo(item) {
try {
const ignoreAreaCode = typeof item.ignoreAreaCode === 'boolean' ? item.ignoreAreaCode : false;
if (!ignoreAreaCode && (typeof item.areaCode !== 'string' || item.areaCode.trim() === '')) {
return { success: false, message: '区域代码不能为空' }
}
const showAll = typeof item.showAll === 'boolean' ? item.showAll : false;
const gjbm = Array.isArray(item.insuranceCodes) ? item.insuranceCodes.filter(i => typeof i === 'string' && i.trim() !== '') : [];
console.log('gjbm: ', gjbm)
if (gjbm.length === 0 && !showAll) {
return { success: false, message: '医保编码不能为空' }
}
const url = `${baseUrl}/his/hlw/drug`;
const payload = {
hospid: '108',
drug_type: 'xy',
area_code: typeof item.areaCode === 'string' ? item.areaCode.trim() : '',
gjbm: gjbm.join(',')
}
const { code, status_code, message, data } = await request.main(
url,
payload,
"POST"
);
if (status_code == 200 && code == '1' && !message) {
return {
success: true,
message: "[his]查询药品信息成功",
data: data && Array.isArray(data.drugs) ? data.drugs : []
};
} else {
return {
success: false,
message: `[his]查询药品信息传失败: ${message || '未知错误'}`,
};
}
} catch (err) {
return {
success: false,
message: `[his]查询药品信息传错误: ${err.message || '未知'}`,
};
}
}
async function uploadMedicinePurchaseRx(item) {
try {
const { data } = item;
const url = `${baseUrl}/his/hlw/mzordersave`;
const { code, status_code, message } = await request.main(
url,
data,
"POST"
);
if (status_code == 200 && code == '1' && !message) {
return {
success: true,
message: "[his]购药订单处上传成功",
};
} else {
return {
success: false,
message: `[his]购药订单处方上传失败: ${message || '未知错误'}`,
};
}
} catch (err) {
return {
success: false,
message: `[his]购药订单处方上传错误: ${err.message || '未知'}`,
};
}
}
async function uploadWinePurchaseRx(item) {
try {
const { data } = item;
const url = `${baseUrl}/his/hlw/tcsave`;
const { code, status_code, message } = await request.main(
url,
data,
"POST"
);
console.log('data', code, status_code, message);
if (status_code == 200 && code == '1' && !message) {
return {
success: true,
message: "[his]浸酒处上传成功",
};
} else {
return {
success: false,
message: `[his]浸酒处方上传失败: ${message || '未知错误'}`,
};
}
} catch (err) {
return {
success: false,
message: `[his]浸酒处方上传错误: ${err.message || '未知'}`,
};
}
}
async function ybReviewPrescription(item) {
try {
const params = { type: "rationalDrugReview", data: item.data };
@ -704,155 +182,6 @@ async function ybReviewPrescription(item) {
}
}
async function getHisMedicinePurchase({ patientId, orderNo }) {
try {
const url = `${baseUrl}/his/hlw/orderquery`;
const { code, status_code, message, data } = await request.main(url, { patientid: patientId, orderno: orderNo }, "POST");
const order = data && Array.isArray(data.orders) ? data.orders[0] : null;
if (status_code == 200 && code == '1' && !message) {
if (order) {
return { success: true, data: order, message: '查询成功' }
}
return { success: false, message: `[his]处方订单查询失败: 未找到订单` }
}
return {
success: false,
message: `[his]处方订单查询失败: ${message || '未知错误'}`,
};
} catch (err) {
return { success: false, message: `[his]处方订单查询错误: ${err.message || '未知错误'}` };
}
}
async function cancelMedicinePurchase({ patientId: patientid, orderNo: orderno }) {
try {
const url = `${baseUrl}/his/hlw/ordercancel`;
const { code, status_code, message } = await request.main(url, { patientid, orderno }, "POST");
if (status_code == 200 && code == '1' && !message) {
return { success: true, message: '[his]取消购药订单处方成功' }
}
return { success: false, message: `[his]取消购药订单处方失败: ${message || '未知错误'}` }
} catch (err) {
return { success: false, message: `[his]取消购药订单处方错误: ${err.message || '未知错误'}` };
}
}
async function refundMedicinePurchase({ patientId: patientid, orderNo: orderno }) {
try {
const url = `${baseUrl}/his/hlw/orderrefund`;
const { code, status_code, message } = await request.main(url, { patientid, orderno }, "POST");
if (status_code == 200 && code == '1' && !message) {
return { success: true, message: '[his]退费购药订单处方成功' }
}
return { success: false, message: `[his]退费购药订单处方失败: ${message || '未知错误'}` }
} catch (err) {
return { success: false, message: `[his]退费购药订单处方错误: ${err.message || '未知错误'}` };
}
}
async function getHisInvoice(item) {
try {
const { patientId, orderNo } = item;
const url = `${baseUrl}/his/hlw/dzfp`;
let { code, status_code, message, data } = await request.main(url, { patientid: patientId, orderno: orderNo }, "POST");
if (status_code == 200 && code == '1' && !message) {
const dzfp = data && Array.isArray(data.dzfp) ? data.dzfp[0] : null;
return { success: true, data: dzfp && dzfp.dzfp_url ? dzfp.dzfp_url : '', message: '发票查询成功' }
}
return { success: false, message: `[his]发票查询失败: ${message || '未知错误'}` }
} catch (err) {
return { success: false, message: `[his]发票查询错误: ${err.message || '未知错误'}` };
}
}
async function updateMedicinePurchaseDelivery(item) {
try {
const {
patientId: patientid,
orderNo: orderno,
backFlag: back_flag = 0,
shippingType: deliv_type,
shippingNo: deliv_no,
shippingFee: deliv_fee,
pickupCode: tracking_no,
payTime: pay_dt,
tradeNo: pay_orderno,
receiveName: shipp_name,
receivePhone: shipp_tel,
fullAddress: shipp_addr,
invoice
} = item;
const payload = {
patientid,
orderno,
back_flag, // 0 收取运费 | 1 退还运费
deliv_type,
deliv_no,
deliv_fee,
pay_dt,
pay_orderno,
shipp_name,
shipp_tel,
shipp_addr,
tracking_no,
invoice
}
const url = `${baseUrl}/his/hlw/delivery`;
let { code, status_code, message, data } = await request.main(url, payload, "POST");
if (status_code == 200 && code == '1' && !message) {
return { success: true, data, message: '[his]同步配药订单物流信息成功' }
}
return { success: false, message: `[his]同步配药订单物流信息失败: ${message || '未知错误'}` }
} catch (err) {
return { success: false, message: `[his]同步配药订单物流信息错误: ${err.message || '未知错误'}` };
}
}
async function syncHisChargeStatus(ctx) {
try {
const payload = {
patientid: ctx.patientId,
orderno: ctx.orderNo,
settle_method: 1,
paymethod: 'h',
paysn: ctx.tradeNo,
payrequestsn: ctx.outTradeNo,
totalamount: ctx.totalAmount,
selfpay: ctx.totalAmount,
operator_code: 'hlw',
operator_name: '互联网医院'
}
const url = `${baseUrl}/his/hlw/ybsettle`;
let { code, status_code, message, data } = await request.main(url, payload, "POST");
if (status_code == 200 && code == '1' && !message) {
return { success: true, data, message: '[his]同步浸酒订单收费成功' }
}
return { success: false, message: `[his]同步浸酒订单收费失败: ${message || '未知错误'}` }
} catch (err) {
return { success: false, message: `[his]同步浸酒订单收费错误: ${err.message || '未知错误'}` };
}
}
async function syncHisRefundStatus(ctx) {
try {
const payload = {
patientid: ctx.patientId,
orderno: ctx.orderNo,
ptlsh: ctx.refundSerialNo
}
const url = `${baseUrl}/his/hlw/orderrefund`;
let { code, status_code, message, data } = await request.main(url, payload, "POST");
if (status_code == 200 && code == '1' && !message) {
return { success: true, data, message: '[his]同步浸酒订单退费成功' }
}
return { success: false, message: `[his]同步浸酒订单退费失败: ${message || '未知错误'}` }
} catch (err) {
return { success: false, message: `[his]同步浸酒订单退费错误: ${err.message || '未知错误'}` };
}
}
async function getHisEmployeeInfo(item) {
const { code } = item;
try {
@ -875,46 +204,6 @@ async function getHisEmployeeInfo(item) {
}
}
async function getWineDrugStockInfo(item) {
try {
if (typeof item.code !== 'string' || item.code.trim() === '') {
return { success: false, message: '中药饮片代码不能为空' }
}
if (!(Number.isInteger(item.num) && item.num > 0)) {
return { success: false, message: '中药饮片数量必须是整数且大于0' }
}
const url = `${baseUrl}/his/hlw/drug`;
const payload = {
hospid: '108',
drug_type: 'zcy',
tc_code: item.code.trim(),
tc_number: item.num
}
const { code, status_code, message, data } = await request.main(
url,
payload,
"POST"
);
if (status_code == 200 && code == '1') {
return {
success: true,
message: "[his]查询药品信息成功",
data: data
};
}
return {
success: false,
message: `中药饮品查询失败: ${message || '未知错误'}`,
};
} catch (err) {
return {
success: false,
message: `中药饮品查询失败错误: ${err.message || '未知'}`,
};
}
}
async function hisTest() {
// const data = {
// patientId: '10428622',
@ -926,3 +215,55 @@ async function hisTest() {
// const data = { "patientid": "30015424", "registerid": "h202501070833145550001", "orderno": "h202501070833145550001", "doctorcode": "10010", "unitcode": "1005000", "visit_time": "2025-12-04 13:15", "area_code": "330602", "hospid": "108", "icds": [{ "icd_code ": "R51.x00", "icd_name": "头痛", "sort": 1 }], "details": [{ "drug_type": "xy", "drug_groupno": "7X", "drug_group": "", "drug_id": "00110", "drug_serial": "01", "drug_name": "富马酸替诺福韦二吡呋酯片", "drug_spec": "300mg×30片/盒", "drug_pric": "329.41", "drug_cnt": "1", "drug_sumamt": "329.41", "supple_code": "1", "supple_name": "口服", "medc_days": "3", "drug_times": "1", "drug_comm": "", "drug_ma": "", "remark": "", "limit_flag": "0", "usage": "11", "usage_name": "每天一次(qd)", "sin_doscnt": "2.00", "sin_dosunt": "片", "unit": "盒" }] }
// return await syncHisChargeStatus(data);
}
async function updateHisDrug({ data: drugArr }) {
try {
const params = { type: "updateOnlineDrug", data: drugArr };
const { data, status_code, message } = await request.main(url, params, "POST");
if (status_code == 200 && !message) {
return {
success: true,
message: `同步his药品成功`,
};
} else {
return {
success: false,
message: message || "同步his药品失败",
};
}
} catch (err) {
return {
success: false,
message: err.message || "同步his药品错误",
};
}
}
async function cancelInsuranceRx(item) {
const patientId = typeof item.patientId === "string" ? item.patientId.trim() : "";
const orderno = typeof item.orderno === "string" ? item.orderno.trim() : "";
if (!patientId || !orderno) {
return { success: false, message: "医保处方缺少患者编号或处方编号" };
}
try {
const params = { type: "hlwPrescriptionOrderCancel", patientId, orderno };
const { code, status_code, message } = await request.main(url, params, "POST");
if (status_code == 200 && code == "1") {
return {
success: true,
message: `处方撤销成功`,
};
} else {
return {
success: false,
message: message || "处方撤销失败",
};
}
} catch (err) {
return {
success: false,
message: err.message || "处方撤销错误",
};
}
}

View File

@ -29,6 +29,7 @@ const chineseMedicineOrder = require("./chinese-medicine-order");
const hlwPatient = require("./hlw-patient");
const scannedCardArchive = require("./scanned-card-archive");
const selfAuth = require("./self-auth");
const hlwSchedule = require("./hlw-schedule");
module.exports = async (item, db, context) => {
switch (item.type) {
@ -57,6 +58,7 @@ module.exports = async (item, db, context) => {
case "getStoreSimilarDrugInfo":
case "searchDrugs":
case "getDrugMatchedDisease":
case "updateHisDrugInfo":
return await drugInfo(item, db);
case "addPatientDescription":
case "deletePatientDescription":
@ -87,6 +89,10 @@ module.exports = async (item, db, context) => {
case "autoSetPeopleOffline":
case "getDoctorByNo":
return await hlwDoctor(item, db);
case "getHlwSchedule":
case "saveHlwSchedule":
case "setTodayScheduledPeopleOnline":
return await hlwSchedule(item, db);
case "getDiagnosisList":
case "deleteHlwDiagnosis":
case "updateHlwDiagnosis":
@ -125,7 +131,6 @@ module.exports = async (item, db, context) => {
case "getAuditDiagnosisList":
case "getAuditedDiagnosisList":
case "auditDiagnosis":
case "uploadDiagnosis":
case "reUploadDiagnosticRecord":
case "getPatientDiagnosisRecord":
case "getPatientPassDiagnosisRecord":
@ -133,6 +138,7 @@ module.exports = async (item, db, context) => {
case "orderHasDiagnosis":
case "getStoreDiagnosisRecord":
case "markStoreRxPrinted":
case "cancelStoreDiagnosisRecord":
case "getAllDiagnosisRecord":
case "getDoctorRxStats":
case "getPharmacistRxStats":

View File

@ -0,0 +1,501 @@
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();
const PDF_TEMPLATE_VERSION = "v11";
const MAX_SIGNATURE_IMAGE_BYTES = 1024 * 1024;
const SIGNATURE_DOWNLOAD_TIMEOUT_MS = 5000;
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" },
};
const PRESCRIPTION_SEAL_IMAGE_URL = process.env.CONFIG_PRESCRIPTION_SEAL_IMAGE_URL
|| "https://hlwyy.hnkhhospital.com/upload-files/other/stamp.png";
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,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
});
const browser = await browserPromise;
browser.on("disconnected", () => {
browserPromise = undefined;
});
}
return browserPromise;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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() {
// 源码运行时:<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");
}
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>`;
}
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;
}
}
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);
const diagnosisText = diagnosis.map((item) => item?.name).filter(Boolean).join("+");
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>
@page { size: ${size} portrait; margin: 8mm; }
* { box-sizing: border-box; }
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; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.prescription { width: calc(100% - 1px); min-height: ${size === "A5" ? "194mm" : "281mm"}; display: flex; flex-direction: column; line-height: 1.28; overflow: hidden; }
.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; }
.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; }
.rx-number { margin-top: 5px; }
.patient-info { padding-top: 5px; }
.info-row { display: flex; padding: 2px; }
.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; }
.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; }
@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">
<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>
<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>
<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>
</main>
</body></html>`;
}
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(() => {});
}
}
async function generatePrescriptionPdf(record, options = {}) {
await acquireSlot();
try {
const [doctor, pharmacist, seal] = await Promise.all([
loadSignatureDataImage(record.doctorSignImg),
loadSignatureDataImage(record.pharmacistSignImg),
loadSignatureDataImage(PRESCRIPTION_SEAL_IMAGE_URL),
]);
const html = buildPrescriptionHtml(record, options.paperSize, { doctor, pharmacist, seal });
const paperSize = options.paperSize === "A4" ? "A4" : "A5";
return generateCompactPrescriptionPdf(html, paperSize);
} 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;
}
return generatePrescriptionPdf(
await addDrugStoreName(db, decryptDiagnosticRecordFields(record)),
options
);
}
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;
}
const pdf = await generatePrescriptionPdf(
await addDrugStoreName(db, decryptDiagnosticRecordFields(record)),
{ paperSize }
);
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,
};

243
index.js
View File

@ -42,6 +42,7 @@ const customerHisSync = require("./customerHisSync");
const hlw = require("./hlw");
const automation = require("./hlw/automation");
const imImageArchive = require("./hlw/im-image-archive");
const { getOrCreatePrescriptionPdf, initializeStorage: initializePrescriptionPdfStorage } = require("./hlw/prescription-pdf");
const trigger = require("./trigger");
const consultTrigger = require("./hlw/consult-order/trigger");
const callBack = require("./callBack");
@ -51,6 +52,7 @@ const { jwtAuth, optionalJwtAuth } = require("./middleware/jwt-auth");
const { smartAuth } = require("./middleware/smart-auth");
const sms = require("./sms");
const path = require("path");
const fs = require("fs/promises");
const { ObjectId } = require("mongodb");
const { useUploadFileParse } = require("./upload-file-parse");
const app = express();
@ -207,6 +209,11 @@ const imageStorageReadyPromise = imImageArchive.initializeStorage().catch((err)
return null;
});
const prescriptionPdfStorageReadyPromise = initializePrescriptionPdfStorage().catch((err) => {
logger.error("处方 PDF 目录初始化失败:", err);
return null;
});
app.get(
"/zyt/stats",
asyncHandler(async (req, res) => {
@ -277,6 +284,35 @@ app.get(
})
);
// 服务端渲染处方笺。Chromium 路径由 PUPPETEER_EXECUTABLE_PATH 配置,避免应用镜像携带浏览器。
app.get(
"/hlw/prescriptions/:id/pdf",
asyncHandler(async (req, res) => {
const db = await getDatabase("Internet-hospital");
const paperSize = req.query.paperSize === "A4" ? "A4" : "A5";
const { pdfPath } = await getOrCreatePrescriptionPdf(db, req.params.id, { paperSize });
res.set({
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename=\"prescription-${req.params.id}.pdf\"`,
"Cache-Control": "no-cache",
});
await new Promise((resolve, reject) => {
res.sendFile(pdfPath, (error) => (error ? reject(error) : resolve()));
});
})
);
// 返回处方笺 PDF 的 Base64 内容,供客户端上传或嵌入预览。
app.get(
"/hlw/prescriptions/:id/base64",
asyncHandler(async (req, res) => {
const db = await getDatabase("Internet-hospital");
const paperSize = req.query.paperSize === "A4" ? "A4" : "A5";
const { pdfPath } = await getOrCreatePrescriptionPdf(db, req.params.id, { paperSize });
sendSuccess(res, (await fs.readFile(pdfPath)).toString("base64"), "查询成功");
})
);
// POST请求处理器
app.post(
"/getYoucanData/member",
@ -293,28 +329,6 @@ app.post(
})
);
app.post(
"/getYoucanData/todo",
jwtAuth,
asyncHandler(async (req, res) => {
const requestData = req.body;
const db = await getDatabase("admin");
let item = await todo.main(requestData, db);
safeSend(res, item);
})
);
app.post(
"/getYoucanData/groupmsg",
jwtAuth,
asyncHandler(async (req, res) => {
const requestData = req.body;
const db = await getDatabase("admin");
let item = await groupmsg.main(requestData, db);
safeSend(res, item);
})
);
app.post(
"/getYoucanData/corp",
smartAuth, // 使用智能鉴权,支持白名单 type 跳过鉴权
@ -361,37 +375,6 @@ app.post(
})
);
app.post(
"/getYoucanData/sessionArchive",
jwtAuth,
asyncHandler(async (req, res) => {
const requestData = req.body;
const db = await getDatabase("corp");
const item = await sessionArchive.main(requestData, db);
safeSend(res, item);
})
);
app.post(
"/getYoucanData/weCom",
jwtAuth,
asyncHandler(async (req, res) => {
const requestData = req.body;
const item = await weCom.main(requestData);
safeSend(res, item);
})
);
app.post(
"/getYoucanData/customerHisSync",
jwtAuth,
asyncHandler(async (req, res) => {
const requestData = req.body;
const item = await customerHisSync.main(requestData);
safeSend(res, item);
})
);
app.post(
"/getYoucanData/sms",
asyncHandler(async (req, res) => {
@ -433,42 +416,6 @@ app.post(
})
);
app.post(
"/union-pay-notify",
smartAuth, // 使用智能鉴权,支持白名单 type 跳过鉴权
asyncHandler(async (req, res) => {
await trade.handleUnionPayNotify(req, res);
})
);
app.post(
"/fengniao-auth",
smartAuth, // 使用智能鉴权,支持白名单 type 跳过鉴权
asyncHandler(async (req, res) => {
await fengniao.handleFengNiaoAuth(req, res);
})
);
app.post(
"/fengniao-notify",
smartAuth, // 使用智能鉴权,支持白名单 type 跳过鉴权
asyncHandler(async (req, res) => {
await fengniao.notify(req, res);
})
);
app.post(
"/zyt-his-notify",
smartAuth, // 使用智能鉴权,支持白名单 type 跳过鉴权
asyncHandler(async (req, res) => {
const notifyHandler = require('./hlw/zyt-his/notify');
const requestData = req.body;
const result = await notifyHandler.handleNotify(requestData);
safeSend(res, result)
})
);
app.post(
"/IMCallBack",
@ -505,124 +452,6 @@ app.get(
})
);
/**
* mPaaS 音视频通话:云端自动录制回调
* - 控制台配置:通话应用 -> 功能配置 -> 配置云端自动录制及录制回调地址
* - 回调方式:POST(application/json)
* - 业务侧响应:{ bizRequestId, code: 0 }
*/
app.post(
"/mrtc/record/callback",
asyncHandler(async (req, res) => {
const payload = req.body && typeof req.body === "object" ? req.body : {};
const {
bizRequestId,
bizName,
appId,
workspaceId,
roomId,
recordId,
eventCode,
recordResult,
} = payload;
// 按文档要求回包,避免云端重试;落库失败也不影响回包
res.json({ bizRequestId: bizRequestId || "", code: 0 });
try {
const db = await getDatabase("Internet-hospital");
const now = Date.now();
let orderId = "";
let doctorCode = "";
if (typeof roomId === "string" && roomId) {
const order = await db.collection("consult-order").findOne(
{ mrtcRoomId: roomId },
{ projection: { orderId: 1, doctorCode: 1 } }
);
if (order && order.orderId) orderId = order.orderId;
if (order && order.doctorCode) doctorCode = order.doctorCode;
}
const normalizedEventCode =
typeof eventCode === "number"
? eventCode
: typeof eventCode === "string" && eventCode !== ""
? Number(eventCode)
: undefined;
const rr = recordResult && typeof recordResult === "object" ? recordResult : null;
const status =
rr && rr.status !== undefined && rr.status !== null && rr.status !== "" ? Number(rr.status) : undefined;
const $set = {
updatedAt: now,
source: "AUTO_RECORD_CALLBACK",
};
if (orderId) $set.orderId = orderId;
if (doctorCode) $set.doctorCode = doctorCode;
if (typeof roomId === "string" && roomId) $set.roomId = roomId;
if (typeof recordId === "string" && recordId) $set.recordId = recordId;
if (typeof bizRequestId === "string" && bizRequestId) $set.bizRequestId = bizRequestId;
if (typeof bizName === "string" && bizName) $set.bizName = bizName;
if (typeof appId === "string" && appId) $set.appId = appId;
if (typeof workspaceId === "string" && workspaceId) $set.workspaceId = workspaceId;
if (normalizedEventCode !== undefined && Number.isFinite(normalizedEventCode)) $set.eventCode = normalizedEventCode;
// eventCode=99 时传 recordResult:status/fileType/filePath/recordStartTime/mediaType
if (normalizedEventCode === 99 && rr) {
if (status !== undefined && Number.isFinite(status)) $set.status = status;
if (rr.fileType !== undefined && rr.fileType !== null && rr.fileType !== "") $set.fileType = Number(rr.fileType);
if (typeof rr.filePath === "string") $set.filePath = rr.filePath;
if (rr.recordStartTime !== undefined && rr.recordStartTime !== null && rr.recordStartTime !== "")
$set.startAt = Number(rr.recordStartTime);
if (rr.mediaType !== undefined && rr.mediaType !== null && rr.mediaType !== "") $set.mediaType = Number(rr.mediaType);
}
// eventCode=11:录制结束(结束时间以回调到达时间为准)
if (normalizedEventCode === 11) {
$set.endAt = now;
}
// 以 recordId 为主键幂等(回调不会携带 recordThirdId)
const filter = recordId ? { recordId } : { bizRequestId: bizRequestId || `AUTO_${now}` };
await db.collection("mrtc-video-record").updateOne(
filter,
{
// 注意:MongoDB 不允许在同一次 update 中同时用 $setOnInsert 与 $push 修改同一路径(会报 conflict)
// events 字段由 $push 在首次写入时自动创建为数组
$setOnInsert: { createdAt: now },
$set,
$push: {
events: {
event: "AUTO_RECORD_CALLBACK",
at: now,
eventCode: normalizedEventCode,
status,
payload,
},
},
},
{ upsert: true }
);
logger.info("[MRTC][AUTO_RECORD] callback stored", {
bizRequestId,
roomId,
recordId,
eventCode: normalizedEventCode,
orderId,
status,
});
} catch (e) {
logger.error("[MRTC][AUTO_RECORD] callback store failed", {
message: e && e.message ? e.message : String(e),
});
}
})
);
app.get(
"/callback/data",
asyncHandler(async (req, res) => {

1229
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,7 @@
"multer": "^1.4.5-lts.1",
"node-fetch": "^3.3.2",
"node-schedule": "^2.1.1",
"puppeteer-core": "^24.0.0",
"querystring": "^0.2.1",
"sm-crypto": "^0.3.13",
"web-streams-polyfill": "^4.1.0",

View File

@ -28,11 +28,15 @@ function get(url) {
}
function post(url, data, config) {
const hasPdf = data && data.data && typeof data.data.pdf == 'string' ? data.data.pdf.length > 50 : false;
const info = { url, method: "POST", data, start: dayjs().format('YYYY-MM-DD HH:mm:ss:SSS') };
return new Promise(async (resolve, reject) => {
try {
const response = await axios.post(url, data, config);
resolve(response.data);
if (hasPdf) {
info.data.data.pdf = 'pdfbase64'
}
writeLog(info, response);
} catch (error) {
info.errMsg = error?.message || error;

View File

@ -4,9 +4,24 @@ const dayjs = require("dayjs");
exports.initSchedule = () => {
// triggerEveryMinute()
triggerAtNight()
triggerAtNight();
triggerDailySchedulePeopleOnline();
};
function triggerDailySchedulePeopleOnline() {
// node-schedule 格式:秒 分 时 日 月 周;每天 00:30 将当天排班的医生、药师上线。
schedule.scheduleJob("0 30 0 * * *", async () => {
try {
const result = await api.getInternetHospitalApi({ type: "setTodayScheduledPeopleOnline" });
if (!result?.success) {
console.log(`[${dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')}] 排班人员自动上线失败: ${result?.message || '未知错误'}`);
}
} catch (error) {
console.log(`[${dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')}] 排班人员自动上线失败: ${error.message}`);
}
});
}
async function triggerEveryMinute() {
schedule.scheduleJob("0 * * * * *", async () => {
runHighFrequencyTask()