from __future__ import annotations import hashlib import json import uuid from datetime import datetime from decimal import Decimal from typing import Any from app.models.financial_record import ExpenseClaim from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics PRE_REVIEW_PIPELINE_VERSION = "2026-07-16.1" PRE_REVIEW_DECISIONS = {"ready", "needs_fix", "ready_with_review"} _DERIVED_RISK_SOURCES = { "ai_pre_review", "application_submission", "approval", "approval_log", "approval_routing", "budget_approval", "expense_claim_approval", "expense_claim_finance_approval", "finance_approval", "manual_approval", "payment", "submission_review", } def build_pre_review_input_fingerprint(claim: ExpenseClaim) -> str: payload = { "claim": { "id": _text(claim.id), "employee_id": _text(claim.employee_id), "employee_name": _text(claim.employee_name), "department_id": _text(claim.department_id), "department_name": _text(claim.department_name), "project_code": _text(claim.project_code), "expense_type": _text(claim.expense_type), "reason": _text(claim.reason), "location": _text(claim.location), "amount": _money(claim.amount), "currency": _text(claim.currency), "occurred_at": _date_time(claim.occurred_at), }, "items": [ { "id": _text(item.id), "item_date": _date_time(item.item_date), "item_type": _text(item.item_type), "item_reason": _text(item.item_reason), "item_location": _text(item.item_location), "item_note": _text(item.item_note), "item_amount": _money(item.item_amount), "invoice_id": _text(item.invoice_id), } for item in sorted(list(claim.items or []), key=lambda entry: _text(entry.id)) ], "risk_inputs": sorted( [ _strip_volatile_fields(flag) for flag in list(claim.risk_flags_json or []) if isinstance(flag, dict) and _text(flag.get("source")).lower() not in _DERIVED_RISK_SOURCES ], key=_canonical_json, ), } return _fingerprint(payload) def build_pre_review_rule_set_fingerprint(platform_rule_set_fingerprint: str) -> str: return _fingerprint( { "pipeline_version": PRE_REVIEW_PIPELINE_VERSION, "platform_rule_set_fingerprint": _text(platform_rule_set_fingerprint), } ) def build_pre_review_decision( claim: ExpenseClaim, *, risk_flags: list[Any], business_stage: str, platform_rule_set_fingerprint: str, reviewed_at: datetime, ) -> dict[str, Any]: input_fingerprint = build_pre_review_input_fingerprint(claim) rule_set_fingerprint = build_pre_review_rule_set_fingerprint( platform_rule_set_fingerprint ) findings = _build_findings(risk_flags, business_stage=business_stage) review_context_fingerprint = _fingerprint(findings) blocking_findings = [ finding for finding in findings if finding["severity"] in {"critical", "high"} and finding["disposition"] == "fix" and finding["resolution_status"] == "unresolved" ] if blocking_findings: decision = "needs_fix" message = ( f"自动检测发现 {len(blocking_findings)} 条需先整改的重大风险," "请按建议处理后重新预审。" ) elif findings: decision = "ready_with_review" message = "自动检测已完成,当前风险可随单进入审批并由对应角色复核。" else: decision = "ready" message = "自动检测通过,费用明细和附件可提交审批。" review_id = str( uuid.uuid5( uuid.NAMESPACE_URL, ":".join( [ "expense-claim-pre-review", _text(claim.id), input_fingerprint, rule_set_fingerprint, review_context_fingerprint, ] ), ) ) return { "review_id": review_id, "input_fingerprint": input_fingerprint, "rule_set_fingerprint": rule_set_fingerprint, "review_context_fingerprint": review_context_fingerprint, "pipeline_version": PRE_REVIEW_PIPELINE_VERSION, "reviewed_at": reviewed_at.isoformat(), "decision": decision, "passed": decision != "needs_fix", "blocking_count": len(blocking_findings), "findings": findings, "message": message, } def find_pre_review_flag(claim: ExpenseClaim) -> dict[str, Any] | None: for flag in reversed(list(claim.risk_flags_json or [])): if isinstance(flag, dict) and _text(flag.get("source")) == "ai_pre_review": return flag return None def is_pre_review_current( claim: ExpenseClaim, flag: dict[str, Any] | None, *, platform_rule_set_fingerprint: str, ) -> bool: if not isinstance(flag, dict): return False return bool( _text(flag.get("review_id")) and _text(flag.get("input_fingerprint")) == build_pre_review_input_fingerprint(claim) and _text(flag.get("rule_set_fingerprint")) == build_pre_review_rule_set_fingerprint(platform_rule_set_fingerprint) and _text(flag.get("decision")) in PRE_REVIEW_DECISIONS ) def pre_review_identity_matches( flag: dict[str, Any] | None, *, review_id: str, input_fingerprint: str, ) -> bool: if not isinstance(flag, dict): return False normalized_review_id = _text(review_id) normalized_input_fingerprint = _text(input_fingerprint) if normalized_review_id and _text(flag.get("review_id")) != normalized_review_id: return False if ( normalized_input_fingerprint and _text(flag.get("input_fingerprint")) != normalized_input_fingerprint ): return False return True def pre_review_public_payload(flag: dict[str, Any] | None) -> dict[str, Any] | None: if not isinstance(flag, dict) or not _text(flag.get("review_id")): return None return { "review_id": _text(flag.get("review_id")), "input_fingerprint": _text(flag.get("input_fingerprint")), "rule_set_fingerprint": _text(flag.get("rule_set_fingerprint")), "review_context_fingerprint": _text( flag.get("review_context_fingerprint") ), "pipeline_version": _text(flag.get("pipeline_version")), "reviewed_at": _text(flag.get("reviewed_at") or flag.get("created_at")), "decision": _text(flag.get("decision")) or "ready_with_review", "passed": bool(flag.get("passed")), "blocking_count": int( flag.get("blocking_count") or flag.get("blocking_risk_count") or 0 ), "message": _text(flag.get("message")), "findings": [ dict(item) for item in list(flag.get("findings") or []) if isinstance(item, dict) ], "historical_case_evidence": _public_historical_evidence(flag), } def _public_historical_evidence(flag: dict[str, Any]) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] for item in list(flag.get("historical_case_evidence") or []): if not isinstance(item, dict): continue public_item = _historical_evidence_public_payload(item) if public_item is not None: result.append(public_item) return result def _historical_evidence_public_payload( item: dict[str, Any], ) -> dict[str, Any] | None: label = _text(item.get("label")).lower() if label not in {"confirmed", "false_positive"}: return None return { "label": label, "label_text": ( "历史已确认,仅供复核" if label == "confirmed" else "历史误报,仅供复核" ), "advisory_only": True, "score": round(float(item.get("score") or 0.0), 4), "scene_code": _text(item.get("scene_code")), "policy_ref": _text(item.get("policy_ref")), "rule_version": _text(item.get("rule_version")), "version_status": ( "stale" if bool(item.get("stale")) else "matched" ), "stale": bool(item.get("stale")), "summary": ( "历史相似案例经人工复核确认风险成立。" if label == "confirmed" else "历史相似案例经人工复核判定为误报。" ), } def _build_findings( risk_flags: list[Any], *, business_stage: str, ) -> list[dict[str, Any]]: findings: list[dict[str, Any]] = [] for flag in list(risk_flags or []): if not isinstance(flag, dict) or _text(flag.get("source")) == "ai_pre_review": continue enriched = enrich_risk_flag_semantics(flag, business_stage=business_stage) severity = _text( enriched.get("severity") or enriched.get("tone") or enriched.get("level") ).lower() if severity not in {"medium", "high", "critical"}: continue actionability = _text(enriched.get("actionability")).lower() if actionability == "system_trace": continue item_ids = _item_ids(enriched) resolution_status = _resolution_status(enriched) disposition = "fix" if actionability == "fixable_by_submitter" else "review" message = _text( enriched.get("message") or enriched.get("summary") or enriched.get("reason") or enriched.get("label") ) risk_key = { "source": _text(enriched.get("source")), "rule_code": _text(enriched.get("rule_code")), "severity": severity, "item_ids": item_ids, "message": message, } findings.append( { "risk_id": _text(enriched.get("risk_id")) or f"risk:{_fingerprint(risk_key).removeprefix('sha256:')[:24]}", "rule_code": _text(enriched.get("rule_code")), "rule_version": _text(enriched.get("rule_version")), "severity": severity, "disposition": disposition, "resolution_status": resolution_status, "actionability": actionability, "source": _text(enriched.get("source")) or "pre_review_finding", "business_stage": _text(enriched.get("business_stage")) or business_stage, "risk_domain": _text( enriched.get("risk_domain") or enriched.get("riskDomain") ), "visibility_scope": _text( enriched.get("visibility_scope") or enriched.get("visibilityScope") ), "item_ids": item_ids, "message": message, "remediation": _build_remediation( disposition=disposition, item_ids=item_ids, flag=enriched, ), } ) return sorted( findings, key=lambda finding: ( {"critical": 0, "high": 1, "medium": 2}.get(finding["severity"], 9), finding["risk_id"], ), )[:50] def _build_remediation( *, disposition: str, item_ids: list[str], flag: dict[str, Any], ) -> dict[str, Any]: if disposition != "fix": return { "action": "manual_review", "target_item_ids": item_ids, "required_fields": [], } corpus = " ".join( _text(flag.get(key)) for key in ("label", "message", "summary", "rule_code") ) remediation = { "action": "edit_item_note" if item_ids else "edit_claim", "target_item_ids": item_ids, "required_fields": ["item_note"] if item_ids else [], } if any(token in corpus for token in ("超标", "标准", "住宿", "金额")): remediation["alternative_action"] = "accept_standard_limit" return remediation def _resolution_status(flag: dict[str, Any]) -> str: explicit = _text(flag.get("resolution_status") or flag.get("resolutionStatus")).lower() if explicit in {"resolved", "accepted", "waived"}: return "resolved" if explicit in {"unresolved", "open", "pending"}: return "unresolved" return "resolved" if bool(flag.get("resolved")) else "unresolved" def _item_ids(flag: dict[str, Any]) -> list[str]: raw_values = [ flag.get("item_id"), flag.get("itemId"), *_as_list(flag.get("item_ids")), *_as_list(flag.get("itemIds")), ] return sorted( dict.fromkeys(_text(value) for value in raw_values if _text(value)) ) def _as_list(value: Any) -> list[Any]: if value is None: return [] if isinstance(value, list): return value if isinstance(value, (tuple, set)): return list(value) return [value] def _strip_volatile_fields(value: Any) -> Any: if isinstance(value, list): return sorted( [_strip_volatile_fields(item) for item in value], key=_canonical_json, ) if not isinstance(value, dict): return value volatile_keys = { "created_at", "updated_at", "review_id", "input_fingerprint", "rule_set_fingerprint", "reviewed_at", } return { str(key): _strip_volatile_fields(item) for key, item in value.items() if str(key) not in volatile_keys } def _fingerprint(value: Any) -> str: canonical = _canonical_json(value) return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}" def _canonical_json(value: Any) -> str: return json.dumps( value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str, ) def _date_time(value: Any) -> str: if hasattr(value, "isoformat"): return str(value.isoformat()) return _text(value) def _money(value: Any) -> str: return f"{Decimal(value or Decimal('0.00')).quantize(Decimal('0.01')):.2f}" def _text(value: Any) -> str: return str(value or "").strip()