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: 抄送人节点支持同时勾选多种选人方式,运行时软并集去重;失败/空策略跳过,最终 0 人不报错不建任务;发布至少勾一种且 users/roles 配置要齐;审批人保持单选。
Architecture: 抄送配置优先读 approverConfig.assigneeTypes[],无则回退旧 assigneeType。新增 resolve_cc_assignees 按类型循环调用现有 resolve_assignees 并吞掉 AssigneeResolveError。引擎抄送建任务/预览/未达进度改走该函数。设计器抄送分段改为多选并双写首项 assigneeType。
Tech Stack: FastAPI · unittest.mock · Vue 3 · Element Plus · TypeScript
Spec: docs/superpowers/specs/2026-07-30-oa-cc-multi-assignee-design.md
| Path | Responsibility |
|---|---|
Modify: backend/app/services/oa_assignee.py |
normalize_cc_assignee_types + resolve_cc_assignees |
Modify: backend/tests/test_oa_assignee.py |
并集 / 软跳过 / 空列表 / 旧字段兼容 |
Modify: backend/app/services/oa_publish.py |
抄送按类型列表校验;审批仍单字段 |
Modify: backend/tests/test_oa_publish.py |
抄送多类型发布用例 |
Modify: backend/app/services/oa_engine.py |
抄送路径改用 resolve_cc_assignees;预览返回 assigneeTypes |
Modify: backend/tests/test_oa_initiator_select_by_node.py |
patch 目标兼容(若 import 变) |
Modify: web-admin/src/components/oa-approval/flow-tree/types.ts |
assigneeTypes;normalizeCcAssigneeTypes / summarizeApprover |
Modify: web-admin/src/components/oa-approval/flow-tree/FlowDesigner.vue |
抄送多选 UI;读写;确定校验 |
Modify: web-admin/src/api/oaApproval.ts |
OaFlowPreviewStep.assigneeTypes? |
Modify: web-admin/src/components/oa-approval/OaFlowPreview.vue |
includes('initiator_select');抄送 people 并集展示 |
Modify: web-admin/src/components/oa-approval/OaProgressChain.vue |
multi 文案 |
Modify: web-admin/src/views/oa-approval/start/index.vue |
提交校验读类型列表 |
Locked rules (from spec):
assigneeTypes。assigneeType = assigneeTypes[0]。normalize_cc_assignee_types + resolve_cc_assignees(TDD)Files:
backend/app/services/oa_assignee.pyModify: backend/tests/test_oa_assignee.py
[ ] Step 1: 写失败测例(追加到 TestOaAssignee)
from app.services.oa_assignee import (
AssigneeResolveError,
normalize_cc_assignee_types,
resolve_assignees,
resolve_cc_assignees,
)
def test_normalize_prefers_assignee_types(self):
self.assertEqual(
normalize_cc_assignee_types(
{"assigneeTypes": ["users", "dept_leader", "users", "bad"], "assigneeType": "oa_roles"}
),
["users", "dept_leader"],
)
def test_normalize_fallback_single(self):
self.assertEqual(
normalize_cc_assignee_types({"assigneeType": "initiator_select"}),
["initiator_select"],
)
self.assertEqual(normalize_cc_assignee_types({}), [])
def test_resolve_cc_union_dedupe(self):
db = MagicMock()
with patch(
"app.services.oa_assignee.resolve_assignees",
side_effect=[
(["u1", "u2"], {"resolve_source": "users"}),
(["u2", "u3"], {"resolve_source": "dept_leader"}),
],
) as mock_resolve:
users, meta = resolve_cc_assignees(
db,
tenant_no="zkxw",
assignee_config={
"assigneeTypes": ["users", "dept_leader"],
"userIds": ["u1", "u2"],
},
initiator_user_id="zhang",
initiator_dept_id="d1",
)
self.assertEqual(users, ["u1", "u2", "u3"])
self.assertEqual(meta.get("resolve_source"), "multi")
self.assertEqual(meta.get("sources"), ["users", "dept_leader"])
self.assertEqual(mock_resolve.call_count, 2)
def test_resolve_cc_skips_failed_strategy(self):
db = MagicMock()
with patch(
"app.services.oa_assignee.resolve_assignees",
side_effect=[
AssigneeResolveError("部门负责人未配置"),
(["u1"], {"resolve_source": "users"}),
],
):
users, meta = resolve_cc_assignees(
db,
tenant_no="zkxw",
assignee_config={
"assigneeTypes": ["dept_leader", "users"],
"userIds": ["u1"],
},
initiator_user_id="zhang",
initiator_dept_id="d1",
)
self.assertEqual(users, ["u1"])
self.assertEqual(meta.get("resolve_source"), "users")
self.assertEqual(len(meta.get("skipped") or []), 1)
def test_resolve_cc_all_fail_returns_empty(self):
db = MagicMock()
with patch(
"app.services.oa_assignee.resolve_assignees",
side_effect=AssigneeResolveError("fail"),
):
users, meta = resolve_cc_assignees(
db,
tenant_no="zkxw",
assignee_config={"assigneeTypes": ["dept_leader"]},
initiator_user_id="zhang",
initiator_dept_id="d1",
)
self.assertEqual(users, [])
self.assertTrue(meta.get("skipped"))
def test_resolve_cc_legacy_single_type(self):
db = MagicMock()
with patch(
"app.services.oa_assignee.resolve_assignees",
return_value=(["u1"], {"resolve_source": "users"}),
) as mock_resolve:
users, meta = resolve_cc_assignees(
db,
tenant_no="zkxw",
assignee_config={"assigneeType": "users", "userIds": ["u1"]},
initiator_user_id="zhang",
)
self.assertEqual(users, ["u1"])
self.assertEqual(meta.get("resolve_source"), "users")
single_cfg = mock_resolve.call_args.kwargs["assignee_config"]
self.assertEqual(single_cfg.get("assigneeType"), "users")
cd backend && source venv/bin/activate && pytest tests/test_oa_assignee.py::TestOaAssignee::test_normalize_prefers_assignee_types tests/test_oa_assignee.py::TestOaAssignee::test_resolve_cc_union_dedupe -v
Expected: FAIL(函数未定义)
resolve_cc_assignees在 oa_assignee.py 增加:
VALID_ASSIGNEE_TYPES = frozenset(
{
"users",
"oa_roles",
"dept_leader",
"direct_supervisor",
"initiator_select",
}
)
def normalize_cc_assignee_types(assignee_config: Optional[Dict[str, Any]]) -> List[str]:
cfg = assignee_config or {}
raw = cfg.get("assigneeTypes") or cfg.get("assignee_types")
out: List[str] = []
seen = set()
if isinstance(raw, list) and raw:
for item in raw:
t = str(item or "").strip()
if t in VALID_ASSIGNEE_TYPES and t not in seen:
seen.add(t)
out.append(t)
return out
single = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
if single in VALID_ASSIGNEE_TYPES:
return [single]
return []
def resolve_cc_assignees(
db: Session,
*,
tenant_no: str,
assignee_config: Dict[str, Any],
initiator_user_id: str,
initiator_dept_id: Optional[str] = None,
advanced: Optional[Dict[str, Any]] = None,
initiator_select_user_ids: Optional[List[str]] = None,
) -> Tuple[List[str], Dict[str, Any]]:
"""抄送多策略软并集:单策略失败/空则跳过;全部无人返回 [],不抛错。"""
cfg = assignee_config or {}
types = normalize_cc_assignee_types(cfg)
merged: List[str] = []
seen = set()
sources: List[str] = []
skipped: List[Dict[str, str]] = []
for t in types:
single_cfg = {**cfg, "assigneeType": t}
try:
users, meta = resolve_assignees(
db,
tenant_no=tenant_no,
assignee_config=single_cfg,
initiator_user_id=initiator_user_id,
initiator_dept_id=initiator_dept_id,
advanced=advanced,
initiator_select_user_ids=initiator_select_user_ids,
)
except AssigneeResolveError as e:
skipped.append({"type": t, "reason": str(e)})
continue
if not users:
skipped.append({"type": t, "reason": "empty"})
continue
src = str((meta or {}).get("resolve_source") or t)
sources.append(src)
for uid in users:
if uid and uid not in seen:
seen.add(uid)
merged.append(uid)
if len(sources) == 1:
resolve_source = sources[0]
elif len(sources) > 1:
resolve_source = "multi"
else:
resolve_source = ""
return merged, {
"resolve_source": resolve_source,
"sources": sources,
"skipped": skipped,
"assigneeTypes": types,
}
cd backend && source venv/bin/activate && pytest tests/test_oa_assignee.py -v
Expected: PASS
git add backend/app/services/oa_assignee.py backend/tests/test_oa_assignee.py
git commit -m "$(cat <<'EOF'
feat(oa): add resolve_cc_assignees for multi-strategy CC soft union
EOF
)"
assigneeTypesFiles:
backend/app/services/oa_publish.py(validate_flow_tree 内 approval/cc 分支)Modify: backend/tests/test_oa_publish.py
[ ] Step 1: 写失败测例
以 test_oa_publish.py 现有 validate_flow_tree 调用签名为准。示例:
def test_cc_multi_types_ok(self):
flow = {
"type": "start",
"child": {
"type": "cc",
"name": "抄送",
"approverConfig": {
"assigneeTypes": ["users", "dept_leader"],
"userIds": ["u1"],
},
"child": {"type": "end"},
},
}
errs = validate_flow_tree(flow, form_fields=set())
self.assertEqual(errs, [])
def test_cc_multi_users_empty_fails(self):
flow = {
"type": "start",
"child": {
"type": "cc",
"name": "抄送",
"approverConfig": {
"assigneeTypes": ["users", "initiator_select"],
"userIds": [],
},
"child": {"type": "end"},
},
}
errs = validate_flow_tree(flow, form_fields=set())
self.assertTrue(any("指定成员" in e for e in errs))
def test_cc_no_types_fails(self):
flow = {
"type": "start",
"child": {
"type": "cc",
"name": "抄送",
"approverConfig": {},
"child": {"type": "end"},
},
}
errs = validate_flow_tree(flow, form_fields=set())
self.assertTrue(len(errs) >= 1)
def test_approval_ignores_assignee_types_array(self):
flow = {
"type": "start",
"child": {
"type": "approval",
"name": "审批",
"approverConfig": {"assigneeTypes": ["dept_leader"]},
"child": {"type": "end"},
},
}
errs = validate_flow_tree(flow, form_fields=set())
self.assertTrue(len(errs) >= 1)
cd backend && source venv/bin/activate && pytest tests/test_oa_publish.py -k "cc_multi or cc_no_types or approval_ignores" -v
Expected: FAIL 或断言不符
validate_flow_tree从 oa_assignee 导入 VALID_ASSIGNEE_TYPES、normalize_cc_assignee_types(删除 publish 内重复集合,避免漂移)。
将原 if ntype in ("approval", "cc"): 拆成:
assigneeType(逻辑与现网一致)。cc:types = normalize_cc_assignee_types(cfg);空则报错;对每个类型校验 users/oa_roles 非空。
[ ] Step 4: 跑发布测例
cd backend && source venv/bin/activate && pytest tests/test_oa_publish.py -v
Expected: PASS
git add backend/app/services/oa_publish.py backend/tests/test_oa_publish.py
git commit -m "$(cat <<'EOF'
feat(oa): validate CC assigneeTypes on publish
EOF
)"
resolve_cc_assigneesFiles:
backend/app/services/oa_engine.pyModify: 相关 tests 中 patch("app.services.oa_engine.resolve_assignees")(抄送路径需改 patch resolve_cc_assignees 或双 patch)
[ ] Step 1: 更新 import
from app.services.oa_assignee import (
AssigneeResolveError,
normalize_cc_assignee_types,
resolve_assignees,
resolve_cc_assignees,
)
[ ] Step 2: 改 _create_tasks_for_node
cc:调用 resolve_cc_assignees;assignees 空则 return created;删除「仅 initiator_select 且空 early return」旧特判。
approval:保持 resolve_assignees + 自选空抛错。
[ ] Step 3: 改 _pending_people_for_node
cc:走 resolve_cc_assignees,第三返回值用 meta.resolve_source(或 types 推导的 multi/单类型)。
approval:逻辑不变。
[ ] Step 4: 改 _walk_preview_nodes 抄送块
锁定展示约定:
assigneeTypes: types。assigneeType:若含 initiator_select → "initiator_select"(旧前端仍出 ➕);否则单类型用该类型,多类型用 "multi"。people:resolve_cc_assignees 并集结果。meta:多类型时用 " + ".join(中文标签)(可抽 _assignee_meta_for_cc)。
[ ] Step 5: 跑相关测例并修 patch
cd backend && source venv/bin/activate && pytest tests/test_oa_assignee.py tests/test_oa_publish.py tests/test_oa_initiator_select_by_node.py tests/test_oa_flow_preview.py tests/test_oa_engine.py tests/test_oa_auto_pass_initiator.py -v
Expected: PASS
git add backend/app/services/oa_engine.py backend/tests/
git commit -m "$(cat <<'EOF'
feat(oa): resolve CC tasks with multi-strategy soft union
EOF
)"
Files:
web-admin/src/components/oa-approval/flow-tree/types.tsModify: web-admin/src/components/oa-approval/flow-tree/FlowDesigner.vue
[ ] Step 1: types.ts
ApproverConfig 增加可选 assigneeTypes?: AssigneeType[];assigneeType 改为可选(兼容)。
新增 normalizeCcAssigneeTypes(cfg)。
summarizeApprover(cfg, nodeType?):抄送按类型拼接 A + B,过长截断;审批保持原逻辑。
createNode('cc') 默认:{ assigneeTypes: ['initiator_select'], assigneeType: 'initiator_select' }。
所有 summarizeApprover( 调用传入 node.type。
[ ] Step 2: FlowDesigner.vue UI
抄送:分段按钮多选 toggle;users/oa_roles 区按 assigneeTypes.includes 同时可显。
审批:仍单选 setAssigneeType。
副标题抄送:可同时选择多种方式,抄送时合并去重。
[ ] Step 3: openEdit / applyEdit
打开抄送:cfg.assigneeTypes = normalizeCcAssigneeTypes(ac),空则默认 ['initiator_select']。
保存抄送:校验 ≥1 种;勾了 users/roles 必须有值;写入 assigneeTypes + 双写 assigneeType = types[0];保留 userIds/oaRoleIds。
保存审批:不写 assigneeTypes。
[ ] Step 4: 目视设计器
多选、双区同显、0 种确定拦截。
git add web-admin/src/components/oa-approval/flow-tree/types.ts web-admin/src/components/oa-approval/flow-tree/FlowDesigner.vue
git commit -m "$(cat <<'EOF'
feat(oa): allow multi assignee strategies on CC node designer
EOF
)"
Files:
web-admin/src/api/oaApproval.tsweb-admin/src/components/oa-approval/OaFlowPreview.vueweb-admin/src/components/oa-approval/OaProgressChain.vueModify: web-admin/src/views/oa-approval/start/index.vue
[ ] Step 1: OaFlowPreviewStep 增加 assigneeTypes?: string[]
[ ] Step 2: isInitiatorSelect
function stepAssigneeTypes(s: OaFlowPreviewStep): string[] {
if (Array.isArray(s.assigneeTypes) && s.assigneeTypes.length) return s.assigneeTypes
return s.assigneeType ? [s.assigneeType] : []
}
function isInitiatorSelect(s: OaFlowPreviewStep) {
if (!(s.type === 'approval' || s.type === 'cc')) return false
return stepAssigneeTypes(s).includes('initiator_select')
}
function displayPeople(s: OaFlowPreviewStep) {
if (!isInitiatorSelect(s)) return s.people || []
const fromLocal = localPeople(s)
if (s.type !== 'cc') return fromLocal
const map = new Map<string, { id: string; nickName: string }>()
for (const p of s.people || []) {
if (p?.id) map.set(p.id, { id: p.id, nickName: p.nickName || p.id })
}
for (const p of fromLocal) map.set(p.id, p)
return [...map.values()]
}
模板自选节点人员区改用 displayPeople(s)。
[ ] Step 4: OaProgressChain.resolveSourceText 增加 multi: '多种选人方式'
[ ] Step 5: start/index.vue 审批自选校验改为 types.includes('initiator_select')
[ ] Step 6: 手测
assigneeType 抄送正常。git add web-admin/src/api/oaApproval.ts web-admin/src/components/oa-approval/OaFlowPreview.vue web-admin/src/components/oa-approval/OaProgressChain.vue web-admin/src/views/oa-approval/start/index.vue
git commit -m "$(cat <<'EOF'
feat(oa): preview and progress support CC multi assigneeTypes
EOF
)"
cd backend && source venv/bin/activate && pytest tests/test_oa_assignee.py tests/test_oa_publish.py tests/test_oa_initiator_select_by_node.py tests/test_oa_flow_preview.py tests/test_oa_engine.py tests/test_oa_auto_pass_initiator.py -v
./scripts/update-graph.sh all
| Spec 项 | Task |
|---|---|
assigneeTypes + 旧字段回退 |
Task 1, 4 |
resolve_cc_assignees 软并集 / 0 人 |
Task 1, 3 |
| 发布校验 | Task 2 |
| 引擎建任务 / 预览 / 未达 | Task 3 |
| 设计器多选 UI | Task 4 |
| 发起 ➕ / people 并集展示 | Task 5 |
| 审批不变 | Task 2/3/4 显式隔离 |
| 无 SQL | — |
无 TBD;关键实现与测例已写出。Commit 步骤默认跳过,除非用户要求提交。