diff --git a/fastapi_modules/fastapi_leaudit/controllers/documentController.py b/fastapi_modules/fastapi_leaudit/controllers/documentController.py index e9c6e4b..b3cb029 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/documentController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/documentController.py @@ -1,5 +1,6 @@ """文档控制器。""" +import json from typing import Any from fastapi import Depends, File, Form, Query, UploadFile @@ -8,7 +9,8 @@ from sqlalchemy import text from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_web.controller import BaseController -from fastapi_common.fastapi_common_web.domain.responses import Result +from fastapi_common.fastapi_common_web.domain.responses import Result, StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException from fastapi_common.fastapi_common_security.security import verify_access_token from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( @@ -21,6 +23,7 @@ from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( DocumentTypeRootCreateDTO, DocumentTypeRootItemVO, DocumentTypeRootUpdateDTO, + ContractTemplateUploadVO, DocumentTypeUpdateDTO, DocumentUploadVO, ) @@ -94,6 +97,33 @@ class DocumentController(BaseController): ) return Result.success(data=Data) + @self.router.post("/upload/upload_contract_template", response_model=Result[ContractTemplateUploadVO]) + async def UploadContractTemplate( + file: UploadFile = File(..., description="合同模板文件"), + upload_info: str = Form(..., description="模板上传信息 JSON,包含 document_id/comparison_id"), + payload: dict[str, Any] = Depends(verify_access_token), + ): + """兼容旧前端的合同模板上传接口。""" + try: + uploadInfo = json.loads(upload_info or "{}") + except json.JSONDecodeError as error: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "upload_info 不是合法 JSON") from error + + documentId = int(uploadInfo.get("document_id") or 0) + comparisonIdRaw = uploadInfo.get("comparison_id") + comparisonId = int(comparisonIdRaw) if comparisonIdRaw not in (None, "") else None + content = await file.read() + + data = await self.DocumentService.UploadContractTemplate( + CurrentUserId=int(payload["user_id"]), + DocumentId=documentId, + FileName=file.filename or "template.bin", + FileContent=content, + ContentType=file.content_type, + ComparisonId=comparisonId, + ) + return Result.success(data=data, message="合同模板上传成功") + @self.router.get("/documents/list", response_model=Result[DocumentListPageVO]) async def ListDocuments( page: int = 1, diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py index 19dbcc5..eeead21 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py @@ -28,6 +28,17 @@ class DocumentUploadVO(BaseModel): run: AuditRunVO | None = Field(None, description="自动触发后的运行信息") +class ContractTemplateUploadVO(BaseModel): + """合同模板上传响应。""" + + documentId: int = Field(..., description="目标文档ID") + comparisonId: int = Field(..., description="合同结构对比记录ID") + templateName: str = Field(..., description="模板文件名") + templateContractPath: str = Field(..., description="模板文件 OSS 路径") + fileSize: int = Field(..., description="模板文件大小") + status: str = Field("uploaded", description="上传状态") + + class DocumentStatusItemVO(BaseModel): """文档状态项。""" diff --git a/fastapi_modules/fastapi_leaudit/services/documentService.py b/fastapi_modules/fastapi_leaudit/services/documentService.py index c01b9c1..3e9362c 100644 --- a/fastapi_modules/fastapi_leaudit/services/documentService.py +++ b/fastapi_modules/fastapi_leaudit/services/documentService.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( + ContractTemplateUploadVO, DocumentDetailVO, DocumentListPageVO, DocumentStatusItemVO, @@ -118,6 +119,19 @@ class IDocumentService(ABC): """为现有文档追加附件,并执行数据隔离校验。""" ... + @abstractmethod + async def UploadContractTemplate( + self, + CurrentUserId: int, + DocumentId: int, + FileName: str, + FileContent: bytes, + ContentType: str | None, + ComparisonId: int | None = None, + ) -> ContractTemplateUploadVO: + """为现有合同文档上传结构对比模板,并持久化记录。""" + ... + @abstractmethod async def ListDocumentTypes(self, Ids: list[int] | None = None, EntryModuleId: int | None = None) -> list[DocumentTypeItemVO]: """获取文档类型列表。""" diff --git a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py index 9be7f12..bfe2447 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py @@ -24,6 +24,7 @@ from fastapi_common.fastapi_common_web.exception.LeauditException import Leaudit from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( + ContractTemplateUploadVO, DocumentAttachmentVO, DocumentDetailVO, DocumentHistoryVersionVO, @@ -632,6 +633,7 @@ class DocumentServiceImpl(IDocumentService): async with GetAsyncSession() as Session: await self._ensureDocumentGroupColumn(Session) await self._ensureReviewPointAuditTable(Session) + await self._ensureCrossReviewProposalSchema(Session) currentUser = await self._getCurrentUserContext(CurrentUserId) documentColumns = await self._loadDocumentColumns(Session) detail = await self._getDocumentDetail(Session, DocumentId, CurrentUserId, currentUser, documentColumns) @@ -655,6 +657,10 @@ class DocumentServiceImpl(IDocumentService): reviewPoints = await self._loadReviewPointResults(Session, detail, int(runRow["id"])) stats = self._buildReviewPointStats(reviewPoints) + approvedSupplementDelta = await self._loadApprovedSupplementScoreDelta(Session, detail.documentId) + if approvedSupplementDelta: + stats.score += approvedSupplementDelta + documentPayload = await self._buildReviewDocumentPayload(Session, detail, runRow) reviewInfo = self._buildReviewInfo(runRow, reviewPoints, stats) comparisonDocument = await self._loadComparisonDocument(Session, detail.documentId) @@ -866,6 +872,85 @@ class DocumentServiceImpl(IDocumentService): for row in rows ] + async def UploadContractTemplate( + self, + CurrentUserId: int, + DocumentId: int, + FileName: str, + FileContent: bytes, + ContentType: str | None, + ComparisonId: int | None = None, + ) -> ContractTemplateUploadVO: + """为现有合同文档上传结构对比模板,并写入 contract_structure_comparison。""" + if DocumentId <= 0: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "文档ID不能为空") + if not FileName: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板文件名不能为空") + if not FileContent: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板文件内容不能为空") + + fileExt = Path(FileName).suffix.lstrip(".").lower() + if fileExt not in {"pdf", "doc", "docx"}: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板文件仅支持 pdf/doc/docx") + + mimeType = ContentType or mimetypes.guess_type(FileName)[0] or "application/octet-stream" + fileSize = len(FileContent) + uploadedAt = datetime.now() + + async with GetAsyncSession() as Session: + await self._ensureDocumentGroupColumn(Session) + await self._ensureContractStructureComparisonTable(Session) + currentUser = await self._getCurrentUserContext(CurrentUserId) + documentColumns = await self._loadDocumentColumns(Session) + detail = await self._getDocumentDetail(Session, DocumentId, CurrentUserId, currentUser, documentColumns) + if not detail and await self._hasCrossReviewDocumentAccess(Session, DocumentId, CurrentUserId): + detail = await self._getDocumentDetail( + Session, + DocumentId, + CurrentUserId, + currentUser, + documentColumns, + BypassScopeCheck=True, + ) + if not detail: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "目标文档不存在或无权限访问") + + versionLabel = f"v{int(detail.versionNo or 1)}" + objectKey = OssPathUtils.BuildBusinessDocKey( + Region=detail.region or "default", + TypeCode=detail.typeCode or "contract", + DocumentId=DocumentId, + Version=versionLabel, + FileRole="template", + FileName=FileName, + Year=uploadedAt.year, + Month=uploadedAt.month, + ) + ossUrl = await self.OssService.UploadBytes( + ObjectKey=objectKey, + Content=FileContent, + ContentType=mimeType, + ) + + comparisonId = await self._upsertContractStructureComparison( + Session=Session, + DocumentId=DocumentId, + ComparisonId=ComparisonId, + TemplateName=FileName, + TemplatePath=ossUrl, + FileSize=fileSize, + ) + await Session.commit() + + return ContractTemplateUploadVO( + documentId=DocumentId, + comparisonId=comparisonId, + templateName=FileName, + templateContractPath=ossUrl, + fileSize=fileSize, + status="uploaded", + ) + async def UpdateDocument(self, CurrentUserId: int, Id: int, Body: DocumentUpdateDTO) -> DocumentDetailVO: """更新文档元数据,并执行数据隔离校验。""" async with GetAsyncSession() as Session: @@ -1521,6 +1606,214 @@ class DocumentServiceImpl(IDocumentService): ) ) + async def _ensureCrossReviewProposalSchema(self, Session) -> None: + """补齐交叉评查提案表的补充意见字段,兼容旧环境。""" + if not await self._tableExists(Session, "leaudit_cross_review_proposals"): + return + + proposalColumns = await self._loadTableColumns(Session, "leaudit_cross_review_proposals") + touched = False + + if "proposal_type" not in proposalColumns: + await Session.execute( + text( + "ALTER TABLE leaudit_cross_review_proposals ADD COLUMN proposal_type VARCHAR(32) NOT NULL DEFAULT 'review_point'" + ) + ) + touched = True + if "evaluation_point_name" not in proposalColumns: + await Session.execute( + text( + "ALTER TABLE leaudit_cross_review_proposals ADD COLUMN evaluation_point_name VARCHAR(255)" + ) + ) + touched = True + if "extraction_result_text" not in proposalColumns: + await Session.execute( + text( + "ALTER TABLE leaudit_cross_review_proposals ADD COLUMN extraction_result_text TEXT" + ) + ) + touched = True + + isRuleResultNotNull = bool( + await Session.scalar( + text( + """ + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'leaudit_cross_review_proposals' + AND column_name = 'rule_result_id' + AND is_nullable = 'NO' + LIMIT 1 + """ + ) + ) + ) + if isRuleResultNotNull: + await Session.execute( + text( + "ALTER TABLE leaudit_cross_review_proposals ALTER COLUMN rule_result_id DROP NOT NULL" + ) + ) + touched = True + + if touched: + await Session.commit() + + async def _ensureContractStructureComparisonTable(self, Session) -> None: + """补齐合同结构比对表,兼容旧前端模板上传与详情页读取。""" + await Session.execute( + text( + """ + CREATE TABLE IF NOT EXISTS contract_structure_comparison ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL, + comparison_id BIGINT NULL, + template_contract_name VARCHAR(512) NULL, + template_contract_path TEXT NOT NULL DEFAULT '', + file_size BIGINT NOT NULL DEFAULT 0, + comparison_results JSONB NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + ) + await Session.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_contract_structure_comparison_document_id ON contract_structure_comparison(document_id)" + ) + ) + + existingColumns = await self._loadTableColumns(Session, "contract_structure_comparison") + if "comparison_id" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN comparison_id BIGINT NULL")) + if "template_contract_name" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN template_contract_name VARCHAR(512) NULL")) + if "template_contract_path" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN template_contract_path TEXT NOT NULL DEFAULT ''")) + if "file_size" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN file_size BIGINT NOT NULL DEFAULT 0")) + if "comparison_results" not in existingColumns: + await Session.execute(text("ALTER TABLE contract_structure_comparison ADD COLUMN comparison_results JSONB NULL")) + + async def _upsertContractStructureComparison( + self, + Session, + DocumentId: int, + ComparisonId: int | None, + TemplateName: str, + TemplatePath: str, + FileSize: int, + ) -> int: + """新增或更新合同结构比对模板记录。""" + targetRow = None + if ComparisonId is not None: + targetRow = ( + await Session.execute( + text( + """ + SELECT id + FROM contract_structure_comparison + WHERE id = :comparison_id + AND document_id = :document_id + LIMIT 1 + """ + ), + {"comparison_id": ComparisonId, "document_id": DocumentId}, + ) + ).mappings().first() + + if targetRow is None: + targetRow = ( + await Session.execute( + text( + """ + SELECT id + FROM contract_structure_comparison + WHERE document_id = :document_id + ORDER BY id DESC + LIMIT 1 + """ + ), + {"document_id": DocumentId}, + ) + ).mappings().first() + + if targetRow: + comparisonId = int(targetRow["id"]) + await Session.execute( + text( + """ + UPDATE contract_structure_comparison + SET comparison_id = COALESCE(comparison_id, :comparison_id), + template_contract_name = :template_name, + template_contract_path = :template_path, + file_size = :file_size, + updated_at = NOW() + WHERE id = :comparison_id + """ + ), + { + "comparison_id": comparisonId, + "template_name": TemplateName, + "template_path": TemplatePath, + "file_size": FileSize, + }, + ) + return comparisonId + + inserted = ( + await Session.execute( + text( + """ + INSERT INTO contract_structure_comparison ( + document_id, + comparison_id, + template_contract_name, + template_contract_path, + file_size, + created_at, + updated_at + ) VALUES ( + :document_id, + NULL, + :template_name, + :template_path, + :file_size, + NOW(), + NOW() + ) + RETURNING id + """ + ), + { + "document_id": DocumentId, + "template_name": TemplateName, + "template_path": TemplatePath, + "file_size": FileSize, + }, + ) + ).mappings().first() + if not inserted: + raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板记录写入失败") + + comparisonId = int(inserted["id"]) + await Session.execute( + text( + """ + UPDATE contract_structure_comparison + SET comparison_id = COALESCE(comparison_id, :comparison_id), + updated_at = NOW() + WHERE id = :comparison_id + """ + ), + {"comparison_id": comparisonId}, + ) + return comparisonId + async def _tableExists(self, Session, TableName: str) -> bool: """检查表是否存在。""" row = ( @@ -2268,20 +2561,33 @@ class DocumentServiceImpl(IDocumentService): async def _loadScoringProposals(self, Session, DocumentId: int) -> list[dict[str, Any]]: """读取交叉评分提案;缺表时降级为空。""" if await self._tableExists(Session, "leaudit_cross_review_proposals"): + columns = await self._loadTableColumns(Session, "leaudit_cross_review_proposals") + selectColumns = [ + "id", + "'review_point' AS proposal_type", + "rule_result_id AS evaluation_result_id", + "proposer_id", + "NULL::varchar AS evaluation_point_name", + "NULL::text AS extraction_result_text", + "proposed_score_delta AS proposed_score", + "reason", + "status", + "create_time AS created_at", + "update_time AS updated_at", + "document_id", + ] + if "proposal_type" in columns: + selectColumns[1] = "proposal_type" + if "evaluation_point_name" in columns: + selectColumns[4] = "evaluation_point_name" + if "extraction_result_text" in columns: + selectColumns[5] = "extraction_result_text" rows = ( await Session.execute( text( - """ + f""" SELECT - id, - rule_result_id AS evaluation_result_id, - proposer_id, - proposed_score_delta AS proposed_score, - reason, - status, - create_time AS created_at, - update_time AS updated_at, - document_id + {', '.join(selectColumns)} FROM leaudit_cross_review_proposals WHERE document_id = :document_id AND delete_time IS NULL @@ -2336,6 +2642,29 @@ class DocumentServiceImpl(IDocumentService): ).mappings().all() return [dict(row) for row in rows] + async def _loadApprovedSupplementScoreDelta(self, Session, DocumentId: int) -> float: + """读取已通过的补充意见分值调整,计入详情页文档总分。""" + if not await self._tableExists(Session, "leaudit_cross_review_proposals"): + return 0.0 + columns = await self._loadTableColumns(Session, "leaudit_cross_review_proposals") + if "proposal_type" not in columns: + return 0.0 + + delta = await Session.scalar( + text( + """ + SELECT COALESCE(SUM(proposed_score_delta), 0) + FROM leaudit_cross_review_proposals + WHERE document_id = :document_id + AND COALESCE(proposal_type, 'review_point') = 'supplement' + AND status = 'approved' + AND delete_time IS NULL + """ + ), + {"document_id": DocumentId}, + ) + return float(delta or 0.0) + async def _loadReviewPointResults( self, Session, @@ -2377,6 +2706,7 @@ class DocumentServiceImpl(IDocumentService): FROM leaudit_cross_review_proposals WHERE rule_result_id = rr.id AND document_id = :document_id + AND COALESCE(proposal_type, 'review_point') = 'review_point' AND status = 'approved' AND delete_time IS NULL ) ad ON TRUE @@ -2652,7 +2982,7 @@ class DocumentServiceImpl(IDocumentService): stats.warning += 1 elif item.status == "error": stats.error += 1 - stats.score += float(item.score or 0) + stats.score += float(item.currentScore if item.currentScore is not None else (item.score or 0)) return stats def _buildReviewInfo( diff --git a/legal-platform-frontend b/legal-platform-frontend index c41ddc8..dc81598 160000 --- a/legal-platform-frontend +++ b/legal-platform-frontend @@ -1 +1 @@ -Subproject commit c41ddc844ce04ad2ae5ee2679cc2155cc25d44bf +Subproject commit dc8159837b1911c29f8a94b5dc5861862c7ca174