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,
|
|
|
|
|
|
};
|