Browse Source

docs(oa): mark dept condition spec implemented

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 months ago
parent
commit
ac4c3d1886

+ 656 - 0
docs/superpowers/plans/2026-08-06-oa-dept-condition.md

@@ -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.

+ 1 - 1
docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md

@@ -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 打印 / 关联展示
 

+ 213 - 0
docs/superpowers/specs/2026-08-06-oa-dept-condition-design.md

@@ -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