feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
@@ -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)):
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
373
server/src/app/services/demo_company_simulation_rebalance.py
Normal file
373
server/src/app/services/demo_company_simulation_rebalance.py
Normal 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
|
||||
)
|
||||
@@ -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 []),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
185
server/src/app/services/ontology_field_registry.py
Normal file
185
server/src/app/services/ontology_field_registry.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
@@ -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 [],
|
||||
},
|
||||
|
||||
@@ -621,7 +621,6 @@ class RiskRuleGenerationService:
|
||||
in {
|
||||
"claim.reason",
|
||||
"claim.location",
|
||||
"employee.location",
|
||||
"item.item_date",
|
||||
"item.item_reason",
|
||||
"item.item_location",
|
||||
|
||||
@@ -111,7 +111,7 @@ FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
|
||||
"员工常驻地",
|
||||
"text",
|
||||
"employee",
|
||||
("常驻地", "办公地", "员工所在地", "出发地", "所在城市"),
|
||||
("常驻地", "办公地", "员工所在地", "所在城市"),
|
||||
),
|
||||
RiskRuleField("item.item_type", "费用类型", "enum", "item", ("费用类型", "科目", "类型")),
|
||||
RiskRuleField("item.item_reason", "明细事由", "text", "item", ("明细事由", "明细说明")),
|
||||
|
||||
@@ -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中出现B∪C之外城市时命中。"
|
||||
"A与B无交集且无合理说明,或A中出现无法由本次票据起终点和申报目的地解释的额外城市时命中。"
|
||||
),
|
||||
"keywords": [],
|
||||
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
||||
|
||||
@@ -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无交集且无合理说明,"
|
||||
"或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。"
|
||||
"若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 B∪C)"
|
||||
"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,
|
||||
}
|
||||
|
||||
120
server/src/app/services/risk_rule_manifest_classifier.py
Normal file
120
server/src/app/services/risk_rule_manifest_classifier.py
Normal 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()
|
||||
@@ -28,14 +28,15 @@ RISK_LEVEL_LABELS = {
|
||||
|
||||
CITY_ROUTE_CONDITION_SUMMARY = (
|
||||
"判断公式:A=交通票行程城市∪住宿发票城市,B=申报目的地∪明细发生地点,"
|
||||
"C=员工常驻地/合理出发地。若A或B为空则要求补充识别;若A与B无交集且无合理说明,"
|
||||
"或票据路线中存在不属于B∪C的额外城市,则命中目的地不一致/中途周转异常风险。"
|
||||
"若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}并要求补充说明或退回修改"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ""),
|
||||
|
||||
Reference in New Issue
Block a user