# 方案关联关系功能 状态:已完成(2026-08-22) 测试结果: - 后端 pytest:171 passed,3 failed 均为既有脱节(test_http.py 路由白名单/统一 404 格式、test_b5_cors_headers 同一路由 404 requestId,与本次无关),385 skipped(无 MySQL 时集成用例自动跳过);其中新增 test_document_relations.py 55 条用例全绿(集成 8 条跳过)。 - 前端 vitest:54 文件 / 358 用例全绿(基线 343 + 新增 15:documentRelations.test.ts 守卫/API 11 条、relationScope.test.ts scope 断言 4 条);tsc --noEmit 通过。 - 既有测试同步更新:passwordScope.test.ts 的 withDocumentPassword 计数 2→3(关联保存走 EDIT 密码包装)、test_models.py 模型集合 +doc_document_relation(10→11 表)、三处 OpenAPI 版本断言 1.8.0→1.9.0、test_openapi_storage.py 路径与版本、test_migration.py 迁移 head 锚定 0014。 ## 1. 需求 在"编辑"功能里为方案增加关联关系: - 某方案可设置 0/1/多个关联方案,并指出其关系(如 A 方案指定 B 方案为其"依据"、C 方案为其"支撑"); - 关系类型提供预置下拉(配置文件),同时保留用户自定义输入的口子; - "查看"弹窗一并展示关联清单,且点击后可跳转至对应方案的"查看"窗口。 已确认的三点决策: 1. 预置关系初始清单:默认三项——依据(BASIS)/ 支撑(SUPPORT)/ 参照(REFERENCE); 2. 关联对象范围:仅主体方案(MAIN)可作为被关联对象; 3. 展示:查看弹窗一并展示,且点击可跳转打开对应方案的查看窗口。 补充约定(未显式询问,按"方案"语义推定): - **发起方**(声明关联的文档)允许主案(MAIN)与子方案(SUB_PLAN),附件(ATTACHMENT)不参与——与 `update_document` 的类型约束一致; - 关系为**单向**声明(A 关联 B 不等于 B 关联 A),查看弹窗只展示本方案声明的关联; - side 模式右侧预览栏不展示关联(宽度受限,与提取文本同策略),仅 modal(查看窗口)展示并支持跳转。 ## 2. 关键设计决策 ### 2.1 预置关系放独立配置文件,不进 ui-dictionaries ui-dictionaries 是**强枚举绑定**体系:字典 key 必须对应后端 StrEnum(`DICTIONARY_ENUMS`),JSON 集合与枚举成员必须完全一致,否则启动报错。而关系类型是**开放集合**(预置 + 用户自定义文本),不适合枚举绑定。 新建 `backend/dms/resources/document-relation-presets.zh-CN.json`: ```json { "presets": [ { "code": "BASIS", "label": "依据", "sortNo": 10 }, { "code": "SUPPORT", "label": "支撑", "sortNo": 20 }, { "code": "REFERENCE", "label": "参照", "sortNo": 30 } ] } ``` ### 2.2 relation_type 存文本 `doc_document_relation.relation_type` 直接存最终文本(预置 label 或用户自定义文字): - 展示免翻译(预置清单变更不影响历史数据展示); - 代价:预置 label 改名后旧数据仍是旧文字(可接受,属数据治理范畴); - code 仅用于下拉匹配与排序,不落库。 ### 2.3 新表 doc_document_relation(仿 doc_attachment_binding) 沿用挂载关系表的成熟模式(软删 + 生成列唯一约束): | 列 | 类型 | 说明 | |---|---|---| | id / created_by / updated_by / created_at / updated_at / deleted_at / is_deleted / row_version | BusinessTableMixin | 通用字段 | | document_id | BigInteger FK doc_document.id RESTRICT | 发起方 | | related_document_id | BigInteger FK doc_document.id RESTRICT | 被关联方(须为 MAIN) | | relation_type | String(64) NOT NULL | 关系文本 | | sort_no | Integer NOT NULL default 0 | 行序(10 步长) | | active_marker | Computed IF(is_deleted=0,1,NULL) | 软删唯一约束载体 | 约束与索引: - `uq_doc_document_relation_active (document_id, related_document_id, active_marker)`——同一发起方对同一目标至多一条有效关联; - `ck document_relation_no_self: document_id <> related_document_id`; - `ix_doc_document_relation_document_active (document_id, is_deleted, sort_no)`; - `ix_doc_document_relation_related_active (related_document_id, is_deleted)`。 ### 2.4 API(整体替换语义) | 方法 | 路径 | 权限 | 说明 | |---|---|---|---| | GET | /documents/relation-presets | 登录 | 预置关系清单(启动期加载校验) | | GET | /documents/{id}/relations | 登录 | 查看关联(走与 get_document 相同的访问控制) | | PUT | /documents/{id}/relations | 管理员 | 整体替换保存 | PUT 请求体:`{ "relations": [{ "relatedDocumentId": "…", "relationType": "…" }], "rowVersion": n }`;空数组 = 清空全部关联。 服务层要点(仿 bind_attachments / update_document): - `with transaction()` + 发起方 `with_for_update()` 行锁;类型限 MAIN/SUB_PLAN; - 密码锁:`require_document_password(document, EDIT, …)`(设密主案编辑关联同样需密码); - 乐观锁:校验 rowVersion,成功后 `row_version += 1`(与挂载/解挂一致); - 校验:不可自关联;被关联方必须存在、未删且为 MAIN;relationType 1~64 非空文本;去重(relatedDocumentId 不可重复);上限 50 条; - 保存:软删旧有效行 → 插入新行(sort_no 10,20,…); - 审计:复用 `EDIT_DOCUMENT` 动作(detail 标注 scope=relations 与前后清单),**不扩展 sys_audit_log 的 CHECK 约束**(避免迁移面扩大); - GET 查询联表过滤被关联方 `is_deleted=False`(目标进回收站后隐藏,恢复后自动重现)。 ### 2.5 前端 **编辑弹窗(EditMetadataModal)**: - MAIN/SUB_PLAN 显示"关联方案"区块(附件不显示); - 行结构:`[关系下拉(预置 + "自定义…")] [自定义文本输入(选中自定义时显示)] [方案下拉(复用 useReferenceData 的 mainPlans,排除自身)] [删除]`; - 关系下拉 value 存文本(选中预置即取其 label),`matchPresetCode(text, presets)` 派生是否自定义; - **独立"保存关联"按钮**:不混入元数据提交流程;成功后用响应中的 rowVersion 同步本地 detail(与密码锁保存同模式),避免后续保存元数据撞版本; - DATA_VERSION_CONFLICT 处理复用现有模式。 **查看窗口(FilePreviewModal,modal 模式)**: - textPanel 顶部新增"关联方案"区块:每行 `关系 · 方案名`,点击即跳转; - 跳转实现:内部 `navigated: DocumentSummary | null` 状态,`current = navigated ?? item`;点击关联项 → `documentApi.detail(targetId)` → setNavigated(detail),预览/正文/说明/关联全部以 current 驱动重新加载; - navigated 非空时区块头部显示"返回原方案"按钮; - 跳转失败(无权限/已删除)在区块内提示,不影响当前展示。 **类型与 API client**: - 新文件 `frontend/src/features/documents/documentRelationTypes.ts`:DocumentRelationItem / DocumentRelationListResult / DocumentRelationPreset(s) / UpdateDocumentRelationsRequest + 运行时守卫 + `matchPresetCode` 纯函数; - documentApi 增 `listRelations` / `relationPresets`;documentMutationApi 增 `updateRelations`(带密码头)。 ## 3. 实施阶段 1. 后端配置与模型:presets JSON + 加载校验服务 + DocumentRelation 模型 + 迁移 0014 + models/__init__ 注册; 2. 后端服务与接口:document_relation_service(查询/整体替换)+ documents.py 三条路由 + OpenAPI(paths/documents.yaml、schemas.yaml、openapi.yaml 版本说明); 3. 前端:类型/守卫/纯函数 + API client + EditMetadataModal 关联区块 + FilePreviewModal 展示与跳转; 4. 测试与回归:后端 pytest(迁移锚定 0013→0014、presets 校验、relations API 用例)+ 前端 vitest(类型守卫、matchPresetCode、scope 断言)+ tsc。 ## 4. 测试基线 - 前端 vitest:343 全绿(新增用例后相应增加); - 后端 pytest:120 passed + 1 failed(langchain 既有)+ 12 errors(既有)+ 377 skipped,运行需 `export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1`。