dodil/erp-brick-gl

DODIL ERP brick: general ledger

src/brick_gl/domain/odoo_map.py

"""How Odoo's accounting maps onto the books (module.yaml migration.odoo.maps): pure functions, no I/O."""
import re
from decimal import ROUND_HALF_EVEN, Decimal
from typing import Optional

from brick_gl.domain.money import ZERO

SIX = Decimal("0.000001")


def account_type(row: dict) -> tuple:
    """(type, subtype) of an Odoo account: account_type on 16+, user_type + internal_type on 15 and older."""
    t = (row.get("account_type") or "").lower()
    internal = (row.get("internal_type") or "").lower()
    user = (row.get("user_type") or "").lower()
    if t:
        if t == "asset_receivable":
            return "asset", "receivable"
        if t == "asset_cash":
            return "asset", "bank"
        if t == "liability_payable":
            return "liability", "payable"
        if t == "equity_unaffected":
            return "equity", "retained_earnings"
        for prefix, typ in (("asset", "asset"), ("liability", "liability"), ("equity", "equity"), ("income", "income"), ("expense", "expense")):
            if t.startswith(prefix):
                return typ, "other"
        return "asset", "other"
    if internal == "receivable":
        return "asset", "receivable"
    if internal == "payable":
        return "liability", "payable"
    if internal == "liquidity":
        return "asset", "bank"
    for key, typ in (("income", "income"), ("revenue", "income"), ("expense", "expense"), ("cost", "expense"), ("equity", "equity"),
                     ("liabilit", "liability"), ("payable", "liability"), ("asset", "asset"), ("receivable", "asset"), ("bank", "asset")):
        if key in user:
            return typ, "other"
    return "asset", "other"


def book_for_journal_type(kind: str) -> str:
    return {"sale": "sales", "purchase": "purchases", "bank": "cash", "cash": "cash", "general": "misc"}.get(kind or "", "misc")


def parse_entry_number(name: str) -> Optional[tuple]:
    """('INV/2026/0042' -> (2026, 42)): the year and counter Odoo's sequence rendered, else None (a name that is not a sequence)."""
    m = re.search(r"(\d{4})\D+0*(\d+)\s*$", name or "")
    if m and 1900 < int(m.group(1)) < 2200:
        return int(m.group(1)), int(m.group(2))
    return None


def rate_from_odoo(row: dict) -> Optional[Decimal]:
    """Our rate (units of the company currency for one of the foreign currency) from Odoo's: its inverse_company_rate (16+), else 1 / rate
    rounded half-even to six places."""
    inv = row.get("inverse_company_rate")
    if inv not in (None, "", 0, "0"):
        return Decimal(str(inv)).quantize(SIX, rounding=ROUND_HALF_EVEN)
    r = Decimal(str(row.get("rate") or 0))
    if r == 0:
        return None
    return (Decimal(1) / r).quantize(SIX, rounding=ROUND_HALF_EVEN)


def recurring_frequency(auto_post: str) -> Optional[str]:
    return {"monthly": "monthly", "quarterly": "quarterly", "yearly": "yearly"}.get(auto_post or "")


def share_pcts(distribution: Optional[dict]) -> list:
    """[(analytic account id, share 0..1)] largest share first (ties: lowest id) from an analytic_distribution {"7": 60.0, "9": 40.0}."""
    out = []
    for k, v in (distribution or {}).items():
        try:
            out.append((int(k), Decimal(str(v)) / 100))
        except (ValueError, ArithmeticError):
            continue
    return sorted(out, key=lambda x: (-x[1], x[0]))


def quarter(d) -> str:
    return f"{d.year}-Q{(d.month - 1) // 3 + 1}"


def zero() -> Decimal:
    return ZERO