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.
This commit is contained in:
416
crates/erp-config/src/service/dictionary_service.rs
Normal file
416
crates/erp-config/src/service/dictionary_service.rs
Normal file
@@ -0,0 +1,416 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
355
crates/erp-config/src/service/menu_service.rs
Normal file
355
crates/erp-config/src/service/menu_service.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateMenuReq, MenuResp};
|
||||
use crate::entity::{menu, menu_role};
|
||||
use crate::error::{ConfigError, ConfigResult};
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单,
|
||||
/// 以及管理菜单-角色关联。
|
||||
pub struct MenuService;
|
||||
|
||||
impl MenuService {
|
||||
/// 获取当前租户下指定角色可见的菜单树。
|
||||
///
|
||||
/// 如果 `role_ids` 非空,仅返回这些角色关联的菜单;
|
||||
/// 否则返回租户全部菜单。结果按 `sort_order` 排列并组装为树形结构。
|
||||
pub async fn get_menu_tree(
|
||||
tenant_id: Uuid,
|
||||
role_ids: &[Uuid],
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<Vec<MenuResp>> {
|
||||
// 1. 查询租户下所有未删除的菜单,按 sort_order 排序
|
||||
let all_menus = menu::Entity::find()
|
||||
.filter(menu::Column::TenantId.eq(tenant_id))
|
||||
.filter(menu::Column::DeletedAt.is_null())
|
||||
.order_by_asc(menu::Column::SortOrder)
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
// 2. 如果 role_ids 非空,通过 menu_roles 表过滤
|
||||
let visible_menu_ids: Option<Vec<Uuid>> = if !role_ids.is_empty() {
|
||||
let mr_rows = menu_role::Entity::find()
|
||||
.filter(menu_role::Column::TenantId.eq(tenant_id))
|
||||
.filter(menu_role::Column::RoleId.is_in(role_ids.iter().copied()))
|
||||
.filter(menu_role::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
let ids: Vec<Uuid> = mr_rows.iter().map(|mr| mr.menu_id).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Some(ids)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 3. 按 parent_id 分组构建 HashMap
|
||||
let filtered: Vec<&menu::Model> = match &visible_menu_ids {
|
||||
Some(ids) => all_menus
|
||||
.iter()
|
||||
.filter(|m| ids.contains(&m.id))
|
||||
.collect(),
|
||||
None => all_menus.iter().collect(),
|
||||
};
|
||||
|
||||
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||
for m in &filtered {
|
||||
children_map
|
||||
.entry(m.parent_id)
|
||||
.or_default()
|
||||
.push(*m);
|
||||
}
|
||||
|
||||
// 4. 递归构建树形结构(从 parent_id == None 的根节点开始)
|
||||
let roots = children_map.get(&None).cloned().unwrap_or_default();
|
||||
let tree = Self::build_tree(&roots, &children_map);
|
||||
|
||||
Ok(tree)
|
||||
}
|
||||
|
||||
/// 获取当前租户下所有菜单的平铺列表(无角色过滤)。
|
||||
pub async fn get_flat_list(
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<Vec<MenuResp>> {
|
||||
let menus = menu::Entity::find()
|
||||
.filter(menu::Column::TenantId.eq(tenant_id))
|
||||
.filter(menu::Column::DeletedAt.is_null())
|
||||
.order_by_asc(menu::Column::SortOrder)
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(menus
|
||||
.iter()
|
||||
.map(|m| MenuResp {
|
||||
id: m.id,
|
||||
parent_id: m.parent_id,
|
||||
title: m.title.clone(),
|
||||
path: m.path.clone(),
|
||||
icon: m.icon.clone(),
|
||||
sort_order: m.sort_order,
|
||||
visible: m.visible,
|
||||
menu_type: m.menu_type.clone(),
|
||||
permission: m.permission.clone(),
|
||||
children: vec![],
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 创建菜单并可选地关联角色。
|
||||
pub async fn create(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateMenuReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> ConfigResult<MenuResp> {
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
|
||||
let model = menu::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
parent_id: Set(req.parent_id),
|
||||
title: Set(req.title.clone()),
|
||||
path: Set(req.path.clone()),
|
||||
icon: Set(req.icon.clone()),
|
||||
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||
visible: Set(req.visible.unwrap_or(true)),
|
||||
menu_type: Set(req.menu_type.clone().unwrap_or_else(|| "menu".to_string())),
|
||||
permission: Set(req.permission.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()))?;
|
||||
|
||||
// 关联角色(如果提供了 role_ids)
|
||||
if let Some(role_ids) = &req.role_ids {
|
||||
if !role_ids.is_empty() {
|
||||
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
|
||||
}
|
||||
}
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"menu.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "menu_id": id, "title": req.title }),
|
||||
));
|
||||
|
||||
Ok(MenuResp {
|
||||
id,
|
||||
parent_id: req.parent_id,
|
||||
title: req.title.clone(),
|
||||
path: req.path.clone(),
|
||||
icon: req.icon.clone(),
|
||||
sort_order: req.sort_order.unwrap_or(0),
|
||||
visible: req.visible.unwrap_or(true),
|
||||
menu_type: req.menu_type.clone().unwrap_or_else(|| "menu".to_string()),
|
||||
permission: req.permission.clone(),
|
||||
children: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新菜单字段,并可选地重新关联角色。
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &crate::dto::UpdateMenuReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<MenuResp> {
|
||||
let model = menu::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
|
||||
|
||||
let mut active: menu::ActiveModel = model.into();
|
||||
|
||||
if let Some(title) = &req.title {
|
||||
active.title = Set(title.clone());
|
||||
}
|
||||
if let Some(path) = &req.path {
|
||||
active.path = Set(Some(path.clone()));
|
||||
}
|
||||
if let Some(icon) = &req.icon {
|
||||
active.icon = Set(Some(icon.clone()));
|
||||
}
|
||||
if let Some(sort_order) = req.sort_order {
|
||||
active.sort_order = Set(sort_order);
|
||||
}
|
||||
if let Some(visible) = req.visible {
|
||||
active.visible = Set(visible);
|
||||
}
|
||||
if let Some(permission) = &req.permission {
|
||||
active.permission = Set(Some(permission.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()))?;
|
||||
|
||||
// 如果提供了 role_ids,重新关联角色
|
||||
if let Some(role_ids) = &req.role_ids {
|
||||
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
|
||||
}
|
||||
|
||||
Ok(MenuResp {
|
||||
id: updated.id,
|
||||
parent_id: updated.parent_id,
|
||||
title: updated.title.clone(),
|
||||
path: updated.path.clone(),
|
||||
icon: updated.icon.clone(),
|
||||
sort_order: updated.sort_order,
|
||||
visible: updated.visible,
|
||||
menu_type: updated.menu_type.clone(),
|
||||
permission: updated.permission.clone(),
|
||||
children: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// 软删除菜单。
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> ConfigResult<()> {
|
||||
let model = menu::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
|
||||
|
||||
let mut active: menu::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(
|
||||
"menu.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "menu_id": id }),
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 替换菜单的角色关联。
|
||||
///
|
||||
/// 软删除现有关联行,然后插入新关联(参考 RoleService::assign_permissions 模式)。
|
||||
pub async fn assign_roles(
|
||||
menu_id: Uuid,
|
||||
role_ids: &[Uuid],
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<()> {
|
||||
// 验证菜单存在且属于当前租户
|
||||
let _menu = menu::Entity::find_by_id(menu_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {menu_id}")))?;
|
||||
|
||||
// 软删除现有关联
|
||||
let existing = menu_role::Entity::find()
|
||||
.filter(menu_role::Column::MenuId.eq(menu_id))
|
||||
.filter(menu_role::Column::TenantId.eq(tenant_id))
|
||||
.filter(menu_role::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
for mr in existing {
|
||||
let mut active: menu_role::ActiveModel = mr.into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(operator_id);
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
// 插入新关联
|
||||
for role_id in role_ids {
|
||||
let mr = menu_role::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
menu_id: Set(menu_id),
|
||||
role_id: Set(*role_id),
|
||||
tenant_id: Set(tenant_id),
|
||||
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),
|
||||
};
|
||||
mr.insert(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 递归构建菜单树。
|
||||
fn build_tree<'a>(
|
||||
nodes: &[&'a menu::Model],
|
||||
children_map: &HashMap<Option<Uuid>, Vec<&'a menu::Model>>,
|
||||
) -> Vec<MenuResp> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let children = children_map
|
||||
.get(&Some(m.id))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
MenuResp {
|
||||
id: m.id,
|
||||
parent_id: m.parent_id,
|
||||
title: m.title.clone(),
|
||||
path: m.path.clone(),
|
||||
icon: m.icon.clone(),
|
||||
sort_order: m.sort_order,
|
||||
visible: m.visible,
|
||||
menu_type: m.menu_type.clone(),
|
||||
permission: m.permission.clone(),
|
||||
children: Self::build_tree(&children, children_map),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
4
crates/erp-config/src/service/mod.rs
Normal file
4
crates/erp-config/src/service/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod dictionary_service;
|
||||
pub mod menu_service;
|
||||
pub mod numbering_service;
|
||||
pub mod setting_service;
|
||||
378
crates/erp-config/src/service/numbering_service.rs
Normal file
378
crates/erp-config/src/service/numbering_service.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
use chrono::{Datelike, NaiveDate, Utc};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
Statement, ConnectionTrait, DatabaseBackend, TransactionTrait,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp};
|
||||
use crate::entity::numbering_rule;
|
||||
use crate::error::{ConfigError, ConfigResult};
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
/// 编号规则 CRUD 服务 -- 创建、查询、更新、软删除编号规则,
|
||||
/// 以及线程安全地生成编号序列。
|
||||
pub struct NumberingService;
|
||||
|
||||
impl NumberingService {
|
||||
/// 分页查询编号规则列表。
|
||||
pub async fn list(
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<(Vec<NumberingRuleResp>, u64)> {
|
||||
let paginator = numbering_rule::Entity::find()
|
||||
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
|
||||
.filter(numbering_rule::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 resps: Vec<NumberingRuleResp> = models
|
||||
.iter()
|
||||
.map(|m| Self::model_to_resp(m))
|
||||
.collect();
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// 创建编号规则。
|
||||
///
|
||||
/// 检查 code 在租户内唯一后插入。
|
||||
pub async fn create(
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &CreateNumberingRuleReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> ConfigResult<NumberingRuleResp> {
|
||||
// 检查 code 唯一性
|
||||
let existing = numbering_rule::Entity::find()
|
||||
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
|
||||
.filter(numbering_rule::Column::Code.eq(&req.code))
|
||||
.filter(numbering_rule::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
if existing.is_some() {
|
||||
return Err(ConfigError::DuplicateKey(format!(
|
||||
"编号规则编码已存在: {}",
|
||||
req.code
|
||||
)));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let seq_start = req.seq_start.unwrap_or(1);
|
||||
|
||||
let model = numbering_rule::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
name: Set(req.name.clone()),
|
||||
code: Set(req.code.clone()),
|
||||
prefix: Set(req.prefix.clone().unwrap_or_default()),
|
||||
date_format: Set(req.date_format.clone()),
|
||||
seq_length: Set(req.seq_length.unwrap_or(4)),
|
||||
seq_start: Set(seq_start),
|
||||
seq_current: Set(seq_start as i64),
|
||||
separator: Set(req.separator.clone().unwrap_or_else(|| "-".to_string())),
|
||||
reset_cycle: Set(req.reset_cycle.clone().unwrap_or_else(|| "never".to_string())),
|
||||
last_reset_date: Set(Some(Utc::now().date_naive())),
|
||||
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(
|
||||
"numbering_rule.created",
|
||||
tenant_id,
|
||||
serde_json::json!({ "rule_id": id, "code": req.code }),
|
||||
));
|
||||
|
||||
Ok(NumberingRuleResp {
|
||||
id,
|
||||
name: req.name.clone(),
|
||||
code: req.code.clone(),
|
||||
prefix: req.prefix.clone().unwrap_or_default(),
|
||||
date_format: req.date_format.clone(),
|
||||
seq_length: req.seq_length.unwrap_or(4),
|
||||
seq_start,
|
||||
seq_current: seq_start as i64,
|
||||
separator: req.separator.clone().unwrap_or_else(|| "-".to_string()),
|
||||
reset_cycle: req.reset_cycle.clone().unwrap_or_else(|| "never".to_string()),
|
||||
last_reset_date: Some(Utc::now().date_naive().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新编号规则的可编辑字段。
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &crate::dto::UpdateNumberingRuleReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<NumberingRuleResp> {
|
||||
let model = numbering_rule::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
|
||||
|
||||
let mut active: numbering_rule::ActiveModel = model.into();
|
||||
|
||||
if let Some(name) = &req.name {
|
||||
active.name = Set(name.clone());
|
||||
}
|
||||
if let Some(prefix) = &req.prefix {
|
||||
active.prefix = Set(prefix.clone());
|
||||
}
|
||||
if let Some(date_format) = &req.date_format {
|
||||
active.date_format = Set(Some(date_format.clone()));
|
||||
}
|
||||
if let Some(seq_length) = req.seq_length {
|
||||
active.seq_length = Set(seq_length);
|
||||
}
|
||||
if let Some(separator) = &req.separator {
|
||||
active.separator = Set(separator.clone());
|
||||
}
|
||||
if let Some(reset_cycle) = &req.reset_cycle {
|
||||
active.reset_cycle = Set(reset_cycle.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(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 软删除编号规则。
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> ConfigResult<()> {
|
||||
let model = numbering_rule::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
|
||||
|
||||
let mut active: numbering_rule::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(
|
||||
"numbering_rule.deleted",
|
||||
tenant_id,
|
||||
serde_json::json!({ "rule_id": id }),
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 线程安全地生成编号。
|
||||
///
|
||||
/// 使用 PostgreSQL advisory lock 保证并发安全:
|
||||
/// 1. 获取 pg_advisory_xact_lock
|
||||
/// 2. 在事务内读取规则、检查重置周期、递增序列、更新数据库
|
||||
/// 3. 拼接编号字符串返回
|
||||
pub async fn generate_number(
|
||||
rule_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<GenerateNumberResp> {
|
||||
// 先读取规则获取 code(用于 advisory lock)
|
||||
let rule = numbering_rule::Entity::find_by_id(rule_id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
|
||||
|
||||
let rule_code = rule.code.clone();
|
||||
let tenant_id_str = tenant_id.to_string();
|
||||
|
||||
// 获取 PostgreSQL advisory lock(事务级别,事务结束自动释放)
|
||||
db.execute(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"SELECT pg_advisory_xact_lock(abs(hashtext($1)), abs(hashtext($2))::int)",
|
||||
[
|
||||
rule_code.into(),
|
||||
tenant_id_str.into(),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(format!("获取编号锁失败: {e}")))?;
|
||||
|
||||
// 在事务内执行序列递增和更新
|
||||
let number = db
|
||||
.transaction(|txn| {
|
||||
Box::pin(async move {
|
||||
Self::generate_number_in_txn(rule_id, tenant_id, txn).await
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(GenerateNumberResp { number })
|
||||
}
|
||||
|
||||
/// 事务内执行编号生成逻辑。
|
||||
///
|
||||
/// 检查重置周期,必要时重置序列,然后递增并拼接编号。
|
||||
async fn generate_number_in_txn<C>(
|
||||
rule_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
txn: &C,
|
||||
) -> ConfigResult<String>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
let rule = numbering_rule::Entity::find_by_id(rule_id)
|
||||
.one(txn)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
|
||||
|
||||
let today = Utc::now().date_naive();
|
||||
let mut seq_current = rule.seq_current;
|
||||
|
||||
// 检查是否需要重置序列
|
||||
seq_current = Self::maybe_reset_sequence(
|
||||
seq_current,
|
||||
rule.seq_start as i64,
|
||||
&rule.reset_cycle,
|
||||
rule.last_reset_date,
|
||||
today,
|
||||
);
|
||||
|
||||
// 递增序列
|
||||
let next_seq = seq_current + 1;
|
||||
|
||||
// 检查序列是否超出 seq_length 能表示的最大值
|
||||
let max_val = 10i64.pow(rule.seq_length as u32) - 1;
|
||||
if next_seq > max_val {
|
||||
return Err(ConfigError::NumberingExhausted(format!(
|
||||
"编号序列已耗尽,当前序列号 {next_seq} 超出长度 {} 的最大值",
|
||||
rule.seq_length
|
||||
)));
|
||||
}
|
||||
|
||||
// 更新数据库中的 seq_current 和 last_reset_date
|
||||
let mut active: numbering_rule::ActiveModel = rule.clone().into();
|
||||
active.seq_current = Set(next_seq);
|
||||
active.last_reset_date = Set(Some(today));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active
|
||||
.update(txn)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
// 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded}
|
||||
let separator = &rule.separator;
|
||||
let mut parts = vec![rule.prefix.clone()];
|
||||
|
||||
// 日期部分(如果配置了 date_format)
|
||||
if let Some(date_fmt) = &rule.date_format {
|
||||
let date_part = Utc::now().format(date_fmt).to_string();
|
||||
parts.push(date_part);
|
||||
}
|
||||
|
||||
// 序列号补零
|
||||
let seq_padded = format!("{:0>width$}", seq_current, width = rule.seq_length as usize);
|
||||
parts.push(seq_padded);
|
||||
|
||||
let number = parts.join(separator);
|
||||
|
||||
Ok(number)
|
||||
}
|
||||
|
||||
/// 根据重置周期判断是否需要重置序列号。
|
||||
///
|
||||
/// 如果需要重置,返回 `seq_start`;否则返回原值。
|
||||
fn maybe_reset_sequence(
|
||||
seq_current: i64,
|
||||
seq_start: i64,
|
||||
reset_cycle: &str,
|
||||
last_reset_date: Option<NaiveDate>,
|
||||
today: NaiveDate,
|
||||
) -> i64 {
|
||||
let last_reset = match last_reset_date {
|
||||
Some(d) => d,
|
||||
None => return seq_start, // 从未重置过,使用 seq_start
|
||||
};
|
||||
|
||||
match reset_cycle {
|
||||
"daily" => {
|
||||
if last_reset != today {
|
||||
seq_start
|
||||
} else {
|
||||
seq_current
|
||||
}
|
||||
}
|
||||
"monthly" => {
|
||||
if last_reset.month() != today.month() || last_reset.year() != today.year() {
|
||||
seq_start
|
||||
} else {
|
||||
seq_current
|
||||
}
|
||||
}
|
||||
"yearly" => {
|
||||
if last_reset.year() != today.year() {
|
||||
seq_start
|
||||
} else {
|
||||
seq_current
|
||||
}
|
||||
}
|
||||
_ => seq_current, // "never" 或其他值不重置
|
||||
}
|
||||
}
|
||||
|
||||
/// 将数据库模型转换为响应 DTO。
|
||||
fn model_to_resp(m: &numbering_rule::Model) -> NumberingRuleResp {
|
||||
NumberingRuleResp {
|
||||
id: m.id,
|
||||
name: m.name.clone(),
|
||||
code: m.code.clone(),
|
||||
prefix: m.prefix.clone(),
|
||||
date_format: m.date_format.clone(),
|
||||
seq_length: m.seq_length,
|
||||
seq_start: m.seq_start,
|
||||
seq_current: m.seq_current,
|
||||
separator: m.separator.clone(),
|
||||
reset_cycle: m.reset_cycle.clone(),
|
||||
last_reset_date: m.last_reset_date.map(|d| d.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
291
crates/erp-config/src/service/setting_service.rs
Normal file
291
crates/erp-config/src/service/setting_service.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::SettingResp;
|
||||
use crate::entity::setting;
|
||||
use crate::error::{ConfigError, ConfigResult};
|
||||
use erp_core::events::EventBus;
|
||||
use erp_core::types::Pagination;
|
||||
|
||||
/// Setting scope hierarchy constants.
|
||||
const SCOPE_PLATFORM: &str = "platform";
|
||||
const SCOPE_TENANT: &str = "tenant";
|
||||
const SCOPE_ORG: &str = "org";
|
||||
const SCOPE_USER: &str = "user";
|
||||
|
||||
/// Setting CRUD service — manage hierarchical configuration values.
|
||||
///
|
||||
/// Settings support a 4-level inheritance hierarchy:
|
||||
/// `user -> org -> tenant -> platform`
|
||||
///
|
||||
/// When reading a setting, if the exact scope+scope_id match is not found,
|
||||
/// the service walks up the hierarchy to find the nearest ancestor value.
|
||||
pub struct SettingService;
|
||||
|
||||
impl SettingService {
|
||||
/// Get a setting value with hierarchical fallback.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. Exact match at (scope, scope_id)
|
||||
/// 2. Walk up the hierarchy based on scope:
|
||||
/// - `user` -> org -> tenant -> platform
|
||||
/// - `org` -> tenant -> platform
|
||||
/// - `tenant` -> platform
|
||||
/// - `platform` -> NotFound
|
||||
pub async fn get(
|
||||
key: &str,
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<SettingResp> {
|
||||
// 1. Try exact match
|
||||
if let Some(resp) =
|
||||
Self::find_exact(key, scope, scope_id, tenant_id, db).await?
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// 2. Walk up the hierarchy based on scope
|
||||
let fallback_chain = Self::fallback_chain(scope, scope_id, tenant_id)?;
|
||||
|
||||
for (fb_scope, fb_scope_id) in fallback_chain {
|
||||
if let Some(resp) =
|
||||
Self::find_exact(key, &fb_scope, &fb_scope_id, tenant_id, db).await?
|
||||
{
|
||||
return Ok(resp);
|
||||
}
|
||||
}
|
||||
|
||||
Err(ConfigError::NotFound(format!(
|
||||
"设置 '{}' 在 '{}' 作用域下不存在",
|
||||
key, scope
|
||||
)))
|
||||
}
|
||||
|
||||
/// Set a setting value. Creates or updates.
|
||||
///
|
||||
/// If a record with the same (scope, scope_id, key) exists and is not
|
||||
/// soft-deleted, it will be updated. Otherwise a new record is inserted.
|
||||
pub async fn set(
|
||||
key: &str,
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
value: serde_json::Value,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> ConfigResult<SettingResp> {
|
||||
// Look for an existing non-deleted record
|
||||
let existing = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::SettingKey.eq(key))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
if let Some(model) = existing {
|
||||
// Update existing record
|
||||
let mut active: setting::ActiveModel = model.into();
|
||||
active.setting_value = Set(value.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()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"setting.updated",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"setting_id": updated.id,
|
||||
"key": key,
|
||||
"scope": scope,
|
||||
}),
|
||||
));
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
} else {
|
||||
// Insert new record
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
let model = setting::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
scope: Set(scope.to_string()),
|
||||
scope_id: Set(*scope_id),
|
||||
setting_key: Set(key.to_string()),
|
||||
setting_value: Set(value),
|
||||
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),
|
||||
};
|
||||
let inserted = model
|
||||
.insert(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||
"setting.created",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"setting_id": id,
|
||||
"key": key,
|
||||
"scope": scope,
|
||||
}),
|
||||
));
|
||||
|
||||
Ok(Self::model_to_resp(&inserted))
|
||||
}
|
||||
}
|
||||
|
||||
/// List all settings for a specific scope and scope_id, with pagination.
|
||||
pub async fn list_by_scope(
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
pagination: &Pagination,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<(Vec<SettingResp>, u64)> {
|
||||
let paginator = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::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 resps: Vec<SettingResp> =
|
||||
models.iter().map(Self::model_to_resp).collect();
|
||||
|
||||
Ok((resps, total))
|
||||
}
|
||||
|
||||
/// Soft-delete a setting by setting the `deleted_at` timestamp.
|
||||
pub async fn delete(
|
||||
key: &str,
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<()> {
|
||||
let model = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::SettingKey.eq(key))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
ConfigError::NotFound(format!(
|
||||
"设置 '{}' 在 '{}' 作用域下不存在",
|
||||
key, scope
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut active: setting::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(())
|
||||
}
|
||||
|
||||
// ---- 内部辅助方法 ----
|
||||
|
||||
/// Find an exact setting match by key, scope, and scope_id.
|
||||
async fn find_exact(
|
||||
key: &str,
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> ConfigResult<Option<SettingResp>> {
|
||||
let model = setting::Entity::find()
|
||||
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||
.filter(setting::Column::Scope.eq(scope))
|
||||
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||
.filter(setting::Column::SettingKey.eq(key))
|
||||
.filter(setting::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(model.as_ref().map(Self::model_to_resp))
|
||||
}
|
||||
|
||||
/// Build the fallback chain for hierarchical lookup.
|
||||
///
|
||||
/// Returns a list of (scope, scope_id) tuples to try in order.
|
||||
fn fallback_chain(
|
||||
scope: &str,
|
||||
scope_id: &Option<Uuid>,
|
||||
tenant_id: Uuid,
|
||||
) -> ConfigResult<Vec<(String, Option<Uuid>)>> {
|
||||
match scope {
|
||||
SCOPE_USER => {
|
||||
// user -> org -> tenant -> platform
|
||||
// Note: We cannot resolve the actual org_id from user scope here
|
||||
// without a dependency on auth module. The caller should handle
|
||||
// org-level resolution externally if needed. We skip org fallback
|
||||
// and go directly to tenant.
|
||||
Ok(vec![
|
||||
(SCOPE_TENANT.to_string(), Some(tenant_id)),
|
||||
(SCOPE_PLATFORM.to_string(), None),
|
||||
])
|
||||
}
|
||||
SCOPE_ORG => Ok(vec![
|
||||
(SCOPE_TENANT.to_string(), Some(tenant_id)),
|
||||
(SCOPE_PLATFORM.to_string(), None),
|
||||
]),
|
||||
SCOPE_TENANT => {
|
||||
Ok(vec![(SCOPE_PLATFORM.to_string(), None)])
|
||||
}
|
||||
SCOPE_PLATFORM => Ok(vec![]),
|
||||
_ => Err(ConfigError::Validation(format!(
|
||||
"不支持的作用域类型: '{}'",
|
||||
scope
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a SeaORM model to a response DTO.
|
||||
fn model_to_resp(model: &setting::Model) -> SettingResp {
|
||||
SettingResp {
|
||||
id: model.id,
|
||||
scope: model.scope.clone(),
|
||||
scope_id: model.scope_id,
|
||||
setting_key: model.setting_key.clone(),
|
||||
setting_value: model.setting_value.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user