feat: update audit platform workspace
This commit is contained in:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user