dodil/erp-brick-fixed-assets

DODIL ERP brick: fixed assets

src/brick_fixed_assets/domain/odoo.py

"""Mapping an Odoo account.asset to this register (module.yaml migration.odoo.maps) — pure functions over the rows the reader
port hands over. Exact Decimals; Odoo's `False` for an empty field is treated as empty."""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Optional

from brick_fixed_assets.domain.errors import RuleBroken
from brick_fixed_assets.domain.values import ZERO, money, period_of


def num(v) -> Decimal:
    return ZERO if not v else Decimal(str(v))


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


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


def method_map(method: str, progress_factor) -> tuple:
    """(our method, rate_pct): linear -> straight_line; degressive -> declining_balance at method_progress_factor x 100;
    degressive_then_linear -> declining_then_straight."""
    m = (method or "linear").lower()
    if m == "linear":
        return "straight_line", None
    rate = money(num(progress_factor) * 100) if progress_factor else None
    if m == "degressive":
        return "declining_balance", rate
    if m == "degressive_then_linear":
        return "declining_then_straight", rate
    raise RuleBroken(f"Odoo method {method!r} has no key here: map it by hand")


def life_periods(method_number, method_period) -> int:
    """method_number x method_period months (Odoo's method_period is 1 (monthly) or 12 (yearly))."""
    n = int(method_number or 0)
    p = int(method_period or 1)
    return max(n * p, 1)


def convention(prorata_computation_type) -> str:
    """none -> full_month; constant_periods -> full_month from prorata_date's month; daily_computation -> daily."""
    return {"daily_computation": "daily"}.get(prorata_computation_type or "none", "full_month")


def class_code(name: str) -> str:
    code = "".join(ch for ch in (name or "").upper() if ch.isalnum())[:24]
    return code or "ODOO"


def valid_moves(moves: list) -> list:
    """Posted depreciation moves that stand: a move Odoo reversed, and the reversal itself, cancel out."""
    reversed_ids = {ref_id(m.get("reversed_entry_id")) for m in moves if m.get("reversed_entry_id")}
    return [m for m in moves if m["id"] not in reversed_ids and not m.get("reversed_entry_id") and
            num(m.get("depreciation_value")) != 0 and not m.get("asset_value_change")]


def split(moves: list, fy_start: date, cutover: date) -> tuple:
    """(depreciation before the fiscal year start, depreciation in the year to the cutover, periods depreciated)."""
    before = in_year = ZERO
    n = 0
    for m in valid_moves(moves):
        d = iso(m["date"])
        if d is None or d > cutover:
            continue
        v = num(m.get("depreciation_value"))
        n += 1
        if d < fy_start:
            before += v
        else:
            in_year += v
    return before, in_year, n


def value_changes(moves: list, cutover: date) -> Decimal:
    """asset_value_change moves (increases / decreases) as Odoo stored them: folded into cost, listed for the door."""
    return sum((num(m.get("depreciation_value")) for m in moves if m.get("asset_value_change") and
                (iso(m["date"]) or cutover) <= cutover), ZERO)


def fy_start_of(cutover: date, fy_end_month: int) -> date:
    """The first day of the fiscal year the cutover date is in (the year ends on the last day of fy_end_month)."""
    if fy_end_month == 12:
        return date(cutover.year, 1, 1)
    first_month = fy_end_month + 1
    year = cutover.year if cutover.month > fy_end_month else cutover.year - 1
    return date(year, first_month, 1)


@dataclass(frozen=True)
class Carried:
    """What one running asset brings: cost, accumulated depreciation at the start of the fiscal year, the year's charge, the
    life left in periods, and the first period depreciated here."""
    cost: Decimal
    acc_start: Decimal
    in_year: Decimal
    remaining: int
    first_period: str
    nbv: Decimal
    scrap: Decimal


def carry(asset: dict, moves: list, fy_start: date, cutover: date, life: int) -> Carried:
    before, in_year, n = split(moves, fy_start, cutover)
    cost = num(asset.get("original_value")) + value_changes(moves, cutover)
    acc_start = before + num(asset.get("already_depreciated_amount_import"))
    nbv = cost - acc_start - in_year
    from brick_fixed_assets.domain.values import period_add
    return Carried(cost, acc_start, in_year, max(life - n, 0), period_add(period_of(cutover), 1), nbv,
                   num(asset.get("salvage_value")))