feat(auth): add org/dept/position management, user page, and Phase 2 completion

Complete Phase 2 identity & authentication module:
- Organization CRUD with tree structure (parent_id + materialized path)
- Department CRUD nested under organizations with tree support
- Position CRUD nested under departments
- User management page with table, create/edit modal, role assignment
- Organization architecture page with 3-panel tree layout
- Frontend API layer for orgs/depts/positions
- Sidebar navigation updated with organization menu item
- Fix parse_ttl edge case for strings ending in 'd' (e.g. "invalid")
This commit is contained in:
iven
2026-04-11 04:00:32 +08:00
parent 6fd0288e7c
commit 8a012f6c6a
15 changed files with 2409 additions and 10 deletions

View File

@@ -28,14 +28,14 @@ pub struct AuthState {
/// Falls back to parsing the raw string as seconds if no unit suffix is recognized.
pub fn parse_ttl(ttl: &str) -> i64 {
let ttl = ttl.trim();
if ttl.ends_with('s') {
ttl.trim_end_matches('s').parse::<i64>().unwrap_or(900)
} else if ttl.ends_with('m') {
ttl.trim_end_matches('m').parse::<i64>().unwrap_or(15) * 60
} else if ttl.ends_with('h') {
ttl.trim_end_matches('h').parse::<i64>().unwrap_or(1) * 3600
} else if ttl.ends_with('d') {
ttl.trim_end_matches('d').parse::<i64>().unwrap_or(1) * 86400
if let Some(num) = ttl.strip_suffix('s') {
num.parse::<i64>().unwrap_or(900)
} else if let Some(num) = ttl.strip_suffix('m') {
num.parse::<i64>().map(|n| n * 60).unwrap_or(900)
} else if let Some(num) = ttl.strip_suffix('h') {
num.parse::<i64>().map(|n| n * 3600).unwrap_or(900)
} else if let Some(num) = ttl.strip_suffix('d') {
num.parse::<i64>().map(|n| n * 86400).unwrap_or(900)
} else {
ttl.parse::<i64>().unwrap_or(900)
}

View File

@@ -163,6 +163,14 @@ pub struct CreateDepartmentReq {
pub sort_order: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateDepartmentReq {
pub name: Option<String>,
pub code: Option<String>,
pub manager_id: Option<Uuid>,
pub sort_order: Option<i32>,
}
// --- Position DTOs ---
#[derive(Debug, Serialize, ToSchema)]
@@ -183,3 +191,11 @@ pub struct CreatePositionReq {
pub level: Option<i32>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdatePositionReq {
pub name: Option<String>,
pub code: Option<String>,
pub level: Option<i32>,
pub sort_order: Option<i32>,
}

View File

@@ -1,3 +1,4 @@
pub mod auth_handler;
pub mod org_handler;
pub mod role_handler;
pub mod user_handler;

View File

@@ -0,0 +1,326 @@
use axum::Extension;
use axum::extract::{FromRef, Path, State};
use axum::response::Json;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, TenantContext};
use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
};
use crate::middleware::rbac::require_permission;
use crate::service::dept_service::DeptService;
use crate::service::org_service::OrgService;
use crate::service::position_service::PositionService;
// --- Organization handlers ---
/// GET /api/v1/organizations
///
/// List all organizations within the current tenant as a nested tree.
/// Requires the `organization.list` permission.
pub async fn list_organizations<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<OrganizationResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.list")?;
let tree = OrgService::get_tree(ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(tree)))
}
/// POST /api/v1/organizations
///
/// Create a new organization within the current tenant.
/// Requires the `organization.create` permission.
pub async fn create_organization<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateOrganizationReq>,
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let org = OrgService::create(
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(org)))
}
/// PUT /api/v1/organizations/{id}
///
/// Update editable organization fields (name, code, sort_order).
/// Requires the `organization.update` permission.
pub async fn update_organization<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateOrganizationReq>,
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.update")?;
let org = OrgService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
Ok(Json(ApiResponse::ok(org)))
}
/// DELETE /api/v1/organizations/{id}
///
/// Soft-delete an organization by ID.
/// Requires the `organization.delete` permission.
pub async fn delete_organization<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.delete")?;
OrgService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("组织已删除".to_string()),
}))
}
// --- Department handlers ---
/// GET /api/v1/organizations/{org_id}/departments
///
/// List all departments for an organization as a nested tree.
/// Requires the `department.list` permission.
pub async fn list_departments<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(org_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<DepartmentResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.list")?;
let tree = DeptService::list_tree(org_id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(tree)))
}
/// POST /api/v1/organizations/{org_id}/departments
///
/// Create a new department under the specified organization.
/// Requires the `department.create` permission.
pub async fn create_department<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(org_id): Path<Uuid>,
Json(req): Json<CreateDepartmentReq>,
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let dept = DeptService::create(
org_id,
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(dept)))
}
/// PUT /api/v1/departments/{id}
///
/// Update editable department fields (name, code, manager_id, sort_order).
/// Requires the `department.update` permission.
pub async fn update_department<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateDepartmentReq>,
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.update")?;
let dept = DeptService::update(
id,
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.code,
&req.manager_id,
&req.sort_order,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(dept)))
}
/// DELETE /api/v1/departments/{id}
///
/// Soft-delete a department by ID.
/// Requires the `department.delete` permission.
pub async fn delete_department<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.delete")?;
DeptService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("部门已删除".to_string()),
}))
}
// --- Position handlers ---
/// GET /api/v1/departments/{dept_id}/positions
///
/// List all positions for a department.
/// Requires the `position.list` permission.
pub async fn list_positions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(dept_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<PositionResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.list")?;
let positions = PositionService::list(dept_id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(positions)))
}
/// POST /api/v1/departments/{dept_id}/positions
///
/// Create a new position under the specified department.
/// Requires the `position.create` permission.
pub async fn create_position<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(dept_id): Path<Uuid>,
Json(req): Json<CreatePositionReq>,
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let pos = PositionService::create(
dept_id,
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(pos)))
}
/// PUT /api/v1/positions/{id}
///
/// Update editable position fields (name, code, level, sort_order).
/// Requires the `position.update` permission.
pub async fn update_position<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePositionReq>,
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.update")?;
let pos = PositionService::update(
id,
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.code,
&req.level,
&req.sort_order,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(pos)))
}
/// DELETE /api/v1/positions/{id}
///
/// Soft-delete a position by ID.
/// Requires the `position.delete` permission.
pub async fn delete_position<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.delete")?;
PositionService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("岗位已删除".to_string()),
}))
}

