fix: 修复测试发现的 7 个问题 + 全 workspace clippy 清零
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

功能修复:
1. 患者创建空名称验证:后端添加 name.trim().is_empty() 检查
2. 仪表盘统计容错:单个查询失败返回零值而非 500
3. FHIR 路由修复:从 /fhir 移到 /api/v1/fhir 保持一致
4. 冻结模块后端中间件:新增 frozen_module_middleware 拦截冻结路径
5. 积分端点权限码:health.health-data.list → health.points.list
6. 角色权限迁移:护士补充 devices.list,运营补充 points.list/manage
7. 测试结果文档:R01-R05 角色测试 + T00/T10 结果归档

Clippy 全 workspace 清零(14→0 errors):
- erp-core: 修复 empty doc line、collapsible if、redundant closure 等 9 处
- erp-health: 修复 too_many_arguments、unused var、unnecessary parens 等 58 处
- erp-ai: 修复 dead_code、unused import 等 11 处
- erp-plugin: 修复 too_many_arguments、wildcard pattern 等 11 处
- erp-server-migration: 修复 enum_variant_names 5 处
- erp-auth/config/workflow/message: 各 1-3 处

工程改进:
- lint-staged 配置迁移到 .lintstagedrc.js(函数式避免文件列表传给 clippy)
- cargo fmt 统一格式化
This commit is contained in:
iven
2026-05-07 23:43:14 +08:00
parent 786f57c151
commit 6d5a711d2c
323 changed files with 15662 additions and 6603 deletions

View File

