feat: 财务看板口径重构与半年模拟数据及报销状态注册表

- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 16:22:59 +08:00
parent ca691f3ee0
commit 0c74b4ab4a
54 changed files with 6810 additions and 1238 deletions

View File

@@ -17,5 +17,8 @@ class FinanceDashboardRead(BaseModel):
spend_by_category: list[dict[str, Any]] = Field(default_factory=list)
exception_mix: list[dict[str, Any]] = Field(default_factory=list)
department_ranking: list[dict[str, Any]] = Field(default_factory=list)
employee_ranking: list[dict[str, Any]] = Field(default_factory=list)
top_claims: list[dict[str, Any]] = Field(default_factory=list)
bottlenecks: list[dict[str, Any]] = Field(default_factory=list)
budget_summary: dict[str, Any] = Field(default_factory=dict)
budget_metrics: list[dict[str, Any]] = Field(default_factory=list)

View File

@@ -163,24 +163,13 @@ def build_application_system_estimate(
lodging_display = format_application_money(lodging)
allowance_display = format_application_money(allowance)
total_display = format_application_money(total_amount)
band_label = {
"premium": "一线/高频城市",
"remote": "远途地区",
"coastal": "沿海城市",
"default": "普通城市",
}[location_band]
query_label = query_date or "出行日期待确认"
return {
"amount": f"{total_display}",
"lodging_daily_cap": f"{format_application_money(lodging_daily)}元/天",
"subsidy_daily_cap": f"{format_application_money(allowance_daily)}元/天",
"transport_policy": (
f"已查询 {query_label} {mode}参考票价,按{band_label}往返 {transport_display}元预估"
f"(查询耗时 {simulated_latency_ms}ms报销阶段按真实票据复核"
),
"transport_policy": f"预估交通费用 {transport_display}",
"policy_estimate": (
f"交通 {transport_display}(按 {query_label} 参考票价) + 住宿 {lodging_display}"
f"交通 {transport_display}元 + 住宿 {lodging_display}"
f" + 补贴 {allowance_display}元 = {total_display}元({days}天)"
),
"matched_city": str(location or "").strip(),

View File

@@ -0,0 +1,274 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import date, datetime
from decimal import Decimal
from typing import Any
SIM_EMPLOYEE_PREFIX = "SIM2026"
SIM_CLAIM_PREFIX = "SIM-EXP-2026"
SIM_BUDGET_PREFIX = "SIM-BUD-2026"
SIM_TRANSACTION_PREFIX = "SIM-BTX-2026"
SIM_RESERVATION_PREFIX = "SIM-BRS-2026"
SIM_RISK_PREFIX = "SIM-RISK-2026"
SIM_PROJECT_CODE = "SIM-DEMO"
DEFAULT_PASSWORD = "123456"
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
PENDING_STATUSES = {
"submitted",
"review",
"pending_review",
"manager_review",
"budget_review",
"finance_review",
"approving",
}
BUDGETED_STATUSES = SUCCESS_STATUSES | PENDING_STATUSES
@dataclass(frozen=True, slots=True)
class SimulationConfig:
target_employees: int = 100
start_date: date = date(2026, 1, 1)
months: int = 6
seed: int = 20260602
@dataclass(frozen=True, slots=True)
class SimulationSummary:
mode: str
current_employee_count: int
target_employee_count: int
selected_employee_count: int
employees_to_create: int
claims_to_create: int
claim_items_to_create: int
budget_allocations_to_create: int
budget_transactions_to_create: int
budget_reservations_to_create: int
risk_observations_to_create: int
period_start: str
period_end: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True, slots=True)
class DepartmentRef:
id: str
unit_code: str
name: str
cost_center: str
location: str
manager_name: str
@dataclass(frozen=True, slots=True)
class EmployeeRef:
id: str
employee_no: str
name: str
email: str
grade: str
position: str
department: DepartmentRef
is_new: bool = False
@dataclass(frozen=True, slots=True)
class ClaimItemPlan:
item_date: date
item_type: str
item_reason: str
item_location: str
item_amount: Decimal
invoice_id: str
@dataclass(frozen=True, slots=True)
class ClaimPlan:
id: str
claim_no: str
employee: EmployeeRef
expense_type: str
reason: str
location: str
amount: Decimal
invoice_count: int
occurred_at: datetime
submitted_at: datetime | None
status: str
approval_stage: str | None
risk_flags: list[dict[str, Any]]
hermes_risk_flag: bool
items: list[ClaimItemPlan]
@property
def period_key(self) -> str:
quarter = ((self.occurred_at.month - 1) // 3) + 1
return f"{self.occurred_at.year}Q{quarter}"
@property
def budget_subject_code(self) -> str:
return "meal" if self.expense_type == "entertainment" else self.expense_type
@dataclass(frozen=True, slots=True)
class AllocationPlan:
key: tuple[int, str, str, str, str]
department: DepartmentRef
subject_code: str
subject_name: str
period_key: str
original_amount: Decimal
DEFAULT_DEPARTMENTS = (
DepartmentRef("sim-dept-tech", "TECH-DEPT", "技术部", "CC-6100", "北京", "吴磊"),
DepartmentRef("sim-dept-market", "MARKET-DEPT", "市场部", "CC-4100", "上海", "刘思雨"),
DepartmentRef("sim-dept-finance", "FINANCE-DEPT", "财务部", "CC-2100", "上海", "张晓晴"),
DepartmentRef("sim-dept-hr", "HR-DEPT", "人力资源部", "CC-3200", "杭州", "陈硕"),
DepartmentRef("sim-dept-prod", "PRODUCTION-DEPT", "生产部", "CC-7200", "南京", "梁雨辰"),
DepartmentRef("sim-dept-office", "PRESIDENT-OFFICE", "总裁办", "CC-1000", "上海", "李文静"),
)
SUBJECT_LABELS = {
"travel": "差旅",
"meal": "招待费",
"office": "办公用品",
"communication": "通信",
}
SUBJECT_BASE_AMOUNTS = {
"travel": Decimal("5600.00"),
"meal": Decimal("1800.00"),
"office": Decimal("820.00"),
"communication": Decimal("320.00"),
}
DEPARTMENT_CLAIM_WEIGHTS = {
"TECH-DEPT": {"travel": 4, "meal": 1, "office": 3, "communication": 2},
"MARKET-DEPT": {"travel": 5, "meal": 4, "office": 1, "communication": 1},
"FINANCE-DEPT": {"travel": 2, "meal": 1, "office": 3, "communication": 2},
"HR-DEPT": {"travel": 2, "meal": 2, "office": 3, "communication": 1},
"PRODUCTION-DEPT": {"travel": 3, "meal": 1, "office": 4, "communication": 1},
"PRESIDENT-OFFICE": {"travel": 4, "meal": 3, "office": 2, "communication": 1},
}
DEPARTMENT_EMPLOYEE_WEIGHTS = {
"TECH-DEPT": 30,
"MARKET-DEPT": 24,
"PRODUCTION-DEPT": 20,
"FINANCE-DEPT": 12,
"HR-DEPT": 9,
"PRESIDENT-OFFICE": 5,
}
GRADE_FACTORS = {
"P3": Decimal("0.82"),
"P4": Decimal("0.92"),
"P5": Decimal("1.00"),
"P6": Decimal("1.15"),
"P7": Decimal("1.32"),
"P8": Decimal("1.55"),
}
MONTH_FACTORS = {
1: Decimal("0.86"),
2: Decimal("0.72"),
3: Decimal("1.05"),
4: Decimal("1.12"),
5: Decimal("1.22"),
6: Decimal("1.34"),
}
def build_employee_name(index: int) -> str:
surnames = ("", "", "", "", "", "", "", "", "", "", "", "")
names = ("嘉宁", "思远", "雨桐", "景行", "明轩", "若琳", "子涵", "安琪", "奕辰", "诗涵")
return f"{surnames[index % len(surnames)]}{names[(index * 3) % len(names)]}"
def grade_for_index(index: int) -> str:
grades = ("P3", "P4", "P4", "P5", "P5", "P6", "P6", "P7", "P8")
return grades[index % len(grades)]
def position_for_grade(grade: str) -> str:
return {
"P3": "专员",
"P4": "高级专员",
"P5": "主管",
"P6": "经理",
"P7": "高级经理",
"P8": "部门负责人",
}.get(grade, "员工")
def claim_reason(expense_type: str, department_name: str, occurred_day: date) -> str:
labels = {
"travel": "客户拜访与项目交付差旅",
"meal": "客户沟通与商务招待",
"office": "团队办公用品采购",
"communication": "项目通信与移动办公",
}
return f"{department_name}{occurred_day.month}{labels.get(expense_type, '业务费用')}"
def item_reason(expense_type: str) -> str:
return {
"meal": "商务招待餐费",
"office": "办公用品采购",
"communication": "通信服务费",
}.get(expense_type, "业务费用")
def claim_location(default_location: str, claim_index: int) -> str:
cities = ("上海", "北京", "深圳", "广州", "杭州", "南京", "成都", "武汉")
return cities[claim_index % len(cities)] or default_location
def risk_type(claim_index: int, expense_type: str) -> tuple[str, str]:
options = (
("amount_outlier", "金额异常"),
("budget_pressure", "预算压力偏高"),
("missing_material", "材料不完整"),
("duplicate_invoice", "疑似重复票据"),
("split_billing", "疑似拆分报销"),
)
if expense_type == "travel" and claim_index % 5 == 0:
return "location_mismatch", "地点不一致"
return options[claim_index % len(options)]
def target_budget_usage(period_key: str, subject_code: str, index: int) -> Decimal:
sequence = (
Decimal("0.62"),
Decimal("0.74"),
Decimal("0.83"),
Decimal("0.91"),
Decimal("1.06"),
)
usage = sequence[index % len(sequence)]
if period_key.endswith("Q2") and subject_code in {"travel", "meal"}:
usage += Decimal("0.07")
return min(usage, Decimal("1.12"))
def department_from_row(row: Any | None) -> DepartmentRef:
if row is None:
return DEFAULT_DEPARTMENTS[0]
return DepartmentRef(
id=row.id,
unit_code=row.unit_code,
name=row.name,
cost_center=row.cost_center or "",
location=row.location or "上海",
manager_name=row.manager_name or "",
)
def updated_at_for_claim_plan(plan: ClaimPlan) -> datetime:
from datetime import timedelta
base = plan.submitted_at or plan.occurred_at
if plan.status in SUCCESS_STATUSES | {"rejected", "returned"}:
return base + timedelta(hours=2 + int(plan.claim_no[-2:]) % 24)
return base + timedelta(hours=1)

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import calendar
from datetime import date
from typing import Any
ADMIN_KEYWORDS = {
"admin",
"administrator",
"root",
"system",
"sysadmin",
"superadmin",
}
ADMIN_CN_KEYWORDS = ("管理员", "系统")
APPLICATION_EXPENSE_TYPES = {
"application",
"expense_application",
"travel_application",
"trip_application",
"preapproval",
}
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
RECENT_VISIBLE_CLAIM_START = 501
RECENT_VISIBLE_CLAIM_END = 950
def is_admin_identity(*values: Any) -> bool:
for value in values:
text = str(value or "").strip()
lowered = text.lower()
if not text:
continue
if lowered in ADMIN_KEYWORDS:
return True
if any(token in lowered for token in ADMIN_KEYWORDS):
return True
if any(token in text for token in ADMIN_CN_KEYWORDS):
return True
return False
def is_admin_employee_like(employee: Any) -> bool:
return is_admin_identity(
getattr(employee, "employee_no", None),
getattr(employee, "name", None),
getattr(employee, "email", None),
)
def is_application_claim(claim: Any) -> bool:
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
if expense_type in APPLICATION_EXPENSE_TYPES:
return True
return claim_no.startswith(APPLICATION_CLAIM_PREFIXES)
def is_finance_reimbursement_claim(claim: Any) -> bool:
if is_application_claim(claim):
return False
return not is_admin_identity(
getattr(claim, "employee_name", None),
getattr(claim, "employee_id", None),
)
def recent_visible_claim_day(
months: list[date],
*,
employee_index: int,
claim_index: int,
) -> date | None:
if not months or not (RECENT_VISIBLE_CLAIM_START <= claim_index <= RECENT_VISIBLE_CLAIM_END):
return None
month = months[-1]
_, max_day = calendar.monthrange(month.year, month.month)
day = min(2, max_day)
return month.replace(day=1 + ((employee_index + claim_index) % day))

View File

@@ -0,0 +1,821 @@
from __future__ import annotations
import calendar
import random
import uuid
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.core.security import hash_password
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.models.risk_observation import RiskObservation
from app.models.role import Role
from app.services.demo_company_simulation_catalog import (
BUDGETED_STATUSES,
DEFAULT_DEPARTMENTS,
DEFAULT_PASSWORD,
DEPARTMENT_CLAIM_WEIGHTS,
DEPARTMENT_EMPLOYEE_WEIGHTS,
GRADE_FACTORS,
MONTH_FACTORS,
PENDING_STATUSES,
SIM_BUDGET_PREFIX,
SIM_CLAIM_PREFIX,
SIM_EMPLOYEE_PREFIX,
SIM_PROJECT_CODE,
SIM_RESERVATION_PREFIX,
SIM_RISK_PREFIX,
SIM_TRANSACTION_PREFIX,
SUBJECT_BASE_AMOUNTS,
SUBJECT_LABELS,
SUCCESS_STATUSES,
AllocationPlan,
ClaimItemPlan,
ClaimPlan,
DepartmentRef,
EmployeeRef,
SimulationConfig,
SimulationSummary,
build_employee_name,
claim_location,
claim_reason,
department_from_row,
grade_for_index,
item_reason,
position_for_grade,
risk_type,
target_budget_usage,
updated_at_for_claim_plan,
)
from app.services.demo_company_simulation_filters import (
is_admin_employee_like,
recent_visible_claim_day,
)
class HalfYearExpenseSimulationSeeder:
def __init__(self, db: Session, config: SimulationConfig | None = None) -> None:
self.db = db
self.config = config or SimulationConfig()
self.rng = random.Random(self.config.seed)
def preview(self) -> SimulationSummary:
return self._run(apply=False)
def apply(self) -> SimulationSummary:
return self._run(apply=True)
def _run(self, *, apply: bool) -> SimulationSummary:
Base.metadata.create_all(bind=self.db.get_bind())
departments = self._department_refs(apply=apply)
current_employee_count = self._employee_count()
planned_employees = self._build_new_employee_refs(departments, current_employee_count)
if apply:
self._ensure_user_role()
self._create_missing_employees(planned_employees)
self.db.flush()
employees = self._employee_refs(departments)
if not apply:
employees = [*employees, *planned_employees]
selected_employees = self._select_company_employees(employees)
claim_plans = self._build_claim_plans(selected_employees)
allocation_plans = self._build_allocation_plans(claim_plans)
allocation_map, allocation_count = self._ensure_allocations(
allocation_plans,
apply=apply,
)
claim_count, item_count = self._ensure_claims(claim_plans, apply=apply)
transaction_count, reservation_count = self._ensure_budget_usage(
claim_plans,
allocation_map,
apply=apply,
)
risk_count = self._ensure_risk_observations(claim_plans, apply=apply)
return SimulationSummary(
mode="apply" if apply else "dry-run",
current_employee_count=current_employee_count,
target_employee_count=self.config.target_employees,
selected_employee_count=len(selected_employees),
employees_to_create=len(planned_employees),
claims_to_create=claim_count,
claim_items_to_create=item_count,
budget_allocations_to_create=allocation_count,
budget_transactions_to_create=transaction_count,
budget_reservations_to_create=reservation_count,
risk_observations_to_create=risk_count,
period_start=self.config.start_date.isoformat(),
period_end=self._period_end().isoformat(),
)
def _department_refs(self, *, apply: bool) -> list[DepartmentRef]:
rows = list(
self.db.scalars(
select(OrganizationUnit)
.where(OrganizationUnit.unit_type == "department")
.order_by(OrganizationUnit.unit_code.asc())
).all()
)
if rows:
return [department_from_row(row) for row in rows]
if not apply:
return list(DEFAULT_DEPARTMENTS)
for item in DEFAULT_DEPARTMENTS:
self.db.add(
OrganizationUnit(
id=item.id,
unit_code=item.unit_code,
name=item.name,
unit_type="department",
cost_center=item.cost_center,
location=item.location,
manager_name=item.manager_name,
)
)
self.db.flush()
return list(DEFAULT_DEPARTMENTS)
def _employee_count(self) -> int:
employees = list(self.db.scalars(select(Employee)).all())
return sum(1 for employee in employees if not is_admin_employee_like(employee))
def _build_new_employee_refs(
self,
departments: list[DepartmentRef],
current_employee_count: int,
) -> list[EmployeeRef]:
missing_count = max(self.config.target_employees - current_employee_count, 0)
if missing_count <= 0:
return []
existing_nos = set(self.db.scalars(select(Employee.employee_no)).all())
refs: list[EmployeeRef] = []
next_index = 1
while len(refs) < missing_count:
employee_no = f"{SIM_EMPLOYEE_PREFIX}{next_index:03d}"
next_index += 1
if employee_no in existing_nos:
continue
department = self._weighted_department(departments, len(refs))
grade = grade_for_index(len(refs))
refs.append(
EmployeeRef(
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{employee_no}")),
employee_no=employee_no,
name=build_employee_name(len(refs)),
email=f"{employee_no.lower()}@xf.com",
grade=grade,
position=position_for_grade(grade),
department=department,
is_new=True,
)
)
return refs
def _ensure_user_role(self) -> Role:
role = self.db.scalar(select(Role).where(Role.role_code == "user"))
if role is not None:
return role
role = Role(
role_code="user",
name="使用者",
description="可以发起费用申请、报销和查看个人单据。",
)
self.db.add(role)
self.db.flush()
return role
def _create_missing_employees(self, refs: list[EmployeeRef]) -> None:
if not refs:
return
user_role = self._ensure_user_role()
existing_nos = set(self.db.scalars(select(Employee.employee_no)).all())
departments_by_id = {row.id: row for row in self.db.scalars(select(OrganizationUnit)).all()}
for ref in refs:
if ref.employee_no in existing_nos:
continue
employee = Employee(
id=ref.id,
employee_no=ref.employee_no,
name=ref.name,
email=ref.email,
gender="" if int(ref.employee_no[-1]) % 2 == 0 else "",
phone=f"139{int(ref.employee_no[-3:]):08d}",
join_date=date(2025, (int(ref.employee_no[-3:]) % 12) + 1, 10),
location=ref.department.location,
position=ref.position,
grade=ref.grade,
cost_center=ref.department.cost_center,
finance_owner_name=f"{ref.department.name}财务BP",
bank_name="招商银行",
bank_account_no=f"622588{int(ref.employee_no[-3:]):013d}",
bank_account_name=ref.name,
password_hash=hash_password(DEFAULT_PASSWORD),
employment_status="在职",
sync_state="已同步",
compliance_score=92 + int(ref.employee_no[-3:]) % 8,
organization_unit=departments_by_id.get(ref.department.id),
roles=[user_role],
last_sync_at=datetime.now(UTC),
)
self.db.add(employee)
def _employee_refs(self, departments: list[DepartmentRef]) -> list[EmployeeRef]:
department_by_id = {item.id: item for item in departments}
fallback_departments = departments or list(DEFAULT_DEPARTMENTS)
rows = list(
self.db.scalars(
select(Employee)
.options(selectinload(Employee.organization_unit))
.order_by(Employee.employee_no.asc())
).all()
)
refs: list[EmployeeRef] = []
for index, employee in enumerate(rows):
department = (
department_by_id.get(str(employee.organization_unit_id or ""))
or department_from_row(employee.organization_unit)
if employee.organization_unit is not None
else fallback_departments[index % len(fallback_departments)]
)
refs.append(
EmployeeRef(
id=employee.id,
employee_no=employee.employee_no,
name=employee.name,
email=employee.email,
grade=employee.grade or "P4",
position=employee.position or "员工",
department=department,
is_new=False,
)
)
return refs
def _select_company_employees(self, employees: list[EmployeeRef]) -> list[EmployeeRef]:
sorted_employees = sorted(
(employee for employee in employees if not is_admin_employee_like(employee)),
key=lambda item: item.employee_no,
)
target = max(1, self.config.target_employees)
return sorted_employees[:target] if len(sorted_employees) > target else sorted_employees
def _build_claim_plans(self, employees: list[EmployeeRef]) -> list[ClaimPlan]:
plans: list[ClaimPlan] = []
months = self._month_starts()
claim_index = 1
for employee_index, employee in enumerate(employees):
count = self._claim_count_for_employee(employee, employee_index)
for local_index in range(count):
occurred_day = self._claim_day(
months,
employee_index,
local_index,
claim_index,
)
expense_type = self._expense_type_for_employee(employee)
amount = self._claim_amount(employee, expense_type, occurred_day)
status, stage = self._status_for_claim(employee_index, local_index)
risk_flags = self._risk_flags(employee, expense_type, amount, claim_index)
submitted_at = None
if status != "draft":
submitted_at = datetime.combine(occurred_day, datetime.min.time(), tzinfo=UTC)
submitted_at += timedelta(hours=9 + (claim_index % 7))
occurred_at = datetime.combine(occurred_day, datetime.min.time(), tzinfo=UTC)
occurred_at += timedelta(hours=8 + (claim_index % 9))
plans.append(
ClaimPlan(
id=str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
f"x-financial:{SIM_CLAIM_PREFIX}:{claim_index}",
)
),
claim_no=f"{SIM_CLAIM_PREFIX}-{claim_index:04d}",
employee=employee,
expense_type=expense_type,
reason=claim_reason(
expense_type,
employee.department.name,
occurred_day,
),
location=claim_location(employee.department.location, claim_index),
amount=amount,
invoice_count=1 + (claim_index % 3),
occurred_at=occurred_at,
submitted_at=submitted_at,
status=status,
approval_stage=stage,
risk_flags=risk_flags,
hermes_risk_flag=bool(risk_flags and claim_index % 2 == 0),
items=self._claim_items(expense_type, amount, occurred_day, claim_index),
)
)
claim_index += 1
return plans
def _build_allocation_plans(self, claim_plans: list[ClaimPlan]) -> list[AllocationPlan]:
bucket_amounts: dict[tuple[int, str, str, str, str], Decimal] = {}
bucket_departments: dict[tuple[int, str, str, str, str], DepartmentRef] = {}
for plan in claim_plans:
if plan.status not in BUDGETED_STATUSES:
continue
department = plan.employee.department
key = (
plan.occurred_at.year,
plan.period_key,
department.id,
department.cost_center,
plan.budget_subject_code,
)
bucket_amounts[key] = bucket_amounts.get(key, Decimal("0.00")) + plan.amount
bucket_departments[key] = department
plans: list[AllocationPlan] = []
for index, (key, used_amount) in enumerate(sorted(bucket_amounts.items())):
year, period_key, _department_id, _cost_center, subject_code = key
target_usage = target_budget_usage(period_key, subject_code, index)
original_amount = max(
(used_amount / target_usage).quantize(Decimal("0.01")),
Decimal("3000.00"),
)
plans.append(
AllocationPlan(
key=key,
department=bucket_departments[key],
subject_code=subject_code,
subject_name=SUBJECT_LABELS.get(subject_code, subject_code),
period_key=period_key,
original_amount=original_amount,
)
)
return plans
def _ensure_allocations(
self,
plans: list[AllocationPlan],
*,
apply: bool,
) -> tuple[dict[tuple[int, str, str, str, str], str], int]:
allocation_map: dict[tuple[int, str, str, str, str], str] = {}
created_count = 0
for index, plan in enumerate(plans, start=1):
existing = self._find_sim_allocation(plan)
if existing is not None:
allocation_map[plan.key] = existing.id
continue
created_count += 1
allocation_id = str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
f"x-financial:{SIM_BUDGET_PREFIX}:{plan.key}",
)
)
allocation_map[plan.key] = allocation_id
if not apply:
continue
self.db.add(
BudgetAllocation(
id=allocation_id,
budget_no=f"{SIM_BUDGET_PREFIX}-{index:04d}",
fiscal_year=plan.key[0],
period_type="quarter",
period_key=plan.period_key,
department_id=plan.department.id,
department_name=plan.department.name,
cost_center=plan.department.cost_center,
project_code=SIM_PROJECT_CODE,
subject_code=plan.subject_code,
subject_name=plan.subject_name,
original_amount=plan.original_amount,
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="warn",
description="半年报销模拟数据预算池",
created_by="simulation",
updated_by="simulation",
)
)
if apply:
self.db.flush()
return allocation_map, created_count
def _ensure_claims(self, plans: list[ClaimPlan], *, apply: bool) -> tuple[int, int]:
existing_claim_nos = set(
self.db.scalars(
select(ExpenseClaim.claim_no).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
).all()
)
claim_count = 0
item_count = 0
for plan in plans:
if plan.claim_no in existing_claim_nos:
continue
claim_count += 1
item_count += len(plan.items)
if not apply:
continue
claim = ExpenseClaim(
id=plan.id,
claim_no=plan.claim_no,
employee_id=plan.employee.id,
employee_name=plan.employee.name,
department_id=plan.employee.department.id,
department_name=plan.employee.department.name,
project_code=SIM_PROJECT_CODE,
expense_type=plan.expense_type,
reason=plan.reason,
location=plan.location,
amount=plan.amount,
currency="CNY",
invoice_count=plan.invoice_count,
occurred_at=plan.occurred_at,
submitted_at=plan.submitted_at,
status=plan.status,
approval_stage=plan.approval_stage,
risk_flags_json=plan.risk_flags,
hermes_risk_flag=plan.hermes_risk_flag,
created_at=plan.occurred_at,
updated_at=updated_at_for_claim_plan(plan),
)
claim.items = [
ExpenseClaimItem(
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{plan.claim_no}:{index}")),
item_date=item.item_date,
item_type=item.item_type,
item_reason=item.item_reason,
item_location=item.item_location,
item_amount=item.item_amount,
invoice_id=item.invoice_id,
)
for index, item in enumerate(plan.items, start=1)
]
self.db.add(claim)
if apply:
self.db.flush()
return claim_count, item_count
def _ensure_budget_usage(
self,
plans: list[ClaimPlan],
allocation_map: dict[tuple[int, str, str, str, str], str],
*,
apply: bool,
) -> tuple[int, int]:
existing_transactions = set(
self.db.scalars(
select(BudgetTransaction.transaction_no).where(
BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")
)
).all()
)
existing_reservations = set(
self.db.scalars(
select(BudgetReservation.reservation_no).where(
BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%")
)
).all()
)
transaction_count = 0
reservation_count = 0
for index, plan in enumerate(plans, start=1):
if plan.status not in BUDGETED_STATUSES:
continue
allocation_id = allocation_map.get(self._allocation_key(plan))
if not allocation_id:
continue
transaction_no = f"{SIM_TRANSACTION_PREFIX}-{index:04d}"
if transaction_no not in existing_transactions:
transaction_count += 1
if apply:
self.db.add(self._transaction_for_plan(plan, allocation_id, transaction_no))
if plan.status in PENDING_STATUSES:
reservation_no = f"{SIM_RESERVATION_PREFIX}-{index:04d}"
if reservation_no not in existing_reservations:
reservation_count += 1
if apply:
self.db.add(self._reservation_for_plan(plan, allocation_id, reservation_no))
if apply:
self.db.flush()
return transaction_count, reservation_count
def _ensure_risk_observations(self, plans: list[ClaimPlan], *, apply: bool) -> int:
existing_keys = set(
self.db.scalars(
select(RiskObservation.observation_key).where(
RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%")
)
).all()
)
count = 0
for index, plan in enumerate(plans, start=1):
if not plan.risk_flags:
continue
key = f"{SIM_RISK_PREFIX}-{index:04d}"
if key in existing_keys:
continue
count += 1
if not apply:
continue
first_flag = plan.risk_flags[0]
self.db.add(
RiskObservation(
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{key}")),
observation_key=key,
subject_type="expense_claim",
subject_key=plan.claim_no,
subject_label=plan.claim_no,
claim_id=plan.id,
claim_no=plan.claim_no,
risk_type="simulation",
risk_signal=str(first_flag.get("event_type") or "amount_outlier"),
title=str(first_flag.get("label") or "模拟风险观察"),
description=str(first_flag.get("message") or ""),
risk_score=int(first_flag.get("risk_score") or 72),
risk_level=str(first_flag.get("severity") or "medium"),
confidence_score=0.78,
control_stage="reimbursement",
control_mode="manual_review",
automation_mode="simulation",
source="half_year_expense_simulation",
algorithm_version="simulation.v1",
status="pending_review",
evidence_json=[
{"label": "报销单号", "value": plan.claim_no},
{"label": "金额", "value": str(plan.amount)},
],
ontology_json={"scenario": "expense", "intent": "risk_check"},
created_at=plan.submitted_at or plan.occurred_at,
updated_at=updated_at_for_claim_plan(plan),
)
)
if apply:
self.db.flush()
return count
def _find_sim_allocation(self, plan: AllocationPlan) -> BudgetAllocation | None:
year, period_key, department_id, cost_center, subject_code = plan.key
stmt = (
select(BudgetAllocation)
.where(BudgetAllocation.fiscal_year == year)
.where(BudgetAllocation.period_key == period_key)
.where(BudgetAllocation.subject_code == subject_code)
.where(BudgetAllocation.project_code == SIM_PROJECT_CODE)
.where(
or_(
BudgetAllocation.department_id == department_id,
BudgetAllocation.cost_center == cost_center,
BudgetAllocation.department_name == plan.department.name,
)
)
.limit(1)
)
return self.db.scalar(stmt)
def _transaction_for_plan(
self,
plan: ClaimPlan,
allocation_id: str,
transaction_no: str,
) -> BudgetTransaction:
transaction_type = "consume" if plan.status in SUCCESS_STATUSES else "reserve"
return BudgetTransaction(
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{transaction_no}")),
transaction_no=transaction_no,
allocation_id=allocation_id,
source_type="claim",
source_id=plan.id,
source_no=plan.claim_no,
transaction_type=transaction_type,
amount=plan.amount,
before_available_amount=Decimal("0.00"),
after_available_amount=Decimal("0.00"),
operator="simulation",
reason=(
"半年报销模拟数据预算核销"
if transaction_type == "consume"
else "半年报销模拟数据预算预占"
),
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True},
created_at=plan.submitted_at or plan.occurred_at,
)
def _reservation_for_plan(
self,
plan: ClaimPlan,
allocation_id: str,
reservation_no: str,
) -> BudgetReservation:
return BudgetReservation(
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{reservation_no}")),
reservation_no=reservation_no,
allocation_id=allocation_id,
source_type="claim",
source_id=plan.id,
source_no=plan.claim_no,
source_status="active",
amount=plan.amount,
consumed_amount=Decimal("0.00"),
released_amount=Decimal("0.00"),
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True},
created_at=plan.submitted_at or plan.occurred_at,
)
def _allocation_key(self, plan: ClaimPlan) -> tuple[int, str, str, str, str]:
department = plan.employee.department
return (
plan.occurred_at.year,
plan.period_key,
department.id,
department.cost_center,
plan.budget_subject_code,
)
def _month_starts(self) -> list[date]:
current = self.config.start_date.replace(day=1)
months: list[date] = []
for _ in range(max(1, self.config.months)):
months.append(current)
year = current.year + (1 if current.month == 12 else 0)
month = 1 if current.month == 12 else current.month + 1
current = date(year, month, 1)
return months
def _period_end(self) -> date:
months = self._month_starts()
last_month = months[-1]
return last_month.replace(day=calendar.monthrange(last_month.year, last_month.month)[1])
def _claim_day(
self,
months: list[date],
employee_index: int,
local_index: int,
claim_index: int,
) -> date:
visible_day = recent_visible_claim_day(
months,
employee_index=employee_index,
claim_index=claim_index,
)
if visible_day is not None:
return visible_day
month = months[(employee_index + local_index * 2) % len(months)]
_, max_day = calendar.monthrange(month.year, month.month)
day = 1 + ((employee_index * 7 + local_index * 11 + self.rng.randint(0, 5)) % max_day)
return month.replace(day=day)
def _weighted_department(self, departments: list[DepartmentRef], index: int) -> DepartmentRef:
weighted: list[DepartmentRef] = []
by_code = {item.unit_code: item for item in departments}
for code, weight in DEPARTMENT_EMPLOYEE_WEIGHTS.items():
if code in by_code:
weighted.extend([by_code[code]] * weight)
weighted = weighted or departments or list(DEFAULT_DEPARTMENTS)
return weighted[index % len(weighted)]
def _expense_type_for_employee(self, employee: EmployeeRef) -> str:
weights = DEPARTMENT_CLAIM_WEIGHTS.get(
employee.department.unit_code,
{"travel": 3, "meal": 2, "office": 2, "communication": 1},
)
subjects = list(weights)
return self.rng.choices(subjects, weights=[weights[item] for item in subjects], k=1)[0]
def _claim_count_for_employee(self, employee: EmployeeRef, index: int) -> int:
base = 7 + (index % 5)
if employee.department.unit_code in {"MARKET-DEPT", "TECH-DEPT"}:
base += 3
elif employee.department.unit_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
base += 2
if employee.grade in {"P7", "P8"}:
base += 2
return max(6, min(base, 16))
def _claim_amount(
self,
employee: EmployeeRef,
expense_type: str,
occurred_day: date,
) -> Decimal:
subject = "meal" if expense_type == "entertainment" else expense_type
base = SUBJECT_BASE_AMOUNTS.get(subject, Decimal("1000.00"))
grade_factor = GRADE_FACTORS.get(employee.grade, Decimal("1.00"))
month_factor = MONTH_FACTORS.get(occurred_day.month, Decimal("1.00"))
department_factor = (
Decimal("1.18")
if employee.department.unit_code == "MARKET-DEPT"
else Decimal("1.00")
)
noise = Decimal(str(self.rng.uniform(0.72, 1.42))).quantize(Decimal("0.01"))
return (base * grade_factor * month_factor * department_factor * noise).quantize(
Decimal("0.01")
)
def _status_for_claim(self, employee_index: int, local_index: int) -> tuple[str, str | None]:
selector = (employee_index * 11 + local_index * 17 + self.config.seed) % 100
if selector < 42:
return "paid", "已付款"
if selector < 62:
return "approved", "归档入账"
if selector < 75:
return "pending_payment", "待付款"
if selector < 84:
return "submitted", "财务审批"
if selector < 92:
return "submitted", "直属领导审批"
if selector < 96:
return "returned", "待补充"
if selector < 99:
return "rejected", "已驳回"
return "draft", "待提交"
def _risk_flags(
self,
employee: EmployeeRef,
expense_type: str,
amount: Decimal,
claim_index: int,
) -> list[dict[str, Any]]:
base_probability = Decimal("0.10")
if amount >= SUBJECT_BASE_AMOUNTS.get(expense_type, Decimal("1000.00")) * Decimal("1.55"):
base_probability += Decimal("0.08")
if employee.department.unit_code in {"MARKET-DEPT", "PRESIDENT-OFFICE"}:
base_probability += Decimal("0.04")
if Decimal(str(self.rng.random())) > base_probability:
return []
event_type, label = risk_type(claim_index, expense_type)
severity = "high" if amount > Decimal("9000.00") or claim_index % 7 == 0 else "medium"
return [
{
"source": "half_year_expense_simulation",
"event_type": event_type,
"severity": severity,
"label": label,
"message": (
f"{employee.name}"
f"{SUBJECT_LABELS.get(expense_type, expense_type)}样本触发{label}"
),
"risk_score": 82 if severity == "high" else 68,
"created_at": datetime.now(UTC).isoformat(),
}
]
def _claim_items(
self,
expense_type: str,
amount: Decimal,
occurred_day: date,
claim_index: int,
) -> list[ClaimItemPlan]:
if expense_type == "travel":
hotel = (amount * Decimal("0.48")).quantize(Decimal("0.01"))
transport = (amount * Decimal("0.37")).quantize(Decimal("0.01"))
allowance = amount - hotel - transport
return [
self._item("hotel", "项目出差住宿", hotel, occurred_day, claim_index, 1),
self._item("transport", "项目往返交通", transport, occurred_day, claim_index, 2),
self._item("travel_allowance", "差旅补贴", allowance, occurred_day, claim_index, 3),
]
return [
self._item(
expense_type,
item_reason(expense_type),
amount,
occurred_day,
claim_index,
1,
)
]
def _item(
self,
item_type: str,
reason: str,
amount: Decimal,
item_date: date,
claim_index: int,
item_index: int,
) -> ClaimItemPlan:
return ClaimItemPlan(
item_date=item_date,
item_type=item_type,
item_reason=reason,
item_location=claim_location("上海", claim_index + item_index),
item_amount=amount.quantize(Decimal("0.01")),
invoice_id=f"SIM-INV-2026-{claim_index:04d}-{item_index}",
)

