"""Hardware-asset depreciation math — pure, deterministic, testable. The financial lifecycle of a hardware asset is: purchase (cost @ date) → depreciation (book value declines over the useful life) → repair investment (adds to total cost of ownership) → disposal (proceeds vs. net book value ⇒ gain / loss) This module owns ONLY the depreciation arithmetic. It has no DB / ORM dependency so it can be unit-tested in isolation and reused by the service layer, the monthly snapshot cron, and the per-asset financial profile endpoint without importing the world. Two methods are supported, both standard accounting models: * ``straight_line`` — equal depreciation each month over the useful life. ``monthly = (cost - residual) / useful_life_months``. * ``declining_balance`` — depreciate a fixed *rate* of the remaining book value each month (``annual_rate / 12``), floored at the residual value and forced to the residual once the useful life elapses (the conventional "switch to nothing left" terminal behaviour). The residual value is ``cost * residual_rate`` (``residual_rate`` is a 0..1 fraction — e.g. ``0.05`` keeps 5% salvage). Net book value never drops below the residual. """ from __future__ import annotations from dataclasses import dataclass from datetime import date from decimal import ROUND_HALF_UP, Decimal METHOD_STRAIGHT_LINE = "straight_line" METHOD_DECLINING_BALANCE = "declining_balance" ALLOWED_METHODS: tuple[str, ...] = ( METHOD_STRAIGHT_LINE, METHOD_DECLINING_BALANCE, ) # Sensible platform defaults when no per-category policy is configured. # 3-year straight-line, 5% salvage — the typical laptop/electronics # convention. Admins override per category in 折旧策略 management. DEFAULT_USEFUL_LIFE_MONTHS = 36 DEFAULT_RESIDUAL_RATE = Decimal("0.05") DEFAULT_METHOD = METHOD_STRAIGHT_LINE _CENTS = Decimal("0.01") def _q(value: Decimal) -> Decimal: """Quantise to 2 decimal places, half-up (currency rounding).""" return value.quantize(_CENTS, rounding=ROUND_HALF_UP) @dataclass(frozen=True) class DepreciationPolicy: """Resolved depreciation policy for one asset. Constructed from an ``AssetDepreciationPolicy`` row (per category or the platform default), the asset's own ``usage_limit_months`` (which takes precedence when set — an admin annotated a specific lifetime on that unit), and the module defaults as the final fallback. """ method: str = DEFAULT_METHOD useful_life_months: int = DEFAULT_USEFUL_LIFE_MONTHS residual_rate: Decimal = DEFAULT_RESIDUAL_RATE # Annual rate (0..1) for declining_balance; ignored for straight_line. declining_rate: Decimal | None = None def normalised(self) -> DepreciationPolicy: method = self.method if self.method in ALLOWED_METHODS else DEFAULT_METHOD life = self.useful_life_months if self.useful_life_months and self.useful_life_months > 0 else DEFAULT_USEFUL_LIFE_MONTHS residual = self.residual_rate if self.residual_rate is not None else DEFAULT_RESIDUAL_RATE if residual < 0: residual = Decimal("0") if residual > 1: residual = Decimal("1") decl = self.declining_rate if method == METHOD_DECLINING_BALANCE: if decl is None or decl <= 0: # No explicit rate — derive the double-declining rate # from the useful life (2 / years), the textbook default. years = max(Decimal("0.5"), Decimal(life) / Decimal(12)) decl = (Decimal("2") / years) if decl > 1: decl = Decimal("1") return DepreciationPolicy( method=method, useful_life_months=life, residual_rate=residual, declining_rate=decl, ) @dataclass(frozen=True) class BookValue: """Computed depreciation state of one asset at a point in time.""" original_cost: Decimal residual_value: Decimal elapsed_months: int useful_life_months: int accumulated_depreciation: Decimal net_book_value: Decimal fully_depreciated: bool # True when the asset carries cost but no purchase_date, so it could # not be aged — net book value falls back to the full cost and the # caller surfaces it as "undepreciable" for honesty. undated: bool = False def months_between(start: date, end: date) -> int: """Whole calendar months elapsed from ``start`` to ``end`` (>= 0). Counts a month as elapsed only once the day-of-month is reached, so an asset purchased on the 20th has depreciated 0 months on the 19th of the next month and 1 month on the 20th. """ if end <= start: return 0 months = (end.year - start.year) * 12 + (end.month - start.month) if end.day < start.day: months -= 1 return max(0, months) def compute_book_value( cost: Decimal | None, purchase_date: date | None, policy: DepreciationPolicy, as_of: date, ) -> BookValue | None: """Net book value of an asset at ``as_of``. Returns ``None`` when the asset has no usable cost (nothing to depreciate). When the asset has a cost but no purchase date it is treated as undepreciated (NBV == cost) and flagged ``undated``. """ if cost is None: return None cost = Decimal(cost) if cost <= 0: return None pol = policy.normalised() residual = _q(cost * pol.residual_rate) if residual > cost: residual = cost if purchase_date is None: return BookValue( original_cost=_q(cost), residual_value=residual, elapsed_months=0, useful_life_months=pol.useful_life_months, accumulated_depreciation=Decimal("0.00"), net_book_value=_q(cost), fully_depreciated=False, undated=True, ) elapsed = months_between(purchase_date, as_of) life = max(1, pol.useful_life_months) if elapsed >= life: nbv = residual elif pol.method == METHOD_DECLINING_BALANCE and pol.declining_rate: monthly_factor = Decimal("1") - (pol.declining_rate / Decimal("12")) if monthly_factor < 0: monthly_factor = Decimal("0") nbv = cost * (monthly_factor ** elapsed) if nbv < residual: nbv = residual else: # straight_line monthly_dep = (cost - residual) / Decimal(life) nbv = cost - (monthly_dep * Decimal(elapsed)) if nbv < residual: nbv = residual nbv = _q(nbv) accumulated = _q(cost - nbv) return BookValue( original_cost=_q(cost), residual_value=residual, elapsed_months=elapsed, useful_life_months=life, accumulated_depreciation=accumulated, net_book_value=nbv, fully_depreciated=elapsed >= life, ) def build_schedule( cost: Decimal | None, purchase_date: date | None, policy: DepreciationPolicy, as_of: date, ) -> list[dict]: """Annual depreciation checkpoints over the asset's useful life. Produces one point per year from purchase to end-of-life (plus the end-of-life terminal point), each with the as-of-year-end net book value and accumulated depreciation. Used by the per-asset financial timeline. Returns ``[]`` when the asset can't be depreciated. """ if cost is None or Decimal(cost) <= 0 or purchase_date is None: return [] pol = policy.normalised() life = max(1, pol.useful_life_months) points: list[dict] = [] # Year boundaries: 12, 24, ... up to and including the life, plus the # exact end-of-life month if it isn't a clean year multiple. checkpoints = list(range(12, life + 1, 12)) if not checkpoints or checkpoints[-1] != life: checkpoints.append(life) checkpoints = [0, *checkpoints] for months in checkpoints: point_date = _add_months(purchase_date, months) bv = compute_book_value(cost, purchase_date, pol, point_date) if bv is None: continue points.append({ "elapsed_months": months, "as_of": point_date.isoformat(), "net_book_value": str(bv.net_book_value), "accumulated_depreciation": str(bv.accumulated_depreciation), "fully_depreciated": bv.fully_depreciated, }) return points def _add_months(start: date, months: int) -> date: """Add whole months to a date, clamping the day to the month length.""" total = start.month - 1 + months year = start.year + total // 12 month = total % 12 + 1 # Clamp the day to the target month's length. if month == 12: next_month_first = date(year + 1, 1, 1) else: next_month_first = date(year, month + 1, 1) last_day = (next_month_first - date.resolution).day day = min(start.day, last_day) return date(year, month, day)