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.
417 lines
14 KiB
Rust
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())
|
|
}
|
|
}
|