fix(health+dialysis): S2 smoke test 修复 — Entity 表名 + 透析状态转换
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 修复 6 个 Entity table_name 与迁移不匹配: shift, handoff_log,
  patient_assignment, blind_index, critical_alert, critical_alert_response
- 添加透析记录 draft→completed 状态转换 API (PUT /complete)
- 修复 family_proxy_service 告警状态过滤 (active→pending/acknowledged)
- dev.ps1 添加 RATE_LIMIT__FAIL_CLOSE=false 开发模式
- S2 透析日流程 smoke test 报告
This commit is contained in:
iven
2026-05-05 03:07:41 +08:00
parent 99dad17eac
commit 2acd9485c7
13 changed files with 150 additions and 8 deletions

View File

@@ -125,6 +125,24 @@ where
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }
pub async fn complete_dialysis_record<S>(
State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>,
Path(record_id): Path<Uuid>,
Json(req): Json<ReviewDialysisWithVersion>,
) -> Result<Json<ApiResponse<DialysisRecordResp>>, AppError>
where
DialysisState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.dialysis.manage")?;
let result = dialysis_service::complete_dialysis_record(
&state, ctx.tenant_id, record_id, Some(ctx.user_id), req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn delete_dialysis_record<S>( pub async fn delete_dialysis_record<S>(
State(state): State<DialysisState>, State(state): State<DialysisState>,
Extension(ctx): Extension<TenantContext>, Extension(ctx): Extension<TenantContext>,

View File

@@ -42,6 +42,10 @@ impl DialysisModule {
"/health/dialysis-records/{id}/review", "/health/dialysis-records/{id}/review",
axum::routing::put(dialysis_handler::review_dialysis_record), axum::routing::put(dialysis_handler::review_dialysis_record),
) )
.route(
"/health/dialysis-records/{id}/complete",
axum::routing::put(dialysis_handler::complete_dialysis_record),
)
// 透析方案 // 透析方案
.route( .route(
"/health/dialysis-prescriptions", "/health/dialysis-prescriptions",

View File

@@ -211,6 +211,43 @@ pub async fn update_dialysis_record(
Ok(to_resp(&state.crypto, m)) Ok(to_resp(&state.crypto, m))
} }
pub async fn complete_dialysis_record(
state: &DialysisState,
tenant_id: Uuid,
record_id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> DialysisResult<DialysisRecordResp> {
let model = dialysis_record::Entity::find()
.filter(dialysis_record::Column::Id.eq(record_id))
.filter(dialysis_record::Column::TenantId.eq(tenant_id))
.filter(dialysis_record::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(DialysisError::DialysisRecordNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| DialysisError::VersionMismatch)?;
validate_dialysis_status_transition(&model.status, "completed")?;
let now = Utc::now();
let mut active: dialysis_record::ActiveModel = model.into();
active.status = Set("completed".to_string());
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "dialysis_record.completed", "dialysis_record")
.with_resource_id(m.id),
&state.db,
).await;
Ok(to_resp(&state.crypto, m))
}
pub async fn review_dialysis_record( pub async fn review_dialysis_record(
state: &DialysisState, state: &DialysisState,
tenant_id: Uuid, tenant_id: Uuid,

View File

@@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "blind_indexes")] #[sea_orm(table_name = "blind_index")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,

View File

@@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "critical_alerts")] #[sea_orm(table_name = "critical_alert")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,

View File

@@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "critical_alert_responses")] #[sea_orm(table_name = "critical_alert_response")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,

View File

@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[sea_orm(table_name = "shift_handoff_log")] #[sea_orm(table_name = "handoff_log")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,

View File

@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[sea_orm(table_name = "patient_assignments")] #[sea_orm(table_name = "patient_assignment")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,

View File

@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[sea_orm(table_name = "shifts")] #[sea_orm(table_name = "shift")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid, pub id: Uuid,

View File

@@ -327,7 +327,7 @@ async fn count_recent_alerts(
let count = alerts::Entity::find() let count = alerts::Entity::find()
.filter(alerts::Column::TenantId.eq(tenant_id)) .filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::PatientId.eq(patient_id)) .filter(alerts::Column::PatientId.eq(patient_id))
.filter(alerts::Column::Status.eq("active")) .filter(alerts::Column::Status.is_in(["pending", "acknowledged"]))
.count(db) .count(db)
.await?; .await?;

View File

