feat(hospital-adapter): 新增广口 HIS 中继适配

This commit is contained in:
Jafeng 2026-06-22 15:07:44 +08:00
parent 7a9350adcf
commit 11c141fc21
14 changed files with 1014 additions and 41 deletions

9
.env.gateway.example Normal file
View File

@ -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

View File

@ -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

17
.env.gk.example Normal file
View File

@ -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

View File

@ -6,13 +6,23 @@ CONFIG_EDGE_HOSPITAL_CODE=zpfb
CONFIG_HOSPITAL_ADAPTER_CORP_MAP={} CONFIG_HOSPITAL_ADAPTER_CORP_MAP={}
CONFIG_MONGO_ENABLED=false 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_SOURCE_TYPE=view
CONFIG_ZPFB_DB_TYPE=oracle_sqlplus CONFIG_ZPFB_DB_TYPE=oracle_sqlplus
CONFIG_ZPFB_DB_HOST=172.16.1.116 CONFIG_ZPFB_DB_HOST=<db-host>
CONFIG_ZPFB_DB_PORT=1521 CONFIG_ZPFB_DB_PORT=1521
CONFIG_ZPFB_DB_NAME=sknew CONFIG_ZPFB_DB_NAME=<db-name>
CONFIG_ZPFB_DB_USER=inte CONFIG_ZPFB_DB_USER=<db-user>
CONFIG_ZPFB_DB_PASSWORD= CONFIG_ZPFB_DB_PASSWORD=
CONFIG_ZPFB_DB_ORACLE_CONNECT_MODE=service 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=<sqlplus-path>
CONFIG_ZPFB_DB_SQLPLUS_ENCODING=gbk CONFIG_ZPFB_DB_SQLPLUS_ENCODING=gbk

7
.gitignore vendored
View File

@ -3,6 +3,7 @@ dist/
release/ release/
logs/ logs/
tmp-*.log tmp-*.log
.env.production .env.*
.env.local !.env.development
.env.*.local !.env.production.example
!.env.*.example

111
README.md
View File

@ -21,6 +21,12 @@ npm run dev
默认端口: `8082` 默认端口: `8082`
测试 gateway 本地启动:
```cmd
npm run gateway
```
前置机轻量部署可以不安装 Mongo只安装 Node.js 和 PM2。Mongo 默认关闭,查询接口仍可直接访问医院视图。 前置机轻量部署可以不安装 Mongo只安装 Node.js 和 PM2。Mongo 默认关闭,查询接口仍可直接访问医院视图。
## 运行角色 ## 运行角色
@ -41,21 +47,27 @@ CONFIG_HOSPITAL_ADAPTER_CORP_MAP={"corpId":"zpfb"}
## Windows 前置机部署 ## Windows 前置机部署
本机生成单文件 bundle: 本机生成单文件 bundle。建议一个医院一个 env 文件,例如 `.env.zpfb``.env.gateway`:
```cmd ```cmd
cd /d C:\code\yk\ykt\hospital-adapter-service cd /d C:\code\yk\ykt\hospital-adapter-service
npm install npm install
npm run build npm run build -- --env zpfb
``` ```
构建输出: 构建输出:
```text ```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 ```cmd
node bundle.js node bundle.js
@ -67,6 +79,59 @@ PM2 启动示例:
pm2 start bundle.js --name hospital-adapter-service 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`: 服务端口 - `CONFIG_NODE_PORT`: 服务端口
@ -94,6 +159,36 @@ pm2 start bundle.js --name hospital-adapter-service
- `CONFIG_<HOSPITAL>_<RESOURCE>_ORDER_BY`: view 分页排序字段 - `CONFIG_<HOSPITAL>_<RESOURCE>_ORDER_BY`: view 分页排序字段
- `CONFIG_<HOSPITAL>_<RESOURCE>_DATE_RANGE_FIELD`: `start_date/end_date` 对应的标准日期字段 - `CONFIG_<HOSPITAL>_<RESOURCE>_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 ```bash
@ -161,13 +256,13 @@ CONFIG_HOSPITAL_ADAPTER_GATEWAY_URL=http://127.0.0.1:8082
```bash ```bash
CONFIG_ZPFB_SOURCE_TYPE=view CONFIG_ZPFB_SOURCE_TYPE=view
CONFIG_ZPFB_DB_TYPE=oracle_sqlplus CONFIG_ZPFB_DB_TYPE=oracle_sqlplus
CONFIG_ZPFB_DB_HOST=172.16.1.116 CONFIG_ZPFB_DB_HOST=<db-host>
CONFIG_ZPFB_DB_PORT=1521 CONFIG_ZPFB_DB_PORT=1521
CONFIG_ZPFB_DB_NAME=sknew CONFIG_ZPFB_DB_NAME=<db-name>
CONFIG_ZPFB_DB_USER=inte CONFIG_ZPFB_DB_USER=<db-user>
CONFIG_ZPFB_DB_PASSWORD=<deploy-secret> CONFIG_ZPFB_DB_PASSWORD=<deploy-secret>
CONFIG_ZPFB_DB_ORACLE_CONNECT_MODE=service 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=<sqlplus-path>
CONFIG_ZPFB_DB_SQLPLUS_ENCODING=gbk CONFIG_ZPFB_DB_SQLPLUS_ENCODING=gbk
``` ```

