1088 lines
31 KiB
JavaScript
Raw Normal View History

2026-07-31 16:22:09 +08:00
const { ObjectId } = require("mongodb");
const { randomUUID } = require("crypto");
const {
AUTOMATION_TYPES,
getTypeConfig,
normalizeAutomationConfig,
randomInteger,
} = require("./config");
const { decryptOrderFields } = require("../consult-order/format");
2026-08-03 16:14:45 +08:00
const tencentIM = require("../tencent-im");
2026-07-31 16:22:09 +08:00
const COLLECTION = "hlw-automation-task";
const TASK_STATUS = {
PENDING: "PENDING",
RUNNING: "RUNNING",
SUCCEEDED: "SUCCEEDED",
CANCELLED: "CANCELLED",
FAILED: "FAILED",
};
2026-08-05 18:39:56 +08:00
const ERROR_SOURCES = {
SYSTEM: "SYSTEM",
HIS: "HIS",
};
2026-07-31 16:22:09 +08:00
const LEASE_MS = 2 * 60 * 1000;
const POLL_INTERVAL_MS = 1000;
const RECONCILE_INTERVAL_MS = 30 * 1000;
const MAX_TASKS_PER_TICK = 10;
const MAX_RETRY_DELAY_MS = 10 * 60 * 1000;
const WORKER_ID = `${process.pid}:${randomUUID()}`;
let workerDb = null;
let pollTimer = null;
let reconcileTimer = null;
let pollIsRunning = false;
let reconcileIsRunning = false;
class AutomationError extends Error {
2026-08-05 18:39:56 +08:00
constructor(message, { retryable = false, source = ERROR_SOURCES.SYSTEM } = {}) {
2026-07-31 16:22:09 +08:00
super(message);
this.name = "AutomationError";
this.retryable = retryable;
2026-08-05 18:39:56 +08:00
this.source = source === ERROR_SOURCES.HIS ? ERROR_SOURCES.HIS : ERROR_SOURCES.SYSTEM;
2026-07-31 16:22:09 +08:00
}
}
function getTaskKey(type, orderId, rxId) {
if (type === AUTOMATION_TYPES.PASS_RX) {
return `${type}:${rxId}`;
}
return `${type}:${orderId}`;
}
async function getConfig(db, corpId) {
const config = await db.collection("hlw-config").findOne({ corpId });
return normalizeAutomationConfig(config || {});
}
function getBusinessExpiry(order, fallbackMs = 30 * 60 * 1000) {
if (order && Number.isFinite(order.expireTime) && order.expireTime > Date.now()) {
return order.expireTime;
}
return Date.now() + fallbackMs;
}
function calculateDueAt(type, config, baseTime = Date.now(), random = Math.random) {
const { minSeconds, maxSeconds } = getTypeConfig(config, type);
const seconds = randomInteger(minSeconds, maxSeconds, random);
let dueAt = baseTime + seconds * 1000;
if (type === AUTOMATION_TYPES.OPEN_RX) {
const firstSubmitRxIntervel =
Number.isInteger(config.firstSubmitRxIntervel) && config.firstSubmitRxIntervel > 0
? config.firstSubmitRxIntervel
: 0;
dueAt = Math.max(dueAt, baseTime + firstSubmitRxIntervel * 1000);
}
if (dueAt < Date.now()) {
dueAt = Date.now() + randomInteger(0, 2, random) * 1000;
}
return dueAt;
}
async function enqueueTask(
db,
{ type, corpId, orderId, rxId = "", baseTime = Date.now(), expiresAt }
) {
if (!corpId || !orderId || !Object.values(AUTOMATION_TYPES).includes(type)) {
return { success: false, message: "自动化任务参数错误" };
}
if (type === AUTOMATION_TYPES.PASS_RX && !rxId) {
return { success: false, message: "自动审方任务缺少处方ID" };
}
const config = await getConfig(db, corpId);
const stageConfig = getTypeConfig(config, type);
if (!stageConfig.enabled) {
return { success: false, skipped: true, message: "自动化配置未开启" };
}
const now = Date.now();
const taskKey = getTaskKey(type, orderId, rxId);
const dueAt = calculateDueAt(type, config, baseTime);
const task = {
taskKey,
type,
corpId,
orderId,
rxId,
status: TASK_STATUS.PENDING,
dueAt,
nextRunAt: dueAt,
expiresAt: Number.isFinite(expiresAt) ? expiresAt : now + 30 * 60 * 1000,
attempts: 0,
leaseUntil: 0,
leaseOwner: "",
lastError: "",
createdAt: now,
updatedAt: now,
};
const reactivated = await db.collection(COLLECTION).updateOne(
{ taskKey, status: TASK_STATUS.CANCELLED },
{
$set: {
...task,
createdAt: now,
},
$unset: {
completedAt: "",
result: "",
},
}
);
if (reactivated.modifiedCount === 1) {
return {
success: true,
created: true,
reactivated: true,
taskKey,
dueAt,
};
}
let result;
try {
result = await db.collection(COLLECTION).updateOne(
{ taskKey },
{ $setOnInsert: task },
{ upsert: true }
);
} catch (error) {
if (error && error.code === 11000) {
return { success: true, created: false, taskKey, dueAt };
}
throw error;
}
return {
success: true,
created: result.upsertedCount === 1,
taskKey,
dueAt,
};
}
async function scheduleAutoAccept({ db, orderId, corpId, baseTime, expiresAt }) {
const eligible = await db.collection("consult-order").findOne(
{ orderId, corpId, orderSource: "ALIPAY_MINI" },
{ projection: { _id: 1 } }
);
if (!eligible) {
return { success: false, skipped: true, message: "非支付宝小程序订单" };
}
return enqueueTask(db, {
type: AUTOMATION_TYPES.ACCEPT,
orderId,
corpId,
baseTime,
expiresAt,
});
}
async function scheduleAutoOpenRx({ db, orderId, corpId, baseTime, expiresAt }) {
const eligible = await db.collection("consult-order").findOne(
{ orderId, corpId, orderSource: "ALIPAY_MINI" },
{ projection: { _id: 1 } }
);
if (!eligible) {
return { success: false, skipped: true, message: "非支付宝小程序订单" };
}
return enqueueTask(db, {
type: AUTOMATION_TYPES.OPEN_RX,
orderId,
corpId,
baseTime,
expiresAt,
});
}
async function scheduleAutoPassRx({
db,
orderId,
rxId,
corpId,
baseTime,
expiresAt,
}) {
const eligible = await db.collection("consult-order").findOne(
{ orderId, corpId, orderSource: "ALIPAY_MINI" },
{ projection: { _id: 1 } }
);
if (!eligible) {
return { success: false, skipped: true, message: "非支付宝小程序订单" };
}
return enqueueTask(db, {
type: AUTOMATION_TYPES.PASS_RX,
orderId,
rxId: rxId && rxId.toString(),
corpId,
baseTime,
expiresAt,
});
}
async function ensureIndexes(db) {
const collection = db.collection(COLLECTION);
await collection.createIndex({ taskKey: 1 }, { unique: true });
await collection.createIndex({ status: 1, nextRunAt: 1, leaseUntil: 1 });
await collection.createIndex({ orderId: 1, type: 1 });
await collection.createIndex({ completedAt: 1 });
}
async function claimNextTask(db, now = Date.now()) {
const result = await db.collection(COLLECTION).findOneAndUpdate(
{
$or: [
{
status: TASK_STATUS.PENDING,
nextRunAt: { $lte: now },
},
{
status: TASK_STATUS.RUNNING,
leaseUntil: { $lte: now },
},
],
},
{
$set: {
status: TASK_STATUS.RUNNING,
leaseUntil: now + LEASE_MS,
leaseOwner: WORKER_ID,
updatedAt: now,
},
$inc: { attempts: 1 },
},
{
sort: { nextRunAt: 1, createdAt: 1 },
returnDocument: "after",
}
);
return result && result.value ? result.value : result;
}
async function markTask(db, task, status, extra = {}) {
const now = Date.now();
const terminal = [
TASK_STATUS.SUCCEEDED,
TASK_STATUS.CANCELLED,
TASK_STATUS.FAILED,
].includes(status);
await db.collection(COLLECTION).updateOne(
{
_id: task._id,
status: TASK_STATUS.RUNNING,
leaseOwner: WORKER_ID,
},
{
$set: {
status,
leaseUntil: 0,
leaseOwner: "",
updatedAt: now,
...(terminal ? { completedAt: now } : {}),
...extra,
},
}
);
}
async function renewLease(db, task) {
await db.collection(COLLECTION).updateOne(
{
_id: task._id,
status: TASK_STATUS.RUNNING,
leaseOwner: WORKER_ID,
},
{
$set: {
leaseUntil: Date.now() + LEASE_MS,
updatedAt: Date.now(),
},
}
);
}
function getRetryDelay(attempts) {
const exponent = Math.max(0, Math.min(attempts - 1, 10));
const base = Math.min(5000 * 2 ** exponent, MAX_RETRY_DELAY_MS);
return Math.min(base + Math.floor(Math.random() * 3000), MAX_RETRY_DELAY_MS);
}
async function retryTask(db, task, error) {
const now = Date.now();
if (!Number.isFinite(task.expiresAt) || task.expiresAt <= now) {
await markTask(db, task, TASK_STATUS.FAILED, {
lastError: `业务已过期: ${error.message}`,
});
return;
}
const nextRunAt = Math.min(now + getRetryDelay(task.attempts || 1), task.expiresAt);
await markTask(db, task, TASK_STATUS.PENDING, {
nextRunAt,
lastError: error.message,
});
}
2026-08-03 16:14:45 +08:00
function isSingleAttemptTask(type) {
return [AUTOMATION_TYPES.OPEN_RX, AUTOMATION_TYPES.PASS_RX].includes(type);
}
2026-08-05 18:39:56 +08:00
function isHisUploadFailure(message) {
return typeof message === "string" && /^\s*\[his\]/i.test(message);
}
function cleanHisUploadFailureMessage(message) {
if (typeof message !== "string") return "";
return message
.replace(/^\s*\[his\]\s*/i, "")
.replace(/^处方上传(?:失败|错误)\s*[:]\s*/, "")
.trim();
}
function getFailureNotificationExt(error) {
const errorSource =
error && error.source === ERROR_SOURCES.HIS
? ERROR_SOURCES.HIS
: ERROR_SOURCES.SYSTEM;
const rawReason =
error && typeof error.message === "string" ? error.message.trim() : "";
const reason =
errorSource === ERROR_SOURCES.HIS
? `HIS上传错误${cleanHisUploadFailureMessage(rawReason) || "未知错误"}`
: `系统错误:${rawReason || "未知错误"}`;
return { errorSource, reason };
2026-08-03 16:14:45 +08:00
}
async function sendFailureNotification(db, task, error) {
let doctorCode = "";
try {
const order = await db.collection("consult-order").findOne(
{ orderId: task.orderId, corpId: task.corpId },
{ projection: { doctorCode: 1 } }
);
doctorCode = order && order.doctorCode ? order.doctorCode : "";
} catch (lookupError) {
console.error(
"[自动化任务] 查询失败通知接收人异常:",
lookupError.message
);
}
if (
!doctorCode &&
task.type === AUTOMATION_TYPES.PASS_RX &&
ObjectId.isValid(task.rxId)
) {
try {
const rx = await db.collection("diagnostic-record").findOne(
{ _id: new ObjectId(task.rxId), orderId: task.orderId },
{ projection: { doctorCode: 1 } }
);
doctorCode = rx && rx.doctorCode ? rx.doctorCode : "";
} catch (lookupError) {
console.error(
"[自动化任务] 查询处方失败通知接收人异常:",
lookupError.message
);
}
}
if (!doctorCode) {
console.error(
`[自动化任务] 未找到订单 ${task.orderId} 的医生,无法发送失败通知`
);
return;
}
2026-08-05 18:39:56 +08:00
const ext = getFailureNotificationExt(error);
2026-08-03 16:14:45 +08:00
try {
const result = await tencentIM(
{
type: "sendSystemNotification",
corpId: task.corpId,
formAccount: task.orderId,
toAccount: doctorCode,
SyncOtherMachine: 1,
msgBody: [
{
MsgType: "TIMCustomElem",
MsgContent: {
2026-08-04 16:45:52 +08:00
Data: "AUTORXFAIL",
2026-08-03 16:14:45 +08:00
Desc: "notification",
2026-08-04 16:45:52 +08:00
Ext: JSON.stringify(ext),
2026-08-03 16:14:45 +08:00
},
},
],
},
db
);
if (!result || !result.success) {
console.error(
`[自动化任务] 发送失败通知失败: ${result?.message || "未知错误"}`
);
}
} catch (notificationError) {
console.error(
"[自动化任务] 发送失败通知异常:",
notificationError.message
);
}
}
2026-07-31 16:22:09 +08:00
async function assertTaskEnabled(db, task) {
const config = await getConfig(db, task.corpId);
const { enabled } = getTypeConfig(config, task.type);
if (!enabled) {
await markTask(db, task, TASK_STATUS.CANCELLED, {
lastError: "执行前检查发现自动化配置已关闭",
});
return null;
}
return config;
}
async function getAlipayOrder(db, orderId) {
const raw = await db.collection("consult-order").findOne({ orderId });
if (!raw) {
throw new AutomationError("咨询订单不存在");
}
if (raw.orderSource !== "ALIPAY_MINI") {
throw new AutomationError("仅支持支付宝小程序咨询订单");
}
return decryptOrderFields(raw);
}
async function handleAutoAccept(db, task) {
const order = await getAlipayOrder(db, task.orderId);
if (order.orderStatus === "processing") {
await scheduleAutoOpenRx({
db,
orderId: order.orderId,
corpId: order.corpId,
baseTime: order.prescriptionStartTime || Date.now(),
expiresAt: getBusinessExpiry(order),
});
return { orderId: order.orderId };
}
if (order.orderStatus !== "pending") {
throw new AutomationError(`订单状态不支持自动接诊: ${order.orderStatus}`);
}
if (Number.isFinite(order.expireTime) && order.expireTime <= Date.now()) {
throw new AutomationError("咨询订单已过期");
}
const consultOrder = require("../consult-order");
const result = await consultOrder(
{
type: "acceptConsultOrder",
orderId: order.orderId,
corpId: order.corpId,
doctorCode: order.doctorCode,
operationSource: "AUTO",
},
db
);
if (!result || !result.success) {
throw new AutomationError(result?.message || "自动接诊失败", { retryable: true });
}
return { orderId: order.orderId };
}
async function resolveDiagnosisList(db, order) {
const result = [];
const seen = new Set();
const add = (code, name) => {
const normalizedCode = typeof code === "string" ? code.trim() : "";
const normalizedName = typeof name === "string" ? name.trim() : "";
if (!normalizedCode || !normalizedName) return;
const key = `${normalizedCode}:${normalizedName}`;
if (!seen.has(key)) {
result.push({ code: normalizedCode, name: normalizedName, desc: "" });
seen.add(key);
}
};
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())
: [];
if (diseaseNames.length) {
const diagnosisRecords = await db
.collection("hlw-diagnosis")
.find({ name: { $in: diseaseNames } }, { projection: { code: 1, name: 1 } })
.toArray();
for (const name of diseaseNames) {
const match = diagnosisRecords.find((item) => item.name === name);
if (!match || !match.code) {
throw new AutomationError(`诊断“${name}”无法精确匹配诊断编码`);
}
add(match.code, match.name);
}
}
// if (!result.length) {
// throw new AutomationError("订单缺少可用于开方的诊断编码");
// }
return result;
}
async function getMedicineConfig(db, corpId) {
const records = await db
.collection("hlw-config")
.find(
{ group: `${corpId}-medicine-related` },
{ projection: { key: 1, list: 1 } }
)
.toArray();
return records.reduce((map, item) => {
if (item.key && Array.isArray(item.list)) {
map[item.key] = item.list;
}
return map;
}, {});
}
function matchConfig(list, code, name) {
const items = Array.isArray(list) ? list : [];
return (
items.find((item) => code !== undefined && code !== "" && item.code == code) ||
items.find((item) => name && item.name === name)
);
}
async function resolvePrescriptionDrugs(db, order) {
const orderDrugs = Array.isArray(order.drugs) ? order.drugs : [];
if (!orderDrugs.length) {
throw new AutomationError("订单缺少药品,无法自动开方");
}
const ids = orderDrugs
.map((item) => (ObjectId.isValid(item._id) ? new ObjectId(item._id) : null))
.filter(Boolean);
if (ids.length !== orderDrugs.length) {
throw new AutomationError("订单药品ID不完整");
}
const collectionName =
order.consultType === "onlineMedicinePurchase" ? "online-drug-info" : "drug-info";
const masterDrugs = await db
.collection(collectionName)
.find({ _id: { $in: ids }, onSale: true })
.toArray();
if (masterDrugs.length !== orderDrugs.length) {
throw new AutomationError("订单中存在已下架或不存在的药品");
}
const medicineConfig = await getMedicineConfig(db, order.corpId);
const prefix =
order.consultType === "onlineMedicinePurchase" ? "online-medicine" : "store-medicine";
const dosageUnitList = medicineConfig[`${prefix}-dosage-unit`] || [];
const frequencyList = medicineConfig[`${prefix}-frequence`] || [];
const usageList = medicineConfig[`${prefix}-administration`] || [];
const unitList = medicineConfig["medicine-package-unit"] || [];
return orderDrugs.map((requested) => {
const master = masterDrugs.find(
(item) => item._id.toString() === requested._id.toString()
);
const usage = matchConfig(
usageList,
requested.usageCode,
requested.usageName || master.administration_method
);
const frequency = matchConfig(
frequencyList,
requested.frequencyCode,
requested.frequencyName || master.freq
);
const dosageUnit = matchConfig(
dosageUnitList,
requested.dosage_unit_code,
requested.dosage_unit || master.dosage_unit
);
const unit = matchConfig(unitList, requested.unit || master.unit, requested.unit || master.unit);
const dosage = Number(requested.dosage);
const quantity = Number(requested.quantity);
const days = Number(master.days);
if (!usage || !frequency || !dosageUnit || !unit) {
throw new AutomationError(`药品“${master.name}”的用法用量配置无法匹配`);
}
if (!(dosage > 0) || !(quantity > 0) || !Number.isInteger(quantity)) {
throw new AutomationError(`药品“${master.name}”的剂量或数量不正确`);
}
if (!(days > 0) || !Number.isInteger(days)) {
throw new AutomationError(`药品“${master.name}”未维护有效的用药天数`);
}
if (!master.erpId || !master.insurance_code) {
throw new AutomationError(`药品“${master.name}”缺少HIS所需编码`);
}
return {
_id: master._id.toString(),
erpId: master.erpId,
dosage_form: master.dosage_form || "",
days,
dosage,
dosage_unit: dosageUnit.name,
dosage_unit_code: dosageUnit.code,
drugName: master.name,
specification: master.specification || "",
frequencyCode: frequency.code,
frequencyName: frequency.name,
insurance_code: master.insurance_code,
product_id: master.product_id,
quantity,
unit: unit.code,
usageCode: usage.code,
usageName: usage.name,
package_amount: master.package_amount,
recommended_quantity: master.recommended_quantity,
forceSelfPay: master.forceSelfPay === "Y" ? "Y" : "N",
limitUsageScope:
typeof master.usage_restriction_desc === "string" &&
master.usage_restriction_desc.trim()
? "Y"
: "N",
};
});
}
async function buildAutoPrescriptionParams(db, order) {
const doctor = await db.collection("hlw-doctor").findOne({
corpId: order.corpId,
doctorNo: order.doctorCode,
job: "doctor",
});
if (!doctor) {
throw new AutomationError("开方医生不存在");
}
if (doctor.onlineStatus !== "online") {
throw new AutomationError("开方医生暂不在线", { retryable: true });
}
const [diagnosisList, drugs] = await Promise.all([
resolveDiagnosisList(db, order),
resolvePrescriptionDrugs(db, order),
]);
const diseaseText = Array.isArray(order.diseases) ? order.diseases.join("") : "";
const complaint = [diseaseText, order.description]
.filter((item) => typeof item === "string" && item.trim())
.join(" ")
.slice(0, 500);
if (!complaint) {
throw new AutomationError("订单缺少主诉和病情描述");
}
const config = await getConfig(db, order.corpId);
const prescriptionType =
order.consultType === "onlineMedicinePurchase"
? "onlineMedicinePurchase"
: "storeMedicinePurchase";
const medicinePurchaseRxDuration =
Number.isInteger(config.medicinePurchaseRxDuration) &&
config.medicinePurchaseRxDuration > 0
? config.medicinePurchaseRxDuration
: 30;
return {
complaint,
presentIllness:
typeof order.pastHistoryStr === "string" ? order.pastHistoryStr : "",
dispose: "",
doctorCAUserId: doctor.signatureUrl || "",
patientId: order.patientId,
name: order.name,
orderId: order.orderId,
doctorCode: order.doctorCode,
doctorName: order.doctorName,
deptName: order.deptName,
unitCode: order.unitCode,
drugStoreNo: order.drugStoreNo,
orderSource: order.orderSource,
idCard: order.idCard,
blhno: order.blhno,
medOrgOrderNo: order.medorg_order_no,
address: order.address,
mobile: order.mobile,
prescriptionType,
pickUpType: order.pickUpType,
expireTime:
prescriptionType === "onlineMedicinePurchase"
? Date.now() + medicinePurchaseRxDuration * 60 * 1000
: order.expireTime,
diagnosisList,
drugs,
};
}
async function handleAutoOpenRx(db, task) {
const order = await getAlipayOrder(db, task.orderId);
const existing = await db.collection("diagnostic-record").findOne(
{
orderId: order.orderId,
status: { $in: ["INIT", "PASS"] },
},
{ projection: { _id: 1, status: 1, createTime: 1, expireTime: 1 } }
);
if (existing) {
await scheduleAutoPassRx({
db,
orderId: order.orderId,
rxId: existing._id,
corpId: order.corpId,
baseTime: existing.createTime || Date.now(),
expiresAt: existing.expireTime || getBusinessExpiry(order),
});
return { orderId: order.orderId, rxId: existing._id.toString() };
}
if (order.orderStatus !== "processing") {
throw new AutomationError(`订单状态不支持自动开方: ${order.orderStatus}`);
}
if (Number.isFinite(order.expireTime) && order.expireTime <= Date.now()) {
throw new AutomationError("咨询订单已过期");
}
const params = await buildAutoPrescriptionParams(db, order);
const diagnosticRecord = require("../diagnostic-record");
const result = await diagnosticRecord(
{
type: "addConsultDiagnosis",
corpId: order.corpId,
params,
automated: true,
operationSource: "AUTO",
},
db
);
if (!result || !result.success) {
2026-08-03 16:14:45 +08:00
throw new AutomationError(result?.message || "开方接口返回失败", {
retryable: true,
});
2026-07-31 16:22:09 +08:00
}
const rx = await db.collection("diagnostic-record").findOne(
{ orderId: order.orderId, status: { $in: ["INIT", "PASS"] } },
{ projection: { _id: 1, createTime: 1, expireTime: 1 } }
);
if (!rx) {
throw new AutomationError("自动开方成功但未查询到处方", { retryable: true });
}
await scheduleAutoPassRx({
db,
orderId: order.orderId,
rxId: rx._id,
corpId: order.corpId,
baseTime: rx.createTime || Date.now(),
expiresAt: rx.expireTime || getBusinessExpiry(order),
});
return { orderId: order.orderId, rxId: rx._id.toString() };
}
async function handleAutoPassRx(db, task) {
if (!ObjectId.isValid(task.rxId)) {
throw new AutomationError("处方ID格式错误");
}
const rx = await db.collection("diagnostic-record").findOne({
_id: new ObjectId(task.rxId),
orderId: task.orderId,
});
if (!rx) {
throw new AutomationError("处方不存在");
}
if (rx.status === "PASS") {
return { orderId: task.orderId, rxId: task.rxId };
}
if (rx.status !== "INIT") {
throw new AutomationError(`处方状态不支持自动审方: ${rx.status}`);
}
if (!rx.pharmacistNo) {
throw new AutomationError("处方未分配审方药师", { retryable: true });
}
const pharmacist = await db.collection("hlw-doctor").findOne({
corpId: task.corpId,
doctorNo: rx.pharmacistNo,
job: "pharmacist",
});
if (!pharmacist) {
throw new AutomationError("审方药师不存在");
}
if (pharmacist.onlineStatus !== "online") {
throw new AutomationError("审方药师暂不在线", { retryable: true });
}
const diagnosticRecord = require("../diagnostic-record");
const result = await diagnosticRecord(
{
type: "auditDiagnosis",
ids: [task.rxId],
status: "PASS",
pharmacistNo: rx.pharmacistNo,
corpId: task.corpId,
operationSource: "AUTO",
},
db
);
if (!result || !result.success) {
2026-08-05 18:39:56 +08:00
const failure =
2026-07-31 16:22:09 +08:00
Array.isArray(result?.failList) && result.failList[0]
2026-08-05 18:39:56 +08:00
? result.failList[0]
: null;
const detail = failure ? failure.message : result?.message;
throw new AutomationError(detail || "审方接口返回失败", {
retryable: true,
source: isHisUploadFailure(detail)
? ERROR_SOURCES.HIS
: ERROR_SOURCES.SYSTEM,
});
2026-07-31 16:22:09 +08:00
}
return { orderId: task.orderId, rxId: task.rxId };
}
async function executeTask(db, task) {
const config = await assertTaskEnabled(db, task);
if (!config) return;
let result;
if (task.type === AUTOMATION_TYPES.ACCEPT) {
result = await handleAutoAccept(db, task);
} else if (task.type === AUTOMATION_TYPES.OPEN_RX) {
result = await handleAutoOpenRx(db, task);
} else if (task.type === AUTOMATION_TYPES.PASS_RX) {
result = await handleAutoPassRx(db, task);
} else {
throw new AutomationError(`未知自动化任务类型: ${task.type}`);
}
await markTask(db, task, TASK_STATUS.SUCCEEDED, {
lastError: "",
result: result || {},
});
}
async function processClaimedTask(db, task) {
const heartbeat = setInterval(
() =>
renewLease(db, task).catch((error) => {
console.error("[自动化任务] 续租失败:", error.message);
}),
Math.floor(LEASE_MS / 3)
);
if (typeof heartbeat.unref === "function") heartbeat.unref();
try {
await executeTask(db, task);
} catch (error) {
const automationError =
error instanceof AutomationError
? error
: new AutomationError(error.message || "自动化任务执行失败", {
retryable: true,
});
2026-08-03 16:14:45 +08:00
if (isSingleAttemptTask(task.type)) {
await markTask(db, task, TASK_STATUS.FAILED, {
lastError: automationError.message,
});
await sendFailureNotification(db, task, automationError);
} else if (automationError.retryable) {
2026-07-31 16:22:09 +08:00
await retryTask(db, task, automationError);
} else {
await markTask(db, task, TASK_STATUS.FAILED, {
lastError: automationError.message,
});
}
} finally {
clearInterval(heartbeat);
}
}
async function runDueTasks(db = workerDb) {
if (!db || pollIsRunning) return;
pollIsRunning = true;
try {
const tasks = [];
for (let i = 0; i < MAX_TASKS_PER_TICK; i += 1) {
const task = await claimNextTask(db);
if (!task) break;
tasks.push(task);
}
await Promise.all(tasks.map((task) => processClaimedTask(db, task)));
} catch (error) {
console.error("[自动化任务] 执行器异常:", error.message);
} finally {
pollIsRunning = false;
}
}
async function reconcileAutomationTasks(db = workerDb) {
if (!db || reconcileIsRunning) return;
reconcileIsRunning = true;
try {
const now = Date.now();
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const orders = await db
.collection("consult-order")
.find(
{
orderSource: "ALIPAY_MINI",
orderStatus: { $in: ["pending", "processing"] },
$or: [
{ expireTime: { $gt: now } },
{
expireTime: { $exists: false },
createTime: { $gte: todayStart.getTime() },
},
],
},
{
projection: {
orderId: 1,
corpId: 1,
orderStatus: 1,
createTime: 1,
prescriptionStartTime: 1,
expireTime: 1,
},
}
)
.toArray();
const corpIds = [...new Set(orders.map((item) => item.corpId).filter(Boolean))];
const configs = await db
.collection("hlw-config")
.find({ corpId: { $in: corpIds } })
.toArray();
const configMap = new Map(
configs.map((item) => [item.corpId, normalizeAutomationConfig(item)])
);
for (const order of orders) {
const config = configMap.get(order.corpId) || normalizeAutomationConfig({});
if (order.orderStatus === "pending" && config.autoAcceptOrder) {
await scheduleAutoAccept({
db,
orderId: order.orderId,
corpId: order.corpId,
baseTime: order.createTime,
expiresAt: order.expireTime,
});
}
if (order.orderStatus === "processing" && config.autoOpenRx) {
const record = await db.collection("diagnostic-record").findOne(
{ orderId: order.orderId, status: { $in: ["INIT", "PASS"] } },
{ projection: { _id: 1 } }
);
if (!record) {
await scheduleAutoOpenRx({
db,
orderId: order.orderId,
corpId: order.corpId,
baseTime: order.prescriptionStartTime || order.createTime,
expiresAt: order.expireTime,
});
}
}
}
const pendingRxList = await db
.collection("diagnostic-record")
.find(
{
status: "INIT",
orderSource: "ALIPAY_MINI",
$or: [
{ expireTime: { $gt: now } },
{
expireTime: { $exists: false },
createTime: { $gte: todayStart.getTime() },
},
],
},
{
projection: {
_id: 1,
orderId: 1,
corpId: 1,
createTime: 1,
expireTime: 1,
},
}
)
.toArray();
for (const rx of pendingRxList) {
const config =
configMap.get(rx.corpId) ||
(await getConfig(db, rx.corpId));
if (config.autoPassRx) {
const order = orders.find((item) => item.orderId === rx.orderId);
await scheduleAutoPassRx({
db,
orderId: rx.orderId,
rxId: rx._id,
corpId: rx.corpId,
baseTime: rx.createTime,
expiresAt: rx.expireTime || getBusinessExpiry(order),
});
}
}
} catch (error) {
console.error("[自动化任务] 核对任务失败:", error.message);
} finally {
reconcileIsRunning = false;
}
}
async function start(db) {
2026-08-05 18:39:56 +08:00
return
2026-07-31 16:22:09 +08:00
if (pollTimer || reconcileTimer) return;
workerDb = db;
await ensureIndexes(db);
await reconcileAutomationTasks(db);
await runDueTasks(db);
pollTimer = setInterval(() => runDueTasks(db), POLL_INTERVAL_MS);
reconcileTimer = setInterval(
() => reconcileAutomationTasks(db),
RECONCILE_INTERVAL_MS
);
if (typeof pollTimer.unref === "function") pollTimer.unref();
if (typeof reconcileTimer.unref === "function") reconcileTimer.unref();
console.log("[自动化任务] 执行器已启动");
}
function stop() {
if (pollTimer) clearInterval(pollTimer);
if (reconcileTimer) clearInterval(reconcileTimer);
pollTimer = null;
reconcileTimer = null;
workerDb = null;
pollIsRunning = false;
reconcileIsRunning = false;
}
module.exports = {
COLLECTION,
TASK_STATUS,
AUTOMATION_TYPES,
2026-08-05 18:39:56 +08:00
ERROR_SOURCES,
2026-07-31 16:22:09 +08:00
AutomationError,
2026-08-05 18:39:56 +08:00
getFailureNotificationExt,
2026-07-31 16:22:09 +08:00
calculateDueAt,
enqueueTask,
scheduleAutoAccept,
scheduleAutoOpenRx,
scheduleAutoPassRx,
buildAutoPrescriptionParams,
claimNextTask,
processClaimedTask,
reconcileAutomationTasks,
runDueTasks,
start,
stop,
};