View File

@@ -5,7 +5,7 @@ use erp_core::error::AppResult;
use erp_core::events::EventBus;
use erp_core::module::ErpModule;
use crate::handler::{auth_handler, role_handler, user_handler};
use crate::handler::{auth_handler, org_handler, role_handler, user_handler};
/// Auth module implementing the `ErpModule` trait.
///
@@ -72,6 +72,39 @@ impl AuthModule {
"/permissions",
axum::routing::get(role_handler::list_permissions),
)
// Organization routes
.route(
"/organizations",
axum::routing::get(org_handler::list_organizations)
.post(org_handler::create_organization),
)
.route(
"/organizations/{id}",
axum::routing::put(org_handler::update_organization)
.delete(org_handler::delete_organization),
)
// Department routes (nested under organization)
.route(
"/organizations/{org_id}/departments",
axum::routing::get(org_handler::list_departments)
.post(org_handler::create_department),
)
.route(
"/departments/{id}",
axum::routing::put(org_handler::update_department)
.delete(org_handler::delete_department),
)
// Position routes (nested under department)
.route(
"/departments/{dept_id}/positions",
axum::routing::get(org_handler::list_positions)
.post(org_handler::create_position),
)
.route(
"/positions/{id}",
axum::routing::put(org_handler::update_position)
.delete(org_handler::delete_position),
)
}
}

View File