View File

@ -2,6 +2,8 @@ const { build } = require("esbuild");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const buildOptions = parseBuildOptions(process.argv.slice(2));
function loadEnvFile(filePath) { function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {}; if (!fs.existsSync(filePath)) return {};
return fs.readFileSync(filePath, "utf8").split(/\r?\n/).reduce((acc, line) => { return fs.readFileSync(filePath, "utf8").split(/\r?\n/).reduce((acc, line) => {
@ -16,23 +18,64 @@ function loadEnvFile(filePath) {
}, {}); }, {});
} }
function buildEmbeddedEnvBanner() { function buildEmbeddedEnvBanner(envFilePath) {
const env = loadEnvFile(path.resolve(__dirname, ".env.production")); const env = loadEnvFile(envFilePath);
const lines = Object.entries(env).map(([key, value]) => `process.env[${JSON.stringify(key)}]=${JSON.stringify(value)};`); const lines = Object.entries(env).map(([key, value]) => `process.env[${JSON.stringify(key)}]=${JSON.stringify(value)};`);
if (!lines.length) return ""; 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({ build({
entryPoints: ["./index.js"], entryPoints: ["./index.js"],
outfile: "./dist/bundle.js", outfile: buildOptions.outfile,
platform: "node", platform: "node",
bundle: true, bundle: true,
minify: true, minify: true,
sourcemap: false, sourcemap: false,
target: ["node16", "node18", "node20"], target: ["node16", "node18", "node20"],
banner: { banner: {
js: buildEmbeddedEnvBanner(), js: buildEmbeddedEnvBanner(buildOptions.envFilePath),
}, },
external: ["mysql2", "pg"], external: ["mysql2", "pg"],
plugins: [ plugins: [

View File

@ -8,7 +8,7 @@ const { handleQuery, queryStandardResource } = require("./src/core/query-control
const { handleSync } = require("./src/sync/sync-controller"); const { handleSync } = require("./src/sync/sync-controller");
const { connectMongo, ensureIndexes, isMongoAvailable, getMongoDisabledReason } = require("./src/mongo"); const { connectMongo, ensureIndexes, isMongoAvailable, getMongoDisabledReason } = require("./src/mongo");
const { handleYktCustomerHisSync } = require("./src/ykt/customer-his-sync"); 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"); const { startEdgeClient } = require("./src/edge/client");
loadEnv(); loadEnv();
@ -43,7 +43,10 @@ app.post("/api/ykt/customerHisSync", async (req, res) => {
config.role === "gateway" config.role === "gateway"
? queryEdgeResource ? queryEdgeResource
: ({ hospitalCode, resource, query }) => queryStandardResource({ hospitalCode, resource, query, saveSnapshot: false }).then((result) => ({ status: "success", message: "查询成功", ...result })); : ({ 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); app.post("/api/his/query", handleQuery);

View File

@ -5,10 +5,13 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "node index.js", "dev": "node index.js",
"gateway": "cross-env NODE_ENV=gateway node index.js",
"pro": "cross-env NODE_ENV=production node index.js", "pro": "cross-env NODE_ENV=production node index.js",
"build": "node esbuild.config.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", "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": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",

737
src/adapters/gk/index.js Normal file
View File

@ -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,
};

View File

@ -1,6 +1,7 @@
const zpfb = require("./zpfb"); 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; acc[adapter.hospitalCode] = adapter;
return acc; return acc;
}, {}); }, {});
@ -12,4 +13,3 @@ async function getAdapter(hospitalCode) {
module.exports = { module.exports = {
getAdapter, getAdapter,
}; };

View File

@ -1,6 +1,7 @@
const WebSocket = require("ws"); const WebSocket = require("ws");
const { getConfig } = require("../core/config"); const { getConfig } = require("../core/config");
const { queryStandardResource } = require("../core/query-controller"); const { queryStandardResource } = require("../core/query-controller");
const { runYktCustomerHisSync } = require("../ykt/customer-his-sync");
let socket = null; let socket = null;
let reconnectTimer = null; let reconnectTimer = null;
@ -25,7 +26,7 @@ function connect() {
type: "register", type: "register",
edgeId: config.edgeId, edgeId: config.edgeId,
hospitalCode: config.edgeHospitalCode, hospitalCode: config.edgeHospitalCode,
capabilities: ["queryStandardResource"], capabilities: ["queryStandardResource", "customerHisSync"],
}) })
); );
}); });
@ -45,14 +46,22 @@ async function handleMessage(raw) {
const request = message.payload; const request = message.payload;
const response = { type: "response", requestId: request.requestId, payload: null }; const response = { type: "response", requestId: request.requestId, payload: null };
try { try {
if (request.action !== "queryStandardResource") throwEdgeError("不支持的 Agent 动作", "EDGE_ACTION_UNSUPPORTED"); if (request.action === "queryStandardResource") {
const result = await queryStandardResource({ const result = await queryStandardResource({
hospitalCode: request.hospitalCode, hospitalCode: request.hospitalCode,
resource: request.resource, resource: request.resource,
query: request.query || {}, query: request.query || {},
saveSnapshot: false, saveSnapshot: false,
}); });
response.payload = { status: "success", message: "查询成功", ...result }; 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) { } catch (err) {
response.payload = { status: "fail", message: err && err.message ? err.message : "Agent 查询失败", code: err && err.code, data: [] }; response.payload = { status: "fail", message: err && err.message ? err.message : "Agent 查询失败", code: err && err.code, data: [] };
} }

View File

@ -51,22 +51,44 @@ function unregisterEdge(state, ws) {
} }
async function queryEdgeResource({ hospitalCode, resource, query }) { 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); const edge = edges.get(hospitalCode);
if (!edge || edge.ws.readyState !== WebSocket.OPEN) throwGatewayError("医院 Agent 离线", "EDGE_OFFLINE"); if (!edge || edge.ws.readyState !== WebSocket.OPEN) throwGatewayError("医院 Agent 离线", "EDGE_OFFLINE");
const requestId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}${Math.random()}`; 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; const timeoutMs = getConfig().rpcTimeoutMs;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
pending.delete(requestId); pending.delete(requestId);
rejectGateway(reject, `医院 Agent 响应超时(${timeoutMs}ms)`, "EDGE_TIMEOUT"); rejectGateway(reject, `医院 Agent 响应超时(${timeoutMs}ms)`, timeoutCode);
}, timeoutMs); }, timeoutMs);
pending.set(requestId, { resolve, reject, timer }); 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; if (!err) return;
clearTimeout(timer); clearTimeout(timer);
pending.delete(requestId); 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 = { module.exports = {
attachGateway, attachGateway,
queryEdgeResource, queryEdgeResource,
queryEdgeCustomerHisSync,
listEdges, listEdges,
}; };

View File

@ -1,4 +1,5 @@
const { getConfig } = require("../core/config"); const { getConfig } = require("../core/config");
const { getAdapter } = require("../adapters");
async function handleYktCustomerHisSync(req, res, options) { async function handleYktCustomerHisSync(req, res, options) {
const result = await runYktCustomerHisSync(req.body || {}, options); const result = await runYktCustomerHisSync(req.body || {}, options);
@ -10,12 +11,18 @@ async function runYktCustomerHisSync(event = {}, options = {}) {
const type = normalizeText(event.type); const type = normalizeText(event.type);
if (!type) return fail("缺少参数: type"); if (!type) return fail("缺少参数: type");
if (type === "getHisInHospitalRecord") return successEmpty("中转平台标准资源暂未提供住院接口");
if (type === "getHisFeeRecord") return successEmpty("中转平台标准资源暂未提供费用接口");
const hospitalCode = resolveHospitalCode(event); const hospitalCode = resolveHospitalCode(event);
if (!hospitalCode) return fail("未配置中转平台医院编码"); 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 === "getHisCustomerArchive") return queryCustomerArchive({ event, hospitalCode, queryResource: options.queryResource });
if (type === "getHisOutHospitalRecord") return queryOutHospitalRecord({ event, hospitalCode, queryResource: options.queryResource }); if (type === "getHisOutHospitalRecord") return queryOutHospitalRecord({ event, hospitalCode, queryResource: options.queryResource });