From 6cd22b218d7558e082a3502b7662a3821b75a239 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 16 Oct 2026 16:16:15 +0800 Subject: [PATCH] docs(plan): OA history department filter implementation Break down backend tests, engine/API wiring, and web-admin filter UI into executable tasks. Co-authored-by: Cursor --- .../2026-10-16-oa-history-dept-filter.md | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-16-oa-history-dept-filter.md diff --git a/docs/superpowers/plans/2026-10-16-oa-history-dept-filter.md b/docs/superpowers/plans/2026-10-16-oa-history-dept-filter.md new file mode 100644 index 00000000..aeb859d3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-oa-history-dept-filter.md @@ -0,0 +1,346 @@ +# OA 历史记录按部门检索 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 web-admin OA「历史记录」检索区增加按发起部门(本级+下级)过滤,并让列表与导出 API 共用该条件。 + +**Architecture:** 前端用 `fetchDeptTree` + `el-tree-select` 传 `initiatorDeptId`;后端在现有 `can_view_all` 收窄之后,用 `data_scope._get_child_dept_ids` 展开部门树,对 `OaInstance.initiator_dept_id` 做 `IN` 过滤。不改角色数据范围模型。 + +**Tech Stack:** FastAPI + SQLAlchemy、pytest、Vue 3 + Element Plus、`@/utils/tree-path` + +**Spec:** `docs/superpowers/specs/2026-10-16-oa-history-dept-filter-design.md` + +--- + +## File structure + +| 文件 | 职责 | +|------|------| +| `backend/tests/test_oa_instance_history.py` | 部门过滤单测(含 SysDept 树) | +| `backend/app/services/oa_engine.py` | `list_instance_history` / `_history_export_query` / `export_instance_history` 增加 `initiator_dept_id` | +| `backend/app/api/oa_approval.py` | history / export Query 透传 `initiatorDeptId` | +| `web-admin/src/api/oaApproval.ts` | 请求参数类型增加 `initiatorDeptId` | +| `web-admin/src/views/oa-approval/history/index.vue` | 检索区部门树选 + 查询/导出透传 | + +--- + +### Task 1: Backend failing tests for dept filter + +**Files:** +- Modify: `backend/tests/test_oa_instance_history.py` + +- [ ] **Step 1: Extend setUp with SysDept tree and initiator_dept_id on instances** + +Import `SysDept` from `app.models.models`. + +In `Base.metadata.create_all` tables list, add `SysDept.__table__`. + +In `setUp` `add_all`, add three depts and set instance dept fields: + +```python +SysDept( + id="dept-root", + parent_id="0", + dept_name="总部", + status="1", + tenant_no="zkxw", + create_dept_id="", + create_time=t1, + update_time=t1, + del_flag=0, +), +SysDept( + id="dept-sales", + parent_id="dept-root", + dept_name="销售部", + status="1", + tenant_no="zkxw", + create_dept_id="", + create_time=t1, + update_time=t1, + del_flag=0, +), +SysDept( + id="dept-sales-east", + parent_id="dept-sales", + dept_name="华东销售", + status="1", + tenant_no="zkxw", + create_dept_id="", + create_time=t1, + update_time=t1, + del_flag=0, +), +``` + +On `inst-alice-done` set `initiator_dept_id="dept-sales"`. +On `inst-bob-run` set `initiator_dept_id="dept-sales-east"`. + +- [ ] **Step 2: Add three tests** + +```python +def test_dept_filter_includes_descendants(self): + data = oa_engine.list_instance_history( + self.db, + tenant_no="zkxw", + user_id="u-a", + can_view_all=True, + app_id="app-leave", + initiator_dept_id="dept-sales", + ) + self.assertEqual(data["total"], 2) + ids = {r["id"] for r in data["list"]} + self.assertEqual(ids, {"inst-alice-done", "inst-bob-run"}) + +def test_dept_filter_child_only(self): + data = oa_engine.list_instance_history( + self.db, + tenant_no="zkxw", + user_id="u-a", + can_view_all=True, + app_id="app-leave", + initiator_dept_id="dept-sales-east", + ) + self.assertEqual(data["total"], 1) + self.assertEqual(data["list"][0]["id"], "inst-bob-run") + +def test_dept_filter_respects_self_scope(self): + data = oa_engine.list_instance_history( + self.db, + tenant_no="zkxw", + user_id="u-a", + can_view_all=False, + app_id="app-leave", + initiator_dept_id="dept-sales-east", + ) + self.assertEqual(data["total"], 0) +``` + +- [ ] **Step 3: Run tests — expect FAIL (unknown kwarg)** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_instance_history.py::TestOaInstanceHistory::test_dept_filter_includes_descendants tests/test_oa_instance_history.py::TestOaInstanceHistory::test_dept_filter_child_only tests/test_oa_instance_history.py::TestOaInstanceHistory::test_dept_filter_respects_self_scope -v +``` + +Expected: FAIL with `TypeError: list_instance_history() got an unexpected keyword argument 'initiator_dept_id'` (or similar). + +- [ ] **Step 4: Commit** + +```bash +git add backend/tests/test_oa_instance_history.py +git commit -m "test(oa): add history initiator dept filter cases" +``` + +--- + +### Task 2: Backend engine + API for initiatorDeptId + +**Files:** +- Modify: `backend/app/services/oa_engine.py` +- Modify: `backend/app/api/oa_approval.py` + +- [ ] **Step 1: Add helper near history list helpers in `oa_engine.py`** + +Near `_apply_instance_list_filters` / `list_instance_history`, add: + +```python +def _filter_by_initiator_dept(q, db: Session, *, tenant_no: str, initiator_dept_id: Optional[str]): + """按发起部门(含下级)过滤;空参数不改 query。""" + did = (initiator_dept_id or "").strip() + if not did: + return q + from app.core.data_scope import _get_child_dept_ids + + dept_ids = _get_child_dept_ids(db, did, tenant_no) + if not dept_ids: + return q.filter(OaInstance.id == "") # 无匹配 + return q.filter(OaInstance.initiator_dept_id.in_(dept_ids)) +``` + +- [ ] **Step 2: Wire into `list_instance_history`** + +Add param `initiator_dept_id: Optional[str] = None` after `instance_status`. + +After the `can_view_all` block (or after instance_id like filter), apply: + +```python +q = _filter_by_initiator_dept( + q, db, tenant_no=tenant_no, initiator_dept_id=initiator_dept_id +) +``` + +Place it **after** `can_view_all` narrowing and before or after other filters — AND is commutative; prefer after `can_view_all` and before pagination count. + +- [ ] **Step 3: Wire into `_history_export_query` and `export_instance_history`** + +Add the same optional param to both; call `_filter_by_initiator_dept` inside `_history_export_query` after `can_view_all` block (same as list). Pass through from `export_instance_history` into `_history_export_query`. + +- [ ] **Step 4: API Query params** + +In `list_oa_instance_history` and `export_oa_instance_history`: + +```python +initiatorDeptId: str = Query(""), +``` + +Pass `initiator_dept_id=initiatorDeptId or None` into engine calls. + +- [ ] **Step 5: Run tests — expect PASS** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_oa_instance_history.py -v +``` + +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/services/oa_engine.py backend/app/api/oa_approval.py +git commit -m "feat(oa): filter instance history by initiator dept subtree" +``` + +--- + +### Task 3: Frontend API types + history page dept filter + +**Files:** +- Modify: `web-admin/src/api/oaApproval.ts` +- Modify: `web-admin/src/views/oa-approval/history/index.vue` + +- [ ] **Step 1: Extend API param types** + +In `fetchOaInstanceHistory` and `fetchOaInstanceHistoryExport` params, add: + +```typescript +initiatorDeptId?: string +``` + +(Params object is already forwarded as Query — no body change.) + +- [ ] **Step 2: Add dept tree select in template** + +In `filter-grid`, after「完成时间」item (before「申请人」), insert: + +```vue + + + + + +``` + +- [ ] **Step 3: Script — imports, state, tree helpers, wire load/export/reset** + +Add imports: + +```typescript +import { fetchDeptTree } from '@/api/admin' +import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path' +``` + +Extend `searchForm`: + +```typescript +const searchForm = reactive({ + instanceStatus: '', + instanceId: '', + initiatorName: '', + initiatorDeptId: '' as string +}) +``` + +Add dept tree state (mirror user page pattern, keep local helpers small): + +```typescript +type DeptTreeSelectNode = { value: string; label: string; children?: DeptTreeSelectNode[] } + +const deptTree = ref([]) + +function buildDeptTreeSelectData(nodes: Api.Dept.DeptItem[]): DeptTreeSelectNode[] { + return (nodes || []).map((n) => ({ + value: n.id, + label: n.deptName || '', + children: n.children?.length ? buildDeptTreeSelectData(n.children) : undefined + })) +} + +const deptTreeSelectData = computed(() => buildDeptTreeSelectData(deptTree.value)) + +const deptPathLabelMap = computed(() => + buildPathLabelMapFromTreeRoots(deptTree.value, { + idKey: 'id', + nameKey: 'deptName', + childrenKey: 'children' + }) +) + +function formatDeptPathLabel(nodeId: string | null | undefined) { + const id = String(nodeId ?? '').trim() + if (!id) return '' + return deptPathLabelMap.value.get(id) || '' +} + +async function loadDeptTree() { + try { + deptTree.value = (await fetchDeptTree()) || [] + } catch { + deptTree.value = [] + } +} +``` + +In `resetFilters`, clear `searchForm.initiatorDeptId = ''`. + +In `load()` params and `buildExportParams()`, when `searchForm.initiatorDeptId` is non-empty, set `params.initiatorDeptId = searchForm.initiatorDeptId`. + +Call `void loadDeptTree()` once on setup (alongside existing bootstrap/`onMounted` pattern used by this page — if page uses `onActivated`/`bootstrapFromRoute`, call `loadDeptTree` there or in `onMounted` so KeepAlive re-entry still has tree). + +- [ ] **Step 4: Manual smoke** + +1. Open `#/system-manage/oa-approval/history/?appName=...` +2. Confirm「部门」可选、路径展示、清空有效 +3. With `oa:start:history:all`: parent dept shows child-dept rows +4. Without that perm: other-dept selection yields empty (own rows only) + +- [ ] **Step 5: Update graph + commit** + +```bash +./scripts/update-graph.sh backend +./scripts/update-graph.sh frontend +git add web-admin/src/api/oaApproval.ts web-admin/src/views/oa-approval/history/index.vue backend/graphify-out web-admin/graphify-out +git commit -m "feat(web-admin): add OA history department filter" +``` + +--- + +## Spec coverage checklist + +| Spec requirement | Task | +|------------------|------| +| 检索区部门树(全部部门) | Task 3 | +| 本级+下级匹配 `initiator_dept_id` | Task 2 (`_get_child_dept_ids`) | +| 全员权限模型不变 | Task 1 `test_dept_filter_respects_self_scope` + Task 2 | +| 列表 + 导出共用参数 | Task 2 + Task 3 | +| 单测 | Task 1–2 | +| 非目标:不改数据范围 / 不改移动端 | 无对应改动 | + +--- + +## Self-review notes + +- Param name consistent: API `initiatorDeptId` ↔ engine `initiator_dept_id` ↔ form `initiatorDeptId` +- Empty / unknown dept → empty list via `IN` empty/`id == ""` fallback +- No placeholders left in steps