dodil/erp-brick-expenses

DODIL ERP brick: expenses

src/brick_expenses/domain/policy.py

"""The expense policy (EXP-07): limits and bans per category, each a dated ROW finance edits (exp_policy_rule), never code.

Plain Python. A rule says: for this category (and optionally this city), an amount per item / per night / per person / per day above
`limit_amount` breaks the policy; `mode` is `warn` (the claim may be submitted, the approver sees it and the employee's reason) or `block`
(the claim cannot be submitted). `requires` names what excuses an excess: `attendees` (a meal split over the people named) or `justification`
(the employee's written reason). A limit of 0.00 is a ban (ALCOHOL). Each result names its row, so finance can change it without code.
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Optional

from brick_expenses.domain.money import money

SCOPES = ("per item", "per night", "per person", "per day")
MODES = ("warn", "block")
REQUIRES = ("", "attendees", "justification")


@dataclass(frozen=True)
class Rule:
    company_code: str
    rule_code: str
    valid_from: date
    category_code: str
    scope: str
    city_or_country: str = ""
    limit_amount: Decimal = Decimal("0.00")
    currency: str = ""
    mode: str = "warn"
    requires: str = ""
    message: str = ""


def in_force(rows: list, on: date) -> list:
    """The row of each (company, rule_code) in force on a date: the latest valid_from on or before it; a company's own row beats '*'."""
    best: dict = {}
    for r in rows:
        if r.valid_from > on:
            continue
        key = r.rule_code
        have = best.get(key)
        mine = r.company_code != "*"
        if have is None or (mine, r.valid_from) > (have.company_code != "*", have.valid_from):
            best[key] = r
    return sorted(best.values(), key=lambda r: r.rule_code)


def _applicable(rules: list, category_code: str, city: str) -> list:
    """Per (scope): the rule for the line's city if there is one, else the general ('' or '*') one."""
    by_scope: dict = {}
    for r in rules:
        if r.category_code != category_code:
            continue
        specific = bool(r.city_or_country) and r.city_or_country != "*"
        if specific and r.city_or_country.strip().lower() != (city or "").strip().lower():
            continue
        cur = by_scope.get(r.scope)
        if cur is None or (specific and not (bool(cur.city_or_country) and cur.city_or_country != "*")):
            by_scope[r.scope] = r
    return list(by_scope.values())


def evaluate(line, rules: list, currency: str) -> list:
    """The results of every rule a line breaks: [{rule_code, valid_from, mode, message, scope, limit, actual, excused}]. `rules` are the
    rows IN FORCE on the line's date (see in_force). A rule in another currency than the claim's is not compared."""
    out = []
    for r in _applicable(rules, line.category_code, line.city):
        if r.currency and r.currency != currency:
            continue
        if r.scope == "per night":
            actual = money(line.amount / max(int(line.nights or 1), 1))
        elif r.scope == "per person":
            actual = money(line.amount / max(len(line.attendees) or 1, 1))
        else:
            actual = line.amount
        if actual <= r.limit_amount:
            continue
        if r.requires == "attendees" and line.attendees and r.scope != "per person":
            continue
        justified = bool(line.justification.strip())
        needs_reason = r.requires == "justification" and not justified
        if r.requires == "justification" and justified and r.mode == "warn":
            mode = "warn"
        else:
            mode = "block" if (r.mode == "block" or needs_reason) else "warn"
        what = r.message or (f"{line.category_code} is not reimbursed" if r.limit_amount == 0 else
                             f"{line.category_code} {actual:.2f} {r.scope} is above {r.limit_amount:.2f}")
        out.append({"rule_code": r.rule_code, "company_code": r.company_code, "valid_from": r.valid_from.isoformat(), "mode": mode, "scope": r.scope,
                    "limit": f"{r.limit_amount:.2f}", "actual": f"{actual:.2f}", "requires": r.requires or None,
                    "message": f"{what} (policy {r.company_code if r.company_code != '*' else 'default'} {r.rule_code})"
                               + (": add a justification" if needs_reason else "")})
    return out


def blocks(lines: list) -> list:
    """[(line_no, message)] for every line that breaks a block rule."""
    return [(ln.line_no, res["message"]) for ln in lines for res in ln.policy_results if res["mode"] == "block"]


def changed(before: list, after: list) -> bool:
    key = lambda rs: sorted((x["rule_code"], x["valid_from"], x["mode"], x["actual"]) for x in rs)      # noqa: E731
    return key(before) != key(after)


def loosens(old: Optional[Rule], new: Rule) -> bool:
    """A change finance may not make alone (EXP-22): a block turned into a warn, or a limit raised on a block rule, or a ban lifted."""
    if old is None:
        return False
    return (old.mode == "block" and new.mode == "warn") or (old.mode == "block" and new.limit_amount > old.limit_amount)