View File

@@ -31,7 +31,12 @@ BUDGET_MONITOR_APPROVAL_GRADE = "P8"
CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
ARCHIVED_REIMBURSEMENT_STAGES = (ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE, "completed")
ARCHIVED_REIMBURSEMENT_STAGES = (
ARCHIVE_ACCOUNTING_STAGE,
PAYMENT_PAID_STAGE,
"payment",
"completed",
)
class ExpenseClaimAccessPolicy:
@@ -640,9 +645,23 @@ class ExpenseClaimAccessPolicy:
include_approval_scope: bool = False,
) -> Any:
conditions = self.build_personal_claim_conditions(current_user)
role_codes = self.normalize_role_codes(current_user)
if self.has_privileged_claim_access(current_user):
company_reimbursement_condition = and_(
func.lower(func.coalesce(ExpenseClaim.status, "")) != "draft",
func.lower(func.coalesce(ExpenseClaim.expense_type, "")) != "application",
~func.lower(func.coalesce(ExpenseClaim.expense_type, "")).like(
"%\\_application",
escape="\\",
),
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("AP-%"),
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("APP-%"),
~self.build_archived_claim_condition(),
)
conditions.append(company_reimbursement_condition)
if include_approval_scope:
role_codes = self.normalize_role_codes(current_user)
if current_user.is_admin or "executive" in role_codes:
conditions.append(ExpenseClaim.status.in_(("submitted", PAYMENT_PENDING_STATUS, "returned")))
elif "finance" in role_codes:

