Files
erp/crates/erp-config/src/service/dictionary_service.rs
iven 0baaf5f7ee feat(config): add system configuration module (Phase 3)
Implement the complete erp-config crate with:
- Data dictionaries (CRUD + items management)
- Dynamic menus (tree structure with role filtering)
- System settings (hierarchical: platform > tenant > org > user)
- Numbering rules (concurrency-safe via PostgreSQL advisory_lock)
- Theme and language configuration (via settings store)
- 6 database migrations (dictionaries, menus, settings, numbering_rules)
- Frontend Settings page with 5 tabs (dictionary, menu, numbering, settings, theme)

Refactor: move RBAC functions (require_permission) from erp-auth to erp-core
to avoid cross-module dependencies.

Add 20 new seed permissions for config module operations.
2026-04-11 08:09:19 +08:00

417 lines
14 KiB
Rust

use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
};
use uuid::Uuid;
use crate::dto::{DictionaryItemResp, DictionaryResp};
use crate::entity::{dictionary, dictionary_item};
use crate::error::{ConfigError, ConfigResult};
use erp_core::events::EventBus;
use erp_core::types::Pagination;
/// Dictionary CRUD service — manage dictionaries and their items within a tenant.
///
/// Dictionaries provide enumerated value sets (e.g. status codes, categories)
/// that can be referenced throughout the system by their unique `code`.
pub struct DictionaryService;
impl DictionaryService {
/// List dictionaries within a tenant with pagination.
///
/// Each dictionary includes its associated items.
/// Returns `(dictionaries, total_count)`.
pub async fn list(
tenant_id: Uuid,
pagination: &Pagination,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<(Vec<DictionaryResp>, u64)> {
let paginator = dictionary::Entity::find()
.filter(dictionary::Column::TenantId.eq(tenant_id))
.filter(dictionary::Column::DeletedAt.is_null())
.paginate(db, pagination.limit());
let total = paginator
.num_items()
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
let models = paginator
.fetch_page(page_index)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let mut resps = Vec::with_capacity(models.len());
for m in &models {
let items = Self::fetch_items(m.id, tenant_id, db).await?;
resps.push(DictionaryResp {
id: m.id,
name: m.name.clone(),
code: m.code.clone(),
description: m.description.clone(),
items,
});
}
Ok((resps, total))
}
/// Fetch a single dictionary by ID, scoped to the given tenant.
///
/// Includes all associated items.
pub async fn get_by_id(
id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<DictionaryResp> {
let model = dictionary::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
let items = Self::fetch_items(model.id, tenant_id, db).await?;
Ok(DictionaryResp {
id: model.id,
name: model.name.clone(),
code: model.code.clone(),
description: model.description.clone(),
items,
})
}
/// Create a new dictionary within the current tenant.
///
/// Validates code uniqueness, then inserts the record and publishes
/// a `dictionary.created` domain event.
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
name: &str,
code: &str,
description: &Option<String>,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<DictionaryResp> {
// Check code uniqueness within tenant
let existing = dictionary::Entity::find()
.filter(dictionary::Column::TenantId.eq(tenant_id))
.filter(dictionary::Column::Code.eq(code))
.filter(dictionary::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(ConfigError::Validation("字典编码已存在".to_string()));
}
let now = Utc::now();
let id = Uuid::now_v7();
let model = dictionary::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(name.to_string()),
code: Set(code.to_string()),
description: Set(description.clone()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus.publish(erp_core::events::DomainEvent::new(
"dictionary.created",
tenant_id,
serde_json::json!({ "dictionary_id": id, "code": code }),
));
Ok(DictionaryResp {
id,
name: name.to_string(),
code: code.to_string(),
description: description.clone(),
items: vec![],
})
}
/// Update editable dictionary fields (name and description).
///
/// Code cannot be changed after creation.
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
name: &Option<String>,
description: &Option<String>,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<DictionaryResp> {
let model = dictionary::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
let mut active: dictionary::ActiveModel = model.into();
if let Some(n) = name {
active.name = Set(n.clone());
}
if let Some(d) = description {
active.description = Set(Some(d.clone()));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
let updated = active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let items = Self::fetch_items(updated.id, tenant_id, db).await?;
Ok(DictionaryResp {
id: updated.id,
name: updated.name.clone(),
code: updated.code.clone(),
description: updated.description.clone(),
items,
})
}
/// Soft-delete a dictionary by setting the `deleted_at` timestamp.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> ConfigResult<()> {
let model = dictionary::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
let mut active: dictionary::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
event_bus.publish(erp_core::events::DomainEvent::new(
"dictionary.deleted",
tenant_id,
serde_json::json!({ "dictionary_id": id }),
));
Ok(())
}
/// Add a new item to a dictionary.
///
/// Validates that the item `value` is unique within the dictionary.
pub async fn add_item(
dictionary_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
label: &str,
value: &str,
sort_order: i32,
color: &Option<String>,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<DictionaryItemResp> {
// Verify the dictionary exists and belongs to this tenant
let _dict = dictionary::Entity::find_by_id(dictionary_id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
// Check value uniqueness within dictionary
let existing = dictionary_item::Entity::find()
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
.filter(dictionary_item::Column::Value.eq(value))
.filter(dictionary_item::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(ConfigError::Validation("字典项值已存在".to_string()));
}
let now = Utc::now();
let id = Uuid::now_v7();
let model = dictionary_item::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
dictionary_id: Set(dictionary_id),
label: Set(label.to_string()),
value: Set(value.to_string()),
sort_order: Set(sort_order),
color: Set(color.clone()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
model
.insert(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(DictionaryItemResp {
id,
dictionary_id,
label: label.to_string(),
value: value.to_string(),
sort_order,
color: color.clone(),
})
}
/// Update editable dictionary item fields (label, value, sort_order, color).
pub async fn update_item(
item_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
label: &Option<String>,
value: &Option<String>,
sort_order: &Option<i32>,
color: &Option<String>,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<DictionaryItemResp> {
let model = dictionary_item::Entity::find_by_id(item_id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
let mut active: dictionary_item::ActiveModel = model.into();
if let Some(l) = label {
active.label = Set(l.clone());
}
if let Some(v) = value {
active.value = Set(v.clone());
}
if let Some(s) = sort_order {
active.sort_order = Set(*s);
}
if let Some(c) = color {
active.color = Set(Some(c.clone()));
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
let updated = active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(DictionaryItemResp {
id: updated.id,
dictionary_id: updated.dictionary_id,
label: updated.label.clone(),
value: updated.value.clone(),
sort_order: updated.sort_order,
color: updated.color.clone(),
})
}
/// Soft-delete a dictionary item by setting the `deleted_at` timestamp.
pub async fn delete_item(
item_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<()> {
let model = dictionary_item::Entity::find_by_id(item_id)
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
let mut active: dictionary_item::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active
.update(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(())
}
/// Look up a dictionary by its `code` and return all items.
///
/// Useful for frontend dropdowns and enum-like lookups.
pub async fn list_items_by_code(
code: &str,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Vec<DictionaryItemResp>> {
let dict = dictionary::Entity::find()
.filter(dictionary::Column::TenantId.eq(tenant_id))
.filter(dictionary::Column::Code.eq(code))
.filter(dictionary::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?
.ok_or_else(|| ConfigError::NotFound(format!("字典编码 '{}' 不存在", code)))?;
Self::fetch_items(dict.id, tenant_id, db).await
}
// ---- 内部辅助方法 ----
/// Fetch all non-deleted items for a given dictionary.
async fn fetch_items(
dictionary_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> ConfigResult<Vec<DictionaryItemResp>> {
let items = dictionary_item::Entity::find()
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
.filter(dictionary_item::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(items
.iter()
.map(|i| DictionaryItemResp {
id: i.id,
dictionary_id: i.dictionary_id,
label: i.label.clone(),
value: i.value.clone(),
sort_order: i.sort_order,
color: i.color.clone(),
})
.collect())
}
}