feat: 完善模板对比持久化与附件版本处理

This commit is contained in:
wren
2026-05-20 10:55:28 +08:00
parent 7c6f134808
commit a2c2bf1969
14 changed files with 1701 additions and 77 deletions
@@ -10,6 +10,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.crossReviewDto import (
CrossReviewTaskQueryDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import (
CrossReviewTaskDocumentAppendVO,
CrossReviewPendingVotesVO,
CrossReviewPermissionVO,
CrossReviewProposalCancelVO,
@@ -117,3 +118,15 @@ class ICrossReviewService(ABC):
) -> CrossReviewTaskDocumentUploadVO:
"""向交叉评查任务补传文档。"""
...
@abstractmethod
async def AppendTaskDocumentAttachments(
self,
CurrentUserId: int,
TaskId: int,
DocumentId: int,
Files: list[tuple[str, bytes, str | None]],
Remark: str | None = None,
) -> CrossReviewTaskDocumentAppendVO:
"""为交叉评查任务文档追加附件,并生成同版本链新版本。"""
...
@@ -115,6 +115,8 @@ class IDocumentService(ABC):
CurrentUserId: int,
Id: int,
Files: list[tuple[str, bytes, str | None]],
MergeMode: str = "new",
Remark: str | None = None,
) -> DocumentDetailVO:
"""为现有文档追加附件,并执行数据隔离校验。"""
...
@@ -681,18 +681,20 @@ class ContractTemplateServiceImpl(IContractTemplateService):
"area": str(row["area"] or ""),
"is_global": bool(row["is_global"]),
"can_manage": bool(row["can_manage"]),
"is_area_admin": bool(row["can_manage"]) and not bool(row["is_global"]),
}
finally:
if own_session:
await session_cm.__aexit__(None, None, None)
def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str | None) -> str:
_ = requestedRegion
area = str(currentUser["area"] or "").strip()
if currentUser["can_manage"]:
if not area:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前管理员账号未配置地区,无法上传合同模板")
return area
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户没有上传合同模板权限")
if not currentUser.get("is_area_admin"):
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅支持地区管理员上传合同模板")
if not area:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前地区管理员账号未配置所属地区,无法上传合同模板")
return area
async def _ensureContractTemplateSchema(self, session) -> None:
statements = [
@@ -26,6 +26,8 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.crossReviewDto import (
CrossReviewTaskQueryDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import (
CrossReviewTaskDocumentAppendVO,
CrossReviewTaskHistoryVersionVO,
CrossReviewPendingProposalVO,
CrossReviewPendingVotesVO,
CrossReviewPermissionVO,
@@ -463,14 +465,16 @@ class CrossReviewServiceImpl(ICrossReviewService):
"limit": Body.pageSize,
"offset": (Body.page - 1) * Body.pageSize,
}
whereClauses = [
baseWhereClauses = [
"td.task_id = :task_id",
"td.delete_time IS NULL",
"d.deleted_at IS NULL",
]
if Body.keyword:
whereClauses.append("(d.normalized_name ILIKE :keyword OR CAST(d.biz_document_id AS TEXT) ILIKE :keyword)")
baseWhereClauses.append("(d.normalized_name ILIKE :keyword OR CAST(d.biz_document_id AS TEXT) ILIKE :keyword)")
params["keyword"] = f"%{Body.keyword.strip()}%"
whereSql = " AND ".join(whereClauses)
baseWhereSql = " AND ".join(baseWhereClauses)
latestWhereSql = f"{baseWhereSql} AND COALESCE(d.is_latest_version, false) = true"
total = int(
(
@@ -481,7 +485,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
WHERE {whereSql}
WHERE {latestWhereSql}
"""
),
params,
@@ -510,7 +514,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
) AS processing_status,
d.version_no,
d.is_latest_version,
COALESCE(d.version_group_key, '') AS version_group_key,
COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text)) AS version_group_key,
COALESCE(vc.total_versions, 1)::int AS total_versions,
d.created_at,
td.audit_status,
@@ -549,7 +553,9 @@ class CrossReviewServiceImpl(ICrossReviewService):
LIMIT 1
) df ON TRUE
LEFT JOIN (
SELECT d2.version_group_key, COUNT(*) AS total_versions
SELECT
COALESCE(NULLIF(d2.version_group_key, ''), CONCAT('root:', COALESCE(d2.root_version_id, d2.id)::text)) AS version_group_key,
COUNT(*) AS total_versions
FROM leaudit_documents d2
JOIN leaudit_cross_review_task_documents td2
ON td2.document_id = d2.id
@@ -557,7 +563,8 @@ class CrossReviewServiceImpl(ICrossReviewService):
AND td2.task_id = :task_id
WHERE d2.deleted_at IS NULL
GROUP BY d2.version_group_key
) vc ON vc.version_group_key = d.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))
LEFT JOIN LATERAL (
SELECT
COUNT(*)::int AS total_evaluation_points,
@@ -610,7 +617,7 @@ class CrossReviewServiceImpl(ICrossReviewService):
AND p.status = 'approved'
AND p.delete_time IS NULL
) pd ON TRUE
WHERE {whereSql}
WHERE {latestWhereSql}
ORDER BY d.created_at DESC, d.id DESC
LIMIT :limit OFFSET :offset
"""
@@ -619,47 +626,208 @@ class CrossReviewServiceImpl(ICrossReviewService):
)
).mappings().all()
items = [
CrossReviewTaskDocumentVO(
documentId=int(row["document_id"]),
name=str(row["name"] or ""),
documentNumber=row.get("document_number"),
typeId=self._to_int(row.get("type_id")),
typeName=row.get("type_name"),
processingStatus=row.get("processing_status"),
versionNo=int(row.get("version_no") or 1),
isLatestVersion=bool(row.get("is_latest_version")),
versionGroupKey=str(row.get("version_group_key") or ""),
totalVersions=int(row.get("total_versions") or 1),
auditStatus=int(row.get("audit_status") or 0),
createdAt=row.get("created_at"),
fileSize=int(row.get("file_size") or 0),
path=str(row.get("path") or ""),
uploadTime=row.get("upload_time"),
fileExt=str(row.get("file_ext") or "") or None,
totalEvaluationPoints=int(row.get("total_evaluation_points") or 0),
passCount=int(row.get("pass_count") or 0),
warningCount=int(row.get("warning_count") or 0),
errorCount=int(row.get("error_count") or 0),
manualCount=int(row.get("manual_count") or 0),
issueCount=int(row.get("issue_count") or 0),
warningMessages=self._parse_text_array(row.get("warning_messages")),
errorMessages=self._parse_text_array(row.get("error_messages")),
issueMessages=self._parse_text_array(row.get("issue_messages")),
manualMessages=self._parse_text_array(row.get("manual_messages")),
finalScore=float(row.get("final_score") or 0) + float(row.get("approved_delta") or 0),
fullScore=float(row.get("full_score") or 0),
scoreSummary=self._build_score_summary(
float(row.get("final_score") or 0) + float(row.get("approved_delta") or 0),
float(row.get("full_score") or 0),
),
scorePercent=self._build_score_percent(
float(row.get("final_score") or 0) + float(row.get("approved_delta") or 0),
float(row.get("full_score") or 0),
),
latestDocumentIds = [int(row["document_id"]) for row in rows]
historyByGroup: dict[str, list[CrossReviewTaskHistoryVersionVO]] = {}
if latestDocumentIds:
historyRows = (
await session.execute(
text(
"""
WITH target_groups AS (
SELECT DISTINCT COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text)) AS version_group_key
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
WHERE td.task_id = :task_id
AND td.delete_time IS NULL
AND d.id = ANY(:document_ids)
)
SELECT
d.id AS document_id,
COALESCE(d.normalized_name, '') AS name,
CAST(d.biz_document_id AS TEXT) AS document_number,
d.type_id,
COALESCE(
CASE
WHEN LOWER(COALESCE(ar.status, '')) IN ('pending', 'queued', 'running', 'retrying')
THEN ar.status
ELSE d.processing_status
END,
d.processing_status,
'waiting'
) 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,
td.audit_status,
d.created_at,
COALESCE(dt.name, '') AS type_name,
COALESCE(df.file_size, 0) AS file_size,
COALESCE(df.file_path, '') AS path,
df.file_upload_time AS upload_time,
COALESCE(df.file_ext, '') AS file_ext,
COALESCE(es.total_evaluation_points, 0) AS total_evaluation_points,
COALESCE(es.pass_count, 0) AS pass_count,
COALESCE(es.warning_count, 0) AS warning_count,
COALESCE(es.error_count, 0) AS error_count,
COALESCE(es.manual_count, 0) AS manual_count,
COALESCE(es.issue_count, 0) AS issue_count,
COALESCE(es.warning_messages, ARRAY[]::text[]) AS warning_messages,
COALESCE(es.error_messages, ARRAY[]::text[]) AS error_messages,
COALESCE(es.issue_messages, ARRAY[]::text[]) AS issue_messages,
COALESCE(es.manual_messages, ARRAY[]::text[]) AS manual_messages,
COALESCE(es.final_score, 0) AS final_score,
COALESCE(es.full_score, 0) AS full_score,
COALESCE(pd.approved_delta, 0) AS approved_delta
FROM leaudit_cross_review_task_documents td
JOIN leaudit_documents d
ON d.id = td.document_id
JOIN target_groups tg
ON tg.version_group_key = COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text))
LEFT JOIN leaudit_audit_runs ar
ON ar.id = d.current_run_id
LEFT JOIN leaudit_document_types dt
ON dt.id = d.type_id
LEFT JOIN LATERAL (
SELECT file_size, local_path AS file_path, created_at AS file_upload_time,
COALESCE(file_ext, '') AS file_ext
FROM leaudit_document_files
WHERE document_id = d.id
ORDER BY id ASC
LIMIT 1
) df ON TRUE
LEFT JOIN LATERAL (
SELECT
COUNT(*)::int AS total_evaluation_points,
COUNT(*) FILTER (WHERE rr.passed IS TRUE)::int AS pass_count,
COUNT(*) FILTER (WHERE rr.passed IS FALSE AND rr.risk = 'high')::int AS error_count,
COUNT(*) FILTER (WHERE rr.passed IS FALSE AND rr.risk IN ('low', 'medium'))::int AS warning_count,
0::int AS manual_count,
COUNT(*) FILTER (WHERE rr.passed IS FALSE)::int AS issue_count,
ARRAY_AGG(rr.fail_message ORDER BY rr.id) FILTER (
WHERE rr.passed IS FALSE AND rr.risk = 'high' AND rr.fail_message IS NOT NULL AND rr.fail_message != ''
) AS error_messages,
ARRAY_AGG(rr.fail_message ORDER BY rr.id) FILTER (
WHERE rr.passed IS FALSE AND rr.risk IN ('low', 'medium') AND rr.fail_message IS NOT NULL AND rr.fail_message != ''
) AS warning_messages,
ARRAY_AGG(rr.fail_message ORDER BY rr.id) FILTER (
WHERE rr.passed IS FALSE AND rr.fail_message IS NOT NULL AND rr.fail_message != ''
) AS issue_messages,
ARRAY[]::text[] AS manual_messages,
COALESCE(SUM(rr.score) FILTER (WHERE rr.passed IS TRUE), 0) AS final_score,
COALESCE(SUM(rr.score), 0) AS full_score
FROM leaudit_rule_results rr
WHERE rr.document_id = d.id
AND rr.run_id = d.current_run_id
) es ON TRUE
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(proposed_score_delta), 0) AS approved_delta
FROM leaudit_cross_review_proposals p
WHERE p.document_id = d.id
AND p.status = 'approved'
AND p.delete_time IS NULL
) pd ON TRUE
WHERE td.task_id = :task_id
AND td.delete_time IS NULL
AND d.deleted_at IS NULL
ORDER BY COALESCE(NULLIF(d.version_group_key, ''), CONCAT('root:', COALESCE(d.root_version_id, d.id)::text)), d.version_no DESC, d.id DESC
"""
).bindparams(bindparam("document_ids", expanding=False)),
{"task_id": TaskId, "document_ids": latestDocumentIds},
)
).mappings().all()
groupedRows: dict[str, list[dict]] = {}
for row in historyRows:
groupedRows.setdefault(str(row.get("version_group_key") or ""), []).append(dict(row))
for groupKey, groupRows in groupedRows.items():
orderedRows = sorted(
groupRows,
key=lambda item: (int(item.get("version_no") or 1), int(item.get("document_id") or 0)),
)
localVersionMap = {
int(item["document_id"]): index + 1 for index, item in enumerate(orderedRows)
}
historyItems: list[CrossReviewTaskHistoryVersionVO] = []
for item in reversed(orderedRows[:-1]):
finalScore = float(item.get("final_score") or 0) + float(item.get("approved_delta") or 0)
fullScore = float(item.get("full_score") or 0)
historyItems.append(
CrossReviewTaskHistoryVersionVO(
documentId=int(item["document_id"]),
name=str(item.get("name") or ""),
documentNumber=item.get("document_number"),
typeId=self._to_int(item.get("type_id")),
typeName=item.get("type_name"),
processingStatus=item.get("processing_status"),
versionNo=int(localVersionMap.get(int(item["document_id"]), 1)),
auditStatus=int(item.get("audit_status") or 0),
createdAt=item.get("created_at"),
fileSize=int(item.get("file_size") or 0),
path=str(item.get("path") or ""),
uploadTime=item.get("upload_time"),
fileExt=str(item.get("file_ext") or "") or None,
totalEvaluationPoints=int(item.get("total_evaluation_points") or 0),
passCount=int(item.get("pass_count") or 0),
warningCount=int(item.get("warning_count") or 0),
errorCount=int(item.get("error_count") or 0),
manualCount=int(item.get("manual_count") or 0),
issueCount=int(item.get("issue_count") or 0),
warningMessages=self._parse_text_array(item.get("warning_messages")),
errorMessages=self._parse_text_array(item.get("error_messages")),
issueMessages=self._parse_text_array(item.get("issue_messages")),
manualMessages=self._parse_text_array(item.get("manual_messages")),
finalScore=finalScore,
fullScore=fullScore,
scoreSummary=self._build_score_summary(finalScore, fullScore),
scorePercent=self._build_score_percent(finalScore, fullScore),
)
)
historyByGroup[groupKey] = historyItems
items: list[CrossReviewTaskDocumentVO] = []
for row in rows:
groupKey = str(row.get("version_group_key") or "")
historyVersions = historyByGroup.get(groupKey, [])
finalScore = float(row.get("final_score") or 0) + float(row.get("approved_delta") or 0)
fullScore = float(row.get("full_score") or 0)
versionNo = int(row.get("total_versions") or 1)
items.append(
CrossReviewTaskDocumentVO(
documentId=int(row["document_id"]),
name=str(row["name"] or ""),
documentNumber=row.get("document_number"),
typeId=self._to_int(row.get("type_id")),
typeName=row.get("type_name"),
processingStatus=row.get("processing_status"),
versionNo=versionNo,
isLatestVersion=True,
versionGroupKey=groupKey,
totalVersions=int(row.get("total_versions") or 1),
auditStatus=int(row.get("audit_status") or 0),
createdAt=row.get("created_at"),
fileSize=int(row.get("file_size") or 0),
path=str(row.get("path") or ""),
uploadTime=row.get("upload_time"),
fileExt=str(row.get("file_ext") or "") or None,
totalEvaluationPoints=int(row.get("total_evaluation_points") or 0),
passCount=int(row.get("pass_count") or 0),
warningCount=int(row.get("warning_count") or 0),
errorCount=int(row.get("error_count") or 0),
manualCount=int(row.get("manual_count") or 0),
issueCount=int(row.get("issue_count") or 0),
warningMessages=self._parse_text_array(row.get("warning_messages")),
errorMessages=self._parse_text_array(row.get("error_messages")),
issueMessages=self._parse_text_array(row.get("issue_messages")),
manualMessages=self._parse_text_array(row.get("manual_messages")),
finalScore=finalScore,
fullScore=fullScore,
scoreSummary=self._build_score_summary(finalScore, fullScore),
scorePercent=self._build_score_percent(finalScore, fullScore),
historyVersions=historyVersions,
)
)
for row in rows
]
return CrossReviewTaskDocumentPageVO(
taskId=TaskId,
total=total,
@@ -1242,6 +1410,84 @@ class CrossReviewServiceImpl(ICrossReviewService):
processingStatus=uploadResult.processingStatus,
)
async def AppendTaskDocumentAttachments(
self,
CurrentUserId: int,
TaskId: int,
DocumentId: int,
Files: list[tuple[str, bytes, str | None]],
Remark: str | None = None,
) -> CrossReviewTaskDocumentAppendVO:
"""为交叉评查任务文档追加附件,并生成同版本链新版本。"""
async with GetAsyncSession() as session:
await self._ensure_tables_ready(session)
permission = await self.CanConfirmTaskDocument(CurrentUserId, TaskId)
if not permission.canConfirm:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, permission.reason)
await self._ensure_task_document(session, TaskId, DocumentId)
appendResult = await self.DocumentService.AppendAttachments(
CurrentUserId=CurrentUserId,
Id=DocumentId,
Files=Files,
MergeMode="new",
Remark=Remark,
)
async with GetAsyncSession() as session:
await self._ensure_tables_ready(session)
await self._reset_transaction_for_write(session)
async with session.begin():
exists = bool(
await session.scalar(
text(
"""
SELECT 1
FROM leaudit_cross_review_task_documents
WHERE task_id = :task_id
AND document_id = :document_id
AND delete_time IS NULL
LIMIT 1
"""
),
{"task_id": TaskId, "document_id": appendResult.documentId},
)
)
if not exists:
await session.execute(
text(
"""
INSERT INTO leaudit_cross_review_task_documents
(task_id, document_id, audit_status)
VALUES
(:task_id, :document_id, 0)
"""
),
{"task_id": TaskId, "document_id": appendResult.documentId},
)
await session.execute(
text(
"""
UPDATE leaudit_cross_review_tasks
SET status = 'in_progress',
update_time = NOW()
WHERE id = :task_id
AND delete_time IS NULL
"""
),
{"task_id": TaskId},
)
return CrossReviewTaskDocumentAppendVO(
taskId=TaskId,
originalDocumentId=DocumentId,
documentId=appendResult.documentId,
versionNo=appendResult.versionNo,
versionGroupKey=appendResult.versionGroupKey,
auditStatus=0,
processingStatus=appendResult.processingStatus,
)
async def _build_document_proposals_page(
self,
session,
@@ -11,12 +11,17 @@ from datetime import date as date_type, datetime
import hashlib
import mimetypes
import re
import tempfile
import time
import unicodedata
import uuid
from copy import deepcopy
from pathlib import Path
from sqlalchemy import text
import fitz
from leaudit.converters import doc2pdf
from sqlalchemy import bindparam, text
from docx import Document as DocxDocument
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum
@@ -49,6 +54,7 @@ from fastapi_modules.fastapi_leaudit.domian.vo.reviewPointVo import (
ReviewPointsAggregateVO,
)
from fastapi_modules.fastapi_leaudit.models import LeauditDocument, LeauditDocumentFile
from fastapi_modules.fastapi_leaudit.leaudit_bridge.fileSourceResolver import FileSourceResolver
from fastapi_modules.fastapi_leaudit.services import IAuditService, IDocumentService, IOssService
from fastapi_modules.fastapi_leaudit.services.impl.auditServiceImpl import AuditServiceImpl
from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl
@@ -313,7 +319,12 @@ class DocumentServiceImpl(IDocumentService):
documentColumns = await self._loadDocumentColumns(Session)
currentUser = await self._getCurrentUserContext(CurrentUserId)
filters = ["d.is_latest_version = true", "d.deleted_at IS NULL", "f.is_active = true", "f.file_role = 'primary'"]
filters = [
"d.is_latest_version = true",
"d.deleted_at IS NULL",
"f.is_active = true",
"f.file_role = 'primary'",
]
params: dict[str, object] = {"limit": page_size, "offset": offset}
filters.extend(
self._buildDocumentScopeFilters(
@@ -516,6 +527,7 @@ class DocumentServiceImpl(IDocumentService):
f.file_name,
f.file_ext,
f.file_size,
f.oss_url,
ar.status AS run_status,
ar.result_status,
ar.total_score,
@@ -555,6 +567,7 @@ class DocumentServiceImpl(IDocumentService):
fileName=row["file_name"],
fileExt=row["file_ext"],
fileSize=int(row["file_size"]) if row["file_size"] is not None else None,
ossUrl=row["oss_url"],
processingStatus=row["processing_status"],
runStatus=row["run_status"],
resultStatus=row["result_status"],
@@ -1049,11 +1062,687 @@ class DocumentServiceImpl(IDocumentService):
await Session.commit()
async def _load_active_primary_file_row(
self,
Session,
*,
DocumentId: int,
) -> dict[str, Any]:
row = (
await Session.execute(
text(
"""
SELECT
id,
file_name,
file_ext,
mime_type,
file_role,
oss_url,
local_path
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = 'primary'
ORDER BY id DESC
LIMIT 1
"""
),
{"document_id": DocumentId},
)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档缺少主文件")
return dict(row)
def _normalize_document_source_kind(self, file_name: str, file_ext: str | None, mime_type: str | None) -> str:
suffix = (f".{str(file_ext).lstrip('.')}" if file_ext else Path(file_name).suffix).lower()
normalizedMime = (mime_type or "").lower()
if suffix == ".pdf" or normalizedMime == "application/pdf":
return "pdf"
if suffix == ".docx" or normalizedMime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return "docx"
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前仅支持 PDF 或 DOCX 主文档追加附件")
def _normalize_attachment_source_kind(self, file_name: str, file_ext: str | None, mime_type: str | None) -> str:
suffix = (f".{str(file_ext).lstrip('.')}" if file_ext else Path(file_name).suffix).lower()
normalizedMime = (mime_type or "").lower()
if suffix == ".pdf" or normalizedMime == "application/pdf":
return "pdf"
if suffix == ".docx" or normalizedMime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return "docx"
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, f"附件 {file_name} 仅支持 PDF 或 DOCX 格式")
def _validate_attachment_matrix(
self,
*,
MainSourceKind: str,
Files: list[tuple[str, bytes, str | None]],
) -> None:
for fileName, _, contentType in Files:
attachmentKind = self._normalize_attachment_source_kind(fileName, Path(fileName).suffix.lstrip(".").lower() or None, contentType)
if MainSourceKind == "docx" and attachmentKind != "docx":
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
"源文档为 DOCX 时,仅允许追加 DOCX 附件,且合并结果仍为 DOCX",
)
def _merge_docx_paths_for_merge(
self,
*,
DocxPaths: list[str],
TempPaths: list[str],
) -> str:
if not DocxPaths:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "缺少可合并的 DOCX 文件")
mergedTemp = tempfile.NamedTemporaryFile(suffix=".docx", delete=False)
mergedTemp.close()
TempPaths.append(mergedTemp.name)
baseDoc = DocxDocument(DocxPaths[0])
for attachmentPath in DocxPaths[1:]:
attachmentDoc = DocxDocument(attachmentPath)
if baseDoc.paragraphs:
baseDoc.add_page_break()
for element in attachmentDoc.element.body:
baseDoc.element.body.append(deepcopy(element))
baseDoc.save(mergedTemp.name)
return mergedTemp.name
async def _cloneActiveFilesToNewDocument(
self,
Session,
*,
SourceDocumentId: int,
TargetDocumentId: int,
CreatedBy: int | None,
IncludeAttachments: bool = True,
) -> None:
"""复制当前文档的主文件与现有附件到新版本文档。"""
fileRoles = ["primary", "attachment"] if IncludeAttachments else ["primary"]
sourceFiles = (
await Session.execute(
text(
"""
SELECT
id,
file_role,
file_name,
file_ext,
mime_type,
file_size,
sha256,
local_path,
oss_url,
storage_provider
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = ANY(:file_roles)
ORDER BY
CASE WHEN file_role = 'primary' THEN 0 ELSE 1 END,
id ASC
"""
).bindparams(bindparam("file_roles", expanding=False)),
{"document_id": SourceDocumentId, "file_roles": fileRoles},
)
).mappings().all()
if not sourceFiles:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "源文档缺少可复制的有效文件")
for row in sourceFiles:
clonedFile = LeauditDocumentFile(
documentId=TargetDocumentId,
fileRole=str(row["file_role"] or "attachment"),
fileName=str(row["file_name"] or ""),
fileExt=row["file_ext"],
mimeType=row["mime_type"],
fileSize=int(row["file_size"]) if row["file_size"] is not None else None,
sha256=row["sha256"],
localPath=row["local_path"],
ossUrl=row["oss_url"],
storageProvider=row["storage_provider"],
isActive=True,
createdBy=CreatedBy,
)
Session.add(clonedFile)
await Session.flush()
async def _buildMergedPdfBytesForDocument(
self,
Session,
*,
DocumentId: int,
) -> tuple[bytes, str]:
"""读取当前主文件与附件,生成新的主 PDF 内容。"""
primaryFile = (
await Session.execute(
text(
"""
SELECT id
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = 'primary'
ORDER BY id DESC
LIMIT 1
"""
),
{"document_id": DocumentId},
)
).scalar_one_or_none()
if primaryFile is None:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档缺少主文件,无法生成合并文件")
attachmentRows = (
await Session.execute(
text(
"""
SELECT id
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = 'attachment'
ORDER BY id ASC
"""
),
{"document_id": DocumentId},
)
).scalars().all()
if not attachmentRows:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档没有附件,无法生成合并文件")
resolver = FileSourceResolver()
primaryModel = await Session.get(LeauditDocumentFile, int(primaryFile))
if primaryModel is None:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "主文件记录不存在,无法生成合并文件")
primaryPayload = await resolver.ResolvePayload(primaryModel)
attachmentModels: list[LeauditDocumentFile] = []
for attachmentId in attachmentRows:
attachmentModel = await Session.get(LeauditDocumentFile, int(attachmentId))
if attachmentModel is not None:
attachmentModels.append(attachmentModel)
attachmentPayloads = await resolver.ResolvePayloads(attachmentModels)
tempPaths: list[str] = []
try:
mainLocalPath = self._write_temp_file_for_merge(
FileName=primaryPayload.fileName,
Content=primaryPayload.fileContent,
TempPaths=tempPaths,
)
mainPdfPath = self._convert_source_to_pdf(
SourcePath=mainLocalPath,
TempPaths=tempPaths,
)
pdfPaths = [mainPdfPath]
for attachmentPayload in attachmentPayloads:
attachmentLocalPath = self._write_temp_file_for_merge(
FileName=attachmentPayload.fileName,
Content=attachmentPayload.fileContent,
TempPaths=tempPaths,
)
attachmentPdfPath = self._convert_source_to_pdf(
SourcePath=attachmentLocalPath,
TempPaths=tempPaths,
)
pdfPaths.append(attachmentPdfPath)
mergedPdfPath = self._merge_pdf_paths_for_merge(
PdfPaths=pdfPaths,
TempPaths=tempPaths,
)
mergedBytes = Path(mergedPdfPath).read_bytes()
mergedName = f"{Path(primaryPayload.fileName).stem}.pdf"
return mergedBytes, mergedName
except LeauditException:
raise
except Exception as error:
raise LeauditException(
StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR,
f"生成合并PDF失败: {error}",
) from error
finally:
for tempPath in reversed(tempPaths):
try:
pathObj = Path(tempPath)
if pathObj.is_file():
pathObj.unlink(missing_ok=True)
except Exception:
pass
async def _buildMergedDocxBytesForDocument(
self,
Session,
*,
DocumentId: int,
) -> tuple[bytes, str]:
"""读取当前主文件与附件,生成新的主 DOCX 内容。"""
primaryFile = await self._load_active_primary_file_row(Session, DocumentId=DocumentId)
if self._normalize_document_source_kind(
str(primaryFile["file_name"] or ""),
primaryFile.get("file_ext"),
primaryFile.get("mime_type"),
) != "docx":
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "仅 DOCX 主文档支持生成 DOCX 合并结果")
attachmentRows = (
await Session.execute(
text(
"""
SELECT id
FROM leaudit_document_files
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = 'attachment'
ORDER BY id ASC
"""
),
{"document_id": DocumentId},
)
).scalars().all()
if not attachmentRows:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档没有附件,无法生成合并文件")
resolver = FileSourceResolver()
primaryModel = await Session.get(LeauditDocumentFile, int(primaryFile["id"]))
if primaryModel is None:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "主文件记录不存在,无法生成合并文件")
primaryPayload = await resolver.ResolvePayload(primaryModel)
attachmentModels: list[LeauditDocumentFile] = []
for attachmentId in attachmentRows:
attachmentModel = await Session.get(LeauditDocumentFile, int(attachmentId))
if attachmentModel is not None:
attachmentModels.append(attachmentModel)
attachmentPayloads = await resolver.ResolvePayloads(attachmentModels)
tempPaths: list[str] = []
try:
docxPaths = [
self._write_temp_file_for_merge(
FileName=primaryPayload.fileName,
Content=primaryPayload.fileContent,
TempPaths=tempPaths,
)
]
for attachmentPayload in attachmentPayloads:
attachmentKind = self._normalize_attachment_source_kind(
attachmentPayload.fileName,
Path(attachmentPayload.fileName).suffix.lstrip(".").lower() or None,
None,
)
if attachmentKind != "docx":
raise LeauditException(
StatusCodeEnum.HTTP_400_BAD_REQUEST,
f"DOCX 主文档仅允许追加 DOCX 附件,当前附件 {attachmentPayload.fileName} 不符合要求",
)
docxPaths.append(
self._write_temp_file_for_merge(
FileName=attachmentPayload.fileName,
Content=attachmentPayload.fileContent,
TempPaths=tempPaths,
)
)
mergedDocxPath = self._merge_docx_paths_for_merge(
DocxPaths=docxPaths,
TempPaths=tempPaths,
)
mergedBytes = Path(mergedDocxPath).read_bytes()
mergedName = f"{Path(primaryPayload.fileName).stem}.docx"
return mergedBytes, mergedName
finally:
for tempPath in reversed(tempPaths):
try:
pathObj = Path(tempPath)
if pathObj.is_file():
pathObj.unlink(missing_ok=True)
except Exception:
pass
def _write_temp_file_for_merge(
self,
*,
FileName: str,
Content: bytes,
TempPaths: list[str],
) -> str:
"""为持久化合并流程写入临时文件。"""
suffix = Path(FileName).suffix or ".bin"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tempFile:
tempFile.write(Content)
tempPath = tempFile.name
TempPaths.append(tempPath)
return tempPath
def _convert_source_to_pdf(
self,
*,
SourcePath: str,
TempPaths: list[str],
) -> str:
"""将源文件转换为 PDF。"""
source = Path(SourcePath)
if source.suffix.lower() == ".pdf":
return str(source)
pdfTemp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
pdfTemp.close()
TempPaths.append(pdfTemp.name)
doc2pdf.convert(source, pdfTemp.name, soffice="auto", pdfa=False, force=True, verify=False)
return pdfTemp.name
def _merge_pdf_paths_for_merge(
self,
*,
PdfPaths: list[str],
TempPaths: list[str],
) -> str:
"""合并多个 PDF 为一个临时 PDF。"""
mergedTemp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
mergedTemp.close()
TempPaths.append(mergedTemp.name)
output = fitz.open()
try:
for pdfPath in PdfPaths:
source = fitz.open(pdfPath)
try:
output.insert_pdf(source)
finally:
source.close()
output.save(mergedTemp.name)
finally:
output.close()
return mergedTemp.name
async def _persistMergedPdfForDocument(
self,
Session,
*,
DocumentId: int,
TypeCode: str,
Region: str,
VersionNo: int,
CreatedBy: int | None,
FileRole: str = "primary",
) -> None:
"""为当前文档生成并替换主 PDF 文件。"""
mergedBytes, mergedName = await self._buildMergedPdfBytesForDocument(
Session,
DocumentId=DocumentId,
)
mergedSha256 = hashlib.sha256(mergedBytes).hexdigest()
mergedSize = len(mergedBytes)
uploadedAt = datetime.now()
versionLabel = f"v{VersionNo}"
objectKey = OssPathUtils.BuildBusinessDocKey(
Region=Region,
TypeCode=TypeCode,
DocumentId=DocumentId,
Version=versionLabel,
FileRole=FileRole,
FileName=mergedName,
Year=uploadedAt.year,
Month=uploadedAt.month,
)
ossUrl = await self.OssService.UploadBytes(
ObjectKey=objectKey,
Content=mergedBytes,
ContentType="application/pdf",
)
if FileRole == "primary":
await Session.execute(
text(
"""
UPDATE leaudit_document_files
SET is_active = false
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role IN ('primary', 'merged_pdf', 'merged_docx')
"""
),
{"document_id": DocumentId},
)
else:
await Session.execute(
text(
"""
UPDATE leaudit_document_files
SET is_active = false
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role = :file_role
"""
),
{"document_id": DocumentId, "file_role": FileRole},
)
Session.add(
LeauditDocumentFile(
documentId=DocumentId,
fileRole=FileRole,
fileName=mergedName,
fileExt="pdf",
mimeType="application/pdf",
fileSize=mergedSize,
sha256=mergedSha256,
localPath=None,
ossUrl=ossUrl,
storageProvider="minio",
isActive=True,
createdBy=CreatedBy,
)
)
await Session.flush()
async def _persistMergedDocxForDocument(
self,
Session,
*,
DocumentId: int,
TypeCode: str,
Region: str,
VersionNo: int,
CreatedBy: int | None,
) -> None:
"""为当前文档生成并替换主 DOCX 文件。"""
mergedBytes, mergedName = await self._buildMergedDocxBytesForDocument(
Session,
DocumentId=DocumentId,
)
mergedSha256 = hashlib.sha256(mergedBytes).hexdigest()
mergedSize = len(mergedBytes)
uploadedAt = datetime.now()
versionLabel = f"v{VersionNo}"
objectKey = OssPathUtils.BuildBusinessDocKey(
Region=Region,
TypeCode=TypeCode,
DocumentId=DocumentId,
Version=versionLabel,
FileRole="primary",
FileName=mergedName,
Year=uploadedAt.year,
Month=uploadedAt.month,
)
ossUrl = await self.OssService.UploadBytes(
ObjectKey=objectKey,
Content=mergedBytes,
ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
await Session.execute(
text(
"""
UPDATE leaudit_document_files
SET is_active = false
WHERE document_id = :document_id
AND deleted_at IS NULL
AND is_active = true
AND file_role IN ('primary', 'merged_pdf', 'merged_docx')
"""
),
{"document_id": DocumentId},
)
Session.add(
LeauditDocumentFile(
documentId=DocumentId,
fileRole="primary",
fileName=mergedName,
fileExt="docx",
mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
fileSize=mergedSize,
sha256=mergedSha256,
localPath=None,
ossUrl=ossUrl,
storageProvider="minio",
isActive=True,
createdBy=CreatedBy,
)
)
await Session.flush()
async def _createNextVersionFromExistingDocument(
self,
Session,
*,
SourceDocumentId: int,
CreatedBy: int,
Remark: str | None = None,
) -> tuple[LeauditDocument, str, str]:
"""基于现有文档创建一个新版本,并复制当前主文件/附件。"""
documentColumns = await self._loadDocumentColumns(Session)
optionalSelects = [
"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",
"d.audit_status AS audit_status" if "audit_status" in documentColumns else "NULL::integer AS audit_status",
]
sourceRow = (
await Session.execute(
text(
"""
SELECT
d.id,
d.biz_document_id,
d.type_id,
d.group_id,
d.region,
d.processing_status,
d.version_group_key,
d.version_no,
d.previous_version_id,
d.root_version_id,
d.is_latest_version,
d.normalized_name,
d.review_scope,
"""
+ ",\n ".join(optionalSelects)
+ """
,
dt.code AS type_code
FROM leaudit_documents d
LEFT JOIN leaudit_document_types dt
ON dt.id = d.type_id
WHERE d.id = :document_id
AND d.deleted_at IS NULL
LIMIT 1
"""
),
{"document_id": SourceDocumentId},
)
).mappings().first()
if not sourceRow:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
resolvedTypeCode = str(sourceRow["type_code"] or "").strip()
if not resolvedTypeCode:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档缺少文档类型编码,无法创建新版本")
previousDocument = await Session.get(LeauditDocument, SourceDocumentId)
if previousDocument is None:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
previousDocument.isLatestVersion = False
documentFields: dict[str, object] = {
"bizDocumentId": time.time_ns(),
"typeId": int(sourceRow["type_id"]) if sourceRow["type_id"] is not None else None,
"groupId": int(sourceRow["group_id"]) if sourceRow["group_id"] is not None else None,
"region": str(sourceRow["region"] or "default").strip() or "default",
"processingStatus": "waiting",
"versionGroupKey": str(sourceRow["version_group_key"] or uuid.uuid4().hex),
"versionNo": int(sourceRow["version_no"] or 1) + 1,
"previousVersionId": SourceDocumentId,
"rootVersionId": int(sourceRow["root_version_id"] or SourceDocumentId),
"isLatestVersion": True,
"normalizedName": sourceRow["normalized_name"],
"reviewScope": str(sourceRow["review_scope"] or "standard"),
}
newDocument = await LeauditDocument.create_new(Session, **documentFields)
if newDocument.rootVersionId is None:
newDocument.rootVersionId = newDocument.Id
assignments: list[str] = []
params: dict[str, object] = {"id": int(newDocument.Id)}
if "document_number" in documentColumns:
assignments.append("document_number = :document_number")
params["document_number"] = sourceRow["document_number"]
if "remark" in documentColumns:
assignments.append("remark = :remark")
params["remark"] = Remark.strip() if Remark and Remark.strip() else sourceRow["remark"]
if "is_test_document" in documentColumns:
assignments.append("is_test_document = :is_test_document")
params["is_test_document"] = bool(sourceRow["is_test_document"])
if "audit_status" in documentColumns:
assignments.append("audit_status = :audit_status")
params["audit_status"] = int(sourceRow["audit_status"]) if sourceRow["audit_status"] is not None else None
if assignments:
assignments.append("updated_at = NOW()")
await Session.execute(
text(f"UPDATE leaudit_documents SET {', '.join(assignments)} WHERE id = :id"),
params,
)
await self._cloneActiveFilesToNewDocument(
Session,
SourceDocumentId=SourceDocumentId,
TargetDocumentId=newDocument.Id,
CreatedBy=CreatedBy,
IncludeAttachments=False,
)
await Session.flush()
return newDocument, resolvedTypeCode, str(sourceRow["region"] or "default").strip() or "default"
def _normalizeAttachmentMergeMode(self, MergeMode: str | None) -> str:
"""标准化附件合并模式。"""
normalizedMode = (MergeMode or "new").strip().lower()
if normalizedMode not in {"overwrite", "new"}:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "mergeMode 仅支持 overwrite 或 new")
return normalizedMode
async def DeleteDocument(self, CurrentUserId: int, Id: int) -> None:
"""软删除文档,并执行数据隔离校验。"""
async with GetAsyncSession() as Session:
currentUser = await self._getCurrentUserContext(CurrentUserId)
filters = ["d.id = :id", "d.deleted_at IS NULL", "f.is_active = true", "f.file_role = 'primary'"]
filters = [
"d.id = :id",
"d.deleted_at IS NULL",
"f.is_active = true",
"f.file_role = 'primary'",
]
params: dict[str, object] = {"id": Id}
filters.extend(
self._buildDocumentScopeFilters(
@@ -1141,10 +1830,13 @@ class DocumentServiceImpl(IDocumentService):
CurrentUserId: int,
Id: int,
Files: list[tuple[str, bytes, str | None]],
MergeMode: str = "overwrite",
Remark: str | None = None,
) -> DocumentDetailVO:
"""为现有文档追加附件,并执行数据隔离校验。"""
if not Files:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "至少上传一个附件文件")
normalizedMergeMode = self._normalizeAttachmentMergeMode(MergeMode)
async with GetAsyncSession() as Session:
await self._ensureDocumentGroupColumn(Session)
@@ -1182,18 +1874,72 @@ class DocumentServiceImpl(IDocumentService):
if not resolvedTypeCode:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前文档缺少文档类型编码,无法追加附件")
primaryFileRow = await self._load_active_primary_file_row(Session, DocumentId=Id)
mainSourceKind = self._normalize_document_source_kind(
str(primaryFileRow["file_name"] or ""),
primaryFileRow.get("file_ext"),
primaryFileRow.get("mime_type"),
)
self._validate_attachment_matrix(
MainSourceKind=mainSourceKind,
Files=Files,
)
targetDocumentId = int(documentMeta["document_id"])
targetVersionNo = int(documentMeta["version_no"] or 1)
normalizedRegion = str(documentMeta["region"] or "default").strip() or "default"
if normalizedMergeMode == "new":
newDocument, resolvedTypeCode, normalizedRegion = await self._createNextVersionFromExistingDocument(
Session,
SourceDocumentId=Id,
CreatedBy=CurrentUserId,
Remark=Remark,
)
targetDocumentId = int(newDocument.Id)
targetVersionNo = int(newDocument.versionNo or (int(documentMeta["version_no"] or 1) + 1))
await self._appendAttachmentFiles(
Session=Session,
DocumentId=int(documentMeta["document_id"]),
DocumentId=targetDocumentId,
TypeCode=resolvedTypeCode,
Region=normalizedRegion,
VersionNo=int(documentMeta["version_no"] or 1),
VersionNo=targetVersionNo,
Files=Files,
CreatedBy=CurrentUserId,
)
refreshed = await self._getDocumentDetail(Session, Id, CurrentUserId, currentUser, documentColumns)
if mainSourceKind == "docx":
await self._persistMergedDocxForDocument(
Session,
DocumentId=targetDocumentId,
TypeCode=resolvedTypeCode,
Region=normalizedRegion,
VersionNo=targetVersionNo,
CreatedBy=CurrentUserId,
)
await self._persistMergedPdfForDocument(
Session,
DocumentId=targetDocumentId,
TypeCode=resolvedTypeCode,
Region=normalizedRegion,
VersionNo=targetVersionNo,
CreatedBy=CurrentUserId,
FileRole="merged_pdf",
)
else:
await self._persistMergedPdfForDocument(
Session,
DocumentId=targetDocumentId,
TypeCode=resolvedTypeCode,
Region=normalizedRegion,
VersionNo=targetVersionNo,
CreatedBy=CurrentUserId,
)
await Session.commit()
refreshed = await self._getDocumentDetail(Session, targetDocumentId, CurrentUserId, currentUser, documentColumns)
if not refreshed:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档不存在或无权访问")
return refreshed
@@ -1949,7 +2695,12 @@ class DocumentServiceImpl(IDocumentService):
) -> DocumentDetailVO | None:
"""查询单文档详情,并附带历史版本。"""
params: dict[str, object] = {"id": DocumentId}
filters = ["d.id = :id", "d.deleted_at IS NULL", "f.is_active = true", "f.file_role = 'primary'"]
filters = [
"d.id = :id",
"d.deleted_at IS NULL",
"f.is_active = true",
"f.file_role = 'primary'",
]
if not BypassScopeCheck:
filters.extend(
self._buildDocumentScopeFilters(
@@ -2065,6 +2816,7 @@ class DocumentServiceImpl(IDocumentService):
f.id AS file_id,
f.file_name,
f.file_ext,
f.oss_url,
ar.status AS run_status,
ar.result_status
FROM leaudit_documents d
@@ -2090,6 +2842,7 @@ class DocumentServiceImpl(IDocumentService):
versionNo=int(row["version_no"]),
fileName=row["file_name"],
fileExt=row["file_ext"],
ossUrl=row["oss_url"],
processingStatus=row["processing_status"],
runStatus=row["run_status"],
resultStatus=row["result_status"],
@@ -2913,8 +3666,9 @@ class DocumentServiceImpl(IDocumentService):
ORDER BY
CASE file_role
WHEN 'converted_pdf' THEN 0
WHEN 'merged_pdf' THEN 1
WHEN 'primary' THEN 2
WHEN 'primary' THEN 1
WHEN 'merged_docx' THEN 2
WHEN 'merged_pdf' THEN 3
ELSE 9
END,
id DESC
@@ -119,6 +119,41 @@ class RbacAdminServiceImpl(IRbacAdminService):
"is_cache": True,
"meta": {"group": "cross-review"},
},
{
"route_path": "/contract-template",
"route_name": "contract-template",
"component": "contract-template",
"route_title": "合同管理",
"icon": "ri-file-search-line",
"sort_order": 50,
"is_hidden": False,
"is_cache": True,
"meta": {"group": "contract"},
},
{
"route_path": "/contract-template/search",
"route_name": "contract-template-search",
"component": "contract-template.search",
"route_title": "模板搜索",
"icon": "ri-search-line",
"sort_order": 1,
"parent_path": "/contract-template",
"is_hidden": False,
"is_cache": True,
"meta": {"group": "contract"},
},
{
"route_path": "/contract-template/list",
"route_name": "contract-template-list",
"component": "contract-template.list",
"route_title": "模板列表",
"icon": "ri-folder-line",
"sort_order": 2,
"parent_path": "/contract-template",
"is_hidden": False,
"is_cache": True,
"meta": {"group": "contract"},
},
{
"route_path": "/cross-checking/upload",
"route_name": "cross-checking-upload",