View File

@@ -64,6 +64,12 @@ class ExpenseClaimApplicationHandoffMixin:
"application_amount": application_amount,
"application_time": application_time,
"application_transport_mode": str(detail.get("transport_mode") or "").strip(),
"application_lodging_daily_cap": str(detail.get("lodging_daily_cap") or "").strip(),
"application_subsidy_daily_cap": str(detail.get("subsidy_daily_cap") or "").strip(),
"application_transport_policy": str(detail.get("transport_policy") or "").strip(),
"application_policy_estimate": str(detail.get("policy_estimate") or "").strip(),
"application_rule_name": str(detail.get("rule_name") or "").strip(),
"application_rule_version": str(detail.get("rule_version") or "").strip(),
}
def _create_reimbursement_draft_from_application(

View File

@@ -327,7 +327,11 @@ class ExpenseClaimDraftFlowMixin:
)
self._sync_claim_from_items(claim)
elif skip_primary_item:
self._sync_application_link_draft_without_items(claim)
self._clear_application_link_placeholder_items(claim, context_json=context_json)
if claim.items:
self._sync_claim_from_items(claim)
else:
self._sync_application_link_draft_without_items(claim)
else:
self._upsert_primary_item(
claim=claim,
@@ -394,6 +398,61 @@ class ExpenseClaimDraftFlowMixin:
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
def _clear_application_link_placeholder_items(
self,
claim: ExpenseClaim,
*,
context_json: dict[str, Any],
) -> None:
application_amounts = self._resolve_application_amount_candidates(context_json)
for item in list(claim.items or []):
if not self._is_application_link_placeholder_item(
item,
claim=claim,
context_json=context_json,
application_amounts=application_amounts,
):
continue
claim.items.remove(item)
self.db.delete(item)
def _is_application_link_placeholder_item(
self,
item: ExpenseClaimItem,
*,
claim: ExpenseClaim,
context_json: dict[str, Any],
application_amounts: set[Decimal],
) -> bool:
if str(item.invoice_id or "").strip():
return False
item_type = str(item.item_type or "").strip().lower()
if item_type in DOCUMENT_FACT_ITEM_TYPES:
return False
if item_type in SYSTEM_GENERATED_ITEM_TYPES:
return True
claim_type = str(claim.expense_type or "").strip().lower()
if item_type and claim_type and item_type != claim_type:
return False
amount = self._parse_context_money_amount(item.item_amount)
if application_amounts and amount is not None and amount > Decimal("0.00") and amount not in application_amounts:
return False
reason = str(item.item_reason or "").strip()
if not reason or reason == "待补充":
return True
review_values = self._normalize_context_object(context_json.get("review_form_values"))
linked_reasons = {
str(review_values.get(key) or "").strip()
for key in ("application_reason", "reason", "business_reason")
}
linked_reasons.add(str(claim.reason or "").strip())
return reason in {value for value in linked_reasons if value}
def _should_skip_application_link_placeholder_item(
self,
*,
@@ -405,23 +464,10 @@ class ExpenseClaimDraftFlowMixin:
) -> bool:
if document_specs or attachment_count > 0:
return False
if claim is not None and list(claim.items or []):
return False
if self._build_application_link_flag(context_json) is None:
return False
application_amounts = self._resolve_application_amount_candidates(context_json)
review_values = self._normalize_context_object(context_json.get("review_form_values"))
raw_amount = str(review_values.get("amount") or "").strip()
if raw_amount:
parsed_amount = self._parse_context_money_amount(raw_amount)
if parsed_amount is None:
return True
return bool(application_amounts and parsed_amount in application_amounts)
if amount is None or amount <= Decimal("0.00"):
return True
return bool(application_amounts and amount in application_amounts)
return True
@classmethod
def _resolve_application_amount_candidates(cls, context_json: dict[str, Any]) -> set[Decimal]:
@@ -497,7 +543,26 @@ class ExpenseClaimDraftFlowMixin:
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
application_reason = pick("application_reason", "applicationReason", "reason")
application_location = pick("application_location", "applicationLocation", "location")
application_date = pick("application_date", "applicationDate", "business_time", "time_range")
application_time = pick(
"application_business_time",
"applicationBusinessTime",
"application_time",
"applicationTime",
"business_time",
"businessTime",
"time_range",
"timeRange",
"time",
)
application_date = pick("application_date", "applicationDate")
application_days = pick("application_days", "applicationDays", "days")
application_transport_mode = pick("application_transport_mode", "applicationTransportMode", "transport_mode", "transportMode")
application_lodging_daily_cap = pick("application_lodging_daily_cap", "applicationLodgingDailyCap", "lodging_daily_cap", "lodgingDailyCap")
application_subsidy_daily_cap = pick("application_subsidy_daily_cap", "applicationSubsidyDailyCap", "subsidy_daily_cap", "subsidyDailyCap")
application_transport_policy = pick("application_transport_policy", "applicationTransportPolicy", "transport_policy", "transportPolicy")
application_policy_estimate = pick("application_policy_estimate", "applicationPolicyEstimate", "policy_estimate", "policyEstimate")
application_rule_name = pick("application_rule_name", "applicationRuleName", "rule_name", "ruleName")
application_rule_version = pick("application_rule_version", "applicationRuleVersion", "rule_version", "ruleVersion")
application_status = pick("application_status", "applicationStatus")
application_status_label = pick("application_status_label", "applicationStatusLabel")
@@ -517,7 +582,17 @@ class ExpenseClaimDraftFlowMixin:
"application_location": application_location,
"application_amount": application_amount,
"application_amount_label": application_amount_label,
"application_time": application_date,
"application_time": application_time or application_date,
"application_business_time": application_time,
"application_date": application_date,
"application_days": application_days,
"application_transport_mode": application_transport_mode,
"application_lodging_daily_cap": application_lodging_daily_cap,
"application_subsidy_daily_cap": application_subsidy_daily_cap,
"application_transport_policy": application_transport_policy,
"application_policy_estimate": application_policy_estimate,
"application_rule_name": application_rule_name,
"application_rule_version": application_rule_version,
},
"review_form_values": review_values,
"expense_scene_selection": scene_selection,

