docs(oa): mark dept condition spec implemented

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-06 15:41:08 +08:00
1 parent 6beb243604
commit ac4c3d1886
3 files changed
+870 -1

No files matched your search

@@ -0,0 +1,656 @@
# OA Dept / OwnerDept Condition Contains 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:** Let form controls `dept` and `ownerDept` participate in workflow condition branches with person-parity `contains` matching (exact department id intersection) and a tree multi-select value picker.
**Architecture:** Mirror the existing person/post path: whitelist types in front/back `CONDITION_ELIGIBLE_TYPES`; add `extract_dept_ids` / `dept_contains_match` in `oa_dept_select.py`; OR them into `oa_engine._eval_condition_rules` contains branch; extend `ConditionRuleEditor` with `el-tree-select` fed by `fetchDeptTree`. Matching is exact id only — tree UI is for picking, not hierarchical match.
**Tech Stack:** FastAPI · pytest · Vue 3 · Element Plus · existing ConditionRuleEditor / oa-condition-fields / oa_engine contains path
**Spec:** `docs/superpowers/specs/2026-08-06-oa-dept-condition-design.md`
---
## File map
| Path | Responsibility |
|------|----------------|
| Modify: `backend/app/services/oa_dept_select.py` | `extract_dept_ids` + `dept_contains_match` |
| Modify: `backend/tests/test_oa_dept_select.py` | Unit tests for extract/contains |
| Modify: `backend/app/services/oa_engine.py` | OR `dept_contains_match` into contains eval |
| Modify: `backend/tests/test_oa_engine.py` | Engine condition tests for dept |
| Modify: `backend/app/services/oa_publish.py` | Add `dept` / `ownerDept` to `CONDITION_ELIGIBLE_TYPES` |
| Modify: `backend/tests/test_oa_publish.py` | Publish allows flagged dept field in rules |
| Modify: `web-admin/src/components/oa-approval/oa-condition-fields.ts` | Frontend whitelist + ops |
| Modify: `web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue` | Dept-like RHS tree multi-select |
| Optional: `docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md` | Strike “ownerDept 暂不进条件” note |
| Modify: `docs/superpowers/specs/2026-08-06-oa-dept-condition-design.md` | Mark status implemented when done |
---
### Task 1: `dept_contains_match` helpers (TDD)
**Files:**
- Modify: `backend/app/services/oa_dept_select.py`
- Modify: `backend/tests/test_oa_dept_select.py`
- [ ] **Step 1: Write failing tests** — append to `TestOaDeptSelect` in `backend/tests/test_oa_dept_select.py`:
```python
from app.services.oa_dept_select import (
extract_dept_ids,
dept_contains_match,
validate_dept_form_values,
)
def test_extract_and_contains(self):
self.assertEqual(extract_dept_ids({"id": "d1", "name": "研发"}), {"d1"})
self.assertEqual(
extract_dept_ids([{"id": "d1", "name": "A"}, {"id": "d2", "name": "B"}]),
{"d1", "d2"},
)
self.assertTrue(
dept_contains_match(
[{"id": "d1", "name": "A"}],
["d1", "d9"],
)
)
self.assertFalse(
dept_contains_match(
[{"id": "d3", "name": "C"}],
["d1", "d2"],
)
)
# 精确匹配:下级 id 不等于上级 id
self.assertFalse(
dept_contains_match(
{"id": "d1-child", "name": "前端组"},
["d1"],
)
)
```
Update the import at the top of the test file to include `extract_dept_ids` and `dept_contains_match` (keep existing `validate_dept_form_values` import).
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py::TestOaDeptSelect::test_extract_and_contains -v
```
Expected: FAIL (import error or `AttributeError`: `extract_dept_ids` / `dept_contains_match` missing).
- [ ] **Step 3: Implement helpers** — append to `backend/app/services/oa_dept_select.py` (after `validate_dept_form_values`, mirror `oa_person_select.py`):
```python
def extract_dept_ids(raw: Any) -> Set[str]:
ids: Set[str] = set()
items = raw if isinstance(raw, list) else ([raw] if isinstance(raw, dict) else [])
for item in items:
if not isinstance(item, dict):
continue
did = str(item.get("id") or "").strip()
if did:
ids.add(did)
return ids
def dept_contains_match(form_value: Any, cond_ids: Any) -> bool:
form_ids = extract_dept_ids(form_value)
if isinstance(cond_ids, str):
want = {cond_ids.strip()} if cond_ids.strip() else set()
elif isinstance(cond_ids, list):
want = {str(x).strip() for x in cond_ids if str(x).strip()}
else:
want = set()
return bool(form_ids & want)
```
Add to the module imports at top:
```python
from typing import Any, Dict, List, Optional, Set
```
(`Set` is new; keep existing imports.)
- [ ] **Step 4: Run tests to verify they pass**
```bash
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py -v
```
Expected: PASS (all tests in file).
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/oa_dept_select.py backend/tests/test_oa_dept_select.py
git commit -m "$(cat <<'EOF'
feat(oa): add dept_contains_match for condition id intersection
EOF
)"
```
---
### Task 2: Engine wires dept contains (TDD)
**Files:**
- Modify: `backend/app/services/oa_engine.py` (`_eval_condition_rules`)
- Modify: `backend/tests/test_oa_engine.py`
- [ ] **Step 1: Write failing engine tests** — append after `test_condition_contains_person_single_object` in `TestOaEngineHelpers`:
```python
def test_condition_contains_dept(self):
groups = [
{
"name": "hit",
"rules": [
{"field": "Fdept", "op": "contains", "value": ["d1", "d2"]}
],
"child": {"id": "n1", "type": "end"},
},
{"name": "default", "rules": [], "child": {"id": "n2", "type": "end"}},
]
g = _match_condition_group(
groups,
{"Fdept": [{"id": "d2", "name": "研发"}]},
)
self.assertEqual(g["name"], "hit")
g2 = _match_condition_group(
groups,
{"Fdept": [{"id": "d9", "name": "其他"}]},
)
self.assertEqual(g2["name"], "default")
def test_condition_contains_owner_dept_single_object(self):
groups = [
{
"name": "hit",
"rules": [{"field": "Fod", "op": "contains", "value": ["d1"]}],
"child": {"id": "n1", "type": "end"},
},
{"name": "default", "isDefault": True, "child": {"id": "n2", "type": "end"}},
]
g = _match_condition_group(
groups, {"Fod": {"id": "d1", "name": "研发"}}
)
self.assertEqual(g["name"], "hit")
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_dept tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_owner_dept_single_object -v
```
Expected: FAIL — dept form values do not match (person/post helpers return False → group falls through to default).
- [ ] **Step 3: Wire engine** — in `backend/app/services/oa_engine.py` function `_eval_condition_rules`, change the contains branch imports and OR:
Find:
```python
from app.services.oa_person_select import person_contains_match
from app.services.oa_post_select import post_contains_match
```
Replace with:
```python
from app.services.oa_person_select import person_contains_match
from app.services.oa_post_select import post_contains_match
from app.services.oa_dept_select import dept_contains_match
```
Find:
```python
form_val = form_data.get(field)
if not (person_contains_match(form_val, val) or post_contains_match(form_val, val)):
return False
```
Replace with:
```python
form_val = form_data.get(field)
if not (
person_contains_match(form_val, val)
or post_contains_match(form_val, val)
or dept_contains_match(form_val, val)
):
return False
```
Also update the docstring of `_eval_condition_rules` from 「人员/职位」to 「人员/职位/部门」.
- [ ] **Step 4: Run tests to verify they pass**
```bash
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && pytest tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_person tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_person_single_object tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_dept tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_owner_dept_single_object tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_initiator -v
```
Expected: PASS (dept new + person/initiator regression).
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/oa_engine.py backend/tests/test_oa_engine.py
git commit -m "$(cat <<'EOF'
feat(oa): evaluate dept contains in condition rules
EOF
)"
```
---
### Task 3: Publish whitelist (TDD)
**Files:**
- Modify: `backend/app/services/oa_publish.py`
- Modify: `backend/tests/test_oa_publish.py`
- [ ] **Step 1: Write failing publish test** — append near other condition field tests in `TestConditionPublish` (`backend/tests/test_oa_publish.py`):
```python
def test_dept_condition_field_allowed(self):
errs = validate_condition_rules_against_form(
{
"rule": [
{
"type": "dept",
"field": "Fdept",
"title": "部门",
"useInCondition": True,
"$required": True,
"props": {"multiple": True},
}
]
},
_condition_flow(
rules=[
{"field": "Fdept", "op": "contains", "value": ["d1", "d2"]}
]
),
)
self.assertEqual(errs, [])
def test_owner_dept_condition_field_allowed(self):
errs = validate_condition_rules_against_form(
{
"rule": [
{
"type": "ownerDept",
"field": "Fod",
"title": "归属部门",
"useInCondition": True,
"$required": True,
}
]
},
_condition_flow(
rules=[{"field": "Fod", "op": "contains", "value": ["d1"]}]
),
)
self.assertEqual(errs, [])
```
Ensure `validate_condition_rules_against_form` and `_condition_flow` are already imported/defined in that file (they are).
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && pytest tests/test_oa_publish.py::TestConditionPublish::test_dept_condition_field_allowed tests/test_oa_publish.py::TestConditionPublish::test_owner_dept_condition_field_allowed -v
```
Expected: FAIL — `条件引用了无效字段:Fdept` (or Fod) because type not in `CONDITION_ELIGIBLE_TYPES`.
- [ ] **Step 3: Update whitelist** — in `backend/app/services/oa_publish.py`, extend:
```python
CONDITION_ELIGIBLE_TYPES = frozenset(
{
"input",
"inputNumber",
"number",
"money",
"radio",
"select",
"datePicker",
"date",
"person",
"ownerPerson",
"post",
"dept",
"ownerDept",
}
)
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && pytest tests/test_oa_publish.py -k "dept_condition or initiator_condition or field_not_flagged" -v
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/oa_publish.py backend/tests/test_oa_publish.py
git commit -m "$(cat <<'EOF'
feat(oa): allow dept and ownerDept in condition publish whitelist
EOF
)"
```
---
### Task 4: Frontend condition field whitelist
**Files:**
- Modify: `web-admin/src/components/oa-approval/oa-condition-fields.ts`
- [ ] **Step 1: Extend `CONDITION_ELIGIBLE_TYPES`**
In `web-admin/src/components/oa-approval/oa-condition-fields.ts`, change the Set to include dept types:
```typescript
export const CONDITION_ELIGIBLE_TYPES = new Set([
'input',
'inputNumber',
'number',
'money',
'person',
'ownerPerson',
'dept',
'ownerDept',
'post',
'radio',
'select',
'datePicker',
'date'
])
```
- [ ] **Step 2: Extend `opsForFieldType`**
Find:
```typescript
if (type === 'person' || type === 'ownerPerson' || type === 'post' || type === 'initiator') {
return [{ value: 'contains', label: '包含' }]
}
```
Replace with:
```typescript
if (
type === 'person' ||
type === 'ownerPerson' ||
type === 'dept' ||
type === 'ownerDept' ||
type === 'post' ||
type === 'initiator'
) {
return [{ value: 'contains', label: '包含' }]
}
```
(`oa-fc-host/App.vue` already gates the switch on `CONDITION_ELIGIBLE_TYPES` — no host change required.)
- [ ] **Step 3: Smoke-check TypeScript of the module** (optional quick grep)
```bash
cd /Users/wuzilong/work/00code/zkxw/web-admin && rg "CONDITION_ELIGIBLE_TYPES|opsForFieldType" src/components/oa-approval/oa-condition-fields.ts
```
Expected: both `dept` and `ownerDept` appear in the Set and ops branch.
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/components/oa-approval/oa-condition-fields.ts
git commit -m "$(cat <<'EOF'
feat(oa): whitelist dept and ownerDept for condition participation
EOF
)"
```
---
### Task 5: ConditionRuleEditor tree multi-select
**Files:**
- Modify: `web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue`
- [ ] **Step 1: Import `fetchDeptTree` and add dept tree state**
In script imports, change:
```typescript
import { fetchPostList, fetchUserList } from '@/api/admin'
```
to:
```typescript
import { fetchDeptTree, fetchPostList, fetchUserList } from '@/api/admin'
```
After `postOptions` ref, add:
```typescript
type DeptTreeNode = { value: string; label: string; children?: DeptTreeNode[] }
const deptTreeData = ref<DeptTreeNode[]>([])
```
- [ ] **Step 2: Add helpers** next to `isPostField`:
```typescript
function isDeptLikeField(field: string) {
const t = meta(field)?.type
return t === 'dept' || t === 'ownerDept'
}
function mapDeptTree(nodes: Api.Dept.DeptItem[]): DeptTreeNode[] {
return (nodes || []).map((n) => ({
value: String(n.id),
label: String(n.deptName || n.id),
children: n.children?.length ? mapDeptTree(n.children) : undefined
}))
}
```
Update `defaultValueForType`:
```typescript
function defaultValueForType(type: string, opts?: ConditionCandidate['options']) {
if (
type === 'person' ||
type === 'ownerPerson' ||
type === 'dept' ||
type === 'ownerDept' ||
type === 'post' ||
type === 'initiator'
) {
return [] as string[]
}
return opts?.[0]?.value ?? ''
}
```
- [ ] **Step 3: Add `loadDepts` + mount/watch**
```typescript
async function loadDepts() {
try {
const tree = (await fetchDeptTree()) || []
deptTreeData.value = mapDeptTree(tree as Api.Dept.DeptItem[])
} catch {
deptTreeData.value = []
}
}
```
In `onMounted`, after the post block:
```typescript
if (props.candidates.some((c) => c.type === 'dept' || c.type === 'ownerDept')) {
loadDepts()
}
```
Add watch:
```typescript
watch(
() => props.candidates.some((c) => c.type === 'dept' || c.type === 'ownerDept'),
(need) => {
if (need && !deptTreeData.value.length) loadDepts()
}
)
```
- [ ] **Step 4: Template RHS for dept** — insert **after** the person `el-select` block and **before** the post `el-select` (or after post; order among non-person is free as long as `v-else-if` chain is correct). Recommended chain:
1. `isPersonLikeField` → user select (unchanged)
2. `isPostField` → post select (unchanged)
3. `isDeptLikeField` → tree select (new)
4. options / input (unchanged)
New block:
```vue
<el-tree-select
v-else-if="isDeptLikeField(rule.field)"
:model-value="personValueIds(rule.value)"
multiple
filterable
clearable
check-strictly
collapse-tags
collapse-tags-tooltip
default-expand-all
:data="deptTreeData"
:props="{ value: 'value', label: 'label', children: 'children' }"
placeholder="选择部门(任一命中)"
:disabled="disabled"
class="cond-rule-editor__dept-select"
@update:model-value="(v) => onValueChange(idx, Array.isArray(v) ? v.map(String) : [])"
/>
```
Reuse `personValueIds` for normalizing `value` to `string[]` (same shape).
Optional style (mirror post):
```scss
.cond-rule-editor__dept-select :deep(.el-select__selection) {
flex-wrap: wrap;
}
```
- [ ] **Step 5: Manual UI check**
1. Open designer →「2 表单设计」→ 拖入「部门」与「归属部门」→ 右侧应出现「参与流程条件分支」→ 勾选。
2. 「3 流程设计」→ 条件分支 → 字段可选这两项;运算符仅「包含」;值侧为部门树多选。
3. 保存/发布草稿(若环境可用)应通过。
- [ ] **Step 6: Commit**
```bash
git add web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue
git commit -m "$(cat <<'EOF'
feat(oa): tree multi-select for dept condition values
EOF
)"
```
---
### Task 6: Spec footnotes + mark implemented
**Files:**
- Modify: `docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md` (one line)
- Modify: `docs/superpowers/specs/2026-08-06-oa-dept-condition-design.md`
- Optional: checkboxes in this plan file as tasks complete
- [ ] **Step 1: Update owner-fields historical note**
In `docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md` §4.2, replace:
```markdown
- 条件分支:`ownerPerson` 与 `person` 同属人员类(`contains`);`ownerDept` 暂不进条件候选(与现有 `dept` 一致)。
```
with:
```markdown
- 条件分支:`ownerPerson` 与 `person` 同属人员类(`contains`);`ownerDept` 与 `dept` 同属部门类(`contains`,精确 id)。详见 `2026-08-06-oa-dept-condition-design.md`。
```
- [ ] **Step 2: Mark dept condition spec status**
In `docs/superpowers/specs/2026-08-06-oa-dept-condition-design.md` header, set:
```markdown
**状态:** 已实现
```
Check off §8 验收标准 items that were verified.
- [ ] **Step 3: Commit**
```bash
git add docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md docs/superpowers/specs/2026-08-06-oa-dept-condition-design.md
git commit -m "$(cat <<'EOF'
docs(oa): mark dept condition spec implemented
EOF
)"
```
- [ ] **Step 4: Final regression**
```bash
cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && pytest tests/test_oa_dept_select.py tests/test_oa_engine.py tests/test_oa_publish.py tests/test_oa_person_select.py tests/test_oa_post_select.py -v
```
Expected: PASS.
After frontend changes, if graph update is required by repo rules:
```bash
cd /Users/wuzilong/work/00code/zkxw && ./scripts/update-graph.sh frontend
```
---
## Plan self-review
| Spec requirement | Task |
|------------------|------|
| Whitelist `dept` / `ownerDept` (form switch) | Task 4 (+ host already gated) |
| Exact id `contains` match | Task 1–2 |
| Condition value tree multi-select | Task 5 |
| Publish eligible types | Task 3 |
| Tests extract/engine/publish | Task 1–3 |
| Non-goals (subtree, initiator dept, ==) | Not implemented (intentional) |
| Update owner-fields note | Task 6 |
No TBD placeholders. Function names consistent: `extract_dept_ids`, `dept_contains_match` across tasks.
@@ -83,7 +83,7 @@
- 发布白名单纳入 `ownerPerson` / `ownerDept`。
- 校验值格式同单选 person / dept。
- `allowModify` 为假时:提交前强制写入发起人 id/姓名、主部门 id/名称。
- 条件分支:`ownerPerson` 与 `person` 同属人员类(`contains`);`ownerDept` 暂不进条件候选(与现有 `dept` 一致)。
- 条件分支:`ownerPerson` 与 `person` 同属人员类(`contains`);`ownerDept` 与 `dept` 同属部门类(`contains`,精确 id)。详见 `2026-08-06-oa-dept-condition-design.md`。
### 4.3 打印 / 关联展示
@@ -0,0 +1,213 @@
# OA 条件分支:部门 / 归属部门「包含」
**日期:** 2026-08-06
**范围:** `web-admin` 审批设计器步骤 2(表单设计)勾选 + 步骤 3(流程设计)条件值;`backend` 条件匹配、发布白名单、单测
**关联:**
- `docs/superpowers/specs/2026-07-15-oa-condition-from-form-design.md`(表单驱动条件)
- `docs/superpowers/specs/2026-07-30-oa-person-select-design.md`(`contains` / 人员多选)
- `docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md`(曾约定 `ownerDept` 暂不进条件,**本期废止该限制**)
**状态:** 已实现
---
## 1. 背景与目标
表单「人员 / 归属人」已可勾选参与条件分支(`contains` + id 交集)。「部门 / 归属部门」值结构同为 `{id, name}`(可单可多),但前后端白名单均未纳入,条件编辑器也无部门值选择器。
### 1.1 产品结论(已确认)
| 项 | 结论 |
|----|------|
| 控件 | `dept`、`ownerDept` 均可勾选「参与流程条件分支」 |
| 匹配语义 | **精确 id**;表单填下级部门 ≠ 条件选中上级(不含树包含) |
| 运算符 | 仅「包含」`contains` |
| 条件值 | `string[]` 部门 id;多选;与表单部门 id **有交集即命中**(OR) |
| 条件值 UI | **树形多选**(与表单部门控件同源数据 `fetchDeptTree`) |
| 必填联动 | 勾选 ⇒ 强制必填并锁定,与现网一致 |
| 实现路径 | 镜像人员路径(方案 1) |
### 1.2 与人员条件对照
| | `person` / `ownerPerson` | `dept` / `ownerDept` |
|---|--------------------------|----------------------|
| 入池 | `useInCondition` + 白名单 | 同左(本期补白名单) |
| op | `contains` | `contains` |
| value | 用户 id[] | 部门 id[] |
| 命中 | `extract_*_ids(form) ∩ value ≠ ∅` | 同左 |
| RHS UI | 用户扁平多选 | **部门树多选** |
### 1.3 非目标
- 含下级 / 祖先匹配、部门路径字符串比较
- 系统变量「发起人部门」
- `==` / `!=` / 「不包含」
- 改写公式引擎核心
- 改部门表单控件本身的选人/选部门交互
---
## 2. 架构
```
[2 表单设计] [3 流程设计]
dept / ownerDept ConditionRuleEditor
· useInCondition 开关可见 · 字段候选含勾选后的部门控件
· 勾选 ⇒ required 锁定 · op 仅「包含」
↓ · RHS:el-tree-select 多选部门 id
form_schema_json rules: { field, op:'contains', value: string[] }
↘ ↙
发布 / 运行引擎
· CONDITION_ELIGIBLE 含 dept、ownerDept
· dept_contains_match(form_val, value)
```
**职责边界**
- **表单:** 谁可进条件池(白名单 + 勾选)。
- **流程编辑器:** 树多选写出 `value: string[]`。
- **引擎:** `contains` 时对部门值走 `dept_contains_match`(与 person/post 并列)。
---
## 3. 数据契约(锁定)
```ts
// 表单填报值(已有)
// 单选:{ id: string, name: string }
// 多选:Array<{ id: string, name: string }>
// ownerDept:始终单选对象
// 条件规则(与人员同形)
{ field: string, op: 'contains', value: string[] } // length >= 1 在发布/完善规则时由现有校验约束
```
命中:
```
extract_dept_ids(form_data[field]) ∩ Set(value) ≠ ∅
```
`extract_dept_ids`:与 `extract_person_ids` 同构——支持单对象或对象数组,只取非空 `id`。
**预览文案:** `格式化字段名 包含 部门A、部门B`(有树 map 用名称,否则 id)。
---
## 4. 前端
### 4.1 白名单与运算符
`web-admin/src/components/oa-approval/oa-condition-fields.ts`:
- `CONDITION_ELIGIBLE_TYPES` 增加 `'dept'`、`'ownerDept'`
- `opsForFieldType`:`dept` / `ownerDept` 与 person 一样仅返回 `contains`
- `formatConditionPreview`:沿用现有 op 文案「包含」(无需新 op)
`oa-fc-host/App.vue` 已用 `CONDITION_ELIGIBLE_TYPES` 控制开关显隐,**加类型即可**,不必改宿主逻辑。
### 4.2 条件编辑器
`ConditionRuleEditor.vue`:
- `isDeptLikeField`:`type === 'dept' || type === 'ownerDept'`
- RHS:`el-tree-select`(`multiple`、`filterable`、`check-strictly`、`collapse-tags`),数据来自 `fetchDeptTree`,节点展示本级 `deptName`(与表单部门控件一致;**不必**在条件下拉里拼全路径)
- `defaultValueForType`:部门类返回 `[]`
- 挂载时若候选含部门类字段则加载部门树(可与用户/职位加载并列)
**不**复用完整 `OaDeptSelect` 组件(表单控件含窄屏抽屉、emit 对象值);条件侧只存 id[],用轻量 tree-select 即可。
### 4.3 取消勾选拦截
沿用现有「流程仍引用该 field → 禁止取消 useInCondition」。部门字段无特殊伪字段,无需改引用集合逻辑。
---
## 5. 后端
### 5.1 `oa_dept_select.py`
新增:
- `extract_dept_ids(raw) -> Set[str]`
- `dept_contains_match(form_value, cond_ids) -> bool`
(实现与 `person_contains_match` / `post_contains_match` 对齐)
### 5.2 引擎
`oa_engine._eval_condition_rules` 中 `op == "contains"` 且非 `__initiator__` 时:
```python
if not (
person_contains_match(form_val, val)
or post_contains_match(form_val, val)
or dept_contains_match(form_val, val)
):
return False
```
保持与现网「多 helper OR」一致:错误形态抽取为空集,不会误命中。
### 5.3 发布
`oa_publish.CONDITION_ELIGIBLE_TYPES` 增加 `"dept"`、`"ownerDept"`。
`index_condition_fields` / `validate_condition_rules_against_form` 自动吃到新类型;**不要求**为部门单独加「发起人式」校验块(与人员表单字段同级:依赖流程树「规则须完善」+ 字段在 index 内)。
可选增强(非必须):对 `dept`/`ownerDept`/`person`/`ownerPerson`/`post` 统一校验 `op == contains` 且 `value` 为非空 string 列表——若本期做,须覆盖人员回归;**默认可不做**,避免扩大范围。
---
## 6. 测试
| 场景 | 期望 |
|------|------|
| 表单多选部门与 value 有交集 | `dept_contains_match` True |
| 仅下级 id、条件为上级 id | False(精确匹配) |
| 单对象 `ownerDept` 命中 | True |
| 引擎条件组含 dept contains | 正确分流 / 走默认 |
| 发布:勾选 dept 字段可被 rules 引用 | 通过 |
| 回归 person / post / initiator contains | 通过 |
文件:`backend/tests/test_oa_dept_select.py`(补 extract/contains)、`backend/tests/test_oa_engine.py`(补一条 dept 条件)。
---
## 7. 文件清单
**前端:**
`oa-condition-fields.ts`、`ConditionRuleEditor.vue`(必要时样式微调)
**后端:**
`oa_dept_select.py`、`oa_engine.py`、`oa_publish.py`、
`tests/test_oa_dept_select.py`、`tests/test_oa_engine.py`
**文档(旁注,非阻塞):**
`2026-07-30-oa-owner-fields-design.md` §4.2 一句改为「`ownerDept` 与 `dept` 同属部门类(`contains`)」——可选 follow-up,避免历史规格误导。
---
## 8. 验收标准
- [x] 表单设计选中部门/归属部门时,出现「参与流程条件分支」且勾选后必填锁定
- [x] 流程条件可选该字段;仅「包含」;RHS 为部门树多选
- [x] 运行时按部门 id 精确交集分流;下级不命中上级条件
- [x] 发布与单测通过;人员/职位/发起人条件无回归
---
## 9. 实现顺序
1. `extract_dept_ids` + `dept_contains_match` + 单测
2. 引擎 OR 接入 + 引擎单测
3. 发布白名单
4. 前端白名单 + `ConditionRuleEditor` 树多选
5. 设计器手测(勾选 → 配条件 → 预览/发起)
---
## 10. Spec 自检
- [x] 无 TBD / 占位未决
- [x] 与人员路径一致,无互相矛盾的 op/value 约定
- [x] 范围单一:仅部门进条件;不含发起人部门、不含树包含
- [x] 「精确匹配」与「树形多选 UI」语义已区分:UI 树仅便于挑选,匹配仍比 id