const { ObjectId } = require("mongodb"); jest.mock("../tencent-im", () => jest.fn()); jest.mock("../diagnostic-record", () => jest.fn()); const tencentIM = require("../tencent-im"); const diagnosticRecord = require("../diagnostic-record"); const { AUTOMATION_TYPES, COLLECTION, ERROR_SOURCES, TASK_STATUS, AutomationError, buildAutoPrescriptionParams, calculateDueAt, claimNextTask, enqueueTask, getFailureNotificationExt, processClaimedTask, reconcileAutomationTasks, } = require("./index"); function createCollectionDb(collections) { return { collection: jest.fn((name) => { if (!collections[name]) { throw new Error(`unexpected collection: ${name}`); } return collections[name]; }), }; } describe("automation task scheduling", () => { afterEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); }); test("uses the configured delay and respects firstSubmitRxIntervel", () => { jest.spyOn(Date, "now").mockReturnValue(1_000_000); const config = { autoOpenRx: true, autoOpenRxDelayMinSeconds: 20, autoOpenRxDelayMaxSeconds: 40, firstSubmitRxIntervel: 60, }; expect( calculateDueAt(AUTOMATION_TYPES.OPEN_RX, config, 1_000_000, () => 0) ).toBe(1_060_000); expect( calculateDueAt( AUTOMATION_TYPES.OPEN_RX, { ...config, firstSubmitRxIntervel: 10 }, 1_000_000, () => 0.999999 ) ).toBe(1_040_000); }); test("creates a task idempotently through a unique task key", async () => { const updateOne = jest .fn() .mockResolvedValueOnce({ modifiedCount: 0 }) .mockResolvedValueOnce({ upsertedCount: 1 }); const db = createCollectionDb({ "hlw-config": { findOne: jest.fn().mockResolvedValue({ autoAcceptOrder: true, autoAcceptDelayMinSeconds: 2, autoAcceptDelayMaxSeconds: 5, }), }, [COLLECTION]: { updateOne }, }); const result = await enqueueTask(db, { type: AUTOMATION_TYPES.ACCEPT, corpId: "corp-1", orderId: "order-1", baseTime: Date.now(), expiresAt: Date.now() + 60_000, }); expect(result).toMatchObject({ success: true, created: true, taskKey: "AUTO_ACCEPT:order-1", }); expect(updateOne).toHaveBeenLastCalledWith( { taskKey: "AUTO_ACCEPT:order-1" }, expect.objectContaining({ $setOnInsert: expect.objectContaining({ status: TASK_STATUS.PENDING, attempts: 0, }), }), { upsert: true } ); }); test("reactivates a task cancelled by a previously disabled switch", async () => { const updateOne = jest.fn().mockResolvedValue({ modifiedCount: 1 }); const db = createCollectionDb({ "hlw-config": { findOne: jest.fn().mockResolvedValue({ autoAcceptOrder: true }), }, [COLLECTION]: { updateOne }, }); const result = await enqueueTask(db, { type: AUTOMATION_TYPES.ACCEPT, corpId: "corp-1", orderId: "order-1", }); expect(result).toMatchObject({ success: true, created: true, reactivated: true, }); expect(updateOne).toHaveBeenCalledTimes(1); expect(updateOne).toHaveBeenCalledWith( { taskKey: "AUTO_ACCEPT:order-1", status: TASK_STATUS.CANCELLED, }, expect.objectContaining({ $set: expect.objectContaining({ status: TASK_STATUS.PENDING }), $unset: { completedAt: "", result: "" }, }) ); }); test("treats a concurrent unique-key insert as idempotent success", async () => { const duplicateError = Object.assign(new Error("duplicate key"), { code: 11000, }); const updateOne = jest .fn() .mockResolvedValueOnce({ modifiedCount: 0 }) .mockRejectedValueOnce(duplicateError); const db = createCollectionDb({ "hlw-config": { findOne: jest.fn().mockResolvedValue({ autoPassRx: true }), }, [COLLECTION]: { updateOne }, }); await expect( enqueueTask(db, { type: AUTOMATION_TYPES.PASS_RX, corpId: "corp-1", orderId: "order-1", rxId: new ObjectId().toString(), }) ).resolves.toMatchObject({ success: true, created: false, }); }); test("claims due and expired-lease tasks with one atomic update", async () => { const claimed = { _id: new ObjectId(), taskKey: "AUTO_ACCEPT:order-1", status: TASK_STATUS.RUNNING, }; const findOneAndUpdate = jest.fn().mockResolvedValue(claimed); const db = createCollectionDb({ [COLLECTION]: { findOneAndUpdate }, }); await expect(claimNextTask(db, 10_000)).resolves.toBe(claimed); expect(findOneAndUpdate).toHaveBeenCalledWith( { $or: [ { status: TASK_STATUS.PENDING, nextRunAt: { $lte: 10_000 }, }, { status: TASK_STATUS.RUNNING, leaseUntil: { $lte: 10_000 }, }, ], }, expect.objectContaining({ $set: expect.objectContaining({ status: TASK_STATUS.RUNNING, leaseOwner: expect.any(String), }), $inc: { attempts: 1 }, }), { sort: { nextRunAt: 1, createdAt: 1 }, returnDocument: "after", } ); }); test("cancels a claimed task when its switch has been turned off", async () => { const updateOne = jest.fn().mockResolvedValue({ modifiedCount: 1 }); const db = createCollectionDb({ "hlw-config": { findOne: jest.fn().mockResolvedValue({ autoAcceptOrder: false }), }, [COLLECTION]: { updateOne }, }); const task = { _id: new ObjectId(), type: AUTOMATION_TYPES.ACCEPT, corpId: "corp-1", orderId: "order-1", attempts: 1, expiresAt: Date.now() + 60_000, }; await processClaimedTask(db, task); expect(updateOne).toHaveBeenCalledWith( expect.objectContaining({ _id: task._id, status: TASK_STATUS.RUNNING, leaseOwner: expect.any(String), }), expect.objectContaining({ $set: expect.objectContaining({ status: TASK_STATUS.CANCELLED, completedAt: expect.any(Number), }), }) ); }); test("reconciliation recreates a missing task after service restart", async () => { jest.spyOn(Date, "now").mockReturnValue(2_000_000); const taskUpdateOne = jest .fn() .mockResolvedValueOnce({ modifiedCount: 0 }) .mockResolvedValueOnce({ upsertedCount: 1 }); const order = { orderId: "order-recovery", corpId: "corp-1", orderSource: "ALIPAY_MINI", orderStatus: "pending", createTime: 1_990_000, expireTime: 2_060_000, }; const db = createCollectionDb({ "consult-order": { find: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([order]), }), findOne: jest.fn().mockResolvedValue({ _id: new ObjectId() }), }, "hlw-config": { find: jest.fn().mockReturnValue({ toArray: jest .fn() .mockResolvedValue([{ corpId: "corp-1", autoAcceptOrder: true }]), }), findOne: jest .fn() .mockResolvedValue({ corpId: "corp-1", autoAcceptOrder: true }), }, "diagnostic-record": { find: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([]), }), }, [COLLECTION]: { updateOne: taskUpdateOne }, }); await reconcileAutomationTasks(db); expect(taskUpdateOne).toHaveBeenLastCalledWith( { taskKey: "AUTO_ACCEPT:order-recovery" }, expect.objectContaining({ $setOnInsert: expect.objectContaining({ orderId: "order-recovery", status: TASK_STATUS.PENDING, }), }), { upsert: true } ); }); 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: "AUTORXFAIL", Desc: "notification", Ext: JSON.stringify({ errorSource: ERROR_SOURCES.SYSTEM, reason: "系统错误:开方医生不存在", }), }), }), ], }), 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({ "hlw-config": { findOne: jest.fn().mockResolvedValue({ autoPassRx: true }), }, "diagnostic-record": { findOne: jest.fn().mockResolvedValue({ _id: rxId, orderId: "order-1", status: "INIT", pharmacistNo: "P001", }), }, "hlw-doctor": { findOne: jest.fn().mockResolvedValue({ doctorNo: "P001", onlineStatus: "offline", }), }, "consult-order": { findOne: jest.fn().mockResolvedValue({ doctorCode: "D001" }), }, [COLLECTION]: { updateOne: taskUpdateOne }, }); const task = { _id: new ObjectId(), type: AUTOMATION_TYPES.PASS_RX, corpId: "corp-1", orderId: "order-1", rxId: rxId.toString(), 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: "AUTORXFAIL", Desc: "notification", Ext: JSON.stringify({ errorSource: ERROR_SOURCES.SYSTEM, reason: "系统错误:审方药师暂不在线", }), }), }), ], }), db ); expect(taskUpdateOne).not.toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ $set: expect.objectContaining({ status: TASK_STATUS.PENDING }), }) ); }); test("labels HIS prescription upload failures in the notification", async () => { tencentIM.mockResolvedValue({ success: true }); diagnosticRecord.mockResolvedValue({ success: false, failList: [{ message: "[his]处方上传失败: HIS拒绝接收处方" }], }); const rxId = new ObjectId(); const taskUpdateOne = jest.fn().mockResolvedValue({ modifiedCount: 1 }); const db = createCollectionDb({ "hlw-config": { findOne: jest.fn().mockResolvedValue({ autoPassRx: true }), }, "diagnostic-record": { findOne: jest.fn().mockResolvedValue({ _id: rxId, orderId: "order-his-failure", status: "INIT", pharmacistNo: "P001", }), }, "hlw-doctor": { findOne: jest.fn().mockResolvedValue({ doctorNo: "P001", onlineStatus: "online", }), }, "consult-order": { findOne: jest.fn().mockResolvedValue({ doctorCode: "D001" }), }, [COLLECTION]: { updateOne: taskUpdateOne }, }); const task = { _id: new ObjectId(), type: AUTOMATION_TYPES.PASS_RX, corpId: "corp-1", orderId: "order-his-failure", rxId: rxId.toString(), attempts: 1, expiresAt: Date.now() + 60_000, }; await processClaimedTask(db, task); expect(tencentIM).toHaveBeenCalledWith( expect.objectContaining({ msgBody: [ expect.objectContaining({ MsgContent: expect.objectContaining({ Data: "AUTORXFAIL", Ext: JSON.stringify({ errorSource: ERROR_SOURCES.HIS, reason: "HIS上传错误:HIS拒绝接收处方", }), }), }), ], }), db ); expect(taskUpdateOne).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ $set: expect.objectContaining({ status: TASK_STATUS.FAILED, lastError: "[his]处方上传失败: HIS拒绝接收处方", }), }) ); }); test("keeps ordinary automatic audit failures classified as system errors", async () => { tencentIM.mockResolvedValue({ success: true }); diagnosticRecord.mockResolvedValue({ success: false, failList: [{ message: "处方数据更新失败" }], }); const rxId = new ObjectId(); const db = createCollectionDb({ "hlw-config": { findOne: jest.fn().mockResolvedValue({ autoPassRx: true }), }, "diagnostic-record": { findOne: jest.fn().mockResolvedValue({ _id: rxId, orderId: "order-system-failure", status: "INIT", pharmacistNo: "P001", }), }, "hlw-doctor": { findOne: jest.fn().mockResolvedValue({ doctorNo: "P001", onlineStatus: "online", }), }, "consult-order": { findOne: jest.fn().mockResolvedValue({ doctorCode: "D001" }), }, [COLLECTION]: { updateOne: jest.fn().mockResolvedValue({ modifiedCount: 1 }), }, }); const task = { _id: new ObjectId(), type: AUTOMATION_TYPES.PASS_RX, corpId: "corp-1", orderId: "order-system-failure", rxId: rxId.toString(), attempts: 1, expiresAt: Date.now() + 60_000, }; await processClaimedTask(db, task); expect(tencentIM).toHaveBeenCalledWith( expect.objectContaining({ msgBody: [ expect.objectContaining({ MsgContent: expect.objectContaining({ Ext: JSON.stringify({ errorSource: ERROR_SOURCES.SYSTEM, reason: "系统错误:处方数据更新失败", }), }), }), ], }), db ); }); test("uses the system unknown-error fallback when no error message exists", () => { expect(getFailureNotificationExt()).toEqual({ errorSource: ERROR_SOURCES.SYSTEM, reason: "系统错误:未知错误", }); expect( getFailureNotificationExt( new AutomationError("", { source: ERROR_SOURCES.SYSTEM }) ) ).toEqual({ errorSource: ERROR_SOURCES.SYSTEM, reason: "系统错误:未知错误", }); }); }); describe("automatic prescription assembly", () => { test("reloads master data and produces the existing prescription contract", async () => { const drugId = new ObjectId(); const findConfig = jest.fn((query) => { if (query.group) { return { toArray: jest.fn().mockResolvedValue([ { key: "store-medicine-dosage-unit", list: [{ code: "MG", name: "mg" }], }, { key: "store-medicine-frequence", list: [{ code: "BID", name: "每日两次" }], }, { key: "store-medicine-administration", list: [{ code: "PO", name: "口服" }], }, { key: "medicine-package-unit", list: [{ code: "盒", name: "盒" }], }, ]), }; } throw new Error("unexpected config find"); }); const db = createCollectionDb({ "hlw-doctor": { findOne: jest.fn().mockResolvedValue({ doctorNo: "D001", signatureUrl: "doctor-sign", onlineStatus: "online", }), }, "hlw-diagnosis": { find: jest.fn().mockReturnValue({ toArray: jest .fn() .mockResolvedValue([{ code: "J00", name: "普通感冒" }]), }), }, "drug-info": { find: jest.fn().mockReturnValue({ toArray: jest.fn().mockResolvedValue([ { _id: drugId, onSale: true, name: "测试药品", erpId: "ERP-1", insurance_code: "MED-1", product_id: "P-1", dosage_form: "片剂", days: 3, dosage_unit: "mg", administration_method: "口服", freq: "每日两次", unit: "盒", specification: "10mg*10片", package_amount: 10, recommended_quantity: 1, }, ]), }), }, "hlw-config": { find: findConfig, findOne: jest .fn() .mockResolvedValue({ medicinePurchaseRxDuration: 30 }), }, }); const order = { corpId: "corp-1", orderId: "order-1", orderSource: "ALIPAY_MINI", consultType: "storeMedicinePurchase", doctorCode: "D001", doctorName: "医生甲", patientId: "P001", name: "患者甲", diseases: ["普通感冒"], description: "鼻塞流涕", pastHistoryStr: "两天前起病", drugs: [ { _id: drugId.toString(), dosage: 10, dosage_unit: "mg", quantity: 1, usageName: "口服", frequencyName: "每日两次", unit: "盒", }, ], expireTime: Date.now() + 60_000, }; const params = await buildAutoPrescriptionParams(db, order); expect(params).toMatchObject({ complaint: "普通感冒 鼻塞流涕", presentIllness: "两天前起病", doctorCAUserId: "doctor-sign", prescriptionType: "storeMedicinePurchase", diagnosisList: [{ code: "J00", name: "普通感冒", desc: "" }], drugs: [ expect.objectContaining({ _id: drugId.toString(), erpId: "ERP-1", dosage_unit_code: "MG", frequencyCode: "BID", usageCode: "PO", quantity: 1, days: 3, }), ], }); }); });