Functional Weave
Code in Python

logistics.demurrage@1.0.0

impl/python.py

3,503 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import List, Optional, Sequence

from .dates_add_days import add_days  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_days_between import days_between  ← from dates.days-between ^1.0.0 · built alongside by fune
from .money_amount import assert_same_currency, money  ← from money.amount ^1.0.0 · built alongside by fune
from .money_sum import sum_money  ← from money.sum ^1.0.0 · built alongside by fune
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),
    )