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)