Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
部门模块
"""
from core.dept.model import Dept
from core.dept.service import DeptService
__all__ = ["Dept", "DeptService"]
+431
View File
@@ -0,0 +1,431 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dept API - 部门管理接口
提供部门的 CRUD 操作和树形结构查询
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse, ResponseModel
from core.dept.schema import (
DeptCreate, DeptUpdate, DeptResponse, DeptTreeNode, DeptSimple,
DeptBatchDeleteIn, DeptBatchDeleteOut, DeptBatchUpdateStatusIn, DeptBatchUpdateStatusOut,
DeptPathOut, DeptUserSchema, DeptUserIn, DeptStatsResponse, DeptMoveRequest, DeptSearchRequest
)
from core.dept.service import DeptService
router = APIRouter(prefix="/dept", tags=["部门管理"])
@router.post("", response_model=DeptResponse, summary="创建部门")
async def create_dept(data: DeptCreate, db: AsyncSession = Depends(get_db)):
"""创建部门"""
# 编码唯一性校验
if data.code:
if not await DeptService.check_unique(db, field="code", value=data.code):
raise HTTPException(status_code=400, detail="部门编码已存在")
# 检查父部门是否存在
if data.parent_id:
parent = await DeptService.get_by_id(db, data.parent_id)
if not parent:
raise HTTPException(status_code=400, detail="父部门不存在")
dept = await DeptService.create(db=db, data=data)
return _build_dept_response(dept)
@router.get("/tree", response_model=List[DeptTreeNode], summary="获取部门树")
async def get_dept_tree(
parent_id: Optional[str] = Query(None, alias="parentId", description="父部门ID"),
db: AsyncSession = Depends(get_db)
):
"""获取部门树形结构"""
return await DeptService.get_tree(db, parent_id)
@router.get("", response_model=PaginatedResponse[DeptResponse], summary="获取部门列表")
async def get_dept_list(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize", description="每页数量"),
name: Optional[str] = Query(None, description="部门名称"),
code: Optional[str] = Query(None, description="部门编码"),
status: Optional[bool] = Query(None, description="部门状态"),
dept_type: Optional[str] = Query(None, alias="deptType", description="部门类型"),
parent_id: Optional[str] = Query(None, alias="parentId", description="父部门ID"),
db: AsyncSession = Depends(get_db)
):
"""获取部门列表(分页)"""
from core.dept.model import Dept
filters = []
if name:
filters.append(Dept.name.ilike(f"%{name}%"))
if code:
filters.append(Dept.code.ilike(f"%{code}%"))
if status is not None:
filters.append(Dept.status == status)
if dept_type:
filters.append(Dept.dept_type == dept_type)
if parent_id:
filters.append(Dept.parent_id == parent_id)
items, total = await DeptService.get_list(db, page=page, page_size=page_size, filters=filters)
return PaginatedResponse(
items=[_build_dept_response(item) for item in items],
total=total
)
@router.get("/simple", response_model=List[DeptSimple], summary="获取部门简单列表")
async def get_dept_simple_list(
status: Optional[bool] = Query(None, description="部门状态"),
db: AsyncSession = Depends(get_db)
):
"""获取部门简单列表(用于选择器)"""
from core.dept.model import Dept
filters = []
if status is not None:
filters.append(Dept.status == status)
items, _ = await DeptService.get_list(db, page=1, page_size=1000, filters=filters)
return [DeptSimple.model_validate(item) for item in items]
@router.get("/export/excel", summary="导出部门Excel")
async def export_dept_excel(db: AsyncSession = Depends(get_db)):
"""导出部门到Excel"""
output = await DeptService.export_to_excel(db)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=depts.xlsx"}
)
@router.get("/import/template", summary="下载部门导入模板")
async def download_dept_template():
"""下载部门导入模板"""
output = DeptService.get_import_template()
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=dept_template.xlsx"}
)
@router.post("/import/excel", response_model=ResponseModel, summary="导入部门Excel")
async def import_dept_excel(
file: UploadFile = File(..., description="Excel文件(.xlsx)"),
db: AsyncSession = Depends(get_db)
):
"""从Excel导入部门"""
if not file.filename.endswith(".xlsx"):
raise HTTPException(status_code=400, detail="只支持.xlsx格式")
content = await file.read()
success, fail = await DeptService.import_from_excel(db, content)
return ResponseModel(message=f"成功{success}条,失败{fail}", data={"success": success, "fail": fail})
@router.get("/check/unique", response_model=ResponseModel, summary="检查部门唯一性")
async def check_dept_unique(
field: str = Query(..., description="字段名"),
value: str = Query(..., description="字段值"),
exclude_id: Optional[str] = Query(None, alias="excludeId", description="排除ID"),
db: AsyncSession = Depends(get_db)
):
"""检查部门字段唯一性"""
allowed_fields = ["code", "name"]
if field not in allowed_fields:
raise HTTPException(status_code=400, detail=f"不支持检查字段: {field}")
is_unique = await DeptService.check_unique(db, field=field, value=value, exclude_id=exclude_id)
return ResponseModel(message="可用" if is_unique else "已存在", data={"unique": is_unique})
@router.post("/batch/delete", response_model=DeptBatchDeleteOut, summary="批量删除部门")
async def batch_delete_depts(
data: DeptBatchDeleteIn,
hard: bool = Query(default=False, description="是否物理删除"),
db: AsyncSession = Depends(get_db)
):
"""批量删除部门"""
count, failed_ids = await DeptService.batch_delete(db, data.ids, hard=hard)
return DeptBatchDeleteOut(count=count, failed_ids=failed_ids)
@router.post("/batch/status", response_model=DeptBatchUpdateStatusOut, summary="批量更新部门状态")
async def batch_update_dept_status(
data: DeptBatchUpdateStatusIn,
db: AsyncSession = Depends(get_db)
):
"""批量更新部门状态"""
count = await DeptService.batch_update_status(db, data.ids, data.status)
return DeptBatchUpdateStatusOut(count=count)
@router.get("/{dept_id}/children", response_model=List[DeptResponse], summary="获取子部门")
async def get_dept_children(
dept_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取直接子部门列表"""
children = await DeptService.get_children(db, dept_id)
return [_build_dept_response(item) for item in children]
@router.get("/{dept_id}/descendants", response_model=List[DeptResponse], summary="获取所有后代部门")
async def get_dept_descendants(
dept_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取所有后代部门"""
descendants = await DeptService.get_descendants(db, dept_id)
return [_build_dept_response(item) for item in descendants]
@router.get("/{dept_id}/ancestors", response_model=List[DeptResponse], summary="获取所有祖先部门")
async def get_dept_ancestors(
dept_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取所有祖先部门"""
ancestors = await DeptService.get_ancestors(db, dept_id)
return [_build_dept_response(item) for item in ancestors]
@router.get("/{dept_id}", response_model=DeptResponse, summary="获取部门详情")
async def get_dept_by_id(dept_id: str, db: AsyncSession = Depends(get_db)):
"""获取部门详情"""
dept = await DeptService.get_by_id(db, dept_id)
if dept is None:
raise HTTPException(status_code=404, detail="部门不存在")
return _build_dept_response(dept)
@router.put("/{dept_id}", response_model=DeptResponse, summary="更新部门")
async def update_dept(dept_id: str, data: DeptUpdate, db: AsyncSession = Depends(get_db)):
"""更新部门"""
# 编码唯一性校验
if data.code:
if not await DeptService.check_unique(db, field="code", value=data.code, exclude_id=dept_id):
raise HTTPException(status_code=400, detail="部门编码已存在")
# 检查父部门是否存在
if data.parent_id:
if data.parent_id == dept_id:
raise HTTPException(status_code=400, detail="不能将自己设为父部门")
parent = await DeptService.get_by_id(db, data.parent_id)
if not parent:
raise HTTPException(status_code=400, detail="父部门不存在")
dept = await DeptService.update(db, record_id=dept_id, data=data)
if dept is None:
raise HTTPException(status_code=404, detail="部门不存在")
return _build_dept_response(dept)
@router.delete("/{dept_id}", response_model=ResponseModel, summary="删除部门")
async def delete_dept(
dept_id: str,
hard: bool = Query(default=False, description="是否物理删除"),
db: AsyncSession = Depends(get_db)
):
"""删除部门"""
can_del, reason = await DeptService.can_delete(db, dept_id)
if not can_del:
raise HTTPException(status_code=400, detail=reason)
success = await DeptService.delete(db, record_id=dept_id, hard=hard)
if not success:
raise HTTPException(status_code=404, detail="部门不存在")
return ResponseModel(message="删除成功")
@router.get("/by/parent/{parent_id}", response_model=List[dict], summary="根据父部门ID获取子部门")
async def get_dept_by_parent(
parent_id: str,
db: AsyncSession = Depends(get_db)
):
"""
根据父部门ID获取直接子部门
- parent_id="null" 获取根部门
"""
if parent_id == "null":
parent_id = None
return await DeptService.get_by_parent(db, parent_id)
@router.post("/search", response_model=List[dict], summary="搜索部门")
async def search_dept(
data: DeptSearchRequest,
db: AsyncSession = Depends(get_db)
):
"""
搜索部门(模糊匹配部门名称或编码)
返回匹配部门及其完整的层级路径
"""
return await DeptService.search(db, data.keyword)
@router.get("/by/ids", response_model=List[dict], summary="根据ID列表获取部门")
async def get_depts_by_ids(
ids: str = Query(..., description="部门ID列表,逗号分隔"),
db: AsyncSession = Depends(get_db)
):
"""
根据部门ID列表批量获取部门信息(包含完整的层级路径)
"""
dept_ids = [id.strip() for id in ids.split(',') if id.strip()]
return await DeptService.get_by_ids(db, dept_ids)
@router.get("/path/{dept_id}", response_model=DeptPathOut, summary="获取部门路径")
async def get_dept_path(
dept_id: str,
db: AsyncSession = Depends(get_db)
):
"""获取部门的完整路径(从根到当前部门)"""
dept = await DeptService.get_by_id(db, dept_id)
if not dept:
raise HTTPException(status_code=404, detail="部门不存在")
# 获取所有祖先
ancestors = await DeptService.get_ancestors(db, dept_id)
path = []
for ancestor in reversed(ancestors):
path.append(DeptSimple(
id=ancestor.id,
name=ancestor.name,
code=ancestor.code,
parent_id=ancestor.parent_id,
level=ancestor.level,
status=ancestor.status,
))
# 添加当前部门
path.append(DeptSimple(
id=dept.id,
name=dept.name,
code=dept.code,
parent_id=dept.parent_id,
level=dept.level,
status=dept.status,
))
return DeptPathOut(
dept_id=dept.id,
dept_name=dept.name,
path=path
)
@router.get("/stats", response_model=DeptStatsResponse, summary="获取部门统计信息")
async def get_dept_stats(
db: AsyncSession = Depends(get_db)
):
"""获取部门统计信息"""
stats = await DeptService.get_stats(db)
return DeptStatsResponse(**stats)
@router.post("/move", response_model=ResponseModel, summary="移动部门")
async def move_dept(
data: DeptMoveRequest,
db: AsyncSession = Depends(get_db)
):
"""移动部门到新的父部门下"""
success, message = await DeptService.move(db, data.dept_id, data.new_parent_id)
if not success:
raise HTTPException(status_code=400, detail=message)
return ResponseModel(message=message)
@router.get("/users/{dept_id}", response_model=List[DeptUserSchema], summary="获取部门用户列表")
async def get_dept_users(
dept_id: str,
include_children: bool = Query(False, alias="includeChildren", description="是否包含子部门用户"),
db: AsyncSession = Depends(get_db)
):
"""获取部门下的用户列表"""
dept = await DeptService.get_by_id(db, dept_id)
if not dept:
raise HTTPException(status_code=404, detail="部门不存在")
users = await DeptService.get_dept_users(db, dept_id, include_children)
return [DeptUserSchema.model_validate(user) for user in users]
@router.post("/users/{dept_id}", response_model=ResponseModel, summary="为部门添加用户")
async def add_user_to_dept(
dept_id: str,
data: DeptUserIn,
db: AsyncSession = Depends(get_db)
):
"""将用户添加到部门"""
dept = await DeptService.get_by_id(db, dept_id)
if not dept:
raise HTTPException(status_code=404, detail="部门不存在")
if not data.user_ids:
raise HTTPException(status_code=400, detail="用户ID列表不能为空")
added_count = await DeptService.add_users_to_dept(db, dept_id, data.user_ids)
return ResponseModel(message=f"成功添加 {added_count} 个用户")
@router.delete("/users/{dept_id}", response_model=ResponseModel, summary="从部门中移除用户")
async def remove_user_from_dept(
dept_id: str,
data: DeptUserIn,
db: AsyncSession = Depends(get_db)
):
"""从部门中移除用户(支持批量删除)"""
dept = await DeptService.get_by_id(db, dept_id)
if not dept:
raise HTTPException(status_code=404, detail="部门不存在")
# 优先使用 user_ids(批量),如果没有则使用 user_id(单个)
user_ids_to_remove = data.user_ids if data.user_ids else ([data.user_id] if data.user_id else [])
if not user_ids_to_remove:
raise HTTPException(status_code=400, detail="用户ID不能为空")
removed_count = await DeptService.remove_users_from_dept(db, dept_id, user_ids_to_remove)
return ResponseModel(message=f"成功移除 {removed_count} 个用户")
def _build_dept_response(dept) -> DeptResponse:
"""构建部门响应"""
return DeptResponse(
id=dept.id,
name=dept.name,
code=dept.code,
dept_type=dept.dept_type,
dept_type_display=dept.get_dept_type_display(),
phone=dept.phone,
email=dept.email,
status=dept.status,
description=dept.description,
parent_id=dept.parent_id,
lead_id=dept.lead_id,
lead_name=dept.lead.name if dept.lead else None,
level=dept.level,
path=dept.path,
sort=dept.sort,
is_deleted=dept.is_deleted,
sys_create_datetime=dept.sys_create_datetime,
sys_update_datetime=dept.sys_update_datetime,
)
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dept Model - 部门模型
用于管理组织架构中的部门信息
"""
from sqlalchemy import Column, String, Text, Boolean, Integer
from sqlalchemy.orm import relationship
from app.base_model import BaseModel
class Dept(BaseModel):
"""
部门模型 - 用于组织架构管理
字段说明:
- name: 部门名称
- code: 部门编码(唯一)
- dept_type: 部门类型(company/department/team/other
- phone: 部门电话
- email: 部门邮箱
- status: 部门状态(启用/禁用)
- description: 部门描述
- parent_id: 父部门ID
- lead_id: 部门领导ID
- level: 部门层级(0为顶层)
- path: 部门路径(便于查询,格式:/id1/id2/)
"""
__tablename__ = "core_dept"
# 部门名称
name = Column(String(64), nullable=False, index=True, comment="部门名称")
# 部门编码
code = Column(String(32), unique=True, nullable=True, index=True, comment="部门编码")
# 部门类型:company-公司, department-部门, team-小组, other-其他
dept_type = Column(String(20), default="department", index=True, comment="部门类型")
# 部门电话
phone = Column(String(20), nullable=True, comment="部门电话")
# 部门邮箱
email = Column(String(64), nullable=True, comment="部门邮箱")
# 部门状态
status = Column(Boolean, default=True, index=True, comment="部门状态")
# 部门描述
description = Column(Text, nullable=True, comment="部门描述")
# 父部门ID(逻辑外键,不创建数据库约束)
parent_id = Column(String(21), nullable=True, index=True, comment="父部门ID")
# 部门领导ID(逻辑外键,不创建数据库约束)
lead_id = Column(String(21), nullable=True, comment="部门领导ID")
# 部门层级(0为顶层)
level = Column(Integer, default=0, index=True, comment="部门层级")
# 部门路径(便于查询,格式:/id1/id2/)
path = Column(String(500), nullable=True, index=True, comment="部门路径")
# 钉钉部门ID(用于组织架构同步映射)
dingtalk_dept_id = Column(String(64), unique=True, nullable=True, index=True, comment="钉钉部门ID")
# 企业微信部门ID(用于组织架构同步映射)
wecom_dept_id = Column(String(64), unique=True, nullable=True, index=True, comment="企业微信部门ID")
# 飞书部门ID(用于组织架构同步映射,open_department_id 格式)
feishu_dept_id = Column(String(128), unique=True, nullable=True, index=True, comment="飞书部门ID")
# 关系定义(使用primaryjoin指定逻辑关联,lazy='selectin'支持异步加载)
parent = relationship("Dept", remote_side="Dept.id", backref="children", foreign_keys="Dept.parent_id", primaryjoin="Dept.parent_id == Dept.id", lazy="selectin")
lead = relationship("User", foreign_keys="Dept.lead_id", primaryjoin="Dept.lead_id == User.id", backref="leading_depts", lazy="selectin")
def __repr__(self):
return f"<Dept {self.name} ({self.code or 'N/A'})>"
def get_dept_type_display(self) -> str:
"""获取部门类型的显示名称"""
type_map = {
"company": "公司",
"department": "部门",
"team": "小组",
"other": "其他",
}
return type_map.get(self.dept_type, "未知")
def get_full_name(self) -> str:
"""获取部门全名(包含父部门)"""
if self.parent:
return f"{self.parent.get_full_name()} / {self.name}"
return self.name
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dept Schema - 部门数据验证模式
"""
from datetime import datetime
from typing import Optional, List, Dict
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.base_schema import CSTDatetime
class DeptBase(BaseModel):
"""部门基础Schema"""
name: str = Field(..., min_length=1, max_length=64, description="部门名称")
code: Optional[str] = Field(None, max_length=32, description="部门编码")
dept_type: str = Field(default="department", description="部门类型")
phone: Optional[str] = Field(None, max_length=20, description="部门电话")
email: Optional[str] = Field(None, max_length=64, description="部门邮箱")
status: bool = Field(default=True, description="部门状态")
description: Optional[str] = Field(None, description="部门描述")
parent_id: Optional[str] = Field(None, description="父部门ID")
lead_id: Optional[str] = Field(None, description="部门领导ID")
sort: int = Field(default=0, description="排序")
@field_validator("code")
@classmethod
def validate_code(cls, v):
"""验证部门编码格式"""
if v and not v.replace("-", "").replace("_", "").isalnum():
raise ValueError("部门编码只能包含字母、数字、下划线和横线")
return v
@field_validator("dept_type")
@classmethod
def validate_dept_type(cls, v):
"""验证部门类型"""
valid_types = ["company", "department", "team", "other"]
if v not in valid_types:
raise ValueError(f"部门类型必须是以下之一: {', '.join(valid_types)}")
return v
@field_validator("phone")
@classmethod
def validate_phone(cls, v):
"""验证电话格式"""
if v and not all(c.isdigit() or c in "-+() " for c in v):
raise ValueError("电话号码格式不正确")
return v
class DeptCreate(DeptBase):
"""部门创建Schema"""
pass
class DeptUpdate(BaseModel):
"""部门更新Schema - 所有字段可选"""
name: Optional[str] = Field(None, min_length=1, max_length=64, description="部门名称")
code: Optional[str] = Field(None, max_length=32, description="部门编码")
dept_type: Optional[str] = Field(None, description="部门类型")
phone: Optional[str] = Field(None, max_length=20, description="部门电话")
email: Optional[str] = Field(None, max_length=64, description="部门邮箱")
status: Optional[bool] = Field(None, description="部门状态")
description: Optional[str] = Field(None, description="部门描述")
parent_id: Optional[str] = Field(None, description="父部门ID")
lead_id: Optional[str] = Field(None, description="部门领导ID")
sort: Optional[int] = Field(None, description="排序")
@field_validator("code")
@classmethod
def validate_code(cls, v):
"""验证部门编码格式"""
if v is not None and v and not v.replace("-", "").replace("_", "").isalnum():
raise ValueError("部门编码只能包含字母、数字、下划线和横线")
return v
@field_validator("dept_type")
@classmethod
def validate_dept_type(cls, v):
"""验证部门类型"""
if v is not None:
valid_types = ["company", "department", "team", "other"]
if v not in valid_types:
raise ValueError(f"部门类型必须是以下之一: {', '.join(valid_types)}")
return v
@field_validator("phone")
@classmethod
def validate_phone(cls, v):
"""验证电话格式"""
if v is not None and v and not all(c.isdigit() or c in "-+() " for c in v):
raise ValueError("电话号码格式不正确")
return v
class DeptResponse(BaseModel):
"""部门响应Schema"""
id: str
name: str
code: Optional[str] = None
dept_type: str
dept_type_display: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
status: bool
description: Optional[str] = None
parent_id: Optional[str] = None
lead_id: Optional[str] = None
lead_name: Optional[str] = None
level: int = 0
path: Optional[str] = None
sort: int = 0
is_deleted: bool = False
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
class DeptTreeNode(BaseModel):
"""部门树形节点"""
id: str
name: str
code: Optional[str] = None
parent_id: Optional[str] = None
dept_type: str
status: bool
level: int
sort: int = 0
children: List["DeptTreeNode"] = []
model_config = ConfigDict(from_attributes=True)
class DeptSimple(BaseModel):
"""部门简单输出(用于选择器)"""
id: str
name: str
code: Optional[str] = None
parent_id: Optional[str] = None
level: int
status: bool
model_config = ConfigDict(from_attributes=True)
class DeptBatchDeleteIn(BaseModel):
"""批量删除部门输入"""
ids: List[str] = Field(..., description="要删除的部门ID列表")
class DeptBatchDeleteOut(BaseModel):
"""批量删除部门输出"""
count: int = Field(..., description="删除的记录数")
failed_ids: List[str] = Field(default=[], description="删除失败的ID列表")
class DeptBatchUpdateStatusIn(BaseModel):
"""批量更新部门状态输入"""
ids: List[str] = Field(..., description="部门ID列表")
status: bool = Field(..., description="部门状态")
class DeptBatchUpdateStatusOut(BaseModel):
"""批量更新部门状态输出"""
count: int = Field(..., description="更新的记录数")
class DeptPathOut(BaseModel):
"""部门路径响应"""
dept_id: str = Field(..., description="部门ID")
dept_name: str = Field(..., description="部门名称")
path: List[DeptSimple] = Field(..., description="从根到当前部门的路径")
class DeptUserSchema(BaseModel):
"""部门用户Schema"""
id: str
username: str
name: Optional[str] = None
email: Optional[str] = None
mobile: Optional[str] = None
dept_id: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class DeptUserIn(BaseModel):
"""部门用户输入"""
user_id: Optional[str] = Field(None, description="单个用户ID")
user_ids: List[str] = Field(default=[], description="用户ID列表")
class DeptStatsResponse(BaseModel):
"""部门统计响应"""
total_count: int = Field(..., description="总部门数")
active_count: int = Field(..., description="启用部门数")
inactive_count: int = Field(..., description="禁用部门数")
root_count: int = Field(..., description="根部门数")
type_stats: Dict[str, int] = Field(..., description="按类型统计")
max_level: int = Field(..., description="最大层级")
class DeptMoveRequest(BaseModel):
"""移动部门请求"""
dept_id: str = Field(..., description="要移动的部门ID")
new_parent_id: Optional[str] = Field(None, description="新父部门ID,为空表示移动到根节点")
class DeptSearchRequest(BaseModel):
"""搜索部门请求"""
keyword: str = Field(..., description="搜索关键词")
+712
View File
@@ -0,0 +1,712 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dept Service - 部门服务层
"""
from io import BytesIO
from typing import Tuple, Dict, Any, Optional, List
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.base_service import BaseService
from core.dept.model import Dept
from core.dept.schema import DeptCreate, DeptUpdate, DeptTreeNode
class DeptService(BaseService[Dept, DeptCreate, DeptUpdate]):
"""
部门服务层
继承BaseService,自动获得增删改查功能
"""
model = Dept
# Excel导入导出配置
excel_columns = {
"name": "部门名称",
"code": "部门编码",
"dept_type": "部门类型",
"phone": "部门电话",
"email": "部门邮箱",
"status": "状态",
"description": "描述",
}
excel_sheet_name = "部门列表"
@classmethod
def _export_converter(cls, item: Any) -> Dict[str, Any]:
"""导出数据转换器"""
return {
"name": item.name,
"code": item.code or "",
"dept_type": item.get_dept_type_display(),
"phone": item.phone or "",
"email": item.email or "",
"status": "启用" if item.status else "禁用",
"description": item.description or "",
}
@classmethod
def _import_processor(cls, row: Dict[str, Any]) -> Optional[Dept]:
"""导入数据处理器"""
name = row.get("name")
if not name:
return None
# 部门类型映射
type_map = {"公司": "company", "部门": "department", "小组": "team", "其他": "other"}
dept_type_str = row.get("dept_type", "部门")
dept_type = type_map.get(dept_type_str, "department")
status_str = row.get("status", "启用")
status = status_str in ("启用", "true", "True", "1", True)
return Dept(
name=str(name),
code=str(row.get("code") or "") or None,
dept_type=dept_type,
phone=str(row.get("phone") or "") or None,
email=str(row.get("email") or "") or None,
status=status,
description=str(row.get("description") or "") or None,
)
@classmethod
async def export_to_excel(
cls,
db: AsyncSession,
data_converter: Any = None
) -> BytesIO:
"""导出到Excel"""
return await super().export_to_excel(db, cls._export_converter)
@classmethod
async def import_from_excel(
cls,
db: AsyncSession,
file_content: bytes,
row_processor: Any = None
) -> Tuple[int, int]:
"""从Excel导入"""
return await super().import_from_excel(db, file_content, cls._import_processor)
@classmethod
async def create(cls, db: AsyncSession, data: DeptCreate) -> Dept:
"""
创建部门,自动计算层级和路径
"""
dept_data = data.model_dump()
# 计算层级和路径
parent_id = dept_data.get("parent_id")
if parent_id:
parent = await cls.get_by_id(db, parent_id)
if parent:
dept_data["level"] = parent.level + 1
dept_data["path"] = f"{parent.path or '/'}{parent.id}/"
else:
dept_data["level"] = 0
dept_data["path"] = "/"
else:
dept_data["level"] = 0
dept_data["path"] = "/"
db_obj = Dept(**dept_data)
db.add(db_obj)
await db.commit()
await db.refresh(db_obj)
return db_obj
@classmethod
async def update(
cls,
db: AsyncSession,
record_id: str,
data: DeptUpdate
) -> Optional[Dept]:
"""
更新部门,如果父部门变化则重新计算层级和路径
"""
db_obj = await cls.get_by_id(db, record_id)
if not db_obj:
return None
update_data = data.model_dump(exclude_unset=True)
# 如果父部门变化,重新计算层级和路径
if "parent_id" in update_data:
parent_id = update_data["parent_id"]
if parent_id:
parent = await cls.get_by_id(db, parent_id)
if parent:
update_data["level"] = parent.level + 1
update_data["path"] = f"{parent.path or '/'}{parent.id}/"
else:
update_data["level"] = 0
update_data["path"] = "/"
else:
update_data["level"] = 0
update_data["path"] = "/"
for field, value in update_data.items():
setattr(db_obj, field, value)
await db.commit()
await db.refresh(db_obj)
return db_obj
@classmethod
async def get_tree(cls, db: AsyncSession, parent_id: Optional[str] = None) -> List[DeptTreeNode]:
"""
获取部门树形结构
:param db: 数据库会话
:param parent_id: 父部门IDNone表示获取所有顶级部门
:return: 部门树形列表
"""
# 获取所有未删除的部门
result = await db.execute(
select(Dept)
.where(Dept.is_deleted == False) # noqa: E712
.order_by(Dept.sort.desc(), Dept.sys_create_datetime)
)
all_depts = result.scalars().all()
# 构建部门字典
dept_dict = {dept.id: dept for dept in all_depts}
# 构建树形结构
def build_tree(parent_id: Optional[str]) -> List[DeptTreeNode]:
children = []
for dept in all_depts:
if dept.parent_id == parent_id:
node = DeptTreeNode(
id=dept.id,
name=dept.name,
code=dept.code,
parent_id=dept.parent_id,
dept_type=dept.dept_type,
status=dept.status,
level=dept.level,
sort=dept.sort,
children=build_tree(dept.id)
)
children.append(node)
return children
return build_tree(parent_id)
@classmethod
async def get_children(cls, db: AsyncSession, parent_id: str) -> List[Dept]:
"""
获取直接子部门列表
"""
result = await db.execute(
select(Dept)
.where(
Dept.parent_id == parent_id,
Dept.is_deleted == False # noqa: E712
)
.order_by(Dept.sort.desc(), Dept.sys_create_datetime)
)
return list(result.scalars().all())
@classmethod
async def get_descendants(cls, db: AsyncSession, dept_id: str) -> List[Dept]:
"""
获取所有后代部门(通过path字段查询)
"""
dept = await cls.get_by_id(db, dept_id)
if not dept:
return []
# 使用path字段进行模糊查询
search_path = f"{dept.path or '/'}{dept.id}/"
result = await db.execute(
select(Dept)
.where(
Dept.path.like(f"{search_path}%"),
Dept.is_deleted == False # noqa: E712
)
.order_by(Dept.level, Dept.sort.desc())
)
return list(result.scalars().all())
@classmethod
async def get_ancestors(cls, db: AsyncSession, dept_id: str) -> List[Dept]:
"""
获取所有祖先部门
"""
ancestors = []
current = await cls.get_by_id(db, dept_id)
while current and current.parent_id:
parent = await cls.get_by_id(db, current.parent_id)
if parent:
ancestors.append(parent)
current = parent
else:
break
return ancestors
@classmethod
async def can_delete(cls, db: AsyncSession, dept_id: str) -> Tuple[bool, str]:
"""
检查部门是否可以删除
:return: (是否可删除, 原因)
"""
# 检查是否有子部门
children = await cls.get_children(db, dept_id)
if children:
return False, "该部门下存在子部门,无法删除"
# 检查是否有用户(需要导入User模型后才能检查)
# 这里暂时返回True,后续可以添加用户检查
return True, ""
@classmethod
async def batch_update_status(
cls,
db: AsyncSession,
ids: List[str],
status: bool
) -> int:
"""
批量更新部门状态
:return: 更新的记录数
"""
count = 0
for dept_id in ids:
dept = await cls.get_by_id(db, dept_id)
if dept:
dept.status = status
count += 1
if count > 0:
await db.commit()
return count
@classmethod
async def batch_delete(
cls,
db: AsyncSession,
ids: List[str],
hard: bool = False
) -> Tuple[int, List[str]]:
"""
批量删除部门
:return: (删除成功数, 删除失败的ID列表)
"""
success_count = 0
failed_ids = []
for dept_id in ids:
can_del, reason = await cls.can_delete(db, dept_id)
if can_del:
if await cls.delete(db, dept_id, hard=hard):
success_count += 1
else:
failed_ids.append(dept_id)
else:
failed_ids.append(dept_id)
return success_count, failed_ids
@classmethod
async def get_user_count(cls, db: AsyncSession, dept_id: str) -> int:
"""获取部门下的用户数量"""
from core.user.model import User
result = await db.execute(
select(func.count(User.id)).where(
User.dept_id == dept_id,
User.is_deleted == False # noqa: E712
)
)
return result.scalar() or 0
@classmethod
async def get_child_count(cls, db: AsyncSession, dept_id: str) -> int:
"""获取直接子部门数量"""
result = await db.execute(
select(func.count(Dept.id)).where(
Dept.parent_id == dept_id,
Dept.is_deleted == False # noqa: E712
)
)
return result.scalar() or 0
@classmethod
async def search(cls, db: AsyncSession, keyword: str) -> List[Dict[str, Any]]:
"""
搜索部门(模糊匹配部门名称或编码)
返回匹配部门及其完整的层级路径
"""
if not keyword:
return []
# 搜索部门
result = await db.execute(
select(Dept).where(
(Dept.name.ilike(f"%{keyword}%") | Dept.code.ilike(f"%{keyword}%")),
Dept.is_deleted == False # noqa: E712
)
)
matched_depts = list(result.scalars().all())
# 收集所有需要的部门ID(包括匹配部门和其所有祖先)
dept_ids_to_include = set()
for dept in matched_depts:
dept_ids_to_include.add(dept.id)
ancestors = await cls.get_ancestors(db, dept.id)
for ancestor in ancestors:
dept_ids_to_include.add(ancestor.id)
# 获取所有需要的部门
result = await db.execute(
select(Dept).where(
Dept.id.in_(dept_ids_to_include),
Dept.is_deleted == False # noqa: E712
)
)
all_depts = list(result.scalars().all())
# 构建部门字典
dept_dict_map = {}
for dept in all_depts:
child_count = await cls.get_child_count(db, dept.id)
user_count = await cls.get_user_count(db, dept.id)
dept_dict = {
'id': dept.id,
'name': dept.name,
'code': dept.code,
'dept_type': dept.dept_type,
'dept_type_display': dept.get_dept_type_display(),
'status': dept.status,
'level': dept.level,
'path': dept.path,
'parent_id': dept.parent_id,
'lead_id': dept.lead_id,
'phone': dept.phone,
'email': dept.email,
'description': dept.description,
'sort': dept.sort,
'child_count': child_count,
'user_count': user_count,
}
dept_dict_map[dept.id] = dept_dict
# 构建树形结构
roots = []
for dept_id, dept in dept_dict_map.items():
parent_id = dept['parent_id']
if parent_id is None:
roots.append(dept)
elif parent_id in dept_dict_map:
parent = dept_dict_map[parent_id]
if 'children' not in parent:
parent['children'] = []
parent['children'].append(dept)
return roots
@classmethod
async def get_by_ids(cls, db: AsyncSession, ids: List[str]) -> List[Dict[str, Any]]:
"""
根据ID列表批量获取部门信息(包含完整的层级路径)
"""
if not ids:
return []
# 收集所有需要的部门ID
dept_ids_to_include = set()
result = await db.execute(
select(Dept).where(
Dept.id.in_(ids),
Dept.is_deleted == False # noqa: E712
)
)
target_depts = list(result.scalars().all())
for dept in target_depts:
dept_ids_to_include.add(dept.id)
ancestors = await cls.get_ancestors(db, dept.id)
for ancestor in ancestors:
dept_ids_to_include.add(ancestor.id)
# 获取所有需要的部门
result = await db.execute(
select(Dept).where(
Dept.id.in_(dept_ids_to_include),
Dept.is_deleted == False # noqa: E712
)
)
all_depts = list(result.scalars().all())
# 构建字典和树形结构
dept_dict_map = {}
for dept in all_depts:
child_count = await cls.get_child_count(db, dept.id)
user_count = await cls.get_user_count(db, dept.id)
dept_dict = {
'id': dept.id,
'name': dept.name,
'code': dept.code,
'dept_type': dept.dept_type,
'status': dept.status,
'level': dept.level,
'parent_id': dept.parent_id,
'child_count': child_count,
'user_count': user_count,
}
dept_dict_map[dept.id] = dept_dict
roots = []
for dept_id, dept in dept_dict_map.items():
parent_id = dept['parent_id']
if parent_id is None:
roots.append(dept)
elif parent_id in dept_dict_map:
parent = dept_dict_map[parent_id]
if 'children' not in parent:
parent['children'] = []
parent['children'].append(dept)
return roots
@classmethod
async def get_stats(cls, db: AsyncSession) -> Dict[str, Any]:
"""获取部门统计信息"""
# 总数
total_result = await db.execute(
select(func.count(Dept.id)).where(Dept.is_deleted == False) # noqa: E712
)
total_count = total_result.scalar() or 0
# 启用数
active_result = await db.execute(
select(func.count(Dept.id)).where(
Dept.status == True, # noqa: E712
Dept.is_deleted == False # noqa: E712
)
)
active_count = active_result.scalar() or 0
# 根部门数
root_result = await db.execute(
select(func.count(Dept.id)).where(
Dept.parent_id.is_(None),
Dept.is_deleted == False # noqa: E712
)
)
root_count = root_result.scalar() or 0
# 按类型统计
type_stats = {}
type_choices = [
('company', '公司'),
('department', '部门'),
('team', '小组'),
('other', '其他'),
]
for type_code, type_name in type_choices:
count_result = await db.execute(
select(func.count(Dept.id)).where(
Dept.dept_type == type_code,
Dept.is_deleted == False # noqa: E712
)
)
type_stats[type_name] = count_result.scalar() or 0
# 最大层级
max_level_result = await db.execute(
select(func.max(Dept.level)).where(Dept.is_deleted == False) # noqa: E712
)
max_level = max_level_result.scalar() or 0
return {
'total_count': total_count,
'active_count': active_count,
'inactive_count': total_count - active_count,
'root_count': root_count,
'type_stats': type_stats,
'max_level': max_level,
}
@classmethod
async def move(
cls,
db: AsyncSession,
dept_id: str,
new_parent_id: Optional[str]
) -> Tuple[bool, str]:
"""
移动部门到新的父部门下
:return: (是否成功, 消息)
"""
dept = await cls.get_by_id(db, dept_id)
if not dept:
return False, "部门不存在"
# 检查新父部门
if new_parent_id:
if new_parent_id == dept_id:
return False, "不能将自己设置为父部门"
new_parent = await cls.get_by_id(db, new_parent_id)
if not new_parent:
return False, "父部门不存在"
# 检查是否会形成循环引用
ancestors = await cls.get_ancestors(db, new_parent_id)
ancestor_ids = [a.id for a in ancestors]
if dept.id in ancestor_ids or dept.id == new_parent.id:
return False, "不能移动到自己或子部门下"
dept.parent_id = new_parent_id
dept.level = new_parent.level + 1
dept.path = f"{new_parent.path or '/'}{new_parent.id}/"
else:
dept.parent_id = None
dept.level = 0
dept.path = "/"
await db.commit()
return True, "移动成功"
@classmethod
async def get_dept_users(
cls,
db: AsyncSession,
dept_id: str,
include_children: bool = False
) -> List[Any]:
"""获取部门下的用户列表"""
from core.user.model import User
if include_children:
# 获取部门及其所有子部门的用户
descendants = await cls.get_descendants(db, dept_id)
dept_ids = [dept_id] + [d.id for d in descendants]
result = await db.execute(
select(User).where(
User.dept_id.in_(dept_ids),
User.user_status == 1,
User.is_deleted == False # noqa: E712
)
)
else:
# 只获取当前部门的用户
result = await db.execute(
select(User).where(
User.dept_id == dept_id,
User.user_status == 1,
User.is_deleted == False # noqa: E712
)
)
return list(result.scalars().all())
@classmethod
async def add_users_to_dept(
cls,
db: AsyncSession,
dept_id: str,
user_ids: List[str]
) -> int:
"""将用户添加到部门"""
from core.user.model import User
dept = await cls.get_by_id(db, dept_id)
if not dept:
return 0
added_count = 0
for user_id in user_ids:
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user and user.dept_id != dept_id:
user.dept_id = dept_id
added_count += 1
if added_count > 0:
await db.commit()
return added_count
@classmethod
async def remove_users_from_dept(
cls,
db: AsyncSession,
dept_id: str,
user_ids: List[str]
) -> int:
"""从部门中移除用户"""
from core.user.model import User
removed_count = 0
for user_id in user_ids:
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user and user.dept_id == dept_id:
user.dept_id = None
removed_count += 1
if removed_count > 0:
await db.commit()
return removed_count
@classmethod
async def get_by_parent(cls, db: AsyncSession, parent_id: Optional[str]) -> List[Dict[str, Any]]:
"""根据父部门ID获取直接子部门"""
if parent_id:
query = select(Dept).where(
Dept.parent_id == parent_id,
Dept.is_deleted == False # noqa: E712
).order_by(Dept.sort.desc())
else:
query = select(Dept).where(
Dept.parent_id.is_(None),
Dept.is_deleted == False # noqa: E712
).order_by(Dept.sort.desc())
result = await db.execute(query)
depts = list(result.scalars().all())
dept_list = []
for dept in depts:
child_count = await cls.get_child_count(db, dept.id)
user_count = await cls.get_user_count(db, dept.id)
dept_dict = {
'id': dept.id,
'name': dept.name,
'code': dept.code,
'dept_type': dept.dept_type,
'dept_type_display': dept.get_dept_type_display(),
'status': dept.status,
'level': dept.level,
'path': dept.path,
'parent_id': dept.parent_id,
'lead_id': dept.lead_id,
'phone': dept.phone,
'email': dept.email,
'description': dept.description,
'sort': dept.sort,
'child_count': child_count,
'user_count': user_count,
}
dept_list.append(dept_dict)
return dept_list