@@ -1,9 +1,12 @@
use erp_core::types::Pagination;
use futures::Stream;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use sha2::{Digest, Sha256};
use std::pin::Pin;
use uuid::Uuid;
use erp_core::types::Pagination;
use crate::dto::{AnalysisType, GenerateRequest};
use crate::entity::ai_analysis;
@@ -38,6 +41,7 @@ impl AnalysisService {
}
/// 执行流式分析 — 返回 SSE 事件流
#[allow(clippy::too_many_arguments)]
pub async fn stream_analyze(
&self,
tenant_id: Uuid,
@@ -64,7 +68,10 @@ impl AnalysisService {
if let Some(cached) = self.find_cached(tenant_id, &input_hash, 1).await? {
tracing::info!(analysis = %cached.id, "AI 分析缓存命中,复用已有结果");
let content = cached.result_content.clone().unwrap_or_default();
let metadata = cached.result_metadata.clone().unwrap_or(serde_json::json!({}));
let metadata = cached
.result_metadata
.clone()
.unwrap_or(serde_json::json!({}));
let stream = self.replay_cached(content, metadata);
return Ok((stream, cached.id, provider_name));
}
@@ -86,7 +93,10 @@ impl AnalysisService {
confidence = ctx.confidence,
"知识库上下文注入"
);
format!("{}\n\n=== 知识库参考 ===\n{}", system_prompt, ctx.context_text)
format!(
"{}\n\n=== 知识库参考 ===\n{}",
system_prompt, ctx.context_text
)
}
Ok(_) => system_prompt,
Err(e) => {
@@ -234,11 +244,7 @@ impl AnalysisService {
}
/// 获取单条分析记录
pub async fn get_analysis(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_analysis::Model> {
pub async fn get_analysis(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_analysis::Model> {
let model = ai_analysis::Entity::find_by_id(id)
.one(&self.db)
.await?
@@ -249,6 +255,7 @@ impl AnalysisService {
Ok(model)
}
#[allow(clippy::too_many_arguments)]
async fn create_analysis_record(
&self,
id: Uuid,

View File

@@ -1,10 +1,11 @@
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QueryOrder, Set, Statement};
use sea_orm::{ActiveModelTrait, EntityTrait, FromQueryResult, Set, Statement};
use uuid::Uuid;
use crate::entity::ai_analysis_queue;
use crate::error::{AiError, AiResult};
#[derive(Debug, FromQueryResult)]
#[allow(dead_code)]
struct QueueRow {
id: Uuid,
tenant_id: Uuid,
@@ -88,7 +89,10 @@ impl AnalysisQueue {
Ok(id)
}
pub async fn claim_next(&self, tenant_id: Option<Uuid>) -> AiResult<Option<ai_analysis_queue::Model>> {
pub async fn claim_next(
&self,
tenant_id: Option<Uuid>,
) -> AiResult<Option<ai_analysis_queue::Model>> {
let sql = match tenant_id {
Some(tid) => format!(
"SELECT * FROM ai_analysis_queue WHERE tenant_id = '{}' AND status = 'pending' AND deleted_at IS NULL AND scheduled_at <= NOW() ORDER BY priority DESC, scheduled_at ASC LIMIT 1",
@@ -101,19 +105,22 @@ impl AnalysisQueue {
AND scheduled_at <= NOW()
ORDER BY priority DESC, scheduled_at ASC
LIMIT 1
"#.to_string(),
"#
.to_string(),
};
let row: Option<QueueRow> = QueueRow::find_by_statement(
Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql.to_string()),
)
let row: Option<QueueRow> = QueueRow::find_by_statement(Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
sql.to_string(),
))
.one(&self.db)
.await?;
match row {
Some(r) => {
let now = chrono::Utc::now();
let mut active: ai_analysis_queue::ActiveModel = self.find_by_id(r.id).await?.into();
let mut active: ai_analysis_queue::ActiveModel =
self.find_by_id(r.id).await?.into();
active.status = Set("running".to_string());
active.started_at = Set(Some(now));
active.updated_at = Set(now);
@@ -125,11 +132,7 @@ impl AnalysisQueue {
}
}
pub async fn mark_completed(
&self,
id: Uuid,
result_analysis_id: Uuid,
) -> AiResult<()> {
pub async fn mark_completed(&self, id: Uuid, result_analysis_id: Uuid) -> AiResult<()> {
let job = self.find_by_id(id).await?;
let now = chrono::Utc::now();
let mut active: ai_analysis_queue::ActiveModel = job.into();
@@ -179,15 +182,14 @@ impl AnalysisQueue {
GROUP BY status
"#;
let rows: Vec<StatusCount> = StatusCount::find_by_statement(
Statement::from_sql_and_values(
let rows: Vec<StatusCount> =
StatusCount::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into()],
),
)
.all(&self.db)
.await?;
))
.all(&self.db)
.await?;
let mut pending = 0i64;
let mut running = 0i64;

View File

@@ -50,19 +50,13 @@ async fn run_auto_analysis(state: &AiState) -> Result<(), String> {
}
}
tracing::info!(
total_analyzed,
total_errors,
"自动趋势分析任务完成"
);
tracing::info!(total_analyzed, total_errors, "自动趋势分析任务完成");
Ok(())
}
/// 查找所有活跃租户 ID
async fn find_active_tenants(
db: &sea_orm::DatabaseConnection,
) -> Result<Vec<Uuid>, String> {
async fn find_active_tenants(db: &sea_orm::DatabaseConnection) -> Result<Vec<Uuid>, String> {
#[derive(Debug, FromQueryResult)]
struct TenantId {
id: Uuid,

View File

@@ -16,7 +16,12 @@ pub struct CacheKey {
}
impl CacheKey {
pub fn new(tenant_id: Uuid, analysis_type: &str, input: &serde_json::Value, prompt_version: i32) -> Self {
pub fn new(
tenant_id: Uuid,
analysis_type: &str,
input: &serde_json::Value,
prompt_version: i32,
) -> Self {
let canonical = serde_json::to_string(input).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
@@ -54,8 +59,16 @@ pub struct CacheService {
}
impl CacheService {
pub fn new(redis: redis::Client, db: sea_orm::DatabaseConnection, default_ttl: Duration) -> Self {
Self { redis, db, default_ttl }
pub fn new(
redis: redis::Client,
db: sea_orm::DatabaseConnection,
default_ttl: Duration,
) -> Self {
Self {
redis,
db,
default_ttl,
}
}
pub async fn get(&self, key: &CacheKey) -> AiResult<Option<CachedAnalysis>> {
@@ -127,12 +140,13 @@ impl CacheService {
let data: Option<String> = conn.get(key).await?;
match data {
Some(json) => {
let cached: CachedAnalysis = serde_json::from_str(&json)
.map_err(|e| redis::RedisError::from((
let cached: CachedAnalysis = serde_json::from_str(&json).map_err(|e| {
redis::RedisError::from((
redis::ErrorKind::TypeError,
"反序列化失败",
e.to_string(),
)))?;
))
})?;
Ok(Some(cached))
}
None => Ok(None),
@@ -141,12 +155,10 @@ impl CacheService {
async fn try_redis_set(&self, key: &str, value: &CachedAnalysis) -> redis::RedisResult<()> {
let mut conn = self.redis.get_multiplexed_async_connection().await?;
let json = serde_json::to_string(value).map_err(|e| redis::RedisError::from((
redis::ErrorKind::TypeError,
"序列化失败",
e.to_string(),
)))?;
let (): () = conn.set_ex(key, json, self.default_ttl.as_secs() as u64).await?;
let json = serde_json::to_string(value).map_err(|e| {
redis::RedisError::from((redis::ErrorKind::TypeError, "序列化失败", e.to_string()))
})?;
let (): () = conn.set_ex(key, json, self.default_ttl.as_secs()).await?;
Ok(())
}
@@ -155,15 +167,14 @@ impl CacheService {
let mut count = 0u64;
let mut cursor: u64 = 0;
loop {
let (new_cursor, keys): (u64, Vec<String>) =
redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100)
.query_async(&mut conn)
.await?;
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100)
.query_async(&mut conn)
.await?;
if !keys.is_empty() {
let del_count: u64 = conn.del(&keys).await?;
count += del_count;

View File

@@ -38,32 +38,35 @@ pub fn generate_comparison(
// 提取可比较的数值指标
if let (Some(b_obj), Some(c_obj)) = (baseline.as_object(), current.as_object()) {
for key in b_obj.keys() {
if let (Some(b_val), Some(c_val)) = (b_obj.get(key), c_obj.get(key)) {
if let (Some(b_num), Some(c_num)) = (b_val.as_f64(), c_val.as_f64()) {
let change_pct = if b_num.abs() > 0.0001 {
((c_num - b_num) / b_num.abs()) * 100.0
} else {
0.0
};
let trend = if change_pct.abs() > 5.0 {
TrendDirection::Worsening
} else {
TrendDirection::Stable
};
changes.push(MetricChange {
metric: key.clone(),
baseline_value: b_num,
current_value: c_num,
change_percent: change_pct,
trend,
});
}
if let (Some(b_val), Some(c_val)) = (b_obj.get(key), c_obj.get(key))
&& let (Some(b_num), Some(c_num)) = (b_val.as_f64(), c_val.as_f64())
{
let change_pct = if b_num.abs() > 0.0001 {
((c_num - b_num) / b_num.abs()) * 100.0
} else {
0.0
};
let trend = if change_pct.abs() > 5.0 {
TrendDirection::Worsening
} else {
TrendDirection::Stable
};
changes.push(MetricChange {
metric: key.clone(),
baseline_value: b_num,
current_value: c_num,
change_percent: change_pct,
trend,
});
}
}
}
// 综合趋势判断
let changed = changes.iter().filter(|c| c.trend == TrendDirection::Worsening).count();
let changed = changes
.iter()
.filter(|c| c.trend == TrendDirection::Worsening)
.count();
let overall = if changed > 0 {
TrendDirection::Worsening
} else {

View File

@@ -74,9 +74,8 @@ impl CostService {
pub fn estimate_cost(analysis_type: &str, model: &str) -> CostEstimate {
let (input_tokens, output_tokens) = default_token_estimate(analysis_type);
let (input_cost, output_cost) = model_cost_per_million(model);
let estimated_cost_usd =
(input_tokens as f64 * input_cost / 1_000_000.0)
+ (output_tokens as f64 * output_cost / 1_000_000.0);
let estimated_cost_usd = (input_tokens as f64 * input_cost / 1_000_000.0)
+ (output_tokens as f64 * output_cost / 1_000_000.0);
CostEstimate {
analysis_type: analysis_type.to_string(),
@@ -143,13 +142,11 @@ impl CostService {
AND created_at >= DATE_TRUNC('month', CURRENT_DATE)
"#;
let row: Option<TokenSum> = TokenSum::find_by_statement(
Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into()],
),
)
let row: Option<TokenSum> = TokenSum::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into()],
))
.one(&self.db)
.await?;
@@ -179,7 +176,10 @@ mod tests {
#[test]
fn budget_warning_levels() {
assert_eq!(BudgetWarningLevel::Normal, BudgetWarningLevel::Normal);
assert!(matches!(BudgetWarningLevel::Exceeded, BudgetWarningLevel::Exceeded));
assert!(matches!(
BudgetWarningLevel::Exceeded,
BudgetWarningLevel::Exceeded
));
}
#[test]

View File

@@ -1,4 +1,4 @@
use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion};
use crate::dto::suggestion::{RiskLevel, StructuredSuggestion, SuggestionType};
use crate::service::local_rules::{CompareOp, LocalRule, LocalRulesEngine};
/// 透析患者实验室指标输入
@@ -73,6 +73,12 @@ pub struct DialysisRiskScorer {
engine: LocalRulesEngine,
}
impl Default for DialysisRiskScorer {
fn default() -> Self {
Self::new()
}
}
impl DialysisRiskScorer {
pub fn new() -> Self {
let rules = vec![
@@ -113,8 +119,7 @@ impl DialysisRiskScorer {
threshold: 7.0,
risk_level: RiskLevel::High,
suggestion_type: SuggestionType::Alert,
message_template: "血磷={value}mg/dL严重偏高>7.0),需紧急评估钙磷代谢"
.into(),
message_template: "血磷={value}mg/dL严重偏高>7.0),需紧急评估钙磷代谢".into(),
},
// 透前血钾 > 6.0 mEq/L危急高钾
LocalRule {
@@ -161,8 +166,7 @@ impl DialysisRiskScorer {
threshold: 5.0,
risk_level: RiskLevel::High,
suggestion_type: SuggestionType::Alert,
message_template: "透析间期体重增长{value}%>5%干体重),容量超负荷风险高"
.into(),
message_template: "透析间期体重增长{value}%>5%干体重),容量超负荷风险高".into(),
},
// 体重增长 > 3.5%:需关注
LocalRule {
@@ -199,8 +203,7 @@ impl DialysisRiskScorer {
threshold: 3.0,
risk_level: RiskLevel::High,
suggestion_type: SuggestionType::Alert,
message_template: "白蛋白={value}g/dL严重偏低<3.0),营养不良增加死亡风险"
.into(),
message_template: "白蛋白={value}g/dL严重偏低<3.0),营养不良增加死亡风险".into(),
},
];
Self {
@@ -221,17 +224,14 @@ impl DialysisRiskScorer {
let suggestions = self.engine.evaluate(&metrics);
let mut risk_factors: Vec<String> = suggestions
.iter()
.map(|s| s.reason.clone())
.collect();
let mut risk_factors: Vec<String> = suggestions.iter().map(|s| s.reason.clone()).collect();
let kdigo_stage = input.egfr.map(KdigoStage::from_egfr);
if let Some(stage) = kdigo_stage {
if matches!(stage, KdigoStage::G4 | KdigoStage::G5) {
risk_factors.push(format!("KDIGO分期{},肾功能严重受损", stage.label()));
}
if let Some(stage) = kdigo_stage
&& matches!(stage, KdigoStage::G4 | KdigoStage::G5)
{
risk_factors.push(format!("KDIGO分期{},肾功能严重受损", stage.label()));
}
let overall_risk = if suggestions.iter().any(|s| s.priority == 1) {

View File

@@ -1,4 +1,4 @@
use crate::dto::suggestion::{RiskLevel, SuggestionType, StructuredSuggestion};
use crate::dto::suggestion::{RiskLevel, StructuredSuggestion, SuggestionType};
#[derive(Debug, Clone)]
pub struct LocalRule {
@@ -120,9 +120,7 @@ impl LocalRulesEngine {
RiskLevel::Medium => "2周内".into(),
RiskLevel::Low => "1个月内".into(),
},
reason: rule
.message_template
.replace("{value}", &value.to_string()),
reason: rule.message_template.replace("{value}", &value.to_string()),
params: serde_json::json!({
"metric": rule.metric,
"value": value,
@@ -156,7 +154,8 @@ mod tests {
#[test]
fn evaluate_all_normal_no_suggestions() {
let rules = LocalRulesEngine::default_rules();
let metrics = serde_json::json!({"systolic_bp": 120.0, "heart_rate": 72.0, "blood_sugar": 5.5});
let metrics =
serde_json::json!({"systolic_bp": 120.0, "heart_rate": 72.0, "blood_sugar": 5.5});
let suggestions = rules.evaluate(&metrics);
assert!(suggestions.is_empty());
}
@@ -166,9 +165,11 @@ mod tests {
let rules = LocalRulesEngine::default_rules();
let metrics = serde_json::json!({"heart_rate": 110.0});
let suggestions = rules.evaluate(&metrics);
assert!(suggestions
.iter()
.any(|s| s.suggestion_type == SuggestionType::Followup));
assert!(
suggestions
.iter()
.any(|s| s.suggestion_type == SuggestionType::Followup)
);
}
#[test]

View File

@@ -11,11 +11,10 @@ pub fn parse_dual_channel(raw: &str) -> AiResult<ParsedOutput> {
.trim()
.to_string();
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER)
.and_then(|json_str| {
let parsed: Result<StructuredOutput, _> = serde_json::from_str(json_str.trim());
parsed.ok()
});
let structured = extract_section(raw, JSON_MARKER, TEXT_MARKER).and_then(|json_str| {
let parsed: Result<StructuredOutput, _> = serde_json::from_str(json_str.trim());
parsed.ok()
});
Ok(ParsedOutput {
text_content,

View File

@@ -17,6 +17,7 @@ pub struct PostProcessResult {
}
/// 对完成的分析执行后处理:解析双通道输出、创建建议、发布事件
#[allow(clippy::too_many_arguments)]
pub async fn post_process_analysis(
state: &AiState,
analysis_id: Uuid,

View File

@@ -34,6 +34,7 @@ impl PromptService {
}
/// 新建 Prompt
#[allow(clippy::too_many_arguments)]
pub async fn create_prompt(
&self,
tenant_id: Uuid,
@@ -95,6 +96,7 @@ impl PromptService {
}
/// 更新 Prompt创建新版本
#[allow(clippy::too_many_arguments)]
pub async fn update_prompt(
&self,
id: Uuid,
@@ -122,7 +124,9 @@ impl PromptService {
name: Set(entity.name.clone()),
description: Set(description.unwrap_or(entity.description.clone())),
system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.clone())),
user_prompt_template: Set(user_prompt_template.unwrap_or(entity.user_prompt_template.clone())),
user_prompt_template: Set(
user_prompt_template.unwrap_or(entity.user_prompt_template.clone())
),
variables_schema: Set(entity.variables_schema.clone()),
model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
version: Set(entity.version + 1),
@@ -140,11 +144,7 @@ impl PromptService {
}
/// 激活指定 Prompt停用同 name+category 的其他版本)
pub async fn activate_prompt(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_prompt::Model> {
pub async fn activate_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db)
.await?
@@ -179,11 +179,7 @@ impl PromptService {
}
/// 回滚(= 激活指定旧版本)
pub async fn rollback_prompt(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_prompt::Model> {
pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
self.activate_prompt(id, tenant_id).await
}
}

View File

@@ -27,11 +27,7 @@ impl QuotaService {
Ok(config)
}
pub async fn check_quota(
&self,
tenant_id: Uuid,
patient_id: Option<Uuid>,
) -> AiResult<()> {
pub async fn check_quota(&self, tenant_id: Uuid, patient_id: Option<Uuid>) -> AiResult<()> {
if !self.enabled {
return Ok(());
}
@@ -81,24 +77,18 @@ impl QuotaService {
AND created_at >= date_trunc('month', CURRENT_DATE)
"#;
let result: Option<TokenSum> = TokenSum::find_by_statement(
Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into()],
),
)
let result: Option<TokenSum> = TokenSum::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into()],
))
.one(&self.db)
.await?;
Ok(result.map(|r| r.total_tokens).unwrap_or(0))
}
async fn get_daily_patient_count(
&self,
tenant_id: Uuid,
patient_id: Uuid,
) -> AiResult<i64> {
async fn get_daily_patient_count(&self, tenant_id: Uuid, patient_id: Uuid) -> AiResult<i64> {
#[derive(Debug, FromQueryResult)]
struct CountResult {
count: i64,
@@ -113,23 +103,19 @@ impl QuotaService {
AND created_at >= CURRENT_DATE
"#;
let result: Option<CountResult> = CountResult::find_by_statement(
Statement::from_sql_and_values(
let result: Option<CountResult> =
CountResult::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into(), patient_id.into()],
),
)
.one(&self.db)
.await?;
))
.one(&self.db)
.await?;
Ok(result.map(|r| r.count).unwrap_or(0))
}
pub async fn get_usage_summary(
&self,
tenant_id: Uuid,
) -> AiResult<QuotaSummary> {
pub async fn get_usage_summary(&self, tenant_id: Uuid) -> AiResult<QuotaSummary> {
let config = self.get_tenant_config(tenant_id).await?;
let budget = config
.as_ref()
@@ -142,10 +128,7 @@ impl QuotaService {
tenant_id,
monthly_budget: budget,
monthly_used: used,
daily_patient_limit: config
.as_ref()
.map(|c| c.daily_patient_limit)
.unwrap_or(50),
daily_patient_limit: config.as_ref().map(|c| c.daily_patient_limit).unwrap_or(50),
})
}
}

View File

@@ -21,15 +21,14 @@ pub async fn handle_reanalysis_requested(
FROM ai_suggestion
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
"#;
let original: Option<OriginalSuggestion> = OriginalSuggestion::find_by_statement(
Statement::from_sql_and_values(
let original: Option<OriginalSuggestion> =
OriginalSuggestion::find_by_statement(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[original_suggestion_id.into(), tenant_id.into()],
),
)
.one(db)
.await?;
))
.one(db)
.await?;
match original {
Some(orig) => {

View File

@@ -1,8 +1,8 @@
use uuid::Uuid;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use erp_core::error::AppResult;
use crate::dto::suggestion::*;
use crate::entity::ai_suggestion;
use erp_core::error::AppResult;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
pub struct SuggestionService;
@@ -85,9 +85,7 @@ impl SuggestionService {
.filter(ai_suggestion::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| {
crate::error::AiError::AnalysisNotFound("建议不存在".into())
})?;
.ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
let current_status = parse_status(&item.status);
if !current_status.can_transition_to(new_status) {
@@ -122,13 +120,14 @@ impl SuggestionService {
.filter(ai_suggestion::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| {
crate::error::AiError::AnalysisNotFound("建议不存在".into())
})?;
.ok_or_else(|| crate::error::AiError::AnalysisNotFound("建议不存在".into()))?;
let current_status = parse_status(&item.status);
// 允许从 Pending 或 Approved 直接执行(护士可能跳过审批)
if !matches!(current_status, SuggestionStatus::Pending | SuggestionStatus::Approved) {
if !matches!(
current_status,
SuggestionStatus::Pending | SuggestionStatus::Approved
) {
return Err(crate::error::AiError::Validation(format!(
"建议状态为 {},无法执行(需要 pending 或 approved",
current_status.as_str()

View File

@@ -1,4 +1,6 @@
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, Set,
};
use uuid::Uuid;
use crate::entity::ai_analysis;
@@ -14,6 +16,7 @@ impl UsageService {
Self { db }
}
#[allow(clippy::too_many_arguments)]
pub async fn log_usage(
&self,
tenant_id: Uuid,