View File

@@ -158,21 +158,139 @@ class ExpenseClaimItemSyncMixin:
end_date = start_date
days = (end_date - start_date).days + 1
application_days = self._resolve_travel_allowance_days_from_application_link(claim)
explicit_days = max(
(self._extract_travel_day_count(item.item_reason) for item in business_items),
default=0,
)
unique_dates = {value for value in dated_items}
if application_days is not None and application_days[0] > days and len(unique_dates) <= 1:
return application_days
if explicit_days > 0:
days = explicit_days
end_date = start_date + timedelta(days=days - 1)
if application_days is not None and application_days[0] > days and len(unique_dates) <= 1:
return application_days
return max(1, days), start_date, end_date
existing_days = self._extract_travel_allowance_days(existing_allowance)
unique_dates = {value for value in dated_items}
if existing_days > days and len(unique_dates) <= 1:
days = existing_days
end_date = start_date + timedelta(days=days - 1)
return max(1, days), start_date, end_date
def _resolve_travel_allowance_days_from_application_link(
self,
claim: ExpenseClaim,
) -> tuple[int, date, date] | None:
values = self._collect_application_link_values(claim)
if not values:
return None
time_text = str(
values.get("application_business_time")
or values.get("business_time")
or values.get("time_range")
or values.get("application_time")
or values.get("time")
or ""
).strip()
dates = self._extract_application_link_dates(time_text)
if len(dates) >= 2:
start_date, end_date = dates[0], dates[-1]
if end_date < start_date:
start_date, end_date = end_date, start_date
return max(1, (end_date - start_date).days + 1), start_date, end_date
days = self._extract_travel_day_count(
str(values.get("application_days") or values.get("days") or "").strip()
)
if days <= 0:
return None
start_date = dates[0] if dates else claim.occurred_at.date() if claim.occurred_at is not None else date.today()
end_date = start_date + timedelta(days=days - 1)
return days, start_date, end_date
def _collect_application_link_values(self, claim: ExpenseClaim) -> dict[str, Any]:
values: dict[str, Any] = {}
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("source") or "").strip() not in {"application_link", "application_handoff"}:
continue
for source in (
flag.get("expense_scene_selection"),
flag.get("review_form_values"),
flag.get("application_detail"),
flag,
):
if isinstance(source, dict):
values.update(source)
linked_detail = self._resolve_linked_application_detail_values(values)
for key, value in linked_detail.items():
values.setdefault(key, value)
return values
def _resolve_linked_application_detail_values(self, values: dict[str, Any]) -> dict[str, Any]:
application_claim = self._find_linked_application_claim(values)
if application_claim is None:
return {}
detail: dict[str, Any] = {}
for flag in list(application_claim.risk_flags_json or []):
if not isinstance(flag, dict) or str(flag.get("source") or "").strip() != "application_detail":
continue
payload = flag.get("application_detail") or flag.get("applicationDetail") or {}
if isinstance(payload, dict):
detail.update(payload)
if detail.get("time"):
detail.setdefault("application_time", detail.get("time"))
if detail.get("days"):
detail.setdefault("application_days", detail.get("days"))
if detail.get("transport_mode"):
detail.setdefault("application_transport_mode", detail.get("transport_mode"))
if detail.get("location"):
detail.setdefault("application_location", detail.get("location"))
if detail.get("reason"):
detail.setdefault("application_reason", detail.get("reason"))
if application_claim.occurred_at is not None:
detail.setdefault("application_time", application_claim.occurred_at.date().isoformat())
detail.setdefault("time", application_claim.occurred_at.date().isoformat())
detail.setdefault("application_reason", str(application_claim.reason or "").strip())
detail.setdefault("application_location", str(application_claim.location or "").strip())
return {str(key): value for key, value in detail.items() if str(value or "").strip()}
def _find_linked_application_claim(self, values: dict[str, Any]) -> ExpenseClaim | None:
application_claim_id = str(
values.get("application_claim_id")
or values.get("applicationClaimId")
or ""
).strip()
if application_claim_id:
linked_claim = self.db.get(ExpenseClaim, application_claim_id)
if linked_claim is not None:
return linked_claim
application_claim_no = str(
values.get("application_claim_no")
or values.get("applicationClaimNo")
or ""
).strip()
if not application_claim_no:
return None
return self.db.scalar(
select(ExpenseClaim).where(ExpenseClaim.claim_no == application_claim_no)
)
@staticmethod
def _extract_application_link_dates(value: str) -> list[date]:
dates: list[date] = []
for matched in re.findall(r"\d{4}-\d{2}-\d{2}", str(value or "")):
try:
dates.append(date.fromisoformat(matched))
except ValueError:
continue
return dates
@staticmethod
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
if item is None:

