diff --git a/hlw/automation/index.js b/hlw/automation/index.js index 3158713..ddbb247 100644 --- a/hlw/automation/index.js +++ b/hlw/automation/index.js @@ -7,6 +7,7 @@ const { randomInteger, } = require("./config"); const { decryptOrderFields } = require("../consult-order/format"); +const tencentIM = require("../tencent-im"); const COLLECTION = "hlw-automation-task"; const TASK_STATUS = { @@ -318,6 +319,94 @@ async function retryTask(db, task, error) { }); } +function isSingleAttemptTask(type) { + return [AUTOMATION_TYPES.OPEN_RX, AUTOMATION_TYPES.PASS_RX].includes(type); +} + +function getFailureNotificationText(task, error) { + const action = + task.type === AUTOMATION_TYPES.OPEN_RX ? "自动开方" : "自动审方"; + const reason = error && error.message ? error.message : "未知错误"; + return `${action}失败:${reason}`; +} + +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; + } + + const notification = getFailureNotificationText(task, error); + try { + const result = await tencentIM( + { + type: "sendSystemNotification", + corpId: task.corpId, + formAccount: task.orderId, + toAccount: doctorCode, + SyncOtherMachine: 1, + msgBody: [ + { + MsgType: "TIMCustomElem", + MsgContent: { + Data: "AUTOMATIONFAIL", + Desc: "notification", + Ext: notification, + }, + }, + ], + }, + db + ); + if (!result || !result.success) { + console.error( + `[自动化任务] 发送失败通知失败: ${result?.message || "未知错误"}` + ); + } + } catch (notificationError) { + console.error( + "[自动化任务] 发送失败通知异常:", + notificationError.message + ); + } +} + async function assertTaskEnabled(db, task) { const config = await getConfig(db, task.corpId); const { enabled } = getTypeConfig(config, task.type); @@ -648,7 +737,9 @@ async function handleAutoOpenRx(db, task) { db ); if (!result || !result.success) { - throw new AutomationError(result?.message || "自动开方失败", { retryable: true }); + throw new AutomationError(result?.message || "开方接口返回失败", { + retryable: true, + }); } const rx = await db.collection("diagnostic-record").findOne( @@ -719,7 +810,7 @@ async function handleAutoPassRx(db, task) { Array.isArray(result?.failList) && result.failList[0] ? result.failList[0].message : result?.message; - throw new AutomationError(detail || "自动审方失败", { retryable: true }); + throw new AutomationError(detail || "审方接口返回失败", { retryable: true }); } return { orderId: task.orderId, rxId: task.rxId }; } @@ -763,7 +854,12 @@ async function processClaimedTask(db, task) { : new AutomationError(error.message || "自动化任务执行失败", { retryable: true, }); - if (automationError.retryable) { + if (isSingleAttemptTask(task.type)) { + await markTask(db, task, TASK_STATUS.FAILED, { + lastError: automationError.message, + }); + await sendFailureNotification(db, task, automationError); + } else if (automationError.retryable) { await retryTask(db, task, automationError); } else { await markTask(db, task, TASK_STATUS.FAILED, { diff --git a/hlw/automation/index.test.js b/hlw/automation/index.test.js index 1952fce..702ae2c 100644 --- a/hlw/automation/index.test.js +++ b/hlw/automation/index.test.js @@ -1,4 +1,6 @@ const { ObjectId } = require("mongodb"); +jest.mock("../tencent-im", () => jest.fn()); +const tencentIM = require("../tencent-im"); const { AUTOMATION_TYPES, COLLECTION, @@ -25,6 +27,7 @@ function createCollectionDb(collections) { describe("automation task scheduling", () => { afterEach(() => { jest.restoreAllMocks(); + jest.clearAllMocks(); }); test("uses the configured delay and respects firstSubmitRxIntervel", () => { @@ -277,7 +280,84 @@ describe("automation task scheduling", () => { ); }); - test("retries automatic audit while the assigned pharmacist is offline", async () => { + test("fails automatic prescription immediately and sends the failure reason", async () => { + tencentIM.mockResolvedValue({ success: true }); + const taskUpdateOne = jest.fn().mockResolvedValue({ modifiedCount: 1 }); + const order = { + orderId: "order-1", + corpId: "corp-1", + orderSource: "ALIPAY_MINI", + orderStatus: "processing", + doctorCode: "D001", + expireTime: Date.now() + 60_000, + }; + const db = createCollectionDb({ + "hlw-config": { + findOne: jest.fn().mockResolvedValue({ autoOpenRx: true }), + }, + "consult-order": { + findOne: jest.fn().mockResolvedValue(order), + }, + "diagnostic-record": { + findOne: jest.fn().mockResolvedValue(null), + }, + "hlw-doctor": { + findOne: jest.fn().mockResolvedValue(null), + }, + [COLLECTION]: { updateOne: taskUpdateOne }, + }); + const task = { + _id: new ObjectId(), + type: AUTOMATION_TYPES.OPEN_RX, + corpId: "corp-1", + orderId: "order-1", + attempts: 1, + expiresAt: Date.now() + 60_000, + }; + + await processClaimedTask(db, task); + + expect(taskUpdateOne).toHaveBeenCalledWith( + expect.objectContaining({ + _id: task._id, + status: TASK_STATUS.RUNNING, + }), + expect.objectContaining({ + $set: expect.objectContaining({ + status: TASK_STATUS.FAILED, + completedAt: expect.any(Number), + lastError: "开方医生不存在", + }), + }) + ); + expect(tencentIM).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendSystemNotification", + formAccount: "order-1", + toAccount: "D001", + SyncOtherMachine: 1, + msgBody: [ + expect.objectContaining({ + MsgContent: expect.objectContaining({ + Data: "AUTOMATIONFAIL", + Desc: "notification", + Ext: "自动开方失败:开方医生不存在", + }), + }), + ], + }), + db + ); + expect(taskUpdateOne).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + $set: expect.objectContaining({ status: TASK_STATUS.PENDING }), + }) + ); + }); + + test("fails automatic audit immediately and sends the failure reason", async () => { + tencentIM.mockResolvedValue({ success: true }); const rxId = new ObjectId(); const taskUpdateOne = jest.fn().mockResolvedValue({ modifiedCount: 1 }); const db = createCollectionDb({ @@ -298,6 +378,9 @@ describe("automation task scheduling", () => { onlineStatus: "offline", }), }, + "consult-order": { + findOne: jest.fn().mockResolvedValue({ doctorCode: "D001" }), + }, [COLLECTION]: { updateOne: taskUpdateOne }, }); const task = { @@ -319,12 +402,36 @@ describe("automation task scheduling", () => { }), expect.objectContaining({ $set: expect.objectContaining({ - status: TASK_STATUS.PENDING, - nextRunAt: expect.any(Number), + status: TASK_STATUS.FAILED, + completedAt: expect.any(Number), lastError: "审方药师暂不在线", }), }) ); + expect(tencentIM).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sendSystemNotification", + formAccount: "order-1", + toAccount: "D001", + SyncOtherMachine: 1, + msgBody: [ + expect.objectContaining({ + MsgContent: expect.objectContaining({ + Data: "AUTOMATIONFAIL", + Desc: "notification", + Ext: "自动审方失败:审方药师暂不在线", + }), + }), + ], + }), + db + ); + expect(taskUpdateOne).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + $set: expect.objectContaining({ status: TASK_STATUS.PENDING }), + }) + ); }); });