diff --git a/docs/交叉评查/新项目交叉评查实现方案设计稿.md b/docs/交叉评查/新项目交叉评查实现方案设计稿.md new file mode 100644 index 0000000..1dd38a9 --- /dev/null +++ b/docs/交叉评查/新项目交叉评查实现方案设计稿.md @@ -0,0 +1,562 @@ +# 新项目交叉评查实现方案设计稿 + +> 目标:基于 `leaudit-platform` 当前代码结构,给出交叉评查在新项目中的推荐实现方案,并明确数据模型、接口设计、目录落点、迁移顺序与实施边界。 + +## 1. 背景判断 + +结合当前仓库代码可以确认: + +- 前端交叉评查页面已经存在于 `new_doc_review` +- RBAC 路由中已经存在 `/cross-checking` +- 新平台文档与评查底座已经切到 `leaudit_*` +- 但交叉评查协作层仍未完整迁入 `fastapi_modules/fastapi_leaudit` + +当前现状更接近: + +- 页面壳子是新的 +- 文档与评查引擎底座是新的 +- 交叉评查业务逻辑仍然大量沿用老系统思维和老接口口径 + +因此,交叉评查在新项目中的核心任务,不是“新做一个页面”,而是把“协作复核层”从老系统迁到新平台。 + +## 2. 总体设计原则 + +推荐遵循以下原则: + +### 2.1 交叉评查是协作层,不是评查引擎层 + +交叉评查不负责: + +- OCR +- 文本抽取 +- 规则执行 +- 原始机器评查结果生成 + +交叉评查只负责: + +- 任务 +- 参与人 +- 文档挂载 +- 提案 +- 投票 +- 完成确认 +- 结果展示叠加 + +### 2.2 原始评查结果尽量不可变 + +不建议像老系统那样把通过提案直接回写到旧 `evaluation_results.final_score`。 + +新平台里更合理的方式是: + +- `leaudit_rule_results` 保留机器评查真相 +- `leaudit_review_point_audits` 保留人工审核覆盖 +- `leaudit_cross_review_proposals` 保留交叉评查加减分提案 +- 页面展示时动态聚合出最终展示分数 + +### 2.3 交叉评查单独建模,不污染 LeAudit 核心表 + +推荐新增专属表: + +- `leaudit_cross_review_tasks` +- `leaudit_cross_review_task_members` +- `leaudit_cross_review_task_documents` +- `leaudit_cross_review_proposals` +- `leaudit_cross_review_votes` + +### 2.4 版本归纳使用新平台版本能力 + +不要继续使用老系统“同名 + 同类型推断版本”的方案作为主路径。 + +新平台已有: + +- `leaudit_documents.versionGroupKey` +- `leaudit_documents.versionNo` +- `leaudit_documents.previousVersionId` +- `leaudit_documents.rootVersionId` +- `leaudit_documents.isLatestVersion` + +交叉评查列表应优先基于这些字段实现版本归纳。 + +### 2.5 权限必须后端收口 + +前端可以决定是否显示按钮,但最终权限校验必须在后端 service 层完成。 + +不能继续依赖前端自己拼 PostgREST 查询判断权限。 + +## 3. 新项目里可直接复用的基础能力 + +## 3.1 文档主链路 + +现有能力: + +- 上传文档:`fastapi_modules/fastapi_leaudit/controllers/documentController.py` +- 文档服务:`fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py` +- 文档主表:`fastapi_modules/fastapi_leaudit/models/leauditDocument.py` +- 文档文件表:`fastapi_modules/fastapi_leaudit/models/leauditDocumentFile.py` + +可复用点: + +- 文档上传 +- OSS 存储 +- 文档版本管理 +- 附件追加 +- 文档详情读取 + +## 3.2 评查运行与结果主链路 + +现有能力: + +- 评查运行主表:`fastapi_modules/fastapi_leaudit/models/leauditAuditRun.py` +- 评查运行控制器:`fastapi_modules/fastapi_leaudit/controllers/auditController.py` +- 评查详情聚合:`DocumentServiceImpl.GetReviewPoints()` + +可复用点: + +- 评查运行追踪 +- 详情页规则结果聚合 +- 人工审核结果叠加 + +## 3.3 详情页结果聚合 + +现有 VO: + +- `fastapi_modules/fastapi_leaudit/domian/vo/reviewPointVo.py` + +现有能力: + +- `ReviewPointsAggregateVO` +- `scoring_proposals` +- `ReviewPointResultVO.finalScore` +- `ReviewPointResultVO.machineScore` + +这意味着: + +- 详情页主结构已经可承载交叉评查提案数据 +- 只需把“提案来源”和“最终分数计算方式”替换为新模型 + +## 4. 目标架构 + +推荐架构分成两层: + +### 4.1 LeAudit 评查结果层 + +职责: + +- 文档上传 +- 规则执行 +- 机器评查结果生成 +- 人工审核覆盖记录 + +主要表: + +- `leaudit_documents` +- `leaudit_document_files` +- `leaudit_audit_runs` +- `leaudit_rule_results` +- `leaudit_review_point_audits` + +### 4.2 Cross Review 协作层 + +职责: + +- 交叉评查任务 +- 成员管理 +- 任务文档挂载 +- 加减分提案 +- 投票 +- 文档完成确认 +- 协作态进度管理 + +主要表: + +- `leaudit_cross_review_tasks` +- `leaudit_cross_review_task_members` +- `leaudit_cross_review_task_documents` +- `leaudit_cross_review_proposals` +- `leaudit_cross_review_votes` + +## 5. 新数据模型设计 + +## 5.1 `leaudit_cross_review_tasks` + +任务主表。 + +建议字段: + +- `id` +- `task_name` +- `task_type` +- `doc_type_id` +- `doc_type_code` +- `assigner_id` +- `status` +- `created_at` +- `updated_at` +- `deleted_at` + +说明: + +- `status` 建议值:`pending`、`in_progress`、`completed` +- `doc_type_id` 可用于快速关联新平台文档类型 +- `doc_type_code` 用于兼容前端和历史口径 + +## 5.2 `leaudit_cross_review_task_members` + +任务成员表。 + +建议字段: + +- `id` +- `task_id` +- `user_id` +- `member_role` +- `created_at` +- `updated_at` +- `deleted_at` + +说明: + +- `member_role` 建议值:`participant`、`principal` +- 任务创建者不建议仅放数组,应显式保存在任务主表 `assigner_id` + +## 5.3 `leaudit_cross_review_task_documents` + +任务文档表。 + +建议字段: + +- `id` +- `task_id` +- `document_id` +- `audit_status` +- `created_at` +- `updated_at` +- `deleted_at` + +说明: + +- `audit_status=0` 表示未完成 +- `audit_status=1` 表示已确认完成 +- 这是任务内状态,不是文档全局状态 + +## 5.4 `leaudit_cross_review_proposals` + +提案表。 + +建议字段: + +- `id` +- `task_id` +- `document_id` +- `rule_result_id` +- `proposer_id` +- `proposed_score_delta` +- `reason` +- `status` +- `created_at` +- `updated_at` +- `deleted_at` + +说明: + +- `rule_result_id` 应关联 `leaudit_rule_results.id` +- `proposed_score_delta` 使用增量值,正数加分,负数扣分 +- `status` 建议值:`pending`、`approved`、`rejected`、`cancelled` + +## 5.5 `leaudit_cross_review_votes` + +投票表。 + +建议字段: + +- `id` +- `proposal_id` +- `voter_id` +- `vote_type` +- `created_at` +- `updated_at` +- `deleted_at` + +说明: + +- `vote_type` 建议值:`agree`、`disagree` +- `cancel` 建议用软删除表示,不必单独存为最终值 + +## 6. 关键业务规则如何映射到新平台 + +## 6.1 创建任务 + +目标保留的老规则: + +- 创建者强制加入任务成员 +- 可指定普通参与人和主要负责人 +- 任务创建时可同时挂一批文档 + +新实现建议: + +- 创建任务时先写 `leaudit_cross_review_tasks` +- 再写 `leaudit_cross_review_task_members` +- 再写 `leaudit_cross_review_task_documents` + +## 6.2 文档访问校验 + +目标保留的老规则: + +- 只有任务成员才能看任务文档 +- 提案必须绑定任务内文档 + +新实现建议: + +- 不再让前端自己查 `cross_task_document_mapping` +- 提供统一后端方法: + - `CheckTaskMember(taskId, userId)` + - `CheckTaskDocument(taskId, documentId)` + - `CheckTaskDocumentRuleResult(documentId, ruleResultId)` + +## 6.3 提案创建 + +目标保留的老规则: + +- 必须是任务参与者 +- 同一用户不能对同一评查点重复创建有效提案 +- 不允许 0 分提案 +- 当前分数为 0 时不能继续扣 +- 当前已满分时不能继续加 + +新实现建议: + +- 通过 `rule_result_id` 读取对应规则结果 +- 计算当前展示分数 +- 校验是否还能继续加减 +- 写入 `leaudit_cross_review_proposals` +- 自动插入一条提案人同意票 + +## 6.4 投票与状态流转 + +目标保留的老规则: + +- 采用绝对多数制 +- 阈值为 `floor(n / 2) + 1` + +新实现建议: + +- `n` 基于 `leaudit_cross_review_task_members` 统计活跃成员 +- 同意达到阈值 -> `approved` +- 反对达到阈值 -> `rejected` +- 剩余票全部同意也无法通过 -> `rejected` + +## 6.5 文档完成确认 + +目标保留的老规则: + +- 只有创建者或负责人可确认 +- 文档完成改的是任务内状态 +- 全部文档完成后任务才完成 + +新实现建议: + +- 确认文档完成时更新 `leaudit_cross_review_task_documents.audit_status` +- 若同任务下所有未删除文档都为完成,则更新 `leaudit_cross_review_tasks.status = completed` + +## 6.6 分数计算 + +这是新实现里最重要的变化。 + +### 不建议 + +- 直接更新 `leaudit_rule_results.score` +- 直接写回机器结果源字段 + +### 建议 + +按聚合方式计算展示分数: + +- `machineScore`:来自 `leaudit_rule_results` +- `auditOverride`:来自 `leaudit_review_point_audits` +- `crossReviewDelta`:来自所有已通过的 `leaudit_cross_review_proposals` +- `finalScore`:在详情页聚合阶段动态计算 + +建议公式: + +1. 先根据机器结果与人工审核确定基础结果 +2. 基础结果换算出基础得分 +3. 加上 `approved` 提案累计增量 +4. 最终再做上下限裁剪: + - 不低于 0 + - 不高于规则满分 + +## 7. 详情页如何接入新实现 + +当前详情页主入口已经存在: + +- `fastapi_modules/fastapi_leaudit/controllers/documentController.py` +- `GET /v3/review-points/{DocumentId}` + +建议做法: + +### 7.1 保留 `GetReviewPoints()` 作为主入口 + +这样前端详情页无需大改。 + +### 7.2 修改 `_loadScoringProposals()` + +当前只是兼容读取旧 `cross_scoring_proposals`。 + +建议改为: + +- 优先读取 `leaudit_cross_review_proposals` +- 若尚未迁移完成,可保留旧表兼容兜底一段时间 + +### 7.3 修改评查点聚合逻辑 + +在 `_loadReviewPointResults()` 或其上层聚合逻辑中: + +- 读取单点所有已批准提案的分值增量 +- 计算 `finalScore` +- 继续把提案列表放入 `scoring_proposals` + +### 7.4 前端详情页不用重构为新的页面协议 + +保持 `ReviewPointsAggregateVO` 不变或小幅扩展即可。 + +## 8. 任务列表和任务文档列表如何实现 + +## 8.1 任务列表 + +返回建议字段: + +- `task_id` +- `task_name` +- `task_status` +- `doc_type` +- `task_type` +- `task_created_at` +- `progress` +- `total_documents` +- `evaluation_region` + +实现来源: + +- 任务主表 +- 成员表 +- 任务文档表 + +## 8.2 任务文档列表 + +建议按以下逻辑组织: + +1. 先取任务绑定文档 +2. 通过 `leaudit_documents.versionGroupKey` 做版本归组 +3. 当前版本优先取 `isLatestVersion = true` +4. 统计信息来自 `leaudit_rule_results` +5. 最终展示分数叠加 `approved` 提案增量 + +## 8.3 不建议继续使用旧版“同名同类型猜版本” + +因为新平台已经有更稳定的版本链字段,不必再依赖脆弱的文件名规则。 + +## 9. 上传与追加附件的实现建议 + +## 9.1 推荐长期方案 + +采用“文档系统”和“交叉评查系统”解耦: + +1. 先用通用文档接口上传 +2. 拿到 `documentId` +3. 再把文档挂入交叉任务 + +优点: + +- 复用现有 `DocumentServiceImpl.Upload` +- 文档生命周期统一 +- 附件处理逻辑不重复 + +## 9.2 推荐短期兼容方案 + +为兼容当前前端,可先提供聚合接口: + +- `UploadAndCreateTask` +- `UploadDocumentsToTask` +- `AppendTaskDocumentAttachments` + +这些接口内部再调用: + +- `DocumentService.Upload` +- `DocumentService.AppendAttachments` + +这样前端先不需要大改。 + +## 10. 权限设计 + +建议保留现有 RBAC 权限点: + +- `cross_review:task:read` +- `cross_review:task:create` +- `cross_review:progress:view` +- `cross_review:proposal:create` +- `cross_review:proposal:read` +- `cross_review:proposal:delete` +- `cross_review:proposal:vote` +- `cross_review:document:complete` + +同时叠加业务层权限判断: + +- 是否任务成员 +- 是否创建者 +- 是否主要负责人 +- 文档是否属于任务 +- 评查点结果是否属于该文档 + +## 11. 推荐实施顺序 + +### 第一阶段:先建协作层表和后端接口 + +目标: + +- 新建 `leaudit_cross_review_*` 表 +- 新建 `CrossReviewController / Service / DTO / VO` +- 打通任务、提案、投票、完成确认 + +### 第二阶段:详情页切新提案逻辑 + +目标: + +- `GetReviewPoints()` 改读新提案表 +- 动态计算最终分数 +- 详情页不再依赖旧 `cross_scoring_proposals` + +### 第三阶段:任务文档列表切新版本底座 + +目标: + +- 改用 `leaudit_documents` 版本字段归组 +- 任务内分数与统计改读新平台结果 + +### 第四阶段:前端路径和历史兼容收口 + +目标: + +- 逐步下线 `/admin/v2/cross_review/*` 历史兼容口径 +- 清理前端直查旧表逻辑 +- 清理旧 `cross_*` 兼容读取 + +## 12. 风险与注意事项 + +### 12.1 最大风险:最终分数语义变化 + +如果继续沿用老系统“直接回写”的做法,会污染新平台结果底座。 + +建议统一改为聚合展示分数。 + +### 12.2 第二风险:前端隐式依赖旧接口字段 + +当前 `new_doc_review/app/api/cross-checking/*` 里仍存在很多历史字段名和旧路径假设。 + +新后端第一期最好兼容这些字段,避免前端同步改动过大。 + +### 12.3 第三风险:权限校验分散 + +当前部分前端仍尝试自行查表判断权限。 + +迁移时应尽快把权限判断收敛到后端。 + diff --git a/docs/交叉评查/新项目交叉评查新表结构与接口清单.md b/docs/交叉评查/新项目交叉评查新表结构与接口清单.md new file mode 100644 index 0000000..963709c --- /dev/null +++ b/docs/交叉评查/新项目交叉评查新表结构与接口清单.md @@ -0,0 +1,450 @@ +# 新项目交叉评查新表结构与接口清单 + +> 目标:给出交叉评查在新平台中的建议表结构、接口清单以及代码文件落点,便于直接拆任务实施。 + +## 1. 推荐新增表 + +## 1.1 `leaudit_cross_review_tasks` + +用途: + +- 交叉评查任务主表 + +建议字段: + +```sql +id bigint primary key, +task_name varchar(255) not null, +task_type varchar(32) not null, +doc_type_id bigint null, +doc_type_code varchar(64) null, +assigner_id bigint not null, +status varchar(32) not null default 'in_progress', +created_at timestamptz not null default now(), +updated_at timestamptz not null default now(), +deleted_at timestamptz null +``` + +建议索引: + +- `idx_lcrt_assigner_id` +- `idx_lcrt_status` +- `idx_lcrt_doc_type_id` + +## 1.2 `leaudit_cross_review_task_members` + +用途: + +- 任务参与人和负责人关系表 + +建议字段: + +```sql +id bigint primary key, +task_id bigint not null, +user_id bigint not null, +member_role varchar(32) not null, +created_at timestamptz not null default now(), +updated_at timestamptz not null default now(), +deleted_at timestamptz null +``` + +约束建议: + +- 唯一约束:`(task_id, user_id, deleted_at is null)` 语义上唯一 + +建议索引: + +- `idx_lcrtm_task_id` +- `idx_lcrtm_user_id` +- `idx_lcrtm_role` + +## 1.3 `leaudit_cross_review_task_documents` + +用途: + +- 任务绑定文档 + +建议字段: + +```sql +id bigint primary key, +task_id bigint not null, +document_id bigint not null, +audit_status integer not null default 0, +created_at timestamptz not null default now(), +updated_at timestamptz not null default now(), +deleted_at timestamptz null +``` + +说明: + +- `audit_status=0` 未完成 +- `audit_status=1` 已完成 + +建议索引: + +- `idx_lcrtd_task_id` +- `idx_lcrtd_document_id` +- `idx_lcrtd_task_status` + +## 1.4 `leaudit_cross_review_proposals` + +用途: + +- 交叉评查加减分提案 + +建议字段: + +```sql +id bigint primary key, +task_id bigint not null, +document_id bigint not null, +rule_result_id bigint not null, +proposer_id bigint not null, +proposed_score_delta numeric(10,2) not null, +reason text not null, +status varchar(32) not null default 'pending', +created_at timestamptz not null default now(), +updated_at timestamptz not null default now(), +deleted_at timestamptz null +``` + +建议索引: + +- `idx_lcrp_task_id` +- `idx_lcrp_document_id` +- `idx_lcrp_rule_result_id` +- `idx_lcrp_proposer_id` +- `idx_lcrp_status` + +业务唯一性建议: + +- 同一用户对同一 `document_id + rule_result_id` 同时只能存在一条有效提案 + +## 1.5 `leaudit_cross_review_votes` + +用途: + +- 提案投票 + +建议字段: + +```sql +id bigint primary key, +proposal_id bigint not null, +voter_id bigint not null, +vote_type varchar(16) not null, +created_at timestamptz not null default now(), +updated_at timestamptz not null default now(), +deleted_at timestamptz null +``` + +建议索引: + +- `idx_lcrv_proposal_id` +- `idx_lcrv_voter_id` + +业务唯一性建议: + +- 同一提案、同一用户仅保留一条有效投票 + +## 2. 推荐后端文件落点 + +## 2.1 Models + +建议新增: + +- `fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTask.py` +- `fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTaskMember.py` +- `fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTaskDocument.py` +- `fastapi_modules/fastapi_leaudit/models/leauditCrossReviewProposal.py` +- `fastapi_modules/fastapi_leaudit/models/leauditCrossReviewVote.py` + +## 2.2 DTO + +建议新增: + +- `fastapi_modules/fastapi_leaudit/domian/Dto/crossReviewDto.py` + +建议包含: + +- `CrossReviewTaskCreateDTO` +- `CrossReviewProposalCreateDTO` +- `CrossReviewVoteDTO` +- `CrossReviewTaskDocumentQueryDTO` + +## 2.3 VO + +建议新增: + +- `fastapi_modules/fastapi_leaudit/domian/vo/crossReviewVo.py` + +建议包含: + +- `CrossReviewTaskItemVO` +- `CrossReviewTaskPageVO` +- `CrossReviewTaskDocumentVO` +- `CrossReviewTaskDocumentPageVO` +- `CrossReviewProposalVO` +- `CrossReviewProposalPageVO` +- `CrossReviewPermissionVO` +- `CrossReviewCompleteVO` + +## 2.4 Service + +建议新增: + +- `fastapi_modules/fastapi_leaudit/services/crossReviewService.py` +- `fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py` + +## 2.5 Controller + +建议新增: + +- `fastapi_modules/fastapi_leaudit/controllers/crossReviewController.py` + +## 3. 推荐服务接口 + +建议在 `crossReviewService.py` 中定义以下能力: + +- `CreateTask(...)` +- `GetUserTasks(...)` +- `GetTaskDocuments(...)` +- `CanConfirmTaskDocument(...)` +- `CompleteTaskDocument(...)` +- `CreateProposal(...)` +- `VoteProposal(...)` +- `CancelProposal(...)` +- `GetDocumentProposals(...)` +- `GetPendingProposalDetails(...)` +- `CheckPendingVotesByDocument(...)` +- `UploadDocumentsToTask(...)` +- `AppendTaskDocumentAttachments(...)` +- `LoadApprovedProposalDeltas(...)` + +## 4. 推荐接口清单 + +## 4.1 任务接口 + +### `POST /api/v2/cross_review/tasks` + +用途: + +- 创建交叉评查任务 + +请求体建议: + +```json +{ + "document_ids": [101, 102], + "user_ids": [11, 12], + "principal_user_ids": [21], + "task_name": "市局间交叉评查-合同类", + "doc_type_id": 3, + "doc_type_code": "XZCF", + "task_type": "CITY" +} +``` + +### `POST /api/v2/cross_review/tasks/user_tasks` + +用途: + +- 获取当前用户参与的任务列表 + +### `GET /api/v2/cross_review/tasks/{taskId}/documents` + +用途: + +- 获取任务下文档列表 +- 支持版本归纳 + +查询参数建议: + +- `page` +- `page_size` +- `keyword` + +### `GET /api/v2/cross_review/tasks/{taskId}/can-confirm` + +用途: + +- 检查当前用户是否能确认文档评查完成 + +### `POST /api/v2/cross_review/tasks/{taskId}/documents/{documentId}/complete` + +用途: + +- 确认任务内文档完成 + +### `POST /api/v2/cross_review/tasks/{taskId}/upload_documents` + +用途: + +- 向已有任务追加新文档 + +### `POST /api/v2/cross_review/tasks/{taskId}/documents/{documentId}/append_attachments` + +用途: + +- 给任务文档追加附件并生成新版本 + +## 4.2 提案接口 + +### `POST /api/v2/cross_review/proposals` + +用途: + +- 创建提案 + +请求体建议: + +```json +{ + "task_id": 1, + "document_id": 101, + "rule_result_id": 10001, + "proposed_score_delta": -5, + "reason": "该点应扣 5 分" +} +``` + +### `POST /api/v2/cross_review/proposals/{proposalId}/votes` + +用途: + +- 对提案投票 + +请求体建议: + +```json +{ + "vote_type": "agree" +} +``` + +### `DELETE /api/v2/cross_review/proposals/{proposalId}` + +用途: + +- 撤销提案 + +### `POST /api/v2/cross_review/proposals/document` + +用途: + +- 获取某文档的提案列表 + +请求体建议: + +```json +{ + "document_id": 101, + "page": 1, + "page_size": 10 +} +``` + +### `POST /api/v2/cross_review/proposals/details` + +用途: + +- 获取当前用户待处理提案列表 + +### `POST /api/v2/cross_review/proposals/document/check_pending_votes` + +用途: + +- 检查某文档是否仍有提案未完成投票 + +## 5. 与现有 `DocumentServiceImpl` 的衔接点 + +## 5.1 详情页提案列表 + +当前衔接点: + +- `DocumentServiceImpl._loadScoringProposals()` + +建议: + +- 改成读 `leaudit_cross_review_proposals` +- 后续移除对旧 `cross_scoring_proposals` 的直接依赖 + +## 5.2 最终得分计算 + +当前衔接点: + +- `DocumentServiceImpl._loadReviewPointResults()` + +建议: + +- 增加按 `rule_result_id` 聚合已通过提案分数的逻辑 +- 返回前写入 `ReviewPointResultVO.finalScore` + +## 5.3 文档上传与附件追加 + +建议复用: + +- `DocumentServiceImpl.Upload()` +- `DocumentServiceImpl.AppendAttachments()` + +交叉评查只负责: + +- 上传后把文档挂入任务 +- 维护任务内状态和版本展示 + +## 6. 建议前端改造策略 + +## 6.1 第一阶段 + +前端尽量少改,只调整 API 基址和字段映射。 + +重点文件: + +- `new_doc_review/app/api/cross-checking/cross-files.ts` +- `new_doc_review/app/api/cross-checking/cross-file-result.ts` +- `new_doc_review/app/api/cross-checking/cross-files-upload.ts` + +## 6.2 第二阶段 + +逐步清理这些旧依赖: + +- 前端直接查 `cross_examination_tasks` +- 前端直接查 `cross_task_document_mapping` +- 前端假设旧路径 `/admin/cross_review/*` + +## 7. 推荐实施拆解 + +### P0:表结构与模型 + +- 建新表 +- 建模型 +- 建基础迁移 + +### P1:任务与提案接口 + +- 创建任务 +- 任务列表 +- 文档列表 +- 提案创建 +- 投票 +- 完成确认 + +### P2:详情页聚合接入 + +- `GetReviewPoints()` 改接新提案表 +- 动态计算最终分数 + +### P3:上传与版本链闭环 + +- 任务上传文档 +- 追加附件 +- 版本归纳改用 `versionGroupKey` + +### P4:清理旧兼容 + +- 清理旧表直查 +- 清理前端旧路径 +- 清理旧逻辑兜底 + diff --git a/docs/交叉评查/新项目交叉评查状态流转图与边界案例清单.md b/docs/交叉评查/新项目交叉评查状态流转图与边界案例清单.md new file mode 100644 index 0000000..8856506 --- /dev/null +++ b/docs/交叉评查/新项目交叉评查状态流转图与边界案例清单.md @@ -0,0 +1,594 @@ +# 新项目交叉评查状态流转图与边界案例清单 + +> 目标:把新项目交叉评查的状态流转、时序过程和高风险边界案例一次性说清,避免实现时出现“以为如此,实际上不是”的尴尬。 + +## 1. 为什么需要这份文档 + +交叉评查最容易出错的地方,不是 CRUD,而是: + +- 状态什么时候变 +- 谁能触发状态变更 +- 分数什么时候生效 +- 文档什么时候算完成 +- 任务什么时候算完成 +- 新版本文档进来后旧版本怎么处理 + +这些地方一旦实现错了,前端看起来也许还能跑,但业务上会很尴尬。 + +所以这份文档专门回答三类问题: + +- 状态怎么流转 +- 链路怎么串起来 +- 边界情况怎么处理 + +## 2. 五类核心状态 + +建议在实现时,把状态拆成五套,不要混用。 + +### 2.1 文档处理状态 + +来源: + +- `leaudit_documents.processing_status` +- `leaudit_audit_runs.status` + +这套状态表示: + +- 文档是否完成机器评查 + +它不表示: + +- 是否完成交叉评查 + +### 2.2 任务状态 + +来源: + +- `leaudit_cross_review_tasks.status` + +建议值: + +- `pending` +- `in_progress` +- `completed` + +这套状态表示: + +- 任务层面的协作进度 + +### 2.3 任务文档状态 + +来源: + +- `leaudit_cross_review_task_documents.audit_status` + +建议值: + +- `0`:未完成 +- `1`:已完成 + +这套状态表示: + +- 某个文档在某个任务里是否被负责人确认完成 + +### 2.4 提案状态 + +来源: + +- `leaudit_cross_review_proposals.status` + +建议值: + +- `pending` +- `approved` +- `rejected` +- `cancelled` + +### 2.5 投票状态 + +来源: + +- `leaudit_cross_review_votes.vote_type` + +建议值: + +- `agree` +- `disagree` + +取消投票建议通过软删除表达,不额外引入 `cancel` 作为终态值。 + +## 3. 任务状态流转图 + +```text +创建任务 + | + v +in_progress + | + | 所有任务文档 audit_status = 1 + v +completed +``` + +### 3.1 任务创建后状态 + +- 默认直接进入 `in_progress` + +### 3.2 任务完成条件 + +只有当: + +- 该任务下所有未删除任务文档都为 `audit_status = 1` + +才允许变为: + +- `completed` + +### 3.3 明确不是任务完成条件的事件 + +以下事件都不能直接导致任务完成: + +- 文档机器评查跑完 +- 所有提案都投票结束 +- 没有提案 +- 所有人都看过详情页 +- 追加了新附件 + +## 4. 任务文档状态流转图 + +```text +文档被挂入任务 + | + v +audit_status = 0 + | + | assigner 或 principal 确认完成 + v +audit_status = 1 +``` + +### 4.1 进入任务默认状态 + +- 新挂入任务的文档默认 `audit_status = 0` + +### 4.2 谁能把 `0` 改成 `1` + +只有: + +- `assigner` +- `principal` + +普通参与人不允许确认完成。 + +### 4.3 新版本文档进入任务后的状态 + +如果文档追加附件或上传了新版本: + +- 新文档进入任务后必须重新记为 `audit_status = 0` + +原因: + +- 新版本代表新的评查对象 +- 不能继承旧版本的“已完成” + +## 5. 提案状态流转图 + +```text +创建提案 + | + v +pending + | \ + | \ + | \ 提案人主动撤销 + | \ + | v + | cancelled + | + | agree 票达到阈值 + v +approved + +pending + | + | disagree 票达到阈值 + | 或剩余票全部同意也无法通过 + v +rejected +``` + +### 5.1 初始状态 + +- 提案创建后默认 `pending` + +### 5.2 通过条件 + +若: + +- `agree >= floor(n / 2) + 1` + +则: + +- `approved` + +其中 `n` 为任务有效成员总数。 + +### 5.3 否决条件 + +若: + +- `disagree >= floor(n / 2) + 1` + +则: + +- `rejected` + +### 5.4 提前否决条件 + +若: + +- 即使所有剩余未投票成员都改投同意,提案也不可能过阈值 + +则: + +- 直接 `rejected` + +### 5.5 撤销条件 + +只有提案人可以撤销,且必须满足: + +- 当前状态仍为 `pending` + +撤销后: + +- 提案变为 `cancelled` + +### 5.6 终态不可再操作 + +提案一旦进入: + +- `approved` +- `rejected` +- `cancelled` + +即视为终态: + +- 不允许再投票 +- 不允许再撤销 +- 不允许再改状态 + +## 6. 投票时序图 + +```text +参与人 -> CrossReviewController: POST /proposals/{id}/votes +CrossReviewController -> CrossReviewService: VoteProposal() +CrossReviewService -> DB: 校验提案存在且状态= pending +CrossReviewService -> DB: 校验当前用户是任务成员 +CrossReviewService -> DB: 写入或更新投票 +CrossReviewService -> DB: 重新统计 agree/disagree/remaining +CrossReviewService -> DB: 如达阈值则更新 proposal.status +CrossReviewService --> CrossReviewController: 返回 proposal_status +CrossReviewController --> 参与人: success + 最新状态 +``` + +### 6.1 投票后必须立即判定状态 + +不要把“投票成功”和“状态判定”拆成异步延迟任务。 + +推荐: + +- 每次投票后同步判定提案状态 + +原因: + +- 前端能立刻拿到最新状态 +- 避免多人并发时出现短暂假状态 + +## 7. 提案创建时序图 + +```text +参与人 -> CrossReviewController: POST /proposals +CrossReviewController -> CrossReviewService: CreateProposal() +CrossReviewService -> DB: 校验任务/文档/规则结果关系 +CrossReviewService -> DB: 校验用户是否为任务成员 +CrossReviewService -> DB: 校验是否重复提案 +CrossReviewService -> DB: 校验分值边界 +CrossReviewService -> DB: 写入 proposal(status=pending) +CrossReviewService -> DB: 自动写入 proposer agree 投票 +CrossReviewService -> DB: 判定 proposal 是否已直接过阈值 +CrossReviewService --> CrossReviewController: proposal + latest_status +CrossReviewController --> 参与人: success +``` + +### 7.1 自动同意票后的特殊情况 + +如果任务成员数很少,例如: + +- 只有 1 人 + +那么提案创建后可能因为自动同意票立即通过。 + +这是允许的,只要符合阈值规则。 + +## 8. 文档完成确认时序图 + +```text +负责人 -> CrossReviewController: POST /tasks/{taskId}/documents/{documentId}/complete +CrossReviewController -> CrossReviewService: CompleteTaskDocument() +CrossReviewService -> DB: 校验任务存在 +CrossReviewService -> DB: 校验用户是 assigner 或 principal +CrossReviewService -> DB: 校验文档属于该任务 +CrossReviewService -> DB: 更新 task_document.audit_status = 1 +CrossReviewService -> DB: 统计任务下是否所有文档已完成 +CrossReviewService -> DB: 若是,则更新 task.status = completed +CrossReviewService --> CrossReviewController: document completed / task completed +CrossReviewController --> 负责人: success +``` + +### 8.1 确认完成时是否强制要求“所有提案已投完” + +不建议强阻断。 + +建议策略: + +- 后端检测是否还有 `pending` 提案或未投票成员 +- 返回提示信息 +- 由前端给出二次确认 + +原因: + +- 真实业务里负责人可能需要“带风险确认” +- 如果完全强阻断,会把流程卡死 + +## 9. 详情页分数计算时序图 + +```text +前端详情页 -> DocumentController: GET /v3/review-points/{documentId} +DocumentController -> DocumentServiceImpl: GetReviewPoints() +DocumentServiceImpl -> DB: 读取 leaudit_rule_results +DocumentServiceImpl -> DB: 读取 leaudit_review_point_audits +DocumentServiceImpl -> DB: 读取 approved proposals +DocumentServiceImpl: 计算 baseScore +DocumentServiceImpl: 计算 crossDelta +DocumentServiceImpl: 计算 finalScore = clamp(baseScore + crossDelta) +DocumentServiceImpl --> DocumentController: ReviewPointsAggregateVO +DocumentController --> 前端详情页: data + stats + scoring_proposals +``` + +### 9.1 分数生效时点 + +提案只有在: + +- `approved` + +后,才计入 `crossDelta`。 + +以下状态都不计入: + +- `pending` +- `rejected` +- `cancelled` + +## 10. 版本链时序图 + +```text +用户上传新版本/追加附件 + | + v +DocumentService 创建新 leaudit_document + | + v +新文档进入原 versionGroupKey + | + v +CrossReviewService 将新 document_id 挂入任务 + | + v +task_document.audit_status = 0 + | + v +任务列表按 versionGroupKey 归组展示 +``` + +### 10.1 旧版本是否自动失效 + +不建议把旧版本任务记录自动删掉。 + +建议: + +- 保留历史版本 +- 页面默认展示最新版本 +- 历史版本折叠展示 + +### 10.2 新版本是否自动继承提案 + +不建议继承。 + +原因: + +- 提案是针对具体 `rule_result_id` +- 新版本会产生新的 `rule_result_id` +- 旧提案不能自动迁移到新结果上 + +## 11. 边界案例清单 + +下面这些情况,后端必须提前定好策略。 + +## 11.1 只有 1 个任务成员 + +### 情况 + +- 创建者自己给自己建任务,且没有别的成员 + +### 正确处理 + +- 阈值 = 1 +- 提案创建后自动同意票即可直接 `approved` + +### 不要出错 + +- 不要硬编码“至少两人才能交叉评查” + +## 11.2 只有 2 个任务成员 + +### 情况 + +- A 发起提案 +- A 自动同意 +- B 反对 + +### 正确处理 + +- 成员数 `n=2` +- 阈值 `floor(2/2)+1 = 2` +- A 同意数 1,不足通过 +- B 反对数 1,也不足否决 +- 但此时无剩余票,提案已不可能达到 2 票同意 +- 应直接 `rejected` + +### 不要出错 + +- 不要把它一直挂在 `pending` + +## 11.3 提案人重复点击提交 + +### 情况 + +- 网络慢,用户多次点提交 + +### 正确处理 + +- 通过唯一性规则防重 +- 返回“已存在有效提案” + +### 不要出错 + +- 不要产生两条同时有效的同目标提案 + +## 11.4 文档已有新版本,但旧版本已完成 + +### 情况 + +- 旧版本在任务里已确认完成 +- 新版本追加进来 + +### 正确处理 + +- 新版本独立新增一条 `task_document` +- `audit_status = 0` +- 旧版本保持历史完成状态 + +### 不要出错 + +- 不要把新版本直接继承旧版本的完成状态 + +## 11.5 提案通过后又有人想继续改 + +### 情况 + +- 某条提案已经 `approved` +- 又有人想针对同一个规则点继续提分或扣分 + +### 正确处理 + +- 允许新建后续提案,但必须满足唯一性规则 +- 即同一用户不能重复,但不同用户可继续提出新的有效提案 + +### 不要出错 + +- 不要错误地把“某点已有 approved 提案”理解成“该点以后都不能再提案” + +## 11.6 文档没有任何提案 + +### 情况 + +- 任务成员只看文档,不提出任何提案 + +### 正确处理 + +- 允许负责人直接确认完成 + +### 不要出错 + +- 不要把“没有提案”当成“不能完成” + +## 11.7 存在 `pending` 提案,但负责人仍要完成 + +### 情况 + +- 业务上要强行收口 + +### 正确处理 + +- 推荐允许,但返回 warning 信息 + +### 不要出错 + +- 不要一刀切强阻断,除非产品明确要求 + +## 11.8 文档重跑评查后,旧提案怎么办 + +### 情况 + +- 同一个文档重新触发 audit run +- 产生新 `rule_result_id` + +### 正确处理建议 + +- 提案绑定的是具体 `rule_result_id` +- 如果是同一 document 的新 run,建议只对“当前有效 run”的结果展示可新建提案 +- 旧 run 上的提案保留历史,不再作为主展示对象 + +### 不要出错 + +- 不要把旧 run 的提案直接混到新 run 的点位上 + +## 11.9 删除任务成员后,历史票怎么办 + +### 情况 + +- 某成员中途被移出任务 + +### 正确处理建议 + +- 历史投票保留 +- 后续阈值计算以当前有效成员还是任务创建时成员为准,必须提前定死 + +### 推荐口径 + +- 阈值按“当前有效成员数”计算 + +### 注意 + +- 这个规则一旦确定,就不能前后漂移 + +## 12. 推荐的最终业务口径 + +为了避免后续实现反复改口,建议最终统一采用以下口径: + +- 任务默认 `in_progress` +- 文档进入任务默认未完成 +- 文档完成只能由创建者或负责人确认 +- 任务完成由“所有任务文档完成”推导 +- 提案默认 `pending` +- 提案自动给提案人一票同意 +- 提案采用绝对多数制 +- 提案通过后只影响展示层最终分,不改机器原始结果 +- 版本归纳优先使用 `versionGroupKey` +- 权限由后端统一判断 + +## 13. 开发前最后核对清单 + +开始写代码前,建议逐项确认: + +- 是否已经把五类状态拆开 +- 是否已经明确最终分不是回写机器结果 +- 是否已经明确任务完成只看任务文档状态 +- 是否已经明确新版本进任务后默认未完成 +- 是否已经明确提案绑定到 `rule_result_id` +- 是否已经明确提案通过阈值算法 +- 是否已经明确权限是 RBAC + 业务归属双校验 +- 是否已经明确详情页主入口仍可复用 `GetReviewPoints()` + +如果这几项都确认过,交叉评查的实现基本就不会跑偏。 + diff --git a/docs/交叉评查/新项目交叉评查详细业务逻辑定稿.md b/docs/交叉评查/新项目交叉评查详细业务逻辑定稿.md new file mode 100644 index 0000000..70e8f63 --- /dev/null +++ b/docs/交叉评查/新项目交叉评查详细业务逻辑定稿.md @@ -0,0 +1,771 @@ +# 新项目交叉评查详细业务逻辑定稿 + +> 目标:把新项目交叉评查的业务逻辑收敛成一份“可直接指导实现”的定稿,重点避免实现阶段把任务状态、评分语义、权限边界、版本逻辑和完成条件做错。 + +## 1. 定位 + +交叉评查在新项目中必须被视为: + +- 一层独立的“协作复核业务” +- 建立在 `leaudit` 已有文档、评查运行、规则结果之上 +- 不负责重新生成底层规则结果 +- 只负责任务协作、提案、投票、完成确认和展示分数叠加 + +因此,交叉评查不是评查引擎的一部分,而是评查结果之上的二次协作层。 + +## 2. 核心业务对象 + +新项目中交叉评查至少包含五类核心对象: + +### 2.1 任务 + +任务是交叉评查的协作容器,负责绑定: + +- 一组参与人 +- 一组主要负责人 +- 一批文档 +- 一个文档类型语义 +- 一个任务状态 + +任务是交叉评查权限判断的顶层边界。 + +### 2.2 任务成员 + +任务成员分为两类: + +- `participant`:普通参与人 +- `principal`:主要负责人 + +另有一个特殊角色: + +- `assigner`:任务创建者 + +实现上建议: + +- `assigner` 保存在任务主表 +- `participant/principal` 保存在成员表 + +### 2.3 任务文档 + +任务文档表示: + +- 哪些文档进入了交叉评查任务 +- 每个文档在该任务内的完成状态 + +注意: + +- 任务文档状态是“任务内状态” +- 不等于文档全局状态 +- 不等于底层评查运行状态 + +### 2.4 提案 + +提案表示: + +- 某个参与人针对某个“规则结果”提出的分数调整建议 + +提案的本质是: + +- 绑定到具体 `rule_result` +- 以“增量分值”表达加分或扣分 + +### 2.5 投票 + +投票表示: + +- 任务成员对某条提案的同意或反对 + +投票只作用于提案状态流转,不直接改文档状态。 + +## 3. 不可搞错的几个真相源 + +实现阶段最容易尴尬的就是“到底哪张表是真相源”。 + +这里必须定死: + +### 3.1 文档真相源 + +- `leaudit_documents` + +### 3.2 文档文件与版本真相源 + +- `leaudit_document_files` +- `leaudit_documents.versionGroupKey` +- `leaudit_documents.versionNo` +- `leaudit_documents.previousVersionId` +- `leaudit_documents.rootVersionId` +- `leaudit_documents.isLatestVersion` + +### 3.3 机器评查结果真相源 + +- `leaudit_rule_results` + +### 3.4 人工审核覆盖真相源 + +- `leaudit_review_point_audits` + +### 3.5 交叉评查协作真相源 + +- `leaudit_cross_review_tasks` +- `leaudit_cross_review_task_members` +- `leaudit_cross_review_task_documents` +- `leaudit_cross_review_proposals` +- `leaudit_cross_review_votes` + +## 4. 明确禁止的实现方式 + +以下做法不要再用: + +### 4.1 不要直接修改 `leaudit_rule_results` 原始分数 + +原因: + +- 会污染机器评查原始结果 +- 会让重新跑批、调试、审计对账变得混乱 + +### 4.2 不要把交叉评查完成状态写回文档全局状态 + +原因: + +- 交叉评查完成只是任务内完成 +- 同一文档可能同时出现在多个任务或多个业务视角下 + +### 4.3 不要靠前端自行拼表判断权限 + +原因: + +- 字段口径容易漂移 +- 任务成员、负责人和创建者语义容易搞混 +- 前端无法作为权限真相源 + +### 4.4 不要再用“同名同类型猜版本”作为主逻辑 + +原因: + +- 新平台已经有正式版本字段 +- 同名同类型只能作为兼容或兜底 + +## 5. 任务业务逻辑定稿 + +## 5.1 创建任务 + +### 输入 + +- 文档 ID 列表 +- 参与人 ID 列表 +- 主要负责人 ID 列表 +- 任务名称 +- 任务类型 +- 文档类型信息 + +### 必须规则 + +1. `assigner_id` 必须自动加入任务成员集合 +2. 成员列表必须去重 +3. 主要负责人必须同时是任务成员 +4. 所有文档必须真实存在 +5. 所有文档必须可被当前用户纳入交叉评查 +6. 初始状态必须是 `in_progress` + +### 输出 + +- 任务主记录 +- 任务成员记录 +- 任务文档记录 + +### 注意 + +- 创建任务不等于重新跑评查 +- 默认不触发新的机器评查 +- 如果文档上传接口内包含自动评查,是上传链路行为,不是任务创建行为 + +## 5.2 任务列表 + +任务列表只返回当前用户参与的任务。 + +### 当前用户可见任务的判定 + +用户满足以下任一条件即可见: + +- 是 `assigner` +- 是成员表里的 `participant` +- 是成员表里的 `principal` + +### 返回字段必须稳定 + +- `task_id` +- `task_name` +- `task_status` +- `doc_type` +- `task_type` +- `task_created_at` +- `progress` +- `total_documents` +- `evaluation_region` + +### 进度定义 + +`progress = 已完成任务文档数 / 任务文档总数 * 100` + +这里的“已完成”只看: + +- `leaudit_cross_review_task_documents.audit_status` + +不看: + +- `leaudit_documents.audit_status` +- `leaudit_audit_runs.status` +- `leaudit_rule_results` + +## 5.3 任务状态 + +任务状态建议保留三态: + +- `pending` +- `in_progress` +- `completed` + +但如果当前产品没有明确“待启动”态,实际上可以只使用: + +- `in_progress` +- `completed` + +### 状态流转规则 + +- 创建任务 -> `in_progress` +- 所有任务内文档完成 -> `completed` +- 只要还有未完成文档 -> 保持 `in_progress` + +### 禁止的隐式流转 + +不要因为: + +- 提案全部投完 +- 文档所有规则点都看过 +- 有人上传了附件 + +就自动把任务置完成。 + +任务完成只能由“所有任务内文档被确认完成”推导出来。 + +## 6. 任务文档业务逻辑定稿 + +## 6.1 文档进入任务 + +文档进入任务只有两种方式: + +- 创建任务时挂入 +- 向已有任务追加文档 + +### 文档进入任务后默认状态 + +- `audit_status = 0` + +即: + +- 新纳入任务的文档,默认都还未完成交叉评查 + +## 6.2 文档完成的真正含义 + +任务内文档“完成”表示: + +- 负责人或创建者确认:该文档在这个任务内的交叉评查工作结束 + +它不表示: + +- 文档机器评查已完成 +- 所有人都看过 +- 所有提案都被通过 +- 文档在全局业务上彻底完结 + +## 6.3 谁可以确认文档完成 + +只有: + +- 任务创建者 `assigner` +- 主要负责人 `principal` + +普通 `participant` 不可确认完成。 + +## 6.4 确认完成前的业务建议 + +建议在后端允许确认前做提醒型校验,而不是强阻断: + +- 文档下是否还有 `pending` 提案 +- 是否存在未投票成员 + +推荐策略: + +- 默认允许负责人确认完成 +- 如果仍有 `pending` 提案,返回告警信息给前端二次确认 + +这样更贴近真实业务,不会因为个别滞后投票把流程卡死。 + +## 7. 提案业务逻辑定稿 + +## 7.1 提案的最小绑定单位 + +提案必须绑定到: + +- 一个任务 +- 一个文档 +- 一个规则结果 `rule_result_id` + +不要只绑定: + +- 文档 + 评查点名称 +- 文档 + 规则名 + +因为这些都不够稳定。 + +## 7.2 谁可以提案 + +只有该任务成员可以提案。 + +### 必须满足 + +1. 用户是任务成员 +2. 文档属于该任务 +3. `rule_result_id` 属于该文档 +4. 当前提案目标不是已删除/无效结果 + +## 7.3 提案唯一性规则 + +建议规则: + +- 同一用户对同一任务下同一文档的同一 `rule_result_id` +- 同时只能存在一条“有效提案” + +有效提案指: + +- `pending` +- `approved` +- `rejected` + +如果需要允许历史重提,建议前提是: + +- 原提案已 `cancelled` +- 或被软删 + +## 7.4 提案分值规则 + +### 必须禁止 + +- `0` 分提案 + +### 必须校验 + +1. 当前分值已为 `0` 时,不允许继续扣分 +2. 当前分值已达该规则满分时,不允许继续加分 +3. 提案增量应用后,理论结果不能越界 + +边界是: + +- 最低 `0` +- 最高该规则满分 + +## 7.5 提案创建后的自动动作 + +提案创建成功后: + +1. 自动写入一条提案人 `agree` 投票 +2. 立刻触发提案状态判定 + +## 8. 投票业务逻辑定稿 + +## 8.1 谁可以投票 + +只有该任务成员可以投票。 + +### 额外限制 + +- 非成员不能投 +- 已删除提案不能投 +- 已结束提案不能投 + +## 8.2 提案人能否投票 + +建议保留老逻辑: + +- 提案创建后自动算提案人一票同意 + +这意味着: + +- 提案人不需要再手动投一次 +- 后续若要改票,只能通过显式撤销/重投逻辑支持 + +## 8.3 投票值 + +推荐只保留: + +- `agree` +- `disagree` + +“取消投票”不作为状态值保存,而是: + +- 将该投票软删 + +## 8.4 一人一票 + +同一提案、同一成员: + +- 同一时刻只能存在一条有效投票 + +如重复投票: + +- 覆盖旧投票类型 + +## 9. 提案状态机定稿 + +提案状态建议固定为: + +- `pending` +- `approved` +- `rejected` +- `cancelled` + +## 9.1 创建后的初始状态 + +- `pending` + +## 9.2 通过规则 + +设任务有效成员数为 `n` + +通过阈值: + +- `threshold = floor(n / 2) + 1` + +若: + +- `agree >= threshold` + +则: + +- `proposal.status = approved` + +## 9.3 否决规则 + +若: + +- `disagree >= threshold` + +则: + +- `proposal.status = rejected` + +## 9.4 提前否决规则 + +若: + +- 即使所有剩余未投票者都改投同意,`agree` 也达不到 `threshold` + +则: + +- `proposal.status = rejected` + +## 9.5 撤销规则 + +只有提案人可撤销,且只允许撤销: + +- `pending` + +撤销后: + +- `status = cancelled` +- 同时软删或停用关联投票 + +## 9.6 提案终态不可再操作 + +对于: + +- `approved` +- `rejected` +- `cancelled` + +不允许再继续投票或修改状态。 + +## 10. 分数语义定稿 + +这是实现中最容易“做着做着又回到老系统”的地方,必须说清楚。 + +## 10.1 三层分数 + +新项目详情页涉及三层分数: + +### 机器分 + +来自: + +- `leaudit_rule_results` + +含义: + +- 规则执行后机器给出的原始结果分值 + +### 人工审核分 + +来自: + +- `leaudit_review_point_audits` + +含义: + +- 人工对规则结果的覆盖性判断 + +### 交叉评查调整分 + +来自: + +- 所有已通过的 `leaudit_cross_review_proposals` + +含义: + +- 协作层加减分累计值 + +## 10.2 推荐最终展示分数计算 + +建议按以下顺序: + +1. 先计算基础分 `baseScore` +2. 再叠加交叉评查增量 `crossDelta` +3. 再做边界裁剪 + +### `baseScore` 计算建议 + +若存在人工审核覆盖: + +- 按人工审核后的通过/不通过结果计算基础分 + +否则: + +- 按机器结果计算基础分 + +### `crossDelta` + +取该 `rule_result_id` 下所有: + +- `status = approved` +- `deleted_at is null` + +提案的 `proposed_score_delta` 累加值 + +### `finalScore` + +`finalScore = clamp(baseScore + crossDelta, 0, ruleMaxScore)` + +## 10.3 绝对禁止 + +不要: + +- 直接更新 `leaudit_rule_results.score` +- 直接把聚合后的最终分落回机器结果表 + +## 11. 详情页聚合逻辑定稿 + +当前详情页主入口已经是: + +- `DocumentServiceImpl.GetReviewPoints()` + +这条主链路可以保留。 + +## 11.1 详情页返回内容应包含 + +- 规则点列表 +- 单点机器分 +- 单点人工审核覆盖结果 +- 单点最终展示分 +- 交叉评查提案列表 +- 文档级统计信息 + +## 11.2 提案列表读取规则 + +提案列表优先从: + +- `leaudit_cross_review_proposals` + +读取。 + +如果需要历史兼容,可短期允许: + +- 新表优先 +- 旧 `cross_scoring_proposals` 兜底 + +但这必须是过渡方案,不是长期方案。 + +## 11.3 文档总分统计 + +文档总分建议由每个规则点的最终展示分累加得到。 + +即: + +- 总分不是单独存储字段 +- 而是聚合计算结果 + +## 12. 版本归纳逻辑定稿 + +## 12.1 主规则 + +任务文档列表必须优先基于: + +- `versionGroupKey` + +归组。 + +### 当前版本判定 + +优先使用: + +- `isLatestVersion = true` + +### 历史版本排序 + +按: + +- `versionNo desc` + +或时间倒序 + +## 12.2 兜底规则 + +只有在极端兼容场景下,才允许用: + +- `normalizedName + typeId` + +做辅助兜底。 + +## 12.3 追加附件后的语义 + +追加附件如果产生新文档,则该新文档: + +- 应进入同一版本链 +- 自动挂到当前任务 +- 默认 `audit_status = 0` + +原因: + +- 新版本进入任务后,需要重新做交叉评查确认 + +## 13. 权限逻辑定稿 + +## 13.1 权限判断分两层 + +### 第一层:RBAC 权限点 + +例如: + +- `cross_review:task:read` +- `cross_review:task:create` +- `cross_review:proposal:create` +- `cross_review:proposal:vote` +- `cross_review:document:complete` + +### 第二层:业务归属校验 + +例如: + +- 是否是该任务成员 +- 是否是负责人 +- 文档是否属于该任务 +- 规则结果是否属于该文档 + +两层都通过,才允许操作。 + +## 13.2 读权限 + +用户可读某任务,当且仅当: + +- 是 `assigner` +- 或是任务成员 + +## 13.3 写权限 + +### 创建提案 + +- 任务成员即可 + +### 投票 + +- 任务成员即可 + +### 撤销提案 + +- 仅提案人 + +### 确认文档完成 + +- 仅 `assigner` 或 `principal` + +### 上传任务文档 / 追加附件 + +- 建议仅 `assigner` 或 `principal` + +## 14. 实施时的接口行为定稿 + +## 14.1 后端必须兜住这些错误 + +- 文档不存在 +- 文档不属于任务 +- 规则结果不属于文档 +- 用户不是任务成员 +- 用户不是负责人 +- 提案重复 +- 提案已结束 +- 分值越界 + +## 14.2 后端返回建议 + +每个关键接口尽量返回: + +- `success` +- `message` +- 必要结果对象 +- 必要告警信息 + +例如确认完成时可以带: + +- `has_pending_proposals` +- `pending_proposal_count` +- `has_pending_votes` + +## 15. 推荐开发顺序 + +为了避免做到一半逻辑漂移,建议严格按以下顺序: + +### 第一步 + +先落表结构和状态定义。 + +### 第二步 + +实现 service 层纯业务逻辑: + +- 任务 +- 提案 +- 投票 +- 完成确认 + +### 第三步 + +实现 controller 接口。 + +### 第四步 + +把详情页聚合接到新提案表。 + +### 第五步 + +再做上传、附件、版本链整合。 + +## 16. 最终一句话定稿 + +新项目交叉评查的正确实现方式应该是: + +- 用 `leaudit_*` 作为文档与评查结果底座 +- 用 `leaudit_cross_review_*` 承接协作层 +- 用聚合计算得到最终展示分 +- 用任务内状态管理完成进度 +- 用后端统一收口权限和状态流转 + +只要这五点不偏,整体实现就不会跑歪。 + diff --git a/docs/交叉评查/老项目交叉评查逻辑梳理.md b/docs/交叉评查/老项目交叉评查逻辑梳理.md new file mode 100644 index 0000000..7c16bee --- /dev/null +++ b/docs/交叉评查/老项目交叉评查逻辑梳理.md @@ -0,0 +1,568 @@ +# 老项目交叉评查逻辑梳理 + +> 目标:整理老项目 `docauditai` 中“交叉评查”模块的真实业务链路、数据模型、权限边界和当前新项目承接情况,便于后续迁移、重构和对照实现。 + +## 1. 项目路径与主要参考文件 + +### 1.1 老项目路径 + +- 老项目根目录:`/home/wren-dev/Porject/docauditai` + +### 1.2 当前新项目前端路径 + +- 新前端目录:`/home/wren-dev/Porject/leaudit-platform/new_doc_review` + +### 1.3 本次整理的核心参考文件 + +- 老逻辑总说明:`/home/wren-dev/Porject/docauditai/dcos/cross_review_business_logic.md` +- 老表关系图:`/home/wren-dev/Porject/docauditai/dcos/cross_review_table_relationships.md` +- 老路由主文件:`/home/wren-dev/Porject/docauditai/app/routes/v2/crossreview/cross_review.py` +- 老服务主文件:`/home/wren-dev/Porject/docauditai/services/documents/v2/cross_review_service.py` +- 历史接口文档:`new_doc_review/auth_doc/交叉评查接口文档.md` + +## 2. 一句话定义 + +交叉评查不是重新跑 OCR、抽取、规则评查,而是围绕已经存在的评查结果做“多人协同复核”。 + +它的核心目标是: + +- 把一批文档挂到一个交叉评查任务里 +- 让任务参与人围绕具体评查点发起加分/减分提案 +- 让参与人对提案投票 +- 提案通过后,把分数回写到原始评查结果 +- 由负责人手动确认文档评查完成,并最终推动任务完成 + +换句话说,这是一条“评后协作复核”流程,不是底层文档处理流水线。 + +## 3. 核心数据模型 + +交叉评查主要依赖以下几张表: + +### 3.1 `cross_examination_tasks` + +交叉评查任务主表。 + +核心字段: + +- `assigner_id`:任务创建者 +- `user_ids`:参与评查的用户列表 +- `principal_user_ids`:主要负责人列表 +- `task_status`:任务状态,常见值有 `in_progress`、`completed` +- `task_name`:任务名称 +- `doc_type`:任务绑定的文档类型编码 +- `task_type`:任务类型,如 `CITY`、`DISTRICT` + +业务含义: + +- 一个任务绑定一组参与人和一批文档 +- 创建者和主要负责人拥有更高权限 + +### 3.2 `cross_task_document_mapping` + +交叉评查任务与文档的映射表。 + +核心字段: + +- `task_id` +- `document_id` +- `audit_status` +- `deleted_at` + +业务含义: + +- 一条记录表示某文档被纳入某个交叉评查任务 +- `audit_status=1` 表示该文档在该任务内已确认完成 +- 这里的 `audit_status` 是任务内状态,不等于 `documents.audit_status` + +### 3.3 `cross_scoring_proposals` + +交叉评分提案表。 + +核心字段: + +- `document_id` +- `evaluation_point_id` +- `evaluation_result_id` +- `proposer_id` +- `proposed_score` +- `reason` +- `status` +- `deleted_at` + +业务含义: + +- 针对某个评查结果提出加分或减分建议 +- 常见状态:`pending`、`approved`、`rejected` + +### 3.4 `cross_opinion_votes` + +交叉评查投票表。 + +核心字段: + +- `proposal_id` +- `voter_id` +- `vote_type` +- `deleted_at` + +业务含义: + +- 参与者对提案表态 +- 支持 `agree`、`disagree`、`cancel` +- 提案创建后,系统会自动给提案人补一票 `agree` + +### 3.5 `evaluation_results` + +旧评查结果表,是交叉评查当前的评分底座。 + +主要作用: + +- 读取当前机器分 / 最终分 / 评查结果消息 +- 在提案通过后更新 `final_score` + +### 3.6 `evaluation_points` + +评查点定义表。 + +交叉评查依赖它读取: + +- 单点评查满分 `score` +- 风险级别 +- `fail_message` +- `post_action` +- `suggestion_message_type` + +## 4. 主业务链路 + +## 4.1 创建任务 + +核心服务: + +- `assign_cross_examination_tasks()` +- 文件位置:`/home/wren-dev/Porject/docauditai/services/documents/v2/cross_review_service.py` + +处理逻辑: + +1. 把 `assigner_id` 强制加入 `user_ids` 并去重 +2. 创建 `cross_examination_tasks` +3. 为每个文档写入 `cross_task_document_mapping` +4. 初始状态设置为 `task_status = in_progress` + +补充说明: + +- 在老项目里,任务创建很多时候不是单独走一个“先建任务”的管理入口 +- 更常见的是在上传文档后直接触发任务分配 +- 相关入口可见:`/home/wren-dev/Porject/docauditai/app/routes/v2/documents/documents.py` + +## 4.2 任务列表 + +接口: + +- `POST /tasks/user_tasks` + +实现位置: + +- 路由:`/home/wren-dev/Porject/docauditai/app/routes/v2/crossreview/cross_review.py` + +逻辑特点: + +- 按 `current_user.id = ANY(user_ids)` 查询用户参与的任务 +- 进度不是看 `documents.audit_status` +- 而是看 `cross_task_document_mapping.audit_status` + +返回内容通常包括: + +- 任务 ID +- 任务名称 +- 任务状态 +- 文档类型 +- 任务类型 +- 创建时间 +- 进度百分比 +- 总文档数 +- 参与地区 + +## 4.3 任务下文档列表 + +老项目实际上有两套接口思路。 + +### 旧列表接口 + +- `POST /tasks/{task_id}/documents` + +特点: + +- 返回任务下文档的分页列表 +- 聚合每个文档的评查统计、问题摘要、得分等信息 + +### 新版版本归纳接口 + +- `GET /tasks/{task_id}/documents` + +对应服务: + +- `get_task_documents_with_versions()` +- 文件:`/home/wren-dev/Porject/docauditai/services/documents/v2/cross_review_service.py` + +版本归纳规则: + +- 同一任务内 `(name, type_id)` 相同的文档归为一组 +- 组内按 `created_at` 降序排列 +- 最新上传的是当前版本 +- 其他的是历史版本 + +统计信息来源: + +- 文档实体:`documents` +- 评查统计:`evaluation_results + evaluation_points` +- 得分规则:优先 `final_score`,否则按机器结果/通过结果推导 + +## 4.4 发起评分提案 + +接口: + +- `POST /proposals` + +路由位置: + +- `/home/wren-dev/Porject/docauditai/app/routes/v2/crossreview/cross_review.py` + +服务位置: + +- `create_scoring_proposal()` +- `/home/wren-dev/Porject/docauditai/services/documents/v2/cross_review_service.py` + +核心校验规则: + +1. 用户必须有文档访问权限 +2. `evaluation_result_id` 必填 +3. `evaluation_result_id` 必须真实属于当前 `document_id` +4. 文档必须已纳入某个交叉评查任务 +5. 提案人必须是该任务参与者 +6. 同一用户不能对同一文档、同一评查点重复创建未删除提案 +7. 不允许创建 0 分提案 +8. 当前分数已经为 0 时,不允许继续扣分 +9. 当前分数已经达到该评查点满分时,不允许继续加分 + +创建成功后: + +1. 插入 `cross_scoring_proposals` +2. 自动给提案人插入一条 `agree` 投票 +3. 立即触发提案状态检查 + +## 4.5 投票 + +接口: + +- `POST /proposals/{proposal_id}/votes` + +服务: + +- `create_opinion_vote()` + +支持动作: + +- `agree` +- `disagree` +- `cancel` + +主要规则: + +- 只有任务参与者能投票 +- 已经 `approved` 或 `rejected` 的提案不允许再投/撤销 +- `cancel` 本质是把投票软删除 + +## 4.6 提案状态判定 + +内部方法: + +- `_check_and_process_proposal_status()` + +判定规则: + +1. 找到提案关联文档 +2. 取该文档所在的最新任务 +3. 获取任务参与人数 `n` +4. 统计同意票 `a` 与反对票 `d` +5. 通过阈值为 `floor(n / 2) + 1` + +状态流转: + +- `agree >= threshold` -> `approved` +- `disagree >= threshold` -> `rejected` +- 即使剩余未投票者全部同意也无法达到阈值 -> `rejected` +- 否则保持 `pending` + +这说明老逻辑走的是“绝对多数制”,不是全票制。 + +## 4.7 提案通过后的分数回写 + +内部方法: + +- `_update_evaluation_result_score()` + +处理逻辑: + +1. 找到提案关联的 `evaluation_result_id` +2. 读取当前 `evaluation_results.final_score / machine_score` +3. 读取评查点满分 `evaluation_points.score` +4. 将 `proposed_score` 累加到最终分数 +5. 做边界保护: + - 最低不小于 0 + - 最高不超过评查点满分 + +结论: + +- 老交叉评查的“最终结果”是直接回写旧评查结果表 `evaluation_results.final_score` + +## 4.8 查看文档上的所有提案 + +接口: + +- `POST /proposals/document` + +服务: + +- `get_proposals_by_document()` + +典型返回字段: + +- 提案 ID +- 评查点名称 +- 建议分值 +- 理由 +- 提案人 +- 已投票列表 +- 同意人 +- 反对人 +- 待投票人 +- 当前用户是否可投票 +- 发现问题文案 +- 提案状态 + +这是详情页“交叉意见区”的核心数据接口。 + +## 4.9 获取待处理提案列表 + +接口: + +- `POST /proposals/details` + +服务: + +- `get_proposals_with_details()` + +作用: + +- 查当前用户需要处理的待投票提案 + +过滤规则: + +- 只看 `pending` +- 排除自己发起的提案 +- 只看自己参与任务下的文档 + +## 4.10 检查是否存在未投票用户 + +接口: + +- `POST /proposals/document/check_pending_votes` + +服务: + +- `check_pending_votes_by_document()` + +权限要求: + +- 只有任务创建者或主要负责人可以调用 + +作用: + +- 在确认完成前检查该文档下是否还有提案未完成投票 + +## 4.11 确认文档评查完成 + +接口: + +- `POST /tasks/{task_id}/documents/{document_id}/complete` + +服务: + +- `complete_document_audit()` + +主要规则: + +- 只有 `assigner_id` 或 `principal_user_ids` 中的用户才有权限确认 +- 确认后会把 `cross_task_document_mapping.audit_status` 改为 `1` +- 如果任务下所有文档都完成,则把 `cross_examination_tasks.task_status` 改成 `completed` + +注意: + +- 文档是否完成是“任务内状态” +- 任务是否完成是所有任务内文档完成后的汇总状态 + +## 4.12 检查当前用户是否可确认完成 + +接口: + +- `GET /tasks/{task_id}/can-confirm` + +服务: + +- `check_confirm_permission()` + +作用: + +- 前端先调用此接口,再决定是否显示“确认完成”按钮 + +## 4.13 向任务补文档 / 补附件 + +相关接口: + +- `POST /tasks/{task_id}/documents/{document_id}/append_attachments` +- `POST /tasks/{task_id}/upload_documents` + +核心规则: + +- 只有任务创建者或主要负责人可以操作 +- 追加附件会创建新文档,而不是覆盖老文档 +- 新文档会重新写入 `cross_task_document_mapping` +- 新文档默认 `audit_status = 0` +- 同名同类型文档会自动并入版本链 + +## 5. 权限模型 + +交叉评查存在三层权限。 + +### 5.1 文档访问权限 + +发起提案和查看文档提案前,会先检查用户是否有权访问该文档。 + +### 5.2 任务参与权限 + +任务参与者来自 `cross_examination_tasks.user_ids`。 + +参与者可以: + +- 发起提案 +- 对提案投票 +- 查看自己参与任务下的任务、文档、提案和进度 + +### 5.3 高权限用户 + +高权限用户包括: + +- 任务创建者 `assigner_id` +- 主要负责人 `principal_user_ids` + +高权限用户可以: + +- 确认文档评查完成 +- 检查文档下是否仍有待投票人员 +- 为任务文档追加附件 +- 向任务继续上传新文档 + +## 6. 交叉评查与 PostgREST 权限扩展 + +老项目还有一个关键点:交叉评查不是只靠业务接口做权限控制,代理层也会扩展文档可见范围。 + +关键文件: + +- `/home/wren-dev/Porject/docauditai/app/routes/postgrest.py` + +它的作用是: + +- 获取用户参与的交叉评查任务 +- 提取这些任务关联的文档 ID +- 把这些文档加入用户可访问范围 + +这意味着: + +- 用户即使不在原始文档所属部门 +- 只要参与了交叉评查任务,也可能被允许跨部门访问这些任务文档 + +这部分是老系统交叉评查跨部门协同的重要权限补丁。 + +## 7. 几个必须记住的业务事实 + +- 交叉评查不是重新跑评查,而是消费旧 `evaluation_results` +- 提案通过后会直接回写 `evaluation_results.final_score` +- 文档完成状态落在 `cross_task_document_mapping.audit_status` +- 任务完成状态落在 `cross_examination_tasks.task_status` +- 文档完成不是投票自动完成,而是负责人手动确认 +- 同名同类型文档在任务内会被视为一条版本链 + +## 8. 当前新项目承接情况 + +当前新项目前端已经有完整交叉评查页面,但其业务底座仍然大量依赖老接口和老表思路。 + +### 8.1 新前端页面 + +- `new_doc_review/app/routes/cross-checking._index.tsx` +- `new_doc_review/app/routes/cross-checking.result.tsx` +- `new_doc_review/app/routes/cross-checking.upload.tsx` + +### 8.2 新前端接口封装 + +- `new_doc_review/app/api/cross-checking/cross-files.ts` +- `new_doc_review/app/api/cross-checking/cross-file-result.ts` +- `new_doc_review/app/api/cross-checking/cross-files-upload.ts` + +### 8.3 仍在使用的老接口口径 + +前端仍直接调用这些接口: + +- `/api/v2/cross_review/...` +- `/admin/v2/cross_review/...` +- `/admin/v2/documents/cross_review/documents/upload_and_assign` +- `/admin/cross_review/tasks/assign` + +### 8.4 当前观察结论 + +- 新前端页面壳子已经迁到 `new_doc_review` +- 但交叉评查业务底座仍大体延续老项目 `docauditai` 的接口和数据模型 +- 在 `fastapi_modules/fastapi_leaudit` 中,没有检索到一套完整的新交叉评查业务路由/服务闭环 +- 只看到少量兼容读取老表的痕迹,例如 `cross_scoring_proposals` + +## 9. 后续迁移时最难拆的三个耦合点 + +如果后续要把交叉评查彻底迁到新平台,最难拆的不是页面,而是以下 3 个耦合点: + +### 9.1 提案通过后回写旧评查结果 + +- `cross_scoring_proposals` -> `evaluation_results.final_score` + +### 9.2 任务内文档完成状态 + +- `cross_task_document_mapping.audit_status` 决定任务内文档是否完成 + +### 9.3 跨任务的文档访问权限扩展 + +- `app/routes/postgrest.py` 对交叉任务文档做了额外放行 + +## 10. 迁移判断 + +从当前代码和前端调用情况看,可以做出一个比较明确的判断: + +- 现在的交叉评查“前端承载是新的” +- 但“业务模型、接口语义、分数回写、权限扩展”仍然主要是老系统延续下来的 + +因此,后续如果要迁移: + +- 不能只迁页面 +- 也不能只补几个接口 +- 必须连同任务模型、提案模型、投票模型、评分回写模型、跨部门访问权限一起迁 + +## 11. 推荐继续查看的文件 + +如果后续要继续深挖,建议按这个顺序看: + +1. `/home/wren-dev/Porject/docauditai/dcos/cross_review_business_logic.md` +2. `/home/wren-dev/Porject/docauditai/services/documents/v2/cross_review_service.py` +3. `/home/wren-dev/Porject/docauditai/app/routes/v2/crossreview/cross_review.py` +4. `new_doc_review/app/api/cross-checking/cross-files.ts` +5. `new_doc_review/app/api/cross-checking/cross-file-result.ts` + +--- + +整理时间:`2026-05-07` diff --git a/fastapi_modules/fastapi_leaudit/controllers/crossReviewController.py b/fastapi_modules/fastapi_leaudit/controllers/crossReviewController.py new file mode 100644 index 0000000..7794eeb --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/controllers/crossReviewController.py @@ -0,0 +1,225 @@ +"""交叉评查控制器(第一阶段骨架)。""" + +from typing import Any + +from fastapi import Depends, File, Query, UploadFile +from fastapi.responses import JSONResponse + +from fastapi_common.fastapi_common_security.security import verify_access_token +from fastapi_common.fastapi_common_web.controller import BaseController +from fastapi_common.fastapi_common_web.domain.responses import Result + +from fastapi_modules.fastapi_leaudit.domian.Dto.crossReviewDto import ( + CrossReviewProposalCreateDTO, + CrossReviewProposalVoteDTO, + CrossReviewTaskCreateDTO, + CrossReviewTaskDocumentQueryDTO, + CrossReviewTaskQueryDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import ( + CrossReviewPendingVotesVO, + CrossReviewPermissionVO, + CrossReviewProposalCancelVO, + CrossReviewProposalCreateVO, + CrossReviewProposalPageVO, + CrossReviewProposalVoteVO, + CrossReviewTaskCompleteVO, + CrossReviewTaskCreateVO, + CrossReviewTaskDocumentPageVO, + CrossReviewTaskDocumentUploadVO, + CrossReviewTaskPageVO, + CrossReviewTaskProgressVO, +) +from fastapi_modules.fastapi_leaudit.services.crossReviewService import ICrossReviewService +from fastapi_modules.fastapi_leaudit.services.impl.crossReviewServiceImpl import CrossReviewServiceImpl +from fastapi_modules.fastapi_leaudit.services.impl.permissionServiceImpl import PermissionServiceImpl +from fastapi_modules.fastapi_leaudit.services.permissionService import IPermissionService + + +class CrossReviewController(BaseController): + """交叉评查控制器。""" + + _PERMISSIONS = { + "task_create": "cross_review:task:create", + "task_read": "cross_review:task:read", + "progress_view": "cross_review:progress:view", + "document_read": "cross_review:document:read", + "document_complete": "cross_review:document:complete", + "proposal_create": "cross_review:proposal:create", + "proposal_read": "cross_review:proposal:read", + "proposal_delete": "cross_review:proposal:delete", + "proposal_vote": "cross_review:proposal:vote", + } + + def __init__(self): + super().__init__(prefix="/v3/cross-review", tags=["交叉评查"]) + self.CrossReviewService: ICrossReviewService = CrossReviewServiceImpl() + self.PermissionService: IPermissionService = PermissionServiceImpl() + + @self.router.post("/tasks", response_model=Result[CrossReviewTaskCreateVO]) + async def CreateTask( + Body: CrossReviewTaskCreateDTO, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """创建交叉评查任务。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["task_create"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建交叉评查任务权限", "data": None}) + Data = await self.CrossReviewService.CreateTask(CurrentUserId=int(payload["user_id"]), Body=Body) + return Result.success(data=Data, message="交叉评查任务创建成功") + + @self.router.post("/tasks/query", response_model=Result[CrossReviewTaskPageVO]) + async def GetUserTasks( + Body: CrossReviewTaskQueryDTO, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """查询当前用户参与的交叉评查任务。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["task_read"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看交叉评查任务权限", "data": None}) + Data = await self.CrossReviewService.GetUserTasks(CurrentUserId=int(payload["user_id"]), Body=Body) + return Result.success(data=Data) + + @self.router.get("/tasks/{TaskId}/progress", response_model=Result[CrossReviewTaskProgressVO]) + async def GetTaskProgress( + TaskId: int, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """查询任务进度。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["progress_view"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看交叉评查任务进度权限", "data": None}) + Data = await self.CrossReviewService.GetTaskProgress(CurrentUserId=int(payload["user_id"]), TaskId=TaskId) + return Result.success(data=Data) + + @self.router.get("/tasks/{TaskId}/documents", response_model=Result[CrossReviewTaskDocumentPageVO]) + async def GetTaskDocuments( + TaskId: int, + page: int = Query(1, ge=1, description="页码"), + pageSize: int = Query(20, ge=1, le=100, description="每页大小"), + keyword: str | None = Query(None, description="关键字"), + payload: dict[str, Any] = Depends(verify_access_token), + ): + """查询任务文档列表。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["task_read"], self._PERMISSIONS["document_read"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看交叉评查任务文档权限", "data": None}) + Body = CrossReviewTaskDocumentQueryDTO(page=page, pageSize=pageSize, keyword=keyword) + Data = await self.CrossReviewService.GetTaskDocuments( + CurrentUserId=int(payload["user_id"]), + TaskId=TaskId, + Body=Body, + ) + return Result.success(data=Data) + + @self.router.get("/tasks/{TaskId}/can-confirm", response_model=Result[CrossReviewPermissionVO]) + async def CanConfirmTaskDocument( + TaskId: int, + 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}) + Data = await self.CrossReviewService.CanConfirmTaskDocument(CurrentUserId=int(payload["user_id"]), TaskId=TaskId) + return Result.success(data=Data) + + @self.router.post("/tasks/{TaskId}/documents/{DocumentId}/complete", response_model=Result[CrossReviewTaskCompleteVO]) + async def CompleteTaskDocument( + TaskId: int, + DocumentId: int, + 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}) + Data = await self.CrossReviewService.CompleteTaskDocument( + CurrentUserId=int(payload["user_id"]), + TaskId=TaskId, + DocumentId=DocumentId, + ) + return Result.success(data=Data, message="交叉评查文档已确认完成") + + @self.router.post("/tasks/{TaskId}/documents/upload", response_model=Result[CrossReviewTaskDocumentUploadVO]) + async def UploadTaskDocument( + TaskId: int, + file: UploadFile = File(..., 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}) + content = await file.read() + Data = await self.CrossReviewService.UploadTaskDocument( + CurrentUserId=int(payload["user_id"]), + TaskId=TaskId, + FileName=file.filename or "upload.bin", + FileContent=content, + ContentType=file.content_type, + ) + return Result.success(data=Data, message="交叉评查任务文档上传成功") + + @self.router.post("/proposals", response_model=Result[CrossReviewProposalCreateVO]) + async def CreateProposal( + Body: CrossReviewProposalCreateDTO, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """创建交叉评查提案。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["proposal_create"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有创建交叉评查提案权限", "data": None}) + Data = await self.CrossReviewService.CreateProposal(CurrentUserId=int(payload["user_id"]), Body=Body) + return Result.success(data=Data, message="交叉评查提案创建成功") + + @self.router.post("/proposals/{ProposalId}/votes", response_model=Result[CrossReviewProposalVoteVO]) + async def VoteProposal( + ProposalId: int, + Body: CrossReviewProposalVoteDTO, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """对交叉评查提案投票。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["proposal_vote"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有交叉评查提案投票权限", "data": None}) + Data = await self.CrossReviewService.VoteProposal(CurrentUserId=int(payload["user_id"]), ProposalId=ProposalId, Body=Body) + return Result.success(data=Data, message="交叉评查提案投票成功") + + @self.router.delete("/proposals/{ProposalId}", response_model=Result[CrossReviewProposalCancelVO]) + async def CancelProposal( + ProposalId: int, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """撤销交叉评查提案。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["proposal_delete"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有撤销交叉评查提案权限", "data": None}) + Data = await self.CrossReviewService.CancelProposal(CurrentUserId=int(payload["user_id"]), ProposalId=ProposalId) + return Result.success(data=Data, message="交叉评查提案已撤销") + + @self.router.get("/documents/{DocumentId}/proposals", response_model=Result[CrossReviewProposalPageVO]) + async def GetDocumentProposals( + DocumentId: int, + page: int = Query(1, ge=1, description="页码"), + pageSize: int = Query(20, ge=1, le=100, description="每页大小"), + payload: dict[str, Any] = Depends(verify_access_token), + ): + """获取文档提案列表。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["proposal_read"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看交叉评查提案权限", "data": None}) + Data = await self.CrossReviewService.GetDocumentProposals( + CurrentUserId=int(payload["user_id"]), + DocumentId=DocumentId, + Page=page, + PageSize=pageSize, + ) + return Result.success(data=Data) + + @self.router.get("/documents/{DocumentId}/pending-votes", response_model=Result[CrossReviewPendingVotesVO]) + async def GetDocumentPendingVotes( + DocumentId: int, + payload: dict[str, Any] = Depends(verify_access_token), + ): + """获取文档待投票摘要。""" + if not await self._check_permission(int(payload["user_id"]), [self._PERMISSIONS["proposal_read"], self._PERMISSIONS["document_complete"]]): + return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看待投票信息权限", "data": None}) + Data = await self.CrossReviewService.GetDocumentPendingVotes(CurrentUserId=int(payload["user_id"]), DocumentId=DocumentId) + return Result.success(data=Data) + + async def _check_permission(self, user_id: int, permission_keys: list[str]) -> bool: + """OR 逻辑权限校验。""" + for permission_key in permission_keys: + if await self.PermissionService.CheckPermission(user_id, permission_key): + return True + return False diff --git a/fastapi_modules/fastapi_leaudit/domian/Dto/crossReviewDto.py b/fastapi_modules/fastapi_leaudit/domian/Dto/crossReviewDto.py new file mode 100644 index 0000000..5ff1766 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/Dto/crossReviewDto.py @@ -0,0 +1,52 @@ +"""交叉评查 DTO。""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class CrossReviewTaskCreateDTO(BaseModel): + """创建交叉评查任务。""" + + taskName: str = Field(..., min_length=1, description="任务名称") + taskType: str = Field("CITY", description="任务类型") + docTypeId: int | None = Field(None, description="文档类型ID") + docTypeCode: str | None = Field(None, description="文档类型编码") + memberUserIds: list[int] = Field(default_factory=list, description="参与成员用户ID") + principalUserIds: list[int] = Field(default_factory=list, description="负责人用户ID") + documentIds: list[int] = Field(default_factory=list, description="挂载文档ID") + + +class CrossReviewTaskQueryDTO(BaseModel): + """查询当前用户交叉评查任务。""" + + page: int = Field(1, ge=1, description="页码") + pageSize: int = Field(20, ge=1, le=100, description="每页大小") + keyword: str | None = Field(None, description="关键字") + status: str | None = Field(None, description="任务状态") + taskType: str | None = Field(None, description="任务类型") + docTypeCode: str | None = Field(None, description="文档类型编码") + + +class CrossReviewTaskDocumentQueryDTO(BaseModel): + """查询任务文档。""" + + page: int = Field(1, ge=1, description="页码") + pageSize: int = Field(20, ge=1, le=100, description="每页大小") + keyword: str | None = Field(None, description="关键字") + + +class CrossReviewProposalCreateDTO(BaseModel): + """创建交叉评查提案。""" + + reviewPointResultId: int = Field(..., description="规则结果ID") + documentId: int = Field(..., description="文档ID") + evaluationPointId: int | None = Field(None, description="评查点ID") + auditOpinion: str = Field(..., min_length=1, description="提案理由") + deductionScore: float = Field(..., description="分值调整量") + + +class CrossReviewProposalVoteDTO(BaseModel): + """提案投票。""" + + voteType: str = Field(..., description="agree/disagree/cancel") diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/crossReviewVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/crossReviewVo.py new file mode 100644 index 0000000..bba7997 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/domian/vo/crossReviewVo.py @@ -0,0 +1,171 @@ +"""交叉评查 VO。""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class CrossReviewTaskItemVO(BaseModel): + """任务列表项。""" + + taskId: int = Field(..., description="任务ID") + taskName: str = Field(..., description="任务名称") + taskType: str = Field(..., description="任务类型") + docTypeId: int | None = Field(None, description="文档类型ID") + docTypeCode: str | None = Field(None, description="文档类型编码") + status: str = Field(..., description="任务状态") + progress: float = Field(0, description="进度百分比") + totalDocuments: int = Field(0, description="文档总数") + completedDocuments: int = Field(0, description="已完成文档数") + createdAt: datetime | None = Field(None, description="创建时间") + + +class CrossReviewTaskPageVO(BaseModel): + """任务分页响应。""" + + total: int = Field(0, description="总数") + page: int = Field(1, description="页码") + pageSize: int = Field(20, description="每页大小") + items: list[CrossReviewTaskItemVO] = Field(default_factory=list, description="任务列表") + + +class CrossReviewTaskProgressVO(BaseModel): + """任务进度。""" + + taskId: int = Field(..., description="任务ID") + totalDocuments: int = Field(0, description="文档总数") + completedDocuments: int = Field(0, description="已完成文档数") + progress: float = Field(0, description="进度百分比") + + +class CrossReviewTaskDocumentVO(BaseModel): + """任务文档列表项。""" + + documentId: int = Field(..., description="文档ID") + name: str = Field("", description="文档名称") + documentNumber: str | None = Field(None, description="文号") + typeId: int | None = Field(None, description="文档类型ID") + processingStatus: str | None = Field(None, description="处理状态") + versionNo: int = Field(1, description="版本号") + isLatestVersion: bool = Field(True, description="是否最新版本") + auditStatus: int = Field(0, description="任务内完成状态") + createdAt: datetime | None = Field(None, description="创建时间") + + +class CrossReviewTaskDocumentPageVO(BaseModel): + """任务文档分页响应。""" + + taskId: int = Field(..., description="任务ID") + total: int = Field(0, description="总数") + page: int = Field(1, description="页码") + pageSize: int = Field(20, description="每页大小") + items: list[CrossReviewTaskDocumentVO] = Field(default_factory=list, description="文档列表") + + +class CrossReviewPermissionVO(BaseModel): + """是否有权确认完成。""" + + canConfirm: bool = Field(False, description="是否可以确认完成") + reason: str = Field("", description="原因") + + +class CrossReviewTaskCreateVO(BaseModel): + """创建任务结果。""" + + taskId: int = Field(..., description="任务ID") + taskName: str = Field(..., description="任务名称") + memberCount: int = Field(0, description="成员数") + documentCount: int = Field(0, description="挂载文档数") + + +class CrossReviewTaskCompleteVO(BaseModel): + """确认任务文档完成结果。""" + + taskId: int = Field(..., description="任务ID") + documentId: int = Field(..., description="文档ID") + auditStatus: int = Field(..., description="任务内完成状态") + taskStatus: str = Field(..., description="任务状态") + taskCompleted: bool = Field(False, description="任务是否已全部完成") + + +class CrossReviewProposalCreateVO(BaseModel): + """创建提案结果。""" + + proposalId: int = Field(..., description="提案ID") + createdAt: datetime | None = Field(None, description="创建时间") + + +class CrossReviewProposalVoteVO(BaseModel): + """提案投票结果。""" + + proposalId: int = Field(..., description="提案ID") + voterId: int = Field(..., description="投票人ID") + voteType: str = Field(..., description="投票类型") + proposalStatus: str = Field(..., description="提案状态") + + +class CrossReviewProposalCancelVO(BaseModel): + """撤销提案结果。""" + + proposalId: int = Field(..., description="提案ID") + status: str = Field(..., description="提案状态") + + +class CrossReviewProposalVoteItemVO(BaseModel): + """提案已投票明细。""" + + voter: str = Field("", description="投票人姓名") + voteType: str = Field(..., description="投票类型") + + +class CrossReviewProposalItemVO(BaseModel): + """提案列表项。""" + + proposalId: int = Field(..., description="提案ID") + evaluationPointName: str = Field("", description="评查点名称") + proposedScore: float = Field(..., description="调整分值") + reason: str = Field("", description="提案理由") + proposer: str = Field("", description="提案人姓名") + votes: list[CrossReviewProposalVoteItemVO] = Field(default_factory=list, description="已投票明细") + agreeVoters: list[str] = Field(default_factory=list, description="同意人") + disagreeVoters: list[str] = Field(default_factory=list, description="反对人") + pendingVoters: list[str] = Field(default_factory=list, description="待投票人") + canVote: bool = Field(False, description="当前用户是否可投票") + problemMessage: str = Field("", description="问题描述") + proposerId: int = Field(..., description="提案人ID") + createdAt: datetime | None = Field(None, description="创建时间") + status: str = Field("pending", description="提案状态") + + +class CrossReviewProposalPageVO(BaseModel): + """提案分页结果。""" + + total: int = Field(0, description="总数") + page: int = Field(1, description="页码") + pageSize: int = Field(20, description="每页数量") + items: list[CrossReviewProposalItemVO] = Field(default_factory=list, description="提案列表") + + +class CrossReviewPendingProposalVO(BaseModel): + """待投票提案摘要。""" + + evaluationPointName: str = Field("", description="评查点名称") + pendingVotersNum: int = Field(0, description="待投票人数") + + +class CrossReviewPendingVotesVO(BaseModel): + """文档待投票检查结果。""" + + hasPendingVotes: bool = Field(False, description="是否存在待投票") + pendingProposals: list[CrossReviewPendingProposalVO] = Field(default_factory=list, description="待投票提案摘要") + + +class CrossReviewTaskDocumentUploadVO(BaseModel): + """交叉评查任务补传文档结果。""" + + taskId: int = Field(..., description="任务ID") + documentId: int = Field(..., description="文档ID") + auditStatus: int = Field(0, description="任务内评查状态") + processingStatus: str | None = Field(None, description="文档处理状态") diff --git a/fastapi_modules/fastapi_leaudit/models/__init__.py b/fastapi_modules/fastapi_leaudit/models/__init__.py index 2f955d6..3f52069 100644 --- a/fastapi_modules/fastapi_leaudit/models/__init__.py +++ b/fastapi_modules/fastapi_leaudit/models/__init__.py @@ -3,9 +3,19 @@ from fastapi_modules.fastapi_leaudit.models.leauditDocument import LeauditDocument from fastapi_modules.fastapi_leaudit.models.leauditDocumentFile import LeauditDocumentFile from fastapi_modules.fastapi_leaudit.models.leauditAuditRun import LeauditAuditRun +from fastapi_modules.fastapi_leaudit.models.leauditCrossReviewProposal import LeauditCrossReviewProposal +from fastapi_modules.fastapi_leaudit.models.leauditCrossReviewTask import LeauditCrossReviewTask +from fastapi_modules.fastapi_leaudit.models.leauditCrossReviewTaskDocument import LeauditCrossReviewTaskDocument +from fastapi_modules.fastapi_leaudit.models.leauditCrossReviewTaskMember import LeauditCrossReviewTaskMember +from fastapi_modules.fastapi_leaudit.models.leauditCrossReviewVote import LeauditCrossReviewVote __all__ = [ "LeauditDocument", "LeauditDocumentFile", "LeauditAuditRun", + "LeauditCrossReviewTask", + "LeauditCrossReviewTaskMember", + "LeauditCrossReviewTaskDocument", + "LeauditCrossReviewProposal", + "LeauditCrossReviewVote", ] diff --git a/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewProposal.py b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewProposal.py new file mode 100644 index 0000000..24454ea --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewProposal.py @@ -0,0 +1,23 @@ +"""LeAudit 交叉评查提案模型。""" + +from __future__ import annotations + +from sqlalchemy import BigInteger, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from fastapi_common.fastapi_common_web.models import BaseModel + + +class LeauditCrossReviewProposal(BaseModel): + """交叉评查提案表。""" + + __tablename__ = "leaudit_cross_review_proposals" + + Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True) + taskId: Mapped[int] = mapped_column("task_id", BigInteger, comment="任务ID") + documentId: Mapped[int] = mapped_column("document_id", BigInteger, comment="文档ID") + ruleResultId: Mapped[int] = mapped_column("rule_result_id", BigInteger, comment="结果项ID") + proposerId: Mapped[int] = mapped_column("proposer_id", BigInteger, comment="提案人ID") + proposedScoreDelta: Mapped[float] = mapped_column("proposed_score_delta", Numeric(10, 2), comment="分数变化量") + reason: Mapped[str] = mapped_column(Text, comment="提案理由") + status: Mapped[str] = mapped_column(String(32), default="pending", comment="pending/approved/rejected") diff --git a/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTask.py b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTask.py new file mode 100644 index 0000000..c0d7e34 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTask.py @@ -0,0 +1,22 @@ +"""LeAudit 交叉评查任务模型。""" + +from __future__ import annotations + +from sqlalchemy import BigInteger, String +from sqlalchemy.orm import Mapped, mapped_column + +from fastapi_common.fastapi_common_web.models import BaseModel + + +class LeauditCrossReviewTask(BaseModel): + """交叉评查任务主表。""" + + __tablename__ = "leaudit_cross_review_tasks" + + Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True) + taskName: Mapped[str] = mapped_column("task_name", String(255), comment="任务名称") + taskType: Mapped[str] = mapped_column("task_type", String(32), comment="任务类型") + docTypeId: Mapped[int | None] = mapped_column("doc_type_id", BigInteger, comment="文档类型ID") + docTypeCode: Mapped[str | None] = mapped_column("doc_type_code", String(64), comment="文档类型编码") + assignerId: Mapped[int] = mapped_column("assigner_id", BigInteger, comment="创建者ID") + status: Mapped[str] = mapped_column("status", String(32), default="in_progress", comment="任务状态") diff --git a/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTaskDocument.py b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTaskDocument.py new file mode 100644 index 0000000..9af0c87 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTaskDocument.py @@ -0,0 +1,19 @@ +"""LeAudit 交叉评查任务文档模型。""" + +from __future__ import annotations + +from sqlalchemy import BigInteger, Integer +from sqlalchemy.orm import Mapped, mapped_column + +from fastapi_common.fastapi_common_web.models import BaseModel + + +class LeauditCrossReviewTaskDocument(BaseModel): + """交叉评查任务文档挂载表。""" + + __tablename__ = "leaudit_cross_review_task_documents" + + Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True) + taskId: Mapped[int] = mapped_column("task_id", BigInteger, comment="任务ID") + documentId: Mapped[int] = mapped_column("document_id", BigInteger, comment="文档ID") + auditStatus: Mapped[int] = mapped_column("audit_status", Integer, default=0, comment="0=未完成,1=已完成") diff --git a/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTaskMember.py b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTaskMember.py new file mode 100644 index 0000000..aca05b8 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewTaskMember.py @@ -0,0 +1,19 @@ +"""LeAudit 交叉评查任务成员模型。""" + +from __future__ import annotations + +from sqlalchemy import BigInteger, String +from sqlalchemy.orm import Mapped, mapped_column + +from fastapi_common.fastapi_common_web.models import BaseModel + + +class LeauditCrossReviewTaskMember(BaseModel): + """交叉评查任务成员表。""" + + __tablename__ = "leaudit_cross_review_task_members" + + Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True) + taskId: Mapped[int] = mapped_column("task_id", BigInteger, comment="任务ID") + userId: Mapped[int] = mapped_column("user_id", BigInteger, comment="用户ID") + memberRole: Mapped[str] = mapped_column("member_role", String(32), default="participant", comment="participant/principal") diff --git a/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewVote.py b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewVote.py new file mode 100644 index 0000000..157865b --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/models/leauditCrossReviewVote.py @@ -0,0 +1,19 @@ +"""LeAudit 交叉评查投票模型。""" + +from __future__ import annotations + +from sqlalchemy import BigInteger, String +from sqlalchemy.orm import Mapped, mapped_column + +from fastapi_common.fastapi_common_web.models import BaseModel + + +class LeauditCrossReviewVote(BaseModel): + """交叉评查投票表。""" + + __tablename__ = "leaudit_cross_review_votes" + + Id: Mapped[int] = mapped_column("id", BigInteger, primary_key=True, autoincrement=True) + proposalId: Mapped[int] = mapped_column("proposal_id", BigInteger, comment="提案ID") + voterId: Mapped[int] = mapped_column("voter_id", BigInteger, comment="投票用户ID") + voteType: Mapped[str] = mapped_column("vote_type", String(16), comment="agree/disagree/cancel") diff --git a/fastapi_modules/fastapi_leaudit/services/__init__.py b/fastapi_modules/fastapi_leaudit/services/__init__.py index 4d68279..d5b8ff0 100644 --- a/fastapi_modules/fastapi_leaudit/services/__init__.py +++ b/fastapi_modules/fastapi_leaudit/services/__init__.py @@ -1,6 +1,7 @@ """LeAudit 服务层导出。""" from fastapi_modules.fastapi_leaudit.services.auditService import IAuditService +from fastapi_modules.fastapi_leaudit.services.crossReviewService import ICrossReviewService from fastapi_modules.fastapi_leaudit.services.documentService import IDocumentService from fastapi_modules.fastapi_leaudit.services.evaluationPointService import IEvaluationPointService from fastapi_modules.fastapi_leaudit.services.entryModuleAdminService import IEntryModuleAdminService @@ -16,6 +17,7 @@ from fastapi_modules.fastapi_leaudit.services.ruleService import IRuleService __all__ = [ "IAuditService", + "ICrossReviewService", "IDocumentService", "IEvaluationPointService", "IEntryModuleAdminService", diff --git a/fastapi_modules/fastapi_leaudit/services/crossReviewService.py b/fastapi_modules/fastapi_leaudit/services/crossReviewService.py new file mode 100644 index 0000000..e6b806f --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/crossReviewService.py @@ -0,0 +1,112 @@ +"""交叉评查服务接口。""" + +from abc import ABC, abstractmethod + +from fastapi_modules.fastapi_leaudit.domian.Dto.crossReviewDto import ( + CrossReviewProposalCreateDTO, + CrossReviewProposalVoteDTO, + CrossReviewTaskCreateDTO, + CrossReviewTaskDocumentQueryDTO, + CrossReviewTaskQueryDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import ( + CrossReviewPendingVotesVO, + CrossReviewPermissionVO, + CrossReviewProposalCancelVO, + CrossReviewProposalCreateVO, + CrossReviewProposalPageVO, + CrossReviewProposalVoteVO, + CrossReviewTaskCompleteVO, + CrossReviewTaskCreateVO, + CrossReviewTaskDocumentPageVO, + CrossReviewTaskDocumentUploadVO, + CrossReviewTaskPageVO, + CrossReviewTaskProgressVO, +) + + +class ICrossReviewService(ABC): + """交叉评查服务接口。""" + + @abstractmethod + async def CreateTask(self, CurrentUserId: int, Body: CrossReviewTaskCreateDTO) -> CrossReviewTaskCreateVO: + """创建交叉评查任务。""" + ... + + @abstractmethod + async def GetUserTasks(self, CurrentUserId: int, Body: CrossReviewTaskQueryDTO) -> CrossReviewTaskPageVO: + """查询当前用户参与的交叉评查任务。""" + ... + + @abstractmethod + async def GetTaskProgress(self, CurrentUserId: int, TaskId: int) -> CrossReviewTaskProgressVO: + """查询任务进度。""" + ... + + @abstractmethod + async def GetTaskDocuments( + self, + CurrentUserId: int, + TaskId: int, + Body: CrossReviewTaskDocumentQueryDTO, + ) -> CrossReviewTaskDocumentPageVO: + """查询任务文档列表。""" + ... + + @abstractmethod + async def CanConfirmTaskDocument(self, CurrentUserId: int, TaskId: int) -> CrossReviewPermissionVO: + """判断当前用户是否有权确认任务文档完成。""" + ... + + @abstractmethod + async def CompleteTaskDocument(self, CurrentUserId: int, TaskId: int, DocumentId: int) -> CrossReviewTaskCompleteVO: + """确认任务文档完成。""" + ... + + @abstractmethod + async def CreateProposal(self, CurrentUserId: int, Body: CrossReviewProposalCreateDTO) -> CrossReviewProposalCreateVO: + """创建交叉评查提案。""" + ... + + @abstractmethod + async def VoteProposal( + self, + CurrentUserId: int, + ProposalId: int, + Body: CrossReviewProposalVoteDTO, + ) -> CrossReviewProposalVoteVO: + """对交叉评查提案投票。""" + ... + + @abstractmethod + async def CancelProposal(self, CurrentUserId: int, ProposalId: int) -> CrossReviewProposalCancelVO: + """撤销交叉评查提案。""" + ... + + @abstractmethod + async def GetDocumentProposals( + self, + CurrentUserId: int, + DocumentId: int, + Page: int, + PageSize: int, + ) -> CrossReviewProposalPageVO: + """获取文档提案列表。""" + ... + + @abstractmethod + async def GetDocumentPendingVotes(self, CurrentUserId: int, DocumentId: int) -> CrossReviewPendingVotesVO: + """获取文档待投票信息。""" + ... + + @abstractmethod + async def UploadTaskDocument( + self, + CurrentUserId: int, + TaskId: int, + FileName: str, + FileContent: bytes, + ContentType: str | None, + ) -> CrossReviewTaskDocumentUploadVO: + """向交叉评查任务补传文档。""" + ... diff --git a/fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py new file mode 100644 index 0000000..1d58ef5 --- /dev/null +++ b/fastapi_modules/fastapi_leaudit/services/impl/crossReviewServiceImpl.py @@ -0,0 +1,1214 @@ +"""交叉评查服务实现(第一阶段骨架)。""" + +from __future__ import annotations + +from math import floor + +from sqlalchemy import bindparam, text + +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException + +from fastapi_modules.fastapi_leaudit.domian.Dto.crossReviewDto import ( + CrossReviewProposalCreateDTO, + CrossReviewProposalVoteDTO, + CrossReviewTaskCreateDTO, + CrossReviewTaskDocumentQueryDTO, + CrossReviewTaskQueryDTO, +) +from fastapi_modules.fastapi_leaudit.domian.vo.crossReviewVo import ( + CrossReviewPendingProposalVO, + CrossReviewPendingVotesVO, + CrossReviewPermissionVO, + CrossReviewProposalCancelVO, + CrossReviewProposalCreateVO, + CrossReviewProposalItemVO, + CrossReviewProposalPageVO, + CrossReviewProposalVoteItemVO, + CrossReviewProposalVoteVO, + CrossReviewTaskCompleteVO, + CrossReviewTaskCreateVO, + CrossReviewTaskDocumentPageVO, + CrossReviewTaskDocumentUploadVO, + CrossReviewTaskDocumentVO, + CrossReviewTaskItemVO, + CrossReviewTaskPageVO, + CrossReviewTaskProgressVO, +) +from fastapi_modules.fastapi_leaudit.services.crossReviewService import ICrossReviewService +from fastapi_modules.fastapi_leaudit.services.documentService import IDocumentService +from fastapi_modules.fastapi_leaudit.services.impl.documentServiceImpl import DocumentServiceImpl + + +class CrossReviewServiceImpl(ICrossReviewService): + """交叉评查服务实现。""" + + def __init__(self): + self.DocumentService: IDocumentService = DocumentServiceImpl() + + async def CreateTask(self, CurrentUserId: int, Body: CrossReviewTaskCreateDTO) -> CrossReviewTaskCreateVO: + """创建交叉评查任务。""" + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + + memberUserIds = self._unique_int_list(Body.memberUserIds + [CurrentUserId]) + principalUserIds = self._unique_int_list(Body.principalUserIds) + documentIds = self._unique_int_list(Body.documentIds) + + async with session.begin(): + taskRow = ( + await session.execute( + text( + """ + INSERT INTO leaudit_cross_review_tasks + (task_name, task_type, doc_type_id, doc_type_code, assigner_id, status) + VALUES + (:task_name, :task_type, :doc_type_id, :doc_type_code, :assigner_id, 'in_progress') + RETURNING id, task_name + """ + ), + { + "task_name": Body.taskName.strip(), + "task_type": Body.taskType, + "doc_type_id": Body.docTypeId, + "doc_type_code": Body.docTypeCode, + "assigner_id": CurrentUserId, + }, + ) + ).mappings().first() + + if not taskRow: + raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "创建交叉评查任务失败") + + taskId = int(taskRow["id"]) + + for userId in memberUserIds: + await session.execute( + text( + """ + INSERT INTO leaudit_cross_review_task_members + (task_id, user_id, member_role) + VALUES + (:task_id, :user_id, :member_role) + """ + ), + { + "task_id": taskId, + "user_id": userId, + "member_role": "principal" if userId in principalUserIds else "participant", + }, + ) + + for documentId in documentIds: + 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": documentId, + }, + ) + + return CrossReviewTaskCreateVO( + taskId=taskId, + taskName=str(taskRow["task_name"]), + memberCount=len(memberUserIds), + documentCount=len(documentIds), + ) + + async def GetUserTasks(self, CurrentUserId: int, Body: CrossReviewTaskQueryDTO) -> CrossReviewTaskPageVO: + """查询当前用户参与的交叉评查任务。""" + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + offset = (Body.page - 1) * Body.pageSize + params: dict[str, object] = { + "current_user_id": CurrentUserId, + "limit": Body.pageSize, + "offset": offset, + } + whereClauses = [ + "tm.user_id = :current_user_id", + "tm.delete_time IS NULL", + "t.delete_time IS NULL", + ] + if Body.keyword: + whereClauses.append("t.task_name ILIKE :keyword") + params["keyword"] = f"%{Body.keyword.strip()}%" + if Body.status: + whereClauses.append("t.status = :status") + params["status"] = Body.status + if Body.taskType: + whereClauses.append("t.task_type = :task_type") + params["task_type"] = Body.taskType + if Body.docTypeCode: + whereClauses.append("t.doc_type_code = :doc_type_code") + params["doc_type_code"] = Body.docTypeCode + + whereSql = " AND ".join(whereClauses) + + total = int( + ( + await session.scalar( + text( + f""" + SELECT COUNT(DISTINCT t.id) + FROM leaudit_cross_review_tasks t + JOIN leaudit_cross_review_task_members tm + ON tm.task_id = t.id + WHERE {whereSql} + """ + ), + params, + ) + ) + or 0 + ) + + rows = ( + await session.execute( + text( + f""" + WITH doc_stats AS ( + SELECT + td.task_id, + COUNT(*)::int AS total_documents, + COUNT(*) FILTER (WHERE td.audit_status = 1)::int AS completed_documents + FROM leaudit_cross_review_task_documents td + WHERE td.delete_time IS NULL + GROUP BY td.task_id + ) + SELECT + t.id AS task_id, + t.task_name, + t.task_type, + t.doc_type_id, + t.doc_type_code, + t.status, + t.create_time, + COALESCE(ds.total_documents, 0) AS total_documents, + COALESCE(ds.completed_documents, 0) AS completed_documents + FROM leaudit_cross_review_tasks t + JOIN leaudit_cross_review_task_members tm + ON tm.task_id = t.id + LEFT JOIN doc_stats ds + ON ds.task_id = t.id + WHERE {whereSql} + GROUP BY + t.id, t.task_name, t.task_type, t.doc_type_id, t.doc_type_code, + t.status, t.create_time, ds.total_documents, ds.completed_documents + ORDER BY t.create_time DESC, t.id DESC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).mappings().all() + + items = [] + for row in rows: + totalDocuments = int(row["total_documents"] or 0) + completedDocuments = int(row["completed_documents"] or 0) + progress = round((completedDocuments / totalDocuments * 100) if totalDocuments > 0 else 0, 2) + items.append( + CrossReviewTaskItemVO( + taskId=int(row["task_id"]), + taskName=str(row["task_name"]), + taskType=str(row["task_type"]), + docTypeId=self._to_int(row.get("doc_type_id")), + docTypeCode=row.get("doc_type_code"), + status=str(row["status"]), + progress=progress, + totalDocuments=totalDocuments, + completedDocuments=completedDocuments, + createdAt=row.get("create_time"), + ) + ) + + return CrossReviewTaskPageVO(total=total, page=Body.page, pageSize=Body.pageSize, items=items) + + async def GetTaskProgress(self, CurrentUserId: int, TaskId: int) -> CrossReviewTaskProgressVO: + """查询任务进度。""" + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + await self._ensure_task_member(session, TaskId, CurrentUserId) + + row = ( + await session.execute( + text( + """ + SELECT + COUNT(*)::int AS total_documents, + COUNT(*) FILTER (WHERE audit_status = 1)::int AS completed_documents + FROM leaudit_cross_review_task_documents + WHERE task_id = :task_id + AND delete_time IS NULL + """ + ), + {"task_id": TaskId}, + ) + ).mappings().first() + + totalDocuments = int((row or {}).get("total_documents") or 0) + completedDocuments = int((row or {}).get("completed_documents") or 0) + progress = round((completedDocuments / totalDocuments * 100) if totalDocuments > 0 else 0, 2) + return CrossReviewTaskProgressVO( + taskId=TaskId, + totalDocuments=totalDocuments, + completedDocuments=completedDocuments, + progress=progress, + ) + + async def GetTaskDocuments( + self, + CurrentUserId: int, + TaskId: int, + Body: CrossReviewTaskDocumentQueryDTO, + ) -> CrossReviewTaskDocumentPageVO: + """查询任务文档列表。""" + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + await self._ensure_task_member(session, TaskId, CurrentUserId) + + params: dict[str, object] = { + "task_id": TaskId, + "limit": Body.pageSize, + "offset": (Body.page - 1) * Body.pageSize, + } + whereClauses = [ + "td.task_id = :task_id", + "td.delete_time IS NULL", + ] + if Body.keyword: + whereClauses.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) + + total = int( + ( + await session.scalar( + text( + f""" + SELECT COUNT(*) + FROM leaudit_cross_review_task_documents td + JOIN leaudit_documents d + ON d.id = td.document_id + WHERE {whereSql} + """ + ), + params, + ) + ) + or 0 + ) + + rows = ( + await session.execute( + text( + f""" + 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, + d.processing_status, + d.version_no, + d.is_latest_version, + d.created_at, + td.audit_status + FROM leaudit_cross_review_task_documents td + JOIN leaudit_documents d + ON d.id = td.document_id + WHERE {whereSql} + ORDER BY d.created_at DESC, d.id DESC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).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")), + processingStatus=row.get("processing_status"), + versionNo=int(row.get("version_no") or 1), + isLatestVersion=bool(row.get("is_latest_version")), + auditStatus=int(row.get("audit_status") or 0), + createdAt=row.get("created_at"), + ) + for row in rows + ] + return CrossReviewTaskDocumentPageVO( + taskId=TaskId, + total=total, + page=Body.page, + pageSize=Body.pageSize, + items=items, + ) + + async def CanConfirmTaskDocument(self, CurrentUserId: int, TaskId: int) -> CrossReviewPermissionVO: + """判断当前用户是否有权确认任务文档完成。""" + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + row = ( + await session.execute( + text( + """ + SELECT + t.assigner_id, + COALESCE( + MAX(CASE WHEN tm.user_id = :current_user_id AND tm.member_role = 'principal' THEN 1 ELSE 0 END), + 0 + ) AS is_principal + FROM leaudit_cross_review_tasks t + LEFT JOIN leaudit_cross_review_task_members tm + ON tm.task_id = t.id + AND tm.delete_time IS NULL + WHERE t.id = :task_id + AND t.delete_time IS NULL + GROUP BY t.assigner_id + """ + ), + {"task_id": TaskId, "current_user_id": CurrentUserId}, + ) + ).mappings().first() + + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "交叉评查任务不存在") + + if int(row["assigner_id"]) == CurrentUserId: + return CrossReviewPermissionVO(canConfirm=True, reason="您是任务创建者,可确认完成") + if int(row["is_principal"] or 0) == 1: + return CrossReviewPermissionVO(canConfirm=True, reason="您是任务负责人,可确认完成") + return CrossReviewPermissionVO(canConfirm=False, reason="您不是任务创建者或负责人") + + async def CompleteTaskDocument(self, CurrentUserId: int, TaskId: int, DocumentId: int) -> CrossReviewTaskCompleteVO: + """确认任务文档完成。""" + 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) + + async with session.begin(): + mapping = ( + await session.execute( + text( + """ + SELECT id, audit_status + 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": DocumentId}, + ) + ).mappings().first() + if not mapping: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "任务文档不存在") + + await session.execute( + text( + """ + UPDATE leaudit_cross_review_task_documents + SET audit_status = 1, + update_time = NOW() + WHERE id = :id + """ + ), + {"id": int(mapping["id"])}, + ) + + stats = ( + await session.execute( + text( + """ + SELECT + COUNT(*)::int AS total_documents, + COUNT(*) FILTER (WHERE audit_status = 1)::int AS completed_documents + FROM leaudit_cross_review_task_documents + WHERE task_id = :task_id + AND delete_time IS NULL + """ + ), + {"task_id": TaskId}, + ) + ).mappings().first() + + totalDocuments = int((stats or {}).get("total_documents") or 0) + completedDocuments = int((stats or {}).get("completed_documents") or 0) + taskCompleted = totalDocuments > 0 and totalDocuments == completedDocuments + taskStatus = "completed" if taskCompleted else "in_progress" + + await session.execute( + text( + """ + UPDATE leaudit_cross_review_tasks + SET status = :status, + update_time = NOW() + WHERE id = :task_id + AND delete_time IS NULL + """ + ), + {"task_id": TaskId, "status": taskStatus}, + ) + + return CrossReviewTaskCompleteVO( + taskId=TaskId, + documentId=DocumentId, + auditStatus=1, + taskStatus=taskStatus, + taskCompleted=taskCompleted, + ) + + async def CreateProposal(self, CurrentUserId: int, Body: CrossReviewProposalCreateDTO) -> CrossReviewProposalCreateVO: + """创建交叉评查提案。""" + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + if Body.deductionScore == 0: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "不允许创建 0 分提案") + + taskId = await self._resolve_task_id_for_document(session, Body.documentId, CurrentUserId) + await self._ensure_task_member(session, taskId, CurrentUserId) + await self._ensure_task_document(session, taskId, Body.documentId) + ruleResult = await self._load_rule_result(session, Body.reviewPointResultId, Body.documentId) + + duplicateExists = bool( + await session.scalar( + text( + """ + SELECT 1 + FROM leaudit_cross_review_proposals + WHERE task_id = :task_id + AND document_id = :document_id + AND rule_result_id = :rule_result_id + AND proposer_id = :proposer_id + AND status IN ('pending', 'approved') + AND delete_time IS NULL + LIMIT 1 + """ + ), + { + "task_id": taskId, + "document_id": Body.documentId, + "rule_result_id": Body.reviewPointResultId, + "proposer_id": CurrentUserId, + }, + ) + ) + if duplicateExists: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前评查点已存在您的有效提案") + + currentScore, fullScore = await self._calculate_current_score(session, Body.reviewPointResultId, Body.documentId) + if Body.deductionScore < 0 and currentScore <= 0: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前分值已为 0,不能继续扣分") + if Body.deductionScore > 0 and currentScore >= fullScore: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前分值已满分,不能继续加分") + + async with session.begin(): + proposalRow = ( + await session.execute( + text( + """ + INSERT INTO leaudit_cross_review_proposals + (task_id, document_id, rule_result_id, proposer_id, proposed_score_delta, reason, status) + VALUES + (:task_id, :document_id, :rule_result_id, :proposer_id, :proposed_score_delta, :reason, 'pending') + RETURNING id, create_time + """ + ), + { + "task_id": taskId, + "document_id": Body.documentId, + "rule_result_id": Body.reviewPointResultId, + "proposer_id": CurrentUserId, + "proposed_score_delta": Body.deductionScore, + "reason": Body.auditOpinion.strip(), + }, + ) + ).mappings().first() + if not proposalRow: + raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "创建交叉评查提案失败") + + proposalId = int(proposalRow["id"]) + await session.execute( + text( + """ + INSERT INTO leaudit_cross_review_votes + (proposal_id, voter_id, vote_type) + VALUES + (:proposal_id, :voter_id, 'agree') + """ + ), + {"proposal_id": proposalId, "voter_id": CurrentUserId}, + ) + await self._refresh_proposal_status(session, proposalId) + + return CrossReviewProposalCreateVO(proposalId=proposalId, createdAt=proposalRow.get("create_time")) + + async def VoteProposal( + self, + CurrentUserId: int, + ProposalId: int, + Body: CrossReviewProposalVoteDTO, + ) -> CrossReviewProposalVoteVO: + """对交叉评查提案投票。""" + voteType = Body.voteType.strip().lower() + if voteType not in {"agree", "disagree", "cancel"}: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "投票类型仅支持 agree/disagree/cancel") + + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + proposal = await self._load_proposal(session, ProposalId) + await self._ensure_task_member(session, int(proposal["task_id"]), CurrentUserId) + if str(proposal["status"]) in {"approved", "rejected", "cancelled"}: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前提案状态不允许继续投票") + + async with session.begin(): + if voteType == "cancel": + deleted = await session.execute( + text( + """ + UPDATE leaudit_cross_review_votes + SET delete_time = NOW(), + update_time = NOW() + WHERE proposal_id = :proposal_id + AND voter_id = :voter_id + AND delete_time IS NULL + """ + ), + {"proposal_id": ProposalId, "voter_id": CurrentUserId}, + ) + if deleted.rowcount == 0: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前没有可撤销的投票") + else: + await session.execute( + text( + """ + UPDATE leaudit_cross_review_votes + SET delete_time = NOW(), + update_time = NOW() + WHERE proposal_id = :proposal_id + AND voter_id = :voter_id + AND delete_time IS NULL + """ + ), + {"proposal_id": ProposalId, "voter_id": CurrentUserId}, + ) + await session.execute( + text( + """ + INSERT INTO leaudit_cross_review_votes + (proposal_id, voter_id, vote_type) + VALUES + (:proposal_id, :voter_id, :vote_type) + """ + ), + {"proposal_id": ProposalId, "voter_id": CurrentUserId, "vote_type": voteType}, + ) + + proposalStatus = await self._refresh_proposal_status(session, ProposalId) + + return CrossReviewProposalVoteVO( + proposalId=ProposalId, + voterId=CurrentUserId, + voteType=voteType, + proposalStatus=proposalStatus, + ) + + async def CancelProposal(self, CurrentUserId: int, ProposalId: int) -> CrossReviewProposalCancelVO: + """撤销交叉评查提案。""" + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + proposal = await self._load_proposal(session, ProposalId) + if int(proposal["proposer_id"]) != CurrentUserId: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "仅提案人可撤销提案") + if str(proposal["status"]) not in {"pending"}: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前提案状态不允许撤销") + + async with session.begin(): + await session.execute( + text( + """ + UPDATE leaudit_cross_review_proposals + SET status = 'cancelled', + update_time = NOW() + WHERE id = :proposal_id + AND delete_time IS NULL + """ + ), + {"proposal_id": ProposalId}, + ) + await session.execute( + text( + """ + UPDATE leaudit_cross_review_votes + SET delete_time = NOW(), + update_time = NOW() + WHERE proposal_id = :proposal_id + AND delete_time IS NULL + """ + ), + {"proposal_id": ProposalId}, + ) + + return CrossReviewProposalCancelVO(proposalId=ProposalId, status="cancelled") + + async def GetDocumentProposals( + self, + CurrentUserId: int, + DocumentId: int, + Page: int, + PageSize: int, + ) -> CrossReviewProposalPageVO: + """获取文档提案列表。""" + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + taskId = await self._resolve_task_id_for_document(session, DocumentId, CurrentUserId) + await self._ensure_task_member(session, taskId, CurrentUserId) + return await self._build_document_proposals_page(session, CurrentUserId, taskId, DocumentId, Page, PageSize) + + async def GetDocumentPendingVotes(self, CurrentUserId: int, DocumentId: int) -> CrossReviewPendingVotesVO: + """获取文档待投票信息。""" + async with GetAsyncSession() as session: + await self._ensure_tables_ready(session) + taskId = await self._resolve_task_id_for_document(session, DocumentId, CurrentUserId) + await self._ensure_task_member(session, taskId, CurrentUserId) + + proposalPage = await self._build_document_proposals_page(session, CurrentUserId, taskId, DocumentId, 1, 200) + pendingProposals = [ + CrossReviewPendingProposalVO( + evaluationPointName=item.evaluationPointName, + pendingVotersNum=len(item.pendingVoters), + ) + for item in proposalPage.items + if item.status == "pending" and len(item.pendingVoters) > 0 + ] + return CrossReviewPendingVotesVO( + hasPendingVotes=len(pendingProposals) > 0, + pendingProposals=pendingProposals, + ) + + async def UploadTaskDocument( + self, + CurrentUserId: int, + TaskId: int, + FileName: str, + FileContent: bytes, + ContentType: str | None, + ) -> CrossReviewTaskDocumentUploadVO: + """向交叉评查任务补传文档。""" + 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) + + taskMeta = ( + await session.execute( + text( + """ + SELECT id, doc_type_id, doc_type_code + FROM leaudit_cross_review_tasks + WHERE id = :task_id + AND delete_time IS NULL + LIMIT 1 + """ + ), + {"task_id": TaskId}, + ) + ).mappings().first() + if not taskMeta: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "交叉评查任务不存在") + + uploadResult = await self.DocumentService.Upload( + FileName=FileName, + FileContent=FileContent, + ContentType=ContentType, + TypeId=self._to_int(taskMeta.get("doc_type_id")), + TypeCode=taskMeta.get("doc_type_code"), + CreatedBy=CurrentUserId, + AutoRun=True, + ) + + async with GetAsyncSession() as session: + await self._ensure_tables_ready(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": uploadResult.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": uploadResult.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 CrossReviewTaskDocumentUploadVO( + taskId=TaskId, + documentId=uploadResult.documentId, + auditStatus=0, + processingStatus=uploadResult.processingStatus, + ) + + async def _build_document_proposals_page( + self, + session, + CurrentUserId: int, + TaskId: int, + DocumentId: int, + Page: int, + PageSize: int, + ) -> CrossReviewProposalPageVO: + params = { + "task_id": TaskId, + "document_id": DocumentId, + "limit": PageSize, + "offset": (Page - 1) * PageSize, + } + total = int( + ( + await session.scalar( + text( + """ + SELECT COUNT(*) + FROM leaudit_cross_review_proposals p + WHERE p.task_id = :task_id + AND p.document_id = :document_id + AND p.delete_time IS NULL + """ + ), + params, + ) + ) + or 0 + ) + proposalRows = ( + await session.execute( + text( + """ + SELECT + p.id, + p.task_id, + p.document_id, + p.rule_result_id, + p.proposer_id, + p.proposed_score_delta, + p.reason, + p.status, + p.create_time, + rr.rule_name, + rr.fail_message, + COALESCE(u.nick_name, u.username, '') AS proposer_name + FROM leaudit_cross_review_proposals p + LEFT JOIN leaudit_rule_results rr + ON rr.id = p.rule_result_id + LEFT JOIN sso_users u + ON u.id = p.proposer_id + WHERE p.task_id = :task_id + AND p.document_id = :document_id + AND p.delete_time IS NULL + ORDER BY p.id DESC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).mappings().all() + taskMembers = await self._load_task_member_names(session, TaskId) + proposalIds = [int(row["id"]) for row in proposalRows] + votesMap = await self._load_votes_map(session, proposalIds) + + items: list[CrossReviewProposalItemVO] = [] + for row in proposalRows: + proposalId = int(row["id"]) + voteRows = votesMap.get(proposalId, []) + agreeVoters = [str(vote["voter_name"]) for vote in voteRows if vote["vote_type"] == "agree"] + disagreeVoters = [str(vote["voter_name"]) for vote in voteRows if vote["vote_type"] == "disagree"] + votedUserIds = {int(vote["voter_id"]) for vote in voteRows} + pendingVoters = [name for userId, name in taskMembers.items() if userId not in votedUserIds] + canVote = CurrentUserId in taskMembers and CurrentUserId not in votedUserIds and str(row["status"]) == "pending" + items.append( + CrossReviewProposalItemVO( + proposalId=proposalId, + evaluationPointName=str(row.get("rule_name") or ""), + proposedScore=float(row["proposed_score_delta"] or 0), + reason=str(row.get("reason") or ""), + proposer=str(row.get("proposer_name") or ""), + votes=[ + CrossReviewProposalVoteItemVO( + voter=str(vote["voter_name"]), + voteType=str(vote["vote_type"]), + ) + for vote in voteRows + ], + agreeVoters=agreeVoters, + disagreeVoters=disagreeVoters, + pendingVoters=pendingVoters, + canVote=canVote, + problemMessage=str(row.get("fail_message") or row.get("rule_name") or ""), + proposerId=int(row["proposer_id"]), + createdAt=row.get("create_time"), + status=str(row.get("status") or "pending"), + ) + ) + return CrossReviewProposalPageVO(total=total, page=Page, pageSize=PageSize, items=items) + + async def _resolve_task_id_for_document(self, session, documentId: int, userId: int) -> int: + row = ( + await session.execute( + text( + """ + SELECT td.task_id + FROM leaudit_cross_review_task_documents td + JOIN leaudit_cross_review_task_members tm + ON tm.task_id = td.task_id + JOIN leaudit_cross_review_tasks t + ON t.id = td.task_id + WHERE td.document_id = :document_id + AND td.delete_time IS NULL + AND tm.user_id = :user_id + AND tm.delete_time IS NULL + AND t.delete_time IS NULL + ORDER BY td.task_id DESC + LIMIT 1 + """ + ), + {"document_id": documentId, "user_id": userId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前文档不在您的交叉评查任务中") + return int(row["task_id"]) + + async def _ensure_task_document(self, session, taskId: int, documentId: int) -> None: + 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": documentId}, + ) + ) + if not exists: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前文档不在交叉评查任务内") + + async def _load_rule_result(self, session, ruleResultId: int, documentId: int): + row = ( + await session.execute( + text( + """ + SELECT id, document_id, rule_id, rule_name, score, passed + FROM leaudit_rule_results + WHERE id = :rule_result_id + AND document_id = :document_id + LIMIT 1 + """ + ), + {"rule_result_id": ruleResultId, "document_id": documentId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "对应评查点结果不存在") + return row + + async def _calculate_current_score(self, session, ruleResultId: int, documentId: int) -> tuple[float, float]: + row = ( + await session.execute( + text( + """ + WITH approved_delta AS ( + SELECT COALESCE(SUM(proposed_score_delta), 0) AS delta + FROM leaudit_cross_review_proposals + WHERE rule_result_id = :rule_result_id + AND document_id = :document_id + AND status = 'approved' + AND delete_time IS NULL + ) + SELECT + COALESCE(rr.score, 0) AS full_score, + CASE + WHEN a.edit_audit_status = 1 THEN CASE WHEN a.override_result IS TRUE THEN COALESCE(rr.score, 0) ELSE 0 END + WHEN rr.passed IS TRUE THEN COALESCE(rr.score, 0) + ELSE 0 + END + COALESCE(ad.delta, 0) AS current_score + FROM leaudit_rule_results rr + LEFT JOIN leaudit_review_point_audits a + ON a.rule_result_id = rr.id + CROSS JOIN approved_delta ad + WHERE rr.id = :rule_result_id + AND rr.document_id = :document_id + LIMIT 1 + """ + ), + {"rule_result_id": ruleResultId, "document_id": documentId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "对应评查点结果不存在") + return float(row.get("current_score") or 0), float(row.get("full_score") or 0) + + async def _load_proposal(self, session, proposalId: int): + row = ( + await session.execute( + text( + """ + SELECT id, task_id, document_id, proposer_id, status + FROM leaudit_cross_review_proposals + WHERE id = :proposal_id + AND delete_time IS NULL + LIMIT 1 + """ + ), + {"proposal_id": proposalId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "交叉评查提案不存在") + return row + + async def _load_task_member_names(self, session, taskId: int) -> dict[int, str]: + rows = ( + await session.execute( + text( + """ + SELECT tm.user_id, COALESCE(u.nick_name, u.username, CAST(tm.user_id AS TEXT)) AS display_name + FROM leaudit_cross_review_task_members tm + LEFT JOIN sso_users u + ON u.id = tm.user_id + WHERE tm.task_id = :task_id + AND tm.delete_time IS NULL + ORDER BY tm.id ASC + """ + ), + {"task_id": taskId}, + ) + ).mappings().all() + return {int(row["user_id"]): str(row.get("display_name") or row["user_id"]) for row in rows} + + async def _load_votes_map(self, session, proposalIds: list[int]) -> dict[int, list[dict[str, object]]]: + if not proposalIds: + return {} + rows = ( + await session.execute( + text( + """ + SELECT + v.proposal_id, + v.voter_id, + v.vote_type, + COALESCE(u.nick_name, u.username, CAST(v.voter_id AS TEXT)) AS voter_name + FROM leaudit_cross_review_votes v + LEFT JOIN sso_users u + ON u.id = v.voter_id + WHERE v.proposal_id IN :proposal_ids + AND v.delete_time IS NULL + ORDER BY v.id ASC + """ + ).bindparams(bindparam("proposal_ids", expanding=True)), + {"proposal_ids": proposalIds}, + ) + ).mappings().all() + result: dict[int, list[dict[str, object]]] = {} + for row in rows: + result.setdefault(int(row["proposal_id"]), []).append(dict(row)) + return result + + async def _refresh_proposal_status(self, session, proposalId: int) -> str: + row = ( + await session.execute( + text( + """ + SELECT p.id, p.task_id, p.status + FROM leaudit_cross_review_proposals p + WHERE p.id = :proposal_id + AND p.delete_time IS NULL + LIMIT 1 + """ + ), + {"proposal_id": proposalId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "交叉评查提案不存在") + currentStatus = str(row["status"]) + if currentStatus == "cancelled": + return currentStatus + + taskId = int(row["task_id"]) + memberCount = int( + ( + await session.scalar( + text( + """ + SELECT COUNT(*) + FROM leaudit_cross_review_task_members + WHERE task_id = :task_id + AND delete_time IS NULL + """ + ), + {"task_id": taskId}, + ) + ) + or 0 + ) + threshold = floor(memberCount / 2) + 1 if memberCount > 0 else 1 + voteStats = ( + await session.execute( + text( + """ + SELECT + COUNT(*) FILTER (WHERE vote_type = 'agree')::int AS agree_count, + COUNT(*) FILTER (WHERE vote_type = 'disagree')::int AS disagree_count + FROM leaudit_cross_review_votes + WHERE proposal_id = :proposal_id + AND delete_time IS NULL + """ + ), + {"proposal_id": proposalId}, + ) + ).mappings().first() + agreeCount = int((voteStats or {}).get("agree_count") or 0) + disagreeCount = int((voteStats or {}).get("disagree_count") or 0) + remaining = max(memberCount - agreeCount - disagreeCount, 0) + + if agreeCount >= threshold: + nextStatus = "approved" + elif disagreeCount >= threshold: + nextStatus = "rejected" + elif agreeCount + remaining < threshold: + nextStatus = "rejected" + else: + nextStatus = "pending" + + if nextStatus != currentStatus: + await session.execute( + text( + """ + UPDATE leaudit_cross_review_proposals + SET status = :status, + update_time = NOW() + WHERE id = :proposal_id + """ + ), + {"proposal_id": proposalId, "status": nextStatus}, + ) + return nextStatus + + async def _ensure_tables_ready(self, session) -> None: + """确保第一阶段所需表已初始化。""" + required = [ + "leaudit_cross_review_tasks", + "leaudit_cross_review_task_members", + "leaudit_cross_review_task_documents", + "leaudit_cross_review_proposals", + "leaudit_cross_review_votes", + ] + for tableName in required: + exists = bool( + await session.scalar( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = :table_name + ) + """ + ), + {"table_name": tableName}, + ) + ) + if not exists: + raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, f"交叉评查表未初始化: {tableName}") + + async def _ensure_task_member(self, session, taskId: int, userId: int) -> None: + """校验当前用户是否是任务成员。""" + exists = bool( + await session.scalar( + text( + """ + SELECT 1 + FROM leaudit_cross_review_task_members tm + JOIN leaudit_cross_review_tasks t + ON t.id = tm.task_id + WHERE tm.task_id = :task_id + AND tm.user_id = :user_id + AND tm.delete_time IS NULL + AND t.delete_time IS NULL + LIMIT 1 + """ + ), + {"task_id": taskId, "user_id": userId}, + ) + ) + if not exists: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前用户不是交叉评查任务成员") + + def _unique_int_list(self, values: list[int]) -> list[int]: + """去重并保留原顺序。""" + seen: set[int] = set() + result: list[int] = [] + for value in values: + intValue = int(value) + if intValue in seen: + continue + seen.add(intValue) + result.append(intValue) + return result + + def _to_int(self, value) -> int | None: + """安全转 int。""" + if value is None: + return None + return int(value) diff --git a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py index 06dae53..9183993 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py @@ -1128,6 +1128,7 @@ class DocumentServiceImpl(IDocumentService): ) ).scalar_one() await self._syncRuleBindings(Session, int(row), Body.ruleSetIds, "default") + await sync_group_bindings_from_doc_type(Session, int(row), Body.ruleSetIds) await Session.commit() return await self.GetDocumentType(int(row)) @@ -1170,6 +1171,7 @@ class DocumentServiceImpl(IDocumentService): if "ruleSetIds" in providedFields and Body.ruleSetIds is not None: await self._syncRuleBindings(Session, Id, Body.ruleSetIds, "default") + await sync_group_bindings_from_doc_type(Session, Id, Body.ruleSetIds) await Session.commit() return await self.GetDocumentType(Id) @@ -1186,6 +1188,7 @@ class DocumentServiceImpl(IDocumentService): if not current: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "文档类型不存在") await Session.execute(text("UPDATE leaudit_document_types SET deleted_at = NOW() WHERE id = :id"), {"id": Id}) + await Session.execute(text("UPDATE leaudit_rule_type_bindings SET deleted_at = NOW() WHERE doc_type_id = :id AND deleted_at IS NULL"), {"id": Id}) await Session.commit() async def ListDocumentTypeRoots(self, EntryModuleId: int | None = None) -> list[DocumentTypeRootItemVO]: @@ -1331,36 +1334,17 @@ class DocumentServiceImpl(IDocumentService): await Session.execute( text( """ - SELECT - child.document_type_id AS doc_type_id, - rgb.rule_set_id, - child.sort_order AS child_sort_order, - rgb.priority, - rgb.id - FROM leaudit_evaluation_point_groups child - JOIN leaudit_rule_group_bindings rgb - ON rgb.group_id = child.id - AND rgb.deleted_at IS NULL - AND rgb.is_active = TRUE - WHERE child.document_type_id = ANY(:ids) - AND child.deleted_at IS NULL - AND COALESCE(child.pid, 0) <> 0 - ORDER BY - child.document_type_id ASC, - COALESCE(child.sort_order, 0) ASC, - COALESCE(rgb.priority, 0) DESC, - rgb.id ASC + SELECT doc_type_id, rule_set_id + FROM leaudit_rule_type_bindings + WHERE doc_type_id = ANY(:ids) AND deleted_at IS NULL AND is_active = true + ORDER BY priority DESC """ ), {"ids": allIds}, ) ).fetchall() - for docTypeId, ruleSetId, *_ in bindingRows: - current = bindingsMap.setdefault(int(docTypeId), []) - normalizedRuleSetId = int(ruleSetId) - if normalizedRuleSetId not in current: - current.append(normalizedRuleSetId) - + for b in bindingRows: + bindingsMap.setdefault(int(b[0]), []).append(int(b[1])) return rows, bindingsMap async def _queryDocumentTypeRoots(self, Session, Ids: list[int] | None = None, EntryModuleId: int | None = None): @@ -2198,6 +2182,33 @@ class DocumentServiceImpl(IDocumentService): async def _loadScoringProposals(self, Session, DocumentId: int) -> list[dict[str, Any]]: """读取交叉评分提案;缺表时降级为空。""" + if await self._tableExists(Session, "leaudit_cross_review_proposals"): + rows = ( + await Session.execute( + text( + """ + 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 + FROM leaudit_cross_review_proposals + WHERE document_id = :document_id + AND delete_time IS NULL + ORDER BY id DESC + """ + ), + {"document_id": DocumentId}, + ) + ).mappings().all() + if rows: + return [dict(row) for row in rows] + if not await self._tableExists(Session, "cross_scoring_proposals"): return [] @@ -2580,8 +2591,21 @@ class DocumentServiceImpl(IDocumentService): return str(Row.get("rule_name") or Row.get("rule_id") or "") async def _syncRuleBindings(self, Session, DocTypeId: int, RuleSetIds: list[int], Region: str = "default") -> None: - """全量替换规则绑定,仅写入新分组绑定。""" - await sync_group_bindings_from_doc_type(Session, DocTypeId, RuleSetIds) + """全量替换规则绑定。""" + await Session.execute( + text("UPDATE leaudit_rule_type_bindings SET deleted_at = NOW() WHERE doc_type_id = :id AND deleted_at IS NULL"), + {"id": DocTypeId}, + ) + for idx, ruleSetId in enumerate(RuleSetIds): + await Session.execute( + text( + """ + INSERT INTO leaudit_rule_type_bindings (doc_type_id, rule_set_id, binding_mode, priority, region, is_active, created_at, updated_at) + VALUES (:doc_type_id, :rule_set_id, 'explicit', :priority, :region, true, NOW(), NOW()) + """ + ), + {"doc_type_id": DocTypeId, "rule_set_id": ruleSetId, "priority": 100 - idx, "region": Region}, + ) async def _find_latest_version_candidate( diff --git a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py index 99546da..a81dc67 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/rbacAdminServiceImpl.py @@ -97,6 +97,41 @@ class RbacAdminServiceImpl(IRbacAdminService): "is_cache": True, "meta": {"group": "documents"}, }, + { + "route_path": "/cross-checking", + "route_name": "cross-checking", + "component": "cross-checking", + "route_title": "交叉评查", + "icon": "ri-color-filter-line", + "sort_order": 60, + "is_hidden": False, + "is_cache": True, + "meta": {"group": "cross-review"}, + }, + { + "route_path": "/cross-checking/upload", + "route_name": "cross-checking-upload", + "component": "cross-checking.upload", + "route_title": "创建任务", + "icon": "ri-upload-cloud-line", + "sort_order": 1, + "parent_path": "/cross-checking", + "is_hidden": False, + "is_cache": True, + "meta": {"group": "cross-review"}, + }, + { + "route_path": "/cross-checking/result", + "route_name": "cross-checking-result", + "component": "cross-checking.result", + "route_title": "评查结果", + "icon": "ri-file-list-3-line", + "sort_order": 2, + "parent_path": "/cross-checking", + "is_hidden": False, + "is_cache": True, + "meta": {"group": "cross-review"}, + }, { "route_path": "/settings", "route_name": "system-settings", @@ -171,6 +206,15 @@ class RbacAdminServiceImpl(IRbacAdminService): {"permission_key": "evaluation_point:create:write", "display_name": "创建评查点", "module": "evaluation_point", "resource": "create", "action": "write", "api_method": "POST", "api_path": "/api/v3/evaluation-points", "route_path": "/rules"}, {"permission_key": "evaluation_point:update:write", "display_name": "更新评查点", "module": "evaluation_point", "resource": "update", "action": "write", "api_method": "PUT", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"}, {"permission_key": "evaluation_point:delete:delete", "display_name": "删除评查点", "module": "evaluation_point", "resource": "delete", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/evaluation-points/{id}", "route_path": "/rules"}, + {"permission_key": "cross_review:task:create", "display_name": "创建交叉评查任务", "module": "cross_review", "resource": "task", "action": "create", "api_method": "POST", "api_path": "/api/v3/cross-review/tasks", "route_path": "/cross-checking/upload"}, + {"permission_key": "cross_review:task:read", "display_name": "查看交叉评查任务", "module": "cross_review", "resource": "task", "action": "read", "api_method": "POST", "api_path": "/api/v3/cross-review/tasks/query", "route_path": "/cross-checking"}, + {"permission_key": "cross_review:progress:view", "display_name": "查看交叉评查任务进度", "module": "cross_review", "resource": "progress", "action": "view", "api_method": "GET", "api_path": "/api/v3/cross-review/tasks/{task_id}/progress", "route_path": "/cross-checking"}, + {"permission_key": "cross_review:document:read", "display_name": "查看交叉评查任务文档", "module": "cross_review", "resource": "document", "action": "read", "api_method": "GET", "api_path": "/api/v3/cross-review/tasks/{task_id}/documents", "route_path": "/cross-checking/result"}, + {"permission_key": "cross_review:document:complete", "display_name": "确认交叉评查文档完成", "module": "cross_review", "resource": "document", "action": "complete", "api_method": "GET", "api_path": "/api/v3/cross-review/tasks/{task_id}/can-confirm", "route_path": "/cross-checking/result"}, + {"permission_key": "cross_review:proposal:create", "display_name": "创建交叉评查提案", "module": "cross_review", "resource": "proposal", "action": "create", "api_method": "POST", "api_path": "/api/v3/cross-review/proposals", "route_path": "/cross-checking/result"}, + {"permission_key": "cross_review:proposal:read", "display_name": "查看交叉评查提案", "module": "cross_review", "resource": "proposal", "action": "read", "api_method": "GET", "api_path": "/api/v3/cross-review/documents/{document_id}/proposals", "route_path": "/cross-checking/result"}, + {"permission_key": "cross_review:proposal:delete", "display_name": "撤销交叉评查提案", "module": "cross_review", "resource": "proposal", "action": "delete", "api_method": "DELETE", "api_path": "/api/v3/cross-review/proposals/{proposal_id}", "route_path": "/cross-checking/result"}, + {"permission_key": "cross_review:proposal:vote", "display_name": "交叉评查提案投票", "module": "cross_review", "resource": "proposal", "action": "vote", "api_method": "POST", "api_path": "/api/v3/cross-review/proposals/{proposal_id}/votes", "route_path": "/cross-checking/result"}, {"permission_key": "rbac:roles:read", "display_name": "角色列表", "module": "rbac", "resource": "roles", "action": "read", "api_method": "GET", "api_path": "/api/v3/rbac/roles", "route_path": "/role-permissions"}, {"permission_key": "rbac:roles:create", "display_name": "创建角色", "module": "rbac", "resource": "roles", "action": "create", "api_method": "POST", "api_path": "/api/v3/rbac/roles", "route_path": "/role-permissions"}, {"permission_key": "rbac:roles:update", "display_name": "更新角色", "module": "rbac", "resource": "roles", "action": "update", "api_method": "PUT", "api_path": "/api/v3/rbac/roles/{role_id}", "route_path": "/role-permissions"}, diff --git a/scripts/schema_v3_add_cross_review_phase1.sql b/scripts/schema_v3_add_cross_review_phase1.sql new file mode 100644 index 0000000..f322754 --- /dev/null +++ b/scripts/schema_v3_add_cross_review_phase1.sql @@ -0,0 +1,104 @@ +-- ============================================================================ +-- LeAudit Platform — 交叉评查第一阶段表结构 +-- ============================================================================ + +BEGIN; + +CREATE TABLE IF NOT EXISTS leaudit_cross_review_tasks ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_name VARCHAR(255) NOT NULL, + task_type VARCHAR(32) NOT NULL, + doc_type_id BIGINT, + doc_type_code VARCHAR(64), + assigner_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'in_progress', + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delete_time TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_lcr_tasks_assigner_id ON leaudit_cross_review_tasks (assigner_id); +CREATE INDEX IF NOT EXISTS idx_lcr_tasks_status ON leaudit_cross_review_tasks (status); +CREATE INDEX IF NOT EXISTS idx_lcr_tasks_doc_type_id ON leaudit_cross_review_tasks (doc_type_id); + +COMMENT ON TABLE leaudit_cross_review_tasks IS '交叉评查任务主表'; + +CREATE TABLE IF NOT EXISTS leaudit_cross_review_task_members ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + member_role VARCHAR(32) NOT NULL DEFAULT 'participant', + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delete_time TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_lcr_task_members_task_id ON leaudit_cross_review_task_members (task_id); +CREATE INDEX IF NOT EXISTS idx_lcr_task_members_user_id ON leaudit_cross_review_task_members (user_id); +CREATE INDEX IF NOT EXISTS idx_lcr_task_members_role ON leaudit_cross_review_task_members (member_role); +CREATE UNIQUE INDEX IF NOT EXISTS uq_lcr_task_members_task_user_active + ON leaudit_cross_review_task_members (task_id, user_id) + WHERE delete_time IS NULL; + +COMMENT ON TABLE leaudit_cross_review_task_members IS '交叉评查任务成员表'; + +CREATE TABLE IF NOT EXISTS leaudit_cross_review_task_documents ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id BIGINT NOT NULL, + document_id BIGINT NOT NULL, + audit_status INTEGER NOT NULL DEFAULT 0, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delete_time TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_lcr_task_documents_task_id ON leaudit_cross_review_task_documents (task_id); +CREATE INDEX IF NOT EXISTS idx_lcr_task_documents_document_id ON leaudit_cross_review_task_documents (document_id); +CREATE INDEX IF NOT EXISTS idx_lcr_task_documents_task_status ON leaudit_cross_review_task_documents (task_id, audit_status); +CREATE UNIQUE INDEX IF NOT EXISTS uq_lcr_task_documents_task_document_active + ON leaudit_cross_review_task_documents (task_id, document_id) + WHERE delete_time IS NULL; + +COMMENT ON TABLE leaudit_cross_review_task_documents IS '交叉评查任务文档挂载表'; + +CREATE TABLE IF NOT EXISTS leaudit_cross_review_proposals ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + task_id BIGINT NOT NULL, + document_id BIGINT NOT NULL, + rule_result_id BIGINT NOT NULL, + proposer_id BIGINT NOT NULL, + proposed_score_delta NUMERIC(10, 2) NOT NULL, + reason TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delete_time TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_lcr_proposals_task_id ON leaudit_cross_review_proposals (task_id); +CREATE INDEX IF NOT EXISTS idx_lcr_proposals_document_id ON leaudit_cross_review_proposals (document_id); +CREATE INDEX IF NOT EXISTS idx_lcr_proposals_rule_result_id ON leaudit_cross_review_proposals (rule_result_id); +CREATE INDEX IF NOT EXISTS idx_lcr_proposals_proposer_id ON leaudit_cross_review_proposals (proposer_id); +CREATE INDEX IF NOT EXISTS idx_lcr_proposals_status ON leaudit_cross_review_proposals (status); + +COMMENT ON TABLE leaudit_cross_review_proposals IS '交叉评查提案表'; + +CREATE TABLE IF NOT EXISTS leaudit_cross_review_votes ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + proposal_id BIGINT NOT NULL, + voter_id BIGINT NOT NULL, + vote_type VARCHAR(16) NOT NULL, + create_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + update_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + delete_time TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_lcr_votes_proposal_id ON leaudit_cross_review_votes (proposal_id); +CREATE INDEX IF NOT EXISTS idx_lcr_votes_voter_id ON leaudit_cross_review_votes (voter_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_lcr_votes_proposal_voter_active + ON leaudit_cross_review_votes (proposal_id, voter_id) + WHERE delete_time IS NULL; + +COMMENT ON TABLE leaudit_cross_review_votes IS '交叉评查投票表'; + +COMMIT; diff --git a/scripts/seed_cross_review_phase1_permissions.sql b/scripts/seed_cross_review_phase1_permissions.sql new file mode 100644 index 0000000..5d0d536 --- /dev/null +++ b/scripts/seed_cross_review_phase1_permissions.sql @@ -0,0 +1,84 @@ +-- ============================================================================ +-- 交叉评查第一阶段权限种子 +-- ============================================================================ + +BEGIN; + +INSERT INTO permissions ( + permission_key, module, resource, action, description, display_name, + permission_type, is_system, metadata, created_at, updated_at, + created_by, updated_by, parent_id, sort_order, route_id, api_path, api_method, related_routes +) +VALUES + ('cross_review:task:create', 'cross_review', 'task', 'create', '创建交叉评查任务', '创建交叉评查任务', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 200, NULL, '/api/v3/cross-review/tasks', 'POST', ARRAY['/cross-checking/upload']), + ('cross_review:task:read', 'cross_review', 'task', 'read', '查看交叉评查任务', '查看交叉评查任务', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 201, NULL, '/api/v3/cross-review/tasks/query', 'POST', ARRAY['/cross-checking']), + ('cross_review:progress:view', 'cross_review', 'progress', 'view', '查看交叉评查任务进度', '查看任务进度', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 202, NULL, '/api/v3/cross-review/tasks/{task_id}/progress', 'GET', ARRAY['/cross-checking']), + ('cross_review:document:read', 'cross_review', 'document', 'read', '查看交叉评查任务文档', '查看任务文档', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 203, NULL, '/api/v3/cross-review/tasks/{task_id}/documents', 'GET', ARRAY['/cross-checking','/cross-checking/result']), + ('cross_review:document:complete', 'cross_review', 'document', 'complete', '确认交叉评查文档完成', '确认文档完成', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 204, NULL, '/api/v3/cross-review/tasks/{task_id}/can-confirm', 'GET', ARRAY['/cross-checking/result']), + ('cross_review:proposal:create', 'cross_review', 'proposal', 'create', '创建交叉评查提案', '创建交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 205, NULL, '/api/v3/cross-review/proposals', 'POST', ARRAY['/cross-checking/result']), + ('cross_review:proposal:read', 'cross_review', 'proposal', 'read', '查看交叉评查提案', '查看交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 206, NULL, '/api/v3/cross-review/documents/{document_id}/proposals', 'GET', ARRAY['/cross-checking/result']), + ('cross_review:proposal:delete', 'cross_review', 'proposal', 'delete', '撤销交叉评查提案', '撤销交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 207, NULL, '/api/v3/cross-review/proposals/{proposal_id}', 'DELETE', ARRAY['/cross-checking/result']), + ('cross_review:proposal:vote', 'cross_review', 'proposal', 'vote', '交叉评查提案投票', '交叉评查提案投票', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 208, NULL, '/api/v3/cross-review/proposals/{proposal_id}/votes', 'POST', ARRAY['/cross-checking/result']) +ON CONFLICT (permission_key) DO UPDATE SET + module = EXCLUDED.module, + resource = EXCLUDED.resource, + action = EXCLUDED.action, + description = EXCLUDED.description, + display_name = EXCLUDED.display_name, + updated_at = CURRENT_TIMESTAMP, + api_path = EXCLUDED.api_path, + api_method = EXCLUDED.api_method, + sort_order = EXCLUDED.sort_order, + related_routes = EXCLUDED.related_routes; + +WITH role_map AS ( + SELECT id, role_key FROM roles WHERE role_key IN ('super_admin', 'provincial_admin', 'admin', 'common') +), +perm_map AS ( + SELECT id, permission_key FROM permissions WHERE permission_key LIKE 'cross_review:%' +), +seed(role_key, permission_key, grant_type, data_scope) AS ( + VALUES + ('super_admin', 'cross_review:task:create', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:task:read', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:progress:view', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:document:read', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:document:complete', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:proposal:create', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:proposal:read', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:proposal:delete', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:proposal:vote', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:task:create', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:task:read', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:progress:view', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:document:read', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:document:complete', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:proposal:create', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:proposal:read', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:proposal:delete', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:proposal:vote', 'GRANT', 'ALL'), + ('admin', 'cross_review:task:create', 'GRANT', 'DEPT'), + ('admin', 'cross_review:task:read', 'GRANT', 'DEPT'), + ('admin', 'cross_review:progress:view', 'GRANT', 'DEPT'), + ('admin', 'cross_review:document:read', 'GRANT', 'DEPT'), + ('admin', 'cross_review:document:complete', 'GRANT', 'DEPT'), + ('admin', 'cross_review:proposal:create', 'GRANT', 'DEPT'), + ('admin', 'cross_review:proposal:read', 'GRANT', 'DEPT'), + ('admin', 'cross_review:proposal:delete', 'GRANT', 'DEPT'), + ('admin', 'cross_review:proposal:vote', 'GRANT', 'DEPT'), + ('common', 'cross_review:task:read', 'GRANT', 'SELF'), + ('common', 'cross_review:progress:view', 'GRANT', 'SELF'), + ('common', 'cross_review:document:read', 'GRANT', 'SELF'), + ('common', 'cross_review:proposal:read', 'GRANT', 'SELF') +) +INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope, created_at, updated_at) +SELECT rm.id, pm.id, seed.grant_type, seed.data_scope, NOW(), NOW() +FROM seed +JOIN role_map rm ON rm.role_key = seed.role_key +JOIN perm_map pm ON pm.permission_key = seed.permission_key +ON CONFLICT (role_id, permission_id) DO UPDATE SET + grant_type = EXCLUDED.grant_type, + data_scope = EXCLUDED.data_scope, + updated_at = NOW(); + +COMMIT; diff --git a/scripts/seed_frontend_route_scope.sql b/scripts/seed_frontend_route_scope.sql index 4348c84..a7dffff 100644 --- a/scripts/seed_frontend_route_scope.sql +++ b/scripts/seed_frontend_route_scope.sql @@ -162,7 +162,10 @@ seed(role_key, route_path, permission, status) AS ( ('admin', '/contract-template/list', 'RW', 1), ('admin', '/cross-checking', 'RW', 1), ('admin', '/cross-checking/upload', 'RW', 1), - ('admin', '/cross-checking/result', 'RW', 1) + ('admin', '/cross-checking/result', 'RW', 1), + + ('common', '/cross-checking', 'R', 1), + ('common', '/cross-checking/result', 'R', 1) ) INSERT INTO role_route (role_id, route_id, permission, status, created_at, updated_at) SELECT rm.id, tm.id, s.permission, s.status, NOW(), NOW() diff --git a/scripts/user_rbac_seed.sql b/scripts/user_rbac_seed.sql index 381b6e7..0cf8faa 100644 --- a/scripts/user_rbac_seed.sql +++ b/scripts/user_rbac_seed.sql @@ -96,18 +96,27 @@ VALUES ('evaluation_point:create:write', 'evaluation_point', 'create', 'write', '创建评查点', '创建评查点', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 54, NULL, '/api/v3/evaluation-points', 'POST', NULL), ('evaluation_point:update:write', 'evaluation_point', 'update', 'write', '更新评查点', '更新评查点', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 55, NULL, '/api/v3/evaluation-points/{id}', 'PUT', NULL), ('evaluation_point:delete:delete', 'evaluation_point', 'delete', 'delete', '删除评查点', '删除评查点', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 56, NULL, '/api/v3/evaluation-points/{id}', 'DELETE', NULL), + ('cross_review:task:create', 'cross_review', 'task', 'create', '创建交叉评查任务', '创建交叉评查任务', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 57, NULL, '/api/v3/cross-review/tasks', 'POST', ARRAY['/cross-checking/upload']), + ('cross_review:task:read', 'cross_review', 'task', 'read', '查看交叉评查任务', '查看交叉评查任务', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 58, NULL, '/api/v3/cross-review/tasks/query', 'POST', ARRAY['/cross-checking']), + ('cross_review:progress:view', 'cross_review', 'progress', 'view', '查看交叉评查任务进度', '查看交叉评查任务进度', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 59, NULL, '/api/v3/cross-review/tasks/{task_id}/progress', 'GET', ARRAY['/cross-checking']), + ('cross_review:document:read', 'cross_review', 'document', 'read', '查看交叉评查任务文档', '查看交叉评查任务文档', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 60, NULL, '/api/v3/cross-review/tasks/{task_id}/documents', 'GET', ARRAY['/cross-checking','/cross-checking/result']), + ('cross_review:document:complete', 'cross_review', 'document', 'complete', '确认交叉评查文档完成', '确认交叉评查文档完成', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 61, NULL, '/api/v3/cross-review/tasks/{task_id}/can-confirm', 'GET', ARRAY['/cross-checking/result']), + ('cross_review:proposal:create', 'cross_review', 'proposal', 'create', '创建交叉评查提案', '创建交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 62, NULL, '/api/v3/cross-review/proposals', 'POST', ARRAY['/cross-checking/result']), + ('cross_review:proposal:read', 'cross_review', 'proposal', 'read', '查看交叉评查提案', '查看交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 63, NULL, '/api/v3/cross-review/documents/{document_id}/proposals', 'GET', ARRAY['/cross-checking/result']), + ('cross_review:proposal:delete', 'cross_review', 'proposal', 'delete', '撤销交叉评查提案', '撤销交叉评查提案', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 64, NULL, '/api/v3/cross-review/proposals/{proposal_id}', 'DELETE', ARRAY['/cross-checking/result']), + ('cross_review:proposal:vote', 'cross_review', 'proposal', 'vote', '交叉评查提案投票', '交叉评查提案投票', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 65, NULL, '/api/v3/cross-review/proposals/{proposal_id}/votes', 'POST', ARRAY['/cross-checking/result']), - ('users:list:read', 'users', 'list', 'read', '查看用户列表', '用户列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 60, NULL, '/api/users/list', 'GET', NULL), - ('users:create:write', 'users', 'create', 'write', '创建用户', '创建用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 61, NULL, '/api/users', 'POST', NULL), - ('users:update:write', 'users', 'update', 'write', '更新用户', '更新用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 62, NULL, '/api/users/{user_id}', 'PUT', NULL), - ('users:disable:write', 'users', 'disable', 'write', '禁用/启用用户', '禁用用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 63, NULL, '/api/users/{user_id}/disable', 'PUT', NULL), - ('users:roles_assign:write', 'users', 'roles_assign', 'write', '分配用户角色', '分配用户角色', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 64, NULL, '/api/users/{user_id}/roles', 'POST', NULL), + ('users:list:read', 'users', 'list', 'read', '查看用户列表', '用户列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 70, NULL, '/api/users/list', 'GET', NULL), + ('users:create:write', 'users', 'create', 'write', '创建用户', '创建用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 71, NULL, '/api/users', 'POST', NULL), + ('users:update:write', 'users', 'update', 'write', '更新用户', '更新用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 72, NULL, '/api/users/{user_id}', 'PUT', NULL), + ('users:disable:write', 'users', 'disable', 'write', '禁用/启用用户', '禁用用户', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 73, NULL, '/api/users/{user_id}/disable', 'PUT', NULL), + ('users:roles_assign:write', 'users', 'roles_assign', 'write', '分配用户角色', '分配用户角色', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 74, NULL, '/api/users/{user_id}/roles', 'POST', NULL), - ('rbac:roles:read', 'rbac', 'roles', 'read', '查看角色列表', '角色列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 70, NULL, '/api/rbac/roles', 'GET', NULL), - ('rbac:roles:update', 'rbac', 'roles', 'update', '维护角色信息', '维护角色', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 71, NULL, '/api/rbac/roles/{role_id}', 'PUT', NULL), - ('rbac:permissions:read', 'rbac', 'permissions', 'read', '查看权限点列表', '权限点列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 72, NULL, '/api/rbac/permissions', 'GET', NULL), - ('rbac:role_permissions:write', 'rbac', 'role_permissions', 'write', '分配角色权限', '分配角色权限', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 73, NULL, '/api/rbac/roles/{role_id}/permissions', 'POST', NULL), - ('rbac:role_routes:write', 'rbac', 'role_routes', 'write', '分配角色菜单', '分配角色菜单', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 74, NULL, '/api/rbac/roles/{role_id}/routes', 'PUT', NULL) + ('rbac:roles:read', 'rbac', 'roles', 'read', '查看角色列表', '角色列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 80, NULL, '/api/rbac/roles', 'GET', NULL), + ('rbac:roles:update', 'rbac', 'roles', 'update', '维护角色信息', '维护角色', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 81, NULL, '/api/rbac/roles/{role_id}', 'PUT', NULL), + ('rbac:permissions:read', 'rbac', 'permissions', 'read', '查看权限点列表', '权限点列表', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 82, NULL, '/api/rbac/permissions', 'GET', NULL), + ('rbac:role_permissions:write', 'rbac', 'role_permissions', 'write', '分配角色权限', '分配角色权限', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 83, NULL, '/api/rbac/roles/{role_id}/permissions', 'POST', NULL), + ('rbac:role_routes:write', 'rbac', 'role_routes', 'write', '分配角色菜单', '分配角色菜单', 'API', TRUE, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, NULL, NULL, 84, NULL, '/api/rbac/roles/{role_id}/routes', 'PUT', NULL) ON CONFLICT (permission_key) DO UPDATE SET module = EXCLUDED.module, resource = EXCLUDED.resource, @@ -182,7 +191,9 @@ seed(role_key, route_path, permission, status) AS ( ('common', '/documents', 'R', 1), ('common', '/documents/list', 'R', 1), ('common', '/audit', 'R', 1), - ('common', '/audit/runs', 'R', 1) + ('common', '/audit/runs', 'R', 1), + ('common', '/cross-checking', 'R', 1), + ('common', '/cross-checking/result', 'R', 1) ) INSERT INTO role_route (role_id, route_id, permission, status, created_at, updated_at) SELECT rm.id, tm.id, s.permission, s.status, NOW(), NOW() @@ -231,6 +242,15 @@ seed(role_key, permission_key, grant_type, data_scope) AS ( ('super_admin', 'evaluation_point:create:write', 'GRANT', 'ALL'), ('super_admin', 'evaluation_point:update:write', 'GRANT', 'ALL'), ('super_admin', 'evaluation_point:delete:delete', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:task:create', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:task:read', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:progress:view', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:document:read', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:document:complete', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:proposal:create', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:proposal:read', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:proposal:delete', 'GRANT', 'ALL'), + ('super_admin', 'cross_review:proposal:vote', 'GRANT', 'ALL'), ('super_admin', 'users:list:read', 'GRANT', 'ALL'), ('super_admin', 'users:create:write', 'GRANT', 'ALL'), ('super_admin', 'users:update:write', 'GRANT', 'ALL'), @@ -268,6 +288,15 @@ seed(role_key, permission_key, grant_type, data_scope) AS ( ('provincial_admin', 'evaluation_point:create:write', 'GRANT', 'ALL'), ('provincial_admin', 'evaluation_point:update:write', 'GRANT', 'ALL'), ('provincial_admin', 'evaluation_point:delete:delete', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:task:create', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:task:read', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:progress:view', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:document:read', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:document:complete', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:proposal:create', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:proposal:read', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:proposal:delete', 'GRANT', 'ALL'), + ('provincial_admin', 'cross_review:proposal:vote', 'GRANT', 'ALL'), ('provincial_admin', 'users:list:read', 'GRANT', 'ALL'), ('provincial_admin', 'users:create:write', 'GRANT', 'ALL'), ('provincial_admin', 'users:update:write', 'GRANT', 'ALL'), @@ -296,6 +325,15 @@ seed(role_key, permission_key, grant_type, data_scope) AS ( ('admin', 'rules:binding_list:read', 'GRANT', 'DEPT'), ('admin', 'rules:binding_create:write', 'GRANT', 'DEPT'), ('admin', 'rules:binding_update:write', 'GRANT', 'DEPT'), + ('admin', 'cross_review:task:create', 'GRANT', 'DEPT'), + ('admin', 'cross_review:task:read', 'GRANT', 'DEPT'), + ('admin', 'cross_review:progress:view', 'GRANT', 'DEPT'), + ('admin', 'cross_review:document:read', 'GRANT', 'DEPT'), + ('admin', 'cross_review:document:complete', 'GRANT', 'DEPT'), + ('admin', 'cross_review:proposal:create', 'GRANT', 'DEPT'), + ('admin', 'cross_review:proposal:read', 'GRANT', 'DEPT'), + ('admin', 'cross_review:proposal:delete', 'GRANT', 'DEPT'), + ('admin', 'cross_review:proposal:vote', 'GRANT', 'DEPT'), ('admin', 'evaluation_point:list:read', 'GRANT', 'DEPT'), ('admin', 'evaluation_point:detail:read', 'GRANT', 'DEPT'), ('admin', 'evaluation_point:create:write', 'GRANT', 'DEPT'), @@ -312,6 +350,9 @@ seed(role_key, permission_key, grant_type, data_scope) AS ( ('common', 'audit:run:execute', 'GRANT', 'SELF'), ('common', 'audit:status:read', 'GRANT', 'SELF'), ('common', 'audit:result:read', 'GRANT', 'SELF'), + ('common', 'cross_review:task:read', 'GRANT', 'SELF'), + ('common', 'cross_review:progress:view', 'GRANT', 'SELF'), + ('common', 'cross_review:document:read', 'GRANT', 'SELF'), ('common', 'rules:list:read', 'GRANT', 'DEPT'), ('common', 'rules:version_list:read', 'GRANT', 'DEPT'), ('common', 'rules:content:read', 'GRANT', 'DEPT'),