@@ -0,0 +1,295 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreateDepartmentReq, DepartmentResp};
use crate::entity::department;
use crate::entity::organization;
use crate::error::{AuthError, AuthResult};
use erp_core::events::EventBus;
/// Department CRUD service -- create, read, update, soft-delete departments
/// within an organization, supporting tree-structured hierarchy.
pub struct DeptService;
impl DeptService {
/// Fetch all departments for an organization as a nested tree.
///
/// Root departments (parent_id = None) form the top level.
pub async fn list_tree(
org_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<DepartmentResp>> {
// Verify the organization exists
let _org = organization::Entity::find_by_id(org_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()))?;
let items = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::OrgId.eq(org_id))
.filter(department::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(build_dept_tree(&items))
}
/// Create a new department under the specified organization.
///
/// If `parent_id` is provided, computes `path` from the parent department.
/// Otherwise, path is computed from the organization root.
pub async fn create(
org_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateDepartmentReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<DepartmentResp> {
// Verify the organization exists
let org = organization::Entity::find_by_id(org_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 code uniqueness within tenant if code is provided
if let Some(ref code) = req.code {
let existing = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::Code.eq(code.as_str()))
.filter(department::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()));
}
}
// Compute path from parent department or organization root
let path = if let Some(parent_id) = req.parent_id {
let parent = department::Entity::find_by_id(parent_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?;
let parent_path = parent.path.clone().unwrap_or_default();
Some(format!("{}{}/", parent_path, parent.id))
} else {
// Root department under the organization
let org_path = org.path.clone().unwrap_or_default();
Some(format!("{}{}/", org_path, org.id))
};
let now = Utc::now();
let id = Uuid::now_v7();
let model = department::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
org_id: Set(org_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
parent_id: Set(req.parent_id),
manager_id: Set(req.manager_id),
path: Set(path),
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(
"department.created",
tenant_id,
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
));
Ok(DepartmentResp {
id,
org_id,
name: req.name.clone(),
code: req.code.clone(),
parent_id: req.parent_id,
manager_id: req.manager_id,
path: None,
sort_order: req.sort_order.unwrap_or(0),
children: vec![],
})
}
/// Update editable department fields (name, code, manager_id, sort_order).
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
name: &Option<String>,
code: &Option<String>,
manager_id: &Option<Uuid>,
sort_order: &Option<i32>,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<DepartmentResp> {
let model = department::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
// If code is being changed, check uniqueness
if let Some(new_code) = code {
if Some(new_code) != model.code.as_ref() {
let existing = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::Code.eq(new_code.as_str()))
.filter(department::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()));
}
}
}
let mut active: department::ActiveModel = model.into();
if let Some(n) = name {
active.name = Set(n.clone());
}
if let Some(c) = code {
active.code = Set(Some(c.clone()));
}
if let Some(mgr_id) = manager_id {
active.manager_id = Set(Some(*mgr_id));
}
if let Some(so) = sort_order {
active.sort_order = Set(*so);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(DepartmentResp {
id: updated.id,
org_id: updated.org_id,
name: updated.name.clone(),
code: updated.code.clone(),
parent_id: updated.parent_id,
manager_id: updated.manager_id,
path: updated.path.clone(),
sort_order: updated.sort_order,
children: vec![],
})
}
/// Soft-delete a department by setting the `deleted_at` timestamp.
///
/// Will not delete if child departments exist.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<()> {
let model = department::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
// Check for child departments
let children = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::ParentId.eq(id))
.filter(department::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(),
));
}
let mut active: department::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| AuthError::Validation(e.to_string()))?;
event_bus.publish(erp_core::events::DomainEvent::new(
"department.deleted",
tenant_id,
serde_json::json!({ "dept_id": id }),
));
Ok(())
}
}
/// Build a nested tree of `DepartmentResp` from a flat list of models.
fn build_dept_tree(items: &[department::Model]) -> Vec<DepartmentResp> {
let mut children_map: HashMap<Option<Uuid>, Vec<&department::Model>> = HashMap::new();
for item in items {
children_map.entry(item.parent_id).or_default().push(item);
}
fn build_node(
item: &department::Model,
map: &HashMap<Option<Uuid>, Vec<&department::Model>>,
) -> DepartmentResp {
let children = map
.get(&Some(item.id))
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
.unwrap_or_default();
DepartmentResp {
id: item.id,
org_id: item.org_id,
name: item.name.clone(),
code: item.code.clone(),
parent_id: item.parent_id,
manager_id: item.manager_id,
path: item.path.clone(),
sort_order: item.sort_order,
children,
}
}
children_map
.get(&None)
.map(|root_items| {
root_items
.iter()
.map(|item| build_node(item, &children_map))
.collect()
})
.unwrap_or_default()
}