View File

@@ -314,7 +314,13 @@ 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"):
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
@@ -322,7 +328,9 @@ class ExpenseClaimOntologyResolverMixin:
parsed = date.fromisoformat(value)
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
except ValueError:
continue
parsed = ExpenseClaimOntologyResolverMixin._resolve_first_date_from_text(value)
if parsed is not None:
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
start_date = ontology.time_range.start_date
if start_date:
@@ -333,6 +341,21 @@ class ExpenseClaimOntologyResolverMixin:
pass
return None
@staticmethod
def _resolve_first_date_from_text(value: str) -> date | None:
match = re.search(r"20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}", str(value or ""))
if not match:
return None
normalized = match.group(0).replace("/", "-").replace(".", "-")
parts = [part for part in normalized.split("-") if part]
if len(parts) != 3:
return None
try:
year, month, day = (int(part) for part in parts)
return date(year, month, day)
except ValueError:
return None
@staticmethod
def _resolve_amount(
entities: list[OntologyEntity],

View File

@@ -0,0 +1,224 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
ARCHIVE_ACCOUNTING_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
PAYMENT_PAID_STAGE,
PAYMENT_PENDING_STAGE,
)
@dataclass(frozen=True, slots=True)
class ExpenseClaimStatusSpec:
code: int
value: str
label: str
terminal: bool = False
@dataclass(frozen=True, slots=True)
class ExpenseClaimState:
status: str
approval_stage: str
status_code: int | None
status_label: str
changed: bool
CLAIM_STATUS_REGISTRY: dict[str, ExpenseClaimStatusSpec] = {
"draft": ExpenseClaimStatusSpec(10, "draft", "草稿"),
"submitted": ExpenseClaimStatusSpec(20, "submitted", "审批中"),
"approved": ExpenseClaimStatusSpec(30, "approved", "已通过"),
"pending_payment": ExpenseClaimStatusSpec(40, "pending_payment", "待付款"),
"paid": ExpenseClaimStatusSpec(50, "paid", "已付款", terminal=True),
"returned": ExpenseClaimStatusSpec(60, "returned", "待补充"),
"rejected": ExpenseClaimStatusSpec(70, "rejected", "已驳回", terminal=True),
}
CLAIM_STATUS_ALIASES = {
"review": "submitted",
"pending_review": "submitted",
"approving": "submitted",
"manager_review": "submitted",
"budget_review": "submitted",
"finance_review": "submitted",
"completed": "approved",
"complete": "approved",
"payment": "pending_payment",
"supplement": "returned",
"草稿": "draft",
"待提交": "draft",
"已提交": "submitted",
"审批中": "submitted",
"审核中": "submitted",
"审批完成": "approved",
"已通过": "approved",
"归档入账": "approved",
"待付款": "pending_payment",
"已付款": "paid",
"待补充": "returned",
"已驳回": "rejected",
}
CANONICAL_APPROVAL_STAGES = {
"",
"待提交",
DIRECT_MANAGER_APPROVAL_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
APPROVAL_DONE_STAGE,
ARCHIVE_ACCOUNTING_STAGE,
PAYMENT_PENDING_STAGE,
PAYMENT_PAID_STAGE,
"待补充",
"已驳回",
}
STAGE_ALIASES = {
"draft": "待提交",
"review": DIRECT_MANAGER_APPROVAL_STAGE,
"pending_review": DIRECT_MANAGER_APPROVAL_STAGE,
"approving": DIRECT_MANAGER_APPROVAL_STAGE,
"manager_review": DIRECT_MANAGER_APPROVAL_STAGE,
"budget_review": BUDGET_MANAGER_APPROVAL_STAGE,
"finance_review": FINANCE_APPROVAL_STAGE,
"pending_payment": PAYMENT_PENDING_STAGE,
"supplement": "待补充",
"rejected": "已驳回",
"草稿": "待提交",
"审核中": DIRECT_MANAGER_APPROVAL_STAGE,
}
STATUS_DEFAULT_STAGE = {
"draft": "待提交",
"submitted": DIRECT_MANAGER_APPROVAL_STAGE,
"pending_payment": PAYMENT_PENDING_STAGE,
"paid": PAYMENT_PAID_STAGE,
"returned": "待补充",
"rejected": "已驳回",
}
LEGACY_REVIEW_STATUS_STAGE = {
"review": DIRECT_MANAGER_APPROVAL_STAGE,
"pending_review": DIRECT_MANAGER_APPROVAL_STAGE,
"approving": DIRECT_MANAGER_APPROVAL_STAGE,
"manager_review": DIRECT_MANAGER_APPROVAL_STAGE,
"budget_review": BUDGET_MANAGER_APPROVAL_STAGE,
"finance_review": FINANCE_APPROVAL_STAGE,
}
def normalize_claim_status(value: Any) -> str:
raw = str(value or "").strip()
if not raw:
return ""
lowered = raw.lower()
if lowered in CLAIM_STATUS_REGISTRY:
return lowered
return CLAIM_STATUS_ALIASES.get(lowered) or CLAIM_STATUS_ALIASES.get(raw) or raw
def claim_status_code(value: Any) -> int | None:
status = normalize_claim_status(value)
spec = CLAIM_STATUS_REGISTRY.get(status)
return spec.code if spec is not None else None
def claim_status_label(value: Any) -> str:
status = normalize_claim_status(value)
spec = CLAIM_STATUS_REGISTRY.get(status)
return spec.label if spec is not None else str(value or "").strip()
def is_known_claim_status(value: Any) -> bool:
return normalize_claim_status(value) in CLAIM_STATUS_REGISTRY
def is_known_approval_stage(value: Any) -> bool:
stage = str(value or "").strip()
normalized_stage = _normalize_stage_alias(stage)
return stage in CANONICAL_APPROVAL_STAGES or normalized_stage in CANONICAL_APPROVAL_STAGES
def is_application_claim_reference(
*,
claim_no: str | None = None,
expense_type: str | None = None,
) -> bool:
normalized_no = str(claim_no or "").strip().upper()
normalized_type = str(expense_type or "").strip().lower()
return (
normalized_no.startswith(("AP-", "APP-"))
or normalized_type == "application"
or normalized_type.endswith("_application")
)
def normalize_expense_claim_state(
status: Any,
approval_stage: Any,
*,
claim_no: str | None = None,
expense_type: str | None = None,
is_application_claim: bool | None = None,
) -> ExpenseClaimState:
original_status = str(status or "").strip()
original_stage = str(approval_stage or "").strip()
normalized_status = normalize_claim_status(original_status)
normalized_stage = _normalize_stage_alias(original_stage)
application = (
is_application_claim
if is_application_claim is not None
else is_application_claim_reference(claim_no=claim_no, expense_type=expense_type)
)
legacy_status = original_status.lower()
if legacy_status in LEGACY_REVIEW_STATUS_STAGE:
normalized_stage = LEGACY_REVIEW_STATUS_STAGE[legacy_status]
elif normalized_status == "approved":
normalized_stage = _approved_stage(original_stage, application)
elif normalized_status == "pending_payment":
normalized_stage = PAYMENT_PENDING_STAGE
elif normalized_status == "paid":
normalized_stage = PAYMENT_PAID_STAGE
elif normalized_status in STATUS_DEFAULT_STAGE and not normalized_stage:
normalized_stage = STATUS_DEFAULT_STAGE[normalized_status]
if normalized_status == "submitted" and normalized_stage in {"payment", "completed"}:
normalized_stage = DIRECT_MANAGER_APPROVAL_STAGE
spec = CLAIM_STATUS_REGISTRY.get(normalized_status)
return ExpenseClaimState(
status=normalized_status,
approval_stage=normalized_stage,
status_code=spec.code if spec is not None else None,
status_label=spec.label if spec is not None else normalized_status,
changed=normalized_status != original_status or normalized_stage != original_stage,
)
def _normalize_stage_alias(value: str) -> str:
if not value:
return ""
lowered = value.lower()
return STAGE_ALIASES.get(lowered) or STAGE_ALIASES.get(value) or value
def _approved_stage(raw_stage: str, is_application_claim: bool) -> str:
stage = _normalize_stage_alias(raw_stage)
lowered = str(raw_stage or "").strip().lower()
if is_application_claim:
if not stage or lowered == "completed":
return APPROVAL_DONE_STAGE
return stage
if stage in {ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE}:
return stage
if lowered in {"completed", "complete", ""} or stage == APPROVAL_DONE_STAGE:
return ARCHIVE_ACCOUNTING_STAGE
return stage

