diff --git a/CLAUDE.md b/CLAUDE.md index 3aeb5b5..75c8f06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,26 +1,26 @@ @wiki/index.md 整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。 -# ERP 平台底座 — 协作与实现规则 +# HMS 健康管理平台 — 协作与实现规则 -> **ERP Platform Base** 是一个模块化的商业 SaaS ERP 底座,目标是提供核心基础设施(身份权限、工作流、消息、配置),使行业业务模块(进销存、生产、财务等)可以快速插接。 +> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉独立,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。 -> **当前阶段: Phase 1 基础设施搭建。** 从零构建 Rust workspace + Web 前端 + 核心共享层。 +> **当前阶段: erp-health 模块开发。** 设计规格已确认,开始实施。 ## 1. 项目定位 ### 1.1 这是什么 -一个 **底座 + 行业插件** 架构的商业 SaaS ERP 平台: +一个 **健康管理 + ERP 基础设施** 架构的医疗 SaaS 平台: -- **全功能底座** — 身份权限、工作流引擎、消息中心、系统配置 -- **渐进式扩展** — 从小微企业起步,逐步支持中大型企业 +- **医疗核心** — 患者管理、健康数据、预约排班、随访管理、咨询管理(原生 Rust 模块 erp-health) +- **基础底座** — 身份权限、工作流引擎、消息中心、系统配置(继承自 ERP) - **多租户 + 私有化** — 默认 SaaS 共享数据库隔离,支持独立 schema 私有部署 -- **Web 优先** — 浏览器 SPA 是主力,可选 Tauri 桌面端用于特定行业场景 +- **Web 优先** — 浏览器 SPA 是 PC 管理后台主力,小程序(患者端/医护端)独立开发 ### 1.2 决策原则 -**任何改动都要问:这对 ERP 底座的模块化和可扩展性有帮助吗?** +**任何改动都要问:这对健康管理平台的医疗业务和可扩展性有帮助吗?** - ✅ 完善模块接口和 trait 定义 → 最高优先 - ✅ 确保多租户隔离的正确性 → 最高优先 @@ -46,18 +46,18 @@ ## 2. 项目结构 ```text -erp/ +hms/ ├── crates/ # Rust Workspace │ ├── erp-core/ # L1: 基础类型、错误、事件、模块 trait -│ ├── erp-common/ # L1: 共享工具、宏 │ ├── erp-auth/ # L2: 身份与权限模块 │ ├── erp-workflow/ # L2: 工作流引擎模块 │ ├── erp-message/ # L2: 消息中心模块 │ ├── erp-config/ # L2: 系统配置模块 +│ ├── erp-health/ # L2: 健康管理模块 ★ HMS 核心 │ └── erp-server/ # L3: Axum 服务入口,组装所有模块 │ └── migration/ # SeaORM 数据库迁移 ├── apps/ -│ └── web/ # Vite + React 18 SPA (主力前端) +│ └── web/ # Vite + React 19 SPA (主力前端) ├── packages/ │ └── ui-components/ # React 共享组件库 ├── desktop/ # (可选) Tauri 桌面端,行业需要时启用 @@ -74,18 +74,18 @@ erp/ ```text erp-core (无业务依赖) -erp-common (无业务依赖) ↑ erp-auth (→ core) erp-config (→ core) erp-workflow (→ core) erp-message (→ core) +erp-health (→ core) ★ HMS 核心 ↑ erp-server (→ 所有 crate,组装入口) ``` **规则:** -- `erp-core` 和 `erp-common` 不依赖任何业务 crate +- `erp-core` 不依赖任何业务 crate - 业务 crate 之间**禁止**直接依赖,只通过事件总线和 `erp-core` trait 通信 - `erp-server` 是唯一的组装点 @@ -95,11 +95,11 @@ erp-server (→ 所有 crate,组装入口) |------|------| | 后端框架 | Axum 0.8 + Tokio | | ORM | SeaORM (异步、类型安全) | -| 数据库 | PostgreSQL 16+ | +| 数据库 | PostgreSQL 18 | | 缓存 | Redis 7+ | -| 前端框架 | React 18 + TypeScript (Vite) | -| UI 组件库 | Ant Design 5 | -| 状态管理 | Zustand | +| 前端框架 | React 19 + TypeScript 6 (Vite 8) | +| UI 组件库 | Ant Design 6 | +| 状态管理 | Zustand 5 | | 路由 | React Router 7 | | 样式 | TailwindCSS + CSS Variables | | API 文档 | utoipa (OpenAPI 3) | @@ -422,7 +422,6 @@ cargo test -p erp-plugin-prototype # 运行插件集成测试 | scope | 范围 | |-------|------| | `core` | erp-core | -| `common` | erp-common | | `auth` | erp-auth | | `workflow` | erp-workflow | | `message` | erp-message | @@ -452,12 +451,10 @@ chore(docker): 添加 PostgreSQL 健康检查 | 文档 | 说明 | |------|------| +| `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | **HMS 健康模块设计规格** ★ 当前 | | `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 | -| `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` | 平台底座实施计划 | | `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 | -| `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | WASM 插件原型验证计划 | | `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` | CRM 客户管理插件设计规格 | -| `docs/superpowers/plans/2026-04-16-crm-plugin-plan.md` | CRM 插件实施计划 | 所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。 @@ -490,8 +487,7 @@ chore(docker): 添加 PostgreSQL 健康检查 | Crate | 功能 | 状态 | |-------|------|------| | erp-core | 错误类型、共享类型、事件总线、ErpModule trait、审计日志 | ✅ 完成 | -| erp-common | 共享工具 | ✅ 完成 | -| erp-server | Axum 服务入口、配置、数据库连接、CORS | ✅ 完成 | +| erp-server | Axum 服务入口、配置、数据库连接、CORS、模块注册、后台任务 | ✅ 完成 | | erp-auth | 身份与权限 (用户/角色/权限/组织/部门/岗位/行级数据权限) | ✅ 完成 | | erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 | | erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 | @@ -501,6 +497,9 @@ chore(docker): 添加 PostgreSQL 健康检查 | erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 | | erp-plugin-crm | CRM 客户管理插件 (5 实体/9 权限/6 页面) | ✅ 完成 | | erp-plugin-inventory | 进销存管理插件 (6 实体/12 权限/6 页面) | ✅ 完成 | +| erp-plugin-freelance | 自由职业者管理插件 | ✅ 完成 | +| erp-plugin-itops | IT 运维管理插件 | ✅ 完成 | +| erp-health | 健康管理原生模块 (16 实体/12 权限/13 页面) | 🔧 开发中 | diff --git a/Cargo.lock b/Cargo.lock index 5a7d3b9..bcfaa00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1252,6 +1252,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "erp-health" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "chrono", + "erp-core", + "sea-orm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "utoipa", + "uuid", + "validator", +] + [[package]] name = "erp-message" version = "0.1.0" @@ -1365,6 +1384,7 @@ dependencies = [ "erp-auth", "erp-config", "erp-core", + "erp-health", "erp-message", "erp-plugin", "erp-server-migration", diff --git a/Cargo.toml b/Cargo.toml index bbde865..12b15b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/erp-plugin-inventory", "crates/erp-plugin-freelance", "crates/erp-plugin-itops", + "crates/erp-health", ] [workspace.package] @@ -90,3 +91,4 @@ erp-workflow = { path = "crates/erp-workflow" } erp-message = { path = "crates/erp-message" } erp-config = { path = "crates/erp-config" } erp-plugin = { path = "crates/erp-plugin" } +erp-health = { path = "crates/erp-health" } diff --git a/crates/erp-health/Cargo.toml b/crates/erp-health/Cargo.toml new file mode 100644 index 0000000..418e08f --- /dev/null +++ b/crates/erp-health/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "erp-health" +version.workspace = true +edition.workspace = true + +[dependencies] +erp-core.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +axum.workspace = true +sea-orm.workspace = true +tracing.workspace = true +thiserror.workspace = true +validator.workspace = true +utoipa.workspace = true +async-trait.workspace = true diff --git a/crates/erp-health/src/dto/appointment_dto.rs b/crates/erp-health/src/dto/appointment_dto.rs new file mode 100644 index 0000000..451e7b2 --- /dev/null +++ b/crates/erp-health/src/dto/appointment_dto.rs @@ -0,0 +1,95 @@ +use chrono::{NaiveDate, NaiveTime}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateAppointmentReq { + pub patient_id: Uuid, + pub doctor_id: Option, + pub appointment_type: Option, + pub appointment_date: NaiveDate, + pub start_time: NaiveTime, + pub end_time: NaiveTime, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateAppointmentStatusReq { + pub status: String, + pub cancel_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct AppointmentResp { + pub id: Uuid, + pub patient_id: Uuid, + pub doctor_id: Option, + pub appointment_type: String, + pub appointment_date: NaiveDate, + pub start_time: NaiveTime, + pub end_time: NaiveTime, + pub status: String, + pub cancel_reason: Option, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateScheduleReq { + pub doctor_id: Uuid, + pub schedule_date: NaiveDate, + pub period_type: Option, + pub start_time: NaiveTime, + pub end_time: NaiveTime, + pub max_appointments: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateScheduleReq { + pub start_time: Option, + pub end_time: Option, + pub max_appointments: Option, + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ScheduleResp { + pub id: Uuid, + pub doctor_id: Uuid, + pub schedule_date: NaiveDate, + pub period_type: String, + pub start_time: NaiveTime, + pub end_time: NaiveTime, + pub max_appointments: i32, + pub current_appointments: i32, + pub status: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] +pub struct CalendarQuery { + pub start_date: Option, + pub end_date: Option, + pub doctor_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CalendarDayResp { + pub date: NaiveDate, + pub schedules: Vec, +} + +#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] +pub struct AppointmentListQuery { + pub page: Option, + pub page_size: Option, + pub status: Option, + pub patient_id: Option, + pub doctor_id: Option, + pub date: Option, +} diff --git a/crates/erp-health/src/dto/consultation_dto.rs b/crates/erp-health/src/dto/consultation_dto.rs new file mode 100644 index 0000000..2ff055f --- /dev/null +++ b/crates/erp-health/src/dto/consultation_dto.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SessionResp { + pub id: Uuid, + pub patient_id: Uuid, + pub doctor_id: Option, + pub consultation_type: String, + pub status: String, + pub last_message_at: Option>, + pub unread_count_patient: i32, + pub unread_count_doctor: i32, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct MessageResp { + pub id: Uuid, + pub session_id: Uuid, + pub sender_id: Uuid, + pub sender_role: String, + pub content_type: String, + pub content: String, + pub is_read: bool, + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateMessageReq { + pub session_id: Uuid, + pub sender_id: Uuid, + pub sender_role: String, + pub content_type: Option, + pub content: String, +} + +#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] +pub struct SessionQuery { + pub status: Option, + pub patient_id: Option, + pub doctor_id: Option, + pub page: Option, + pub page_size: Option, +} diff --git a/crates/erp-health/src/dto/health_data_dto.rs b/crates/erp-health/src/dto/health_data_dto.rs new file mode 100644 index 0000000..a05e527 --- /dev/null +++ b/crates/erp-health/src/dto/health_data_dto.rs @@ -0,0 +1,123 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +/// 用 f64 替代 Decimal 以满足 utoipa ToSchema +type Decimal = f64; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateVitalSignsReq { + pub record_date: NaiveDate, + pub systolic_bp_morning: Option, + pub diastolic_bp_morning: Option, + pub systolic_bp_evening: Option, + pub diastolic_bp_evening: Option, + pub heart_rate: Option, + pub weight: Option, + pub blood_sugar: Option, + pub water_intake_ml: Option, + pub urine_output_ml: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateVitalSignsReq { + pub record_date: Option, + pub systolic_bp_morning: Option, + pub diastolic_bp_morning: Option, + pub systolic_bp_evening: Option, + pub diastolic_bp_evening: Option, + pub heart_rate: Option, + pub weight: Option, + pub blood_sugar: Option, + pub water_intake_ml: Option, + pub urine_output_ml: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct VitalSignsResp { + pub id: Uuid, + pub patient_id: Uuid, + pub record_date: NaiveDate, + pub systolic_bp_morning: Option, + pub diastolic_bp_morning: Option, + pub systolic_bp_evening: Option, + pub diastolic_bp_evening: Option, + pub heart_rate: Option, + pub weight: Option, + pub blood_sugar: Option, + pub water_intake_ml: Option, + pub urine_output_ml: Option, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateLabReportReq { + pub report_date: NaiveDate, + pub report_type: String, + pub indicators: Option, + pub image_urls: Option, + pub doctor_interpretation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct LabReportResp { + pub id: Uuid, + pub patient_id: Uuid, + pub report_date: NaiveDate, + pub report_type: String, + pub indicators: Option, + pub image_urls: Option, + pub doctor_interpretation: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateHealthRecordReq { + pub record_type: Option, + pub record_date: NaiveDate, + pub source: Option, + pub overall_assessment: Option, + pub report_file_url: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct HealthRecordResp { + pub id: Uuid, + pub patient_id: Uuid, + pub record_type: String, + pub record_date: NaiveDate, + pub source: Option, + pub overall_assessment: Option, + pub report_file_url: Option, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TrendResp { + pub id: Uuid, + pub patient_id: Uuid, + pub period_start: NaiveDate, + pub period_end: NaiveDate, + pub indicator_summary: Option, + pub abnormal_items: Option, + pub generation_type: String, + pub report_file_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct IndicatorTimeseriesResp { + pub indicator: String, + pub data: Vec<(NaiveDate, f64)>, +} diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs new file mode 100644 index 0000000..06cb056 --- /dev/null +++ b/crates/erp-health/src/dto/mod.rs @@ -0,0 +1,4 @@ +pub mod appointment_dto; +pub mod consultation_dto; +pub mod health_data_dto; +pub mod patient_dto; diff --git a/crates/erp-health/src/dto/patient_dto.rs b/crates/erp-health/src/dto/patient_dto.rs new file mode 100644 index 0000000..ab164a3 --- /dev/null +++ b/crates/erp-health/src/dto/patient_dto.rs @@ -0,0 +1,93 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreatePatientReq { + pub name: String, + pub gender: Option, + pub birth_date: Option, + pub blood_type: Option, + pub id_number: Option, + pub allergy_history: Option, + pub medical_history_summary: Option, + pub emergency_contact_name: Option, + pub emergency_contact_phone: Option, + pub source: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdatePatientReq { + pub name: Option, + pub gender: Option, + pub birth_date: Option, + pub blood_type: Option, + pub id_number: Option, + pub allergy_history: Option, + pub medical_history_summary: Option, + pub emergency_contact_name: Option, + pub emergency_contact_phone: Option, + pub source: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PatientResp { + pub id: Uuid, + pub user_id: Option, + pub name: String, + pub gender: Option, + pub birth_date: Option, + pub blood_type: Option, + pub id_number: Option, + pub allergy_history: Option, + pub medical_history_summary: Option, + pub emergency_contact_name: Option, + pub emergency_contact_phone: Option, + pub status: String, + pub verification_status: String, + pub source: Option, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FamilyMemberReq { + pub name: String, + pub relationship: String, + pub phone: Option, + pub birth_date: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FamilyMemberResp { + pub id: Uuid, + pub patient_id: Uuid, + pub name: String, + pub relationship: String, + pub phone: Option, + pub birth_date: Option, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ManageTagsReq { + pub tag_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)] +pub struct PatientListQuery { + pub page: Option, + pub page_size: Option, + pub search: Option, + pub tag_id: Option, + pub status: Option, +} diff --git a/crates/erp-health/src/entity/appointment.rs b/crates/erp-health/src/entity/appointment.rs new file mode 100644 index 0000000..10924a9 --- /dev/null +++ b/crates/erp-health/src/entity/appointment.rs @@ -0,0 +1,55 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "appointment")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub doctor_id: Option, + pub appointment_type: String, + pub appointment_date: chrono::NaiveDate, + pub start_time: chrono::NaiveTime, + pub end_time: chrono::NaiveTime, + pub status: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub cancel_reason: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, + #[sea_orm( + belongs_to = "super::doctor_profile::Entity", + from = "Column::DoctorId", + to = "super::doctor_profile::Column::Id" + )] + Doctor, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/consultation_message.rs b/crates/erp-health/src/entity/consultation_message.rs new file mode 100644 index 0000000..cc0c02b --- /dev/null +++ b/crates/erp-health/src/entity/consultation_message.rs @@ -0,0 +1,43 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "consultation_message")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub session_id: Uuid, + pub sender_id: Uuid, + pub sender_role: String, + pub content_type: String, + pub content: String, + pub is_read: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::consultation_session::Entity", + from = "Column::SessionId", + to = "super::consultation_session::Column::Id" + )] + Session, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Session.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/consultation_session.rs b/crates/erp-health/src/entity/consultation_session.rs new file mode 100644 index 0000000..ca1d0cd --- /dev/null +++ b/crates/erp-health/src/entity/consultation_session.rs @@ -0,0 +1,61 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "consultation_session")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub doctor_id: Option, + #[sea_orm(rename = "type")] + pub consultation_type: String, + pub status: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub last_message_at: Option, + pub unread_count_patient: i32, + pub unread_count_doctor: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, + #[sea_orm( + belongs_to = "super::doctor_profile::Entity", + from = "Column::DoctorId", + to = "super::doctor_profile::Column::Id" + )] + Doctor, + #[sea_orm(has_many = "super::consultation_message::Entity")] + Message, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Message.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/doctor_profile.rs b/crates/erp-health/src/entity/doctor_profile.rs new file mode 100644 index 0000000..6bafe5e --- /dev/null +++ b/crates/erp-health/src/entity/doctor_profile.rs @@ -0,0 +1,54 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "doctor_profile")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub department: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub specialty: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub license_number: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub bio: Option, + pub online_status: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::patient_doctor_relation::Entity")] + PatientRelation, + #[sea_orm(has_many = "super::doctor_schedule::Entity")] + Schedule, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::PatientRelation.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Schedule.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/doctor_schedule.rs b/crates/erp-health/src/entity/doctor_schedule.rs new file mode 100644 index 0000000..130f098 --- /dev/null +++ b/crates/erp-health/src/entity/doctor_schedule.rs @@ -0,0 +1,45 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "doctor_schedule")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub doctor_id: Uuid, + pub schedule_date: chrono::NaiveDate, + pub period_type: String, + pub start_time: chrono::NaiveTime, + pub end_time: chrono::NaiveTime, + pub max_appointments: i32, + pub current_appointments: i32, + pub status: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::doctor_profile::Entity", + from = "Column::DoctorId", + to = "super::doctor_profile::Column::Id" + )] + Doctor, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Doctor.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/follow_up_record.rs b/crates/erp-health/src/entity/follow_up_record.rs new file mode 100644 index 0000000..0f80401 --- /dev/null +++ b/crates/erp-health/src/entity/follow_up_record.rs @@ -0,0 +1,48 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "follow_up_record")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub task_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub executed_by: Option, + pub executed_date: chrono::NaiveDate, + pub result: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub patient_condition: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub medical_advice: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub next_follow_up_date: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::follow_up_task::Entity", + from = "Column::TaskId", + to = "super::follow_up_task::Column::Id" + )] + Task, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Task.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/follow_up_task.rs b/crates/erp-health/src/entity/follow_up_task.rs new file mode 100644 index 0000000..6504a2d --- /dev/null +++ b/crates/erp-health/src/entity/follow_up_task.rs @@ -0,0 +1,55 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "follow_up_task")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub assigned_to: Option, + pub follow_up_type: String, + pub planned_date: chrono::NaiveDate, + pub status: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub content_template: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub related_appointment_id: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, + #[sea_orm(has_many = "super::follow_up_record::Entity")] + Record, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Record.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/health_record.rs b/crates/erp-health/src/entity/health_record.rs new file mode 100644 index 0000000..0169f8b --- /dev/null +++ b/crates/erp-health/src/entity/health_record.rs @@ -0,0 +1,48 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "health_record")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub record_type: String, + pub record_date: chrono::NaiveDate, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub overall_assessment: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub report_file_url: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/health_trend.rs b/crates/erp-health/src/entity/health_trend.rs new file mode 100644 index 0000000..8d24adf --- /dev/null +++ b/crates/erp-health/src/entity/health_trend.rs @@ -0,0 +1,47 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "health_trend")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub period_start: chrono::NaiveDate, + pub period_end: chrono::NaiveDate, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub indicator_summary: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub abnormal_items: Option, + pub generation_type: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub report_file_url: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/lab_report.rs b/crates/erp-health/src/entity/lab_report.rs new file mode 100644 index 0000000..6f81bcd --- /dev/null +++ b/crates/erp-health/src/entity/lab_report.rs @@ -0,0 +1,46 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "lab_report")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub report_date: chrono::NaiveDate, + pub report_type: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub indicators: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub image_urls: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub doctor_interpretation: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs new file mode 100644 index 0000000..8e468ce --- /dev/null +++ b/crates/erp-health/src/entity/mod.rs @@ -0,0 +1,16 @@ +pub mod appointment; +pub mod consultation_message; +pub mod consultation_session; +pub mod doctor_profile; +pub mod doctor_schedule; +pub mod follow_up_record; +pub mod follow_up_task; +pub mod health_record; +pub mod health_trend; +pub mod lab_report; +pub mod patient; +pub mod patient_doctor_relation; +pub mod patient_family_member; +pub mod patient_tag; +pub mod patient_tag_relation; +pub mod vital_signs; diff --git a/crates/erp-health/src/entity/patient.rs b/crates/erp-health/src/entity/patient.rs new file mode 100644 index 0000000..4f12dd1 --- /dev/null +++ b/crates/erp-health/src/entity/patient.rs @@ -0,0 +1,74 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "patient")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + pub name: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub gender: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub birth_date: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub blood_type: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub id_number: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub allergy_history: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub medical_history_summary: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub emergency_contact_name: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub emergency_contact_phone: Option, + pub status: String, + pub verification_status: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::patient_family_member::Entity")] + FamilyMember, + #[sea_orm(has_many = "super::patient_tag_relation::Entity")] + TagRelation, + #[sea_orm(has_many = "super::patient_doctor_relation::Entity")] + DoctorRelation, + #[sea_orm(has_many = "super::health_record::Entity")] + HealthRecord, + #[sea_orm(has_many = "super::vital_signs::Entity")] + VitalSigns, + #[sea_orm(has_many = "super::lab_report::Entity")] + LabReport, + #[sea_orm(has_many = "super::appointment::Entity")] + Appointment, + #[sea_orm(has_many = "super::follow_up_task::Entity")] + FollowUpTask, + #[sea_orm(has_many = "super::consultation_session::Entity")] + ConsultationSession, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::FamilyMember.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/patient_doctor_relation.rs b/crates/erp-health/src/entity/patient_doctor_relation.rs new file mode 100644 index 0000000..ef0e0e1 --- /dev/null +++ b/crates/erp-health/src/entity/patient_doctor_relation.rs @@ -0,0 +1,51 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "patient_doctor_relation")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub doctor_id: Uuid, + pub relationship_type: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, + #[sea_orm( + belongs_to = "super::doctor_profile::Entity", + from = "Column::DoctorId", + to = "super::doctor_profile::Column::Id" + )] + Doctor, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Doctor.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/patient_family_member.rs b/crates/erp-health/src/entity/patient_family_member.rs new file mode 100644 index 0000000..d988b27 --- /dev/null +++ b/crates/erp-health/src/entity/patient_family_member.rs @@ -0,0 +1,46 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "patient_family_member")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub name: String, + pub relationship: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub phone: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub birth_date: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/patient_tag.rs b/crates/erp-health/src/entity/patient_tag.rs new file mode 100644 index 0000000..e232b00 --- /dev/null +++ b/crates/erp-health/src/entity/patient_tag.rs @@ -0,0 +1,39 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "patient_tag")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub is_system: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::patient_tag_relation::Entity")] + TagRelation, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TagRelation.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/patient_tag_relation.rs b/crates/erp-health/src/entity/patient_tag_relation.rs new file mode 100644 index 0000000..737fdc2 --- /dev/null +++ b/crates/erp-health/src/entity/patient_tag_relation.rs @@ -0,0 +1,50 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "patient_tag_relation")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub tag_id: Uuid, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, + #[sea_orm( + belongs_to = "super::patient_tag::Entity", + from = "Column::TagId", + to = "super::patient_tag::Column::Id" + )] + Tag, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Tag.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/vital_signs.rs b/crates/erp-health/src/entity/vital_signs.rs new file mode 100644 index 0000000..caeaadc --- /dev/null +++ b/crates/erp-health/src/entity/vital_signs.rs @@ -0,0 +1,59 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "vital_signs")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub patient_id: Uuid, + pub record_date: chrono::NaiveDate, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub systolic_bp_morning: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub diastolic_bp_morning: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub systolic_bp_evening: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub diastolic_bp_evening: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub heart_rate: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub weight: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub blood_sugar: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub water_intake_ml: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub urine_output_ml: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs new file mode 100644 index 0000000..558a40d --- /dev/null +++ b/crates/erp-health/src/error.rs @@ -0,0 +1,63 @@ +use erp_core::error::AppError; + +#[derive(Debug, thiserror::Error)] +pub enum HealthError { + #[error("{0}")] + Validation(String), + + #[error("患者不存在")] + PatientNotFound, + + #[error("医护档案不存在")] + DoctorNotFound, + + #[error("预约不存在")] + AppointmentNotFound, + + #[error("排班不存在")] + ScheduleNotFound, + + #[error("排班已满,无法预约")] + ScheduleFull, + + #[error("随访任务不存在")] + FollowUpTaskNotFound, + + #[error("会话不存在")] + ConsultationNotFound, + + #[error("状态转换无效: {0}")] + InvalidStatusTransition(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, + + #[error("数据库操作失败: {0}")] + DbError(String), +} + +impl From for AppError { + fn from(err: HealthError) -> Self { + match err { + HealthError::Validation(s) => AppError::Validation(s), + HealthError::PatientNotFound + | HealthError::DoctorNotFound + | HealthError::AppointmentNotFound + | HealthError::ScheduleNotFound + | HealthError::FollowUpTaskNotFound + | HealthError::ConsultationNotFound => AppError::NotFound(err.to_string()), + HealthError::ScheduleFull => AppError::Validation(err.to_string()), + HealthError::InvalidStatusTransition(s) => AppError::Validation(s), + HealthError::VersionMismatch => AppError::VersionMismatch, + HealthError::DbError(_) => AppError::Internal(err.to_string()), + } + } +} + +impl From for HealthError { + fn from(err: sea_orm::DbErr) -> Self { + HealthError::DbError(err.to_string()) + } +} + +pub type HealthResult = Result; diff --git a/crates/erp-health/src/event.rs b/crates/erp-health/src/event.rs new file mode 100644 index 0000000..e9daed8 --- /dev/null +++ b/crates/erp-health/src/event.rs @@ -0,0 +1,7 @@ +use erp_core::events::EventBus; + +pub fn register_handlers(_bus: &EventBus) { + // Health 模块订阅的事件处理器 + // - workflow.task.completed → 更新随访任务状态 + // - message.sent → 联动咨询会话 last_message_at +} diff --git a/crates/erp-health/src/handler/appointment_handler.rs b/crates/erp-health/src/handler/appointment_handler.rs new file mode 100644 index 0000000..ebbe318 --- /dev/null +++ b/crates/erp-health/src/handler/appointment_handler.rs @@ -0,0 +1,226 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, Query, State}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// DTO — 预约排班 +// --------------------------------------------------------------------------- + +/// 预约列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct AppointmentListParams { + pub page: Option, + pub page_size: Option, + pub patient_id: Option, + pub doctor_id: Option, + pub status: Option, + pub date: Option, +} + +/// 创建预约请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateAppointmentReq { + pub patient_id: Uuid, + pub doctor_id: Uuid, + pub schedule_id: Uuid, + pub appointment_date: String, + pub start_time: String, + pub end_time: String, + pub reason: Option, +} + +/// 更新预约状态请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateAppointmentStatusReq { + pub status: String, + pub cancel_reason: Option, +} + +/// 预约响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct AppointmentResp { + pub id: Uuid, + pub patient_id: Uuid, + pub doctor_id: Uuid, + pub schedule_id: Uuid, + pub appointment_date: String, + pub start_time: String, + pub end_time: String, + pub status: String, + pub reason: Option, + pub cancel_reason: Option, + pub created_at: String, + pub updated_at: String, +} + +/// 排班列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct ScheduleListParams { + pub page: Option, + pub page_size: Option, + pub doctor_id: Option, + pub start_date: Option, + pub end_date: Option, +} + +/// 创建排班请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateScheduleReq { + pub doctor_id: Uuid, + pub schedule_date: String, + pub start_time: String, + pub end_time: String, + pub max_appointments: i32, + pub slot_duration_minutes: Option, +} + +/// 更新排班请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateScheduleReq { + pub start_time: Option, + pub end_time: Option, + pub max_appointments: Option, + pub slot_duration_minutes: Option, + pub version: i32, +} + +/// 排班响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct ScheduleResp { + pub id: Uuid, + pub doctor_id: Uuid, + pub schedule_date: String, + pub start_time: String, + pub end_time: String, + pub max_appointments: i32, + pub current_appointments: i32, + pub slot_duration_minutes: Option, + pub created_at: String, + pub updated_at: String, +} + +/// 日历视图查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct CalendarViewParams { + pub doctor_id: Option, + pub start_date: String, + pub end_date: String, +} + +/// 日历视图单个日期条目 +#[derive(Debug, Serialize, ToSchema)] +pub struct CalendarDayEntry { + pub date: String, + pub schedules: Vec, + pub appointments: Vec, +} + +/// 日历视图响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct CalendarViewResp { + pub days: Vec, +} + +// --------------------------------------------------------------------------- +// Handler — 预约排班 (7 个端点) +// --------------------------------------------------------------------------- + +/// GET /api/v1/health/appointments — 预约列表 +pub async fn list_appointments( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/appointments — 创建预约 +pub async fn create_appointment( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/appointments/{id}/status — 更新预约状态 +pub async fn update_appointment_status( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/schedules — 排班列表 +pub async fn list_schedules( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/schedules — 创建排班 +pub async fn create_schedule( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/schedules/{id} — 更新排班 +pub async fn update_schedule( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/calendar — 日历视图 +pub async fn calendar_view( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} diff --git a/crates/erp-health/src/handler/consultation_handler.rs b/crates/erp-health/src/handler/consultation_handler.rs new file mode 100644 index 0000000..8d52b6c --- /dev/null +++ b/crates/erp-health/src/handler/consultation_handler.rs @@ -0,0 +1,142 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, Query, State}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// DTO — 咨询管理 +// --------------------------------------------------------------------------- + +/// 会话列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct SessionListParams { + pub page: Option, + pub page_size: Option, + pub patient_id: Option, + pub doctor_id: Option, + pub status: Option, +} + +/// 会话响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct ConsultationSessionResp { + pub id: Uuid, + pub patient_id: Uuid, + pub doctor_id: Uuid, + pub subject: String, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +/// 消息列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct MessageListParams { + pub page: Option, + pub page_size: Option, +} + +/// 创建消息请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateConsultationMessageReq { + pub content: String, + pub message_type: Option, +} + +/// 消息响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct ConsultationMessageResp { + pub id: Uuid, + pub session_id: Uuid, + pub sender_id: Uuid, + pub sender_type: String, + pub content: String, + pub message_type: String, + pub created_at: String, +} + +/// 导出会话请求 +#[derive(Debug, Deserialize, IntoParams)] +pub struct ExportSessionsParams { + pub patient_id: Option, + pub doctor_id: Option, + pub start_date: Option, + pub end_date: Option, +} + +// --------------------------------------------------------------------------- +// Handler — 咨询管理 (5 个端点) +// --------------------------------------------------------------------------- + +/// GET /api/v1/health/consultations/sessions — 会话列表 +pub async fn list_sessions( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/consultations/sessions/{id}/messages — 消息列表 +pub async fn list_messages( + State(_state): State, + Extension(_ctx): Extension, + Path(_session_id): Path, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/consultations/sessions/{id}/close — 关闭会话 +pub async fn close_session( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/consultations/sessions/{id}/messages — 创建消息 +pub async fn create_message( + State(_state): State, + Extension(_ctx): Extension, + Path(_session_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/consultations/export — 导出会话 +pub async fn export_sessions( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} diff --git a/crates/erp-health/src/handler/doctor_handler.rs b/crates/erp-health/src/handler/doctor_handler.rs new file mode 100644 index 0000000..b5a43dd --- /dev/null +++ b/crates/erp-health/src/handler/doctor_handler.rs @@ -0,0 +1,136 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, Query, State}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// DTO — 医护管理 +// --------------------------------------------------------------------------- + +/// 医护列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct DoctorListParams { + pub page: Option, + pub page_size: Option, + /// 按姓名模糊搜索 + pub search: Option, + /// 按科室筛选 + pub department: Option, + /// 按职称筛选 + pub title: Option, +} + +/// 创建医护档案请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateDoctorReq { + pub user_id: Uuid, + pub name: String, + pub department: Option, + pub title: Option, + pub specialty: Option, + pub license_number: Option, + pub bio: Option, +} + +/// 更新医护档案请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateDoctorReq { + pub name: Option, + pub department: Option, + pub title: Option, + pub specialty: Option, + pub license_number: Option, + pub bio: Option, + pub version: i32, +} + +/// 医护档案响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct DoctorResp { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub department: Option, + pub title: Option, + pub specialty: Option, + pub license_number: Option, + pub bio: Option, + pub created_at: String, + pub updated_at: String, +} + +// --------------------------------------------------------------------------- +// Handler — 医护管理 (5 个端点) +// --------------------------------------------------------------------------- + +/// GET /api/v1/health/doctors — 医护档案列表 +pub async fn list_doctors( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/doctors — 创建医护档案 +pub async fn create_doctor( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/doctors/{id} — 获取医护档案详情 +pub async fn get_doctor( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/doctors/{id} — 更新医护档案 +pub async fn update_doctor( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// DELETE /api/v1/health/doctors/{id} — 删除医护档案 +pub async fn delete_doctor( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} diff --git a/crates/erp-health/src/handler/follow_up_handler.rs b/crates/erp-health/src/handler/follow_up_handler.rs new file mode 100644 index 0000000..d5de9c3 --- /dev/null +++ b/crates/erp-health/src/handler/follow_up_handler.rs @@ -0,0 +1,178 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, Query, State}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// DTO — 随访管理 +// --------------------------------------------------------------------------- + +/// 随访任务列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct FollowUpTaskListParams { + pub page: Option, + pub page_size: Option, + pub patient_id: Option, + pub assigned_to: Option, + pub status: Option, +} + +/// 创建随访任务请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateFollowUpTaskReq { + pub patient_id: Uuid, + pub task_type: String, + pub title: String, + pub description: Option, + pub due_date: String, + pub assigned_to: Option, +} + +/// 更新随访任务请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateFollowUpTaskReq { + pub task_type: Option, + pub title: Option, + pub description: Option, + pub due_date: Option, + pub assigned_to: Option, + pub status: Option, + pub version: i32, +} + +/// 随访任务响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct FollowUpTaskResp { + pub id: Uuid, + pub patient_id: Uuid, + pub task_type: String, + pub title: String, + pub description: Option, + pub due_date: String, + pub assigned_to: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +/// 创建随访记录请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateFollowUpRecordReq { + pub task_id: Uuid, + pub contact_method: String, + pub content: String, + pub outcome: Option, + pub next_follow_up_date: Option, +} + +/// 随访记录列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct FollowUpRecordListParams { + pub page: Option, + pub page_size: Option, + pub task_id: Option, + pub patient_id: Option, +} + +/// 随访记录响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct FollowUpRecordResp { + pub id: Uuid, + pub task_id: Uuid, + pub patient_id: Uuid, + pub contact_method: String, + pub content: String, + pub outcome: Option, + pub next_follow_up_date: Option, + pub created_by: Uuid, + pub created_at: String, +} + +// --------------------------------------------------------------------------- +// Handler — 随访管理 (6 个端点) +// --------------------------------------------------------------------------- + +/// GET /api/v1/health/follow-up/tasks — 随访任务列表 +pub async fn list_tasks( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/follow-up/tasks — 创建随访任务 +pub async fn create_task( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/follow-up/tasks/{id} — 更新随访任务 +pub async fn update_task( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// DELETE /api/v1/health/follow-up/tasks/{id} — 删除随访任务 +pub async fn delete_task( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/follow-up/records — 创建随访记录 +pub async fn create_record( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/follow-up/records — 随访记录列表 +pub async fn list_records( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} diff --git a/crates/erp-health/src/handler/health_data_handler.rs b/crates/erp-health/src/handler/health_data_handler.rs new file mode 100644 index 0000000..eb00078 --- /dev/null +++ b/crates/erp-health/src/handler/health_data_handler.rs @@ -0,0 +1,408 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, Query, State}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// DTO — 健康数据 +// --------------------------------------------------------------------------- + +/// 生命体征列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct VitalSignsListParams { + pub page: Option, + pub page_size: Option, + pub patient_id: Uuid, +} + +/// 创建生命体征请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateVitalSignsReq { + pub patient_id: Uuid, + pub blood_pressure_systolic: Option, + pub blood_pressure_diastolic: Option, + pub heart_rate: Option, + pub temperature: Option, + pub blood_oxygen: Option, + pub weight: Option, + pub height: Option, + pub measured_at: Option, + pub notes: Option, +} + +/// 更新生命体征请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateVitalSignsReq { + pub blood_pressure_systolic: Option, + pub blood_pressure_diastolic: Option, + pub heart_rate: Option, + pub temperature: Option, + pub blood_oxygen: Option, + pub weight: Option, + pub height: Option, + pub measured_at: Option, + pub notes: Option, + pub version: i32, +} + +/// 生命体征响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct VitalSignsResp { + pub id: Uuid, + pub patient_id: Uuid, + pub blood_pressure_systolic: Option, + pub blood_pressure_diastolic: Option, + pub heart_rate: Option, + pub temperature: Option, + pub blood_oxygen: Option, + pub weight: Option, + pub height: Option, + pub measured_at: Option, + pub notes: Option, + pub created_at: String, + pub updated_at: String, +} + +/// 化验报告列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct LabReportListParams { + pub page: Option, + pub page_size: Option, + pub patient_id: Option, +} + +/// 创建化验报告请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateLabReportReq { + pub patient_id: Uuid, + pub report_type: String, + pub report_date: String, + pub indicators: serde_json::Value, + pub file_url: Option, + pub notes: Option, +} + +/// 更新化验报告请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateLabReportReq { + pub report_type: Option, + pub report_date: Option, + pub indicators: Option, + pub file_url: Option, + pub notes: Option, + pub version: i32, +} + +/// 化验报告响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct LabReportResp { + pub id: Uuid, + pub patient_id: Uuid, + pub report_type: String, + pub report_date: String, + pub indicators: serde_json::Value, + pub file_url: Option, + pub notes: Option, + pub created_at: String, + pub updated_at: String, +} + +/// 健康档案列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct HealthRecordListParams { + pub page: Option, + pub page_size: Option, + pub patient_id: Option, +} + +/// 创建健康档案请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateHealthRecordReq { + pub patient_id: Uuid, + pub record_type: String, + pub title: String, + pub content: serde_json::Value, + pub record_date: String, +} + +/// 更新健康档案请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateHealthRecordReq { + pub record_type: Option, + pub title: Option, + pub content: Option, + pub record_date: Option, + pub version: i32, +} + +/// 健康档案响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct HealthRecordResp { + pub id: Uuid, + pub patient_id: Uuid, + pub record_type: String, + pub title: String, + pub content: serde_json::Value, + pub record_date: String, + pub created_at: String, + pub updated_at: String, +} + +/// 趋势分析列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct TrendListParams { + pub patient_id: Option, +} + +/// 生成趋势请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct GenerateTrendReq { + pub patient_id: Uuid, + pub indicator_name: String, + pub start_date: String, + pub end_date: String, +} + +/// 趋势分析响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct TrendResp { + pub id: Uuid, + pub patient_id: Uuid, + pub indicator_name: String, + pub trend_data: serde_json::Value, + pub analysis_summary: Option, + pub generated_at: String, +} + +/// 指标时间序列查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct IndicatorTimeseriesParams { + pub patient_id: Uuid, + pub indicator_name: String, + pub start_date: Option, + pub end_date: Option, +} + +/// 指标时间序列数据点 +#[derive(Debug, Serialize, ToSchema)] +pub struct TimeseriesDataPoint { + pub date: String, + pub value: f64, + pub unit: Option, +} + +/// 指标时间序列响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct IndicatorTimeseriesResp { + pub indicator_name: String, + pub patient_id: Uuid, + pub data_points: Vec, +} + +// --------------------------------------------------------------------------- +// Handler — 健康数据 (15 个端点) +// --------------------------------------------------------------------------- + +/// GET /api/v1/health/vital-signs — 生命体征列表 +pub async fn list_vital_signs( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/vital-signs — 创建生命体征记录 +pub async fn create_vital_signs( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/vital-signs/{id} — 更新生命体征记录 +pub async fn update_vital_signs( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// DELETE /api/v1/health/vital-signs/{id} — 删除生命体征记录 +pub async fn delete_vital_signs( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/lab-reports — 化验报告列表 +pub async fn list_lab_reports( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/lab-reports — 创建化验报告 +pub async fn create_lab_report( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/lab-reports/{id} — 更新化验报告 +pub async fn update_lab_report( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// DELETE /api/v1/health/lab-reports/{id} — 删除化验报告 +pub async fn delete_lab_report( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/records — 健康档案列表 +pub async fn list_health_records( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/records — 创建健康档案 +pub async fn create_health_record( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/records/{id} — 更新健康档案 +pub async fn update_health_record( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// DELETE /api/v1/health/records/{id} — 删除健康档案 +pub async fn delete_health_record( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/trends — 趋势分析列表 +pub async fn list_trends( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/trends/generate — 生成趋势分析 +pub async fn generate_trend( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/trends/timeseries — 获取指标时间序列 +pub async fn get_indicator_timeseries( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs new file mode 100644 index 0000000..c0d29ca --- /dev/null +++ b/crates/erp-health/src/handler/mod.rs @@ -0,0 +1,6 @@ +pub mod appointment_handler; +pub mod consultation_handler; +pub mod doctor_handler; +pub mod follow_up_handler; +pub mod health_data_handler; +pub mod patient_handler; diff --git a/crates/erp-health/src/handler/patient_handler.rs b/crates/erp-health/src/handler/patient_handler.rs new file mode 100644 index 0000000..3218d3d --- /dev/null +++ b/crates/erp-health/src/handler/patient_handler.rs @@ -0,0 +1,311 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, Query, State}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// DTO — 患者管理 +// --------------------------------------------------------------------------- + +/// 患者列表查询参数 +#[derive(Debug, Deserialize, IntoParams)] +pub struct PatientListParams { + pub page: Option, + pub page_size: Option, + /// 按姓名/身份证号模糊搜索 + pub search: Option, + /// 按标签筛选 + pub tag_id: Option, + /// 按负责医生筛选 + pub doctor_id: Option, +} + +/// 创建患者请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreatePatientReq { + pub name: String, + pub id_card: Option, + pub phone: Option, + pub gender: Option, + pub birth_date: Option, + pub address: Option, + pub emergency_contact: Option, + pub emergency_phone: Option, + pub medical_notes: Option, +} + +/// 更新患者请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdatePatientReq { + pub name: Option, + pub id_card: Option, + pub phone: Option, + pub gender: Option, + pub birth_date: Option, + pub address: Option, + pub emergency_contact: Option, + pub emergency_phone: Option, + pub medical_notes: Option, + pub version: i32, +} + +/// 患者标签管理请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct ManagePatientTagsReq { + pub tag_ids: Vec, +} + +/// 患者响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct PatientResp { + pub id: Uuid, + pub name: String, + pub id_card: Option, + pub phone: Option, + pub gender: Option, + pub birth_date: Option, + pub address: Option, + pub emergency_contact: Option, + pub emergency_phone: Option, + pub medical_notes: Option, + pub tags: Vec, + pub created_at: String, + pub updated_at: String, +} + +/// 患者标签响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct PatientTagResp { + pub id: Uuid, + pub name: String, + pub color: Option, +} + +/// 健康摘要响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct HealthSummaryResp { + pub patient_id: Uuid, + pub latest_vital_signs: Option, + pub latest_lab_report: Option, + pub upcoming_appointments: u64, + pub pending_follow_ups: u64, +} + +/// 家庭成员请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateFamilyMemberReq { + pub name: String, + pub relationship: String, + pub phone: Option, + pub id_card: Option, +} + +/// 更新家庭成员请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateFamilyMemberReq { + pub name: Option, + pub relationship: Option, + pub phone: Option, + pub id_card: Option, +} + +/// 家庭成员响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct FamilyMemberResp { + pub id: Uuid, + pub patient_id: Uuid, + pub name: String, + pub relationship: String, + pub phone: Option, + pub id_card: Option, +} + +/// 分配医生请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct AssignDoctorReq { + pub doctor_id: Uuid, +} + +// --------------------------------------------------------------------------- +// Handler — 患者管理 (13 个端点) +// --------------------------------------------------------------------------- + +/// GET /api/v1/health/patients — 患者列表 +pub async fn list_patients( + State(_state): State, + Extension(_ctx): Extension, + Query(_params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/patients — 创建患者 +pub async fn create_patient( + State(_state): State, + Extension(_ctx): Extension, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/patients/{id} — 获取患者详情 +pub async fn get_patient( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/patients/{id} — 更新患者 +pub async fn update_patient( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// DELETE /api/v1/health/patients/{id} — 删除患者(软删除) +pub async fn delete_patient( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/patients/{id}/tags — 管理患者标签 +pub async fn manage_patient_tags( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/patients/{id}/health-summary — 获取患者健康摘要 +pub async fn get_health_summary( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// GET /api/v1/health/patients/{id}/family-members — 家庭成员列表 +pub async fn list_family_members( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/patients/{id}/family-members — 创建家庭成员 +pub async fn create_family_member( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// PUT /api/v1/health/patients/{patient_id}/family-members/{member_id} — 更新家庭成员 +pub async fn update_family_member( + State(_state): State, + Extension(_ctx): Extension, + Path((_patient_id, _member_id)): Path<(Uuid, Uuid)>, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// DELETE /api/v1/health/patients/{patient_id}/family-members/{member_id} — 删除家庭成员 +pub async fn delete_family_member( + State(_state): State, + Extension(_ctx): Extension, + Path((_patient_id, _member_id)): Path<(Uuid, Uuid)>, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// POST /api/v1/health/patients/{id}/doctors — 分配负责医生 +pub async fn assign_doctor( + State(_state): State, + Extension(_ctx): Extension, + Path(_id): Path, + Json(_req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} + +/// DELETE /api/v1/health/patients/{patient_id}/doctors/{doctor_id} — 移除负责医生 +pub async fn remove_doctor( + State(_state): State, + Extension(_ctx): Extension, + Path((_patient_id, _doctor_id)): Path<(Uuid, Uuid)>, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + Err(AppError::Internal("Not implemented yet".into())) +} diff --git a/crates/erp-health/src/lib.rs b/crates/erp-health/src/lib.rs new file mode 100644 index 0000000..f3da8b4 --- /dev/null +++ b/crates/erp-health/src/lib.rs @@ -0,0 +1,11 @@ +pub mod dto; +pub mod entity; +pub mod error; +pub mod event; +pub mod handler; +pub mod module; +pub mod service; +pub mod state; + +pub use module::HealthModule; +pub use state::HealthState; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs new file mode 100644 index 0000000..2dcfcb9 --- /dev/null +++ b/crates/erp-health/src/module.rs @@ -0,0 +1,322 @@ +use axum::Router; +use uuid::Uuid; + +use erp_core::error::AppResult; +use erp_core::events::EventBus; +use erp_core::module::{ErpModule, PermissionDescriptor}; + +use crate::handler::{ + appointment_handler, consultation_handler, doctor_handler, follow_up_handler, + health_data_handler, patient_handler, +}; + +pub struct HealthModule; + +impl HealthModule { + pub fn new() -> Self { + Self + } + + pub fn public_routes() -> Router + where + crate::state::HealthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + } + + pub fn protected_routes() -> Router + where + crate::state::HealthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + // 患者管理 + .route( + "/health/patients", + axum::routing::get(patient_handler::list_patients) + .post(patient_handler::create_patient), + ) + .route( + "/health/patients/{id}", + axum::routing::get(patient_handler::get_patient) + .put(patient_handler::update_patient) + .delete(patient_handler::delete_patient), + ) + .route( + "/health/patients/{id}/tags", + axum::routing::post(patient_handler::manage_patient_tags), + ) + .route( + "/health/patients/{id}/health-summary", + axum::routing::get(patient_handler::get_health_summary), + ) + .route( + "/health/patients/{id}/family-members", + axum::routing::get(patient_handler::list_family_members) + .post(patient_handler::create_family_member), + ) + .route( + "/health/patients/{id}/family-members/{fid}", + axum::routing::put(patient_handler::update_family_member) + .delete(patient_handler::delete_family_member), + ) + .route( + "/health/patients/{id}/doctors", + axum::routing::post(patient_handler::assign_doctor), + ) + .route( + "/health/patients/{id}/doctors/{did}", + axum::routing::delete(patient_handler::remove_doctor), + ) + // 健康数据 + .route( + "/health/patients/{id}/vital-signs", + axum::routing::get(health_data_handler::list_vital_signs) + .post(health_data_handler::create_vital_signs), + ) + .route( + "/health/patients/{id}/vital-signs/{vid}", + axum::routing::put(health_data_handler::update_vital_signs) + .delete(health_data_handler::delete_vital_signs), + ) + .route( + "/health/patients/{id}/lab-reports", + axum::routing::get(health_data_handler::list_lab_reports) + .post(health_data_handler::create_lab_report), + ) + .route( + "/health/patients/{id}/lab-reports/{rid}", + axum::routing::put(health_data_handler::update_lab_report) + .delete(health_data_handler::delete_lab_report), + ) + .route( + "/health/patients/{id}/health-records", + axum::routing::get(health_data_handler::list_health_records) + .post(health_data_handler::create_health_record), + ) + .route( + "/health/patients/{id}/health-records/{rid}", + axum::routing::put(health_data_handler::update_health_record) + .delete(health_data_handler::delete_health_record), + ) + .route( + "/health/patients/{id}/trends", + axum::routing::get(health_data_handler::list_trends), + ) + .route( + "/health/patients/{id}/trends/generate", + axum::routing::post(health_data_handler::generate_trend), + ) + .route( + "/health/patients/{id}/trends/{indicator}", + axum::routing::get(health_data_handler::get_indicator_timeseries), + ) + // 预约排班 + .route( + "/health/appointments", + axum::routing::get(appointment_handler::list_appointments) + .post(appointment_handler::create_appointment), + ) + .route( + "/health/appointments/{id}/status", + axum::routing::put(appointment_handler::update_appointment_status), + ) + .route( + "/health/doctor-schedules", + axum::routing::get(appointment_handler::list_schedules) + .post(appointment_handler::create_schedule), + ) + .route( + "/health/doctor-schedules/{id}", + axum::routing::put(appointment_handler::update_schedule), + ) + .route( + "/health/doctor-schedules/calendar", + axum::routing::get(appointment_handler::calendar_view), + ) + // 随访管理 + .route( + "/health/follow-up-tasks", + axum::routing::get(follow_up_handler::list_tasks) + .post(follow_up_handler::create_task), + ) + .route( + "/health/follow-up-tasks/{id}", + axum::routing::put(follow_up_handler::update_task) + .delete(follow_up_handler::delete_task), + ) + .route( + "/health/follow-up-tasks/{id}/records", + axum::routing::post(follow_up_handler::create_record), + ) + .route( + "/health/follow-up-records", + axum::routing::get(follow_up_handler::list_records), + ) + // 咨询管理 + .route( + "/health/consultation-sessions", + axum::routing::get(consultation_handler::list_sessions), + ) + .route( + "/health/consultation-sessions/{id}/messages", + axum::routing::get(consultation_handler::list_messages), + ) + .route( + "/health/consultation-sessions/{id}/close", + axum::routing::put(consultation_handler::close_session), + ) + .route( + "/health/consultation-messages", + axum::routing::post(consultation_handler::create_message), + ) + .route( + "/health/consultation-sessions/export", + axum::routing::get(consultation_handler::export_sessions), + ) + // 医护管理 + .route( + "/health/doctors", + axum::routing::get(doctor_handler::list_doctors) + .post(doctor_handler::create_doctor), + ) + .route( + "/health/doctors/{id}", + axum::routing::get(doctor_handler::get_doctor) + .put(doctor_handler::update_doctor) + .delete(doctor_handler::delete_doctor), + ) + } +} + +impl Default for HealthModule { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl ErpModule for HealthModule { + fn name(&self) -> &str { + "health" + } + + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + fn dependencies(&self) -> Vec<&str> { + vec!["auth"] + } + + fn register_event_handlers(&self, bus: &EventBus) { + crate::event::register_handlers(bus); + } + + async fn on_tenant_created( + &self, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + _event_bus: &EventBus, + ) -> AppResult<()> { + crate::service::seed::seed_tenant_health(db, tenant_id) + .await + .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + tracing::info!(tenant_id = %tenant_id, "Health module tenant initialized"); + Ok(()) + } + + async fn on_tenant_deleted( + &self, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AppResult<()> { + crate::service::seed::soft_delete_tenant_data(db, tenant_id) + .await + .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + tracing::info!(tenant_id = %tenant_id, "Health module tenant data soft-deleted"); + Ok(()) + } + + fn permissions(&self) -> Vec { + vec![ + PermissionDescriptor { + code: "health.patient.list".into(), + name: "查看患者列表".into(), + description: "查看和搜索患者列表、详情".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.patient.manage".into(), + name: "管理患者".into(), + description: "创建、编辑、删除患者".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.health-data.list".into(), + name: "查看健康数据".into(), + description: "查看体检记录、监测数据、化验报告".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.health-data.manage".into(), + name: "管理健康数据".into(), + description: "录入、编辑、删除健康数据".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.appointment.list".into(), + name: "查看预约".into(), + description: "查看预约列表和排班".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.appointment.manage".into(), + name: "管理预约".into(), + description: "创建、确认、取消预约".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.follow-up.list".into(), + name: "查看随访".into(), + description: "查看随访任务和记录".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.follow-up.manage".into(), + name: "管理随访".into(), + description: "创建、分配、完成随访任务".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.consultation.list".into(), + name: "查看咨询".into(), + description: "查看咨询会话和消息记录".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.consultation.manage".into(), + name: "管理咨询".into(), + description: "关闭会话、导出记录".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.doctor.list".into(), + name: "查看医护".into(), + description: "查看医护列表和详情".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.doctor.manage".into(), + name: "管理医护".into(), + description: "创建、编辑医护档案、排班".into(), + module: "health".into(), + }, + ] + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/crates/erp-health/src/service/appointment_service.rs b/crates/erp-health/src/service/appointment_service.rs new file mode 100644 index 0000000..c0a562b --- /dev/null +++ b/crates/erp-health/src/service/appointment_service.rs @@ -0,0 +1,120 @@ +//! 预约排班 Service — 预约CRUD、排班管理、日历视图、原子CAS预约 + +use chrono::NaiveDate; +use uuid::Uuid; + +use erp_core::types::{PaginatedResponse, Pagination}; + +use crate::dto::appointment_dto::{ + AppointmentResp, CalendarDayResp, CreateAppointmentReq, CreateScheduleReq, + ScheduleResp, UpdateAppointmentStatusReq, UpdateScheduleReq, +}; +use crate::error::HealthResult; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 预约管理 (Appointments) +// --------------------------------------------------------------------------- + +/// 预约列表(分页 + 多条件筛选) +pub async fn list_appointments( + state: &HealthState, + tenant_id: Uuid, + pagination: Pagination, + status: Option, + patient_id: Option, + doctor_id: Option, + date: Option, +) -> HealthResult> { + let _ = (state, tenant_id, pagination, status, patient_id, doctor_id, date); + todo!() +} + +/// 创建预约(原子 CAS 占位,防止超额预约) +pub async fn create_appointment( + state: &HealthState, + tenant_id: Uuid, + req: CreateAppointmentReq, + user_id: Option, +) -> HealthResult { + let _ = (state, tenant_id, req, user_id); + // 实现时需要: + // 1. 查找对应排班档位 + // 2. 原子 CAS: UPDATE doctor_schedule SET current_appointments = current_appointments + 1 + // WHERE id = ? AND current_appointments < max_appointments + // 3. CAS 失败返回 ScheduleFull 错误 + // 4. 创建预约记录 + // 5. 发布 appointment.created 事件 + todo!() +} + +/// 更新预约状态(确认/取消/完成/未到) +pub async fn update_appointment_status( + state: &HealthState, + tenant_id: Uuid, + appointment_id: Uuid, + req: UpdateAppointmentStatusReq, + version: i32, +) -> HealthResult { + let _ = (state, tenant_id, appointment_id, req, version); + // 实现时需要: + // 1. 状态机校验:pending -> confirmed/cancelled, confirmed -> completed/no_show/cancelled + // 2. 取消时释放排班名额(原子减 1) + // 3. 发布 appointment.confirmed / appointment.cancelled 事件 + todo!() +} + +// --------------------------------------------------------------------------- +// 排班管理 (Doctor Schedules) +// --------------------------------------------------------------------------- + +/// 排班列表 +pub async fn list_schedules( + state: &HealthState, + tenant_id: Uuid, + pagination: Pagination, + doctor_id: Option, + date: Option, +) -> HealthResult> { + let _ = (state, tenant_id, pagination, doctor_id, date); + todo!() +} + +/// 创建排班 +pub async fn create_schedule( + state: &HealthState, + tenant_id: Uuid, + req: CreateScheduleReq, + user_id: Option, +) -> HealthResult { + let _ = (state, tenant_id, req, user_id); + todo!() +} + +/// 更新排班(乐观锁) +pub async fn update_schedule( + state: &HealthState, + tenant_id: Uuid, + schedule_id: Uuid, + req: UpdateScheduleReq, + version: i32, +) -> HealthResult { + let _ = (state, tenant_id, schedule_id, req, version); + todo!() +} + +// --------------------------------------------------------------------------- +// 日历视图 +// --------------------------------------------------------------------------- + +/// 日历视图(按日期范围返回每天的排班汇总) +pub async fn calendar_view( + state: &HealthState, + tenant_id: Uuid, + start_date: NaiveDate, + end_date: NaiveDate, + doctor_id: Option, +) -> HealthResult> { + let _ = (state, tenant_id, start_date, end_date, doctor_id); + todo!() +} diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs new file mode 100644 index 0000000..6e7822b --- /dev/null +++ b/crates/erp-health/src/service/consultation_service.rs @@ -0,0 +1,83 @@ +//! 咨询管理 Service — 会话管理、消息收发、会话关闭、导出 + +use uuid::Uuid; + +use erp_core::types::{PaginatedResponse, Pagination}; + +use crate::dto::consultation_dto::{ + CreateMessageReq, MessageResp, SessionQuery, SessionResp, +}; +use crate::error::HealthResult; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 咨询会话 (Consultation Sessions) +// --------------------------------------------------------------------------- + +/// 咨询会话列表(分页 + 多条件筛选) +pub async fn list_sessions( + state: &HealthState, + tenant_id: Uuid, + query: SessionQuery, +) -> HealthResult> { + let _ = (state, tenant_id, query); + todo!() +} + +/// 关闭咨询会话 +pub async fn close_session( + state: &HealthState, + tenant_id: Uuid, + session_id: Uuid, + version: i32, +) -> HealthResult { + let _ = (state, tenant_id, session_id, version); + // 实现时需要: + // 1. 校验会话存在且状态为 active + // 2. 更新状态为 closed + // 3. 发布 consultation.closed 事件 + todo!() +} + +/// 导出咨询会话(按条件筛选后返回汇总数据) +pub async fn export_sessions( + state: &HealthState, + tenant_id: Uuid, + status: Option, + patient_id: Option, + doctor_id: Option, +) -> HealthResult> { + let _ = (state, tenant_id, status, patient_id, doctor_id); + todo!() +} + +// --------------------------------------------------------------------------- +// 咨询消息 (Consultation Messages) +// --------------------------------------------------------------------------- + +/// 消息列表(按会话 ID 查询,分页) +pub async fn list_messages( + state: &HealthState, + tenant_id: Uuid, + session_id: Uuid, + pagination: Pagination, +) -> HealthResult> { + let _ = (state, tenant_id, session_id, pagination); + todo!() +} + +/// 发送消息 +pub async fn create_message( + state: &HealthState, + tenant_id: Uuid, + req: CreateMessageReq, +) -> HealthResult { + let _ = (state, tenant_id, req); + // 实现时需要: + // 1. 校验会话存在且状态为 active + // 2. 创建消息记录 + // 3. 更新会话的 last_message_at + // 4. 根据发送者角色更新对方的 unread_count + // 5. 发布 consultation.message.created 事件 + todo!() +} diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs new file mode 100644 index 0000000..788cfb2 --- /dev/null +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -0,0 +1,161 @@ +//! 随访管理 Service — 随访任务CRUD、随访记录、状态流转 + +use chrono::NaiveDate; +use uuid::Uuid; + +use erp_core::types::{PaginatedResponse, Pagination}; + +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 随访任务 DTO(内部使用,follow_up_dto 尚未创建独立文件) +// --------------------------------------------------------------------------- + +/// 创建随访任务请求 +#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] +pub struct CreateFollowUpTaskReq { + pub patient_id: Uuid, + pub assigned_to: Option, + pub follow_up_type: String, + pub planned_date: NaiveDate, + pub content_template: Option, + pub related_appointment_id: Option, +} + +/// 更新随访任务请求 +#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateFollowUpTaskReq { + pub assigned_to: Option, + pub follow_up_type: Option, + pub planned_date: Option, + pub content_template: Option, + pub status: Option, +} + +/// 随访任务响应 +#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] +pub struct FollowUpTaskResp { + pub id: Uuid, + pub patient_id: Uuid, + pub assigned_to: Option, + pub follow_up_type: String, + pub planned_date: NaiveDate, + pub status: String, + pub content_template: Option, + pub related_appointment_id: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +/// 创建随访记录请求 +#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] +pub struct CreateFollowUpRecordReq { + pub task_id: Uuid, + pub executed_by: Option, + pub executed_date: NaiveDate, + pub result: String, + pub patient_condition: Option, + pub medical_advice: Option, + pub next_follow_up_date: Option, +} + +/// 随访记录响应 +#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] +pub struct FollowUpRecordResp { + pub id: Uuid, + pub task_id: Uuid, + pub executed_by: Option, + pub executed_date: NaiveDate, + pub result: String, + pub patient_condition: Option, + pub medical_advice: Option, + pub next_follow_up_date: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +// --------------------------------------------------------------------------- +// 随访任务 +// --------------------------------------------------------------------------- + +/// 随访任务列表(分页 + 多条件筛选) +pub async fn list_tasks( + state: &HealthState, + tenant_id: Uuid, + pagination: Pagination, + patient_id: Option, + assigned_to: Option, + status: Option, +) -> HealthResult> { + let _ = (state, tenant_id, pagination, patient_id, assigned_to, status); + todo!() +} + +/// 创建随访任务 +pub async fn create_task( + state: &HealthState, + tenant_id: Uuid, + req: CreateFollowUpTaskReq, + user_id: Option, +) -> HealthResult { + let _ = (state, tenant_id, req, user_id); + todo!() +} + +/// 更新随访任务(乐观锁) +pub async fn update_task( + state: &HealthState, + tenant_id: Uuid, + task_id: Uuid, + req: UpdateFollowUpTaskReq, + version: i32, +) -> HealthResult { + let _ = (state, tenant_id, task_id, req, version); + todo!() +} + +/// 删除随访任务(软删除) +pub async fn delete_task( + state: &HealthState, + tenant_id: Uuid, + task_id: Uuid, +) -> HealthResult<()> { + let _ = (state, tenant_id, task_id); + todo!() +} + +// --------------------------------------------------------------------------- +// 随访记录 +// --------------------------------------------------------------------------- + +/// 创建随访执行记录(同时将任务状态推进为 completed) +pub async fn create_record( + state: &HealthState, + tenant_id: Uuid, + req: CreateFollowUpRecordReq, + user_id: Option, +) -> HealthResult { + let _ = (state, tenant_id, req, user_id); + // 实现时需要: + // 1. 校验任务存在且状态为 in_progress / pending + // 2. 创建随访记录 + // 3. 更新任务状态为 completed + // 4. 如果设置了 next_follow_up_date,自动创建下一个随访任务 + // 5. 发布 follow_up.completed 事件 + todo!() +} + +/// 随访记录列表(分页) +pub async fn list_records( + state: &HealthState, + tenant_id: Uuid, + pagination: Pagination, + task_id: Option, + patient_id: Option, +) -> HealthResult> { + let _ = (state, tenant_id, pagination, task_id, patient_id); + todo!() +} diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs new file mode 100644 index 0000000..c1048e2 --- /dev/null +++ b/crates/erp-health/src/service/health_data_service.rs @@ -0,0 +1,207 @@ +//! 健康数据 Service — 体征记录、化验报告、体检记录、趋势分析 + +use chrono::NaiveDate; +use uuid::Uuid; + +use erp_core::types::{PaginatedResponse, Pagination}; + +use crate::dto::health_data_dto::{ + CreateHealthRecordReq, CreateLabReportReq, CreateVitalSignsReq, HealthRecordResp, + IndicatorTimeseriesResp, LabReportResp, TrendResp, UpdateVitalSignsReq, +}; +use crate::error::HealthResult; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 体征记录 (Vital Signs) +// --------------------------------------------------------------------------- + +/// 体征记录列表 +pub async fn list_vital_signs( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + pagination: Pagination, +) -> HealthResult> { + let _ = (state, tenant_id, patient_id, pagination); + todo!() +} + +/// 创建体征记录 +pub async fn create_vital_signs( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + req: CreateVitalSignsReq, + user_id: Option, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, req, user_id); + todo!() +} + +/// 更新体征记录(乐观锁) +pub async fn update_vital_signs( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + vital_signs_id: Uuid, + req: UpdateVitalSignsReq, + version: i32, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, vital_signs_id, req, version); + todo!() +} + +/// 删除体征记录 +pub async fn delete_vital_signs( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + vital_signs_id: Uuid, +) -> HealthResult<()> { + let _ = (state, tenant_id, patient_id, vital_signs_id); + todo!() +} + +// --------------------------------------------------------------------------- +// 化验报告 (Lab Reports) +// --------------------------------------------------------------------------- + +/// 化验报告列表 +pub async fn list_lab_reports( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + pagination: Pagination, +) -> HealthResult> { + let _ = (state, tenant_id, patient_id, pagination); + todo!() +} + +/// 创建化验报告 +pub async fn create_lab_report( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + req: CreateLabReportReq, + user_id: Option, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, req, user_id); + todo!() +} + +/// 更新化验报告(乐观锁) +pub async fn update_lab_report( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + report_id: Uuid, + req: CreateLabReportReq, + version: i32, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, report_id, req, version); + todo!() +} + +/// 删除化验报告 +pub async fn delete_lab_report( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + report_id: Uuid, +) -> HealthResult<()> { + let _ = (state, tenant_id, patient_id, report_id); + todo!() +} + +// --------------------------------------------------------------------------- +// 体检记录 (Health Records) +// --------------------------------------------------------------------------- + +/// 体检记录列表 +pub async fn list_health_records( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + pagination: Pagination, +) -> HealthResult> { + let _ = (state, tenant_id, patient_id, pagination); + todo!() +} + +/// 创建体检记录 +pub async fn create_health_record( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + req: CreateHealthRecordReq, + user_id: Option, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, req, user_id); + todo!() +} + +/// 更新体检记录(乐观锁) +pub async fn update_health_record( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + record_id: Uuid, + req: CreateHealthRecordReq, + version: i32, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, record_id, req, version); + todo!() +} + +/// 删除体检记录 +pub async fn delete_health_record( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + record_id: Uuid, +) -> HealthResult<()> { + let _ = (state, tenant_id, patient_id, record_id); + todo!() +} + +// --------------------------------------------------------------------------- +// 趋势分析 (Trends) +// --------------------------------------------------------------------------- + +/// 趋势列表 +pub async fn list_trends( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + pagination: Pagination, +) -> HealthResult> { + let _ = (state, tenant_id, patient_id, pagination); + todo!() +} + +/// 生成趋势分析报告(基于历史体征 + 化验数据聚合) +pub async fn generate_trend( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + period_start: NaiveDate, + period_end: NaiveDate, + user_id: Option, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, period_start, period_end, user_id); + todo!() +} + +/// 获取单个指标的时间序列数据 +pub async fn get_indicator_timeseries( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + indicator: String, + start_date: Option, + end_date: Option, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, indicator, start_date, end_date); + todo!() +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs new file mode 100644 index 0000000..50ffa04 --- /dev/null +++ b/crates/erp-health/src/service/mod.rs @@ -0,0 +1,6 @@ +pub mod appointment_service; +pub mod consultation_service; +pub mod follow_up_service; +pub mod health_data_service; +pub mod patient_service; +pub mod seed; diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs new file mode 100644 index 0000000..899ad41 --- /dev/null +++ b/crates/erp-health/src/service/patient_service.rs @@ -0,0 +1,179 @@ +//! 患者管理 Service — 患者CRUD、家庭成员、标签、医生关联、健康摘要 + +use uuid::Uuid; + +use erp_core::types::{PaginatedResponse, Pagination}; + +use crate::dto::patient_dto::{ + CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, + UpdatePatientReq, +}; +use crate::error::HealthResult; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// 患者 CRUD +// --------------------------------------------------------------------------- + +/// 患者列表(分页 + 搜索 + 标签筛选) +pub async fn list_patients( + state: &HealthState, + tenant_id: Uuid, + pagination: Pagination, + search: Option, + tag_id: Option, +) -> HealthResult> { + let _ = (state, tenant_id, pagination, search, tag_id); + todo!() +} + +/// 创建患者 +pub async fn create_patient( + state: &HealthState, + tenant_id: Uuid, + user_id: Option, + req: CreatePatientReq, +) -> HealthResult { + let _ = (state, tenant_id, user_id, req); + todo!() +} + +/// 获取患者详情 +pub async fn get_patient( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + let _ = (state, tenant_id, id); + todo!() +} + +/// 更新患者信息(乐观锁) +pub async fn update_patient( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + req: UpdatePatientReq, + version: i32, +) -> HealthResult { + let _ = (state, tenant_id, id, req, version); + todo!() +} + +/// 软删除患者 +pub async fn delete_patient( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult<()> { + let _ = (state, tenant_id, id); + todo!() +} + +// --------------------------------------------------------------------------- +// 标签管理 +// --------------------------------------------------------------------------- + +/// 管理患者标签(覆盖式:传入的 tag_ids 替换当前关联) +pub async fn manage_patient_tags( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + req: ManageTagsReq, + user_id: Option, +) -> HealthResult<()> { + let _ = (state, tenant_id, patient_id, req, user_id); + todo!() +} + +// --------------------------------------------------------------------------- +// 健康摘要 +// --------------------------------------------------------------------------- + +/// 获取患者健康摘要(最新体征 + 最新化验 + 待处理预约 + 待办随访) +pub async fn get_health_summary( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + let _ = (state, tenant_id, patient_id); + todo!() +} + +// --------------------------------------------------------------------------- +// 家庭成员 +// --------------------------------------------------------------------------- + +/// 家庭成员列表 +pub async fn list_family_members( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult> { + let _ = (state, tenant_id, patient_id); + todo!() +} + +/// 创建家庭成员 +pub async fn create_family_member( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + req: FamilyMemberReq, + user_id: Option, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, req, user_id); + todo!() +} + +/// 更新家庭成员(乐观锁) +pub async fn update_family_member( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + family_member_id: Uuid, + req: FamilyMemberReq, + version: i32, +) -> HealthResult { + let _ = (state, tenant_id, patient_id, family_member_id, req, version); + todo!() +} + +/// 删除家庭成员 +pub async fn delete_family_member( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + family_member_id: Uuid, +) -> HealthResult<()> { + let _ = (state, tenant_id, patient_id, family_member_id); + todo!() +} + +// --------------------------------------------------------------------------- +// 患者-医生关联 +// --------------------------------------------------------------------------- + +/// 分配负责医生 +pub async fn assign_doctor( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + doctor_id: Uuid, + relationship_type: String, + user_id: Option, +) -> HealthResult<()> { + let _ = (state, tenant_id, patient_id, doctor_id, relationship_type, user_id); + todo!() +} + +/// 移除负责医生 +pub async fn remove_doctor( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + doctor_id: Uuid, +) -> HealthResult<()> { + let _ = (state, tenant_id, patient_id, doctor_id); + todo!() +} diff --git a/crates/erp-health/src/service/seed.rs b/crates/erp-health/src/service/seed.rs new file mode 100644 index 0000000..1f622c5 --- /dev/null +++ b/crates/erp-health/src/service/seed.rs @@ -0,0 +1,22 @@ +//! 租户初始化种子数据 — 创建默认标签、默认排班模板等 + +use sea_orm::DatabaseConnection; +use uuid::Uuid; + +/// 初始化租户健康模块默认数据 +pub async fn seed_tenant_health( + _db: &DatabaseConnection, + tenant_id: Uuid, +) -> Result<(), Box> { + tracing::info!(tenant_id = %tenant_id, "Seeding health module default data"); + Ok(()) +} + +/// 软删除该租户下所有健康模块数据 +pub async fn soft_delete_tenant_data( + _db: &DatabaseConnection, + tenant_id: Uuid, +) -> Result<(), Box> { + tracing::info!(tenant_id = %tenant_id, "Soft-deleting health module data for tenant"); + Ok(()) +} diff --git a/crates/erp-health/src/state.rs b/crates/erp-health/src/state.rs new file mode 100644 index 0000000..3b7e807 --- /dev/null +++ b/crates/erp-health/src/state.rs @@ -0,0 +1,8 @@ +use erp_core::events::EventBus; +use sea_orm::DatabaseConnection; + +#[derive(Clone)] +pub struct HealthState { + pub db: DatabaseConnection, + pub event_bus: EventBus, +} diff --git a/crates/erp-server/Cargo.toml b/crates/erp-server/Cargo.toml index 8c96f5d..56c85cb 100644 --- a/crates/erp-server/Cargo.toml +++ b/crates/erp-server/Cargo.toml @@ -27,6 +27,7 @@ erp-config.workspace = true erp-workflow.workspace = true erp-message.workspace = true erp-plugin.workspace = true +erp-health.workspace = true anyhow.workspace = true uuid.workspace = true chrono.workspace = true diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 38d11b7..4e5b7c9 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -41,6 +41,7 @@ mod m20260419_000038_fix_crm_permission_codes; mod m20260419_000039_entity_registry_columns; mod m20260419_000040_plugin_market; mod m20260419_000041_plugin_user_views; +mod m20260423_000042_create_health_tables; pub struct Migrator; @@ -89,6 +90,7 @@ impl MigratorTrait for Migrator { Box::new(m20260419_000039_entity_registry_columns::Migration), Box::new(m20260419_000040_plugin_market::Migration), Box::new(m20260419_000041_plugin_user_views::Migration), + Box::new(m20260423_000042_create_health_tables::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260423_000042_create_health_tables.rs b/crates/erp-server/migration/src/m20260423_000042_create_health_tables.rs new file mode 100644 index 0000000..3cf8855 --- /dev/null +++ b/crates/erp-server/migration/src/m20260423_000042_create_health_tables.rs @@ -0,0 +1,1473 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 1. patient — 患者档案 + manager + .create_table( + Table::create() + .table(Patient::Table) + .if_not_exists() + .col(ColumnDef::new(Patient::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Patient::TenantId).uuid().not_null()) + .col(ColumnDef::new(Patient::UserId).uuid().null()) + .col(ColumnDef::new(Patient::Name).string_len(100).not_null()) + .col(ColumnDef::new(Patient::Gender).string_len(10).null()) + .col(ColumnDef::new(Patient::BirthDate).date().null()) + .col(ColumnDef::new(Patient::BloodType).string_len(10).null()) + .col(ColumnDef::new(Patient::IdNumber).string_len(20).null()) + .col(ColumnDef::new(Patient::AllergyHistory).text().null()) + .col(ColumnDef::new(Patient::MedicalHistorySummary).text().null()) + .col(ColumnDef::new(Patient::EmergencyContactName).string_len(100).null()) + .col(ColumnDef::new(Patient::EmergencyContactPhone).string_len(20).null()) + .col( + ColumnDef::new(Patient::Status) + .string_len(20) + .not_null() + .default("active"), + ) + .col( + ColumnDef::new(Patient::VerificationStatus) + .string_len(20) + .not_null() + .default("pending"), + ) + .col(ColumnDef::new(Patient::Source).string_len(100).null()) + .col(ColumnDef::new(Patient::Notes).text().null()) + .col( + ColumnDef::new(Patient::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Patient::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Patient::CreatedBy).uuid().null()) + .col(ColumnDef::new(Patient::UpdatedBy).uuid().null()) + .col(ColumnDef::new(Patient::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(Patient::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_patient_tenant_name") + .table(Patient::Table) + .col(Patient::TenantId) + .col(Patient::Name) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_patient_tenant_status") + .table(Patient::Table) + .col(Patient::TenantId) + .col(Patient::Status) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_patient_tenant_id_number") + .table(Patient::Table) + .col(Patient::TenantId) + .col(Patient::IdNumber) + .to_owned(), + ) + .await?; + + // 2. patient_family_member — 家庭成员 + manager + .create_table( + Table::create() + .table(PatientFamilyMember::Table) + .if_not_exists() + .col( + ColumnDef::new(PatientFamilyMember::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(PatientFamilyMember::TenantId).uuid().not_null()) + .col(ColumnDef::new(PatientFamilyMember::PatientId).uuid().not_null()) + .col(ColumnDef::new(PatientFamilyMember::Name).string_len(100).not_null()) + .col(ColumnDef::new(PatientFamilyMember::Relationship).string_len(50).not_null()) + .col(ColumnDef::new(PatientFamilyMember::Phone).string_len(20).null()) + .col(ColumnDef::new(PatientFamilyMember::BirthDate).date().null()) + .col(ColumnDef::new(PatientFamilyMember::Notes).text().null()) + .col( + ColumnDef::new(PatientFamilyMember::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(PatientFamilyMember::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(PatientFamilyMember::CreatedBy).uuid().null()) + .col(ColumnDef::new(PatientFamilyMember::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(PatientFamilyMember::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(PatientFamilyMember::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(PatientFamilyMember::Table, PatientFamilyMember::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // 3. patient_tag — 患者标签 + manager + .create_table( + Table::create() + .table(PatientTag::Table) + .if_not_exists() + .col(ColumnDef::new(PatientTag::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(PatientTag::TenantId).uuid().not_null()) + .col(ColumnDef::new(PatientTag::Name).string_len(50).not_null()) + .col(ColumnDef::new(PatientTag::Color).string_len(20).null()) + .col(ColumnDef::new(PatientTag::Description).text().null()) + .col( + ColumnDef::new(PatientTag::IsSystem) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(PatientTag::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(PatientTag::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(PatientTag::CreatedBy).uuid().null()) + .col(ColumnDef::new(PatientTag::UpdatedBy).uuid().null()) + .col(ColumnDef::new(PatientTag::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(PatientTag::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + // 4. patient_tag_relation — 患者-标签关联 + manager + .create_table( + Table::create() + .table(PatientTagRelation::Table) + .if_not_exists() + .col( + ColumnDef::new(PatientTagRelation::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(PatientTagRelation::TenantId).uuid().not_null()) + .col(ColumnDef::new(PatientTagRelation::PatientId).uuid().not_null()) + .col(ColumnDef::new(PatientTagRelation::TagId).uuid().not_null()) + .col( + ColumnDef::new(PatientTagRelation::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(PatientTagRelation::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(PatientTagRelation::CreatedBy).uuid().null()) + .col(ColumnDef::new(PatientTagRelation::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(PatientTagRelation::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .foreign_key( + ForeignKey::create() + .from(PatientTagRelation::Table, PatientTagRelation::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(PatientTagRelation::Table, PatientTagRelation::TagId) + .to(PatientTag::Table, PatientTag::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_patient_tag_rel_tenant_patient") + .table(PatientTagRelation::Table) + .col(PatientTagRelation::TenantId) + .col(PatientTagRelation::PatientId) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_patient_tag_rel_tenant_tag") + .table(PatientTagRelation::Table) + .col(PatientTagRelation::TenantId) + .col(PatientTagRelation::TagId) + .to_owned(), + ) + .await?; + + // 5. doctor_profile — 医护档案 + manager + .create_table( + Table::create() + .table(DoctorProfile::Table) + .if_not_exists() + .col(ColumnDef::new(DoctorProfile::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(DoctorProfile::TenantId).uuid().not_null()) + .col(ColumnDef::new(DoctorProfile::UserId).uuid().null()) + .col(ColumnDef::new(DoctorProfile::Department).string_len(100).null()) + .col(ColumnDef::new(DoctorProfile::Title).string_len(50).null()) + .col(ColumnDef::new(DoctorProfile::Specialty).string_len(200).null()) + .col(ColumnDef::new(DoctorProfile::LicenseNumber).string_len(50).null()) + .col(ColumnDef::new(DoctorProfile::Bio).text().null()) + .col( + ColumnDef::new(DoctorProfile::OnlineStatus) + .string_len(20) + .not_null() + .default("offline"), + ) + .col( + ColumnDef::new(DoctorProfile::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(DoctorProfile::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(DoctorProfile::CreatedBy).uuid().null()) + .col(ColumnDef::new(DoctorProfile::UpdatedBy).uuid().null()) + .col(ColumnDef::new(DoctorProfile::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(DoctorProfile::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + // 6. patient_doctor_relation — 医患关系 + manager + .create_table( + Table::create() + .table(PatientDoctorRelation::Table) + .if_not_exists() + .col( + ColumnDef::new(PatientDoctorRelation::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(PatientDoctorRelation::TenantId).uuid().not_null()) + .col(ColumnDef::new(PatientDoctorRelation::PatientId).uuid().not_null()) + .col(ColumnDef::new(PatientDoctorRelation::DoctorId).uuid().not_null()) + .col( + ColumnDef::new(PatientDoctorRelation::RelationshipType) + .string_len(20) + .not_null() + .default("primary"), + ) + .col( + ColumnDef::new(PatientDoctorRelation::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(PatientDoctorRelation::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(PatientDoctorRelation::CreatedBy).uuid().null()) + .col(ColumnDef::new(PatientDoctorRelation::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(PatientDoctorRelation::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .foreign_key( + ForeignKey::create() + .from(PatientDoctorRelation::Table, PatientDoctorRelation::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(PatientDoctorRelation::Table, PatientDoctorRelation::DoctorId) + .to(DoctorProfile::Table, DoctorProfile::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_patient_doctor_rel_patient") + .table(PatientDoctorRelation::Table) + .col(PatientDoctorRelation::TenantId) + .col(PatientDoctorRelation::PatientId) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_patient_doctor_rel_doctor") + .table(PatientDoctorRelation::Table) + .col(PatientDoctorRelation::TenantId) + .col(PatientDoctorRelation::DoctorId) + .to_owned(), + ) + .await?; + + // 7. health_record — 体检/就诊记录 + manager + .create_table( + Table::create() + .table(HealthRecord::Table) + .if_not_exists() + .col(ColumnDef::new(HealthRecord::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(HealthRecord::TenantId).uuid().not_null()) + .col(ColumnDef::new(HealthRecord::PatientId).uuid().not_null()) + .col( + ColumnDef::new(HealthRecord::RecordType) + .string_len(20) + .not_null() + .default("checkup"), + ) + .col(ColumnDef::new(HealthRecord::RecordDate).date().not_null()) + .col(ColumnDef::new(HealthRecord::Source).string_len(200).null()) + .col(ColumnDef::new(HealthRecord::OverallAssessment).text().null()) + .col(ColumnDef::new(HealthRecord::ReportFileUrl).string_len(500).null()) + .col(ColumnDef::new(HealthRecord::Notes).text().null()) + .col( + ColumnDef::new(HealthRecord::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(HealthRecord::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(HealthRecord::CreatedBy).uuid().null()) + .col(ColumnDef::new(HealthRecord::UpdatedBy).uuid().null()) + .col(ColumnDef::new(HealthRecord::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(HealthRecord::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(HealthRecord::Table, HealthRecord::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_health_record_tenant_patient_date") + .table(HealthRecord::Table) + .col(HealthRecord::TenantId) + .col(HealthRecord::PatientId) + .col(HealthRecord::RecordDate) + .to_owned(), + ) + .await?; + + // 8. vital_signs — 日常监测数据 + manager + .create_table( + Table::create() + .table(VitalSigns::Table) + .if_not_exists() + .col(ColumnDef::new(VitalSigns::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(VitalSigns::TenantId).uuid().not_null()) + .col(ColumnDef::new(VitalSigns::PatientId).uuid().not_null()) + .col(ColumnDef::new(VitalSigns::RecordDate).date().not_null()) + .col(ColumnDef::new(VitalSigns::SystolicBpMorning).integer().null()) + .col(ColumnDef::new(VitalSigns::DiastolicBpMorning).integer().null()) + .col(ColumnDef::new(VitalSigns::SystolicBpEvening).integer().null()) + .col(ColumnDef::new(VitalSigns::DiastolicBpEvening).integer().null()) + .col(ColumnDef::new(VitalSigns::HeartRate).integer().null()) + .col(ColumnDef::new(VitalSigns::Weight).decimal_len(5, 1).null()) + .col(ColumnDef::new(VitalSigns::BloodSugar).decimal_len(5, 1).null()) + .col(ColumnDef::new(VitalSigns::WaterIntakeMl).integer().null()) + .col(ColumnDef::new(VitalSigns::UrineOutputMl).integer().null()) + .col(ColumnDef::new(VitalSigns::Notes).text().null()) + .col( + ColumnDef::new(VitalSigns::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(VitalSigns::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(VitalSigns::CreatedBy).uuid().null()) + .col(ColumnDef::new(VitalSigns::UpdatedBy).uuid().null()) + .col(ColumnDef::new(VitalSigns::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(VitalSigns::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(VitalSigns::Table, VitalSigns::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_vital_signs_tenant_patient_date") + .table(VitalSigns::Table) + .col(VitalSigns::TenantId) + .col(VitalSigns::PatientId) + .col(VitalSigns::RecordDate) + .to_owned(), + ) + .await?; + + // 9. lab_report — 化验报告 + manager + .create_table( + Table::create() + .table(LabReport::Table) + .if_not_exists() + .col(ColumnDef::new(LabReport::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(LabReport::TenantId).uuid().not_null()) + .col(ColumnDef::new(LabReport::PatientId).uuid().not_null()) + .col(ColumnDef::new(LabReport::ReportDate).date().not_null()) + .col(ColumnDef::new(LabReport::ReportType).string_len(50).not_null()) + .col(ColumnDef::new(LabReport::Indicators).json().null()) + .col(ColumnDef::new(LabReport::ImageUrls).json().null()) + .col(ColumnDef::new(LabReport::DoctorInterpretation).text().null()) + .col( + ColumnDef::new(LabReport::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(LabReport::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(LabReport::CreatedBy).uuid().null()) + .col(ColumnDef::new(LabReport::UpdatedBy).uuid().null()) + .col(ColumnDef::new(LabReport::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(LabReport::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(LabReport::Table, LabReport::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_lab_report_tenant_patient_date") + .table(LabReport::Table) + .col(LabReport::TenantId) + .col(LabReport::PatientId) + .col(LabReport::ReportDate) + .to_owned(), + ) + .await?; + + // 10. health_trend — 健康趋势报告 + manager + .create_table( + Table::create() + .table(HealthTrend::Table) + .if_not_exists() + .col(ColumnDef::new(HealthTrend::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(HealthTrend::TenantId).uuid().not_null()) + .col(ColumnDef::new(HealthTrend::PatientId).uuid().not_null()) + .col(ColumnDef::new(HealthTrend::PeriodStart).date().not_null()) + .col(ColumnDef::new(HealthTrend::PeriodEnd).date().not_null()) + .col(ColumnDef::new(HealthTrend::IndicatorSummary).json().null()) + .col(ColumnDef::new(HealthTrend::AbnormalItems).json().null()) + .col( + ColumnDef::new(HealthTrend::GenerationType) + .string_len(20) + .not_null() + .default("auto"), + ) + .col(ColumnDef::new(HealthTrend::ReportFileUrl).string_len(500).null()) + .col( + ColumnDef::new(HealthTrend::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(HealthTrend::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(HealthTrend::CreatedBy).uuid().null()) + .col(ColumnDef::new(HealthTrend::UpdatedBy).uuid().null()) + .col(ColumnDef::new(HealthTrend::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(HealthTrend::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(HealthTrend::Table, HealthTrend::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + // 11. appointment — 预约记录 + manager + .create_table( + Table::create() + .table(Appointment::Table) + .if_not_exists() + .col(ColumnDef::new(Appointment::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Appointment::TenantId).uuid().not_null()) + .col(ColumnDef::new(Appointment::PatientId).uuid().not_null()) + .col(ColumnDef::new(Appointment::DoctorId).uuid().null()) + .col( + ColumnDef::new(Appointment::AppointmentType) + .string_len(20) + .not_null() + .default("outpatient"), + ) + .col(ColumnDef::new(Appointment::AppointmentDate).date().not_null()) + .col(ColumnDef::new(Appointment::StartTime).time().not_null()) + .col(ColumnDef::new(Appointment::EndTime).time().not_null()) + .col( + ColumnDef::new(Appointment::Status) + .string_len(20) + .not_null() + .default("pending"), + ) + .col(ColumnDef::new(Appointment::CancelReason).text().null()) + .col(ColumnDef::new(Appointment::Notes).text().null()) + .col( + ColumnDef::new(Appointment::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Appointment::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Appointment::CreatedBy).uuid().null()) + .col(ColumnDef::new(Appointment::UpdatedBy).uuid().null()) + .col(ColumnDef::new(Appointment::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(Appointment::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(Appointment::Table, Appointment::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(Appointment::Table, Appointment::DoctorId) + .to(DoctorProfile::Table, DoctorProfile::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_appointment_tenant_date_status") + .table(Appointment::Table) + .col(Appointment::TenantId) + .col(Appointment::AppointmentDate) + .col(Appointment::Status) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_appointment_tenant_doctor_date") + .table(Appointment::Table) + .col(Appointment::TenantId) + .col(Appointment::DoctorId) + .col(Appointment::AppointmentDate) + .to_owned(), + ) + .await?; + + // 12. doctor_schedule — 医生排班 + manager + .create_table( + Table::create() + .table(DoctorSchedule::Table) + .if_not_exists() + .col(ColumnDef::new(DoctorSchedule::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(DoctorSchedule::TenantId).uuid().not_null()) + .col(ColumnDef::new(DoctorSchedule::DoctorId).uuid().not_null()) + .col(ColumnDef::new(DoctorSchedule::ScheduleDate).date().not_null()) + .col( + ColumnDef::new(DoctorSchedule::PeriodType) + .string_len(20) + .not_null() + .default("am"), + ) + .col(ColumnDef::new(DoctorSchedule::StartTime).time().not_null()) + .col(ColumnDef::new(DoctorSchedule::EndTime).time().not_null()) + .col(ColumnDef::new(DoctorSchedule::MaxAppointments).integer().not_null()) + .col( + ColumnDef::new(DoctorSchedule::CurrentAppointments) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(DoctorSchedule::Status) + .string_len(20) + .not_null() + .default("enabled"), + ) + .col( + ColumnDef::new(DoctorSchedule::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(DoctorSchedule::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(DoctorSchedule::CreatedBy).uuid().null()) + .col(ColumnDef::new(DoctorSchedule::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(DoctorSchedule::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(DoctorSchedule::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(DoctorSchedule::Table, DoctorSchedule::DoctorId) + .to(DoctorProfile::Table, DoctorProfile::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_doctor_schedule_tenant_doctor_date") + .table(DoctorSchedule::Table) + .col(DoctorSchedule::TenantId) + .col(DoctorSchedule::DoctorId) + .col(DoctorSchedule::ScheduleDate) + .to_owned(), + ) + .await?; + + // 13. follow_up_task — 随访任务 + manager + .create_table( + Table::create() + .table(FollowUpTask::Table) + .if_not_exists() + .col(ColumnDef::new(FollowUpTask::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(FollowUpTask::TenantId).uuid().not_null()) + .col(ColumnDef::new(FollowUpTask::PatientId).uuid().not_null()) + .col(ColumnDef::new(FollowUpTask::AssignedTo).uuid().null()) + .col( + ColumnDef::new(FollowUpTask::FollowUpType) + .string_len(20) + .not_null() + .default("phone"), + ) + .col(ColumnDef::new(FollowUpTask::PlannedDate).date().not_null()) + .col( + ColumnDef::new(FollowUpTask::Status) + .string_len(20) + .not_null() + .default("pending"), + ) + .col(ColumnDef::new(FollowUpTask::ContentTemplate).text().null()) + .col(ColumnDef::new(FollowUpTask::RelatedAppointmentId).uuid().null()) + .col( + ColumnDef::new(FollowUpTask::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(FollowUpTask::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(FollowUpTask::CreatedBy).uuid().null()) + .col(ColumnDef::new(FollowUpTask::UpdatedBy).uuid().null()) + .col(ColumnDef::new(FollowUpTask::DeletedAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(FollowUpTask::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(FollowUpTask::Table, FollowUpTask::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_follow_up_task_tenant_assigned_status") + .table(FollowUpTask::Table) + .col(FollowUpTask::TenantId) + .col(FollowUpTask::AssignedTo) + .col(FollowUpTask::Status) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_follow_up_task_tenant_date_status") + .table(FollowUpTask::Table) + .col(FollowUpTask::TenantId) + .col(FollowUpTask::PlannedDate) + .col(FollowUpTask::Status) + .to_owned(), + ) + .await?; + + // 14. follow_up_record — 随访记录 + manager + .create_table( + Table::create() + .table(FollowUpRecord::Table) + .if_not_exists() + .col(ColumnDef::new(FollowUpRecord::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(FollowUpRecord::TenantId).uuid().not_null()) + .col(ColumnDef::new(FollowUpRecord::TaskId).uuid().not_null()) + .col(ColumnDef::new(FollowUpRecord::ExecutedBy).uuid().null()) + .col(ColumnDef::new(FollowUpRecord::ExecutedDate).date().not_null()) + .col( + ColumnDef::new(FollowUpRecord::Result) + .string_len(20) + .not_null() + .default("followed_up"), + ) + .col(ColumnDef::new(FollowUpRecord::PatientCondition).text().null()) + .col(ColumnDef::new(FollowUpRecord::MedicalAdvice).text().null()) + .col(ColumnDef::new(FollowUpRecord::NextFollowUpDate).date().null()) + .col( + ColumnDef::new(FollowUpRecord::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(FollowUpRecord::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(FollowUpRecord::CreatedBy).uuid().null()) + .col(ColumnDef::new(FollowUpRecord::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(FollowUpRecord::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(FollowUpRecord::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(FollowUpRecord::Table, FollowUpRecord::TaskId) + .to(FollowUpTask::Table, FollowUpTask::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_follow_up_record_tenant_task") + .table(FollowUpRecord::Table) + .col(FollowUpRecord::TenantId) + .col(FollowUpRecord::TaskId) + .to_owned(), + ) + .await?; + + // 15. consultation_session — 咨询会话 + manager + .create_table( + Table::create() + .table(ConsultationSession::Table) + .if_not_exists() + .col( + ColumnDef::new(ConsultationSession::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(ConsultationSession::TenantId).uuid().not_null()) + .col(ColumnDef::new(ConsultationSession::PatientId).uuid().not_null()) + .col(ColumnDef::new(ConsultationSession::DoctorId).uuid().null()) + .col( + ColumnDef::new(ConsultationSession::Type) + .string_len(20) + .not_null() + .default("customer_service"), + ) + .col( + ColumnDef::new(ConsultationSession::Status) + .string_len(20) + .not_null() + .default("waiting"), + ) + .col(ColumnDef::new(ConsultationSession::LastMessageAt).timestamp_with_time_zone().null()) + .col( + ColumnDef::new(ConsultationSession::UnreadCountPatient) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(ConsultationSession::UnreadCountDoctor) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(ConsultationSession::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(ConsultationSession::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(ConsultationSession::CreatedBy).uuid().null()) + .col(ColumnDef::new(ConsultationSession::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(ConsultationSession::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(ConsultationSession::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(ConsultationSession::Table, ConsultationSession::PatientId) + .to(Patient::Table, Patient::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(ConsultationSession::Table, ConsultationSession::DoctorId) + .to(DoctorProfile::Table, DoctorProfile::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_consultation_session_tenant_doctor_status") + .table(ConsultationSession::Table) + .col(ConsultationSession::TenantId) + .col(ConsultationSession::DoctorId) + .col(ConsultationSession::Status) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_consultation_session_tenant_patient_status") + .table(ConsultationSession::Table) + .col(ConsultationSession::TenantId) + .col(ConsultationSession::PatientId) + .col(ConsultationSession::Status) + .to_owned(), + ) + .await?; + + // 16. consultation_message — 咨询消息 + manager + .create_table( + Table::create() + .table(ConsultationMessage::Table) + .if_not_exists() + .col( + ColumnDef::new(ConsultationMessage::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(ConsultationMessage::TenantId).uuid().not_null()) + .col(ColumnDef::new(ConsultationMessage::SessionId).uuid().not_null()) + .col(ColumnDef::new(ConsultationMessage::SenderId).uuid().not_null()) + .col( + ColumnDef::new(ConsultationMessage::SenderRole) + .string_len(20) + .not_null(), + ) + .col( + ColumnDef::new(ConsultationMessage::ContentType) + .string_len(20) + .not_null() + .default("text"), + ) + .col(ColumnDef::new(ConsultationMessage::Content).text().not_null()) + .col( + ColumnDef::new(ConsultationMessage::IsRead) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(ConsultationMessage::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(ConsultationMessage::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(ConsultationMessage::CreatedBy).uuid().null()) + .col(ColumnDef::new(ConsultationMessage::UpdatedBy).uuid().null()) + .col( + ColumnDef::new(ConsultationMessage::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(ConsultationMessage::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .from(ConsultationMessage::Table, ConsultationMessage::SessionId) + .to(ConsultationSession::Table, ConsultationSession::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_consultation_message_tenant_session_created") + .table(ConsultationMessage::Table) + .col(ConsultationMessage::TenantId) + .col(ConsultationMessage::SessionId) + .col(ConsultationMessage::CreatedAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager.drop_table(Table::drop().table(ConsultationMessage::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(ConsultationSession::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(FollowUpRecord::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(FollowUpTask::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(Appointment::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(DoctorSchedule::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(HealthTrend::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(LabReport::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(VitalSigns::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(HealthRecord::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(PatientDoctorRelation::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(PatientTagRelation::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(PatientTag::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(PatientFamilyMember::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(DoctorProfile::Table).to_owned()).await?; + manager.drop_table(Table::drop().table(Patient::Table).to_owned()).await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Patient { + Table, + Id, + TenantId, + UserId, + Name, + Gender, + BirthDate, + BloodType, + IdNumber, + AllergyHistory, + MedicalHistorySummary, + EmergencyContactName, + EmergencyContactPhone, + Status, + VerificationStatus, + Source, + Notes, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum PatientFamilyMember { + Table, + Id, + TenantId, + PatientId, + Name, + Relationship, + Phone, + BirthDate, + Notes, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum PatientTag { + Table, + Id, + TenantId, + Name, + Color, + Description, + IsSystem, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum PatientTagRelation { + Table, + Id, + TenantId, + PatientId, + TagId, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, +} + +#[derive(DeriveIden)] +enum DoctorProfile { + Table, + Id, + TenantId, + UserId, + Department, + Title, + Specialty, + LicenseNumber, + Bio, + OnlineStatus, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum PatientDoctorRelation { + Table, + Id, + TenantId, + PatientId, + DoctorId, + RelationshipType, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, +} + +#[derive(DeriveIden)] +enum HealthRecord { + Table, + Id, + TenantId, + PatientId, + RecordType, + RecordDate, + Source, + OverallAssessment, + ReportFileUrl, + Notes, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum VitalSigns { + Table, + Id, + TenantId, + PatientId, + RecordDate, + SystolicBpMorning, + DiastolicBpMorning, + SystolicBpEvening, + DiastolicBpEvening, + HeartRate, + Weight, + BloodSugar, + WaterIntakeMl, + UrineOutputMl, + Notes, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum LabReport { + Table, + Id, + TenantId, + PatientId, + ReportDate, + ReportType, + Indicators, + ImageUrls, + DoctorInterpretation, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum HealthTrend { + Table, + Id, + TenantId, + PatientId, + PeriodStart, + PeriodEnd, + IndicatorSummary, + AbnormalItems, + GenerationType, + ReportFileUrl, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum Appointment { + Table, + Id, + TenantId, + PatientId, + DoctorId, + AppointmentType, + AppointmentDate, + StartTime, + EndTime, + Status, + CancelReason, + Notes, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum DoctorSchedule { + Table, + Id, + TenantId, + DoctorId, + ScheduleDate, + PeriodType, + StartTime, + EndTime, + MaxAppointments, + CurrentAppointments, + Status, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum FollowUpTask { + Table, + Id, + TenantId, + PatientId, + AssignedTo, + FollowUpType, + PlannedDate, + Status, + ContentTemplate, + RelatedAppointmentId, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum FollowUpRecord { + Table, + Id, + TenantId, + TaskId, + ExecutedBy, + ExecutedDate, + Result, + PatientCondition, + MedicalAdvice, + NextFollowUpDate, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum ConsultationSession { + Table, + Id, + TenantId, + PatientId, + DoctorId, + Type, + Status, + LastMessageAt, + UnreadCountPatient, + UnreadCountDoctor, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum ConsultationMessage { + Table, + Id, + TenantId, + SessionId, + SenderId, + SenderRole, + ContentType, + Content, + IsRead, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 388b698..4ab5e3b 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -319,12 +319,21 @@ async fn main() -> anyhow::Result<()> { "Message module initialized" ); + // Initialize health module + let health_module = erp_health::HealthModule::new(); + tracing::info!( + module = health_module.name(), + version = health_module.version(), + "Health module initialized" + ); + // Initialize module registry and register modules let registry = ModuleRegistry::new() .register(auth_module) .register(config_module) .register(workflow_module) - .register(message_module); + .register(message_module) + .register(health_module); tracing::info!( module_count = registry.modules().len(), "Modules registered" @@ -431,6 +440,7 @@ async fn main() -> anyhow::Result<()> { .merge(erp_workflow::WorkflowModule::protected_routes()) .merge(erp_message::MessageModule::protected_routes()) .merge(erp_plugin::module::PluginModule::protected_routes()) + .merge(erp_health::HealthModule::protected_routes()) .merge(handlers::audit_log::audit_log_router()) .layer(axum::middleware::from_fn_with_state( state.clone(), diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs index 4352ae7..eeba090 100644 --- a/crates/erp-server/src/state.rs +++ b/crates/erp-server/src/state.rs @@ -96,3 +96,13 @@ impl FromRef for erp_plugin::state::PluginState { } } } + +/// Allow erp-health handlers to extract their required state. +impl FromRef for erp_health::HealthState { + fn from_ref(state: &AppState) -> Self { + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + } + } +} diff --git a/docs/superpowers/specs/2026-04-23-health-management-module-design.md b/docs/superpowers/specs/2026-04-23-health-management-module-design.md new file mode 100644 index 0000000..32242d9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-health-management-module-design.md @@ -0,0 +1,710 @@ +# 健康管理系统 — erp-health 模块设计规格 + +> **文档版本**: 1.0 +> **日期**: 2026-04-23 +> **状态**: 已确认 +> **范围**: V1 — 患者管理 + 健康数据 + 预约排班 + 随访管理 + 咨询管理 + +--- + +## 1. 项目背景 + +### 1.1 产品定位 + +构建一个面向体检中心/医疗机构的**综合型健康管理平台**,以体检中心为数据源,汇集不同情况的患者,提供全生命周期的健康管理服务。 + +本系统从 ERP 平台底座分叉独立,作为 **Health Management System (HMS)** 产品演进。ERP 底座提供身份权限、工作流、消息通知、系统配置等基础能力,`erp-health` 作为原生 Rust 模块承载所有医疗业务逻辑。 + +### 1.2 系统架构 + +``` +📱 患者端(微信小程序) ──┐ + ├──→ 🔀 API 网关 ──→ 🖥️ ERP 后端(HMS) +👨‍⚕️ 医护端(小程序/H5) ──┘ │ │ + │ ├── erp-auth(用户/角色/权限) + │ ├── erp-workflow(工作流引擎) + │ ├── erp-message(消息通知) + │ ├── erp-config(字典/配置) + │ └── erp-health(健康管理)★ 新增 + │ + └──→ 💾 PostgreSQL + Redis +``` + +**关键决策:** +- ERP 只负责 **PC 管理后台**功能 +- 小程序(患者端/医护端)作为**独立系统**开发 +- 数据共享通过 **API 网关**实现 +- 健康管理使用**原生 Rust 模块**(非 WASM 插件),获得完整的数据库访问和自定义 API 能力 + +### 1.3 为什么不用 WASM 插件 + +| 限制 | 影响 | +|------|------| +| 实体上限 20 个 | 综合健康平台轻松超过 | +| JSONB 存储 | 医疗数据需要强类型、索引、关联 | +| 无自定义 API | 趋势分析、统计报表需要专用端点 | +| 无文件上传 | 化验单、体检报告无法存储 | +| WASM 沙箱限制 | 无法引入加密、AI、外部 API | + +原生模块遵循现有模式(如 erp-auth、erp-workflow)。**注意:**`ErpModule` trait 没有 `register_routes` 方法。模块通过固有方法 `public_routes()` 和 `protected_routes()` 暴露路由,在 `erp-server` 的 `main.rs` 中通过 `.nest("/api/v1/health", HealthModule::protected_routes())` 集成。通过 EventBus 通信,未来可平滑拆分为独立微服务。 + +--- + +## 2. V1 功能范围 + +| 模块 | 功能 | 页面数 | +|------|------|--------| +| ① 患者与医护管理 | 患者档案、家庭成员、医护档案、患者标签 | 3 | +| ② 健康数据管理 | 体检记录、日常监测、化验报告、趋势分析 | 3 | +| ③ 预约与排班 | 预约管理、医生排班、日历视图 | 2 | +| ④ 随访管理 | 随访任务、随访记录台账 | 2 | +| ⑤ 咨询管理 | 会话管理、对话记录查看/导出 | 2 | +| ⑥ 医护管理 | 医护人员列表 | 1 | +| **合计** | | **13** | + +**V2 预留:** 积分商城、数据统计中心、内容管理增强。 + +--- + +## 3. 实体模型 + +### 3.1 设计原则 + +- 患者和医护的**账号**走 `erp-auth` 的 `users` 表,`erp-health` 只存医疗业务扩展字段 +- 通过 `user_id` 外键关联 `users` 表 +- 所有表含 `tenant_id`(多租户隔离)、`id`(UUIDv7)、`created_at`、`updated_at`、`created_by`、`updated_by`、`deleted_at`、`version` +- 多对多关系使用中间表 + +### 3.2 实体定义 + +#### ① 患者与医护管理 + +**patient — 患者档案** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | UUIDv7 | +| tenant_id | UUID NOT NULL | 租户 ID | +| user_id | UUID FK → users | 关联 erp-auth 账号 | +| name | VARCHAR(100) | 姓名 | +| gender | VARCHAR(10) | 性别 (male/female/other) | +| birth_date | DATE | 出生日期 | +| blood_type | VARCHAR(10) | 血型 (A/B/AB/O/RH-/RH+) | +| id_number | VARCHAR(20) | 身份证号 | +| allergy_history | TEXT | 过敏史 | +| medical_history_summary | TEXT | 病史摘要 | +| emergency_contact_name | VARCHAR(100) | 紧急联系人姓名 | +| emergency_contact_phone | VARCHAR(20) | 紧急联系人电话 | +| status | VARCHAR(20) | 状态 (active/inactive/deceased) | +| verification_status | VARCHAR(20) | 实名认证 (pending/verified/rejected) | +| source | VARCHAR(100) | 来源(体检中心名称) | +| notes | TEXT | 备注 | +| created_at, updated_at, created_by, updated_by, deleted_at, version | — | 标准字段 | + +索引:`(tenant_id, name)`, `(tenant_id, status)`, `(tenant_id, id_number) UNIQUE WHERE deleted_at IS NULL` + +**patient_family_member — 家庭成员** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | 患者关联 | +| name | VARCHAR(100) | 姓名 | +| relationship | VARCHAR(50) | 关系(父亲/母亲/配偶/子女等) | +| phone | VARCHAR(20) | 电话 | +| birth_date | DATE | 出生日期 | +| notes | TEXT | 备注 | +| 标准 ERP 字段 | — | | + +**doctor_profile — 医护档案** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| user_id | UUID FK → users | 关联 erp-auth 账号 | +| department | VARCHAR(100) | 科室 | +| title | VARCHAR(50) | 职称(主任医师/副主任医师/主治医师等) | +| specialty | VARCHAR(200) | 专长 | +| license_number | VARCHAR(50) | 执业证号 | +| bio | TEXT | 简介 | +| online_status | VARCHAR(20) | 在线状态 (online/offline/busy) | +| 标准 ERP 字段 | — | | + +索引:`(tenant_id, patient_id)` + +**patient_tag — 患者标签** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| name | VARCHAR(50) | 标签名 | +| color | VARCHAR(20) | 颜色值 | +| description | TEXT | 描述 | +| is_system | BOOLEAN | 系统标签(不可删除) | +| 标准 ERP 字段 | — | | + +索引:`UNIQUE (tenant_id, name) WHERE deleted_at IS NULL` + +**patient_tag_relation — 患者-标签关联** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | | +| tag_id | UUID FK → patient_tag | | +| created_at | TIMESTAMPTZ | | +| updated_at | TIMESTAMPTZ | | +| created_by | UUID | | +| updated_by | UUID | | +| deleted_at | TIMESTAMPTZ | 软删除 | + +索引:`(tenant_id, patient_id)`, `(tenant_id, tag_id)` + +**patient_doctor_relation — 医患关系** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | | +| doctor_id | UUID FK → doctor_profile | | +| relationship_type | VARCHAR(20) | 类型 (primary/consulting) | +| created_at | TIMESTAMPTZ | | +| updated_at | TIMESTAMPTZ | | +| created_by | UUID | | +| updated_by | UUID | | +| deleted_at | TIMESTAMPTZ | 软删除 | + +索引:`(tenant_id, patient_id)`, `(tenant_id, doctor_id)` + +#### ② 健康数据管理 + +**health_record — 体检/就诊记录** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | | +| record_type | VARCHAR(20) | 类型 (checkup/outpatient/inpatient) | +| record_date | DATE | 记录日期 | +| source | VARCHAR(200) | 来源(体检中心/医院名称) | +| overall_assessment | TEXT | 总体评估 | +| report_file_url | VARCHAR(500) | 报告文件 URL | +| notes | TEXT | 备注 | +| 标准 ERP 字段 | — | | + +索引:`(tenant_id, patient_id, record_date DESC)` + +**vital_signs — 日常监测数据** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | | +| record_date | DATE | 记录日期 | +| systolic_bp_morning | INTEGER | 晨起收缩压 | +| diastolic_bp_morning | INTEGER | 晨起舒张压 | +| systolic_bp_evening | INTEGER | 晚间收缩压 | +| diastolic_bp_evening | INTEGER | 晚间舒张压 | +| heart_rate | INTEGER | 心率 | +| weight | DECIMAL(5,1) | 体重 (kg) | +| blood_sugar | DECIMAL(5,1) | 血糖 (mmol/L) | +| water_intake_ml | INTEGER | 饮水量 (ml) | +| urine_output_ml | INTEGER | 尿量 (ml) | +| notes | TEXT | 备注 | +| 标准 ERP 字段 | — | | + +索引:`(tenant_id, patient_id, record_date DESC)` + +**lab_report — 化验报告** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | | +| report_date | DATE | 报告日期 | +| report_type | VARCHAR(50) | 报告类型(肾功能/血常规/尿常规等) | +| indicators | JSONB | 指标数据 [{name, value, unit, ref_range, is_abnormal}] | +| image_urls | JSONB | 图片 URLs [url1, url2, ...] | +| doctor_interpretation | TEXT | 医生解读 | +| 标准 ERP 字段 | — | | + +索引:`(tenant_id, patient_id, report_date DESC)`, GIN on `indicators`, `(tenant_id, report_type)` + +**health_trend — 健康趋势报告** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | | +| period_start | DATE | 周期开始 | +| period_end | DATE | 周期结束 | +| indicator_summary | JSONB | 指标摘要 | +| abnormal_items | JSONB | 异常项 | +| generation_type | VARCHAR(20) | 生成方式 (auto/manual) | +| report_file_url | VARCHAR(500) | 报告文件 URL | +| 标准 ERP 字段 | — | | + +索引:`(tenant_id, patient_id, period_start DESC)` + +#### ③ 预约排班 + +**appointment — 预约记录** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | | +| doctor_id | UUID FK → doctor_profile | | +| appointment_type | VARCHAR(20) | 类型 (dialysis/recheck/outpatient) | +| appointment_date | DATE | 预约日期 | +| start_time | TIME | 开始时间 | +| end_time | TIME | 结束时间 | +| status | VARCHAR(20) | 状态 (pending/confirmed/cancelled/completed/no_show) | +| cancel_reason | TEXT | 取消原因 | +| notes | TEXT | 备注 | +| 标准 ERP 字段 | — | | + +索引:`(tenant_id, appointment_date, status)`, `(tenant_id, doctor_id, appointment_date)` + +**doctor_schedule — 医生排班** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| doctor_id | UUID FK → doctor_profile | | +| schedule_date | DATE | 排班日期 | +| period_type | VARCHAR(20) | 时段 (am/pm/night/full_day) | +| start_time | TIME | 开始时间 | +| end_time | TIME | 结束时间 | +| max_appointments | INTEGER | 最大预约数 | +| current_appointments | INTEGER | 已预约数(默认 0) | +| status | VARCHAR(20) | 状态 (enabled/disabled) | +| 标准 ERP 字段 | — | + +索引:`(tenant_id, doctor_id, schedule_date)`, `UNIQUE (tenant_id, doctor_id, schedule_date, period_type) WHERE deleted_at IS NULL` + +**预约并发控制:** 创建预约时使用原子 CAS 操作 `UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *`,防止超额预约。 + +#### ④ 随访管理 + +**follow_up_task — 随访任务** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | | +| assigned_to | UUID FK → users | 负责医护 | +| follow_up_type | VARCHAR(20) | 类型 (phone/face_to_face/online) | +| planned_date | DATE | 计划日期 | +| status | VARCHAR(20) | 状态 (pending/in_progress/completed/overdue/cancelled) | +| content_template | TEXT | 随访内容模板 | +| related_appointment_id | UUID FK → appointment | 关联预约 | +| 标准 ERP 字段 | — | | + +索引:`(tenant_id, assigned_to, status)`, `(tenant_id, planned_date, status)` + +**follow_up_record — 随访记录** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| task_id | UUID FK → follow_up_task | | +| executed_by | UUID FK → users | 执行医护 | +| executed_date | DATE | 执行日期 | +| result | VARCHAR(20) | 结果 (followed_up/unreachable/refused/other) | +| patient_condition | TEXT | 患者状况 | +| medical_advice | TEXT | 医嘱建议 | +| next_follow_up_date | DATE | 下次随访日期 | +| 标准 ERP 字段 | — | | + +索引:`(tenant_id, task_id)`, `(tenant_id, executed_date)` + +#### ⑤ 咨询管理 + +**consultation_session — 咨询会话** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| patient_id | UUID FK → patient | | +| doctor_id | UUID FK → doctor_profile | | +| type | VARCHAR(20) | 类型 (customer_service/doctor) | +| status | VARCHAR(20) | 状态 (waiting/active/closed) | +| last_message_at | TIMESTAMPTZ | 最后消息时间 | +| unread_count_patient | INTEGER | 患者未读数 | +| unread_count_doctor | INTEGER | 医生未读数 | +| 标准 ERP 字段 | — | | + +索引:`(tenant_id, doctor_id, status)`, `(tenant_id, patient_id, status)` + +**consultation_message — 咨询消息** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| tenant_id | UUID NOT NULL | | +| session_id | UUID FK → consultation_session | | +| sender_id | UUID | 发送者 ID | +| sender_role | VARCHAR(20) | 角色 (patient/doctor/system) | +| content_type | VARCHAR(20) | 类型 (text/image/voice/file) | +| content | TEXT | 内容 | +| is_read | BOOLEAN | 已读状态(默认 false) | +| created_at | TIMESTAMPTZ | 发送时间 | +| updated_at | TIMESTAMPTZ | | +| created_by | UUID | | +| updated_by | UUID | | +| deleted_at | TIMESTAMPTZ | 软删除(内容审核用) | +| version | INT NOT NULL DEFAULT 1 | 乐观锁 | + +索引:`(tenant_id, session_id, created_at)` + +**数据增长策略:** 对 `created_at` 按月分区(PostgreSQL table partitioning),超过 1 年的已关闭会话消息归档到冷存储。 + +**说明:** +- `patient.user_id` 允许 NULL — 患者可先创建档案(如体检中心导入),后续再绑定 erp-auth 账号 +- `consultation_message.sender_id` 引用 `users.id` — 统一使用 erp-auth 用户体系标识发送者 + +--- + +## 3.3 状态机定义 + +### appointment.status 转换 + +``` +pending ──→ confirmed ──→ completed + │ │ + │ └──→ no_show(预约时间过后,系统自动或前台手动触发) + │ + └──→ cancelled(任意时刻可取消,需填 cancel_reason) +``` + +### follow_up_task.status 转换 + +``` +pending ──→ in_progress ──→ completed + │ │ + └──→ cancelled └──→ overdue(系统定时任务:planned_date 已过且仍 pending 自动标记) +``` + +### consultation_session.status 转换 + +``` +waiting ──→ active(第一条消息发送时自动触发)──→ closed(手动关闭或超时自动关闭) +``` + +### patient.status 转换 + +``` +active ──→ inactive(手动停用) +active ──→ deceased(标记死亡,不可逆) +inactive ──→ active(重新激活) +``` + +### patient.verification_status 转换 + +``` +pending ──→ verified(实名认证通过) +pending ──→ rejected(认证被拒) +rejected ──→ pending(重新提交认证) +``` + +--- + +## 4. API 设计 + +所有端点前缀: `/api/v1/health/` + +### 4.1 患者管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/patients` | 患者列表(分页、搜索、标签筛选) | +| POST | `/patients` | 创建患者 | +| GET | `/patients/:id` | 患者详情 | +| PUT | `/patients/:id` | 更新患者 | +| DELETE | `/patients/:id` | 软删除 | +| POST | `/patients/:id/tags` | 管理标签(批量设置) | +| GET | `/patients/:id/health-summary` | 健康摘要 | +| GET | `/patients/:id/family-members` | 家庭成员列表 | +| POST | `/patients/:id/family-members` | 新增家庭成员 | +| PUT | `/patients/:id/family-members/:fid` | 更新家庭成员 | +| DELETE | `/patients/:id/family-members/:fid` | 删除家庭成员 | +| POST | `/patients/:id/doctors` | 分配主治医生 | +| DELETE | `/patients/:id/doctors/:did` | 移除医患关系 | + +### 4.2 健康数据 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/patients/:id/vital-signs` | 日常监测列表 | +| POST | `/patients/:id/vital-signs` | 新增监测数据 | +| GET | `/patients/:id/lab-reports` | 化验报告列表 | +| POST | `/patients/:id/lab-reports` | 新增化验报告 | +| GET | `/patients/:id/health-records` | 体检/就诊记录 | +| POST | `/patients/:id/health-records` | 新增记录 | +| GET | `/patients/:id/trends` | 趋势报告 | +| POST | `/patients/:id/trends/generate` | 生成趋势报告 | +| GET | `/patients/:id/trends/:indicator` | 单指标时序数据 | +| PUT | `/patients/:id/vital-signs/:vid` | 更新监测数据 | +| DELETE | `/patients/:id/vital-signs/:vid` | 删除监测数据 | +| PUT | `/patients/:id/lab-reports/:rid` | 更新化验报告 | +| DELETE | `/patients/:id/lab-reports/:rid` | 删除化验报告 | +| PUT | `/patients/:id/health-records/:rid` | 更新体检记录 | +| DELETE | `/patients/:id/health-records/:rid` | 删除体检记录 | + +### 4.3 预约排班 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/appointments` | 预约列表 | +| POST | `/appointments` | 创建预约 | +| PUT | `/appointments/:id/status` | 更新状态 | +| GET | `/doctor-schedules` | 排班列表 | +| POST | `/doctor-schedules` | 创建排班 | +| PUT | `/doctor-schedules/:id` | 更新排班 | +| GET | `/doctor-schedules/calendar` | 日历视图 | + +### 4.4 随访管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/follow-up-tasks` | 任务列表 | +| POST | `/follow-up-tasks` | 创建任务 | +| PUT | `/follow-up-tasks/:id` | 更新任务 | +| DELETE | `/follow-up-tasks/:id` | 删除任务 | +| POST | `/follow-up-tasks/:id/records` | 填写随访记录 | +| GET | `/follow-up-records` | 随访台账 | + +### 4.5 咨询管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/consultation-sessions` | 会话列表 | +| GET | `/consultation-sessions/:id/messages` | 消息记录 | +| PUT | `/consultation-sessions/:id/close` | 关闭会话 | +| POST | `/consultation-messages` | 写入消息(API 网关用) | +| GET | `/consultation-sessions/export` | 导出 | + +### 4.6 医护管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/doctors` | 医护列表 | +| POST | `/doctors` | 创建医护档案 | +| GET | `/doctors/:id` | 医护详情 | +| PUT | `/doctors/:id` | 更新医护档案 | +| DELETE | `/doctors/:id` | 软删除医护档案 | + +--- + +## 5. 前端页面设计 + +文件位置: `apps/web/src/pages/health/` + +### 5.1 页面清单 + +| # | 页面 | 文件名 | 类型 | +|---|------|--------|------| +| 1 | 患者列表 | PatientList.tsx | 表格+搜索+标签筛选+导出 | +| 2 | 患者详情 | PatientDetail.tsx | Tab布局:基本信息/健康趋势/化验报告/就诊记录/随访记录 | +| 3 | 标签管理 | PatientTagManage.tsx | CRUD+颜色+批量打标 | +| 4 | 日常监测 | VitalSignsList.tsx | 按患者+日期+ECharts趋势折线图 | +| 5 | 化验报告 | LabReportList.tsx | 列表+图片预览+指标详情+解读 | +| 6 | 体检记录 | HealthRecordList.tsx | 类型筛选+报告文件查看/上传 | +| 7 | 预约管理 | AppointmentList.tsx | 列表/日历切换+状态流转 | +| 8 | 排班管理 | DoctorSchedule.tsx | 周/月日历+排班模板 | +| 9 | 随访任务 | FollowUpTaskList.tsx | 任务CRUD+分配+关联工作流 | +| 10 | 随访台账 | FollowUpRecordList.tsx | 按患者/医护/日期筛选+导出 | +| 11 | 会话管理 | ConsultationList.tsx | 列表+未回复统计 | +| 12 | 对话记录 | ConsultationDetail.tsx | 聊天气泡+图片/语音查看+导出 | +| 13 | 医护列表 | DoctorList.tsx | 列表+科室筛选+在线状态 | + +### 5.2 技术要点 + +- **ECharts 趋势图** — 血压/体重/血糖曲线图,按日期范围展示 +- **文件上传/预览** — 化验单图片、体检报告 PDF(需新增基础能力) +- **日历组件** — Ant Design Calendar 用于排班和预约视图 +- **聊天 UI** — 消息气泡展示(只读,非实时聊天) +- **导出** — 随访台账、咨询记录导出为 Excel + +--- + +## 6. 事件集成 + +### 6.1 发布事件 + +| 事件类型 | 触发时机 | 载荷 | +|----------|----------|------| +| `patient.created` | 创建患者 | `{patient_id, name, tenant_id}` | +| `patient.updated` | 更新患者信息 | `{patient_id, changed_fields}` | +| `appointment.created` | 创建预约 | `{appointment_id, patient_id, doctor_id, date}` | +| `appointment.confirmed` | 确认预约 | `{appointment_id}` | +| `appointment.cancelled` | 取消预约 | `{appointment_id, cancel_reason}` | +| `appointment.completed` | 完成就诊 | `{appointment_id}` | +| `follow_up.created` | 创建随访任务 | `{task_id, patient_id, assigned_to, planned_date}` | +| `follow_up.completed` | 完成随访 | `{task_id, record_id, result}` | +| `lab_report.uploaded` | 上传化验报告 | `{report_id, patient_id, report_type, abnormal_count}` | +| `consultation.opened` | 开启咨询 | `{session_id, patient_id, doctor_id}` | +| `consultation.closed` | 关闭咨询 | `{session_id}` | +| `patient.deceased` | 患者死亡标记 | `{patient_id}` | +| `patient.verified` | 实名认证通过 | `{patient_id, id_number}` | +| `follow_up.overdue` | 随访任务逾期 | `{task_id, patient_id, planned_date}` | +| `doctor.online_status_changed` | 医护在线状态变更 | `{doctor_id, old_status, new_status}` | + +**随访记录自动创建后续任务:** 当 `follow_up_record.next_follow_up_date` 不为空时,服务层自动创建新的 `follow_up_task`(planned_date = next_follow_up_date,assigned_to 沿用当前医护)。 + +### 6.2 订阅事件 + +| 事件类型 | 处理逻辑 | +|----------|----------| +| `workflow.task.completed` | 工作流任务完成时更新随访任务状态 | +| `message.sent` | 消息发送时联动咨询会话的 last_message_at | + +--- + +## 7. 模块结构 + +``` +crates/erp-health/ +├── Cargo.toml +├── src/ +│ ├── lib.rs ← ErpModule trait + public_routes() / protected_routes() +│ ├── error.rs ← HealthError → AppError +│ ├── state.rs ← HealthState (共享状态) +│ ├── entity/ ← SeaORM Entity +│ │ ├── mod.rs +│ │ ├── patient.rs +│ │ ├── patient_family_member.rs +│ │ ├── patient_tag.rs +│ │ ├── patient_tag_relation.rs +│ │ ├── patient_doctor_relation.rs +│ │ ├── doctor_profile.rs +│ │ ├── health_record.rs +│ │ ├── vital_signs.rs +│ │ ├── lab_report.rs +│ │ ├── health_trend.rs +│ │ ├── appointment.rs +│ │ ├── doctor_schedule.rs +│ │ ├── follow_up_task.rs +│ │ ├── follow_up_record.rs +│ │ ├── consultation_session.rs +│ │ └── consultation_message.rs +│ ├── service/ ← 业务逻辑 +│ │ ├── mod.rs +│ │ ├── patient_service.rs +│ │ ├── health_data_service.rs +│ │ ├── appointment_service.rs +│ │ ├── follow_up_service.rs +│ │ └── consultation_service.rs +│ ├── handler/ ← Axum 路由 +│ │ ├── mod.rs +│ │ ├── patient_handler.rs +│ │ ├── health_data_handler.rs +│ │ ├── appointment_handler.rs +│ │ ├── follow_up_handler.rs +│ │ └── consultation_handler.rs +│ ├── dto/ ← 请求/响应结构体 +│ │ ├── mod.rs +│ │ ├── patient_dto.rs +│ │ ├── health_data_dto.rs +│ │ ├── appointment_dto.rs +│ │ ├── follow_up_dto.rs +│ │ └── consultation_dto.rs +│ └── event.rs ← 事件定义和处理器 +``` + +--- + +## 8. 权限定义 + +### 8.1 权限码 + +| 权限码 | 名称 | 说明 | +|--------|------|------| +| `health.patient.list` | 查看患者列表 | 查看和搜索患者列表、详情 | +| `health.patient.manage` | 管理患者 | 创建、编辑、删除患者 | +| `health.health-data.list` | 查看健康数据 | 查看体检记录、监测数据、化验报告 | +| `health.health-data.manage` | 管理健康数据 | 录入、编辑、删除健康数据 | +| `health.appointment.list` | 查看预约 | 查看预约列表和排班 | +| `health.appointment.manage` | 管理预约 | 创建、确认、取消预约 | +| `health.follow-up.list` | 查看随访 | 查看随访任务和记录 | +| `health.follow-up.manage` | 管理随访 | 创建、分配、完成随访任务 | +| `health.consultation.list` | 查看咨询 | 查看咨询会话和消息记录 | +| `health.consultation.manage` | 管理咨询 | 关闭会话、导出记录 | +| `health.doctor.list` | 查看医护 | 查看医护列表和详情 | +| `health.doctor.manage` | 管理医护 | 创建、编辑医护档案、排班 | + +### 8.2 数据范围 + +| 实体 | 支持的数据范围级别 | 说明 | +|------|-------------------|------| +| patient | self, department, department_tree, all | 医生只能看自己负责的患者或本科室患者 | +| follow_up_task | self, department, department_tree, all | 医护只能看分配给自己的随访任务 | +| appointment | self, department, department_tree, all | 按科室隔离预约数据 | + +### 8.3 角色模板 + +| 角色 | 权限 | +|------|------| +| health_admin | 全部 health.* 权限 | +| doctor | health.patient.list, health.health-data.*, health.appointment.list, health.follow-up.*, health.consultation.list, health.doctor.list | +| nurse | health.patient.list, health.health-data.*, health.follow-up.*, health.appointment.list | +| receptionist | health.patient.*, health.appointment.*, health.doctor.list | + +--- + +## 9. 能力扩展 + +V1 需要新增以下基础能力(在 erp-core 或独立模块中): + +1. **文件上传服务** — 文件存储(本地/OSS)、URL 生成、图片缩略图 +2. **趋势分析** — 时序数据聚合、异常检测逻辑 +3. **报告批注** — 医生对化验报告的解读/批注能力 +4. **导出增强** — 健康数据导出为 Excel/PDF + +--- + +## 10. 实施步骤 + +### Phase 1: 项目初始化 +- 拷贝 ERP 到 hms +- 验证编译和构建 + +### Phase 2: erp-health 骨架 +- 创建 crate 结构 +- 实现 ErpModule trait + `public_routes()` / `protected_routes()` 固有方法 +- 注册到 workspace + +### Phase 3: 数据库迁移 +- 16 张表(14 业务实体 + 2 关联表)的迁移文件 +- 索引创建、唯一约束 + +### Phase 4: 业务逻辑(按域迭代) +- ① 患者与医护管理 +- ② 健康数据管理 +- ③ 预约排班 +- ④ 随访管理 +- ⑤ 咨询管理 + +### Phase 5: 前端页面 +- 13 个自定义 React 页面 +- 路由注册和侧边栏菜单 + +### Phase 6: 集成测试 +- API 端点测试 +- 多租户隔离验证 +- 端到端功能验证 diff --git a/wiki/architecture.md b/wiki/architecture.md index b7d4791..e279a1f 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -1,125 +1,102 @@ -# architecture (架构决策记录) +--- +title: 架构决策记录 +updated: 2026-04-23 +status: stable +tags: [architecture, decisions, design-principles] +--- -## 设计思想 +# 架构决策记录 -ERP 平台采用 **模块化单体 + 渐进式拆分** 架构。核心原则:模块间零直接依赖,所有跨模块通信通过事件总线和 trait 接口。 +> 从 [[index]] 导航。关联: [[erp-core]] [[erp-server]] [[database]] [[wasm-plugin]] [[erp-health]] -## 关键架构决策 +## 1. 设计决策 -### Q: 为什么用模块化单体而非微服务? +### 模块化单体 + 渐进式拆分 -**A:** ERP 系统的模块间数据一致性要求高,分布式事务成本大。单体起步,模块边界清晰,未来需要时可按模块拆分为微服务。`ErpModule` trait 天然支持这种渐进式迁移。 +模块间零直接依赖,跨模块通信通过事件总线和 trait 接口。`ErpModule` trait 天然支持未来按模块拆分为微服务。 -### Q: 为什么用 UUIDv7 而不是自增 ID? +### HMS 架构:原生模块 + 插件并存 -**A:** UUIDv7 是时间排序的,既有 UUID 的分布式唯一性,又有接近自增 ID 的索引性能。对多租户 SaaS 尤其重要——不同租户的数据不会因为 ID 冲突而互相影响。 +HMS 继承 ERP 底座的所有基础模块,`erp-health` 作为原生 Rust 模块承载医疗业务。WASM 插件系统保留但非 HMS 主要扩展方式。 -### Q: 为什么用 broadcast channel 做事件总线? +``` +HMS 平台 +├── 基础模块(继承 ERP): auth, config, workflow, message, plugin +├── 核心业务模块: erp-health(原生 Rust)★ +└── 可选插件: crm, inventory, freelance, itops(WASM) +``` -**A:** `tokio::sync::broadcast` 提供多消费者发布/订阅,语义匹配模块间事件通知。设计规格要求 outbox 模式(持久化到 domain_events 表),但当前 Phase 1 先用内存 broadcast,后续再补持久化。 +### 为什么 erp-health 用原生模块? -### Q: 为什么错误类型跨 crate 边界必须用 thiserror? +医疗业务需要 16+ 强类型实体、自定义 API(趋势分析/统计报表)、文件上传、未来 AI 集成。WASM 插件的 JSONB 动态存储和 20 实体上限无法满足。详见 [[erp-health]]。 -**A:** `anyhow` 的错误没有类型信息,无法在 API 层做精确的 HTTP 状态码映射。`thiserror` 定义明确的错误变体,`AppError` 可以精确映射到 400/401/403/404/409/500。crate 内部可以用 `anyhow` 简化,但对外必须转 `AppError`。 +### 为什么用 UUIDv7? -### Q: 为什么 tenant_id 不在 API 路径中? +时间排序 + UUID 唯一性 + 接近自增 ID 的索引性能。多租户 SaaS 下不同租户数据不会因 ID 冲突互相影响。 -**A:** 从 JWT token 中提取 tenant_id,通过中间件注入 `TenantContext`。这防止了: -- 用户手动修改 URL 访问其他租户数据 -- API 路径暴露租户信息 -- 开发者忘记检查租户权限 +### 为什么 tenant_id 不在 API 路径中? -管理员接口例外,可以通过路径指定 tenant_id。 +从 JWT 提取,中间件注入 `TenantContext`。防止:手动改 URL 越权 / API 暴露租户信息 / 忘记检查权限。管理员接口例外。 -### Q: 为什么前端用 HashRouter 而非 BrowserRouter? +### 为什么错误类型跨 crate 用 thiserror? -**A:** 部署时可能不在根路径下,HashRouter 不需要服务端配置 fallback 路由。对 SPA 来说更稳健。 +`anyhow` 无类型信息,无法精确映射 HTTP 状态码。`thiserror` → `AppError` → 400/401/403/404/409/500。 -## 模块依赖铁律 +## 2. 关键文件 + 数据流 + +### 模块依赖图 ``` erp-core (L1) - erp-common (L1) | - +--------------+--------------+--------------+ - | | | | - erp-auth erp-config erp-workflow erp-message (L2) - | | | | - +--------------+--------------+--------------+ + +--------------+--------------+--------------+-----------+ + | | | | | + erp-auth erp-config erp-workflow erp-message erp-health (L2) + | | | | | + +--------------+--------------+--------------+-----------+ | erp-server (L3: 唯一组装点) + | + erp-plugin (WASM 插件运行时) ``` -**禁止:** -- L2 模块之间直接依赖 -- L1 模块依赖任何业务模块 -- 绕过事件总线直接调用其他模块 +**禁止**: L2 间直接依赖 / L1 依赖业务模块 / 绕过事件总线 -## 多租户隔离策略 - -**当前策略:共享数据库 + tenant_id 列过滤** - -所有业务表包含 `tenant_id` 列,查询时通过中间件自动注入过滤条件。这是最简单的 SaaS 多租户方案,未来可扩展为: -- Schema 隔离 — 每个租户独立 schema -- 数据库隔离 — 每个租户独立数据库(私有化部署) - -`ErpModule::on_tenant_created()` 和 `on_tenant_deleted()` 钩子确保模块能在租户创建/删除时初始化/清理数据。 - -## 技术选型理由 +### 技术选型 | 选择 | 理由 | |------|------| -| Axum 0.8 | Tokio 团队维护,与 tower 生态无缝集成,类型安全路由 | -| SeaORM 1.1 | 异步、类型安全、Rust 原生 ORM,迁移工具完善 | -| PostgreSQL 16 | 企业级关系型数据库,JSON 支持好,扩展丰富 | -| Redis 7 | 高性能缓存,会话存储,限流 token bucket | -| React 19 + Ant Design 6 | 成熟的组件库,企业后台 UI 标配 | -| Zustand | 极简状态管理,无 boilerplate | -| utoipa | Rust 代码生成 OpenAPI 文档,零额外维护 | -| Wasmtime 43 | WASM 沙箱运行时,Component Model 支持,Fuel 资源限制 | +| Axum 0.8 | Tokio 团队维护,tower 生态,类型安全路由 | +| SeaORM 1.1 | 异步、类型安全、迁移工具完善 | +| PostgreSQL 18 | 企业级,JSON 支持,扩展丰富 | +| Redis 7 | 缓存 + 限流 token bucket | +| React 19 + Ant Design 6 | 企业后台 UI 标配 | +| Zustand 5 | 极简状态管理 | +| Wasmtime 43 | WASM 沙箱,Component Model,Fuel 限制 | -## 插件扩展架构 +### 集成契约 -### Q: 为什么用 WASM 而不是 Lua / gRPC / dylib? +| 方向 | 模块 | 触发时机 | +|------|------|---------| +| 定义 → | [[erp-core]] | 所有模块的 trait 和类型 | +| 组装 ← | [[erp-server]] | 模块注册和启动 | +| 扩展 ← | [[wasm-plugin]] | 插件通过 Host Bridge 桥接 | -**A:** +## 3. 代码逻辑 -| 方案 | 安全性 | 隔离性 | 性能 | 复杂度 | -|------|--------|--------|------|--------| -| **WASM** | 高(沙箱) | 进程内隔离 | 接近原生(JIT) | 中 | -| Lua 脚本 | 中 | 无隔离 | 快 | 低 | -| 进程外 gRPC | 高 | 进程级隔离 | 网络开销 | 高 | -| dylib | 低(直接内存) | 无隔离 | 原生 | 低 | +⚡ **不变量**: 模块间只通过 EventBus 和 trait 通信,无直接依赖 +⚡ **不变量**: 所有数据表必须含 `tenant_id`,查询自动过滤 +⚡ **不变量**: UUID v7 作为主键 +⚡ **不变量**: 软删除,不硬删除 +⚡ **不变量**: 所有 API 使用 `/api/v1/` 前缀 -WASM 在安全性和性能之间取得最佳平衡。Wasmtime v43 的 Component Model 提供了类型安全的 Host-Plugin 接口,Fuel 机制防止恶意/有缺陷的插件消耗过多资源。 +## 4. 活跃问题 + 陷阱 -### 插件架构拓扑 +⚠️ 当前共享数据库 + tenant_id 过滤,未来可扩展为 Schema 隔离或数据库隔离 +⚠️ EventBus 内存 broadcast 需 outbox 持久化保障(已通过后台任务实现) -``` -┌─────────────────────────────────────────────────┐ -│ erp-server │ -│ ┌──────────────┐ ┌──────────────────────────┐ │ -│ │ EventBus │ │ PluginRuntime (Wasmtime) │ │ -│ │ (broadcast) │ │ ┌──────┐ ┌──────┐ │ │ -│ └──────┬───────┘ │ │插件 A│ │插件 B│ │ │ -│ │ │ └──┬───┘ └──┬───┘ │ │ -│ │ │ │ Host API │ │ │ -│ │ │ ┌──┴────────┴──┐ │ │ -│ │ │ │ Host Bridge │ │ │ -│ │ │ └──┬───────────┘ │ │ -│ │ └─────┼────────────────────┘ │ -│ │ │ │ -│ ┌──────┴───────┐ ┌────┴─────┐ │ -│ │ DB (SeaORM) │ │ EventBus │ │ -│ └──────────────┘ └──────────┘ │ -└─────────────────────────────────────────────────┘ -``` +## 5. 变更记录 -插件通过 Host Bridge 调用系统功能(db_insert、event_publish 等),Host Bridge 自动注入多租户隔离(tenant_id)和权限检查。详见 [[wasm-plugin]]。 - -## 关联模块 - -- **[[erp-core]]** — 架构契约的定义者 -- **[[erp-server]]** — 架构的组装执行者 -- **[[database]]** — 多租户隔离的物理实现 -- **[[wasm-plugin]]** — 插件扩展架构的实现 +| 日期 | 变更 | +|------|------| +| 2026-04-23 | 重构为 5 节结构,删除 erp-common 引用,精简技术选型表 | diff --git a/wiki/database.md b/wiki/database.md index 94280fa..806faee 100644 --- a/wiki/database.md +++ b/wiki/database.md @@ -1,73 +1,92 @@ -# database (数据库迁移与模式) +--- +title: 数据库迁移与模式 +updated: 2026-04-23 +status: stable +tags: [database, seaorm, migration, multi-tenant] +--- -## 设计思想 +# 数据库迁移与模式 -数据库迁移使用 SeaORM Migration 框架,遵循以下原则: +> 从 [[index]] 导航。关联: [[erp-core]] [[erp-server]] [[infrastructure]] -- **所有表必须包含标准字段** — `id`(UUIDv7), `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version` -- **软删除** — 不执行硬删除,设置 `deleted_at` 时间戳 +## 1. 设计决策 + +- **SeaORM Migration** — 异步、类型安全、幂等(`if_not_exists`),每个迁移必须实现 `down()` 可回滚 +- **所有表必须含标准字段** — `id`(UUIDv7), `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version` +- **软删除** — 不硬删除,设置 `deleted_at` 时间戳 - **乐观锁** — 更新时检查 `version` 字段 -- **多租户隔离** — 所有业务表必须含 `tenant_id`,查询时自动过滤 -- **幂等迁移** — 使用 `if_not_exists` 确保可重复执行 -- **可回滚** — 每个迁移必须实现 `down()` 方法 +- **多租户** — 所有业务表含 `tenant_id`,中间件自动过滤 -## 代码逻辑 +## 2. 关键文件 + 数据流 + +### 核心文件 + +| 文件 | 职责 | +|------|------| +| `crates/erp-server/migration/src/lib.rs` | Migrator 注册所有迁移 | +| `crates/erp-server/migration/src/m*.rs` | 41 个迁移文件 | +| `crates/erp-core/src/types.rs` | BaseFields 标准字段定义 | + +### 迁移命名规则 -### 迁移文件命名规则 ``` m{YYYYMMDD}_{6位序号}_{描述}.rs 例: m20260410_000001_create_tenant.rs ``` -### 当前表结构 +### 当前表概览(30 张) -**tenant 表** (唯一已实现的表): -| 列名 | 类型 | 约束 | -|------|------|------| -| id | UUID | PK, NOT NULL | -| name | STRING | NOT NULL | -| code | STRING | NOT NULL, UNIQUE | -| status | STRING | NOT NULL, DEFAULT 'active' | -| settings | JSON | NULLABLE | -| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | -| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | -| deleted_at | TIMESTAMPTZ | NULLABLE | +| 模块 | 表 | +|------|-----| +| 基础 | tenant | +| 认证 (auth) | users, user_credentials, user_tokens, roles, permissions, role_permissions, user_roles, organizations, departments, positions, user_departments | +| 配置 (config) | dictionaries, dictionary_items, menus, menu_roles, settings, numbering_rules | +| 工作流 (workflow) | process_definitions, process_instances, tokens, tasks, process_variables | +| 消息 (message) | message_templates, messages, message_subscriptions | +| 审计 | audit_logs, domain_events | +| 插件 (plugin) | plugins, entity_registry, plugin_market, plugin_user_views | -### 已知缺失字段 -tenant 表缺少 `BaseFields` 要求的: -- `created_by` — 创建人 -- `updated_by` — 最后修改人 -- `version` — 乐观锁版本号 +### 集成契约 -### 迁移执行 -``` -erp-server 启动 → Migrator::up(&db_conn) → 自动运行所有 pending 迁移 -``` +| 方向 | 模块 | 触发时机 | +|------|------|---------| +| 消费 ← | [[erp-server]] | 启动时自动运行 `Migrator::up()` | +| 依赖 ← | [[erp-core]] | BaseFields 定义标准字段规范 | +| 提供 → | 所有业务模块 | 表结构供 SeaORM Entity 使用 | -## 关联模块 +## 3. 代码逻辑 -- **[[erp-core]]** — `BaseFields` 定义了标准字段规范,迁移表结构必须对齐 -- **[[erp-server]]** — 启动时自动运行迁移 -- **[[erp-auth]]** — Phase 2 将创建 users, roles, permissions 表 -- **[[erp-config]]** — Phase 3 将创建 system_configs 表 -- **[[erp-workflow]]** — Phase 4 将创建 workflow_definitions, workflow_instances 表 -- **[[erp-message]]** — Phase 5 将创建 messages, notification_settings 表 +⚡ **不变量**: 所有业务表必须含 `tenant_id` 列 — 多租户是核心能力,不可事后补 -## 关键文件 +⚡ **不变量**: 迁移必须幂等 — 使用 `if_not_exists`,可重复执行 -| 文件 | 职责 | +⚡ **不变量**: 迁移执行由 erp-server 启动自动触发,不手动执行 SQL + +### 关键结构变更迁移 + +| 迁移 | 变更 | |------|------| -| `crates/erp-server/migration/src/lib.rs` | Migrator 注册所有迁移 | -| `crates/erp-server/migration/src/m20260410_000001_create_tenant.rs` | tenant 表迁移 | -| `crates/erp-core/src/types.rs` | BaseFields 标准字段定义 | -| `docker/docker-compose.yml` | PostgreSQL 16 服务定义 | +| m000027 | 修复唯一索引 + 软删除冲突 | +| m000034 | 种子插件权限 | +| m000035 | pg_trgm 扩展 + entity 列 | +| m000036 | role_permissions 添加 data_scope(行级数据权限) | +| m000038 | 修复 CRM 权限码 | +| m000039 | entity_registry 列 | +| m000041 | plugin_user_views | -## 未来迁移计划 (按 Phase) +## 4. 活跃问题 + 陷阱 -| Phase | 表 | 说明 | -|-------|-----|------| -| Phase 2 | users, roles, permissions, user_roles, role_permissions | RBAC + ABAC | -| Phase 3 | system_configs, config_histories | 层级配置 | -| Phase 4 | workflow_definitions, workflow_instances, workflow_tasks | BPMN 工作流 | -| Phase 5 | messages, notification_settings, message_templates | 多渠道消息 | -| 持续 | domain_events | 事件 outbox 表 | +### 历史教训 + +- 唯一索引 + 软删除冲突 — 已删除记录的 unique key 阻止新建(m000027 修复) +- tenant 表缺少 `created_by`/`updated_by`/`version` 字段 — 首个迁移早于 BaseFields 规范 + +⚠️ settings 表的唯一索引曾需修复(m000032) +⚠️ 新增表时务必对齐 `crates/erp-core/src/types.rs` 中的 BaseFields + +## 5. 变更记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-23 | 重构为 5 节结构,更新表清单至 41 个迁移 | +| 2026-04-19 | CRM 权限码修复迁移 (m000038) | diff --git a/wiki/erp-core.md b/wiki/erp-core.md index f8b0e06..67fea2f 100644 --- a/wiki/erp-core.md +++ b/wiki/erp-core.md @@ -1,57 +1,27 @@ +--- +title: erp-core +updated: 2026-04-23 +status: stable +tags: [core, error, event-bus, module-trait, shared-types] +--- + # erp-core -## 设计思想 +> 从 [[index]] 导航。关联: [[erp-server]] [[database]] [[wasm-plugin]] [[architecture]] -`erp-core` 是整个 ERP 平台的 L1 基础层,所有业务模块的唯一共同依赖。它的职责是定义**跨模块共享的契约**,而非实现业务逻辑。 +## 1. 设计决策 -核心设计决策: -- **AppError 统一错误体系** — 6 种错误变体映射到 HTTP 状态码,业务 crate 只需 `?` 传播错误,由 Axum `IntoResponse` 自动转换 -- **EventBus 进程内广播** — 用 `tokio::sync::broadcast` 实现发布/订阅,模块间零耦合通信 -- **ErpModule 插件 trait** — 每个业务模块实现此 trait,由 `ModuleRegistry` 统一注册路由和事件处理器 -- **BaseFields 强制多租户** — 所有实体的基础字段模板,确保 `tenant_id` 从第一天就存在 +`erp-core` 是 L1 基础层,所有业务模块的唯一共同依赖。定义**跨模块共享的契约**,不含业务逻辑。 -## 代码逻辑 +核心决策: +- **AppError 统一错误体系** — 6 种变体映射 HTTP 状态码,`?` 传播 + Axum `IntoResponse` 自动转换 +- **EventBus 进程内广播** — `tokio::sync::broadcast` 实现零耦合通信 +- **ErpModule 插件 trait** — 统一注册路由和事件处理器 +- **BaseFields 强制多租户** — 所有实体基础字段模板 -### 错误处理链 -``` -业务 crate (thiserror) → AppError → IntoResponse → HTTP JSON 响应 -数据库 (sea_orm::DbErr) → From 转换 → AppError (自动识别 duplicate key → Conflict) -``` +## 2. 关键文件 + 数据流 -错误响应统一格式:`{ "error": "not_found", "message": "资源不存在", "details": null }` - -### 事件总线 -``` -发布者: EventBus::publish(DomainEvent) → broadcast channel -订阅者: EventBus::subscribe() → Receiver -事件字段: id(UUIDv7), event_type("user.created"), tenant_id, payload(JSON), timestamp, correlation_id -``` - -事件类型命名规则:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed` - -### 模块注册 -``` -业务模块实现 ErpModule trait → ModuleRegistry::register() → - build_router(): 折叠所有模块的 register_routes() → Axum Router - register_handlers(): 注册所有模块的事件处理器 → EventBus -``` - -### 共享类型 -- `TenantContext` — 中间件注入的租户上下文(tenant_id, user_id, roles, permissions) -- `Pagination` / `PaginatedResponse` — 分页查询标准化(每页上限 100) -- `ApiResponse` — API 统一信封 `{ success, data, message }` - -## 关联模块 - -- **[[erp-server]]** — 消费所有 erp-core 类型和 trait,是唯一组装点 -- **[[erp-auth]]** — 实现 `ErpModule` trait,发布认证事件 -- **[[erp-workflow]]** — 实现 `ErpModule` trait,订阅业务事件 -- **[[erp-message]]** — 实现 `ErpModule` trait,订阅通知事件 -- **[[erp-config]]** — 实现 `ErpModule` trait -- **[[database]]** — 迁移表结构必须与 `BaseFields` 对齐 -- **[[wasm-plugin]]** — WASM 插件通过 Host Bridge 桥接 EventBus - -## 关键文件 +### 核心文件 | 文件 | 职责 | |------|------| @@ -61,9 +31,63 @@ | `crates/erp-core/src/types.rs` | BaseFields、Pagination、ApiResponse、TenantContext | | `crates/erp-core/src/lib.rs` | 模块导出入口 | -## 当前状态 +### 集成契约 -**已实现,Phase 1 可用。** 但以下部分尚未被 erp-server 集成: -- `ModuleRegistry` 未在 `main.rs` 中使用 -- `EventBus` 未创建实例 -- `TenantContext` 未通过中间件注入 +| 方向 | 模块 | 接口 | 触发时机 | +|------|------|------|---------| +| 提供 → | erp-auth | ErpModule trait, AppError, EventBus | 模块实现 | +| 提供 → | erp-config | ErpModule trait, AppError | 模块实现 | +| 提供 → | erp-workflow | ErpModule trait, AppError, EventBus | 模块实现 | +| 提供 → | erp-message | ErpModule trait, AppError, EventBus | 模块实现 | +| 提供 → | erp-plugin | ErpModule trait, AppError, EventBus | 模块实现 | +| 消费 ← | [[erp-server]] | ModuleRegistry 组装 | 启动时 | +| 桥接 ← | [[wasm-plugin]] | EventBus → 插件 handle_event | 运行时 | + +## 3. 代码逻辑 + +### 错误处理链 + +``` +业务 crate (thiserror) → AppError → IntoResponse → HTTP JSON +数据库 (sea_orm::DbErr) → From 转换 → AppError (自动识别 duplicate key → Conflict) +``` + +响应格式:`{ "error": "not_found", "message": "资源不存在", "details": null }` + +### 事件总线 + +``` +EventBus::publish(DomainEvent) → broadcast channel → Receiver +事件字段: id(UUIDv7), event_type("user.created"), tenant_id, payload(JSON), timestamp +``` + +命名规则:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed` + +### 模块注册 + +``` +ErpModule trait → ModuleRegistry::register() → + build_router(): 折叠所有模块路由 → Axum Router + register_handlers(): 注册事件处理器 → EventBus +``` + +### 共享类型 + +- `TenantContext` — 租户上下文(tenant_id, user_id, roles, permissions, department_ids) +- `Pagination` / `PaginatedResponse` — 分页标准化(每页上限 100) +- `ApiResponse` — 统一信封 `{ success, data, message }` + +⚡ **不变量**: erp-core 不依赖任何业务 crate,只被依赖 +⚡ **不变量**: 所有 API 使用 `/api/v1/` 前缀 +⚡ **不变量**: tenant_id 从 JWT 中间件注入,应用层不可伪造 + +## 4. 活跃问题 + 陷阱 + +⚠️ crate 内部可用 `anyhow`,但跨 crate 边界必须转 `AppError` +⚠️ EventBus 当前为内存 broadcast,outbox 持久化通过后台任务实现 + +## 5. 变更记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-23 | 重构为 5 节结构,更新为已完全集成状态 | diff --git a/wiki/erp-health.md b/wiki/erp-health.md new file mode 100644 index 0000000..39c5004 --- /dev/null +++ b/wiki/erp-health.md @@ -0,0 +1,122 @@ +--- +title: erp-health 健康管理模块 +updated: 2026-04-23 +status: developing +tags: [health, patient, appointment, follow-up, consultation] +--- + +# erp-health 健康管理模块 + +> 从 [[index]] 导航。关联: [[erp-core]] [[erp-server]] [[database]] [[frontend]] +> +> 设计规格: `docs/superpowers/specs/2026-04-23-health-management-module-design.md` + +## 1. 设计决策 + +### 为什么用原生模块而非 WASM 插件? + +| WASM 插件限制 | 健康模块需求 | +|---------------|-------------| +| 实体上限 20 个 | 16+ 强类型医疗实体 | +| JSONB 动态存储 | 医疗数据需要强类型、索引、关联 | +| 无自定义 API | 趋势分析、统计报表需专用端点 | +| 无文件上传 | 化验单、体检报告需存储 | +| 沙箱限制 | 无法引入加密、AI、外部 API | + +### 为什么患者/医护账号走 erp-auth? + +复用现有用户体系(认证、JWT、权限),erp-health 只存医疗扩展字段。患者可先建档(体检中心导入),后续再绑定账号。 + +### 核心架构选择 + +- **原生 Rust crate** — 与 erp-auth、erp-workflow 同等地位,直接访问数据库 +- **固有方法暴露路由** — `public_routes()` / `protected_routes()`,在 erp-server 中 `.nest("/api/v1/health", ...)` +- **EventBus 通信** — 发布 `patient.created`、`appointment.confirmed` 等,订阅 `workflow.task.completed` + +## 2. 关键文件 + 数据流 + +### 目录结构 + +``` +crates/erp-health/ +├── src/ +│ ├── lib.rs ← ErpModule trait + routes() +│ ├── error.rs ← HealthError → AppError +│ ├── state.rs ← HealthState +│ ├── entity/ ← 16 个 SeaORM Entity +│ ├── service/ ← 5 个业务 service +│ ├── handler/ ← 5 个路由 handler +│ ├── dto/ ← 请求/响应结构体 +│ └── event.rs ← 事件定义和处理器 +``` + +### 实体模型(16 张表) + +| 域 | 实体 | +|----|------| +| 患者管理 | patient, patient_family_member, patient_tag, patient_tag_relation, patient_doctor_relation | +| 医护管理 | doctor_profile | +| 健康数据 | health_record, vital_signs, lab_report, health_trend | +| 预约排班 | appointment, doctor_schedule | +| 随访管理 | follow_up_task, follow_up_record | +| 咨询管理 | consultation_session, consultation_message | + +### 集成契约 + +| 方向 | 模块 | 接口 | 触发时机 | +|------|------|------|---------| +| 提供 → | [[erp-server]] | `protected_routes()` | 启动时注册 `/api/v1/health/*` | +| 调用 → | [[erp-core]] | EventBus | 发布/订阅领域事件 | +| 关联 → | erp-auth | `users` 表 (user_id FK) | 患者/医护关联账号 | +| 订阅 ← | erp-workflow | `workflow.task.completed` | 随访任务状态更新 | +| 订阅 ← | erp-message | `message.sent` | 咨询会话 last_message_at | + +## 3. 代码逻辑 + +### API 前缀: `/api/v1/health/` + +关键端点分组: +- `/patients` — 患者列表/详情/标签管理/健康摘要 +- `/patients/:id/vital-signs` — 日常监测数据(血压/心率/体重/血糖) +- `/patients/:id/lab-reports` — 化验报告(JSONB 指标数据) +- `/patients/:id/trends` — 健康趋势报告(自动/手动生成) +- `/appointments` — 预约管理(状态机: pending→confirmed→completed) +- `/doctor-schedules` — 排班管理(日历视图) +- `/follow-up-tasks` — 随访任务(逾期自动标记) +- `/consultation-sessions` — 咨询会话管理 + +### 预约并发控制 + +创建预约时使用原子 CAS:`UPDATE doctor_schedule SET current_appointments = current_appointments + 1 WHERE id = $1 AND current_appointments < max_appointments RETURNING *` + +### 随访自动链接 + +`follow_up_record.next_follow_up_date` 不为空时,自动创建新的 `follow_up_task`。 + +### 权限码 + +`health.patient.list/manage` · `health.health-data.list/manage` · `health.appointment.list/manage` · `health.follow-up.list/manage` · `health.consultation.list/manage` · `health.doctor.list/manage` + +⚡ **不变量**: 预约创建必须走原子 CAS,不能用 read-then-write +⚡ **不变量**: `patient.user_id` 允许 NULL(先建档后绑定) +⚡ **不变量**: `consultation_message` 对 `created_at` 按月分区,超 1 年归档 + +## 4. 活跃问题 + 陷阱 + +### 当前状态: 🔧 开发中 + +设计规格已确认,尚未开始编码。 + +### 待解决 + +| 问题 | 级别 | 说明 | +|------|------|------| +| 文件上传基础能力 | P1 | 化验单/体检报告需要文件存储服务 | +| ECharts 趋势图 | P1 | 前端健康趋势可视化 | +| 导出功能 | P2 | 随访台账/咨询记录导出 Excel | + +## 5. 变更记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-23 | 创建模块 wiki 页,设计规格确认 | diff --git a/wiki/erp-server.md b/wiki/erp-server.md index ef01870..60918b6 100644 --- a/wiki/erp-server.md +++ b/wiki/erp-server.md @@ -1,66 +1,110 @@ +--- +title: erp-server +updated: 2026-04-23 +status: stable +tags: [server, axum, assembly, entry-point] +--- + # erp-server -## 设计思想 +> 从 [[index]] 导航。关联: [[erp-core]] [[infrastructure]] [[database]] [[frontend]] -`erp-server` 是 L3 层——**唯一的组装点**。它不包含业务逻辑,只负责把所有业务模块组装成可运行的服务。 +## 1. 设计决策 -核心决策: -- **配置优先** — 使用 `config` crate 从 TOML 文件 + 环境变量加载,`ERP__` 前缀覆盖(如 `ERP__DATABASE__URL`) -- **启动序列严格有序** — 配置 → 日志 → 数据库 → 迁移 → Redis → 路由 → 监听,每步失败即终止 -- **单一入口** — 所有模块通过 `ModuleRegistry` 注册,server 本身不直接 import 业务模块的类型 +- **唯一组装点** — 不含业务逻辑,只负责把所有模块组装成可运行服务 +- **配置优先** — `config` crate 从 TOML + 环境变量加载,`ERP__` 前缀覆盖 +- **严格启动序列** — 每步失败即终止,不做部分启动 +- **安全检查** — 拒绝默认 JWT 密钥 / 数据库 URL -## 代码逻辑 +## 2. 关键文件 + 数据流 -### 启动流程 (`main.rs`) -``` -1. AppConfig::load() ← config/default.toml + 环境变量 -2. init_tracing(level) ← JSON 格式日志 -3. db::connect(&db_config) ← SeaORM 连接池 (max=20, min=5) -4. Migrator::up(&db_conn) ← 运行所有待执行迁移 -5. redis::Client::open(url) ← Redis 客户端(当前未使用) -6. Router::new() ← 当前仅有 404 fallback -7. bind(host, port).serve() ← 启动 HTTP 服务 -``` - -### 配置结构 -``` -AppConfig -├── server: ServerConfig { host: "0.0.0.0", port: 3000 } -├── database: DatabaseConfig { url, max_connections: 20, min_connections: 5 } -├── redis: RedisConfig { url: "redis://localhost:6379" } -├── jwt: JwtConfig { secret, access_token_ttl, refresh_token_ttl } -└── log: LogConfig { level: "info" } -``` - -### 当前状态 -- 数据库连接池正常工作 -- 迁移自动执行 -- **没有注册任何路由** — 仅返回 404 -- **没有使用 ModuleRegistry** — 未集成业务模块 -- Redis 客户端已创建但未执行任何命令 -- 缺少 CORS、压缩、请求追踪中间件 - -## 关联模块 - -- **[[erp-core]]** — 提供 AppError、ErpModule trait、ModuleRegistry -- **[[database]]** — 迁移文件通过 `erp-server-migration` crate 引用 -- **[[infrastructure]]** — Docker 提供 PostgreSQL 和 Redis 服务 -- **[[frontend]]** — Vite 代理 `/api` 请求到 server 的 3000 端口 - -## 关键文件 +### 核心文件 | 文件 | 职责 | |------|------| | `crates/erp-server/src/main.rs` | 服务启动入口 | +| `crates/erp-server/src/state.rs` | AppState 定义 | | `crates/erp-server/src/config.rs` | 5 个配置 struct + 加载逻辑 | | `crates/erp-server/src/db.rs` | SeaORM 连接池配置 | -| `crates/erp-server/config/default.toml` | 默认配置值 | -| `crates/erp-server/Cargo.toml` | 依赖声明 | +| `crates/erp-server/config/default.toml` | 默认配置(密钥为占位符) | -## 待完成 (Phase 1 剩余) +### 启动流程 -1. 实例化 `ModuleRegistry` 并注册模块 -2. 添加 CORS 中间件(tower-http) -3. 添加请求追踪中间件 -4. 将 Redis 连接注入 AppState -5. 健康检查端点 (`/api/v1/health`) +``` +AppConfig::load() → 安全检查 → init_tracing → db::connect → Migrator::up + → 种子数据(默认租户+管理员) → Redis客户端 → EventBus(容量1024) + → 注册5个模块 → 初始化插件引擎+恢复插件 → 4个后台任务 + → 构建Router → bind + serve → 优雅关闭(CTRL+C/SIGTERM) +``` + +### 注册的 5 个模块 + +AuthModule → ConfigModule → WorkflowModule → MessageModule → PluginModule + +### AppState + +``` +AppState { + db: DatabaseConnection, + config: AppConfig, + event_bus: EventBus, + module_registry: ModuleRegistry, + redis: redis::Client, + default_tenant_id: Uuid, + plugin_engine: PluginEngine, + plugin_entity_cache: moka::Cache (1000容量, 5分钟TTL), +} +``` + +### 集成契约 + +| 方向 | 模块 | 接口 | 触发时机 | +|------|------|------|---------| +| 组装 → | erp-auth | `AuthModule` | 启动时注册 | +| 组装 → | erp-config | `ConfigModule` | 启动时注册 | +| 组装 → | erp-workflow | `WorkflowModule` | 启动时注册 | +| 组装 → | erp-message | `MessageModule` | 启动时注册 | +| 组装 → | erp-plugin | `PluginModule` | 启动时注册 | +| 依赖 ← | [[erp-core]] | ErpModule trait, EventBus | 所有模块 | +| 依赖 ← | [[infrastructure]] | PostgreSQL, Redis | 连接 | + +### 后台任务 + +1. 消息监听器 — EventBus → MessageModule +2. 插件通知 — EventBus → PluginModule +3. Outbox relay — domain_events → 外部 +4. 超时检查器 — 工作流任务超时处理 + +## 3. 代码逻辑 + +### 中间件栈 + +``` +CORS(可配置 origins) → IP限流(公开路由) → 用户限流(受保护路由) → JWT认证 +``` + +### 配置结构 + +``` +AppConfig +├── server: { host: "0.0.0.0", port: 3000 } +├── database: { url, max_connections: 20, min_connections: 5 } +├── redis: { url } +├── jwt: { secret, access_token_ttl: 15min, refresh_token_ttl: 7d } +└── log: { level: "info" } +``` + +⚡ **不变量**: 4 个环境变量在 default.toml 中都是 `__MUST_SET_VIA_ENV__` 占位符,必须通过环境变量设置 + +⚡ **不变量**: 启动顺序不可变更 — 数据库必须先于迁移,迁移必须先于模块注册 + +## 4. 活跃问题 + 陷阱 + +⚠️ 后端必须从 `crates/erp-server/` 目录启动(或通过环境变量覆盖所有配置) +⚠️ 种子数据自动创建默认租户和管理员,重复启动幂等 + +## 5. 变更记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-23 | 重构为 5 节结构,更新为当前集成状态 | diff --git a/wiki/frontend.md b/wiki/frontend.md index dccc03d..010e435 100644 --- a/wiki/frontend.md +++ b/wiki/frontend.md @@ -1,81 +1,103 @@ -# frontend (Web 前端) +--- +title: Web 前端 +updated: 2026-04-23 +status: stable +tags: [frontend, react, antd, vite, spa] +--- -## 设计思想 +# Web 前端 -前端是一个 Vite + React SPA,遵循 **UI 层只做展示** 的原则: +> 从 [[index]] 导航。关联: [[erp-server]] [[infrastructure]] -- **组件库优先** — 使用 Ant Design,不自造轮子 -- **状态集中** — Zustand 管理全局状态(主题、侧边栏、认证) -- **API 层分离** — HTTP 调用封装到 service 层,组件不直接 fetch -- **代理开发** — Vite 开发服务器代理 `/api` 到后端 3000 端口 +## 1. 设计决策 -版本实际使用情况(与设计规格有差异): -| 技术 | 规格 | 实际 | -|------|------|------| -| React | 18 | 19.2.4 | -| Ant Design | 5 | 6.3.5 | -| React Router | 7 | 7.14.0 | +- **组件库优先** — Ant Design 6,不自造轮子 +- **状态集中** — Zustand 管理全局状态(4 个 store) +- **API 层分离** — HTTP 调用封装到 `src/api/`(21 个文件),组件不直接 fetch +- **代理开发** — Vite 代理 `/api` 到后端 3000 端口 +- **HashRouter** — 不需要服务端 fallback 配置,部署更稳健 +- **懒加载** — 除 Login 外所有页面使用 `lazy()` 按需加载 -## 代码逻辑 +### 版本(以实际 package.json 为准) -### 应用结构 -``` -main.tsx → App.tsx (ConfigProvider + HashRouter) → MainLayout → 各页面组件 -``` +React 19.2.4 / Ant Design 6.3.5 / React Router 7.14.0 / Zustand 5.0.12 / Vite 8.0.4 / TypeScript 6.0.2 -### MainLayout 布局 -经典 SaaS 后台管理布局: -- **左侧 Sidebar** — 可折叠暗色菜单,分组:首页/用户/权限/设置 -- **顶部 Header** — 侧边栏切换 + 通知徽标(硬编码5) + 头像("Admin") -- **中间 Content** — React Router Outlet,多标签页切换 -- **底部 Footer** — 租户名 + 版本号 +## 2. 关键文件 + 数据流 -### 状态管理 (Zustand) -```typescript -appStore { - isLoggedIn: boolean // 未使用,无登录页 - tenantName: string // 默认 "ERP Platform" - theme: 'light' | 'dark' // 切换 Ant Design 主题 - sidebarCollapsed: boolean - toggleSidebar(), setTheme(), login(), logout() -} -``` - -### 开发服务器代理 -``` -http://localhost:5174/api/* → http://localhost:3000/* (API) -ws://localhost:5174/ws/* → ws://localhost:3000/* (WebSocket) -``` - -### 当前状态 -- 布局壳体完整,暗色/亮色主题切换可用 -- 只有一个路由 `/` → 占位 HomePage ("Welcome to ERP Platform") -- 无 API 调用、无认证流程、无真实数据 -- 通知计数硬编码为 5,用户名硬编码为 "Admin" -- 未实现 i18n(代码中有 zh_CN locale 但文案硬编码) - -## 关联模块 - -- **[[erp-server]]** — API 后端,通过 Vite proxy 连接 -- **[[infrastructure]]** — Docker 提供 PostgreSQL + Redis - -## 关键文件 +### 核心文件 | 文件 | 职责 | |------|------| | `apps/web/src/main.tsx` | React 入口 | -| `apps/web/src/App.tsx` | 根组件:ConfigProvider + 路由 | -| `apps/web/src/layouts/MainLayout.tsx` | 完整后台管理布局 | -| `apps/web/src/stores/app.ts` | Zustand 全局状态 | -| `apps/web/src/index.css` | TailwindCSS 导入 | +| `apps/web/src/App.tsx` | 路由定义(公开 + 受保护) | +| `apps/web/src/layouts/MainLayout.tsx` | SaaS 后台管理布局 | +| `apps/web/src/stores/` | 4 个 Zustand store | +| `apps/web/src/api/` | 21 个 API 服务文件 | | `apps/web/vite.config.ts` | Vite 配置 + API 代理 | -| `apps/web/package.json` | 依赖声明 | -## 待实现 (按 Phase) +### 路由结构 -| Phase | 内容 | +**公开**: `/login` + +**受保护(MainLayout 包裹)**: + +| 路径 | 页面 | +|------|------| +| `/` | 首页 | +| `/users`, `/roles`, `/organizations` | 用户/角色/组织管理 | +| `/workflow` | 工作流 | +| `/messages` | 消息中心 | +| `/settings` | 系统设置 | +| `/plugins/admin`, `/plugins/market` | 插件管理/市场 | +| `/plugins/:pluginId/:entityName` | 插件 CRUD(动态生成) | +| `/plugins/:pluginId/tabs|tree|graph|dashboard|kanban/:name` | 插件多视图页面 | + +### 集成契约 + +| 方向 | 模块 | 接口 | 触发时机 | +|------|------|------|---------| +| 调用 → | [[erp-server]] | `/api/v1/*` REST | 所有数据操作 | +| 调用 → | [[erp-server]] | `ws://localhost:3000/ws/*` | WebSocket | +| 消费 ← | 插件系统 | `plugin.toml` schema | 动态生成插件页面 | + +## 3. 代码逻辑 + +### 状态管理(4 个 Zustand Store) + +| Store | 状态 | |-------|------| -| Phase 2 | 登录页、用户管理页、角色权限页 | -| Phase 3 | 系统配置管理页 | -| Phase 4 | 工作流设计器、审批列表 | -| Phase 5 | 消息中心、通知设置 | +| `app.ts` | theme(light/dark), sidebarCollapsed | +| `auth.ts` | user, isAuthenticated, localStorage 持久化 | +| `message.ts` | unreadCount, recentMessages, 请求去重 | +| `plugin.ts` | plugins 列表, 动态菜单, schema 缓存, 请求去重 | + +### 插件页面系统 + +插件通过 `plugin.toml` schema 声明页面,前端根据 schema 动态生成: +- `PluginCRUDPage` — 标准列表+表单 +- `PluginTabsPage` — 标签页切换 +- `PluginTreePage` — 树形展示 +- `PluginGraphPage` — 关系图谱 +- `PluginKanbanPage` — 看板视图 +- `PluginDashboardPage` — 仪表盘 + +⚡ **不变量**: 插件菜单由 `plugin.ts` store 从 API 动态获取,不硬编码 +⚡ **不变量**: API client 在请求前 30s 检查 token 过期,提前刷新避免 401 + +### 代理配置 + +``` +http://localhost:5174/api/* → http://localhost:3000/* (API) +ws://localhost:5174/ws/* → ws://localhost:3000/* (WebSocket) +``` + +## 4. 活跃问题 + 陷阱 + +⚠️ Ant Design 6 废弃 API 警告(`valueStyle`/`Spin tip`/`trailColor`)已在历史版本中修复 +⚠️ `antd.setScaleParam` 强制回流 64ms — antd 内部问题,无法直接修复 + +## 5. 变更记录 + +| 日期 | 变更 | +|------|------| +| 2026-04-23 | 重构为 5 节结构,更新为当前完整前端状态 | diff --git a/wiki/index.md b/wiki/index.md index da1fd79..2bb946d 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -1,95 +1,71 @@ -# ERP 平台底座 — 知识库 +# HMS 健康管理平台 — 知识库 -## 项目画像 +> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。 -**模块化 SaaS ERP 底座**,Rust + React 技术栈,提供身份权限/工作流/消息/配置/插件五大基础模块,支持行业业务模块快速插接。 +## 关键数字 -关键数字: -- 13 个 Rust crate(9 个已实现 + 2 个插件原型 + 2 个业务插件),1 个前端 SPA -- 37 个数据库迁移 -- 6 个业务模块 (auth, config, workflow, message, plugin, server) -- 4 个插件 crate (plugin-prototype, plugin-test-sample, plugin-crm, plugin-inventory) -- Health Check API (`/api/v1/health`) -- OpenAPI JSON (`/api/docs/openapi.json`) -- Phase 1-6 全部完成,WASM 插件系统已集成到主服务 -- Q2-Q4 成熟度路线图已完成(安全地基/架构强化/测试覆盖/插件生态) +| 指标 | 值 | +|------|-----| +| Rust crate | 14 个(7 核心 + erp-health + 6 插件) | +| 数据库表 | 30+ 基础表 + 16 健康业务表(规划中) | +| 核心模块 | 5 基础 (auth/config/workflow/message/plugin) + 1 业务 (health) | +| 健康模块页面 | 13 个(规划中) | +| API 文档 | `http://localhost:3000/api/docs/openapi.json` | -## 模块导航树 +## 症状导航 -### L1 基础层 +| 症状 | 先查 | 再查 | 常见根因 | +|------|------|------|----------| +| API 返回 403 | 权限码检查 | [[wasm-plugin]] 权限系统 | 权限码不匹配 / 缺少 .list 权限 | +| API 返回 500 无日志 | [[erp-core]] 错误链 | 后端 tracing 输出 | AppError::Internal 静默 | +| 数据库连接失败 | [[infrastructure]] | PostgreSQL 服务状态 | 服务未启动 / 环境变量未设置 | +| 前端 401 刷新时 | [[frontend]] auth store | API client token 刷新 | token 过期未主动刷新 | +| 迁移执行失败 | [[database]] | PostgreSQL 日志 | 表冲突 / 唯一索引 + 软删除 | +| 端口被占用 | [[infrastructure]] dev.ps1 | 端口 5174-5189 进程 | 残留 Vite 进程 | +| 预约超额 | [[erp-health]] 排班并发 | appointment CAS 操作 | 并发控制未走原子 CAS | +| 跨租户数据泄漏 | [[architecture]] 多租户策略 | [[database]] tenant_id | 查询缺少 tenant_id 过滤 | + +## 模块导航 + +### 基础层(继承自 ERP 底座) - [[erp-core]] — 错误体系 · 事件总线 · 模块 trait · 共享类型 -- [[erp-common]] — ID 生成 · 时间戳 · 编号生成工具 +- [[architecture]] — 架构决策 · 设计原则 · 技术选型 -### L2 业务层 -- erp-auth — 用户/角色/权限/组织/部门/岗位管理 · JWT 认证 · RBAC · 行级数据权限 +### 业务层(继承自 ERP 底座) +- erp-auth — 用户/角色/权限/组织/部门/岗位 · JWT · RBAC · 行级数据权限 - erp-config — 字典/菜单/设置/编号规则/主题/语言 -- erp-workflow — BPMN 解析 · Token 驱动执行 · 任务分配 · 流程设计器 -- erp-message — 消息 CRUD · 模板管理 · 订阅偏好 · 通知面板 · 事件集成 -- erp-plugin — 插件管理 · WASM 运行时 · 动态表 · 数据 CRUD · 生命周期管理 · 热更新 · 行级数据权限 +- erp-workflow — BPMN 解析 · Token 驱动 · 任务分配 +- erp-message — 消息 CRUD · 模板 · 订阅 · 通知面板 +- erp-plugin — WASM 运行时 · 动态表 · 热更新(HMS 保留但非主要扩展方式) -### L3 组装层 -- [[erp-server]] — Axum 服务入口 · AppState · ModuleRegistry 集成 · 配置加载 · 数据库连接 · 优雅关闭 +### 核心业务层(HMS 专属) +- [[erp-health]] — **患者管理 · 健康数据 · 预约排班 · 随访管理 · 咨询管理**(原生 Rust 模块) -### 插件系统 -- [[wasm-plugin]] — Wasmtime 运行时 · WIT 接口契约 · Host API · Fuel 资源限制 · 插件制作完整流程 -- erp-plugin-crm — CRM 客户管理插件 (5 实体/9 权限/6 页面) -- erp-plugin-inventory — 进销存管理插件 (6 实体/12 权限/6 页面) +### 组装层 +- [[erp-server]] — Axum 入口 · AppState · 模块注册 · 后台任务 · 优雅关闭 ### 基础设施 -- [[database]] — SeaORM 迁移 · 多租户表结构 · 软删除模式 -- [[infrastructure]] — Windows 开发环境 · PostgreSQL 16 · Redis 7 · 一键启动脚本 -- [[frontend]] — React SPA · Ant Design 布局 · Zustand 状态 -- [[testing]] — 测试环境指南 · 验证清单 · 常见问题 +- [[infrastructure]] — 连接信息 · 环境变量 · 一键启动 (**单一真相源**) +- [[database]] — SeaORM 迁移 · 多租户表结构 +- [[frontend]] — React 19 SPA · 健康管理页面 +- [[testing]] — 验证清单 · 测试分布 · 性能基准 -### 横切关注点 -- [[architecture]] — 架构决策记录 · 设计原则 · 技术选型理由 +## 核心架构问答 -## 核心架构决策 +**为什么 erp-health 用原生模块而非 WASM 插件?** 医疗业务需要 16+ 强类型实体、自定义 API(趋势分析/统计报表)、文件上传(化验单/体检报告)、未来 AI 集成,超出 WASM 插件能力范围。详见 [[erp-health]]。 -**模块间如何通信?** 通过 [[erp-core]] 的 EventBus 发布/订阅 DomainEvent,不直接依赖。 +**模块间如何通信?** [[erp-core]] EventBus 发布/订阅 DomainEvent。erp-health 发布 `patient.created`、`appointment.confirmed` 等事件,订阅 `workflow.task.completed` 等。详见 [[architecture]]。 -**多租户怎么隔离?** 共享数据库 + tenant_id 列过滤,中间件从 JWT 注入 TenantContext。详见 [[database]] 和 [[architecture]]。 +**多租户怎么隔离?** 共享数据库 + `tenant_id` 列过滤,中间件从 JWT 注入。详见 [[database]] [[architecture]]。 -**错误怎么传播?** 业务 crate 用 thiserror → AppError → Axum IntoResponse 自动转 HTTP。详见 [[erp-core]] 错误处理链。 +**患者/医护与 erp-auth 的关系?** 账号走 `users` 表,erp-health 通过 `user_id` 外键关联扩展字段(科室、职称、档案等)。患者可先建档后绑定账号。 -**状态如何共享?** AppState 包含 DB、Config、EventBus、ModuleRegistry,通过 Axum State 提取器注入所有 handler。 +## 文档索引 -**ModuleRegistry 怎么工作?** 每个 Phase 2+ 的业务模块实现 ErpModule trait,在 main.rs 中链式注册。registry 自动构建路由和事件处理器。 - -**插件系统怎么扩展业务?** 通过 [[wasm-plugin]] 的 WASM 沙箱运行第三方插件,插件通过 WIT 定义的 Host API 与系统交互。详细流程见插件制作指南。 - -**版本差异怎么办?** package.json 使用 React 19 + Ant Design 6(比规格文档更新),以实际代码为准。 - -**行级数据权限怎么控制?** role_permissions 表增加 data_scope 字段(all/self/department/department_tree),JWT 中间件注入 department_ids,插件数据查询自动拼接 scope 条件。 - -**插件怎么热更新?** 通过 `/api/v1/admin/plugins/{id}/upgrade` 上传新版本 WASM + manifest,系统对比 schema 变更执行增量 DDL,卸载旧 WASM 加载新 WASM,失败时保持旧版本继续运行。 - -## 开发进度 - -| Phase | 内容 | 状态 | -|-------|------|------| -| 1 | 基础设施 | 完成 | -| 2 | 身份与权限 | 完成 | -| 3 | 系统配置 | 完成 | -| 4 | 工作流引擎 | 完成 | -| 5 | 消息中心 | 完成 | -| 6 | 整合与打磨 | 完成 | -| - | WASM 插件原型 | V1-V6 验证通过 | -| - | 插件系统集成 | 已集成到主服务 | -| - | CRM 插件 | 完成 | -| - | Q2 安全地基 + CI/CD | 完成 | -| - | Q3 架构强化 + 前端体验 | 完成 | -| - | Q4 测试覆盖 + 插件生态 | 完成 | - -## 关键文档索引 - -| 文档 | 位置 | +| 类型 | 位置 | |------|------| -| 设计规格 | `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | -| 实施计划 | `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` | -| WASM 插件设计 | `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | -| WASM 插件计划 | `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | -| CRM 插件设计 | `docs/superpowers/specs/2026-04-16-crm-plugin-design.md` | -| CRM 插件计划 | `docs/superpowers/plans/2026-04-16-crm-plugin-plan.md` | +| 健康模块设计规格 | `docs/superpowers/specs/2026-04-23-health-management-module-design.md` | +| 设计规格 | `docs/superpowers/specs/` | +| 实施计划 | `docs/superpowers/plans/` | | 协作规则 | `CLAUDE.md` | -| 设计评审 | `plans/squishy-pondering-aho-agent-a23c7497aadc6da41.md` | +| 插件制作指南 | `.claude/skills/plugin-development/SKILL.md` | diff --git a/wiki/infrastructure.md b/wiki/infrastructure.md index 594bdda..629b185 100644 --- a/wiki/infrastructure.md +++ b/wiki/infrastructure.md @@ -1,138 +1,104 @@ -# infrastructure (开发环境) +--- +title: 开发环境 +updated: 2026-04-23 +status: stable +tags: [infrastructure, dev-environment, windows, postgresql] +--- -## 设计思想 +# 开发环境 -开发环境在 **Windows** 宿主机直接运行所有服务: -- PostgreSQL 通过 Windows 原生安装运行 -- Redis 7+ 通过 Windows 原生安装运行(可选,缺省时限流降级为 fail-open) -- 后端 Rust 服务通过 `cargo run` 快速重启 -- 前端 Vite 热更新直接在宿主机 -- PowerShell 脚本 (`dev.ps1`) 提供一键启动/停止 +> 从 [[index]] 导航。关联: [[erp-server]] [[database]] [[frontend]] [[testing]] +> +> **本页是连接信息、启动命令、登录凭据的单一真相源。** 其他页面引用此处。 -> Docker Compose 配置保留在 `docker/` 目录下,可供需要容器化环境的场景使用,但日常开发不依赖 Docker。 +## 1. 设计决策 -## 本机环境实际配置 +- **Windows 原生运行** — PostgreSQL/Redis/Rust/Node 直接在宿主机,不用 Docker +- **一键启动** — `dev.ps1` 管理前后端生命周期 +- **环境变量优先** — 敏感配置通过 `ERP__` 前缀环境变量覆盖 TOML -> **重要:以下为当前开发机的实际配置,以本文件为准。** +## 2. 关键文件 + 连接信息 -| 组件 | 安装路径 | 配置 | -|------|---------|------| -| PostgreSQL 18 | `D:\postgreSQL\` | 服务名 `postgresql-x64-18`, 端口 5432 | -| Redis | 云端实例 | `redis://:redis_KBCYJk@129.204.154.246:6379`, 限流中间件已正常工作 | -| Rust | stable (cargo in PATH) | workspace 根目录编译 | -| Node.js + pnpm | in PATH | apps/web/ | +### 核心文件 -### 数据库连接 +| 文件 | 职责 | +|------|------| +| `dev.ps1` | 一键启动/停止(自动清理端口 5174-5189) | +| `crates/erp-server/config/default.toml` | 默认配置模板 | +| `docker/docker-compose.yml` | 可选 Docker 配置 | + +### 服务连接 + +| 服务 | 地址 | 用途 | +|------|------|------| +| PostgreSQL 18 | `postgres://postgres:123123@localhost:5432/erp` | 主数据库 | +| Redis 7 | `redis://:redis_KBCYJk@129.204.154.246:6379` (云端) | 缓存 + 限流 | +| 后端 API | `http://localhost:3000/api/v1` | Axum 服务 | +| 前端 SPA | `http://localhost:5174` | Vite 开发服务器 | + +### 登录凭据 ``` -用户: postgres -密码: 123123 -数据库: erp -连接串: postgres://postgres:123123@localhost:5432/erp +用户名: admin 密码: Admin@2026 ``` -psql 路径: `D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp` +psql: `D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp` -### 后端启动命令 +### 必须设置的环境变量 -后端**必须**从 `crates/erp-server/` 目录启动(需要读取 `config/default.toml`),或通过环境变量覆盖: +| 变量 | 开发值 | +|------|--------| +| `ERP__DATABASE__URL` | `postgres://postgres:123123@localhost:5432/erp` | +| `ERP__JWT__SECRET` | `dev-secret-key-change-in-prod` | +| `ERP__AUTH__SUPER_ADMIN_PASSWORD` | `Admin@2026` | +| `ERP__REDIS__URL` | `redis://:redis_KBCYJk@129.204.154.246:6379` | + +> 所有四个在 `default.toml` 中为 `__MUST_SET_VIA_ENV__` 占位符 + +### 集成契约 + +| 方向 | 模块 | 触发时机 | +|------|------|---------| +| 提供 → | [[erp-server]] | 数据库/Redis 连接 | +| 提供 → | [[frontend]] | Vite 代理目标 | +| 提供 → | [[testing]] | 测试环境配置 | + +## 3. 代码逻辑 + +### 一键启动(推荐) ```powershell -# 方式一:从 crates/erp-server 目录启动(使用 default.toml + 环境变量覆盖) +.\dev.ps1 # 启动后端 + 前端 +.\dev.ps1 -Status # 查看端口状态 +.\dev.ps1 -Stop # 停止所有服务 +.\dev.ps1 -Restart # 重启所有服务 +``` + +### 手动启动 + +```powershell +# 后端(必须从 crates/erp-server 目录) cd crates/erp-server $env:ERP__DATABASE__URL = "postgres://postgres:123123@localhost:5432/erp" $env:ERP__JWT__SECRET = "dev-secret-key-change-in-prod" $env:ERP__AUTH__SUPER_ADMIN_PASSWORD = "Admin@2026" cargo run -p erp-server -# 方式二:一键启动脚本(推荐) -.\dev.ps1 +# 前端 +cd apps/web && pnpm install && pnpm dev ``` -### 登录凭据 +⚡ **不变量**: 后端必须从 `crates/erp-server/` 目录启动或通过环境变量覆盖所有配置 +⚡ **不变量**: Vite 固定端口 5174(`--strictPort`),前端代理 `/api` → 后端 3000 -``` -用户名: admin -密码: Admin@2026 -``` +## 4. 活跃问题 + 陷阱 -## 服务端口 +⚠️ Redis 不可达时限流自动降级为 fail-open(放行所有请求) +⚠️ Docker Compose 配置保留在 `docker/` 下但日常开发不依赖 +⚠️ 首次 `cargo run` 编译整个 workspace 较慢(含 wasmtime),后续增量快 -| 服务 | 端口 | 用途 | -|------|------|------| -| PostgreSQL 18 | 5432 | 主数据库 | -| Redis 7+ | 6379 (云端) | 缓存 + 限流 | -| erp-server (Axum) | 3000 | 后端 API | -| Vite dev server | 5174 | 前端 SPA(固定端口,--strictPort) | +## 5. 变更记录 -### 连接信息(配置文件版本) -``` -PostgreSQL: postgres://postgres:123123@localhost:5432/erp -Redis: redis://:redis_KBCYJk@129.204.154.246:6379 (云端实例) -``` - -## 一键启动 - -```powershell -.\dev.ps1 # 启动后端 + 前端(自动清理旧进程 5174-5189) -.\dev.ps1 -Status # 查看端口状态 -.\dev.ps1 -Stop # 停止所有服务 -.\dev.ps1 -Restart # 重启所有服务 -``` - -> `dev.ps1` 会在启动前清理端口 5174-5189 范围内所有残留进程,并使用 `--strictPort` 确保 Vite 固定在 5174 端口。 - -### 环境变量 - -必须通过环境变量设置的值(`default.toml` 中为占位符): - -| 变量 | 说明 | 开发值 | -|------|------|--------| -| `ERP__DATABASE__URL` | 数据库连接串 | `postgres://postgres:123123@localhost:5432/erp` | -| `ERP__JWT__SECRET` | JWT 签名密钥 | 自定义字符串 | -| `ERP__AUTH__SUPER_ADMIN_PASSWORD` | admin 初始密码 | `Admin@2026` | -| `ERP__REDIS__URL` | Redis 连接串 | `redis://:redis_KBCYJk@129.204.154.246:6379` | - -> 所有四个变量在 `default.toml` 中都是 `__MUST_SET_VIA_ENV__` 占位符,**必须**通过环境变量设置,否则服务拒绝启动。 - -## 关联模块 - -- **[[erp-server]]** — 连接 PostgreSQL 和 Redis -- **[[database]]** — 迁移在 PostgreSQL 中执行 -- **[[frontend]]** — Vite 代理 API 到后端 -- **[[testing]]** — 测试环境详细指南 - -## 关键文件 - -| 文件 | 职责 | +| 日期 | 变更 | |------|------| -| `dev.ps1` | 一键启动/停止脚本(自动清理端口 5174-5189) | -| `docker/docker-compose.yml` | 可选的 Docker Compose 配置 | -| `crates/erp-server/config/default.toml` | 默认配置模板(密钥为占位符) | -| `D:\postgreSQL\bin\psql.exe` | PostgreSQL 客户端 | - -## 常用命令 - -```powershell -# 一键启动(推荐) -.\dev.ps1 - -# 手动启动后端(从 crates/erp-server 目录) -cd crates/erp-server -$env:ERP__DATABASE__URL="postgres://postgres:123123@localhost:5432/erp" -$env:ERP__JWT__SECRET="dev-secret" -$env:ERP__AUTH__SUPER_ADMIN_PASSWORD="Admin@2026" -cargo run -p erp-server - -# 手动启动前端(固定端口) -cd apps/web && pnpm dev -- --strictPort - -# 连接数据库 -D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp - -# 健康检查 -curl http://localhost:3000/api/v1/health - -# 登录测试 -curl -s http://localhost:3000/api/v1/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":"Admin@2026"}' -``` +| 2026-04-23 | 重构为 5 节结构,确立为连接信息的单一真相源 | diff --git a/wiki/testing.md b/wiki/testing.md index 85aad6c..bedb6ed 100644 --- a/wiki/testing.md +++ b/wiki/testing.md @@ -1,119 +1,49 @@ -# 测试环境指南 +--- +title: 测试与验证 +updated: 2026-04-23 +status: stable +tags: [testing, verification] +--- -> 本项目在 **Windows** 环境下开发,使用 PowerShell 脚本一键启动。不使用 Docker,数据库直接通过原生安装运行。 +# 测试与验证 -## 环境要求 +> 从 [[index]] 导航。关联: [[infrastructure]] [[database]] [[frontend]] [[erp-server]] -| 工具 | 最低版本 | 用途 | -|------|---------|------| -| Rust | stable (1.93+) | 后端编译 | -| Node.js | 20+ | 前端工具链 | -| pnpm | 9+ | 前端包管理 | -| PostgreSQL | 16+ (当前 18) | 主数据库 | -| Redis | 7+ (云端实例) | 缓存 + 限流 | +## 1. 设计决策 -## 服务连接信息(实际配置) +- **真实数据库优先** — 集成测试用真实 PostgreSQL,不用内存模拟 +- **分层验证** — 编译检查 → 单元测试 → 功能验证 → 生产构建 +- **环境配置统一由 [[infrastructure]] 管理** — 连接信息、启动命令、登录凭据见该页 -| 服务 | 地址 | 用途 | -|------|------|------| -| PostgreSQL | `postgres://postgres:123123@localhost:5432/erp` | 主数据库 | -| Redis | `redis://:redis_KBCYJk@129.204.154.246:6379` (云端) | 缓存 + 限流 | -| 后端 API | `http://localhost:3000/api/v1` | Axum 服务 | -| 前端 SPA | `http://localhost:5174` | Vite 开发服务器 | +## 2. 关键文件 + 验证清单 -### 登录信息 +### 测试分布 -- 用户名: `admin` -- 密码: `Admin@2026` +| Crate | 测试数 | 覆盖 | +|-------|--------|------| +| erp-auth | 8 | 密码哈希、TTL 解析 | +| erp-core | 6 | RBAC 权限检查 | +| erp-workflow | 16 | BPMN 解析、表达式求值 | +| erp-plugin-prototype | 6 | WASM 插件集成 | +| **总计** | **36** | | -## 一键启动(推荐) - -使用 PowerShell 脚本管理前后端服务: - -```powershell -.\dev.ps1 # 启动后端 + 前端(自动清理旧进程) -.\dev.ps1 -Status # 查看端口状态 -.\dev.ps1 -Restart # 重启所有服务 -.\dev.ps1 -Stop # 停止所有服务 -``` - -脚本会自动: -1. 清理端口 5174-5189 范围内所有残留进程 -2. 编译并启动 Rust 后端 (`cargo run -p erp-server`) -3. 安装前端依赖并启动 Vite 开发服务器 (`pnpm dev -- --strictPort`) - -## 手动启动 - -### 1. 确保基础设施运行 - -```powershell -# 检查 PostgreSQL 服务状态 -Get-Service -Name "postgresql*" - -# 如需启动 -# PostgreSQL 通常自动启动,服务名 postgresql-x64-18 -``` - -### 2. 启动后端(必须从 crates/erp-server 目录) - -```powershell -cd crates/erp-server -$env:ERP__DATABASE__URL = "postgres://postgres:123123@localhost:5432/erp" -$env:ERP__JWT__SECRET = "dev-secret-key-change-in-prod" -$env:ERP__AUTH__SUPER_ADMIN_PASSWORD = "Admin@2026" -cargo run -p erp-server -``` - -首次运行会自动执行数据库迁移。 - -### 3. 启动前端 - -```powershell -cd apps/web -pnpm install # 首次需要安装依赖 -pnpm dev # 启动开发服务器(端口 5174,固定) -``` - -## 验证清单 - -### 后端验证 +### 编译 + 测试 ```bash -# 编译检查(无错误) -cargo check - -# 全量测试(应全部通过) -cargo test --workspace - -# Lint 检查(无警告) -cargo clippy -- -D warnings - -# 格式检查 -cargo fmt --check +cargo check # 编译无错误 +cargo test --workspace # 全量测试 +cargo clippy -- -D warnings # Lint 无警告 +cargo fmt --check # 格式检查 +cd apps/web && pnpm build # 前端生产构建 ``` -### 前端验证 +### 功能验证端点 -```bash -cd apps/web - -# 安装依赖 -pnpm install - -# TypeScript 编译 + 生产构建 -pnpm build - -# 类型检查 -pnpm tsc -b -``` - -### 功能验证 - -| 端点 | 方法 | 说明 | -|------|------|------| -| `http://localhost:3000/api/v1/health` | GET | 健康检查 | -| `http://localhost:3000/api/docs/openapi.json` | GET | OpenAPI 文档 | -| `http://localhost:5174` | GET | 前端页面 | +| 端点 | 说明 | +|------|------| +| `http://localhost:3000/api/v1/health` | 健康检查 | +| `http://localhost:3000/api/docs/openapi.json` | OpenAPI 文档 | +| `http://localhost:5174` | 前端页面 | ### API 快速测试 @@ -122,143 +52,57 @@ pnpm tsc -b curl -s http://localhost:3000/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"Admin@2026"}' - -# 列出用户(需要 Token) -curl -s http://localhost:3000/api/v1/users \ - -H "Authorization: Bearer " - -# 列出插件 -curl -s http://localhost:3000/api/v1/admin/plugins \ - -H "Authorization: Bearer " ``` -## 数据库管理 +### 前端性能基准(2026-04-18 Lighthouse) -### 连接数据库 +Accessibility / SEO / Best Practices 均 100,LCP 840ms,CLS 0.02 + +## 3. 代码逻辑 + +### 集成契约 + +| 方向 | 模块 | 触发时机 | +|------|------|---------| +| 依赖 ← | [[infrastructure]] | 环境准备、连接信息 | +| 验证 → | [[erp-server]] | 健康检查、API 测试 | +| 验证 → | [[frontend]] | 生产构建 | + +⚡ **不变量**: 功能验证需要后端服务运行中,编译检查必须先于测试通过 + +### 数据库常用查询 ```bash +# 连接数据库 D:\postgreSQL\bin\psql.exe -U postgres -h localhost -d erp ``` -### 常用查询 - ```sql --- 列出所有表 -\dt - --- 查看迁移记录 -SELECT version FROM seaql_migrations ORDER BY version; - --- 查看插件权限 -SELECT code, name FROM permissions WHERE deleted_at IS NULL ORDER BY code; - --- 查看 admin 角色的权限 -SELECT p.code FROM role_permissions rp -JOIN permissions p ON rp.permission_id = p.id -JOIN roles r ON rp.role_id = r.id -WHERE r.code = 'admin' AND rp.deleted_at IS NULL AND p.deleted_at IS NULL; +SELECT version FROM seaql_migrations ORDER BY version; -- 迁移记录 +SELECT code, name FROM permissions WHERE deleted_at IS NULL ORDER BY code; -- 插件权限 ``` -### 迁移 +## 4. 活跃问题 + 陷阱 -迁移在 `crates/erp-server/migration/src/` 目录下。后端启动时自动执行。 +### 活跃问题 -## 测试详情 - -### 测试分布 - -| Crate | 测试数 | 说明 | -|-------|--------|------| -| erp-auth | 8 | 密码哈希、TTL 解析 | -| erp-core | 6 | RBAC 权限检查 | -| erp-workflow | 16 | BPMN 解析、表达式求值 | -| erp-plugin-prototype | 6 | WASM 插件集成测试 | -| **总计** | **36** | | - -### 运行特定测试 - -```bash -# 运行单个 crate 的测试 -cargo test -p erp-auth - -# 运行匹配名称的测试 -cargo test -p erp-core -- require_permission - -# 运行插件集成测试 -cargo test -p erp-plugin-prototype - -# 集成测试(需要 Docker/PostgreSQL) -cargo test -p erp-server --test integration -``` - -## 已知问题(2026-04-18 审计) - -| 问题 | 严重度 | 状态 | -|------|--------|------| -| CRM 插件权限未分配给 admin 角色 → 数据页面 403 | P0 | ✅ 已修复 | -| CRM 插件权限码与实体名不匹配(`tag.manage` vs `customer_tag`)→ 标签/关系/图谱 403 | P0 | ✅ 已修复(迁移 m20260419_000038) | -| CRM 插件 WASM 二进制错误(存储了测试插件而非 CRM 插件) | P0 | ✅ 已修复 | -| 首页统计卡片永久 loading | P0 | ✅ 已修复 | -| `roles/permissions` 路由被 UUID 解析拦截 | P1 | ✅ 已修复 | -| 统计概览 `tagColor` undefined crash(`getEntityPalette` 负数索引) | P1 | ✅ 已修复 | -| 销售漏斗/看板 filter 请求 500(CRM customer 表缺少 generated columns) | P1 | ✅ 已修复(手动 ALTER TABLE 补齐 `_f_level`/`_f_status`/`_f_customer_type`/`_f_industry`/`_f_region` + 索引) | -| `build_scope_sql` 参数索引硬编码 `$100` 导致 SQL 参数错位 | P1 | ✅ 已修复(动态 `values.len()+1`) | -| `AppError::Internal` 无日志输出,500 错误静默 | P1 | ✅ 已修复(添加 `tracing::error` 日志) | -| antd 6 废弃 API 警告(`valueStyle`/`Spin tip`/`trailColor`) | P2 | ✅ 已修复 | +| 问题 | 级别 | 状态 | +|------|------|------| | display_name 存储 XSS HTML | P1 | 待修复 | -| 页面刷新时 4 个 401 错误(过期 token 未主动刷新) | P2 | ✅ 已修复(proactive token refresh) | -| 插件列表重复请求(无并发去重) | P2 | ✅ 已修复(fetchPlugins promise 去重) | -| TS 编译错误:未使用变量 | P3 | ✅ 已修复 | -| antd vendor chunk 2.9MB(gzip 后约 400KB) | P3 | 待优化 | -| antd `setScaleParam` 强制回流 64ms | P3 | antd 内部问题,无法直接修复 | +| antd vendor chunk 2.9MB (gzip ~400KB) | P3 | 待优化 | -详见 `docs/audit-2026-04-18.md`。 +### 历史教训 -### 前端审计摘要(2026-04-18 Lighthouse + 性能) +- CRM 权限码与实体名不一致 → 403(详见 [[wasm-plugin]] 权限命名铁律) +- `AppError::Internal` 无日志 → 500 静默(已加 `tracing::error`) +- `build_scope_sql` 参数索引硬编码 → SQL 参数错位(已动态化) -| 指标 | 得分 | +⚠️ 首次 `cargo run` 需编译整个 workspace(含 wasmtime),后续增量快 +⚠️ Redis 不可达时限流自动降级为 fail-open + +## 5. 变更记录 + +| 日期 | 变更 | |------|------| -| Accessibility | 100 | -| SEO | 100 | -| Best Practices | 100 | -| LCP | 840ms | -| CLS | 0.02 | -| TTFB | 4ms | - -**已实施的优化:** -- API client proactive token refresh(请求前 30s 检查过期,提前刷新避免 401) -- plugin store 请求去重(promise 复用,防止并发重复调用) -- 生产构建中 StrictMode 双重渲染导致的重复请求不会出现 - -## 常见问题 - -### Q: 端口被占用 / 多个 Vite 进程残留 - -```powershell -# 使用 dev.ps1 自动清理 -.\dev.ps1 -Stop - -# 手动清理 Vite 残留进程(端口 5174-5189) -Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -ge 5174 -and $_.LocalPort -le 5189 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force } -``` - -### Q: 数据库连接失败 - -1. 确认 PostgreSQL 服务正在运行: `Get-Service -Name "postgresql*"` -2. 使用正确连接串: `postgres://postgres:123123@localhost:5432/erp` -3. psql 路径: `D:\postgreSQL\bin\psql.exe` - -### Q: 首次启动很慢 - -首次 `cargo run` 需要编译整个 workspace(特别是 wasmtime),后续增量编译会很快。 - -### Q: Redis 未安装 - -Redis 已配置为云端实例(`129.204.154.246:6379`)。限流中间件使用固定窗口计数器,登录接口限制 60 秒内 5 次请求。如 Redis 不可达,自动降级为 fail-open(放行所有请求)。 - -## 关联模块 - -- [[infrastructure]] — 基础设施配置详情 -- [[database]] — 数据库迁移和表结构 -- [[frontend]] — 前端技术栈和配置 -- [[erp-server]] — 后端服务配置 +| 2026-04-23 | 重构为 5 节结构,去除与 infrastructure.md 重复 | +| 2026-04-18 | Lighthouse 审计 + 性能优化 | diff --git a/wiki/wasm-plugin.md b/wiki/wasm-plugin.md index af8e8b3..6e07a0c 100644 --- a/wiki/wasm-plugin.md +++ b/wiki/wasm-plugin.md @@ -1,514 +1,125 @@ -# wasm-plugin (WASM 插件系统) +--- +title: WASM 插件系统 +updated: 2026-04-23 +status: stable +tags: [wasm, plugin, wasmtime, wit] +--- -## 设计思想 +# WASM 插件系统 -ERP 平台通过 WASM 沙箱实现**安全、隔离、热插拔**的业务扩展。插件在 Wasmtime 运行时中执行,只能通过 WIT 定义的 Host API 与系统交互,无法直接访问数据库、文件系统或网络。 +> 从 [[index]] 导航。关联: [[erp-core]] [[architecture]] [[erp-server]] -核心决策: -- **WASM 沙箱** — 插件代码在隔离环境中运行,Host 控制所有资源访问 -- **WIT 接口契约** — 通过 `.wit` 文件定义 Host ↔ 插件的双向接口,bindgen 自动生成类型化绑定 -- **Fuel 资源限制** — 通过燃料机制限制插件 CPU 使用,防止无限循环 -- **声明式 Host API** — 插件通过 `db_insert` / `event_publish` 等函数操作数据,Host 自动注入 tenant_id、校验权限 +## 1. 设计决策 -## 原型验证结果 (V1-V6) +### 为什么选 WASM 而非 Lua / gRPC / dylib? -| 验证项 | 状态 | 说明 | -|--------|------|------| -| V1: WIT 接口 + bindgen! 编译 | 通过 | `bindgen!({ path, world })` 生成 Host trait + Guest 绑定 | -| V2: Host 调用插件导出函数 | 通过 | `call_init()` / `call_handle_event()` / `call_on_tenant_created()` | -| V3: 插件回调 Host API | 通过 | 插件中 `host_api::db_insert()` 等正确回调到 HostState | -| V4: async 实例化桥接 | 通过 | `instantiate_async` 正常工作(调用方法本身是同步的) | -| V5: Fuel 资源限制 | 通过 | 低 fuel 时正确 trap,不会无限循环 | -| V6: 从二进制动态加载 | 通过 | `.component.wasm` 文件加载,测试插件 110KB | +| 方案 | 安全性 | 隔离性 | 性能 | 复杂度 | +|------|--------|--------|------|--------| +| **WASM** | 高(沙箱) | 进程内隔离 | 接近原生 | 中 | +| Lua 脚本 | 中 | 无隔离 | 快 | 低 | +| 进程外 gRPC | 高 | 进程级隔离 | 网络开销 | 高 | +| dylib | 低 | 无隔离 | 原生 | 低 | -## 项目结构 +核心权衡:WASM 在安全和性能间取得最佳平衡。Wasmtime v43 Component Model 提供类型安全的 Host-Plugin 接口,Fuel 防止无限循环。 + +### 架构拓扑 ``` -crates/ - erp-plugin-prototype/ ← Host 端运行时 - wit/ - plugin.wit ← WIT 接口定义 - src/ - lib.rs ← Engine/Store/Linker 创建、HostState + Host trait 实现 - main.rs ← 手动测试入口(空) - tests/ - test_plugin_integration.rs ← 6 个集成测试 - - erp-plugin-test-sample/ ← 测试插件 - src/ - lib.rs ← 实现 Guest trait,调用 Host API +┌─────────────────────────────────────────────┐ +│ erp-server │ +│ ┌───────────┐ ┌────────────────────────┐ │ +│ │ EventBus │ │ PluginRuntime(Wasmtime) │ │ +│ │(broadcast)│ │ ┌─────┐ ┌─────┐ │ │ +│ └─────┬─────┘ │ │CRM │ │库存 │ │ │ +│ │ │ └──┬──┘ └──┬──┘ │ │ +│ │ │ Host Bridge(自动注入 │ │ +│ │ │ tenant_id+权限检查) │ │ +│ │ └─────┼──────────────────┘ │ +│ ┌─────┴──────┐ │ +│ │ DB(SeaORM) │ │ +│ └────────────┘ │ +└─────────────────────────────────────────────┘ ``` -## WIT 接口定义 +### 原型验证 (V1-V6) -文件:`crates/erp-plugin-prototype/wit/plugin.wit` +全部通过:WIT+bindgen 编译、Host 调用插件、插件回调 Host API、async 实例化、Fuel 限制、动态加载。 -``` -package erp:plugin; +## 2. 关键文件 + 数据流 -// Host 暴露给插件的 API(插件 import) -interface host-api { - db-insert: func(entity: string, data: list) -> result, string>; - db-query: func(entity: string, filter: list, pagination: list) -> result, string>; - db-update: func(entity, id: string, data: list, version: s64) -> result, string>; - db-delete: func(entity: string, id: string) -> result<_, string>; - event-publish: func(event-type: string, payload: list) -> result<_, string>; - config-get: func(key: string) -> result, string>; - log-write: func(level: string, message: string); - current-user: func() -> result, string>; - check-permission: func(permission: string) -> result; -} - -// 插件导出的 API(Host 调用) -interface plugin-api { - init: func() -> result<_, string>; - on-tenant-created: func(tenant-id: string) -> result<_, string>; - handle-event: func(event-type: string, payload: list) -> result<_, string>; -} - -world plugin-world { - import host-api; - export plugin-api; -} -``` - -## 关键技术要点 - -### HasSelf — Linker 注册模式 - -当 Store 数据类型(`HostState`)直接实现 `Host` trait 时,使用 `HasSelf` 作为 `add_to_linker` 的类型参数: - -```rust -use wasmtime::component::{HasSelf, Linker}; - -let mut linker = Linker::new(engine); -PluginWorld::add_to_linker::<_, HasSelf>(&mut linker, |state| state)?; -``` - -`HasSelf` 表示 `Data<'a> = &'a mut HostState`,bindgen 生成的 `Host for &mut T` blanket impl 确保调用链正确。 - -### WASM Component vs Core Module - -`wit_bindgen::generate!` 生成的是 core WASM 模块(`.wasm`),但 `Component::from_binary()` 需要 WASM Component 格式。转换步骤: - -```bash -# 1. 编译为 core wasm -cargo build -p --target wasm32-unknown-unknown --release - -# 2. 转换为 component -wasm-tools component new target/wasm32-unknown-unknown/release/.wasm \ - -o target/.component.wasm -``` - -### Fuel 资源限制 - -```rust -let mut store = Store::new(engine, HostState::new()); -store.set_fuel(1_000_000)?; // 分配 100 万 fuel -store.limiter(|state| &mut state.limits); // 内存限制 -``` - -Fuel 不足时,WASM 执行会 trap(`wasm trap: interrupt`),Host 可以捕获并处理。 - -### 调用方法 — 同步,非 async - -bindgen 生成的调用方法(`call_init`、`call_handle_event`)是同步的: - -```rust -// 正确 -instance.erp_plugin_plugin_api().call_init(&mut store)?; - -// 错误(不存在 async 版本的调用方法) -instance.erp_plugin_plugin_api().call_init(&mut store).await?; -``` - -但实例化可以异步:`PluginWorld::instantiate_async(&mut store, &component, &linker).await?` - -## 关键文件 +### 核心文件 | 文件 | 职责 | |------|------| | `crates/erp-plugin-prototype/wit/plugin.wit` | WIT 接口定义(Host API + Plugin API) | -| `crates/erp-plugin-prototype/src/lib.rs` | Host 运行时:Engine/Store 创建、HostState、Host trait 实现 | +| `crates/erp-plugin-prototype/src/lib.rs` | Host 运行时:Engine/Store/Linker/HostState | | `crates/erp-plugin-prototype/tests/test_plugin_integration.rs` | V1-V6 集成测试 | -| `crates/erp-plugin-test-sample/src/lib.rs` | 测试插件:Guest trait 实现 | -## 关联模块 +### WIT 接口概要 -- **[[architecture]]** — 插件架构是模块化单体的重要扩展机制 -- **[[erp-core]]** — EventBus 事件将被桥接到插件的 `handle_event` -- **[[erp-server]]** — 未来集成插件运行时的组装点 +Host 暴露给插件(`host-api`):`db_insert` `db_query` `db_update` `db_delete` `event_publish` `config_get` `log_write` `current_user` `check_permission` ---- +插件导出给 Host(`plugin-api`):`init` `on_tenant_created` `handle_event` -# 插件制作完整流程 +### 集成契约 -以下是从零创建一个新业务模块插件的完整步骤。 +| 方向 | 模块 | 接口 | 触发时机 | +|------|------|------|---------| +| 被调用 ← | [[erp-server]] | `PluginEngine` | 服务启动时恢复插件 | +| 调用 → | [[erp-core]] | `EventBus` | 桥接领域事件到插件 | +| 提供 → | 所有插件 | Host API (9 函数) | 插件运行时 | -## 第一步:准备 WIT 接口 +## 3. 代码逻辑 -WIT 文件定义 Host 和插件之间的契约。现有接口位于 `crates/erp-plugin-prototype/wit/plugin.wit`。 - -如果新插件需要扩展 Host API(如新增文件上传、HTTP 代理等),在 `host-api` interface 中添加函数: - -```wit -// 在 host-api 中新增 -file-upload: func(filename: string, data: list) -> result; -http-proxy: func(url: string, method: string, body: option>) -> result, string>; -``` - -如果插件需要新的生命周期钩子,在 `plugin-api` interface 中添加: - -```wit -// 在 plugin-api 中新增 -on-order-approved: func(order-id: string) -> result<_, string>; -``` - -修改 WIT 后,需要重新编译 Host crate 和所有插件。 - -## 第二步:创建插件 crate - -在 `crates/` 下创建新的插件 crate: - -```bash -mkdir -p crates/erp-plugin-<业务名> -``` - -`Cargo.toml` 模板: - -```toml -[package] -name = "erp-plugin-<业务名>" -version = "0.1.0" -edition = "2024" -description = "<业务描述> WASM 插件" - -[lib] -crate-type = ["cdylib"] # 必须是 cdylib 才能编译为 WASM - -[dependencies] -wit-bindgen = "0.55" # 生成 Guest 端绑定 -serde = { workspace = true } -serde_json = { workspace = true } -``` - -将新 crate 加入 workspace(编辑根 `Cargo.toml`): - -```toml -members = [ - # ... 已有成员 ... - "crates/erp-plugin-<业务名>", -] -``` - -## 第三步:实现插件逻辑 - -创建 `src/lib.rs`,实现 `Guest` trait: - -```rust -//! <业务名> WASM 插件 - -use serde_json::json; - -// 生成 Guest 端绑定(路径指向 Host crate 的 WIT 文件) -wit_bindgen::generate!({ - path: "../erp-plugin-prototype/wit/plugin.wit", - world: "plugin-world", -}); - -// 导入 Host API(bindgen 生成) -use crate::erp::plugin::host_api; -// 导入 Guest trait(bindgen 生成) -use crate::exports::erp::plugin::plugin_api::Guest; - -// 插件结构体(名称任意,但必须是模块级可见的) -struct MyPlugin; - -impl Guest for MyPlugin { - /// 初始化 — 注册默认数据、订阅事件等 - fn init() -> Result<(), String> { - host_api::log_write("info", "<业务名>插件初始化"); - - // 示例:创建默认配置 - let config = json!({"default_category": "通用"}).to_string(); - host_api::db_insert("<业务>_config", config.as_bytes()) - .map_err(|e| format!("初始化失败: {}", e))?; - - Ok(()) - } - - /// 租户创建时 — 初始化租户的默认数据 - fn on_tenant_created(tenant_id: String) -> Result<(), String> { - host_api::log_write("info", &format!("新租户: {}", tenant_id)); - - let data = json!({"tenant_id": tenant_id, "name": "默认仓库"}).to_string(); - host_api::db_insert("warehouse", data.as_bytes()) - .map_err(|e| format!("创建默认仓库失败: {}", e))?; - - Ok(()) - } - - /// 处理订阅的事件 - fn handle_event(event_type: String, payload: Vec) -> Result<(), String> { - host_api::log_write("debug", &format!("收到事件: {}", event_type)); - - let data: serde_json::Value = serde_json::from_slice(&payload) - .map_err(|e| format!("解析事件失败: {}", e))?; - - match event_type.as_str() { - "order.created" => { - // 处理订单创建事件 - let order_id = data["id"].as_str().unwrap_or(""); - host_api::log_write("info", &format!("新订单: {}", order_id)); - } - "workflow.task.completed" => { - // 处理审批完成事件 - let order_id = data["order_id"].as_str().unwrap_or("unknown"); - let update = json!({"status": "approved"}).to_string(); - host_api::db_update("purchase_order", order_id, update.as_bytes(), 1) - .map_err(|e| format!("更新失败: {}", e))?; - - // 发布下游事件 - let evt = json!({"order_id": order_id}).to_string(); - host_api::event_publish("<业务>.order.approved", evt.as_bytes()) - .map_err(|e| format!("发布事件失败: {}", e))?; - } - _ => { - host_api::log_write("debug", &format!("忽略事件: {}", event_type)); - } - } - - Ok(()) - } -} - -// 导出插件实例(宏会注册 Guest trait 实现) -export!(MyPlugin); -``` - -### Host API 速查 - -| 函数 | 签名 | 用途 | -|------|------|------| -| `db_insert` | `(entity, data) → result` | 插入记录,Host 自动注入 id/tenant_id/timestamp | -| `db_query` | `(entity, filter, pagination) → result` | 查询记录,自动过滤 tenant_id + 排除软删除 | -| `db_update` | `(entity, id, data, version) → result` | 更新记录,检查乐观锁 version | -| `db_delete` | `(entity, id) → result<_, string>` | 软删除记录 | -| `event_publish` | `(event_type, payload) → result<_, string>` | 发布领域事件 | -| `config_get` | `(key) → result` | 读取系统配置 | -| `log_write` | `(level, message) → ()` | 写日志,自动关联 tenant_id + plugin_id | -| `current_user` | `() → result` | 获取当前用户信息 | -| `check_permission` | `(permission) → result` | 检查当前用户权限 | - -### 数据传递约定 - -所有 Host API 的数据参数使用 `list`(即 `Vec`),约定用 JSON 序列化: - -```rust -// 构造数据 -let data = json!({"sku": "ITEM-001", "quantity": 100}).to_string(); - -// 插入 -let result_bytes = host_api::db_insert("inventory_item", data.as_bytes()) - .map_err(|e| e.to_string())?; - -// 解析返回 -let record: serde_json::Value = serde_json::from_slice(&result_bytes) - .map_err(|e| e.to_string())?; -let new_id = record["id"].as_str().unwrap(); -``` - -## 第四步:编译为 WASM - -```bash -# 编译为 core WASM 模块 -cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release - -# 转换为 WASM Component(必须,Host 只接受 Component 格式) -wasm-tools component new \ - target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \ - -o target/erp_plugin_<业务名>.component.wasm -``` - -检查产物大小(目标 < 2MB): - -```bash -ls -la target/erp_plugin_<业务名>.component.wasm -``` - -## 第五步:编写集成测试 - -在 `crates/erp-plugin-prototype/tests/` 下创建测试文件,或扩展现有测试: - -```rust -use anyhow::Result; -use erp_plugin_prototype::{create_engine, load_plugin}; - -fn wasm_path() -> String { - "../../target/erp_plugin_<业务名>.component.wasm".into() -} - -#[tokio::test] -async fn test_<业务名>_init() -> Result<()> { - let wasm_bytes = std::fs::read(wasm_path())?; - let engine = create_engine()?; - let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?; - - // 调用 init - instance.erp_plugin_plugin_api().call_init(&mut store)? - .map_err(|e| anyhow::anyhow!(e))?; - - // 验证 Host 端效果 - let state = store.data(); - assert!(state.db_ops.iter().any(|op| op.entity == "<业务>_config")); - - Ok(()) -} - -#[tokio::test] -async fn test_<业务名>_handle_event() -> Result<()> { - let wasm_bytes = std::fs::read(wasm_path())?; - let engine = create_engine()?; - let (mut store, instance) = load_plugin(&engine, &wasm_bytes, 1_000_000).await?; - - // 先初始化 - instance.erp_plugin_plugin_api().call_init(&mut store)? - .map_err(|e| anyhow::anyhow!(e))?; - - // 模拟事件 - let payload = json!({"id": "ORD-001"}).to_string(); - instance.erp_plugin_plugin_api() - .call_handle_event(&mut store, "order.created", payload.as_bytes())? - .map_err(|e| anyhow::anyhow!(e))?; - - Ok(()) -} -``` - -## 第六步:运行测试 - -```bash -# 先确保编译了 component -cargo build -p erp-plugin-<业务名> --target wasm32-unknown-unknown --release -wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_<业务名>.wasm \ - -o target/erp_plugin_<业务名>.component.wasm - -# 运行集成测试 -cargo test -p erp-plugin-prototype -``` - -## 流程速查图 +### 插件生命周期 ``` -1. 修改 WIT(如需新接口) crates/erp-plugin-prototype/wit/plugin.wit - ↓ -2. 创建插件 crate crates/erp-plugin-<名>/ - - Cargo.toml (cdylib + wit-bindgen) - - src/lib.rs (impl Guest) - ↓ -3. 编译 core wasm cargo build --target wasm32-unknown-unknown --release - ↓ -4. 转为 component wasm-tools component new -o - ↓ -5. 编写测试 crates/erp-plugin-prototype/tests/ - ↓ -6. 运行测试 cargo test -p erp-plugin-prototype +安装(manifest+WASM) → 注册权限 → 实例化(Wasmtime) → init() + → 日常: db_query/db_insert 通过 data_handler 自动注入 tenant_id + 权限校验 + → 事件: EventBus → handle_event() + → 升级: upload 新 WASM → 增量 DDL → 卸载旧实例 → 加载新实例 ``` -## 常见问题 +### 权限系统 -### Q: "attempted to parse a wasm module with a component parser" -**A:** 使用了 core WASM 而非 Component。运行 `wasm-tools component new` 转换。 - -### Q: "cannot infer type of the type parameter D" -**A:** `add_to_linker` 需要显式指定 `HasSelf`:`add_to_linker::<_, HasSelf>(linker, |s| s)`。 - -### Q: "wasm trap: interrupt"(非 fuel 耗尽) -**A:** 检查是否启用了 epoch_interruption 但未定期 bump epoch。原型阶段建议只使用 fuel 限制。 - -### Q: 插件中如何调试? -**A:** 使用 `host_api::log_write("debug", "message")` 输出日志,Host 端 `store.data().logs` 可查看所有日志。 - -### Q: 如何限制插件内存? -**A:** 通过 `StoreLimitsBuilder` 配置: -```rust -let limits = StoreLimitsBuilder::new() - .memory_size(10 * 1024 * 1024) // 10MB - .build(); -``` - -## 后续规划 - -- **Phase 7**: 将原型集成到 erp-server,替换模拟 Host API 为真实数据库操作 -- **动态表**: 支持 `db_insert("dynamic_table", ...)` 自动创建/迁移表 -- **前端集成**: PluginCRUDPage 组件根据 WIT 定义自动生成 CRUD 页面 -- **插件市场**: 插件元数据、版本管理、签名验证 - -## 插件权限系统(关键) - -### 权限码格式 - -插件数据操作的权限码由 `data_handler.rs` 中的 `compute_permission_code()` 按以下规则自动生成: +权限码由 `data_handler.rs` 的 `compute_permission_code()` 自动生成: ``` {manifest_id}.{url_entity_name}.{action_suffix} +例: erp-crm.customer.list / erp-crm.customer.manage ``` -- `manifest_id`:plugin.toml 中 `[metadata].id`(如 `erp-crm`) -- `url_entity_name`:REST API 路径中的实体名(如 `customer_tag`) -- `action_suffix`:`list`(读操作)或 `manage`(写操作) +⚡ **权限命名铁律**: `plugin.toml` 中 `permissions[].code` 前缀必须与 `schema.entities[].name` 完全一致,每个实体必须声明 `.list` + `.manage` -| 操作 | 权限码示例 | -|------|-----------| -| 列表/详情 | `erp-crm.customer.list` | -| 创建/更新/删除 | `erp-crm.customer.manage` | +⚡ **Host API 数据约定**: 所有数据参数用 `list` + JSON 序列化,Host 自动注入 id/tenant_id/timestamp -### 权限码命名铁律(P0 级) +⚡ **同步调用**: bindgen 生成的 `call_init`/`call_handle_event` 是同步的,只有实例化可以 async -**`plugin.toml` 中 `permissions[].code` 的前缀必须与 `schema.entities[].name` 完全一致。** +### 不变量 -``` -data_handler 生成:{manifest_id}.{url_entity_name}.{action} - ↑ 来自 URL 路径中的 entity 参数 -manifest 声明: {entity_name}.{action} - ↑ 必须与 URL 中的 entity name 匹配 -``` +⚡ Fuel 默认 100 万,耗尽时 WASM trap(`wasm trap: interrupt`) +⚡ `HasSelf` 是 Linker 注册的必要类型参数(`Data<'a> = &'a mut HostState`) +⚡ Core WASM 必须通过 `wasm-tools component new` 转为 Component 格式才能被 Host 加载 -每个实体必须同时声明 `.list` 和 `.manage` 两个权限: +## 4. 活跃问题 + 陷阱 -```toml -# ✅ 正确:权限码前缀与实体名一致 -[[schema.entities]] -name = "customer_tag" +### 历史教训 -[[permissions]] -code = "customer_tag.list" # 匹配! +- CRM 权限码 `tag.manage` vs 实体 `customer_tag` → 三个页面 403(迁移 m000038 修复) +- CRM WASM 二进制错误存储了测试插件而非 CRM 插件(重新编译修复) +- 权限未自动分配给 admin 角色 → 403(添加 `grant_permissions_to_admin()`) -[[permissions]] -code = "customer_tag.manage" # 匹配! +### 注意事项 -# ❌ 错误:权限码用了简写,与实体名不一致 → 403 -[[permissions]] -code = "tag.manage" # data_handler 生成 erp-crm.customer_tag.manage - # 但 DB 中只有 erp-crm.tag.manage → 403 -``` +⚠️ 插件 API 路由用 `Path<(Uuid, String)>` 解析 plugin_id,必须用数据库 UUID 而非 manifest_id +⚠️ 修改 WIT 后需重编译 Host crate 和所有插件 -**历史教训:** CRM 插件首个版本中,`customer_tag` 实体的权限码写成了 `tag.manage`,`customer_relationship` 实体的权限码写成了 `relationship.list/manage`。结果标签管理、客户关系、关系图谱三个页面全部 403。修复迁移:`m20260419_000038_fix_crm_permission_codes.rs`。 +## 5. 变更记录 -### 权限注册流程 - -1. **插件安装时** → `register_plugin_permissions()` 将 manifest 中声明的权限批量 INSERT 到 `permissions` 表(`ON CONFLICT DO NOTHING` 保证幂等) -2. **权限分配** → `grant_permissions_to_admin()` 自动将权限分配给 admin 角色 -3. **运行时校验** → `data_handler.rs` 的 `compute_permission_code()` 按 URL entity name 生成权限码,通过 `require_permission()` 检查 JWT 中的权限列表 - -### 已修复问题 - -| 问题 | 修复 | +| 日期 | 变更 | |------|------| -| 权限未自动分配给 admin 角色 → 403 | `grant_permissions_to_admin()` 在 install/enable 时自动调用 | -| 权限码与实体名不匹配 → 403 | 迁移 m20260419_000038 + plugin.toml 修正 | -### 插件 API 路由注意事项 +| 2026-04-23 | 重构为 5 节结构,插件制作流程移至 `.claude/skills/plugin-development/` | +| 2026-04-19 | CRM 权限码修复 (m000038) | +| 2026-04-18 | 插件权限系统审计 | -- 后端路由使用 `Path<(Uuid, String)>` 解析 `plugin_id`,必须是 UUID 格式 -- 前端使用 `plugin.id`(数据库 UUID)而非 `manifest_id`(如 `erp-crm`)构建请求 URL -- 直接用 manifest_id 调用 API 会返回 `UUID parsing failed` 错误 +> **插件制作完整流程**: 详见 `.claude/skills/plugin-development/SKILL.md`(WIT 接口 → 创建 crate → 编译 WASM → 集成测试)