"""关联审批单:字段扁平化与 form_data 抽链""" from __future__ import annotations from typing import Any, Dict, List, Optional, Sequence, Tuple from app.models.models import OaApp, OaAppVersion, OaInstance from app.services.oa_engine import OaEngineError, _loads LAYOUT_SKIP = frozenset( {"fcRow", "fcTable", "col", "row", "elCard", "space", "divider", "html", "title", "text", "tag"} ) UPLOAD_TYPES = frozenset({"upload", "fcUpload", "image", "oaAttachment"}) LONG_TYPES = frozenset({"textarea", "input"}) 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 _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_value(typ: str, raw: Any) -> Tuple[str, bool]: """returns (text, fullWidth)""" if typ in UPLOAD_TYPES: urls = _extract_upload_urls(raw) if not urls: return "0 个", False # 展示附件地址(多条用中文顿号);地址较长默认占满一行 return "、".join(urls), 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": 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 isinstance(raw, list): text = "、".join(str(x) for x in raw if x is not None and str(x) != "") elif isinstance(raw, dict): text = str(raw.get("label") or raw.get("value") or 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] = { "label": label, "value": f"已关联 {len(ids)} 条", "relatedIds": ids, } out.append(item) continue text, full = _format_value(typ, raw) item = {"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 def assert_linkable_instance(inst: OaInstance, *, user_id: str, tenant_no: str) -> None: if not inst or inst.del_flag != 0 or inst.tenant_no != tenant_no: raise OaEngineError("关联的审批单不存在") if (inst.initiator_user_id or "") != user_id: raise OaEngineError("只能关联本人发起的审批单") if (inst.status or "") != "completed": raise OaEngineError("只能关联已通过的审批单") def list_related_candidates( db, *, tenant_no: str, user_id: str, keyword: str = "", page: int = 1, page_size: int = 20, ) -> Dict[str, Any]: q = db.query(OaInstance).filter( OaInstance.tenant_no == tenant_no, OaInstance.del_flag == 0, OaInstance.initiator_user_id == user_id, OaInstance.status == "completed", ) kw = (keyword or "").strip() if kw: from sqlalchemy import or_ 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 = {} ids = list({r.app_id for r in rows if r.app_id}) if ids: for a in db.query(OaApp).filter(OaApp.id.in_(ids)).all(): app_map[a.id] = a.app_name or "" items = [] for r in rows: items.append( { "id": r.id, "appName": app_map.get(r.app_id, ""), "title": r.title or "", "applyTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "", "status": r.status or "", } ) 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) 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