Przeglądaj źródła

docs: add OA initiator condition implementation plan.

Cover engine matching, publish validation, and condition editor injection
for system field __initiator__.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 miesięcy temu
rodzic
commit
a37bfac213

+ 326 - 0
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<string> {
+  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.