2026-08-06-oa-dept-condition.md 19 KB

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:

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:

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):
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:

from typing import Any, Dict, List, Optional, Set

(Set is new; keep existing imports.)

  • Step 4: Run tests to verify they pass
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
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:

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

    from app.services.oa_person_select import person_contains_match
    from app.services.oa_post_select import post_contains_match

Replace with:

    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:

            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:

            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
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
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):

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

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:

  if (type === 'person' || type === 'ownerPerson' || type === 'post' || type === 'initiator') {
    return [{ value: 'contains', label: '包含' }]
  }

Replace with:

  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)
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
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:

  import { fetchPostList, fetchUserList } from '@/api/admin'

to:

  import { fetchDeptTree, fetchPostList, fetchUserList } from '@/api/admin'

After postOptions ref, add:

  type DeptTreeNode = { value: string; label: string; children?: DeptTreeNode[] }

  const deptTreeData = ref<DeptTreeNode[]>([])
  • Step 2: Add helpers next to isPostField:
  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:

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

    if (props.candidates.some((c) => c.type === 'dept' || c.type === 'ownerDept')) {
      loadDepts()
    }

Add watch:

  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:

          <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):

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

- 条件分支:`ownerPerson` 与 `person` 同属人员类(`contains`);`ownerDept` 暂不进条件候选(与现有 `dept` 一致)。

with:

- 条件分支:`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:

**状态:** 已实现

Check off §8 验收标准 items that were verified.

  • Step 3: Commit
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
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:

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.