feat(mapping): 完成 PHIS 健康档案转换(T-212)

This commit is contained in:
ila
2026-07-16 00:53:16 +08:00
parent d385d6dd3b
commit 8a5550057c
20 changed files with 1078 additions and 124 deletions
+23
View File
@@ -12,6 +12,8 @@ type DictionarySnapshot struct {
regionByName map[string]string
doctorByName map[string]string
orgByName map[string]string
doctorIDs map[string]struct{}
orgIDs map[string]struct{}
}
type PublicClient interface {
@@ -42,15 +44,19 @@ func NewDictionarySnapshot(grids []osi.GridAddress, doctors []osi.Doctor, orgs [
regionByName: make(map[string]string, len(grids)),
doctorByName: make(map[string]string, len(doctors)),
orgByName: make(map[string]string, len(orgs)),
doctorIDs: make(map[string]struct{}, len(doctors)),
orgIDs: make(map[string]struct{}, len(orgs)),
}
for _, row := range grids {
put(snapshot.regionByName, row.RegionName, row.RegionCode)
}
for _, row := range doctors {
put(snapshot.doctorByName, row.PersonName, row.PersonID)
putID(snapshot.doctorIDs, row.PersonID)
}
for _, row := range orgs {
put(snapshot.orgByName, row.OrganizName, row.OrganizCode)
putID(snapshot.orgIDs, row.OrganizCode)
}
return snapshot
}
@@ -102,6 +108,16 @@ func (s DictionarySnapshot) ManaUnitIDByName(name string) (string, bool) {
return lookup(s.orgByName, name)
}
func (s DictionarySnapshot) DoctorIDExists(id string) bool {
_, ok := s.doctorIDs[strings.TrimSpace(id)]
return ok
}
func (s DictionarySnapshot) ManaUnitIDExists(id string) bool {
_, ok := s.orgIDs[strings.TrimSpace(id)]
return ok
}
func put(index map[string]string, name string, code string) {
name = strings.TrimSpace(name)
code = strings.TrimSpace(code)
@@ -113,6 +129,13 @@ func put(index map[string]string, name string, code string) {
}
}
func putID(index map[string]struct{}, id string) {
id = strings.TrimSpace(id)
if id != "" {
index[id] = struct{}{}
}
}
func lookup(index map[string]string, name string) (string, bool) {
code, ok := index[strings.TrimSpace(name)]
return code, ok
+6
View File
@@ -24,6 +24,12 @@ func TestDictionarySnapshotLooksUpMasterDataByName(t *testing.T) {
if got, ok := snapshot.ManaUnitIDByName("测试机构"); !ok || got != "123456789" {
t.Fatalf("ManaUnitIDByName = %q, %v", got, ok)
}
if !snapshot.DoctorIDExists("doc-001") || snapshot.DoctorIDExists("doc-missing") {
t.Fatalf("DoctorIDExists returned unexpected membership")
}
if !snapshot.ManaUnitIDExists("123456789") || snapshot.ManaUnitIDExists("unit-missing") {
t.Fatalf("ManaUnitIDExists returned unexpected membership")
}
}
func TestDictionaryServiceRefreshKeepsMemoryWhenPersistentStoreFails(t *testing.T) {
+15 -1
View File
@@ -122,9 +122,21 @@ type HealthRecordCreateInfo struct {
PersonName string `json:"personName,omitempty"`
SexCode string `json:"sexCode,omitempty"`
Birthday string `json:"birthday,omitempty"`
WorkPlace string `json:"workPlace,omitempty"`
PhrID string `json:"phrId,omitempty"`
MobileNumber string `json:"mobileNumber,omitempty"`
PhoneNumber string `json:"phoneNumber,omitempty"`
Contact string `json:"contact,omitempty"`
ContactPhone string `json:"contactPhone,omitempty"`
RegisteredPermanent string `json:"registeredPermanent,omitempty"`
RegionCode string `json:"regionCode,omitempty"`
Address string `json:"address,omitempty"`
AddressCode string `json:"addressCode,omitempty"`
AddressNumber string `json:"addressNumber,omitempty"`
HomePlace string `json:"homePlace,omitempty"`
HomePlaceCode string `json:"homePlaceCode,omitempty"`
HomePlaceNumber string `json:"homePlaceNumber,omitempty"`
CardType string `json:"cardType,omitempty"`
ManaDoctorID string `json:"manaDoctorId,omitempty"`
ManaUnitID string `json:"manaUnitId,omitempty"`
CreateUser string `json:"createUser,omitempty"`
@@ -136,7 +148,9 @@ type HealthRecordCreateInfo struct {
BloodTypeCode string `json:"bloodTypeCode,omitempty"`
RhBloodCode string `json:"rhBloodCode,omitempty"`
InsuranceCode string `json:"insuranceCode,omitempty"`
AddressNumber string `json:"addressNumber,omitempty"`
InsuranceType string `json:"insuranceType,omitempty"`
IsFillSHHJ string `json:"isFillShhj,omitempty"`
CreateDate string `json:"createDate,omitempty"`
}
type HealthRecordSaveResult struct {
+19 -1
View File
@@ -147,11 +147,20 @@ func TestHealthRecordCreateSerializesCreateFieldNames(t *testing.T) {
PersonName: "测试人员",
SexCode: "1",
Birthday: "1970-01-01",
WorkPlace: "测试单位",
MobileNumber: "13900000000",
Contact: "测试联系人",
ContactPhone: "13800000000",
RegisteredPermanent: "1",
RegionCode: "441625000000",
Address: "测试现住址",
AddressNumber: "101号",
HomePlace: "测试户籍地址",
HomePlaceNumber: "202号",
CardType: "01",
ManaDoctorID: "doc-001",
ManaUnitID: "123456789",
AddressNumber: "101号",
IsFillSHHJ: "y",
},
PastHistory: &PastHistory{YWGMS: "0101"},
}
@@ -171,6 +180,15 @@ func TestHealthRecordCreateSerializesCreateFieldNames(t *testing.T) {
if healthRecord["addressNumber"] != "101号" {
t.Fatalf("addressNumber missing: %#v", healthRecord)
}
for key, want := range map[string]any{
"workPlace": "测试单位", "contact": "测试联系人", "contactPhone": "13800000000",
"registeredPermanent": "1", "address": "测试现住址", "homePlace": "测试户籍地址",
"homePlaceNumber": "202号", "cardType": "01", "isFillShhj": "y",
} {
if healthRecord[key] != want {
t.Fatalf("healthRecord[%s] = %#v, want %#v", key, healthRecord[key], want)
}
}
if _, ok := healthRecord["adressNumber"]; ok {
t.Fatalf("create request used response typo adressNumber: %#v", healthRecord)
}
+38
View File
@@ -402,3 +402,41 @@ Go 侧契约应从只承接 `personSign` 优化为完整承接 `personSign/idCar
- 定位和审计字段:`phrId`、`checkId`、`createUnit`、`createUser`、`createDate`、`inputDate`、`inputUser`、`inputUnit`。
- docx 仍误写成功码为 `"1"`;项目统一按现有 `IsSuccessCode` 兼容处理,实测成功基线仍为 `"01"`。
- docx 请求样例错误写为 `"serviceId":" LNR00004"`,不能用作实现依据。
---
## 14. PHIS 健康档案 → JKDA 写入映射(T-212)
输入契约来自本地 PHIS 待上报响应的脱敏结构;真实样本含居民信息和医生凭据,不入库。`source.HealthRecordTask` 只承接业务字段,解码时忽略 `doctor.sxtAccount/sxtPassword`。
PHIS `record.createDate` 映射到 CHIS 写入 `healthRecord.createDate`;该字段已在 JKDA00002 查询响应中实测存在,但写入端是否接受仍需 T-215 创建后回查确认。空的 `pastHistory` 不上送,`isFillShhj` 在校验与输出前统一去除空白并转为小写。
### 14.1 标识与管理字段
| PHIS | CHIS | 规则 |
| --- | --- | --- |
| `archId` | `healthRecord.checkId` | 稳定源档案键;生成规则见 ADR 001,缺失即拒绝 |
| `businessId` | trace/PHIS 回写 | 不进入 OSI 请求,不参与 checkId |
| `record.manaDoctorId` | `healthRecord.manaDoctorId/createUser`、`manageInfo.operateUser` | 必须命中 CHIS 责任医生字典 |
| `record.manaUnitId` | `healthRecord.manaUnitId/createUnit` | 必须命中 CHIS 机构字典 |
| 配置 `userName/orgCode` | `manageInfo.DSFMC/operateUnit` | 由 OSI 客户端强制覆盖,不能由 PHIS 注入 |
### 14.2 主体与嵌套节点
| PHIS | JKDA 写入字段 |
| --- | --- |
| `idCard/personName/sexCode/birthday/workPlace/mobileNumber/contact/contactPhone/registeredPermanent/regionCode/address/homePlace/homePlaceNumber/cardType` | `healthRecord` 同名字段 |
| `adressNumber` | `healthRecord.addressNumber`(写入拼写;T-215 真实回查校准) |
| `nationCode/bloodTypeCode/rhBloodCode/educationCode/workCode/maritalStatusCode/insuranceCode` | 码表校验后写入 `healthRecord` |
| `diseasetext_check_gm/check_bl/check_fq/CheckMQ/CheckXDJM/CheckZN/RedioYCBS/CheckCJ` | `pastHistory.ywgms/bls/jzsfqn/jzsmq/jzsxdjm/jzszn/ycbs/cjqk` |
| `diseasetext_radio_jb/ss/ws/sx` | `jwsjb/jwsss/jwsws/jwssx` 数组;非空代码(含“无”代码)各生成一项 |
| `shhjCheckCFPFSS/RLLX/YS/CS/QCL` | `familyMiddle.cookAirTool/fuelType/waterSourceCode/washroom/livestockColumn` |
多选兼容英文逗号、中文逗号、顿号和分号,输出统一为英文逗号。`isFillShhj=n` 时省略 `familyMiddle`;为 `y` 时才校验生活环境单选码。PHIS 未提供的名称、确诊日期和可选地址编码不伪造。
### 14.3 投递前校验
- CHIS 必填字段、身份证 18 位、网格码 12 位、生日 `yyyy-MM-dd`、docx 长度上限。
- 主体码表、既往史多选码和生活环境单选码必须合法;未知码返回结构化 `ValidationError`。
- `data.doctor.doctorId` 与 `record.manaDoctorId` 必须一致;医生和机构 ID 必须命中字典快照。
- T-215 真实写入后仍需确认“无”代码数组、`addressNumber`、更新目标标识和逐档案 `operateUser` 的平台最终规则。
+7 -8
View File
@@ -5,10 +5,10 @@
## 当前快照
- 日期:2026-07-15
- 阶段:**T-206 创建/更新本地能力已完成;进入 Phase U 的 PHIS 档案转换与 upsert,真实写入验收由 T-215 单独受阻**;T-303 老年人生活自理能力评估查询代码完成、真实联调受阻
- 日期:2026-07-16
- 阶段:**T-212 PHIS 健康档案转换器已完成;下一步 T-213 upsert 应用编排,真实写入验收由 T-215 单独受阻**;T-303 老年人生活自理能力评估查询代码完成、真实联调受阻
- 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码、JKDA00002 Find、JKDA00005 FindRqbj(人群分类,已按实测 auto 路径校准)、JKDA00001 Create、JKDA00003 Update、LNRZLPG00002 老年人生活自理能力查询(待真实联调),以及 WGDZ/ZRYS/YPML/CXJG 四个公开查询薄封装
- 生产代码:已有 `main.go`、`config/`、`contract/envelope.go`、`contract/jkda.go`、`contract/lnr.go`、`osi/` 薄客户端、`cache/dictionary.go`、`verify_jkda.go`、`go.mod`/`go.sum`;`mapping/dict.go`、`mapping/health_record.go`、`mapping/checkid.go` 已建立映射纯函数、码表基线、主数据反查接入点和创建请求组装;`handler/health_record.go`+`handler/elderly.go`+`handler/public.go`+`server.go` 提供 server 模式档案查询、人群分类查询、老年人自理能力查询、网格地址、责任医生、药品目录、机构查询端点;`pipeline/` 等业务模块仍待后续任务建立
- 生产代码:新增 `source/health_record.go` 承接 PHIS 档案 DTO 并剥离医生凭据;`mapping/health_record.go` 已能把 PHIS 主体、既往史和生活环境转换为完整 JKDA 写入请求,校验稳定 archId、格式/码表及医生/机构主数据;OSI 写入保留逐档案 `operateUser`,配置继续控制 DSFMC/operateUnit;`pipeline/` 等业务模块仍待 T-213 建立
- 联调现实:**JKDA00002 个人档案查询已用 Go 侧真实请求打通**,返回 `code="01" message="操作成功" data_count=1`;**公开查询 WGDZ00001/ZRYS00001/CXJG00002 已用真实档案主数据验证通过**,均返回 `code="01"` 且数组非空;药品目录 YPML00001 已完成客户端封装和单测,尚未做真实药品关键字样本验证;**JKDA00001 create 未跑真实请求**,避免在没有安全测试居民/写入授权时污染平台档案
- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 查询响应契约、JKDA00001/00003 创建更新请求契约与客户端方法、JKDA00002 Find、JKDA00005 FindRqbj(personSign/idCard/phrId)、公开查询四接口、公开查询 HTTP API、字典缓存快照与持久化失败不阻断、映射码表双向查找、民族 01~56 完整性、健康档案映射必填/码表校验、主数据名称反查、创建请求组装、checkId 确定性、docx/联调风格映射样本基线、JKDA00002 验证入口
- 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example`
@@ -100,11 +100,10 @@ python3 scripts/query_health_record.py
> 2026-07-15 已把本地能力与真实验收拆开:T-206 标记本地 DONE,T-215 承接授权后的真实写入验收。
1. **T-212**:确认 PHIS 四类标识语义,建立脱敏 DTO/fixture、完整字段转换器和逐档案操作上下文。
2. **T-213**:用假 OSI、幂等、report、PHIS 回写接口完成 upsert 应用编排;不等待写入授权。
3. **T-204**:复用 T-213 暴露 `POST /api/health-record/upsert`,handler 不复制业务逻辑。
4. 并行催厂家/PHIS 维护方:写入授权与可写测试档案、稳定源主键语义、更新目标标识、逐档案 `operateUser` 规则。
5. 授权到位后执行 T-215 真实 create→query→update→query 验收。
1. **T-213**:用假 OSI、幂等、report、PHIS 回写接口完成 upsert 应用编排;不等待写入授权。
2. **T-204**:复用 T-213 暴露 `POST /api/health-record/upsert`,handler 不复制业务逻辑。
3. 并行催厂家:写入授权与可写测试档案、更新目标标识、逐档案 `operateUser` 最终规则。
4. 授权到位后执行 T-215 真实 create→query→update→query 验收。
## 维护规则
@@ -0,0 +1,21 @@
# 001 · PHIS 健康档案稳定源主键
## 背景
PHIS 待上报响应同时给出 `archId`、`businessId`、`empiId`、`phrId`。项目需要一个跨重试、跨更新时间稳定的源记录键生成 OSI `checkId`,否则同一档案更新会产生新的第三方流水号。
本地真实健康档案样例中 `archId == businessId`,单凭这一条数据无法判断两者语义。参考项目 `chis_upload/worker/phis_poll_worker.go` 的 PHIS 回调会同时原样回传 `archId` 和 `businessId`;其他业务样例中二者不同,说明 `businessId` 是业务记录/任务标识,不能作为跨业务稳定的居民档案标识。`empiId/phrId` 属于 CHIS 侧主键链路,可由创建或查询获得,不适合作为第三方源键。
## 决策
- 健康档案的 `SourceRecordID` 固定使用 PHIS `archId`。
- `businessId` 保留用于 trace 和 PHIS 状态回写,不参与 `checkId`。
- `empiId/phrId` 保留为上游携带的参考值;upsert 时以 CHIS 查询结果为准,不参与 `checkId`。
- `archId` 缺失时返回校验错误,不回退到 `businessId`,避免把一次业务任务误当成稳定档案。
- `checkId = sha1("PHIS|JKDA|<archId>")` 的前 20 位小写十六进制;`updateTime` 不参与计算。
## 影响
- 同一 PHIS 档案重试或内容更新时保持相同 `checkId`。
- 现有 `GenerateCheckID` 移除版本参数;原 T-205 中“更新时间改变 checkId”的基线同步调整。
- 若 PHIS 后续正式契约说明 `archId` 不是稳定档案键,必须新建 ADR 迁移,不能静默改变已投递记录的 checkId。
@@ -0,0 +1,88 @@
# PHIS Health Record Upsert Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Convert one PHIS health-record task into a validated CHIS JKDA write request, then provide a reusable query-first upsert application service.
**Architecture:** `source` owns the PHIS JSON contract and strips credentials, `mapping` performs pure PHIS-to-CHIS conversion, `contract` owns the JKDA write shape, and `pipeline` orchestrates query/create/update through injected interfaces. HTTP exposure and persistent retry/report stores remain in T-204 and Phase 3.
**Tech Stack:** Go 1.24 standard library, existing `contract`/`mapping`/`osi` packages, table-driven tests.
---
### Task 1: T-212 PHIS DTO and source identity
**Files:**
- Create: `source/health_record.go`
- Create: `source/health_record_test.go`
- Create: `source/testdata/health_record.json`
- Create: `docs/decisions/001-phis-health-record-source-key.md`
- [ ] Write failing tests that decode the PHIS envelope, retain `archId/businessId/empiId/phrId`, expose only doctor ID/name, and reject a missing `archId`.
- [ ] Run `go test ./source -count=1` and verify failure because the package/API does not exist.
- [ ] Implement the DTO and `DecodeHealthRecordTask`; use `archId` as the health-record source key and retain `businessId` only for tracing.
- [ ] Run `go test ./source -count=1` and verify pass.
- [ ] Record the source-key evidence and compatibility decision in the ADR.
### Task 2: T-212 complete mapping and write contract
**Files:**
- Modify: `contract/jkda.go`
- Modify: `contract/jkda_test.go`
- Modify: `mapping/health_record.go`
- Modify: `mapping/health_record_test.go`
- Modify: `mapping/health_record_baseline_test.go`
- Modify: `mapping/checkid.go`
- [ ] Write failing contract/mapping tests for all PHIS direct fields, `adressNumber` to `addressNumber`, stable checkId across `updateTime`, past-history nodes, four history arrays, conditional `familyMiddle`, required/date/length/code validation, and per-record `operateUser`.
- [ ] Run targeted `go test ./contract ./mapping -count=1` and verify expected failures.
- [ ] Expand only fields supplied by PHIS and implement pure conversion helpers; normalize multi-select separators and preserve explicit no-history codes.
- [ ] Run targeted tests until green, then run `go test ./contract ./mapping -count=1`.
### Task 3: T-212 per-record operation context
**Files:**
- Modify: `osi/jkda.go`
- Modify: `osi/jkda_test.go`
- Modify: `cache/dictionary.go`
- Modify: `cache/dictionary_test.go`
- [ ] Write failing tests proving a mapped doctor ID survives as `manageInfo.operateUser`, while `DSFMC/operateUnit` still come from trusted client config; add doctor-ID membership validation to dictionary snapshots.
- [ ] Run targeted tests and verify expected failures.
- [ ] Implement minimal request-context merge and dictionary membership lookup.
- [ ] Run `go test ./cache ./osi ./mapping -count=1` and verify pass.
### Task 4: Complete and commit T-212
**Files:**
- Modify: `tasks.md`
- Modify: `progress.md`
- Modify: `docs/current-state.md`
- Modify: `docs/04-字段与接口映射.md`
- [ ] Update mapping documentation, mark T-212 DONE, append RED/GREEN/full validation evidence, and set T-213 as next.
- [ ] Run `gofmt`, `go test ./...`, `go build ./...`, and `git diff --check`.
- [ ] Commit T-212 as one logical commit.
### Task 5: T-213 query-first upsert service
**Files:**
- Create: `pipeline/health_record_upsert.go`
- Create: `pipeline/health_record_upsert_test.go`
- [ ] Write failing tests for zero-result create, one-result update, multiple/cross-unit/inactive manual review, query failure without create fallback, create/update failure classification, idempotent skip, and PHIS/report events.
- [ ] Run `go test ./pipeline -count=1` and verify expected failure because the service does not exist.
- [ ] Implement `HealthRecordUpsertService` with injected OSI, converter, idempotency, report, and PHIS status interfaces; keep persistence and retry scheduling out of scope.
- [ ] Run `go test ./pipeline -count=1` and then `go test ./...`.
### Task 6: Complete and commit T-213
**Files:**
- Modify: `tasks.md`
- Modify: `progress.md`
- Modify: `docs/current-state.md`
- Modify: `docs/03-目标架构设计.md`
- [ ] Document the reusable application-service boundary and mark T-213 DONE; leave T-204 TODO and T-215 BLOCKED.
- [ ] Run `gofmt`, `go test ./...`, `go build ./...`, and `git diff --check`.
- [ ] Commit T-213 as a second logical commit.
+2 -7
View File
@@ -3,14 +3,9 @@ package mapping
import (
"crypto/sha1"
"encoding/hex"
"strings"
)
func GenerateCheckID(sourceSystem, dataType, sourceRecordID, version string) string {
parts := []string{sourceSystem, dataType, sourceRecordID}
if version != "" {
parts = append(parts, version)
}
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
func GenerateCheckID(sourceSystem, dataType, sourceRecordID string) string {
sum := sha1.Sum([]byte(sourceSystem + "|" + dataType + "|" + sourceRecordID))
return hex.EncodeToString(sum[:])[:20]
}
+330 -3
View File
@@ -1,6 +1,14 @@
package mapping
import "chis_osi/contract"
import (
"strconv"
"strings"
"time"
"unicode/utf8"
"chis_osi/contract"
"chis_osi/source"
)
type PHISHealthRecord struct {
SourceSystem string
@@ -24,6 +32,34 @@ type PHISHealthRecord struct {
BloodTypeCode string
RhBloodCode string
InsuranceCode string
WorkPlace string
Contact string
ContactPhone string
RegisteredPermanent string
Address string
AddressNumber string
HomePlace string
HomePlaceNumber string
CardType string
IsFillSHHJ string
CreateDate string
DrugAllergy string
ExposureHistory string
FamilyFather string
FamilyMother string
FamilySiblings string
FamilyChildren string
GeneticDisease string
Disability string
PastDiseaseCode string
PastSurgeryCode string
PastTraumaCode string
PastTransfusionCode string
CookAirTool string
FuelType string
WaterSourceCode string
Washroom string
LivestockColumn string
}
type MappedHealthRecord struct {
@@ -43,6 +79,23 @@ type MappedHealthRecord struct {
BloodTypeCode string
RhBloodCode string
InsuranceCode string
WorkPlace string
Contact string
ContactPhone string
RegisteredPermanent string
Address string
AddressNumber string
HomePlace string
HomePlaceNumber string
CardType string
IsFillSHHJ string
CreateDate string
PastHistory *contract.PastHistory
PastDiseases []contract.PastDisease
PastSurgeries []contract.PastSurgery
PastTraumas []contract.PastTrauma
PastTransfusions []contract.PastBloodTx
FamilyMiddle *contract.FamilyMiddle
}
type DictSnapshot struct {
@@ -60,6 +113,8 @@ type MasterDataLookup interface {
RegionCodeByName(name string) (string, bool)
DoctorIDByName(name string) (string, bool)
ManaUnitIDByName(name string) (string, bool)
DoctorIDExists(id string) bool
ManaUnitIDExists(id string) bool
}
type MapContext struct {
@@ -105,7 +160,7 @@ func MapHealthRecord(src PHISHealthRecord, ctx MapContext) (MappedHealthRecord,
}
mapped := MappedHealthRecord{
CheckID: GenerateCheckID(src.SourceSystem, "JKDA", src.SourceRecordID, src.UpdatedAt),
CheckID: GenerateCheckID(src.SourceSystem, "JKDA", src.SourceRecordID),
IDCard: src.IDCard,
PersonName: src.PersonName,
Birthday: src.Birthday,
@@ -113,6 +168,17 @@ func MapHealthRecord(src PHISHealthRecord, ctx MapContext) (MappedHealthRecord,
RegionCode: src.RegionCode,
ManaDoctorID: src.ManaDoctorID,
ManaUnitID: src.ManaUnitID,
WorkPlace: src.WorkPlace,
Contact: src.Contact,
ContactPhone: src.ContactPhone,
RegisteredPermanent: src.RegisteredPermanent,
Address: src.Address,
AddressNumber: src.AddressNumber,
HomePlace: src.HomePlace,
HomePlaceNumber: src.HomePlaceNumber,
CardType: src.CardType,
IsFillSHHJ: strings.ToLower(strings.TrimSpace(src.IsFillSHHJ)),
CreateDate: src.CreateDate,
}
mapped.SexCode = mapCode(ctx.Dicts.Sex, src.SexCode, &errs)
mapped.NationCode = mapCode(ctx.Dicts.Nation, src.NationCode, &errs)
@@ -121,10 +187,79 @@ func MapHealthRecord(src PHISHealthRecord, ctx MapContext) (MappedHealthRecord,
mapped.MaritalStatusCode = mapCode(ctx.Dicts.MaritalStatus, src.MaritalStatusCode, &errs)
mapped.BloodTypeCode = mapCode(ctx.Dicts.BloodType, src.BloodTypeCode, &errs)
mapped.RhBloodCode = mapCode(ctx.Dicts.RhBlood, src.RhBloodCode, &errs)
mapped.InsuranceCode = mapCode(ctx.Dicts.Insurance, src.InsuranceCode, &errs)
mapped.InsuranceCode = mapDelimitedCode(ctx.Dicts.Insurance, src.InsuranceCode, &errs)
return mapped, errs
}
func MapHealthRecordTask(task source.HealthRecordTask, ctx MapContext) (contract.HealthRecordCreate, []ValidationError) {
r := task.Record
src := PHISHealthRecord{
SourceSystem: "PHIS", SourceRecordID: task.SourceRecordID(), UpdatedAt: r.UpdatedAt,
IDCard: r.IDCard, PersonName: r.PersonName, SexCode: r.SexCode, Birthday: r.Birthday,
MobileNumber: r.MobileNumber, RegionCode: r.RegionCode, ManaDoctorID: r.ManaDoctorID,
ManaUnitID: r.ManaUnitID, NationCode: r.NationCode, EducationCode: r.EducationCode,
WorkCode: r.WorkCode, MaritalStatusCode: r.MaritalStatusCode, BloodTypeCode: r.BloodTypeCode,
RhBloodCode: r.RhBloodCode, InsuranceCode: r.InsuranceCode, WorkPlace: r.WorkPlace,
Contact: r.Contact, ContactPhone: r.ContactPhone, RegisteredPermanent: r.RegisteredPermanent,
Address: r.Address, AddressNumber: r.AddressNumber, HomePlace: r.HomePlace,
HomePlaceNumber: r.HomePlaceNumber, CardType: firstNonEmpty(r.CardType, task.CardType),
IsFillSHHJ: r.IsFillSHHJ, DrugAllergy: r.DrugAllergy, ExposureHistory: r.ExposureHistory,
FamilyFather: r.FamilyFather, FamilyMother: r.FamilyMother, FamilySiblings: r.FamilySiblings,
FamilyChildren: r.FamilyChildren, GeneticDisease: r.GeneticDisease, Disability: r.Disability,
PastDiseaseCode: r.PastDiseaseCode, PastSurgeryCode: r.PastSurgeryCode,
PastTraumaCode: r.PastTraumaCode, PastTransfusionCode: r.PastTransfusionCode,
CookAirTool: r.CookAirTool, FuelType: r.FuelType, WaterSourceCode: r.WaterSourceCode,
Washroom: r.Washroom, LivestockColumn: r.LivestockColumn,
CreateDate: r.CreateDate,
}
mapped, errs := MapHealthRecord(src, ctx)
errs = append(errs, validateTaskFields(task)...)
errs = append(errs, validateMasterData(src, ctx.MasterData)...)
pastHistory := &contract.PastHistory{}
pastHistory.YWGMS = validateDelimitedCodes("ywgms", src.DrugAllergy, allergyCodes, &errs)
pastHistory.BLS = validateDelimitedCodes("bls", src.ExposureHistory, exposureCodes, &errs)
pastHistory.JZSFQN = validateDelimitedCodes("jzsfqn", src.FamilyFather, fatherCodes, &errs)
pastHistory.JZSMQ = validateDelimitedCodes("jzsmq", src.FamilyMother, motherCodes, &errs)
pastHistory.JZSXDJM = validateDelimitedCodes("jzsxdjm", src.FamilySiblings, siblingCodes, &errs)
pastHistory.JZSZN = validateDelimitedCodes("jzszn", src.FamilyChildren, childrenCodes, &errs)
pastHistory.YCBS = validateDelimitedCodes("ycbs", src.GeneticDisease, geneticCodes, &errs)
pastHistory.CJQK = validateDelimitedCodes("cjqk", src.Disability, disabilityCodes, &errs)
if *pastHistory != (contract.PastHistory{}) {
mapped.PastHistory = pastHistory
}
mapped.PastDiseases = mapPastDiseases(validateDelimitedCodes("jwsjbcode", src.PastDiseaseCode, diseaseCodes, &errs))
mapped.PastSurgeries = mapPastSurgeries(validateDelimitedCodes("jwssscode", src.PastSurgeryCode, surgeryCodes, &errs))
mapped.PastTraumas = mapPastTraumas(validateDelimitedCodes("jwswscode", src.PastTraumaCode, traumaCodes, &errs))
mapped.PastTransfusions = mapPastTransfusions(validateDelimitedCodes("jwssxcode", src.PastTransfusionCode, transfusionCodes, &errs))
if mapped.IsFillSHHJ == "y" {
mapped.FamilyMiddle = &contract.FamilyMiddle{
CookAirTool: validateSingleCode("cookAirTool", src.CookAirTool, cookAirCodes, &errs),
FuelType: validateSingleCode("fuelType", src.FuelType, fuelCodes, &errs),
WaterSourceCode: validateSingleCode("waterSourceCode", src.WaterSourceCode, waterCodes, &errs),
Washroom: validateSingleCode("washroom", src.Washroom, washroomCodes, &errs),
LivestockColumn: validateSingleCode("livestockColumn", src.LivestockColumn, livestockCodes, &errs),
IsFillSHHJ: "y",
}
}
return BuildHealthRecordCreate(mapped), errs
}
func validateMasterData(src PHISHealthRecord, lookup MasterDataLookup) []ValidationError {
if lookup == nil {
return []ValidationError{{Field: "masterData", Reason: "CHIS dictionary snapshot is required"}}
}
var errs []ValidationError
if src.ManaDoctorID != "" && !lookup.DoctorIDExists(src.ManaDoctorID) {
errs = append(errs, ValidationError{Field: "manaDoctorId", Code: src.ManaDoctorID, Reason: "not found in CHIS doctor dictionary"})
}
if src.ManaUnitID != "" && !lookup.ManaUnitIDExists(src.ManaUnitID) {
errs = append(errs, ValidationError{Field: "manaUnitId", Code: src.ManaUnitID, Reason: "not found in CHIS organization dictionary"})
}
return errs
}
func resolveMasterData(src *PHISHealthRecord, lookup MasterDataLookup) {
if lookup == nil {
return
@@ -160,12 +295,26 @@ func mapCode(dict Dict, code string, errs *[]ValidationError) string {
}
return mapped
}
func mapDelimitedCode(dict Dict, value string, errs *[]ValidationError) string {
parts := splitCodes(value)
mapped := make([]string, 0, len(parts))
for _, code := range parts {
mappedCode := mapCode(dict, code, errs)
if mappedCode != "" {
mapped = append(mapped, mappedCode)
}
}
return strings.Join(mapped, ",")
}
func BuildHealthRecordCreate(mapped MappedHealthRecord) contract.HealthRecordCreate {
return contract.HealthRecordCreate{
BaseInfo: contract.HealthRecordBaseInfo{
IDCard: mapped.IDCard,
PersonName: mapped.PersonName,
},
ManageInfo: contract.ManageInfo{OperateUser: mapped.ManaDoctorID},
HealthRecord: contract.HealthRecordCreateInfo{
CheckID: mapped.CheckID,
IDCard: mapped.IDCard,
@@ -173,7 +322,16 @@ func BuildHealthRecordCreate(mapped MappedHealthRecord) contract.HealthRecordCre
SexCode: mapped.SexCode,
Birthday: mapped.Birthday,
MobileNumber: mapped.MobileNumber,
WorkPlace: mapped.WorkPlace,
Contact: mapped.Contact,
ContactPhone: mapped.ContactPhone,
RegisteredPermanent: mapped.RegisteredPermanent,
RegionCode: mapped.RegionCode,
Address: mapped.Address,
AddressNumber: mapped.AddressNumber,
HomePlace: mapped.HomePlace,
HomePlaceNumber: mapped.HomePlaceNumber,
CardType: mapped.CardType,
ManaDoctorID: mapped.ManaDoctorID,
ManaUnitID: mapped.ManaUnitID,
CreateUser: mapped.ManaDoctorID,
@@ -185,6 +343,175 @@ func BuildHealthRecordCreate(mapped MappedHealthRecord) contract.HealthRecordCre
BloodTypeCode: mapped.BloodTypeCode,
RhBloodCode: mapped.RhBloodCode,
InsuranceCode: mapped.InsuranceCode,
IsFillSHHJ: mapped.IsFillSHHJ,
CreateDate: mapped.CreateDate,
},
PastHistory: mapped.PastHistory,
PastDiseases: mapped.PastDiseases,
PastSurgeries: mapped.PastSurgeries,
PastTraumas: mapped.PastTraumas,
PastTransfusions: mapped.PastTransfusions,
FamilyMiddle: mapped.FamilyMiddle,
}
}
func validateTaskFields(task source.HealthRecordTask) []ValidationError {
r := task.Record
var errs []ValidationError
for _, field := range []struct{ name, value string }{
{"archId", task.ArchID}, {"contact", r.Contact}, {"contactPhone", r.ContactPhone},
{"registeredPermanent", r.RegisteredPermanent}, {"cardType", firstNonEmpty(r.CardType, task.CardType)},
{"nationCode", r.NationCode}, {"bloodTypeCode", r.BloodTypeCode}, {"rhBloodCode", r.RhBloodCode},
{"educationCode", r.EducationCode}, {"workCode", r.WorkCode}, {"maritalStatusCode", r.MaritalStatusCode},
{"insuranceCode", r.InsuranceCode}, {"ywgms", r.DrugAllergy}, {"cjqk", r.Disability},
} {
if strings.TrimSpace(field.value) == "" {
errs = append(errs, ValidationError{Field: field.name, Reason: "required"})
}
}
for _, field := range []struct {
name, value string
max int
}{
{"idCard", r.IDCard, 18}, {"personName", r.PersonName, 50}, {"workPlace", r.WorkPlace, 70},
{"mobileNumber", r.MobileNumber, 20}, {"contact", r.Contact, 50}, {"contactPhone", r.ContactPhone, 20},
{"address", r.Address, 100}, {"addressNumber", r.AddressNumber, 70}, {"homePlace", r.HomePlace, 100},
{"homePlaceNumber", r.HomePlaceNumber, 70}, {"manaDoctorId", r.ManaDoctorID, 20}, {"manaUnitId", r.ManaUnitID, 20},
} {
if utf8.RuneCountInString(field.value) > field.max {
errs = append(errs, ValidationError{Field: field.name, Reason: "max length " + strconv.Itoa(field.max)})
}
}
for _, field := range []struct {
name, value string
length int
}{
{"idCard", r.IDCard, 18},
{"regionCode", r.RegionCode, 12},
} {
if utf8.RuneCountInString(field.value) != field.length {
errs = append(errs, ValidationError{Field: field.name, Reason: "length " + strconv.Itoa(field.length)})
}
}
if _, err := time.Parse("2006-01-02", r.Birthday); err != nil {
errs = append(errs, ValidationError{Field: "birthday", Reason: "invalid date, want yyyy-MM-dd"})
}
if r.RegisteredPermanent != "1" && r.RegisteredPermanent != "2" {
errs = append(errs, ValidationError{Field: "registeredPermanent", Code: r.RegisteredPermanent, Reason: "unknown code"})
}
fill := strings.ToLower(strings.TrimSpace(r.IsFillSHHJ))
if fill != "y" && fill != "n" {
errs = append(errs, ValidationError{Field: "isFillShhj", Code: r.IsFillSHHJ, Reason: "unknown code"})
}
if task.Doctor.DoctorID != "" && task.Doctor.DoctorID != r.ManaDoctorID {
errs = append(errs, ValidationError{Field: "manaDoctorId", Reason: "does not match data.doctor.doctorId"})
}
return errs
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func splitCodes(value string) []string {
replacer := strings.NewReplacer(",", ",", "、", ",", ";", ",", ";", ",")
var out []string
for _, part := range strings.Split(replacer.Replace(value), ",") {
if code := strings.TrimSpace(part); code != "" {
out = append(out, code)
}
}
return out
}
func validateDelimitedCodes(field, value string, allowed map[string]struct{}, errs *[]ValidationError) string {
parts := splitCodes(value)
valid := make([]string, 0, len(parts))
for _, code := range parts {
if _, ok := allowed[code]; !ok {
*errs = append(*errs, ValidationError{Field: field, Code: code, Reason: "unknown code"})
continue
}
valid = append(valid, code)
}
return strings.Join(valid, ",")
}
func validateSingleCode(field, value string, allowed map[string]struct{}, errs *[]ValidationError) string {
if parts := splitCodes(value); len(parts) != 1 {
*errs = append(*errs, ValidationError{Field: field, Reason: "must contain exactly one code"})
return ""
}
return validateDelimitedCodes(field, value, allowed, errs)
}
func codeSet(values ...string) map[string]struct{} {
set := make(map[string]struct{}, len(values))
for _, value := range values {
set[value] = struct{}{}
}
return set
}
var (
allergyCodes = codeSet("0101", "0102", "0103", "0104", "0109")
exposureCodes = codeSet("1201", "1202", "1203", "1204")
fatherCodes = numberedCodes("07")
motherCodes = numberedCodes("08")
siblingCodes = numberedCodes("09")
childrenCodes = numberedCodes("10")
geneticCodes = codeSet("0501", "0502")
disabilityCodes = codeSet("1101", "1102", "1103", "1104", "1105", "1106", "1107", "1108", "1109", "1199")
diseaseCodes = codeSet("0201", "0202", "0203", "0204", "0205", "0206", "0207", "0208", "0209", "0210", "0211", "0212", "0213", "0214", "0215", "0298", "0299")
surgeryCodes = codeSet("0301", "0302")
traumaCodes = codeSet("0601", "0602")
transfusionCodes = codeSet("0401", "0402")
cookAirCodes = codeSet("1", "2", "3", "4", "9")
fuelCodes = codeSet("1", "2", "3", "4", "5", "9")
waterCodes = codeSet("1", "2", "3", "4", "5", "9")
washroomCodes = codeSet("1", "2", "3", "4", "5", "6")
livestockCodes = codeSet("0", "1", "2", "3")
)
func numberedCodes(prefix string) map[string]struct{} {
values := []string{prefix + "01", prefix + "02", prefix + "03", prefix + "04", prefix + "05", prefix + "06", prefix + "07", prefix + "08", prefix + "09", prefix + "10", prefix + "11", prefix + "99"}
return codeSet(values...)
}
func mapPastDiseases(value string) []contract.PastDisease {
parts := splitCodes(value)
out := make([]contract.PastDisease, 0, len(parts))
for _, code := range parts {
out = append(out, contract.PastDisease{Code: code})
}
return out
}
func mapPastSurgeries(value string) []contract.PastSurgery {
parts := splitCodes(value)
out := make([]contract.PastSurgery, 0, len(parts))
for _, code := range parts {
out = append(out, contract.PastSurgery{Code: code})
}
return out
}
func mapPastTraumas(value string) []contract.PastTrauma {
parts := splitCodes(value)
out := make([]contract.PastTrauma, 0, len(parts))
for _, code := range parts {
out = append(out, contract.PastTrauma{Code: code})
}
return out
}
func mapPastTransfusions(value string) []contract.PastBloodTx {
parts := splitCodes(value)
out := make([]contract.PastBloodTx, 0, len(parts))
for _, code := range parts {
out = append(out, contract.PastBloodTx{Code: code})
}
return out
}
+7 -4
View File
@@ -1,6 +1,9 @@
package mapping
import "testing"
import (
"reflect"
"testing"
)
func TestDocxStyleHealthRecordMappingBaseline(t *testing.T) {
src := PHISHealthRecord{
@@ -29,7 +32,7 @@ func TestDocxStyleHealthRecordMappingBaseline(t *testing.T) {
t.Fatalf("errors = %#v", errs)
}
assertMappedBaseline(t, got, MappedHealthRecord{
CheckID: GenerateCheckID("PHIS", "JKDA", "docx-sample-001", "20260707100000"),
CheckID: GenerateCheckID("PHIS", "JKDA", "docx-sample-001"),
IDCard: "440000********0001",
PersonName: "文档样例",
SexCode: "2",
@@ -74,7 +77,7 @@ func TestJointDebugStyleHealthRecordMappingBaseline(t *testing.T) {
if len(errs) != 0 {
t.Fatalf("errors = %#v", errs)
}
if got.CheckID != GenerateCheckID("PHIS", "JKDA", "joint-debug-001", "20260707110000") {
if got.CheckID != GenerateCheckID("PHIS", "JKDA", "joint-debug-001") {
t.Fatalf("checkId = %q", got.CheckID)
}
if got.IDCard != "440000********1234" || got.PersonName != "联调样例" || got.NationCode != "01" || got.WorkCode != "Y" {
@@ -113,7 +116,7 @@ func TestHealthRecordMappingBaselineRejectsDirtySample(t *testing.T) {
func assertMappedBaseline(t *testing.T, got MappedHealthRecord, want MappedHealthRecord) {
t.Helper()
if got != want {
if !reflect.DeepEqual(got, want) {
t.Fatalf("mapped = %#v\nwant = %#v", got, want)
}
}
+153
View File
@@ -0,0 +1,153 @@
package mapping
import (
"os"
"testing"
"chis_osi/source"
)
func TestMapHealthRecordTaskBuildsCompleteCHISRequest(t *testing.T) {
task := loadPHISHealthRecordTask(t)
req, errs := MapHealthRecordTask(task, validPHISMapContext())
if len(errs) != 0 {
t.Fatalf("errors = %#v", errs)
}
hr := req.HealthRecord
if hr.IDCard != task.Record.IDCard || hr.PersonName != task.Record.PersonName || hr.WorkPlace != task.Record.WorkPlace {
t.Fatalf("direct fields = %#v", hr)
}
if hr.Contact != task.Record.Contact || hr.ContactPhone != task.Record.ContactPhone || hr.RegisteredPermanent != "1" || hr.CardType != "01" {
t.Fatalf("contact/identity fields = %#v", hr)
}
if hr.AddressNumber != task.Record.AddressNumber || hr.Address != task.Record.Address || hr.HomePlace != task.Record.HomePlace || hr.HomePlaceNumber != task.Record.HomePlaceNumber {
t.Fatalf("address fields = %#v", hr)
}
if hr.CreateDate != task.Record.CreateDate {
t.Fatalf("createDate = %q, want %q", hr.CreateDate, task.Record.CreateDate)
}
if req.ManageInfo.OperateUser != task.Record.ManaDoctorID {
t.Fatalf("operateUser = %q", req.ManageInfo.OperateUser)
}
if req.PastHistory == nil || req.PastHistory.YWGMS != "0101" || req.PastHistory.BLS != "1201" || req.PastHistory.CJQK != "1101" {
t.Fatalf("pastHistory = %#v", req.PastHistory)
}
if len(req.PastDiseases) != 1 || req.PastDiseases[0].Code != "0201" {
t.Fatalf("past diseases = %#v", req.PastDiseases)
}
if len(req.PastSurgeries) != 1 || req.PastSurgeries[0].Code != "0301" || len(req.PastTraumas) != 1 || req.PastTraumas[0].Code != "0601" || len(req.PastTransfusions) != 1 || req.PastTransfusions[0].Code != "0401" {
t.Fatalf("past event arrays = %#v %#v %#v", req.PastSurgeries, req.PastTraumas, req.PastTransfusions)
}
if req.FamilyMiddle == nil || req.FamilyMiddle.CookAirTool != "2" || req.FamilyMiddle.FuelType != "3" || req.FamilyMiddle.WaterSourceCode != "1" || req.FamilyMiddle.Washroom != "1" || req.FamilyMiddle.LivestockColumn != "0" {
t.Fatalf("familyMiddle = %#v", req.FamilyMiddle)
}
firstCheckID := hr.CheckID
task.Record.UpdatedAt = "2026-07-16 11:22:33"
second, errs := MapHealthRecordTask(task, validPHISMapContext())
if len(errs) != 0 {
t.Fatalf("second errors = %#v", errs)
}
if firstCheckID == "" || firstCheckID != second.HealthRecord.CheckID {
t.Fatalf("checkId changed across source updates: %q != %q", firstCheckID, second.HealthRecord.CheckID)
}
}
func TestMapHealthRecordTaskNormalizesEnvironmentFlag(t *testing.T) {
task := loadPHISHealthRecordTask(t)
task.Record.IsFillSHHJ = " Y "
req, errs := MapHealthRecordTask(task, validPHISMapContext())
if len(errs) != 0 {
t.Fatalf("errors = %#v", errs)
}
if req.HealthRecord.IsFillSHHJ != "y" || req.FamilyMiddle == nil {
t.Fatalf("environment flag = %q familyMiddle=%#v", req.HealthRecord.IsFillSHHJ, req.FamilyMiddle)
}
}
func TestMapHealthRecordTaskOmitsEmptyPastHistory(t *testing.T) {
task := loadPHISHealthRecordTask(t)
task.Record.DrugAllergy = ""
task.Record.ExposureHistory = ""
task.Record.FamilyFather = ""
task.Record.FamilyMother = ""
task.Record.FamilySiblings = ""
task.Record.FamilyChildren = ""
task.Record.GeneticDisease = ""
task.Record.Disability = ""
req, _ := MapHealthRecordTask(task, validPHISMapContext())
if req.PastHistory != nil {
t.Fatalf("pastHistory = %#v, want nil", req.PastHistory)
}
}
func TestMapHealthRecordTaskOmitsEnvironmentWhenNotFilled(t *testing.T) {
task := loadPHISHealthRecordTask(t)
task.Record.IsFillSHHJ = "n"
task.Record.CookAirTool = ""
task.Record.FuelType = ""
task.Record.WaterSourceCode = ""
task.Record.Washroom = ""
task.Record.LivestockColumn = ""
req, errs := MapHealthRecordTask(task, validPHISMapContext())
if len(errs) != 0 {
t.Fatalf("errors = %#v", errs)
}
if req.FamilyMiddle != nil || req.HealthRecord.IsFillSHHJ != "n" {
t.Fatalf("request environment = %#v healthRecord=%#v", req.FamilyMiddle, req.HealthRecord)
}
}
func TestMapHealthRecordTaskRejectsInvalidDateLengthAndHistoryCode(t *testing.T) {
task := loadPHISHealthRecordTask(t)
task.Record.IDCard = "short"
task.Record.RegionCode = "441625"
task.Record.Birthday = "19800102"
task.Record.PersonName = "这是一个超过五十个字符限制的测试姓名这是一个超过五十个字符限制的测试姓名这是一个超过五十个字符限制的测试姓名"
task.Record.DrugAllergy = "9999"
task.Record.CookAirTool = "1,2"
_, errs := MapHealthRecordTask(task, validPHISMapContext())
assertValidationField(t, errs, "idCard", "length 18")
assertValidationField(t, errs, "regionCode", "length 12")
assertValidationField(t, errs, "birthday", "invalid date, want yyyy-MM-dd")
assertValidationField(t, errs, "personName", "max length 50")
assertValidationField(t, errs, "ywgms", "unknown code")
assertValidationField(t, errs, "cookAirTool", "must contain exactly one code")
}
func TestMapHealthRecordTaskRequiresKnownDoctorAndUnit(t *testing.T) {
task := loadPHISHealthRecordTask(t)
ctx := validPHISMapContext()
ctx.MasterData = rejectingMasterDataLookup{}
_, errs := MapHealthRecordTask(task, ctx)
assertValidationField(t, errs, "manaDoctorId", "not found in CHIS doctor dictionary")
assertValidationField(t, errs, "manaUnitId", "not found in CHIS organization dictionary")
}
func validPHISMapContext() MapContext {
return MapContext{Dicts: DefaultDictSnapshot(), MasterData: fakeMasterDataLookup{}}
}
type rejectingMasterDataLookup struct{ fakeMasterDataLookup }
func (rejectingMasterDataLookup) DoctorIDExists(string) bool { return false }
func (rejectingMasterDataLookup) ManaUnitIDExists(string) bool { return false }
func loadPHISHealthRecordTask(t *testing.T) source.HealthRecordTask {
t.Helper()
raw, err := os.ReadFile("../source/testdata/health_record.json")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
task, err := source.DecodeHealthRecordTask(raw)
if err != nil {
t.Fatalf("DecodeHealthRecordTask: %v", err)
}
return task
}
+13 -5
View File
@@ -108,10 +108,10 @@ func TestMapHealthRecordUsesMasterDataLookupWhenCodesMissing(t *testing.T) {
t.Fatalf("master data not resolved: %#v", got)
}
}
func TestGenerateCheckIDIsDeterministicAndVersionSensitive(t *testing.T) {
first := GenerateCheckID("PHIS", "JKDA", "record-001", "20260707010101")
second := GenerateCheckID("PHIS", "JKDA", "record-001", "20260707010101")
third := GenerateCheckID("PHIS", "JKDA", "record-001", "20260708010101")
func TestGenerateCheckIDIsDeterministicForStableSourceRecord(t *testing.T) {
first := GenerateCheckID("PHIS", "JKDA", "record-001")
second := GenerateCheckID("PHIS", "JKDA", "record-001")
third := GenerateCheckID("PHIS", "JKDA", "record-002")
if first != second {
t.Fatalf("checkId not deterministic: %q != %q", first, second)
}
@@ -119,7 +119,7 @@ func TestGenerateCheckIDIsDeterministicAndVersionSensitive(t *testing.T) {
t.Fatalf("checkId length = %d, want 20", len(first))
}
if first == third {
t.Fatalf("version change did not affect checkId: %q", first)
t.Fatalf("different source record reused checkId: %q", first)
}
}
@@ -136,6 +136,14 @@ func (fakeMasterDataLookup) DoctorIDByName(name string) (string, bool) {
func (fakeMasterDataLookup) ManaUnitIDByName(name string) (string, bool) {
return map[string]string{"测试机构": "123456789"}[name], name == "测试机构"
}
func (fakeMasterDataLookup) DoctorIDExists(id string) bool {
return id == "D001" || id == "doc-001" || id == "DOC001"
}
func (fakeMasterDataLookup) ManaUnitIDExists(id string) bool {
return id == "123456789"
}
func validPHISHealthRecord() PHISHealthRecord {
return PHISHealthRecord{
SourceSystem: "PHIS",
+10 -3
View File
@@ -26,14 +26,14 @@ type PersonSignResult struct {
}
func (c *Client) CreateHealthRecord(ctx context.Context, req contract.HealthRecordCreate) (contract.HealthRecordSaveResult, Result, error) {
req.ManageInfo = c.manageInfo()
req.ManageInfo = c.manageInfoFor(req.ManageInfo.OperateUser)
var out contract.HealthRecordSaveResult
result, err := c.callUploadInfo(ctx, ServiceIDJKDACreate, req, &out)
return out, result, err
}
func (c *Client) UpdateHealthRecord(ctx context.Context, req contract.HealthRecordCreate) (contract.HealthRecordSaveResult, Result, error) {
req.ManageInfo = c.manageInfo()
req.ManageInfo = c.manageInfoFor(req.ManageInfo.OperateUser)
var out contract.HealthRecordSaveResult
result, err := c.callUploadInfo(ctx, ServiceIDJKDAUpdate, req, &out)
return out, result, err
@@ -59,9 +59,16 @@ func (c *Client) FindRqbj(ctx context.Context, query FindRqbjQuery) (PersonSignR
}
func (c *Client) manageInfo() contract.ManageInfo {
return c.manageInfoFor("")
}
func (c *Client) manageInfoFor(operateUser string) contract.ManageInfo {
if operateUser == "" {
operateUser = c.config.OperateUser
}
return contract.ManageInfo{
DSFMC: c.config.UserName,
OperateUser: c.config.OperateUser,
OperateUser: operateUser,
OperateUnit: c.operateUnit(),
}
}
+5
View File
@@ -26,6 +26,7 @@ func TestCreateHealthRecordCallsJKDA00001WithUploadInfo(t *testing.T) {
client := newTestJKDAClient(t, server.URL)
resultData, result, err := client.CreateHealthRecord(context.Background(), contract.HealthRecordCreate{
BaseInfo: contract.HealthRecordBaseInfo{IDCard: "440000********1234", PersonName: "测试人员"},
ManageInfo: contract.ManageInfo{OperateUser: "doc-001"},
HealthRecord: contract.HealthRecordCreateInfo{
CheckID: "CHK2026070700000001",
IDCard: "440000********1234",
@@ -48,6 +49,10 @@ func TestCreateHealthRecordCallsJKDA00001WithUploadInfo(t *testing.T) {
t.Fatalf("serviceId = %v", seenPayload["serviceId"])
}
uploadInfo := seenPayload["uploadinfo"].(map[string]any)
manageInfo := uploadInfo["manageInfo"].(map[string]any)
if manageInfo["operateUser"] != "doc-001" || manageInfo["DSFMC"] != "dyytgw" || manageInfo["operateUnit"] != "12441625456962881G" {
t.Fatalf("manageInfo = %#v", manageInfo)
}
healthRecord := uploadInfo["healthRecord"].(map[string]any)
if healthRecord["checkId"] != "CHK2026070700000001" || healthRecord["manaUnitId"] != "123456789" {
t.Fatalf("healthRecord = %#v", healthRecord)
+10
View File
@@ -340,3 +340,13 @@
- 安全:保留 `/payloads/` 整体忽略规则,并清理新增规则的异常行尾;真实样本不进入 Git。
- 验证:`go test ./...` 通过;`go build ./...` 通过;`git diff --check` 通过。
- 验证:`git check-ignore -v payloads\\441625198611255416_phis_health_record.json` 命中 `.gitignore:50:/payloads/`,真实 PHIS 样本未进入 Git。
## 2026-07-16 T-212 PHIS 健康档案真实结构与转换器
- 状态:DONE。
- RED:`go test ./source -count=1` 初次因缺少 `DecodeHealthRecordTask` 失败;`go test ./contract ./mapping -count=1` 初次因写入字段、稳定 checkId API 和 `MapHealthRecordTask` 缺失失败;`go test ./cache ./osi ./mapping -count=1` 初次命中字典无 ID 校验、映射未校验主数据、OSI 覆盖逐档案操作人三项旧行为。
- GREEN:新增脱敏 `source/testdata/health_record.json` 与 PHIS DTO;完成主体、既往史四数组、生活环境转换和结构化校验;扩充 JKDA 写入契约;字典快照支持医生/机构 ID 校验;OSI 写入保留请求级 `operateUser`。
- 决策:参考 `chis_upload` 的 PHIS 回调契约及多业务样例,健康档案稳定源键使用 `archId`,`businessId` 只用于 trace/回写,`empiId/phrId` 属于 CHIS 标识;`archId` 缺失不回退。ADR:`docs/decisions/001-phis-health-record-source-key.md`。
- 安全:真实 PHIS payload 未复制入仓库;fixture 使用占位身份信息和假凭据,生产 DTO 不声明 `sxtAccount/sxtPassword`。
- 验证:`go test ./source -count=1`、`go test ./contract ./mapping -count=1`、`go test ./cache ./osi ./mapping -count=1`、`go test ./... -count=1`、`go vet ./...` 均通过。
- 下一步:T-213 健康档案 query-first upsert 应用编排。
+106
View File
@@ -0,0 +1,106 @@
package source
import (
"encoding/json"
"fmt"
"strings"
)
type healthRecordEnvelope struct {
Code int `json:"code"`
Message string `json:"msg"`
Compress bool `json:"compress"`
Data HealthRecordTask `json:"data"`
}
// HealthRecordTask is one PHIS health-record upload task. Credentials returned
// under data.doctor are deliberately absent from this model.
type HealthRecordTask struct {
Doctor Doctor `json:"doctor"`
Record HealthRecord `json:"record"`
ArchID string `json:"archId"`
BusinessID string `json:"businessId"`
EMPIID string `json:"empiId"`
PHRID string `json:"phrId"`
CardNo string `json:"cardNo"`
CardType string `json:"cardType"`
}
type Doctor struct {
RealName string `json:"realName"`
DoctorID string `json:"doctorId"`
}
type HealthRecord struct {
HomePlace string `json:"homePlace"`
DeadFlag string `json:"deadFlag"`
IDCard string `json:"idCard"`
MobileNumber string `json:"mobileNumber"`
InputUser string `json:"inputUser"`
MaritalStatusCode string `json:"maritalStatusCode"`
WorkCode string `json:"workCode"`
Contact string `json:"contact"`
WaterSourceCode string `json:"shhjCheckYS"`
ManaUnitID string `json:"manaUnitId"`
PastDiseaseCode string `json:"diseasetext_radio_jb"`
FamilyMother string `json:"diseasetextCheckMQ"`
FamilySiblings string `json:"diseasetextCheckXDJM"`
InputDate string `json:"inputDate"`
GeneticDisease string `json:"diseasetextRedioYCBS"`
FamilyChildren string `json:"diseasetextCheckZN"`
BloodTypeCode string `json:"bloodTypeCode"`
CreateUnit string `json:"createUnit"`
DrugAllergy string `json:"diseasetext_check_gm"`
Birthday string `json:"birthday"`
SignFlag string `json:"signFlag"`
RhBloodCode string `json:"rhBloodCode"`
IsFillSHHJ string `json:"isFillShhj"`
EducationCode string `json:"educationCode"`
InsuranceCode string `json:"insuranceCode"`
InputUnit string `json:"inputUnit"`
RegionCode string `json:"regionCode"`
FamilyFather string `json:"diseasetext_check_fq"`
ExposureHistory string `json:"diseasetext_check_bl"`
WorkPlace string `json:"workPlace"`
NationCode string `json:"nationCode"`
CreateDate string `json:"createDate"`
RegisteredPermanent string `json:"registeredPermanent"`
Address string `json:"address"`
HomePlaceNumber string `json:"homePlaceNumber"`
CardType string `json:"cardType"`
UpdatedAt string `json:"updateTime"`
CookAirTool string `json:"shhjCheckCFPFSS"`
Disability string `json:"diseasetextCheckCJ"`
PersonName string `json:"personName"`
SexCode string `json:"sexCode"`
Washroom string `json:"shhjCheckCS"`
ManaDoctorID string `json:"manaDoctorId"`
AddressNumber string `json:"adressNumber"`
PastTransfusionCode string `json:"diseasetext_sx"`
CreateTime string `json:"createTime"`
PastSurgeryCode string `json:"diseasetext_ss"`
CreateUser string `json:"createUser"`
ContactPhone string `json:"contactPhone"`
PastTraumaCode string `json:"diseasetext_ws"`
FuelType string `json:"shhjCheckRLLX"`
LivestockColumn string `json:"shhjCheckQCL"`
}
func DecodeHealthRecordTask(raw []byte) (HealthRecordTask, error) {
var envelope healthRecordEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return HealthRecordTask{}, fmt.Errorf("decode PHIS health record: %w", err)
}
if envelope.Code != 200 {
return HealthRecordTask{}, fmt.Errorf("phis response code=%d message=%s", envelope.Code, envelope.Message)
}
envelope.Data.ArchID = strings.TrimSpace(envelope.Data.ArchID)
if envelope.Data.ArchID == "" {
return HealthRecordTask{}, fmt.Errorf("archId is required")
}
return envelope.Data, nil
}
func (t HealthRecordTask) SourceRecordID() string {
return t.ArchID
}
+58
View File
@@ -0,0 +1,58 @@
package source
import (
"encoding/json"
"os"
"strings"
"testing"
)
func TestDecodeHealthRecordTaskKeepsBusinessFieldsAndDropsCredentials(t *testing.T) {
raw, err := os.ReadFile("testdata/health_record.json")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
task, err := DecodeHealthRecordTask(raw)
if err != nil {
t.Fatalf("DecodeHealthRecordTask: %v", err)
}
if task.ArchID != "ARCH-001" || task.BusinessID != "BUSINESS-001" || task.EMPIID != "EMPI-001" || task.PHRID != "PHR-001" {
t.Fatalf("identifiers = %#v", task)
}
if task.SourceRecordID() != "ARCH-001" {
t.Fatalf("SourceRecordID = %q", task.SourceRecordID())
}
if task.Doctor.DoctorID != "DOC001" || task.Doctor.RealName != "测试医生" {
t.Fatalf("doctor = %#v", task.Doctor)
}
if task.Record.IDCard != "440000********1234" || task.Record.AddressNumber != "1号" {
t.Fatalf("record = %#v", task.Record)
}
sanitized, err := json.Marshal(task)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if strings.Contains(string(sanitized), "sxtAccount") || strings.Contains(string(sanitized), "sxtPassword") || strings.Contains(string(sanitized), "IGNORED_") {
t.Fatalf("credentials survived decoding: %s", sanitized)
}
}
func TestDecodeHealthRecordTaskRejectsMissingStableArchiveID(t *testing.T) {
raw := []byte(`{"code":200,"data":{"record":{"idCard":"440000********1234"},"businessId":"BUSINESS-001"}}`)
_, err := DecodeHealthRecordTask(raw)
if err == nil || !strings.Contains(err.Error(), "archId is required") {
t.Fatalf("error = %v", err)
}
}
func TestDecodeHealthRecordTaskRejectsFailedPHISResponse(t *testing.T) {
raw := []byte(`{"code":500,"msg":"failed","data":{"archId":"ARCH-001"}}`)
_, err := DecodeHealthRecordTask(raw)
if err == nil || !strings.Contains(err.Error(), "phis response code=500") {
t.Fatalf("error = %v", err)
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"code": 200,
"msg": "success",
"compress": false,
"data": {
"doctor": {
"sxtAccount": "IGNORED_ACCOUNT",
"sxtPassword": "IGNORED_PASSWORD",
"realName": "测试医生",
"doctorId": "DOC001"
},
"record": {
"homePlace": "测试户籍地址",
"deadFlag": "n",
"idCard": "440000********1234",
"mobileNumber": "13900000000",
"inputUser": "DOC001",
"maritalStatusCode": "20",
"workCode": "Y",
"contact": "测试联系人",
"shhjCheckYS": "1",
"manaUnitId": "123456789",
"diseasetext_radio_jb": "0201",
"diseasetextCheckMQ": "0801",
"diseasetextCheckXDJM": "0901",
"inputDate": "2026-07-15",
"diseasetextRedioYCBS": "0501",
"diseasetextCheckZN": "1001",
"bloodTypeCode": "5",
"createUnit": "123456789",
"diseasetext_check_gm": "0101",
"birthday": "1980-01-02",
"signFlag": "n",
"rhBloodCode": "3",
"isFillShhj": "y",
"educationCode": "70",
"insuranceCode": "02",
"inputUnit": "123456789",
"regionCode": "441625000000",
"diseasetext_check_fq": "0701",
"diseasetext_check_bl": "1201",
"workPlace": "测试单位",
"nationCode": "01",
"createDate": "2026-07-15",
"registeredPermanent": "1",
"address": "测试现住址",
"homePlaceNumber": "2号",
"cardType": "01",
"updateTime": "2026-07-15 10:20:30",
"shhjCheckCFPFSS": "2",
"diseasetextCheckCJ": "1101",
"personName": "测试居民",
"sexCode": "1",
"shhjCheckCS": "1",
"manaDoctorId": "DOC001",
"adressNumber": "1号",
"diseasetext_sx": "0401",
"createTime": "2026-07-15 09:00:00",
"diseasetext_ss": "0301",
"createUser": "DOC001",
"contactPhone": "13800000000",
"diseasetext_ws": "0601",
"shhjCheckRLLX": "3",
"shhjCheckQCL": "0"
},
"archId": "ARCH-001",
"businessId": "BUSINESS-001",
"empiId": "EMPI-001",
"phrId": "PHR-001",
"cardNo": "",
"cardType": ""
}
}
+3 -1
View File
@@ -92,7 +92,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- |
| T-212 | PHIS 健康档案真实结构建模与 PHIS→CHIS 转换器 | T-202, T-103 | 建模 PHIS `data.doctor/data.record/archId/businessId/empiId/phrId`;先确认稳定源主键语义并落 ADR;完整转换 JKDA 主体、既往史和生活环境;校验必填/长度/日期/码表;脱敏 fixture 回归;按档案责任医生生成请求操作上下文 | TODO |
| T-212 | PHIS 健康档案真实结构建模与 PHIS→CHIS 转换器 | T-202, T-103 | 建模 PHIS `data.doctor/data.record/archId/businessId/empiId/phrId`;先确认稳定源主键语义并落 ADR;完整转换 JKDA 主体、既往史和生活环境;校验必填/长度/日期/码表;脱敏 fixture 回归;按档案责任医生生成请求操作上下文 | DONE |
| T-213 | 健康档案 upsert 应用编排(外部能力均接口注入) | T-212, T-203, T-206 | 按身份证查询 CHIS:0 条创建、1 条合规档案更新、多条/跨机构/不可更新状态转人工;查询失败不得降级创建;用假 OSI/幂等/report/PHIS 回写实现验收,不在本任务实现持久化 | TODO |
| T-215 | JKDA 健康档案真实 create/update 验收 | T-212, T-213 | 经明确授权的安全测试档案完成 create→query 回查→update→query 回查;确认 `phrId`、checkId、更新目标标识、`addressNumber`、责任医生/机构及重复提交语义,并回填 docs/03 §8、docs/04 §10 | BLOCKED(待写入授权和可写测试档案) |
@@ -119,6 +119,8 @@
- 既往史代码非空时生成对应节点(包括“无”代码),空值省略;名称/日期未提供时不伪造。`isFillShhj=n` 时省略 `familyMiddle`,为 `y` 时才校验并组装生活环境。真实写入后由 T-215 校准平台对“无”代码节点的最终要求。
- 从真实 payload 派生脱敏 fixture,覆盖完整转换、可选字段省略、未知码值、缺失必填、空既往史、生活环境未填写、稳定主键跨版本不变,以及人工构造的主键缺失/回退场景。
**T-212 落地结论(2026-07-16)**:参考 PHIS worker 回调契约与多业务样例后,确认健康档案使用 `archId` 作为稳定源键,`businessId` 只用于追踪/回写;`archId` 缺失直接失败,不做危险回退。详见 `docs/decisions/001-phis-health-record-source-key.md`。
### T-213 upsert 规则
1. 用 PHIS `record.idCard` 调用 JKDA00002 查询,只有明确成功响应才允许判断记录数量。