from typing import List, Optional, Sequence from .dates_add_days import add_days from .dates_days_between import days_between from .money_amount import assert_same_currency, money from .money_sum import sum_money from .logistics_demurrage_types import DemurrageTier, DemurrageLine, DemurrageCharge def _whole(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def demurrage( start_date: str, end_date: str, free_days: int, tiers: Sequence[DemurrageTier], ) -> DemurrageCharge: """Demurrage or detention for one container: calendar days from start_date to end_date inclusive, the first free_days free, every later day at the rate of the tier whose day numbers contain it.""" if not _whole(free_days): raise TypeError("freeDays must be an integer, received %r" % (free_days,)) if free_days < 0: raise ValueError("freeDays must not be negative, received %d" % free_days) total_days = days_between(start_date, end_date) + 1 if total_days < 1: raise ValueError("endDate must not be before startDate: %s to %s" % (start_date, end_date)) if len(tiers) == 0: raise ValueError("tiers must not be empty") for tier in tiers: if not _whole(tier.from_day) or tier.from_day < 1: raise ValueError("tier fromDay must be 1 or more, received %r" % (tier.from_day,)) if tier.to_day is not None and (not _whole(tier.to_day) or tier.to_day < tier.from_day): raise ValueError( "tier toDay must not be before fromDay, received %r to %r" % (tier.from_day, tier.to_day) ) if tier.daily_rate.minor < 0: raise ValueError("dailyRate must not be negative, received %d" % tier.daily_rate.minor) assert_same_currency(tiers[0].daily_rate, tier.daily_rate) ordered = sorted(tiers, key=lambda t: t.from_day) for i in range(1, len(ordered)): previous = ordered[i - 1] if previous.to_day is None or previous.to_day >= ordered[i].from_day: raise ValueError("tiers overlap at day %d" % ordered[i].from_day) lines: List[DemurrageLine] = [] day = free_days + 1 for tier in ordered: if day > total_days: break if tier.to_day is not None and tier.to_day < day: continue # wholly inside free time # A gap is a mistake in the tariff; charging nothing for it would hide it. if tier.from_day > day: raise ValueError("no rate for day %d: no tier covers it" % day) last = total_days if tier.to_day is None else min(tier.to_day, total_days) days = last - day + 1 lines.append( DemurrageLine( from_day=day, to_day=last, days=days, daily_rate=tier.daily_rate, amount=money(tier.daily_rate.minor * days, tier.daily_rate.currency), ) ) day = last + 1 if day <= total_days: raise ValueError("no rate for day %d: no tier covers it" % day) free_time_ends: Optional[str] = add_days(start_date, free_days - 1) if free_days > 0 else None return DemurrageCharge( total_days=total_days, free_days_used=min(free_days, total_days), chargeable_days=max(total_days - free_days, 0), free_time_ends=free_time_ends, lines=lines, total=sum_money([line.amount for line in lines], tiers[0].daily_rate.currency), )