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:
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.
|
||||
Reference in new issue
Block a user