From cf095dba0cc49f315f61d917b742d982de636268 Mon Sep 17 00:00:00 2001 From: huxuejian Date: Fri, 31 Jul 2026 16:22:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=AD=E6=9C=9F=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hlw/automation/config.js | 136 ++++ hlw/automation/config.test.js | 75 ++ hlw/automation/document.ts | 34 + hlw/automation/index.js | 958 +++++++++++++++++++++++++ hlw/automation/index.test.js | 451 ++++++++++++ hlw/consult-order/field-info.ts | 1 + hlw/consult-order/index.js | 128 +++- hlw/diagnostic-record/document.ts | 2 + hlw/diagnostic-record/index.js | 122 +++- hlw/diagnostic-record/types.js | 3 +- hlw/hlw-config/index.js | 64 +- hlw/index.js | 1 + hlw/scanned-card-archive/index.js | 50 ++ hlw/scanned-card-archive/index.test.js | 61 ++ index.js | 20 +- package.json | 1 + 16 files changed, 2051 insertions(+), 56 deletions(-) create mode 100644 hlw/automation/config.js create mode 100644 hlw/automation/config.test.js create mode 100644 hlw/automation/document.ts create mode 100644 hlw/automation/index.js create mode 100644 hlw/automation/index.test.js diff --git a/hlw/automation/config.js b/hlw/automation/config.js new file mode 100644 index 0000000..67c1a78 --- /dev/null +++ b/hlw/automation/config.js @@ -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, +}; diff --git a/hlw/automation/config.test.js b/hlw/automation/config.test.js new file mode 100644 index 0000000..ce6bdaa --- /dev/null +++ b/hlw/automation/config.test.js @@ -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); + }); +}); diff --git a/hlw/automation/document.ts b/hlw/automation/document.ts new file mode 100644 index 0000000..99e7c2f --- /dev/null +++ b/hlw/automation/document.ts @@ -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; + createdAt: number; + updatedAt: number; + completedAt?: number; +} diff --git a/hlw/automation/index.js b/hlw/automation/index.js new file mode 100644 index 0000000..3158713 --- /dev/null +++ b/hlw/automation/index.js @@ -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, +}; diff --git a/hlw/automation/index.test.js b/hlw/automation/index.test.js new file mode 100644 index 0000000..1952fce --- /dev/null +++ b/hlw/automation/index.test.js @@ -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, + }), + ], + }); + }); +}); diff --git a/hlw/consult-order/field-info.ts b/hlw/consult-order/field-info.ts index 1aea02a..ba84dfd 100644 --- a/hlw/consult-order/field-info.ts +++ b/hlw/consult-order/field-info.ts @@ -84,6 +84,7 @@ interface Order { chargeFee: number;// 费用金额 updateTime: number;// 更新时间 expireTime: number;// 过期时间 + acceptSource?: 'AUTO' | 'MANUAL';// 接诊操作来源 signature: string;// 签名图片路径 (ipad发起的咨询需要用户手动签名) wines?: Wine[];// 浸酒订单 } diff --git a/hlw/consult-order/index.js b/hlw/consult-order/index.js index 10ce89b..2382131 100644 --- a/hlw/consult-order/index.js +++ b/hlw/consult-order/index.js @@ -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: "接诊成功", diff --git a/hlw/diagnostic-record/document.ts b/hlw/diagnostic-record/document.ts index 1297279..f7ee1ef 100644 --- a/hlw/diagnostic-record/document.ts +++ b/hlw/diagnostic-record/document.ts @@ -107,6 +107,8 @@ interface DiagnosticRecord { prescriptionStartTime: number; // 处方开始时间 prescriptionEndTime: number; // 处方结束时间 auditTime?: number; // 审核时间 + prescriptionSource?: 'AUTO' | 'MANUAL'; // 开方操作来源 + auditSource?: 'AUTO' | 'MANUAL'; // 审方操作来源 reasons?: string[]; // 拒绝原因列表 expireTime?: number; // 过期时间 diff --git a/hlw/diagnostic-record/index.js b/hlw/diagnostic-record/index.js index 700340e..7835092 100644 --- a/hlw/diagnostic-record/index.js +++ b/hlw/diagnostic-record/index.js @@ -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; } diff --git a/hlw/diagnostic-record/types.js b/hlw/diagnostic-record/types.js index c18be53..2b59286 100644 --- a/hlw/diagnostic-record/types.js +++ b/hlw/diagnostic-record/types.js @@ -14,7 +14,8 @@ exports.output = { "diagnosisList.name": 1, "drugs.drugName": 1, expireTime: 1, - status: 1 + status: 1, + feeType: 1 }, medicinePurchaseOrder: { _id: 1, diff --git a/hlw/hlw-config/index.js b/hlw/hlw-config/index.js index 3745db9..f72fc3e 100644 --- a/hlw/hlw-config/index.js +++ b/hlw/hlw-config/index.js @@ -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 } } -} \ No newline at end of file +} diff --git a/hlw/index.js b/hlw/index.js index a3b0c61..24cd720 100644 --- a/hlw/index.js +++ b/hlw/index.js @@ -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 { diff --git a/hlw/scanned-card-archive/index.js b/hlw/scanned-card-archive/index.js index db69b0d..3c296d7 100644 --- a/hlw/scanned-card-archive/index.js +++ b/hlw/scanned-card-archive/index.js @@ -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; diff --git a/hlw/scanned-card-archive/index.test.js b/hlw/scanned-card-archive/index.test.js index bf7ccfc..7e0c0a7 100644 --- a/hlw/scanned-card-archive/index.test.js +++ b/hlw/scanned-card-archive/index.test.js @@ -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: "无权读取本次扫码信息" }); + }); }); diff --git a/index.js b/index.js index 428d475..fb38043 100644 --- a/index.js +++ b/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); + } }); diff --git a/package.json b/package.json index d164815..c8fa73c 100644 --- a/package.json +++ b/package.json @@ -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": "",