|
|
@@ -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
|
|
|
+<el-form-item label="部门">
|
|
|
+ <el-tree-select
|
|
|
+ v-model="searchForm.initiatorDeptId"
|
|
|
+ :data="deptTreeSelectData"
|
|
|
+ node-key="value"
|
|
|
+ :props="{ label: 'label', children: 'children' }"
|
|
|
+ check-strictly
|
|
|
+ filterable
|
|
|
+ clearable
|
|
|
+ placeholder="请选择部门"
|
|
|
+ style="width: 100%"
|
|
|
+ >
|
|
|
+ <template #label="{ value }">
|
|
|
+ {{ formatDeptPathLabel(value) }}
|
|
|
+ </template>
|
|
|
+ </el-tree-select>
|
|
|
+</el-form-item>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **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<Api.Dept.DeptItem[]>([])
|
|
|
+
|
|
|
+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/<appId>?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
|