feat: add document type CRUD with inline rule set binding

- GET/POST /api/document-types, GET/PUT/DELETE /api/document-types/{id}
- DocumentTypeItemVO extended with description, entryModuleId,
  isEnabled, ruleSetIds
- Create/Update DTOs accept ruleSetIds array for automatic
  leaudit_rule_type_bindings sync (full replace on update)
- Soft delete cascades to rule_type_bindings
This commit is contained in:
wren
2026-04-30 12:50:56 +08:00
parent 32f56f7bf6
commit 52c2bed4f9
4 changed files with 265 additions and 30 deletions
@@ -10,7 +10,13 @@ from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from fastapi_common.fastapi_common_web.controller import BaseController
from fastapi_common.fastapi_common_web.domain.responses import Result
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import DocumentListPageVO, DocumentTypeItemVO, DocumentUploadVO
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
DocumentListPageVO,
DocumentTypeCreateDTO,
DocumentTypeItemVO,
DocumentTypeUpdateDTO,
DocumentUploadVO,
)
from fastapi_modules.fastapi_leaudit.services import IDocumentService
from fastapi_modules.fastapi_leaudit.services.impl.documentServiceImpl import DocumentServiceImpl
@@ -95,6 +101,30 @@ class DocumentController(BaseController):
Data = await self.DocumentService.ListDocumentTypes(Ids=idList)
return Result.success(data=Data)
@self.router.get("/document-types/{TypeId}", response_model=Result[DocumentTypeItemVO])
async def GetDocumentType(TypeId: int):
"""获取文档类型详情。"""
Data = await self.DocumentService.GetDocumentType(Id=TypeId)
return Result.success(data=Data)
@self.router.post("/document-types", response_model=Result[DocumentTypeItemVO])
async def CreateDocumentType(Body: DocumentTypeCreateDTO):
"""创建文档类型。"""
Data = await self.DocumentService.CreateDocumentType(Body=Body)
return Result.success(data=Data, message="文档类型创建成功")
@self.router.put("/document-types/{TypeId}", response_model=Result[DocumentTypeItemVO])
async def UpdateDocumentType(TypeId: int, Body: DocumentTypeUpdateDTO):
"""更新文档类型。"""
Data = await self.DocumentService.UpdateDocumentType(Id=TypeId, Body=Body)
return Result.success(data=Data, message="文档类型更新成功")
@self.router.delete("/document-types/{TypeId}", response_model=Result[None])
async def DeleteDocumentType(TypeId: int):
"""删除文档类型(软删除)。"""
await self.DocumentService.DeleteDocumentType(Id=TypeId)
return Result.success(message="文档类型已删除")
@self.router.get("/v2/system/queue/status", response_model=Result[QueueStatusVO])
async def GetQueueStatus():
"""获取文档处理队列状态。"""
@@ -80,6 +80,33 @@ class DocumentTypeItemVO(BaseModel):
id: int = Field(..., description="类型ID")
name: str = Field(..., description="类型名称")
code: str = Field(..., description="类型编码")
description: str | None = Field(None, description="描述")
entryModuleId: int | None = Field(None, description="入口模块ID")
isEnabled: bool = Field(True, description="是否启用")
ruleSetIds: list[int] = Field(default_factory=list, description="关联的规则集ID")
class DocumentTypeCreateDTO(BaseModel):
"""文档类型创建请求。"""
code: str = Field(..., description="类型编码")
name: str = Field(..., description="类型名称")
description: str = Field("", description="描述")
entryModuleId: int | None = Field(None, description="入口模块ID")
isEnabled: bool = Field(True, description="是否启用")
sortOrder: int = Field(0, description="排序")
ruleSetIds: list[int] = Field(default_factory=list, description="关联的规则集ID")
class DocumentTypeUpdateDTO(BaseModel):
"""文档类型更新请求。"""
name: str | None = Field(None, description="类型名称")
description: str | None = Field(None, description="描述")
entryModuleId: int | None = Field(None, description="入口模块ID")
isEnabled: bool | None = Field(None, description="是否启用")
sortOrder: int | None = Field(None, description="排序")
ruleSetIds: list[int] | None = Field(None, description="关联的规则集ID(传则全量替换)")
class DocumentListPageVO(BaseModel):
@@ -2,7 +2,13 @@
from abc import ABC, abstractmethod
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import DocumentListPageVO, DocumentTypeItemVO, DocumentUploadVO
from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
DocumentListPageVO,
DocumentTypeCreateDTO,
DocumentTypeItemVO,
DocumentTypeUpdateDTO,
DocumentUploadVO,
)
class IDocumentService(ABC):
@@ -46,3 +52,23 @@ class IDocumentService(ABC):
async def ListDocumentTypes(self, Ids: list[int] | None = None) -> list[DocumentTypeItemVO]:
"""获取文档类型列表。"""
...
@abstractmethod
async def GetDocumentType(self, Id: int) -> DocumentTypeItemVO:
"""获取文档类型详情。"""
...
@abstractmethod
async def CreateDocumentType(self, Body: DocumentTypeCreateDTO) -> DocumentTypeItemVO:
"""创建文档类型。"""
...
@abstractmethod
async def UpdateDocumentType(self, Id: int, Body: DocumentTypeUpdateDTO) -> DocumentTypeItemVO:
"""更新文档类型。"""
...
@abstractmethod
async def DeleteDocumentType(self, Id: int) -> None:
"""删除文档类型(软删除)。"""
...
@@ -22,7 +22,9 @@ from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import (
DocumentHistoryVersionVO,
DocumentListItemVO,
DocumentListPageVO,
DocumentTypeCreateDTO,
DocumentTypeItemVO,
DocumentTypeUpdateDTO,
DocumentUploadVO,
)
from fastapi_modules.fastapi_leaudit.models import LeauditDocument, LeauditDocumentFile
@@ -459,38 +461,188 @@ class DocumentServiceImpl(IDocumentService):
async def ListDocumentTypes(self, Ids: list[int] | None = None) -> list[DocumentTypeItemVO]:
"""获取文档类型列表。"""
async with GetAsyncSession() as Session:
if Ids:
rows = (
await Session.execute(
text(
"""
SELECT id, name, code
FROM leaudit_document_types
WHERE deleted_at IS NULL AND id = ANY(:ids)
ORDER BY sort_order ASC, id ASC
"""
),
{"ids": Ids},
)
).mappings().all()
else:
rows = (
await Session.execute(
text(
"""
SELECT id, name, code
FROM leaudit_document_types
WHERE deleted_at IS NULL
ORDER BY sort_order ASC, id ASC
"""
)
)
).mappings().all()
rows, bindingsMap = await self._queryDocumentTypes(Session, Ids)
return [
DocumentTypeItemVO(id=int(r["id"]), name=str(r["name"] or ""), code=str(r["code"] or ""))
DocumentTypeItemVO(
id=int(r["id"]), name=str(r["name"] or ""), code=str(r["code"] or ""),
description=r.get("description"), entryModuleId=r.get("entry_module_id"),
isEnabled=bool(r.get("is_enabled", True)),
ruleSetIds=bindingsMap.get(int(r["id"]), []),
)
for r in rows
]
async def GetDocumentType(self, Id: int) -> DocumentTypeItemVO:
"""获取文档类型详情。"""
async with GetAsyncSession() as Session:
rows, bindingsMap = await self._queryDocumentTypes(Session, [Id])
if not rows:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档类型不存在")
r = rows[0]
return DocumentTypeItemVO(
id=int(r["id"]), name=str(r["name"] or ""), code=str(r["code"] or ""),
description=r.get("description"), entryModuleId=r.get("entry_module_id"),
isEnabled=bool(r.get("is_enabled", True)),
ruleSetIds=bindingsMap.get(int(r["id"]), []),
)
async def CreateDocumentType(self, Body: DocumentTypeCreateDTO) -> DocumentTypeItemVO:
"""创建文档类型。"""
async with GetAsyncSession() as Session:
existing = (
await Session.execute(
text("SELECT 1 FROM leaudit_document_types WHERE code = :code AND deleted_at IS NULL LIMIT 1"),
{"code": Body.code.strip()},
)
).first()
if existing:
raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"文档类型编码 {Body.code} 已存在")
row = (
await Session.execute(
text(
"""
INSERT INTO leaudit_document_types (code, name, description, entry_module_id, is_enabled, sort_order, created_at, updated_at)
VALUES (:code, :name, :description, :entry_module_id, :is_enabled, :sort_order, NOW(), NOW())
RETURNING id
"""
),
{
"code": Body.code.strip(),
"name": Body.name.strip(),
"description": Body.description.strip() or None,
"entry_module_id": Body.entryModuleId,
"is_enabled": Body.isEnabled,
"sort_order": Body.sortOrder,
},
)
).scalar_one()
await self._syncRuleBindings(Session, int(row), Body.ruleSetIds, "default")
await Session.commit()
return await self.GetDocumentType(int(row))
async def UpdateDocumentType(self, Id: int, Body: DocumentTypeUpdateDTO) -> DocumentTypeItemVO:
"""更新文档类型。"""
async with GetAsyncSession() as Session:
current = (
await Session.execute(
text("SELECT id FROM leaudit_document_types WHERE id = :id AND deleted_at IS NULL"),
{"id": Id},
)
).first()
if not current:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档类型不存在")
sets: list[str] = []
params: dict[str, object] = {"id": Id}
if Body.name is not None:
sets.append("name = :name")
params["name"] = Body.name.strip()
if Body.description is not None:
sets.append("description = :description")
params["description"] = Body.description.strip() or None
if Body.entryModuleId is not None:
sets.append("entry_module_id = :entry_module_id")
params["entry_module_id"] = Body.entryModuleId
if Body.isEnabled is not None:
sets.append("is_enabled = :is_enabled")
params["is_enabled"] = Body.isEnabled
if Body.sortOrder is not None:
sets.append("sort_order = :sort_order")
params["sort_order"] = Body.sortOrder
if sets:
sets.append("updated_at = NOW()")
await Session.execute(text(f"UPDATE leaudit_document_types SET {', '.join(sets)} WHERE id = :id"), params)
if Body.ruleSetIds is not None:
await self._syncRuleBindings(Session, Id, Body.ruleSetIds, "default")
await Session.commit()
return await self.GetDocumentType(Id)
async def DeleteDocumentType(self, Id: int) -> None:
"""软删除文档类型。"""
async with GetAsyncSession() as Session:
current = (
await Session.execute(
text("SELECT 1 FROM leaudit_document_types WHERE id = :id AND deleted_at IS NULL"),
{"id": Id},
)
).first()
if not current:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档类型不存在")
await Session.execute(text("UPDATE leaudit_document_types SET deleted_at = NOW() WHERE id = :id"), {"id": Id})
await Session.execute(text("UPDATE leaudit_rule_type_bindings SET deleted_at = NOW() WHERE doc_type_id = :id AND deleted_at IS NULL"), {"id": Id})
await Session.commit()
async def _queryDocumentTypes(self, Session, Ids: list[int] | None = None):
"""查询文档类型及其规则绑定。"""
if Ids:
rows = (
await Session.execute(
text(
"""
SELECT id, code, name, description, entry_module_id, is_enabled, sort_order
FROM leaudit_document_types
WHERE deleted_at IS NULL AND id = ANY(:ids)
ORDER BY sort_order ASC, id ASC
"""
),
{"ids": Ids},
)
).mappings().all()
else:
rows = (
await Session.execute(
text(
"""
SELECT id, code, name, description, entry_module_id, is_enabled, sort_order
FROM leaudit_document_types
WHERE deleted_at IS NULL
ORDER BY sort_order ASC, id ASC
"""
)
)
).mappings().all()
allIds = [int(r["id"]) for r in rows]
bindingsMap: dict[int, list[int]] = {i: [] for i in allIds}
if allIds:
bindingRows = (
await Session.execute(
text(
"""
SELECT doc_type_id, rule_set_id
FROM leaudit_rule_type_bindings
WHERE doc_type_id = ANY(:ids) AND deleted_at IS NULL AND is_active = true
ORDER BY priority DESC
"""
),
{"ids": allIds},
)
).fetchall()
for b in bindingRows:
bindingsMap.setdefault(int(b[0]), []).append(int(b[1]))
return rows, bindingsMap
async def _syncRuleBindings(self, Session, DocTypeId: int, RuleSetIds: list[int], Region: str = "default") -> None:
"""全量替换规则绑定。"""
await Session.execute(
text("UPDATE leaudit_rule_type_bindings SET deleted_at = NOW() WHERE doc_type_id = :id AND deleted_at IS NULL"),
{"id": DocTypeId},
)
for idx, ruleSetId in enumerate(RuleSetIds):
await Session.execute(
text(
"""
INSERT INTO leaudit_rule_type_bindings (doc_type_id, rule_set_id, binding_mode, priority, region, is_active, created_at, updated_at)
VALUES (:doc_type_id, :rule_set_id, 'explicit', :priority, :region, true, NOW(), NOW())
"""
),
{"doc_type_id": DocTypeId, "rule_set_id": ruleSetId, "priority": 100 - idx, "region": Region},
)
async def _find_latest_version_candidate(
session,