- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
590 lines
21 KiB
Python
590 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import uuid
|
|
from collections import defaultdict
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, date, datetime
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
SERVER_DIR = Path(__file__).resolve().parents[1]
|
|
SRC_DIR = SERVER_DIR / "src"
|
|
if str(SRC_DIR) not in sys.path:
|
|
sys.path.insert(0, str(SRC_DIR))
|
|
|
|
from app.db.session import get_session_factory # noqa: E402
|
|
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction # noqa: E402
|
|
from app.models.employee import Employee # noqa: E402
|
|
from app.models.financial_record import ExpenseClaim # noqa: E402
|
|
from app.models.organization import OrganizationUnit # noqa: E402
|
|
from app.services.demo_company_simulation_catalog import ( # noqa: E402
|
|
BUDGETED_STATUSES,
|
|
PENDING_STATUSES,
|
|
SIM_BUDGET_PREFIX,
|
|
SIM_EMPLOYEE_PREFIX,
|
|
SIM_PROJECT_CODE,
|
|
SIM_RESERVATION_PREFIX,
|
|
SIM_TRANSACTION_PREFIX,
|
|
SUBJECT_LABELS,
|
|
SUCCESS_STATUSES,
|
|
target_budget_usage,
|
|
)
|
|
from app.services.demo_company_simulation_filters import is_admin_employee_like # noqa: E402
|
|
from app.services.employee_behavior_profile_service import ( # noqa: E402
|
|
EmployeeBehaviorProfileService,
|
|
)
|
|
from app.services.expense_claim_status_registry import ( # noqa: E402
|
|
normalize_expense_claim_state,
|
|
)
|
|
|
|
DEPARTMENT_PLAN = (
|
|
("TECH-DEPT", Decimal("0.30")),
|
|
("MARKET-DEPT", Decimal("0.24")),
|
|
("PRODUCTION-DEPT", Decimal("0.18")),
|
|
("FINANCE-DEPT", Decimal("0.12")),
|
|
("HR-DEPT", Decimal("0.10")),
|
|
("PRESIDENT-OFFICE", Decimal("0.06")),
|
|
)
|
|
RECENT_PENDING_PER_DEPARTMENT = 3
|
|
RECENT_DATES = (
|
|
datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
|
datetime(2026, 6, 1, 15, 0, tzinfo=UTC),
|
|
datetime(2026, 6, 2, 6, 0, tzinfo=UTC),
|
|
)
|
|
PERIOD_START = date(2026, 1, 1)
|
|
PERIOD_END = date(2026, 6, 2)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RepairSummary:
|
|
mode: str
|
|
sim_employees: int
|
|
sim_claims: int
|
|
employee_department_plan: dict[str, int]
|
|
claim_department_plan: dict[str, int]
|
|
recent_pending_plan: dict[str, int]
|
|
rebuilt_budget_allocations: int
|
|
rebuilt_budget_transactions: int
|
|
rebuilt_budget_reservations: int
|
|
before_all_department_amounts: dict[str, str]
|
|
before_recent_pending_amounts: dict[str, str]
|
|
after_all_department_amounts: dict[str, str]
|
|
after_recent_pending_amounts: dict[str, str]
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return asdict(self)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Repair simulated half-year demo data distribution."
|
|
)
|
|
parser.add_argument("--apply", action="store_true", help="Apply repair. Default is dry-run.")
|
|
parser.add_argument(
|
|
"--refresh-profiles",
|
|
action="store_true",
|
|
help="After --apply, refresh employee behavior profile snapshots for simulated employees.",
|
|
)
|
|
parser.add_argument("--profile-limit", type=int, default=120)
|
|
args = parser.parse_args()
|
|
|
|
session_factory = get_session_factory()
|
|
with session_factory() as db:
|
|
try:
|
|
summary = repair_distribution(db, apply=args.apply)
|
|
profile_refresh = None
|
|
if args.apply and args.refresh_profiles:
|
|
profile_refresh = _refresh_company_profiles(db, limit=args.profile_limit)
|
|
if args.apply:
|
|
db.commit()
|
|
payload = summary.to_dict()
|
|
if profile_refresh is not None:
|
|
payload["profile_refresh"] = profile_refresh
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
if not args.apply:
|
|
print("dry-run only; pass --apply after confirmation to repair simulated data.")
|
|
elif not args.refresh_profiles:
|
|
print("pass --refresh-profiles to generate employee behavior profile snapshots.")
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
|
|
|
|
def repair_distribution(db, *, apply: bool) -> RepairSummary:
|
|
departments = _canonical_departments(db)
|
|
if len(departments) < len(DEPARTMENT_PLAN):
|
|
missing = [code for code, _ in DEPARTMENT_PLAN if code not in departments]
|
|
raise RuntimeError(f"missing canonical departments: {missing}")
|
|
|
|
sim_employees = _sim_employees(db)
|
|
sim_claims = _sim_claims(db)
|
|
before_all = _department_amounts(sim_claims)
|
|
before_recent = _recent_pending_amounts(sim_claims)
|
|
|
|
employee_plan = _counts_by_weight(len(sim_employees))
|
|
claim_plan = _counts_by_weight(len(sim_claims))
|
|
recent_claims = _recent_claims(sim_claims)
|
|
fixed_recent_plan = {code: RECENT_PENDING_PER_DEPARTMENT for code, _ in DEPARTMENT_PLAN}
|
|
regular_plan = {
|
|
code: max(claim_plan.get(code, 0) - fixed_recent_plan.get(code, 0), 0)
|
|
for code, _ in DEPARTMENT_PLAN
|
|
}
|
|
|
|
if apply:
|
|
_normalize_sim_claim_workflow(sim_claims)
|
|
_clamp_sim_claim_dates(sim_claims)
|
|
_redistribute_employees(sim_employees, departments, employee_plan)
|
|
db.flush()
|
|
employees_by_dept = _employees_by_department(db)
|
|
_redistribute_regular_claims(
|
|
[claim for claim in sim_claims if claim not in set(recent_claims)],
|
|
departments,
|
|
employees_by_dept,
|
|
regular_plan,
|
|
)
|
|
_repair_recent_pending_claims(recent_claims, departments, employees_by_dept)
|
|
db.flush()
|
|
_rebuild_sim_budget(db, sim_claims, departments)
|
|
db.flush()
|
|
|
|
after_claims = (
|
|
_sim_claims(db)
|
|
if apply
|
|
else _preview_claims(sim_claims, departments, claim_plan)
|
|
)
|
|
after_all = _department_amounts(after_claims)
|
|
after_recent = _recent_pending_amounts(after_claims)
|
|
allocation_count, transaction_count, reservation_count = _planned_budget_counts(after_claims)
|
|
|
|
return RepairSummary(
|
|
mode="apply" if apply else "dry-run",
|
|
sim_employees=len(sim_employees),
|
|
sim_claims=len(sim_claims),
|
|
employee_department_plan=employee_plan,
|
|
claim_department_plan=claim_plan,
|
|
recent_pending_plan=fixed_recent_plan,
|
|
rebuilt_budget_allocations=allocation_count,
|
|
rebuilt_budget_transactions=transaction_count,
|
|
rebuilt_budget_reservations=reservation_count,
|
|
before_all_department_amounts=before_all,
|
|
before_recent_pending_amounts=before_recent,
|
|
after_all_department_amounts=after_all,
|
|
after_recent_pending_amounts=after_recent,
|
|
)
|
|
|
|
|
|
def _refresh_company_profiles(db, *, limit: int) -> dict[str, object]:
|
|
capped_limit = max(1, min(int(limit or 120), 500))
|
|
employees = list(
|
|
db.scalars(select(Employee).order_by(Employee.employee_no.asc())).all()
|
|
)
|
|
employee_ids = [
|
|
employee.id
|
|
for employee in employees
|
|
if not is_admin_employee_like(employee)
|
|
][:capped_limit]
|
|
service = EmployeeBehaviorProfileService(db)
|
|
snapshot_count = 0
|
|
for employee_id in employee_ids:
|
|
snapshots = service.refresh_employee_profiles(
|
|
employee_id=employee_id,
|
|
window_days=(30, 90, 180),
|
|
expense_type_scope="overall",
|
|
source_task_type="half_year_expense_demo_repair",
|
|
commit=False,
|
|
)
|
|
snapshot_count += len(snapshots)
|
|
|
|
db.commit()
|
|
return {
|
|
"target_employee_count": len(employee_ids),
|
|
"snapshot_count": snapshot_count,
|
|
"window_days": [30, 90, 180],
|
|
"source_task_type": "half_year_expense_demo_repair",
|
|
"scope": "all_non_admin_employees",
|
|
}
|
|
|
|
|
|
def _canonical_departments(db) -> dict[str, OrganizationUnit]:
|
|
department_codes = [code for code, _weight in DEPARTMENT_PLAN]
|
|
rows = db.scalars(
|
|
select(OrganizationUnit).where(OrganizationUnit.unit_code.in_(department_codes))
|
|
).all()
|
|
return {row.unit_code: row for row in rows}
|
|
|
|
|
|
def _sim_employees(db) -> list[Employee]:
|
|
return list(
|
|
db.scalars(
|
|
select(Employee)
|
|
.options(selectinload(Employee.organization_unit))
|
|
.where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%"))
|
|
.order_by(Employee.employee_no.asc())
|
|
).all()
|
|
)
|
|
|
|
|
|
def _sim_claims(db) -> list[ExpenseClaim]:
|
|
return list(
|
|
db.scalars(
|
|
select(ExpenseClaim)
|
|
.options(selectinload(ExpenseClaim.items))
|
|
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
|
|
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
|
|
).all()
|
|
)
|
|
|
|
|
|
def _normalize_sim_claim_workflow(claims: list[ExpenseClaim]) -> None:
|
|
for claim in claims:
|
|
normalized = normalize_expense_claim_state(
|
|
claim.status,
|
|
claim.approval_stage,
|
|
claim_no=claim.claim_no,
|
|
expense_type=claim.expense_type,
|
|
is_application_claim=False,
|
|
)
|
|
claim.status = normalized.status
|
|
claim.approval_stage = normalized.approval_stage
|
|
|
|
|
|
def _clamp_sim_claim_dates(claims: list[ExpenseClaim]) -> None:
|
|
for index, claim in enumerate(claims):
|
|
occurred_at = claim.occurred_at or claim.submitted_at
|
|
if occurred_at is None:
|
|
continue
|
|
if PERIOD_START <= occurred_at.date() <= PERIOD_END:
|
|
continue
|
|
anchor = RECENT_DATES[index % len(RECENT_DATES)]
|
|
claim.occurred_at = anchor - _hours(2)
|
|
if claim.submitted_at is not None or claim.status != "draft":
|
|
claim.submitted_at = anchor
|
|
claim.created_at = claim.occurred_at
|
|
claim.updated_at = anchor + _hours(1)
|
|
for item in claim.items or []:
|
|
item.item_date = claim.occurred_at.date()
|
|
|
|
|
|
def _counts_by_weight(total: int) -> dict[str, int]:
|
|
raw = [(code, total * weight) for code, weight in DEPARTMENT_PLAN]
|
|
counts = {code: int(value) for code, value in raw}
|
|
remainder = total - sum(counts.values())
|
|
remainder_order = sorted(
|
|
raw,
|
|
key=lambda item: item[1] - int(item[1]),
|
|
reverse=True,
|
|
)
|
|
for code, _value in remainder_order[:remainder]:
|
|
counts[code] += 1
|
|
return counts
|
|
|
|
|
|
def _redistribute_employees(
|
|
employees: list[Employee],
|
|
departments: dict[str, OrganizationUnit],
|
|
plan: dict[str, int],
|
|
) -> None:
|
|
index = 0
|
|
for code, _weight in DEPARTMENT_PLAN:
|
|
department = departments[code]
|
|
for employee in employees[index : index + plan.get(code, 0)]:
|
|
employee.organization_unit = department
|
|
employee.cost_center = department.cost_center
|
|
employee.location = department.location
|
|
employee.finance_owner_name = f"{department.name}财务BP"
|
|
index += plan.get(code, 0)
|
|
|
|
|
|
def _employees_by_department(db) -> dict[str, list[Employee]]:
|
|
rows = db.scalars(
|
|
select(Employee)
|
|
.options(selectinload(Employee.organization_unit))
|
|
.where(Employee.organization_unit_id.is_not(None))
|
|
.order_by(Employee.employee_no.asc())
|
|
).all()
|
|
grouped: dict[str, list[Employee]] = defaultdict(list)
|
|
for employee in rows:
|
|
unit = employee.organization_unit
|
|
if unit is not None and unit.unit_code:
|
|
grouped[unit.unit_code].append(employee)
|
|
return grouped
|
|
|
|
|
|
def _redistribute_regular_claims(
|
|
claims: list[ExpenseClaim],
|
|
departments: dict[str, OrganizationUnit],
|
|
employees_by_dept: dict[str, list[Employee]],
|
|
plan: dict[str, int],
|
|
) -> None:
|
|
index = 0
|
|
for code, _weight in DEPARTMENT_PLAN:
|
|
department = departments[code]
|
|
employees = employees_by_dept.get(code) or []
|
|
for offset, claim in enumerate(claims[index : index + plan.get(code, 0)]):
|
|
employee = employees[offset % len(employees)] if employees else None
|
|
_assign_claim_department(claim, department, employee)
|
|
index += plan.get(code, 0)
|
|
|
|
|
|
def _repair_recent_pending_claims(
|
|
claims: list[ExpenseClaim],
|
|
departments: dict[str, OrganizationUnit],
|
|
employees_by_dept: dict[str, list[Employee]],
|
|
) -> None:
|
|
index = 0
|
|
for code, _weight in DEPARTMENT_PLAN:
|
|
department = departments[code]
|
|
employees = employees_by_dept.get(code) or []
|
|
for offset in range(RECENT_PENDING_PER_DEPARTMENT):
|
|
claim = claims[index]
|
|
employee = employees[offset % len(employees)] if employees else None
|
|
_assign_claim_department(claim, department, employee)
|
|
claim.status = "submitted"
|
|
claim.approval_stage = "财务审批" if offset % 2 == 0 else "直属领导审批"
|
|
claim.occurred_at = RECENT_DATES[offset] - _hours(2)
|
|
claim.submitted_at = RECENT_DATES[offset]
|
|
claim.updated_at = RECENT_DATES[offset] + _hours(1)
|
|
index += 1
|
|
|
|
|
|
def _assign_claim_department(
|
|
claim: ExpenseClaim,
|
|
department: OrganizationUnit,
|
|
employee: Employee | None,
|
|
) -> None:
|
|
claim.department_id = department.id
|
|
claim.department_name = department.name
|
|
if employee is not None:
|
|
claim.employee_id = employee.id
|
|
claim.employee_name = employee.name
|
|
claim.location = department.location or claim.location
|
|
|
|
|
|
def _rebuild_sim_budget(
|
|
db,
|
|
claims: list[ExpenseClaim],
|
|
departments: dict[str, OrganizationUnit],
|
|
) -> None:
|
|
for model, field, prefix in (
|
|
(BudgetTransaction, BudgetTransaction.transaction_no, SIM_TRANSACTION_PREFIX),
|
|
(BudgetReservation, BudgetReservation.reservation_no, SIM_RESERVATION_PREFIX),
|
|
(BudgetAllocation, BudgetAllocation.budget_no, SIM_BUDGET_PREFIX),
|
|
):
|
|
for row in db.scalars(select(model).where(field.like(f"{prefix}%"))).all():
|
|
db.delete(row)
|
|
db.flush()
|
|
|
|
groups: dict[tuple[int, str, str, str, str], list[ExpenseClaim]] = defaultdict(list)
|
|
for claim in claims:
|
|
if claim.status not in BUDGETED_STATUSES:
|
|
continue
|
|
subject_code = "meal" if claim.expense_type == "entertainment" else claim.expense_type
|
|
quarter = ((claim.occurred_at.month - 1) // 3) + 1
|
|
period_key = f"{claim.occurred_at.year}Q{quarter}"
|
|
cost_center = _claim_cost_center(claim, departments)
|
|
key = (claim.occurred_at.year, period_key, claim.department_id, cost_center, subject_code)
|
|
groups[key].append(claim)
|
|
|
|
allocation_index = 1
|
|
transaction_index = 1
|
|
for key, group_claims in sorted(groups.items()):
|
|
year, period_key, department_id, cost_center, subject_code = key
|
|
total_used = sum((Decimal(claim.amount or 0) for claim in group_claims), Decimal("0.00"))
|
|
original_amount = (
|
|
total_used / target_budget_usage(period_key, subject_code, allocation_index)
|
|
).quantize(Decimal("0.01"))
|
|
allocation = BudgetAllocation(
|
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"repair:{SIM_BUDGET_PREFIX}:{key}")),
|
|
budget_no=f"{SIM_BUDGET_PREFIX}-R{allocation_index:04d}",
|
|
fiscal_year=year,
|
|
period_type="quarter",
|
|
period_key=period_key,
|
|
department_id=department_id,
|
|
department_name=group_claims[0].department_name,
|
|
cost_center=cost_center,
|
|
project_code=SIM_PROJECT_CODE,
|
|
subject_code=subject_code,
|
|
subject_name=SUBJECT_LABELS.get(subject_code, subject_code),
|
|
original_amount=max(original_amount, Decimal("3000.00")),
|
|
adjusted_amount=Decimal("0.00"),
|
|
status="active",
|
|
warning_threshold=Decimal("80.00"),
|
|
control_action="warn",
|
|
description="半年报销模拟数据部门分布修复预算池",
|
|
created_by="simulation",
|
|
updated_by="simulation",
|
|
)
|
|
db.add(allocation)
|
|
db.flush()
|
|
for claim in group_claims:
|
|
db.add(_budget_transaction(allocation.id, claim, transaction_index))
|
|
if claim.status in PENDING_STATUSES:
|
|
db.add(_budget_reservation(allocation.id, claim, transaction_index))
|
|
transaction_index += 1
|
|
allocation_index += 1
|
|
|
|
|
|
def _budget_transaction(allocation_id: str, claim: ExpenseClaim, index: int) -> BudgetTransaction:
|
|
transaction_no = f"{SIM_TRANSACTION_PREFIX}-R{index:04d}"
|
|
transaction_type = "consume" if claim.status in SUCCESS_STATUSES else "reserve"
|
|
return BudgetTransaction(
|
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"repair:{transaction_no}")),
|
|
transaction_no=transaction_no,
|
|
allocation_id=allocation_id,
|
|
source_type="claim",
|
|
source_id=claim.id,
|
|
source_no=claim.claim_no,
|
|
transaction_type=transaction_type,
|
|
amount=Decimal(claim.amount or 0),
|
|
before_available_amount=Decimal("0.00"),
|
|
after_available_amount=Decimal("0.00"),
|
|
operator="simulation",
|
|
reason="修复后模拟数据预算台账",
|
|
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True, "repair": True},
|
|
created_at=claim.submitted_at or claim.occurred_at,
|
|
)
|
|
|
|
|
|
def _budget_reservation(allocation_id: str, claim: ExpenseClaim, index: int) -> BudgetReservation:
|
|
reservation_no = f"{SIM_RESERVATION_PREFIX}-R{index:04d}"
|
|
return BudgetReservation(
|
|
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"repair:{reservation_no}")),
|
|
reservation_no=reservation_no,
|
|
allocation_id=allocation_id,
|
|
source_type="claim",
|
|
source_id=claim.id,
|
|
source_no=claim.claim_no,
|
|
source_status="active",
|
|
amount=Decimal(claim.amount or 0),
|
|
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True, "repair": True},
|
|
created_at=claim.submitted_at or claim.occurred_at,
|
|
)
|
|
|
|
|
|
def _recent_claims(claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
|
|
needed = RECENT_PENDING_PER_DEPARTMENT * len(DEPARTMENT_PLAN)
|
|
return sorted(claims, key=lambda claim: Decimal(claim.amount or 0), reverse=True)[:needed]
|
|
|
|
|
|
def _department_amounts(claims: list[ExpenseClaim]) -> dict[str, str]:
|
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
|
for claim in claims:
|
|
buckets[claim.department_name or "待补充"] += Decimal(claim.amount or 0)
|
|
return _format_amounts(buckets)
|
|
|
|
|
|
def _recent_pending_amounts(claims: list[ExpenseClaim]) -> dict[str, str]:
|
|
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
|
for claim in claims:
|
|
if claim.status not in PENDING_STATUSES:
|
|
continue
|
|
submitted_at = claim.submitted_at or claim.occurred_at
|
|
if not submitted_at:
|
|
continue
|
|
day = submitted_at.date()
|
|
if date(2026, 6, 1) <= day <= date(2026, 6, 2):
|
|
buckets[claim.department_name or "待补充"] += Decimal(claim.amount or 0)
|
|
return _format_amounts(buckets)
|
|
|
|
|
|
def _preview_claims(
|
|
claims: list[ExpenseClaim],
|
|
departments: dict[str, OrganizationUnit],
|
|
claim_plan: dict[str, int],
|
|
) -> list[ExpenseClaim]:
|
|
preview: list[ExpenseClaim] = []
|
|
recent_claims = _recent_claims(claims)
|
|
recent_claim_set = set(recent_claims)
|
|
regular_claims = [claim for claim in claims if claim not in recent_claim_set]
|
|
index = 0
|
|
for code, _weight in DEPARTMENT_PLAN:
|
|
department = departments[code]
|
|
count = max(claim_plan.get(code, 0) - RECENT_PENDING_PER_DEPARTMENT, 0)
|
|
for claim in regular_claims[index : index + count]:
|
|
preview.append(_clone_claim(claim, department.name, claim.status, claim.submitted_at))
|
|
index += count
|
|
recent_index = 0
|
|
for code, _weight in DEPARTMENT_PLAN:
|
|
department = departments[code]
|
|
for offset in range(RECENT_PENDING_PER_DEPARTMENT):
|
|
preview.append(
|
|
_clone_claim(
|
|
recent_claims[recent_index],
|
|
department.name,
|
|
"submitted",
|
|
RECENT_DATES[offset],
|
|
)
|
|
)
|
|
recent_index += 1
|
|
return preview
|
|
|
|
|
|
def _clone_claim(
|
|
claim: ExpenseClaim,
|
|
department_name: str,
|
|
status: str,
|
|
submitted_at: datetime | None,
|
|
) -> Any:
|
|
return type(
|
|
"ClaimPreview",
|
|
(),
|
|
{
|
|
"department_name": department_name,
|
|
"status": status,
|
|
"submitted_at": submitted_at,
|
|
"occurred_at": claim.occurred_at,
|
|
"expense_type": claim.expense_type,
|
|
"amount": claim.amount,
|
|
},
|
|
)()
|
|
|
|
|
|
def _planned_budget_counts(claims: list[Any]) -> tuple[int, int, int]:
|
|
allocation_keys = set()
|
|
transaction_count = 0
|
|
reservation_count = 0
|
|
for claim in claims:
|
|
if claim.status not in BUDGETED_STATUSES:
|
|
continue
|
|
submitted_at = claim.submitted_at or claim.occurred_at
|
|
period_key = f"{submitted_at.year}Q{((submitted_at.month - 1) // 3) + 1}"
|
|
allocation_keys.add((period_key, claim.department_name, getattr(claim, "expense_type", "")))
|
|
transaction_count += 1
|
|
reservation_count += int(claim.status in PENDING_STATUSES)
|
|
return len(allocation_keys), transaction_count, reservation_count
|
|
|
|
|
|
def _claim_cost_center(
|
|
claim: ExpenseClaim,
|
|
departments: dict[str, OrganizationUnit],
|
|
) -> str | None:
|
|
for department in departments.values():
|
|
if department.id == claim.department_id:
|
|
return department.cost_center
|
|
return None
|
|
|
|
|
|
def _format_amounts(buckets: dict[str, Decimal]) -> dict[str, str]:
|
|
return {
|
|
key: str(value.quantize(Decimal("0.01")))
|
|
for key, value in sorted(buckets.items(), key=lambda item: item[1], reverse=True)
|
|
}
|
|
|
|
|
|
def _hours(value: int):
|
|
from datetime import timedelta
|
|
|
|
return timedelta(hours=value)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|