Source code for translator_directory.models.time_report

from __future__ import annotations

from decimal import Decimal
from uuid import uuid4

from onegov.core.orm import Base
from onegov.core.orm.mixins import TimestampMixin
from onegov.core.orm.types import UUID, UTCDateTime
from sqlalchemy import ARRAY, Column, Date, Enum, ForeignKey, Integer, Numeric
from sqlalchemy import Text
from sqlalchemy.orm import relationship


from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
    import uuid
    from datetime import date, datetime
    from sqlalchemy.orm import Session
    from .translator import Translator
    from onegov.user import User
    from .ticket import TimeReportTicket

[docs] TimeReportStatus = Literal['pending', 'confirmed']
[docs] SurchargeType = Literal['night_work', 'weekend_holiday', 'urgent']
[docs] class TranslatorTimeReport(Base, TimestampMixin):
[docs] __tablename__ = 'translator_time_reports'
[docs] id: Column[uuid.UUID] = Column( UUID, # type:ignore[arg-type] primary_key=True, default=uuid4, )
[docs] translator_id: Column[uuid.UUID] = Column( UUID, # type:ignore[arg-type] ForeignKey('translators.id', ondelete='CASCADE'), nullable=False, )
[docs] translator: relationship[Translator] = relationship( 'Translator', back_populates='time_reports' )
[docs] created_by_id: Column[uuid.UUID | None] = Column( UUID, # type:ignore[arg-type] ForeignKey('users.id', ondelete='SET NULL'), nullable=True, )
[docs] created_by: relationship[User | None] = relationship('User')
[docs] assignment_type: Column[str | None] = Column(Text)
#: The duration in minutes (total work time excluding breaks)
[docs] duration: Column[int] = Column(Integer, nullable=False)
#: Break time in minutes
[docs] break_time: Column[int] = Column(Integer, nullable=False, default=0)
#: Night work duration in MINUTES (20:00-06:00)
[docs] night_minutes: Column[int] = Column(Integer, nullable=False, default=0)
#: Weekend/holiday work duration in MINUTES
[docs] weekend_holiday_minutes: Column[int] = Column( Integer, nullable=False, default=0 )
[docs] case_number: Column[str | None] = Column(Text)
[docs] assignment_date: Column[date] = Column(Date, nullable=False)
[docs] start: Column[datetime | None] = Column(UTCDateTime)
[docs] end: Column[datetime | None] = Column(UTCDateTime)
[docs] hourly_rate: Column[Decimal] = Column( Numeric(precision=10, scale=2), nullable=False, )
[docs] surcharge_types: Column[list[str] | None] = Column( ARRAY(Text), nullable=True, default=list, )
[docs] travel_compensation: Column[Decimal] = Column( Numeric(precision=10, scale=2), nullable=False, default=0, )
[docs] total_compensation: Column[Decimal] = Column( Numeric(precision=10, scale=2), nullable=False, )
[docs] notes: Column[str | None] = Column(Text)
[docs] status: Column[TimeReportStatus] = Column( Enum('pending', 'confirmed', name='time_report_status'), # type: ignore[arg-type] nullable=False, default='pending', )
[docs] SURCHARGE_RATES: dict[str, Decimal] = { 'night_work': Decimal('50'), 'weekend_holiday': Decimal('25'), 'urgent': Decimal('25'), }
@property
[docs] def duration_hours(self) -> Decimal: """Return duration in hours for display.""" return Decimal(self.duration) / Decimal(60)
@property
[docs] def break_time_hours(self) -> Decimal: """Return break time in hours for display.""" return Decimal(self.break_time) / Decimal(60)
@property
[docs] def night_hours_decimal(self) -> Decimal: """Return night hours in decimal format for calculations.""" return Decimal(self.night_minutes) / Decimal(60)
@property
[docs] def weekend_holiday_hours_decimal(self) -> Decimal: """Return weekend/holiday hours in decimal format for calculations.""" return Decimal(self.weekend_holiday_minutes) / Decimal(60)
@property
[docs] def day_hours_decimal(self) -> Decimal: """Return day hours (total - night) in decimal format.""" day_hours = self.duration_hours - self.night_hours_decimal # Ensure non-negative (handle rounding edge cases) return max(day_hours, Decimal('0'))
@property
[docs] def night_hourly_rate(self) -> Decimal: """Return night hourly rate (base rate + 50% surcharge).""" return self.hourly_rate * ( 1 + self.SURCHARGE_RATES['night_work'] / 100 )
[docs] def calculate_compensation_breakdown(self) -> dict[str, Decimal]: """Calculate detailed compensation breakdown. Returns a dictionary with all compensation components: - day_pay: Payment for day hours (base rate) - night_pay: Payment for night hours (base rate + 50% surcharge) - night_surcharge: Just the surcharge portion for night hours - weekend_surcharge: Weekend surcharge (only on non-night hours) - urgent_surcharge: Urgent surcharge (25% on top of everything) - total_surcharges: Sum of all surcharges - subtotal: Total work compensation (before travel/meal) - travel: Travel compensation - meal: Meal allowance - total: Final total compensation """ hourly_rate = self.hourly_rate total_hours = self.duration_hours night_hours = self.night_hours_decimal surcharge_types = self.surcharge_types or [] # Initialize all surcharge values night_pay = Decimal('0') night_surcharge = Decimal('0') weekend_surcharge = Decimal('0') urgent_surcharge = Decimal('0') # Calculate day/night pay if night_hours > 0: # We have night work - split into day and night day_hours = total_hours - night_hours day_pay = hourly_rate * day_hours night_pay = self.night_hourly_rate * night_hours # Just the extra 50% portion night_surcharge = night_pay - (hourly_rate * night_hours) else: # No night work - all hours are day hours day_pay = hourly_rate * total_hours # Weekend/holiday surcharge (only on non-night hours) if 'weekend_holiday' in surcharge_types: weekend_holiday_hours = self.weekend_holiday_hours_decimal # Weekend surcharge only applies to hours that aren't night hours weekend_non_night_hours = weekend_holiday_hours - min( weekend_holiday_hours, night_hours ) rate = self.SURCHARGE_RATES['weekend_holiday'] / 100 weekend_surcharge = (hourly_rate * weekend_non_night_hours) * rate # Urgent surcharge stacks on top of actual work compensation if 'urgent' in surcharge_types: actual_work_pay = day_pay + night_pay + weekend_surcharge rate = self.SURCHARGE_RATES['urgent'] / 100 urgent_surcharge = actual_work_pay * rate # Totals total_surcharges = ( night_surcharge + weekend_surcharge + urgent_surcharge ) subtotal = day_pay + night_pay + weekend_surcharge + urgent_surcharge travel = self.travel_compensation meal = self.meal_allowance total = subtotal + travel + meal return { 'day_pay': day_pay, 'night_pay': night_pay, 'night_surcharge': night_surcharge, 'weekend_surcharge': weekend_surcharge, 'urgent_surcharge': urgent_surcharge, 'total_surcharges': total_surcharges, 'subtotal': subtotal, 'travel': travel, 'meal': meal, 'total': total, }
@property
[docs] def base_compensation(self) -> Decimal: """Calculate compensation without travel and meal (work compensation only). This uses the centralized breakdown calculation which handles partial night work correctly. """ breakdown = self.calculate_compensation_breakdown() return breakdown['subtotal']
@property
[docs] def meal_allowance(self) -> Decimal: """Return meal allowance if duration >= 6 hours.""" return Decimal('40.0') if self.duration_hours >= 6 else Decimal('0')
@property
[docs] def title(self) -> str: """Return a readable title for this time report.""" date_str = self.assignment_date.strftime('%Y-%m-%d') if self.assignment_type: return f'{self.assignment_type} - {date_str}' return f'Time Report - {date_str}'
[docs] def get_ticket(self, session: Session) -> TimeReportTicket | None: """Get the ticket associated with this time report.""" from onegov.translator_directory.models.ticket import TimeReportTicket return ( session.query(TimeReportTicket) .filter( TimeReportTicket.handler_data['handler_data'][ 'time_report_id' ].astext == str(self.id) ) .first() )