This commit is contained in:
zlwu committed 2026-07-30 15:02:13 +08:00
1 parent f7d1004e6b
commit 310b2efe8a
91 files changed
+14420 -10840

No files matched your search

@@ -1,6 +0,0 @@
-- 工艺路线增加路线级默认设备(派发任务时带入任务设备面板)
-- 执行:在目标库运行本脚本。
ALTER TABLE `biz_process_route`
ADD COLUMN `equipment_ids` varchar(1000) DEFAULT NULL COMMENT '默认设备ID(biz_equipment.id,逗号分隔)' AFTER `is_current`,
ADD COLUMN `equipment_name` varchar(1000) DEFAULT NULL COMMENT '默认设备名称快照(逗号分隔)' AFTER `equipment_ids`;
@@ -1,6 +0,0 @@
-- 质检工单增加质检结论(合格/不合格),可编辑落库;列表无值时仍可由任务结论派生。
-- 执行:在目标库运行本脚本。
ALTER TABLE `biz_qc_work_order`
ADD COLUMN `conclusion` varchar(16) DEFAULT NULL COMMENT '质检结论 tg合格 btg不合格'
AFTER `work_order_status`;
@@ -0,0 +1,592 @@
# 抄送人多选选人方式 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`)**
```python
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: 跑测确认失败**
```bash
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` 增加:
```python
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: 跑全文件测例**
```bash
cd backend && source venv/bin/activate && pytest tests/test_oa_assignee.py -v
```
Expected: PASS
- [ ] **Step 5: Commit**(仅当用户明确要求提交时执行;否则跳过)
```bash
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` 调用签名为准。示例:
```python
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: 跑测确认失败**
```bash
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: 跑发布测例**
```bash
cd backend && source venv/bin/activate && pytest tests/test_oa_publish.py -v
```
Expected: PASS
- [ ] **Step 5: Commit**(用户要求时)
```bash
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**
```python
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**
```bash
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**(用户要求时)
```bash
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**(用户要求时)
```bash
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`**
```ts
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: 抄送自选 + 固定人并集展示**
```ts
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**(用户要求时)
```bash
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: 后端相关测全绿**
```bash
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: 更新知识图谱**
```bash
./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 步骤默认跳过,除非用户要求提交。
@@ -0,0 +1,227 @@
# 抄送人节点 · 多种选人方式并集(钉钉式)
**日期:** 2026-07-30
**范围:**
- `web-admin` 审批设计器「3 流程设计」· 抄送人节点配置(`FlowDesigner.vue` / `flow-tree/types.ts`)
- `backend` 发布校验(`oa_publish.py`)、审批人解析(`oa_assignee.py`)、引擎建任务 / 预览 / 进度(`oa_engine.py`)
- 发起预览中「发起人自选」判定与人员并集(`OaFlowPreview` 等相关前端)
**关联:**
- `docs/superpowers/specs/2026-07-14-oa-approval-dingtalk-design.md` §5.3(选人策略)
- `docs/superpowers/specs/2026-07-23-oa-initiator-select-in-flow-design.md`(抄送自选可空)
- 产品对照:钉钉审批 · 抄送人可同时启用多种选人方式,结果取并集
---
## 1. 背景与目标
### 现状
- 审批人 / 抄送人节点共用 `approverConfig.assigneeType`(单值),设计器为分段**单选**。
- 五种方式:`users` | `oa_roles` | `dept_leader` | `direct_supervisor` | `initiator_select`。
- 引擎 `resolve_assignees` 按单一类型硬分支;失败则抛 `AssigneeResolveError`。
- 抄送特例:仅当 `assigneeType === initiator_select` 且发起人未选人时,跳过建任务且不报错。
### 目标
1. **仅抄送人**节点支持同时勾选多种选人方式,运行时对各方式解析结果**并集去重**后建抄送任务。
2. 运行时:**软跳过**失败/空策略;最终 0 人时**不报错、不建任务**(对齐现有「发起人自选抄送可空」)。
3. 发布时:至少勾选 1 种;勾了 `users` / `oa_roles` 则必须已配置成员/角色。
4. **兼容**已发布/草稿中仅含旧字段 `assigneeType` 的抄送配置,行为与现网一致。
### 非目标
- 不改**审批人**节点(仍单选一种 + 或签/会签)。
- 不新增数据库表或列(流程定义仍为 JSON)。
- 不改变抄送任务「建即 done / 不阻断流程」语义。
- 不做历史实例数据回填迁移脚本(只读兼容即可)。
---
## 2. 产品约定(已确认)
| 项 | 结论 |
|----|------|
| 范围 | **仅抄送人**;审批人保持单选 |
| 运行时合并 | 各勾选方式独立解析 → 人 ID **并集去重**(保序:按 `assigneeTypes` 顺序,同策略内保持原序) |
| 单策略失败/空 | **软跳过**该策略,继续其它策略 |
| 最终 0 人 | **不报错**,不创建抄送任务,流程继续 |
| 发布校验 | 必须勾选 ≥1 种;`users` / `oa_roles` 若勾选则配置不可空;`dept_leader` / `direct_supervisor` / `initiator_select` 勾选即可 |
| 取消勾选 | UI 可不清空已填 `userIds` / `oaRoleIds`;发布只校验**当前勾选**的策略 |
| 发起人自选并存 | 仍按节点 ➕ 选人;自选列表可空;与固定策略结果并集 |
---
## 3. 配置模型
### 3.1 类型(前端 `ApproverConfig`)
```ts
export interface ApproverConfig {
/** 审批人:单选(必填)。抄送人:兼容旧数据 / 可选双写首项 */
assigneeType?: AssigneeType
/** 抄送人:多选选人方式(优先)。审批人:不使用 */
assigneeTypes?: AssigneeType[]
userIds?: string[]
oaRoleIds?: string[]
oaRoleCodes?: string[]
signMode?: 'or' | 'and' // 仅审批有意义;抄送引擎侧仍固定 cc
}
```
### 3.2 写入约定(设计器保存抄送节点)
- 写入非空 `assigneeTypes`(去重、仅含合法枚举、保持勾选顺序)。
- **双写** `assigneeType = assigneeTypes[0]`,便于未改完的只读展示兜底。
- `userIds` / `oaRoleIds` 始终可随表单保留;解析时仅当对应类型在 `assigneeTypes` 中才使用。
### 3.3 读取约定(后端 / 前端统一 helper)
对节点 `approverConfig`:
1. 若 `assigneeTypes` 为非空数组 → 过滤非法项后作为类型列表(抄送多选)。
2. 否则若存在非空 `assigneeType` → `[assigneeType]`。
3. 否则 → `[]`(发布校验会拦;运行时抄送视为 0 人)。
审批节点:**忽略** `assigneeTypes`(即使误写入),只认 `assigneeType`。
---
## 4. 解析与引擎
### 4.1 `oa_assignee.py`
保留现有 `resolve_assignees`(单策略、失败抛错)供审批使用。
新增(名称可微调,语义如下):
```text
resolve_cc_assignees(...) -> (user_ids: List[str], meta: Dict)
```
行为:
1. 用 §3.3 得到 `types`。
2. 对每个 `t in types`:构造单策略临时 config,调用现有单策略逻辑(或内联相同分支)。
3. `try/except AssigneeResolveError`:捕获则跳过该策略,并在 `meta.skipped` 记录 `{ type, reason }`。
4. 单策略返回空列表:视为跳过(不抛错)。
5. 合并去重;`meta.resolve_source` 建议:
- 仅一种成功:该策略名(兼容旧展示)
- 多种成功:`"multi"`,并带 `sources: [...]`
6. **永不因「全部失败」抛错**;返回 `([], meta)` 合法。
`initiator_select`:仍使用调用方传入的 `initiator_select_user_ids`;空则该策略跳过。
### 4.2 `oa_engine.py` · `_create_tasks_for_node`(及预览同类路径)
对 `ntype == "cc"`:
- 调用 `resolve_cc_assignees`(不再先特判「仅 initiator_select 且空则 return」——该语义被多策略软跳过覆盖)。
- 若 `assignees` 为空 → `return created`(不抛错)。
- 非空 → 按现逻辑为每人建 `task_type=cc`、`status=done` 任务。
对 `ntype == "approval"`:行为不变(`resolve_assignees` + 硬失败;自选空仍抛错)。
预览 / 进度未达节点解析抄送人员:同样走 `resolve_cc_assignees`,空列表展示为无人抄送(不阻断预览)。
### 4.3 发起预览前端
判断节点是否可 ➕ 自选:
- 旧:`assigneeType === 'initiator_select'`
- 新:类型列表(§3.3)**包含** `initiator_select`
固定策略解析出的人只读展示;自选人仍写入按节点 map,提交后与固定人并集由引擎完成。
---
## 5. 发布校验(`oa_publish.py`)
对 `type === "cc"`:
| 条件 | 结果 |
|------|------|
| 类型列表为空 | 错误:未配置抄送人策略 |
| 含非法类型 | 错误 |
| 含 `users` 且 `userIds` 为空 | 错误:指定成员不能为空 |
| 含 `oa_roles` 且角色 id/code 皆空 | 错误:工作流角色不能为空 |
| 仅含 dept_leader / direct_supervisor / initiator_select | 通过 |
对 `type === "approval"`:保持现有单 `assigneeType` 校验,**不**启用 `assigneeTypes`。
---
## 6. 设计器 UI
### 6.1 抄送人抽屉
- 「选人方式」分段控件改为**多选**:再次点击已选可取消(取消后若变为 0 种,确定保存前前端提示至少一种;发布后端再拦一道)。
- 勾选 `users` 时展示指定成员区;勾选 `oa_roles` 时展示角色区;**可同时展开**。
- 副标题可改为:「可同时选择多种方式,抄送时合并去重」。
### 6.2 审批人抽屉
- 保持单选;逻辑与现网一致。
### 6.3 画布摘要
- 抄送:多种时拼接标签,如 `指定成员 + 部门负责人`;过长可截断。
- 审批:不变。
---
## 7. 兼容与迁移
| 场景 | 行为 |
|------|------|
| 旧抄送仅有 `assigneeType` | 读作单元素列表;运行/发布与现网一致 |
| 新抄送有 `assigneeTypes` | 按多选;双写首项 `assigneeType` |
| 旧运行中实例 | 已落库任务不受影响;未达节点若重解析则走新逻辑(旧 JSON 无 `assigneeTypes` 时仍单策略) |
| 审批节点误带 `assigneeTypes` | 引擎/发布忽略,只认 `assigneeType` |
无需 SQL patch。
---
## 8. 主要改动文件
| 路径 | 变更 |
|------|------|
| `web-admin/.../flow-tree/types.ts` | `assigneeTypes`;摘要文案;默认抄送配置 |
| `web-admin/.../flow-tree/FlowDesigner.vue` | 抄送多选 UI;读写;校验提示 |
| `web-admin/.../OaFlowPreview.vue`(及同类) | `includes('initiator_select')` |
| `web-admin/.../OaProgressChain.vue`(若展示策略名) | 支持 `multi` / 多标签 |
| `backend/app/services/oa_assignee.py` | `resolve_cc_assignees` |
| `backend/app/services/oa_publish.py` | 抄送多类型校验 |
| `backend/app/services/oa_engine.py` | 抄送建任务/预览走新解析 |
| `backend/tests/`(若有 OA 测例) | 并集、软跳过、0 人、发布校验、旧字段兼容 |
---
## 9. 测试要点
1. **兼容**:旧单 `assigneeType=users` 抄送,指定成员仍抄送成功。
2. **并集**:勾选指定成员 A + 部门负责人 B(且 B 有效)→ 两人收到抄送。
3. **软跳过**:勾选部门负责人(未配置)+ 有效指定成员 → 仅抄送指定成员,发起/推进不报错。
4. **0 人**:仅勾发起人自选且发起不选人 → 不建抄送任务,流程可发起/可推进。
5. **全失败**:仅勾部门负责人且未配置 → 不建任务、不报错。
6. **发布**:勾选指定成员但未选人 → 发布失败;至少勾一种通过。
7. **审批回归**:审批人仍单选;多人会签/或签/自选必填行为不变。
8. **发起 ➕**:抄送 `assigneeTypes` 含 `initiator_select` 时节点仍可点选加人。
---
## 10. 开放细节(实现时可定,不阻规格)
- `meta.resolve_source === "multi"` 时进度文案具体用「多种选人方式」还是拼接中文标签——实现时与现有 `OaProgressChain` 文案表对齐即可。
- 设计器取消最后一种选人方式时:禁止取消 vs 允许取消但确定时 ElMessage 拦截——推荐后者与发布校验一致。
---
## 修订记录
| 日期 | 说明 |
|------|------|
| 2026-07-30 | 初稿:仅抄送多选;软跳过+允许 0 人;发布至少一种且 users/roles 配置要齐;`assigneeTypes` + 旧字段兼容 + 双写首项 |
@@ -0,0 +1,16 @@
# OA 表单控件 · 职位(post)
**日期:** 2026-07-30
**范围:** 审批设计「2 表单设计」左侧组件「职位」;发起/详情运行时;条件分支「包含」;发布与发起校验。
**已确认:**
| 项 | 结论 |
|----|------|
| 数据源 | 职位管理 `sys_post` / `fetchPostList`,展示 `postName` |
| 选择模式 | 默认多选(与人员/部门一致) |
| Tag 展示 | 完整换行,不用 collapse-tags / +1/+2 |
| 条件分支 | 参与;运算符仅「包含」(与人员一致) |
| 值结构 | `{ id, name }` 或数组;`name` = `postName` |
**非目标:** 不改职位管理 CRUD;不新建独立 options 接口。