mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-21 05:40:26 -05:00
b4486a627f
- Webhook models: remove duplicate index definitions so db.create_all() no longer raises 'index already exists' (columns already have index=True) - ImportService: fix circular import by late-importing ClientService, ProjectService, TimeTrackingService in __init__ - reports: fix F823 by renaming unpack variable _ to _entry_count to avoid shadowing gettext _ in export_task_excel() - Code quality: add .flake8 with extend-ignore so flake8 CI passes; simplify pyproject.toml isort config (drop unsupported options) - Format: run black and isort on app/ - tests: restore minimal app fixture in test_import_export_models
224 lines
8.3 KiB
Python
224 lines
8.3 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime, timedelta
|
|
from decimal import ROUND_HALF_UP, Decimal
|
|
from typing import Iterable, List, Optional, Set
|
|
|
|
from app import db
|
|
from app.models import Client, ClientPrepaidConsumption, Invoice, TimeEntry
|
|
|
|
SECONDS_IN_HOUR = Decimal("3600")
|
|
TWO_DECIMALS = Decimal("0.01")
|
|
|
|
|
|
@dataclass
|
|
class ProcessedTimeEntry:
|
|
"""Result container describing how a time entry was treated."""
|
|
|
|
entry: TimeEntry
|
|
billable_hours: Decimal
|
|
prepaid_hours: Decimal
|
|
allocation_month: Optional[date]
|
|
|
|
|
|
@dataclass
|
|
class PrepaidMonthSummary:
|
|
"""Summary of prepaid plan usage for a given cycle."""
|
|
|
|
allocation_month: date
|
|
plan_hours: Decimal
|
|
consumed_hours: Decimal
|
|
remaining_hours: Decimal
|
|
|
|
|
|
class PrepaidHoursAllocator:
|
|
"""Encapsulates prepaid hour allocation logic for a client."""
|
|
|
|
def __init__(self, client: Client, invoice: Optional[Invoice] = None):
|
|
self.client = client
|
|
self.invoice = invoice
|
|
self.plan_hours = client.prepaid_hours_decimal if client else Decimal("0")
|
|
self.total_prepaid_hours_assigned = Decimal("0")
|
|
self._consumed_by_period: dict[date, Decimal] = {}
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Public API
|
|
# ----------------------------------------------------------------------
|
|
def process(self, entries: Iterable[TimeEntry]) -> List[ProcessedTimeEntry]:
|
|
"""Allocate prepaid hours for the provided time entries."""
|
|
entries = list(entries or [])
|
|
|
|
if not entries:
|
|
return []
|
|
|
|
# Always work against a deterministic ordering
|
|
entries.sort(key=lambda e: (e.start_time or datetime.min))
|
|
|
|
if not self.client or not self.client.prepaid_plan_enabled:
|
|
return [
|
|
ProcessedTimeEntry(
|
|
entry=entry,
|
|
billable_hours=self._hours_from_entry(entry),
|
|
prepaid_hours=Decimal("0"),
|
|
allocation_month=self._allocation_month(entry),
|
|
)
|
|
for entry in entries
|
|
]
|
|
|
|
self._reset_invoice_allocations()
|
|
months = self._collect_months(entries)
|
|
self._load_existing_consumption(months)
|
|
|
|
processed: List[ProcessedTimeEntry] = []
|
|
|
|
for entry in entries:
|
|
hours = self._hours_from_entry(entry)
|
|
allocation_month = self._allocation_month(entry)
|
|
|
|
if hours <= 0 or allocation_month is None:
|
|
processed.append(
|
|
ProcessedTimeEntry(
|
|
entry=entry,
|
|
billable_hours=Decimal("0"),
|
|
prepaid_hours=Decimal("0"),
|
|
allocation_month=allocation_month,
|
|
)
|
|
)
|
|
continue
|
|
|
|
remaining = self._remaining_allowance(allocation_month)
|
|
prepaid_hours = self._quantize_hours(min(hours, remaining) if remaining > 0 else Decimal("0"))
|
|
billable_hours = self._quantize_hours(hours - prepaid_hours)
|
|
|
|
if prepaid_hours > 0:
|
|
self._record_consumption(entry, allocation_month, prepaid_hours)
|
|
entry.billable = billable_hours > 0
|
|
else:
|
|
entry.billable = True
|
|
|
|
processed.append(
|
|
ProcessedTimeEntry(
|
|
entry=entry,
|
|
billable_hours=billable_hours,
|
|
prepaid_hours=prepaid_hours,
|
|
allocation_month=allocation_month,
|
|
)
|
|
)
|
|
|
|
self.total_prepaid_hours_assigned = sum((item.prepaid_hours for item in processed), Decimal("0"))
|
|
return processed
|
|
|
|
def build_summary(self, entries: Iterable[TimeEntry]) -> List[PrepaidMonthSummary]:
|
|
"""Return prepaid period summaries for UI purposes (no DB mutations)."""
|
|
if not self.client or not self.client.prepaid_plan_enabled:
|
|
return []
|
|
|
|
entries = list(entries or [])
|
|
months = self._collect_months(entries)
|
|
if not months:
|
|
return []
|
|
|
|
# Reset local cache and load existing consumption without mutating data
|
|
self._consumed_by_period = {}
|
|
self._load_existing_consumption(months)
|
|
|
|
summaries: List[PrepaidMonthSummary] = []
|
|
for month in sorted(months):
|
|
consumed = self._quantize_hours(self._consumed_by_period.get(month, Decimal("0")))
|
|
remaining = self._quantize_hours(self.plan_hours - consumed)
|
|
if remaining < 0:
|
|
remaining = Decimal("0").quantize(TWO_DECIMALS)
|
|
summaries.append(
|
|
PrepaidMonthSummary(
|
|
allocation_month=month,
|
|
plan_hours=self._quantize_hours(self.plan_hours),
|
|
consumed_hours=consumed,
|
|
remaining_hours=remaining,
|
|
)
|
|
)
|
|
return summaries
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ----------------------------------------------------------------------
|
|
def _reset_invoice_allocations(self):
|
|
"""Remove existing ledger rows tied to the invoice before recalculating."""
|
|
if not self.invoice:
|
|
return
|
|
|
|
existing_allocations = ClientPrepaidConsumption.query.filter_by(invoice_id=self.invoice.id).all()
|
|
if not existing_allocations:
|
|
return
|
|
|
|
entry_ids = [allocation.time_entry_id for allocation in existing_allocations]
|
|
if entry_ids:
|
|
entries = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids)).all()
|
|
for entry in entries:
|
|
entry.billable = True
|
|
|
|
ClientPrepaidConsumption.query.filter_by(invoice_id=self.invoice.id).delete(synchronize_session=False)
|
|
db.session.flush()
|
|
|
|
def _collect_months(self, entries: Iterable[TimeEntry]) -> Set[date]:
|
|
months: Set[date] = set()
|
|
for entry in entries:
|
|
allocation_month = self._allocation_month(entry)
|
|
if allocation_month:
|
|
months.add(allocation_month)
|
|
return months
|
|
|
|
def _load_existing_consumption(self, months: Set[date]):
|
|
if not months:
|
|
return
|
|
|
|
query = ClientPrepaidConsumption.query.filter(
|
|
ClientPrepaidConsumption.client_id == self.client.id, ClientPrepaidConsumption.allocation_month.in_(months)
|
|
)
|
|
|
|
if self.invoice:
|
|
query = query.filter(ClientPrepaidConsumption.invoice_id != self.invoice.id)
|
|
|
|
for row in query:
|
|
hours = Decimal(row.seconds_consumed or 0) / SECONDS_IN_HOUR
|
|
month = row.allocation_month
|
|
self._consumed_by_period[month] = self._consumed_by_period.get(month, Decimal("0")) + hours
|
|
|
|
def _allocation_month(self, entry: TimeEntry) -> Optional[date]:
|
|
if not entry or not entry.start_time:
|
|
return None
|
|
return self.client.prepaid_month_start(entry.start_time)
|
|
|
|
def _remaining_allowance(self, month: date) -> Decimal:
|
|
consumed = self._consumed_by_period.get(month, Decimal("0"))
|
|
remaining = self.plan_hours - consumed
|
|
return remaining if remaining > 0 else Decimal("0")
|
|
|
|
def _record_consumption(self, entry: TimeEntry, month: date, prepaid_hours: Decimal):
|
|
if prepaid_hours <= 0:
|
|
return
|
|
|
|
seconds = int((prepaid_hours * SECONDS_IN_HOUR).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
|
consumption = ClientPrepaidConsumption(
|
|
client_id=self.client.id,
|
|
time_entry_id=entry.id,
|
|
invoice_id=self.invoice.id if self.invoice else None,
|
|
allocation_month=month,
|
|
seconds_consumed=seconds,
|
|
)
|
|
db.session.add(consumption)
|
|
|
|
# Update cache to reflect newly allocated hours
|
|
self._consumed_by_period[month] = self._consumed_by_period.get(month, Decimal("0")) + prepaid_hours
|
|
|
|
@staticmethod
|
|
def _hours_from_entry(entry: TimeEntry) -> Decimal:
|
|
duration_seconds = entry.duration_seconds or 0
|
|
return (Decimal(duration_seconds) / SECONDS_IN_HOUR).quantize(TWO_DECIMALS)
|
|
|
|
@staticmethod
|
|
def _quantize_hours(value: Decimal) -> Decimal:
|
|
if value is None:
|
|
return Decimal("0").quantize(TWO_DECIMALS)
|
|
return value.quantize(TWO_DECIMALS)
|