|
|
@@ -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.
|