merge: 合并模板对比持久化与附件版本处理

This commit is contained in:
wren
2026-05-20 10:56:06 +08:00
14 changed files with 1701 additions and 77 deletions
@@ -0,0 +1,470 @@
# 合同模板上传与地区隔离改造方案
## 1. 背景
当前合同模板模块已具备以下只读能力:
- `/api/v3/contract-templates/categories`
- `/api/v3/contract-templates`
- `/api/v3/contract-templates/search`
- `/api/v3/contract-templates/{id}`
当前实现可以支撑模板分类、列表、搜索、详情展示,但还不具备正式的模板管理上传能力。现阶段新增需求包括:
-`/contract-template/list` 页面支持上传合同模板
- 新模板必须存入 `leaudit_platform` 主库,不再依赖旧项目库
- 模板数据需要支持地区区分
- 模板数据需要支持完整审计字段
- 模板数据需要支持软删除
- 前端与后端需统一按当前 LeAudit/FastAPI 风格实现
本方案仅覆盖“合同模板管理与上传”,不扩展“合同起草”独立业务模块。
## 2. 现状问题
### 2.1 数据表不足
当前 `contract_templates` 仅包含:
- `template_code`
- `title`
- `category_id`
- `description`
- `file_path`
- `file_format`
- `pdf_file_path`
- `is_featured`
- `created_at`
- `updated_at`
存在以下问题:
- 没有 `region`,无法做地区隔离
- 没有 `created_by / updated_by`,无法追踪操作者
- 没有 `deleted_at`,无法软删除
- 没有 `original_file_name / mime_type / file_size`,无法完整表达上传文件元数据
- 当前唯一约束仅为 `template_code` 全局唯一,不适合多地区模板管理
### 2.2 读接口缺少统一数据范围控制
当前合同模板读接口没有套用平台现有“按用户地区可见”的规则,无法满足:
- 省级管理员查看全部或指定地区
- 地市管理员仅查看本地区及公共模板
- 非管理用户限制可见范围
### 2.3 OSS 路径未带地区
当前 `BuildContractTemplateKey()` 只按:
- 分类
- 模板编码
- 文件角色
生成路径,未带 `region`,多地区下会出现路径命名冲突与后期归档困难。
### 2.4 权限不足
当前仅具备读权限:
- `contract_template:list:read`
- `contract_template:search:read`
- `contract_template:detail:read`
尚未具备:
- 上传创建权限
- 编辑权限
- 删除权限
## 3. 设计目标
本次改造目标如下:
1. 为合同模板模块补齐上传能力
2. 按地区隔离模板数据
3. 支持审计字段与软删除
4. 保持与现有 `govdoc` / `document` 模块相同的权限和地区控制风格
5. 新上传模板统一走新 OSS 路径规范
6. 不影响现有搜索、列表、详情页面的继续使用
## 4. 数据模型设计
### 4.1 `contract_categories`
分类暂不做地区化,保留为全局字典表,但补齐审计与软删除字段。
建议字段:
- `id BIGSERIAL/SERIAL PRIMARY KEY`
- `name VARCHAR(100) NOT NULL`
- `icon VARCHAR(100) NULL`
- `description TEXT NULL`
- `sort_order INTEGER NOT NULL DEFAULT 0`
- `created_by BIGINT NULL`
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
- `updated_by BIGINT NULL`
- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
- `deleted_at TIMESTAMPTZ NULL`
索引建议:
- `UNIQUE INDEX uq_contract_categories_name_active ON contract_categories(name) WHERE deleted_at IS NULL`
- `INDEX idx_contract_categories_sort_active ON contract_categories(sort_order) WHERE deleted_at IS NULL`
### 4.2 `contract_templates`
模板表补齐地区、上传元数据、审计字段、软删除字段。
建议字段:
- `id BIGSERIAL PRIMARY KEY`
- `template_code VARCHAR(50) NOT NULL`
- `title VARCHAR(200) NOT NULL`
- `category_id INTEGER NOT NULL REFERENCES contract_categories(id)`
- `region VARCHAR(50) NOT NULL DEFAULT '省级'`
- `description TEXT NULL`
- `file_path VARCHAR(500) NULL`
- `pdf_file_path VARCHAR(500) NULL`
- `file_format VARCHAR(10) NOT NULL`
- `original_file_name VARCHAR(500) NOT NULL DEFAULT ''`
- `mime_type VARCHAR(200) NULL`
- `file_size BIGINT NOT NULL DEFAULT 0`
- `pdf_file_size BIGINT NULL`
- `is_featured BOOLEAN NOT NULL DEFAULT FALSE`
- `created_by BIGINT NULL`
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
- `updated_by BIGINT NULL`
- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
- `deleted_at TIMESTAMPTZ NULL`
索引建议:
- `UNIQUE INDEX uq_contract_templates_region_code_active ON contract_templates(region, template_code) WHERE deleted_at IS NULL`
- `INDEX idx_contract_templates_region_active ON contract_templates(region) WHERE deleted_at IS NULL`
- `INDEX idx_contract_templates_category_active ON contract_templates(category_id) WHERE deleted_at IS NULL`
- `INDEX idx_contract_templates_updated_active ON contract_templates(updated_at DESC) WHERE deleted_at IS NULL`
### 4.3 软删除策略
所有列表、搜索、详情查询统一增加:
- `deleted_at IS NULL`
删除接口只做:
- `deleted_at = NOW()`
- `updated_at = NOW()`
- `updated_by = 当前用户`
本期不执行物理删除 OSS 对象,避免误删无法恢复。
## 5. 地区隔离策略
### 5.1 用户上下文来源
复用现有用户上下文判断逻辑,基于:
- `sso_users.area`
- 用户角色 `super_admin / provincial_admin / admin / 普通用户`
参考现有实现:
- `documentServiceImpl._getCurrentUserContext`
- `documentServiceImpl._buildDocumentScopeFilters`
- `govdocServiceImpl._resolve_upload_region`
### 5.2 模板可见性规则
采用“省级公共模板 + 地区私有模板”的一层可见性模型:
- `region = '省级'`:全局公共模板
- `region = 用户地区`:当前地区私有模板
读取规则:
- `super_admin / provincial_admin`
- 默认可查看全部地区
- 支持显式传 `region` 做筛选
- `admin`
- 默认可查看 `('省级', 自己地区)` 模板
- 若传入其他地区参数,返回空或直接拒绝
- 普通用户
- 默认可查看 `('省级', 自己地区)` 模板
- 不允许跨地区筛选
### 5.3 上传地区规则
上传接口中,`region` 使用以下规则解析:
- `super_admin / provincial_admin`
- 可选择上传到任意地区,含 `省级`
- `admin`
- 只能上传到自己地区
- 普通用户
- 默认不开放上传权限
### 5.4 为什么本期不做“地区模板覆盖省级模板”
本期不做同 `template_code` 的覆盖优先级逻辑,原因:
- 列表/搜索会出现同编码多条模板
- 详情页需要增加“实际命中版本”优先级
- 后续还会影响起草、下载、预览引用
本期先做“地区隔离 + 独立模板记录”,后续若业务明确需要,再做二期“本地覆盖省级模板”。
## 6. OSS 存储设计
### 6.1 新路径规范
建议合同模板 OSS key 改为:
`contract-templates/{region}/{category}/{template_code}/{role}__{filename}`
例如:
- `contract-templates/省级/房屋租赁/HT-RL-001/source__房屋租赁合同.docx`
- `contract-templates/梅州/房屋租赁/HT-RL-001/preview__房屋租赁合同.pdf`
### 6.2 文件角色
- 主模板文件:`source`
- 预览 PDF 文件:`preview`
### 6.3 老数据策略
老数据短期不强制迁移到新地区路径:
- 已有历史数据继续可读
- 新上传统一走新路径
后续若需要统一整洁,再单独执行历史迁移脚本,将存量模板迁到 `contract-templates/省级/...`
## 7. 接口设计
### 7.1 新增接口
#### 7.1.1 上传模板
- `POST /api/v3/contract-templates`
- 权限:`contract_template:create`
- Content-Type`multipart/form-data`
表单字段:
- `title: str`
- `template_code: str`
- `category_id: int`
- `region: str`
- `description: str | None`
- `is_featured: bool`
- `file: UploadFile`
- `pdf_file: UploadFile | None`
返回:
- 模板基础信息
- 文件路径
- 地区信息
- 审计时间
#### 7.1.2 更新模板
- `PUT /api/v3/contract-templates/{id}`
- 权限:`contract_template:update`
本期先可只支持元数据更新,文件替换可选一起补。
#### 7.1.3 删除模板
- `DELETE /api/v3/contract-templates/{id}`
- 权限:`contract_template:delete`
行为:
- 软删除,不物理删 OSS
### 7.2 既有接口增强
#### 7.2.1 分类接口
- 过滤 `deleted_at IS NULL`
- 返回 `template_count` 时只统计当前用户可见地区且未删除模板
#### 7.2.2 列表接口
新增可选参数:
- `region`
逻辑:
- 仅返回当前用户可见模板
- 默认按 `updated_at DESC`
#### 7.2.3 搜索接口
新增可选参数:
- `region`
逻辑:
- 仅搜索当前用户可见模板
#### 7.2.4 详情接口
逻辑:
- 校验模板存在
- 校验模板未删除
- 校验当前用户对模板所在地区可见
## 8. 权限设计
新增权限:
- `contract_template:create`
- `contract_template:update`
- `contract_template:delete`
保留权限:
- `contract_template:list:read`
- `contract_template:search:read`
- `contract_template:detail:read`
角色建议:
- `super_admin`
- 全部读写删
- `provincial_admin`
- 全部读写删
- `admin`
-
- 上传
- 编辑本地区模板
- 删除本地区模板
- 普通用户
- 仅按需开放读权限
## 9. 前端设计
### 9.1 列表页入口
`/contract-template/list` 页面右上角增加:
- “上传模板”按钮
仅有 `contract_template:create` 权限时展示。
### 9.2 上传弹窗字段
- 模板标题
- 模板编码
- 模板分类
- 所属地区
- 模板简介
- 是否推荐
- 模板主文件
- 预览 PDF 文件(可选)
### 9.3 前端交互规则
- `admin` 用户地区默认锁定为自己地区
- `provincial_admin` 可选择地区
- 上传成功后:
- 提示成功
- 关闭弹窗
- 刷新当前列表
- 上传失败时:
- 表单级错误走 toast
- 403 需给出明确无权限提示
### 9.4 API 封装
`lib/api/contract-template/index.ts` 增加:
- `createContractTemplate`
- `updateContractTemplate`
- `deleteContractTemplate`
上传使用 `FormData` 直接请求后端,不走多余代理语义。
## 10. 数据迁移方案
### 10.1 老表扩展
通过增量 SQL
- 新增缺失列
- 回填默认值
- 修正索引
- 增加 `updated_at` 触发器
### 10.2 老数据回填
建议回填:
- `region = '省级'`
- `original_file_name = ''` 或按旧路径推导
- `file_size = 0`
- `deleted_at = NULL`
### 10.3 回滚策略
若上传接口上线后发现问题:
- 可先关闭前端上传入口
- 已有新字段与索引保持兼容,不影响只读能力
## 11. 开发步骤
### 阶段 1:基础设施
1. 扩展合同模板 SQL
2. 扩展 RBAC seed
3. 更新 OSS 路径工具
### 阶段 2:后端接口
1. 增加 DTO/VO
2. 扩展 Service 接口
3. 实现上传、更新、删除
4. 为列表、搜索、详情补地区过滤
### 阶段 3:前端页面
1. 扩展 API 客户端
2. 在列表页增加上传按钮和弹窗
3. 接地区选择与权限控制
### 阶段 4:验证
1. 省级管理员上传省级模板
2. 地市管理员上传本地区模板
3. 非本地区参数校验
4. 403 提示
5. 列表、搜索、详情联调
## 12. 本期明确不做
- 合同起草模块重构
- 模板占位符自动解析
- docx 自动转 pdf
- 地区模板覆盖省级模板优先级
- 模板版本管理
- OSS 历史模板统一搬迁
## 13. 推荐本期交付范围
建议本期落地以下完整闭环:
1. 合同模板表完成地区化、审计化、软删除改造
2. 合同模板后端新增上传接口
3. 合同模板列表页新增上传弹窗
4. 合同模板读接口支持地区可见性控制
5. 权限种子补齐
这套范围足以让合同模板模块从“只读展示”升级为“可管理上传的地区化模板库”。
@@ -79,7 +79,7 @@ class ContractTemplateController(BaseController):
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:create:write"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有上传合同模板权限", "data": None})
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前仅允许地区管理员上传合同模板", "data": None})
body = ContractTemplateCreateDTO(
title=title,
template_code=template_code,
@@ -17,6 +17,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.crossReviewDto import (
CrossReviewTaskQueryDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import (
CrossReviewTaskDocumentAppendVO,
CrossReviewPendingVotesVO,
CrossReviewPermissionVO,
CrossReviewProposalCancelVO,
@@ -158,6 +159,30 @@ class CrossReviewController(BaseController):
)
return Result.success(data=Data, message="交叉评查任务文档上传成功")
@self.router.post("/tasks/{TaskId}/documents/{DocumentId}/attachments", response_model=Result[CrossReviewTaskDocumentAppendVO])
async def AppendTaskDocumentAttachments(
TaskId: int,
DocumentId: int,
files: list[UploadFile] = File(..., description="附件文件列表"),
remark: str | None = Form(None, description="本次追加附件备注"),
payload: dict[str, Any] = Depends(verify_access_token),
):
"""为交叉评查任务文档追加附件,并生成同版本链新版本。"""
if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["document_complete"]]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有交叉评查任务追加附件权限", "data": None})
filePayloads: list[tuple[str, bytes, str | None]] = []
for file in files:
content = await file.read()
filePayloads.append((file.filename or "attachment.bin", content, file.content_type))
Data = await self.CrossReviewService.AppendTaskDocumentAttachments(
CurrentUserId=int(payload["user_id"]),
TaskId=TaskId,
DocumentId=DocumentId,
Files=filePayloads,
Remark=remark,
)
return Result.success(data=Data, message="附件追加成功")
@self.router.post("/proposals", response_model=Result[CrossReviewProposalCreateVO])
async def CreateProposal(
Body: CrossReviewProposalCreateDTO,
@@ -221,6 +221,8 @@ class DocumentController(BaseController):
async def AppendAttachments(
DocumentId: int,
files: list[UploadFile] = File(..., description="附件文件列表"),
mergeMode: str = Form("new", description="附件合并模式:overwrite/new"),
remark: str | None = Form(None, description="本次追加附件备注"),
payload: dict[str, Any] = Depends(verify_access_token),
):
"""为现有文档追加附件(带数据隔离校验)。"""
@@ -232,6 +234,8 @@ class DocumentController(BaseController):
CurrentUserId=int(payload["user_id"]),
Id=DocumentId,
Files=filePayloads,
MergeMode=mergeMode,
Remark=remark,
)
return Result.success(data=Data, message="附件上传成功")
@@ -74,6 +74,39 @@ class CrossReviewTaskDocumentVO(BaseModel):
fullScore: float = Field(0, description="满分")
scoreSummary: str = Field("", description="得分摘要")
scorePercent: float = Field(0, description="得分百分比")
historyVersions: list["CrossReviewTaskHistoryVersionVO"] = Field(default_factory=list, description="历史版本列表")
class CrossReviewTaskHistoryVersionVO(BaseModel):
"""任务文档历史版本项。"""
documentId: int = Field(..., description="文档ID")
name: str = Field("", description="文档名称")
documentNumber: str | None = Field(None, description="文号")
typeId: int | None = Field(None, description="文档类型ID")
typeName: str | None = Field(None, description="文档类型名称")
processingStatus: str | None = Field(None, description="处理状态")
versionNo: int = Field(1, description="版本号")
auditStatus: int = Field(0, description="任务内完成状态")
createdAt: datetime | None = Field(None, description="创建时间")
fileSize: int = Field(0, description="文件大小(字节)")
path: str | None = Field(None, description="文件存储路径")
uploadTime: datetime | None = Field(None, description="上传时间")
fileExt: str | None = Field(None, description="文件扩展名")
totalEvaluationPoints: int = Field(0, description="总评查点数")
passCount: int = Field(0, description="通过数")
warningCount: int = Field(0, description="警告数")
errorCount: int = Field(0, description="错误数")
manualCount: int = Field(0, description="人工审核数")
issueCount: int = Field(0, description="问题总数")
warningMessages: list[str] = Field(default_factory=list, description="警告消息")
errorMessages: list[str] = Field(default_factory=list, description="错误消息")
issueMessages: list[str] = Field(default_factory=list, description="问题消息")
manualMessages: list[str] = Field(default_factory=list, description="人工审核消息")
finalScore: float = Field(0, description="最终得分")
fullScore: float = Field(0, description="满分")
scoreSummary: str = Field("", description="得分摘要")
scorePercent: float = Field(0, description="得分百分比")
class CrossReviewTaskDocumentPageVO(BaseModel):
@@ -194,3 +227,15 @@ class CrossReviewTaskDocumentUploadVO(BaseModel):
documentId: int = Field(..., description="文档ID")
auditStatus: int = Field(0, description="任务内评查状态")
processingStatus: str | None = Field(None, description="文档处理状态")
class CrossReviewTaskDocumentAppendVO(BaseModel):
"""交叉评查任务文档追加附件结果。"""
taskId: int = Field(..., description="任务ID")
originalDocumentId: int = Field(..., description="原文档ID")
documentId: int = Field(..., description="新版本文档ID")
versionNo: int = Field(..., description="新版本号")
versionGroupKey: str = Field("", description="版本组Key")
auditStatus: int = Field(0, description="任务内评查状态")
processingStatus: str | None = Field(None, description="文档处理状态")
@@ -59,6 +59,7 @@ class DocumentHistoryVersionVO(BaseModel):
fileName: str | None = Field(None, description="文件名")
fileExt: str | None = Field(None, description="文件扩展名")
fileSize: int | None = Field(None, description="文件大小")
ossUrl: str | None = Field(None, description="OSS 路径")
processingStatus: str | None = Field(None, description="处理状态")
runStatus: str | None = Field(None, description="最新运行状态")
resultStatus: str | None = Field(None, description="最新结果状态")
@@ -532,19 +532,41 @@ async def _load_run_context(run_id: int) -> dict[str, Any]:
if not document_file:
raise ValueError(f"未找到 document_file_id={run.documentFileId} 对应的文件记录")
resolver = FileSourceResolver()
payload = await resolver.ResolvePayload(document_file)
attachmentResult = await session.execute(
mergedPdfResult = await session.execute(
select(LeauditDocumentFile)
.where(
LeauditDocumentFile.documentId == document.Id,
LeauditDocumentFile.isActive.is_(True),
LeauditDocumentFile.fileRole == "attachment",
LeauditDocumentFile.fileRole == "merged_pdf",
)
.order_by(LeauditDocumentFile.Id.asc())
.order_by(LeauditDocumentFile.Id.desc())
.limit(1)
)
attachmentFiles = list(attachmentResult.scalars().all())
attachmentPayloads = await resolver.ResolvePayloads(attachmentFiles) if attachmentFiles else []
mergedPdfFile = mergedPdfResult.scalar_one_or_none()
effective_document_file = mergedPdfFile or document_file
resolver = FileSourceResolver()
payload = await resolver.ResolvePayload(effective_document_file)
attachmentFiles: list[LeauditDocumentFile] = []
attachmentPayloads = []
if mergedPdfFile is not None:
attachmentFiles = []
elif str(getattr(document_file, "fileRole", "") or "").lower() != "primary":
attachmentFiles = []
elif str(getattr(document_file, "fileExt", "") or "").lower() not in {"pdf", "docx"}:
attachmentFiles = []
else:
attachmentResult = await session.execute(
select(LeauditDocumentFile)
.where(
LeauditDocumentFile.documentId == document.Id,
LeauditDocumentFile.isActive.is_(True),
LeauditDocumentFile.fileRole == "attachment",
)
.order_by(LeauditDocumentFile.Id.asc())
)
attachmentFiles = list(attachmentResult.scalars().all())
attachmentPayloads = await resolver.ResolvePayloads(attachmentFiles) if attachmentFiles else []
return {
"document_id": document.Id,
@@ -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",
@@ -4,7 +4,7 @@ BEGIN;
-- LeAudit Platform Contract Template RBAC Seed
-- 目标:
-- 1. 补齐合同模板读写删权限
-- 2. 给 super_admin / provincial_admin / admin 分配模板权限
-- 2. 给角色分配模板权限,其中上传/更新/删除仅开放给地区管理员 admin
-- 说明:
-- - 依赖 user_rbac_schema_patch.sql
-- - 依赖合同模板前端路由已存在于 sys_routes
@@ -108,16 +108,10 @@ seed(role_key, permission_key, grant_type, data_scope) AS (
('super_admin', 'contract_template:list:read', 'GRANT', 'ALL'),
('super_admin', 'contract_template:search:read', 'GRANT', 'ALL'),
('super_admin', 'contract_template:detail:read', 'GRANT', 'ALL'),
('super_admin', 'contract_template:create:write', 'GRANT', 'ALL'),
('super_admin', 'contract_template:update:write', 'GRANT', 'ALL'),
('super_admin', 'contract_template:delete:delete', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:list:read', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:search:read', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:detail:read', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:create:write', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:update:write', 'GRANT', 'ALL'),
('provincial_admin', 'contract_template:delete:delete', 'GRANT', 'ALL'),
('admin', 'contract_template:list:read', 'GRANT', 'DEPT'),
('admin', 'contract_template:search:read', 'GRANT', 'DEPT'),
@@ -149,4 +143,15 @@ ON CONFLICT (role_id, permission_id) DO UPDATE SET
data_scope = EXCLUDED.data_scope,
updated_at = NOW();
DELETE FROM role_permissions rp
USING roles r, permissions p
WHERE rp.role_id = r.id
AND rp.permission_id = p.id
AND r.role_key IN ('super_admin', 'provincial_admin')
AND p.permission_key IN (
'contract_template:create:write',
'contract_template:update:write',
'contract_template:delete:delete'
);
COMMIT;