feat: 本体字段治理与风险规则模板执行器重构

- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 15:46:56 +08:00
parent e12b140508
commit 34457f9c3e
81 changed files with 4858 additions and 1073 deletions

View File

@@ -86,6 +86,7 @@ class ExpenseClaimItem(Base):
item_type: Mapped[str] = mapped_column(String(50))
item_reason: Mapped[str] = mapped_column(Text())
item_location: Mapped[str] = mapped_column(String(100))
item_note: Mapped[str] = mapped_column(Text(), default="")
item_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
invoice_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -12,6 +12,19 @@ class ReceiptFolderFieldRead(BaseModel):
value: str = ""
class ReceiptFolderFieldChangeRead(BaseModel):
key: str = ""
label: str = ""
before: str = ""
after: str = ""
class ReceiptFolderEditLogRead(BaseModel):
operated_at: datetime | None = None
operator: str = ""
changes: list[ReceiptFolderFieldChangeRead] = Field(default_factory=list)
class ReceiptFolderItemRead(BaseModel):
id: str
file_name: str
@@ -48,6 +61,7 @@ class ReceiptFolderDetailRead(ReceiptFolderItemRead):
classification_confidence: float = 0.0
classification_evidence: list[str] = Field(default_factory=list)
fields: list[ReceiptFolderFieldRead] = Field(default_factory=list)
edit_logs: list[ReceiptFolderEditLogRead] = Field(default_factory=list)
raw_meta: dict[str, Any] = Field(default_factory=dict)

View File

@@ -39,6 +39,7 @@ class ExpenseClaimItemRead(BaseModel):
item_type: str
item_reason: str
item_location: str
item_note: str = ""
item_amount: Decimal
invoice_id: str | None
is_system_generated: bool = False
@@ -101,6 +102,7 @@ class ExpenseClaimItemUpdate(BaseModel):
item_type: str | None = None
item_reason: str | None = None
item_location: str | None = None
item_note: str | None = None
item_amount: Decimal | None = None
invoice_id: str | None = None
@@ -110,6 +112,7 @@ class ExpenseClaimItemCreate(BaseModel):
item_type: str | None = None
item_reason: str | None = None
item_location: str | None = None
item_note: str | None = None
item_amount: Decimal | None = None
invoice_id: str | None = None
@@ -203,6 +206,7 @@ class ExpenseClaimAttachmentActionResponse(BaseModel):
item_type: str | None = None
item_reason: str | None = None
item_location: str | None = None
item_note: str | None = None
item_amount: Decimal | None = None
claim_amount: Decimal | None = None
claim_risk_flags: list[Any] = Field(default_factory=list)

View File

