dodil/erp-brick-expenses

DODIL ERP brick: expenses

src/brick_expenses/domain/odoo.py

"""Odoo 17's hr_expense as our claims (EXP-24): the arithmetic of the mapping, no I/O.

Plain Python. History is carried read-only and never re-priced or recomputed: a sheet's amounts are Odoo's own, to the penny. What is decided here is only
the status a sheet arrives in and the keys it keeps:
  state post  -> posted (Odoo's journal entry is carried by gl's migration; this brick posts nothing for it)
  state done  -> paid (history)
  state approve -> approved: Odoo booked nothing for it, so the normal path posts it (the journal row waits in the outbox) and it is paid here
  a post sheet whose payment_state is paid is paid; draft, submit and cancel sheets are not carried (finished in Odoo, or re-entered)."""
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Optional

from brick_expenses.domain.errors import RuleBroken
from brick_expenses.domain.money import ZERO, money

CARRIED = ("approve", "post", "done")
MODELS = ("hr.expense.sheet", "hr.expense", "product.product", "ir.attachment")


def ref_id(v) -> Optional[int]:
    """An Odoo many2one: [id, name] or an id (or False)."""
    if isinstance(v, (list, tuple)):
        return int(v[0]) if v else None
    return int(v) if v else None


def ref_name(v) -> str:
    return str(v[1]) if isinstance(v, (list, tuple)) and len(v) > 1 else ""


def report_id(db: str, sheet_id) -> str:
    return f"odoo:{db}:hr.expense.sheet:{int(sheet_id)}"


def status_of(state: str, payment_state: Optional[str]) -> str:
    if state == "done" or (state == "post" and payment_state == "paid"):
        return "paid"
    if state == "post":
        return "posted"
    if state == "approve":
        return "approved"
    raise RuleBroken(f"an Odoo sheet in state {state!r} is not carried (draft, submitted and cancelled sheets are finished in Odoo)")


def iso(v) -> Optional[date]:
    if not v:
        return None
    return v if isinstance(v, date) else date.fromisoformat(str(v)[:10])


def amount(v, what: str) -> Decimal:
    try:
        d = Decimal(str(v if v not in (None, False) else 0))
    except InvalidOperation:
        raise RuleBroken(f"Odoo's {what} {v!r} is not an amount") from None
    return money(d)


def month_of(d: date) -> str:
    return f"{d.year}-{d.month:02d}"


def sheet_day(sheet: dict) -> date:
    """The day a sheet is dated for the month check: Odoo's accounting date, else its approval date."""
    d = iso(sheet.get("accounting_date")) or iso(sheet.get("approval_date"))
    if d is None:
        raise RuleBroken(f"Odoo sheet {sheet.get('id')} has neither an accounting date nor an approval date")
    return d


def own_total(lines: list) -> Decimal:
    """What the employee is owed on a sheet: the own_account lines, in the company currency (Odoo's total_amount)."""
    return money(sum((amount(x.get("total_amount"), "total_amount") for x in lines if x.get("payment_mode") != "company_account"), ZERO))