feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选 - 引入 expense_claim_status_registry 统一报销状态流转 - 完善报销草稿流程、Item Sync 与本体解析器 - 优化总览页趋势图、分页组件与请求进度步骤 - 增强报销申请快速预览、本体工具与详情展示 - 新增半年报销模拟数据种子脚本与状态审计工具 - 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
274
server/src/app/services/demo_company_simulation_catalog.py
Normal file
274
server/src/app/services/demo_company_simulation_catalog.py
Normal 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)
|
||||
79
server/src/app/services/demo_company_simulation_filters.py
Normal file
79
server/src/app/services/demo_company_simulation_filters.py
Normal 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))
|
||||
821
server/src/app/services/demo_company_simulation_seed.py
Normal file
821
server/src/app/services/demo_company_simulation_seed.py
Normal 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}",
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
|
||||
224
server/src/app/services/expense_claim_status_registry.py
Normal file
224
server/src/app/services/expense_claim_status_registry.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
100
server/src/app/services/user_agent_application_summary.py
Normal file
100
server/src/app/services/user_agent_application_summary.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user