diff --git a/backend/app/services/oa_assignee.py b/backend/app/services/oa_assignee.py index 80715bd3..cbe05923 100644 --- a/backend/app/services/oa_assignee.py +++ b/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: diff --git a/backend/tests/test_oa_assignee.py b/backend/tests/test_oa_assignee.py index 0274fa59..979902a6 100644 --- a/backend/tests/test_oa_assignee.py +++ b/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()