import re from typing import List, Optional, Sequence from .math_round_div import round_div from .money_add import add_money from .money_amount import Money, assert_same_currency, money from .money_apply_rate import apply_rate from .money_compare import compare_money from .logistics_freight_rate_types import FreightRate, FuelSurcharge, FreightQuote _ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") def _whole(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def _in_force(valid_from: str, valid_to: Optional[str], on_date: str) -> bool: return on_date >= valid_from and (valid_to is None or on_date <= valid_to) def _round_up_to(grams: int, step: int) -> int: return round_div(grams, step, "up") * step def _charge(rate: FreightRate, rated_grams: int) -> Money: """The break's charge at a weight: base plus the per-kilogram element, at least the minimum.""" variable = round_div(rated_grams * rate.per_kg_minor, 1000, "half-up") freight = money(rate.base.minor + variable, rate.base.currency) if rate.minimum is not None and compare_money(freight, rate.minimum) < 0: return rate.minimum return freight def freight_rate( rates: Sequence[FreightRate], fuel_surcharges: Sequence[FuelSurcharge], zone: str, chargeable_grams: int, ship_date: str, ) -> FreightQuote: """Freight for a consignment on a caller-supplied tariff: the zone's break covering the weight on the ship date, or a heavier break when that is cheaper, plus the fuel surcharge in force that day.""" if not _whole(chargeable_grams) or chargeable_grams < 1: raise ValueError("chargeableGrams must be 1 or more, received %r" % (chargeable_grams,)) if not isinstance(ship_date, str) or not _ISO_DATE.fullmatch(ship_date): raise ValueError('shipDate must be an ISO date (YYYY-MM-DD), received "%s"' % (ship_date,)) candidates: List[FreightRate] = [ r for r in rates if r.zone == zone and _in_force(r.valid_from, r.valid_to, ship_date) ] for rate in candidates: if not _whole(rate.step_grams) or rate.step_grams < 1: raise ValueError("stepGrams must be 1 or more, received %r" % (rate.step_grams,)) if not _whole(rate.per_kg_minor) or rate.per_kg_minor < 0: raise ValueError("perKgMinor must not be negative, received %r" % (rate.per_kg_minor,)) assert_same_currency(candidates[0].base, rate.base) covering = [ r for r in candidates if chargeable_grams >= r.from_grams and (r.to_grams is None or chargeable_grams <= r.to_grams) ] if not covering: raise ValueError( 'no freight rate for zone "%s" on %s covers %d g' % (zone, ship_date, chargeable_grams) ) if len(covering) > 1: raise ValueError( 'two freight rates for zone "%s" on %s cover %d g' % (zone, ship_date, chargeable_grams) ) best = covering[0] best_grams = _round_up_to(chargeable_grams, best.step_grams) best_freight = _charge(best, best_grams) # Rates per kilogram fall as weight rises, so a heavier break charged at # its first weight can undercut the break the consignment falls in. for rate in candidates: if rate.from_grams <= chargeable_grams: continue grams = _round_up_to(rate.from_grams, rate.step_grams) freight = _charge(rate, grams) order = compare_money(freight, best_freight) if order < 0 or (order == 0 and grams < best_grams): best, best_grams, best_freight = rate, grams, freight basis_points = 0 if len(fuel_surcharges) > 0: found: Optional[FuelSurcharge] = None for row in fuel_surcharges: if not _in_force(row.valid_from, row.valid_to, ship_date): continue if found is None or row.valid_from > found.valid_from: found = row # A table that stops short of the date has usually not been updated. if found is None: raise ValueError("no fuel surcharge in force on %s" % ship_date) basis_points = found.basis_points fuel_surcharge = apply_rate(best_freight, basis_points, "half-up") return FreightQuote( zone=zone, rated_grams=best_grams, break_from_grams=best.from_grams, freight=best_freight, fuel_surcharge_basis_points=basis_points, fuel_surcharge=fuel_surcharge, total=add_money(best_freight, fuel_surcharge), )