2026-07-30-oa-cc-multi-assignee.md 19 KB

抄送人多选选人方式 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")(抄送路径需改 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

  • 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.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 种确定拦截。

  • 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: 手测

  1. 旧单 assigneeType 抄送正常。
  2. users + dept_leader 两人抄送。
  3. users + initiator_select:固定人可见且可 ➕。
  4. 仅自选不选人可发起。
  5. 审批仍单选。
  • 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 步骤默认跳过,除非用户要求提交。