diff --git a/docs/superpowers/plans/2026-08-06-oa-dept-condition.md b/docs/superpowers/plans/2026-08-06-oa-dept-condition.md new file mode 100644 index 00000000..c49063ac --- /dev/null +++ b/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([]) +``` + +- [ ] **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 + +``` + +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. diff --git a/docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md b/docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md index bf7d85af..5f085397 100644 --- a/docs/superpowers/specs/2026-07-30-oa-owner-fields-design.md +++ b/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 打印 / 关联展示 diff --git a/docs/superpowers/specs/2026-08-06-oa-dept-condition-design.md b/docs/superpowers/specs/2026-08-06-oa-dept-condition-design.md new file mode 100644 index 00000000..4b97268a --- /dev/null +++ b/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