oa_related_approval.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828
  1. """关联审批单:字段扁平化与 form_data 抽链"""
  2. from __future__ import annotations
  3. from typing import Any, Dict, List, Optional, Sequence, Tuple
  4. from app.models.models import OaApp, OaAppVersion, OaInstance, OaTask
  5. from app.services.oa_engine import OaEngineError, _dept_name, _loads, _user_name_map
  6. LAYOUT_SKIP = frozenset(
  7. {
  8. "fcRow",
  9. "fcTable",
  10. "col",
  11. "row",
  12. "elCard",
  13. "space",
  14. "divider",
  15. "html",
  16. "title",
  17. "text",
  18. "tag",
  19. "card",
  20. "group",
  21. "object",
  22. "leaveKit",
  23. "confirmationKit",
  24. "overtimeKit",
  25. "resignationKit",
  26. "tripKit",
  27. }
  28. )
  29. UPLOAD_TYPES = frozenset({"upload", "fcUpload", "image", "oaAttachment"})
  30. LONG_TYPES = frozenset({"textarea", "input"})
  31. _ACCOUNT_TYPE_LABEL = {
  32. "alipay": "支付宝",
  33. "personal": "个人银行卡",
  34. "corporate": "对公银行账户",
  35. }
  36. def _walk_rules(rules: Any, out: List[dict]) -> None:
  37. if not isinstance(rules, list):
  38. return
  39. for r in rules:
  40. if not isinstance(r, dict):
  41. continue
  42. out.append(r)
  43. children = r.get("children") or (r.get("props") or {}).get("rule") or []
  44. if isinstance(children, list):
  45. _walk_rules(children, out)
  46. def _schema_rules(schema: Any) -> List[dict]:
  47. if isinstance(schema, list):
  48. out: List[dict] = []
  49. _walk_rules(schema, out)
  50. return out
  51. if not isinstance(schema, dict):
  52. return []
  53. out = []
  54. _walk_rules(schema.get("rule") or schema.get("rules") or [], out)
  55. return out
  56. def _extract_upload_urls(raw: Any) -> List[str]:
  57. """Normalize FormCreate upload / 业务附件值为 URL 列表。"""
  58. if raw is None or raw == "" or raw == []:
  59. return []
  60. if isinstance(raw, list):
  61. items: List[Any] = raw
  62. elif isinstance(raw, str):
  63. items = [x.strip() for x in raw.split(",") if x.strip()]
  64. elif isinstance(raw, dict):
  65. items = [raw]
  66. else:
  67. items = [raw]
  68. urls: List[str] = []
  69. for it in items:
  70. if it is None or it == "":
  71. continue
  72. if isinstance(it, dict):
  73. u = it.get("url") or it.get("fileUrl") or ""
  74. resp = it.get("response")
  75. if not u and isinstance(resp, dict):
  76. data = resp.get("data") if isinstance(resp.get("data"), dict) else {}
  77. u = resp.get("url") or resp.get("fileUrl") or data.get("url") or data.get("fileUrl") or ""
  78. u = str(u or "").strip()
  79. if u:
  80. urls.append(u)
  81. continue
  82. s = str(it).strip()
  83. if s:
  84. urls.append(s)
  85. return urls
  86. def _upload_display_name(raw_item: Any, fallback_url: str = "") -> str:
  87. """打印/摘要:优先对象 name,否则取路径最后一段文件名。"""
  88. if isinstance(raw_item, dict):
  89. named = str(raw_item.get("name") or "").strip()
  90. if named:
  91. return named
  92. u = str(fallback_url or raw_item or "").strip()
  93. if not u:
  94. return ""
  95. path = u.split("?", 1)[0]
  96. base = path.rsplit("/", 1)[-1]
  97. if not base:
  98. return u
  99. try:
  100. from urllib.parse import unquote
  101. return unquote(base) or base
  102. except Exception:
  103. return base
  104. def _format_upload_display(raw: Any) -> str:
  105. if raw is None or raw == "" or raw == []:
  106. return "0 个"
  107. if isinstance(raw, list):
  108. items: List[Any] = raw
  109. elif isinstance(raw, str):
  110. items = [x.strip() for x in raw.split(",") if x.strip()]
  111. elif isinstance(raw, dict):
  112. items = [raw]
  113. else:
  114. items = [raw]
  115. names: List[str] = []
  116. for it in items:
  117. if it is None or it == "":
  118. continue
  119. if isinstance(it, dict):
  120. named = str(it.get("name") or "").strip()
  121. if named:
  122. names.append(named)
  123. continue
  124. urls = _extract_upload_urls(it)
  125. if urls:
  126. names.append(_upload_display_name(it, urls[0]))
  127. continue
  128. s = str(it).strip()
  129. if s:
  130. names.append(_upload_display_name(s, s))
  131. return "、".join(names) if names else "0 个"
  132. def _upload_files_meta(raw: Any) -> List[Dict[str, str]]:
  133. """摘要/列表用:上传字段规范化为 {url, name} 列表,供前端缩略图展示。"""
  134. if raw is None or raw == "" or raw == []:
  135. return []
  136. if isinstance(raw, list):
  137. items: List[Any] = raw
  138. elif isinstance(raw, str):
  139. items = [x.strip() for x in raw.split(",") if x.strip()]
  140. elif isinstance(raw, dict):
  141. items = [raw]
  142. else:
  143. items = [raw]
  144. files: List[Dict[str, str]] = []
  145. for it in items:
  146. if it is None or it == "":
  147. continue
  148. if isinstance(it, dict):
  149. urls = _extract_upload_urls(it)
  150. if not urls:
  151. continue
  152. for u in urls:
  153. files.append({"url": u, "name": _upload_display_name(it, u)})
  154. continue
  155. s = str(it).strip()
  156. if not s:
  157. continue
  158. files.append({"url": s, "name": _upload_display_name(s, s)})
  159. return files
  160. def _normalize_related_ids(raw: Any) -> List[str]:
  161. """Order-preserving dedupe of related approval instance ids."""
  162. if isinstance(raw, list):
  163. ids = raw
  164. elif raw is None or raw == "":
  165. ids = []
  166. else:
  167. ids = [raw]
  168. seen = set()
  169. out: List[str] = []
  170. for x in ids:
  171. tid = str(x or "").strip()
  172. if not tid or tid in seen:
  173. continue
  174. seen.add(tid)
  175. out.append(tid)
  176. return out
  177. def _format_trip_leg(leg: Dict[str, Any], index: int) -> str:
  178. transport = str(leg.get("transport") or "").strip() or "-"
  179. trip_type = str(leg.get("tripType") or "").strip()
  180. from_city = str(leg.get("fromCity") or "").strip() or "-"
  181. to_city = str(leg.get("toCity") or "").strip() or "-"
  182. start_date = str(leg.get("startDate") or "").strip()
  183. start_half = str(leg.get("startHalf") or "").strip()
  184. end_date = str(leg.get("endDate") or "").strip()
  185. end_half = str(leg.get("endHalf") or "").strip()
  186. duration = leg.get("duration")
  187. if duration is None or duration == "":
  188. duration_text = "-"
  189. else:
  190. try:
  191. duration_text = f"{float(duration):g}天"
  192. except (TypeError, ValueError):
  193. duration_text = f"{duration}天"
  194. route = f"{from_city} → {to_city}"
  195. if start_date and end_date:
  196. period = (
  197. f"{start_date}{(' ' + start_half) if start_half else ''}"
  198. f" ~ {end_date}{(' ' + end_half) if end_half else ''}"
  199. )
  200. else:
  201. period = "-"
  202. type_part = f" · {trip_type}" if trip_type else ""
  203. return f"行程{index + 1}:{transport}{type_part} · {route} · {period} · {duration_text}"
  204. def _format_trip_itinerary(raw: Any) -> str:
  205. if isinstance(raw, dict):
  206. legs = [raw]
  207. elif isinstance(raw, list):
  208. legs = [x for x in raw if isinstance(x, dict)]
  209. else:
  210. return ""
  211. if not legs:
  212. return ""
  213. return ";".join(_format_trip_leg(leg, i) for i, leg in enumerate(legs))
  214. def _format_receive_account(raw: Any) -> str:
  215. if isinstance(raw, dict):
  216. items = [raw]
  217. elif isinstance(raw, list):
  218. items = [x for x in raw if isinstance(x, dict)]
  219. else:
  220. return ""
  221. lines: List[str] = []
  222. for it in items:
  223. at = str(it.get("accountType") or "").strip()
  224. type_label = _ACCOUNT_TYPE_LABEL.get(at, at)
  225. parts = [
  226. type_label,
  227. str(it.get("accountName") or "").strip(),
  228. str(it.get("accountNo") or "").strip(),
  229. ]
  230. if at in ("personal", "corporate"):
  231. bank = str(it.get("bankName") or "").strip()
  232. if bank:
  233. parts.append(bank)
  234. prov = str(it.get("bankProvince") or "").strip()
  235. city = str(it.get("bankCity") or "").strip()
  236. if prov and city:
  237. parts.append(f"{prov} / {city}")
  238. elif prov or city:
  239. parts.append(prov or city)
  240. branch = str(it.get("bankBranch") or "").strip()
  241. if branch:
  242. parts.append(branch)
  243. remark = str(it.get("remark") or "").strip()
  244. if remark:
  245. parts.append(remark)
  246. line = " · ".join(p for p in parts if p)
  247. if line:
  248. lines.append(line)
  249. return ";".join(lines)
  250. def _format_region_like(raw: Any) -> str:
  251. """省市区 / 级联:string[] 或 {province,city,district} 等。"""
  252. if raw is None or raw == "":
  253. return ""
  254. if isinstance(raw, str):
  255. return raw.strip()
  256. if isinstance(raw, list):
  257. parts: List[str] = []
  258. for x in raw:
  259. if x is None or x == "":
  260. continue
  261. if isinstance(x, dict):
  262. n = str(
  263. x.get("label") or x.get("name") or x.get("text") or x.get("value") or ""
  264. ).strip()
  265. if n:
  266. parts.append(n)
  267. continue
  268. parts.append(str(x).strip())
  269. return " / ".join(p for p in parts if p)
  270. if isinstance(raw, dict):
  271. path = [
  272. str(
  273. raw.get("province")
  274. or raw.get("provinceName")
  275. or raw.get("province_name")
  276. or ""
  277. ).strip(),
  278. str(raw.get("city") or raw.get("cityName") or raw.get("city_name") or "").strip(),
  279. str(
  280. raw.get("district")
  281. or raw.get("districtName")
  282. or raw.get("area")
  283. or raw.get("areaName")
  284. or raw.get("district_name")
  285. or ""
  286. ).strip(),
  287. ]
  288. path = [p for p in path if p]
  289. if path:
  290. return " / ".join(path)
  291. return _format_unknown_object(raw)
  292. return str(raw).strip()
  293. def _format_unknown_object(raw: Dict[str, Any]) -> str:
  294. """Never dump full dict/JSON; prefer common display keys then scalar values."""
  295. named = str(
  296. raw.get("label") or raw.get("name") or raw.get("text") or raw.get("value") or ""
  297. ).strip()
  298. if named:
  299. return named
  300. if raw.get("accountType") or raw.get("accountNo") or raw.get("accountName"):
  301. return _format_receive_account(raw)
  302. if raw.get("fromCity") or raw.get("toCity") or raw.get("transport"):
  303. return _format_trip_leg(raw, 0)
  304. skip_keys = {
  305. "id",
  306. "uuid",
  307. "key",
  308. "durationManual",
  309. "manual",
  310. "__manual",
  311. }
  312. parts: List[str] = []
  313. for k, v in raw.items():
  314. ks = str(k)
  315. if ks in skip_keys or ks.startswith("__") or ks.lower().endswith("id"):
  316. continue
  317. if isinstance(v, bool) or v is None or v == "":
  318. continue
  319. if isinstance(v, (str, int, float)) and not isinstance(v, bool):
  320. parts.append(str(v).strip())
  321. return "、".join(p for p in parts if p)
  322. def _format_value(typ: str, raw: Any) -> Tuple[str, bool]:
  323. """returns (text, fullWidth)"""
  324. if typ in UPLOAD_TYPES:
  325. text = _format_upload_display(raw)
  326. # 展示附件文件名(多条用中文顿号);较长时占满一行
  327. return text, True
  328. if typ == "relatedApproval":
  329. if isinstance(raw, list):
  330. n = len([x for x in raw if str(x).strip()])
  331. elif raw:
  332. n = 1
  333. else:
  334. n = 0
  335. return f"已关联 {n} 条", False
  336. if (typ == "dept" or typ == "person" or typ == "post" or typ == "ownerDept" or typ == "ownerPerson"):
  337. if isinstance(raw, list):
  338. names = []
  339. for x in raw:
  340. if isinstance(x, dict):
  341. n = str(x.get("name") or "").strip()
  342. if n:
  343. names.append(n)
  344. return "、".join(names) if names else "", False
  345. if isinstance(raw, dict):
  346. return str(raw.get("name") or "").strip(), False
  347. return "", False
  348. if typ == "money":
  349. if raw is None or raw == "":
  350. return "", False
  351. try:
  352. if isinstance(raw, bool):
  353. return "", False
  354. v = float(str(raw).replace(",", "").strip())
  355. except (TypeError, ValueError):
  356. return str(raw), False
  357. text = f"{v:,.2f}"
  358. return text, False
  359. if typ == "tripItinerary":
  360. text = _format_trip_itinerary(raw)
  361. return text, True
  362. if typ == "receiveAccount":
  363. text = _format_receive_account(raw)
  364. return text, True
  365. if typ in ("region", "cascader"):
  366. return _format_region_like(raw), False
  367. if typ in ("dateRange", "timeRange") or (
  368. typ in ("datePicker", "date", "timePicker", "time") and isinstance(raw, list)
  369. ):
  370. if isinstance(raw, list) and len(raw) >= 2:
  371. a = str(raw[0] or "").strip()
  372. b = str(raw[1] or "").strip()
  373. if a or b:
  374. return f"{a or '-'} ~ {b or '-'}", False
  375. if isinstance(raw, list):
  376. return "、".join(str(x).strip() for x in raw if x is not None and str(x).strip()), False
  377. if isinstance(raw, list):
  378. parts: List[str] = []
  379. for x in raw:
  380. if x is None or str(x) == "":
  381. continue
  382. if isinstance(x, dict):
  383. n = _format_unknown_object(x)
  384. if n:
  385. parts.append(n)
  386. continue
  387. parts.append(str(x))
  388. text = "、".join(parts)
  389. elif isinstance(raw, dict):
  390. text = _format_unknown_object(raw)
  391. else:
  392. text = "" if raw is None else str(raw)
  393. full = typ in LONG_TYPES and len(text) > 40
  394. return text, full
  395. def flatten_form_fields_for_summary(schema: Any, form_data: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
  396. data = form_data or {}
  397. out: List[Dict[str, Any]] = []
  398. for rule in _schema_rules(schema):
  399. typ = str(rule.get("type") or "")
  400. if not typ or typ in LAYOUT_SKIP:
  401. continue
  402. field = str(rule.get("field") or "").strip()
  403. if not field:
  404. continue
  405. label = str(rule.get("title") or rule.get("label") or field)
  406. raw = data.get(field)
  407. if typ == "relatedApproval":
  408. ids = _normalize_related_ids(raw)
  409. item: Dict[str, Any] = {
  410. "fieldKey": field,
  411. "label": label,
  412. "value": f"已关联 {len(ids)} 条",
  413. "relatedIds": ids,
  414. }
  415. out.append(item)
  416. continue
  417. if typ in UPLOAD_TYPES:
  418. text, full = _format_value(typ, raw)
  419. item = {
  420. "fieldKey": field,
  421. "label": label,
  422. "value": text,
  423. "fieldType": typ,
  424. "files": _upload_files_meta(raw),
  425. }
  426. if full:
  427. item["fullWidth"] = True
  428. out.append(item)
  429. continue
  430. text, full = _format_value(typ, raw)
  431. item = {"fieldKey": field, "label": label, "value": text}
  432. if full:
  433. item["fullWidth"] = True
  434. out.append(item)
  435. return out
  436. def extract_related_approval_links(
  437. schema: Any, form_data: Optional[Dict[str, Any]]
  438. ) -> List[Tuple[str, str]]:
  439. """Ordered unique (field_key, to_instance_id)."""
  440. data = form_data or {}
  441. seen = set()
  442. links: List[Tuple[str, str]] = []
  443. for rule in _schema_rules(schema):
  444. if str(rule.get("type") or "") != "relatedApproval":
  445. continue
  446. field = str(rule.get("field") or "").strip()
  447. if not field:
  448. continue
  449. raw = data.get(field)
  450. ids: Sequence[Any]
  451. if isinstance(raw, list):
  452. ids = raw
  453. elif raw is None or raw == "":
  454. ids = []
  455. else:
  456. ids = [raw]
  457. for x in ids:
  458. tid = str(x or "").strip()
  459. if not tid:
  460. continue
  461. key = (field, tid)
  462. if key in seen:
  463. continue
  464. seen.add(key)
  465. links.append((field, tid))
  466. return links
  467. def merge_relation_targets(
  468. schema: Any,
  469. form_data: Optional[Dict[str, Any]],
  470. legacy_ids: Optional[List[str]] = None,
  471. ) -> List[Tuple[Optional[str], str]]:
  472. """Returns (field_key|None, to_id) — form fields first, then legacy without key."""
  473. links = extract_related_approval_links(schema, form_data)
  474. result: List[Tuple[Optional[str], str]] = [(f, i) for f, i in links]
  475. seen_ids = {i for _, i in links}
  476. for x in legacy_ids or []:
  477. tid = str(x or "").strip()
  478. if not tid or tid in seen_ids:
  479. continue
  480. result.append((None, tid))
  481. seen_ids.add(tid)
  482. return result
  483. _DONE_TASK_STATUSES = ("approved", "rejected", "returned", "transferred")
  484. def _user_can_link_instance(db, *, inst: OaInstance, user_id: str, tenant_no: str) -> bool:
  485. if (inst.initiator_user_id or "") == user_id:
  486. return True
  487. if db is None:
  488. return False
  489. done = (
  490. db.query(OaTask.id)
  491. .filter(
  492. OaTask.tenant_no == tenant_no,
  493. OaTask.del_flag == 0,
  494. OaTask.instance_id == inst.id,
  495. OaTask.assignee_user_id == user_id,
  496. OaTask.task_type == "approval",
  497. OaTask.status.in_(_DONE_TASK_STATUSES),
  498. )
  499. .first()
  500. )
  501. if done:
  502. return True
  503. cc = (
  504. db.query(OaTask.id)
  505. .filter(
  506. OaTask.tenant_no == tenant_no,
  507. OaTask.del_flag == 0,
  508. OaTask.instance_id == inst.id,
  509. OaTask.assignee_user_id == user_id,
  510. OaTask.task_type == "cc",
  511. )
  512. .first()
  513. )
  514. return bool(cc)
  515. def assert_linkable_instance(
  516. inst: OaInstance,
  517. *,
  518. user_id: str,
  519. tenant_no: str,
  520. db=None,
  521. ) -> None:
  522. if not inst or inst.del_flag != 0 or inst.tenant_no != tenant_no:
  523. raise OaEngineError("关联的审批单不存在")
  524. if not _user_can_link_instance(db, inst=inst, user_id=user_id, tenant_no=tenant_no):
  525. raise OaEngineError("无权关联该审批单")
  526. _STATUS_TEXT = {
  527. "running": "处理中",
  528. "completed": "已通过",
  529. "rejected": "已拒绝",
  530. "returned": "已打回",
  531. "cancelled": "已撤销",
  532. }
  533. def related_status_text(status: str) -> str:
  534. s = (status or "").strip()
  535. return _STATUS_TEXT.get(s, s or "-")
  536. def related_display_title(initiator_name: str, app_name: str, title: str) -> str:
  537. name = (initiator_name or "").strip()
  538. app = (app_name or "").strip()
  539. if name and app:
  540. return f"{name}提交的{app}"
  541. return app or (title or "").strip() or "审批单"
  542. def _instance_ids_for_tab(db, *, tenant_no: str, user_id: str, tab: str) -> Optional[List[str]]:
  543. """Return instance id list for done/cc tabs; None means mine (filter on initiator)."""
  544. if tab == "mine":
  545. return None
  546. if tab == "done":
  547. rows = (
  548. db.query(OaTask.instance_id)
  549. .filter(
  550. OaTask.tenant_no == tenant_no,
  551. OaTask.del_flag == 0,
  552. OaTask.assignee_user_id == user_id,
  553. OaTask.task_type == "approval",
  554. OaTask.status.in_(_DONE_TASK_STATUSES),
  555. )
  556. .distinct()
  557. .all()
  558. )
  559. return [r[0] for r in rows if r[0]]
  560. if tab == "cc":
  561. rows = (
  562. db.query(OaTask.instance_id)
  563. .filter(
  564. OaTask.tenant_no == tenant_no,
  565. OaTask.del_flag == 0,
  566. OaTask.assignee_user_id == user_id,
  567. OaTask.task_type == "cc",
  568. )
  569. .distinct()
  570. .all()
  571. )
  572. return [r[0] for r in rows if r[0]]
  573. raise OaEngineError(f"不支持的选单类型: {tab}")
  574. def list_related_candidates(
  575. db,
  576. *,
  577. tenant_no: str,
  578. user_id: str,
  579. tab: str = "mine",
  580. keyword: str = "",
  581. page: int = 1,
  582. page_size: int = 20,
  583. ) -> Dict[str, Any]:
  584. from sqlalchemy import or_
  585. tab_n = (tab or "mine").strip() or "mine"
  586. if tab_n not in ("mine", "done", "cc"):
  587. raise OaEngineError(f"不支持的选单类型: {tab_n}")
  588. q = db.query(OaInstance).filter(
  589. OaInstance.tenant_no == tenant_no,
  590. OaInstance.del_flag == 0,
  591. )
  592. tab_ids = _instance_ids_for_tab(db, tenant_no=tenant_no, user_id=user_id, tab=tab_n)
  593. if tab_ids is None:
  594. q = q.filter(OaInstance.initiator_user_id == user_id)
  595. else:
  596. if not tab_ids:
  597. return {"list": [], "total": 0, "page": max(1, int(page or 1)), "pageSize": min(100, max(1, int(page_size or 20)))}
  598. q = q.filter(OaInstance.id.in_(tab_ids))
  599. kw = (keyword or "").strip()
  600. if kw:
  601. app_ids = [
  602. row[0]
  603. for row in db.query(OaApp.id)
  604. .filter(
  605. OaApp.tenant_no == tenant_no,
  606. OaApp.del_flag == 0,
  607. OaApp.app_name.like(f"%{kw}%"),
  608. )
  609. .all()
  610. ]
  611. clauses = [OaInstance.title.like(f"%{kw}%")]
  612. if app_ids:
  613. clauses.append(OaInstance.app_id.in_(app_ids))
  614. q = q.filter(or_(*clauses))
  615. total = q.count()
  616. page = max(1, int(page or 1))
  617. page_size = min(100, max(1, int(page_size or 20)))
  618. rows = (
  619. q.order_by(OaInstance.create_time.desc())
  620. .offset((page - 1) * page_size)
  621. .limit(page_size)
  622. .all()
  623. )
  624. app_map: Dict[str, str] = {}
  625. app_ids_page = list({r.app_id for r in rows if r.app_id})
  626. if app_ids_page:
  627. for a in db.query(OaApp).filter(OaApp.id.in_(app_ids_page)).all():
  628. app_map[a.id] = a.app_name or ""
  629. initiator_ids = [r.initiator_user_id for r in rows if r.initiator_user_id]
  630. inst_ids = [r.id for r in rows]
  631. pending_tasks = (
  632. db.query(OaTask)
  633. .filter(
  634. OaTask.tenant_no == tenant_no,
  635. OaTask.del_flag == 0,
  636. OaTask.instance_id.in_(inst_ids),
  637. OaTask.task_type == "approval",
  638. OaTask.status == "pending",
  639. )
  640. .all()
  641. if inst_ids
  642. else []
  643. )
  644. pending_by_inst: Dict[str, List[OaTask]] = {}
  645. for t in pending_tasks:
  646. pending_by_inst.setdefault(t.instance_id, []).append(t)
  647. if t.assignee_user_id:
  648. initiator_ids.append(t.assignee_user_id)
  649. name_map = _user_name_map(db, initiator_ids)
  650. ver_ids = list({r.app_version_id for r in rows if r.app_version_id})
  651. ver_by_id: Dict[str, OaAppVersion] = {}
  652. if ver_ids:
  653. for v in (
  654. db.query(OaAppVersion)
  655. .filter(OaAppVersion.id.in_(ver_ids), OaAppVersion.del_flag == 0)
  656. .all()
  657. ):
  658. ver_by_id[v.id] = v
  659. items: List[Dict[str, Any]] = []
  660. for r in rows:
  661. app_name = app_map.get(r.app_id, "")
  662. initiator_name = name_map.get(r.initiator_user_id or "", "") or ""
  663. status = r.status or ""
  664. approver_names = ""
  665. if status == "running":
  666. names: List[str] = []
  667. for t in pending_by_inst.get(r.id, []):
  668. uid = (t.assignee_user_id or "").strip()
  669. if uid:
  670. names.append(name_map.get(uid, uid))
  671. approver_names = "、".join(names)
  672. ver = ver_by_id.get(r.app_version_id or "")
  673. schema = _loads(ver.form_schema_json, {}) if ver else {}
  674. form_data = _loads(r.form_data_json, {}) if r.form_data_json else {}
  675. preview = [
  676. {"label": f.get("label") or "", "value": f.get("value") or ""}
  677. for f in flatten_form_fields_for_summary(schema, form_data)[:2]
  678. ]
  679. items.append(
  680. {
  681. "id": r.id,
  682. "appName": app_name,
  683. "title": r.title or "",
  684. "displayTitle": related_display_title(initiator_name, app_name, r.title or ""),
  685. "initiatorUserName": initiator_name,
  686. "initiatorDeptName": _dept_name(
  687. db, getattr(r, "initiator_dept_id", None), tenant_no=tenant_no
  688. ),
  689. "applyTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
  690. "status": status,
  691. "statusText": related_status_text(status),
  692. "currentApproverNames": approver_names,
  693. "previewFields": preview,
  694. }
  695. )
  696. return {"list": items, "total": total, "page": page, "pageSize": page_size}
  697. def build_related_summaries(
  698. db,
  699. *,
  700. tenant_no: str,
  701. user_id: str,
  702. ids: List[str],
  703. mode: str = "picker",
  704. ) -> List[Dict[str, Any]]:
  705. mode = (mode or "picker").strip()
  706. out: List[Dict[str, Any]] = []
  707. clean = [str(i).strip() for i in ids if str(i).strip()]
  708. if not clean:
  709. return out
  710. insts = (
  711. db.query(OaInstance)
  712. .filter(
  713. OaInstance.tenant_no == tenant_no,
  714. OaInstance.del_flag == 0,
  715. OaInstance.id.in_(clean),
  716. )
  717. .all()
  718. )
  719. by_id = {i.id: i for i in insts}
  720. for iid in clean:
  721. inst = by_id.get(iid)
  722. if not inst:
  723. out.append(
  724. {
  725. "id": iid,
  726. "unavailable": True,
  727. "appName": "",
  728. "applyTime": "",
  729. "status": "",
  730. "fields": [],
  731. }
  732. )
  733. continue
  734. if mode == "picker":
  735. try:
  736. assert_linkable_instance(inst, user_id=user_id, tenant_no=tenant_no, db=db)
  737. except OaEngineError:
  738. out.append(
  739. {
  740. "id": iid,
  741. "unavailable": True,
  742. "appName": "",
  743. "applyTime": "",
  744. "status": "",
  745. "fields": [],
  746. }
  747. )
  748. continue
  749. app = db.query(OaApp).filter(OaApp.id == inst.app_id, OaApp.del_flag == 0).first()
  750. ver = (
  751. db.query(OaAppVersion)
  752. .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
  753. .first()
  754. )
  755. schema = _loads(ver.form_schema_json, {}) if ver else {}
  756. form_data = _loads(inst.form_data_json, {}) if inst.form_data_json else {}
  757. out.append(
  758. {
  759. "id": inst.id,
  760. "unavailable": False,
  761. "appName": (app.app_name if app else "") or (inst.title or ""),
  762. "applyTime": inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "",
  763. "status": inst.status or "",
  764. "fields": flatten_form_fields_for_summary(schema, form_data),
  765. }
  766. )
  767. return out