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
@@ -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:
"""查询企业缓存状态。"""
...