提交
This commit is contained in:
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 接口。
|
||||
Reference in new issue
Block a user