- Base platform from base.git (ERP base: auth, core, config, message, workflow, plugin) - Created erp-diary module skeleton (lib.rs, dto.rs, error.rs, event.rs, state.rs) - Integrated erp-diary into workspace and erp-server - Added DiaryModule registration in main.rs - Added DiaryState FromRef in state.rs - Diary routes mounted (empty routes, ready for implementation) - Product design spec v1.2 preserved in docs/ - Implementation plan preserved in plans/ Cargo check: OK Cargo test: OK (78+ base tests passing)
260 lines
8.6 KiB
Rust
260 lines
8.6 KiB
Rust
use chrono::Utc;
|
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
|
use uuid::Uuid;
|
|
|
|
use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq};
|
|
use crate::entity::department;
|
|
use crate::entity::position;
|
|
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;
|
|
|
|
/// 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,
|
|
version: p.version,
|
|
})
|
|
.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 }),
|
|
),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
audit_service::record(
|
|
AuditLog::new(tenant_id, Some(operator_id), "position.create", "position")
|
|
.with_resource_id(id),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
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),
|
|
version: 1,
|
|
})
|
|
}
|
|
|
|
/// Update editable position fields (name, code, level, sort_order).
|
|
pub async fn update(
|
|
id: Uuid,
|
|
tenant_id: Uuid,
|
|
operator_id: Uuid,
|
|
req: &UpdatePositionReq,
|
|
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) = &req.code
|
|
&& 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 next_ver = check_version(req.version, model.version)
|
|
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
|
|
|
let mut active: position::ActiveModel = model.into();
|
|
|
|
if let Some(n) = &req.name {
|
|
active.name = Set(n.clone());
|
|
}
|
|
if let Some(c) = &req.code {
|
|
active.code = Set(Some(c.clone()));
|
|
}
|
|
if let Some(l) = &req.level {
|
|
active.level = Set(*l);
|
|
}
|
|
if let Some(so) = &req.sort_order {
|
|
active.sort_order = Set(*so);
|
|
}
|
|
|
|
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), "position.update", "position")
|
|
.with_resource_id(id),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
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,
|
|
version: updated.version,
|
|
})
|
|
}
|
|
|
|
/// 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 current_version = model.version;
|
|
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.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(
|
|
"position.deleted",
|
|
tenant_id,
|
|
serde_json::json!({ "position_id": id }),
|
|
),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
audit_service::record(
|
|
AuditLog::new(tenant_id, Some(operator_id), "position.delete", "position")
|
|
.with_resource_id(id),
|
|
db,
|
|
)
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
}
|