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>
This commit is contained in:
zlwuandCursor committed 2026-07-30 13:03:15 +08:00
1 parent a501fb1219
commit a37bfac213
1 file changed
+326
@@ -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.