2026-06-02 16:22:59 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, date, datetime
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import create_engine, func, select
|
|
|
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
|
|
|
|
|
|
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.services.budget import BudgetService
|
|
|
|
|
from app.services.demo_company_simulation_seed import (
|
|
|
|
|
SIM_EMPLOYEE_PREFIX,
|
|
|
|
|
HalfYearExpenseSimulationSeeder,
|
|
|
|
|
SimulationConfig,
|
|
|
|
|
)
|
2026-06-03 15:46:56 +08:00
|
|
|
from app.services.demo_company_simulation_catalog import SIM_PROJECT_CODE
|
|
|
|
|
from app.services.demo_company_simulation_rebalance import HalfYearExpenseSimulationRebalancer
|
2026-06-02 16:22:59 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_session() -> Session:
|
|
|
|
|
engine = create_engine(
|
|
|
|
|
"sqlite+pysqlite:///:memory:",
|
|
|
|
|
connect_args={"check_same_thread": False},
|
|
|
|
|
poolclass=StaticPool,
|
|
|
|
|
)
|
|
|
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
|
|
|
return session_factory()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def seed_company(db: Session) -> None:
|
|
|
|
|
tech = OrganizationUnit(
|
|
|
|
|
id="dept-tech",
|
|
|
|
|
unit_code="TECH-DEPT",
|
|
|
|
|
name="技术部",
|
|
|
|
|
unit_type="department",
|
|
|
|
|
cost_center="CC-6100",
|
|
|
|
|
location="北京",
|
|
|
|
|
)
|
|
|
|
|
market = OrganizationUnit(
|
|
|
|
|
id="dept-market",
|
|
|
|
|
unit_code="MARKET-DEPT",
|
|
|
|
|
name="市场部",
|
|
|
|
|
unit_type="department",
|
|
|
|
|
cost_center="CC-4100",
|
|
|
|
|
location="上海",
|
|
|
|
|
)
|
|
|
|
|
db.add_all([tech, market])
|
|
|
|
|
for index in range(3):
|
|
|
|
|
db.add(
|
|
|
|
|
Employee(
|
|
|
|
|
id=f"emp-existing-{index}",
|
|
|
|
|
employee_no=f"E-EXISTING-{index}",
|
|
|
|
|
name=f"现有员工{index}",
|
|
|
|
|
email=f"existing-{index}@xf.com",
|
|
|
|
|
grade="P5",
|
|
|
|
|
position="主管",
|
|
|
|
|
organization_unit=tech if index % 2 == 0 else market,
|
|
|
|
|
cost_center="CC-6100" if index % 2 == 0 else "CC-4100",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_half_year_simulation_preview_and_apply_are_idempotent() -> None:
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
seed_company(db)
|
|
|
|
|
config = SimulationConfig(target_employees=8, start_date=date(2026, 1, 1), months=6, seed=7)
|
|
|
|
|
|
|
|
|
|
preview = HalfYearExpenseSimulationSeeder(db, config).preview()
|
|
|
|
|
|
|
|
|
|
assert preview.mode == "dry-run"
|
|
|
|
|
assert preview.current_employee_count == 3
|
|
|
|
|
assert preview.employees_to_create == 5
|
|
|
|
|
assert preview.claims_to_create >= 24
|
|
|
|
|
assert preview.budget_allocations_to_create > 0
|
|
|
|
|
assert preview.budget_transactions_to_create > 0
|
|
|
|
|
|
|
|
|
|
applied = HalfYearExpenseSimulationSeeder(db, config).apply()
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
assert applied.mode == "apply"
|
|
|
|
|
assert applied.employees_to_create == 5
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(Employee)) == 8
|
|
|
|
|
assert db.scalar(select(func.count()).select_from(ExpenseClaim)) == applied.claims_to_create
|
|
|
|
|
assert (
|
|
|
|
|
db.scalar(select(func.count()).select_from(ExpenseClaimItem))
|
|
|
|
|
== applied.claim_items_to_create
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
db.scalar(select(func.count()).select_from(BudgetAllocation))
|
|
|
|
|
== applied.budget_allocations_to_create
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
db.scalar(select(func.count()).select_from(BudgetTransaction))
|
|
|
|
|
== applied.budget_transactions_to_create
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
db.scalar(select(func.count()).select_from(BudgetReservation))
|
|
|
|
|
== applied.budget_reservations_to_create
|
|
|
|
|
)
|
|
|
|
|
assert (
|
|
|
|
|
db.scalar(select(func.count()).select_from(RiskObservation))
|
|
|
|
|
== applied.risk_observations_to_create
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
repeated = HalfYearExpenseSimulationSeeder(db, config).apply()
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
assert repeated.employees_to_create == 0
|
|
|
|
|
assert repeated.claims_to_create == 0
|
|
|
|
|
assert repeated.budget_allocations_to_create == 0
|
|
|
|
|
assert repeated.budget_transactions_to_create == 0
|
|
|
|
|
assert repeated.budget_reservations_to_create == 0
|
|
|
|
|
assert repeated.risk_observations_to_create == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_half_year_simulation_feeds_budget_summary() -> None:
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
seed_company(db)
|
|
|
|
|
config = SimulationConfig(
|
|
|
|
|
target_employees=10,
|
|
|
|
|
start_date=date(2026, 1, 1),
|
|
|
|
|
months=6,
|
|
|
|
|
seed=11,
|
|
|
|
|
)
|
|
|
|
|
HalfYearExpenseSimulationSeeder(db, config).apply()
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
summary = BudgetService(db).get_summary(fiscal_year=2026, period_key="2026Q2")
|
|
|
|
|
sim_claim_count = db.scalar(
|
2026-06-03 15:46:56 +08:00
|
|
|
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
2026-06-02 16:22:59 +08:00
|
|
|
)
|
|
|
|
|
sim_employee_count = db.scalar(
|
|
|
|
|
select(func.count()).select_from(Employee).where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%"))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert sim_claim_count and sim_claim_count >= 30
|
|
|
|
|
assert sim_employee_count == 7
|
|
|
|
|
assert summary.trend
|
|
|
|
|
assert {item.period_key for item in summary.trend} == {"2026Q1", "2026Q2"}
|
|
|
|
|
assert summary.warning_count + summary.over_budget_count > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume() -> None:
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
seed_company(db)
|
|
|
|
|
db.add(
|
|
|
|
|
Employee(
|
|
|
|
|
id="emp-admin",
|
|
|
|
|
employee_no="ADMIN",
|
|
|
|
|
name="admin",
|
|
|
|
|
email="admin@xf.com",
|
|
|
|
|
grade="P8",
|
|
|
|
|
position="admin",
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
config = SimulationConfig(
|
|
|
|
|
target_employees=100,
|
|
|
|
|
start_date=date(2026, 1, 1),
|
|
|
|
|
months=6,
|
|
|
|
|
seed=20260602,
|
|
|
|
|
)
|
|
|
|
|
HalfYearExpenseSimulationSeeder(db, config).apply()
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
admin_claim_count = db.scalar(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(ExpenseClaim)
|
|
|
|
|
.where(ExpenseClaim.employee_name == "admin")
|
|
|
|
|
)
|
|
|
|
|
visible_claim_count = db.scalar(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(ExpenseClaim)
|
2026-06-03 15:46:56 +08:00
|
|
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
2026-06-02 16:22:59 +08:00
|
|
|
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
|
|
|
|
|
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
|
|
|
|
|
)
|
2026-06-03 15:46:56 +08:00
|
|
|
total_claim_count = db.scalar(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(ExpenseClaim)
|
|
|
|
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
|
|
|
)
|
|
|
|
|
daily_counts = [
|
|
|
|
|
row[0]
|
|
|
|
|
for row in db.execute(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(ExpenseClaim)
|
|
|
|
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
|
|
|
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
|
|
|
|
|
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
|
|
|
|
|
.group_by(func.date(ExpenseClaim.occurred_at))
|
|
|
|
|
).all()
|
|
|
|
|
]
|
|
|
|
|
max_daily_count = max(daily_counts) if daily_counts else 0
|
2026-06-03 09:25:23 +08:00
|
|
|
earliest_claim_day = db.scalar(
|
|
|
|
|
select(func.min(ExpenseClaim.occurred_at)).where(
|
2026-06-03 15:46:56 +08:00
|
|
|
ExpenseClaim.project_code == SIM_PROJECT_CODE
|
2026-06-03 09:25:23 +08:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
latest_claim_day = db.scalar(
|
|
|
|
|
select(func.max(ExpenseClaim.occurred_at)).where(
|
2026-06-03 15:46:56 +08:00
|
|
|
ExpenseClaim.project_code == SIM_PROJECT_CODE
|
2026-06-03 09:25:23 +08:00
|
|
|
)
|
|
|
|
|
)
|
2026-06-02 16:22:59 +08:00
|
|
|
|
|
|
|
|
assert admin_claim_count == 0
|
2026-06-03 15:46:56 +08:00
|
|
|
assert total_claim_count is not None
|
|
|
|
|
assert 400 <= total_claim_count <= 500
|
2026-06-02 16:22:59 +08:00
|
|
|
assert visible_claim_count is not None
|
2026-06-03 15:46:56 +08:00
|
|
|
assert 12 <= visible_claim_count <= 30
|
|
|
|
|
assert max_daily_count <= 16
|
2026-06-03 09:25:23 +08:00
|
|
|
assert earliest_claim_day is not None
|
|
|
|
|
assert latest_claim_day is not None
|
|
|
|
|
assert earliest_claim_day.date() >= date(2026, 1, 1)
|
|
|
|
|
assert latest_claim_day.date() <= date(2026, 6, 2)
|
2026-06-03 15:46:56 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_half_year_simulation_rebalance_spreads_existing_rows_without_deleting() -> None:
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
seed_company(db)
|
|
|
|
|
config = SimulationConfig(
|
|
|
|
|
target_employees=100,
|
|
|
|
|
start_date=date(2026, 1, 1),
|
|
|
|
|
months=6,
|
|
|
|
|
seed=20260602,
|
|
|
|
|
)
|
|
|
|
|
HalfYearExpenseSimulationSeeder(db, config).apply()
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
claims = list(
|
|
|
|
|
db.scalars(
|
|
|
|
|
select(ExpenseClaim)
|
|
|
|
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
|
|
|
.order_by(ExpenseClaim.claim_no.asc())
|
|
|
|
|
).all()
|
|
|
|
|
)
|
|
|
|
|
for claim in claims:
|
|
|
|
|
claim.occurred_at = datetime(2026, 6, 1, 10, tzinfo=UTC)
|
|
|
|
|
claim.submitted_at = datetime(2026, 6, 1, 11, tzinfo=UTC)
|
|
|
|
|
claim.created_at = claim.occurred_at
|
|
|
|
|
claim.updated_at = claim.submitted_at
|
|
|
|
|
for item in claim.items:
|
|
|
|
|
item.item_date = date(2026, 6, 1)
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
before_count = db.scalar(
|
|
|
|
|
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
|
|
|
)
|
|
|
|
|
preview = HalfYearExpenseSimulationRebalancer(db).preview()
|
|
|
|
|
applied = HalfYearExpenseSimulationRebalancer(db).apply()
|
|
|
|
|
db.commit()
|
|
|
|
|
after_count = db.scalar(
|
|
|
|
|
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
|
|
|
)
|
|
|
|
|
daily_counts = [
|
|
|
|
|
row[0]
|
|
|
|
|
for row in db.execute(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(ExpenseClaim)
|
|
|
|
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
|
|
|
.group_by(func.date(ExpenseClaim.occurred_at))
|
|
|
|
|
).all()
|
|
|
|
|
]
|
|
|
|
|
month_keys = {
|
|
|
|
|
(claim.occurred_at.year, claim.occurred_at.month)
|
|
|
|
|
for claim in db.scalars(
|
|
|
|
|
select(ExpenseClaim).where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
|
|
|
).all()
|
|
|
|
|
}
|
|
|
|
|
sample_claim = db.scalar(
|
|
|
|
|
select(ExpenseClaim)
|
|
|
|
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
|
|
|
.where(ExpenseClaim.status != "draft")
|
|
|
|
|
.order_by(ExpenseClaim.claim_no.asc())
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
sample_transaction = db.scalar(
|
|
|
|
|
select(BudgetTransaction)
|
|
|
|
|
.where(BudgetTransaction.source_id == sample_claim.id)
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
sample_observation = db.scalar(
|
|
|
|
|
select(RiskObservation)
|
|
|
|
|
.where(RiskObservation.claim_id == sample_claim.id)
|
|
|
|
|
.limit(1)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert before_count == after_count
|
|
|
|
|
assert preview.claims == applied.claims == after_count
|
|
|
|
|
assert applied.recent_claims <= 24
|
|
|
|
|
assert max(daily_counts) <= 16
|
|
|
|
|
assert {(2026, month) for month in range(1, 7)}.issubset(month_keys)
|
|
|
|
|
if sample_transaction is not None:
|
|
|
|
|
assert sample_transaction.source_no == sample_claim.claim_no
|
|
|
|
|
assert sample_transaction.created_at.date() == sample_claim.submitted_at.date()
|
|
|
|
|
if sample_observation is not None:
|
|
|
|
|
assert sample_observation.claim_no == sample_claim.claim_no
|
|
|
|
|
assert sample_observation.created_at.date() == sample_claim.submitted_at.date()
|