feat(expenses): add authoritative pre-review workflow
This commit is contained in:
394
server/src/app/services/expense_claim_pre_review_decision.py
Normal file
394
server/src/app/services/expense_claim_pre_review_decision.py
Normal 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()
|
||||
Reference in New Issue
Block a user