- 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖
374 lines
14 KiB
Python
374 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, date, datetime, time, timedelta
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import func, select, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
|
from app.models.financial_record import ExpenseClaim
|
|
from app.models.risk_observation import RiskObservation
|
|
from app.services.demo_company_simulation_catalog import (
|
|
BUDGETED_STATUSES,
|
|
SIM_BUDGET_PREFIX,
|
|
SIM_PROJECT_CODE,
|
|
SIM_RESERVATION_PREFIX,
|
|
SIM_RISK_PREFIX,
|
|
SIM_TRANSACTION_PREFIX,
|
|
build_simulation_reimbursement_no,
|
|
target_budget_usage,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SimulationRebalanceSummary:
|
|
mode: str
|
|
claims: int
|
|
main_period_claims: int
|
|
recent_claims: int
|
|
period_start: str
|
|
period_end: str
|
|
max_daily_count: int
|
|
budget_transactions: int
|
|
budget_reservations: int
|
|
risk_observations: int
|
|
allocation_missing_count: int
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return asdict(self)
|
|
|
|
|
|
class HalfYearExpenseSimulationRebalancer:
|
|
"""Rebalance existing simulation rows without deleting business records."""
|
|
|
|
def __init__(
|
|
self,
|
|
db: Session,
|
|
*,
|
|
start_date: date = date(2026, 1, 1),
|
|
end_date: date = date(2026, 6, 2),
|
|
recent_sample_days: int = 2,
|
|
) -> None:
|
|
self.db = db
|
|
self.start_date = start_date
|
|
self.end_date = end_date
|
|
self.main_period_end = date(end_date.year, end_date.month, 1) - timedelta(days=1)
|
|
self.recent_sample_days = max(1, recent_sample_days)
|
|
|
|
def preview(self) -> SimulationRebalanceSummary:
|
|
return self._run(apply=False)
|
|
|
|
def apply(self) -> SimulationRebalanceSummary:
|
|
return self._run(apply=True)
|
|
|
|
def _run(self, *, apply: bool) -> SimulationRebalanceSummary:
|
|
claims = self._simulation_claims()
|
|
plans = self._claim_plans(claims)
|
|
allocation_map = self._allocation_map()
|
|
allocation_missing_count = self._count_missing_allocations(plans, allocation_map)
|
|
day_counts: dict[date, int] = {}
|
|
for _claim, plan in plans:
|
|
day_counts[plan["day"]] = day_counts.get(plan["day"], 0) + 1
|
|
|
|
if apply and plans:
|
|
self._apply_claim_plans(plans, allocation_map)
|
|
self._rebalance_allocation_amounts()
|
|
self.db.flush()
|
|
|
|
recent_count = sum(1 for _claim, plan in plans if plan["day"] >= date(2026, 6, 1))
|
|
return SimulationRebalanceSummary(
|
|
mode="apply" if apply else "dry-run",
|
|
claims=len(claims),
|
|
main_period_claims=len(claims) - recent_count,
|
|
recent_claims=recent_count,
|
|
period_start=self.start_date.isoformat(),
|
|
period_end=self.end_date.isoformat(),
|
|
max_daily_count=max(day_counts.values()) if day_counts else 0,
|
|
budget_transactions=self._sim_transaction_count(),
|
|
budget_reservations=self._sim_reservation_count(),
|
|
risk_observations=self._sim_risk_count(),
|
|
allocation_missing_count=allocation_missing_count,
|
|
)
|
|
|
|
def _simulation_claims(self) -> list[ExpenseClaim]:
|
|
return list(
|
|
self.db.scalars(
|
|
select(ExpenseClaim)
|
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
.order_by(ExpenseClaim.claim_no.asc(), ExpenseClaim.id.asc())
|
|
).all()
|
|
)
|
|
|
|
def _claim_plans(self, claims: list[ExpenseClaim]) -> list[tuple[ExpenseClaim, dict[str, object]]]:
|
|
recent_count = self._recent_count(len(claims))
|
|
main_count = max(len(claims) - recent_count, 0)
|
|
main_days = self._date_range(self.start_date, self.main_period_end)
|
|
recent_days = self._date_range(date(2026, 6, 1), self.end_date)
|
|
plans: list[tuple[ExpenseClaim, dict[str, object]]] = []
|
|
for index, claim in enumerate(claims):
|
|
if index < main_count:
|
|
day = self._spread_day(index, main_count, main_days)
|
|
else:
|
|
recent_index = index - main_count
|
|
day = recent_days[recent_index % len(recent_days)]
|
|
occurred_at = datetime.combine(day, time(hour=8 + (index % 9)), tzinfo=UTC)
|
|
submitted_at = None
|
|
if self._status(claim) != "draft":
|
|
submitted_at = datetime.combine(day, time(hour=9 + (index % 7)), tzinfo=UTC)
|
|
updated_at = self._updated_at(claim, occurred_at, submitted_at, index)
|
|
final_claim_no = build_simulation_reimbursement_no(occurred_at, index + 1)
|
|
period_key = f"{occurred_at.year}Q{((occurred_at.month - 1) // 3) + 1}"
|
|
subject_code = "meal" if str(claim.expense_type or "") == "entertainment" else str(claim.expense_type or "")
|
|
plans.append(
|
|
(
|
|
claim,
|
|
{
|
|
"sequence": index + 1,
|
|
"day": day,
|
|
"occurred_at": occurred_at,
|
|
"submitted_at": submitted_at,
|
|
"updated_at": updated_at,
|
|
"claim_no": final_claim_no,
|
|
"period_key": period_key,
|
|
"subject_code": subject_code,
|
|
},
|
|
)
|
|
)
|
|
return plans
|
|
|
|
def _apply_claim_plans(
|
|
self,
|
|
plans: list[tuple[ExpenseClaim, dict[str, object]]],
|
|
allocation_map: dict[tuple[str | None, str, str], str],
|
|
) -> None:
|
|
claim_ids = [claim.id for claim, _plan in plans]
|
|
transactions_by_claim = self._transactions_by_claim_id(claim_ids)
|
|
reservations_by_claim = self._reservations_by_claim_id(claim_ids)
|
|
observations_by_claim = self._observations_by_claim_id(claim_ids)
|
|
|
|
for claim, plan in plans:
|
|
claim.claim_no = f"SIM-TEMP-{claim.id}"
|
|
self.db.flush()
|
|
|
|
for claim, plan in plans:
|
|
claim_no = str(plan["claim_no"])
|
|
occurred_at = plan["occurred_at"]
|
|
submitted_at = plan["submitted_at"]
|
|
updated_at = plan["updated_at"]
|
|
allocation_id = allocation_map.get(
|
|
(
|
|
claim.department_id,
|
|
str(plan["period_key"]),
|
|
str(plan["subject_code"]),
|
|
)
|
|
)
|
|
claim.claim_no = claim_no
|
|
claim.occurred_at = occurred_at
|
|
claim.submitted_at = submitted_at
|
|
claim.created_at = occurred_at
|
|
claim.updated_at = updated_at
|
|
claim.reason = self._normalized_reason(claim.reason, occurred_at.date())
|
|
|
|
self.db.execute(
|
|
text(
|
|
"""
|
|
update expense_claim_items
|
|
set item_date = :item_date, updated_at = :updated_at
|
|
where claim_id = :claim_id
|
|
"""
|
|
),
|
|
{
|
|
"item_date": occurred_at.date(),
|
|
"updated_at": updated_at,
|
|
"claim_id": claim.id,
|
|
},
|
|
)
|
|
|
|
for transaction in transactions_by_claim.get(claim.id, []):
|
|
transaction.source_no = claim_no
|
|
transaction.created_at = submitted_at or occurred_at
|
|
if allocation_id:
|
|
transaction.allocation_id = allocation_id
|
|
|
|
for reservation in reservations_by_claim.get(claim.id, []):
|
|
reservation.source_no = claim_no
|
|
reservation.created_at = submitted_at or occurred_at
|
|
reservation.updated_at = updated_at
|
|
if allocation_id:
|
|
reservation.allocation_id = allocation_id
|
|
|
|
for observation in observations_by_claim.get(claim.id, []):
|
|
observation.subject_key = claim_no
|
|
observation.subject_label = claim_no
|
|
observation.claim_no = claim_no
|
|
observation.created_at = submitted_at or occurred_at
|
|
observation.updated_at = updated_at
|
|
|
|
def _allocation_map(self) -> dict[tuple[str | None, str, str], str]:
|
|
rows = self.db.scalars(
|
|
select(BudgetAllocation).where(BudgetAllocation.project_code == SIM_PROJECT_CODE)
|
|
).all()
|
|
return {
|
|
(row.department_id, row.period_key, row.subject_code): row.id
|
|
for row in rows
|
|
}
|
|
|
|
def _count_missing_allocations(
|
|
self,
|
|
plans: list[tuple[ExpenseClaim, dict[str, object]]],
|
|
allocation_map: dict[tuple[str | None, str, str], str],
|
|
) -> int:
|
|
missing = {
|
|
(claim.department_id, str(plan["period_key"]), str(plan["subject_code"]))
|
|
for claim, plan in plans
|
|
if self._status(claim) in BUDGETED_STATUSES
|
|
and (claim.department_id, str(plan["period_key"]), str(plan["subject_code"])) not in allocation_map
|
|
}
|
|
return len(missing)
|
|
|
|
def _rebalance_allocation_amounts(self) -> None:
|
|
allocations = list(
|
|
self.db.scalars(
|
|
select(BudgetAllocation)
|
|
.where(BudgetAllocation.budget_no.like(f"{SIM_BUDGET_PREFIX}%"))
|
|
.order_by(BudgetAllocation.period_key.asc(), BudgetAllocation.subject_code.asc())
|
|
).all()
|
|
)
|
|
transactions = list(
|
|
self.db.scalars(
|
|
select(BudgetTransaction).where(
|
|
BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")
|
|
)
|
|
).all()
|
|
)
|
|
used_by_allocation: dict[str, Decimal] = {}
|
|
for transaction in transactions:
|
|
used_by_allocation[transaction.allocation_id] = (
|
|
used_by_allocation.get(transaction.allocation_id, Decimal("0.00"))
|
|
+ Decimal(transaction.amount or 0)
|
|
)
|
|
for index, allocation in enumerate(allocations):
|
|
used = used_by_allocation.get(allocation.id, Decimal("0.00"))
|
|
usage = target_budget_usage(allocation.period_key, allocation.subject_code, index)
|
|
allocation.original_amount = max(
|
|
(used / usage).quantize(Decimal("0.01")) if usage > 0 else used,
|
|
Decimal("3000.00"),
|
|
)
|
|
allocation.updated_by = "simulation_rebalance"
|
|
allocation.updated_at = datetime.now(UTC)
|
|
|
|
def _transactions_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[BudgetTransaction]]:
|
|
rows = self.db.scalars(
|
|
select(BudgetTransaction)
|
|
.where(BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%"))
|
|
.where(BudgetTransaction.source_id.in_(claim_ids))
|
|
).all()
|
|
return self._group_by_source_id(rows)
|
|
|
|
def _reservations_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[BudgetReservation]]:
|
|
rows = self.db.scalars(
|
|
select(BudgetReservation)
|
|
.where(BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%"))
|
|
.where(BudgetReservation.source_id.in_(claim_ids))
|
|
).all()
|
|
return self._group_by_source_id(rows)
|
|
|
|
def _observations_by_claim_id(self, claim_ids: list[str]) -> dict[str, list[RiskObservation]]:
|
|
rows = self.db.scalars(
|
|
select(RiskObservation)
|
|
.where(RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%"))
|
|
.where(RiskObservation.claim_id.in_(claim_ids))
|
|
).all()
|
|
grouped: dict[str, list[RiskObservation]] = {}
|
|
for row in rows:
|
|
if row.claim_id:
|
|
grouped.setdefault(row.claim_id, []).append(row)
|
|
return grouped
|
|
|
|
@staticmethod
|
|
def _group_by_source_id(rows: object) -> dict[str, list[object]]:
|
|
grouped: dict[str, list[object]] = {}
|
|
for row in rows:
|
|
grouped.setdefault(row.source_id, []).append(row)
|
|
return grouped
|
|
|
|
def _recent_count(self, total: int) -> int:
|
|
if total <= 0:
|
|
return 0
|
|
return min(24, max(12, total // 50))
|
|
|
|
@staticmethod
|
|
def _date_range(start: date, end: date) -> list[date]:
|
|
days = max((end - start).days, 0)
|
|
return [start + timedelta(days=index) for index in range(days + 1)]
|
|
|
|
@staticmethod
|
|
def _spread_day(index: int, count: int, days: list[date]) -> date:
|
|
if not days:
|
|
raise ValueError("days cannot be empty")
|
|
if count <= 1:
|
|
return days[0]
|
|
day_index = round(index * (len(days) - 1) / (count - 1))
|
|
jitter = ((index * 17) % 5) - 2
|
|
return days[max(0, min(len(days) - 1, day_index + jitter))]
|
|
|
|
@staticmethod
|
|
def _updated_at(
|
|
claim: ExpenseClaim,
|
|
occurred_at: datetime,
|
|
submitted_at: datetime | None,
|
|
index: int,
|
|
) -> datetime:
|
|
base = submitted_at or occurred_at
|
|
status = HalfYearExpenseSimulationRebalancer._status(claim)
|
|
if status == "paid":
|
|
return base + timedelta(days=2 + (index % 3), hours=index % 5)
|
|
if status in {"approved", "pending_payment"}:
|
|
return base + timedelta(days=1 + (index % 2), hours=index % 4)
|
|
if status in {"returned", "rejected"}:
|
|
return base + timedelta(hours=6 + (index % 8))
|
|
return base + timedelta(hours=2 + (index % 4))
|
|
|
|
@staticmethod
|
|
def _normalized_reason(reason: str, day: date) -> str:
|
|
text = str(reason or "").strip()
|
|
for month in range(1, 7):
|
|
text = text.replace(f"{month}月", f"{day.month}月")
|
|
return text
|
|
|
|
@staticmethod
|
|
def _status(claim: ExpenseClaim) -> str:
|
|
return str(claim.status or "").strip().lower()
|
|
|
|
def _sim_transaction_count(self) -> int:
|
|
return int(
|
|
self.db.scalar(
|
|
select(func.count()).select_from(BudgetTransaction).where(
|
|
BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
|
|
def _sim_reservation_count(self) -> int:
|
|
return int(
|
|
self.db.scalar(
|
|
select(func.count()).select_from(BudgetReservation).where(
|
|
BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%")
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
|
|
def _sim_risk_count(self) -> int:
|
|
return int(
|
|
self.db.scalar(
|
|
select(func.count()).select_from(RiskObservation).where(
|
|
RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%")
|
|
)
|
|
)
|
|
or 0
|
|
)
|