feat(hospital-adapter): 新增广口 HIS 中继适配
This commit is contained in:
parent
7a9350adcf
commit
11c141fc21
9
.env.gateway.example
Normal file
9
.env.gateway.example
Normal 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
|
||||
16
.env.gk-standalone.example
Normal file
16
.env.gk-standalone.example
Normal 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
17
.env.gk.example
Normal 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
|
||||
@ -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=<db-host>
|
||||
CONFIG_ZPFB_DB_PORT=1521
|
||||
CONFIG_ZPFB_DB_NAME=sknew
|
||||
CONFIG_ZPFB_DB_USER=inte
|
||||
CONFIG_ZPFB_DB_NAME=<db-name>
|
||||
CONFIG_ZPFB_DB_USER=<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=<sqlplus-path>
|
||||
CONFIG_ZPFB_DB_SQLPLUS_ENCODING=gbk
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -3,6 +3,7 @@ dist/
|
||||
release/
|
||||
logs/
|
||||
tmp-*.log
|
||||
.env.production
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.*
|
||||
!.env.development
|
||||
!.env.production.example
|
||||
!.env.*.example
|
||||
|
||||
111
README.md
111
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_<HOSPITAL>_<RESOURCE>_ORDER_BY`: view 分页排序字段
|
||||
- `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
|
||||
@ -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=<db-host>
|
||||
CONFIG_ZPFB_DB_PORT=1521
|
||||
CONFIG_ZPFB_DB_NAME=sknew
|
||||
CONFIG_ZPFB_DB_USER=inte
|
||||
CONFIG_ZPFB_DB_NAME=<db-name>
|
||||
CONFIG_ZPFB_DB_USER=<db-user>
|
||||
CONFIG_ZPFB_DB_PASSWORD=<deploy-secret>
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@ -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: [
|
||||
|
||||
7
index.js
7
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);
|
||||
|
||||
@ -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",
|
||||
|
||||
737
src/adapters/gk/index.js
Normal file
737
src/adapters/gk/index.js
Normal 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,
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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: [] };
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 });
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user