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