feat: update audit platform workspace

This commit is contained in:
wren
2026-05-25 09:50:01 +08:00
parent ba8e93c0d3
commit 68d0b4c878
73 changed files with 12196 additions and 367 deletions
@@ -76,7 +76,9 @@ class CrossReviewController(BaseController):
"""查询当前用户参与的交叉评查任务。"""
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["task_read"]]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看交叉评查任务权限", "data": None})
Data = await self.CrossReviewService.GetUserTasks(CurrentUserId=int(payload["user_id"]), Body=Body)
UserId = int(payload["user_id"])
CanViewProgress = await self._check_permission(UserId, [self._PERMISSIONS["progress_view"]])
Data = await self.CrossReviewService.GetUserTasks(CurrentUserId=UserId, Body=Body, CanViewProgress=CanViewProgress)
return Result.success(data=Data)
@self.router.get("/tasks/{TaskId}/progress", response_model=Result[CrossReviewTaskProgressVO])
@@ -4,6 +4,7 @@ import json
from typing import Any
from fastapi import Depends, File, Form, Query, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import text
@@ -34,6 +35,8 @@ from fastapi_modules.fastapi_leaudit.domian.vo.reviewPointVo import (
)
from fastapi_modules.fastapi_leaudit.services import IDocumentService
from fastapi_modules.fastapi_leaudit.services.impl.documentServiceImpl import DocumentServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl
from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService
class QueueStatusVO(BaseModel):
@@ -51,6 +54,8 @@ class ReviewPointAuditDTO(BaseModel):
class DocumentController(BaseController):
"""文档控制器。"""
_CROSS_REVIEW_DOCUMENT_READ_PERMISSION = "cross_review:document:read"
@staticmethod
def _tenant_context(payload: dict[str, Any]) -> dict[str, str | None]:
return {
@@ -61,6 +66,7 @@ class DocumentController(BaseController):
def __init__(self):
super().__init__(prefix="", tags=["文档"])
self.DocumentService: IDocumentService = DocumentServiceImpl()
self.PermissionService: IPermissionService = PermissionServiceImpl()
@self.router.post("/upload", response_model=Result[DocumentUploadVO])
async def UploadDocument(
@@ -69,6 +75,8 @@ class DocumentController(BaseController):
typeId: int | None = Form(None, description="文档类型ID"),
typeCode: str | None = Form(None, description="文档类型编码"),
groupId: int | None = Form(None, description="二级分组ID"),
entryModuleId: int | None = Form(None, description="入口模块ID"),
entry_module_id: int | None = Form(None, description="入口模块ID,兼容蛇形字段"),
region: str | None = Form(None, description="所属租户/地区"),
tenant_code: str | None = Form(None, description="租户编码"),
fileRole: str = Form("primary", description="文件角色"),
@@ -97,6 +105,7 @@ class DocumentController(BaseController):
TypeId=typeId,
TypeCode=typeCode,
GroupId=groupId,
EntryModuleId=entryModuleId or entry_module_id,
Region=region,
FileRole=fileRole,
CreatedBy=int(payload["user_id"]),
@@ -194,7 +203,11 @@ class DocumentController(BaseController):
payload: dict[str, Any] = Depends(verify_access_token),
):
"""获取单个文档详情(带数据隔离校验)。"""
Data = await self.DocumentService.GetDocument(CurrentUserId=int(payload["user_id"]), Id=DocumentId)
userId = int(payload["user_id"])
deniedResponse = await self._deny_cross_review_document_without_permission(userId, DocumentId)
if deniedResponse:
return deniedResponse
Data = await self.DocumentService.GetDocument(CurrentUserId=userId, Id=DocumentId)
return Result.success(data=Data)
@self.router.get("/v3/review-points/{DocumentId}", response_model=Result[ReviewPointsAggregateVO])
@@ -203,7 +216,11 @@ class DocumentController(BaseController):
payload: dict[str, Any] = Depends(verify_access_token),
):
"""获取评查详情页聚合数据(带数据隔离校验)。"""
Data = await self.DocumentService.GetReviewPoints(CurrentUserId=int(payload["user_id"]), DocumentId=DocumentId)
userId = int(payload["user_id"])
deniedResponse = await self._deny_cross_review_document_without_permission(userId, DocumentId)
if deniedResponse:
return deniedResponse
Data = await self.DocumentService.GetReviewPoints(CurrentUserId=userId, DocumentId=DocumentId)
return Result.success(data=Data)
@self.router.patch("/v3/review-points/{ReviewPointResultId}/audit", response_model=Result[ReviewPointAuditVO])
@@ -400,3 +417,17 @@ class DocumentController(BaseController):
},
)
)
async def _deny_cross_review_document_without_permission(self, UserId: int, DocumentId: int) -> JSONResponse | None:
if not await self.DocumentService.IsCrossReviewDocument(DocumentId):
return None
hasPermission = await self.PermissionService.HasAnyPermission(
UserId=UserId,
PermissionKeys=[self._CROSS_REVIEW_DOCUMENT_READ_PERMISSION],
)
if hasPermission:
return None
return JSONResponse(
status_code=403,
content={"code": 403, "msg": "当前用户没有查看交叉评查结果权限", "data": None},
)
@@ -1,155 +1,40 @@
"""评查点控制器。"""
"""Legacy evaluation point API.
The current rule configuration flow is backed by leaudit_evaluation_point_groups
and rule version tables. The old evaluation_points table is no longer present
in the active schema, so keep this route explicit and non-destructive.
"""
from fastapi import Depends, Query
from fastapi.responses import JSONResponse
from fastapi_common.fastapi_common_security.security import verify_access_token
from fastapi_common.fastapi_common_web.controller import BaseController
from fastapi_modules.fastapi_leaudit.domian.Dto.evaluationPointDto import (
EvaluationPointCreateDTO,
EvaluationPointUpdateDTO,
)
from fastapi_modules.fastapi_leaudit.services.evaluationPointService import IEvaluationPointService
from fastapi_modules.fastapi_leaudit.services.impl.evaluationPointServiceImpl import EvaluationPointServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl
from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService
class EvaluationPointController(BaseController):
"""评查点控制器。"""
_PERMISSIONS = {
"list": "evaluation_point:list:read",
"detail": "evaluation_point:detail:read",
"create": "evaluation_point:create:write",
"update": "evaluation_point:update:write",
"delete": "evaluation_point:delete:delete",
}
@staticmethod
def _tenant_context(payload: dict) -> dict[str, str | None]:
return {
"UserArea": payload.get("area"),
"UserRole": payload.get("user_role"),
"TenantCode": payload.get("tenant_code"),
"TenantName": payload.get("tenant_name"),
}
"""Deprecated legacy evaluation point controller."""
def __init__(self):
super().__init__(prefix="/v3/evaluation-points", tags=["评查点"])
self.PointService: IEvaluationPointService = EvaluationPointServiceImpl()
self.PermissionService: IPermissionService = PermissionServiceImpl()
super().__init__(prefix="/v3/evaluation-points", tags=["评查点接口"])
@self.router.get("")
async def ListEvaluationPoints(
name: str | None = Query(None, description="名称模糊搜索"),
code: str | None = Query(None, description="编码模糊搜索"),
risk: str | None = Query(None, description="风险等级"),
is_enabled: bool | None = Query(None, description="是否启用"),
evaluation_point_groups_pid: int | None = Query(None, description="一级分组ID"),
evaluation_point_groups_id: int | None = Query(None, description="二级分组ID"),
document_attribute_type: str | None = Query(None, description="文档属性类型"),
area: str | None = Query(None, description="地区/兼容租户展示值"),
tenant_code: str | None = Query(None, description="租户编码"),
tenant_name: str | None = Query(None, description="租户名称(兼容筛选)"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=500, description="分页大小"),
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["list"]]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点查看权限", "data": None})
tenant_context = self._tenant_context(payload)
data = await self.PointService.ListPoints(
int(payload["user_id"]),
tenant_context["UserArea"],
tenant_context["UserRole"],
tenant_context["TenantCode"],
tenant_context["TenantName"],
name,
code,
risk,
is_enabled,
evaluation_point_groups_pid,
evaluation_point_groups_id,
document_attribute_type,
area,
tenant_code,
tenant_name,
page,
page_size,
)
return JSONResponse(status_code=200, content=data.model_dump())
@self.router.api_route("", methods=["GET", "POST"])
async def LegacyEvaluationPointsRoot():
return self._gone()
@self.router.get("/attribute-types")
async def GetEvaluationPointAttributeTypes(payload: dict = Depends(verify_access_token)):
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["list"]]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点查看权限", "data": None})
data = await self.PointService.GetAttributeTypes()
return JSONResponse(status_code=200, content=data.model_dump())
@self.router.api_route("/attribute-types", methods=["GET"])
async def LegacyEvaluationPointAttributeTypes():
return self._gone()
@self.router.get("/{PointId}")
async def GetEvaluationPoint(PointId: int, payload: dict = Depends(verify_access_token)):
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["detail"]]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点查看权限", "data": None})
tenant_context = self._tenant_context(payload)
data = await self.PointService.GetPoint(
int(payload["user_id"]),
tenant_context["UserArea"],
tenant_context["UserRole"],
tenant_context["TenantCode"],
tenant_context["TenantName"],
PointId,
)
return JSONResponse(status_code=200, content=data.model_dump())
@self.router.api_route("/{PointId}", methods=["GET", "PUT", "DELETE"])
async def LegacyEvaluationPointDetail(PointId: int):
return self._gone()
@self.router.post("")
async def CreateEvaluationPoint(body: EvaluationPointCreateDTO, payload: dict = Depends(verify_access_token)):
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["create"]]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建评查点权限", "data": None})
tenant_context = self._tenant_context(payload)
data = await self.PointService.CreatePoint(
int(payload["user_id"]),
tenant_context["UserArea"],
tenant_context["UserRole"],
tenant_context["TenantCode"],
tenant_context["TenantName"],
body,
)
return JSONResponse(status_code=200, content=data.model_dump())
@self.router.put("/{PointId}")
async def UpdateEvaluationPoint(PointId: int, body: EvaluationPointUpdateDTO, payload: dict = Depends(verify_access_token)):
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["update"]]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有更新评查点权限", "data": None})
tenant_context = self._tenant_context(payload)
data = await self.PointService.UpdatePoint(
int(payload["user_id"]),
tenant_context["UserArea"],
tenant_context["UserRole"],
tenant_context["TenantCode"],
tenant_context["TenantName"],
PointId,
body,
)
return JSONResponse(status_code=200, content=data.model_dump())
@self.router.delete("/{PointId}")
async def DeleteEvaluationPoint(PointId: int, payload: dict = Depends(verify_access_token)):
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["delete"]]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有删除评查点权限", "data": None})
tenant_context = self._tenant_context(payload)
data = await self.PointService.DeletePoint(
int(payload["user_id"]),
tenant_context["UserArea"],
tenant_context["UserRole"],
tenant_context["TenantCode"],
tenant_context["TenantName"],
PointId,
)
return JSONResponse(status_code=200, content=data.model_dump())
async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool:
for permission_key in permission_keys:
if await self.PermissionService.CheckPermission(user_id, permission_key):
return True
return False
@staticmethod
def _gone() -> JSONResponse:
return JSONResponse(
status_code=410,
content={
"code": 410,
"msg": "旧评查点接口已下线,请使用规则组/规则配置接口",
"data": None,
},
)
@@ -35,24 +35,26 @@ class EvaluationPointGroupController(BaseController):
code: str | None = Query(None, description="分组编码模糊搜索"),
is_enabled: bool | None = Query(None, description="是否启用"),
pid: int | None = Query(None, description="父分组ID0 表示一级分组"),
entry_module_id: int | None = Query(None, description="入口模块ID"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=500, description="分页大小"),
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None})
data = await self.GroupService.ListGroups(name, code, is_enabled, pid, page, page_size, int(payload["user_id"]))
data = await self.GroupService.ListGroups(name, code, is_enabled, pid, page, page_size, int(payload["user_id"]), entry_module_id)
return JSONResponse(status_code=200, content=data.model_dump())
@self.router.get("/all")
async def ListAllEvaluationPointGroups(
include_disabled: bool = Query(False, description="是否包含禁用分组"),
with_rule_count: bool = Query(True, description="是否返回评查点数"),
entry_module_id: int | None = Query(None, description="入口模块ID"),
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["evaluation_group:list:read", "rules:list:read"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有评查点分组查看权限", "data": None})
data = await self.GroupService.ListAllGroups(include_disabled, with_rule_count, int(payload["user_id"]))
data = await self.GroupService.ListAllGroups(include_disabled, with_rule_count, int(payload["user_id"]), entry_module_id)
return JSONResponse(status_code=200, content=[item.model_dump() for item in data])
@self.router.get("/by-document-types")
@@ -8,6 +8,7 @@ from __future__ import annotations
from typing import Any
from fastapi import Depends, File, Form, Query, UploadFile
from fastapi.responses import JSONResponse
from fastapi_common.fastapi_common_security.security import verify_access_token
from fastapi_common.fastapi_common_web.controller import BaseController
@@ -15,6 +16,8 @@ from fastapi_common.fastapi_common_web.domain.responses import Result
from fastapi_modules.fastapi_leaudit.services import IGovdocService
from fastapi_modules.fastapi_leaudit.services.impl.govdocServiceImpl import GovdocServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl
from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService
class GovdocController(BaseController):
@@ -23,6 +26,7 @@ class GovdocController(BaseController):
def __init__(self):
super().__init__(prefix="/govdoc", tags=["内部公文"])
self.GovdocService: IGovdocService = GovdocServiceImpl()
self.PermissionService: IPermissionService = PermissionServiceImpl()
# ── 文档 ──────────────────────────────────────────
@@ -30,6 +34,7 @@ class GovdocController(BaseController):
async def UploadDocument(
file: UploadFile = File(...),
typeId: int | None = Form(default=None),
entry_module_id: int | None = Form(default=None, description="入口模块ID"),
region: str | None = Form(default=None, description="兼容保留字段:租户展示值/旧地区"),
tenant_code: str | None = Form(default=None, description="租户编码"),
autoRun: bool = Form(default=True),
@@ -41,9 +46,12 @@ class GovdocController(BaseController):
创建文档主档记录,engine_type 标记为 govdoc,可选自动触发审查。
"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "govdoc:document:create"):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有上传公文权限", "data": None})
result = await self.GovdocService.UploadDocument(
file=file,
typeId=typeId,
entryModuleId=entry_module_id,
region=region,
tenantCode=tenant_code,
autoRun=autoRun,
@@ -61,6 +69,9 @@ class GovdocController(BaseController):
fileExt: str | None = Query(default=None),
region: str | None = Query(default=None, description="兼容保留字段:租户展示值/旧地区"),
tenant_code: str | None = Query(default=None, description="租户编码"),
entry_module_id: int | None = Query(default=None, description="按入口模块ID过滤"),
type_ids: str | None = Query(default=None, description="按文档类型ID列表过滤,逗号分隔"),
document_type_id: int | None = Query(default=None, description="按单个文档类型ID过滤"),
status: str | None = Query(default=None),
resultStatus: str | None = Query(default=None),
createdBy: int | None = Query(default=None),
@@ -72,6 +83,18 @@ class GovdocController(BaseController):
后端自动附加 engine_type='govdoc' 过滤条件。
"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "govdoc:document:read"):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看公文列表权限", "data": None})
typeIdList: list[int] = []
if type_ids:
typeIdList = [
int(item.strip())
for item in type_ids.split(",")
if item.strip().isdigit() and int(item.strip()) > 0
]
if document_type_id is not None and document_type_id > 0:
typeIdList.append(document_type_id)
result = await self.GovdocService.ListDocuments(
page=page,
pageSize=pageSize,
@@ -79,6 +102,8 @@ class GovdocController(BaseController):
fileExt=fileExt,
region=region,
tenantCode=tenant_code,
entryModuleId=entry_module_id,
typeIds=typeIdList or None,
status=status,
resultStatus=resultStatus,
createdBy=createdBy,
@@ -2,13 +2,13 @@
from typing import Any
from fastapi import Depends
from fastapi import Depends, Query
from fastapi_common.fastapi_common_security.security import verify_access_token
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.homeVo import HomeEntryModuleVO
from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import HomeDashboardStatisticsVO, HomeEntryModuleVO
from fastapi_modules.fastapi_leaudit.services import IHomeService
from fastapi_modules.fastapi_leaudit.services.impl.homeServiceImpl import HomeServiceImpl
@@ -25,3 +25,24 @@ class HomeController(BaseController):
"""获取当前用户可见的首页入口模块。"""
Data = await self.HomeService.GetEntryModules(UserId=int(payload["user_id"]))
return Result.success(data=Data)
@self.router.get("/statistics/dashboard", response_model=Result[HomeDashboardStatisticsVO])
async def GetDashboardStatistics(
today: str | None = Query(None, description="统计基准日期,格式 YYYY-MM-DD"),
type_ids: str | None = Query(None, description="文档类型ID,逗号分隔"),
entry_module_id: int | None = Query(None, description="入口模块ID"),
payload: dict[str, Any] = Depends(verify_access_token),
):
"""获取首页统计卡片数据。"""
typeIds = [
int(item)
for item in str(type_ids or "").split(",")
if item.strip().isdigit() and int(item.strip()) > 0
]
Data = await self.HomeService.GetDashboardStatistics(
UserId=int(payload["user_id"]),
Today=today,
TypeIds=typeIds,
EntryModuleId=entry_module_id,
)
return Result.success(data=Data)
@@ -0,0 +1,99 @@
"""企查查控制器。"""
from typing import Any
from fastapi import Depends, Query
from fastapi.responses import JSONResponse
from fastapi_common.fastapi_common_security.security import verify_access_token
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.Dto.qichachaDto import QichachaBatchQueryDTO, QichachaCompanyQueryDTO
from fastapi_modules.fastapi_leaudit.domian.vo.qichachaVo import (
QichachaBatchQueryVO,
QichachaCompanyQueryVO,
QichachaRecordStatusVO,
)
from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.qichachaServiceImpl import QichachaServiceImpl
from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService
from fastapi_modules.fastapi_leaudit.services.qichachaService import IQichachaService
class QichachaController(BaseController):
"""企查查控制器。"""
def __init__(self):
super().__init__(prefix="/v2/qichacha", tags=["企查查"])
self.QichachaService: IQichachaService = QichachaServiceImpl()
self.PermissionService: IPermissionService = PermissionServiceImpl()
@self.router.post("/company", response_model=Result[QichachaCompanyQueryVO])
async def QueryCompany(Body: QichachaCompanyQueryDTO, payload: dict[str, Any] = Depends(verify_access_token)):
"""查询企业完整信息。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "qichacha:company:query"):
return JSONResponse(
status_code=403,
content={"code": 403, "message": "当前用户没有查询企业信息权限", "data": None},
)
data = await self.QichachaService.QueryCompany(
Keyword=Body.keyword,
ForceRefresh=Body.forceRefresh,
)
return Result.success(data=data)
@self.router.post("/enterprise", response_model=Result[QichachaCompanyQueryVO])
async def QueryEnterprise(Body: QichachaCompanyQueryDTO, payload: dict[str, Any] = Depends(verify_access_token)):
"""仅查询企业工商信息。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "qichacha:company:query"):
return JSONResponse(
status_code=403,
content={"code": 403, "message": "当前用户没有查询企业信息权限", "data": None},
)
data = await self.QichachaService.QueryEnterpriseOnly(
Keyword=Body.keyword,
ForceRefresh=Body.forceRefresh,
)
return Result.success(data=data)
@self.router.post("/dishonesty", response_model=Result[QichachaCompanyQueryVO])
async def QueryDishonesty(Body: QichachaCompanyQueryDTO, payload: dict[str, Any] = Depends(verify_access_token)):
"""仅查询企业失信信息。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "qichacha:company:query"):
return JSONResponse(
status_code=403,
content={"code": 403, "message": "当前用户没有查询企业信息权限", "data": None},
)
data = await self.QichachaService.QueryDishonestyOnly(
Keyword=Body.keyword,
ForceRefresh=Body.forceRefresh,
)
return Result.success(data=data)
@self.router.post("/batch", response_model=Result[QichachaBatchQueryVO])
async def BatchQuery(Body: QichachaBatchQueryDTO, payload: dict[str, Any] = Depends(verify_access_token)):
"""批量查询企业信息。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "qichacha:company:query"):
return JSONResponse(
status_code=403,
content={"code": 403, "message": "当前用户没有查询企业信息权限", "data": None},
)
data = await self.QichachaService.BatchQuery(
Keywords=Body.keywords,
ForceRefresh=Body.forceRefresh,
)
return Result.success(data=data)
@self.router.get("/status", response_model=Result[QichachaRecordStatusVO])
async def GetRecordStatus(
keyword: str = Query(..., min_length=2, description="企业名称或统一社会信用代码"),
payload: dict[str, Any] = Depends(verify_access_token),
):
"""查询企业缓存状态。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "qichacha:status:read"):
return JSONResponse(
status_code=403,
content={"code": 403, "message": "当前用户没有查看企业缓存状态权限", "data": None},
)
data = await self.QichachaService.GetRecordStatus(Keyword=keyword)
return Result.success(data=data)
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import Any
from fastapi import Depends, Query
from fastapi.responses import JSONResponse
from fastapi_common.fastapi_common_security.security import verify_access_token
from fastapi_common.fastapi_common_web.controller import BaseController
@@ -18,7 +19,9 @@ from fastapi_modules.fastapi_leaudit.domian.vo.usageStatsVo import (
UsageStatsTrendVO,
UsageStatsUserPageVO,
)
from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.usageStatsServiceImpl import UsageStatsServiceImpl
from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService
from fastapi_modules.fastapi_leaudit.services.usageStatsService import IUsageStatsService
@@ -28,6 +31,7 @@ class UsageStatsController(BaseController):
def __init__(self):
super().__init__(prefix="/v3/usage-stats", tags=["系统使用统计"])
self.UsageStatsService: IUsageStatsService = UsageStatsServiceImpl()
self.PermissionService: IPermissionService = PermissionServiceImpl()
@self.router.get("/overview", response_model=Result[UsageStatsOverviewVO])
async def GetOverview(
@@ -39,6 +43,9 @@ class UsageStatsController(BaseController):
documentTypeId: int | None = None,
payload: dict[str, Any] = Depends(verify_access_token),
):
"""查询系统使用统计总览。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "usage_stats:overview:read"):
return JSONResponse(status_code=403, content={"code": 403, "message": "当前用户没有查看统计总览权限", "data": None})
data = await self.UsageStatsService.GetOverview(
CurrentUserId=int(payload["user_id"]),
Filters={
@@ -64,6 +71,9 @@ class UsageStatsController(BaseController):
documentTypeId: int | None = None,
payload: dict[str, Any] = Depends(verify_access_token),
):
"""查询系统使用统计趋势。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "usage_stats:trends:read"):
return JSONResponse(status_code=403, content={"code": 403, "message": "当前用户没有查看统计趋势权限", "data": None})
data = await self.UsageStatsService.GetTrends(
CurrentUserId=int(payload["user_id"]),
Filters={
@@ -92,6 +102,9 @@ class UsageStatsController(BaseController):
dateTo: str | None = None,
payload: dict[str, Any] = Depends(verify_access_token),
):
"""按用户维度查询系统使用统计。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "usage_stats:users:read"):
return JSONResponse(status_code=403, content={"code": 403, "message": "当前用户没有查看用户统计权限", "data": None})
data = await self.UsageStatsService.GetUsers(
CurrentUserId=int(payload["user_id"]),
Filters={
@@ -119,6 +132,9 @@ class UsageStatsController(BaseController):
dateTo: str | None = None,
payload: dict[str, Any] = Depends(verify_access_token),
):
"""按部门维度查询系统使用统计。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "usage_stats:departments:read"):
return JSONResponse(status_code=403, content={"code": 403, "message": "当前用户没有查看部门统计权限", "data": None})
data = await self.UsageStatsService.GetDepartments(
CurrentUserId=int(payload["user_id"]),
Filters={
@@ -144,6 +160,9 @@ class UsageStatsController(BaseController):
documentTypeId: int | None = None,
payload: dict[str, Any] = Depends(verify_access_token),
):
"""按地区维度查询系统使用统计。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "usage_stats:areas:read"):
return JSONResponse(status_code=403, content={"code": 403, "message": "当前用户没有查看地区统计权限", "data": None})
data = await self.UsageStatsService.GetAreas(
CurrentUserId=int(payload["user_id"]),
Filters={
@@ -173,6 +192,9 @@ class UsageStatsController(BaseController):
dateTo: str | None = None,
payload: dict[str, Any] = Depends(verify_access_token),
):
"""查询系统使用统计明细。"""
if not await self.PermissionService.CheckPermission(int(payload["user_id"]), "usage_stats:details:read"):
return JSONResponse(status_code=403, content={"code": 403, "message": "当前用户没有查看统计明细权限", "data": None})
data = await self.UsageStatsService.GetDetails(
CurrentUserId=int(payload["user_id"]),
Filters={
@@ -27,6 +27,8 @@ class EntryModuleCreateDTO(BaseModel):
description: str | None = Field(None, description="模块描述")
path: str | None = Field(None, description="前端路由路径")
route_path: str | None = Field(None, description="前端跳转路径")
menu_profile: str | None = Field(None, description="菜单模板:document_review/contract/govdoc/custom")
features: list[str] | None = Field(None, description="启用功能编码列表")
areas: list[EntryModuleAreaDTO] | None = Field(None, description="历史地区配置(兼容字段,建议改用 tenants)")
tenants: list[EntryModuleTenantDTO] | None = Field(None, description="租户配置")
@@ -38,5 +40,7 @@ class EntryModuleUpdateDTO(BaseModel):
description: str | None = Field(None, description="模块描述")
path: str | None = Field(None, description="前端路由路径")
route_path: str | None = Field(None, description="前端跳转路径")
menu_profile: str | None = Field(None, description="菜单模板:document_review/contract/govdoc/custom")
features: list[str] | None = Field(None, description="启用功能编码列表")
areas: list[EntryModuleAreaDTO] | None = Field(None, description="历史地区配置(兼容字段,建议改用 tenants)")
tenants: list[EntryModuleTenantDTO] | None = Field(None, description="租户配置")
@@ -0,0 +1,17 @@
"""企查查 DTO。"""
from pydantic import BaseModel, Field
class QichachaCompanyQueryDTO(BaseModel):
"""企业查询请求。"""
keyword: str = Field(..., min_length=2, max_length=200, description="企业名称或统一社会信用代码")
forceRefresh: bool = Field(False, description="是否强制刷新缓存")
class QichachaBatchQueryDTO(BaseModel):
"""批量企业查询请求。"""
keywords: list[str] = Field(..., min_length=1, max_length=10, description="企业名称或统一社会信用代码列表")
forceRefresh: bool = Field(False, description="是否强制刷新缓存")
@@ -23,12 +23,14 @@ class CrossReviewTaskItemVO(BaseModel):
docTypeId: int | None = Field(None, description="文档类型ID")
docTypeCode: str | None = Field(None, description="文档类型编码")
status: str = Field(..., description="任务状态")
progress: float = Field(0, description="进度百分比")
totalDocuments: int = Field(0, description="文档总数")
completedDocuments: int = Field(0, description="已完成文档数")
progress: float | None = Field(0, description="进度百分比")
totalDocuments: int | None = Field(0, description="文档总数")
completedDocuments: int | None = Field(0, description="已完成文档数")
createdAt: datetime | None = Field(None, description="创建时间")
evaluationTenants: list[CrossReviewTaskTenantVO] = Field(default_factory=list, description="评查租户列表")
evaluationRegion: list[str] = Field(default_factory=list, description="评查租户/地区(兼容展示值)")
currentUserRole: str = Field("participant", description="当前用户在任务中的角色:assigner/principal/participant")
currentUserCanConfirm: bool = Field(False, description="当前用户是否可确认完成任务文档")
class CrossReviewTaskPageVO(BaseModel):
@@ -20,6 +20,7 @@ class DocumentUploadVO(BaseModel):
typeId: int = Field(..., description="文档类型ID")
typeCode: str = Field(..., description="文档类型编码")
groupId: int | None = Field(None, description="命中的二级分组ID")
entryModuleId: int | None = Field(None, description="所属入口模块ID")
region: str = Field(..., description="所属地区")
tenantCode: str | None = Field(None, description="所属租户编码")
tenantName: str | None = Field(None, description="所属租户名称")
@@ -109,6 +110,7 @@ class DocumentListItemVO(BaseModel):
typeName: str | None = Field(None, description="文档类型名称")
groupId: int | None = Field(None, description="命中的二级分组ID")
groupName: str | None = Field(None, description="二级分组名称")
entryModuleId: int | None = Field(None, description="所属入口模块ID")
region: str = Field(..., description="区域")
tenantCode: str | None = Field(None, description="所属租户编码")
tenantName: str | None = Field(None, description="所属租户名称")
@@ -12,6 +12,14 @@ class EntryModuleTenantVO(BaseModel):
sort_order: int = Field(0, description="排序号")
class EntryModuleBusinessScopeVO(BaseModel):
"""入口模块业务范围摘要。"""
category_count: int = Field(0, description="业务大类数量")
business_type_count: int = Field(0, description="业务类型数量")
categories: list[str] = Field(default_factory=list, description="业务大类名称列表")
class EntryModuleVO(BaseModel):
"""入口模块详情。"""
@@ -20,9 +28,12 @@ class EntryModuleVO(BaseModel):
description: str | None = Field(None, description="模块描述")
path: str | None = Field(None, description="图标路径")
route_path: str | None = Field(None, description="前端跳转路径")
menu_profile: str = Field("document_review", description="菜单模板")
features: list[str] = Field(default_factory=list, description="启用功能编码列表")
sort_order: int = Field(0, description="排序")
is_enabled: bool = Field(True, description="是否启用")
tenants: list[EntryModuleTenantVO] = Field(default_factory=list, description="租户配置")
business_scope: EntryModuleBusinessScopeVO = Field(default_factory=EntryModuleBusinessScopeVO, description="业务范围摘要")
created_at: str | None = Field(None, description="创建时间")
updated_at: str | None = Field(None, description="更新时间")
@@ -36,9 +36,31 @@ class HomeEntryModuleVO(BaseModel):
description: str | None = Field(None, description="模块描述")
targetPath: str | None = Field(None, description="点击后跳转路径")
routePath: str | None = Field(None, description="用于 RBAC 校验的页面路径")
menuProfile: str = Field("document_review", description="菜单模板")
features: list[str] = Field(default_factory=list, description="启用功能编码列表")
tenantCode: str | None = Field(None, description="当前命中的租户编码")
iconPath: str | None = Field(None, description="模块图标路径")
sortOrder: int = Field(0, description="排序序号")
requiresDocumentTypes: bool = Field(True, description="是否要求至少绑定一个文档类型")
areas: list[HomeEntryAreaVO] = Field(default_factory=list, description="地区配置")
tenants: list[HomeEntryTenantVO] = Field(default_factory=list, description="租户配置")
documentTypes: list[HomeEntryDocumentTypeVO] = Field(default_factory=list, description="关联文档类型列表")
class HomeDashboardGrowthVO(BaseModel):
"""首页统计环比数据。"""
value: int = Field(0, description="环比百分比")
isUp: bool = Field(True, description="是否上升")
class HomeDashboardStatisticsVO(BaseModel):
"""首页统计卡片数据。"""
todayPendingFiles: int = Field(0, description="今日待审文件数")
monthlyReviewedFiles: int = Field(0, description="本月已审核文件数")
monthlyReviewGrowth: HomeDashboardGrowthVO = Field(default_factory=HomeDashboardGrowthVO, description="本月审核数环比")
monthlyPassRate: int = Field(0, description="本月审核通过率")
passRateGrowth: HomeDashboardGrowthVO = Field(default_factory=HomeDashboardGrowthVO, description="通过率环比")
issuesDetected: int = Field(0, description="本月问题检出数")
issuesGrowth: HomeDashboardGrowthVO = Field(default_factory=HomeDashboardGrowthVO, description="问题检出数环比")
@@ -0,0 +1,55 @@
"""企查查 VO。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class QichachaCompanyInfoVO(BaseModel):
"""企业信息响应。"""
searchKey: str = Field(..., description="查询关键词")
creditCode: str | None = Field(None, description="统一社会信用代码")
companyName: str | None = Field(None, description="企业名称")
enterprise: dict[str, Any] | None = Field(None, description="工商信息")
dishonesty: dict[str, Any] | None = Field(None, description="失信信息")
hasDishonesty: bool = Field(False, description="是否存在失信记录")
dishonestyCount: int = Field(0, description="失信记录数量")
updatedAt: str | None = Field(None, description="更新时间")
class QichachaCompanyQueryVO(BaseModel):
"""企业查询响应。"""
success: bool = Field(..., description="是否成功")
message: str = Field(..., description="响应消息")
data: QichachaCompanyInfoVO | None = Field(None, description="企业信息")
errorCode: str | None = Field(None, description="错误码")
errorDetails: dict[str, Any] | None = Field(None, description="错误详情")
class QichachaBatchQueryVO(BaseModel):
"""批量企业查询响应。"""
success: bool = Field(..., description="是否全部成功")
total: int = Field(..., description="查询总数")
successCount: int = Field(..., description="成功数")
failedCount: int = Field(..., description="失败数")
results: list[QichachaCompanyQueryVO] = Field(default_factory=list, description="查询结果")
class QichachaRecordStatusVO(BaseModel):
"""企业缓存记录状态响应。"""
exists: bool = Field(..., description="是否存在缓存")
searchKey: str = Field(..., description="查询关键词")
creditCode: str | None = Field(None, description="统一社会信用代码")
companyName: str | None = Field(None, description="企业名称")
hasEnterprise: bool = Field(False, description="是否有工商信息")
hasDishonesty: bool = Field(False, description="是否有失信信息")
updatedAt: str | None = Field(None, description="更新时间")
ageDays: int | None = Field(None, description="缓存天数")
refreshThresholdDays: int = Field(..., description="刷新阈值")
needRefresh: bool = Field(False, description="是否需要刷新")
@@ -17,6 +17,7 @@ from fastapi_modules.fastapi_leaudit.models.usageLoginEvent import UsageLoginEve
from fastapi_modules.fastapi_leaudit.models.govdocRun import GovdocRun
from fastapi_modules.fastapi_leaudit.models.govdocRuleResult import GovdocRuleResult
from fastapi_modules.fastapi_leaudit.models.govdocReportArtifact import GovdocReportArtifact
from fastapi_modules.fastapi_leaudit.models.qichachaCompanyInfo import QichachaCompanyInfo
__all__ = [
"LeauditDocument",
@@ -36,4 +37,5 @@ __all__ = [
"GovdocRun",
"GovdocRuleResult",
"GovdocReportArtifact",
"QichachaCompanyInfo",
]
@@ -0,0 +1,84 @@
"""企查查企业信息缓存模型。"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import BigInteger, Index, String, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from fastapi_common.fastapi_common_web.models import BaseModel
class QichachaCompanyInfo(BaseModel):
"""企查查企业信息缓存表。"""
__tablename__ = "qcc_company_info"
__table_args__ = (
Index("idx_qcc_company_search_key", "search_key", unique=True),
Index("idx_qcc_company_credit_code", "credit_code"),
Index("idx_qcc_company_name", "company_name"),
)
Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True)
searchKey: Mapped[str] = mapped_column("search_key", String(200), nullable=False, comment="查询关键词")
creditCode: Mapped[str | None] = mapped_column("credit_code", String(64), comment="统一社会信用代码")
companyName: Mapped[str | None] = mapped_column("company_name", String(255), comment="企业名称")
enterprise: Mapped[dict[str, Any] | None] = mapped_column("enterprise", JSONB, comment="工商信息")
dishonesty: Mapped[dict[str, Any] | None] = mapped_column("dishonesty", JSONB, comment="失信信息")
@classmethod
async def FindByKeyword(cls, session: AsyncSession, Keyword: str) -> "QichachaCompanyInfo | None":
"""按查询词、信用代码或企业名查询缓存。"""
return await session.scalar(
select(cls).where(
cls.deleted_at.is_(None),
or_(
cls.searchKey == Keyword,
cls.creditCode == Keyword,
cls.companyName == Keyword,
),
)
)
@classmethod
async def Upsert(
cls,
session: AsyncSession,
SearchKey: str,
CreditCode: str | None,
CompanyName: str | None,
Enterprise: dict[str, Any] | None,
Dishonesty: dict[str, Any] | None,
) -> "QichachaCompanyInfo":
"""按查询关键词写入或更新缓存。"""
record = await cls.FindByKeyword(session, SearchKey)
if record is None:
record = cls(
searchKey=SearchKey,
creditCode=CreditCode,
companyName=CompanyName,
enterprise=Enterprise,
dishonesty=Dishonesty,
)
session.add(record)
else:
record.searchKey = SearchKey
record.creditCode = CreditCode
record.companyName = CompanyName
record.enterprise = Enterprise
record.dishonesty = Dishonesty
record.updated_at = datetime.now(UTC)
await session.flush()
return record
@classmethod
def GetAgeDays(cls, record: "QichachaCompanyInfo") -> int:
"""计算缓存记录距今天数。"""
updated_at = record.updated_at
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=UTC)
return max((datetime.now(UTC) - updated_at).days, 0)
@@ -20,6 +20,7 @@ from fastapi_modules.fastapi_leaudit.services.ragChatService import IRagChatServ
from fastapi_modules.fastapi_leaudit.services.tenantService import ITenantService
from fastapi_modules.fastapi_leaudit.services.govdocService import IGovdocService
from fastapi_modules.fastapi_leaudit.services.usageStatsService import IUsageStatsService
from fastapi_modules.fastapi_leaudit.services.qichachaService import IQichachaService
__all__ = [
"IAuditService",
@@ -42,4 +43,5 @@ __all__ = [
"ITenantService",
"IUsageStatsService",
"IGovdocService",
"IQichachaService",
]
@@ -35,7 +35,12 @@ class ICrossReviewService(ABC):
...
@abstractmethod
async def GetUserTasks(self, CurrentUserId: int, Body: CrossReviewTaskQueryDTO) -> CrossReviewTaskPageVO:
async def GetUserTasks(
self,
CurrentUserId: int,
Body: CrossReviewTaskQueryDTO,
CanViewProgress: bool = True,
) -> CrossReviewTaskPageVO:
"""查询当前用户参与的交叉评查任务。"""
...
@@ -81,6 +81,11 @@ class IDocumentService(ABC):
"""获取评查详情页所需的聚合数据。"""
...
@abstractmethod
async def IsCrossReviewDocument(self, DocumentId: int) -> bool:
"""判断文档是否属于交叉评查范围。"""
...
@abstractmethod
async def AuditReviewPoint(
self,
@@ -36,11 +36,18 @@ class IEvaluationPointGroupService(ABC):
Page: int,
PageSize: int,
CurrentUserId: int,
EntryModuleId: int | None = None,
) -> EvaluationPointGroupListVO:
...
@abstractmethod
async def ListAllGroups(self, IncludeDisabled: bool, WithRuleCount: bool, CurrentUserId: int) -> list[EvaluationPointGroupVO]:
async def ListAllGroups(
self,
IncludeDisabled: bool,
WithRuleCount: bool,
CurrentUserId: int,
EntryModuleId: int | None = None,
) -> list[EvaluationPointGroupVO]:
...
@abstractmethod
@@ -16,6 +16,7 @@ class IGovdocService(ABC):
self,
file: UploadFile,
typeId: int | None = None,
entryModuleId: int | None = None,
region: str | None = None,
tenantCode: str | None = None,
autoRun: bool = False,
@@ -35,6 +36,8 @@ class IGovdocService(ABC):
fileExt: str | None = None,
region: str | None = None,
tenantCode: str | None = None,
entryModuleId: int | None = None,
typeIds: list[int] | None = None,
status: str | None = None,
resultStatus: str | None = None,
createdBy: int | None = None,
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import HomeEntryModuleVO
from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import HomeDashboardStatisticsVO, HomeEntryModuleVO
class IHomeService(ABC):
@@ -12,3 +12,14 @@ class IHomeService(ABC):
async def GetEntryModules(self, UserId: int) -> list[HomeEntryModuleVO]:
"""获取当前用户可见的首页入口模块。"""
...
@abstractmethod
async def GetDashboardStatistics(
self,
UserId: int,
Today: str | None = None,
TypeIds: list[int] | None = None,
EntryModuleId: int | None = None,
) -> HomeDashboardStatisticsVO:
"""获取当前业务入口的首页统计卡片数据。"""
...
@@ -200,7 +200,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
await self._ensure_tables_ready(session)
memberUserIds = self._unique_int_list(Body.memberUserIds + [CurrentUserId])
principalUserIds = self._unique_int_list(Body.principalUserIds)
principalUserIds = self._unique_int_list(Body.principalUserIds + [CurrentUserId])
documentIds = self._unique_int_list(Body.documentIds)
await self._assert_task_scope_inputs(session, CurrentUserId, memberUserIds, documentIds)
@@ -289,7 +289,12 @@ class CrossReviewServiceImpl(ICrossReviewService):
documentCount=len(documentIds),
)
async def GetUserTasks(self, CurrentUserId: int, Body: CrossReviewTaskQueryDTO) -> CrossReviewTaskPageVO:
async def GetUserTasks(
self,
CurrentUserId: int,
Body: CrossReviewTaskQueryDTO,
CanViewProgress: bool = True,
) -> CrossReviewTaskPageVO:
"""查询当前用户参与的交叉评查任务。"""
async with GetAsyncSession() as session:
await self._ensure_tables_ready(session)
@@ -401,6 +406,15 @@ class CrossReviewServiceImpl(ICrossReviewService):
t.doc_type_code,
t.status,
t.create_time,
CASE
WHEN t.assigner_id = :current_user_id THEN 'assigner'
ELSE COALESCE(MAX(tm.member_role), 'participant')
END AS current_user_role,
CASE
WHEN t.assigner_id = :current_user_id THEN TRUE
WHEN COALESCE(MAX(CASE WHEN tm.member_role = 'principal' THEN 1 ELSE 0 END), 0) = 1 THEN TRUE
ELSE FALSE
END AS current_user_can_confirm,
COALESCE(ds.total_documents, 0) AS total_documents,
COALESCE(ds.completed_documents, 0) AS completed_documents,
COALESCE(tt.evaluation_tenants, '[]'::jsonb) AS evaluation_tenants,
@@ -416,7 +430,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
ON tr.task_id = t.id
WHERE {whereSql}
GROUP BY
t.id, t.task_name, t.task_type, t.doc_type_id, t.doc_type_code,
t.id, t.task_name, t.task_type, t.doc_type_id, t.doc_type_code, t.assigner_id,
t.status, t.create_time, ds.total_documents, ds.completed_documents, tt.evaluation_tenants,
tr.evaluation_regions
ORDER BY t.create_time DESC, t.id DESC
@@ -429,35 +443,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
items = []
for row in rows:
totalDocuments = int(row["total_documents"] or 0)
completedDocuments = int(row["completed_documents"] or 0)
progress = round((completedDocuments / totalDocuments * 100) if totalDocuments > 0 else 0, 2)
evaluationTenants = self._parse_task_tenants(row.get("evaluation_tenants"))
rawRegions = row.get("evaluation_regions")
if rawRegions is None:
evaluationRegion: list[str] = []
elif isinstance(rawRegions, list):
evaluationRegion = [str(r) for r in rawRegions]
else:
evaluationRegion = [str(rawRegions)]
if not evaluationRegion:
evaluationRegion = [tenant.tenantName for tenant in evaluationTenants if tenant.tenantName]
items.append(
CrossReviewTaskItemVO(
taskId=int(row["task_id"]),
taskName=str(row["task_name"]),
taskType=str(row["task_type"]),
docTypeId=self._to_int(row.get("doc_type_id")),
docTypeCode=row.get("doc_type_code"),
status=str(row["status"]),
progress=progress,
totalDocuments=totalDocuments,
completedDocuments=completedDocuments,
createdAt=row.get("create_time"),
evaluationTenants=evaluationTenants,
evaluationRegion=evaluationRegion,
)
)
items.append(self._build_task_item_vo(row=row, CanViewProgress=CanViewProgress))
return CrossReviewTaskPageVO(total=total, page=Body.page, pageSize=Body.pageSize, items=items)
@@ -518,7 +504,32 @@ class CrossReviewServiceImpl(ICrossReviewService):
baseWhereClauses.append("(d.normalized_name ILIKE :keyword OR CAST(d.biz_document_id AS TEXT) ILIKE :keyword)")
params["keyword"] = f"%{Body.keyword.strip()}%"
baseWhereSql = " AND ".join(baseWhereClauses)
latestWhereSql = f"{baseWhereSql} AND COALESCE(d.is_latest_version, false) = true"
taskDocumentsSql = f"""
SELECT
td.task_id,
td.document_id,
td.audit_status,
d.id,
d.normalized_name,
d.biz_document_id,
d.type_id,
d.processing_status,
d.version_no,
d.is_latest_version,
d.version_group_key,
d.root_version_id,
d.current_run_id,
d.created_at,
COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text)) AS task_version_group_key,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text))
ORDER BY d.version_no DESC, d.id DESC
) AS task_version_rank
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
WHERE {baseWhereSql}
"""
total = int(
(
@@ -526,10 +537,8 @@ class CrossReviewServiceImpl(ICrossReviewService):
text(
f"""
SELECT COUNT(*)
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
WHERE {latestWhereSql}
FROM ({taskDocumentsSql}) task_docs
WHERE task_docs.task_version_rank = 1
"""
),
params,
@@ -558,10 +567,10 @@ class CrossReviewServiceImpl(ICrossReviewService):
) AS processing_status,
d.version_no,
d.is_latest_version,
COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text)) AS version_group_key,
d.task_version_group_key AS version_group_key,
COALESCE(vc.total_versions, 1)::int AS total_versions,
d.created_at,
td.audit_status,
d.audit_status,
COALESCE(dt.name, '') AS type_name,
COALESCE(df.file_size, 0) AS file_size,
COALESCE(df.file_path, '') AS path,
@@ -581,9 +590,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
COALESCE(es.full_score, 0) AS full_score,
COALESCE(es.score_summary, '') AS score_summary,
COALESCE(es.score_percent, 0) AS score_percent
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
FROM ({taskDocumentsSql}) d
LEFT JOIN leaudit_audit_runs ar
ON ar.id = d.current_run_id
LEFT JOIN leaudit_document_types dt
@@ -608,7 +615,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
WHERE d2.deleted_at IS NULL
GROUP BY d2.version_group_key
, COALESCE(d2.root_version_id, d2.id)
) vc ON vc.version_group_key = COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text))
) vc ON vc.version_group_key = d.task_version_group_key
LEFT JOIN LATERAL (
SELECT
COUNT(*)::int AS total_evaluation_points,
@@ -661,7 +668,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
AND p.status = 'approved'
AND p.delete_time IS NULL
) pd ON TRUE
WHERE {latestWhereSql}
WHERE d.task_version_rank = 1
ORDER BY d.created_at DESC, d.id DESC
LIMIT :limit OFFSET :offset
"""
@@ -2099,13 +2106,15 @@ class CrossReviewServiceImpl(ICrossReviewService):
if not member_tenant_code and not member_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"成员用户未绑定有效租户: {user_id}")
resolved_scopes.append({"tenant_code": member_tenant_code, "tenant_name": member_tenant_name})
if is_global:
continue
if current_tenant_code:
if member_tenant_code:
if member_tenant_code != current_tenant_code:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户用户加入交叉评查任务: {user_id}")
elif member_tenant_name != current_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户用户加入交叉评查任务: {user_id}")
elif not is_global and member_tenant_name != current_tenant_name:
elif member_tenant_name != current_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户用户加入交叉评查任务: {user_id}")
if document_ids:
@@ -2141,13 +2150,15 @@ class CrossReviewServiceImpl(ICrossReviewService):
if not document_tenant_code and not document_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"任务文档未绑定有效租户: {document_id}")
resolved_scopes.append({"tenant_code": document_tenant_code, "tenant_name": document_tenant_name})
if is_global:
continue
if current_tenant_code:
if document_tenant_code:
if document_tenant_code != current_tenant_code:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户文档加入交叉评查任务: {document_id}")
elif document_tenant_name != current_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户文档加入交叉评查任务: {document_id}")
elif not is_global and document_tenant_name != current_tenant_name:
elif document_tenant_name != current_tenant_name:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"不能将其他租户文档加入交叉评查任务: {document_id}")
self._assert_single_task_scope(resolved_scopes)
@@ -2283,13 +2294,15 @@ class CrossReviewServiceImpl(ICrossReviewService):
result.append(intValue)
return result
def _to_int(self, value) -> int | None:
@staticmethod
def _to_int(value) -> int | None:
"""安全转 int。"""
if value is None:
return None
return int(value)
def _parse_text_array(self, value) -> list[str]:
@staticmethod
def _parse_text_array(value) -> list[str]:
"""安全解析 PostgreSQL text[] 为字符串列表。"""
if value is None:
return []
@@ -2297,7 +2310,8 @@ class CrossReviewServiceImpl(ICrossReviewService):
return [str(v) for v in value]
return [str(value)]
def _parse_task_tenants(self, value) -> list[CrossReviewTaskTenantVO]:
@staticmethod
def _parse_task_tenants(value) -> list[CrossReviewTaskTenantVO]:
"""安全解析任务租户 JSON 聚合结果。"""
if value is None:
return []
@@ -2319,6 +2333,40 @@ class CrossReviewServiceImpl(ICrossReviewService):
result.append(CrossReviewTaskTenantVO(tenantCode=tenant_code, tenantName=tenant_name or tenant_code))
return result
@classmethod
def _build_task_item_vo(cls, row, CanViewProgress: bool = True) -> CrossReviewTaskItemVO:
"""根据进度权限组装任务列表项。"""
totalDocuments = int(row["total_documents"] or 0)
completedDocuments = int(row["completed_documents"] or 0)
progress = round((completedDocuments / totalDocuments * 100) if totalDocuments > 0 else 0, 2)
evaluationTenants = cls._parse_task_tenants(row.get("evaluation_tenants"))
rawRegions = row.get("evaluation_regions")
if rawRegions is None:
evaluationRegion: list[str] = []
elif isinstance(rawRegions, list):
evaluationRegion = [str(r) for r in rawRegions]
else:
evaluationRegion = [str(rawRegions)]
if not evaluationRegion:
evaluationRegion = [tenant.tenantName for tenant in evaluationTenants if tenant.tenantName]
return CrossReviewTaskItemVO(
taskId=int(row["task_id"]),
taskName=str(row["task_name"]),
taskType=str(row["task_type"]),
docTypeId=cls._to_int(row.get("doc_type_id")),
docTypeCode=row.get("doc_type_code"),
status=str(row["status"]),
progress=progress if CanViewProgress else None,
totalDocuments=totalDocuments if CanViewProgress else None,
completedDocuments=completedDocuments if CanViewProgress else None,
createdAt=row.get("create_time"),
evaluationTenants=evaluationTenants,
evaluationRegion=evaluationRegion,
currentUserRole=str(row.get("current_user_role") or "participant"),
currentUserCanConfirm=bool(row.get("current_user_can_confirm")),
)
def _build_score_summary(self, finalScore: float, fullScore: float) -> str:
if fullScore <= 0:
return "0/0"
@@ -88,6 +88,7 @@ class DocumentServiceImpl(IDocumentService):
TypeId: int | None = None,
TypeCode: str | None = None,
GroupId: int | None = None,
EntryModuleId: int | None = None,
Region: str | None = None,
FileRole: str = "primary",
CreatedBy: int | None = None,
@@ -136,6 +137,7 @@ class DocumentServiceImpl(IDocumentService):
async with GetAsyncSession() as Session:
await self._ensureDocumentGroupColumn(Session)
normalizedEntryModuleId = int(EntryModuleId) if EntryModuleId is not None and int(EntryModuleId) > 0 else None
if TypeId is not None and TypeCode is not None:
typeResult = await Session.execute(
text(
@@ -182,7 +184,13 @@ class DocumentServiceImpl(IDocumentService):
resolvedTypeId = int(typeRow["id"])
resolvedTypeCode = str(typeRow["code"])
resolvedGroupId = await self._resolveDocumentGroupId(Session, resolvedTypeId, GroupId)
await self._assertDocumentTypeEntryModule(Session, resolvedTypeId, normalizedEntryModuleId)
resolvedGroupId = await self._resolveDocumentGroupId(
Session,
resolvedTypeId,
GroupId,
normalizedEntryModuleId,
)
resolvedRootGroupId = await self._resolveDocumentRootGroupId(Session, resolvedTypeId, resolvedGroupId)
duplicateUpload = False
previousVersionId: int | None = None
@@ -236,6 +244,21 @@ class DocumentServiceImpl(IDocumentService):
else:
rootVersionId = document.rootVersionId
if normalizedEntryModuleId is not None:
await Session.execute(
text(
"""
UPDATE leaudit_documents
SET entry_module_id = :entry_module_id
WHERE id = :document_id
"""
),
{
"entry_module_id": normalizedEntryModuleId,
"document_id": document.Id,
},
)
versionLabel = f"v{document.versionNo}"
objectKey = OssPathUtils.BuildBusinessDocKey(
Region=normalizedRegion,
@@ -324,6 +347,7 @@ class DocumentServiceImpl(IDocumentService):
typeId=resolvedTypeId,
typeCode=resolvedTypeCode,
groupId=resolvedGroupId,
entryModuleId=normalizedEntryModuleId,
region=normalizedRegion,
tenantCode=resolvedTenant.tenant_code,
tenantName=resolvedTenant.tenant_name or normalizedRegion,
@@ -463,6 +487,7 @@ class DocumentServiceImpl(IDocumentService):
optionalSelects = [
f"{resolvedGroupIdExpr} AS group_id",
"d.entry_module_id AS entry_module_id" if "entry_module_id" in documentColumns else "NULL::bigint AS entry_module_id",
"d.document_number AS document_number" if "document_number" in documentColumns else "NULL::text AS document_number",
f"{persistedOrDerivedAuditStatusExpr} AS audit_status",
"d.is_test_document AS is_test_document" if "is_test_document" in documentColumns else "FALSE AS is_test_document",
@@ -662,6 +687,7 @@ class DocumentServiceImpl(IDocumentService):
typeName=row["type_name"],
groupId=int(row["group_id"]) if row["group_id"] is not None else None,
groupName=row["group_name"],
entryModuleId=int(row["entry_module_id"]) if row["entry_module_id"] is not None else None,
region=row["region"],
tenantCode=row["tenant_code"],
tenantName=row["tenant_name"],
@@ -762,6 +788,26 @@ class DocumentServiceImpl(IDocumentService):
scoring_proposals=scoringProposals,
)
async def IsCrossReviewDocument(self, DocumentId: int) -> bool:
"""判断文档是否属于交叉评查范围。"""
async with GetAsyncSession() as Session:
row = (
await Session.execute(
text(
"""
SELECT 1
FROM leaudit_documents d
WHERE d.id = :document_id
AND d.deleted_at IS NULL
AND COALESCE(d.review_scope, 'standard') = 'cross_review'
LIMIT 1
"""
),
{"document_id": DocumentId},
)
).first()
return bool(row)
async def AuditReviewPoint(
self,
CurrentUserId: int,
@@ -2705,6 +2751,15 @@ class DocumentServiceImpl(IDocumentService):
"""
)
)
await Session.execute(
text(
"""
ALTER TABLE leaudit_documents
ADD COLUMN IF NOT EXISTS entry_module_id BIGINT NULL
REFERENCES leaudit_entry_modules(id)
"""
)
)
await Session.execute(
text(
"""
@@ -2717,23 +2772,71 @@ class DocumentServiceImpl(IDocumentService):
await Session.execute(
text("CREATE INDEX IF NOT EXISTS idx_leaudit_documents_group_id ON leaudit_documents(group_id)")
)
await Session.execute(
text("CREATE INDEX IF NOT EXISTS idx_leaudit_documents_entry_module_id ON leaudit_documents(entry_module_id)")
)
await Session.execute(
text("CREATE INDEX IF NOT EXISTS idx_leaudit_documents_tenant_code ON leaudit_documents(tenant_code)")
)
async def _resolveDocumentGroupId(self, Session, TypeId: int, GroupId: int | None) -> int | None:
"""校验上传时选择的二级分组是否属于当前文档类型。"""
if GroupId is None:
return None
async def _assertDocumentTypeEntryModule(
self,
Session,
TypeId: int,
EntryModuleId: int | None,
) -> None:
if EntryModuleId is None:
return
row = (
await Session.execute(
text(
"""
SELECT id, document_type_id, name
FROM leaudit_evaluation_point_groups
WHERE id = :group_id
SELECT id, name, entry_module_id
FROM leaudit_document_types
WHERE id = :type_id
AND deleted_at IS NULL
AND COALESCE(pid, 0) <> 0
LIMIT 1
"""
),
{"type_id": TypeId},
)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档类型不存在或已停用")
if int(row.get("entry_module_id") or 0) != EntryModuleId:
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
f"文档类型「{row['name']}」不属于当前入口模块,无法上传",
)
async def _resolveDocumentGroupId(
self,
Session,
TypeId: int,
GroupId: int | None,
EntryModuleId: int | None = None,
) -> int | None:
"""校验上传时选择的二级分组是否属于当前文档类型。"""
if GroupId is None:
return await self._resolveUniqueDocumentGroupId(Session, TypeId, EntryModuleId)
row = (
await Session.execute(
text(
"""
SELECT
child.id,
child.document_type_id,
child.name,
COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS entry_module_id
FROM leaudit_evaluation_point_groups child
LEFT JOIN leaudit_evaluation_point_groups parent
ON parent.id = child.pid
LEFT JOIN leaudit_document_types dt
ON dt.id = child.document_type_id
WHERE child.id = :group_id
AND child.deleted_at IS NULL
AND COALESCE(child.pid, 0) <> 0
AND child.is_enabled = true
LIMIT 1
"""
),
@@ -2748,8 +2851,63 @@ class DocumentServiceImpl(IDocumentService):
TypeId, GroupId, row["document_type_id"], row["name"],
)
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"当前子类型「{row['name']}」(id={GroupId}) 属于文档类型 {row['document_type_id']},与所选文档类型 {TypeId} 不匹配,无法上传")
if EntryModuleId is not None and int(row.get("entry_module_id") or 0) != EntryModuleId:
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
f"当前子类型「{row['name']}」不属于当前入口模块,无法上传",
)
return int(row["id"])
async def _resolveUniqueDocumentGroupId(
self,
Session,
TypeId: int,
EntryModuleId: int | None = None,
) -> int | None:
params: dict[str, int] = {"type_id": TypeId}
entry_module_filter = ""
if EntryModuleId is not None:
entry_module_filter = """
AND COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id) = :entry_module_id
"""
params["entry_module_id"] = int(EntryModuleId)
rows = (
await Session.execute(
text(
f"""
SELECT
child.id,
child.name,
COALESCE(child.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS entry_module_id
FROM leaudit_evaluation_point_groups child
LEFT JOIN leaudit_evaluation_point_groups parent
ON parent.id = child.pid
LEFT JOIN leaudit_document_types dt
ON dt.id = child.document_type_id
WHERE child.document_type_id = :type_id
AND child.deleted_at IS NULL
AND COALESCE(child.pid, 0) <> 0
AND child.is_enabled = true
{entry_module_filter}
ORDER BY COALESCE(child.sort_order, 0) ASC, child.id ASC
LIMIT 2
"""
),
params,
)
).mappings().all()
if len(rows) == 1:
return int(rows[0]["id"])
if len(rows) > 1:
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
"当前文档类型存在多个子类型,请选择具体子类型后再上传",
)
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
"当前文档类型未配置可用子类型,请先在评查点分组管理中配置二级分组",
)
async def _resolveDocumentRootGroupId(self, Session, TypeId: int, GroupId: int | None) -> int | None:
"""解析上传命中的一级分组,用于跨二级类型做版本归档。"""
if GroupId is not None:
@@ -2942,6 +3100,7 @@ class DocumentServiceImpl(IDocumentService):
optionalSelects = [
f"{resolvedGroupIdExpr} AS group_id",
"d.entry_module_id AS entry_module_id" if "entry_module_id" in DocumentColumns else "NULL::bigint AS entry_module_id",
"d.document_number AS document_number" if "document_number" in DocumentColumns else "NULL::text AS document_number",
"d.remark AS remark" if "remark" in DocumentColumns else "NULL::text AS remark",
"d.is_test_document AS is_test_document" if "is_test_document" in DocumentColumns else "FALSE AS is_test_document",
@@ -3149,6 +3308,7 @@ class DocumentServiceImpl(IDocumentService):
typeName=detailRow["type_name"],
groupId=int(detailRow["group_id"]) if detailRow["group_id"] is not None else None,
groupName=detailRow["group_name"],
entryModuleId=int(detailRow["entry_module_id"]) if detailRow["entry_module_id"] is not None else None,
region=str(detailRow["region"] or ""),
tenantCode=detailRow["tenant_code"],
tenantName=detailRow["tenant_name"],
@@ -3562,6 +3722,9 @@ class DocumentServiceImpl(IDocumentService):
pageCount = int(metricRow["page_count"]) if metricRow and metricRow["page_count"] is not None else 0
ocrResultPayload = await self._buildReviewOcrPayload(Session, Detail.documentId, RunRow, pageCount)
pageQualityResults = []
if Detail.pageQualityRunId is not None:
pageQualityResults = await self._loadPageQualityIssueResults(Session, int(Detail.pageQualityRunId))
return {
"id": Detail.documentId,
@@ -3593,8 +3756,66 @@ class DocumentServiceImpl(IDocumentService):
"page_count": pageCount,
"ocrResult": ocrResultPayload,
"attachments": [item.model_dump() for item in Detail.attachments],
**self._buildReviewPageQualityPayload(Detail, pageQualityResults),
}
@staticmethod
def _buildReviewPageQualityPayload(
Detail: DocumentDetailVO,
PageQualityResults: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""构建评查详情页需要的图片质量摘要字段。"""
page_quality_summary = Detail.pageQualitySummary.model_dump() if Detail.pageQualitySummary else None
status_order = {"reject": 0, "review": 1}
page_quality_results = sorted(
PageQualityResults or [],
key=lambda item: (
status_order.get(str(item.get("qualityStatus") or ""), 9),
int(item.get("pageNum") or 0),
),
)
return {
"pageQualityRunId": Detail.pageQualityRunId,
"pageQualityRunStatus": Detail.pageQualityRunStatus,
"pageQualitySummaryStatus": Detail.pageQualitySummaryStatus,
"pageQualityIssueCount": Detail.pageQualityIssueCount,
"pageQualityWarningText": Detail.pageQualityWarningText,
"pageQualitySummary": page_quality_summary,
"pageQualityResults": page_quality_results,
}
async def _loadPageQualityIssueResults(self, Session, RunId: int) -> list[dict[str, Any]]:
"""加载页级图片质量问题明细。"""
if not await self._tableExists(Session, "leaudit_page_quality_results"):
return []
rows = (
await Session.execute(
text(
"""
SELECT page_num, quality_status, quality_score, reason_text
FROM leaudit_page_quality_results
WHERE run_id = :run_id
AND quality_status IN ('review', 'reject')
ORDER BY
CASE quality_status WHEN 'reject' THEN 0 WHEN 'review' THEN 1 ELSE 9 END,
page_num ASC,
id ASC
"""
),
{"run_id": RunId},
)
).mappings().all()
return [
{
"pageNum": int(row["page_num"]),
"qualityStatus": str(row["quality_status"] or "review"),
"qualityScore": float(row["quality_score"]) if row["quality_score"] is not None else None,
"reasonText": str(row["reason_text"] or "") or None,
}
for row in rows
if row["page_num"] is not None
]
async def _buildReviewOcrPayload(
self,
Session,
@@ -21,6 +21,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.entryModuleDto import (
EntryModuleUpdateDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.entryModuleAdminVo import (
EntryModuleBusinessScopeVO,
EntryModuleImageUploadVO,
EntryModuleListVO,
EntryModuleTenantVO,
@@ -31,6 +32,31 @@ from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServ
from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolution, TenantResolver
_ALLOWED_MENU_PROFILES = {"document_review", "contract", "govdoc", "cross_checking", "custom"}
_ALLOWED_FEATURES = {
"home",
"documents",
"upload",
"rules",
"rule_groups",
"contract_template_search",
"contract_template_list",
"govdoc_audits",
"govdoc_upload",
"cross_checking",
"cross_checking_upload",
"cross_checking_list",
"usage_stats",
}
_DEFAULT_FEATURES_BY_PROFILE = {
"document_review": ["home", "documents", "upload", "rules", "rule_groups"],
"contract": ["home", "documents", "upload", "rules", "contract_template_search", "contract_template_list"],
"govdoc": ["home", "govdoc_audits", "govdoc_upload", "rule_groups"],
"cross_checking": ["cross_checking", "cross_checking_upload", "cross_checking_list"],
"custom": ["home", "documents"],
}
class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
"""入口模块管理服务实现。"""
@@ -39,6 +65,7 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
self.TenantResolver = TenantResolver()
self._tenant_table_exists_cache: bool | None = None
self._entry_module_tenant_table_exists_cache: bool | None = None
self._entry_module_menu_columns_exist_cache: bool | None = None
async def ListModules(
self,
@@ -128,6 +155,8 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
if has_tenant_mapping_table
else "'[]'::jsonb AS tenants"
)
menu_select_sql = await self._entry_module_menu_select_sql()
business_scope_select_sql = self._entry_module_business_scope_select_sql()
async with GetAsyncSession() as session:
total = int(
@@ -154,6 +183,8 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
em.is_enabled,
em.created_at,
em.updated_at,
{menu_select_sql},
{business_scope_select_sql},
{tenant_select_sql}
FROM leaudit_entry_modules em
WHERE {where_clause}
@@ -182,29 +213,37 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
)
self._ensureTenantAssignments(normalized_tenants)
legacy_areas_json = self._legacyAreasJson(normalized_tenants)
menu_profile = self._normalizeMenuProfile(Body.menu_profile)
features = self._normalizeFeatures(Body.features, menu_profile)
has_menu_columns = await self._entry_module_menu_columns_exist()
async with GetAsyncSession() as session:
try:
menu_insert_columns = ", menu_profile, features" if has_menu_columns else ""
menu_insert_values = ", :menu_profile, CAST(:features AS jsonb)" if has_menu_columns else ""
params = {
"name": Body.name.strip(),
"description": (Body.description or "").strip() or None,
"route_path": route_path,
"icon_path": None,
"areas": legacy_areas_json,
"sort_order": await self._nextSortOrder(session),
"menu_profile": menu_profile,
"features": json.dumps(features, ensure_ascii=False),
}
row = (
await session.execute(
text(
"""
f"""
INSERT INTO leaudit_entry_modules (
name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at, deleted_at
name, description, path, icon_path, areas, sort_order, is_enabled, created_at, updated_at, deleted_at{menu_insert_columns}
) VALUES (
:name, :description, :route_path, :icon_path, CAST(:areas AS jsonb), :sort_order, TRUE, NOW(), NOW(), NULL
:name, :description, :route_path, :icon_path, CAST(:areas AS jsonb), :sort_order, TRUE, NOW(), NOW(), NULL{menu_insert_values}
)
RETURNING id
"""
),
{
"name": Body.name.strip(),
"description": (Body.description or "").strip() or None,
"route_path": route_path,
"icon_path": None,
"areas": legacy_areas_json,
"sort_order": await self._nextSortOrder(session),
},
params,
)
).mappings().one()
module_id = int(row["id"])
@@ -226,12 +265,30 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
else:
normalized_tenants = await self._extractTenantsFromRow(current)
self._ensureTenantAssignments(normalized_tenants)
current_menu_profile = self._safeMenuProfile(current.get("menu_profile"))
menu_profile = self._normalizeMenuProfile(Body.menu_profile) if Body.menu_profile is not None else current_menu_profile
features = (
self._normalizeFeatures(Body.features, menu_profile)
if Body.features is not None
else self._parseFeatures(current.get("features"), menu_profile)
)
has_menu_columns = await self._entry_module_menu_columns_exist()
async with GetAsyncSession() as session:
menu_update_sql = ", menu_profile = :menu_profile, features = CAST(:features AS jsonb)" if has_menu_columns else ""
params = {
"module_id": ModuleId,
"name": Body.name.strip() if Body.name is not None else current["name"],
"description": Body.description.strip() if Body.description is not None else current.get("description"),
"route_path": incoming_route_path.strip() if incoming_route_path is not None else current.get("path"),
"areas": self._legacyAreasJson(normalized_tenants),
"menu_profile": menu_profile,
"features": json.dumps(features, ensure_ascii=False),
}
row = (
await session.execute(
text(
"""
f"""
UPDATE leaudit_entry_modules
SET
name = :name,
@@ -239,18 +296,13 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
path = :route_path,
areas = CAST(:areas AS jsonb),
updated_at = NOW()
{menu_update_sql}
WHERE id = :module_id
AND deleted_at IS NULL
RETURNING id
"""
),
{
"module_id": ModuleId,
"name": Body.name.strip() if Body.name is not None else current["name"],
"description": Body.description.strip() if Body.description is not None else current.get("description"),
"route_path": incoming_route_path.strip() if incoming_route_path is not None else current.get("path"),
"areas": self._legacyAreasJson(normalized_tenants),
},
params,
)
).mappings().first()
if not row:
@@ -341,6 +393,8 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
if has_tenant_mapping_table
else "'[]'::jsonb AS tenants"
)
menu_select_sql = await self._entry_module_menu_select_sql()
business_scope_select_sql = self._entry_module_business_scope_select_sql()
async with GetAsyncSession() as session:
row = (
await session.execute(
@@ -357,6 +411,8 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
em.is_enabled,
em.created_at,
em.updated_at,
{menu_select_sql},
{business_scope_select_sql},
{tenant_select_sql}
FROM leaudit_entry_modules em
WHERE em.id = :module_id
@@ -386,9 +442,12 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
description=Row.get("description"),
path=Row.get("icon_path"),
route_path=Row.get("path"),
menu_profile=self._safeMenuProfile(Row.get("menu_profile")),
features=self._parseFeatures(Row.get("features"), Row.get("menu_profile")),
sort_order=int(Row.get("sort_order") or 0),
is_enabled=bool(Row.get("is_enabled", True)),
tenants=tenants,
business_scope=self._parseBusinessScope(Row.get("business_scope")),
created_at=self._toIso(Row.get("created_at")),
updated_at=self._toIso(Row.get("updated_at")),
)
@@ -568,6 +627,102 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
self._entry_module_tenant_table_exists_cache = exists
return exists
async def _entry_module_menu_columns_exist(self) -> bool:
if self._entry_module_menu_columns_exist_cache is not None:
return self._entry_module_menu_columns_exist_cache
async with GetAsyncSession() as session:
count = int(
(
await session.execute(
text(
"""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'leaudit_entry_modules'
AND column_name IN ('menu_profile', 'features')
"""
)
)
).scalar_one()
)
self._entry_module_menu_columns_exist_cache = count == 2
return self._entry_module_menu_columns_exist_cache
async def _entry_module_menu_select_sql(self) -> str:
if await self._entry_module_menu_columns_exist():
return "em.menu_profile, em.features"
return "'document_review'::varchar AS menu_profile, '[]'::jsonb AS features"
def _entry_module_business_scope_select_sql(self) -> str:
return """
COALESCE(
(
SELECT jsonb_build_object(
'category_count', COUNT(DISTINCT dt.id),
'business_type_count', GREATEST(
COUNT(DISTINCT child_by_type.id),
COALESCE(
(
SELECT COUNT(DISTINCT child_by_entry.id)
FROM leaudit_evaluation_point_groups root_by_entry
JOIN leaudit_evaluation_point_groups child_by_entry
ON child_by_entry.pid = root_by_entry.id
AND child_by_entry.deleted_at IS NULL
WHERE root_by_entry.entry_module_id = em.id
AND root_by_entry.pid = 0
AND root_by_entry.deleted_at IS NULL
),
0
)
),
'categories', COALESCE(
(
SELECT jsonb_agg(category_name ORDER BY category_name)
FROM (
SELECT DISTINCT dt2.name AS category_name
FROM leaudit_document_types dt2
WHERE dt2.entry_module_id = em.id
AND dt2.deleted_at IS NULL
AND dt2.is_enabled = TRUE
) category_rows
),
'[]'::jsonb
)
)
FROM leaudit_document_types dt
LEFT JOIN leaudit_evaluation_point_groups root
ON root.document_type_id = dt.id
AND root.pid = 0
AND root.deleted_at IS NULL
LEFT JOIN leaudit_evaluation_point_groups child_by_type
ON child_by_type.pid = root.id
AND child_by_type.deleted_at IS NULL
WHERE dt.entry_module_id = em.id
AND dt.deleted_at IS NULL
AND dt.is_enabled = TRUE
),
jsonb_build_object('category_count', 0, 'business_type_count', 0, 'categories', '[]'::jsonb)
) AS business_scope
"""
def _parseBusinessScope(self, RawValue: object) -> EntryModuleBusinessScopeVO:
if isinstance(RawValue, str):
try:
RawValue = json.loads(RawValue)
except json.JSONDecodeError:
RawValue = {}
if not isinstance(RawValue, dict):
RawValue = {}
categories_raw = RawValue.get("categories") or []
categories = [str(item).strip() for item in categories_raw if str(item or "").strip()] if isinstance(categories_raw, list) else []
return EntryModuleBusinessScopeVO(
category_count=int(RawValue.get("category_count") or len(categories)),
business_type_count=int(RawValue.get("business_type_count") or 0),
categories=categories,
)
async def _resolveLegacyTenantValue(self, *, RawValue: str, Source: str) -> TenantResolution:
resolution = await self.TenantResolver.Resolve(
RawValue=RawValue,
@@ -705,6 +860,65 @@ class EntryModuleAdminServiceImpl(IEntryModuleAdminService):
"入口模块至少需要配置一个适用租户",
)
@staticmethod
def _normalizeMenuProfile(MenuProfile: str | None) -> str:
value = str(MenuProfile or "document_review").strip() or "document_review"
if value not in _ALLOWED_MENU_PROFILES:
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
f"不支持的菜单模板: {value}",
)
return value
@staticmethod
def _safeMenuProfile(MenuProfile: str | None) -> str:
value = str(MenuProfile or "document_review").strip() or "document_review"
return value if value in _ALLOWED_MENU_PROFILES else "document_review"
@staticmethod
def _normalizeFeatures(Features: list[str] | None, MenuProfile: str) -> list[str]:
raw_features = Features if Features else _DEFAULT_FEATURES_BY_PROFILE.get(MenuProfile, _DEFAULT_FEATURES_BY_PROFILE["document_review"])
normalized: list[str] = []
invalid: list[str] = []
for item in raw_features:
feature = str(item or "").strip()
if not feature:
continue
if feature not in _ALLOWED_FEATURES:
invalid.append(feature)
continue
if feature not in normalized:
normalized.append(feature)
if invalid:
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
f"不支持的功能编码: {', '.join(invalid)}",
)
return normalized or list(_DEFAULT_FEATURES_BY_PROFILE.get(MenuProfile, _DEFAULT_FEATURES_BY_PROFILE["document_review"]))
@classmethod
def _parseFeatures(cls, RawFeatures: Any, MenuProfile: str | None) -> list[str]:
menu_profile = cls._safeMenuProfile(MenuProfile)
if isinstance(RawFeatures, list):
return cls._filterFeatures([str(item) for item in RawFeatures], menu_profile)
if isinstance(RawFeatures, str) and RawFeatures.strip():
try:
parsed = json.loads(RawFeatures)
except json.JSONDecodeError:
parsed = []
if isinstance(parsed, list):
return cls._filterFeatures([str(item) for item in parsed], menu_profile)
return list(_DEFAULT_FEATURES_BY_PROFILE.get(menu_profile, _DEFAULT_FEATURES_BY_PROFILE["document_review"]))
@staticmethod
def _filterFeatures(Features: list[str], MenuProfile: str) -> list[str]:
normalized: list[str] = []
for item in Features:
feature = str(item or "").strip()
if feature in _ALLOWED_FEATURES and feature not in normalized:
normalized.append(feature)
return normalized or list(_DEFAULT_FEATURES_BY_PROFILE.get(MenuProfile, _DEFAULT_FEATURES_BY_PROFILE["document_review"]))
@staticmethod
def _toIso(Value) -> str | None:
"""时间转 ISO 字符串。"""
@@ -62,6 +62,7 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
Page: int,
PageSize: int,
CurrentUserId: int,
EntryModuleId: int | None = None,
) -> EvaluationPointGroupListVO:
async with GetAsyncSession() as session:
await self._ensure_ready(session)
@@ -97,6 +98,10 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
if Pid is not None:
filters.append("COALESCE(g.pid, 0) = :pid")
params["pid"] = self._normalize_pid(Pid)
if EntryModuleId is not None:
await self._assert_entry_module_access(session, EntryModuleId, current_user)
filters.append("COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) = :entry_module_id")
params["entry_module_id"] = int(EntryModuleId)
where_clause = " AND ".join(filters)
total = int(
@@ -149,7 +154,13 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
page_size=PageSize,
)
async def ListAllGroups(self, IncludeDisabled: bool, WithRuleCount: bool, CurrentUserId: int) -> list[EvaluationPointGroupVO]:
async def ListAllGroups(
self,
IncludeDisabled: bool,
WithRuleCount: bool,
CurrentUserId: int,
EntryModuleId: int | None = None,
) -> list[EvaluationPointGroupVO]:
async with GetAsyncSession() as session:
await self._ensure_ready(session)
current_user = await self._get_current_user_context(session, CurrentUserId)
@@ -166,6 +177,10 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
)
if not IncludeDisabled:
filters.append("g.is_enabled = TRUE")
if EntryModuleId is not None:
await self._assert_entry_module_access(session, EntryModuleId, current_user)
filters.append("COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) = :entry_module_id")
params["entry_module_id"] = int(EntryModuleId)
rows = (
await session.execute(
text(
@@ -233,6 +248,7 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
IncludeDisabled=IncludeDisabled,
WithRuleCount=WithRuleCount,
CurrentUserId=CurrentUserId,
EntryModuleId=None,
)
result: list[EvaluationPointGroupVO] = []
for root in roots:
@@ -262,6 +278,7 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
Page=Page,
PageSize=PageSize,
CurrentUserId=CurrentUserId,
EntryModuleId=None,
)
async def CreateGroup(self, Body: EvaluationPointGroupCreateDTO, CurrentUserId: int) -> EvaluationPointGroupVO:
@@ -760,7 +777,7 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
row = (
await session.execute(
text(
"""
f"""
SELECT
rgb.id,
rgb.group_id,
@@ -798,30 +815,43 @@ class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
group_ids = [int(item) for item in group_ids if item]
if not group_ids:
return {}
params: dict[str, Any] = {"group_ids": group_ids}
tenant_filter = ""
if current_user is not None and not current_user.get("is_global"):
visible_tenant_codes = ["PUBLIC", "PROVINCIAL"]
tenant_code = normalize_scoped_tenant_code(str(current_user.get("tenant_code") or ""), default="")
if tenant_code:
visible_tenant_codes.append(tenant_code)
tenant_filter = """
AND COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') IN :visible_tenant_codes
"""
params["visible_tenant_codes"] = sorted(set(visible_tenant_codes))
query = text(
f"""
SELECT
id,
group_id,
rule_set_id,
rule_type_binding_id,
COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') AS tenant_code,
COALESCE(NULLIF(BTRIM(scope_type), ''), 'PROVINCIAL') AS scope_type,
tenant_name_snapshot,
priority,
is_active,
note,
created_at,
updated_at
FROM leaudit_rule_group_bindings
WHERE group_id IN :group_ids
AND deleted_at IS NULL
{tenant_filter}
ORDER BY priority DESC, id ASC
"""
).bindparams(bindparam("group_ids", expanding=True))
if "visible_tenant_codes" in params:
query = query.bindparams(bindparam("visible_tenant_codes", expanding=True))
rows = (
await session.execute(
text(
"""
SELECT
id,
group_id,
rule_set_id,
rule_type_binding_id,
COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') AS tenant_code,
COALESCE(NULLIF(BTRIM(scope_type), ''), 'PROVINCIAL') AS scope_type,
tenant_name_snapshot,
priority,
is_active,
note,
created_at,
updated_at
FROM leaudit_rule_group_bindings
WHERE group_id IN :group_ids AND deleted_at IS NULL
ORDER BY priority DESC, id ASC
"""
).bindparams(bindparam("group_ids", expanding=True)),
{"group_ids": group_ids},
)
await session.execute(query, params)
).mappings().all()
result: dict[int, list[RuleGroupBindingVO]] = {}
for row in rows:
@@ -101,6 +101,7 @@ class GovdocServiceImpl(IGovdocService):
self,
file: UploadFile,
typeId: int | None = None,
entryModuleId: int | None = None,
region: str | None = None,
tenantCode: str | None = None,
autoRun: bool = True,
@@ -229,11 +230,15 @@ class GovdocServiceImpl(IGovdocService):
text(
"""
UPDATE leaudit_documents
SET engine_type = 'govdoc'
SET engine_type = 'govdoc',
entry_module_id = COALESCE(:entry_module_id, entry_module_id)
WHERE id = :document_id
"""
),
{"document_id": document.Id},
{
"document_id": document.Id,
"entry_module_id": int(entryModuleId) if entryModuleId is not None and int(entryModuleId) > 0 else None,
},
)
await session.commit()
@@ -273,6 +278,8 @@ class GovdocServiceImpl(IGovdocService):
fileExt: str | None = None,
region: str | None = None,
tenantCode: str | None = None,
entryModuleId: int | None = None,
typeIds: list[int] | None = None,
status: str | None = None,
resultStatus: str | None = None,
createdBy: int | None = None,
@@ -327,6 +334,13 @@ class GovdocServiceImpl(IGovdocService):
if normalizedExt:
filters.append("LOWER(COALESCE(f.file_ext, '')) = :file_ext")
params["file_ext"] = normalizedExt
normalizedTypeIds = [int(typeId) for typeId in (typeIds or []) if int(typeId) > 0]
if normalizedTypeIds:
filters.append("d.type_id = ANY(:type_ids)")
params["type_ids"] = normalizedTypeIds
if entryModuleId is not None and int(entryModuleId) > 0:
filters.append("COALESCE(d.entry_module_id, dt.entry_module_id) = :entry_module_id")
params["entry_module_id"] = int(entryModuleId)
if status:
filters.append("COALESCE(d.processing_status, '') = :status")
params["status"] = status.strip()
@@ -420,6 +434,8 @@ class GovdocServiceImpl(IGovdocService):
AND f.deleted_at IS NULL
LEFT JOIN govdoc_runs gr
ON gr.id = d.current_run_id
LEFT JOIN leaudit_document_types dt
ON dt.id = d.type_id
LEFT JOIN (
SELECT
run_id,
@@ -1236,6 +1252,10 @@ class GovdocServiceImpl(IGovdocService):
""",
"""
ALTER TABLE leaudit_documents
ADD COLUMN IF NOT EXISTS entry_module_id BIGINT
""",
"""
ALTER TABLE leaudit_documents
ADD COLUMN IF NOT EXISTS engine_type VARCHAR(32) NOT NULL DEFAULT 'leaudit'
""",
"""
@@ -2,6 +2,10 @@
from __future__ import annotations
from datetime import date, datetime
import json
from typing import Any
from sqlalchemy import text
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
@@ -9,12 +13,16 @@ from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
from fastapi_modules.fastapi_leaudit.domian.vo.homeVo import (
HomeDashboardGrowthVO,
HomeDashboardStatisticsVO,
HomeEntryAreaVO,
HomeEntryDocumentTypeVO,
HomeEntryModuleVO,
HomeEntryTenantVO,
)
from fastapi_modules.fastapi_leaudit.services import IDocumentService
from fastapi_modules.fastapi_leaudit.services.homeService import IHomeService
from fastapi_modules.fastapi_leaudit.services.impl.documentServiceImpl import DocumentServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.rbacServiceImpl import RbacServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat
from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolution, TenantResolver
@@ -29,17 +37,34 @@ class HomeServiceImpl(IHomeService):
"/documents",
"/chat-with-llm/chat",
"/cross-checking",
"/govdoc",
"/govdoc/home",
"/govdoc/audits",
"/govdoc/upload",
"/govdoc-audit",
"/govdoc-audit/home",
"/govdoc-audit/audits",
"/govdoc-audit/upload",
)
_DOCUMENT_ENTRY_TARGETS: tuple[str, ...] = (
"/files/upload",
"/documents",
"/documents/list",
)
_DEFAULT_FEATURES_BY_PROFILE: dict[str, list[str]] = {
"document_review": ["home", "documents", "upload", "rules", "rule_groups"],
"contract": ["home", "documents", "upload", "rules", "contract_template_search", "contract_template_list"],
"govdoc": ["home", "govdoc_audits", "govdoc_upload", "rule_groups"],
"cross_checking": ["cross_checking", "cross_checking_upload", "cross_checking_list"],
"custom": ["home", "documents"],
}
def __init__(self) -> None:
def __init__(self, DocumentService: IDocumentService | None = None) -> None:
self.RbacService = RbacServiceImpl()
self.TenantResolver = TenantResolver()
self.DocumentService = DocumentService or DocumentServiceImpl()
self._entry_module_tenant_table_exists_cache: bool | None = None
self._entry_module_menu_columns_exist_cache: bool | None = None
async def GetEntryModules(self, UserId: int) -> list[HomeEntryModuleVO]:
"""获取当前用户可见的首页入口模块。"""
@@ -125,6 +150,9 @@ class HomeServiceImpl(IHomeService):
if has_tenant_mapping_table
else "'[]'::jsonb AS tenants"
)
has_menu_columns = await self._entry_module_menu_columns_exist()
menu_select_sql = self._entry_module_menu_select_sql(has_menu_columns)
menu_group_by_sql = ",\n em.menu_profile,\n em.features" if has_menu_columns else ""
tenant_scope_filter_sql = (
"""
(
@@ -195,6 +223,7 @@ class HomeServiceImpl(IHomeService):
em.icon_path,
em.areas,
em.sort_order,
{menu_select_sql},
{tenant_select_sql},
COALESCE(
json_agg(
@@ -225,6 +254,7 @@ class HomeServiceImpl(IHomeService):
em.icon_path,
em.areas,
em.sort_order
{menu_group_by_sql}
ORDER BY em.sort_order ASC, em.id ASC
"""
),
@@ -311,6 +341,9 @@ class HomeServiceImpl(IHomeService):
description=row["description"],
targetPath=target_path,
routePath=target_path,
menuProfile=self._normalizeMenuProfile(row.get("menu_profile")),
features=self._parseFeatures(row.get("features"), row.get("menu_profile")),
tenantCode=effective_tenant_code or None,
iconPath=row["icon_path"],
sortOrder=int(row["sort_order"] or 0),
requiresDocumentTypes=requires_document_types,
@@ -322,6 +355,95 @@ class HomeServiceImpl(IHomeService):
return modules
async def GetDashboardStatistics(
self,
UserId: int,
Today: str | None = None,
TypeIds: list[int] | None = None,
EntryModuleId: int | None = None,
) -> HomeDashboardStatisticsVO:
"""获取当前业务入口的首页统计卡片数据。"""
today = date.fromisoformat(Today) if Today else date.today()
current_month_start = today.replace(day=1)
previous_month_end = current_month_start.fromordinal(current_month_start.toordinal() - 1)
previous_month_start = previous_month_end.replace(day=1)
normalized_type_ids = [int(typeId) for typeId in (TypeIds or []) if int(typeId) > 0]
normalized_entry_module_id = int(EntryModuleId) if EntryModuleId and int(EntryModuleId) > 0 else None
async def load_documents() -> list[Any]:
page = 1
documents: list[Any] = []
while True:
result = await self.DocumentService.ListDocuments(
CurrentUserId=UserId,
Page=page,
PageSize=100,
TypeIds=normalized_type_ids or None,
EntryModuleId=normalized_entry_module_id,
)
documents.extend(result.documents)
total_pages = max(1, int(result.totalPages or 1))
if page >= total_pages:
return documents
page += 1
def issue_count(Documents: list[Any]) -> int:
return sum(max(0, int(document.failedCount or 0)) for document in Documents)
def pass_rate(Documents: list[Any]) -> int:
if not Documents:
return 0
passed_count = len([document for document in Documents if int(document.failedCount or 0) == 0])
return round((passed_count / len(Documents)) * 100)
def growth(Current: int, Previous: int) -> HomeDashboardGrowthVO:
if Previous <= 0:
return HomeDashboardGrowthVO(value=100 if Current > 0 else 0, isUp=Current >= Previous)
return HomeDashboardGrowthVO(
value=round(abs(((Current - Previous) / Previous) * 100)),
isUp=Current >= Previous,
)
def document_date(Document: Any) -> date | None:
raw_value = str(getattr(Document, "updatedAt", "") or "").strip()
if not raw_value:
return None
normalized = raw_value.replace("Z", "+00:00")
try:
return datetime.fromisoformat(normalized).date()
except ValueError:
try:
return datetime.strptime(raw_value, "%Y-%m-%d %H:%M:%S").date()
except ValueError:
return None
def in_range(Document: Any, DateFrom: date, DateTo: date) -> bool:
current_date = document_date(Document)
return current_date is not None and DateFrom <= current_date <= DateTo
documents = await load_documents()
today_documents = [document for document in documents if in_range(document, today, today)]
current_month_documents = [document for document in documents if in_range(document, current_month_start, today)]
previous_month_documents = [
document for document in documents if in_range(document, previous_month_start, previous_month_end)
]
current_reviewed_documents = [document for document in current_month_documents if int(document.auditStatus or 0) == 1]
previous_reviewed_documents = [document for document in previous_month_documents if int(document.auditStatus or 0) == 1]
current_pass_rate = pass_rate(current_reviewed_documents)
previous_pass_rate = pass_rate(previous_reviewed_documents)
current_issue_count = issue_count(current_reviewed_documents)
previous_issue_count = issue_count(previous_reviewed_documents)
return HomeDashboardStatisticsVO(
todayPendingFiles=len([document for document in today_documents if int(document.auditStatus or 0) != 1]),
monthlyReviewedFiles=len(current_reviewed_documents),
monthlyReviewGrowth=growth(len(current_reviewed_documents), len(previous_reviewed_documents)),
monthlyPassRate=current_pass_rate,
passRateGrowth=growth(current_pass_rate, previous_pass_rate),
issuesDetected=current_issue_count,
issuesGrowth=growth(current_issue_count, previous_issue_count),
)
async def _entry_module_tenant_table_exists(self) -> bool:
if self._entry_module_tenant_table_exists_cache is not None:
return self._entry_module_tenant_table_exists_cache
@@ -345,6 +467,75 @@ class HomeServiceImpl(IHomeService):
self._entry_module_tenant_table_exists_cache = exists
return exists
async def _entry_module_menu_columns_exist(self) -> bool:
if self._entry_module_menu_columns_exist_cache is not None:
return self._entry_module_menu_columns_exist_cache
async with GetAsyncSession() as session:
count = int(
(
await session.execute(
text(
"""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'leaudit_entry_modules'
AND column_name IN ('menu_profile', 'features')
"""
)
)
).scalar_one()
)
self._entry_module_menu_columns_exist_cache = count == 2
return self._entry_module_menu_columns_exist_cache
@staticmethod
def _entry_module_menu_select_sql(HasMenuColumns: bool) -> str:
if HasMenuColumns:
return "em.menu_profile, em.features"
return "'document_review'::varchar AS menu_profile, '[]'::jsonb AS features"
@classmethod
def _normalizeMenuProfile(cls, MenuProfile: str | None) -> str:
value = str(MenuProfile or "document_review").strip() or "document_review"
if value not in cls._DEFAULT_FEATURES_BY_PROFILE:
return "document_review"
return value
@classmethod
def _parseFeatures(cls, RawFeatures: Any, MenuProfile: str | None) -> list[str]:
menu_profile = cls._normalizeMenuProfile(MenuProfile)
allowed_features = {
"home",
"documents",
"upload",
"rules",
"rule_groups",
"contract_template_search",
"contract_template_list",
"govdoc_audits",
"govdoc_upload",
"cross_checking",
"cross_checking_upload",
"cross_checking_list",
"usage_stats",
}
if isinstance(RawFeatures, list):
parsed = RawFeatures
elif isinstance(RawFeatures, str) and RawFeatures.strip():
try:
parsed = json.loads(RawFeatures)
except json.JSONDecodeError:
parsed = []
else:
parsed = []
normalized: list[str] = []
for item in parsed:
feature = str(item or "").strip()
if feature in allowed_features and feature not in normalized:
normalized.append(feature)
return normalized or list(cls._DEFAULT_FEATURES_BY_PROFILE[menu_profile])
async def _resolveLegacyTenantValue(self, *, RawValue: str, Source: str) -> TenantResolution:
resolution = await self.TenantResolver.Resolve(
RawValue=RawValue,
@@ -0,0 +1,119 @@
"""企查查 HTTP 客户端。"""
from __future__ import annotations
import asyncio
import hashlib
import time
from typing import Any
import httpx
from fastapi_admin.config import (
QICHACHA_APP_KEY,
QICHACHA_BASE_URL,
QICHACHA_DISHONESTY_PATH,
QICHACHA_ENTERPRISE_PATH,
QICHACHA_MAX_RETRIES,
QICHACHA_RETRY_DELAY,
QICHACHA_SECRET_KEY,
QICHACHA_TIMEOUT,
)
from fastapi_common.fastapi_common_logger import logger
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
from fastapi_common.fastapi_common_web.exception.QichachaException import QichachaException
class QichachaClient:
"""企查查 HTTP 客户端。"""
def __init__(
self,
AppKey: str | None = None,
SecretKey: str | None = None,
BaseUrl: str | None = None,
EnterprisePath: str | None = None,
DishonestyPath: str | None = None,
Timeout: int | None = None,
MaxRetries: int | None = None,
RetryDelay: float | None = None,
) -> None:
"""初始化客户端配置。"""
self.AppKey = AppKey if AppKey is not None else str(QICHACHA_APP_KEY)
self.SecretKey = SecretKey if SecretKey is not None else str(QICHACHA_SECRET_KEY)
self.BaseUrl = (BaseUrl if BaseUrl is not None else str(QICHACHA_BASE_URL)).rstrip("/")
self.EnterprisePath = EnterprisePath if EnterprisePath is not None else str(QICHACHA_ENTERPRISE_PATH)
self.DishonestyPath = DishonestyPath if DishonestyPath is not None else str(QICHACHA_DISHONESTY_PATH)
self.Timeout = Timeout if Timeout is not None else int(QICHACHA_TIMEOUT)
self.MaxRetries = MaxRetries if MaxRetries is not None else int(QICHACHA_MAX_RETRIES)
self.RetryDelay = RetryDelay if RetryDelay is not None else float(QICHACHA_RETRY_DELAY)
def BuildHeaders(self) -> dict[str, str]:
"""生成企查查鉴权请求头。"""
timespan = str(int(time.time()))
token_source = f"{self.AppKey}{timespan}{self.SecretKey}"
token = hashlib.md5(token_source.encode("utf-8")).hexdigest().upper()
return {"Token": token, "Timespan": timespan}
async def Request(self, Url: str, Params: dict[str, str]) -> dict[str, Any]:
"""发送企查查 GET 请求并返回 JSON。"""
if not self.AppKey:
raise QichachaException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "企查查 APP_KEY 未配置")
if not self.SecretKey:
raise QichachaException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "企查查 SECRET_KEY 未配置")
last_error: Exception | None = None
for attempt in range(max(self.MaxRetries, 1)):
try:
async with httpx.AsyncClient(timeout=self.Timeout) as client:
response = await client.get(Url, params=Params, headers=self.BuildHeaders())
response.raise_for_status()
data = response.json()
if not isinstance(data, dict):
raise QichachaException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "企查查响应格式错误")
status = str(data.get("Status") or "")
if status and status not in {"200", "201"}:
message = str(data.get("Message") or "企查查查询失败")
raise QichachaException(StatusCodeEnum.HTTP_400_BAD_REQUEST, message)
return data
except QichachaException:
raise
except Exception as exc:
last_error = exc
if attempt < max(self.MaxRetries, 1) - 1:
await asyncio.sleep(self.RetryDelay)
logger.error(f"企查查请求失败: url={Url}, error={last_error}")
raise QichachaException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, f"企查查请求失败: {last_error}")
async def GetEnterpriseInfo(self, Keyword: str) -> dict[str, Any] | None:
"""查询企业工商信息。"""
data = await self.Request(
Url=f"{self.BaseUrl}{self.EnterprisePath}",
Params={"key": self.AppKey, "keyword": Keyword},
)
result = data.get("Result")
return result if isinstance(result, dict) else None
async def GetDishonestyInfo(self, Keyword: str) -> dict[str, Any] | None:
"""查询企业失信信息。"""
data = await self.Request(
Url=f"{self.BaseUrl}{self.DishonestyPath}",
Params={"key": self.AppKey, "searchKey": Keyword},
)
result = data.get("Result")
return result if isinstance(result, dict) else None
async def QueryCompany(self, Keyword: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None, str | None, str | None]:
"""并发查询工商信息与失信信息。"""
enterprise_result, dishonesty_result = await asyncio.gather(
self.GetEnterpriseInfo(Keyword),
self.GetDishonestyInfo(Keyword),
)
credit_code = (
str(enterprise_result.get("CreditCode"))
if enterprise_result and enterprise_result.get("CreditCode")
else None
)
company_name = str(enterprise_result.get("Name")) if enterprise_result and enterprise_result.get("Name") else None
return enterprise_result, dishonesty_result, credit_code, company_name
@@ -0,0 +1,174 @@
"""企查查服务实现。"""
from __future__ import annotations
from fastapi_admin.config import QICHACHA_CACHE_DAYS
from fastapi_common.fastapi_common_logger import logger
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
from fastapi_common.fastapi_common_web.exception.QichachaException import QichachaException
from fastapi_modules.fastapi_leaudit.domian.vo.qichachaVo import (
QichachaBatchQueryVO,
QichachaCompanyQueryVO,
QichachaRecordStatusVO,
)
from fastapi_modules.fastapi_leaudit.models.qichachaCompanyInfo import QichachaCompanyInfo
from fastapi_modules.fastapi_leaudit.services.impl.qichachaClient import QichachaClient
from fastapi_modules.fastapi_leaudit.services.impl.qichachaVoAssembler import QichachaVoAssembler
from fastapi_modules.fastapi_leaudit.services.qichachaService import IQichachaService
class QichachaServiceImpl(IQichachaService):
"""企查查服务实现。"""
def __init__(self, Client: QichachaClient | None = None, CacheDays: int | None = None) -> None:
"""初始化企查查服务。"""
self.Client = Client if Client is not None else QichachaClient()
self.CacheDays = CacheDays if CacheDays is not None else int(QICHACHA_CACHE_DAYS)
async def QueryCompany(self, Keyword: str, ForceRefresh: bool = False) -> QichachaCompanyQueryVO:
"""查询企业完整信息。"""
keyword = Keyword.strip()
if not keyword:
raise QichachaException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "查询关键词不能为空")
async with GetAsyncSession() as session:
record = await QichachaCompanyInfo.FindByKeyword(session, keyword)
if record is not None and not ForceRefresh and QichachaCompanyInfo.GetAgeDays(record) <= self.CacheDays:
return QichachaCompanyQueryVO(
success=True,
message="查询成功",
data=QichachaVoAssembler.BuildCompanyInfo(record),
)
enterprise, dishonesty, credit_code, company_name = await self.Client.QueryCompany(keyword)
record = await QichachaCompanyInfo.Upsert(
session,
SearchKey=keyword,
CreditCode=credit_code,
CompanyName=company_name,
Enterprise=enterprise,
Dishonesty=dishonesty,
)
logger.info(f"企查查企业信息已更新: {keyword}")
return QichachaCompanyQueryVO(
success=True,
message="查询成功",
data=QichachaVoAssembler.BuildCompanyInfo(record),
)
async def QueryEnterpriseOnly(self, Keyword: str, ForceRefresh: bool = False) -> QichachaCompanyQueryVO:
"""仅查询企业工商信息。"""
keyword = Keyword.strip()
async with GetAsyncSession() as session:
record = await QichachaCompanyInfo.FindByKeyword(session, keyword)
if (
record is not None
and record.enterprise is not None
and not ForceRefresh
and QichachaCompanyInfo.GetAgeDays(record) <= self.CacheDays
):
return QichachaCompanyQueryVO(
success=True,
message="查询成功",
data=QichachaVoAssembler.BuildCompanyInfo(record),
)
enterprise = await self.Client.GetEnterpriseInfo(keyword)
credit_code = str(enterprise.get("CreditCode")) if enterprise and enterprise.get("CreditCode") else None
company_name = str(enterprise.get("Name")) if enterprise and enterprise.get("Name") else None
record = await QichachaCompanyInfo.Upsert(
session,
SearchKey=keyword,
CreditCode=credit_code,
CompanyName=company_name,
Enterprise=enterprise,
Dishonesty=record.dishonesty if record is not None else None,
)
return QichachaCompanyQueryVO(
success=True,
message="查询成功",
data=QichachaVoAssembler.BuildCompanyInfo(record),
)
async def QueryDishonestyOnly(self, Keyword: str, ForceRefresh: bool = False) -> QichachaCompanyQueryVO:
"""仅查询企业失信信息。"""
keyword = Keyword.strip()
async with GetAsyncSession() as session:
record = await QichachaCompanyInfo.FindByKeyword(session, keyword)
if (
record is not None
and record.dishonesty is not None
and not ForceRefresh
and QichachaCompanyInfo.GetAgeDays(record) <= self.CacheDays
):
return QichachaCompanyQueryVO(
success=True,
message="查询成功",
data=QichachaVoAssembler.BuildCompanyInfo(record),
)
dishonesty = await self.Client.GetDishonestyInfo(keyword)
record = await QichachaCompanyInfo.Upsert(
session,
SearchKey=keyword,
CreditCode=record.creditCode if record is not None else None,
CompanyName=record.companyName if record is not None else keyword,
Enterprise=record.enterprise if record is not None else None,
Dishonesty=dishonesty,
)
return QichachaCompanyQueryVO(
success=True,
message="查询成功",
data=QichachaVoAssembler.BuildCompanyInfo(record),
)
async def BatchQuery(self, Keywords: list[str], ForceRefresh: bool = False) -> QichachaBatchQueryVO:
"""批量查询企业信息。"""
results: list[QichachaCompanyQueryVO] = []
for keyword in Keywords:
try:
results.append(await self.QueryCompany(keyword, ForceRefresh))
except Exception as exc:
results.append(
QichachaCompanyQueryVO(
success=False,
message=str(exc),
data=None,
errorCode="QICHACHA_QUERY_FAILED",
)
)
success_count = len([item for item in results if item.success])
return QichachaBatchQueryVO(
success=success_count == len(results),
total=len(results),
successCount=success_count,
failedCount=len(results) - success_count,
results=results,
)
async def GetRecordStatus(self, Keyword: str) -> QichachaRecordStatusVO:
"""查询企业缓存状态。"""
keyword = Keyword.strip()
async with GetAsyncSession() as session:
record = await QichachaCompanyInfo.FindByKeyword(session, keyword)
if record is None:
return QichachaRecordStatusVO(
exists=False,
searchKey=keyword,
refreshThresholdDays=self.CacheDays,
needRefresh=True,
)
age_days = QichachaCompanyInfo.GetAgeDays(record)
return QichachaRecordStatusVO(
exists=True,
searchKey=record.searchKey,
creditCode=record.creditCode,
companyName=record.companyName,
hasEnterprise=record.enterprise is not None,
hasDishonesty=record.dishonesty is not None,
updatedAt=QichachaVoAssembler.FormatDatetime(record.updated_at),
ageDays=age_days,
refreshThresholdDays=self.CacheDays,
needRefresh=age_days > self.CacheDays,
)
@@ -0,0 +1,40 @@
"""企查查 VO 组装器。"""
from __future__ import annotations
from datetime import UTC
from typing import Any
from fastapi_modules.fastapi_leaudit.domian.vo.qichachaVo import QichachaCompanyInfoVO
from fastapi_modules.fastapi_leaudit.models.qichachaCompanyInfo import QichachaCompanyInfo
class QichachaVoAssembler:
"""企查查 VO 组装器。"""
@classmethod
def BuildCompanyInfo(cls, record: QichachaCompanyInfo) -> QichachaCompanyInfoVO:
"""组装企业信息 VO。"""
dishonesty = record.dishonesty if isinstance(record.dishonesty, dict) else None
data_list = dishonesty.get("Data") if dishonesty else []
has_dishonesty = bool(dishonesty and int(dishonesty.get("VerifyResult") or 0) == 1)
dishonesty_count = len(data_list) if isinstance(data_list, list) else 0
return QichachaCompanyInfoVO(
searchKey=record.searchKey,
creditCode=record.creditCode,
companyName=record.companyName,
enterprise=record.enterprise if isinstance(record.enterprise, dict) else None,
dishonesty=dishonesty,
hasDishonesty=has_dishonesty,
dishonestyCount=dishonesty_count,
updatedAt=cls.FormatDatetime(record.updated_at),
)
@classmethod
def FormatDatetime(cls, value: Any) -> str | None:
"""格式化时间。"""
if value is None:
return None
if getattr(value, "tzinfo", None) is None:
value = value.replace(tzinfo=UTC)
return value.isoformat()
@@ -135,6 +135,41 @@ class RbacAdminServiceImpl(IRbacAdminService):
"is_cache": True,
"meta": {"group": "cross-review"},
},
{
"route_path": "/govdoc",
"route_name": "govdoc",
"component": "govdoc",
"route_title": "内部公文处理",
"icon": "ri-file-paper-2-line",
"sort_order": 65,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "govdoc"},
},
{
"route_path": "/govdoc/audits",
"route_name": "govdoc-audits",
"component": "govdoc.audits",
"route_title": "公文列表",
"icon": "ri-file-list-3-line",
"sort_order": 1,
"parent_path": "/govdoc",
"is_hidden": False,
"is_cache": True,
"meta": {"group": "govdoc"},
},
{
"route_path": "/govdoc/upload",
"route_name": "govdoc-upload",
"component": "govdoc.upload",
"route_title": "公文上传",
"icon": "ri-upload-cloud-line",
"sort_order": 2,
"parent_path": "/govdoc",
"is_hidden": False,
"is_cache": True,
"meta": {"group": "govdoc"},
},
{
"route_path": "/contract-template",
"route_name": "contract-template",
@@ -183,9 +218,9 @@ class RbacAdminServiceImpl(IRbacAdminService):
"meta": {"group": "cross-review"},
},
{
"route_path": "/cross-checking/result",
"route_name": "cross-checking-result",
"component": "cross-checking.result",
"route_path": "/cross-checking/list",
"route_name": "cross-checking-list",
"component": "cross-checking.list",
"route_title": "评查任务列表",
"icon": "ri-file-list-3-line",
"sort_order": 2,
@@ -194,6 +229,18 @@ class RbacAdminServiceImpl(IRbacAdminService):
"is_cache": True,
"meta": {"group": "cross-review"},
},
{
"route_path": "/cross-checking/result",
"route_name": "cross-checking-result",
"component": "cross-checking.result",
"route_title": "评查结果详情",
"icon": "ri-file-search-line",
"sort_order": 3,
"parent_path": "/cross-checking",
"is_hidden": True,
"is_cache": True,
"meta": {"group": "cross-review"},
},
{
"route_path": "/rules",
"route_name": "rule-management",
@@ -294,6 +341,16 @@ class RbacAdminServiceImpl(IRbacAdminService):
{"permission_key": "rbac:tenants:create", "display_name": "创建租户", "module": "rbac", "resource": "tenants", "action": "create", "api_method": "POST", "api_path": "/api/v3/tenants", "route_path": "/tenants"},
{"permission_key": "rbac:tenants:update", "display_name": "更新租户", "module": "rbac", "resource": "tenants", "action": "update", "api_method": "PUT", "api_path": "/api/v3/tenants/{tenant_code}", "route_path": "/tenants"},
{"permission_key": "rbac:tenants:status", "display_name": "启停租户", "module": "rbac", "resource": "tenants", "action": "status", "api_method": "PATCH", "api_path": "/api/v3/tenants/{tenant_code}/status", "route_path": "/tenants"},
{"permission_key": "govdoc:module:read", "display_name": "查看内部公文处理模块", "module": "govdoc", "resource": "module", "action": "read", "api_method": "GET", "api_path": "/api/govdoc", "route_path": "/govdoc"},
{"permission_key": "govdoc:document:create", "display_name": "上传公文", "module": "govdoc", "resource": "document", "action": "create", "api_method": "POST", "api_path": "/api/govdoc/documents", "route_path": "/govdoc/upload"},
{"permission_key": "govdoc:document:read", "display_name": "查看公文列表与详情", "module": "govdoc", "resource": "document", "action": "read", "api_method": "GET", "api_path": "/api/govdoc/documents", "route_path": "/govdoc/audits"},
{"permission_key": "govdoc:document:update", "display_name": "编辑公文", "module": "govdoc", "resource": "document", "action": "update", "api_method": "PATCH", "api_path": "/api/govdoc/documents/{document_id}", "route_path": "/govdoc/audits"},
{"permission_key": "govdoc:document:delete", "display_name": "删除公文", "module": "govdoc", "resource": "document", "action": "delete", "api_method": "DELETE", "api_path": "/api/govdoc/documents/{document_id}", "route_path": "/govdoc/audits"},
{"permission_key": "govdoc:run:create", "display_name": "发起公文格式审查", "module": "govdoc", "resource": "run", "action": "create", "api_method": "POST", "api_path": "/api/govdoc/runs", "route_path": "/govdoc/audits"},
{"permission_key": "govdoc:run:read", "display_name": "查看公文审查状态", "module": "govdoc", "resource": "run", "action": "read", "api_method": "GET", "api_path": "/api/govdoc/runs/{run_id}", "route_path": "/govdoc/audits"},
{"permission_key": "govdoc:report:read", "display_name": "下载公文审查报告", "module": "govdoc", "resource": "report", "action": "read", "api_method": "GET", "api_path": "/api/govdoc/runs/{run_id}/report", "route_path": "/govdoc/audits"},
{"permission_key": "govdoc:result:read", "display_name": "查看公文审查结果", "module": "govdoc", "resource": "result", "action": "read", "api_method": "GET", "api_path": "/api/govdoc/runs/{run_id}/result", "route_path": "/govdoc/audits"},
{"permission_key": "govdoc:rule:read", "display_name": "查看公文规则", "module": "govdoc", "resource": "rule", "action": "read", "api_method": "GET", "api_path": "/api/govdoc/rules", "route_path": "/rules"},
{"permission_key": "usage_stats:overview:read", "display_name": "查看统计总览", "module": "usage_stats", "resource": "overview", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/overview", "route_path": "/usage-stats"},
{"permission_key": "usage_stats:trends:read", "display_name": "查看统计趋势", "module": "usage_stats", "resource": "trends", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/trends", "route_path": "/usage-stats"},
{"permission_key": "usage_stats:users:read", "display_name": "查看用户统计", "module": "usage_stats", "resource": "users", "action": "read", "api_method": "GET", "api_path": "/api/v3/usage-stats/by-users", "route_path": "/usage-stats"},
@@ -323,14 +380,9 @@ class RbacAdminServiceImpl(IRbacAdminService):
{"permission_key": "rules:binding_create:write", "display_name": "创建规则绑定", "module": "rules", "resource": "binding_create", "action": "write", "api_method": "POST", "api_path": "/api/rule-sets/{rule_type}/bindings", "route_path": "/rules"},
{"permission_key": "rules:binding_update:write", "display_name": "更新规则绑定", "module": "rules", "resource": "binding_update", "action": "write", "api_method": "PUT", "api_path": "/api/rule-sets/bindings/{binding_id}", "route_path": "/rules"},
{"permission_key": "rules:binding_delete:delete", "display_name": "删除规则绑定", "module": "rules", "resource": "binding_delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/rule-sets/bindings/{binding_id}", "route_path": "/rules"},
{"permission_key": "evaluation_point:list:read", "display_name": "评查点列表", "module": "evaluation_point", "resource": "list", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-points", "route_path": "/rules"},
{"permission_key": "evaluation_point:detail:read", "display_name": "评查点详情", "module": "evaluation_point", "resource": "detail", "action": "read", "api_method": "GET", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"},
{"permission_key": "evaluation_point:create:write", "display_name": "创建评查点", "module": "evaluation_point", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-points", "route_path": "/rules"},
{"permission_key": "evaluation_point:update:write", "display_name": "更新评查点", "module": "evaluation_point", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"},
{"permission_key": "evaluation_point:delete:delete", "display_name": "删除评查点", "module": "evaluation_point", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"},
{"permission_key": "cross_review:task:create", "display_name": "创建交叉评查任务", "module": "cross_review", "resource": "task", "action": "create", "api_method": "POST", "api_path": "/api/v3/cross-review/tasks", "route_path": "/cross-checking/upload"},
{"permission_key": "cross_review:task:read", "display_name": "查看交叉评查任务", "module": "cross_review", "resource": "task", "action": "read", "api_method": "POST", "api_path": "/api/v3/cross-review/tasks/query", "route_path": "/cross-checking"},
{"permission_key": "cross_review:progress:view", "display_name": "查看交叉评查任务进度", "module": "cross_review", "resource": "progress", "action": "view", "api_method": "GET", "api_path": "/api/v3/cross-review/tasks/{task_id}/progress", "route_path": "/cross-checking"},
{"permission_key": "cross_review:task:read", "display_name": "查看交叉评查任务", "module": "cross_review", "resource": "task", "action": "read", "api_method": "POST", "api_path": "/api/v3/cross-review/tasks/query", "route_path": "/cross-checking/list"},
{"permission_key": "cross_review:progress:view", "display_name": "查看交叉评查任务进度", "module": "cross_review", "resource": "progress", "action": "view", "api_method": "GET", "api_path": "/api/v3/cross-review/tasks/{task_id}/progress", "route_path": "/cross-checking/list"},
{"permission_key": "cross_review:document:read", "display_name": "查看交叉评查任务文档", "module": "cross_review", "resource": "document", "action": "read", "api_method": "GET", "api_path": "/api/v3/cross-review/tasks/{task_id}/documents", "route_path": "/cross-checking/result"},
{"permission_key": "cross_review:document:complete", "display_name": "确认交叉评查文档完成", "module": "cross_review", "resource": "document", "action": "complete", "api_method": "GET", "api_path": "/api/v3/cross-review/tasks/{task_id}/can-confirm", "route_path": "/cross-checking/result"},
{"permission_key": "cross_review:proposal:create", "display_name": "创建交叉评查提案", "module": "cross_review", "resource": "proposal", "action": "create", "api_method": "POST", "api_path": "/api/v3/cross-review/proposals", "route_path": "/cross-checking/result"},
@@ -357,12 +409,14 @@ class RbacAdminServiceImpl(IRbacAdminService):
{"permission_key": "rag:dataset:create", "display_name": "创建知识库", "module": "rag", "resource": "dataset", "action": "create", "api_method": "POST", "api_path": "/api/v3/rag/datasets/admin", "route_path": "/chat-with-llm"},
{"permission_key": "rag:dataset:update", "display_name": "更新知识库与文档", "module": "rag", "resource": "dataset", "action": "update", "api_method": "PATCH", "api_path": "/api/v3/rag/datasets/{DatasetId}", "route_path": "/chat-with-llm"},
{"permission_key": "rag:dataset:delete", "display_name": "删除知识库与文档", "module": "rag", "resource": "dataset", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/rag/datasets/admin/{DatasetId}", "route_path": "/chat-with-llm"},
{"permission_key": "qichacha:company:query", "display_name": "查询企业主体信息", "module": "qichacha", "resource": "company", "action": "query", "api_method": "POST", "api_path": "/api/v2/qichacha/company", "route_path": "/documents"},
{"permission_key": "qichacha:status:read", "display_name": "查看企业主体缓存状态", "module": "qichacha", "resource": "status", "action": "read", "api_method": "GET", "api_path": "/api/v2/qichacha/status", "route_path": "/documents"},
]
_CORE_ROLE_AUTO_GRANTS: dict[str, tuple[str, ...]] = {
"super_admin": ("rbac:user_tenant:update", "rbac:tenants:read", "rbac:tenants:create", "rbac:tenants:update", "rbac:tenants:status"),
"provincial_admin": ("rbac:user_tenant:update", "rbac:tenants:read", "rbac:tenants:create", "rbac:tenants:update", "rbac:tenants:status"),
"admin": ("rbac:user_tenant:update", "rbac:tenants:read", "rbac:tenants:create", "rbac:tenants:update", "rbac:tenants:status"),
"super_admin": ("rbac:user_tenant:update", "rbac:tenants:read", "rbac:tenants:create", "rbac:tenants:update", "rbac:tenants:status", "qichacha:company:query", "qichacha:status:read"),
"provincial_admin": ("rbac:user_tenant:update", "rbac:tenants:read", "rbac:tenants:create", "rbac:tenants:update", "rbac:tenants:status", "qichacha:company:query", "qichacha:status:read"),
"admin": ("rbac:user_tenant:update", "rbac:tenants:read", "rbac:tenants:create", "rbac:tenants:update", "rbac:tenants:status", "qichacha:company:query"),
}
async def ListRoles(self, CurrentUserId: int, Page: int, PageSize: int, RoleKey: str | None, RoleName: str | None, IncludeSystem: bool) -> RoleListVO:
@@ -1393,28 +1447,15 @@ class RbacAdminServiceImpl(IRbacAdminService):
if context["is_super_admin"] or not permissionKeys:
return context
async with GetAsyncSession() as Session:
grantedRows = (
await Session.execute(
text(
"""
SELECT DISTINCT p.permission_key
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
JOIN user_role ur ON ur.role_id = rp.role_id
WHERE ur.user_id = :user_id
AND p.permission_key = ANY(:permission_keys)
AND rp.grant_type = 'GRANT'
"""
).bindparams(permission_keys=permissionKeys),
{"user_id": CurrentUserId},
)
).mappings().all()
granted = {str(row["permission_key"] or "") for row in grantedRows}
missing = [key for key in permissionKeys if key not in granted]
if not missing:
return context
deniedKeys = []
permissionService = PermissionServiceImpl()
for permissionKey in permissionKeys:
if not await permissionService.CheckPermission(CurrentUserId, permissionKey):
deniedKeys.append(permissionKey)
if not deniedKeys:
return context
async with GetAsyncSession() as Session:
displayRows = (
await Session.execute(
text(
@@ -1423,11 +1464,11 @@ class RbacAdminServiceImpl(IRbacAdminService):
FROM permissions
WHERE permission_key = ANY(:permission_keys)
"""
).bindparams(permission_keys=missing)
).bindparams(permission_keys=deniedKeys)
)
).mappings().all()
displayByKey = {str(row["permission_key"] or ""): str(row["display_name"] or "") for row in displayRows}
displayName = displayByKey.get(missing[0]) or missing[0]
displayName = displayByKey.get(deniedKeys[0]) or deniedKeys[0]
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, f"缺少「{displayName}」权限")
async def _assertManageAndPermission(self, CurrentUserId: int, PermissionKey: str) -> dict[str, Any]:
@@ -33,6 +33,8 @@ class RbacServiceImpl(IRbacService):
"/document-types",
"/tenants",
"/usage-stats",
"/govdoc",
"/govdoc-audit",
)
_COMPAT_ROUTE_BLUEPRINTS: dict[str, list[dict[str, Any]]] = {
@@ -110,24 +112,67 @@ class RbacServiceImpl(IRbacService):
},
{
"id": 1006,
"route_path": "/govdoc",
"route_name": "govdoc",
"component": "govdoc",
"parent_id": None,
"route_title": "内部公文处理",
"icon": "ri-file-paper-2-line",
"sort_order": 4,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "govdoc"},
"children": [
{
"id": 1023,
"route_path": "/govdoc/audits",
"route_name": "govdoc-audits",
"component": "govdoc.audits",
"parent_id": 1006,
"route_title": "公文列表",
"icon": "ri-file-list-3-line",
"sort_order": 1,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "govdoc"},
"children": None,
},
{
"id": 1024,
"route_path": "/govdoc/upload",
"route_name": "govdoc-upload",
"component": "govdoc.upload",
"parent_id": 1006,
"route_title": "公文上传",
"icon": "ri-upload-cloud-line",
"sort_order": 2,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "govdoc"},
"children": None,
},
],
},
{
"id": 1007,
"route_path": "/rules",
"route_name": "rule-management",
"component": "rules",
"parent_id": None,
"route_title": "规则管理",
"icon": "ri-book-3-line",
"sort_order": 4,
"sort_order": 5,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "rules"},
"children": [
{
"id": 1008,
"route_path": "/rules/list",
"route_path": "/rules",
"route_name": "rules-list",
"component": "rules.list",
"parent_id": 1006,
"route_title": "评查点列表",
"component": "rules",
"parent_id": 1007,
"route_title": "规则配置列表",
"icon": "ri-list-check-3",
"sort_order": 2,
"is_hidden": False,
@@ -140,7 +185,7 @@ class RbacServiceImpl(IRbacService):
"route_path": "/rules-files",
"route_name": "rules-file",
"component": "rules-files",
"parent_id": 1006,
"parent_id": 1007,
"route_title": "评查文件列表",
"icon": "ri-list-check-2",
"sort_order": 3,
@@ -159,7 +204,7 @@ class RbacServiceImpl(IRbacService):
"parent_id": None,
"route_title": "合同管理",
"icon": "ri-file-search-line",
"sort_order": 5,
"sort_order": 6,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "contract"},
@@ -202,7 +247,7 @@ class RbacServiceImpl(IRbacService):
"parent_id": None,
"route_title": "系统设置",
"icon": "ri-settings-4-line",
"sort_order": 6,
"sort_order": 7,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "settings"},
@@ -287,17 +332,17 @@ class RbacServiceImpl(IRbacService):
"parent_id": None,
"route_title": "交叉评查",
"icon": "ri-color-filter-line",
"sort_order": 7,
"sort_order": 8,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "cross-review"},
"children": [
{
"id": 1019,
"id": 1020,
"route_path": "/cross-checking/upload",
"route_name": "cross-checking-upload",
"component": "cross-checking.upload",
"parent_id": 1018,
"parent_id": 1019,
"route_title": "创建任务",
"icon": "ri-upload-cloud-line",
"sort_order": 1,
@@ -307,11 +352,11 @@ class RbacServiceImpl(IRbacService):
"children": None,
},
{
"id": 1020,
"route_path": "/cross-checking/result",
"route_name": "cross-checking-result",
"component": "cross-checking.result",
"parent_id": 1018,
"id": 1021,
"route_path": "/cross-checking/list",
"route_name": "cross-checking-list",
"component": "cross-checking.list",
"parent_id": 1019,
"route_title": "评查任务列表",
"icon": "ri-file-list-3-line",
"sort_order": 2,
@@ -320,6 +365,20 @@ class RbacServiceImpl(IRbacService):
"meta": {"group": "cross-review"},
"children": None,
},
{
"id": 1022,
"route_path": "/cross-checking/result",
"route_name": "cross-checking-result",
"component": "cross-checking.result",
"parent_id": 1019,
"route_title": "评查结果详情",
"icon": "ri-file-search-line",
"sort_order": 3,
"is_hidden": True,
"is_cache": True,
"meta": {"group": "cross-review"},
"children": None,
},
],
},
],
@@ -383,24 +442,67 @@ class RbacServiceImpl(IRbacService):
},
{
"id": 2005,
"route_path": "/govdoc",
"route_name": "govdoc",
"component": "govdoc",
"parent_id": None,
"route_title": "内部公文处理",
"icon": "ri-file-paper-2-line",
"sort_order": 4,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "govdoc"},
"children": [
{
"id": 2019,
"route_path": "/govdoc/audits",
"route_name": "govdoc-audits",
"component": "govdoc.audits",
"parent_id": 2005,
"route_title": "公文列表",
"icon": "ri-file-list-3-line",
"sort_order": 1,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "govdoc"},
"children": None,
},
{
"id": 2020,
"route_path": "/govdoc/upload",
"route_name": "govdoc-upload",
"component": "govdoc.upload",
"parent_id": 2005,
"route_title": "公文上传",
"icon": "ri-upload-cloud-line",
"sort_order": 2,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "govdoc"},
"children": None,
},
],
},
{
"id": 2006,
"route_path": "/rules",
"route_name": "rule-management",
"component": "rules",
"parent_id": None,
"route_title": "规则管理",
"icon": "ri-book-3-line",
"sort_order": 4,
"sort_order": 5,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "rules"},
"children": [
{
"id": 2007,
"route_path": "/rules/list",
"route_path": "/rules",
"route_name": "rules-list",
"component": "rules.list",
"parent_id": 2005,
"route_title": "评查点列表",
"component": "rules",
"parent_id": 2006,
"route_title": "规则配置列表",
"icon": "ri-list-check-3",
"sort_order": 2,
"is_hidden": False,
@@ -413,7 +515,7 @@ class RbacServiceImpl(IRbacService):
"route_path": "/rules-files",
"route_name": "rules-file",
"component": "rules-files",
"parent_id": 2005,
"parent_id": 2006,
"route_title": "评查文件列表",
"icon": "ri-list-check-2",
"sort_order": 3,
@@ -432,7 +534,7 @@ class RbacServiceImpl(IRbacService):
"parent_id": None,
"route_title": "合同管理",
"icon": "ri-file-search-line",
"sort_order": 5,
"sort_order": 6,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "contract"},
@@ -475,7 +577,7 @@ class RbacServiceImpl(IRbacService):
"parent_id": None,
"route_title": "系统设置",
"icon": "ri-settings-4-line",
"sort_order": 6,
"sort_order": 7,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "settings"},
@@ -518,7 +620,7 @@ class RbacServiceImpl(IRbacService):
"parent_id": None,
"route_title": "交叉评查",
"icon": "ri-color-filter-line",
"sort_order": 7,
"sort_order": 8,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "cross-review"},
@@ -528,7 +630,7 @@ class RbacServiceImpl(IRbacService):
"route_path": "/cross-checking/upload",
"route_name": "cross-checking-upload",
"component": "cross-checking.upload",
"parent_id": 2012,
"parent_id": 2015,
"route_title": "创建任务",
"icon": "ri-upload-cloud-line",
"sort_order": 1,
@@ -539,10 +641,10 @@ class RbacServiceImpl(IRbacService):
},
{
"id": 2017,
"route_path": "/cross-checking/result",
"route_name": "cross-checking-result",
"component": "cross-checking.result",
"parent_id": 2012,
"route_path": "/cross-checking/list",
"route_name": "cross-checking-list",
"component": "cross-checking.list",
"parent_id": 2015,
"route_title": "评查任务列表",
"icon": "ri-file-list-3-line",
"sort_order": 2,
@@ -551,6 +653,20 @@ class RbacServiceImpl(IRbacService):
"meta": {"group": "cross-review"},
"children": None,
},
{
"id": 2018,
"route_path": "/cross-checking/result",
"route_name": "cross-checking-result",
"component": "cross-checking.result",
"parent_id": 2015,
"route_title": "评查结果详情",
"icon": "ri-file-search-line",
"sort_order": 3,
"is_hidden": True,
"is_cache": True,
"meta": {"group": "cross-review"},
"children": None,
},
],
},
],
@@ -561,6 +677,9 @@ class RbacServiceImpl(IRbacService):
"/files": ["documents:"],
"/files/upload": ["documents:upload:"],
"/documents": ["documents:"],
"/govdoc": ["govdoc:"],
"/govdoc/audits": ["govdoc:document:read"],
"/govdoc/upload": ["govdoc:document:create"],
"/settings": ["entry_module:", "rbac:", "doc_type:"],
"/entry-modules": ["entry_module:"],
"/role-permissions": ["rbac:"],
@@ -568,8 +687,14 @@ class RbacServiceImpl(IRbacService):
"/tenants": ["rbac:tenants:"],
"/usage-stats": ["usage_stats:"],
"/rules": ["rules:", "evaluation_point:", "evaluation_group:"],
"/rules/list": ["rules:", "evaluation_point:"],
"/rules-files": ["rules:"],
"/cross-checking": ["cross_review:"],
"/cross-checking/list": ["cross_review:task:", "cross_review:progress:"],
"/cross-checking/upload": ["cross_review:task:create"],
"/cross-checking/result": [
"cross_review:document:",
"cross_review:proposal:",
],
}
async def GetCurrentUserRoutes(self, UserId: int) -> RbacUserRoutesVO:
@@ -793,6 +793,7 @@ class RuleServiceImpl(IRuleService):
await self._assert_document_type_access(Session, DocTypeId, current_user)
GroupId = await self._resolve_unique_accessible_child_group_id(Session, DocTypeId, current_user)
if GroupId is not None:
binding_scope = self._build_group_binding_scope_payload(current_user)
ExistingGroupBinding = await Session.execute(
text(
"""
@@ -800,11 +801,16 @@ class RuleServiceImpl(IRuleService):
FROM leaudit_rule_group_bindings
WHERE group_id = :group_id
AND rule_set_id = :rule_set_id
AND COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL') = :tenant_code
AND deleted_at IS NULL
LIMIT 1
"""
),
{"group_id": GroupId, "rule_set_id": RuleSetId},
{
"group_id": GroupId,
"rule_set_id": RuleSetId,
"tenant_code": binding_scope["tenant_code"],
},
)
if ExistingGroupBinding.mappings().first():
raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, "该文档类型对应子组已绑定此规则集")
@@ -815,6 +821,9 @@ class RuleServiceImpl(IRuleService):
INSERT INTO leaudit_rule_group_bindings (
group_id,
rule_set_id,
tenant_code,
scope_type,
tenant_name_snapshot,
priority,
is_active,
note,
@@ -823,6 +832,9 @@ class RuleServiceImpl(IRuleService):
) VALUES (
:group_id,
:rule_set_id,
:tenant_code,
:scope_type,
:tenant_name_snapshot,
:priority,
true,
:note,
@@ -835,6 +847,9 @@ class RuleServiceImpl(IRuleService):
{
"group_id": GroupId,
"rule_set_id": RuleSetId,
"tenant_code": binding_scope["tenant_code"],
"scope_type": binding_scope["scope_type"],
"tenant_name_snapshot": binding_scope["tenant_name_snapshot"],
"priority": Priority,
"note": Note,
},
@@ -1177,6 +1192,23 @@ class RuleServiceImpl(IRuleService):
"note": "由租户规则集派生自动补绑",
}
def _build_group_binding_scope_payload(self, current_user: dict[str, object] | None) -> dict[str, object | None]:
if current_user and not current_user.get("is_global"):
tenant_code = normalize_scoped_tenant_code(str(current_user.get("tenant_code") or ""), default="")
if not tenant_code:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户上下文缺失,不能绑定规则集")
return {
"tenant_code": tenant_code,
"scope_type": "TENANT",
"tenant_name_snapshot": str(current_user.get("tenant_name") or "").strip() or None,
}
return {
"tenant_code": "PROVINCIAL",
"scope_type": "PROVINCIAL",
"tenant_name_snapshot": None,
}
async def _load_source_group_binding_ids(self, Session, source_rule_set_id: int) -> list[int]:
if not await self._column_exists(Session, "leaudit_rule_group_bindings", "tenant_code"):
return []
@@ -893,9 +893,7 @@ class UsageStatsServiceImpl(IUsageStatsService):
}
def _assert_stats_access(self, context: dict[str, Any]) -> None:
if context["is_global"] or context["can_manage"]:
return
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "仅管理员可查看系统使用统计")
return
def _build_user_scope_condition(self, context: dict[str, Any], filters: dict[str, Any], *, user_alias: str) -> tuple[str, dict[str, Any]]:
conditions = ["1 = 1"]
@@ -0,0 +1,40 @@
"""企查查服务接口。"""
from __future__ import annotations
from abc import ABC, abstractmethod
from fastapi_modules.fastapi_leaudit.domian.vo.qichachaVo import (
QichachaBatchQueryVO,
QichachaCompanyQueryVO,
QichachaRecordStatusVO,
)
class IQichachaService(ABC):
"""企查查服务接口。"""
@abstractmethod
async def QueryCompany(self, Keyword: str, ForceRefresh: bool = False) -> QichachaCompanyQueryVO:
"""查询企业完整信息。"""
...
@abstractmethod
async def QueryEnterpriseOnly(self, Keyword: str, ForceRefresh: bool = False) -> QichachaCompanyQueryVO:
"""仅查询企业工商信息。"""
...
@abstractmethod
async def QueryDishonestyOnly(self, Keyword: str, ForceRefresh: bool = False) -> QichachaCompanyQueryVO:
"""仅查询企业失信信息。"""
...
@abstractmethod
async def BatchQuery(self, Keywords: list[str], ForceRefresh: bool = False) -> QichachaBatchQueryVO:
"""批量查询企业信息。"""
...
@abstractmethod
async def GetRecordStatus(self, Keyword: str) -> QichachaRecordStatusVO:
"""查询企业缓存状态。"""
...