fix: 续期开发
This commit is contained in:
parent
e3e7951310
commit
cf095dba0c
136
hlw/automation/config.js
Normal file
136
hlw/automation/config.js
Normal file
@ -0,0 +1,136 @@
|
||||
const AUTOMATION_TYPES = {
|
||||
ACCEPT: "AUTO_ACCEPT",
|
||||
OPEN_RX: "AUTO_OPEN_RX",
|
||||
PASS_RX: "AUTO_PASS_RX",
|
||||
};
|
||||
|
||||
const DEFAULT_AUTOMATION_CONFIG = {
|
||||
autoAcceptOrder: false,
|
||||
autoOpenRx: false,
|
||||
autoPassRx: false,
|
||||
autoAcceptDelayMinSeconds: 2,
|
||||
autoAcceptDelayMaxSeconds: 5,
|
||||
autoOpenRxDelayMinSeconds: 20,
|
||||
autoOpenRxDelayMaxSeconds: 40,
|
||||
autoPassRxDelayMinSeconds: 10,
|
||||
autoPassRxDelayMaxSeconds: 30,
|
||||
};
|
||||
|
||||
const TYPE_CONFIG = {
|
||||
[AUTOMATION_TYPES.ACCEPT]: {
|
||||
enabledKey: "autoAcceptOrder",
|
||||
minKey: "autoAcceptDelayMinSeconds",
|
||||
maxKey: "autoAcceptDelayMaxSeconds",
|
||||
},
|
||||
[AUTOMATION_TYPES.OPEN_RX]: {
|
||||
enabledKey: "autoOpenRx",
|
||||
minKey: "autoOpenRxDelayMinSeconds",
|
||||
maxKey: "autoOpenRxDelayMaxSeconds",
|
||||
},
|
||||
[AUTOMATION_TYPES.PASS_RX]: {
|
||||
enabledKey: "autoPassRx",
|
||||
minKey: "autoPassRxDelayMinSeconds",
|
||||
maxKey: "autoPassRxDelayMaxSeconds",
|
||||
},
|
||||
};
|
||||
|
||||
const AUTOMATION_DELAY_KEYS = [
|
||||
"autoAcceptDelayMinSeconds",
|
||||
"autoAcceptDelayMaxSeconds",
|
||||
"autoOpenRxDelayMinSeconds",
|
||||
"autoOpenRxDelayMaxSeconds",
|
||||
"autoPassRxDelayMinSeconds",
|
||||
"autoPassRxDelayMaxSeconds",
|
||||
];
|
||||
|
||||
const AUTOMATION_DELAY_PAIRS = [
|
||||
["autoAcceptDelayMinSeconds", "autoAcceptDelayMaxSeconds"],
|
||||
["autoOpenRxDelayMinSeconds", "autoOpenRxDelayMaxSeconds"],
|
||||
["autoPassRxDelayMinSeconds", "autoPassRxDelayMaxSeconds"],
|
||||
];
|
||||
|
||||
function normalizeNonNegativeInteger(value, fallback) {
|
||||
return Number.isInteger(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeAutomationConfig(config = {}) {
|
||||
const result = {
|
||||
...DEFAULT_AUTOMATION_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
Object.values(TYPE_CONFIG).forEach(({ minKey, maxKey }) => {
|
||||
const defaultMin = DEFAULT_AUTOMATION_CONFIG[minKey];
|
||||
const defaultMax = DEFAULT_AUTOMATION_CONFIG[maxKey];
|
||||
result[minKey] = normalizeNonNegativeInteger(config[minKey], defaultMin);
|
||||
result[maxKey] = normalizeNonNegativeInteger(config[maxKey], defaultMax);
|
||||
if (result[minKey] > result[maxKey]) {
|
||||
result[minKey] = defaultMin;
|
||||
result[maxKey] = defaultMax;
|
||||
}
|
||||
});
|
||||
|
||||
result.autoAcceptOrder = config.autoAcceptOrder === true;
|
||||
result.autoOpenRx = config.autoOpenRx === true;
|
||||
result.autoPassRx = config.autoPassRx === true;
|
||||
return result;
|
||||
}
|
||||
|
||||
function getTypeConfig(config, type) {
|
||||
const normalized = normalizeAutomationConfig(config);
|
||||
const mapping = TYPE_CONFIG[type];
|
||||
if (!mapping) {
|
||||
throw new Error(`未知自动化任务类型: ${type}`);
|
||||
}
|
||||
return {
|
||||
enabled: normalized[mapping.enabledKey] === true,
|
||||
minSeconds: normalized[mapping.minKey],
|
||||
maxSeconds: normalized[mapping.maxKey],
|
||||
};
|
||||
}
|
||||
|
||||
function randomInteger(min, max, random = Math.random) {
|
||||
if (min === max) return min;
|
||||
return Math.floor(random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function validateAutomationDelayConfig(input = {}, current = {}) {
|
||||
const data = {};
|
||||
for (const key of AUTOMATION_DELAY_KEYS) {
|
||||
if (key in input) {
|
||||
if (!Number.isInteger(input[key]) || input[key] < 0 || input[key] > 3600) {
|
||||
return {
|
||||
success: false,
|
||||
message: `${key}必须是0到3600之间的整数`,
|
||||
};
|
||||
}
|
||||
data[key] = input[key];
|
||||
}
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...normalizeAutomationConfig(current),
|
||||
...data,
|
||||
};
|
||||
for (const [minKey, maxKey] of AUTOMATION_DELAY_PAIRS) {
|
||||
if (merged[minKey] > merged[maxKey]) {
|
||||
return {
|
||||
success: false,
|
||||
message: `${minKey}不能大于${maxKey}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUTOMATION_TYPES,
|
||||
DEFAULT_AUTOMATION_CONFIG,
|
||||
TYPE_CONFIG,
|
||||
AUTOMATION_DELAY_KEYS,
|
||||
AUTOMATION_DELAY_PAIRS,
|
||||
normalizeAutomationConfig,
|
||||
getTypeConfig,
|
||||
randomInteger,
|
||||
validateAutomationDelayConfig,
|
||||
};
|
||||
75
hlw/automation/config.test.js
Normal file
75
hlw/automation/config.test.js
Normal file
@ -0,0 +1,75 @@
|
||||
const {
|
||||
AUTOMATION_TYPES,
|
||||
getTypeConfig,
|
||||
normalizeAutomationConfig,
|
||||
randomInteger,
|
||||
validateAutomationDelayConfig,
|
||||
} = require("./config");
|
||||
|
||||
describe("automation config", () => {
|
||||
test("provides backward-compatible defaults", () => {
|
||||
expect(normalizeAutomationConfig({})).toMatchObject({
|
||||
autoAcceptOrder: false,
|
||||
autoOpenRx: false,
|
||||
autoPassRx: false,
|
||||
autoAcceptDelayMinSeconds: 2,
|
||||
autoAcceptDelayMaxSeconds: 5,
|
||||
autoOpenRxDelayMinSeconds: 20,
|
||||
autoOpenRxDelayMaxSeconds: 40,
|
||||
autoPassRxDelayMinSeconds: 10,
|
||||
autoPassRxDelayMaxSeconds: 30,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes an invalid stored interval back to defaults", () => {
|
||||
const config = normalizeAutomationConfig({
|
||||
autoOpenRx: true,
|
||||
autoOpenRxDelayMinSeconds: 50,
|
||||
autoOpenRxDelayMaxSeconds: 10,
|
||||
});
|
||||
|
||||
expect(getTypeConfig(config, AUTOMATION_TYPES.OPEN_RX)).toEqual({
|
||||
enabled: true,
|
||||
minSeconds: 20,
|
||||
maxSeconds: 40,
|
||||
});
|
||||
});
|
||||
|
||||
test("validates partial updates against the stored counterpart", () => {
|
||||
expect(
|
||||
validateAutomationDelayConfig(
|
||||
{ autoPassRxDelayMinSeconds: 31 },
|
||||
{ autoPassRxDelayMaxSeconds: 30 }
|
||||
)
|
||||
).toEqual({
|
||||
success: false,
|
||||
message: "autoPassRxDelayMinSeconds不能大于autoPassRxDelayMaxSeconds",
|
||||
});
|
||||
|
||||
expect(
|
||||
validateAutomationDelayConfig(
|
||||
{ autoPassRxDelayMinSeconds: 12 },
|
||||
{ autoPassRxDelayMaxSeconds: 30 }
|
||||
)
|
||||
).toEqual({
|
||||
success: true,
|
||||
data: { autoPassRxDelayMinSeconds: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects non-integer and out-of-range delays", () => {
|
||||
expect(
|
||||
validateAutomationDelayConfig({ autoAcceptDelayMinSeconds: 1.5 })
|
||||
.success
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateAutomationDelayConfig({ autoAcceptDelayMinSeconds: 3601 })
|
||||
.success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("selects both ends of a configured random interval", () => {
|
||||
expect(randomInteger(2, 5, () => 0)).toBe(2);
|
||||
expect(randomInteger(2, 5, () => 0.999999)).toBe(5);
|
||||
});
|
||||
});
|
||||
34
hlw/automation/document.ts
Normal file
34
hlw/automation/document.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
export type AutomationTaskType =
|
||||
| "AUTO_ACCEPT"
|
||||
| "AUTO_OPEN_RX"
|
||||
| "AUTO_PASS_RX";
|
||||
|
||||
export type AutomationTaskStatus =
|
||||
| "PENDING"
|
||||
| "RUNNING"
|
||||
| "SUCCEEDED"
|
||||
| "CANCELLED"
|
||||
| "FAILED";
|
||||
|
||||
export interface AutomationTask {
|
||||
_id: ObjectId;
|
||||
taskKey: string;
|
||||
type: AutomationTaskType;
|
||||
corpId: string;
|
||||
orderId: string;
|
||||
rxId?: string;
|
||||
status: AutomationTaskStatus;
|
||||
dueAt: number;
|
||||
nextRunAt: number;
|
||||
expiresAt: number;
|
||||
attempts: number;
|
||||
leaseUntil: number;
|
||||
leaseOwner: string;
|
||||
lastError: string;
|
||||
result?: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
958
hlw/automation/index.js
Normal file
958
hlw/automation/index.js
Normal file
@ -0,0 +1,958 @@
|
||||
const { ObjectId } = require("mongodb");
|
||||
const { randomUUID } = require("crypto");
|
||||
const {
|
||||
AUTOMATION_TYPES,
|
||||
getTypeConfig,
|
||||
normalizeAutomationConfig,
|
||||
randomInteger,
|
||||
} = require("./config");
|
||||
const { decryptOrderFields } = require("../consult-order/format");
|
||||
|
||||
const COLLECTION = "hlw-automation-task";
|
||||
const TASK_STATUS = {
|
||||
PENDING: "PENDING",
|
||||
RUNNING: "RUNNING",
|
||||
SUCCEEDED: "SUCCEEDED",
|
||||
CANCELLED: "CANCELLED",
|
||||
FAILED: "FAILED",
|
||||
};
|
||||
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 {
|
||||
constructor(message, { retryable = false } = {}) {
|
||||
super(message);
|
||||
this.name = "AutomationError";
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new AutomationError(result?.message || "自动开方失败", { retryable: true });
|
||||
}
|
||||
|
||||
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) {
|
||||
const detail =
|
||||
Array.isArray(result?.failList) && result.failList[0]
|
||||
? result.failList[0].message
|
||||
: result?.message;
|
||||
throw new AutomationError(detail || "自动审方失败", { retryable: true });
|
||||
}
|
||||
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,
|
||||
});
|
||||
if (automationError.retryable) {
|
||||
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) {
|
||||
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,
|
||||
AutomationError,
|
||||
calculateDueAt,
|
||||
enqueueTask,
|
||||
scheduleAutoAccept,
|
||||
scheduleAutoOpenRx,
|
||||
scheduleAutoPassRx,
|
||||
buildAutoPrescriptionParams,
|
||||
claimNextTask,
|
||||
processClaimedTask,
|
||||
reconcileAutomationTasks,
|
||||
runDueTasks,
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
451
hlw/automation/index.test.js
Normal file
451
hlw/automation/index.test.js
Normal file
@ -0,0 +1,451 @@
|
||||
const { ObjectId } = require("mongodb");
|
||||
const {
|
||||
AUTOMATION_TYPES,
|
||||
COLLECTION,
|
||||
TASK_STATUS,
|
||||
buildAutoPrescriptionParams,
|
||||
calculateDueAt,
|
||||
claimNextTask,
|
||||
enqueueTask,
|
||||
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();
|
||||
});
|
||||
|
||||
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("retries automatic audit while the assigned pharmacist is offline", async () => {
|
||||
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",
|
||||
}),
|
||||
},
|
||||
[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.PENDING,
|
||||
nextRunAt: expect.any(Number),
|
||||
lastError: "审方药师暂不在线",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -84,6 +84,7 @@ interface Order {
|
||||
chargeFee: number;// 费用金额
|
||||
updateTime: number;// 更新时间
|
||||
expireTime: number;// 过期时间
|
||||
acceptSource?: 'AUTO' | 'MANUAL';// 接诊操作来源
|
||||
signature: string;// 签名图片路径 (ipad发起的咨询需要用户手动签名)
|
||||
wines?: Wine[];// 浸酒订单
|
||||
}
|
||||
|
||||
@ -97,6 +97,42 @@ module.exports = async (item, mongodb) => {
|
||||
};
|
||||
|
||||
let scheduleTaskIsRunning = false;
|
||||
|
||||
async function scheduleAutoAcceptAfterCreate({ orderId, corpId, createTime, expireTime }) {
|
||||
try {
|
||||
const automation = require("../automation");
|
||||
await automation.scheduleAutoAccept({
|
||||
db,
|
||||
orderId,
|
||||
corpId,
|
||||
baseTime: createTime,
|
||||
expiresAt: expireTime,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[自动化任务] 创建自动接诊任务失败:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleAutoOpenAfterAccept({
|
||||
orderId,
|
||||
corpId,
|
||||
prescriptionStartTime,
|
||||
expireTime,
|
||||
}) {
|
||||
try {
|
||||
const automation = require("../automation");
|
||||
await automation.scheduleAutoOpenRx({
|
||||
db,
|
||||
orderId,
|
||||
corpId,
|
||||
baseTime: prescriptionStartTime || Date.now(),
|
||||
expiresAt: expireTime,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[自动化任务] 创建自动开方任务失败:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 咨询订单库 数据库是 consult-order
|
||||
async function addConsultOrder(item) {
|
||||
let { params, corpId } = item;
|
||||
@ -215,14 +251,16 @@ async function addOnlineConsultOrder(params) {
|
||||
.collection("consult-order")
|
||||
.insertOne(encryptedParams);
|
||||
if (insertedId) {
|
||||
let startResult = null;
|
||||
if (params.payStatus === 'success') {
|
||||
await startConsultOrder(params);
|
||||
}
|
||||
const res1 = await getConfig(params.corpId);
|
||||
const { autoAcceptOrder } = res1 || {};
|
||||
if (autoAcceptOrder) {
|
||||
await acceptConsultOrder(params);
|
||||
startResult = await startConsultOrder(params);
|
||||
}
|
||||
await scheduleAutoAcceptAfterCreate({
|
||||
orderId: params.orderId,
|
||||
corpId: params.corpId,
|
||||
createTime: params.createTime,
|
||||
expireTime: startResult && startResult.expireTime,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "新增成功",
|
||||
@ -641,9 +679,10 @@ async function getDrugStoreOrderList(ctx) {
|
||||
|
||||
async function acceptConsultOrder(item) {
|
||||
const { orderId, corpId, doctorCode } = item;
|
||||
const operationSource = item.operationSource === "AUTO" ? "AUTO" : "MANUAL";
|
||||
try {
|
||||
const item = await db.collection("consult-order").findOne(
|
||||
{ orderId },
|
||||
{ orderId, corpId },
|
||||
{
|
||||
projection: {
|
||||
expireTime: 1,
|
||||
@ -651,6 +690,7 @@ async function acceptConsultOrder(item) {
|
||||
doctorCode: 1,
|
||||
orderStatus: 1,
|
||||
doctorName: 1,
|
||||
prescriptionStartTime: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -664,6 +704,12 @@ async function acceptConsultOrder(item) {
|
||||
return { success: false, message: "订单已过期,请刷新" };
|
||||
}
|
||||
if (item.orderStatus === 'processing') {
|
||||
await scheduleAutoOpenAfterAccept({
|
||||
orderId,
|
||||
corpId,
|
||||
prescriptionStartTime: item.prescriptionStartTime,
|
||||
expireTime: item.expireTime,
|
||||
});
|
||||
return { success: true, message: "订单已处理" };
|
||||
}
|
||||
if (item.orderStatus !== "pending") {
|
||||
@ -671,13 +717,41 @@ async function acceptConsultOrder(item) {
|
||||
}
|
||||
const { consultationDuration } = await getConfig(corpId);
|
||||
const expireTime = dayjs().add(consultationDuration, "minute").valueOf();
|
||||
await updateConsultOrderStatus({
|
||||
orderId,
|
||||
orderStatus: "processing",
|
||||
expireTime,
|
||||
doctorCode: item.doctorCode,
|
||||
corpId,
|
||||
});
|
||||
const serverPrescriptionStartTime = Date.now();
|
||||
const acceptResult = await db.collection("consult-order").updateOne(
|
||||
{ orderId, corpId, orderStatus: "pending" },
|
||||
{
|
||||
$set: {
|
||||
orderStatus: "processing",
|
||||
expireTime,
|
||||
prescriptionStartTime: serverPrescriptionStartTime,
|
||||
acceptSource: operationSource,
|
||||
updateTime: serverPrescriptionStartTime,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (acceptResult.modifiedCount !== 1) {
|
||||
const current = await db.collection("consult-order").findOne(
|
||||
{ orderId, corpId },
|
||||
{
|
||||
projection: {
|
||||
orderStatus: 1,
|
||||
prescriptionStartTime: 1,
|
||||
expireTime: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (current && current.orderStatus === "processing") {
|
||||
await scheduleAutoOpenAfterAccept({
|
||||
orderId,
|
||||
corpId,
|
||||
prescriptionStartTime: current.prescriptionStartTime,
|
||||
expireTime: current.expireTime,
|
||||
});
|
||||
return { success: true, message: "订单已处理" };
|
||||
}
|
||||
return { success: false, message: "订单已失效,请刷新" };
|
||||
}
|
||||
timeApi(
|
||||
{
|
||||
type: "addDoctorOrderDuration",
|
||||
@ -688,26 +762,6 @@ async function acceptConsultOrder(item) {
|
||||
},
|
||||
db
|
||||
);
|
||||
// 后端自动生成并保存处方开始时间(毫秒),由服务器记录接单时间点
|
||||
let serverPrescriptionStartTime = null;
|
||||
try {
|
||||
serverPrescriptionStartTime = Date.now();
|
||||
await db.collection("consult-order").updateOne(
|
||||
{ orderId },
|
||||
{
|
||||
$set: {
|
||||
prescriptionStartTime: serverPrescriptionStartTime,
|
||||
updateTime: Date.now(),
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"保存 prescriptionStartTime 到 consult-order 失败",
|
||||
e && e.message
|
||||
);
|
||||
// 保持 serverPrescriptionStartTime 的值(可能为 null 如果 Date.now() 未赋值),以便返回给调用者以作判断
|
||||
}
|
||||
|
||||
// 接受问诊 发送系统消息
|
||||
await tencentIM({
|
||||
@ -740,6 +794,12 @@ async function acceptConsultOrder(item) {
|
||||
endTime: expireTime,
|
||||
corpId,
|
||||
});
|
||||
await scheduleAutoOpenAfterAccept({
|
||||
orderId,
|
||||
corpId,
|
||||
prescriptionStartTime: serverPrescriptionStartTime,
|
||||
expireTime,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "接诊成功",
|
||||
|
||||
@ -107,6 +107,8 @@ interface DiagnosticRecord {
|
||||
prescriptionStartTime: number; // 处方开始时间
|
||||
prescriptionEndTime: number; // 处方结束时间
|
||||
auditTime?: number; // 审核时间
|
||||
prescriptionSource?: 'AUTO' | 'MANUAL'; // 开方操作来源
|
||||
auditSource?: 'AUTO' | 'MANUAL'; // 审方操作来源
|
||||
reasons?: string[]; // 拒绝原因列表
|
||||
expireTime?: number; // 过期时间
|
||||
|
||||
|
||||
@ -145,8 +145,32 @@ module.exports = async (item, mongodb, user) => {
|
||||
|
||||
let scheduleTaskIsRunning = false;
|
||||
|
||||
async function scheduleAutoPassAfterPrescription({
|
||||
rxId,
|
||||
orderId,
|
||||
corpId,
|
||||
createTime,
|
||||
expireTime,
|
||||
}) {
|
||||
try {
|
||||
const automation = require("../automation");
|
||||
await automation.scheduleAutoPassRx({
|
||||
db,
|
||||
rxId,
|
||||
orderId,
|
||||
corpId,
|
||||
baseTime: createTime || Date.now(),
|
||||
expiresAt: expireTime,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[自动化任务] 创建自动审方任务失败:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function addConsultDiagnosis(item) {
|
||||
let { params = {} } = item;
|
||||
const automated = item.automated === true;
|
||||
const operationSource = item.operationSource === "AUTO" ? "AUTO" : "MANUAL";
|
||||
const corpId = typeof item.corpId === "string" ? item.corpId.trim() : '';
|
||||
const { orderId, doctorCode } = params;
|
||||
if (
|
||||
@ -170,6 +194,7 @@ async function addConsultDiagnosis(item) {
|
||||
medorg_order_no: 1,
|
||||
accountId: 1,
|
||||
prescriptionStartTime: 1,
|
||||
expireTime: 1,
|
||||
areaCode: 1,
|
||||
pickUpType: 1,
|
||||
medInfo: 1,
|
||||
@ -186,7 +211,11 @@ async function addConsultDiagnosis(item) {
|
||||
if (order.orderStatus !== "processing") {
|
||||
return { success: false, message: "当前订单状态不支持开方" };
|
||||
}
|
||||
if (!(order.createTime > dayjs().startOf("day").valueOf())) {
|
||||
const orderExpired =
|
||||
Number.isFinite(order.expireTime)
|
||||
? order.expireTime <= Date.now()
|
||||
: !(order.createTime > dayjs().startOf("day").valueOf());
|
||||
if (orderExpired) {
|
||||
return { success: false, message: "当前订单不在有效期内" };
|
||||
}
|
||||
timeApi({ type: "addDoctorRxDuration", orderId }, db);
|
||||
@ -195,12 +224,14 @@ async function addConsultDiagnosis(item) {
|
||||
return { success: false, message: "医生不存在" };
|
||||
}
|
||||
// 判断是否医生发的消息是否大于2条
|
||||
const chatCount = await db.collection("im-chat-msg").countDocuments({
|
||||
From_Account: doctorCode,
|
||||
To_Account: orderId,
|
||||
});
|
||||
const chatCount = automated
|
||||
? 3
|
||||
: await db.collection("im-chat-msg").countDocuments({
|
||||
From_Account: doctorCode,
|
||||
To_Account: orderId,
|
||||
});
|
||||
timeData.chatCountQueryTime = dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
if (chatCount < 3) {
|
||||
if (!automated && chatCount < 3) {
|
||||
return {
|
||||
success: false,
|
||||
message: "请先向患者发送2条提问消息后再开方",
|
||||
@ -210,9 +241,31 @@ async function addConsultDiagnosis(item) {
|
||||
.collection("diagnostic-record")
|
||||
.findOne(
|
||||
{ orderId, doctorCode },
|
||||
{ projection: { _id: 1, status: 1, pharmacistNo: 1 } }
|
||||
{
|
||||
projection: {
|
||||
_id: 1,
|
||||
status: 1,
|
||||
pharmacistNo: 1,
|
||||
createTime: 1,
|
||||
expireTime: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
timeData.recordQueryTime = dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
if (automated && record && [status.init, status.pass].includes(record.status)) {
|
||||
await scheduleAutoPassAfterPrescription({
|
||||
rxId: record._id,
|
||||
orderId,
|
||||
corpId,
|
||||
createTime: record.createTime,
|
||||
expireTime: record.expireTime || order.expireTime,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: "处方已存在",
|
||||
data: record._id,
|
||||
};
|
||||
}
|
||||
if (record && record.status === status.init) {
|
||||
return { success: false, message: "该订单已存在诊断信息,请勿重复添加" };
|
||||
}
|
||||
@ -222,14 +275,25 @@ async function addConsultDiagnosis(item) {
|
||||
}
|
||||
|
||||
if (record && record.status === status.unpass) {
|
||||
return await rewriteConsultDiagnosis(
|
||||
const rewriteResult = await rewriteConsultDiagnosis(
|
||||
params,
|
||||
record._id,
|
||||
corpId,
|
||||
record.pharmacistNo,
|
||||
timeData,
|
||||
order
|
||||
order,
|
||||
operationSource
|
||||
);
|
||||
if (rewriteResult && rewriteResult.success) {
|
||||
await scheduleAutoPassAfterPrescription({
|
||||
rxId: record._id,
|
||||
orderId,
|
||||
corpId,
|
||||
createTime: Date.now(),
|
||||
expireTime: order.expireTime,
|
||||
});
|
||||
}
|
||||
return rewriteResult;
|
||||
}
|
||||
// 获取推荐药师 随机获取药师
|
||||
const {
|
||||
@ -259,6 +323,7 @@ async function addConsultDiagnosis(item) {
|
||||
dise_name: order.medInfo.dise_name,
|
||||
feeType: order.feeType,
|
||||
corpId,
|
||||
prescriptionSource: operationSource,
|
||||
};
|
||||
// 由后端统一设置处方时间:
|
||||
// - 开始时间从 consult-order 读取(接单时已由后端写入)
|
||||
@ -314,6 +379,13 @@ async function addConsultDiagnosis(item) {
|
||||
orderStatus: "completed",
|
||||
msgType: "MEDICALADVICE",
|
||||
}, timeData);
|
||||
await scheduleAutoPassAfterPrescription({
|
||||
rxId: insertedId,
|
||||
orderId,
|
||||
corpId,
|
||||
createTime: data.createTime,
|
||||
expireTime: data.expireTime || order.expireTime,
|
||||
});
|
||||
timeData.duration = dayjs().diff(timeData.startTime, 's');
|
||||
if (timeData.duration > 3) {
|
||||
db.collection("perf-observation").insertOne(timeData);
|
||||
@ -331,7 +403,15 @@ async function addConsultDiagnosis(item) {
|
||||
}
|
||||
}
|
||||
// 未通过的处方 重新提交 原药师在线的话
|
||||
async function rewriteConsultDiagnosis(item, _id, corpId, pharmacistNo, timeData, order) {
|
||||
async function rewriteConsultDiagnosis(
|
||||
item,
|
||||
_id,
|
||||
corpId,
|
||||
pharmacistNo,
|
||||
timeData,
|
||||
order,
|
||||
operationSource = "MANUAL"
|
||||
) {
|
||||
const {
|
||||
complaint,
|
||||
presentIllness,
|
||||
@ -352,6 +432,7 @@ async function rewriteConsultDiagnosis(item, _id, corpId, pharmacistNo, timeData
|
||||
wines: item.wines,
|
||||
updateTime: Date.now(),
|
||||
status: status.init,
|
||||
prescriptionSource: operationSource,
|
||||
};
|
||||
const doctorType = order.consultType === 'wineConsult' ? 'chinese' : 'west'
|
||||
const {
|
||||
@ -918,6 +999,7 @@ async function auditDiagnosis(item, db) {
|
||||
}
|
||||
const pharmacist = yaoshi.doctorName;
|
||||
const pharmacistCAUserId = yaoshi.signatureUrl;
|
||||
const auditSource = item.operationSource === "AUTO" ? "AUTO" : "MANUAL";
|
||||
const reasons = Array.isArray(item.reasons)
|
||||
? item.reasons.filter((i) => typeof i === "string" && i.trim() !== "")
|
||||
: [];
|
||||
@ -979,13 +1061,17 @@ async function auditDiagnosis(item, db) {
|
||||
})
|
||||
|
||||
const { modifiedCount } = await db.collection("diagnostic-record").updateMany(
|
||||
{ _id: { $in: successRxList.map(i => i.rx._id) } },
|
||||
{
|
||||
_id: { $in: successRxList.map(i => i.rx._id) },
|
||||
status: status.init,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
uploadStatus: "uploaded",
|
||||
status: status.pass,
|
||||
auditTime: Date.now(),
|
||||
pharmacistSignImg: pharmacistCAUserId
|
||||
pharmacistSignImg: pharmacistCAUserId,
|
||||
auditSource
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -1003,7 +1089,8 @@ async function auditDiagnosis(item, db) {
|
||||
$set: {
|
||||
status: item.status,
|
||||
auditTime: Date.now(),
|
||||
reasons
|
||||
reasons,
|
||||
auditSource
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -1029,7 +1116,14 @@ async function auditDiagnosis(item, db) {
|
||||
if (expireList.length) {
|
||||
const { modifiedCount } = await db.collection("diagnostic-record").updateMany(
|
||||
{ _id: { $in: expireList.map(i => i.rx._id) }, status: status.init },
|
||||
{ $set: { status: status.expired }, updateTime: Date.now() }
|
||||
{
|
||||
$set: {
|
||||
status: status.expired,
|
||||
auditTime: Date.now(),
|
||||
auditSource,
|
||||
updateTime: Date.now(),
|
||||
},
|
||||
}
|
||||
)
|
||||
statsCount.expireCount += modifiedCount;
|
||||
}
|
||||
|
||||
@ -14,7 +14,8 @@ exports.output = {
|
||||
"diagnosisList.name": 1,
|
||||
"drugs.drugName": 1,
|
||||
expireTime: 1,
|
||||
status: 1
|
||||
status: 1,
|
||||
feeType: 1
|
||||
},
|
||||
medicinePurchaseOrder: {
|
||||
_id: 1,
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
const validator = require('../../utils/validator.js')
|
||||
const {
|
||||
AUTOMATION_DELAY_KEYS,
|
||||
DEFAULT_AUTOMATION_CONFIG,
|
||||
validateAutomationDelayConfig,
|
||||
} = require("../automation/config");
|
||||
|
||||
let db = "";
|
||||
module.exports = async (item, mongodb) => {
|
||||
@ -34,13 +39,26 @@ async function getHlwOrderConfig({ corpId }) {
|
||||
fengNiaoExpressDiscountFee: 1,
|
||||
fengNiaoExpressFee: 1,
|
||||
firstSubmitRxIntervel: 1,
|
||||
submitRxInterval: 1
|
||||
submitRxInterval: 1,
|
||||
autoAcceptOrder: 1,
|
||||
autoOpenRx: 1,
|
||||
autoPassRx: 1,
|
||||
autoAcceptDelayMinSeconds: 1,
|
||||
autoAcceptDelayMaxSeconds: 1,
|
||||
autoOpenRxDelayMinSeconds: 1,
|
||||
autoOpenRxDelayMaxSeconds: 1,
|
||||
autoPassRxDelayMinSeconds: 1,
|
||||
autoPassRxDelayMaxSeconds: 1
|
||||
}
|
||||
});
|
||||
if (!res) {
|
||||
return { success: false, message: "未找到配置" };
|
||||
}
|
||||
return { success: true, data: res, message: "获取配置成功" };
|
||||
return {
|
||||
success: true,
|
||||
data: { ...DEFAULT_AUTOMATION_CONFIG, ...res },
|
||||
message: "获取配置成功",
|
||||
};
|
||||
} catch (e) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
@ -48,7 +66,20 @@ async function getHlwOrderConfig({ corpId }) {
|
||||
|
||||
async function updateHlwOrderConfig(ctx) {
|
||||
try {
|
||||
const { payDuration, acceptDuration, consultationDuration, medicinePurchaseRxDuration, emsExpressDiscountFee, emsExpressFee, fengNiaoExpressDiscountFee, fengNiaoExpressFee } = ctx;
|
||||
const {
|
||||
payDuration,
|
||||
acceptDuration,
|
||||
consultationDuration,
|
||||
medicinePurchaseRxDuration,
|
||||
emsExpressDiscountFee,
|
||||
emsExpressFee,
|
||||
fengNiaoExpressDiscountFee,
|
||||
fengNiaoExpressFee,
|
||||
presOpen,
|
||||
autoAcceptOrder,
|
||||
autoOpenRx,
|
||||
autoPassRx,
|
||||
} = ctx;
|
||||
const data = {}
|
||||
if (typeof payDuration == 'number') {
|
||||
const [valid, message] = validator.verifyNumber(payDuration, "支付时长", 0, 5, 60, true, true);
|
||||
@ -81,6 +112,31 @@ async function updateHlwOrderConfig(ctx) {
|
||||
if (typeof presOpen == 'boolean') {
|
||||
data.presOpen = presOpen;
|
||||
}
|
||||
if (typeof autoAcceptOrder === 'boolean') {
|
||||
data.autoAcceptOrder = autoAcceptOrder;
|
||||
}
|
||||
if (typeof autoOpenRx === 'boolean') {
|
||||
data.autoOpenRx = autoOpenRx;
|
||||
}
|
||||
if (typeof autoPassRx === 'boolean') {
|
||||
data.autoPassRx = autoPassRx;
|
||||
}
|
||||
|
||||
if (AUTOMATION_DELAY_KEYS.some((key) => key in ctx)) {
|
||||
const current = await db.collection("hlw-config").findOne(
|
||||
{ corpId: ctx.corpId },
|
||||
{
|
||||
projection: Object.fromEntries(
|
||||
AUTOMATION_DELAY_KEYS.map((key) => [key, 1])
|
||||
),
|
||||
}
|
||||
);
|
||||
const validation = validateAutomationDelayConfig(ctx, current || {});
|
||||
if (!validation.success) {
|
||||
return validation;
|
||||
}
|
||||
Object.assign(data, validation.data);
|
||||
}
|
||||
|
||||
if (typeof emsExpressDiscountFee == 'number' || typeof emsExpressFee == 'number') {
|
||||
const [valid, message] = validator.verifyNumber(emsExpressDiscountFee, "邮政快递优惠费用", 2, 0, 9999, true, true);
|
||||
@ -187,4 +243,4 @@ async function getHlwConfigByCorpIds(ctx) {
|
||||
} catch (e) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -270,6 +270,7 @@ module.exports = async (item, db, context) => {
|
||||
case "deleteHlwPatient":
|
||||
return await hlwPatient.main(item, db);
|
||||
case "saveScannedCard":
|
||||
case "getScannedCard":
|
||||
return await scannedCardArchive(item, db);
|
||||
default:
|
||||
return {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
const { ObjectId } = require("mongodb");
|
||||
const Sm4Util = require("../../utils/sm4-util");
|
||||
|
||||
const CARD_STRING_FIELDS = [
|
||||
@ -74,10 +75,58 @@ async function saveScannedCard(item, db) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getScannedCard(item, db) {
|
||||
const id = normalizeString(item.id, 24);
|
||||
if (!ObjectId.isValid(id)) {
|
||||
return { success: false, message: "二维码无效" };
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await db.collection("scanned-card-archive").findOne({
|
||||
_id: new ObjectId(id),
|
||||
});
|
||||
if (!record) {
|
||||
return { success: false, message: "未找到本次扫码信息" };
|
||||
}
|
||||
|
||||
const storeId = normalizeString(item.storeId, 100);
|
||||
const corpId = normalizeString(item.corpId, 100);
|
||||
if (record.corpId && record.corpId !== corpId) {
|
||||
return { success: false, message: "无权读取本次扫码信息" };
|
||||
}
|
||||
if (
|
||||
Array.isArray(record.storeIds) &&
|
||||
record.storeIds.length > 0 &&
|
||||
!record.storeIds.includes(storeId)
|
||||
) {
|
||||
return { success: false, message: "无权读取本次扫码信息" };
|
||||
}
|
||||
if (!record.encryptedCardData || record.encryption !== "SM4") {
|
||||
return { success: false, message: "扫码信息无效" };
|
||||
}
|
||||
|
||||
const decrypted = Sm4Util.decryptDataForSm3(record.encryptedCardData);
|
||||
const cardData = normalizeCardData(JSON.parse(decrypted));
|
||||
if (!cardData) {
|
||||
return { success: false, message: "扫码信息无效" };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: cardData,
|
||||
message: "获取扫码信息成功",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("获取扫码建档数据失败:", error.message);
|
||||
return { success: false, message: "获取扫码信息失败" };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async (item, db) => {
|
||||
switch (item.type) {
|
||||
case "saveScannedCard":
|
||||
return saveScannedCard(item, db);
|
||||
case "getScannedCard":
|
||||
return getScannedCard(item, db);
|
||||
default:
|
||||
return { success: false, message: "未找到接口" };
|
||||
}
|
||||
@ -85,3 +134,4 @@ module.exports = async (item, db) => {
|
||||
|
||||
module.exports.normalizeCardData = normalizeCardData;
|
||||
module.exports.saveScannedCard = saveScannedCard;
|
||||
module.exports.getScannedCard = getScannedCard;
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
jest.mock("../../utils/sm4-util", () => ({
|
||||
encryptDataForSm3: jest.fn(() => "encrypted-card-data"),
|
||||
decryptDataForSm3: jest.fn(() => JSON.stringify({
|
||||
CanOpen: true,
|
||||
Success: true,
|
||||
IDNum: "330601199001011234",
|
||||
Name: "张三",
|
||||
CardNum: "DB5712345",
|
||||
CardIDCode: "3306012345678901A",
|
||||
})),
|
||||
}));
|
||||
|
||||
const Sm4Util = require("../../utils/sm4-util");
|
||||
@ -71,4 +79,57 @@ describe("scanned-card-archive", () => {
|
||||
expect(result).toEqual({ success: false, message: "卡片数据无效" });
|
||||
expect(db.collection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("decrypts a scanned card that belongs to the current store", async () => {
|
||||
const findOne = jest.fn().mockResolvedValue({
|
||||
encryptedCardData: "encrypted-card-data",
|
||||
encryption: "SM4",
|
||||
corpId: "corp-1",
|
||||
storeIds: ["store-1"],
|
||||
});
|
||||
const db = {
|
||||
collection: jest.fn(() => ({ findOne })),
|
||||
};
|
||||
|
||||
const result = await scannedCardArchive({
|
||||
type: "getScannedCard",
|
||||
id: "507f1f77bcf86cd799439011",
|
||||
corpId: "corp-1",
|
||||
storeId: "store-1",
|
||||
}, db);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(expect.objectContaining({
|
||||
IDNum: "330601199001011234",
|
||||
Name: "张三",
|
||||
CardNum: "DB5712345",
|
||||
CardIDCode: "3306012345678901A",
|
||||
}));
|
||||
expect(Sm4Util.decryptDataForSm3).toHaveBeenCalledWith("encrypted-card-data");
|
||||
});
|
||||
|
||||
test("rejects invalid ids and records from another store", async () => {
|
||||
const db = {
|
||||
collection: jest.fn(() => ({
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
encryptedCardData: "encrypted-card-data",
|
||||
encryption: "SM4",
|
||||
corpId: "corp-1",
|
||||
storeIds: ["store-2"],
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
await expect(scannedCardArchive({
|
||||
type: "getScannedCard",
|
||||
id: "invalid",
|
||||
}, db)).resolves.toEqual({ success: false, message: "二维码无效" });
|
||||
|
||||
await expect(scannedCardArchive({
|
||||
type: "getScannedCard",
|
||||
id: "507f1f77bcf86cd799439011",
|
||||
corpId: "corp-1",
|
||||
storeId: "store-1",
|
||||
}, db)).resolves.toEqual({ success: false, message: "无权读取本次扫码信息" });
|
||||
});
|
||||
});
|
||||
|
||||
20
index.js
20
index.js
@ -40,6 +40,7 @@ const system = require("./system");
|
||||
const sessionArchive = require("./sessionArchive");
|
||||
const customerHisSync = require("./customerHisSync");
|
||||
const hlw = require("./hlw");
|
||||
const automation = require("./hlw/automation");
|
||||
const trigger = require("./trigger");
|
||||
const consultTrigger = require("./hlw/consult-order/trigger");
|
||||
const callBack = require("./callBack");
|
||||
@ -193,9 +194,10 @@ app.post(
|
||||
// ==================== 以下接口需要 JWT 验证 ====================
|
||||
|
||||
// 连接数据库
|
||||
connectToMongoDB().catch((err) => {
|
||||
const databaseReadyPromise = connectToMongoDB().catch((err) => {
|
||||
logger.error("数据库连接失败,服务器将无法正常工作:", err);
|
||||
// 不退出进程,让错误处理中间件处理后续请求
|
||||
return null;
|
||||
});
|
||||
|
||||
app.get(
|
||||
@ -702,6 +704,18 @@ console.log(process.env.CONFIG_NODE_ENV);
|
||||
// 定时任务
|
||||
trigger.initSchedule();
|
||||
|
||||
consultTrigger({
|
||||
type: "recoverDelayedTasks",
|
||||
databaseReadyPromise.then(async (connected) => {
|
||||
if (!connected) return;
|
||||
try {
|
||||
const internetHospitalDb = await getDatabase("Internet-hospital");
|
||||
await consultTrigger(
|
||||
{
|
||||
type: "recoverDelayedTasks",
|
||||
},
|
||||
internetHospitalDb
|
||||
);
|
||||
await automation.start(internetHospitalDb);
|
||||
} catch (error) {
|
||||
logger.error("恢复问诊延迟任务或启动自动化任务失败:", error);
|
||||
}
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"test-startup": "node test-startup.js",
|
||||
"test-concurrent": "node test-concurrent.js",
|
||||
"test-production": "node test-production-environment.js",
|
||||
"test:automation": "jest hlw/automation --runInBand",
|
||||
"verify-fix": "npm run check-env && npm run test-db && npm run test-production"
|
||||
},
|
||||
"author": "",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user