From 11c141fc21b9ac470928b1e3bba566c7624f7bab Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Mon, 22 Jun 2026 15:07:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(hospital-adapter):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B9=BF=E5=8F=A3=20HIS=20=E4=B8=AD=E7=BB=A7=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.gateway.example | 9 + .env.gk-standalone.example | 16 + .env.gk.example | 17 + .env.production.example | 18 +- .gitignore | 7 +- README.md | 111 +++++- esbuild.config.js | 53 ++- index.js | 7 +- package.json | 5 +- src/adapters/gk/index.js | 737 +++++++++++++++++++++++++++++++++++ src/adapters/index.js | 4 +- src/edge/client.js | 27 +- src/gateway/edge-registry.js | 31 +- src/ykt/customer-his-sync.js | 13 +- 14 files changed, 1014 insertions(+), 41 deletions(-) create mode 100644 .env.gateway.example create mode 100644 .env.gk-standalone.example create mode 100644 .env.gk.example create mode 100644 src/adapters/gk/index.js diff --git a/.env.gateway.example b/.env.gateway.example new file mode 100644 index 0000000..49f5a87 --- /dev/null +++ b/.env.gateway.example @@ -0,0 +1,9 @@ +CONFIG_NODE_PORT=8082 +CONFIG_ADAPTER_ROLE=standalone +CONFIG_MONGO_ENABLED=false + +# 测试环境: 前端按 HIS 选项传固定 corpId,网关按 corpId 路由到 gk 映射。 +CONFIG_HOSPITAL_ADAPTER_CORP_MAP={"wwa54dfba0b5441ef1":"gk"} +CONFIG_GK_JHIDS_PROXY_URL=https://crm.gykqyy.com/ykt/getYoucanData/gkJhids + +CONFIG_RPC_TIMEOUT_MS=30000 diff --git a/.env.gk-standalone.example b/.env.gk-standalone.example new file mode 100644 index 0000000..85c6a0e --- /dev/null +++ b/.env.gk-standalone.example @@ -0,0 +1,16 @@ +CONFIG_NODE_PORT=8082 +CONFIG_ADAPTER_ROLE=standalone +CONFIG_MONGO_ENABLED=false + +CONFIG_HOSPITAL_ADAPTER_CORP_MAP={"wwa54dfba0b5441ef1":"gk"} + +CONFIG_GK_JHIDS_PROXY_URL= +CONFIG_GK_JHIDS_DATA_QUERY_URL= +CONFIG_GK_JHIDS_TOKEN_URL= +CONFIG_GK_JHIDS_CLIENT_ID= +CONFIG_GK_JHIDS_CLIENT_SECRET= +CONFIG_GK_JHIDS_GRANT_TYPE=client_credentials +CONFIG_GK_JHIDS_SYS_CODE=GKDW +CONFIG_GK_JHIDS_PAGE_SIZE=1000 +CONFIG_GK_JHIDS_TIMEOUT_MS=15000 +CONFIG_GK_JHIDS_TLS_REJECT_UNAUTHORIZED=false diff --git a/.env.gk.example b/.env.gk.example new file mode 100644 index 0000000..29d8562 --- /dev/null +++ b/.env.gk.example @@ -0,0 +1,17 @@ +CONFIG_NODE_PORT=8083 +CONFIG_ADAPTER_ROLE=edge +CONFIG_GATEWAY_URL=http://127.0.0.1:8082 +CONFIG_EDGE_ID=gk-edge-1 +CONFIG_EDGE_HOSPITAL_CODE=gk +CONFIG_MONGO_ENABLED=false + +CONFIG_GK_JHIDS_PROXY_URL= +CONFIG_GK_JHIDS_DATA_QUERY_URL= +CONFIG_GK_JHIDS_TOKEN_URL= +CONFIG_GK_JHIDS_CLIENT_ID= +CONFIG_GK_JHIDS_CLIENT_SECRET= +CONFIG_GK_JHIDS_GRANT_TYPE=client_credentials +CONFIG_GK_JHIDS_SYS_CODE=GKDW +CONFIG_GK_JHIDS_PAGE_SIZE=1000 +CONFIG_GK_JHIDS_TIMEOUT_MS=15000 +CONFIG_GK_JHIDS_TLS_REJECT_UNAUTHORIZED=false diff --git a/.env.production.example b/.env.production.example index 2462ccc..05b0c3f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -6,13 +6,23 @@ CONFIG_EDGE_HOSPITAL_CODE=zpfb CONFIG_HOSPITAL_ADAPTER_CORP_MAP={} CONFIG_MONGO_ENABLED=false +CONFIG_GK_JHIDS_PROXY_URL= +CONFIG_GK_JHIDS_DATA_QUERY_URL= +CONFIG_GK_JHIDS_TOKEN_URL= +CONFIG_GK_JHIDS_CLIENT_ID= +CONFIG_GK_JHIDS_CLIENT_SECRET= +CONFIG_GK_JHIDS_SYS_CODE=GKDW +CONFIG_GK_JHIDS_PAGE_SIZE=1000 +CONFIG_GK_JHIDS_TIMEOUT_MS=15000 +CONFIG_GK_JHIDS_TLS_REJECT_UNAUTHORIZED=true + CONFIG_ZPFB_SOURCE_TYPE=view CONFIG_ZPFB_DB_TYPE=oracle_sqlplus -CONFIG_ZPFB_DB_HOST=172.16.1.116 +CONFIG_ZPFB_DB_HOST= CONFIG_ZPFB_DB_PORT=1521 -CONFIG_ZPFB_DB_NAME=sknew -CONFIG_ZPFB_DB_USER=inte +CONFIG_ZPFB_DB_NAME= +CONFIG_ZPFB_DB_USER= CONFIG_ZPFB_DB_PASSWORD= CONFIG_ZPFB_DB_ORACLE_CONNECT_MODE=service -CONFIG_ZPFB_DB_SQLPLUS_PATH=E:\oracle\product\10.2.0\client_1\BIN\sqlplus.exe +CONFIG_ZPFB_DB_SQLPLUS_PATH= CONFIG_ZPFB_DB_SQLPLUS_ENCODING=gbk diff --git a/.gitignore b/.gitignore index ad1b002..9311838 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ release/ logs/ tmp-*.log -.env.production -.env.local -.env.*.local +.env.* +!.env.development +!.env.production.example +!.env.*.example diff --git a/README.md b/README.md index 61e9da5..6d8b957 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ npm run dev 默认端口: `8082` +测试 gateway 本地启动: + +```cmd +npm run gateway +``` + 前置机轻量部署可以不安装 Mongo,只安装 Node.js 和 PM2。Mongo 默认关闭,查询接口仍可直接访问医院视图。 ## 运行角色 @@ -41,21 +47,27 @@ CONFIG_HOSPITAL_ADAPTER_CORP_MAP={"corpId":"zpfb"} ## Windows 前置机部署 -本机生成单文件 bundle: +本机生成单文件 bundle。建议一个医院一个 env 文件,例如 `.env.zpfb`、`.env.gateway`: ```cmd cd /d C:\code\yk\ykt\hospital-adapter-service npm install -npm run build +npm run build -- --env zpfb ``` 构建输出: ```text -dist\bundle.js +dist\bundle.zpfb.js ``` -构建时会把本机 `.env.production` 的变量直接写进 bundle。部署到前置机时只需要复制 `bundle.js`,不需要复制源码目录、`node_modules`、`.env` 或启动脚本。 +构建时会把指定 env 文件的变量直接写进 bundle。部署到前置机时只需要复制对应 bundle,不需要复制源码目录、`node_modules`、`.env` 或启动脚本。 + +如果希望输出文件固定叫 `bundle.js`: + +```cmd +npm run build -- --env zpfb --out dist\bundle.js +``` ```cmd node bundle.js @@ -67,6 +79,59 @@ PM2 启动示例: pm2 start bundle.js --name hospital-adapter-service ``` +edge bundle 也是同一个构建命令,只是每家医院用自己的 env 文件。比如 `.env.zpfb`: + +```bash +CONFIG_ADAPTER_ROLE=edge +CONFIG_GATEWAY_URL=http://中心网关地址 +CONFIG_EDGE_ID=zpfb-edge-1 +CONFIG_EDGE_HOSPITAL_CODE=zpfb +CONFIG_MONGO_ENABLED=false +``` + +然后重新构建: + +```cmd +npm run build -- --env zpfb +``` + +把生成的 `dist\bundle.zpfb.js` 复制到前置机后启动,可以复制时改名为 `bundle.js`: + +```cmd +pm2 start bundle.js --name hospital-adapter-edge-zpfb +``` + +中心 gateway 用自己的 env,例如 `.env.gateway`: + +```cmd +npm run build:gateway +``` + +构建输出: + +```text +dist\bundle.gateway.js +``` + +当前 `.env.gateway` 用 standalone 模式,前端 HIS 选项会传固定广口 `corpId`,网关按 `corpId` 路由到本地 `gk` adapter;`gk` adapter 再调用现有 dev-jcpt 服务获取原始数据,并由中继平台完成字段映射: + +```bash +CONFIG_HOSPITAL_ADAPTER_CORP_MAP={"wwa54dfba0b5441ef1":"gk"} +CONFIG_GK_JHIDS_PROXY_URL=https://crm.gykqyy.com/ykt/getYoucanData/gkJhids +``` + +如果后续要直接连接嘉和 JHIDS,再使用 `gk` 或 `gk-standalone` env: + +```cmd +npm run build -- --env gk-standalone --out dist\bundle.gk-standalone.js +``` + +也可以直接指定 env 文件路径: + +```cmd +npm run build -- --env-file .env.zpfb --out dist\bundle.zpfb.js +``` + ## 环境变量 - `CONFIG_NODE_PORT`: 服务端口 @@ -94,6 +159,36 @@ pm2 start bundle.js --name hospital-adapter-service - `CONFIG___ORDER_BY`: view 分页排序字段 - `CONFIG___DATE_RANGE_FIELD`: `start_date/end_date` 对应的标准日期字段 +广医口腔嘉和模块固定医院编码为 `gk`。gateway 路由到该模块时配置: + +```bash +CONFIG_HOSPITAL_ADAPTER_CORP_MAP={"wwa54dfba0b5441ef1":"gk"} +``` + +测试环境前端可通过 HIS 选项传固定广口 `corpId`,网关仍按 `corpId` 路由: + +```bash +CONFIG_HOSPITAL_ADAPTER_CORP_MAP={"wwa54dfba0b5441ef1":"gk"} +``` + +`gk` 模块使用嘉和 JHIDS 配置: + +```bash +CONFIG_GK_JHIDS_PROXY_URL= +CONFIG_GK_JHIDS_DATA_QUERY_URL= +CONFIG_GK_JHIDS_TOKEN_URL= +CONFIG_GK_JHIDS_CLIENT_ID= +CONFIG_GK_JHIDS_CLIENT_SECRET= +CONFIG_GK_JHIDS_SYS_CODE=GKDW +CONFIG_GK_JHIDS_PAGE_SIZE=1000 +CONFIG_GK_JHIDS_TIMEOUT_MS=15000 +CONFIG_GK_JHIDS_TLS_REJECT_UNAUTHORIZED=true +``` + +测试环境使用现有 dev-jcpt 服务时,只需要配置 `CONFIG_GK_JHIDS_PROXY_URL`;中继平台会请求 dev-jcpt 的 `queryJhidsData` 原始能力,再执行本地 `gk` 字段映射。 + +兼容旧变量名 `CONFIG_JHIDS_*`,但新部署建议使用 `CONFIG_GK_JHIDS_*`。 + ## 查询接口 ```bash @@ -161,13 +256,13 @@ CONFIG_HOSPITAL_ADAPTER_GATEWAY_URL=http://127.0.0.1:8082 ```bash CONFIG_ZPFB_SOURCE_TYPE=view CONFIG_ZPFB_DB_TYPE=oracle_sqlplus -CONFIG_ZPFB_DB_HOST=172.16.1.116 +CONFIG_ZPFB_DB_HOST= CONFIG_ZPFB_DB_PORT=1521 -CONFIG_ZPFB_DB_NAME=sknew -CONFIG_ZPFB_DB_USER=inte +CONFIG_ZPFB_DB_NAME= +CONFIG_ZPFB_DB_USER= CONFIG_ZPFB_DB_PASSWORD= CONFIG_ZPFB_DB_ORACLE_CONNECT_MODE=service -CONFIG_ZPFB_DB_SQLPLUS_PATH=E:\oracle\product\10.2.0\client_1\BIN\sqlplus.exe +CONFIG_ZPFB_DB_SQLPLUS_PATH= CONFIG_ZPFB_DB_SQLPLUS_ENCODING=gbk ``` diff --git a/esbuild.config.js b/esbuild.config.js index c3875b4..a9cd52d 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -2,6 +2,8 @@ const { build } = require("esbuild"); const fs = require("fs"); const path = require("path"); +const buildOptions = parseBuildOptions(process.argv.slice(2)); + function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return {}; return fs.readFileSync(filePath, "utf8").split(/\r?\n/).reduce((acc, line) => { @@ -16,23 +18,64 @@ function loadEnvFile(filePath) { }, {}); } -function buildEmbeddedEnvBanner() { - const env = loadEnvFile(path.resolve(__dirname, ".env.production")); +function buildEmbeddedEnvBanner(envFilePath) { + const env = loadEnvFile(envFilePath); const lines = Object.entries(env).map(([key, value]) => `process.env[${JSON.stringify(key)}]=${JSON.stringify(value)};`); if (!lines.length) return ""; - return `/* embedded .env.production */\n${lines.join("\n")}`; + return `/* embedded ${path.basename(envFilePath)} */\n${lines.join("\n")}`; } +function parseBuildOptions(args) { + const raw = {}; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--env" || arg === "--env-file" || arg === "--out" || arg === "--outfile") { + raw[arg.slice(2)] = args[i + 1]; + i += 1; + continue; + } + if (arg.startsWith("--env=")) raw.env = arg.slice("--env=".length); + if (arg.startsWith("--env-file=")) raw["env-file"] = arg.slice("--env-file=".length); + if (arg.startsWith("--out=")) raw.out = arg.slice("--out=".length); + if (arg.startsWith("--outfile=")) raw.outfile = arg.slice("--outfile=".length); + } + + const envName = normalizeName(raw.env || process.env.CONFIG_BUILD_ENV || ""); + const envFileInput = raw["env-file"] || process.env.CONFIG_BUILD_ENV_FILE || (envName ? `.env.${envName}` : ".env.production"); + const envFilePath = path.resolve(__dirname, envFileInput); + const outfile = raw.outfile || raw.out || process.env.CONFIG_BUILD_OUTFILE || path.resolve(__dirname, "dist", envName ? `bundle.${envName}.js` : "bundle.js"); + + if ((raw.env || raw["env-file"] || process.env.CONFIG_BUILD_ENV || process.env.CONFIG_BUILD_ENV_FILE) && !fs.existsSync(envFilePath)) { + console.error(`[build] env file not found: ${envFilePath}`); + process.exit(1); + } + + return { + envName, + envFilePath, + outfile: path.resolve(__dirname, outfile), + }; +} + +function normalizeName(input) { + const text = String(input || "").trim(); + if (!text) return ""; + return text.replace(/^\.env\./, "").replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +console.log(`[build] env: ${buildOptions.envFilePath}`); +console.log(`[build] outfile: ${buildOptions.outfile}`); + build({ entryPoints: ["./index.js"], - outfile: "./dist/bundle.js", + outfile: buildOptions.outfile, platform: "node", bundle: true, minify: true, sourcemap: false, target: ["node16", "node18", "node20"], banner: { - js: buildEmbeddedEnvBanner(), + js: buildEmbeddedEnvBanner(buildOptions.envFilePath), }, external: ["mysql2", "pg"], plugins: [ diff --git a/index.js b/index.js index b3f87a0..c7b3a5b 100644 --- a/index.js +++ b/index.js @@ -8,7 +8,7 @@ const { handleQuery, queryStandardResource } = require("./src/core/query-control const { handleSync } = require("./src/sync/sync-controller"); const { connectMongo, ensureIndexes, isMongoAvailable, getMongoDisabledReason } = require("./src/mongo"); const { handleYktCustomerHisSync } = require("./src/ykt/customer-his-sync"); -const { attachGateway, queryEdgeResource, listEdges } = require("./src/gateway/edge-registry"); +const { attachGateway, queryEdgeResource, queryEdgeCustomerHisSync, listEdges } = require("./src/gateway/edge-registry"); const { startEdgeClient } = require("./src/edge/client"); loadEnv(); @@ -43,7 +43,10 @@ app.post("/api/ykt/customerHisSync", async (req, res) => { config.role === "gateway" ? queryEdgeResource : ({ hospitalCode, resource, query }) => queryStandardResource({ hospitalCode, resource, query, saveSnapshot: false }).then((result) => ({ status: "success", message: "查询成功", ...result })); - return handleYktCustomerHisSync(req, res, { queryResource }); + return handleYktCustomerHisSync(req, res, { + queryResource, + forwardCustomHisSync: config.role === "gateway" ? queryEdgeCustomerHisSync : undefined, + }); }); app.post("/api/his/query", handleQuery); diff --git a/package.json b/package.json index 5a5d31e..a98aecc 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,13 @@ "main": "index.js", "scripts": { "dev": "node index.js", + "gateway": "cross-env NODE_ENV=gateway node index.js", "pro": "cross-env NODE_ENV=production node index.js", "build": "node esbuild.config.js", + "build:gateway": "node esbuild.config.js --env gateway --out dist/bundle.gateway.js", "build:pro": "cross-env NODE_ENV=production node esbuild.config.js", - "start": "node dist/bundle.js" + "start": "node dist/bundle.js", + "start:gateway": "node dist/bundle.gateway.js" }, "dependencies": { "axios": "^1.7.7", diff --git a/src/adapters/gk/index.js b/src/adapters/gk/index.js new file mode 100644 index 0000000..63ef22b --- /dev/null +++ b/src/adapters/gk/index.js @@ -0,0 +1,737 @@ +const http = require("http"); +const https = require("https"); +const dayjs = require("dayjs"); +const { parseBoolean, parsePositiveInteger } = require("../../core/config"); +const { maskValue } = require("../../core/privacy"); + +const hospitalCode = "gk"; +const envPrefix = "GK"; +const GK_CORP_ID = "wwa54dfba0b5441ef1"; +const DEFAULT_SYS_CODE = "GKDW"; +const DEFAULT_PAGE_SIZE = 1000; +const DEFAULT_TIMEOUT_MS = 15000; + +let cachedToken = null; + +const JHIDS_SERVICES = { + "JHIDS-BAS-PAT-001": { + serverCode: "JHIDS-BAS-PAT-001", + aliases: ["getJhidsPatientBasic"], + defaultMappings: [ + { column: "HIS_PAT_ID", keys: ["hisPatId", "HIS_PAT_ID", "his_pat_id", "idNo", "customerNumber"] }, + { column: "PATIENT_SN", keys: ["patientSn", "PATIENT_SN", "patient_sn"] }, + { column: "ID_CARD_NO", keys: ["idCard", "ID_CARD_NO", "id_card_no"] }, + { column: "MOBILE", keys: ["mobile", "MOBILE"] }, + ], + requiredMessage: "请至少传入 HIS_PAT_ID、PATIENT_SN、ID_CARD_NO 或 MOBILE", + mapper: mapPatient, + }, + "JHIDS-BAS-OHR-002": { + serverCode: "JHIDS-BAS-OHR-002", + aliases: ["getJhidsOutpatientVisits"], + defaultMappings: [ + { column: "HIS_PAT_ID", keys: ["hisPatId", "HIS_PAT_ID", "his_pat_id", "idNo", "customerNumber"] }, + { column: "HIS_VIS_ID", keys: ["hisVisId", "HIS_VIS_ID", "his_vis_id", "visitId"] }, + { column: "PATIENT_SN", keys: ["patientSn", "PATIENT_SN", "patient_sn"] }, + { column: "PAT_VISIT_SN", keys: ["patVisitSn", "PAT_VISIT_SN", "pat_visit_sn"] }, + ], + requiredMessage: "请至少传入 HIS_PAT_ID、HIS_VIS_ID、PATIENT_SN 或 PAT_VISIT_SN", + mapper: mapOutpatientVisit, + }, + "JHIDS-BAS-ERT-010": { + serverCode: "JHIDS-BAS-ERT-010", + aliases: ["getJhidsExamReports"], + defaultMappings: [ + { column: "HIS_PAT_ID", keys: ["hisPatId", "HIS_PAT_ID", "his_pat_id", "idNo", "customerNumber"] }, + { column: "HIS_VIS_ID", keys: ["hisVisId", "HIS_VIS_ID", "his_vis_id", "visitId"] }, + { column: "EXAM_REPORT_SN", keys: ["examReportSn", "EXAM_REPORT_SN", "exam_report_sn"] }, + { column: "EXAM_REPORT_CODE", keys: ["examReportCode", "EXAM_REPORT_CODE", "exam_report_code"] }, + ], + requiredMessage: "请至少传入 HIS_PAT_ID、HIS_VIS_ID、EXAM_REPORT_SN 或 EXAM_REPORT_CODE", + mapper: mapExamReport, + }, + "JHIDS-BAS-LRS-020": { + serverCode: "JHIDS-BAS-LRS-020", + aliases: ["getJhidsLabResults"], + defaultMappings: [ + { column: "HIS_PAT_ID", keys: ["hisPatId", "HIS_PAT_ID", "his_pat_id", "idNo", "customerNumber"] }, + { column: "HIS_VIS_ID", keys: ["hisVisId", "HIS_VIS_ID", "his_vis_id", "visitId"] }, + { column: "LAB_RESULT_SN", keys: ["labResultSn", "LAB_RESULT_SN", "lab_result_sn"] }, + { column: "LAB_APPLY_SN", keys: ["labApplySn", "LAB_APPLY_SN", "lab_apply_sn"] }, + ], + requiredMessage: "请至少传入 HIS_PAT_ID、HIS_VIS_ID、LAB_RESULT_SN 或 LAB_APPLY_SN", + mapper: mapLabResult, + }, + "JHIDS-BAS-LRD-021": { + serverCode: "JHIDS-BAS-LRD-021", + aliases: ["getJhidsLabResultDetails"], + defaultMappings: [ + { column: "HIS_PAT_ID", keys: ["hisPatId", "HIS_PAT_ID", "his_pat_id", "idNo", "customerNumber"] }, + { column: "HIS_VIS_ID", keys: ["hisVisId", "HIS_VIS_ID", "his_vis_id", "visitId"] }, + { column: "LAB_RESULT_SN", keys: ["labResultSn", "LAB_RESULT_SN", "lab_result_sn"] }, + { column: "LAB_APPLY_SN", keys: ["labApplySn", "LAB_APPLY_SN", "lab_apply_sn"] }, + ], + requiredMessage: "请至少传入 HIS_PAT_ID、HIS_VIS_ID、LAB_RESULT_SN 或 LAB_APPLY_SN", + mapper: mapLabResultDetail, + }, +}; + +const TYPE_TO_SERVICE = Object.values(JHIDS_SERVICES).reduce((acc, spec) => { + acc[spec.serverCode] = spec; + for (const alias of spec.aliases) acc[alias] = spec; + return acc; +}, {}); + +function getConfig() { + const config = { + proxyUrl: pickEnv("CONFIG_GK_JHIDS_PROXY_URL", "CONFIG_JHIDS_PROXY_URL"), + dataQueryUrl: pickEnv("CONFIG_GK_JHIDS_DATA_QUERY_URL", "CONFIG_JHIDS_DATA_QUERY_URL"), + tokenUrl: pickEnv("CONFIG_GK_JHIDS_TOKEN_URL", "CONFIG_JHIDS_TOKEN_URL"), + clientId: pickEnv("CONFIG_GK_JHIDS_CLIENT_ID", "CONFIG_JHIDS_CLIENT_ID"), + clientSecret: pickEnv("CONFIG_GK_JHIDS_CLIENT_SECRET", "CONFIG_JHIDS_CLIENT_SECRET"), + grantType: pickEnv("CONFIG_GK_JHIDS_GRANT_TYPE", "CONFIG_JHIDS_GRANT_TYPE") || "client_credentials", + sysCode: pickEnv("CONFIG_GK_JHIDS_SYS_CODE", "CONFIG_JHIDS_SYS_CODE") || DEFAULT_SYS_CODE, + pageSize: parsePositiveInteger(pickEnv("CONFIG_GK_JHIDS_PAGE_SIZE", "CONFIG_JHIDS_PAGE_SIZE"), DEFAULT_PAGE_SIZE), + timeoutMs: parsePositiveInteger(pickEnv("CONFIG_GK_JHIDS_TIMEOUT_MS", "CONFIG_JHIDS_TIMEOUT_MS"), DEFAULT_TIMEOUT_MS), + rejectUnauthorized: parseBoolean(pickEnv("CONFIG_GK_JHIDS_TLS_REJECT_UNAUTHORIZED", "CONFIG_JHIDS_TLS_REJECT_UNAUTHORIZED"), true), + }; + + if (config.proxyUrl) return config; + if (!config.dataQueryUrl) throwConfigError("缺少配置: CONFIG_GK_JHIDS_DATA_QUERY_URL"); + if (!config.tokenUrl) throwConfigError("缺少配置: CONFIG_GK_JHIDS_TOKEN_URL"); + if (!config.clientId) throwConfigError("缺少配置: CONFIG_GK_JHIDS_CLIENT_ID"); + if (!config.clientSecret) throwConfigError("缺少配置: CONFIG_GK_JHIDS_CLIENT_SECRET"); + return config; +} + +function pickEnv(...keys) { + for (const key of keys) { + const value = normalizeText(process.env[key]); + if (value) return value; + } + return ""; +} + +function throwConfigError(message) { + const err = new Error(message); + err.code = "JHIDS_CONFIG_INVALID"; + throw err; +} + +function createMultipartFormData(fields) { + const boundary = `----YktJhidsBoundary${Date.now().toString(16)}`; + const chunks = []; + for (const [name, rawValue] of Object.entries(fields)) { + if (rawValue === undefined || rawValue === null) continue; + chunks.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${String(rawValue)}\r\n`, "utf8")); + } + chunks.push(Buffer.from(`--${boundary}--\r\n`, "utf8")); + return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` }; +} + +function parseResponseBody(buffer, contentType) { + const text = buffer.toString("utf8"); + if (!text) return null; + const mimeType = String(contentType || "").toLowerCase(); + if (mimeType.includes("json")) return JSON.parse(text); + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function requestUpstream({ method, url, headers, body, timeoutMs, rejectUnauthorized }) { + return new Promise((resolve, reject) => { + const target = new URL(url); + const transport = target.protocol === "https:" ? https : http; + const req = transport.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port || undefined, + path: `${target.pathname}${target.search}`, + method, + headers, + timeout: timeoutMs, + rejectUnauthorized, + }, + (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + try { + resolve({ + statusCode: res.statusCode || 500, + headers: res.headers || {}, + data: parseResponseBody(Buffer.concat(chunks), res.headers && res.headers["content-type"]), + }); + } catch (err) { + reject(err); + } + }); + } + ); + + req.on("timeout", () => req.destroy(new Error(`嘉和接口请求超时(${timeoutMs}ms)`))); + req.on("error", reject); + if (body && body.length) req.write(body); + req.end(); + }); +} + +async function requestJson(options) { + const response = await requestUpstream(options); + if (response.statusCode >= 400) { + const err = new Error(`嘉和上游接口调用失败(${response.statusCode})`); + err.code = "JHIDS_UPSTREAM_FAILED"; + err.statusCode = response.statusCode; + err.upstream = response.data; + throw err; + } + return response.data; +} + +function unwrapPayload(payload) { + if (payload && payload.data && typeof payload.data === "object" && !Array.isArray(payload.data)) return payload.data; + return payload; +} + +function composeAuthorizationValue(tokenHead, tokenValue) { + const head = tokenHead === undefined || tokenHead === null || tokenHead === "" ? "Bearer " : String(tokenHead); + if (/\s$/.test(head)) return `${head}${tokenValue}`; + if (head.toLowerCase() === "bearer") return `${head} ${tokenValue}`; + return `${head}${tokenValue}`; +} + +async function fetchToken({ forceRefresh = false } = {}) { + const config = getConfig(); + if (!forceRefresh && cachedToken && cachedToken.expiresAt > Date.now()) return cachedToken; + + const { body, contentType } = createMultipartFormData({ + grant_type: config.grantType, + client_id: config.clientId, + client_secret: config.clientSecret, + }); + + const payload = await requestJson({ + method: "POST", + url: config.tokenUrl, + headers: { + Accept: "application/json", + "Content-Type": contentType, + "Content-Length": body.length, + }, + body, + timeoutMs: config.timeoutMs, + rejectUnauthorized: config.rejectUnauthorized, + }); + + const data = unwrapPayload(payload); + const tokenValue = data && (data.token || data.access_token); + if (!tokenValue) { + const err = new Error("未从嘉和认证接口获取到 token"); + err.code = "JHIDS_TOKEN_INVALID"; + err.upstream = payload; + throw err; + } + + const expiresIn = Math.max(parsePositiveInteger(data.expiresIn || data.expires_in, 3600) - 60, 60); + cachedToken = { + authorization: composeAuthorizationValue(data.tokenHead || data.token_type, tokenValue), + expiresAt: Date.now() + expiresIn * 1000, + }; + return cachedToken; +} + +async function queryJhidsData(payload, options = {}) { + const config = getConfig(); + if (config.proxyUrl) return queryJhidsProxy(payload, options); + const requestBody = Buffer.from(JSON.stringify(payload), "utf8"); + + const execute = async (authorization) => { + logRequestSummary(config.dataQueryUrl, payload); + return requestJson({ + method: "POST", + url: config.dataQueryUrl, + headers: { + Accept: "application/json", + Authorization: authorization, + "Content-Type": "application/json; charset=utf-8", + "Content-Length": requestBody.length, + }, + body: requestBody, + timeoutMs: config.timeoutMs, + rejectUnauthorized: config.rejectUnauthorized, + }); + }; + + try { + const token = await fetchToken({ forceRefresh: options.forceRefresh === true }); + return await execute(token.authorization); + } catch (err) { + if (err && err.statusCode === 401 && options.forceRefresh !== true) { + const token = await fetchToken({ forceRefresh: true }); + return execute(token.authorization); + } + throw err; + } +} + +async function queryJhidsProxy(payload, options = {}) { + const config = getConfig(); + const requestPayload = { + type: "queryJhidsData", + serverCode: payload.serverCode, + sysCode: payload.sysCode, + condition: payload.condition, + pageNo: payload.pageNo, + pageSize: payload.pageSize, + }; + if (payload.maxResultSize) requestPayload.maxResultSize = payload.maxResultSize; + if (options.includeRaw === true) requestPayload.includeRaw = true; + + const requestBody = Buffer.from(JSON.stringify(requestPayload), "utf8"); + logRequestSummary(config.proxyUrl, requestPayload); + const response = await requestJson({ + method: "POST", + url: config.proxyUrl, + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + "Content-Length": requestBody.length, + }, + body: requestBody, + timeoutMs: config.timeoutMs, + rejectUnauthorized: config.rejectUnauthorized, + }); + if (response && response.success === false) { + const err = new Error(response.message || "嘉和代理接口返回失败"); + err.code = response.err || response.code || "JHIDS_PROXY_FAILED"; + err.upstream = response; + throw err; + } + return response; +} + +function logRequestSummary(url, payload) { + try { + const target = new URL(url); + console.log("[hospital-adapter-service] gk jhids request", { + serverCode: payload.serverCode, + url: `${target.origin}${target.pathname}`, + pageNo: payload.pageNo, + pageSize: payload.pageSize, + condition: sanitizeCondition(payload.condition), + }); + } catch {} +} + +function sanitizeCondition(condition) { + if (!Array.isArray(condition)) return []; + return condition.map((item) => ({ + column: item && item.column, + type: item && item.type, + value: maskSensitiveValue(item && item.column, item && item.value), + })); +} + +function maskSensitiveValue(column, value) { + const text = normalizeText(value); + if (!text) return ""; + const sensitiveColumns = ["ID_CARD_NO", "MOBILE", "HIS_PAT_ID", "HIS_VIS_ID", "PATIENT_SN"]; + if (!sensitiveColumns.includes(String(column || "").toUpperCase())) return "***"; + return maskValue(text); +} + +function normalizeText(value) { + if (value === undefined || value === null) return ""; + return String(value).trim(); +} + +function pickValue(body, keys) { + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(body, key)) continue; + const value = body[key]; + if (value === undefined || value === null) continue; + if (typeof value === "string" && value.trim() === "") continue; + return value; + } + return undefined; +} + +function normalizeConditionItem(item) { + if (!item || typeof item !== "object") return null; + const column = normalizeText(item.column); + const type = normalizeText(item.type) || "eq"; + const value = item.value; + if (!column || value === undefined || value === null || String(value).trim() === "") return null; + return { column, type, value: String(value).trim() }; +} + +function buildCondition(body, spec) { + const rawCondition = Array.isArray(body.condition) ? body.condition : Array.isArray(body.conditions) ? body.conditions : null; + if (rawCondition) return rawCondition.map(normalizeConditionItem).filter(Boolean); + + const singleCondition = normalizeConditionItem({ + column: pickValue(body, ["column", "COLUMN"]), + type: pickValue(body, ["type", "TYPE"]), + value: pickValue(body, ["value", "VALUE"]), + }); + if (singleCondition) return [singleCondition]; + + const condition = []; + for (const mapping of spec.defaultMappings || []) { + const value = pickValue(body, mapping.keys); + if (value === undefined) continue; + condition.push({ column: mapping.column, type: "eq", value: String(value).trim() }); + } + return condition; +} + +function buildPayload(body, spec) { + const serverCode = spec.serverCode || normalizeText(body.serverCode); + if (!serverCode) throwInvalidParam("缺少参数: serverCode"); + + const condition = buildCondition(body, spec); + if (!condition.length) throwInvalidParam(spec.requiredMessage || "缺少查询条件: condition"); + + const config = getConfig(); + const payload = { + pageSize: parsePositiveInteger(body.pageSize, config.pageSize), + pageNo: parsePositiveInteger(body.pageNo, 1), + serverCode, + sysCode: pickValue(body, ["sysCode", "SysCode", "SYS_CODE", "sys_code"]) || config.sysCode, + condition, + }; + + const maxResultSize = parsePositiveInteger(body.maxResultSize, 0); + if (maxResultSize) payload.maxResultSize = maxResultSize; + return payload; +} + +function throwInvalidParam(message) { + const err = new Error(message); + err.code = "INVALID_PARAM"; + throw err; +} + +function extractRows(rawData) { + if (Array.isArray(rawData)) return rawData; + if (!rawData || typeof rawData !== "object") return []; + if (Array.isArray(rawData.data)) return rawData.data; + if (Array.isArray(rawData.list)) return rawData.list; + if (Array.isArray(rawData.rows)) return rawData.rows; + if (Array.isArray(rawData.result)) return rawData.result; + if (rawData.data && typeof rawData.data === "object") return extractRows(rawData.data); + if (rawData.result && typeof rawData.result === "object") return extractRows(rawData.result); + return []; +} + +function pick(row, keys) { + for (const key of keys) { + const value = row && row[key]; + if (value !== undefined && value !== null && String(value).trim() !== "") return value; + } + return ""; +} + +function joinPicked(row, keys, separator = " / ") { + const values = []; + for (const key of keys) { + const value = pick(row, [key]); + if (value && !values.includes(value)) values.push(value); + } + return values.join(separator); +} + +function withSourceFields(mapped, row, includeSourceFields) { + return includeSourceFields ? { ...row, ...mapped } : mapped; +} + +function mapPatient(row, options = {}) { + return withSourceFields( + { + name: pick(row, ["PATIENT_NAME"]), + cardType: pick(row, ["ID_CARD_NO"]) ? "身份证" : "", + idCard: pick(row, ["ID_CARD_NO"]), + mobile: pick(row, ["MOBILE", "HOME_PHONE", "OTHER_PHONE"]), + address: pick(row, ["MAILING_ADDRESS", "FAMILY_ADDRESS", "EMPLOYER_ADDRESS"]), + customerNumber: pick(row, ["HIS_PAT_ID", "HIS_KEY_SRC", "HIS_KEY"]), + sex: pick(row, ["ST_SEX", "SEX"]), + birthday: pick(row, ["DATE_OF_BIRTH"]), + hospitalCode: pick(row, ["MED_ORG_SN"]), + hospitalVisitNo: pick(row, ["OUTP_NO", "VISIT_CARD_NO"]), + medicalRecordNo: pick(row, ["CASE_NO"]), + visitType: "", + }, + row, + options.includeSourceFields + ); +} + +function mapOutpatientVisit(row, options = {}) { + const deptName = pick(row, ["ST_OUT_DEPT", "OUT_DEPT"]); + const doctor = pick(row, ["ST_OUT_DOCT", "OUT_DOCTOR"]); + const diagnosisId = pick(row, ["SC_CLINIC_DIAG"]); + const diagnosisName = pick(row, ["ST_CLINIC_DIAG"]); + const visitTime = pick(row, ["OUT_TIME", "REG_TIME", "REC_TREAT_TIME"]); + const registerType = pick(row, ["ST_REG_TYPE_NAME", "ST_REG_CATEGORY_NAME"]); + const visitType = pick(row, ["VISIT_TYPE", "ST_VISIT_TYPE"]); + const chargeType = pick(row, ["ST_CHARGE_TYPE", "SC_CHARGE_TYPE"]); + const serviceItems = joinPicked(row, ["ST_REG_TYPE_NAME", "ST_REG_CATEGORY_NAME", "ST_VISIT_TYPE", "VISIT_TYPE", "ST_CHARGE_TYPE"]); + const amount = pick(row, [ + "TREATMENT_COST", + "REGISTER_TOTAL_COST", + "REGISTER_COST", + "WECHAT_COST", + "ACCOUNT_COST", + "CASH_COST", + "FUND_COST", + "PERSON_COST", + ]); + + return withSourceFields( + { + customerNumber: pick(row, ["HIS_PAT_ID", "HIS_KEY_SRC"]), + visitId: pick(row, ["HIS_VIS_ID", "OUT_CODE", "PAT_VISIT_SN"]), + visitTime, + deptName, + doctor, + diagnosisId, + diagnosisCode: diagnosisId, + diagnosisName, + diagnosis: diagnosisName, + outpatientDiagnosis: diagnosisName, + medicalRecordNo: pick(row, ["OUT_CODE"]), + hospitalCode: pick(row, ["MED_ORG_SN"]), + hospitalName: "", + registrationNo: pick(row, ["OUT_CODE", "OUT_HOSP_SN"]), + settlementNo: "", + visitStatus: pick(row, ["ST_VISIT_STATE", "SC_VISIT_STATE", "ST_REG_STATE", "REG_STATE"]), + patientSn: pick(row, ["PATIENT_SN"]), + patVisitSn: pick(row, ["PAT_VISIT_SN"]), + outHospitalSn: pick(row, ["OUT_HOSP_SN"]), + outpatientNo: pick(row, ["OUT_CODE", "OUT_NUM"]), + registerTime: pick(row, ["REG_TIME"]), + receiveTime: pick(row, ["REC_TREAT_TIME"]), + deptCode: pick(row, ["SC_OUT_DEPT"]), + doctorCode: pick(row, ["SC_OUT_DOCT"]), + registerMode: pick(row, ["ST_REG_MODE"]), + registerType, + visitType, + chargeType, + insuranceType: pick(row, ["INSURANCE_TYPE"]), + serviceItems, + serviceItem: serviceItems, + amount, + treatmentCost: pick(row, ["TREATMENT_COST"]), + registerCost: pick(row, ["REGISTER_COST"]), + registerTotalCost: pick(row, ["REGISTER_TOTAL_COST"]), + currentAge: joinPicked(row, ["CURRENT_AGE", "CURRENT_AGE_UNIT"], ""), + }, + row, + options.includeSourceFields + ); +} + +function mapExamReport(row, options = {}) { + return withSourceFields( + { + reportNo: pick(row, ["EXAM_REPORT_CODE", "EXAM_REPORT_SN"]), + examNo: pick(row, ["EXAM_REPORT_SN"]), + applyNo: pick(row, ["EXAM_APPLY_CODE", "EXAM_APPLY_SN"]), + itemName: pick(row, ["EXAM_ITEM_NAME"]), + finding: pick(row, ["EXAM_DESC"]), + conclusion: pick(row, ["EXAM_DIAG", "EXAM_IMPR"]), + bodyPart: pick(row, ["ST_EXAM_PART", "EXAM_PART"]), + positiveFlag: pick(row, ["ABNORMAL_FLAG"]), + reportTime: pick(row, ["REPORT_TIME"]), + examTime: pick(row, ["EXAM_TIME"]), + deptName: pick(row, ["ST_EXAM_DEPT", "EXAM_DEPT", "EXAM_ROOM"]), + doctor: pick(row, ["ST_EXAM_DOCT", "REPORT_DOCTOR", "EXAM_DOCTOR"]), + url: pick(row, ["IMAGE_URL"]), + patid: pick(row, ["HIS_PAT_ID"]), + visitId: pick(row, ["HIS_VIS_ID", "PAT_VISIT_SN"]), + hospitalCode: pick(row, ["MED_ORG_SN"]), + }, + row, + options.includeSourceFields + ); +} + +function mapLabResult(row, options = {}) { + return withSourceFields( + { + reportNo: pick(row, ["LAB_RESULT_CODE", "LAB_RESULT_SN"]), + resultNo: pick(row, ["LAB_RESULT_SN"]), + applyNo: pick(row, ["LAB_APPLY_CODE", "LAB_APPLY_SN"]), + itemName: pick(row, ["LAB_ITEM_NAME"]), + specimen: pick(row, ["SPECIMEN_DESC", "SPECIMEN"]), + reportTime: pick(row, ["RESULTS_TIME"]), + sampleTime: pick(row, ["SPCM_SAM_TIME"]), + receiveTime: pick(row, ["SPCM_REC_TIME"]), + deptName: pick(row, ["LAB_DEPT"]), + doctor: pick(row, ["RESULTS_DOCTOR"]), + status: pick(row, ["REPORT_STATUS"]), + patid: pick(row, ["HIS_PAT_ID"]), + visitId: pick(row, ["HIS_VIS_ID", "PAT_VISIT_SN"]), + hospitalCode: pick(row, ["MED_ORG_SN"]), + }, + row, + options.includeSourceFields + ); +} + +function mapLabResultDetail(row, options = {}) { + return withSourceFields( + { + detailId: pick(row, ["LAB_RES_DTL_SN"]), + reportNo: pick(row, ["LAB_RESULT_SN", "LAB_RESULT_CODE"]), + applyNo: pick(row, ["LAB_APPLY_SN", "LAB_APPLY_CODE"]), + itemCode: pick(row, ["ITEM_EN_NAME"]), + itemName: pick(row, ["ITEM_CN_NAME"]), + result: pick(row, ["RESULT_CHAR", "RESULT_NUM"]), + unit: pick(row, ["RESULT_UNIT"]), + reference: pick(row, ["RESULT_REF"]), + abnormalFlag: pick(row, ["ST_RESULT_WARN", "RESULT_WARN"]), + patid: pick(row, ["HIS_PAT_ID"]), + visitId: pick(row, ["HIS_VIS_ID", "PAT_VISIT_SN"]), + }, + row, + options.includeSourceFields + ); +} + +function buildSuccessResponse({ message = "获取成功", rawData, rows, list, body }) { + const pageNo = parsePositiveInteger(body.pageNo, 1); + const pageSize = parsePositiveInteger(body.pageSize, getConfig().pageSize); + const response = { + success: true, + message, + data: { + pageNo, + pageSize, + totalElements: list.length, + rawRowCount: rows.length, + }, + list, + }; + if (body.includeRaw === true) response.rawData = rawData; + return response; +} + +async function runService(body, spec) { + const payload = buildPayload(body, spec); + const rawData = await queryJhidsData(payload, { forceRefresh: body.forceRefresh === true, includeRaw: body.includeRaw === true }); + const rows = extractRows(rawData); + const mapper = spec.mapper || ((row, options) => withSourceFields({}, row, options.includeSourceFields)); + const list = rows.map((row) => mapper(row, { includeSourceFields: body.includeSourceFields === true })); + return buildSuccessResponse({ rawData, rows, list, body }); +} + +function buildErrorResponse(err) { + const message = err && err.message ? String(err.message) : "嘉和接口调用失败"; + const payload = { success: false, message, list: [] }; + if (err && err.code) payload.err = err.code; + console.error("[hospital-adapter-service] gk jhids error", { + code: err && err.code, + statusCode: err && err.statusCode, + message, + }); + return payload; +} + +async function handleRawJhids(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) throwInvalidParam("请求体必须是 JSON 对象"); + const type = normalizeText(body.type); + if (!type) throwInvalidParam("缺少参数: type"); + + if (type === "queryJhidsData") { + const payload = buildPayload(body, { defaultMappings: [], mapper: (row, options) => withSourceFields({}, row, options.includeSourceFields) }); + const rawData = await queryJhidsData(payload, { forceRefresh: body.forceRefresh === true }); + const rows = extractRows(rawData); + return buildSuccessResponse({ rawData, rows, list: rows, body }); + } + + const spec = TYPE_TO_SERVICE[type]; + if (!spec) throwInvalidParam("未找到对应的嘉和服务类型"); + return runService(body, spec); +} + +function buildCustomerArchiveCondition(event) { + if (event.idCard) return [{ column: "ID_CARD_NO", type: "eq", value: normalizeText(event.idCard) }]; + if (event.mobile) return [{ column: "MOBILE", type: "eq", value: normalizeText(event.mobile) }]; + if (event.idNo || event.customerNumber || event.hisPatId) { + return [{ column: "HIS_PAT_ID", type: "eq", value: normalizeText(event.idNo || event.customerNumber || event.hisPatId) }]; + } + if (event.patientSn) return [{ column: "PATIENT_SN", type: "eq", value: normalizeText(event.patientSn) }]; + return []; +} + +async function getHisCustomerArchive(event) { + const condition = buildCustomerArchiveCondition(event); + if (!condition.length) return { success: false, message: "请输入患者信息", list: [] }; + return runService({ ...event, type: "getJhidsPatientBasic", condition }, JHIDS_SERVICES["JHIDS-BAS-PAT-001"]); +} + +async function getHisOutHospitalRecord(event) { + const idNo = normalizeText(event.idNo || event.customerNumber || event.hisPatId); + if (!idNo) return { success: false, message: "缺少患者编号", list: [] }; + + const condition = [{ column: "HIS_PAT_ID", type: "eq", value: idNo }]; + const visitId = normalizeText(event.hisVisId || event.visitId); + if (visitId) condition.push({ column: "HIS_VIS_ID", type: "eq", value: visitId }); + + const result = await runService({ ...event, type: "getJhidsOutpatientVisits", condition }, JHIDS_SERVICES["JHIDS-BAS-OHR-002"]); + if (result.success) { + result.list = filterByVisitTime(result.list, event.startTime, event.endTime); + result.data.totalElements = result.list.length; + } + return result; +} + +function filterByVisitTime(list, startTime, endTime) { + const start = startTime ? dayjs(startTime).startOf("day") : null; + const end = endTime ? dayjs(endTime).endOf("day") : null; + if ((!start || !start.isValid()) && (!end || !end.isValid())) return list; + + return list.filter((item) => { + const visitTime = dayjs(item.visitTime); + if (!visitTime.isValid()) return true; + if (start && start.isValid() && visitTime.isBefore(start)) return false; + if (end && end.isValid() && visitTime.isAfter(end)) return false; + return true; + }); +} + +async function customerHisSync(event = {}) { + try { + switch (event.type) { + case "getHisCustomerArchive": + return await getHisCustomerArchive(event); + case "getHisOutHospitalRecord": + return await getHisOutHospitalRecord(event); + case "getHisInHospitalRecord": + return { success: true, message: "嘉和对外服务文档未提供住院接口", list: [] }; + case "getHisFeeRecord": + return { success: true, message: "嘉和对外服务文档未提供费用接口", list: [] }; + default: + return await handleRawJhids(event); + } + } catch (err) { + return buildErrorResponse(err); + } +} + +module.exports = { + hospitalCode, + name: "广医口腔嘉和", + envPrefix, + corpId: GK_CORP_ID, + resources: {}, + customerHisSync, + main: customerHisSync, + getConfig, + queryJhidsData, +}; diff --git a/src/adapters/index.js b/src/adapters/index.js index 141ca77..5798077 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -1,6 +1,7 @@ const zpfb = require("./zpfb"); +const gk = require("./gk"); -const STATIC_ADAPTERS = [zpfb].reduce((acc, adapter) => { +const STATIC_ADAPTERS = [zpfb, gk].reduce((acc, adapter) => { acc[adapter.hospitalCode] = adapter; return acc; }, {}); @@ -12,4 +13,3 @@ async function getAdapter(hospitalCode) { module.exports = { getAdapter, }; - diff --git a/src/edge/client.js b/src/edge/client.js index 95972b9..80781df 100644 --- a/src/edge/client.js +++ b/src/edge/client.js @@ -1,6 +1,7 @@ const WebSocket = require("ws"); const { getConfig } = require("../core/config"); const { queryStandardResource } = require("../core/query-controller"); +const { runYktCustomerHisSync } = require("../ykt/customer-his-sync"); let socket = null; let reconnectTimer = null; @@ -25,7 +26,7 @@ function connect() { type: "register", edgeId: config.edgeId, hospitalCode: config.edgeHospitalCode, - capabilities: ["queryStandardResource"], + capabilities: ["queryStandardResource", "customerHisSync"], }) ); }); @@ -45,14 +46,22 @@ async function handleMessage(raw) { const request = message.payload; const response = { type: "response", requestId: request.requestId, payload: null }; try { - if (request.action !== "queryStandardResource") throwEdgeError("不支持的 Agent 动作", "EDGE_ACTION_UNSUPPORTED"); - const result = await queryStandardResource({ - hospitalCode: request.hospitalCode, - resource: request.resource, - query: request.query || {}, - saveSnapshot: false, - }); - response.payload = { status: "success", message: "查询成功", ...result }; + if (request.action === "queryStandardResource") { + const result = await queryStandardResource({ + hospitalCode: request.hospitalCode, + resource: request.resource, + query: request.query || {}, + saveSnapshot: false, + }); + response.payload = { status: "success", message: "查询成功", ...result }; + } else if (request.action === "customerHisSync") { + response.payload = await runYktCustomerHisSync({ ...(request.event || {}), hospitalCode: request.hospitalCode }, { + queryResource: ({ hospitalCode, resource, query }) => + queryStandardResource({ hospitalCode, resource, query, saveSnapshot: false }).then((result) => ({ status: "success", message: "查询成功", ...result })), + }); + } else { + throwEdgeError("不支持的 Agent 动作", "EDGE_ACTION_UNSUPPORTED"); + } } catch (err) { response.payload = { status: "fail", message: err && err.message ? err.message : "Agent 查询失败", code: err && err.code, data: [] }; } diff --git a/src/gateway/edge-registry.js b/src/gateway/edge-registry.js index babb295..66e6554 100644 --- a/src/gateway/edge-registry.js +++ b/src/gateway/edge-registry.js @@ -51,22 +51,44 @@ function unregisterEdge(state, ws) { } async function queryEdgeResource({ hospitalCode, resource, query }) { + return queryEdge({ + hospitalCode, + payload: { action: "queryStandardResource", hospitalCode, resource, query }, + timeoutCode: "EDGE_TIMEOUT", + sendCode: "EDGE_SEND_FAILED", + }); +} + +async function queryEdgeCustomerHisSync({ hospitalCode, event }) { + const result = await queryEdge({ + hospitalCode, + payload: { action: "customerHisSync", hospitalCode, event }, + timeoutCode: "EDGE_TIMEOUT", + sendCode: "EDGE_SEND_FAILED", + }); + if (result && result.status === "fail") { + return { success: false, message: result.message || "Agent 查询失败", list: [], err: result.code }; + } + return result; +} + +async function queryEdge({ hospitalCode, payload, timeoutCode, sendCode }) { const edge = edges.get(hospitalCode); if (!edge || edge.ws.readyState !== WebSocket.OPEN) throwGatewayError("医院 Agent 离线", "EDGE_OFFLINE"); const requestId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}${Math.random()}`; - const payload = { requestId, action: "queryStandardResource", hospitalCode, resource, query }; + const requestPayload = { requestId, ...payload }; const timeoutMs = getConfig().rpcTimeoutMs; return new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(requestId); - rejectGateway(reject, `医院 Agent 响应超时(${timeoutMs}ms)`, "EDGE_TIMEOUT"); + rejectGateway(reject, `医院 Agent 响应超时(${timeoutMs}ms)`, timeoutCode); }, timeoutMs); pending.set(requestId, { resolve, reject, timer }); - edge.ws.send(JSON.stringify({ type: "request", payload }), (err) => { + edge.ws.send(JSON.stringify({ type: "request", payload: requestPayload }), (err) => { if (!err) return; clearTimeout(timer); pending.delete(requestId); - rejectGateway(reject, err.message || "发送 Agent 请求失败", "EDGE_SEND_FAILED"); + rejectGateway(reject, err.message || "发送 Agent 请求失败", sendCode); }); }); } @@ -95,5 +117,6 @@ function rejectGateway(reject, message, code) { module.exports = { attachGateway, queryEdgeResource, + queryEdgeCustomerHisSync, listEdges, }; diff --git a/src/ykt/customer-his-sync.js b/src/ykt/customer-his-sync.js index d4d3b28..d9aba19 100644 --- a/src/ykt/customer-his-sync.js +++ b/src/ykt/customer-his-sync.js @@ -1,4 +1,5 @@ const { getConfig } = require("../core/config"); +const { getAdapter } = require("../adapters"); async function handleYktCustomerHisSync(req, res, options) { const result = await runYktCustomerHisSync(req.body || {}, options); @@ -10,12 +11,18 @@ async function runYktCustomerHisSync(event = {}, options = {}) { const type = normalizeText(event.type); if (!type) return fail("缺少参数: type"); - if (type === "getHisInHospitalRecord") return successEmpty("中转平台标准资源暂未提供住院接口"); - if (type === "getHisFeeRecord") return successEmpty("中转平台标准资源暂未提供费用接口"); - const hospitalCode = resolveHospitalCode(event); if (!hospitalCode) return fail("未配置中转平台医院编码"); + const adapter = await getAdapter(hospitalCode); + if (adapter && typeof adapter.customerHisSync === "function") { + if (options.forwardCustomHisSync) return options.forwardCustomHisSync({ hospitalCode, event }); + return adapter.customerHisSync(event); + } + + if (type === "getHisInHospitalRecord") return successEmpty("中转平台标准资源暂未提供住院接口"); + if (type === "getHisFeeRecord") return successEmpty("中转平台标准资源暂未提供费用接口"); + if (type === "getHisCustomerArchive") return queryCustomerArchive({ event, hospitalCode, queryResource: options.queryResource }); if (type === "getHisOutHospitalRecord") return queryOutHospitalRecord({ event, hospitalCode, queryResource: options.queryResource });