19 KiB
抄送人多选选人方式 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: 抄送人节点支持同时勾选多种选人方式,运行时软并集去重;失败/空策略跳过,最终 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
File map
| 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。 - 运行时软跳过 + 允许 0 人。
- 发布:≥1 种;勾了 users/oa_roles 必须有值。
- 双写
assigneeType = assigneeTypes[0]。 - 无需 SQL。
Task 1: normalize_cc_assignee_types + resolve_cc_assignees(TDD)
Files:
-
Modify:
backend/app/services/oa_assignee.py -
Modify:
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")
- Step 2: 跑测确认失败
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(函数未定义)
- Step 3: 实现 helper +
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,
}
- Step 4: 跑全文件测例
cd backend && source venv/bin/activate && pytest tests/test_oa_assignee.py -v
Expected: PASS
- Step 5: Commit(仅当用户明确要求提交时执行;否则跳过)
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
)"
Task 2: 发布校验支持抄送 assigneeTypes
Files:
-
Modify:
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)
- Step 2: 跑测确认失败
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 或断言不符
- Step 3: 改
validate_flow_tree
从 oa_assignee 导入 VALID_ASSIGNEE_TYPES、normalize_cc_assignee_types(删除 publish 内重复集合,避免漂移)。
将原 if ntype in ("approval", "cc"): 拆成:
-
approval:只认单字段
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
- Step 5: Commit(用户要求时)
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
)"
Task 3: 引擎抄送路径改用 resolve_cc_assignees
Files:
-
Modify:
backend/app/services/oa_engine.py -
Modify: 相关 tests 中
patch("app.services.oa_engine.resolve_assignees")(抄送路径需改 patchresolve_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
- Step 6: Commit(用户要求时)
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
)"
Task 4: 前端类型 + 设计器多选 UI
Files:
-
Modify:
web-admin/src/components/oa-approval/flow-tree/types.ts -
Modify:
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.vueUI -
抄送:分段按钮多选 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 种确定拦截。
- Step 5: Commit(用户要求时)
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
)"
Task 5: 发起预览 / 进度文案适配
Files:
-
Modify:
web-admin/src/api/oaApproval.ts -
Modify:
web-admin/src/components/oa-approval/OaFlowPreview.vue -
Modify:
web-admin/src/components/oa-approval/OaProgressChain.vue -
Modify:
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')
}
- Step 3: 抄送自选 + 固定人并集展示
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抄送正常。 - users + dept_leader 两人抄送。
- users + initiator_select:固定人可见且可 ➕。
- 仅自选不选人可发起。
- 审批仍单选。
- Step 7: Commit(用户要求时)
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
)"
Task 6: 回归与图谱
- Step 1: 后端相关测全绿
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
- Step 2: 更新知识图谱
./scripts/update-graph.sh all
- Step 3: 对照 spec §9 勾选手测 8 条
Spec coverage checklist
| 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 | — |
Placeholder scan
无 TBD;关键实现与测例已写出。Commit 步骤默认跳过,除非用户要求提交。