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 <cursoragent@cursor.com>
This commit is contained in:
1 parent
3ecd8f4a74
commit
6cd22b218d
1 file changed
+346
@@ -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
|
||||
Reference in new issue
Block a user