汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。 Co-authored-by: Cursor <cursoragent@cursor.com>
249 lines
8.1 KiB
Python
249 lines
8.1 KiB
Python
"""OA 审批人解析(不读 sys_role;职位成员走 sys_user_post)"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.crud import user as user_crud
|
||
from app.models.models import SysDept, SysUser
|
||
|
||
|
||
class AssigneeResolveError(ValueError):
|
||
"""审批人解析失败"""
|
||
|
||
|
||
VALID_ASSIGNEE_TYPES = frozenset(
|
||
{
|
||
"users",
|
||
"posts",
|
||
"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:
|
||
return None
|
||
u = (
|
||
db.query(SysUser)
|
||
.filter(
|
||
SysUser.id == uid,
|
||
SysUser.tenant_no == tenant_no,
|
||
SysUser.del_flag == 0,
|
||
SysUser.status == "1",
|
||
)
|
||
.first()
|
||
)
|
||
return u.id if u else None
|
||
|
||
|
||
def resolve_dept_leader(
|
||
db: Session, *, tenant_no: str, initiator_dept_id: Optional[str]
|
||
) -> Tuple[List[str], Dict[str, Any]]:
|
||
dept_id = (initiator_dept_id or "").strip()
|
||
if not dept_id:
|
||
raise AssigneeResolveError("发起人部门为空,无法解析部门负责人")
|
||
dept = (
|
||
db.query(SysDept)
|
||
.filter(SysDept.id == dept_id, SysDept.tenant_no == tenant_no, SysDept.del_flag == 0)
|
||
.first()
|
||
)
|
||
if not dept:
|
||
raise AssigneeResolveError("发起人部门不存在")
|
||
leader_uid = _active_user_id(db, getattr(dept, "leader_user_id", None), tenant_no)
|
||
if not leader_uid:
|
||
raise AssigneeResolveError("部门负责人未配置或用户无效,请在组织管理中维护负责人")
|
||
return [leader_uid], {"resolve_source": "dept_leader"}
|
||
|
||
|
||
def resolve_direct_supervisor(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
initiator_user_id: str,
|
||
initiator_dept_id: Optional[str],
|
||
fallback_to_dept_leader: bool = True,
|
||
) -> Tuple[List[str], Dict[str, Any]]:
|
||
initiator = (
|
||
db.query(SysUser)
|
||
.filter(
|
||
SysUser.id == initiator_user_id,
|
||
SysUser.tenant_no == tenant_no,
|
||
SysUser.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not initiator:
|
||
raise AssigneeResolveError("发起人不存在")
|
||
supervisor_uid = _active_user_id(
|
||
db, getattr(initiator, "supervisor_user_id", None), tenant_no
|
||
)
|
||
if supervisor_uid:
|
||
# 允许主管配置为本人(如部门经理),由高级设置 autoPassWhenApproverIsInitiator 决定是否自动通过
|
||
return [supervisor_uid], {"resolve_source": "direct_supervisor"}
|
||
if fallback_to_dept_leader:
|
||
users, meta = resolve_dept_leader(
|
||
db, tenant_no=tenant_no, initiator_dept_id=initiator_dept_id or initiator.dept_id
|
||
)
|
||
meta = {**meta, "resolve_source": "supervisor_fallback_dept_leader"}
|
||
return users, meta
|
||
raise AssigneeResolveError("未配置直属主管,且未开启回退部门负责人")
|
||
|
||
|
||
def resolve_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]]:
|
||
"""
|
||
按节点 approverConfig 解析处理人。
|
||
assigneeType: users | posts | dept_leader | direct_supervisor | initiator_select
|
||
"""
|
||
cfg = assignee_config or {}
|
||
assignee_type = (cfg.get("assigneeType") or cfg.get("assignee_type") or "").strip()
|
||
advanced = advanced or {}
|
||
fallback = advanced.get("supervisorFallbackToDeptLeader", True)
|
||
if isinstance(fallback, str):
|
||
fallback = fallback.lower() in ("1", "true", "yes")
|
||
|
||
if assignee_type == "users":
|
||
ids = cfg.get("userIds") or cfg.get("user_ids") or []
|
||
out: List[str] = []
|
||
seen = set()
|
||
for uid in ids:
|
||
real = _active_user_id(db, uid, tenant_no)
|
||
if real and real not in seen:
|
||
seen.add(real)
|
||
out.append(real)
|
||
if not out:
|
||
raise AssigneeResolveError("指定成员为空或均无效")
|
||
return out, {"resolve_source": "users"}
|
||
|
||
if assignee_type == "posts":
|
||
post_ids = cfg.get("postIds") or cfg.get("post_ids") or []
|
||
users = user_crud.list_user_ids_bound_to_posts(db, list(post_ids), tenant_no)
|
||
out = []
|
||
seen = set()
|
||
for uid in users:
|
||
real = _active_user_id(db, uid, tenant_no)
|
||
if real and real not in seen:
|
||
seen.add(real)
|
||
out.append(real)
|
||
if not out:
|
||
raise AssigneeResolveError("职位成员为空或均无效")
|
||
return out, {"resolve_source": "posts"}
|
||
|
||
if assignee_type == "dept_leader":
|
||
return resolve_dept_leader(
|
||
db, tenant_no=tenant_no, initiator_dept_id=initiator_dept_id
|
||
)
|
||
|
||
if assignee_type == "direct_supervisor":
|
||
return resolve_direct_supervisor(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
initiator_user_id=initiator_user_id,
|
||
initiator_dept_id=initiator_dept_id,
|
||
fallback_to_dept_leader=bool(fallback),
|
||
)
|
||
|
||
if assignee_type == "initiator_select":
|
||
ids = initiator_select_user_ids or cfg.get("userIds") or []
|
||
out = []
|
||
seen = set()
|
||
for uid in ids:
|
||
real = _active_user_id(db, uid, tenant_no)
|
||
if real and real not in seen:
|
||
seen.add(real)
|
||
out.append(real)
|
||
if not out:
|
||
raise AssigneeResolveError("发起人未选择审批人")
|
||
return out, {"resolve_source": "initiator_select"}
|
||
|
||
raise AssigneeResolveError(f"不支持的审批人类型: {assignee_type or '(空)'}")
|