View File

@@ -12,9 +12,9 @@ from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.budget import BudgetAllocation
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.schemas.finance_dashboard import FinanceDashboardRead
from app.services.budget_support import BudgetSupportMixin
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
SLA_TARGET_HOURS = Decimal("8.0")
@@ -30,6 +30,17 @@ PENDING_STATUSES = {
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
EXPENSE_TYPE_ALIASES = {
"travel_application": "travel",
"business_travel": "travel",
"trip": "travel",
"traffic": "travel",
"transportation": "travel",
"hotel": "travel",
"accommodation": "travel",
"business_meal": "meal",
"communication_fee": "communication",
}
CHART_COLORS = [
"var(--theme-primary)",
"var(--chart-blue)",
@@ -55,6 +66,17 @@ RISK_SIGNAL_LABELS = {
"location_mismatch": "地点不一致",
"amount_outlier": "金额异常",
"preapproval_absent": "缺少事前申请",
"missing_material": "材料不完整",
"budget_pressure": "预算压力偏高",
"budget_overrun": "预算超支",
"budget_warning": "预算预警",
"over_budget": "预算超支",
"invoice_abnormal": "发票异常",
"invoice_missing": "缺少发票",
"missing_invoice": "缺少发票",
"policy_violation": "政策不符",
"abnormal_frequency": "频次异常",
"manual_review": "人工复核",
}
@@ -83,31 +105,34 @@ class FinanceDashboardService(BudgetSupportMixin):
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
department_start, department_end = self._resolve_department_scope(department_range, now)
claims = self._fetch_claims()
observations = self._fetch_risk_observations()
claims = [
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
]
scope_claims = self._claims_between(claims, start, end)
previous_claims = self._claims_between(claims, previous_start, start)
trend_claims = self._claims_between(claims, trend_start, trend_end)
department_claims = self._claims_between(claims, department_start, department_end)
scope_observations = self._observations_between(observations, start, end)
totals = self._totals(scope_claims, scope_observations, now)
previous_totals = self._totals(previous_claims, [], now)
totals = self._totals(scope_claims)
previous_totals = self._totals(previous_claims)
return FinanceDashboardRead(
range_key=resolved_key,
start_date=start.date().isoformat(),
end_date=(end - timedelta(days=1)).date().isoformat(),
generated_at=now.isoformat(),
has_real_data=bool(claims or observations or self._fetch_budget_allocations(now.year)),
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
totals=totals,
metric_meta=self._metric_meta(totals, previous_totals),
trend=self._trend(trend_labels, trend_claims, now),
spend_by_category=self._spend_by_category(scope_claims),
exception_mix=self._exception_mix(scope_claims, scope_observations),
exception_mix=self._payment_status_mix(scope_claims),
department_ranking=self._department_ranking(department_claims),
bottlenecks=self._bottlenecks(scope_claims, now),
employee_ranking=self._employee_ranking(department_claims),
top_claims=self._top_claims(department_claims),
bottlenecks=self._bottlenecks(scope_claims),
budget_summary=self._budget_summary(now.year),
budget_metrics=self._budget_metrics(now.year),
)
def _ensure_storage_ready(self) -> None:
@@ -117,10 +142,6 @@ class FinanceDashboardService(BudgetSupportMixin):
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
return list(self.db.scalars(stmt).all())
def _fetch_risk_observations(self) -> list[RiskObservation]:
stmt = select(RiskObservation).order_by(RiskObservation.created_at.asc())
return list(self.db.scalars(stmt).all())
def _fetch_budget_allocations(self, fiscal_year: int) -> list[BudgetAllocation]:
stmt = (
select(BudgetAllocation)
@@ -192,50 +213,49 @@ class FinanceDashboardService(BudgetSupportMixin):
) -> list[ExpenseClaim]:
return [claim for claim in claims if start <= self._claim_time(claim) < end]
def _observations_between(
self,
observations: list[RiskObservation],
start: datetime,
end: datetime,
) -> list[RiskObservation]:
return [item for item in observations if start <= self._as_utc(item.created_at) < end]
def _totals(
self,
claims: list[ExpenseClaim],
observations: list[RiskObservation],
now: datetime,
) -> dict[str, Any]:
active_claims = [claim for claim in claims if self._status(claim) not in {"draft", "deleted"}]
pending_claims = [claim for claim in active_claims if self._status(claim) in PENDING_STATUSES]
success_claims = [claim for claim in active_claims if self._status(claim) in SUCCESS_STATUSES]
risk_claim_keys = {self._claim_key(claim) for claim in active_claims if self._has_claim_risk(claim)}
observation_keys = {
str(item.claim_no or item.subject_key or item.id).strip()
for item in observations
if str(item.status or "").strip().lower() != "false_positive"
}
sla_hours = [self._claim_sla_hours(claim, now) for claim in active_claims if claim.submitted_at]
sla_met = sum(1 for hours in sla_hours if hours <= SLA_TARGET_HOURS)
clean_success = sum(1 for claim in success_claims if not self._has_claim_risk(claim))
active_claims = [
claim for claim in claims if self._status(claim) not in {"draft", "deleted"}
]
spend_claims = [
claim for claim in active_claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
]
pending_payment_claims = [
claim for claim in spend_claims if self._status(claim) == "pending_payment"
]
paid_claims = [claim for claim in spend_claims if self._status(claim) == "paid"]
total_amount = sum((self._claim_amount(claim) for claim in spend_claims), Decimal("0.00"))
pending_payment_amount = sum(
(self._claim_amount(claim) for claim in pending_payment_claims),
Decimal("0.00"),
)
budget_summary = self._budget_summary(datetime.now(UTC).year)
avg_amount = (
total_amount / Decimal(str(len(spend_claims)))
if spend_claims
else Decimal("0.00")
)
return {
"pendingCount": len(pending_claims),
"pendingAmount": self._decimal_number(sum((self._claim_amount(claim) for claim in pending_claims), Decimal("0.00"))),
"avgSla": self._decimal_number(self._average(sla_hours)),
"autoPassRate": self._percent(clean_success, len(active_claims)),
"riskCount": len({key for key in risk_claim_keys | observation_keys if key}),
"slaRate": self._percent(sla_met, len(sla_hours)),
"reimbursementAmount": self._decimal_number(total_amount),
"reimbursementCount": len(spend_claims),
"pendingPaymentAmount": self._decimal_number(pending_payment_amount),
"avgClaimAmount": self._decimal_number(avg_amount),
"budgetUsageRate": float(budget_summary.get("ratio") or 0),
"paymentClearanceRate": self._percent(len(paid_claims), len(spend_claims)),
}
def _metric_meta(self, current: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
unit_by_key = {
"pendingCount": "",
"pendingAmount": "",
"avgSla": "h",
"autoPassRate": "%",
"riskCount": "",
"slaRate": "%",
"reimbursementAmount": "",
"reimbursementCount": "",
"pendingPaymentAmount": "",
"avgClaimAmount": "",
"budgetUsageRate": "%",
"paymentClearanceRate": "%",
}
meta: dict[str, Any] = {}
for key, current_value in current.items():
@@ -257,28 +277,34 @@ class FinanceDashboardService(BudgetSupportMixin):
claims: list[ExpenseClaim],
now: datetime,
) -> dict[str, Any]:
applications = [0 for _ in labels]
approved = [0 for _ in labels]
claim_count = [0 for _ in labels]
claim_amount = [Decimal("0.00") for _ in labels]
success_count = [0 for _ in labels]
hours: list[list[Decimal]] = [[] for _ in labels]
index = {label: idx for idx, label in enumerate(labels)}
for claim in claims:
if self._status(claim) == "draft":
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
label = self._date_label(self._claim_time(claim).date())
if label not in index:
continue
bucket = index[label]
applications[bucket] += 1
claim_count[bucket] += 1
claim_amount[bucket] += self._claim_amount(claim)
if self._status(claim) in SUCCESS_STATUSES:
approved[bucket] += 1
success_count[bucket] += 1
if claim.submitted_at:
hours[bucket].append(self._claim_sla_hours(claim, now))
return {
"labels": labels,
"applications": applications,
"approved": approved,
"claimCount": claim_count,
"claimAmount": [self._decimal_number(value) for value in claim_amount],
"successCount": success_count,
# 兼容旧前端字段;新财务看板不再使用审批趋势语义。
"applications": claim_count,
"approved": success_count,
"avgHours": [self._decimal_number(self._average(row)) for row in hours],
}
@@ -287,79 +313,178 @@ class FinanceDashboardService(BudgetSupportMixin):
for claim in claims:
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
label = EXPENSE_TYPE_LABELS.get(str(claim.expense_type or "").strip(), claim.expense_type)
buckets[str(label or "其他费用")] += self._claim_amount(claim)
buckets[self._expense_type_label(claim.expense_type)] += self._claim_amount(claim)
rows = [
{"name": name, "value": self._decimal_number(value), "color": CHART_COLORS[index % len(CHART_COLORS)]}
for index, (name, value) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
{
"name": name,
"value": self._decimal_number(value),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
for index, (name, value) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
]
return rows or EMPTY_DONUT
def _exception_mix(
self,
claims: list[ExpenseClaim],
observations: list[RiskObservation],
) -> list[dict[str, Any]]:
def _payment_status_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, int] = defaultdict(int)
for observation in observations:
key = str(observation.risk_signal or observation.risk_type or "").strip()
buckets[RISK_SIGNAL_LABELS.get(key, key.replace("_", " ") or "风险观察")] += 1
if not buckets:
for claim in claims:
if self._status(claim) in {"draft", "deleted"}:
continue
for label in self._claim_risk_labels(claim):
buckets[label] += 1
for claim in claims:
status = self._status(claim)
if status in {"draft", "deleted"}:
continue
buckets[self._finance_status_label(status)] += 1
rows = [
{"name": name, "value": count, "color": CHART_COLORS[index % len(CHART_COLORS)]}
for index, (name, count) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
for index, (name, count) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
]
return rows or EMPTY_DONUT
def _department_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
for claim in claims:
if self._status(claim) not in PENDING_STATUSES:
status = self._status(claim)
if status in EXCLUDED_SPEND_STATUSES:
continue
buckets[str(claim.department_name or "未归属部门")] += self._claim_amount(claim)
department_name = str(claim.department_name or "").strip()
if self._is_missing_finance_dimension(department_name):
continue
amount = self._claim_amount(claim)
buckets[department_name] += amount
counts[department_name] += 1
if status in PENDING_STATUSES:
pending_amounts[department_name] += amount
rows = [
{
"name": name,
"amount": self._decimal_number(amount),
"value": self._decimal_number(amount),
"count": counts[name],
"pendingAmount": self._decimal_number(pending_amounts[name]),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
for index, (name, amount) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:5])
for index, (name, amount) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
]
return rows
def _bottlenecks(self, claims: list[ExpenseClaim], now: datetime) -> list[dict[str, Any]]:
buckets: dict[str, list[Decimal]] = defaultdict(list)
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
buckets: dict[str, Decimal] = defaultdict(Decimal)
counts: dict[str, int] = defaultdict(int)
departments: dict[str, str] = {}
for claim in claims:
if self._status(claim) not in PENDING_STATUSES:
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
continue
stage = self._stage_label(claim)
buckets[stage].append(self._claim_sla_hours(claim, now))
employee_name = str(claim.employee_name or "").strip()
if self._is_missing_finance_dimension(employee_name):
continue
amount = self._claim_amount(claim)
buckets[employee_name] += amount
counts[employee_name] += 1
departments.setdefault(employee_name, str(claim.department_name or "").strip())
rows: list[dict[str, Any]] = []
for index, (stage, values) in enumerate(sorted(buckets.items(), key=lambda item: self._average(item[1]), reverse=True)[:3]):
avg_hours = self._average(values)
rows.append(
{
"name": stage,
"role": "审批节点",
"duration": f"{self._decimal_number(avg_hours):.1f} h",
"status": self._duration_status(avg_hours),
"tone": self._duration_tone(avg_hours),
"avatar": stage[:1] or str(index + 1),
}
return [
{
"name": name,
"department": departments.get(name, ""),
"amount": self._decimal_number(amount),
"value": self._decimal_number(amount),
"count": counts[name],
"avgAmount": self._decimal_number(amount / Decimal(str(counts[name]))),
"color": CHART_COLORS[index % len(CHART_COLORS)],
}
for index, (name, amount) in enumerate(
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
)
return rows
]
def _top_claims(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
spend_claims = [
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
]
return [
{
"claimNo": claim.claim_no,
"employeeName": claim.employee_name,
"departmentName": self._display_finance_dimension(
claim.department_name,
fallback="未归属部门",
),
"expenseTypeLabel": self._expense_type_label(claim.expense_type),
"amount": self._decimal_number(self._claim_amount(claim)),
"amountLabel": self._currency(self._claim_amount(claim)),
"statusLabel": self._finance_status_label(self._status(claim)),
}
for claim in sorted(spend_claims, key=self._claim_amount, reverse=True)[:6]
]
def _bottlenecks(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
active_claims = [
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
]
pending_payment_claims = [
claim for claim in active_claims if self._status(claim) == "pending_payment"
]
paid_claims = [claim for claim in active_claims if self._status(claim) == "paid"]
submitted_claims = [
claim for claim in active_claims if self._status(claim) in PENDING_STATUSES
]
budget_rows = self._budget_focus_rows()
pending_payment_amount = sum(
(self._claim_amount(claim) for claim in pending_payment_claims),
Decimal("0.00"),
)
high_claim = max(
(self._claim_amount(claim) for claim in active_claims),
default=Decimal("0.00"),
)
payment_clearance = self._percent(len(paid_claims), len(active_claims))
rows = [
*budget_rows,
self._focus_item(
name="待付款",
role="资金计划",
duration=self._currency(pending_payment_amount),
status=f"{len(pending_payment_claims)}",
tone="warning" if pending_payment_claims else "success",
avatar="",
),
self._focus_item(
name="高额单据",
role="费用集中度",
duration=self._currency(high_claim),
status="本期最高",
tone="warning" if high_claim >= Decimal("10000") else "success",
avatar="",
),
self._focus_item(
name="待入账",
role="月结准备",
duration=f"{len(submitted_claims)}",
status="待流转" if submitted_claims else "已清理",
tone="warning" if submitted_claims else "success",
avatar="",
),
self._focus_item(
name="付款完成率",
role="付款执行",
duration=f"{payment_clearance:.1f}%",
status=f"{len(paid_claims)} 单已付",
tone="success" if payment_clearance >= 80 else "warning",
avatar="",
),
]
priority = {"danger": 0, "warning": 1, "success": 2}
return sorted(rows, key=lambda item: priority.get(str(item.get("tone")), 3))[:6]
def _budget_summary(self, fiscal_year: int) -> dict[str, Any]:
allocations = self._fetch_budget_allocations(fiscal_year)
@@ -384,6 +509,149 @@ class FinanceDashboardService(BudgetSupportMixin):
"left": self._currency(available),
}
def _budget_metrics(self, fiscal_year: int) -> list[dict[str, Any]]:
allocations = self._fetch_budget_allocations(fiscal_year)
total = Decimal("0.00")
consumed = Decimal("0.00")
reserved = Decimal("0.00")
available = Decimal("0.00")
over_count = 0
warning_count = 0
for allocation in allocations:
balance = self.get_balance(allocation)
total += balance.total_amount
consumed += balance.consumed_amount
reserved += balance.reserved_amount
available += balance.available_amount
if balance.available_amount < Decimal("0.00"):
over_count += 1
continue
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
warning_count += 1
used = consumed + reserved
usage_rate = Decimal("0.00")
if total > Decimal("0.00"):
usage_rate = (used / total) * Decimal("100")
return [
self._budget_metric(
label="预算池数量",
value=f"{len(allocations)}",
detail="年度有效预算池",
tone="neutral",
icon="mdi mdi-database-outline",
),
self._budget_metric(
label="总预算",
value=self._currency(total),
detail="原始预算 + 调整",
tone="neutral",
icon="mdi mdi-cash-register",
),
self._budget_metric(
label="已用预算",
value=self._currency(used),
detail=f"使用率 {self._decimal_number(usage_rate):.1f}%",
tone="warning" if usage_rate >= Decimal("80") else "success",
icon="mdi mdi-chart-arc",
),
self._budget_metric(
label="预占预算",
value=self._currency(reserved),
detail="待流转单据占用",
tone="warning" if reserved > Decimal("0.00") else "success",
icon="mdi mdi-lock-outline",
),
self._budget_metric(
label="可用预算",
value=self._currency(available),
detail="可继续使用额度",
tone="danger" if available < Decimal("0.00") else "success",
icon="mdi mdi-wallet-outline",
),
self._budget_metric(
label="预警预算池",
value=f"{warning_count}",
detail=f"超支 {over_count}",
tone="danger" if over_count else "warning" if warning_count else "success",
icon="mdi mdi-alert-outline",
),
]
def _budget_metric(
self,
*,
label: str,
value: str,
detail: str,
tone: str,
icon: str,
) -> dict[str, Any]:
return {
"label": label,
"value": value,
"detail": detail,
"tone": tone,
"icon": icon,
}
def _budget_focus_rows(self) -> list[dict[str, Any]]:
allocations = self._fetch_budget_allocations(datetime.now(UTC).year)
over_count = 0
warning_count = 0
over_amount = Decimal("0.00")
warning_used = Decimal("0.00")
for allocation in allocations:
balance = self.get_balance(allocation)
if balance.available_amount < Decimal("0.00"):
over_count += 1
over_amount += abs(balance.available_amount)
continue
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
warning_count += 1
warning_used += balance.reserved_amount + balance.consumed_amount
return [
self._focus_item(
name="预算超支",
role="预算控制",
duration=f"{over_count} 个池",
status=self._currency(over_amount),
tone="danger" if over_count else "success",
avatar="",
),
self._focus_item(
name="预算预警",
role="预算控制",
duration=f"{warning_count} 个池",
status=self._currency(warning_used),
tone="warning" if warning_count else "success",
avatar="",
),
]
def _focus_item(
self,
*,
name: str,
role: str,
duration: str,
status: str,
tone: str,
avatar: str,
) -> dict[str, Any]:
return {
"name": name,
"role": role,
"duration": duration,
"status": status,
"tone": tone,
"avatar": avatar,
}
def _claim_time(self, claim: ExpenseClaim) -> datetime:
return self._as_utc(claim.submitted_at or claim.occurred_at or claim.created_at)
@@ -410,10 +678,14 @@ class FinanceDashboardService(BudgetSupportMixin):
labels.append("风险扫描命中")
for flag in self._risk_flags(claim):
if isinstance(flag, dict):
label = str(flag.get("label") or flag.get("message") or flag.get("type") or "").strip()
label = str(flag.get("label") or flag.get("message") or "").strip()
if not label:
label = self._risk_signal_label(
flag.get("type") or flag.get("risk_signal") or ""
)
else:
label = str(flag or "").strip()
labels.append(label or "规则异常")
label = self._risk_signal_label(flag)
labels.append(self._display_risk_label(label))
return labels
def _risk_flags(self, claim: ExpenseClaim) -> list[Any]:
@@ -424,6 +696,70 @@ class FinanceDashboardService(BudgetSupportMixin):
stage = str(claim.approval_stage or self._status(claim) or "").strip().lower()
return STAGE_LABELS.get(stage, stage.replace("_", " ").strip() or "待审批")
def _finance_status_label(self, status: str) -> str:
labels = {
"submitted": "审批中",
"review": "审批中",
"pending_review": "审批中",
"manager_review": "审批中",
"budget_review": "审批中",
"finance_review": "审批中",
"approving": "审批中",
"approved": "已入账",
"pending_payment": "待付款",
"paid": "已付款",
"returned": "待补充",
"rejected": "已驳回",
}
return labels.get(str(status or "").strip().lower(), "其他")
def _expense_type_label(self, value: str | None) -> str:
raw = str(value or "").strip()
normalized = raw.lower().replace(" ", "_").replace("-", "_")
normalized = EXPENSE_TYPE_ALIASES.get(normalized, normalized)
if normalized.endswith("_application"):
normalized = normalized.removesuffix("_application")
return EXPENSE_TYPE_LABELS.get(normalized, "其他费用")
def _is_missing_finance_dimension(self, value: str | None) -> bool:
normalized = str(value or "").strip()
return not normalized or normalized in {
"待补充",
"待确认",
"未归属部门",
"未归属",
"N/A",
"n/a",
"-",
}
def _display_finance_dimension(self, value: str | None, *, fallback: str) -> str:
text = str(value or "").strip()
return fallback if self._is_missing_finance_dimension(text) else text
def _risk_signal_label(self, value: Any) -> str:
normalized = str(value or "").strip()
if not normalized:
return "风险观察"
key = normalized.lower().replace(" ", "_").replace("-", "_")
if key in RISK_SIGNAL_LABELS:
return RISK_SIGNAL_LABELS[key]
return self._display_risk_label(normalized)
def _display_risk_label(self, value: Any) -> str:
text = str(value or "").strip()
if not text:
return "风险观察"
key = text.lower().replace(" ", "_").replace("-", "_")
if key in RISK_SIGNAL_LABELS:
return RISK_SIGNAL_LABELS[key]
if self._contains_cjk(text):
return text
return "风险观察"
def _contains_cjk(self, value: str) -> bool:
return any("\u4e00" <= char <= "\u9fff" for char in value)
def _status(self, claim: ExpenseClaim) -> str:
return str(claim.status or "").strip().lower()

View File

@@ -14,8 +14,10 @@ from app.schemas.user_agent import (
UserAgentResponse,
UserAgentSuggestedAction,
)
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.document_numbering import (
build_document_number,
generate_unique_expense_claim_no,
@@ -25,6 +27,11 @@ from app.services.user_agent_application_dates import (
resolve_application_days_from_time_range,
)
from app.services.user_agent_application_locations import normalize_application_location
from app.services.user_agent_application_summary import (
build_application_summary,
build_application_summary_table,
resolve_application_time_label,
)
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
APPLICATION_CONTEXT_VALUES = {
@@ -35,7 +42,7 @@ APPLICATION_CONTEXT_VALUES = {
"preapproval",
}
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
APPLICATION_TIME_LABELS = ("行程时间", "出发时间", "返回时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
APPLICATION_FIELD_LABELS = (
"申请类型",
"费用类型",
@@ -202,7 +209,7 @@ class UserAgentApplicationMixin:
facts: dict[str, str],
step: str,
) -> str:
recognized_table = self._build_application_summary_table(facts, include_empty=False)
recognized_table = build_application_summary_table(facts, include_empty=False)
if step == "ask_missing":
missing_fields = self._resolve_application_missing_fields(facts)
@@ -234,7 +241,7 @@ class UserAgentApplicationMixin:
if step == "duplicate":
application_no = str(facts.get("application_no") or "").strip()
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
time_label = self._resolve_application_time_label(facts)
time_label = resolve_application_time_label(facts)
return "\n\n".join(
[
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
@@ -247,7 +254,7 @@ class UserAgentApplicationMixin:
return "\n\n".join(
[
"这是费用申请核对结果,请核对:",
self._build_application_summary_table(facts),
build_application_summary_table(facts),
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
]
)
@@ -375,9 +382,71 @@ class UserAgentApplicationMixin:
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
if range_days:
facts["days"] = f"{range_days}"
self._apply_rule_center_travel_policy_to_application_facts(payload, facts)
apply_application_system_estimate_to_facts(facts)
return facts
def _apply_rule_center_travel_policy_to_application_facts(
self,
payload: UserAgentRequest,
facts: dict[str, str],
) -> None:
if "差旅" not in str(facts.get("application_type") or "") and "出差" not in str(facts.get("application_type") or ""):
return
location = str(facts.get("location") or "").strip()
grade = str(facts.get("grade") or "").strip()
if not location or not grade:
return
days = self._parse_application_days_count(facts.get("days", "")) or 1
try:
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
self._build_application_current_user(payload),
)
except ValueError:
return
hotel_rate = self._format_application_policy_money(result.hotel_rate)
hotel_amount = self._format_application_policy_money(result.hotel_amount)
allowance_rate = self._format_application_policy_money(result.total_allowance_rate)
allowance_amount = self._format_application_policy_money(result.allowance_amount)
if hotel_rate:
facts["lodging_daily_cap"] = f"{hotel_rate}元/天"
if hotel_amount:
facts["hotel_amount"] = f"{hotel_amount}"
if allowance_rate:
facts["subsidy_daily_cap"] = f"{allowance_rate}元/天"
if allowance_amount:
facts["allowance_amount"] = f"{allowance_amount}"
if str(result.matched_city or "").strip():
facts["matched_city"] = str(result.matched_city).strip()
if str(result.rule_name or "").strip():
facts["rule_name"] = str(result.rule_name).strip()
if str(result.rule_version or "").strip():
facts["rule_version"] = str(result.rule_version).strip()
@staticmethod
def _format_application_policy_money(value: object) -> str:
try:
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return ""
if amount == amount.to_integral():
return f"{int(amount):,}"
return f"{amount:,.2f}".rstrip("0").rstrip(".")
@staticmethod
def _parse_application_days_count(value: object) -> int:
match = re.search(r"\d+", str(value or ""))
if not match:
return 0
try:
return max(0, int(match.group(0)))
except ValueError:
return 0
@staticmethod
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
preview = context_json.get("application_preview")
@@ -496,6 +565,17 @@ class UserAgentApplicationMixin:
@staticmethod
def _resolve_application_time_from_text(message: str) -> str:
departure_time = UserAgentApplicationMixin._resolve_application_labeled_value(
message,
("出发时间", "出发日期"),
)
return_time = UserAgentApplicationMixin._resolve_application_labeled_value(
message,
("返回时间", "返回日期"),
)
if departure_time and return_time:
return departure_time if departure_time == return_time else f"{departure_time}{return_time}"
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
message,
APPLICATION_TIME_LABELS,
@@ -543,6 +623,13 @@ class UserAgentApplicationMixin:
@staticmethod
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
label_pattern = "|".join(re.escape(label) for label in labels)
table_match = re.search(
rf"\|\s*(?:{label_pattern})\s*\|\s*(?P<value>[^|\n]+?)\s*\|",
str(message or ""),
)
if table_match:
return table_match.group("value").strip()
next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
match = re.search(
rf"(?:{label_pattern})[:]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[:]|[\n;]|$)",
@@ -644,7 +731,7 @@ class UserAgentApplicationMixin:
return ""
text = re.sub(
r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[:]\s*",
r"^(?:行程时间|出发时间|返回时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[:]\s*",
"",
text,
)
@@ -843,73 +930,6 @@ class UserAgentApplicationMixin:
return "会务费用申请"
return "差旅费用申请"
@staticmethod
def _resolve_application_time_label(facts: dict[str, str]) -> str:
application_type = str(facts.get("application_type") or "").strip()
if "差旅" in application_type or "出差" in application_type:
return "行程时间"
if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type:
return "招待时间"
return "申请时间"
@classmethod
def _build_application_summary(cls, facts: dict[str, str]) -> str:
time_label = cls._resolve_application_time_label(facts)
return "\n".join(
f"{label}{value or '待补充'}"
for label, value in (
("申请类型", facts.get("application_type", "")),
("姓名", facts.get("applicant", "")),
("部门", facts.get("department", "")),
("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")),
(time_label, facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("系统预估费用", facts.get("amount", "")),
)
)
@classmethod
def _build_application_summary_table(
cls,
facts: dict[str, str],
*,
include_empty: bool = True,
) -> str:
time_label = cls._resolve_application_time_label(facts)
rows = [
("申请类型", facts.get("application_type", "")),
("姓名", facts.get("applicant", "")),
("部门", facts.get("department", "")),
("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")),
(time_label, facts.get("time", "")),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("系统预估费用", facts.get("amount", "")),
]
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
if not visible_rows:
visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")]
lines = ["| 字段 | 内容 |", "| --- | --- |"]
lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows)
return "\n".join(lines)
def _create_expense_application_record(
self,
payload: UserAgentRequest,
@@ -1204,7 +1224,7 @@ class UserAgentApplicationMixin:
return UserAgentDraftPayload(
draft_type="expense_application",
title=str(facts.get("application_type") or "费用申请").strip() or "费用申请",
body=self._build_application_summary(facts),
body=build_application_summary(facts),
confirmation_required=False,
claim_id=claim.id,
claim_no=claim.claim_no,

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta
def resolve_application_time_label(facts: dict[str, str]) -> str:
application_type = str(facts.get("application_type") or "").strip()
if "差旅" in application_type or "出差" in application_type:
return "出发时间"
if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type:
return "招待时间"
return "申请时间"
def _is_travel_application(facts: dict[str, str]) -> bool:
application_type = str(facts.get("application_type") or "").strip()
return "差旅" in application_type or "出差" in application_type
def _extract_application_day_count(value: str) -> int:
match = re.search(r"(\d{1,2})\s*天", str(value or ""))
if not match:
return 0
try:
return max(0, int(match.group(1)))
except ValueError:
return 0
def _add_application_days(start_date: str, days: int) -> str:
if not start_date or days <= 1:
return start_date
try:
value = datetime.fromisoformat(start_date)
except ValueError:
return start_date
return (value + timedelta(days=days - 1)).date().isoformat()
def _resolve_application_trip_dates(facts: dict[str, str]) -> tuple[str, str]:
time_text = str(facts.get("time") or "").strip()
matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text)
start_date = matched_dates[0] if matched_dates else time_text
end_date = matched_dates[-1] if len(matched_dates) >= 2 else ""
if not end_date or end_date == start_date:
end_date = _add_application_days(start_date, _extract_application_day_count(facts.get("days", "")))
return start_date, end_date or start_date
def build_application_time_rows(facts: dict[str, str]) -> list[tuple[str, str]]:
if _is_travel_application(facts):
start_date, end_date = _resolve_application_trip_dates(facts)
return [
("出发时间", start_date),
("返回时间", end_date),
]
return [(resolve_application_time_label(facts), facts.get("time", ""))]
def build_application_summary_rows(facts: dict[str, str]) -> list[tuple[str, str]]:
return [
("申请类型", facts.get("application_type", "")),
("姓名", facts.get("applicant", "")),
("部门", facts.get("department", "")),
("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")),
*build_application_time_rows(facts),
("地点", facts.get("location", "")),
("事由", facts.get("reason", "")),
("天数", facts.get("days", "")),
("出行方式", facts.get("transport_mode", "")),
("住宿上限/天", facts.get("lodging_daily_cap", "")),
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
("交通费用口径", facts.get("transport_policy", "")),
("规则测算参考", facts.get("policy_estimate", "")),
("系统预估费用", facts.get("amount", "")),
]
def build_application_summary(facts: dict[str, str]) -> str:
return "\n".join(
f"{label}{value or '待补充'}"
for label, value in build_application_summary_rows(facts)
)
def build_application_summary_table(
facts: dict[str, str],
*,
include_empty: bool = True,
) -> str:
rows = build_application_summary_rows(facts)
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
if not visible_rows:
visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")]
lines = ["| 字段 | 内容 |", "| --- | --- |"]
lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows)
return "\n".join(lines)

View File

@@ -190,6 +190,11 @@ class UserAgentReviewSlotMixin:
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