Просмотр исходного кода

feat(oa): add resolve_cc_assignees for multi-strategy CC soft union

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 месяцев назад
Родитель
Сommit
2326f8bf99
2 измененных файлов с 188 добавлено и 1 удалено
  1. 87 0
      backend/app/services/oa_assignee.py
  2. 101 1
      backend/tests/test_oa_assignee.py

+ 87 - 0
backend/app/services/oa_assignee.py

@@ -13,6 +13,93 @@ class AssigneeResolveError(ValueError):
     """审批人解析失败"""
 
 
+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,
+    }
+
+
 def _active_user_id(db: Session, user_id: Optional[str], tenant_no: str) -> Optional[str]:
     uid = (user_id or "").strip()
     if not uid:

+ 101 - 1
backend/tests/test_oa_assignee.py

@@ -2,7 +2,12 @@
 import unittest
 from unittest.mock import MagicMock, patch
 
-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,
+)
 
 
 class TestOaAssignee(unittest.TestCase):
@@ -131,6 +136,101 @@ class TestOaAssignee(unittest.TestCase):
                 initiator_select_user_ids=[],
             )
 
+    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")
+
 
 if __name__ == "__main__":
     unittest.main()