diff --git a/docs/superpowers/plans/2026-07-30-oa-initiator-condition.md b/docs/superpowers/plans/2026-07-30-oa-initiator-condition.md new file mode 100644 index 00000000..e3454d36 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-oa-initiator-condition.md @@ -0,0 +1,326 @@ +# OA Initiator Contains Condition 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 workflow condition branches select system field「发起人」with contains-only multi-user matching (`initiator_user_id ∈ value[]`), without requiring a form control. + +**Architecture:** Inject pseudo-field `__initiator__` into condition candidates. Engine `_eval_condition_rules` / `_match_condition_group` take `initiator_user_id` and special-case that field. Publish validation whitelists the system field and rejects empty contains values. Frontend reuses person multi-select UI for `type === 'initiator'`. + +**Tech Stack:** FastAPI · pytest · Vue 3 · existing ConditionRuleEditor / oa-condition-fields / oa_engine contains path + +**Spec:** `docs/superpowers/specs/2026-07-30-oa-initiator-condition-design.md` + +--- + +## File map + +| Path | Responsibility | +|------|----------------| +| Modify: `backend/app/services/oa_engine.py` | Pass `initiator_user_id` into condition match; handle `__initiator__` | +| Modify: `backend/tests/test_oa_engine.py` | Initiator contains unit tests | +| Modify: `backend/app/services/oa_publish.py` | Whitelist + validate system field rules | +| Modify: `backend/tests/test_oa_publish.py` | Publish validation cases | +| Modify: `web-admin/src/components/oa-approval/oa-condition-fields.ts` | Constant, inject candidate, ops, filter refs for form lock | +| Modify: `web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue` | Treat `initiator` like `person` for RHS | +| Modify: `web-admin/src/views/oa-approval/designer/index.vue` | Filter `__initiator__` out of `flowConditionFields` sent to form host | +| Modify: `docs/superpowers/specs/2026-07-30-oa-initiator-condition-design.md` | Mark implemented | + +--- + +### Task 1: Engine initiator matching (TDD) + +**Files:** +- Modify: `backend/app/services/oa_engine.py` +- Modify: `backend/tests/test_oa_engine.py` + +- [ ] **Step 1: Write failing tests** in `TestOaEngineHelpers`: + +```python +def test_condition_contains_initiator(self): + groups = [ + { + "name": "hit", + "rules": [ + { + "field": "__initiator__", + "op": "contains", + "value": ["u1", "u2"], + } + ], + "child": {"id": "n1", "type": "end"}, + }, + {"name": "default", "rules": [], "child": {"id": "n2", "type": "end"}}, + ] + g = _match_condition_group(groups, {}, initiator_user_id="u2") + self.assertEqual(g["name"], "hit") + g2 = _match_condition_group(groups, {}, initiator_user_id="u9") + self.assertEqual(g2["name"], "default") + +def test_condition_initiator_and_form_field(self): + groups = [ + { + "name": "both", + "rules": [ + {"field": "__initiator__", "op": "contains", "value": ["u1"]}, + {"field": "days", "op": ">", "value": 3}, + ], + "child": {"id": "n1", "type": "end"}, + }, + {"name": "default", "isDefault": True, "child": {"id": "n2", "type": "end"}}, + ] + self.assertEqual( + _match_condition_group( + groups, {"days": 5}, initiator_user_id="u1" + )["name"], + "both", + ) + self.assertEqual( + _match_condition_group( + groups, {"days": 5}, initiator_user_id="u9" + )["name"], + "default", + ) +``` + +- [ ] **Step 2: Run — expect FAIL** (kwargs not accepted / always default) + +```bash +cd backend && source venv/bin/activate && PYTHONPATH=. pytest \ + tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_contains_initiator \ + tests/test_oa_engine.py::TestOaEngineHelpers::test_condition_initiator_and_form_field -v +``` + +- [ ] **Step 3: Implement** + +Constant near top of engine module (or next to helpers): + +```python +INITIATOR_CONDITION_FIELD = "__initiator__" +``` + +Update signatures: + +```python +def _eval_condition_rules( + rules: List[Any], + form_data: Dict[str, Any], + *, + initiator_user_id: Optional[str] = None, +) -> bool: + ... + if op == "contains": + if str(field) == INITIATOR_CONDITION_FIELD: + uid = (initiator_user_id or "").strip() + if isinstance(val, str): + want = {val.strip()} if val.strip() else set() + elif isinstance(val, list): + want = {str(x).strip() for x in val if str(x).strip()} + else: + want = set() + if not uid or uid not in want: + return False + continue + if not person_contains_match(form_data.get(field), val): + return False + continue +``` + +```python +def _match_condition_group( + groups: List[Any], + form_data: Dict[str, Any], + *, + initiator_user_id: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + ... + if _eval_condition_rules( + rules, form_data, initiator_user_id=initiator_user_id + ): +``` + +Wire call sites to pass initiator: + +1. `advance_from` → `_match_condition_group(..., initiator_user_id=inst.initiator_user_id)` +2. `_iter_resolved_path_nodes` — add `initiator_user_id` param; thread through recursive calls and `_match_condition_group` +3. Preview walk (`_walk_preview_nodes` around line 1183) — already has `initiator_user_id`; pass it into `_match_condition_group` +4. Any other `_match_condition_group(` / `_iter_resolved_path_nodes(` callers — grep and update + +Keep keyword-only `initiator_user_id` so existing positional `(groups, data)` tests still work (default None → initiator rules fail closed / miss). + +- [ ] **Step 4: Run helpers suite PASS** + +```bash +PYTHONPATH=. pytest tests/test_oa_engine.py::TestOaEngineHelpers -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/oa_engine.py backend/tests/test_oa_engine.py +git commit -m "feat(backend): match __initiator__ contains in condition groups" +``` + +--- + +### Task 2: Publish validation (TDD) + +**Files:** +- Modify: `backend/app/services/oa_publish.py` +- Modify: `backend/tests/test_oa_publish.py` + +- [ ] **Step 1: Read existing publish tests** for pattern, then add: + +```python +def test_initiator_condition_field_allowed(self): + # flow with rules field=__initiator__, op=contains, value=["u1"] + # form_schema with no useInCondition fields + # validate_condition_rules_against_form → [] + +def test_initiator_condition_empty_value_rejected(self): + # value=[] → error mentioning 发起人 or __initiator__ or 包含 +``` + +(Exact assertion strings should match implementation.) + +- [ ] **Step 2: Run FAIL then implement in `validate_condition_rules_against_form`:** + +```python +INITIATOR_CONDITION_FIELD = "__initiator__" + +# when collecting rules: +field = (r.get("field") or "").strip() +if field == INITIATOR_CONDITION_FIELD: + op = (r.get("op") or "").strip() + val = r.get("value") + if op != "contains": + errors.append("发起人条件仅支持「包含」") + elif not isinstance(val, list) or not any(str(x).strip() for x in val): + errors.append("发起人包含条件须选择至少一名用户") + # do NOT require field in indexed + continue +if field not in indexed: + errors.append(f"条件引用了无效字段:{field}") +``` + +Dedup: still use `seen` carefully — for initiator, validate every rule instance (or validate once per rule occurrence). Prefer validating **each** initiator rule for empty value, not skipping via `seen`. + +Suggested loop change: remove `if field in seen: continue` for initiator checks; for form fields keep seen for «无效字段» once. + +- [ ] **Step 3: Tests PASS + commit** + +```bash +PYTHONPATH=. pytest tests/test_oa_publish.py -v -k initiator +git add backend/app/services/oa_publish.py backend/tests/test_oa_publish.py +git commit -m "feat(backend): allow and validate initiator condition on publish" +``` + +--- + +### Task 3: Frontend candidates + editor + +**Files:** +- Modify: `web-admin/src/components/oa-approval/oa-condition-fields.ts` +- Modify: `web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue` +- Modify: `web-admin/src/views/oa-approval/designer/index.vue` + +- [ ] **Step 1: `oa-condition-fields.ts`** + +```ts +export const INITIATOR_CONDITION_FIELD = '__initiator__' + +export const INITIATOR_CONDITION_CANDIDATE: ConditionCandidate = { + field: INITIATOR_CONDITION_FIELD, + title: '发起人', + type: 'initiator' +} + +export function listConditionCandidates(schema: unknown): ConditionCandidate[] { + const out: ConditionCandidate[] = [INITIATOR_CONDITION_CANDIDATE] + // ... existing form field loop, push to out + return out +} + +export function opsForFieldType(type: string) { + if (type === 'person' || type === 'initiator') { + return [{ value: 'contains' as const, label: '包含' }] + } + // ... +} + +/** Fields that lock form useInCondition — exclude system pseudo fields */ +export function collectFormConditionReferencedFields(flow: unknown): Set { + const all = collectConditionReferencedFields(flow) + all.delete(INITIATOR_CONDITION_FIELD) + return all +} +``` + +- [ ] **Step 2: `designer/index.vue`** + +Change: + +```ts +const flowConditionFields = computed(() => + Array.from(collectFormConditionReferencedFields(flowTreeJson.value)) +) +``` + +(Import the new helper; keep `collectConditionReferencedFields` if used elsewhere.) + +- [ ] **Step 3: `ConditionRuleEditor.vue`** + +Treat initiator like person for RHS multi-select: + +```ts +function isPersonLikeField(field: string) { + const t = meta(field)?.type + return t === 'person' || t === 'initiator' +} +``` + +In template replace `isPersonField` with `isPersonLikeField`. +`defaultValueForType`: `initiator` → `[]`. +`onMounted` / watch: load users if any candidate type is `person` **or** `initiator` (always true once initiator injected — OK to always load users when editor mounts with candidates). + +- [ ] **Step 4: Smoke** + +1. Designer → 3 流程设计 → 条件分支 → 字段下拉第一项「发起人」 +2. 无表单条件字段时仍可选发起人 +3. 包含多选人;保存发布;用名单内/外账号发起验证分支(或看流程预览) + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/components/oa-approval/oa-condition-fields.ts \ + web-admin/src/components/oa-approval/flow-tree/ConditionRuleEditor.vue \ + web-admin/src/views/oa-approval/designer/index.vue +git commit -m "feat(web-admin): add initiator system field to condition editor" +``` + +--- + +### Task 4: Spec status + checklist + +**Files:** +- Modify: `docs/superpowers/specs/2026-07-30-oa-initiator-condition-design.md` — status → 已实现 + +- [ ] Hand-test checklist from spec §8 +- [ ] Commit docs +- [ ] `./scripts/update-graph.sh all` if desired after code commits + +--- + +## Spec coverage + +| Spec | Task | +|------|------| +| `__initiator__` + contains | 1, 3 | +| No auto-prefill | 3 (no change to createNode) | +| Engine initiator_user_id | 1 | +| Publish whitelist / empty value | 2 | +| Candidates always include 发起人 | 3 | +| Form uncheck lock ignores system field | 3 | +| Preview uses current uid | 1 (wire preview call site) | + +**Placeholder scan:** none. +**Type consistency:** `__initiator__` / `initiator` / `contains` / `string[]` aligned.