@@ -216,7 +216,7 @@ class AgentAssetRiskRuleSimulationMixin:
if field_key == "item.item_location":
return self._extract_labeled_city(corpus, city_mentions, ("明细地点", "发生地点"))
if field_key == "employee.location":
return self._extract_labeled_city(corpus, city_mentions, ("员工常驻地", "常驻地", "办公地", "出发地"))
return self._extract_labeled_city(corpus, city_mentions, ("员工常驻地", "常驻地", "办公地"))
if "city" in field_key or "location" in field_key:
if any(
token in key_text
@@ -387,7 +387,6 @@ class AgentAssetRiskRuleSimulationMixin:
for group_name in (
"attachment_city_fields",
"reference_city_fields",
"home_city_fields",
"exception_fields",
):
for key in self._read_string_list(params.get(group_name)):

View File

@@ -622,7 +622,7 @@ class AgentAssetRiskRuleTestingMixin:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
if template_key == "field_compare_v1":
if str(params.get("semantic_type") or "").strip() in {"travel_city_consistency", "travel_route_city_consistency"}:
values.update({"attachment.hotel_city": "上海" if hit else "北京", "attachment.route_cities": ["上海"] if hit else ["北京"], "claim.location": "北京", "item.item_location": "北京", "employee.location": "北京"})
values.update({"attachment.hotel_city": "上海" if hit else "北京", "attachment.route_cities": ["上海"] if hit else ["北京"], "claim.location": "北京", "item.item_location": "北京"})
return values
condition = next(
(item for item in params.get("conditions", []) if isinstance(item, dict)),

View File

@@ -39,7 +39,8 @@ from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHe
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
from app.services.pagination import PageResult
from app.services.pagination import PageResult, normalize_page_params
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score
logger = get_logger("app.services.agent_assets")
@@ -77,6 +78,7 @@ class AgentAssetService(
assets = self.repository.list(
asset_type=asset_type, status=status, domain=domain, keyword=keyword
)
assets = self._filter_excluded_risk_assets(assets)
version_stats = self._collect_version_stats(assets)
return [self._serialize_list_item(asset, version_stats.get(asset.id)) for asset in assets]
@@ -93,17 +95,24 @@ class AgentAssetService(
self._ensure_ready()
if asset_type in {None, "", AgentAssetType.RULE.value}:
self.sync_platform_risk_rules_from_library()
result = self.repository.list_page(
assets = self.repository.list(
asset_type=asset_type,
status=status,
domain=domain,
keyword=keyword,
page=page,
page_size=page_size,
)
version_stats = self._collect_version_stats(result.items)
return result.map(
lambda asset: self._serialize_list_item(asset, version_stats.get(asset.id))
assets = self._filter_excluded_risk_assets(assets)
page_params = normalize_page_params(page, page_size)
paged_assets = assets[page_params.offset : page_params.offset + page_params.page_size]
version_stats = self._collect_version_stats(paged_assets)
return PageResult(
items=[
self._serialize_list_item(asset, version_stats.get(asset.id))
for asset in paged_assets
],
total=len(assets),
page=page_params.page,
page_size=page_params.page_size,
)
def get_asset(self, asset_id: str) -> AgentAssetRead | None:
@@ -151,6 +160,26 @@ class AgentAssetService(
else None,
)
@staticmethod
def _filter_excluded_risk_assets(assets: list[AgentAsset]) -> list[AgentAsset]:
return [asset for asset in assets if not AgentAssetService._is_excluded_budget_risk_asset(asset)]
@staticmethod
def _is_excluded_budget_risk_asset(asset: AgentAsset) -> bool:
if asset.asset_type != AgentAssetType.RULE.value:
return False
config_json = asset.config_json if isinstance(asset.config_json, dict) else {}
if str(config_json.get("detail_mode") or "").strip().lower() != "json_risk":
return False
manifest_like = {
**config_json,
"rule_code": str(asset.code or "").strip(),
"name": str(asset.name or "").strip(),
"description": str(asset.description or "").strip(),
"metadata": config_json,
}
return is_budget_risk_manifest(manifest_like)
def create_asset(
self,
payload: AgentAssetCreate,

View File

@@ -124,6 +124,12 @@ class AgentFoundationService(
"ON expense_claims (hermes_risk_flag)"
)
)
if "expense_claim_items" in inspector.get_table_names():
item_column_names = {column["name"] for column in inspector.get_columns("expense_claim_items")}
if "item_note" not in item_column_names:
self.db.execute(
text("ALTER TABLE expense_claim_items ADD COLUMN item_note TEXT DEFAULT '' NOT NULL")
)
self.db.flush()
def _sync_demo_financial_records(self) -> None:

View File

@@ -20,6 +20,7 @@ from app.services.agent_asset_spreadsheet import (
from app.services.agent_foundation_constants import (
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
logger = get_logger("app.services.agent_foundation")
@@ -63,6 +64,10 @@ class AgentFoundationRiskRuleMixin:
continue
if is_budget_risk_manifest(payload):
continue
manifests.append((file_name, payload))
return manifests

View File

@@ -21,8 +21,8 @@ APPLICATION_EXPENSE_TYPES = {
"preapproval",
}
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
RECENT_VISIBLE_CLAIM_START = 501
RECENT_VISIBLE_CLAIM_END = 817
RECENT_VISIBLE_CLAIM_START = 401
RECENT_VISIBLE_CLAIM_END = 424
def is_admin_identity(*values: Any) -> bool:
@@ -99,7 +99,8 @@ def simulation_claim_day(
)
if visible_day is not None:
return visible_day
month = months[(employee_index + local_index * 2) % len(months)]
distribution_months = complete_distribution_months(months, period_end)
month = distribution_months[(employee_index + local_index * 2) % len(distribution_months)]
_, max_day = calendar.monthrange(month.year, month.month)
if month.year == period_end.year and month.month == period_end.month:
max_day = min(max_day, period_end.day)
@@ -108,16 +109,26 @@ def simulation_claim_day(
def simulation_claim_count(employee: Any, index: int) -> int:
base = 7 + (index % 5)
base = 3 + (1 if index % 3 == 0 else 0)
department_code = str(getattr(getattr(employee, "department", None), "unit_code", "") or "")
grade = str(getattr(employee, "grade", "") or "")
if department_code in {"MARKET-DEPT", "TECH-DEPT"}:
base += 3
base += 1
elif department_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
base += 2
base += 1
if grade in {"P7", "P8"}:
base += 2
return max(6, min(base, 16))
base += 1
return max(3, min(base, 6))
def complete_distribution_months(months: list[date], period_end: date) -> list[date]:
complete_months: list[date] = []
for month in months:
_, max_day = calendar.monthrange(month.year, month.month)
if month.year == period_end.year and month.month == period_end.month and period_end.day < 10:
continue
complete_months.append(month)
return complete_months or months
def next_simulation_number(prefix: str, used_numbers: set[str], cursor: int) -> tuple[str, int]:

View File

@@ -0,0 +1,373 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, date, datetime, time, timedelta
from decimal import Decimal
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.services.demo_company_simulation_catalog import (
BUDGETED_STATUSES,
SIM_BUDGET_PREFIX,
SIM_PROJECT_CODE,
SIM_RESERVATION_PREFIX,
SIM_RISK_PREFIX,
SIM_TRANSACTION_PREFIX,
build_simulation_reimbursement_no,
target_budget_usage,
)
@dataclass(frozen=True, slots=True)
class SimulationRebalanceSummary:
mode: str
claims: int
main_period_claims: int
recent_claims: int
period_start: str
period_end: str
max_daily_count: int
budget_transactions: int
budget_reservations: int
risk_observations: int
allocation_missing_count: int
def to_dict(self) -> dict[str, object]:
return asdict(self)
class HalfYearExpenseSimulationRebalancer:
"""Rebalance existing simulation rows without deleting business records."""
def __init__(
self,
db: Session,
*,
start_date: date = date(2026, 1, 1),
end_date: date = date(2026, 6, 2),
recent_sample_days: int = 2,
) -> None:
self.db = db
self.start_date = start_date
self.end_date = end_date
self.main_period_end = date(end_date.year, end_date.month, 1) - timedelta(days=1)
self.recent_sample_days = max(1, recent_sample_days)
def preview(self) -> SimulationRebalanceSummary:
return self._run(apply=False)
def apply(self) -> SimulationRebalanceSummary:
return self._run(apply=True)
def _run(self, *, apply: bool) -> SimulationRebalanceSummary:
claims = self._simulation_claims()
plans = self._claim_plans(claims)
allocation_map = self._allocation_map()
allocation_missing_count = self._count_missing_allocations(plans, allocation_map)
day_counts: dict[date, int] = {}
for _claim, plan in plans:
day_counts[plan["day"]] = day_counts.get(plan["day"], 0) + 1
if apply and plans:
self._apply_claim_plans(plans, allocation_map)
self._rebalance_allocation_amounts()
self.db.flush()
recent_count = sum(1 for _claim, plan in plans if plan["day"] >= date(2026, 6, 1))
return SimulationRebalanceSummary(
mode="apply" if apply else "dry-run",
claims=len(claims),
main_period_claims=len(claims) - recent_count,
recent_claims=recent_count,
period_start=self.start_date.isoformat(),
period_end=self.end_date.isoformat(),
max_daily_count=max(day_counts.values()) if day_counts else 0,
budget_transactions=self._sim_transaction_count(),
budget_reservations=self._sim_reservation_count(),
risk_observations=self._sim_risk_count(),
allocation_missing_count=allocation_missing_count,
)
def _simulation_claims(self) -> list[ExpenseClaim]:
return list(
self.db.scalars(
select(ExpenseClaim)
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.claim_no.asc(), ExpenseClaim.id.asc())
).all()
)
def _claim_plans(self, claims: list[ExpenseClaim]) -> list[tuple[ExpenseClaim, dict[str, object]]]:
recent_count = self._recent_count(len(claims))
main_count = max(len(claims) - recent_count, 0)
main_days = self._date_range(self.start_date, self.main_period_end)
recent_days = self._date_range(date(2026, 6, 1), self.end_date)
plans: list[tuple[ExpenseClaim, dict[str, object]]] = []
for index, claim in enumerate(claims):
if index < main_count:
day = self._spread_day(index, main_count, main_days)
else:
recent_index = index - main_count
day = recent_days[recent_index % len(recent_days)]
occurred_at = datetime.combine(day, time(hour=8 + (index % 9)), tzinfo=UTC)
submitted_at = None
if self._status(claim) != "draft":
submitted_at = datetime.combine(day, time(hour=9 + (index % 7)), tzinfo=UTC)
updated_at = self._updated_at(claim, occurred_at, submitted_at, index)
final_claim_no = build_simulation_reimbursement_no(occurred_at, index + 1)
period_key = f"{occurred_at.year}Q{((occurred_at.month - 1) // 3) + 1}"
subject_code = "meal" if str(claim.expense_type or "") == "entertainment" else str(claim.expense_type or "")
plans.append(
(
claim,
{
"sequence": index + 1,
"day": day,
"occurred_at": occurred_at,
"submitted_at": submitted_at,
"updated_at": updated_at,
"claim_no": final_claim_no,
"period_key": period_key,
"subject_code": subject_code,
},
)
)
return plans
def _apply_claim_plans(
self,
plans: list[tuple[ExpenseClaim, dict[str, object]]],
allocation_map: dict[tuple[str | None, str, str], str],
) -> None:
claim_ids = [claim.id for claim, _plan in plans]
transactions_by_claim = self._transactions_by_claim_id(claim_ids)
reservations_by_claim = self._reservations_by_claim_id(claim_ids)
observations_by_claim = self._observations_by_claim_id(claim_ids)
for claim, plan in plans:
claim.claim_no = f"SIM-TEMP-{claim.id}"
self.db.flush()
for claim, plan in plans:
claim_no = str(plan["claim_no"])
occurred_at = plan["occurred_at"]
submitted_at = plan["submitted_at"]
updated_at = plan["updated_at"]
allocation_id = allocation_map.get(
(
claim.department_id,
str(plan["period_key"]),
str(plan["subject_code"]),
)
)
claim.claim_no = claim_no
claim.occurred_at = occurred_at
claim.submitted_at = submitted_at
claim.created_at = occurred_at
claim.updated_at = updated_at
claim.reason = self._normalized_reason(claim.reason, occurred_at.date())
self.db.execute(
text(
"""
update expense_claim_items
set item_date = :item_date, updated_at = :updated_at
where claim_id = :claim_id
"""
),
{
"item_date": occurred_at.date(),
"updated_at": updated_at,
"claim_id": claim.id,
},
)
for transaction in transactions_by_claim.get(claim.id, []):
transaction.source_no = claim_no
transaction.created_at = submitted_at or occurred_at
if allocation_id:
transaction.allocation_id = allocation_id
for reservation in reservations_by_claim.get(claim.id, []):
reservation.source_no = claim_no
reservation.created_at = submitted_at or occurred_at
reservation.updated_at = updated_at
if allocation_id:
reservation.allocation_id = allocation_id
for observation in observations_by_claim.get(claim.id, []):
observation.subject_key = claim_no
observation.subject_label = claim_no
observation.claim_no = claim_no
observation.created_at = submitted_at or occurred_at
observation.updated_at = updated_at
def _allocation_map(self) -> dict[tuple[str | None, str, str], str]:
rows = self.db.scalars(
select(BudgetAllocation).where(BudgetAllocation.project_code == SIM_PROJECT_CODE)
).all()
return {
(row.department_id, row.period_key, row.subject_code): row.id
for row in rows
}
def _count_missing_allocations(
self,
plans: list[tuple[ExpenseClaim, dict[str, object]]],
allocation_map: dict[tuple[str | None, str, str], str],
) -> int:
missing = {
(claim.department_id, str(plan["period_key"]), str(plan["subject_code"]))
for claim, plan in plans
if self._status(claim) in BUDGETED_STATUSES
and (claim.department_id, str(plan["period_key"]), str(plan["subject_code"])) not in allocation_map
}
return len(missing)
def _rebalance_allocation_amounts(self) -> None:
allocations = list(
self.db.scalars(
select(BudgetAllocation)
.where(BudgetAllocation.budget_no.like(f"{SIM_BUDGET_PREFIX}%"))
.order_by(BudgetAllocation.period_key.asc(), BudgetAllocation.subject_code.asc())
).all()
)
transactions = list(
self.db.scalars(
select(BudgetTransaction).where(
BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")
)
).all()
)
used_by_allocation: dict[str, Decimal] = {}
for transaction in transactions:
used_by_allocation[transaction.allocation_id] = (
used_by_allocation.get(transaction.allocation_id, Decimal("0.00"))
+ Decimal(transaction.amount or 0)
)
for index, allocation in enumerate(allocations):
used = used_by_allocation.get(allocation.id, Decimal("0.00"))
usage = target_budget_usage(allocation.period_key, allocation.subject_code, index)
allocation.original_amount = max(
(used / usage).quantize(Decimal("0.01")) if usage > 0 else used,
Decimal("3000.00"),
)
allocation.updated_by = "simulation_rebalance"
allocation.updated_at = datetime.now(UTC)
def _transactions_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[BudgetTransaction]]:
rows = self.db.scalars(
select(BudgetTransaction)
.where(BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%"))
.where(BudgetTransaction.source_id.in_(claim_ids))
).all()
return self._group_by_source_id(rows)
def _reservations_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[BudgetReservation]]:
rows = self.db.scalars(
select(BudgetReservation)
.where(BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%"))
.where(BudgetReservation.source_id.in_(claim_ids))
).all()
return self._group_by_source_id(rows)
def _observations_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[RiskObservation]]:
rows = self.db.scalars(
select(RiskObservation)
.where(RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%"))
.where(RiskObservation.claim_id.in_(claim_ids))
).all()
grouped: dict[str, list[RiskObservation]] = {}
for row in rows:
if row.claim_id:
grouped.setdefault(row.claim_id, []).append(row)
return grouped
@staticmethod
def _group_by_source_id(rows: object) -> dict[str, list[object]]:
grouped: dict[str, list[object]] = {}
for row in rows:
grouped.setdefault(row.source_id, []).append(row)
return grouped
def _recent_count(self, total: int) -> int:
if total <= 0:
return 0
return min(24, max(12, total // 50))
@staticmethod
def _date_range(start: date, end: date) -> list[date]:
days = max((end - start).days, 0)
return [start + timedelta(days=index) for index in range(days + 1)]
@staticmethod
def _spread_day(index: int, count: int, days: list[date]) -> date:
if not days:
raise ValueError("days cannot be empty")
if count <= 1:
return days[0]
day_index = round(index * (len(days) - 1) / (count - 1))
jitter = ((index * 17) % 5) - 2
return days[max(0, min(len(days) - 1, day_index + jitter))]
@staticmethod
def _updated_at(
claim: ExpenseClaim,
occurred_at: datetime,
submitted_at: datetime | None,
index: int,
) -> datetime:
base = submitted_at or occurred_at
status = HalfYearExpenseSimulationRebalancer._status(claim)
if status == "paid":
return base + timedelta(days=2 + (index % 3), hours=index % 5)
if status in {"approved", "pending_payment"}:
return base + timedelta(days=1 + (index % 2), hours=index % 4)
if status in {"returned", "rejected"}:
return base + timedelta(hours=6 + (index % 8))
return base + timedelta(hours=2 + (index % 4))
@staticmethod
def _normalized_reason(reason: str, day: date) -> str:
text = str(reason or "").strip()
for month in range(1, 7):
text = text.replace(f"{month}", f"{day.month}")
return text
@staticmethod
def _status(claim: ExpenseClaim) -> str:
return str(claim.status or "").strip().lower()
def _sim_transaction_count(self) -> int:
return int(
self.db.scalar(
select(func.count()).select_from(BudgetTransaction).where(
BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")
)
)
or 0
)
def _sim_reservation_count(self) -> int:
return int(
self.db.scalar(
select(func.count()).select_from(BudgetReservation).where(
BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%")
)
)
or 0
)
def _sim_risk_count(self) -> int:
return int(
self.db.scalar(
select(func.count()).select_from(RiskObservation).where(
RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%")
)
)
or 0
)

View File

@@ -275,6 +275,7 @@ class ExpenseClaimAttachmentOperationsMixin:
"item_type": item.item_type,
"item_reason": item.item_reason,
"item_location": item.item_location,
"item_note": item.item_note,
"item_amount": item.item_amount,
"claim_amount": claim.amount,
"claim_risk_flags": list(claim.risk_flags_json or []),

View File

@@ -107,6 +107,7 @@ from app.services.expense_rule_runtime import (
build_default_expense_rule_catalog,
resolve_document_type_label,
)
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.ocr import OcrService
@@ -344,10 +345,10 @@ class ExpenseClaimDocumentItemBuilderMixin:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
review_form_values = normalize_ontology_form_values(review_form_values)
review_type = str(
review_form_values.get("expense_type")
or review_form_values.get("scene_label")
or review_form_values.get("reason_value")
or review_form_values.get("reason")
or ""
)
if any(keyword in review_type for keyword in ("差旅", "出差")):
@@ -377,12 +378,8 @@ class ExpenseClaimDocumentItemBuilderMixin:
else:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
time_text = str(
review_form_values.get("time_range")
or review_form_values.get("business_time")
or review_form_values.get("occurred_date")
or ""
).strip()
review_form_values = normalize_ontology_form_values(review_form_values)
time_text = str(review_form_values.get("time_range") or "").strip()
matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text)
if matched_dates:
start_date = self._parse_iso_date_or_default(matched_dates[0], start_date)
@@ -400,15 +397,13 @@ class ExpenseClaimDocumentItemBuilderMixin:
review_form_values = context_json.get("review_form_values")
text_parts: list[str] = []
if isinstance(review_form_values, dict):
review_form_values = normalize_ontology_form_values(review_form_values)
text_parts.extend(
str(review_form_values.get(key) or "")
for key in (
"reason",
"business_reason",
"reason_value",
"scene_label",
"time_range",
"business_time",
"expense_type",
)
)
text_parts.extend(

View File

@@ -108,6 +108,7 @@ from app.services.expense_rule_runtime import (
build_default_expense_rule_catalog,
resolve_document_type_label,
)
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.ocr import OcrService
@@ -204,11 +205,8 @@ class ExpenseClaimOntologyResolverMixin:
def _resolve_explicit_review_expense_type(context_json: dict[str, Any]) -> str | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
compact = str(
review_form_values.get("expense_type")
or review_form_values.get("reimbursement_type")
or ""
).replace(" ", "")
review_form_values = normalize_ontology_form_values(review_form_values)
compact = str(review_form_values.get("expense_type") or "").replace(" ", "")
if compact:
return resolve_expense_type_code_from_text(compact)
return None
@@ -238,10 +236,10 @@ class ExpenseClaimOntologyResolverMixin:
) -> str | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
for key in ("reason", "business_reason"):
value = str(review_form_values.get(key) or "").strip()
if value:
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value)
review_form_values = normalize_ontology_form_values(review_form_values)
value = str(review_form_values.get("reason") or "").strip()
if value:
return ExpenseClaimOntologyResolverMixin._strip_leading_time_from_reason(value)
explicit_text = context_json.get("user_input_text")
if isinstance(explicit_text, str):
@@ -281,10 +279,10 @@ class ExpenseClaimOntologyResolverMixin:
def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
for key in ("business_location", "location"):
value = str(review_form_values.get(key) or "").strip()
if value:
return value
review_form_values = normalize_ontology_form_values(review_form_values)
value = str(review_form_values.get("location") or "").strip()
if value:
return value
request_context = context_json.get("request_context")
if (
@@ -314,16 +312,9 @@ class ExpenseClaimOntologyResolverMixin:
) -> datetime | None:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
for key in (
"occurred_date",
"time_range",
"business_time",
"application_business_time",
"application_time",
):
value = str(review_form_values.get(key) or "").strip()
if not value:
continue
review_form_values = normalize_ontology_form_values(review_form_values)
value = str(review_form_values.get("time_range") or "").strip()
if value:
try:
parsed = date.fromisoformat(value)
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)

View File

@@ -16,6 +16,7 @@ from app.services.expense_rule_runtime import (
)
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
@@ -23,6 +24,44 @@ from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class ExpenseClaimPlatformRiskMixin:
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
_CLEAR_TRAVEL_DOCUMENT_TYPES = {
"flight_itinerary",
"train_ticket",
"ship_ticket",
"hotel_invoice",
"taxi_receipt",
"parking_toll_receipt",
}
_CLEAR_TRAVEL_SCENE_CODES = {"travel", "hotel", "transport"}
_GOODS_DESCRIPTION_FIELD_KEYS = {
"goodsname",
"servicename",
"itemname",
"project",
"productname",
"description",
"content",
"expensecontent",
"feeitem",
}
_GOODS_DESCRIPTION_LABEL_TOKENS = (
"商品",
"服务",
"货物",
"项目",
"品名",
"名称",
"费用内容",
"消费内容",
)
_VAGUE_KEYWORD_NEGATION_MARKERS = (
"不含",
"不包含",
"不包括",
"未包含",
"不涉及",
"不属于",
)
def evaluate_platform_risk_rules(
self,
@@ -127,6 +166,8 @@ class ExpenseClaimPlatformRiskMixin:
manifest_code = str(payload.get("rule_code") or rule_code).strip()
if not manifest_code or (code_filter and manifest_code not in code_filter):
continue
if is_budget_risk_manifest(payload):
continue
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
payload,
business_stage=business_stage,
@@ -162,6 +203,8 @@ class ExpenseClaimPlatformRiskMixin:
continue
if code_filter and rule_code not in missing_codes:
continue
if is_budget_risk_manifest(payload):
continue
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
payload,
business_stage=business_stage,
@@ -364,7 +407,7 @@ class ExpenseClaimPlatformRiskMixin:
fallback_message="票据文本中出现作废、红冲或红字发票相关信息,建议退回补充或人工复核。",
)
if evaluator == "vague_goods_description":
return self._evaluate_text_keyword_risk(
return self._evaluate_vague_goods_description_risk(
manifest,
contexts=contexts,
keywords=["详见清单", "服务费", "咨询费", "其他", "办公用品"],
@@ -663,6 +706,107 @@ class ExpenseClaimPlatformRiskMixin:
evidence={"matched_keywords": matched},
)
def _evaluate_vague_goods_description_risk(
self,
manifest: dict[str, Any],
*,
contexts: list[dict[str, Any]],
keywords: list[str],
fallback_message: str,
) -> dict[str, Any] | None:
matched_keywords: list[str] = []
matched_fields: list[dict[str, str]] = []
for context in contexts:
document_info = context.get("document_info") or {}
if self._is_clear_travel_document(document_info):
continue
field_values = self._collect_goods_description_field_values(document_info)
if field_values:
for value in field_values:
hits = self._collect_non_negated_keyword_hits(value, keywords)
for keyword in hits:
if keyword not in matched_keywords:
matched_keywords.append(keyword)
if hits:
matched_fields.append(
{
"item_index": str(context.get("index") or ""),
"value": value[:80],
}
)
continue
fallback_text = f"{context.get('ocr_summary') or ''}\n{context.get('ocr_text') or ''}"
hits = self._collect_non_negated_keyword_hits(fallback_text, keywords)
for keyword in hits:
if keyword not in matched_keywords:
matched_keywords.append(keyword)
if hits:
matched_fields.append(
{
"item_index": str(context.get("index") or ""),
"value": "OCR全文兜底",
}
)
if not matched_keywords:
return None
return self._build_platform_risk_flag(
manifest,
message=fallback_message,
evidence={
"matched_keywords": matched_keywords,
"matched_fields": matched_fields[:5],
},
)
@classmethod
def _is_clear_travel_document(cls, document_info: dict[str, Any]) -> bool:
document_type = str(document_info.get("document_type") or "").strip().lower()
scene_code = str(document_info.get("scene_code") or "").strip().lower()
return (
document_type in cls._CLEAR_TRAVEL_DOCUMENT_TYPES
or scene_code in cls._CLEAR_TRAVEL_SCENE_CODES
)
@classmethod
def _collect_goods_description_field_values(cls, document_info: dict[str, Any]) -> list[str]:
values: list[str] = []
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if field_key in cls._GOODS_DESCRIPTION_FIELD_KEYS or any(
token in label for token in cls._GOODS_DESCRIPTION_LABEL_TOKENS
):
values.append(value)
return values
@classmethod
def _collect_non_negated_keyword_hits(cls, text: str, keywords: list[str]) -> list[str]:
normalized = str(text or "")
if not normalized:
return []
hits: list[str] = []
for keyword in keywords:
if not keyword:
continue
for match in re.finditer(re.escape(keyword), normalized):
window = normalized[max(0, match.start() - 12): match.end() + 12]
if any(marker in window for marker in cls._VAGUE_KEYWORD_NEGATION_MARKERS):
continue
hits.append(keyword)
break
return hits
def _evaluate_multi_city_reason_required_risk(
self,
manifest: dict[str, Any],

View File

@@ -36,6 +36,7 @@ from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
from app.services.document_intelligence import build_document_insight
from app.services.document_numbering import is_application_claim_no
from app.services.budget_types import BudgetControlError
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
@@ -57,6 +58,7 @@ from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyRe
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.receipt_folder import ReceiptFolderService
from app.services.expense_claim_constants import (
EXPENSE_TYPE_LABELS,
MAX_DRAFT_CLAIMS_PER_USER,
@@ -320,6 +322,8 @@ class ExpenseClaimService(
item.item_location = (
self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
)
if payload.item_note is not None:
item.item_note = self._normalize_optional_text(payload.item_note, allow_empty=True) or ""
if payload.item_amount is not None:
amount = payload.item_amount.quantize(Decimal("0.01"))
if amount < Decimal("0.00"):
@@ -376,6 +380,7 @@ class ExpenseClaimService(
or "other",
item_reason=self._normalize_optional_text(payload.item_reason, fallback="") or "",
item_location=self._normalize_optional_text(payload.item_location, fallback="") or "",
item_note=self._normalize_optional_text(payload.item_note, allow_empty=True) or "",
item_amount=item_amount,
invoice_id=self._normalize_optional_text(payload.invoice_id, allow_empty=True),
)
@@ -462,11 +467,16 @@ class ExpenseClaimService(
if missing_fields:
raise ExpenseClaimSubmissionBlockedError(missing_fields)
budget_flags = self._reserve_budget_for_submission(
claim,
current_user,
is_application_claim=is_application_claim,
)
try:
budget_flags = self._reserve_budget_for_submission(
claim,
current_user,
is_application_claim=is_application_claim,
)
except BudgetControlError as exc:
if is_application_claim:
raise
budget_flags = list(exc.flags or [])
before_json = self._serialize_claim(claim)
if is_application_claim:
submitted_at = datetime.now(UTC)
@@ -576,6 +586,7 @@ class ExpenseClaimService(
self._release_budget_for_delete(claim, current_user)
self._delete_claim_analysis_records(resource_id)
self._attachment_storage.delete_claim_files(claim)
ReceiptFolderService().delete_receipts_for_claim(resource_id)
self.db.delete(claim)
self.db.commit()
@@ -747,11 +758,6 @@ class ExpenseClaimService(

View File

@@ -49,8 +49,18 @@ class FinanceDashboardService(BudgetSupportMixin):
now=now,
)
previous_start = start - (end - start)
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
ranking_start, ranking_end = self._resolve_ranking_scope(department_range, now)
trend_start, trend_end, trend_labels = self._resolve_trend_scope(
trend_range,
now,
fallback_start=start,
fallback_end=end,
)
ranking_start, ranking_end = self._resolve_ranking_scope(
department_range,
now,
fallback_start=start,
fallback_end=end,
)
claims = [
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
@@ -127,10 +137,31 @@ class FinanceDashboardService(BudgetSupportMixin):
self,
trend_range: str,
now: datetime,
*,
fallback_start: datetime | None = None,
fallback_end: datetime | None = None,
) -> tuple[datetime, datetime, list[str]]:
days = self._days_from_label(trend_range, default=12)
end_day = now.date()
start_day = end_day - timedelta(days=days - 1)
today = now.date()
key = str(trend_range or "").strip()
if key in {"custom", "自定义"} and fallback_start and fallback_end:
start_day = fallback_start.date()
end_day = (fallback_end - timedelta(days=1)).date()
elif key == "今日":
start_day = today
end_day = today
elif key == "本周":
start_day = today - timedelta(days=today.weekday())
end_day = today
elif key == "本月":
start_day = today.replace(day=1)
end_day = today
else:
days = self._days_from_label(trend_range, default=12)
end_day = today
start_day = end_day - timedelta(days=days - 1)
if start_day > end_day:
start_day, end_day = end_day, start_day
days = max(1, (end_day - start_day).days + 1)
labels = [self._date_label(start_day + timedelta(days=index)) for index in range(days)]
return self._day_start(start_day), self._day_after(end_day), labels
@@ -138,9 +169,32 @@ class FinanceDashboardService(BudgetSupportMixin):
self,
department_range: str,
now: datetime,
*,
fallback_start: datetime | None = None,
fallback_end: datetime | None = None,
) -> tuple[datetime, datetime]:
today = now.date()
key = str(department_range or "").strip()
if key in {"custom", "自定义"} and fallback_start and fallback_end:
return fallback_start, fallback_end
if key == "今日":
return self._day_start(today), self._day_after(today)
if key == "本周":
start_day = today - timedelta(days=today.weekday())
return self._day_start(start_day), self._day_after(today)
if key == "全部":
return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today)
if key == "本季度":
quarter_month = ((today.month - 1) // 3) * 3 + 1
return self._day_start(today.replace(month=quarter_month, day=1)), self._day_after(today)
if key == "本年":
return self._day_start(today.replace(month=1, day=1)), self._day_after(today)
if key == "本月":
return self._day_start(today.replace(day=1)), self._day_after(today)
if re.search(r"\d+", key):
days = self._days_from_label(key, default=10)
start_day = today - timedelta(days=days - 1)
return self._day_start(start_day), self._day_after(today)
if key == "全部":
return datetime(1970, 1, 1, tzinfo=UTC), self._day_after(today)
if key == "本季度":
@@ -227,6 +281,8 @@ class FinanceDashboardService(BudgetSupportMixin):
claim_count = [0 for _ in labels]
claim_amount = [Decimal("0.00") for _ in labels]
success_count = [0 for _ in labels]
category_amounts: dict[str, list[Decimal]] = {}
category_totals: dict[str, Decimal] = defaultdict(Decimal)
hours: list[list[Decimal]] = [[] for _ in labels]
index = {label: idx for idx, label in enumerate(labels)}
@@ -237,8 +293,12 @@ class FinanceDashboardService(BudgetSupportMixin):
if label not in index:
continue
bucket = index[label]
amount = self._claim_amount(claim)
category = self._expense_type_label(claim.expense_type)
claim_count[bucket] += 1
claim_amount[bucket] += self._claim_amount(claim)
claim_amount[bucket] += amount
category_amounts.setdefault(category, [Decimal("0.00") for _ in labels])[bucket] += amount
category_totals[category] += amount
if self._status(claim) in SUCCESS_STATUSES:
success_count[bucket] += 1
if claim.submitted_at:
@@ -248,6 +308,17 @@ class FinanceDashboardService(BudgetSupportMixin):
"labels": labels,
"claimCount": claim_count,
"claimAmount": [self._decimal_number(value) for value in claim_amount],
"categoryAmountSeries": [
{
"name": name,
"color": CHART_COLORS[index % len(CHART_COLORS)],
"data": [self._decimal_number(value) for value in category_amounts[name]],
"total": self._decimal_number(category_totals[name]),
}
for index, name in enumerate(
sorted(category_amounts, key=lambda item: category_totals[item], reverse=True)[:6]
)
],
"successCount": success_count,
# 兼容旧前端字段;新财务看板不再使用审批趋势语义。
"applications": claim_count,

View File

@@ -29,6 +29,7 @@ from app.services.agent_foundation import AgentFoundationService
from app.services.agent_runs import AgentRunService
from app.services.ontology_detection import OntologyDetectionMixin
from app.services.ontology_extraction import OntologyExtractionMixin
from app.services.ontology_field_registry import normalize_ontology_context_json
from app.services.ontology_rules import (
CONTEXTUAL_SCENARIOS,
EXPENSE_REVIEW_ACTIONS,
@@ -103,7 +104,8 @@ class SemanticOntologyService(
raise ValueError("query 不能为空。")
AgentFoundationService(self.db).ensure_foundation_ready()
context_json = payload.context_json or {}
context_json = normalize_ontology_context_json(payload.context_json or {})
payload = payload.model_copy(update={"context_json": context_json})
reference = self._load_reference_catalog()
compact_query = self._compact(query)
entities = self._extract_entities(query, compact_query, reference, context_json=context_json)

View File

@@ -14,6 +14,7 @@ from app.schemas.ontology import (
OntologyTimeRange,
)
from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.ontology_budget import BudgetOntologyMixin
from app.services.ontology_rules import (
AMOUNT_PATTERN,
@@ -82,9 +83,7 @@ class OntologyExtractionMixin(BudgetOntologyMixin):
)
if application_mode:
form_values = context_json.get("review_form_values")
if not isinstance(form_values, dict):
form_values = {}
form_values = normalize_ontology_form_values(context_json.get("review_form_values"))
expense_type_codes = {
str(item.normalized_value or item.value or "").strip()
for item in entities
@@ -95,17 +94,10 @@ class OntologyExtractionMixin(BudgetOntologyMixin):
missing_slots.append("expense_type")
if "amount" not in entity_types and not str(form_values.get("amount") or "").strip():
missing_slots.append("amount")
if not time_range.start_date and not (
str(form_values.get("time_range") or form_values.get("business_time") or "").strip()
):
if not time_range.start_date and not str(form_values.get("time_range") or "").strip():
missing_slots.append("time_range")
reason_value = str(
form_values.get("reason")
or form_values.get("business_reason")
or form_values.get("reason_value")
or ""
).strip()
if not reason_value and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS:
reason_text = str(form_values.get("reason") or "").strip()
if not reason_text and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS:
missing_slots.append("reason")
if (
attachment_count <= 0
@@ -171,12 +163,33 @@ class OntologyExtractionMixin(BudgetOntologyMixin):
) -> list[OntologyEntity]:
entities: dict[tuple[str, str], OntologyEntity] = {}
context_json = context_json or {}
form_values = normalize_ontology_form_values(context_json.get("review_form_values"))
def upsert(entity: OntologyEntity) -> None:
key = (entity.type, entity.normalized_value)
if key not in entities:
entities[key] = entity
context_entity_specs = (
("expense_type", "expense_type", "filter", 0.86),
("location", "location", "filter", 0.82),
("reason", "reason", "target", 0.82),
("amount", "amount", "target", 0.82),
("transport_mode", "transport_mode", "filter", 0.9),
)
for field_key, entity_type, role, confidence in context_entity_specs:
value = str(form_values.get(field_key) or "").strip()
if value:
upsert(
self._make_entity(
entity_type,
value,
value,
role=role,
confidence=confidence,
)
)
if (
self._is_expense_application_context_value(context_json)
or self._has_expense_application_signal(compact_query)

View File

@@ -0,0 +1,185 @@
from __future__ import annotations
from typing import Any
ONTOLOGY_FIELD_ALIASES: dict[str, tuple[str, ...]] = {
"expense_type": ("reimbursement_type", "scene_label", "expenseType"),
"time_range": (
"business_time",
"businessTime",
"occurred_date",
"occurredDate",
"application_business_time",
"applicationBusinessTime",
"application_time",
"applicationTime",
),
"location": (
"business_location",
"businessLocation",
"application_location",
"applicationLocation",
),
"reason": (
"reason_value",
"reasonValue",
"business_reason",
"businessReason",
"application_reason",
"applicationReason",
),
"amount": (
"application_amount",
"applicationAmount",
"application_amount_label",
"applicationAmountLabel",
),
"transport_mode": (
"transport_type",
"transportType",
"transportMode",
"application_transport_mode",
"applicationTransportMode",
),
"attachments": ("attachment_names", "attachmentNames"),
"customer_name": ("customerName",),
"merchant_name": ("merchantName",),
"cost_center": ("costCenter",),
"department_name": ("department", "departmentName", "deptName"),
"employee_grade": ("grade", "user_grade", "employeeGrade", "position_grade"),
"employee_name": ("name", "user_name", "applicant", "claimant_name", "reporter_name"),
"employee_no": ("employeeNo",),
"employee_position": ("position", "employeePosition"),
"manager_name": ("managerName", "direct_manager_name", "directManagerName"),
}
CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
{
"participants",
"department",
"budget_period",
"budget_subject",
"budget_amount",
"cost_center",
"warning_threshold",
"control_action",
"employee_location",
"employee_risk_profile",
"finance_owner_name",
"document_id",
"application_claim_id",
"application_claim_no",
"application_days",
"application_date",
"application_lodging_daily_cap",
"application_subsidy_daily_cap",
"application_transport_policy",
"application_policy_estimate",
"application_rule_name",
"application_rule_version",
}
)
ONTOLOGY_CONTEXT_METADATA_FIELDS = frozenset(
{
"_claim_no_retry_count",
"actor",
"application_edit_claim_id",
"application_edit_mode",
"applicationEditClaimId",
"applicationEditMode",
"application_fields",
"application_preview",
"application_stage",
"attachment_count",
"attachment_names",
"business_time_context",
"budget_details",
"budget_header",
"client_now_iso",
"client_timezone_offset_minutes",
"conversation_history",
"conversation_id",
"conversation_intent",
"conversation_scenario",
"conversation_state",
"document_type",
"draft_claim_id",
"dry_run_email",
"email",
"entry_source",
"expense_scene_selection",
"force",
"is_admin",
"ocr_documents",
"ocr_summary",
"report_type",
"request_context",
"requested_by_name",
"requested_by_username",
"review_action",
"review_document_form_values",
"review_form_values",
"role_codes",
"role",
"send_email",
"session_type",
"simulate_orchestrator_exception",
"simulate_tool_failure",
"time_range_raw",
"user_id",
"user_input_text",
"username",
}
)
REGISTERED_ONTOLOGY_CONTEXT_FIELDS = (
CANONICAL_ONTOLOGY_FIELDS
| ONTOLOGY_CONTEXT_METADATA_FIELDS
| frozenset(alias for aliases in ONTOLOGY_FIELD_ALIASES.values() for alias in aliases)
)
def normalize_ontology_form_values(values: Any) -> dict[str, str]:
if not isinstance(values, dict):
return {}
normalized: dict[str, str] = {}
for key, value in values.items():
cleaned_key = str(key or "").strip()
if not cleaned_key:
continue
normalized[cleaned_key] = str(value or "").strip()
for canonical_key, aliases in ONTOLOGY_FIELD_ALIASES.items():
if normalized.get(canonical_key):
continue
for alias in aliases:
if normalized.get(alias):
normalized[canonical_key] = normalized[alias]
break
return normalized
def normalize_ontology_context_json(context_json: Any) -> dict[str, Any]:
if not isinstance(context_json, dict):
return {}
normalized = dict(context_json)
for canonical_key, aliases in ONTOLOGY_FIELD_ALIASES.items():
if normalized.get(canonical_key):
continue
for alias in aliases:
if normalized.get(alias):
normalized[canonical_key] = normalized[alias]
break
form_values = normalize_ontology_form_values(normalized.get("review_form_values"))
if form_values:
normalized["review_form_values"] = form_values
return normalized
def is_registered_ontology_context_field(field_name: str) -> bool:
return str(field_name or "").strip() in REGISTERED_ONTOLOGY_CONTEXT_FIELDS

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import hashlib
import mimetypes
import re
import shutil
@@ -85,6 +86,26 @@ class ReceiptFolderService:
if not self._should_persist_source(filename, content):
enriched.append(document)
continue
duplicate_receipt = self.find_duplicate_receipt(
filename=filename,
content=content,
current_user=current_user,
)
if duplicate_receipt is not None:
warning = "已上传过同样的单据,请不要重复上传。"
existing_warnings = [str(item) for item in list(document.warnings or []) if str(item).strip()]
enriched.append(
document.model_copy(
update={
"receipt_id": duplicate_receipt.id,
"receipt_status": duplicate_receipt.status,
"receipt_preview_url": duplicate_receipt.preview_url,
"receipt_source_url": duplicate_receipt.source_url,
"warnings": list(dict.fromkeys([*existing_warnings, warning])),
}
)
)
continue
receipt = self.save_receipt(
filename=filename,
content=content,
@@ -140,6 +161,7 @@ class ReceiptFolderService:
"source_file_name": normalized_name,
"media_type": resolved_media_type,
"size_bytes": len(content),
"file_sha256": self._content_hash(content),
"uploaded_at": now.isoformat(),
"status": "linked" if linked else "unlinked",
"linked_claim_id": str(linked_claim_id or "").strip(),
@@ -243,8 +265,24 @@ class ReceiptFolderService:
],
fields=self._resolve_fields(meta),
raw_meta=meta,
edit_logs=self._resolve_edit_logs(meta),
)
def find_duplicate_receipt(
self,
*,
filename: str,
content: bytes,
current_user: CurrentUserContext,
) -> ReceiptFolderItemRead | None:
if not self._should_persist_source(filename, content):
return None
file_hash = self._content_hash(content)
for meta in self._iter_owner_meta(self._owner_key(current_user)):
if file_hash and str(meta.get("file_sha256") or "").strip() == file_hash:
return self._build_item(meta)
return None
def update_receipt(
self,
*,
@@ -255,6 +293,7 @@ class ReceiptFolderService:
owner_key = self._owner_key(current_user)
receipt_dir = self._receipt_dir(owner_key, receipt_id)
meta = self._read_meta(receipt_dir)
before_meta = json.loads(json.dumps(meta, ensure_ascii=False))
updates = payload.model_dump(exclude_unset=True)
for key in ("document_type", "document_type_label", "scene_code", "scene_label", "summary"):
if key in updates and updates[key] is not None:
@@ -270,6 +309,18 @@ class ReceiptFolderService:
for field in payload.fields or []
]
meta["editable_fields"] = editable
changes = self._build_edit_changes(before_meta, meta)
if changes:
logs = list(meta.get("edit_logs") or [])
logs.insert(
0,
{
"operated_at": datetime.now(UTC).isoformat(),
"operator": self._operator_label(current_user),
"changes": changes,
},
)
meta["edit_logs"] = logs[:50]
meta["updated_at"] = datetime.now(UTC).isoformat()
self._write_meta(receipt_dir, meta)
return self.get_receipt(receipt_id, current_user)
@@ -285,6 +336,23 @@ class ReceiptFolderService:
shutil.rmtree(receipt_dir)
return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id)
def delete_receipts_for_claim(self, claim_id: str) -> int:
normalized_claim_id = str(claim_id or "").strip()
if not normalized_claim_id:
return 0
deleted_count = 0
self.root.mkdir(parents=True, exist_ok=True)
for meta_path in list(self.root.glob("*/*/meta.json")):
try:
meta = self._read_meta(meta_path.parent)
except FileNotFoundError:
continue
if str(meta.get("linked_claim_id") or "").strip() != normalized_claim_id:
continue
shutil.rmtree(meta_path.parent, ignore_errors=True)
deleted_count += 1
return deleted_count
def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
meta = self._read_receipt_meta(receipt_id, current_user)
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
@@ -501,6 +569,14 @@ class ReceiptFolderService:
encoding="utf-8",
)
@staticmethod
def _content_hash(content: bytes) -> str:
return hashlib.sha256(content or b"").hexdigest() if content else ""
@staticmethod
def _operator_label(current_user: CurrentUserContext) -> str:
return str(current_user.name or current_user.username or "当前用户").strip() or "当前用户"
@staticmethod
def _matches_status(meta: dict[str, Any], status_filter: str) -> bool:
if status_filter in {"", "all"}:
@@ -557,6 +633,97 @@ class ReceiptFolderService:
]
return fields
def _resolve_edit_logs(self, meta: dict[str, Any]) -> list[dict[str, Any]]:
logs = []
for log in list(meta.get("edit_logs") or []):
if not isinstance(log, dict):
continue
changes = [
{
"key": str(change.get("key") or ""),
"label": str(change.get("label") or ""),
"before": str(change.get("before") or ""),
"after": str(change.get("after") or ""),
}
for change in list(log.get("changes") or [])
if isinstance(change, dict)
and str(change.get("label") or change.get("key") or "").strip()
]
if not changes:
continue
logs.append(
{
"operated_at": self._parse_datetime(log.get("operated_at")),
"operator": str(log.get("operator") or "当前用户").strip() or "当前用户",
"changes": changes,
}
)
return logs
def _build_edit_changes(self, before_meta: dict[str, Any], after_meta: dict[str, Any]) -> list[dict[str, str]]:
before_values = self._flatten_editable_receipt_values(before_meta)
after_values = self._flatten_editable_receipt_values(after_meta)
changes = []
for key in sorted(set(before_values) | set(after_values)):
before = before_values.get(key, {})
after = after_values.get(key, {})
before_value = str(before.get("value") or "").strip()
after_value = str(after.get("value") or "").strip()
if before_value == after_value:
continue
label = str(after.get("label") or before.get("label") or key).strip()
changes.append(
{
"key": key,
"label": label,
"before": before_value,
"after": after_value,
}
)
return changes
def _flatten_editable_receipt_values(self, meta: dict[str, Any]) -> dict[str, dict[str, str]]:
values = {
"document_type_label": {
"label": "票据类型",
"value": str(meta.get("document_type_label") or "").strip(),
},
"scene_label": {
"label": "费用场景",
"value": str(meta.get("scene_label") or "").strip(),
},
"summary": {
"label": "摘要",
"value": str(meta.get("summary") or "").strip(),
},
"amount": {
"label": "金额",
"value": self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")),
},
"document_date": {
"label": "票据日期",
"value": self._resolve_receipt_document_date(meta),
},
"merchant_name": {
"label": "商户",
"value": self._resolve_receipt_merchant_name(meta),
},
}
for index, field in enumerate(list(meta.get("document_fields") or [])):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip()
label = str(field.get("label") or "").strip()
value = str(field.get("value") or "").strip()
stable_key = key or f"field_{index}_{label}"
if not stable_key and not label:
continue
values[stable_key] = {
"label": label or stable_key,
"value": value,
}
return values
def _resolve_receipt_document_date(self, meta: dict[str, Any]) -> str:
editable = meta.get("editable_fields")
if isinstance(editable, dict):

View File

@@ -202,7 +202,7 @@ def _build_structured_conditions(text: str, fields: list[RiskRuleField]) -> list
field_keys = [field.key for field in fields]
attachment_fields = [key for key in field_keys if key.startswith("attachment.")]
city_left = [key for key in field_keys if key in {"attachment.hotel_city", "attachment.route_cities"}]
city_right = [key for key in field_keys if key in {"claim.location", "item.item_location", "employee.location"}]
city_right = [key for key in field_keys if key in {"claim.location", "item.item_location"}]
date_fields = [key for key in field_keys if _field_type(key, fields) == "date" and key.startswith("attachment.")]
range_start = [key for key in field_keys if key in {"claim.trip_start_date", "item.item_date"}]
range_end = [key for key in field_keys if key in {"claim.trip_end_date", "item.item_date"}]

View File

@@ -65,9 +65,10 @@ def _build_condition_steps(manifest: dict[str, Any], evidence: dict[str, Any]) -
),
"operator": "route_city_consistency",
"inputs": {
"application_reference_values": city_consistency.get("application_reference_values") or [],
"claim_reference_values": city_consistency.get("claim_reference_values") or [],
"attachment_values": city_consistency.get("attachment_values") or [],
"reference_values": city_consistency.get("reference_values") or [],
"home_values": city_consistency.get("home_values") or [],
"unexpected_route_cities": city_consistency.get("unexpected_route_cities") or [],
"explanation_hits": city_consistency.get("explanation_hits") or [],
},

View File

@@ -621,7 +621,6 @@ class RiskRuleGenerationService:
in {
"claim.reason",
"claim.location",
"employee.location",
"item.item_date",
"item.item_reason",
"item.item_location",

View File

@@ -111,7 +111,7 @@ FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
"员工常驻地",
"text",
"employee",
("常驻地", "办公地", "员工所在地", "出发地", "所在城市"),
("常驻地", "办公地", "员工所在地", "所在城市"),
),
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),

View File

@@ -105,9 +105,10 @@ def build_risk_rule_compiler_messages(
"重复发票、同一票据号、重复报销等规则必须用 duplicate_value例如 attachment.invoice_no 在本次附件或明细中出现重复,不得写成重复风险关键词匹配。",
"差旅路线规则中,交通票行程城市和住宿发票城市属于附件城市集合。",
"申报目的地和明细发生地点属于申报行程城市集合。",
"员工常驻地/出发地如可用,属于合理起终点集合,不等同于申报目的地",
"员工常驻地只能作为员工档案背景,不能作为本次出发地或返回地的硬依据",
"本次出发地和返回地应来自申请单明确字段或交通票路线本身。",
"绕行、跨城办事、临时改签是例外说明证据,不是风险命中关键词。",
"如果票据路线出现申报目的地和常驻地之外的额外城市,应描述为中途周转/绕行异常。",
"如果票据路线出现无法由本次票据起终点和申报目的地解释的额外城市,应描述为中途周转/绕行异常。",
"keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。",
"不要直接指定 risk_level 或 risk_score只输出 risk_scoring_evidence后端会按固定评分模型计算 0-100 分和风险等级。",
"评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。",
@@ -128,13 +129,12 @@ def build_risk_rule_compiler_messages(
"attachment.hotel_city",
"claim.location",
"item.item_location",
"employee.location",
"claim.reason",
"item.item_reason",
],
"condition_summary": (
"A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理起终点;A与B无交集且无合理说明或A中出现BC之外城市时命中。"
"A与B无交集且无合理说明或A中出现无法由本次票据起终点和申报目的地解释的额外城市时命中。"
),
"keywords": [],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],

View File

@@ -19,7 +19,7 @@ RISK_LEVEL_LABELS = {
CITY_ATTACHMENT_FIELDS = ("attachment.route_cities", "attachment.hotel_city")
CITY_REFERENCE_FIELDS = ("claim.location", "item.item_location")
CITY_HOME_FIELDS = ("employee.location",)
CITY_HOME_FIELDS: tuple[str, ...] = ()
CITY_EXCEPTION_FIELDS = ("claim.reason", "item.item_reason")
CITY_EXCEPTION_KEYWORDS = ("绕行", "跨城办事", "跨城", "临时改签", "改签", "变更")
@@ -64,8 +64,9 @@ def build_city_consistency_draft(
risk_label = RISK_LEVEL_LABELS.get(risk_level, "风险")
condition_summary = (
"判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。"
"若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市,"
"则命中目的地不一致/中途周转异常风险。"
)
flow = draft.get("flow") if isinstance(draft.get("flow"), dict) else {}
return {
@@ -79,9 +80,9 @@ def build_city_consistency_draft(
"flow": {
**flow,
"start": "差旅报销单据提交,并上传交通票据、住宿票据或其他可识别城市的附件",
"evidence": "读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由",
"decision": "附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市",
"pass": "票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市",
"evidence": "读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由",
"decision": "附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市",
"pass": "票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市",
"fail": f"票据路线存在目的地不一致或额外中转城市,命中{risk_label}并要求补充说明或退回修改",
},
}
@@ -102,16 +103,15 @@ def build_city_consistency_params(draft: dict[str, Any]) -> dict[str, Any]:
"formula": (
"A=UNION(attachment.route_cities, attachment.hotel_city); "
"B=UNION(claim.location, item.item_location); "
"C=UNION(employee.location); "
"HIT WHEN (A∩B=∅ AND NOT CONTAINS_ANY(exception_fields, exception_keywords)) "
"OR EXISTS(city IN A WHERE city NOT IN BC)"
"OR EXISTS(city IN route_cities WHERE city NOT EXPLAINED BY B OR ROUTE_ENDPOINTS)"
),
"conditions": [
{
"left_group": list(CITY_ATTACHMENT_FIELDS),
"operator": "route_city_consistency",
"right_group": list(CITY_REFERENCE_FIELDS),
"home_group": list(CITY_HOME_FIELDS),
"home_group": [],
"exception_fields": list(CITY_EXCEPTION_FIELDS),
"exception_keywords": exception_keywords,
}

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from typing import Any
BUDGET_RISK_STAGES = {"budget_execution", "budget_control", "budget_review"}
def is_budget_risk_manifest(manifest: dict[str, Any]) -> bool:
"""判断规则是否属于预算治理风险,而不是普通费用行为风险。"""
if not isinstance(manifest, dict):
return False
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
rule_code = str(manifest.get("rule_code") or "").strip().lower()
finance_rule_code = str(
manifest.get("finance_rule_code") or metadata.get("finance_rule_code") or ""
).strip().lower()
if rule_code.startswith("risk.budget.") or rule_code.startswith("budget."):
return True
if finance_rule_code.startswith("budget."):
return True
if _normalized_text(manifest.get("risk_domain") or metadata.get("risk_domain")) == "budget":
return True
domains = {_normalized_text(value) for value in _as_list(applies_to.get("domains"))}
if "budget" in domains and not domains.difference({"budget"}):
return True
stages = {
_normalized_text(value)
for value in [
*_as_list(manifest.get("business_stage")),
*_as_list(metadata.get("business_stage")),
*_as_list(applies_to.get("business_stages")),
]
}
if stages & BUDGET_RISK_STAGES:
return True
category_text = " ".join(
str(value or "")
for value in (
manifest.get("risk_category"),
metadata.get("risk_category"),
manifest.get("name"),
)
)
if "预算" in category_text and any(key.startswith("budget.") for key in _iter_field_keys(manifest)):
return True
return any(key.startswith("budget.") for key in _iter_field_keys(manifest))
def _iter_field_keys(value: Any) -> list[str]:
keys: list[str] = []
def visit(node: Any) -> None:
if isinstance(node, dict):
for key, item in node.items():
normalized_key = str(key or "").strip()
if normalized_key in {
"key",
"field",
"left",
"right",
"field_key",
"fieldKey",
}:
_append_key(item)
elif normalized_key in {
"fields",
"field_keys",
"fieldKeys",
"search_fields",
"searchFields",
"left_fields",
"leftFields",
"right_fields",
"rightFields",
"left_group",
"leftGroup",
"right_group",
"rightGroup",
"date_fields",
"range_start_fields",
"range_end_fields",
}:
for child in _as_list(item):
_append_key(child)
visit(item)
return
if isinstance(node, list):
for item in node:
visit(item)
def _append_key(item: Any) -> None:
text = str(item or "").strip().lower()
if text and text not in keys:
keys.append(text)
visit(value)
return keys
def _as_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, (tuple, set)):
return list(value)
if value in (None, ""):
return []
return [value]
def _normalized_text(value: Any) -> str:
return str(value or "").strip().lower()

View File

@@ -28,14 +28,15 @@ RISK_LEVEL_LABELS = {
CITY_ROUTE_CONDITION_SUMMARY = (
"判断公式A=交通票行程城市住宿发票城市B=申报目的地∪明细发生地点,"
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在不属于BC的额外城市则命中目的地不一致/中途周转异常风险。"
"若A或B为空则要求补充识别若A与B无交集且无合理说明"
"或票据路线中存在无法由本次票据起终点和申报目的地解释的额外城市,"
"则命中目的地不一致/中途周转异常风险。"
)
CITY_ROUTE_FLOW_DECISION = (
"附件城市是否覆盖申报行程,且票据路线是否出现申报目的地和常驻地之外的中转城市"
"附件城市是否覆盖申报行程,且票据路线是否出现无法由本次票据起终点和申报目的地解释的中转城市"
)
CITY_ROUTE_FLOW_EVIDENCE = (
"读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
"读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由"
)
@@ -82,7 +83,7 @@ def normalize_risk_rule_manifest(manifest: dict[str, Any]) -> dict[str, Any]:
)
flow.setdefault(
"pass",
"票据城市覆盖申报行程,且未出现申报目的地和常驻地之外的额外城市",
"票据城市覆盖申报行程,且未出现无法由本次票据起终点和申报目的地解释的额外城市",
)
flow["fail"] = (
f"票据路线存在目的地不一致或额外中转城市,命中{severity_label}并要求补充说明或退回修改"

View File

@@ -212,14 +212,13 @@ _TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = (
"requires_attachment": True,
"natural_language": (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
"再读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。"
"再读取申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。"
"若交通票或住宿票据中的城市无法与申报目的地、明细地点形成一致关系,"
"或票据路线中出现申报目的地与员工常驻地之外的额外中转城市,"
"或票据路线中出现无法由本次票据起终点和申报目的地解释的额外中转城市,"
"且报销事由中没有说明绕行、跨城办事或临时改签原因,"
"则标记为高风险,要求补充行程说明或退回修改。"
),
"field_keys": [
"employee.location",
"claim.location",
"item.item_location",
"attachment.route_cities",
@@ -236,7 +235,7 @@ _TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = (
"id": "city_outside_business_scope",
"operator": "not_in_scope",
"left_fields": ["attachment.route_cities", "attachment.hotel_city"],
"right_fields": ["claim.location", "item.item_location", "employee.location"],
"right_fields": ["claim.location", "item.item_location"],
},
{
"id": "missing_route_exception",

View File

@@ -198,25 +198,23 @@ class RiskRuleTemplateExecutor:
for key in field_keys
if key in {"attachment.route_cities", "attachment.hotel_city"}
] or ["attachment.route_cities", "attachment.hotel_city"]
home_keys = self._read_string_list(params.get("home_city_fields")) or ["employee.location"]
reference_values: list[str] = []
application_reference_values: list[str] = []
attachment_values: list[str] = []
home_values: list[str] = []
route_values: list[str] = []
for key in reference_keys:
reference_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
application_reference_values.extend(self._iter_application_location_values(claim))
reference_values.extend(application_reference_values)
for key in attachment_keys:
resolved = self._resolve_values(key, claim=claim, contexts=contexts)
attachment_values.extend(resolved)
if key == "attachment.route_cities":
route_values.extend(resolved)
for key in home_keys:
home_values.extend(self._resolve_values(key, claim=claim, contexts=contexts))
route_sequence_values = list(route_values)
reference_values = self._dedupe_values(reference_values)
application_reference_values = self._dedupe_values(application_reference_values)
attachment_values = self._dedupe_values(attachment_values)
home_values = self._dedupe_values(home_values)
route_values = self._dedupe_values(route_values)
if not reference_values or not attachment_values:
return None
@@ -239,9 +237,8 @@ class RiskRuleTemplateExecutor:
if keyword and keyword in explanation_corpus
]
unexpected_route_cities = self._resolve_unexpected_route_cities(
route_values,
route_sequence_values,
reference_values=reference_values,
home_values=home_values,
)
has_destination_overlap = self._condition_passes(
"overlap",
@@ -252,7 +249,7 @@ class RiskRuleTemplateExecutor:
return None
reason = (
"票据路线包含申报行程和常驻地之外的中转城市。"
"票据路线包含无法由申请单、报销单或附件起终点解释的额外城市。"
if unexpected_route_cities
else "票据城市与申报目的地或明细地点不一致,且未说明绕行、跨城或改签原因。"
)
@@ -280,9 +277,15 @@ class RiskRuleTemplateExecutor:
"reasonable_exception": bool(keyword_hits),
},
"city_consistency": {
"application_reference_values": application_reference_values[:8],
"claim_reference_values": self._dedupe_values(
[
*self._resolve_values("claim.location", claim=claim, contexts=contexts),
*self._resolve_values("item.item_location", claim=claim, contexts=contexts),
]
)[:8],
"attachment_values": attachment_values[:8],
"reference_values": reference_values[:8],
"home_values": home_values[:8],
"route_values": route_values[:8],
"unexpected_route_cities": unexpected_route_cities[:8],
"explanation_keywords": explanation_keywords[:8],
@@ -609,14 +612,19 @@ class RiskRuleTemplateExecutor:
route_values: list[str],
*,
reference_values: list[str],
home_values: list[str],
) -> list[str]:
if len(route_values) < 2:
return []
allowed_values = [value for value in [*reference_values, *home_values] if value]
allowed_values = [value for value in reference_values if value]
if not allowed_values:
return []
candidates = route_values if home_values else route_values[1:-1]
allowed_values.extend(
RiskRuleTemplateExecutor._resolve_inferred_route_endpoint_values(
route_values,
reference_values=reference_values,
)
)
candidates = route_values
unexpected: list[str] = []
for city in candidates:
if RiskRuleTemplateExecutor._values_overlap([city], allowed_values):
@@ -625,6 +633,37 @@ class RiskRuleTemplateExecutor:
unexpected.append(city)
return unexpected
@staticmethod
def _resolve_inferred_route_endpoint_values(
route_values: list[str],
*,
reference_values: list[str],
) -> list[str]:
if len(route_values) < 2 or not reference_values:
return []
has_declared_destination = any(
RiskRuleTemplateExecutor._values_overlap([city], reference_values)
for city in route_values
)
if not has_declared_destination:
return []
inferred: list[str] = []
first_city = str(route_values[0] or "").strip()
last_city = str(route_values[-1] or "").strip()
if first_city:
inferred.append(first_city)
if (
last_city
and (
len(route_values) == 2
or RiskRuleTemplateExecutor._values_overlap([last_city], [first_city])
)
and last_city not in inferred
):
inferred.append(last_city)
return inferred
@staticmethod
def _expand_route_city_values(values: list[Any]) -> list[Any]:
expanded: list[Any] = []
@@ -750,6 +789,56 @@ class RiskRuleTemplateExecutor:
return parsed.year
return None
@staticmethod
def _iter_application_location_values(claim: ExpenseClaim) -> list[Any]:
values: list[Any] = []
application_sources = {"application_detail", "application_handoff", "application_link"}
location_keys = (
"application_location",
"applicationLocation",
"business_location",
"businessLocation",
"location",
"destination",
"destination_city",
"destinationCity",
"matched_city",
"matchedCity",
)
nested_keys = (
"application_detail",
"applicationDetail",
"review_form_values",
"reviewFormValues",
"expense_scene_selection",
"expenseSceneSelection",
)
for flag in list(getattr(claim, "risk_flags_json", None) or []):
if not isinstance(flag, dict):
continue
source = str(flag.get("source") or "").strip()
has_application_anchor = (
source in application_sources
or any(key in flag for key in ("application_claim_no", "applicationClaimNo"))
or any(
isinstance(flag.get(key), dict)
for key in ("application_detail", "applicationDetail")
)
)
if not has_application_anchor:
continue
sources: list[dict[str, Any]] = [flag]
for key in nested_keys:
nested = flag.get(key)
if isinstance(nested, dict):
sources.append(nested)
for source_dict in sources:
for key in location_keys:
value = source_dict.get(key)
if value not in (None, ""):
values.append(value)
return RiskRuleTemplateExecutor._normalize_values(values)
@staticmethod
def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]:
values: list[Any] = []

View File

@@ -35,6 +35,7 @@ from app.schemas.user_agent import (
from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_constants import *
@@ -49,8 +50,8 @@ class UserAgentReviewCoreMixin:
return False
if str(payload.context_json.get("review_action") or "").strip():
return False
review_form_values = self._resolve_review_form_values(payload)
if str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip():
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
if str(review_form_values.get("expense_type") or "").strip():
return False
if self._resolve_attachment_count(payload) > 0 or self._resolve_ocr_documents(payload):
return False

View File

@@ -38,6 +38,7 @@ from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, Runtime
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.expense_type_keywords import resolve_expense_type_label_from_text
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.user_agent_constants import *
@@ -151,10 +152,9 @@ class UserAgentReviewSlotMixin:
def _resolve_location_value(self, payload: UserAgentRequest) -> str:
review_form_values = self._resolve_review_form_values(payload)
for key in ("business_location", "location"):
value = str(review_form_values.get(key) or "").strip()
if value:
return value
value = str(review_form_values.get("location") or "").strip()
if value:
return value
if str(payload.context_json.get("entry_source") or "").strip() == "detail":
request_context = payload.context_json.get("request_context")
@@ -181,21 +181,7 @@ class UserAgentReviewSlotMixin:
@staticmethod
def _resolve_review_form_values(payload: UserAgentRequest) -> dict[str, str]:
values = payload.context_json.get("review_form_values")
if not isinstance(values, dict):
return {}
normalized: dict[str, str] = {}
for key, value in values.items():
cleaned_key = str(key or "").strip()
if not cleaned_key:
continue
normalized[cleaned_key] = str(value or "").strip()
if not normalized.get("transport_mode"):
for alias in ("transportMode", "application_transport_mode", "applicationTransportMode"):
if normalized.get(alias):
normalized["transport_mode"] = normalized[alias]
break
return normalized
return normalize_ontology_form_values(payload.context_json.get("review_form_values"))
@staticmethod
@@ -220,12 +206,7 @@ class UserAgentReviewSlotMixin:
def _build_time_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
edited_value = str(
review_form_values.get("time_range")
or review_form_values.get("business_time")
or review_form_values.get("occurred_date")
or ""
).strip()
edited_value = str(review_form_values.get("time_range") or "").strip()
if edited_value:
raw_value = str(review_form_values.get("time_range_raw") or edited_value).strip()
return self._build_slot_value(
@@ -237,17 +218,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。",
)
application_time = str(review_form_values.get("application_business_time") or "").strip()
if application_time:
return self._build_slot_value(
value=application_time,
raw_value=application_time,
normalized_value=application_time,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的发生时间依据。",
)
time_range = payload.ontology.time_range
if time_range.start_date and time_range.end_date:
normalized_value = (
@@ -270,25 +240,14 @@ class UserAgentReviewSlotMixin:
def _build_location_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
for key in ("business_location", "location"):
value = str(review_form_values.get(key) or "").strip()
if value:
return self._build_slot_value(
value=value,
normalized_value=value,
source="user_form",
confidence=1.0,
evidence="来源于用户修改后的结构化表单。",
)
application_location = str(review_form_values.get("application_location") or "").strip()
if application_location:
value = str(review_form_values.get("location") or "").strip()
if value:
return self._build_slot_value(
value=application_location,
normalized_value=application_location,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的地点依据",
value=value,
normalized_value=value,
source="user_form",
confidence=1.0,
evidence="来源于用户修改后的结构化表单",
)
if str(payload.context_json.get("entry_source") or "").strip() == "detail":
@@ -396,17 +355,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。",
)
application_reason = str(review_form_values.get("application_reason") or "").strip()
if application_reason:
return self._build_slot_value(
value=application_reason,
raw_value=application_reason,
normalized_value=application_reason,
source="detail_context",
confidence=0.9,
evidence="来源于已关联申请单,作为本次报销草稿的事由依据。",
)
inferred_reason = self._infer_reason_from_claim_groups(
claim_groups=claim_groups,
)
@@ -457,22 +405,6 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。",
)
application_amount = str(
review_form_values.get("application_amount")
or review_form_values.get("application_amount_label")
or ""
).strip()
if application_amount:
normalized = self._normalize_amount_text(application_amount)
return self._build_slot_value(
value=normalized,
raw_value=application_amount,
normalized_value=normalized,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的金额依据。",
)
amount_value = entity_map.get("amount", "")
if amount_value:
normalized = self._normalize_amount_text(amount_value)
@@ -506,7 +438,7 @@ class UserAgentReviewSlotMixin:
ocr_documents: list[dict[str, object]],
) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
edited_value = str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip()
edited_value = str(review_form_values.get("expense_type") or "").strip()
if edited_value:
normalized_code, normalized_label = self._normalize_expense_type_input(edited_value)
return self._build_slot_value(
@@ -581,7 +513,7 @@ class UserAgentReviewSlotMixin:
def _build_attachment_slot(self, payload: UserAgentRequest) -> dict[str, str | float]:
review_form_values = self._resolve_review_form_values(payload)
attachment_names = str(review_form_values.get("attachment_names") or "").strip()
attachment_names = str(review_form_values.get("attachments") or "").strip()
if attachment_names:
return self._build_slot_value(
value=attachment_names,

View File

@@ -35,6 +35,7 @@ from app.schemas.user_agent import (
from app.services.agent_assets import AgentAssetService
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent_constants import *
@@ -422,22 +423,19 @@ class UserAgentReviewTravelReceiptMixin:
def _build_review_reason_corpus(self, payload: UserAgentRequest) -> str:
review_form_values = self._resolve_review_form_values(payload)
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
parts = [
str(payload.message or ""),
str(payload.context_json.get("user_input_text") or ""),
str(review_form_values.get("reason") or ""),
str(review_form_values.get("business_reason") or ""),
str(review_form_values.get("location") or ""),
str(review_form_values.get("business_location") or ""),
]
return "\n".join(part.strip() for part in parts if part and part.strip())
def _resolve_declared_travel_city(self, payload: UserAgentRequest, policy: RuntimeTravelPolicy) -> str:
review_form_values = self._resolve_review_form_values(payload)
review_form_values = normalize_ontology_form_values(self._resolve_review_form_values(payload))
candidates = [
str(review_form_values.get("business_location") or ""),
str(review_form_values.get("location") or ""),
self._resolve_location_value(payload),
str(payload.message or ""),