View File

@@ -1,6 +1,9 @@
pub mod auth_service;
pub mod dept_service;
pub mod org_service;
pub mod password;
pub mod permission_service;
pub mod position_service;
pub mod role_service;
pub mod seed;
pub mod token_service;

View File

@@ -0,0 +1,273 @@
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::organization;
use crate::error::{AuthError, AuthResult};
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()));
}
}
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 }),
));
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![],
})
}
/// 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 {
if 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()));
}
}
}
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);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
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![],
})
}
/// 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(),
));
}
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
.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 }),
));
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.
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,
}
}
children_map
.get(&None)
.map(|root_items| {
root_items
.iter()
.map(|item| build_node(item, &children_map))
.collect()
})
.unwrap_or_default()
}

View File

@@ -0,0 +1,218 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreatePositionReq, PositionResp};
use crate::entity::department;
use crate::entity::position;
use crate::error::{AuthError, AuthResult};
use erp_core::events::EventBus;
/// Position CRUD service -- create, read, update, soft-delete positions
/// within a department.
pub struct PositionService;
impl PositionService {
/// List all positions for a department within the given tenant.
pub async fn list(
dept_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<PositionResp>> {
// Verify the department exists
let _dept = department::Entity::find_by_id(dept_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
let items = position::Entity::find()
.filter(position::Column::TenantId.eq(tenant_id))
.filter(position::Column::DeptId.eq(dept_id))
.filter(position::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(items
.iter()
.map(|p| PositionResp {
id: p.id,
dept_id: p.dept_id,
name: p.name.clone(),
code: p.code.clone(),
level: p.level,
sort_order: p.sort_order,
})
.collect())
}
/// Create a new position under the specified department.
pub async fn create(
dept_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &CreatePositionReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<PositionResp> {
// Verify the department exists
let _dept = department::Entity::find_by_id(dept_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
// Check code uniqueness within tenant if code is provided
if let Some(ref code) = req.code {
let existing = position::Entity::find()
.filter(position::Column::TenantId.eq(tenant_id))
.filter(position::Column::Code.eq(code.as_str()))
.filter(position::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()));
}
}
let now = Utc::now();
let id = Uuid::now_v7();
let model = position::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
dept_id: Set(dept_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
level: Set(req.level.unwrap_or(1)),
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(
"position.created",
tenant_id,
serde_json::json!({ "position_id": id, "dept_id": dept_id, "name": req.name }),
));
Ok(PositionResp {
id,
dept_id,
name: req.name.clone(),
code: req.code.clone(),
level: req.level.unwrap_or(1),
sort_order: req.sort_order.unwrap_or(0),
})
}
/// Update editable position fields (name, code, level, sort_order).
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
name: &Option<String>,
code: &Option<String>,
level: &Option<i32>,
sort_order: &Option<i32>,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<PositionResp> {
let model = position::Entity::find_by_id(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()))?;
// If code is being changed, check uniqueness
if let Some(new_code) = code {
if Some(new_code) != model.code.as_ref() {
let existing = position::Entity::find()
.filter(position::Column::TenantId.eq(tenant_id))
.filter(position::Column::Code.eq(new_code.as_str()))
.filter(position::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()));
}
}
}
let mut active: position::ActiveModel = model.into();
if let Some(n) = name {
active.name = Set(n.clone());
}
if let Some(c) = code {
active.code = Set(Some(c.clone()));
}
if let Some(l) = level {
active.level = Set(*l);
}
if let Some(so) = sort_order {
active.sort_order = Set(*so);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(PositionResp {
id: updated.id,
dept_id: updated.dept_id,
name: updated.name.clone(),
code: updated.code.clone(),
level: updated.level,
sort_order: updated.sort_order,
})
}
/// Soft-delete a position 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 = position::Entity::find_by_id(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 mut active: position::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| AuthError::Validation(e.to_string()))?;
event_bus.publish(erp_core::events::DomainEvent::new(
"position.deleted",
tenant_id,
serde_json::json!({ "position_id": id }),
));
Ok(())
}
}