feat(mapping): 实现健康档案映射基线(T-202)

This commit is contained in:
ila
2026-07-07 14:36:40 +08:00
parent 56e1510f6b
commit 767c876519
8 changed files with 278 additions and 7 deletions
+1 -1
View File
@@ -28,7 +28,7 @@
## 阶段 2 · 健康档案闭环(第一条业务线)
- [x] `contract/jkda.go` 查询响应结构体(T-201;创建/更新请求结构体留到 T-206)。
- [ ] `mapping/health_record.go` + `mapping/checkid.go`。
- [x] `mapping/health_record.go` + `mapping/checkid.go`。
- [x] `osi/jkda.go`:Find/FindRqbj(T-203;Find 已真实请求验收,FindRqbj 已按文档契约单测覆盖)。
- [ ] `osi/jkda.go`:Create/Update(T-206,依赖映射与字典缓存)。
- [ ] `handler` + `router`:暴露 `/api/health-record/save`(server 模式联调用)。
+6 -5
View File
@@ -6,11 +6,11 @@
## 当前快照
- 日期:2026-07-07
- 阶段:**Phase M 映射基线已起步**;T-001~T-006、T-201、T-203、T-102 已验收,下一步进入 T-202
- 阶段:**Phase M 映射层主体已起步**;T-001~T-006、T-201、T-203、T-102、T-202 已验收,下一步进入 T-205
- 技术栈:Go 1.24 单二进制;`main.go -mode server|deliver`;配置读取使用 viper,支持环境变量覆盖;OSI 客户端已具备签名、信封、传输、基础判码、JKDA00002 Find 与 JKDA00005 FindRqbj
- 生产代码:已有 `main.go`、`config/`、`contract/envelope.go`、`osi/sign.go`、`osi/transport.go`、`osi/codes.go`、`osi/client.go`、`verify_jkda.go`、`go.mod`/`go.sum`;`contract/jkda.go` 已建立 JKDA00002 查询响应契约;`osi/jkda.go` 已实现 Find/FindRqbj;`mapping/dict.go` 已建立码表基线;`pipeline/` 等业务模块仍待后续任务建立
- 生产代码:已有 `main.go`、`config/`、`contract/envelope.go`、`osi/sign.go`、`osi/transport.go`、`osi/codes.go`、`osi/client.go`、`verify_jkda.go`、`go.mod`/`go.sum`;`contract/jkda.go` 已建立 JKDA00002 查询响应契约;`osi/jkda.go` 已实现 Find/FindRqbj;`mapping/dict.go` 已建立码表基线;`mapping/health_record.go` 与 `mapping/checkid.go` 已建立映射纯函数基线;`pipeline/` 等业务模块仍待后续任务建立
- 联调现实:**JKDA00002 个人档案查询已用 Go 侧真实请求打通**,返回 `code="01" message="操作成功" data_count=1`;Python 联调样本和实测契约沉淀在 `docs/04 §8`
- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 查询响应契约、JKDA00002 Find、JKDA00005 FindRqbj、映射码表双向查找、民族 01~56 完整性、JKDA00002 验证入口
- 测试:`go test ./...` 通过;当前测试覆盖 mode 解析、配置加载与环境变量覆盖、MD5 签名、请求头组装、JSON POST 传输、头名大小写保留、identity 响应编码声明、超时配置、SOCKS5 代理地址校验、信封结构、serviceId 路由、成功/重试判码、Client.Call 请求与响应解析、JKDA00002 查询响应契约、JKDA00002 Find、JKDA00005 FindRqbj、映射码表双向查找、民族 01~56 完整性、健康档案映射必填/码表校验、checkId 确定性、JKDA00002 验证入口
- 标准启动路径:`./init.sh` 已配置三步:依赖下载、`go test ./...`、`go run . -mode server -config config.yaml.example`
- 标准验证路径:`go test ./...`、`go build ./...`
- 当前 blocker:无硬 blocker。软限制:当前机器从 Git Bash 启动 Go 会出现标准库路径/构建缓存权限异常,`init.sh` 无法完整跑完;PowerShell 下等价 Go 命令和真实请求通过。厂家侧 B1/B2/B4/B5 契约缺口只影响阶段 4,不阻塞 Phase Q/M/D 的当前路径
@@ -36,6 +36,7 @@
| `scripts/` | 已有·不入库 | Python 联调脚本(硬编码真实凭据与身份证,勿提交) |
| `config.yaml` | 已有·不入库 | 真实凭据;不要提交 |
| `mapping/dict.go` | 已有 | 性别/民族/血型/RH/文化程度/职业/婚姻/医保/personSign 双向码表;未命中返回 `ValidationError` |
| `mapping/health_record.go` `mapping/checkid.go` | 已有 | 健康档案映射草稿、字典快照注入、必填/码表校验、确定性 checkId |
| `pipeline/` 等 | 待建 | 后续任务 |
## 已验证事实(写代码时直接依赖)
@@ -71,8 +72,8 @@ python3 scripts/query_health_record.py
## 下一步
1. T-202:`mapping/health_record.go` + `mapping/checkid.go`,基于码表和 JKDA 查询契约建立映射纯函数。
2. T-205:映射单测基线。
1. T-205:映射单测基线,补 docx 样例 + 联调样本风格的回归用例。
2. T-101:映射基线完成后进入真实字典服务。
## 维护规则
+16
View File
@@ -0,0 +1,16 @@
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, "|")))
return hex.EncodeToString(sum[:])[:20]
}
+1
View File
@@ -14,6 +14,7 @@ type ValidationError struct {
DictName string
Direction Direction
Code string
Reason string
}
func (e ValidationError) Error() string {
+129
View File
@@ -0,0 +1,129 @@
package mapping
type PHISHealthRecord struct {
SourceSystem string
SourceRecordID string
UpdatedAt string
IDCard string
PersonName string
SexCode string
Birthday string
MobileNumber string
RegionCode string
ManaDoctorID string
ManaUnitID string
NationCode string
EducationCode string
WorkCode string
MaritalStatusCode string
BloodTypeCode string
RhBloodCode string
InsuranceCode string
}
type MappedHealthRecord struct {
CheckID string
IDCard string
PersonName string
SexCode string
Birthday string
MobileNumber string
RegionCode string
ManaDoctorID string
ManaUnitID string
NationCode string
EducationCode string
WorkCode string
MaritalStatusCode string
BloodTypeCode string
RhBloodCode string
InsuranceCode string
}
type DictSnapshot struct {
Sex Dict
Nation Dict
Education Dict
Work Dict
MaritalStatus Dict
BloodType Dict
RhBlood Dict
Insurance Dict
}
type MapContext struct {
Dicts DictSnapshot
}
func DefaultDictSnapshot() DictSnapshot {
return DictSnapshot{
Sex: SexDict,
Nation: NationDict,
Education: EducationDict,
Work: WorkDict,
MaritalStatus: MaritalStatusDict,
BloodType: BloodTypeDict,
RhBlood: RhBloodDict,
Insurance: InsuranceDict,
}
}
func MapHealthRecord(src PHISHealthRecord, ctx MapContext) (MappedHealthRecord, []ValidationError) {
if ctx.Dicts.Sex.Name == "" {
ctx.Dicts = DefaultDictSnapshot()
}
var errs []ValidationError
for _, field := range []struct {
name string
value string
}{
{name: "idCard", value: src.IDCard},
{name: "personName", value: src.PersonName},
{name: "sexCode", value: src.SexCode},
{name: "birthday", value: src.Birthday},
{name: "mobileNumber", value: src.MobileNumber},
{name: "regionCode", value: src.RegionCode},
{name: "manaDoctorId", value: src.ManaDoctorID},
{name: "manaUnitId", value: src.ManaUnitID},
} {
if field.value == "" {
errs = append(errs, ValidationError{Field: field.name, Reason: "required"})
}
}
mapped := MappedHealthRecord{
CheckID: GenerateCheckID(src.SourceSystem, "JKDA", src.SourceRecordID, src.UpdatedAt),
IDCard: src.IDCard,
PersonName: src.PersonName,
Birthday: src.Birthday,
MobileNumber: src.MobileNumber,
RegionCode: src.RegionCode,
ManaDoctorID: src.ManaDoctorID,
ManaUnitID: src.ManaUnitID,
}
mapped.SexCode = mapCode(ctx.Dicts.Sex, src.SexCode, &errs)
mapped.NationCode = mapCode(ctx.Dicts.Nation, src.NationCode, &errs)
mapped.EducationCode = mapCode(ctx.Dicts.Education, src.EducationCode, &errs)
mapped.WorkCode = mapCode(ctx.Dicts.Work, src.WorkCode, &errs)
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)
return mapped, errs
}
func mapCode(dict Dict, code string, errs *[]ValidationError) string {
if code == "" {
return ""
}
mapped, err := dict.PHISToOSI(code)
if err != nil {
if validationErr, ok := err.(ValidationError); ok {
*errs = append(*errs, validationErr)
return ""
}
*errs = append(*errs, ValidationError{Field: dict.Field, Code: code, Reason: err.Error()})
return ""
}
return mapped
}
+117
View File
@@ -0,0 +1,117 @@
package mapping
import "testing"
func TestMapHealthRecordMapsDirectFieldsDictsAndCheckID(t *testing.T) {
src := validPHISHealthRecord()
got, errs := MapHealthRecord(src, MapContext{Dicts: DefaultDictSnapshot()})
if len(errs) != 0 {
t.Fatalf("errors = %#v", errs)
}
if got.IDCard != src.IDCard || got.PersonName != src.PersonName || got.Birthday != src.Birthday || got.MobileNumber != src.MobileNumber {
t.Fatalf("direct fields not mapped: %#v", got)
}
if got.SexCode != "1" || got.NationCode != "01" || got.EducationCode != "31" || got.WorkCode != "Y" {
t.Fatalf("dict fields not mapped: %#v", got)
}
if got.MaritalStatusCode != "20" || got.BloodTypeCode != "4" || got.RhBloodCode != "1" || got.InsuranceCode != "01" {
t.Fatalf("dict fields not mapped: %#v", got)
}
if got.RegionCode != src.RegionCode || got.ManaDoctorID != src.ManaDoctorID || got.ManaUnitID != src.ManaUnitID {
t.Fatalf("management fields not mapped: %#v", got)
}
if got.CheckID == "" || len(got.CheckID) != 20 {
t.Fatalf("checkId = %q", got.CheckID)
}
}
func TestMapHealthRecordReportsRequiredFieldErrors(t *testing.T) {
src := validPHISHealthRecord()
src.IDCard = ""
src.PersonName = ""
src.ManaUnitID = ""
_, errs := MapHealthRecord(src, MapContext{Dicts: DefaultDictSnapshot()})
if len(errs) != 3 {
t.Fatalf("errors = %#v", errs)
}
assertValidationField(t, errs, "idCard", "required")
assertValidationField(t, errs, "personName", "required")
assertValidationField(t, errs, "manaUnitId", "required")
}
func TestMapHealthRecordReportsDictValidationErrors(t *testing.T) {
src := validPHISHealthRecord()
src.SexCode = "bad"
_, errs := MapHealthRecord(src, MapContext{Dicts: DefaultDictSnapshot()})
if len(errs) != 1 {
t.Fatalf("errors = %#v", errs)
}
if errs[0].Field != "sexCode" || errs[0].Code != "bad" || errs[0].Direction != DirectionPHISToOSI {
t.Fatalf("error = %#v", errs[0])
}
}
func TestMapHealthRecordUsesInjectedDictSnapshot(t *testing.T) {
src := validPHISHealthRecord()
src.SexCode = "M"
dicts := DefaultDictSnapshot()
dicts.Sex = Dict{Name: "sex", Field: "sexCode", Entries: []Entry{{PHIS: "M", OSI: "1", Name: "男"}}}
got, errs := MapHealthRecord(src, MapContext{Dicts: dicts})
if len(errs) != 0 {
t.Fatalf("errors = %#v", errs)
}
if got.SexCode != "1" {
t.Fatalf("SexCode = %q", got.SexCode)
}
}
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")
if first != second {
t.Fatalf("checkId not deterministic: %q != %q", first, second)
}
if len(first) != 20 {
t.Fatalf("checkId length = %d, want 20", len(first))
}
if first == third {
t.Fatalf("version change did not affect checkId: %q", first)
}
}
func validPHISHealthRecord() PHISHealthRecord {
return PHISHealthRecord{
SourceSystem: "PHIS",
SourceRecordID: "record-001",
UpdatedAt: "20260707010101",
IDCard: "440000********1234",
PersonName: "测试人员",
SexCode: "1",
Birthday: "1970-01-01",
MobileNumber: "13900000000",
RegionCode: "441625000000",
ManaDoctorID: "D001",
ManaUnitID: "123456789",
NationCode: "01",
EducationCode: "31",
WorkCode: "Y",
MaritalStatusCode: "20",
BloodTypeCode: "4",
RhBloodCode: "1",
InsuranceCode: "01",
}
}
func assertValidationField(t *testing.T, errs []ValidationError, field string, reason string) {
t.Helper()
for _, err := range errs {
if err.Field == field && err.Reason == reason {
return
}
}
t.Fatalf("missing validation error field=%s reason=%s in %#v", field, reason, errs)
}
+7
View File
@@ -133,3 +133,10 @@
- 验证:按 TDD 先运行 `go test ./mapping -count=1`,因 `SexDict`/`Dict`/`ValidationError` 等未定义失败;实现后 `go test ./mapping -count=1` 通过;补充民族 `01~56` 连续码检查后再次通过。最终 `go test ./...` 通过,`go build ./...` 通过。
- 决策:当前文档未给 PHIS 侧差异码值,T-102 先以 PHIS 与 OSI 同码作为静态基线;保留 `Entry{PHIS, OSI}` 结构,后续 T-202 若发现 PHIS 码值差异可局部覆盖,不改调用方。
- 下一步:T-202(`mapping/health_record.go` + `mapping/checkid.go`)。
## 2026-07-07 T-202 健康档案映射与 checkId
- 状态:DONE
- 变更:新增 `mapping/health_record.go`、`mapping/checkid.go` 与 `mapping/health_record_test.go`;建立 PHIS 健康档案输入、映射层 `MappedHealthRecord` 草稿、`MapContext`/`DictSnapshot` 注入点;主数据直传字段、码表字段、必填校验、结构化 `ValidationError` 与确定性 `GenerateCheckID` 已覆盖。
- 验证:按 TDD 先运行 `go test ./mapping -run "TestMapHealthRecord|TestGenerateCheckID" -count=1`,因 `MapHealthRecord`/`MapContext`/`GenerateCheckID` 未定义失败;实现后目标测试通过。最终 `go test ./mapping -count=1`、`go test ./...`、`go build ./...`、`git diff --check` 均通过。
- 决策:创建请求结构体仍留到 T-206;T-202 只输出映射层草稿结构,先把纯函数、校验、字典注入和 checkId 稳定性打牢。
- 下一步:T-205(映射单测基线:docx 样例 + 联调样本)。
+1 -1
View File
@@ -50,7 +50,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- |
| T-102 | `mapping/dict.go` 全量码表(含 56 项民族) | T-001 | 双向查表;未命中显式 ValidationError;单测覆盖(静态枚举,不走网络) | DONE |
| T-202 | `mapping/health_record.go` + `mapping/checkid.go` | T-102, T-201 | 映射纯函数 + 结构化校验错误;主数据经 `MapContext` 字典快照注入(单测塞假快照);checkId 确定性生成单测 | TODO |
| T-202 | `mapping/health_record.go` + `mapping/checkid.go` | T-102, T-201 | 映射纯函数 + 结构化校验错误;主数据经 `MapContext` 字典快照注入(单测塞假快照);checkId 确定性生成单测 | DONE |
| T-205 | 映射单测基线:docx 样例 + 联调样本 | T-202 | `go test ./mapping/...` 全绿;必填/码表/格式校验生效 | TODO |
## Phase D · 字典服务与创建闭环(真实主数据反查)