2026-07-16 11:42:08 +08:00
|
|
|
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)
|
|
|
|
|
],
|
2026-07-16 14:30:41 +08:00
|
|
|
"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 "历史相似案例经人工复核判定为误报。"
|
|
|
|
|
),
|
2026-07-16 11:42:08 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|