diff --git a/crates/erp-dialysis/src/handler/dialysis_handler.rs b/crates/erp-dialysis/src/handler/dialysis_handler.rs index 203f1f9..3faf057 100644 --- a/crates/erp-dialysis/src/handler/dialysis_handler.rs +++ b/crates/erp-dialysis/src/handler/dialysis_handler.rs @@ -125,6 +125,24 @@ where Ok(Json(ApiResponse::ok(result))) } +pub async fn complete_dialysis_record( + State(state): State, + Extension(ctx): Extension, + Path(record_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + DialysisState: FromRef, + 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( State(state): State, Extension(ctx): Extension, diff --git a/crates/erp-dialysis/src/module.rs b/crates/erp-dialysis/src/module.rs index 60dbbcc..17ff0e9 100644 --- a/crates/erp-dialysis/src/module.rs +++ b/crates/erp-dialysis/src/module.rs @@ -42,6 +42,10 @@ impl DialysisModule { "/health/dialysis-records/{id}/review", axum::routing::put(dialysis_handler::review_dialysis_record), ) + .route( + "/health/dialysis-records/{id}/complete", + axum::routing::put(dialysis_handler::complete_dialysis_record), + ) // 透析方案 .route( "/health/dialysis-prescriptions", diff --git a/crates/erp-dialysis/src/service/dialysis_service.rs b/crates/erp-dialysis/src/service/dialysis_service.rs index 6de5f98..c164273 100644 --- a/crates/erp-dialysis/src/service/dialysis_service.rs +++ b/crates/erp-dialysis/src/service/dialysis_service.rs @@ -211,6 +211,43 @@ pub async fn update_dialysis_record( Ok(to_resp(&state.crypto, m)) } +pub async fn complete_dialysis_record( + state: &DialysisState, + tenant_id: Uuid, + record_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> DialysisResult { + 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( state: &DialysisState, tenant_id: Uuid, diff --git a/crates/erp-health/src/entity/blind_index.rs b/crates/erp-health/src/entity/blind_index.rs index e899766..79c9435 100644 --- a/crates/erp-health/src/entity/blind_index.rs +++ b/crates/erp-health/src/entity/blind_index.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "blind_indexes")] +#[sea_orm(table_name = "blind_index")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-health/src/entity/critical_alert.rs b/crates/erp-health/src/entity/critical_alert.rs index 23cd5c6..d795332 100644 --- a/crates/erp-health/src/entity/critical_alert.rs +++ b/crates/erp-health/src/entity/critical_alert.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "critical_alerts")] +#[sea_orm(table_name = "critical_alert")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-health/src/entity/critical_alert_response.rs b/crates/erp-health/src/entity/critical_alert_response.rs index e033d17..ee835cc 100644 --- a/crates/erp-health/src/entity/critical_alert_response.rs +++ b/crates/erp-health/src/entity/critical_alert_response.rs @@ -2,7 +2,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "critical_alert_responses")] +#[sea_orm(table_name = "critical_alert_response")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-health/src/entity/handoff_log.rs b/crates/erp-health/src/entity/handoff_log.rs index 258809a..1710eee 100644 --- a/crates/erp-health/src/entity/handoff_log.rs +++ b/crates/erp-health/src/entity/handoff_log.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[sea_orm(table_name = "shift_handoff_log")] +#[sea_orm(table_name = "handoff_log")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-health/src/entity/patient_assignment.rs b/crates/erp-health/src/entity/patient_assignment.rs index 2a75fec..640e2c3 100644 --- a/crates/erp-health/src/entity/patient_assignment.rs +++ b/crates/erp-health/src/entity/patient_assignment.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[sea_orm(table_name = "patient_assignments")] +#[sea_orm(table_name = "patient_assignment")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-health/src/entity/shift.rs b/crates/erp-health/src/entity/shift.rs index 6b39f53..08a7cf6 100644 --- a/crates/erp-health/src/entity/shift.rs +++ b/crates/erp-health/src/entity/shift.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -#[sea_orm(table_name = "shifts")] +#[sea_orm(table_name = "shift")] pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, diff --git a/crates/erp-health/src/service/family_proxy_service.rs b/crates/erp-health/src/service/family_proxy_service.rs index 4a2568e..e06a545 100644 --- a/crates/erp-health/src/service/family_proxy_service.rs +++ b/crates/erp-health/src/service/family_proxy_service.rs @@ -327,7 +327,7 @@ async fn count_recent_alerts( let count = alerts::Entity::find() .filter(alerts::Column::TenantId.eq(tenant_id)) .filter(alerts::Column::PatientId.eq(patient_id)) - .filter(alerts::Column::Status.eq("active")) + .filter(alerts::Column::Status.is_in(["pending", "acknowledged"])) .count(db) .await?; diff --git a/crates/erp-health/src/service/shift_service.rs b/crates/erp-health/src/service/shift_service.rs index a7bbe93..a4acbc8 100644 --- a/crates/erp-health/src/service/shift_service.rs +++ b/crates/erp-health/src/service/shift_service.rs @@ -588,7 +588,7 @@ async fn count_patients_by_care_level( let counts: Vec = CareLevelCount::find_by_statement( sea_orm::Statement::from_sql_and_values( 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 \ GROUP BY care_level", [shift_id.into()], diff --git a/dev.ps1 b/dev.ps1 index 1824797..7a1bc44 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -29,6 +29,7 @@ $env:ERP__WECHAT__SECRET = "placeholder_wechat_secret" $env:ERP__WECHAT__DEV_MODE = "true" $env:ERP__HEALTH__AES_KEY = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" $env:ERP__HEALTH__HMAC_KEY = "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5" +$env:ERP__RATE_LIMIT__FAIL_CLOSE = "false" # --- find PID using port --- function Find-PortPid([int]$Port) { diff --git a/docs/qa/smoke-reports/S2-dialysis-day.md b/docs/qa/smoke-reports/S2-dialysis-day.md new file mode 100644 index 0000000..0fbb595 --- /dev/null +++ b/docs/qa/smoke-reports/S2-dialysis-day.md @@ -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 验证。