@@ -588,7 +588,7 @@ async fn count_patients_by_care_level(
let counts: Vec<CareLevelCount> = CareLevelCount::find_by_statement( let counts: Vec<CareLevelCount> = CareLevelCount::find_by_statement(
sea_orm::Statement::from_sql_and_values( sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres, sea_orm::DatabaseBackend::Postgres,
"SELECT care_level, CAST(COUNT(*) AS BIGINT) as count FROM patient_assignments \ "SELECT care_level, CAST(COUNT(*) AS BIGINT) as count FROM patient_assignment \
WHERE shift_id = $1 AND deleted_at IS NULL \ WHERE shift_id = $1 AND deleted_at IS NULL \
GROUP BY care_level", GROUP BY care_level",
[shift_id.into()], [shift_id.into()],

View File

@@ -29,6 +29,7 @@ $env:ERP__WECHAT__SECRET = "placeholder_wechat_secret"
$env:ERP__WECHAT__DEV_MODE = "true" $env:ERP__WECHAT__DEV_MODE = "true"
$env:ERP__HEALTH__AES_KEY = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" $env:ERP__HEALTH__AES_KEY = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
$env:ERP__HEALTH__HMAC_KEY = "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5" $env:ERP__HEALTH__HMAC_KEY = "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5"
$env:ERP__RATE_LIMIT__FAIL_CLOSE = "false"
# --- find PID using port --- # --- find PID using port ---
function Find-PortPid([int]$Port) { function Find-PortPid([int]$Port) {

View File

@@ -0,0 +1,82 @@
# S2 透析日流程 Smoke Test 报告
> 日期: 2026-05-05 | 测试环境: dev (localhost:3000) | 测试者: Claude AI
## 概述
S2 场景验证血透中心完整透析日工作流:护士登录 → 查看排班 → 患者签到 → 体征录入 → 透析记录 → 触发告警 → 确认告警 → 班次交接。
**结果: PASS_WITH_ISSUES** — 核心流程可走通,发现 2 个 CRITICAL + 1 个 HIGH + 1 个 MEDIUM bug。
---
## 测试步骤
| 步骤 | 测试项 | 结果 | 说明 |
|------|--------|------|------|
| S2-1 | 护士登录 | PASS | admin/Admin@2026 登录成功JWT 获取正常 |
| S2-2 | 查看今日排班 | PASS | GET /health/doctor-schedules 返回排班列表(需先通过 API 创建测试数据) |
| S2-3 | 患者签到 | PASS | POST /health/appointments/{id}/confirm 确认预约 |
| S2-4 | 体征录入 | PASS | POST /health/patients/{id}/vital-signs 创建血压/心率记录 |
| S2-5 | 透析记录 | **FAIL** | CRITICAL: draft→completed 状态转换无 API 入口 |
| S2-6 | 异常体征触发告警 | **FAIL** | HIGH: 手动体征录入不触发告警规则 |
| S2-7 | 确认告警 | **PARTIAL** | acknowledged→resolved 正常active→acknowledged 因状态不匹配失败 |
| S2-8 | 班次交接 | PASS | POST /health/shifts + POST /health/handoff-logs 均成功 |
---
## Bug 列表
### CRITICAL-1: 透析记录状态 draft→completed 无 API 入口
- **位置:** `crates/erp-dialysis/src/module.rs`
- **现象:** `update_dialysis_record` 不修改 status 字段;`review_dialysis_record` 只处理 completed→reviewed。无任何端点支持 draft→completed 转换。
- **影响:** 透析记录创建后无法标记为"已完成",整个透析流程中断。
- **修复建议:** 在 `DialysisService` 中添加 `complete_dialysis_record` 方法,在 module.rs 注册 `PUT /health/dialysis-records/{id}/complete` 路由。
### CRITICAL-2: 告警状态 "active" vs "pending" 不匹配
- **位置:** `crates/erp-health/src/service/validation.rs:230-251`
- **现象:** 告警创建时 status 设为 "active",但 `validate_alert_status_transition` 只允许从 "pending" 开始转换。active→acknowledged 被拒绝。
- **影响:** 所有通过告警规则的告警无法被确认/解决。
- **修复建议:** 统一告警创建状态为 "pending",或在 validation 中增加 "active" 作为合法起始状态。
### HIGH-1: 手动体征录入不触发告警规则
- **位置:** `crates/erp-health/src/service/vital_signs_service.rs`
- **现象:** 告警规则检查只在设备数据上传BLE gateway路径触发手动 API 录入体征不触发告警评估。
- **影响:** 护士手动录入异常体征时不会自动生成告警,依赖人工发现。
- **修复建议:** 在 vital_signs_service 的 create 方法中添加告警评估调用。
### MEDIUM-1: Entity 表名与迁移不匹配6 处)
- **位置:** `crates/erp-health/src/entity/` 下 6 个文件
- **现象:** Entity `table_name` 使用复数形式或不同前缀,但迁移建表使用单数/不同名称。
- **已修复的文件:**
| Entity 文件 | entity table_name (修复前) | 迁移表名 | 修复后 |
|-------------|--------------------------|---------|--------|
| shift.rs | `shifts` | `shift` | `shift` |
| handoff_log.rs | `shift_handoff_log` | `handoff_log` | `handoff_log` |
| patient_assignment.rs | `patient_assignments` | `patient_assignment` | `patient_assignment` |
| blind_index.rs | `blind_indexes` | `blind_index` | `blind_index` |
| critical_alert.rs | `critical_alerts` | `critical_alert` | `critical_alert` |
| critical_alert_response.rs | `critical_alert_responses` | `critical_alert_response` | `critical_alert_response` |
- **影响:** shift/handoff API 返回 500其他功能受影响程度取决于使用频率。
---
## 测试数据
- 排班: 2026-05-05 AM/PM (doctor-schedules)
- 班次: 2026-05-05 morning/afternoon (shifts)
- 交接记录: AM→PM shift handoff
- 体征记录: 血压 180/110 (异常值)
- 透析记录: draft 状态(无法完成)
---
## 结论
S2 透析日流程**核心端点连通性已验证**,但发现多个阻断性 bug。建议优先修复 CRITICAL-1透析状态转换和 CRITICAL-2告警状态不匹配然后重新运行 S2 验证。