feat: Kotlin+Solon 重构医院前置机(业务+被控端+Agent+升级流程)

主程序(com.hospital.front):
- Kotlin 2.0.21 + Solon 3.10.7 + MyBatis-Plus + HikariCP + Logback
- HospitalAdapter 策略模式:5 家医院(xinglongyuan/qingyang/ningxia/yanan/jinghe)+ dev Mock
- 三方言 Mapper:bsm(Oracle)/ nx / ya(SqlServer),存储过程参数原样迁移
- 20 个业务接口(/order/*)+ 11 个被控端接口(/api/admin/*,Token 鉴权+脱敏+防穿越)
- 全局异常处理、Result 统一响应、业务开关热更新

Agent(com.hospital.agent,独立进程,单 jar 双 main):
- WebSocket 主动出站连 B 端(指数退避重连),心跳转发主程序健康
- ProcessGuard 看门狗:3 次探测失败自动拉起主程序
- UpgradeCommand:下载→SHA256 校验→备份→替换→拉起→健康检查→失败自动回滚
- 命令分发:PING/日志/配置/重启等 10 类命令

部署(pac/):
- Windows Server 脚本(start.bat/agent.bat/WinSW 服务),纯 ASCII 注释防 GBK 错乱
- 5 家医院配置模板 + agent.properties

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-21 12:35:05 +08:00
co-authored by Claude
parent b6c6b436c3
commit 535162e63a
49 changed files with 4893 additions and 25 deletions
+52 -23
View File
@@ -1,28 +1,57 @@
# ---> Kotlin
# Compiled class file
# 编译产物
target/
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
# 运行时目录(本地联调用,不入 Git
bin/
conf/
pid/
log/
logs/
tmp/
backup/
# Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects
# IDE
.idea/
*.iml
*.iws
*.ipr
.vscode/
.project
.classpath
.settings/
# 系统文件
.DS_Store
Thumbs.db
desktop.ini
# 日志
*.log
logs/
# 外置配置(部署时单独注入,不入 Git)
pac/conf/*.yml
pac/log/
pac/backup/
pac/pid/
pac/tmp/
# 临时文件
*.tmp
*.bak
*.swp
*~
# Maven
.mvn/
mvnw
mvnw.cmd
# Kotlin
.kotlin/
# JDK
*.jdk
*.jre
+144 -2
View File
@@ -1,3 +1,145 @@
# hospital-front
# Hospital Front — 医院前置机程序
医院前置机程序
> 基于 Kotlin + Solon 4.0.6 重构的医院前置机对接系统
>
> 重构源:[hospitalmiddle](../hospitalmiddle)SpringBoot 2.7 + MyBatis-Plus + Oracle/SqlServer
>
> 完整方案:[E:\data\ob_data\myob\11 健康长庆\医院前置机重构\](../ob_data)
---
## 1. 项目简介
体检预约系统通过本程序与多家医院 HIS 系统对接,实现:
- 体检预约的提交、取消
- 体检项目查询、状态查询
- 体检结果/结论/建议的获取
- 单位信息同步
**支持 5 家医院**(拼音编码):
| 编码 | 医院名 | 数据库 |
|---|---|---|
| `xinglongyuan` | 兴隆园医院 | Oracle |
| `qingyang` | 庆阳医院 | Oracle |
| `ningxia` | 宁夏宝石花医院 | SqlServer |
| `yanan` | 延安医院 | SqlServer |
| `jinghe` | 泾河医院 | Oracle |
---
## 2. 技术栈
- **语言**Kotlin 2.0.21`jvmTarget=17`
- **框架**Solon 4.0.6(国产轻量级 Java 框架)
- **JDK**17 LTS(推荐)或 21 LTS
- **ORM**MyBatis-Plus 3.5.9
- **数据库**Oracle / SqlServer(直连医院库,不可修改)
- **日志**Logback 1.5.12
- **HTTP/WS**OkHttp 4.12.0
- **构建**Maven 3.9+
> ✅ **依赖瘦身**:不接入 Knife4j / Druid / Lombok / Jasypt(详见 [01-技术栈选型.md](../ob_data/01-技术栈选型.md)
---
## 3. 项目结构
```
hospital-front/
├── pom.xml
├── README.md
├── .gitignore
├── src/
│ ├── main/
│ │ ├── kotlin/com/hospital/front/
│ │ │ ├── HospitalFrontApp.kt # @SolonMain 启动类
│ │ │ ├── adapter/ # 医院适配器(策略模式)
│ │ │ ├── api/ # HTTP 接口层(按业务域)
│ │ │ ├── service/ # 业务服务层
│ │ │ ├── mapper/ # 数据访问层
│ │ │ ├── vo/ # 值对象
│ │ │ ├── dto/ # 数据传输对象
│ │ │ ├── infra/ # 基础设施
│ │ │ └── task/ # 定时任务
│ │ └── resources/
│ │ ├── app.yml # Solon 主配置
│ │ ├── logback.xml # 日志配置
│ │ └── mapper/ # MyBatis XML
│ └── test/kotlin/ # 单元测试
└── pac/ # 部署包(运行时外置)
├── bin/ # 启动脚本
├── conf/ # 外置配置
├── backup/ # 历史版本
├── log/ # 日志
├── pid/ # PID 文件
└── tmp/ # 临时文件
```
---
## 4. 构建
```bash
mvn clean package -DskipTests
# 产出:target/hospital-front-1.0.0.jar
```
---
## 5. 启动(开发环境)
```bash
# 拷贝外置配置(首次启动)
cp -r pac/conf-example pac/conf
# 编辑配置
vim pac/conf/app-jinghe.yml
# 启动(默认加载 app.yml + conf/app-{HOSPITAL}.yml
HOSPITAL=jinghe java -jar target/hospital-front-1.0.0.jar
```
---
## 6. 部署
详细部署、升级、回滚 SOP[05-部署运维.md](../ob_data/05-部署运维.md)
**Windows Server 简版**
```batch
D:
cd D:\hospital-front\bin
call start.bat start
```
---
## 7. 文档
完整设计文档位于:`E:\data\ob_data\myob\11 健康长庆\医院前置机重构\`
| 文档 | 内容 |
|---|---|
| [00-总体方案.md](../ob_data/00-总体方案.md) | 整体方案 |
| [01-技术栈选型.md](../ob_data/01-技术栈选型.md) | Solon 选型、版本、注解对照 |
| [02-医院差异化矩阵.md](../ob_data/02-医院差异化矩阵.md) | 5 家医院差异、Adapter 拆分 |
| [03-架构设计.md](../ob_data/03-架构设计.md) | 系统架构、模块、时序 |
| [04-数据流与接口契约.md](../ob_data/04-数据流与接口契约.md) | 接口清单、入参出参 |
| [05-部署运维.md](../ob_data/05-部署运维.md) | 部署、升级、回滚 SOP |
| [06-变更记录.md](../ob_data/06-变更记录.md) | 所有变更登记 |
---
## 8. 任务进度
| Step | 内容 | 状态 |
|---|---|---|
| 1 | 工程骨架 | ✅ |
| 2 | 5 医院配置中心 | ⏳ |
| 3 | 业务接口 Kotlin 重写 | ⏳ |
| 4 | 5 套 Mapper 适配 | ⏳ |
| 5 | 主程序被控端 API | ⏳ |
| 6 | Agent 工程 | ⏳ |
| 7 | 升级流程 | ⏳ |
| 8 | 日志推送 | ⏳ |
| 9 | B 端管理后台 | ⏳ |
| 10 | 联调 + 文档 | ⏳ |
+49
View File
@@ -0,0 +1,49 @@
# 外置配置示例(部署时拷贝到 conf\)
> **使用流程**
> 1. 首次部署:把整个 `conf-example\` 目录拷贝为 `conf\`
> ```
> xcopy /E /I conf-example conf
> ```
> 2. 编辑 `conf\app-{编码}.yml`,把 `CHANGE_ME` 替换为真实密码(或通过环境变量注入)
> 3. 根据实际部署环境修改 `hospital.id`、数据库 URL 等
>
> **本目录不入 Git**(已在 `.gitignore` 中声明 `pac/conf/*.yml`)。
## 5 家医院配置清单
| 医院 | 文件 | 数据库 | 默认端口 | `hm.type` |
|---|---|---|---|---|
| 兴隆园医院 | `app-xinglongyuan.yml` | Oracle | 8082 | `bsm` |
| 庆阳医院 | `app-qingyang.yml` | Oracle | 8082 | `bsm` |
| 宁夏宝石花医院 | `app-ningxia.yml` | SqlServer | 8082 | `nx` |
| 延安医院 | `app-yanan.yml` | SqlServer | 8082 | `ya` |
| 泾河医院 | `app-jinghe.yml` | Oracle | 8082 | `bsm` |
## 切换医院
```batch
# 启动泾河(默认)
bin\start.bat start
# 启动其他医院
set HOSPITAL=xinglongyuan
bin\start.bat start
```
## 环境变量占位
配置文件中使用 `${ENV_VAR:DEFAULT}` 占位语法,例如:
```yaml
password: ${DB_PWD_JINGHE:CHANGE_ME}
```
启动前设置环境变量(推荐,避免明文写密码):
```batch
set DB_PWD_JINGHE=ActualPassword
bin\start.bat start
```
> **安全提示**
> 1. 部署后 `chmod 600` 整个 `conf\` 目录(Linux)或 `Set-Acl`Windows PowerShell
> 2. **不要**把包含真实密码的 yml 提交到 Git
+29
View File
@@ -0,0 +1,29 @@
# ====================================
# Agent 配置(部署时拷贝到 D:\hospital-front\conf\agent.properties
# Agent 启动命令:javaw -cp bin\hospital-front.jar com.hospital.agent.AgentMainKt
# 环境变量可覆盖:AGENT_ 前缀 + 大写 + 下划线(如 AGENT_SERVER_URL
# ====================================
# B 端管理服务器 WebSocket 地址
server.url=ws://127.0.0.1:9091/ws
# 设备编号(每台前置机唯一,建议按医院+序号命名)
device.id=jinghe-01
# 设备鉴权 Token(与 B 端注册信息一致)
device.token=
# 主程序健康检查地址
main.health.url=http://127.0.0.1:8082/api/admin/health
# 主程序管理接口 Token(与主程序 app.yml 的 admin.token 一致)
main.admin.token=
# 主程序启动命令(宕机拉起用;Windows 用 .batLinux 用 .sh
main.start.cmd=bin/start.bat
# 心跳间隔(秒)
heartbeat.sec=30
# 是否守护主程序(宕机自动拉起)
guard.enable=true
+18
View File
@@ -0,0 +1,18 @@
# 泾河医院 → 本地 Oracle 测试库(mac204bsm 方言)
hospital:
active: jinghe
hospital.id: 40289fa481b283a90181b340ec060001
hm:
type: bsm
ver: V2
allsecondFlag: false
occFlag: false
solon.dataSources:
db1!:
class: "com.zaxxer.hikari.HikariDataSource"
jdbcUrl: jdbc:oracle:thin:@192.168.50.204:1521/FREEPDB1
username: SYSTEM
password: ${DB_PWD:jyf@2024}
minimumIdle: 2
maximumPoolSize: 10
connectionTimeout: 30000
+18
View File
@@ -0,0 +1,18 @@
# 宁夏宝石花医院 → 本地 SqlServer 测试库(mac204nx 方言)
hospital:
active: ningxia
hospital.id: 402885e0788113ad01788124b3430007
hm:
type: nx
ver: V2
allsecondFlag: true
occFlag: false
solon.dataSources:
db1!:
class: "com.zaxxer.hikari.HikariDataSource"
jdbcUrl: jdbc:sqlserver://192.168.50.204:1433;DatabaseName=HospitalTest;trustServerCertificate=true;encrypt=false
username: sa
password: ${DB_PWD:jyf@2024}
driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
minimumIdle: 2
maximumPoolSize: 10
+32
View File
@@ -0,0 +1,32 @@
# ====================================
# 庆阳医院(先与 jinghe 一致,正式环境再调整) 配置
# 文件路径:D:\hospital-front\conf\app-qingyang.yml
# 启动:set HOSPITAL=qingyang && bin\start.bat start
# ====================================
hospital:
active: qingyang
# 医院业务 ID(HIS 系统分配,正式环境核对)
hospital.id: CHANGE_ME_HOSPITAL_ID
# 业务开关
hm:
type: bsm
ver: V2
allsecondFlag: false
occFlag: false
# 数据源(HikariCPSolon 默认连接池;db1! 中 ! 表示默认数据源)
solon.dataSources:
db1!:
class: "com.zaxxer.hikari.HikariDataSource"
jdbcUrl: jdbc:oracle:thin:@CHANGE_ME:1521/ORCL
username: CHANGE_ME
password: ${DB_PWD:CHANGE_ME}
minimumIdle: 2
maximumPoolSize: 10
connectionTimeout: 30000
# 驱动说明:Oracle 由 jdbcUrl 自动识别;SqlServer 需加 driverClassName
# Oracle 驱动按 jdbcUrl 自动识别
+32
View File
@@ -0,0 +1,32 @@
# ====================================
# 兴隆园医院 配置
# 文件路径:D:\hospital-front\conf\app-xinglongyuan.yml
# 启动:set HOSPITAL=xinglongyuan && bin\start.bat start
# ====================================
hospital:
active: xinglongyuan
# 医院业务 ID(HIS 系统分配,正式环境核对)
hospital.id: CHANGE_ME_HOSPITAL_ID
# 业务开关
hm:
type: bsm
ver: V2
allsecondFlag: false
occFlag: false
# 数据源(HikariCPSolon 默认连接池;db1! 中 ! 表示默认数据源)
solon.dataSources:
db1!:
class: "com.zaxxer.hikari.HikariDataSource"
jdbcUrl: jdbc:oracle:thin:@172.17.10.8:1521/ORCL
username: CHANGE_ME
password: ${DB_PWD:CHANGE_ME}
minimumIdle: 2
maximumPoolSize: 10
connectionTimeout: 30000
# 驱动说明:Oracle 由 jdbcUrl 自动识别;SqlServer 需加 driverClassName
# Oracle 驱动按 jdbcUrl 自动识别
+18
View File
@@ -0,0 +1,18 @@
# 延安医院 → 本地 SqlServer 测试库(mac204ya 方言)
hospital:
active: yanan
hospital.id: 402885e0788113ad01788124b3430007
hm:
type: ya
ver: V2
allsecondFlag: false
occFlag: false
solon.dataSources:
db1!:
class: "com.zaxxer.hikari.HikariDataSource"
jdbcUrl: jdbc:sqlserver://192.168.50.204:1433;DatabaseName=HospitalTest;trustServerCertificate=true;encrypt=false
username: sa
password: ${DB_PWD:jyf@2024}
driverClassName: com.microsoft.sqlserver.jdbc.SQLServerDriver
minimumIdle: 2
maximumPoolSize: 10
+235
View File
@@ -0,0 +1,235 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!--
Solon 版本说明:
- Maven Central 上 solon-parent 最新是 4.0.6(短横线命名)
- 但 mybatis-solon-plugin 4.0.6 与 solon 4.0.6 存在 ABI 不兼容(NoSuchMethodError
- 退回 solon-parent 3.10.7(点号命名,生态完整稳定,已实测可启动)
-->
<parent>
<groupId>org.noear</groupId>
<artifactId>solon-parent</artifactId>
<version>3.10.7</version>
<relativePath/>
</parent>
<groupId>com.hospital</groupId>
<artifactId>hospital-front</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>hospital-front</name>
<description>医院前置机程序(Kotlin + Solon 3.10.7</description>
<properties>
<!-- JDK 与 Kotlin(覆盖 solon-parent 的 java.version=1.8 -->
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<kotlin.version>2.0.21</kotlin.version>
<kotlin.jvmTarget>17</kotlin.jvmTarget>
<!-- 数据库驱动(solon-parent 不管理) -->
<ojdbc.version>23.3.0.23.09</ojdbc.version>
<mssql-jdbc.version>12.6.1.jre11</mssql-jdbc.version>
<sqlite-jdbc.version>3.46.1.0</sqlite-jdbc.version>
<!-- 鉴权 -->
<sa-token.version>1.39.0</sa-token.version>
</properties>
<dependencies>
<!-- ========== Solon 核心 ========== -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon</artifactId>
</dependency>
<!-- Solon Web 服务器(smart-http 异步) -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-server-smarthttp</artifactId>
</dependency>
<!-- Solon YAML 配置支持 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-config-yaml</artifactId>
</dependency>
<!-- Solon Logback 日志桥接 -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-logging-logback-jakarta</artifactId>
</dependency>
<!-- Solon 调度(@Scheduled 注解) -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-scheduling</artifactId>
</dependency>
<!-- ========== MyBatis-Plus 集成 ========== -->
<!-- Solon 官方插件(与 Solon 3.10.x ABI 兼容) -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>mybatis-plus-solon-plugin</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus</artifactId>
<version>3.5.9</version>
</dependency>
<!-- ========== 数据库驱动 ========== -->
<!-- 连接池(Solon 数据源类型) -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>
<!-- Oracle -->
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>
<version>${ojdbc.version}</version>
</dependency>
<!-- SqlServer -->
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
<version>${mssql-jdbc.version}</version>
</dependency>
<!-- SQLiteB 端 Agent 用) -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>${sqlite-jdbc.version}</version>
</dependency>
<!-- H2(开发 Mock 数据源) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.3.232</version>
<scope>runtime</scope>
</dependency>
<!-- ========== HTTP / WebSocketAgent 用) ========== -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</dependency>
<!-- ========== 鉴权(可选,按需启用) ========== -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-solon-plugin</artifactId>
<version>${sa-token.version}</version>
<optional>true</optional>
</dependency>
<!-- ========== Kotlin 标准库 ========== -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
<!-- ========== JSON 解析(Solon 生态标准库) ========== -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>snack3</artifactId>
</dependency>
<!-- ========== 测试 ========== -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>hospital-front-${project.version}</finalName>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<!-- Kotlin 编译 -->
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<configuration>
<compilerPlugins>
<plugin>spring</plugin>
</compilerPlugins>
<jvmTarget>${kotlin.jvmTarget}</jvmTarget>
</configuration>
<executions>
<execution>
<id>compile</id>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-allopen</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
<!-- 打 fat jar(包含所有依赖) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.hospital.front.HospitalFrontAppKt</mainClass>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<finalName>hospital-front-${project.version}</finalName>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,56 @@
package com.hospital.agent
import java.io.File
import java.util.Properties
/**
* Agent 配置。
*
* 从 conf/agent.properties 读取(key=value),支持环境变量覆盖(AGENT_ 前缀大写)。
* 不依赖 Solon 容器,Agent 以纯 main 方式运行。
*
* 配置项:
* - server.url B 端 WebSocket 地址(ws://host:9091/ws
* - device.id 设备编号(医院唯一,如 jinghe-01)
* - device.token 设备鉴权 Token
* - main.health.url 主程序健康检查地址(http://127.0.0.1:8082/api/admin/health
* - main.admin.token 主程序管理接口 Token(与主程序 admin.token 一致)
* - main.start.cmd 主程序启动命令(Windows: bin\start.batLinux: bin/start.sh
* - heartbeat.sec 心跳间隔(默认 30 秒)
* - guard.enable 是否守护主程序(默认 true)
*/
class AgentConfig private constructor(
val serverUrl: String,
val deviceId: String,
val deviceToken: String,
val mainHealthUrl: String,
val mainAdminToken: String,
val mainStartCmd: String,
val heartbeatSec: Long,
val guardEnabled: Boolean,
) {
companion object {
/** 从 conf/agent.properties 加载,环境变量优先 */
fun load(confPath: String = "conf/agent.properties"): AgentConfig {
val props = Properties()
val file = File(confPath)
if (file.exists()) file.inputStream().use { props.load(it) }
fun get(key: String, def: String): String {
val envKey = "AGENT_" + key.replace(".", "_").uppercase()
return System.getenv(envKey) ?: props.getProperty(key) ?: def
}
return AgentConfig(
serverUrl = get("server.url", "ws://127.0.0.1:9091/ws"),
deviceId = get("device.id", "unknown-device"),
deviceToken = get("device.token", ""),
mainHealthUrl = get("main.health.url", "http://127.0.0.1:8082/api/admin/health"),
mainAdminToken = get("main.admin.token", ""),
mainStartCmd = get("main.start.cmd", "bin/start.bat"),
heartbeatSec = get("heartbeat.sec", "30").toLong(),
guardEnabled = get("guard.enable", "true") == "true",
)
}
}
}
@@ -0,0 +1,73 @@
package com.hospital.agent
import com.hospital.agent.cmd.CommandDispatcher
import com.hospital.agent.cmd.UpgradeCommand
import com.hospital.agent.ipc.MainAppClient
import com.hospital.agent.process.ProcessGuard
import com.hospital.agent.ws.AgentClient
import org.slf4j.LoggerFactory
import java.io.File
/**
* Agent 启动入口(独立进程,不启动 Solon 容器)。
*
* 启动方式:
* java -cp hospital-front-1.0.0.jar com.hospital.agent.AgentMainKt
*
* 职责:
* 1. WebSocket 主动连接 B 端管理服务器(注册/心跳/命令)
* 2. 守护主程序(健康探测 + 宕机拉起)
* 3. 写 PID 文件(pid/hospital-agent.pid
*
* 由 WinSW Windows 服务(HospitalAgent)守护本进程。
*/
object AgentMain {
private val log = LoggerFactory.getLogger(AgentMain::class.java)
fun run() {
val config = AgentConfig.load()
log.info("Agent 启动:deviceId={}, server={}", config.deviceId, config.serverUrl)
println("[Agent] deviceId=${config.deviceId}, server=${config.serverUrl}")
// 写 PID 文件
writePidFile()
// 组装组件(client 与 dispatcher 互相引用,通过 lateinit 解耦)
val mainAppClient = MainAppClient(config)
val dispatcher = CommandDispatcher(config, mainAppClient)
val client = AgentClient(config, dispatcher)
dispatcher.client = client
val guard = ProcessGuard(config, mainAppClient, client)
// 优雅停机钩子
Runtime.getRuntime().addShutdownHook(Thread {
log.info("Agent 停机中...")
guard.stop()
client.stop()
})
client.start()
guard.start()
// 主线程保活
Thread.currentThread().join()
}
private fun writePidFile() {
try {
val dir = File("pid")
if (!dir.exists()) dir.mkdirs()
File(dir, "hospital-agent.pid").writeText(ProcessHandle.current().pid().toString())
} catch (e: Exception) {
log.warn("PID 文件写入失败:{}", e.message)
}
}
}
/**
* 主入口(顶层函数,编译为 AgentMainKt 的静态 main)。
*/
fun main() {
AgentMain.run()
}
@@ -0,0 +1,99 @@
package com.hospital.agent.cmd
import com.hospital.agent.AgentConfig
import com.hospital.agent.ipc.MainAppClient
import com.hospital.agent.ws.AgentClient
import org.noear.snack.ONode
import org.slf4j.LoggerFactory
/**
* 下行命令分发器。
*
* B 端通过 WebSocket 下发 JSON 命令:{"type":"CMD_TYPE","payload":{...}}
*
* 已支持命令:
* - PING 探活(立即回 PONG)
* - GET_HEALTH 查询主程序健康
* - GET_VERSION 查询主程序版本
* - GET_LOG_FILES 日志文件列表
* - LOG_TAIL 日志尾部(payload: file, lines
* - RESTART 重启主程序(优雅停机后由 ProcessGuard 拉起)
* - GET_CONFIG 读取医院配置文件(payload: hospital
* - SAVE_CONFIG 写回医院配置文件(payload: hospital, content
* - UPGRADE 升级主程序(Step 7 实现,当前返回未实现)
*
* 所有命令执行结果通过 AgentClient 上行(CMD_RESULT)。
*/
class CommandDispatcher(
val config: AgentConfig,
val mainAppClient: MainAppClient,
) {
private val log = LoggerFactory.getLogger(CommandDispatcher::class.java)
/** 由 AgentMain 注入(避免构造环) */
lateinit var client: AgentClient
/** 升级命令(懒加载,依赖 client 注入完成后可用) */
private val upgradeCommand: UpgradeCommand by lazy {
UpgradeCommand(config, mainAppClient, client)
}
/** 分发并执行命令 */
fun dispatch(type: String, payloadJson: String?) {
log.info("收到命令:{}", type)
val payload = payloadJson?.let { runCatching { ONode.load(it) }.getOrNull() }
val result: Map<String, Any?> = when (type) {
"PING" -> mapOf("pong" to true, "ts" to System.currentTimeMillis() / 1000)
"GET_HEALTH" -> mainAppClient.health()
"GET_VERSION" -> mainAppClient.version()
"GET_LOG_FILES" -> mapOf("ok" to true, "data" to mainAppClient.logFiles())
"LOG_TAIL" -> {
val file = payload?.get("file")?.getString() ?: "info.log"
val lines = payload?.get("lines")?.getInt() ?: 200
mapOf("ok" to true, "data" to mainAppClient.logTail(file, lines))
}
"RESTART" -> {
val ok = mainAppClient.restart()
mapOf("ok" to ok, "msg" to if (ok) "已触发优雅重启,等待 ProcessGuard 拉起" else "重启请求失败")
}
"GET_CONFIG" -> {
val hospital = payload?.get("hospital")?.getString() ?: ""
val content = mainAppClient.configFile(hospital)
mapOf("ok" to (content != null), "data" to content)
}
"SAVE_CONFIG" -> {
val hospital = payload?.get("hospital")?.getString() ?: ""
val content = payload?.get("content")?.getString() ?: ""
mapOf("ok" to mainAppClient.saveConfigFile(hospital, content))
}
"UPGRADE" -> {
// 异步执行(升级耗时长,避免阻塞 WS 消息循环)
val version = payload?.get("version")?.getString() ?: "unknown"
val downloadUrl = payload?.get("downloadUrl")?.getString()
val sha256 = payload?.get("sha256")?.getString()
if (downloadUrl.isNullOrBlank()) {
mapOf("ok" to false, "msg" to "缺少 downloadUrl")
} else {
Thread {
val result = upgradeCommand.upgrade(version, downloadUrl, sha256)
client.send("UPGRADE_RESULT", result)
}.apply { isDaemon = true }.start()
mapOf("ok" to true, "msg" to "升级已开始(异步执行,结果将通过 UPGRADE_RESULT 上报)")
}
}
else -> mapOf("ok" to false, "msg" to "未知命令:$type")
}
client.send("CMD_RESULT", mapOf("cmd" to type, "result" to result))
}
}
@@ -0,0 +1,296 @@
package com.hospital.agent.cmd
import com.hospital.agent.AgentConfig
import com.hospital.agent.ipc.MainAppClient
import com.hospital.agent.ws.AgentClient
import okhttp3.OkHttpClient
import okhttp3.Request
import org.slf4j.LoggerFactory
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/**
* 主程序升级命令。
*
* 流程(见 05-部署运维.md SOP):
* 1. 从 B 端下载新 jar 到 tmp(SHA-256 校验,防传输损坏)
* 2. 备份当前 bin/hospital-front.jar → backup/hospital-front-{时间戳}.jar(保留最近 5 个)
* 3. 优雅停止主程序(调 /api/admin/restart;超时强杀)
* 4. 替换 jar
* 5. 调启动命令拉起主程序
* 6. 轮询健康检查(最长 90 秒)
* 7. 成功 → 上报;失败 → 用备份 jar 自动回滚并重启 → 上报
*
* 期间整个流程串行(upgradeLock 防并发升级)。
*/
class UpgradeCommand(
private val config: AgentConfig,
private val mainAppClient: MainAppClient,
private val client: AgentClient,
) {
private val log = LoggerFactory.getLogger(UpgradeCommand::class.java)
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS) // 大文件下载
.build()
/** 升级互斥锁(同一时刻只允许一个升级在跑) */
private val upgradeLock = Any()
/** 备份保留数量 */
private val keepBackups = 5
/**
* 执行升级。
*
* @param version 新版本号(仅用于日志与上报)
* @param downloadUrl B 端提供的 jar 下载地址(A 主动出站拉取)
* @param sha256 可选校验值
*/
fun upgrade(version: String, downloadUrl: String, sha256: String?): Map<String, Any?> = synchronized(upgradeLock) {
log.info("开始升级到版本 {}{}", version, downloadUrl)
client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "download"))
// ===== 1. 下载 =====
val tmpJar = File("tmp/upgrade-$version.jar")
tmpJar.parentFile?.mkdirs()
try {
http.newCall(Request.Builder().url(downloadUrl).get().build()).execute().use { resp ->
if (!resp.isSuccessful) return fail(version, "下载失败:HTTP ${resp.code}")
resp.body?.byteStream()?.use { input ->
tmpJar.outputStream().use { output -> input.copyTo(output) }
} ?: return fail(version, "下载失败:响应体为空")
}
} catch (e: Exception) {
return fail(version, "下载异常:${e.message}")
}
log.info("下载完成:{}{} 字节)", tmpJar.name, tmpJar.length())
// ===== 2. 校验 =====
if (sha256 != null) {
val actual = sha256File(tmpJar)
if (!actual.equals(sha256, ignoreCase = true)) {
tmpJar.delete()
return fail(version, "SHA-256 校验失败:expected=$sha256, actual=$actual")
}
log.info("SHA-256 校验通过")
}
// ===== 3. 备份 =====
val jarFile = File("bin/hospital-front.jar")
val backupFile = File("backup/hospital-front-${System.currentTimeMillis()}.jar")
backupFile.parentFile?.mkdirs()
if (jarFile.exists()) {
jarFile.copyTo(backupFile, overwrite = true)
log.info("已备份当前版本 → {}", backupFile.name)
cleanOldBackups()
}
// ===== 4. 停止主程序 =====
client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "stop"))
stopMainApp()
log.info("主程序已停止")
// ===== 5. 替换 =====
// 注意:Agent 自身以 -cp bin/hospital-agent.jar(主 jar 的副本)运行,
// 因此可以安全替换 bin/hospital-front.jar;替换后同步刷新 Agent 副本
// (Agent 副本在下次重启时生效,不影响本次升级)。
try {
copyWithRetry(tmpJar, jarFile)
tmpJar.delete()
copyWithRetry(jarFile, File("bin/hospital-agent.jar"))
} catch (e: Exception) {
// 替换失败(极少见):直接回滚
rollback(backupFile)
return fail(version, "替换 jar 失败,已回滚:${e.message}")
}
log.info("jar 已替换(含 Agent 副本)")
// ===== 6. 拉起 + 健康检查 =====
client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "restart"))
startMainApp()
client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "healthcheck"))
if (waitHealthy(90)) {
log.info("升级成功:{} ", version)
return mapOf(
"ok" to true,
"version" to version,
"msg" to "升级成功",
)
}
// ===== 7. 健康检查失败 → 回滚 =====
log.error("新版本健康检查失败,自动回滚到 {}", backupFile.name)
client.send("UPGRADE_PROGRESS", mapOf("version" to version, "stage" to "rollback"))
stopMainApp()
rollback(backupFile)
startMainApp()
val recovered = waitHealthy(90)
return if (recovered) {
fail(version, "新版本启动失败,已自动回滚到 ${backupFile.name} 并恢复服务")
} else {
client.send("ALERT", mapOf(
"level" to "CRITICAL",
"msg" to "升级失败且回滚后仍不健康,请人工介入!版本=$version",
))
fail(version, "升级失败且回滚后仍不健康,请人工介入")
}
}
// ================= 内部步骤 =================
/** 停止主程序:优先优雅(admin API),超时后按 PID 强杀;确保进程完全退出(jar 锁释放) */
private fun stopMainApp() {
val pid = readPidFile()
if (mainAppClient.restart(delayMs = 100)) {
// 等待端口关闭(最长 30 秒)
val deadline = System.currentTimeMillis() + 30_000
while (System.currentTimeMillis() < deadline) {
if (mainAppClient.health()["ok"] != true) break
Thread.sleep(1000)
}
}
// 兜底:按 PID 文件强杀
killByPidFile("pid/hospital-front.pid")
// 等待进程真正退出(Windows 下进程退出后才释放 jar 文件锁)
if (pid != null) waitProcessExit(pid, 15)
Thread.sleep(1000)
}
/** 读 PID 文件 */
private fun readPidFile(): String? = try {
val f = File("pid/hospital-front.pid")
if (f.exists()) f.readText().trim().ifEmpty { null } else null
} catch (_: Exception) {
null
}
/** 轮询等待进程退出(tasklist 查不到即退出) */
private fun waitProcessExit(pid: String, timeoutSec: Long) {
val os = System.getProperty("os.name").lowercase()
val deadline = System.currentTimeMillis() + timeoutSec * 1000
while (System.currentTimeMillis() < deadline) {
val alive = try {
if (os.contains("win")) {
ProcessBuilder("tasklist", "/FI", "PID eq $pid")
.start().inputStream.use { it.readBytes().toString(Charsets.UTF_8).contains(pid) }
} else {
ProcessBuilder("kill", "-0", pid).start().waitFor() == 0
}
} catch (_: Exception) {
false
}
if (!alive) return
Thread.sleep(500)
}
}
/** 拉起主程序 */
private fun startMainApp() {
try {
val cmd = config.mainStartCmd.replace("/", "\\") // cmd/c 要求反斜杠路径
val pb = if (cmd.endsWith(".bat", ignoreCase = true)) {
ProcessBuilder("cmd", "/c", cmd, "start")
} else {
ProcessBuilder("sh", cmd, "start")
}
pb.directory(File(".").absoluteFile)
pb.redirectErrorStream(true)
val proc = pb.start()
log.info("主程序拉起命令已执行,pid={}", proc.pid())
} catch (e: Exception) {
log.error("主程序拉起失败:{}", e.message)
}
}
/** 轮询等待主程序健康 */
private fun waitHealthy(timeoutSec: Long): Boolean {
val deadline = System.currentTimeMillis() + timeoutSec * 1000
while (System.currentTimeMillis() < deadline) {
if (mainAppClient.health()["ok"] == true) return true
Thread.sleep(3000)
}
return false
}
/** 回滚:备份 jar 覆盖回 bin(含 Agent 副本) */
private fun rollback(backupFile: File) {
try {
if (backupFile.exists()) {
val jarFile = File("bin/hospital-front.jar")
copyWithRetry(backupFile, jarFile)
copyWithRetry(jarFile, File("bin/hospital-agent.jar"))
log.info("已回滚到 {}", backupFile.name)
}
} catch (e: Exception) {
log.error("回滚失败:{}", e.message)
}
}
/** 带重试的文件覆盖(Windows 下进程退出有延迟,锁释放需等待) */
private fun copyWithRetry(src: File, dst: File, retries: Int = 10) {
var lastError: Exception? = null
repeat(retries) {
try {
if (dst.exists()) dst.delete()
if (!dst.exists()) {
src.copyTo(dst)
return
}
} catch (e: Exception) {
lastError = e
}
Thread.sleep(1000)
}
throw lastError ?: IllegalStateException("文件替换失败(重试 $retries 次):${dst.name}")
}
/** 清理旧备份(保留最近 N 个) */
private fun cleanOldBackups() {
val dir = File("backup")
val backups = dir.listFiles { f -> f.name.startsWith("hospital-front-") }
?.sortedByDescending { it.name } ?: return
backups.drop(keepBackups).forEach { it.delete() }
}
/** 按 PID 文件强杀进程 */
private fun killByPidFile(pidFile: String) {
try {
val f = File(pidFile)
if (!f.exists()) return
val pid = f.readText().trim()
if (pid.isEmpty()) return
val os = System.getProperty("os.name").lowercase()
if (os.contains("win")) {
ProcessBuilder("taskkill", "/PID", pid, "/T", "/F").start().waitFor()
} else {
ProcessBuilder("kill", "-9", pid).start().waitFor()
}
} catch (e: Exception) {
log.warn("PID 强杀失败:{}", e.message)
}
}
/** 文件 SHA-256 */
private fun sha256File(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buf = ByteArray(8192)
while (true) {
val n = input.read(buf)
if (n < 0) break
digest.update(buf, 0, n)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
/** 统一失败上报 */
private fun fail(version: String, msg: String): Map<String, Any?> {
log.error("升级失败:{}", msg)
return mapOf("ok" to false, "version" to version, "msg" to msg)
}
}
@@ -0,0 +1,101 @@
package com.hospital.agent.ipc
import com.hospital.agent.AgentConfig
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.noear.snack.ONode
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
/**
* 主程序 IPC 客户端(环回 HTTP)。
*
* Agent 通过本机 HTTP 调用主程序被控端 API/api/admin)。
*/
class MainAppClient(private val config: AgentConfig) {
private val log = LoggerFactory.getLogger(MainAppClient::class.java)
private val http = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/** 主程序健康检查:返回解析后的 JSON 字符串;不可达返回 {"ok":false,"error":"..."} */
fun health(): Map<String, Any?> {
val body = getJson(config.mainHealthUrl) ?: return mapOf("ok" to false, "error" to "主程序不可达")
return try {
val node = ONode.load(body)
mapOf(
"ok" to true,
"code" to node.get("code").getInt(),
"data" to node.get("data")?.toJson(),
)
} catch (e: Exception) {
mapOf("ok" to false, "error" to "健康响应解析失败:${e.message}")
}
}
/** 主程序版本 */
fun version(): Map<String, Any?> {
val body = getJson(url("/api/admin/version")) ?: return mapOf("ok" to false)
return try {
val node = ONode.load(body)
mapOf("ok" to true, "data" to node.get("data")?.toJson())
} catch (e: Exception) {
mapOf("ok" to false, "error" to e.message)
}
}
/** 日志尾部 */
fun logTail(file: String, lines: Int): String? =
getJson(url("/api/admin/log/tail?file=$file&lines=$lines"))
/** 日志文件列表 */
fun logFiles(): String? = getJson(url("/api/admin/log/files"))
/** 触发主程序优雅重启 */
fun restart(delayMs: Long = 1000): Boolean =
post(url("/api/admin/restart?delayMs=$delayMs"), "") != null
/** 读取医院配置文件 */
fun configFile(hospital: String): String? =
getJson(url("/api/admin/config/file?hospital=$hospital"))
/** 写回医院配置文件 */
fun saveConfigFile(hospital: String, content: String): Boolean {
val body = post(url("/api/admin/config/file"), "hospital=$hospital&content=$content")
return body != null
}
// ================= 基础 HTTP =================
private fun url(path: String): String {
val base = config.mainHealthUrl.substringBefore("/api/")
return base + path
}
private fun getJson(url: String): String? = try {
http.newCall(
Request.Builder().url(url)
.header("X-Admin-Token", config.mainAdminToken)
.get().build()
).execute().use { resp -> if (resp.isSuccessful) resp.body?.string() else null }
} catch (e: Exception) {
log.debug("GET {} 失败:{}", url, e.message)
null
}
private fun post(url: String, formBody: String): String? = try {
http.newCall(
Request.Builder().url(url)
.header("X-Admin-Token", config.mainAdminToken)
.post(formBody.toRequestBody(null))
.build()
).execute().use { resp -> if (resp.isSuccessful) resp.body?.string() else null }
} catch (e: Exception) {
log.debug("POST {} 失败:{}", url, e.message)
null
}
}
@@ -0,0 +1,93 @@
package com.hospital.agent.process
import com.hospital.agent.AgentConfig
import com.hospital.agent.ipc.MainAppClient
import com.hospital.agent.ws.AgentClient
import org.slf4j.LoggerFactory
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* 主程序进程守护(看门狗)。
*
* 周期探测主程序健康:
* - 连续 3 次失败 → 认为主程序宕机 → 强杀残留 → 调启动命令拉起
* - 拉起后等待健康恢复;连续 3 次拉起失败 → 上报告警(ALERT)
*/
class ProcessGuard(
private val config: AgentConfig,
private val mainAppClient: MainAppClient,
private val client: AgentClient,
) {
private val log = LoggerFactory.getLogger(ProcessGuard::class.java)
private val failCount = AtomicInteger(0)
private val restartCount = AtomicInteger(0)
private val scheduler = Executors.newSingleThreadScheduledExecutor()
/** 启动守护(默认 15 秒探测一次) */
fun start() {
if (!config.guardEnabled) {
log.info("进程守护未启用(guard.enable=false")
return
}
scheduler.scheduleWithFixedDelay({ check() }, 30, 15, TimeUnit.SECONDS)
log.info("进程守护已启动:每 15 秒探测 {}", config.mainHealthUrl)
}
fun stop() {
scheduler.shutdownNow()
}
private fun check() {
val health = mainAppClient.health()
val ok = health["ok"] == true
if (ok) {
failCount.set(0)
return
}
val fails = failCount.incrementAndGet()
log.warn("主程序健康探测失败({}/3):{}", fails, health["error"])
if (fails < 3) return
// 连续 3 次失败 → 拉起
failCount.set(0)
val attempts = restartCount.incrementAndGet()
if (attempts > 3) {
client.send("ALERT", mapOf(
"level" to "CRITICAL",
"msg" to "主程序连续 $attempts 次拉起失败,请人工介入",
))
// 重置计数,之后继续尝试(降频可后续优化)
restartCount.set(3)
return
}
log.error("主程序宕机,执行第 {} 次拉起:{}", attempts, config.mainStartCmd)
client.send("ALERT", mapOf(
"level" to "WARN",
"msg" to "主程序宕机,Agent 正在执行第 $attempts 次拉起",
))
run {
try {
val cmd = config.mainStartCmd.replace("/", "\\") // cmd/c 要求反斜杠路径
val isBat = cmd.endsWith(".bat", ignoreCase = true)
val pb = if (isBat) {
ProcessBuilder("cmd", "/c", cmd, "start")
} else {
ProcessBuilder("sh", cmd, "start")
}
pb.directory(File(".").absoluteFile)
pb.redirectErrorStream(true)
val proc = pb.start()
log.info("启动命令已执行,pid={}", proc.pid())
} catch (e: Exception) {
log.error("启动命令执行失败:{}", e.message)
}
}
}
}
@@ -0,0 +1,145 @@
package com.hospital.agent.ws
import com.hospital.agent.AgentConfig
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.noear.snack.ONode
import org.slf4j.LoggerFactory
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* Agent WebSocket 客户端。
*
* - 主动出站连接 B 端(穿透白名单,无需 A 端开放入站端口)
* - 断线指数退避重连(1s → 2s → 4s → ... 最大 60s
* - 周期心跳:拉取主程序 /api/admin/health 并转发给 B 端
* - 收到下行命令交给 CommandDispatcher 处理
*
* 上行消息格式:{"type":"HEARTBEAT","deviceId":"...","ts":...,"payload":{...}}
*/
class AgentClient(
private val config: AgentConfig,
private val dispatcher: com.hospital.agent.cmd.CommandDispatcher,
) {
private val log = LoggerFactory.getLogger(AgentClient::class.java)
private val client = OkHttpClient.Builder()
.pingInterval(25, TimeUnit.SECONDS) // 协议层心跳
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 不设读超时
.build()
private var webSocket: WebSocket? = null
private val connected = AtomicBoolean(false)
private val reconnectAttempts = AtomicInteger(0)
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
/** 启动:连接 + 心跳调度 */
fun start() {
connect()
scheduler.scheduleWithFixedDelay(
{ heartbeat() },
config.heartbeatSec, config.heartbeatSec, TimeUnit.SECONDS,
)
}
/** 停止并释放资源 */
fun stop() {
scheduler.shutdownNow()
webSocket?.close(1000, "agent stopping")
}
// ================= 连接管理 =================
private fun connect() {
if (connected.get()) return
log.info("连接管理服务器:{}", config.serverUrl)
val request = Request.Builder()
.url(config.serverUrl)
.header("X-Device-Id", config.deviceId)
.header("X-Device-Token", config.deviceToken)
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
connected.set(true)
reconnectAttempts.set(0)
log.info("已连接管理服务器")
send("REGISTER", mapOf(
"deviceId" to config.deviceId,
"version" to "1.0.0",
))
}
override fun onMessage(ws: WebSocket, text: String) {
try {
val node = ONode.load(text)
val type = node.get("type").getString()
val payload = node.get("payload")?.toJson()
dispatcher.dispatch(type, payload)
} catch (e: Exception) {
log.warn("命令处理异常:{}", e.message)
}
}
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
connected.set(false)
log.info("连接关闭:code={}, reason={}", code, reason)
scheduleReconnect()
}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
connected.set(false)
log.warn("连接失败:{},将重连", t.message)
scheduleReconnect()
}
})
}
/** 指数退避重连:1s 起,翻倍至 60s 封顶 */
private fun scheduleReconnect() {
val attempt = reconnectAttempts.incrementAndGet()
val delay = (1000L shl (attempt - 1).coerceAtMost(6)).coerceAtMost(60_000L)
log.info("{}ms 后进行第 {} 次重连", delay, attempt)
scheduler.schedule({ connect() }, delay, TimeUnit.MILLISECONDS)
}
// ================= 上行消息 =================
/** 心跳:拉主程序健康数据转发 */
private fun heartbeat() {
val mainHealth = dispatcher.mainAppClient.health()
send("HEARTBEAT", mapOf(
"deviceId" to config.deviceId,
"mainApp" to mainHealth,
))
}
/** 发送上行消息(未连接时丢弃并记日志) */
fun send(type: String, payload: Any?) {
val ws = webSocket ?: run {
log.debug("未连接,丢弃上行消息:{}", type)
return
}
val json = ONode.load(
linkedMapOf(
"type" to type,
"deviceId" to config.deviceId,
"ts" to System.currentTimeMillis() / 1000,
"payload" to payload,
)
).toJson()
ws.send(json)
}
/** 当前是否已连接(供健康状态展示) */
fun isConnected(): Boolean = connected.get()
}
@@ -0,0 +1,40 @@
package com.hospital.front
import org.noear.solon.Solon
import org.noear.solon.annotation.SolonMain
/**
* 医院前置机主程序启动类。
*
* 使用 Solon 3.x 框架,对外暴露体检预约业务接口(/order 路径)
* 与被控端接口(/api/admin 路径)。
*
* 启动命令示例:
* java -Xms256m -Xmx512m -Dsolon.env=jinghe -Dsolon.config.load=app.yml,conf/app-jinghe.yml -jar hospital-front-1.0.0.jar
*/
@SolonMain
class HospitalFrontApp
/**
* 主入口(顶层函数,编译为 HospitalFrontAppKt 的静态 main)。
*/
fun main(args: Array<String>) {
Solon.start(HospitalFrontApp::class.java, args)
val pid = ProcessHandle.current().pid()
println("[HospitalFront] 启动完成,PID=$pid")
// 写 PID 文件(供 start.bat stop / Agent 升级流程强杀使用)
try {
val dir = java.io.File("pid")
if (!dir.exists()) dir.mkdirs()
java.io.File(dir, "hospital-front.pid").writeText(pid.toString())
} catch (_: Exception) {
}
// 注册停机钩子:清理 PID + 记录优雅退出日志
Runtime.getRuntime().addShutdownHook(Thread {
try {
java.io.File("pid/hospital-front.pid").delete()
} catch (_: Exception) {
}
println("[HospitalFront] 优雅停机中...")
})
}
@@ -0,0 +1,89 @@
package com.hospital.front.adapter
import com.hospital.front.dto.ProcResult
import com.hospital.front.dto.Result
import com.hospital.front.mapper.bsm.BsmOrderMapper
import com.hospital.front.vo.UnitVo
import org.noear.solon.annotation.Inject
/**
* BSM 方言适配器基类(Oracle)。
*
* 适用医院:xinglongyuan(兴隆园)/ jinghe(泾河)/ qingyang(庆阳)。
* 由具体医院子类提供 code / displayName,其余行为完全一致。
*/
abstract class BsmAdapterBase : HospitalAdapter {
/** 数据库 MapperSolon 按类型注入) */
@Inject
protected lateinit var mapper: BsmOrderMapper
override fun hmType(): String = "bsm"
override fun healthCheck(): Result<Boolean> = try {
Result.success(mapper.ping() == 1)
} catch (e: Exception) {
Result.error(Result.CODE_DB_UNREACHABLE, "数据库连接失败:${e.message}")
}
override fun saveOrder(peInfoStr: String, peItemStr: String): ProcResult {
val map = mutableMapOf<String, Any?>("peInfoStr" to peInfoStr, "peItemStr" to peItemStr)
mapper.saveOrder(map)
return ProcResult(
resultCode = map["resultCode"]?.toString(),
errorMsg = map["errorMsg"]?.toString(),
peId = map["peId"]?.toString(),
)
}
override fun cancelOrder(peId: String, name: String, idcard: String): ProcResult {
val map = mutableMapOf<String, Any?>("peId" to peId, "name" to name, "idcard" to idcard)
mapper.cancelOrder(map)
return ProcResult(
resultCode = map["resultCode"]?.toString(),
errorMsg = map["errorMsg"]?.toString(),
)
}
override fun addUnit(unit: UnitVo, useV2: Boolean): ProcResult {
val map = mutableMapOf<String, Any?>(
"unitCode" to unit.unitCode,
"unitName" to unit.unitName,
"parentUnitCode" to unit.parentUnitCode,
"address" to unit.address,
"connecter1" to unit.connecter1,
"phone1" to unit.phone1,
"connecter2" to unit.connecter2,
"phone2" to unit.phone2,
)
if (useV2) mapper.addUnitV2(map) else mapper.addUnit(map)
return ProcResult(
resultCode = map["resultCode"]?.toString(),
errorMsg = map["errorMsg"]?.toString(),
)
}
override fun getPhyexamStatus(peIds: String) = mapper.getPhyexamStatus(peIds)
override fun getItemList() = mapper.getItemList()
override fun getAllItemList() = mapper.getAllItemList()
override fun getPeItemList() = mapper.getPeItemList()
override fun getItemInfo(peItemCode: String) = mapper.getItemInfo(peItemCode)
override fun getEmpItem(peId: String) = mapper.getEmpItem(peId)
override fun getResult(peId: String) = mapper.getResult(peId)
override fun getConclusion(peId: String) = mapper.getConclusion(peId)
override fun getConclusionAll(peId: String) = mapper.getConclusionAll(peId)
override fun getSuggest(peId: String) = mapper.getSuggest(peId)
override fun getSuggestAll(peId: String) = mapper.getSuggestAll(peId)
override fun getPeStatusByIdnoAndYear(idNo: String, year: String) = mapper.getPeStatusByIdnoAndYear(idNo, year)
override fun getResultByIdcardAndYear(idNo: String, year: String) = mapper.getResultByIdcardAndYear(idNo, year)
override fun getResultByIdcardAndYearAll(idNo: String, year: String) = mapper.getResultByIdcardAndYearAll(idNo, year)
override fun getResultByPeId(peId: String) = mapper.getResultByPeId(peId)
override fun getDisResultByAuditDate(auditDate: String) = mapper.getDisResultByAuditDate(auditDate)
override fun getItemByPeId(peId: String) = mapper.getItemByPeId(peId)
override fun getItemByPeIdAll(peId: String) = mapper.getItemByPeIdAll(peId)
override fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String) =
mapper.getResultByIdNoOrPeId(idNo, peId, auditYear)
override fun getPeIdAndDateByIdcard(idNo: String) = mapper.getPeIdAndDateByIdcard(idNo)
override fun getPeIdAndDateByIdcardList(idNo: String) = mapper.getPeIdAndDateByIdcardList(idNo)
override fun getExamListByDate(auditDate: String?) = mapper.getExamListByDate(auditDate)
}
@@ -0,0 +1,56 @@
package com.hospital.front.adapter
import com.hospital.front.dto.ProcResult
import com.hospital.front.dto.Result
import com.hospital.front.vo.UnitVo
import org.noear.solon.annotation.Component
/**
* 开发环境适配器(hospital.active=dev 时激活)。
*
* 不连接任何数据库:查询一律返回空列表,存储过程一律返回失败。
* 用于本机无医院数据库时的启动与联调。
*/
@Component
class DevAdapter : HospitalAdapter {
override fun code(): String = "dev"
override fun displayName(): String = "开发环境(Mock"
override fun hmType(): String = "dev"
override fun healthCheck(): Result<Boolean> = Result.success(true)
override fun saveOrder(peInfoStr: String, peItemStr: String): ProcResult =
ProcResult(resultCode = "0", errorMsg = "开发环境不支持预约提交")
override fun cancelOrder(peId: String, name: String, idcard: String): ProcResult =
ProcResult(resultCode = "0", errorMsg = "开发环境不支持取消预约")
override fun addUnit(unit: UnitVo, useV2: Boolean): ProcResult =
ProcResult(resultCode = "0", errorMsg = "开发环境不支持单位同步")
override fun getPhyexamStatus(peIds: String): List<Map<String, Any?>> = emptyList()
override fun getItemList(): List<Map<String, Any?>> = emptyList()
override fun getAllItemList(): List<Map<String, Any?>> = emptyList()
override fun getPeItemList(): List<Map<String, Any?>> = emptyList()
override fun getItemInfo(peItemCode: String): List<Map<String, Any?>> = emptyList()
override fun getEmpItem(peId: String): List<Map<String, Any?>> = emptyList()
override fun getResult(peId: String): List<Map<String, Any?>> = emptyList()
override fun getConclusion(peId: String): List<Map<String, Any?>> = emptyList()
override fun getConclusionAll(peId: String): List<Map<String, Any?>> = emptyList()
override fun getSuggest(peId: String): List<Map<String, Any?>> = emptyList()
override fun getSuggestAll(peId: String): List<Map<String, Any?>> = emptyList()
override fun getPeStatusByIdnoAndYear(idNo: String, year: String): Map<String, Any?>? = null
override fun getResultByIdcardAndYear(idNo: String, year: String): List<Map<String, Any?>> = emptyList()
override fun getResultByIdcardAndYearAll(idNo: String, year: String): List<Map<String, Any?>> = emptyList()
override fun getResultByPeId(peId: String): List<Map<String, Any?>> = emptyList()
override fun getDisResultByAuditDate(auditDate: String): List<Map<String, Any?>> = emptyList()
override fun getItemByPeId(peId: String): List<Map<String, Any?>> = emptyList()
override fun getItemByPeIdAll(peId: String): List<Map<String, Any?>> = emptyList()
override fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String): List<Map<String, Any?>> = emptyList()
override fun getPeIdAndDateByIdcard(idNo: String): Map<String, Any?>? = null
override fun getPeIdAndDateByIdcardList(idNo: String): List<Map<String, Any?>> = emptyList()
override fun getExamListByDate(auditDate: String?): List<Map<String, Any?>> = emptyList()
}
@@ -0,0 +1,100 @@
package com.hospital.front.adapter
import com.hospital.front.dto.ProcResult
import com.hospital.front.dto.Result
import com.hospital.front.vo.OrderItemVo
import com.hospital.front.vo.OrderV2Vo
import com.hospital.front.vo.UnitVo
/**
* 医院适配器接口(策略模式)。
*
* 5 家医院各自实现本接口,通过 HospitalRouter 按医院编码路由。
* 所有数据库差异(Oracle/SqlServer 方言、存储过程名)均封装在实现内;
* 存储过程的入参出参契约由医院 HIS 系统约定,不可修改。
*
* 查询类方法统一返回 Map 列表(列名与医院视图/存储过程保持一致)。
*/
interface HospitalAdapter {
/** 医院编码(拼音):xinglongyuan / qingyang / ningxia / yanan / jinghe / dev */
fun code(): String
/** 医院中文名(日志与展示用) */
fun displayName(): String
/** Mapper 方言类型:bsmOracle/ nx / yaSqlServer */
fun hmType(): String
/** 轻量健康检查(SELECT 1 探测) */
fun healthCheck(): Result<Boolean>
// ================= 存储过程(写) =================
/** 提交预约(P_ADD_PE_REGIST */
fun saveOrder(peInfoStr: String, peItemStr: String): ProcResult
/** 取消预约(P_DEL_PE_REGIST */
fun cancelOrder(peId: String, name: String, idcard: String): ProcResult
/** 添加/修改单位(P_ADD_UNIT */
fun addUnit(unit: UnitVo, useV2: Boolean): ProcResult
// ================= 查询(视图) =================
/** 按体检预约 ID(逗号分隔多个)查询体检状态 */
fun getPhyexamStatus(peIds: String): List<Map<String, Any?>>
/** 医院所有体检项目(组合项) */
fun getItemList(): List<Map<String, Any?>>
/** 医院所有体检项目及指标项 */
fun getAllItemList(): List<Map<String, Any?>>
/** 医院所有体检指标项 */
fun getPeItemList(): List<Map<String, Any?>>
/** 按指标项 CODE 查询所属体检组合 */
fun getItemInfo(peItemCode: String): List<Map<String, Any?>>
/** 员工体检项目 */
fun getEmpItem(peId: String): List<Map<String, Any?>>
/** 员工体检结果 */
fun getResult(peId: String): List<Map<String, Any?>>
/** 体检结论(列表 / 全量) */
fun getConclusion(peId: String): List<Map<String, Any?>>
fun getConclusionAll(peId: String): List<Map<String, Any?>>
/** 体检建议(列表 / 全量) */
fun getSuggest(peId: String): List<Map<String, Any?>>
fun getSuggestAll(peId: String): List<Map<String, Any?>>
/** 按身份证号 + 年份查询体检状态 */
fun getPeStatusByIdnoAndYear(idNo: String, year: String): Map<String, Any?>?
/** 按身份证号 + 年份查询体检结果(默认 / 全量) */
fun getResultByIdcardAndYear(idNo: String, year: String): List<Map<String, Any?>>
fun getResultByIdcardAndYearAll(idNo: String, year: String): List<Map<String, Any?>>
/** 按体检编号查询体检结果 */
fun getResultByPeId(peId: String): List<Map<String, Any?>>
/** 按审核日期查询体检报告(去重主表) */
fun getDisResultByAuditDate(auditDate: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表(默认 / 全量) */
fun getItemByPeId(peId: String): List<Map<String, Any?>>
fun getItemByPeIdAll(peId: String): List<Map<String, Any?>>
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String): List<Map<String, Any?>>
/** 按身份证号查询当年 peId 与预约日期(单条 / 列表) */
fun getPeIdAndDateByIdcard(idNo: String): Map<String, Any?>?
fun getPeIdAndDateByIdcardList(idNo: String): List<Map<String, Any?>>
/** 按审核日期查询当天体检人员列表(去重;日期空则当天) */
fun getExamListByDate(auditDate: String?): List<Map<String, Any?>>
}
@@ -0,0 +1,43 @@
package com.hospital.front.adapter
import org.noear.solon.Solon
import org.noear.solon.annotation.Component
/**
* 医院适配器路由。
*
* 通过 Solon.context().getBeanOfType 在首次使用时动态获取所有 HospitalAdapter 实现,
* 避免依赖注入时序问题。
*/
@Component
class HospitalRouter {
private val activeCode: String = Solon.cfg().get("hospital.active", "jinghe")!!
/**
* 动态拉取所有 HospitalAdapter 实现(每次调用都重新拉取,避免 bean 注入时序问题)。
*/
private fun listAdapters(): List<HospitalAdapter> =
Solon.context().getBeansOfType(HospitalAdapter::class.java)
private val map: Map<String, HospitalAdapter> by lazy {
listAdapters().associateBy { it.code() }
}
init {
println("[HospitalRouter] 启动激活医院:$activeCode")
}
/** 获取指定医院编码的适配器 */
fun get(code: String): HospitalAdapter =
map[code] ?: throw IllegalStateException("未注册医院适配器:$code(已注册:${map.keys}")
/** 获取启动激活的医院适配器 */
fun active(): HospitalAdapter = get(activeCode)
/** 当前激活医院编码 */
fun activeCode(): String = activeCode
/** 所有已注册的医院编码 */
fun registeredCodes(): Set<String> = map.keys
}
@@ -0,0 +1,14 @@
package com.hospital.front.adapter
import org.noear.solon.annotation.Component
/**
* 泾河医院适配器(Oracle,bsm 方言,默认激活医院)。
*/
@Component
class JingheAdapter : BsmAdapterBase() {
override fun code(): String = "jinghe"
override fun displayName(): String = "泾河医院"
}
@@ -0,0 +1,17 @@
package com.hospital.front.adapter
import org.noear.solon.annotation.Component
/**
* 宁夏宝石花医院适配器(SqlServer,nx 方言)。
*
* 业务开关(在 OrderService 中按配置生效):
* - hm.allsecondFlag=true:强制二级单位编码/名称截断
*/
@Component
class NingxiaAdapter : NxAdapterBase() {
override fun code(): String = "ningxia"
override fun displayName(): String = "宁夏宝石花医院"
}
@@ -0,0 +1,89 @@
package com.hospital.front.adapter
import com.hospital.front.dto.ProcResult
import com.hospital.front.dto.Result
import com.hospital.front.mapper.nx.NxOrderMapper
import com.hospital.front.vo.UnitVo
import org.noear.solon.annotation.Inject
/**
* NX 方言适配器基类(SqlServer)。
*
* 适用医院:ningxia(宁夏宝石花)。
*/
abstract class NxAdapterBase : HospitalAdapter {
/** 数据库 MapperSolon 按类型注入) */
@Inject
protected lateinit var mapper: NxOrderMapper
override fun hmType(): String = "nx"
override fun healthCheck(): Result<Boolean> = try {
Result.success(mapper.ping() == 1)
} catch (e: Exception) {
Result.error(Result.CODE_DB_UNREACHABLE, "数据库连接失败:${e.message}")
}
override fun saveOrder(peInfoStr: String, peItemStr: String): ProcResult {
val map = mutableMapOf<String, Any?>("peInfoStr" to peInfoStr, "peItemStr" to peItemStr)
mapper.saveOrder(map)
return ProcResult(
resultCode = map["resultCode"]?.toString(),
errorMsg = map["errorMsg"]?.toString(),
peId = map["peId"]?.toString(),
)
}
override fun cancelOrder(peId: String, name: String, idcard: String): ProcResult {
val map = mutableMapOf<String, Any?>("peId" to peId, "name" to name, "idcard" to idcard)
mapper.cancelOrder(map)
return ProcResult(
resultCode = map["resultCode"]?.toString(),
errorMsg = map["errorMsg"]?.toString(),
)
}
override fun addUnit(unit: UnitVo, useV2: Boolean): ProcResult {
// 宁夏使用 addUnitV2(原 OrderServiceImpl 中 nx 分支固定走 V2
val map = mutableMapOf<String, Any?>(
"unitCode" to unit.unitCode,
"unitName" to unit.unitName,
"parentUnitCode" to unit.parentUnitCode,
"address" to unit.address,
"connecter1" to unit.connecter1,
"phone1" to unit.phone1,
"connecter2" to unit.connecter2,
"phone2" to unit.phone2,
)
mapper.addUnitV2(map)
return ProcResult(
resultCode = map["resultCode"]?.toString(),
errorMsg = map["errorMsg"]?.toString(),
)
}
override fun getPhyexamStatus(peIds: String) = mapper.getPhyexamStatus(peIds)
override fun getItemList() = mapper.getItemList()
override fun getAllItemList() = mapper.getAllItemList()
override fun getPeItemList() = mapper.getPeItemList()
override fun getItemInfo(peItemCode: String) = mapper.getItemInfo(peItemCode)
override fun getEmpItem(peId: String) = mapper.getEmpItem(peId)
override fun getResult(peId: String) = mapper.getResult(peId)
override fun getConclusion(peId: String) = mapper.getConclusion(peId)
override fun getConclusionAll(peId: String) = mapper.getConclusionAll(peId)
override fun getSuggest(peId: String) = mapper.getSuggest(peId)
override fun getSuggestAll(peId: String) = mapper.getSuggestAll(peId)
override fun getPeStatusByIdnoAndYear(idNo: String, year: String) = mapper.getPeStatusByIdnoAndYear(idNo, year)
override fun getResultByIdcardAndYear(idNo: String, year: String) = mapper.getResultByIdcardAndYear(idNo, year)
override fun getResultByIdcardAndYearAll(idNo: String, year: String) = mapper.getResultByIdcardAndYearAll(idNo, year)
override fun getResultByPeId(peId: String) = mapper.getResultByPeId(peId)
override fun getDisResultByAuditDate(auditDate: String) = mapper.getDisResultByAuditDate(auditDate)
override fun getItemByPeId(peId: String) = mapper.getItemByPeId(peId)
override fun getItemByPeIdAll(peId: String) = mapper.getItemByPeIdAll(peId)
override fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String) =
mapper.getResultByIdNoOrPeId(idNo, peId, auditYear)
override fun getPeIdAndDateByIdcard(idNo: String) = mapper.getPeIdAndDateByIdcard(idNo)
override fun getPeIdAndDateByIdcardList(idNo: String) = mapper.getPeIdAndDateByIdcardList(idNo)
override fun getExamListByDate(auditDate: String?) = mapper.getExamListByDate(auditDate)
}
@@ -0,0 +1,16 @@
package com.hospital.front.adapter
import org.noear.solon.annotation.Component
/**
* 庆阳医院适配器(Oracle,bsm 方言)。
*
* 数据库连接暂与泾河一致,正式环境就绪后调整 pac/conf/app-qingyang.yml。
*/
@Component
class QingyangAdapter : BsmAdapterBase() {
override fun code(): String = "qingyang"
override fun displayName(): String = "庆阳医院"
}
@@ -0,0 +1,14 @@
package com.hospital.front.adapter
import org.noear.solon.annotation.Component
/**
* 兴隆园医院适配器(Oracle,bsm 方言)。
*/
@Component
class XingLongYuanAdapter : BsmAdapterBase() {
override fun code(): String = "xinglongyuan"
override fun displayName(): String = "兴隆园医院"
}
@@ -0,0 +1,88 @@
package com.hospital.front.adapter
import com.hospital.front.dto.ProcResult
import com.hospital.front.dto.Result
import com.hospital.front.mapper.ya.YaOrderMapper
import com.hospital.front.vo.UnitVo
import org.noear.solon.annotation.Inject
/**
* YA 方言适配器基类(SqlServer)。
*
* 适用医院:yanan(延安)。
*/
abstract class YaAdapterBase : HospitalAdapter {
/** 数据库 MapperSolon 按类型注入) */
@Inject
protected lateinit var mapper: YaOrderMapper
override fun hmType(): String = "ya"
override fun healthCheck(): Result<Boolean> = try {
Result.success(mapper.ping() == 1)
} catch (e: Exception) {
Result.error(Result.CODE_DB_UNREACHABLE, "数据库连接失败:${e.message}")
}
override fun saveOrder(peInfoStr: String, peItemStr: String): ProcResult {
val map = mutableMapOf<String, Any?>("peInfoStr" to peInfoStr, "peItemStr" to peItemStr)
mapper.saveOrder(map)
return ProcResult(
resultCode = map["resultCode"]?.toString(),
errorMsg = map["errorMsg"]?.toString(),
peId = map["peId"]?.toString(),
)
}
override fun cancelOrder(peId: String, name: String, idcard: String): ProcResult {
val map = mutableMapOf<String, Any?>("peId" to peId, "name" to name, "idcard" to idcard)
mapper.cancelOrder(map)
return ProcResult(
resultCode = map["resultCode"]?.toString(),
errorMsg = map["errorMsg"]?.toString(),
)
}
override fun addUnit(unit: UnitVo, useV2: Boolean): ProcResult {
val map = mutableMapOf<String, Any?>(
"unitCode" to unit.unitCode,
"unitName" to unit.unitName,
"parentUnitCode" to unit.parentUnitCode,
"address" to unit.address,
"connecter1" to unit.connecter1,
"phone1" to unit.phone1,
"connecter2" to unit.connecter2,
"phone2" to unit.phone2,
)
if (useV2) mapper.addUnitV2(map) else mapper.addUnit(map)
return ProcResult(
resultCode = map["resultCode"]?.toString(),
errorMsg = map["errorMsg"]?.toString(),
)
}
override fun getPhyexamStatus(peIds: String) = mapper.getPhyexamStatus(peIds)
override fun getItemList() = mapper.getItemList()
override fun getAllItemList() = mapper.getAllItemList()
override fun getPeItemList() = mapper.getPeItemList()
override fun getItemInfo(peItemCode: String) = mapper.getItemInfo(peItemCode)
override fun getEmpItem(peId: String) = mapper.getEmpItem(peId)
override fun getResult(peId: String) = mapper.getResult(peId)
override fun getConclusion(peId: String) = mapper.getConclusion(peId)
override fun getConclusionAll(peId: String) = mapper.getConclusionAll(peId)
override fun getSuggest(peId: String) = mapper.getSuggest(peId)
override fun getSuggestAll(peId: String) = mapper.getSuggestAll(peId)
override fun getPeStatusByIdnoAndYear(idNo: String, year: String) = mapper.getPeStatusByIdnoAndYear(idNo, year)
override fun getResultByIdcardAndYear(idNo: String, year: String) = mapper.getResultByIdcardAndYear(idNo, year)
override fun getResultByIdcardAndYearAll(idNo: String, year: String) = mapper.getResultByIdcardAndYearAll(idNo, year)
override fun getResultByPeId(peId: String) = mapper.getResultByPeId(peId)
override fun getDisResultByAuditDate(auditDate: String) = mapper.getDisResultByAuditDate(auditDate)
override fun getItemByPeId(peId: String) = mapper.getItemByPeId(peId)
override fun getItemByPeIdAll(peId: String) = mapper.getItemByPeIdAll(peId)
override fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String) =
mapper.getResultByIdNoOrPeId(idNo, peId, auditYear)
override fun getPeIdAndDateByIdcard(idNo: String) = mapper.getPeIdAndDateByIdcard(idNo)
override fun getPeIdAndDateByIdcardList(idNo: String) = mapper.getPeIdAndDateByIdcardList(idNo)
override fun getExamListByDate(auditDate: String?) = mapper.getExamListByDate(auditDate)
}
@@ -0,0 +1,14 @@
package com.hospital.front.adapter
import org.noear.solon.annotation.Component
/**
* 延安医院适配器(SqlServer,ya 方言)。
*/
@Component
class YananAdapter : YaAdapterBase() {
override fun code(): String = "yanan"
override fun displayName(): String = "延安医院"
}
@@ -0,0 +1,208 @@
package com.hospital.front.api.admin
import com.hospital.front.adapter.HospitalRouter
import com.hospital.front.dto.Result
import com.hospital.front.infra.exception.BusinessException
import org.noear.solon.Solon
import org.noear.solon.annotation.Controller
import org.noear.solon.annotation.Inject
import org.noear.solon.annotation.Mapping
import org.noear.solon.annotation.Param
import org.noear.solon.core.handle.Context
import java.io.File
import java.lang.management.ManagementFactory
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Paths
/**
* 主程序被控端 API/api/admin)。
*
* 供同机 Agent(环回地址)调用,实现远程运维:
* - 健康检查 / 版本查询 / JVM 指标
* - 配置查看(脱敏)
* - 日志文件列表 / 尾部查看 / 全量下载
* - 优雅重启
*
* 鉴权:请求头 X-Admin-Token 与配置 admin.token 比对(未配置时放行,仅本机使用)。
*/
@Controller
@Mapping("/api/admin")
class AdminApi {
@Inject
private lateinit var router: HospitalRouter
/** 日志目录(与 logback.xml 的 log.path 保持一致) */
private val logDir: File
get() = File(Solon.cfg().get("log.path", "./log")!!)
// ================= 鉴权 =================
private fun checkToken(ctx: Context) {
val expected = Solon.cfg().get("admin.token", "")
if (expected.isNotBlank() && ctx.header("X-Admin-Token") != expected) {
throw BusinessException.of(Result.CODE_AUTH_FAILED, "管理接口鉴权失败")
}
}
// ================= 1. 健康与指标 =================
/** 健康检查(含数据库探测) */
@Mapping("/health")
fun health(ctx: Context): Result<Map<String, Any?>> {
checkToken(ctx)
val mem = Runtime.getRuntime()
val db = router.active().healthCheck()
return Result.success(
mapOf(
"status" to if (db.code == 0) "UP" else "DEGRADED",
"hospital" to router.activeCode(),
"hospitalName" to router.active().displayName(),
"registeredHospitals" to router.registeredCodes(),
"database" to mapOf(
"ok" to (db.code == 0),
"msg" to db.msg,
),
"jvm" to mapOf(
"totalMemoryMb" to mem.totalMemory() / 1048576,
"freeMemoryMb" to mem.freeMemory() / 1048576,
"maxMemoryMb" to mem.maxMemory() / 1048576,
"availableProcessors" to mem.availableProcessors(),
),
"uptimeSec" to ManagementFactory.getRuntimeMXBean().uptime / 1000,
"pid" to ProcessHandle.current().pid(),
)
)
}
/** 版本信息(读 MANIFEST */
@Mapping("/version")
fun version(ctx: Context): Result<Map<String, String>> {
checkToken(ctx)
val pkg = this::class.java.`package`
return Result.success(
mapOf(
"app" to "hospital-front",
"version" to (pkg?.implementationVersion ?: "1.0.0-dev"),
"hospital" to router.activeCode(),
"javaVersion" to System.getProperty("java.version"),
"startTime" to ManagementFactory.getRuntimeMXBean().startTime.toString(),
)
)
}
// ================= 2. 配置管理 =================
/** 查看当前生效配置(密码脱敏) */
@Mapping("/config")
fun config(ctx: Context): Result<Map<String, Any?>> {
checkToken(ctx)
val keys = listOf(
"hospital.active", "hospital.id",
"hm.type", "hm.ver", "hm.allsecondFlag", "hm.occFlag",
"server.port", "admin.token", "log.path",
)
val conf = keys.associateWith { Solon.cfg().get(it, "") }
// 数据源脱敏(db1! 的 ! 在 cfg 键中已剥离,两种写法都尝试)
val ds = mapOf(
"db1.jdbcUrl" to (Solon.cfg().get("solon.dataSources.db1.jdbcUrl")
?: Solon.cfg().get("solon.dataSources.db1!.jdbcUrl") ?: ""),
"db1.username" to (Solon.cfg().get("solon.dataSources.db1.username")
?: Solon.cfg().get("solon.dataSources.db1!.username") ?: ""),
"db1.password" to "******",
)
return Result.success(mapOf("config" to conf, "dataSource" to ds))
}
/**
* 列出 pac/conf 目录下的医院配置文件内容(供 B 端查看/修改)。
*/
@Mapping("/config/file")
fun configFile(ctx: Context, @Param("hospital") hospital: String): Result<String> {
checkToken(ctx)
val file = File("conf/app-$hospital.yml")
if (!file.exists()) {
throw BusinessException.paramMissing("配置文件 conf/app-$hospital.yml 不存在")
}
return Result.success(String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8))
}
/**
* 写回医院配置文件(B 端修改后下发)。重启后生效。
*/
@Mapping(value = "/config/file", method = [org.noear.solon.core.handle.MethodType.POST])
fun saveConfigFile(ctx: Context, @Param("hospital") hospital: String, @Param(name = "content") content: String): Result<Nothing?> {
checkToken(ctx)
if (!hospital.matches(Regex("[a-z]+"))) {
throw BusinessException.businessFailed("非法医院编码:$hospital")
}
val file = File("conf/app-$hospital.yml")
file.parentFile?.mkdirs()
Files.write(file.toPath(), content.toByteArray(StandardCharsets.UTF_8))
return Result.success()
}
// ================= 3. 日志管理 =================
/** 日志文件列表 */
@Mapping("/log/files")
fun logFiles(ctx: Context): Result<List<Map<String, Any?>>> {
checkToken(ctx)
val dir = logDir
if (!dir.exists()) return Result.success(emptyList())
return Result.success(
dir.listFiles { f -> f.isFile }
?.sortedByDescending { it.lastModified() }
?.take(50)
?.map { mapOf("name" to it.name, "sizeBytes" to it.length(), "lastModified" to it.lastModified()) }
?: emptyList()
)
}
/** 日志尾部 N 行(默认 200) */
@Mapping("/log/tail")
fun logTail(ctx: Context, @Param("file") file: String, @Param("lines") lines: Int?): Result<String> {
checkToken(ctx)
val target = safeLogFile(file)
val n = (lines ?: 200).coerceIn(1, 2000)
val all = Files.readAllLines(target.toPath(), StandardCharsets.UTF_8)
return Result.success(all.takeLast(n).joinToString("\n"))
}
/** 日志全量下载 */
@Mapping("/log/download")
fun logDownload(ctx: Context, @Param("file") file: String) {
checkToken(ctx)
val target = safeLogFile(file)
ctx.contentType("application/octet-stream")
ctx.headerSet("Content-Disposition", "attachment; filename=\"${target.name}\"")
Files.copy(target.toPath(), ctx.outputStream())
}
/** 限制只能读日志目录内的文件(防路径穿越) */
private fun safeLogFile(name: String): File {
if (name.contains("..") || name.contains("/") || name.contains("\\")) {
throw BusinessException.businessFailed("非法文件名")
}
val f = File(logDir, name)
if (!f.exists()) throw BusinessException.paramMissing("日志文件不存在:$name")
return f
}
// ================= 4. 进程管理 =================
/**
* 优雅重启:记录标记后延迟退出,Agent 检测到进程退出后拉起新进程。
*/
@Mapping(value = "/restart", method = [org.noear.solon.core.handle.MethodType.POST])
fun restart(ctx: Context, @Param("delayMs") delayMs: Long?): Result<Nothing?> {
checkToken(ctx)
val delay = (delayMs ?: 1000L).coerceIn(0, 10000)
Thread {
Thread.sleep(delay)
Solon.stopBlock() // 触发 Solon 停机事件(优雅退出)
}.apply { isDaemon = true }.start()
return Result.success()
}
}
@@ -0,0 +1,179 @@
package com.hospital.front.api.order
import com.hospital.front.dto.Result
import com.hospital.front.infra.exception.BusinessException
import com.hospital.front.service.OrderService
import org.noear.solon.annotation.Body
import org.noear.solon.annotation.Controller
import org.noear.solon.annotation.Inject
import org.noear.solon.annotation.Mapping
import org.noear.solon.annotation.Param
import org.noear.solon.core.handle.MethodType
import org.noear.solon.annotation.Path
/**
* 体检预约业务接口,路径前缀 /order。
*
* 路径与出参结构保持与原 hospitalmiddle 一致(调用方契约,不可修改)。
* 根路径前缀为 /order,各方法 Mapping 为相对路径。
*/
@Controller
@Mapping("/order")
class OrderApi {
@Inject
private lateinit var service: OrderService
// ================= 1. 预约提交 / 取消 =================
/** 4. 提交预约保存 */
@Mapping(value = "/saveOrder", method = [MethodType.POST])
fun saveOrder(@Body body: Map<String, Any?>): Result<String> =
Result.success(service.saveOrder(body))
/** 5. 取消体检预约 */
@Mapping(value = "/cancelOrder/{peId}", method = [MethodType.POST])
fun cancelOrder(
@Path("peId") peId: String,
@Param("name") name: String,
@Param("idcard") idcard: String,
): Result<Nothing?> {
service.cancelOrder(peId, name, idcard)
return Result.success()
}
// ================= 2. 体检状态 =================
/** 3. 体检状态查询(多个 peId 逗号分隔) */
@Mapping("/getPeStatus/{peIds}")
fun getPeStatus(@Path("peIds") peIds: String): Result<List<Map<String, String>>> =
Result.success(service.getPhyexamStatus(peIds))
/** 13. 根据身份证号及年份获取当前体检状态及体检项状态 */
@Mapping("/getPeStatusAndItemList/{idNo}/{year}")
fun getPeStatusAndItemList(@Path("idNo") idNo: String, @Path("year") year: String): Result<Map<String, Any?>?> =
Result.success(service.getPeStatusByIdnoAndYear(idNo, year))
// ================= 3. 体检项目 =================
/** 1. 查询员工体检项目 */
@Mapping("/getEmpItem/{peId}")
fun getEmpItem(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getEmpItem(peId))
/** 2. 体检项目查询 */
@Mapping("/getItemList")
fun getItemList(): Result<List<Map<String, Any?>>> =
Result.success(service.getItemList())
/** 6. 体检指标项数据获取 */
@Mapping("/getPeItemList")
fun getPeItemList(): Result<List<Map<String, Any?>>> =
Result.success(service.getPeItemList())
/** 6.1 体检指标项及体检组合数据获取 */
@Mapping("/getAllItemList")
fun getAllItemList(): Result<List<Map<String, Any?>>> =
Result.success(service.getAllItemList())
/** 7. 根据体检指标项查询所属体检组合项 */
@Mapping("/getItemInfo/{peItemCode}")
fun getItemInfo(@Path("peItemCode") peItemCode: String): Result<List<Map<String, Any?>>> =
Result.success(service.getItemInfo(peItemCode))
// ================= 4. 体检结果 / 结论 / 建议 =================
/** 8. 根据体检编号获取员工体检结果 */
@Mapping("/getResult/{peId}")
fun getResult(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResult(peId))
/** 9. 体检结论(汇总) */
@Mapping("/getTotalConclusion/{peId}")
fun getTotalConclusion(@Path("peId") peId: String): Result<Map<String, String>> =
Result.success(service.getTotalConclusion(peId))
/** 10. 体检建议(汇总) */
@Mapping("/getTotalSuggest/{peId}")
fun getTotalSuggest(@Path("peId") peId: String): Result<Map<String, String>> =
Result.success(service.getTotalSuggest(peId))
/** 11. 体检结论(列表) */
@Mapping("/getConclusion/{peId}")
fun getConclusion(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getConclusion(peId))
/** 12. 体检建议(列表) */
@Mapping("/getSuggest/{peId}")
fun getSuggest(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getSuggest(peId))
// ================= 5. 按身份证 / 日期查询 =================
/** 14. 根据身份证号及年份获取体检结果 */
@Mapping("/getResultByIdcardAndYear/{idNo}/{year}")
fun getResultByIdcardAndYear(@Path("idNo") idNo: String, @Path("year") year: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByIdcardAndYear(idNo, year))
/** 14.1 根据身份证号及年份获取体检结果(全量) */
@Mapping("/getResultByIdcardAndYearAll/{idNo}/{year}")
fun getResultByIdcardAndYearAll(@Path("idNo") idNo: String, @Path("year") year: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByIdcardAndYearAll(idNo, year))
/** 15.1 根据体检编号获取体检结果 */
@Mapping("/getResultByPeId/{peId}")
fun getResultByPeId(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByPeId(peId))
/** 15.2 根据体检编号获取体检状态(简) */
@Mapping("/getResultStatus/{peId}")
fun getResultStatus(@Path("peId") peId: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByPeId(peId))
/** 15.3 根据 audit_date 查询当天的体检报告 */
@Mapping("/getResultByAuditDate/{auditDate}")
fun getResultByAuditDate(@Path("auditDate") auditDate: String): Result<List<Map<String, Any?>>> =
Result.success(service.getResultByAuditDate(auditDate))
/** 16. 根据 id_code 或 pe_id 和审查年份查询体检报告 */
@Mapping(value = "/getResultByIdNoOrPeId", method = [MethodType.POST])
fun getResultByIdNoOrPeId(
@Param("idNo") idNo: String?,
@Param("peId") peId: String?,
@Param("auditYear") auditYear: String,
): Result<List<Map<String, Any?>>> {
if (idNo.isNullOrBlank() && peId.isNullOrBlank()) {
throw BusinessException.paramMissing("身份证号或体检编号")
}
return Result.success(service.getResultByIdNoOrPeId(idNo, peId, auditYear))
}
/** 17. 根据身份证号查询当前年的 peId 和预约日期 */
@Mapping("/getPeIdAndDateByIdcard/{idNo}")
fun getPeIdAndDateByIdcard(@Path("idNo") idNo: String): Result<Map<String, Any?>?> =
Result.success(service.getPeIdAndDateByIdcard(idNo))
/** 17.1 同上(数组) */
@Mapping("/getPeIdAndDateByIdcardList/{idNo}")
fun getPeIdAndDateByIdcardList(@Path("idNo") idNo: String): Result<List<Map<String, Any?>>> =
Result.success(service.getPeIdAndDateByIdcardList(idNo))
// ================= 6. 单位 / 人员列表 =================
/** 18. 添加修改单位信息 */
@Mapping(value = "/addUnit", method = [MethodType.POST])
fun addUnit(@Body body: String): Result<Nothing?> {
// Solon 对 List 泛型 body 反序列化支持不佳,此处用 snack3 手动解析
@Suppress("UNCHECKED_CAST")
val units: List<Map<String, Any?>> =
if (body.isBlank()) emptyList()
else org.noear.snack.ONode.load(body).toObject(List::class.java) as List<Map<String, Any?>>
val errors = service.addUnits(units)
return if (errors != null) Result.error(errors) else Result.success()
}
/** 19. 根据日期查询当天体检人员列表(去重),日期为空则查当天 */
@Mapping("/getExamListByDate")
fun getExamListByDate(@Param("auditDate") auditDate: String?): Result<List<Map<String, Any?>>> =
Result.success(service.getExamListByDate(auditDate))
}
@@ -0,0 +1,17 @@
package com.hospital.front.dto
/**
* 医院存储过程执行结果。
*
* @property resultCode 医院侧返回码("1" = 成功)
* @property errorMsg 错误信息(失败时)
* @property peId 预约成功后医院分配的体检编号(仅 saveOrder 有值)
*/
data class ProcResult(
val resultCode: String? = null,
val errorMsg: String? = null,
val peId: String? = null,
) {
/** 医院侧是否执行成功 */
val isSuccess: Boolean get() = resultCode == "1"
}
@@ -0,0 +1,69 @@
package com.hospital.front.dto
/**
* 统一 API 响应包装。
*
* 业务接口均使用此结构返回,便于前端统一处理。
*
* @param T 数据类型
* @property code 业务码(0=成功)
* @property msg 提示信息
* @property data 业务数据
*/
data class Result<T>(
val code: Int,
val msg: String,
val data: T? = null,
) {
companion object {
/** 业务成功码 */
const val SUCCESS_CODE = 0
/** 业务失败默认码 */
const val ERROR_CODE = 5001
/** 参数缺失 */
const val CODE_PARAM_MISSING = 1001
/** 参数格式错误 */
const val CODE_PARAM_FORMAT = 1002
/** 业务校验失败 */
const val CODE_BUSINESS = 1003
/** 存储过程调用失败 */
const val CODE_PROC_FAILED = 2001
/** 数据库连接失败 */
const val CODE_DB_UNREACHABLE = 2002
/** 医院系统返回业务失败 */
const val CODE_HOSPITAL_FAILED = 2003
/** 鉴权失败 */
const val CODE_AUTH_FAILED = 3001
/** Token 过期 */
const val CODE_TOKEN_EXPIRED = 3002
/**
* 成功响应(无数据)。
*/
fun <T> success(): Result<T?> = Result(SUCCESS_CODE, "success", null)
/**
* 成功响应。
*/
fun <T> success(data: T): Result<T> = Result(SUCCESS_CODE, "success", data)
/**
* 错误响应(默认错误码)。
*/
fun <T> error(msg: String): Result<T> = Result(ERROR_CODE, msg, null)
/**
* 错误响应(指定错误码)。
*/
fun <T> error(code: Int, msg: String): Result<T> = Result(code, msg, null)
}
}
@@ -0,0 +1,23 @@
package com.hospital.front.infra.config
import org.noear.solon.annotation.BindProps
import org.noear.solon.annotation.Configuration
import org.noear.solon.annotation.Inject
/**
* 应用配置(Solon @ConfigurationProperties)。
*
* 加载 `app.yml` 与 `pac/conf/app-{编码}.yml` 中的 hospital.* 配置。
*
* @author hospital-front重构
*/
@BindProps(prefix = "hospital")
@Configuration
open class AppConfig {
/** 启动激活哪家医院 */
var active: String = "jinghe"
/** 注册的医院编码清单(启动自检) */
var adapters: List<String> = emptyList()
}
@@ -0,0 +1,73 @@
package com.hospital.front.infra.exception
import com.hospital.front.dto.Result
import org.noear.solon.annotation.Component
import org.noear.solon.core.handle.Context
import org.noear.solon.core.handle.Filter
import org.noear.solon.core.handle.FilterChain
import org.slf4j.LoggerFactory
/**
* 全局异常处理(Solon Filter)。
*
* 将未捕获的异常统一包装为 Result 格式返回。
* 业务异常请直接抛出 BusinessException。
*/
@Component
class GlobalExceptionHandler : Filter {
private val log = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)
override fun doFilter(ctx: Context, chain: FilterChain) {
try {
chain.doFilter(ctx)
} catch (e: BusinessException) {
log.warn("业务异常:code={}, msg={}", e.code, e.message)
renderResult(ctx, Result.error<Any>(e.code, e.message ?: "业务异常"))
} catch (e: IllegalArgumentException) {
log.warn("参数异常:{}", e.message)
renderResult(ctx, Result.error<Any>(Result.CODE_PARAM_FORMAT, e.message ?: "参数错误"))
} catch (e: Exception) {
log.error("系统异常", e)
renderResult(ctx, Result.error<Any>(Result.ERROR_CODE, "系统异常:" + (e.message ?: e.javaClass.simpleName)))
}
}
private fun renderResult(ctx: Context, result: Result<*>) {
ctx.status(200)
ctx.contentType("application/json;charset=UTF-8")
// 简易 JSON 序列化(避免引入 fastjson 依赖)
val dataJson = result.data?.let { toJsonValue(it) } ?: "null"
ctx.output("""{"code":${result.code},"msg":${toJsonValue(result.msg)},"data":$dataJson}""")
}
/** 极简 JSON 值序列化(字符串加引号,其它直接 toString) */
private fun toJsonValue(v: Any?): String = when (v) {
null -> "null"
is Number, is Boolean -> v.toString()
else -> "\"" + v.toString().replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\""
}
}
/**
* 业务异常。
*
* 用于业务层显式抛出指定错误码的异常,会被 GlobalExceptionHandler 捕获并包装为 Result。
*
* @param code 业务错误码(参见 Result 中的常量)
* @param message 错误描述
*/
class BusinessException(
val code: Int,
override val message: String?,
) : RuntimeException(message) {
companion object {
fun of(code: Int, message: String?): BusinessException = BusinessException(code, message)
fun paramMissing(field: String): BusinessException =
BusinessException(Result.CODE_PARAM_MISSING, "参数缺失:$field")
fun businessFailed(msg: String): BusinessException =
BusinessException(Result.CODE_BUSINESS, msg)
fun procFailed(msg: String): BusinessException =
BusinessException(Result.CODE_PROC_FAILED, msg)
}
}
@@ -0,0 +1,42 @@
package com.hospital.front.infra.util
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
/**
* 日期工具类(统一格式)。
*
* 业务系统约定:
- 日期:yyyy-MM-dd
- 时间:yyyy-MM-dd HH:mm:ss
- 时区:GMT+8
*
* @author hospital-front重构
*/
object DateUtils {
/** 日期格式 */
val DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
/** 日期时间格式 */
val DATETIME: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
/** 当前日期字符串(yyyy-MM-dd */
fun today(): String = LocalDate.now().format(DATE)
/** 当前时间字符串(yyyy-MM-dd HH:mm:ss */
fun now(): String = LocalDateTime.now().format(DATETIME)
/** 解析日期字符串 */
fun parseDate(text: String): LocalDate = LocalDate.parse(text, DATE)
/** 解析日期时间字符串 */
fun parseDateTime(text: String): LocalDateTime = LocalDateTime.parse(text, DATETIME)
/** 格式化为日期字符串 */
fun format(date: LocalDate): String = date.format(DATE)
/** 格式化为日期时间字符串 */
fun format(dateTime: LocalDateTime): String = dateTime.format(DATETIME)
}
@@ -0,0 +1,101 @@
package com.hospital.front.mapper.bsm
import org.apache.ibatis.annotations.Mapper
import org.apache.ibatis.annotations.Param
/**
* BSM 医院组 MapperOracle 方言)。
*
* 适用医院:xinglongyuan(兴隆园)/ jinghe(泾河)/ qingyang(庆阳)
* XML 位置:classpath:mapper/bsm/OrderMapper.xml
*
* 存储过程参数与医院 HIS 系统约定,不可修改。
*/
@Mapper
interface BsmOrderMapper {
/** 健康探测(SELECT 1 */
fun ping(): Int
/** 预约提交(存储过程 P_ADD_PE_REGIST,出参 resultCode/errorMsg/peId */
fun saveOrder(map: MutableMap<String, Any?>)
/** 取消预约(存储过程 P_DEL_PE_REGIST */
fun cancelOrder(map: MutableMap<String, Any?>)
/** 添加/修改单位 V1(存储过程 P_ADD_UNIT */
fun addUnit(map: MutableMap<String, Any?>)
/** 添加/修改单位 V2(存储过程 P_ADD_UNIT8 入参版) */
fun addUnitV2(map: MutableMap<String, Any?>)
/** 按体检预约 ID(逗号分隔)查询体检状态 */
fun getPhyexamStatus(@Param("peIds") peIds: String): List<Map<String, Any?>>
/** 医院所有体检项目(组合项) */
fun getItemList(): List<Map<String, Any?>>
/** 医院所有体检项目及指标项 */
fun getAllItemList(): List<Map<String, Any?>>
/** 医院所有体检指标项 */
fun getPeItemList(): List<Map<String, Any?>>
/** 员工体检项目 */
fun getEmpItem(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按指标项 CODE 查询体检组合 */
fun getItemInfo(@Param("peItemCode") peItemCode: String): List<Map<String, Any?>>
/** 员工体检结果 */
fun getResult(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(列表) */
fun getConclusion(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(全量) */
fun getConclusionAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(列表) */
fun getSuggest(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(全量) */
fun getSuggestAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检状态 */
fun getPeStatusByIdnoAndYear(@Param("idNo") idNo: String, @Param("year") year: String): Map<String, Any?>?
/** 按身份证号及年份查询体检结果 */
fun getResultByIdcardAndYear(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检结果(全量) */
fun getResultByIdcardAndYearAll(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按体检编号查询体检结果 */
fun getResultByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按审核日期查询体检报告(去重) */
fun getDisResultByAuditDate(@Param("auditDate") auditDate: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表 */
fun getItemByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表(全量) */
fun getItemByPeIdAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
fun getResultByIdNoOrPeId(
@Param("idNo") idNo: String?,
@Param("peId") peId: String?,
@Param("auditYear") auditYear: String,
): List<Map<String, Any?>>
/** 按身份证号查询当年 peId 与预约日期 */
fun getPeIdAndDateByIdcard(@Param("idNo") idNo: String): Map<String, Any?>?
/** 按身份证号查询当年 peId 与预约日期(数组) */
fun getPeIdAndDateByIdcardList(@Param("idNo") idNo: String): List<Map<String, Any?>>
/** 按审核日期查询当天体检人员列表(去重) */
fun getExamListByDate(@Param("auditDate") auditDate: String?): List<Map<String, Any?>>
}
@@ -0,0 +1,101 @@
package com.hospital.front.mapper.nx
import org.apache.ibatis.annotations.Mapper
import org.apache.ibatis.annotations.Param
/**
* BSM 医院组 MapperOracle 方言)。
*
* 适用医院:ningxia(宁夏宝石花,SqlServer
* XML 位置:classpath:mapper/nx/OrderMapper.xml
*
* 存储过程参数与医院 HIS 系统约定,不可修改。
*/
@Mapper
interface NxOrderMapper {
/** 健康探测(SELECT 1 */
fun ping(): Int
/** 预约提交(存储过程 P_ADD_PE_REGIST,出参 resultCode/errorMsg/peId */
fun saveOrder(map: MutableMap<String, Any?>)
/** 取消预约(存储过程 P_DEL_PE_REGIST */
fun cancelOrder(map: MutableMap<String, Any?>)
/** 添加/修改单位 V1(存储过程 P_ADD_UNIT */
fun addUnit(map: MutableMap<String, Any?>)
/** 添加/修改单位 V2(存储过程 P_ADD_UNIT8 入参版) */
fun addUnitV2(map: MutableMap<String, Any?>)
/** 按体检预约 ID(逗号分隔)查询体检状态 */
fun getPhyexamStatus(@Param("peIds") peIds: String): List<Map<String, Any?>>
/** 医院所有体检项目(组合项) */
fun getItemList(): List<Map<String, Any?>>
/** 医院所有体检项目及指标项 */
fun getAllItemList(): List<Map<String, Any?>>
/** 医院所有体检指标项 */
fun getPeItemList(): List<Map<String, Any?>>
/** 员工体检项目 */
fun getEmpItem(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按指标项 CODE 查询体检组合 */
fun getItemInfo(@Param("peItemCode") peItemCode: String): List<Map<String, Any?>>
/** 员工体检结果 */
fun getResult(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(列表) */
fun getConclusion(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(全量) */
fun getConclusionAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(列表) */
fun getSuggest(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(全量) */
fun getSuggestAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检状态 */
fun getPeStatusByIdnoAndYear(@Param("idNo") idNo: String, @Param("year") year: String): Map<String, Any?>?
/** 按身份证号及年份查询体检结果 */
fun getResultByIdcardAndYear(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检结果(全量) */
fun getResultByIdcardAndYearAll(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按体检编号查询体检结果 */
fun getResultByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按审核日期查询体检报告(去重) */
fun getDisResultByAuditDate(@Param("auditDate") auditDate: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表 */
fun getItemByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表(全量) */
fun getItemByPeIdAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
fun getResultByIdNoOrPeId(
@Param("idNo") idNo: String?,
@Param("peId") peId: String?,
@Param("auditYear") auditYear: String,
): List<Map<String, Any?>>
/** 按身份证号查询当年 peId 与预约日期 */
fun getPeIdAndDateByIdcard(@Param("idNo") idNo: String): Map<String, Any?>?
/** 按身份证号查询当年 peId 与预约日期(数组) */
fun getPeIdAndDateByIdcardList(@Param("idNo") idNo: String): List<Map<String, Any?>>
/** 按审核日期查询当天体检人员列表(去重) */
fun getExamListByDate(@Param("auditDate") auditDate: String?): List<Map<String, Any?>>
}
@@ -0,0 +1,101 @@
package com.hospital.front.mapper.ya
import org.apache.ibatis.annotations.Mapper
import org.apache.ibatis.annotations.Param
/**
* BSM 医院组 MapperOracle 方言)。
*
* 适用医院:yanan(延安,SqlServer
* XML 位置:classpath:mapper/ya/OrderMapper.xml
*
* 存储过程参数与医院 HIS 系统约定,不可修改。
*/
@Mapper
interface YaOrderMapper {
/** 健康探测(SELECT 1 */
fun ping(): Int
/** 预约提交(存储过程 P_ADD_PE_REGIST,出参 resultCode/errorMsg/peId */
fun saveOrder(map: MutableMap<String, Any?>)
/** 取消预约(存储过程 P_DEL_PE_REGIST */
fun cancelOrder(map: MutableMap<String, Any?>)
/** 添加/修改单位 V1(存储过程 P_ADD_UNIT */
fun addUnit(map: MutableMap<String, Any?>)
/** 添加/修改单位 V2(存储过程 P_ADD_UNIT8 入参版) */
fun addUnitV2(map: MutableMap<String, Any?>)
/** 按体检预约 ID(逗号分隔)查询体检状态 */
fun getPhyexamStatus(@Param("peIds") peIds: String): List<Map<String, Any?>>
/** 医院所有体检项目(组合项) */
fun getItemList(): List<Map<String, Any?>>
/** 医院所有体检项目及指标项 */
fun getAllItemList(): List<Map<String, Any?>>
/** 医院所有体检指标项 */
fun getPeItemList(): List<Map<String, Any?>>
/** 员工体检项目 */
fun getEmpItem(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按指标项 CODE 查询体检组合 */
fun getItemInfo(@Param("peItemCode") peItemCode: String): List<Map<String, Any?>>
/** 员工体检结果 */
fun getResult(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(列表) */
fun getConclusion(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检结论(全量) */
fun getConclusionAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(列表) */
fun getSuggest(@Param("peId") peId: String): List<Map<String, Any?>>
/** 体检建议(全量) */
fun getSuggestAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检状态 */
fun getPeStatusByIdnoAndYear(@Param("idNo") idNo: String, @Param("year") year: String): Map<String, Any?>?
/** 按身份证号及年份查询体检结果 */
fun getResultByIdcardAndYear(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按身份证号及年份查询体检结果(全量) */
fun getResultByIdcardAndYearAll(@Param("idNo") idNo: String, @Param("year") year: String): List<Map<String, Any?>>
/** 按体检编号查询体检结果 */
fun getResultByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按审核日期查询体检报告(去重) */
fun getDisResultByAuditDate(@Param("auditDate") auditDate: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表 */
fun getItemByPeId(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按体检编号查询体检项目子表(全量) */
fun getItemByPeIdAll(@Param("peId") peId: String): List<Map<String, Any?>>
/** 按身份证号或体检编号 + 审核年份查询体检结果 */
fun getResultByIdNoOrPeId(
@Param("idNo") idNo: String?,
@Param("peId") peId: String?,
@Param("auditYear") auditYear: String,
): List<Map<String, Any?>>
/** 按身份证号查询当年 peId 与预约日期 */
fun getPeIdAndDateByIdcard(@Param("idNo") idNo: String): Map<String, Any?>?
/** 按身份证号查询当年 peId 与预约日期(数组) */
fun getPeIdAndDateByIdcardList(@Param("idNo") idNo: String): List<Map<String, Any?>>
/** 按审核日期查询当天体检人员列表(去重) */
fun getExamListByDate(@Param("auditDate") auditDate: String?): List<Map<String, Any?>>
}
@@ -0,0 +1,260 @@
package com.hospital.front.service
import com.hospital.front.adapter.HospitalAdapter
import com.hospital.front.adapter.HospitalRouter
import com.hospital.front.infra.exception.BusinessException
import com.hospital.front.infra.util.DateUtils
import com.hospital.front.vo.OrderItemVo
import com.hospital.front.vo.OrderV2Vo
import org.noear.solon.Solon
import org.noear.solon.annotation.Component
import org.noear.solon.annotation.Inject
import org.slf4j.LoggerFactory
/**
* 体检预约业务服务。
*
* 通过 HospitalRouter 委托给当前激活医院的适配器,
* 并承载跨医院共有的组装/汇总逻辑(替代原 OrderServiceImpl 的重复代码)。
*/
@Component
class OrderService {
private val log = LoggerFactory.getLogger(OrderService::class.java)
@Inject
private lateinit var router: HospitalRouter
// ================= 配置开关(惰性读取,支持热更新) =================
/** VO 版本(V1 / V2),默认 V2 */
private val hmVer: String get() = Solon.cfg().get("hm.ver", "V2")!!
/** 宁夏:强制发送二级单位编码和名称 */
private val allsecondFlag: Boolean get() = Solon.cfg().get("hm.allsecondFlag", "false") == "true"
/** 健康体检不发送职业相关信息 */
private val occFlag: Boolean get() = Solon.cfg().get("hm.occFlag", "false") == "true"
/** 当前激活医院的适配器 */
private fun adapter(): HospitalAdapter = router.active()
// ================= 体检状态字典 =================
/** 体检状态名 → 字典数字 */
private val peStatusDict = mapOf("预约" to "1", "报到" to "2", "检中" to "3", "初审" to "4", "终审" to "5")
// ================= 写操作 =================
/**
* 提交预约:入参 Map(V1/V2 VO 结构)→ 拼接管道符字符串 → 调医院存储过程。
*
* @return 成功时返回 peId
*/
fun saveOrder(body: Map<String, Any?>): String {
var vo = convertOrderV2(body)
if (allsecondFlag) vo = vo.applyAllsecondFlag()
if (occFlag) vo = vo.applyOccFlag()
val peInfoStr = vo.toPeInfoStr()
val peItemStr = OrderItemVo.toPeItemStr(vo.itemList)
log.info("提交预约:peInfoStr={}, peItemStr={}", peInfoStr, peItemStr)
val result = adapter().saveOrder(peInfoStr, peItemStr)
return if (result.isSuccess) {
result.peId ?: throw BusinessException.procFailed("医院未返回体检编号")
} else {
throwHospitalFailed(result.errorMsg)
}
}
/** 取消预约 */
fun cancelOrder(peId: String, name: String, idcard: String) {
log.info("取消预约:peId={}, name={}", peId, name)
val result = adapter().cancelOrder(peId, name, idcard)
if (!result.isSuccess) throwHospitalFailed(result.errorMsg)
}
/** 批量添加/修改单位;返回失败信息(全部成功返回 null) */
fun addUnits(units: List<Map<String, Any?>>): String? {
val errors = StringBuilder()
val useV2 = hmVer == "V2"
for ((index, raw) in units.withIndex()) {
try {
val unit = convertUnit(raw)
val result = adapter().addUnit(unit, useV2)
if (!result.isSuccess) errors.append(result.errorMsg ?: "${index + 1}条单位同步失败;")
} catch (e: Exception) {
log.warn("addUnit 第{}条处理异常:{}", index + 1, e.message)
errors.append("${index + 1}条异常:${e.message}")
}
}
return errors.toString().ifEmpty { null }
}
// ================= 查询操作(直通适配器) =================
fun getEmpItem(peId: String) = adapter().getEmpItem(peId)
fun getItemList() = adapter().getItemList()
fun getAllItemList() = adapter().getAllItemList()
fun getPeItemList() = adapter().getPeItemList()
fun getItemInfo(peItemCode: String) = adapter().getItemInfo(peItemCode)
fun getResult(peId: String) = adapter().getResult(peId)
fun getConclusion(peId: String) = adapter().getConclusion(peId)
fun getSuggest(peId: String) = adapter().getSuggest(peId)
fun getResultByIdcardAndYear(idNo: String, year: String) = adapter().getResultByIdcardAndYear(idNo, year)
fun getResultByIdcardAndYearAll(idNo: String, year: String) = adapter().getResultByIdcardAndYearAll(idNo, year)
fun getResultByPeId(peId: String) = adapter().getResultByPeId(peId)
/** 按审核日期查询体检报告(去重主表) */
fun getResultByAuditDate(auditDate: String) = adapter().getDisResultByAuditDate(auditDate)
fun getResultByIdNoOrPeId(idNo: String?, peId: String?, auditYear: String) =
adapter().getResultByIdNoOrPeId(idNo, peId, auditYear)
fun getPeIdAndDateByIdcard(idNo: String) = adapter().getPeIdAndDateByIdcard(idNo)
fun getPeIdAndDateByIdcardList(idNo: String) = adapter().getPeIdAndDateByIdcardList(idNo)
/**
* 体检状态查询:按医院返回的 PE_STATUS 名转字典数字。
*/
fun getPhyexamStatus(peIds: String): List<Map<String, String>> =
adapter().getPhyexamStatus(peIds).map { row ->
mapOf(
"peId" to (row["PE_ID"]?.toString() ?: ""),
"peStatus" to (peStatusDict[row["PE_STATUS"]?.toString() ?: ""] ?: ""),
)
}
/**
* 按日期查询当天体检人员列表(日期空则当天)。
*/
fun getExamListByDate(auditDate: String?) =
adapter().getExamListByDate(auditDate ?: DateUtils.today())
/**
* 体检结论(汇总为单字符串)。
*/
fun getTotalConclusion(peId: String): Map<String, String> =
mapOf("totalConclusion" to buildSummary(adapter().getConclusion(peId)))
/** 体检结论(汇总,全量来源) */
fun getTotalConclusionAll(peId: String): Map<String, String> =
mapOf("totalConclusion" to buildSummary(adapter().getConclusionAll(peId)))
/**
* 体检建议(汇总)。宁夏(nx)仅取首条 GUIDE_CONTENT。
*/
fun getTotalSuggest(peId: String): Map<String, String> =
mapOf("totalSuggest" to buildSuggest(adapter().getSuggest(peId), adapter().hmType() == "nx"))
/** 体检建议(汇总,全量来源) */
fun getTotalSuggestAll(peId: String): Map<String, String> =
mapOf("totalSuggest" to buildSuggest(adapter().getSuggestAll(peId), adapter().hmType() == "nx"))
/**
* 按身份证号 + 年份查询体检状态及项目(含宁夏体检报告兜底)。
*/
fun getPeStatusByIdnoAndYear(idNo: String, year: String): Map<String, Any?>? {
val map = adapter().getPeStatusByIdnoAndYear(idNo, year)
return if (map != null && map["PE_ID"] != null) {
val result = map.toMutableMap()
result["peItemList"] = adapter().getEmpItem(map["PE_ID"].toString())
result
} else {
// 宁夏:从体检报告继续查
if (adapter().hmType() == "nx") {
val rows = adapter().getResultByIdcardAndYear(idNo, year)
if (rows.isNotEmpty()) {
val row = rows.first()
val auditDate = row["AUDIT_DATE"]?.toString()
buildMap {
put("PE_ID", row["PE_ID"])
put("PE_PRE_DATE", auditDate?.takeIf { it.length >= 10 }?.substring(0, 10) ?: auditDate)
put("peItemList", adapter().getEmpItem(row["PE_ID"].toString()))
}
} else null
} else null
}
}
// ================= 私有工具 =================
/** 医院侧业务失败:抛出 2003 业务异常 */
private fun throwHospitalFailed(errorMsg: String?): Nothing =
if (!errorMsg.isNullOrEmpty()) throw BusinessException.of(2003, errorMsg)
else throw BusinessException.procFailed("医院系统返回业务失败")
/** 结论汇总:1.科室:结论;<br/> */
private fun buildSummary(rows: List<Map<String, Any?>>): String =
rows.mapIndexed { i, row ->
val dept = row["PE_DEPT_NAME"]?.toString()?.takeIf { it.isNotEmpty() }?.let { "$it:" } ?: ""
"${i + 1}.$dept${row["CONCLUSION_TEXT"] ?: ""};<br/>"
}.joinToString("")
/** 建议汇总:宁夏仅取首条 GUIDE_CONTENT;其余 1.建议项:内容;<br/> */
private fun buildSuggest(rows: List<Map<String, Any?>>, nxStyle: Boolean): String =
if (nxStyle) {
rows.firstOrNull()?.get("GUIDE_CONTENT")?.toString() ?: ""
} else {
rows.filter { !it["GUIDE_CONTENT"].toString().isNullOrEmpty() }
.mapIndexed { i, row -> "${i + 1}.${row["REC_CONTENT"] ?: ""}:${row["GUIDE_CONTENT"] ?: ""};<br/>" }
.joinToString("")
}
/** Map → OrderV2Vo(数值/空值容错) */
private fun convertOrderV2(body: Map<String, Any?>): OrderV2Vo = OrderV2Vo(
orderDate = body["orderDate"]?.toString(),
secondDepartId = body["secondDepartId"]?.toString(),
secondDepartName = body["secondDepartName"]?.toString(),
thirdDepartName = body["thirdDepartName"]?.toString(),
name = body["name"]?.toString(),
sexName = body["sexName"]?.toString(),
birthDay = body["birthDay"]?.toString(),
age = (body["age"] as? Number)?.toInt(),
idCard = body["idCard"]?.toString(),
marrigeStatusName = body["marrigeStatusName"]?.toString(),
country = body["country"]?.toString(),
nationName = body["nationName"]?.toString(),
birthplace = body["birthplace"]?.toString(),
position = body["position"]?.toString(),
profession = body["profession"]?.toString(),
feeType = body["feeType"]?.toString(),
liveSpace = body["liveSpace"]?.toString(),
zipCode = body["zipCode"]?.toString(),
mobile = body["mobile"]?.toString(),
email = body["email"]?.toString(),
empSysno = body["empSysno"]?.toString(),
medicalType = body["medicalType"]?.toString(),
medicalClass = body["medicalClass"]?.toString(),
workShape = body["workShape"]?.toString(),
workDate = body["workDate"]?.toString(),
jobLevel = body["jobLevel"]?.toString(),
workPlace = body["workPlace"]?.toString(),
baseSite = body["baseSite"]?.toString(),
workTypeCode = body["workTypeCode"]?.toString(),
workTypeOther = body["workTypeOther"]?.toString(),
education = body["education"]?.toString(),
title = body["title"]?.toString(),
oldDepartName = body["oldDepartName"]?.toString(),
harmReasonCode = body["harmReasonCode"]?.toString(),
harmReasonOther = body["harmReasonOther"]?.toString(),
harmDate = body["harmDate"]?.toString(),
defendCancer = body["defendCancer"]?.toString(),
itemList = (body["itemList"] as? List<*>)?.mapNotNull { item ->
(item as? Map<*, *>)?.let {
OrderItemVo(it["itemNo"]?.toString(), it["itemName"]?.toString())
}
},
)
/** Map → UnitVo */
private fun convertUnit(raw: Map<String, Any?>) = com.hospital.front.vo.UnitVo(
unitCode = raw["unitCode"]?.toString(),
unitName = raw["unitName"]?.toString(),
parentUnitCode = raw["parentUnitCode"]?.toString(),
address = raw["address"]?.toString(),
connecter1 = raw["connecter1"]?.toString(),
phone1 = raw["phone1"]?.toString(),
connecter2 = raw["connecter2"]?.toString(),
phone2 = raw["phone2"]?.toString(),
)
}
@@ -0,0 +1,33 @@
package com.hospital.front.task
import com.hospital.front.adapter.HospitalRouter
import org.noear.solon.annotation.Component
import org.noear.solon.annotation.Inject
import org.noear.solon.scheduling.annotation.Scheduled
import org.slf4j.LoggerFactory
/**
* 心跳任务(占位)。
*
* 后续 Step 6 实现 Agent 后,本任务会上报心跳到 B 端。
* 当前仅打印日志,验证 @Scheduled 工作正常。
*
* @author hospital-front重构
*/
@Component
class HeartbeatTask {
private val log = LoggerFactory.getLogger(HeartbeatTask::class.java)
@Inject
private lateinit var router: HospitalRouter
/**
* 每 60 秒一次心跳(默认 cron:0/60)。
* Step 6 将改为上报 B 端的 WS 消息。
*/
@Scheduled(cron = "0 * * * * *")
fun heartbeat() {
log.debug("[Heartbeat] hospital={}, uptime ok", router.activeCode())
}
}
@@ -0,0 +1,30 @@
package com.hospital.front.vo
/**
* 体检结果传输 VOMapper XML resultType)。
*
* 字段名与原 hospitalmiddle 的 MedicalMiddleResultVo 保持一致(XML 映射契约)。
* 数据库日期列已由 SQL 转为字符串(to_char / CONVERT)。
*/
data class MedicalMiddleResultVo(
var idNo: String? = null,
var peResult: String? = null,
var hospitalId: String? = null,
var unitCode: String? = null,
var peQueueDate: String? = null,
var printContext: String? = null,
var peItemName: String? = null,
var peId: String? = null,
var name: String? = null,
var itemAssemName: String? = null,
var peItemCode: String? = null,
var auditDate: String? = null,
var itemAssemCode: String? = null,
var unitName: String? = null,
var peVisitId: String? = null,
var unit: String? = null,
var conclusion: String? = null,
var suggest: String? = null,
/** 子表:体检项目结果列表(汇总接口组装用) */
var resultVoList: List<MedicalMiddleResultVo>? = null,
)
@@ -0,0 +1,169 @@
package com.hospital.front.vo
/**
* 预约订单体检项目条目。
*
* JSON 字段名与原 hospitalmiddle 的 OrderItemVo 保持一致(调用方契约,不可修改)。
*/
data class OrderItemVo(
val itemNo: String? = null,
val itemName: String? = null,
) {
companion object {
/**
* 拼接子表字符串:多项之间用 & 分隔,项内 编号|名称 分隔。
*/
fun toPeItemStr(items: List<OrderItemVo>?): String =
items?.joinToString("&") { "${it.itemNo ?: ""}|${it.itemName ?: ""}" } ?: ""
}
}
/**
* V1 版预约订单 VOhm.ver=V1 时使用)。
*
* 字段名与字段顺序均与原 hospitalmiddle 的 OrderVoJava)保持一致,
* 字段顺序即存储过程 P_ADD_PE_REGIST 的 peInfoStr 竖线分隔顺序,不可调整。
*/
data class OrderVo(
val orderDate: String? = null, // 1 预约日期 yyyy-MM-dd
val secondDepartId: String? = null, // 2 所属单位编码
val secondDepartName: String? = null, // 3 所属单位名称
val thirdDepartName: String? = null, // 4 所属部门
val name: String? = null, // 5 姓名
val sexName: String? = null, // 6 性别(男/女)
val birthDay: String? = null, // 7 出生日期 yyyy-MM-dd
val age: Int? = null, // 8 年龄
val idCard: String? = null, // 9 身份证号
val marrigeStatusName: String? = null, // 10 婚姻
val country: String? = null, // 11 国家
val nationName: String? = null, // 12 民族
val birthplace: String? = null, // 13 出生地
val position: String? = null, // 14 身份
val profession: String? = null, // 15 职业
val feeType: String? = null, // 16 费别
val liveSpace: String? = null, // 17 家庭住址
val zipCode: String? = null, // 18 邮编
val mobile: String? = null, // 19 移动电话
val email: String? = null, // 20 邮箱
val empSysno: String? = null, // 21 员工编号
val medicalType: String? = null, // 22 体检类型(职业/健康)
val medicalClass: String? = null, // 23 体检类别(岗前/在岗期间)
val workShape: String? = null, // 24 用工形式(合同/劳务)
val workDate: String? = null, // 25 入职时间 yyyy-MM-dd
val jobLevel: String? = null, // 26 职务级别
val workPlace: String? = null, // 27 工作地
val baseSite: String? = null, // 28 基层站
val workType: String? = null, // 29 职业工种(V1 单字段)
val education: String? = null, // 30 学历
val title: String? = null, // 31 职称
val oldDepartName: String? = null, // 32 原单位
val harmReason: String? = null, // 33 危害因素(V1 单字段)
val defendCancer: String? = null, // 34 防癌项
val itemList: List<OrderItemVo>? = null,
) {
/**
* 按存储过程约定顺序拼接主表字符串(竖线分隔,空值为空串)。
*/
fun toPeInfoStr(): String = listOf(
orderDate, secondDepartId, secondDepartName, thirdDepartName, name,
sexName, birthDay, age, idCard, marrigeStatusName,
country, nationName, birthplace, position, profession,
feeType, liveSpace, zipCode, mobile, email,
empSysno, medicalType, medicalClass, workShape, workDate,
jobLevel, workPlace, baseSite, workType, education,
title, oldDepartName, harmReason, defendCancer,
).joinToString("|") { it?.toString() ?: "" }
}
/**
* V2 版预约订单 VOhm.ver=V2 时使用)。
*
* 与 V1 的差异:工种拆为 编码+其它;危害因素拆为 编码+其它+接害时间。
*/
data class OrderV2Vo(
val orderDate: String? = null, // 1 预约日期 yyyy-MM-dd
val secondDepartId: String? = null, // 2 所属单位编码
val secondDepartName: String? = null, // 3 所属单位名称
val thirdDepartName: String? = null, // 4 所属部门
val name: String? = null, // 5 姓名
val sexName: String? = null, // 6 性别(男/女)
val birthDay: String? = null, // 7 出生日期 yyyy-MM-dd
val age: Int? = null, // 8 年龄
val idCard: String? = null, // 9 身份证号
val marrigeStatusName: String? = null, // 10 婚姻
val country: String? = null, // 11 国家
val nationName: String? = null, // 12 民族
val birthplace: String? = null, // 13 出生地
val position: String? = null, // 14 身份
val profession: String? = null, // 15 职业
val feeType: String? = null, // 16 费别
val liveSpace: String? = null, // 17 家庭住址
val zipCode: String? = null, // 18 邮编
val mobile: String? = null, // 19 移动电话
val email: String? = null, // 20 邮箱
val empSysno: String? = null, // 21 员工编号
val medicalType: String? = null, // 22 体检类型(职业/健康)
val medicalClass: String? = null, // 23 体检类别(岗前/在岗期间)
val workShape: String? = null, // 24 用工形式(合同/劳务)
val workDate: String? = null, // 25 入职时间 yyyy-MM-dd
val jobLevel: String? = null, // 26 职务级别
val workPlace: String? = null, // 27 工作地
val baseSite: String? = null, // 28 基层站
val workTypeCode: String? = null, // 29 职业工种编码(V2
val workTypeOther: String? = null, // 29b 其它工种(V2
val education: String? = null, // 30 学历
val title: String? = null, // 31 职称
val oldDepartName: String? = null, // 32 原单位
val harmReasonCode: String? = null, // 33 危害因素编码(V2
val harmReasonOther: String? = null, // 33b 其它危害因素(V2
val harmDate: String? = null, // 33c 接害时间 yyyy-MM-ddV2
val defendCancer: String? = null, // 34 防癌项
val itemList: List<OrderItemVo>? = null,
) {
/**
* 按存储过程约定顺序拼接主表字符串(竖线分隔,空值为空串)。
*/
fun toPeInfoStr(): String = listOf(
orderDate, secondDepartId, secondDepartName, thirdDepartName, name,
sexName, birthDay, age, idCard, marrigeStatusName,
country, nationName, birthplace, position, profession,
feeType, liveSpace, zipCode, mobile, email,
empSysno, medicalType, medicalClass, workShape, workDate,
jobLevel, workPlace, baseSite, workTypeCode, workTypeOther,
education, title, oldDepartName, harmReasonCode, harmReasonOther,
harmDate, defendCancer,
).joinToString("|") { it?.toString() ?: "" }
/**
* allsecondFlag 特殊处理(宁夏):二级单位编码 9 位截前 6 位,名称截"-"之前。
*/
fun applyAllsecondFlag(): OrderV2Vo {
val newId = secondDepartId?.takeIf { it.length == 9 }?.substring(0, 6) ?: secondDepartId
val newName = secondDepartName?.takeIf { it.contains("-") }?.substringBefore("-") ?: secondDepartName
return copy(secondDepartId = newId, secondDepartName = newName)
}
/**
* occFlag 特殊处理:健康体检不发送职业相关信息(危害因素、工种、接害时间)。
*/
fun applyOccFlag(): OrderV2Vo =
if (medicalType == "职业") this
else copy(
harmDate = null, harmReasonCode = null, harmReasonOther = null,
workTypeCode = null, workTypeOther = null,
)
}
/**
* 单位信息 VO(addUnit 接口入参,字段名与原 UnitVo 一致)。
*/
data class UnitVo(
val unitCode: String? = null, // 单位编码
val unitName: String? = null, // 单位名称
val parentUnitCode: String? = null, // 上级体检单位编号(V2
val address: String? = null, // 单位地址(V2
val connecter1: String? = null, // 联系人1V2
val phone1: String? = null, // 电话1V2
val connecter2: String? = null, // 联系人2V2
val phone2: String? = null, // 电话2V2
)
+19
View File
@@ -0,0 +1,19 @@
# ====================================
# 开发环境配置(H2 内存数据库 Mock)
# 启动:java -Dsolon.env=dev -Dsolon.config.load=app.yml,app-dev.yml -jar hospital-front-1.0.0.jar
# ====================================
# 本机无法直连医院数据库时使用;H2 以 Oracle 兼容模式运行
hospital:
active: dev # 启动激活"开发医院"
hm:
type: bsm
ver: V2
solon.dataSources:
db1!:
class: "org.h2.jdbcx.JdbcDataSource"
url: "jdbc:h2:mem:dev;MODE=Oracle;DB_CLOSE_DELAY=-1"
username: sa
password: ""
+63
View File
@@ -0,0 +1,63 @@
# ====================================
# Solon 主配置(框架级 + MyBatis 公共配置)
# ====================================
# 文件编码:UTF-8
# 数据源在各医院配置(pac/conf/app-{医院}.yml)中以 solon.dataSources.db1 定义
server:
port: 8082
solon:
app:
name: "hospital-front"
group: "hospital"
# 应用配置
hospital:
active: "jinghe" # 启动激活哪家医院
adapters: # 注册的医院清单(启动自检)
- "xinglongyuan"
- "qingyang"
- "ningxia"
- "yanan"
- "jinghe"
# ====================================
# 业务开关
# ====================================
hm:
type: "bsm" # Mapper 方言:bsmOracle/ nx / yaSqlServer
ver: "V2" # VO 版本
allsecondFlag: false # 宁夏医院强制发送二级单位编码和名称
occFlag: false # 健康体检不发送职业相关信息
# ====================================
# MyBatis-Plus 公共配置(db1 为唯一数据源,随医院配置注入)
# mappers 条目两种形式(Solon mybatis 约定):
# - 接口包名:扫描注册 Mapper 接口
# - .xml 资源路径:XMLMapperBuilder 加载语句(与接口包同路径)
# ====================================
mybatis.db1:
typeAliases:
- "com.hospital.front.vo"
mappers:
- "com.hospital.front.mapper.bsm"
- "com.hospital.front.mapper.nx"
- "com.hospital.front.mapper.ya"
- "com/hospital/front/mapper/bsm/OrderMapper.xml"
- "com/hospital/front/mapper/nx/OrderMapper.xml"
- "com/hospital/front/mapper/ya/OrderMapper.xml"
configuration:
mapUnderscoreToCamelCase: true
logImpl: "org.apache.ibatis.logging.slf4j.Slf4jImpl"
globalConfig:
banner: false
# ====================================
# 日志
# ====================================
logging:
level:
root: INFO
com.hospital.front: INFO
org.noear: WARN
@@ -0,0 +1,420 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hospital.front.mapper.bsm.BsmOrderMapper">
<!-- 健康探测 -->
<select id="ping" resultType="int">
SELECT 1 FROM DUAL
</select>
<parameterMap id="pm" type="java.util.Map">
<parameter property="peInfoStr" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="peItemStr" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="peId" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="saveOrder" parameterMap="pm" statementType="CALLABLE">
{call P_ADD_PE_REGIST(
#{peInfoStr,mode=IN,jdbcType=VARCHAR},
#{peItemStr,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR},
#{peId,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<parameterMap id="cancenlPm" type="java.util.Map">
<parameter property="peId" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="name" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="idcard" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="cancelOrder" parameterMap="cancenlPm" statementType="CALLABLE">
{call P_DEL_PE_REGIST(
#{peId,mode=IN,jdbcType=VARCHAR},
#{name,mode=IN,jdbcType=VARCHAR},
#{idcard,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<parameterMap id="unitPm" type="java.util.Map">
<parameter property="unitCode" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="unitName" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="addUnit" parameterMap="unitPm" statementType="CALLABLE">
{call P_ADD_UNIT(
#{unitCode,mode=IN,jdbcType=VARCHAR},
#{unitName,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<parameterMap id="unitPmV2" type="java.util.Map">
<parameter property="unitCode" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="unitName" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="parentUnitCode" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="address" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="connecter1" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="phone1" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="connecter2" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="phone2" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="addUnitV2" parameterMap="unitPmV2" statementType="CALLABLE">
{call P_ADD_UNIT(
#{unitCode,mode=IN,jdbcType=VARCHAR},
#{unitName,mode=IN,jdbcType=VARCHAR},
#{parentUnitCode,mode=IN,jdbcType=VARCHAR},
#{address,mode=IN,jdbcType=VARCHAR},
#{connecter1,mode=IN,jdbcType=VARCHAR},
#{phone1,mode=IN,jdbcType=VARCHAR},
#{connecter2,mode=IN,jdbcType=VARCHAR},
#{phone2,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<select id="getPhyexamStatus" resultType="java.util.Map">
select PE_ID, NAME, ID_NO, UNIT_NAME, DEPARTMENT, PE_PRE_DATE, PE_QUEUE_DATE, PE_STATUS
from V_PHYEXAM_PE_STATUS
where instr(',' || #{peIds} || ',', ',' || pe_id || ',') > 0
</select>
<select id="getItemList" resultType="java.util.Map">
select distinct item_assem_code, item_assem_name
from V_PHYEXAM_ITEM_ASSEMBLED
</select>
<select id="getAllItemList" resultType="java.util.Map">
select ITEM_ASSEM_CODE, ITEM_ASSEM_NAME, PE_ITEM_CODE, PE_ITEM_NAME
from V_PHYEXAM_ITEM_ASSEMBLED
</select>
<select id="getPeItemList" resultType="java.util.Map">
select *
from V_PHYEXAM_ITEM
</select>
<select id="getEmpItem" resultType="java.util.Map">
select PE_ID, UNIT_NAME, DEPARTMENT, ITEM_ASSEM_CODE, ITEM_ASSEM_NAME, PE_DEPT_CODE, PE_DEPT_NAME, REGISTER_DATE, FINISHED_SIGN, PE_DATE
from V_PHYEXAM_PE_PERSONAL_ITEM
where PE_ID = #{peId}
</select>
<select id="getItemInfo" resultType="java.util.Map">
select ITEM_ASSEM_CODE, ITEM_ASSEM_NAME, PE_ITEM_CODE, PE_ITEM_NAME
from V_PHYEXAM_ITEM_ASSEMBLED
where PE_ITEM_CODE = #{peItemCode}
</select>
<select id="getResult" resultType="java.util.Map">
select PE_ID,
PE_VISIT_ID,
ID_NO,
NAME,
UNIT_CODE,
UNIT_NAME,
TO_CHAR(PE_QUEUE_DATE, 'yyyy-mm-dd HH24:mi:ss') PE_QUEUE_DATE,
TO_CHAR(AUDIT_DATE, 'yyyy-mm-dd HH24:mi:ss') AUDIT_DATE,
PE_DEPT_CODE,
PE_DEPT_NAME,
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
PE_ITEM_CODE,
PE_ITEM_NAME,
PE_RESULT,
UNIT,
PRINT_CONTEXT
from V_PHYEXAM_RESULT
where PE_ID = #{peId}
</select>
<select id="getConclusion" resultType="java.util.Map">
select PE_ID,
PE_VISIT_ID,
PE_DEPT_CODE,
PE_DEPT_NAME,
REPLACE(REPLACE(REPLACE(CONCLUSION_TEXT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') CONCLUSION_TEXT
from V_PHYEXAM_DEPT_RESULT
where PE_ID = #{peId}
and CONCLUSION_TEXT is not null
</select>
<select id="getConclusionAll" resultType="java.util.Map">
select PE_ID,
PE_VISIT_ID,
PE_DEPT_CODE,
PE_DEPT_NAME,
REPLACE(REPLACE(REPLACE(CONCLUSION_TEXT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') CONCLUSION_TEXT
from (select a.pe_id,
a.pe_visit_id,
a.pe_dept_code,
a.pe_dept_name,
a.conclusion_text
from pe_dept_result_dict a,pe_visit b
where a.pe_id = b.pe_id
and a.pe_visit_id = b.pe_visit_id) a
where PE_ID = #{peId}
and CONCLUSION_TEXT is not null
</select>
<select id="getTotalConclusion" resultType="java.util.HashMap">
select wm_concat(PE_DEPT_NAME || ':' || REPLACE(REPLACE(REPLACE(to_lob(CONCLUSION_TEXT), chr(13) || chr(10), ''), chr(13), ''), chr(10), '') || '###') CONCLUSION_TEXT
from V_PHYEXAM_DEPT_RESULT
where PE_ID = #{peId}
and CONCLUSION_TEXT is not null
</select>
<select id="getSuggest" resultType="java.util.Map">
select PE_ID, PE_VISIT_ID, REC_CONTENT, REPLACE(REPLACE(REPLACE(GUIDE_CONTENT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') GUIDE_CONTENT
from V_PHYEXAM_DIAGNOSIS
where PE_ID = #{peId}
and GUIDE_CONTENT is not null
</select>
<select id="getSuggestAll" resultType="java.util.HashMap">
select PE_ID, PE_VISIT_ID, REC_CONTENT, REPLACE(REPLACE(REPLACE(GUIDE_CONTENT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') GUIDE_CONTENT
from (select a.pe_id,
a.pe_visit_id,
a.rec_content,
a.guide_content
from pe_diagnosis_record a,pe_visit b
where a.pe_id = b.pe_id
and a.pe_visit_id = b.pe_visit_id) a
where PE_ID = #{peId}
and GUIDE_CONTENT is not null
</select>
<select id="getTotalSuggest" resultType="java.util.HashMap">
select wm_concat(REC_CONTENT || ':' || REPLACE(REPLACE(REPLACE(GUIDE_CONTENT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') || '###') GUIDE_CONTENT
from V_PHYEXAM_DIAGNOSIS
where PE_ID = #{peId}
and GUIDE_CONTENT is not null
</select>
<select id="getPeStatusByIdnoAndYear" resultType="java.util.HashMap">
select *
from V_PHYEXAM_PE_STATUS
where ID_NO = #{idNo}
and to_char(PE_PRE_DATE, 'yyyy') = #{year}
</select>
<select id="getResultByIdcardAndYear" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select DISTINCT
ID_NO,
NAME,
PE_ID,
PE_VISIT_ID,
UNIT_CODE,
UNIT_NAME,
to_char( PE_QUEUE_DATE, 'yyyy-mm-dd HH24:mi:ss' ) PE_QUEUE_DATE,
to_char( AUDIT_DATE, 'yyyy-mm-dd HH24:mi:ss' ) AUDIT_DATE
from V_PHYEXAM_RESULT
where ID_NO = #{idNo}
and to_char(PE_QUEUE_DATE, 'yyyy') = #{year}
</select>
<select id="getResultByIdcardAndYearAll" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT DISTINCT
ID_NO,
NAME,
PE_ID,
PE_VISIT_ID,
UNIT_CODE,
UNIT_NAME,
to_char ( PE_QUEUE_DATE, 'yyyy-mm-dd HH24:mi:ss' ) PE_QUEUE_DATE,
to_char ( AUDIT_DATE, 'yyyy-mm-dd HH24:mi:ss' ) AUDIT_DATE
FROM
(
SELECT
b.pe_id,
b.pe_visit_id,
a.id_no,
a.NAME,
b.unit_code,
( SELECT t.unit_name FROM pe_unit_dict t WHERE t.unit_code = b.unit_code ) unit_name,
b.pe_queue_date,
b.audit_date,
c.pe_dept_code,
c.pe_dept_name,
c.item_assem_code,
c.item_assem_name,
c.pe_item_code,
c.pe_item_name,
c.pe_result,
c.unit,
c.print_context
FROM
pe_master_index a,
pe_visit b,
pe_result_dict c
WHERE
a.pe_id = b.pe_id
AND b.pe_id = c.pe_id
AND b.pe_visit_id = c.pe_visit_id
and TO_CHAR(b.audit_date, 'YYYY') = #{year}
and id_no = #{idNo}
) a
</select>
<select id="getResultByAuditDate" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select *
from V_PHYEXAM_RESULT
where to_char(AUDIT_DATE,'yyyy-mm-dd')=#{auditDate}
</select>
<select id="getDisResultByAuditDate" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT DISTINCT
ID_NO,
NAME,
PE_ID,
PE_VISIT_ID,
UNIT_CODE,
UNIT_NAME,
to_char( PE_QUEUE_DATE, 'yyyy-mm-dd HH24:mi:ss' ) PE_QUEUE_DATE,
to_char( AUDIT_DATE, 'yyyy-mm-dd HH24:mi:ss' ) AUDIT_DATE
FROM
V_PHYEXAM_RESULT
WHERE
to_char( AUDIT_DATE, 'yyyy-mm-dd' ) = #{auditDate}
</select>
<select id="getItemByPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
PE_ITEM_CODE,
PE_ITEM_NAME,
PE_RESULT,
UNIT,
PRINT_CONTEXT
FROM
V_PHYEXAM_RESULT
WHERE
PE_ID = #{peId}
</select>
<select id="getItemByPeIdAll" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
PE_ITEM_CODE,
PE_ITEM_NAME,
PE_RESULT,
UNIT,
PRINT_CONTEXT
FROM
(
SELECT
b.pe_id,
b.pe_visit_id,
a.id_no,
a.NAME,
b.unit_code,
( SELECT t.unit_name FROM pe_unit_dict t WHERE t.unit_code = b.unit_code ) unit_name,
b.pe_queue_date,
b.audit_date,
c.pe_dept_code,
c.pe_dept_name,
c.item_assem_code,
c.item_assem_name,
c.pe_item_code,
c.pe_item_name,
c.pe_result,
c.unit,
c.print_context
FROM
pe_master_index a,
pe_visit b,
pe_result_dict c
WHERE
a.pe_id = b.pe_id
AND b.pe_id = c.pe_id
AND b.pe_visit_id = c.pe_visit_id
AND b.pe_id= #{peId}
) a
</select>
<select id="getResultByIdNoOrPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select DISTINCT
ID_NO,
NAME,
PE_ID,
PE_VISIT_ID,
UNIT_CODE,
UNIT_NAME,
to_char( PE_QUEUE_DATE, 'yyyy-mm-dd HH24:mi:ss' ) PE_QUEUE_DATE,
to_char( AUDIT_DATE, 'yyyy-mm-dd HH24:mi:ss' ) AUDIT_DATE
from V_PHYEXAM_RESULT
<where>
1=1
<if test="idNo != null and idNo != '' ">
and ID_NO = #{idNo}
</if>
<if test="peId != null and peId != '' ">
and PE_ID = #{peId}
</if>
and to_char(AUDIT_DATE,'yyyy')=#{auditYear}
</where>
</select>
<select id="getPeIdAndDateByIdcard" resultType="java.util.Map">
select PE_ID,to_char( PE_PRE_DATE, 'yyyy-mm-dd' ) PE_PRE_DATE from V_PHYEXAM_PE_STATUS
<where>
<if test="idNo != null and idNo != '' ">
and ID_NO = #{idNo}
</if>
and to_char(PE_PRE_DATE,'yyyy') = to_char(SYSDATE,'yyyy')
</where>
order by PE_PRE_DATE desc
</select>
<select id="getResultByPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select DISTINCT
ID_NO,
NAME,
PE_ID,
PE_VISIT_ID,
UNIT_CODE,
UNIT_NAME,
to_char( PE_QUEUE_DATE, 'yyyy-mm-dd HH24:mi:ss' ) PE_QUEUE_DATE,
to_char( AUDIT_DATE, 'yyyy-mm-dd HH24:mi:ss' ) AUDIT_DATE
from V_PHYEXAM_RESULT
where PE_ID = #{peId}
</select>
<select id="getPeIdAndDateByIdcardList" resultType="java.util.Map">
select PE_ID,to_char( PE_PRE_DATE, 'yyyy-mm-dd' ) PE_PRE_DATE from V_PHYEXAM_PE_STATUS
<where>
<if test="idNo != null and idNo != '' ">
and ID_NO = #{idNo}
</if>
and to_char(PE_PRE_DATE,'yyyy') = to_char(SYSDATE,'yyyy')
</where>
order by PE_PRE_DATE desc
</select>
<!-- 根据audit_date查询当天体检人员列表(去重) -->
<select id="getExamListByDate" resultType="java.util.Map">
SELECT DISTINCT PE_ID AS "peId",
ID_NO AS "idNo",
NAME AS "name",
TO_CHAR(audit_date, 'YYYY-MM-DD') AS "auditDate"
FROM V_PHYEXAM_RESULT
<where>
<choose>
<when test="auditDate != null and auditDate != ''">
audit_date &gt;= TO_DATE(#{auditDate}, 'YYYY-MM-DD')
AND audit_date &lt; TO_DATE(#{auditDate}, 'YYYY-MM-DD') + 1
</when>
<otherwise>
audit_date &gt;= TRUNC(SYSDATE)
AND audit_date &lt; TRUNC(SYSDATE) + 1
</otherwise>
</choose>
</where>
</select>
</mapper>
@@ -0,0 +1,416 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hospital.front.mapper.nx.NxOrderMapper">
<!-- 健康探测 -->
<select id="ping" resultType="int">
SELECT 1
</select>
<parameterMap id="pm" type="java.util.Map">
<parameter property="peInfoStr" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="peItemStr" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="peId" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="saveOrder" parameterMap="pm" statementType="CALLABLE">
{call P_ADD_PE_REGIST(
#{peInfoStr,mode=IN,jdbcType=VARCHAR},
#{peItemStr,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR},
#{peId,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<parameterMap id="cancenlPm" type="java.util.Map">
<parameter property="peId" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="name" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="idcard" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="cancelOrder" parameterMap="cancenlPm" statementType="CALLABLE">
{call P_DEL_PE_REGIST(
#{peId,mode=IN,jdbcType=VARCHAR},
#{name,mode=IN,jdbcType=VARCHAR},
#{idcard,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<parameterMap id="unitPm" type="java.util.Map">
<parameter property="unitCode" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="unitName" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="parentUnitCode" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="address" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="connecter1" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="phone1" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="connecter2" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="phone2" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="addUnitV2" parameterMap="unitPm" statementType="CALLABLE">
{call P_ADD_UNIT(
#{unitCode,mode=IN,jdbcType=VARCHAR},
#{unitName,mode=IN,jdbcType=VARCHAR},
#{parentUnitCode,mode=IN,jdbcType=VARCHAR},
#{address,mode=IN,jdbcType=VARCHAR},
#{connecter1,mode=IN,jdbcType=VARCHAR},
#{phone1,mode=IN,jdbcType=VARCHAR},
#{connecter2,mode=IN,jdbcType=VARCHAR},
#{phone2,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<select id="getPhyexamStatus" resultType="java.util.Map">
SELECT
PE_ID,
NAME,
ID_NO,
UNIT_NAME,
DEPARTMENT,
(CONVERT(varchar(100), PE_PRE_DATE, 20)) PE_PRE_DATE,
PE_STATUE PE_STATUS
FROM
V_PHYEXAM_PE_STATUS
WHERE
CHARINDEX ( ',' + pe_id + ',', ',' + #{peIds} + ',' ) >0
</select>
<select id="getItemList" resultType="java.util.Map">
SELECT DISTINCT
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME
FROM
V_PHYEXAM_ITEM_ASSEMBLED
WHERE
isnull( item_assem_code, '' ) != ''
</select>
<select id="getAllItemList" resultType="java.util.Map">
SELECT DISTINCT
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
PE_ITEM_CODE,
PE_ITEM_NAME
FROM
V_PHYEXAM_ITEM_ASSEMBLED
WHERE
isnull( item_assem_code, '' ) != ''
AND isnull( PE_ITEM_CODE, '' ) != ''
</select>
<select id="getPeItemList" resultType="java.util.Map">
SELECT DISTINCT
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME
FROM
V_PHYEXAM_ITEM_ASSEMBLED
WHERE
isnull( item_assem_code, '' ) != ''
</select>
<select id="getEmpItem" resultType="java.util.Map">
SELECT
PE_ID,
UNIT_NAME,
DEPARTMENT,
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
(case when Finished_sign = '1' then '完成' when Finished_sign != '1' then '未完成' end) FINISHED_SIGN,
CONVERT ( VARCHAR ( 100 ), PE_DATE, 20 ) PE_DATE
from V_PHYEXAM_PE_PERSONAL_ITEM
where PE_ID = #{peId}
and isnull(Item_assem_code,'') != ''
</select>
<select id="getItemInfo" resultType="java.util.Map">
SELECT
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
PE_ITEM_CODE,
PE_ITEM_NAME
FROM
V_PHYEXAM_ITEM_ASSEMBLED
WHERE
pe_item_code = #{peItemCode}
</select>
<select id="getConclusion" resultType="java.util.Map">
select PE_ID,
'' PE_VISIT_ID,
PE_DEPT_CODE,
PE_DEPT_NAME,
REPLACE(REPLACE(REPLACE(REPLACE(cast(CONCLUSION_TEXT as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT
from V_PHYEXAM_DEPT_RESULT
where PE_ID = #{peId}
and CONCLUSION_TEXT is not null
</select>
<select id="getConclusionAll" resultType="java.util.Map">
select PE_ID,
'' PE_VISIT_ID,
PE_DEPT_CODE,
PE_DEPT_NAME,
REPLACE(REPLACE(REPLACE(REPLACE(cast(CONCLUSION_TEXT as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT
from V_PHYEXAM_DEPT_RESULT
where PE_ID = #{peId}
and CONCLUSION_TEXT is not null
</select>
<select id="getSuggest" resultType="java.util.Map">
SELECT top 1
PE_ID,
REPLACE(
REPLACE(
REPLACE(
REPLACE(
REPLACE(REPLACE(CAST (GUIDE_CONTENT AS VARCHAR (8000)), CHAR(10), '&lt;br/&gt;'), ' ', ''),
CHAR(9),''
), CHAR(13), ''
), '&lt;br/&gt;&lt;br/&gt;','&lt;br/&gt;'
), '&lt;br/&gt;-谢谢合作祝您健康!&lt;br/&gt;',''
) GUIDE_CONTENT
FROM
V_PHYEXAM_DIAGNOSIS
where PE_ID = #{peId}
AND REC_CONTENT IS NOT NULL
</select>
<select id="getSuggestAll" resultType="java.util.Map">
SELECT top 1
PE_ID,
REPLACE(
REPLACE(
REPLACE(
REPLACE(
REPLACE(REPLACE(CAST (GUIDE_CONTENT AS VARCHAR (8000)), CHAR(10), '&lt;br/&gt;'), ' ', ''),
CHAR(9),''
), CHAR(13), ''
), '&lt;br/&gt;&lt;br/&gt;','&lt;br/&gt;'
), '&lt;br/&gt;-谢谢合作祝您健康!&lt;br/&gt;',''
) GUIDE_CONTENT
FROM
V_PHYEXAM_DIAGNOSIS
where PE_ID = #{peId}
AND REC_CONTENT IS NOT NULL
</select>
<select id="getPeStatusByIdnoAndYear" resultType="java.util.Map">
SELECT
PE_ID,
NAME,
ID_NO,
UNIT_NAME,
DEPARTMENT,
( CONVERT ( VARCHAR ( 100 ), Pe_pre_date, 20 ) ) PE_PRE_DATE,
Pe_statue PE_STATUS
FROM
V_PHYEXAM_PE_STATUS
WHERE
id_no = #{idNo}
AND CONVERT ( VARCHAR ( 4 ), Pe_pre_date, 23 ) = #{year}
</select>
<select id="getPeIdAndDateByIdcard" resultType="java.util.Map">
SELECT
PE_ID,
CONVERT ( VARCHAR ( 100 ), PE_PRE_DATE, 23 ) PE_PRE_DATE
FROM
V_PHYEXAM_PE_STATUS
<where>
<if test="idNo != null and idNo != '' ">
and ID_NO = #{idNo}
</if>
and CONVERT ( VARCHAR ( 4 ), PE_PRE_DATE, 23 ) = CONVERT ( VARCHAR ( 4 ), GETDATE(), 23 )
</where>
order by PE_PRE_DATE desc
</select>
<select id="getPeIdAndDateByIdcardList" resultType="java.util.Map">
SELECT
distinct
PE_ID,
CONVERT ( VARCHAR ( 100 ), PE_PRE_DATE, 23 ) PE_PRE_DATE
FROM
V_PHYEXAM_PE_STATUS
<where>
<if test="idNo != null and idNo != '' ">
and ID_NO = #{idNo}
</if>
and CONVERT ( VARCHAR ( 4 ), PE_PRE_DATE, 23 ) = CONVERT ( VARCHAR ( 4 ), GETDATE(), 23 )
</where>
</select>
<select id="getResult" resultType="java.util.Map">
select PE_ID,
ID_NO,
NAME,
UNIT_CODE,
UNIT_NAME,
CONVERT(varchar(100),PE_QUEUE_DATE,20) PE_QUEUE_DATE,
CONVERT(varchar(100),AUDIT_DATE,20) AUDIT_DATE,
PE_DEPT_CODE,
PE_DEPT_NAME,
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
PE_ITEM_CODE,
PE_ITEM_NAME,
PE_RESULT,
UNIT,
PRINT_CONTEXT
from V_PHYEXAM_RESULT
where PE_ID = #{peId}
and PE_RESULT is not null
</select>
<select id="getResultByIdcardAndYear" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select
ID_NO,
NAME,
PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
UNIT_NAME,
CONVERT(varchar(100),max(PE_QUEUE_DATE),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(AUDIT_DATE),20) AUDIT_DATE
from V_PHYEXAM_RESULT
where ID_NO = #{idNo}
AND CONVERT(varchar(4),AUDIT_DATE,23) = #{year}
group by id_no,NAME,PE_ID,UNIT_NAME
</select>
<select id="getResultByIdcardAndYearAll" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select
ID_NO,
NAME,
PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
UNIT_NAME,
CONVERT(varchar(100),max(PE_QUEUE_DATE),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(AUDIT_DATE),20) AUDIT_DATE
from V_PHYEXAM_RESULT
where ID_NO = #{idNo}
AND CONVERT(varchar(4),AUDIT_DATE,23) = #{year}
group by id_no,NAME,PE_ID,UNIT_NAME
</select>
<select id="getDisResultByAuditDate" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select
ID_NO,
NAME,
PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
UNIT_NAME,
CONVERT(varchar(100),max(PE_QUEUE_DATE),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(AUDIT_DATE),20) AUDIT_DATE
from V_PHYEXAM_RESULT
where CONVERT(varchar(100),AUDIT_DATE,23) = #{auditDate}
group by id_no,NAME,PE_ID,UNIT_NAME
</select>
<select id="getItemByPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
PE_ITEM_CODE,
PE_ITEM_NAME,
PE_RESULT,
UNIT,
PRINT_CONTEXT
FROM
V_PHYEXAM_RESULT
WHERE
PE_ID = #{peId}
and PE_RESULT is not null
</select>
<select id="getItemByPeIdAll" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT
ITEM_ASSEM_CODE,
ITEM_ASSEM_NAME,
PE_ITEM_CODE,
PE_ITEM_NAME,
PE_RESULT,
UNIT,
PRINT_CONTEXT
FROM
V_PHYEXAM_RESULT
WHERE
PE_ID = #{peId}
and PE_RESULT is not null
</select>
<select id="getResultByIdNoOrPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select
ID_NO,
NAME,
PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
UNIT_NAME,
CONVERT(varchar(100),max(PE_QUEUE_DATE),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(AUDIT_DATE),20) AUDIT_DATE
from V_PHYEXAM_RESULT
<where>
1=1
<if test="idNo != null and idNo != '' ">
and ID_NO = #{idNo}
</if>
<if test="peId != null and peId != '' ">
and PE_ID = #{peId}
</if>
and CONVERT(varchar(4),AUDIT_DATE,23)=#{auditYear}
</where>
group by id_no,NAME,PE_ID,UNIT_NAME
</select>
<select id="getResultByPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select
ID_NO,
NAME,
PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
UNIT_NAME,
CONVERT(varchar(100),max(PE_QUEUE_DATE),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(AUDIT_DATE),20) AUDIT_DATE
from V_PHYEXAM_RESULT
WHERE
PE_ID = #{peId}
group by id_no,NAME,PE_ID,UNIT_NAME
</select>
<!-- 根据audit_date查询当天体检人员列表(去重) -->
<select id="getExamListByDate" resultType="java.util.Map">
SELECT DISTINCT PE_ID AS "peId",
ID_NO AS "idNo",
NAME AS "name",
CONVERT(VARCHAR(10), AUDIT_DATE, 23) AS "auditDate"
FROM V_PHYEXAM_RESULT
<where>
<choose>
<when test="auditDate != null and auditDate != ''">
CONVERT(VARCHAR(10), AUDIT_DATE, 23) = #{auditDate}
</when>
<otherwise>
CONVERT(VARCHAR(10), AUDIT_DATE, 23) = CONVERT(VARCHAR(10), GETDATE(), 23)
</otherwise>
</choose>
</where>
</select>
</mapper>
@@ -0,0 +1,415 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hospital.front.mapper.ya.YaOrderMapper">
<!-- 健康探测 -->
<select id="ping" resultType="int">
SELECT 1
</select>
<parameterMap id="pm" type="java.util.Map">
<parameter property="peInfoStr" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="peItemStr" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="peId" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="saveOrder" parameterMap="pm" statementType="CALLABLE">
{call P_OrderRegister_YARMYY(
#{peInfoStr,mode=IN,jdbcType=VARCHAR},
#{peItemStr,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR},
#{peId,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<parameterMap id="cancenlPm" type="java.util.Map">
<parameter property="peId" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="name" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="idcard" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="cancelOrder" parameterMap="cancenlPm" statementType="CALLABLE">
{call P_CANCELORDER(
#{peId,mode=IN,jdbcType=VARCHAR},
#{name,mode=IN,jdbcType=VARCHAR},
#{idcard,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<parameterMap id="unitPm" type="java.util.Map">
<parameter property="unitCode" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="unitName" jdbcType="VARCHAR" mode="IN"></parameter>
<parameter property="resultCode" jdbcType="VARCHAR" mode="OUT"></parameter>
<parameter property="errorMsg" jdbcType="VARCHAR" mode="OUT"></parameter>
</parameterMap>
<insert id="addUnit" parameterMap="unitPm" statementType="CALLABLE">
{call P_ORGINFOUPDATE(
#{unitCode,mode=IN,jdbcType=VARCHAR},
#{unitName,mode=IN,jdbcType=VARCHAR},
#{resultCode,mode=OUT,jdbcType=VARCHAR},
#{errorMsg,mode=OUT,jdbcType=VARCHAR}
)}
</insert>
<select id="getPhyexamStatus" resultType="java.util.Map">
SELECT
tjh PE_ID,
xm NAME,
sfzh ID_NO,
dwmc UNIT_NAME,
dwbm DEPARTMENT,
(CONVERT(varchar(100), yyrq, 20)) PE_PRE_DATE,
(
CASE
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '4' THEN
'终审'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '3' THEN
'初审'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '2' THEN
'检中'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '1' THEN
'报道'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '0' THEN
'预约'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) IS NULL THEN
'预约'
END
) PE_STATUS
FROM
V_YGTJZT
WHERE
CHARINDEX( ',' + tjh + ',', ',' + #{peIds} + ',' ) >0
</select>
<select id="getItemList" resultType="java.util.Map">
SELECT DISTINCT
tjzhxbm item_assem_code,
tjzhxmc item_assem_name
FROM
V_TJXMZH
</select>
<select id="getAllItemList" resultType="java.util.Map">
SELECT DISTINCT
tjzhxbm ITEM_ASSEM_CODE,
tjzhxmc ITEM_ASSEM_NAME,
tjzbxbm PE_ITEM_CODE,
tjzbxmc PE_ITEM_NAME
FROM
V_TJXMZH
</select>
<select id="getPeItemList" resultType="java.util.Map">
SELECT DISTINCT
tjzbxbm PE_ITEM_CODE,
tjzbxmc PE_ITEM_NAME
FROM
V_TJXMZH
</select>
<select id="getEmpItem" resultType="java.util.Map">
SELECT
TJH PE_ID,
XM NAME,
DWMC UNIT_NAME,
DWBM DEPARTMENT,
TJZHXBM ITEM_ASSEM_CODE,
TJZHXMC ITEM_ASSEM_NAME,
(case when WCZT = '1' then '完成' when WCZT != '1' then '未完成' end) FINISHED_SIGN,
CONVERT ( VARCHAR ( 100 ), JCWCSJ, 20 ) PE_DATE
from V_YGYYTJZHX
where TJH = #{peId}
</select>
<select id="getItemInfo" resultType="java.util.Map">
SELECT
tjzhxbm ITEM_ASSEM_CODE,
tjzhxmc ITEM_ASSEM_NAME,
TJZBXBM PE_ITEM_CODE,
TJZBXMC PE_ITEM_NAME
FROM
V_TJXMZH
where tjzhxbm = #{peItemCode}
</select>
<select id="getResult" resultType="java.util.Map">
SELECT
TJH PE_ID ,
XM NAME,
SFZH ID_NO,
DWMC UNIT_NAME,
DWBM PE_DEPT_NAME,
TJZHXBM ITEM_ASSEM_CODE,
TJZHXMC ITEM_ASSEM_NAME,
TJZBXBM PE_ITEM_CODE,
TJZBXMC PE_ITEM_NAME,
TJZBZ PE_RESULT,
TJZBDW UNIT,
CONVERT(varchar(100),shsj,20) PE_QUEUE_DATE,
CONVERT(varchar(100),shsj,20) AUDIT_DATE,
CKFW PRINT_CONTEXT
FROM
V_TJBGJG
WHERE
TJH = #{peId}
and TJZBXBM is not null
</select>
<select id="getConclusion" resultType="java.util.Map">
select TJH PE_ID,'' PE_VISIT_ID,
REPLACE(REPLACE(REPLACE(REPLACE(cast(TJJL as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT
from V_TJJY
where TJH = #{peId}
and TJJL is not null
</select>
<select id="getConclusionAll" resultType="java.util.Map">
select TJH PE_ID,'' PE_VISIT_ID,
REPLACE(REPLACE(REPLACE(REPLACE(cast(TJJL as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') CONCLUSION_TEXT
from V_TJJY
where TJH = #{peId}
and TJJL is not null
</select>
<!--<select id="getTotalConclusion" resultType="java.util.HashMap">
select wm_concat(PE_DEPT_NAME || ':' || REPLACE(REPLACE(REPLACE(to_lob(CONCLUSION_TEXT), chr(13) || chr(10), ''), chr(13), ''), chr(10), '') || '###') CONCLUSION_TEXT
from V_PHYEXAM_DEPT_RESULT
where PE_ID = #{peId}
and CONCLUSION_TEXT is not null
</select>-->
<select id="getSuggest" resultType="java.util.Map">
select TJH PE_ID,'' PE_VISIT_ID,
REPLACE(REPLACE(REPLACE(REPLACE(cast(TJJL as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') REC_CONTENT,
REPLACE(REPLACE(REPLACE(REPLACE(cast(TJJY as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') GUIDE_CONTENT
from V_TJJY
where TJH = #{peId}
and TJJL is not null
</select>
<select id="getSuggestAll" resultType="java.util.Map">
select TJH PE_ID,'' PE_VISIT_ID,
REPLACE(REPLACE(REPLACE(REPLACE(cast(TJJL as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') REC_CONTENT,
REPLACE(REPLACE(REPLACE(REPLACE(cast(TJJY as varchar(8000)),CHAR(10),''), ' ', ''),char(9),''),char(13),'') GUIDE_CONTENT
from V_TJJY
where TJH = #{peId}
and TJJL is not null
</select>
<!--<select id="getTotalSuggest" resultType="java.util.HashMap">
select wm_concat(REC_CONTENT || ':' || REPLACE(REPLACE(REPLACE(GUIDE_CONTENT, chr(13) || chr(10), ''), chr(13), ''), chr(10), '') || '###') GUIDE_CONTENT
from V_PHYEXAM_DIAGNOSIS
where PE_ID = #{peId}
and GUIDE_CONTENT is not null
</select>-->
<select id="getPeStatusByIdnoAndYear" resultType="java.util.Map">
SELECT
tjh PE_ID,
xm NAME,
sfzh ID_NO,
dwmc UNIT_NAME,
dwbm DEPARTMENT,
(CONVERT(varchar(100), yyrq, 20)) PE_PRE_DATE,
(
CASE
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '4' THEN
'终审'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '3' THEN
'初审'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '2' THEN
'检中'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '1' THEN
'报道'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) = '0' THEN
'预约'
WHEN ( bdzt + 0+jczt + 0+cszt + 0+zszt + 0 ) IS NULL THEN
'预约'
END
) PE_STATUS
FROM
V_YGTJZT
WHERE SFZH = #{idNo}
AND CONVERT(varchar(4), YYRQ, 23) = #{year}
</select>
<select id="getResultByIdcardAndYear" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT
SFZH ID_NO,
XM NAME,
TJH PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
DWMC UNIT_NAME,
CONVERT(varchar(100),max(shsj),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(shsj),20) AUDIT_DATE
FROM
V_TJBGJG
WHERE
SFZH = #{ idNo }
AND CONVERT(varchar(4),SHSJ,23) = #{year}
group by SFZH,XM,TJH,DWMC
</select>
<select id="getResultByIdcardAndYearAll" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT
SFZH ID_NO,
XM NAME,
TJH PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
DWMC UNIT_NAME,
CONVERT(varchar(100),max(shsj),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(shsj),20) AUDIT_DATE
FROM
V_TJBGJG
WHERE
SFZH = #{ idNo }
AND CONVERT(varchar(4),SHSJ,23) = #{year}
group by SFZH,XM,TJH,DWMC
</select>
<!--<select id="getResultByAuditDate" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
select *
from V_PHYEXAM_RESULT
where to_char(AUDIT_DATE,'yyyy-mm-dd')=#{auditDate}
</select>-->
<select id="getDisResultByAuditDate" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT
SFZH ID_NO,
XM NAME,
TJH PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
DWMC UNIT_NAME,
CONVERT(varchar(100),max(shsj),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(shsj),20) AUDIT_DATE
FROM
V_TJBGJG
WHERE
CONVERT(varchar(100),shsj,23)= #{auditDate}
GROUP BY SFZH,XM,TJH,DWMC
</select>
<select id="getItemByPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT DISTINCT
TJZHXBM ITEM_ASSEM_CODE,
TJZHXMC ITEM_ASSEM_NAME,
TJZBXBM PE_ITEM_CODE,
TJZBXMC PE_ITEM_NAME,
TJZBZ PE_RESULT,
TJZBDW UNIT,
CKFW PRINT_CONTEXT
FROM
V_TJBGJG
WHERE
TJH = #{peId}
and TJZBXBM is not null
</select>
<select id="getItemByPeIdAll" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT DISTINCT
TJZHXBM ITEM_ASSEM_CODE,
TJZHXMC ITEM_ASSEM_NAME,
TJZBXBM PE_ITEM_CODE,
TJZBXMC PE_ITEM_NAME,
TJZBZ PE_RESULT,
TJZBDW UNIT,
CKFW PRINT_CONTEXT
FROM
V_TJBGJG
WHERE
TJH = #{peId}
and TJZBXBM is not null
</select>
<select id="getResultByIdNoOrPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT
SFZH ID_NO,
XM NAME,
TJH PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
DWMC UNIT_NAME,
CONVERT(varchar(100),max(shsj),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(shsj),20) AUDIT_DATE
FROM
V_TJBGJG
<where>
1=1
<if test="idNo != null and idNo != '' ">
and SFZH = #{idNo}
</if>
<if test="peId != null and peId != '' ">
and TJH = #{peId}
</if>
and CONVERT(varchar(4),shsj,23)=#{auditYear}
</where>
GROUP BY SFZH,XM,TJH,DWMC
</select>
<select id="getPeIdAndDateByIdcard" resultType="java.util.Map">
SELECT
tjh pe_id,
CONVERT ( VARCHAR ( 100 ), JCWCSJ, 23 ) PE_PRE_DATE
FROM
V_YGYYTJZHX
<where>
<if test="idNo != null and idNo != '' ">
and SFZH = #{idNo}
</if>
and CONVERT ( VARCHAR ( 4 ), JCWCSJ, 23 ) = CONVERT ( VARCHAR ( 4 ), GETDATE(), 23 )
</where>
order by JCWCSJ desc
</select>
<select id="getResultByPeId" resultType="com.hospital.front.vo.MedicalMiddleResultVo">
SELECT
SFZH ID_NO,
XM NAME,
TJH PE_ID,
'' PE_VISIT_ID,
'' UNIT_CODE,
DWMC UNIT_NAME,
CONVERT(varchar(100),max(shsj),20) PE_QUEUE_DATE,
CONVERT(varchar(100),max(shsj),20) AUDIT_DATE
FROM
V_TJBGJG
WHERE
TJH = #{peId}
GROUP BY SFZH,XM,TJH,DWMC
</select>
<select id="getPeIdAndDateByIdcardList" resultType="java.util.Map">
SELECT
distinct
tjh PE_ID,
CONVERT ( VARCHAR ( 100 ), JCWCSJ, 23 ) PE_PRE_DATE
FROM
V_YGYYTJZHX
<where>
<if test="idNo != null and idNo != '' ">
and SFZH = #{idNo}
</if>
and CONVERT ( VARCHAR ( 4 ), JCWCSJ, 23 ) = CONVERT ( VARCHAR ( 4 ), GETDATE(), 23 )
</where>
</select>
<!-- 根据audit_date查询当天体检人员列表(去重) -->
<select id="getExamListByDate" resultType="java.util.Map">
SELECT DISTINCT TJH AS "peId",
SFZH AS "idNo",
XM AS "name",
CONVERT(VARCHAR(10), shsj, 23) AS "auditDate"
FROM V_TJBGJG
<where>
<choose>
<when test="auditDate != null and auditDate != ''">
CONVERT(VARCHAR(10), shsj, 23) = #{auditDate}
</when>
<otherwise>
CONVERT(VARCHAR(10), shsj, 23) = CONVERT(VARCHAR(10), GETDATE(), 23)
</otherwise>
</choose>
</where>
</select>
</mapper>
+84
View File
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Logback 配置(Solon 4.0.6 集成)
- 控制台:开发期实时输出
- info.log:业务日志(50MB/30天滚动)
- error.log:错误日志(独立)
- 异步 appender:高并发不阻塞业务
Step 8 将追加自定义 WebSocketAppender,用于实时日志推送至 B 端。
-->
<configuration debug="false" scan="true" scanPeriod="30 seconds">
<!-- 日志路径(可通过 -Dlog.path=xxx 覆盖) -->
<property name="LOG_PATH" value="${log.path:-./log}"/>
<!-- ==================== 控制台 ==================== -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%thread] %logger{40} - %msg%n</pattern>
</encoder>
</appender>
<!-- ==================== 业务日志 info.log ==================== -->
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/info.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/info.%d{yyyy-MM-dd}.%i.log.zip</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%date [%thread] %-5level [%logger{50}] %file:%line - %msg%n</pattern>
</encoder>
</appender>
<!-- ==================== 错误日志 error.log ==================== -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/error.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log.zip</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>2GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%date [%thread] %-5level [%logger{50}] %file:%line - %msg%n</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
</appender>
<!-- ==================== 异步包装(不阻塞业务) ==================== -->
<appender name="ASYNC_INFO" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>2048</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<appender-ref ref="INFO_FILE"/>
</appender>
<appender name="ASYNC_ERROR" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>1024</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<appender-ref ref="ERROR_FILE"/>
</appender>
<!-- ==================== 根日志 ==================== -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_INFO"/>
<appender-ref ref="ASYNC_ERROR"/>
</root>
<!-- ==================== 框架级别 ==================== -->
<logger name="org.noear" level="WARN"/>
<logger name="org.springframework" level="WARN"/>
<logger name="com.baomidou.mybatisplus" level="WARN"/>
<logger name="org.mybatis" level="WARN"/>
<!-- ==================== 业务级别(开发期可改 DEBUG ==================== -->
<logger name="com.hospital.front" level="INFO"/>
</configuration>