feat(expenses): add authoritative pre-review workflow

This commit is contained in:
caoxiaozhu
2026-07-16 11:42:08 +08:00
parent ae3f02c35a
commit 6bdf65bc24
52 changed files with 4300 additions and 1069 deletions

View File

@@ -0,0 +1,394 @@
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)
],
}
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()