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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth_handler;
|
||||
pub mod org_handler;
|
||||
pub mod role_handler;
|
||||
pub mod user_handler;
|
||||
|
||||
326
crates/erp-auth/src/handler/org_handler.rs
Normal file
326
crates/erp-auth/src/handler/org_handler.rs
Normal 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()),
|
||||
}))
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
295
crates/erp-auth/src/service/dept_service.rs
Normal file
295
crates/erp-auth/src/service/dept_service.rs
Normal 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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
273
crates/erp-auth/src/service/org_service.rs
Normal file
273
crates/erp-auth/src/service/org_service.rs
Normal 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()
|
||||
}
|
||||
218
crates/erp-auth/src/service/position_service.rs
Normal file
218
crates/erp-auth/src/service/position_service.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user