Files
X-Financial/server/src/app/services/demo_company_simulation_seed.py
caoxiaozhu 15006a05a7 feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
2026-06-03 09:25:23 +08:00

806 lines
32 KiB
Python

from __future__ import annotations
import random
import uuid
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import 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_ID_NAMESPACE,
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,
build_simulation_reimbursement_no,
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,
next_simulation_number,
simulation_claim_count,
simulation_claim_day,
simulation_month_starts,
simulation_period_end,
)
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=simulation_period_end(self.config).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 = simulation_month_starts(self.config)
period_end = simulation_period_end(self.config)
claim_index = 1
for employee_index, employee in enumerate(employees):
count = simulation_claim_count(employee, employee_index)
for local_index in range(count):
occurred_day = simulation_claim_day(
self.rng,
months,
employee_index=employee_index,
local_index=local_index,
claim_index=claim_index,
period_end=period_end,
)
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_ID_NAMESPACE}:{claim_index}",
)
),
claim_no=self._simulation_claim_no(occurred_at, claim_index),
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
used_budget_nos = set(
self.db.scalars(
select(BudgetAllocation.budget_no).where(
BudgetAllocation.budget_no.like(f"{SIM_BUDGET_PREFIX}%")
)
).all()
)
budget_no_cursor = 1
for plan in plans:
existing = self._find_sim_allocation(plan)
if existing is not None:
allocation_map[plan.key] = existing.id
continue
created_count += 1
budget_no, budget_no_cursor = next_simulation_number(
SIM_BUDGET_PREFIX,
used_budget_nos,
budget_no_cursor,
)
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=budget_no,
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_rows = list(
self.db.execute(
select(ExpenseClaim.id, ExpenseClaim.claim_no).where(
ExpenseClaim.project_code == SIM_PROJECT_CODE
)
).all()
)
existing_claim_ids = {str(row.id) for row in existing_rows}
existing_claim_nos = set(self.db.scalars(select(ExpenseClaim.claim_no)).all())
claim_count = 0
item_count = 0
for plan in plans:
if plan.id in existing_claim_ids or 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 _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_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")
)
@staticmethod
def _simulation_claim_no(occurred_at: datetime, claim_index: int) -> str:
return build_simulation_reimbursement_no(occurred_at, claim_index)
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}",
)