Phase 6 功能补全: - P1-3: 消息 SSE 实时推送端点 + 前端 EventSource 连接 - P1-6: ServiceTask HTTP 调用能力 (reqwest GET/POST) - P1-7: user.deleted 事件处理 — 终止相关流程实例 - P1-8: 任务认领 (claim) 端点 + handler - P1-9: 超时检查器发布 task.timeout 事件 - P1-15: 组织/部门名称唯一性校验 (create + update) - P1-18: 消息群发 fan-out (role/department/all 批量投递) Phase 7 P3-P4 收尾: - PluginAdmin purge 按钮状态修复 - ChangePassword 最小 8 字符 + 新旧密码不同验证 - AuditLogViewer 用户名缓存 + 扩展资源类型 - InstanceMonitor 通过 definition 缓存解析 node_name - NotificationPreferences DND 时间范围校验
495 lines
16 KiB
Rust
495 lines
16 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use chrono::Utc;
|
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
|
use uuid::Uuid;
|
|
|
|
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
|
|
use crate::entity::department;
|
|
use crate::entity::organization;
|
|
use crate::error::{AuthError, AuthResult};
|
|
use erp_core::audit::AuditLog;
|
|
use erp_core::audit_service;
|
|
use erp_core::error::check_version;
|
|
use erp_core::events::EventBus;
|
|
|
|
/// Organization CRUD service -- create, read, update, soft-delete organizations
|
|
/// within a tenant, supporting tree-structured hierarchy with path and level.
|
|
pub struct OrgService;
|
|
|
|
impl OrgService {
|
|
/// Fetch all organizations for a tenant as a flat list (not deleted).
|
|
pub async fn list_flat(
|
|
tenant_id: Uuid,
|
|
db: &sea_orm::DatabaseConnection,
|
|
) -> AuthResult<Vec<organization::Model>> {
|
|
let items = organization::Entity::find()
|
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
|
.filter(organization::Column::DeletedAt.is_null())
|
|
.all(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
|
|
Ok(items)
|
|
}
|
|
|
|
/// Fetch all organizations for a tenant as a nested tree.
|
|
///
|
|
/// Root nodes have `parent_id = None`. Children are grouped by `parent_id`.
|
|
pub async fn get_tree(
|
|
tenant_id: Uuid,
|
|
db: &sea_orm::DatabaseConnection,
|
|
) -> AuthResult<Vec<OrganizationResp>> {
|
|
let items = Self::list_flat(tenant_id, db).await?;
|
|
Ok(build_org_tree(&items))
|
|
}
|
|
|
|
/// Create a new organization within the current tenant.
|
|
///
|
|
/// If `parent_id` is provided, computes `path` from the parent's path and id,
|
|
/// and sets `level = parent.level + 1`. Otherwise, level defaults to 1.
|
|
pub async fn create(
|
|
tenant_id: Uuid,
|
|
operator_id: Uuid,
|
|
req: &CreateOrganizationReq,
|
|
db: &sea_orm::DatabaseConnection,
|
|
event_bus: &EventBus,
|
|
) -> AuthResult<OrganizationResp> {
|
|
// Check code uniqueness within tenant if code is provided
|
|
if let Some(ref code) = req.code {
|
|
let existing = organization::Entity::find()
|
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
|
.filter(organization::Column::Code.eq(code.as_str()))
|
|
.filter(organization::Column::DeletedAt.is_null())
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
if existing.is_some() {
|
|
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
|
}
|
|
}
|
|
|
|
// Check name uniqueness within tenant
|
|
let name_exists = organization::Entity::find()
|
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
|
.filter(organization::Column::Name.eq(&req.name))
|
|
.filter(organization::Column::DeletedAt.is_null())
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
if name_exists.is_some() {
|
|
return Err(AuthError::Validation("组织名称已存在".to_string()));
|
|
}
|
|
|
|
let (path, level) = if let Some(parent_id) = req.parent_id {
|
|
let parent = organization::Entity::find_by_id(parent_id)
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
|
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
|
|
.ok_or_else(|| AuthError::Validation("父级组织不存在".to_string()))?;
|
|
|
|
let parent_path = parent.path.clone().unwrap_or_default();
|
|
let computed_path = format!("{}{}/", parent_path, parent.id);
|
|
(Some(computed_path), parent.level + 1)
|
|
} else {
|
|
(None, 1)
|
|
};
|
|
|
|
let now = Utc::now();
|
|
let id = Uuid::now_v7();
|
|
let model = organization::ActiveModel {
|
|
id: Set(id),
|
|
tenant_id: Set(tenant_id),
|
|
name: Set(req.name.clone()),
|
|
code: Set(req.code.clone()),
|
|
parent_id: Set(req.parent_id),
|
|
path: Set(path),
|
|
level: Set(level),
|
|
sort_order: Set(req.sort_order.unwrap_or(0)),
|
|
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| AuthError::Validation(e.to_string()))?;
|
|
|
|
event_bus
|
|
.publish(
|
|
erp_core::events::DomainEvent::new(
|
|
"organization.created",
|
|
tenant_id,
|
|
serde_json::json!({ "org_id": id, "name": req.name }),
|
|
),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
audit_service::record(
|
|
AuditLog::new(
|
|
tenant_id,
|
|
Some(operator_id),
|
|
"organization.create",
|
|
"organization",
|
|
)
|
|
.with_resource_id(id),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
Ok(OrganizationResp {
|
|
id,
|
|
name: req.name.clone(),
|
|
code: req.code.clone(),
|
|
parent_id: req.parent_id,
|
|
path: None,
|
|
level,
|
|
sort_order: req.sort_order.unwrap_or(0),
|
|
children: vec![],
|
|
version: 1,
|
|
})
|
|
}
|
|
|
|
/// Update editable organization fields (name, code, sort_order).
|
|
pub async fn update(
|
|
id: Uuid,
|
|
tenant_id: Uuid,
|
|
operator_id: Uuid,
|
|
req: &UpdateOrganizationReq,
|
|
db: &sea_orm::DatabaseConnection,
|
|
) -> AuthResult<OrganizationResp> {
|
|
let model = organization::Entity::find_by_id(id)
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
|
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
|
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
|
|
|
// If code is being changed, check uniqueness
|
|
if let Some(ref new_code) = req.code
|
|
&& Some(new_code) != model.code.as_ref()
|
|
{
|
|
let existing = organization::Entity::find()
|
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
|
.filter(organization::Column::Code.eq(new_code.as_str()))
|
|
.filter(organization::Column::DeletedAt.is_null())
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
if existing.is_some() {
|
|
return Err(AuthError::Validation("组织编码已存在".to_string()));
|
|
}
|
|
}
|
|
|
|
// If name is being changed, check uniqueness (exclude self)
|
|
if let Some(ref new_name) = req.name
|
|
&& new_name != &model.name
|
|
{
|
|
let name_exists = organization::Entity::find()
|
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
|
.filter(organization::Column::Name.eq(new_name.as_str()))
|
|
.filter(organization::Column::DeletedAt.is_null())
|
|
.filter(organization::Column::Id.ne(id))
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
if name_exists.is_some() {
|
|
return Err(AuthError::Validation("组织名称已存在".to_string()));
|
|
}
|
|
}
|
|
|
|
let next_ver = check_version(req.version, model.version)
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
|
|
let mut active: organization::ActiveModel = model.into();
|
|
|
|
if let Some(ref name) = req.name {
|
|
active.name = Set(name.clone());
|
|
}
|
|
if let Some(ref code) = req.code {
|
|
active.code = Set(Some(code.clone()));
|
|
}
|
|
if let Some(sort_order) = req.sort_order {
|
|
active.sort_order = Set(sort_order);
|
|
}
|
|
|
|
active.updated_at = Set(Utc::now());
|
|
active.updated_by = Set(operator_id);
|
|
active.version = Set(next_ver);
|
|
|
|
let updated = active
|
|
.update(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
|
|
audit_service::record(
|
|
AuditLog::new(
|
|
tenant_id,
|
|
Some(operator_id),
|
|
"organization.update",
|
|
"organization",
|
|
)
|
|
.with_resource_id(id),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
Ok(OrganizationResp {
|
|
id: updated.id,
|
|
name: updated.name.clone(),
|
|
code: updated.code.clone(),
|
|
parent_id: updated.parent_id,
|
|
path: updated.path.clone(),
|
|
level: updated.level,
|
|
sort_order: updated.sort_order,
|
|
children: vec![],
|
|
version: updated.version,
|
|
})
|
|
}
|
|
|
|
/// Soft-delete an organization 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,
|
|
) -> AuthResult<()> {
|
|
let model = organization::Entity::find_by_id(id)
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?
|
|
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
|
|
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
|
|
|
|
// Check for child organizations
|
|
let children = organization::Entity::find()
|
|
.filter(organization::Column::TenantId.eq(tenant_id))
|
|
.filter(organization::Column::ParentId.eq(id))
|
|
.filter(organization::Column::DeletedAt.is_null())
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
if children.is_some() {
|
|
return Err(AuthError::Validation(
|
|
"该组织下存在子组织,无法删除".to_string(),
|
|
));
|
|
}
|
|
|
|
// Check for departments under this organization
|
|
let depts = department::Entity::find()
|
|
.filter(department::Column::TenantId.eq(tenant_id))
|
|
.filter(department::Column::OrgId.eq(id))
|
|
.filter(department::Column::DeletedAt.is_null())
|
|
.one(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
if depts.is_some() {
|
|
return Err(AuthError::Validation(
|
|
"该组织下存在部门,无法删除".to_string(),
|
|
));
|
|
}
|
|
|
|
let current_version = model.version;
|
|
let mut active: organization::ActiveModel = model.into();
|
|
active.deleted_at = Set(Some(Utc::now()));
|
|
active.updated_at = Set(Utc::now());
|
|
active.updated_by = Set(operator_id);
|
|
active.version = Set(current_version + 1);
|
|
active
|
|
.update(db)
|
|
.await
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
|
|
event_bus
|
|
.publish(
|
|
erp_core::events::DomainEvent::new(
|
|
"organization.deleted",
|
|
tenant_id,
|
|
serde_json::json!({ "org_id": id }),
|
|
),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
audit_service::record(
|
|
AuditLog::new(
|
|
tenant_id,
|
|
Some(operator_id),
|
|
"organization.delete",
|
|
"organization",
|
|
)
|
|
.with_resource_id(id),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Build a nested tree of `OrganizationResp` from a flat list of models.
|
|
///
|
|
/// Root nodes (parent_id = None) form the top level. Each node recursively
|
|
/// includes its children grouped by parent_id.
|
|
pub(crate) fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
|
|
let mut children_map: HashMap<Option<Uuid>, Vec<&organization::Model>> = HashMap::new();
|
|
for item in items {
|
|
children_map.entry(item.parent_id).or_default().push(item);
|
|
}
|
|
|
|
fn build_node(
|
|
item: &organization::Model,
|
|
map: &HashMap<Option<Uuid>, Vec<&organization::Model>>,
|
|
) -> OrganizationResp {
|
|
let children = map
|
|
.get(&Some(item.id))
|
|
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
|
|
.unwrap_or_default();
|
|
OrganizationResp {
|
|
id: item.id,
|
|
name: item.name.clone(),
|
|
code: item.code.clone(),
|
|
parent_id: item.parent_id,
|
|
path: item.path.clone(),
|
|
level: item.level,
|
|
sort_order: item.sort_order,
|
|
children,
|
|
version: item.version,
|
|
}
|
|
}
|
|
|
|
children_map
|
|
.get(&None)
|
|
.map(|root_items| {
|
|
root_items
|
|
.iter()
|
|
.map(|item| build_node(item, &children_map))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use chrono::Utc;
|
|
use uuid::Uuid;
|
|
|
|
use crate::entity::organization;
|
|
|
|
use super::*;
|
|
|
|
fn make_org(
|
|
id: Uuid,
|
|
tenant_id: Uuid,
|
|
name: &str,
|
|
parent_id: Option<Uuid>,
|
|
level: i32,
|
|
version: i32,
|
|
) -> organization::Model {
|
|
organization::Model {
|
|
id,
|
|
tenant_id,
|
|
name: name.to_string(),
|
|
code: None,
|
|
parent_id,
|
|
path: None,
|
|
level,
|
|
sort_order: 0,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
created_by: Uuid::now_v7(),
|
|
updated_by: Uuid::now_v7(),
|
|
deleted_at: None,
|
|
version,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_org_tree_empty() {
|
|
let tree = build_org_tree(&[]);
|
|
assert!(tree.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn build_org_tree_single_root() {
|
|
let tid = Uuid::now_v7();
|
|
let root_id = Uuid::now_v7();
|
|
let items = vec![make_org(root_id, tid, "总公司", None, 1, 1)];
|
|
|
|
let tree = build_org_tree(&items);
|
|
assert_eq!(tree.len(), 1);
|
|
assert_eq!(tree[0].name, "总公司");
|
|
assert!(tree[0].children.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn build_org_tree_multiple_roots() {
|
|
let tid = Uuid::now_v7();
|
|
let items = vec![
|
|
make_org(Uuid::now_v7(), tid, "公司A", None, 1, 1),
|
|
make_org(Uuid::now_v7(), tid, "公司B", None, 1, 1),
|
|
];
|
|
|
|
let tree = build_org_tree(&items);
|
|
assert_eq!(tree.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn build_org_tree_nested_children() {
|
|
let tid = Uuid::now_v7();
|
|
let root_id = Uuid::now_v7();
|
|
let child1_id = Uuid::now_v7();
|
|
let child2_id = Uuid::now_v7();
|
|
let grandchild_id = Uuid::now_v7();
|
|
|
|
let items = vec![
|
|
make_org(root_id, tid, "总公司", None, 1, 1),
|
|
make_org(child1_id, tid, "分公司A", Some(root_id), 2, 1),
|
|
make_org(child2_id, tid, "分公司B", Some(root_id), 2, 1),
|
|
make_org(grandchild_id, tid, "部门A1", Some(child1_id), 3, 1),
|
|
];
|
|
|
|
let tree = build_org_tree(&items);
|
|
assert_eq!(tree.len(), 1); // one root
|
|
assert_eq!(tree[0].children.len(), 2); // two children
|
|
assert_eq!(tree[0].children[0].children.len(), 1); // one grandchild
|
|
assert_eq!(tree[0].children[0].children[0].name, "部门A1");
|
|
}
|
|
|
|
#[test]
|
|
fn build_org_tree_deep_nesting() {
|
|
let tid = Uuid::now_v7();
|
|
let l1 = Uuid::now_v7();
|
|
let l2 = Uuid::now_v7();
|
|
let l3 = Uuid::now_v7();
|
|
let l4 = Uuid::now_v7();
|
|
|
|
let items = vec![
|
|
make_org(l1, tid, "L1", None, 1, 1),
|
|
make_org(l2, tid, "L2", Some(l1), 2, 1),
|
|
make_org(l3, tid, "L3", Some(l2), 3, 1),
|
|
make_org(l4, tid, "L4", Some(l3), 4, 1),
|
|
];
|
|
|
|
let tree = build_org_tree(&items);
|
|
assert_eq!(tree.len(), 1);
|
|
assert_eq!(tree[0].children[0].children[0].children[0].name, "L4");
|
|
}
|
|
|
|
#[test]
|
|
fn build_org_tree_preserves_version() {
|
|
let tid = Uuid::now_v7();
|
|
let root_id = Uuid::now_v7();
|
|
let items = vec![make_org(root_id, tid, "测试", None, 1, 5)];
|
|
|
|
let tree = build_org_tree(&items);
|
|
assert_eq!(tree[0].version, 5);
|
|
}
|
|
}
|