feat(oa): add resolve_cc_assignees for multi-strategy CC soft union
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
6c4c0184d0
commit
2326f8bf99
2 files changed
+188
-1
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user