Files
zkxw/backend/app/services/oa_related_approval.py
2026-08-28 17:38:37 +08:00

829 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""关联审批单:字段扁平化与 form_data 抽链"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence, Tuple
from app.models.models import OaApp, OaAppVersion, OaInstance, OaTask
from app.services.oa_engine import OaEngineError, _dept_name, _loads, _user_name_map
LAYOUT_SKIP = frozenset(
{
"fcRow",
"fcTable",
"col",
"row",
"elCard",
"space",
"divider",
"html",
"title",
"text",
"tag",
"card",
"group",
"object",
"leaveKit",
"confirmationKit",
"overtimeKit",
"resignationKit",
"tripKit",
}
)
UPLOAD_TYPES = frozenset({"upload", "fcUpload", "image", "oaAttachment"})
LONG_TYPES = frozenset({"textarea", "input"})
_ACCOUNT_TYPE_LABEL = {
"alipay": "支付宝",
"personal": "个人银行卡",
"corporate": "对公银行账户",
}
def _walk_rules(rules: Any, out: List[dict]) -> None:
if not isinstance(rules, list):
return
for r in rules:
if not isinstance(r, dict):
continue
out.append(r)
children = r.get("children") or (r.get("props") or {}).get("rule") or []
if isinstance(children, list):
_walk_rules(children, out)
def _schema_rules(schema: Any) -> List[dict]:
if isinstance(schema, list):
out: List[dict] = []
_walk_rules(schema, out)
return out
if not isinstance(schema, dict):
return []
out = []
_walk_rules(schema.get("rule") or schema.get("rules") or [], out)
return out
def _extract_upload_urls(raw: Any) -> List[str]:
"""Normalize FormCreate upload / 业务附件值为 URL 列表。"""
if raw is None or raw == "" or raw == []:
return []
if isinstance(raw, list):
items: List[Any] = raw
elif isinstance(raw, str):
items = [x.strip() for x in raw.split(",") if x.strip()]
elif isinstance(raw, dict):
items = [raw]
else:
items = [raw]
urls: List[str] = []
for it in items:
if it is None or it == "":
continue
if isinstance(it, dict):
u = it.get("url") or it.get("fileUrl") or ""
resp = it.get("response")
if not u and isinstance(resp, dict):
data = resp.get("data") if isinstance(resp.get("data"), dict) else {}
u = resp.get("url") or resp.get("fileUrl") or data.get("url") or data.get("fileUrl") or ""
u = str(u or "").strip()
if u:
urls.append(u)
continue
s = str(it).strip()
if s:
urls.append(s)
return urls
def _upload_display_name(raw_item: Any, fallback_url: str = "") -> str:
"""打印/摘要:优先对象 name,否则取路径最后一段文件名。"""
if isinstance(raw_item, dict):
named = str(raw_item.get("name") or "").strip()
if named:
return named
u = str(fallback_url or raw_item or "").strip()
if not u:
return ""
path = u.split("?", 1)[0]
base = path.rsplit("/", 1)[-1]
if not base:
return u
try:
from urllib.parse import unquote
return unquote(base) or base
except Exception:
return base
def _format_upload_display(raw: Any) -> str:
if raw is None or raw == "" or raw == []:
return "0 个"
if isinstance(raw, list):
items: List[Any] = raw
elif isinstance(raw, str):
items = [x.strip() for x in raw.split(",") if x.strip()]
elif isinstance(raw, dict):
items = [raw]
else:
items = [raw]
names: List[str] = []
for it in items:
if it is None or it == "":
continue
if isinstance(it, dict):
named = str(it.get("name") or "").strip()
if named:
names.append(named)
continue
urls = _extract_upload_urls(it)
if urls:
names.append(_upload_display_name(it, urls[0]))
continue
s = str(it).strip()
if s:
names.append(_upload_display_name(s, s))
return "、".join(names) if names else "0 个"
def _upload_files_meta(raw: Any) -> List[Dict[str, str]]:
"""摘要/列表用:上传字段规范化为 {url, name} 列表,供前端缩略图展示。"""
if raw is None or raw == "" or raw == []:
return []
if isinstance(raw, list):
items: List[Any] = raw
elif isinstance(raw, str):
items = [x.strip() for x in raw.split(",") if x.strip()]
elif isinstance(raw, dict):
items = [raw]
else:
items = [raw]
files: List[Dict[str, str]] = []
for it in items:
if it is None or it == "":
continue
if isinstance(it, dict):
urls = _extract_upload_urls(it)
if not urls:
continue
for u in urls:
files.append({"url": u, "name": _upload_display_name(it, u)})
continue
s = str(it).strip()
if not s:
continue
files.append({"url": s, "name": _upload_display_name(s, s)})
return files
def _normalize_related_ids(raw: Any) -> List[str]:
"""Order-preserving dedupe of related approval instance ids."""
if isinstance(raw, list):
ids = raw
elif raw is None or raw == "":
ids = []
else:
ids = [raw]
seen = set()
out: List[str] = []
for x in ids:
tid = str(x or "").strip()
if not tid or tid in seen:
continue
seen.add(tid)
out.append(tid)
return out
def _format_trip_leg(leg: Dict[str, Any], index: int) -> str:
transport = str(leg.get("transport") or "").strip() or "-"
trip_type = str(leg.get("tripType") or "").strip()
from_city = str(leg.get("fromCity") or "").strip() or "-"
to_city = str(leg.get("toCity") or "").strip() or "-"
start_date = str(leg.get("startDate") or "").strip()
start_half = str(leg.get("startHalf") or "").strip()
end_date = str(leg.get("endDate") or "").strip()
end_half = str(leg.get("endHalf") or "").strip()
duration = leg.get("duration")
if duration is None or duration == "":
duration_text = "-"
else:
try:
duration_text = f"{float(duration):g}天"
except (TypeError, ValueError):
duration_text = f"{duration}天"
route = f"{from_city} → {to_city}"
if start_date and end_date:
period = (
f"{start_date}{(' ' + start_half) if start_half else ''}"
f" ~ {end_date}{(' ' + end_half) if end_half else ''}"
)
else:
period = "-"
type_part = f" · {trip_type}" if trip_type else ""
return f"行程{index + 1}:{transport}{type_part} · {route} · {period} · {duration_text}"
def _format_trip_itinerary(raw: Any) -> str:
if isinstance(raw, dict):
legs = [raw]
elif isinstance(raw, list):
legs = [x for x in raw if isinstance(x, dict)]
else:
return ""
if not legs:
return ""
return ";".join(_format_trip_leg(leg, i) for i, leg in enumerate(legs))
def _format_receive_account(raw: Any) -> str:
if isinstance(raw, dict):
items = [raw]
elif isinstance(raw, list):
items = [x for x in raw if isinstance(x, dict)]
else:
return ""
lines: List[str] = []
for it in items:
at = str(it.get("accountType") or "").strip()
type_label = _ACCOUNT_TYPE_LABEL.get(at, at)
parts = [
type_label,
str(it.get("accountName") or "").strip(),
str(it.get("accountNo") or "").strip(),
]
if at in ("personal", "corporate"):
bank = str(it.get("bankName") or "").strip()
if bank:
parts.append(bank)
prov = str(it.get("bankProvince") or "").strip()
city = str(it.get("bankCity") or "").strip()
if prov and city:
parts.append(f"{prov} / {city}")
elif prov or city:
parts.append(prov or city)
branch = str(it.get("bankBranch") or "").strip()
if branch:
parts.append(branch)
remark = str(it.get("remark") or "").strip()
if remark:
parts.append(remark)
line = " · ".join(p for p in parts if p)
if line:
lines.append(line)
return ";".join(lines)
def _format_region_like(raw: Any) -> str:
"""省市区 / 级联:string[] 或 {province,city,district} 等。"""
if raw is None or raw == "":
return ""
if isinstance(raw, str):
return raw.strip()
if isinstance(raw, list):
parts: List[str] = []
for x in raw:
if x is None or x == "":
continue
if isinstance(x, dict):
n = str(
x.get("label") or x.get("name") or x.get("text") or x.get("value") or ""
).strip()
if n:
parts.append(n)
continue
parts.append(str(x).strip())
return " / ".join(p for p in parts if p)
if isinstance(raw, dict):
path = [
str(
raw.get("province")
or raw.get("provinceName")
or raw.get("province_name")
or ""
).strip(),
str(raw.get("city") or raw.get("cityName") or raw.get("city_name") or "").strip(),
str(
raw.get("district")
or raw.get("districtName")
or raw.get("area")
or raw.get("areaName")
or raw.get("district_name")
or ""
).strip(),
]
path = [p for p in path if p]
if path:
return " / ".join(path)
return _format_unknown_object(raw)
return str(raw).strip()
def _format_unknown_object(raw: Dict[str, Any]) -> str:
"""Never dump full dict/JSON; prefer common display keys then scalar values."""
named = str(
raw.get("label") or raw.get("name") or raw.get("text") or raw.get("value") or ""
).strip()
if named:
return named
if raw.get("accountType") or raw.get("accountNo") or raw.get("accountName"):
return _format_receive_account(raw)
if raw.get("fromCity") or raw.get("toCity") or raw.get("transport"):
return _format_trip_leg(raw, 0)
skip_keys = {
"id",
"uuid",
"key",
"durationManual",
"manual",
"__manual",
}
parts: List[str] = []
for k, v in raw.items():
ks = str(k)
if ks in skip_keys or ks.startswith("__") or ks.lower().endswith("id"):
continue
if isinstance(v, bool) or v is None or v == "":
continue
if isinstance(v, (str, int, float)) and not isinstance(v, bool):
parts.append(str(v).strip())
return "、".join(p for p in parts if p)
def _format_value(typ: str, raw: Any) -> Tuple[str, bool]:
"""returns (text, fullWidth)"""
if typ in UPLOAD_TYPES:
text = _format_upload_display(raw)
# 展示附件文件名(多条用中文顿号);较长时占满一行
return text, True
if typ == "relatedApproval":
if isinstance(raw, list):
n = len([x for x in raw if str(x).strip()])
elif raw:
n = 1
else:
n = 0
return f"已关联 {n} 条", False
if (typ == "dept" or typ == "person" or typ == "post" or typ == "ownerDept" or typ == "ownerPerson"):
if isinstance(raw, list):
names = []
for x in raw:
if isinstance(x, dict):
n = str(x.get("name") or "").strip()
if n:
names.append(n)
return "、".join(names) if names else "", False
if isinstance(raw, dict):
return str(raw.get("name") or "").strip(), False
return "", False
if typ == "money":
if raw is None or raw == "":
return "", False
try:
if isinstance(raw, bool):
return "", False
v = float(str(raw).replace(",", "").strip())
except (TypeError, ValueError):
return str(raw), False
text = f"{v:,.2f}"
return text, False
if typ == "tripItinerary":
text = _format_trip_itinerary(raw)
return text, True
if typ == "receiveAccount":
text = _format_receive_account(raw)
return text, True
if typ in ("region", "cascader"):
return _format_region_like(raw), False
if typ in ("dateRange", "timeRange") or (
typ in ("datePicker", "date", "timePicker", "time") and isinstance(raw, list)
):
if isinstance(raw, list) and len(raw) >= 2:
a = str(raw[0] or "").strip()
b = str(raw[1] or "").strip()
if a or b:
return f"{a or '-'} ~ {b or '-'}", False
if isinstance(raw, list):
return "、".join(str(x).strip() for x in raw if x is not None and str(x).strip()), False
if isinstance(raw, list):
parts: List[str] = []
for x in raw:
if x is None or str(x) == "":
continue
if isinstance(x, dict):
n = _format_unknown_object(x)
if n:
parts.append(n)
continue
parts.append(str(x))
text = "、".join(parts)
elif isinstance(raw, dict):
text = _format_unknown_object(raw)
else:
text = "" if raw is None else str(raw)
full = typ in LONG_TYPES and len(text) > 40
return text, full
def flatten_form_fields_for_summary(schema: Any, form_data: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
data = form_data or {}
out: List[Dict[str, Any]] = []
for rule in _schema_rules(schema):
typ = str(rule.get("type") or "")
if not typ or typ in LAYOUT_SKIP:
continue
field = str(rule.get("field") or "").strip()
if not field:
continue
label = str(rule.get("title") or rule.get("label") or field)
raw = data.get(field)
if typ == "relatedApproval":
ids = _normalize_related_ids(raw)
item: Dict[str, Any] = {
"fieldKey": field,
"label": label,
"value": f"已关联 {len(ids)} 条",
"relatedIds": ids,
}
out.append(item)
continue
if typ in UPLOAD_TYPES:
text, full = _format_value(typ, raw)
item = {
"fieldKey": field,
"label": label,
"value": text,
"fieldType": typ,
"files": _upload_files_meta(raw),
}
if full:
item["fullWidth"] = True
out.append(item)
continue
text, full = _format_value(typ, raw)
item = {"fieldKey": field, "label": label, "value": text}
if full:
item["fullWidth"] = True
out.append(item)
return out
def extract_related_approval_links(
schema: Any, form_data: Optional[Dict[str, Any]]
) -> List[Tuple[str, str]]:
"""Ordered unique (field_key, to_instance_id)."""
data = form_data or {}
seen = set()
links: List[Tuple[str, str]] = []
for rule in _schema_rules(schema):
if str(rule.get("type") or "") != "relatedApproval":
continue
field = str(rule.get("field") or "").strip()
if not field:
continue
raw = data.get(field)
ids: Sequence[Any]
if isinstance(raw, list):
ids = raw
elif raw is None or raw == "":
ids = []
else:
ids = [raw]
for x in ids:
tid = str(x or "").strip()
if not tid:
continue
key = (field, tid)
if key in seen:
continue
seen.add(key)
links.append((field, tid))
return links
def merge_relation_targets(
schema: Any,
form_data: Optional[Dict[str, Any]],
legacy_ids: Optional[List[str]] = None,
) -> List[Tuple[Optional[str], str]]:
"""Returns (field_key|None, to_id) — form fields first, then legacy without key."""
links = extract_related_approval_links(schema, form_data)
result: List[Tuple[Optional[str], str]] = [(f, i) for f, i in links]
seen_ids = {i for _, i in links}
for x in legacy_ids or []:
tid = str(x or "").strip()
if not tid or tid in seen_ids:
continue
result.append((None, tid))
seen_ids.add(tid)
return result
_DONE_TASK_STATUSES = ("approved", "rejected", "returned", "transferred")
def _user_can_link_instance(db, *, inst: OaInstance, user_id: str, tenant_no: str) -> bool:
if (inst.initiator_user_id or "") == user_id:
return True
if db is None:
return False
done = (
db.query(OaTask.id)
.filter(
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
OaTask.instance_id == inst.id,
OaTask.assignee_user_id == user_id,
OaTask.task_type == "approval",
OaTask.status.in_(_DONE_TASK_STATUSES),
)
.first()
)
if done:
return True
cc = (
db.query(OaTask.id)
.filter(
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
OaTask.instance_id == inst.id,
OaTask.assignee_user_id == user_id,
OaTask.task_type == "cc",
)
.first()
)
return bool(cc)
def assert_linkable_instance(
inst: OaInstance,
*,
user_id: str,
tenant_no: str,
db=None,
) -> None:
if not inst or inst.del_flag != 0 or inst.tenant_no != tenant_no:
raise OaEngineError("关联的审批单不存在")
if not _user_can_link_instance(db, inst=inst, user_id=user_id, tenant_no=tenant_no):
raise OaEngineError("无权关联该审批单")
_STATUS_TEXT = {
"running": "处理中",
"completed": "已通过",
"rejected": "已拒绝",
"returned": "已打回",
"cancelled": "已撤销",
}
def related_status_text(status: str) -> str:
s = (status or "").strip()
return _STATUS_TEXT.get(s, s or "-")
def related_display_title(initiator_name: str, app_name: str, title: str) -> str:
name = (initiator_name or "").strip()
app = (app_name or "").strip()
if name and app:
return f"{name}提交的{app}"
return app or (title or "").strip() or "审批单"
def _instance_ids_for_tab(db, *, tenant_no: str, user_id: str, tab: str) -> Optional[List[str]]:
"""Return instance id list for done/cc tabs; None means mine (filter on initiator)."""
if tab == "mine":
return None
if tab == "done":
rows = (
db.query(OaTask.instance_id)
.filter(
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
OaTask.assignee_user_id == user_id,
OaTask.task_type == "approval",
OaTask.status.in_(_DONE_TASK_STATUSES),
)
.distinct()
.all()
)
return [r[0] for r in rows if r[0]]
if tab == "cc":
rows = (
db.query(OaTask.instance_id)
.filter(
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
OaTask.assignee_user_id == user_id,
OaTask.task_type == "cc",
)
.distinct()
.all()
)
return [r[0] for r in rows if r[0]]
raise OaEngineError(f"不支持的选单类型: {tab}")
def list_related_candidates(
db,
*,
tenant_no: str,
user_id: str,
tab: str = "mine",
keyword: str = "",
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
from sqlalchemy import or_
tab_n = (tab or "mine").strip() or "mine"
if tab_n not in ("mine", "done", "cc"):
raise OaEngineError(f"不支持的选单类型: {tab_n}")
q = db.query(OaInstance).filter(
OaInstance.tenant_no == tenant_no,
OaInstance.del_flag == 0,
)
tab_ids = _instance_ids_for_tab(db, tenant_no=tenant_no, user_id=user_id, tab=tab_n)
if tab_ids is None:
q = q.filter(OaInstance.initiator_user_id == user_id)
else:
if not tab_ids:
return {"list": [], "total": 0, "page": max(1, int(page or 1)), "pageSize": min(100, max(1, int(page_size or 20)))}
q = q.filter(OaInstance.id.in_(tab_ids))
kw = (keyword or "").strip()
if kw:
app_ids = [
row[0]
for row in db.query(OaApp.id)
.filter(
OaApp.tenant_no == tenant_no,
OaApp.del_flag == 0,
OaApp.app_name.like(f"%{kw}%"),
)
.all()
]
clauses = [OaInstance.title.like(f"%{kw}%")]
if app_ids:
clauses.append(OaInstance.app_id.in_(app_ids))
q = q.filter(or_(*clauses))
total = q.count()
page = max(1, int(page or 1))
page_size = min(100, max(1, int(page_size or 20)))
rows = (
q.order_by(OaInstance.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
app_map: Dict[str, str] = {}
app_ids_page = list({r.app_id for r in rows if r.app_id})
if app_ids_page:
for a in db.query(OaApp).filter(OaApp.id.in_(app_ids_page)).all():
app_map[a.id] = a.app_name or ""
initiator_ids = [r.initiator_user_id for r in rows if r.initiator_user_id]
inst_ids = [r.id for r in rows]
pending_tasks = (
db.query(OaTask)
.filter(
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
OaTask.instance_id.in_(inst_ids),
OaTask.task_type == "approval",
OaTask.status == "pending",
)
.all()
if inst_ids
else []
)
pending_by_inst: Dict[str, List[OaTask]] = {}
for t in pending_tasks:
pending_by_inst.setdefault(t.instance_id, []).append(t)
if t.assignee_user_id:
initiator_ids.append(t.assignee_user_id)
name_map = _user_name_map(db, initiator_ids)
ver_ids = list({r.app_version_id for r in rows if r.app_version_id})
ver_by_id: Dict[str, OaAppVersion] = {}
if ver_ids:
for v in (
db.query(OaAppVersion)
.filter(OaAppVersion.id.in_(ver_ids), OaAppVersion.del_flag == 0)
.all()
):
ver_by_id[v.id] = v
items: List[Dict[str, Any]] = []
for r in rows:
app_name = app_map.get(r.app_id, "")
initiator_name = name_map.get(r.initiator_user_id or "", "") or ""
status = r.status or ""
approver_names = ""
if status == "running":
names: List[str] = []
for t in pending_by_inst.get(r.id, []):
uid = (t.assignee_user_id or "").strip()
if uid:
names.append(name_map.get(uid, uid))
approver_names = "、".join(names)
ver = ver_by_id.get(r.app_version_id or "")
schema = _loads(ver.form_schema_json, {}) if ver else {}
form_data = _loads(r.form_data_json, {}) if r.form_data_json else {}
preview = [
{"label": f.get("label") or "", "value": f.get("value") or ""}
for f in flatten_form_fields_for_summary(schema, form_data)[:2]
]
items.append(
{
"id": r.id,
"appName": app_name,
"title": r.title or "",
"displayTitle": related_display_title(initiator_name, app_name, r.title or ""),
"initiatorUserName": initiator_name,
"initiatorDeptName": _dept_name(
db, getattr(r, "initiator_dept_id", None), tenant_no=tenant_no
),
"applyTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
"status": status,
"statusText": related_status_text(status),
"currentApproverNames": approver_names,
"previewFields": preview,
}
)
return {"list": items, "total": total, "page": page, "pageSize": page_size}
def build_related_summaries(
db,
*,
tenant_no: str,
user_id: str,
ids: List[str],
mode: str = "picker",
) -> List[Dict[str, Any]]:
mode = (mode or "picker").strip()
out: List[Dict[str, Any]] = []
clean = [str(i).strip() for i in ids if str(i).strip()]
if not clean:
return out
insts = (
db.query(OaInstance)
.filter(
OaInstance.tenant_no == tenant_no,
OaInstance.del_flag == 0,
OaInstance.id.in_(clean),
)
.all()
)
by_id = {i.id: i for i in insts}
for iid in clean:
inst = by_id.get(iid)
if not inst:
out.append(
{
"id": iid,
"unavailable": True,
"appName": "",
"applyTime": "",
"status": "",
"fields": [],
}
)
continue
if mode == "picker":
try:
assert_linkable_instance(inst, user_id=user_id, tenant_no=tenant_no, db=db)
except OaEngineError:
out.append(
{
"id": iid,
"unavailable": True,
"appName": "",
"applyTime": "",
"status": "",
"fields": [],
}
)
continue
app = db.query(OaApp).filter(OaApp.id == inst.app_id, OaApp.del_flag == 0).first()
ver = (
db.query(OaAppVersion)
.filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
.first()
)
schema = _loads(ver.form_schema_json, {}) if ver else {}
form_data = _loads(inst.form_data_json, {}) if inst.form_data_json else {}
out.append(
{
"id": inst.id,
"unavailable": False,
"appName": (app.app_name if app else "") or (inst.title or ""),
"applyTime": inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "",
"status": inst.status or "",
"fields": flatten_form_fields_for_summary(schema, form_data),
}
)
return out