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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-30 14:28:24 +08:00
1 parent ef91e189dd
commit 835c1c0836
2 files changed
+143 -50

No files matched your search

+138 -48
View File
@@ -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")
@@ -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):