Ver Fonte

feat(oa): resolve CC tasks with multi-strategy soft union

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu há 3 meses atrás
pai
commit
835c1c0836

+ 138 - 48
backend/app/services/oa_engine.py

@@ -22,7 +22,12 @@ from app.models.models import (
     SysDept,
     SysUser,
 )
-from app.services.oa_assignee import AssigneeResolveError, resolve_assignees
+from app.services.oa_assignee import (
+    AssigneeResolveError,
+    normalize_cc_assignee_types,
+    resolve_assignees,
+    resolve_cc_assignees,
+)
 from app.services.oa_formula import FormulaError, apply_formulas_and_linkages, eval_formula
 
 
@@ -309,7 +314,33 @@ def _pending_people_for_node(
     node_select_ids = _ids_for_initiator_select_node(
         initiator_select_by_node, node.get("id") or ""
     )
-    if assignee_type == "initiator_select":
+    resolve_source = assignee_type
+    if ntype == "cc":
+        types = normalize_cc_assignee_types(cfg)
+        assignees, meta = resolve_cc_assignees(
+            db,
+            tenant_no=tenant_no,
+            assignee_config=cfg,
+            initiator_user_id=initiator_user_id,
+            initiator_dept_id=initiator_dept_id,
+            advanced=advanced,
+            initiator_select_user_ids=node_select_ids,
+        )
+        resolve_source = str(meta.get("resolve_source") or "")
+        if not resolve_source:
+            resolve_source = "multi" if len(types) > 1 else (types[0] if types else "")
+        for uid in assignees:
+            people.append(
+                {
+                    "id": uid,
+                    "nickName": "",
+                    "status": "not_reached",
+                    "timestamp": "",
+                    "commentText": "",
+                    "taskId": "",
+                }
+            )
+    elif assignee_type == "initiator_select":
         for uid in node_select_ids:
             people.append(
                 {
@@ -349,7 +380,7 @@ def _pending_people_for_node(
         name_map = _user_name_map(db, [p["id"] for p in people if p.get("id")])
         for p in people:
             p["nickName"] = name_map.get(p["id"], "") or p["id"]
-    return people, sign_mode, assignee_type
+    return people, sign_mode, resolve_source
 
 
 def _iter_resolved_path_nodes(
@@ -1046,6 +1077,17 @@ def _assignee_meta(db: Session, tenant_no: str, cfg: Dict[str, Any]) -> str:
     return base
 
 
+def _assignee_meta_for_cc(
+    db: Session, tenant_no: str, cfg: Dict[str, Any], types: List[str]
+) -> str:
+    if not types:
+        return "未配置抄送人"
+    if len(types) == 1:
+        return _assignee_meta(db, tenant_no, {**cfg, "assigneeType": types[0]})
+    labels = [_ASSIGNEE_TYPE_LABEL.get(t, t) for t in types]
+    return " + ".join(labels)
+
+
 def _preview_people(db: Session, user_ids: List[str]) -> List[Dict[str, str]]:
     name_map = _user_name_map(db, user_ids)
     out: List[Dict[str, str]] = []
@@ -1216,48 +1258,86 @@ def _walk_preview_nodes(
 
         if ntype in ("approval", "cc"):
             cfg = cur.get("approverConfig") or {}
-            assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
             sign_mode = (cfg.get("signMode") or cfg.get("sign_mode") or "").strip()
-            if ntype == "cc":
-                sign_mode = "cc"
-            elif not sign_mode and assignee_type != "initiator_select":
-                sign_mode = "or"
-            meta = _assignee_meta(db, tenant_no, cfg)
             people: List[Dict[str, str]] = []
             resolve_error = ""
             node_select_ids = _ids_for_initiator_select_node(
                 initiator_select_by_node, cur.get("id") or ""
             )
-            if assignee_type == "initiator_select":
-                people = _preview_people(db, [str(x) for x in node_select_ids if str(x).strip()])
-                if not people:
-                    meta = meta or "发起人自选"
+            if ntype == "cc":
+                types = normalize_cc_assignee_types(cfg)
+                if "initiator_select" in types:
+                    display_type = "initiator_select"
+                elif len(types) == 1:
+                    display_type = types[0]
+                elif types:
+                    display_type = "multi"
+                else:
+                    display_type = ""
+                sign_mode = "cc"
+                meta = _assignee_meta_for_cc(db, tenant_no, cfg, types)
+                assignees, _meta = resolve_cc_assignees(
+                    db,
+                    tenant_no=tenant_no,
+                    assignee_config=cfg,
+                    initiator_user_id=initiator_user_id,
+                    initiator_dept_id=initiator_dept_id,
+                    advanced=advanced,
+                    initiator_select_user_ids=node_select_ids,
+                )
+                people = _preview_people(db, assignees)
+                steps.append(
+                    {
+                        "id": cur.get("id") or "",
+                        "type": ntype,
+                        "name": cur.get("name") or "抄送人",
+                        "signMode": sign_mode,
+                        "assigneeType": display_type,
+                        "assigneeTypes": types,
+                        "meta": meta,
+                        "people": people,
+                        "resolveError": resolve_error,
+                    }
+                )
             else:
-                try:
-                    assignees, _meta = resolve_assignees(
-                        db,
-                        tenant_no=tenant_no,
-                        assignee_config=cfg,
-                        initiator_user_id=initiator_user_id,
-                        initiator_dept_id=initiator_dept_id,
-                        advanced=advanced,
-                        initiator_select_user_ids=node_select_ids,
+                assignee_type = (
+                    cfg.get("assigneeType") or cfg.get("assignee_type") or ""
+                ).strip()
+                if not sign_mode and assignee_type != "initiator_select":
+                    sign_mode = "or"
+                meta = _assignee_meta(db, tenant_no, cfg)
+                if assignee_type == "initiator_select":
+                    people = _preview_people(
+                        db, [str(x) for x in node_select_ids if str(x).strip()]
                     )
-                    people = _preview_people(db, assignees)
-                except AssigneeResolveError as e:
-                    resolve_error = str(e)
-            steps.append(
-                {
-                    "id": cur.get("id") or "",
-                    "type": ntype,
-                    "name": cur.get("name") or ("抄送人" if ntype == "cc" else "审批人"),
-                    "signMode": sign_mode,
-                    "assigneeType": assignee_type,
-                    "meta": meta,
-                    "people": people,
-                    "resolveError": resolve_error,
-                }
-            )
+                    if not people:
+                        meta = meta or "发起人自选"
+                else:
+                    try:
+                        assignees, _meta = resolve_assignees(
+                            db,
+                            tenant_no=tenant_no,
+                            assignee_config=cfg,
+                            initiator_user_id=initiator_user_id,
+                            initiator_dept_id=initiator_dept_id,
+                            advanced=advanced,
+                            initiator_select_user_ids=node_select_ids,
+                        )
+                        people = _preview_people(db, assignees)
+                    except AssigneeResolveError as e:
+                        resolve_error = str(e)
+                steps.append(
+                    {
+                        "id": cur.get("id") or "",
+                        "type": ntype,
+                        "name": cur.get("name") or "审批人",
+                        "signMode": sign_mode,
+                        "assigneeType": assignee_type,
+                        "meta": meta,
+                        "people": people,
+                        "resolveError": resolve_error,
+                    }
+                )
             cur = cur.get("child")
             continue
 
@@ -1353,19 +1433,13 @@ def _create_tasks_for_node(
         return created
 
     cfg = node.get("approverConfig") or {}
-    assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
     node_select_ids = _ids_for_initiator_select_node(
         initiator_select_by_node, node.get("id") or ""
     )
-    if assignee_type == "initiator_select":
-        if ntype == "cc" and not node_select_ids:
-            return created
-        if ntype == "approval" and not node_select_ids:
-            raise OaEngineError("发起人未选择审批人")
-
     advanced = _advanced(ver)
-    try:
-        assignees, meta = resolve_assignees(
+
+    if ntype == "cc":
+        assignees, meta = resolve_cc_assignees(
             db,
             tenant_no=inst.tenant_no,
             assignee_config=cfg,
@@ -1374,8 +1448,24 @@ def _create_tasks_for_node(
             advanced=advanced,
             initiator_select_user_ids=node_select_ids,
         )
-    except AssigneeResolveError as e:
-        raise OaEngineError(str(e)) from e
+        if not assignees:
+            return created
+    else:
+        assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
+        if assignee_type == "initiator_select" and not node_select_ids:
+            raise OaEngineError("发起人未选择审批人")
+        try:
+            assignees, meta = resolve_assignees(
+                db,
+                tenant_no=inst.tenant_no,
+                assignee_config=cfg,
+                initiator_user_id=inst.initiator_user_id,
+                initiator_dept_id=inst.initiator_dept_id,
+                advanced=advanced,
+                initiator_select_user_ids=node_select_ids,
+            )
+        except AssigneeResolveError as e:
+            raise OaEngineError(str(e)) from e
 
     sign_mode = (cfg.get("signMode") or cfg.get("sign_mode") or "or").strip() or "or"
     resolve_source = meta.get("resolve_source")

+ 5 - 2
backend/tests/test_oa_initiator_select_by_node.py

@@ -43,7 +43,10 @@ class TestCreateTasksInitiatorSelect(unittest.TestCase):
         }
         ver = MagicMock()
         ver.advanced_settings_json = "{}"
-        with patch("app.services.oa_engine.resolve_assignees") as resolve:
+        with patch(
+            "app.services.oa_engine.resolve_cc_assignees",
+            return_value=([], {"resolve_source": "", "skipped": [{"type": "initiator_select"}]}),
+        ) as resolve:
             created = _create_tasks_for_node(
                 db,
                 inst=self._inst(),
@@ -52,7 +55,7 @@ class TestCreateTasksInitiatorSelect(unittest.TestCase):
                 user_id="init",
                 initiator_select_by_node={},
             )
-            resolve.assert_not_called()
+            resolve.assert_called_once()
         self.assertEqual(created, [])
 
     def test_approval_empty_raises(self):