"""Hardware cost axis — monthly depreciation (consumption basis) + purchase cash (cash basis), attributed to the holder's BU, no double-count. Uses USD-priced assets so the FX path (shared ``resolve_usd_per_unit``, already covered by the software-row + rollup tests) doesn't enter the arithmetic and the depreciation amounts are exact. Default policy = the module default 3-year straight-line, 5 % salvage: monthly_dep = (cost - cost*0.05) / 36 cost 36000 → (36000 - 1800)/36 = 950 / month """ from __future__ import annotations import uuid from datetime import date from decimal import Decimal import pytest from app.core.coordinators import finance_spend_coordinator as coord from app.domains.asset.models import AssetItem from app.identity.person_models import IdentityOrgMembership, IdentityPerson async def _person(db, *, dept: str, cost_center: str) -> uuid.UUID: pid = uuid.uuid4() db.add(IdentityPerson( id=pid, work_email=f"{pid.hex[:8]}@test.com", display_name="HW Owner", employment_status="active", source_system="successfactors", hris_person_id=f"sf-{pid.hex[:8]}", cost_center=cost_center, )) db.add(IdentityOrgMembership( id=uuid.uuid4(), person_id=pid, org_node_source_id=f"DP-{pid.hex[:6]}", org_node_type="department", org_node_name=dept, is_primary=True, )) await db.flush() # person must exist before an asset FKs to it return pid # ``status`` default was "in_use" — never a valid asset status; the ORM # now refuses it (2026-08-07). def _asset(*, cost: str | None, pdate: date | None, assigned_to=None, currency="USD", category="laptop", status="claimed") -> AssetItem: return AssetItem( id=uuid.uuid4(), asset_tag=f"HW-{uuid.uuid4().hex[:8]}", category=category, status=status, purchase_cost=(Decimal(cost) if cost is not None else None), purchase_date=pdate, currency=currency, assigned_to=assigned_to, ) def _rows_by(rows, *, basis): return [r for r in rows if r.basis == basis] @pytest.mark.asyncio async def test_depreciation_consumption_basis_monthly_delta(db_session): db = db_session owner = await _person(db, dept="Eng", cost_center="C0906") db.add(_asset(cost="36000", pdate=date(2026, 1, 15), assigned_to=owner)) await db.flush() rows = await coord.collect_hardware_rows(db, "2026-06") cons = _rows_by(rows, basis="consumption") assert len(cons) == 1 r = cons[0] assert r.cost_type == "hardware" assert r.amount_usd == Decimal("950.000000") # one month of straight-line assert r.department == "Eng" assert r.cost_center == "C0906" assert str(r.owner_person_id) == str(owner) assert r.allocation_source == "derived" # No cash row — the asset was not purchased in 2026-06. assert _rows_by(rows, basis="cash") == [] @pytest.mark.asyncio async def test_cash_basis_on_purchase_month(db_session): db = db_session owner = await _person(db, dept="Sales", cost_center="C0200") # Purchased inside the reported month → cash row = full purchase cost; # depreciation is 0 in the buy month (no full month elapsed yet). db.add(_asset(cost="36000", pdate=date(2026, 6, 10), assigned_to=owner)) await db.flush() rows = await coord.collect_hardware_rows(db, "2026-06") cash = _rows_by(rows, basis="cash") assert len(cash) == 1 assert cash[0].amount_usd == Decimal("36000.000000") assert cash[0].cost_type == "hardware" assert cash[0].department == "Sales" assert _rows_by(rows, basis="consumption") == [] @pytest.mark.asyncio async def test_consumption_total_excludes_cash_no_double_count(db_session): db = db_session owner = await _person(db, dept="Eng", cost_center="C0906") # Old asset (depreciating) + a brand-new purchase this month (cash only). db.add(_asset(cost="36000", pdate=date(2026, 1, 15), assigned_to=owner)) db.add(_asset(cost="12000", pdate=date(2026, 6, 5), assigned_to=owner)) await db.flush() rows = await coord.collect_hardware_rows(db, "2026-06") # The shared consumption-total helper must see depreciation but NOT cash. consumption = coord._consumption_total(rows) assert consumption == Decimal("950.00") # only the old asset's month dep cash_total = sum((r.amount_usd for r in _rows_by(rows, basis="cash")), Decimal("0")) assert cash_total == Decimal("12000.000000") # the new purchase, separate lens @pytest.mark.asyncio async def test_undated_and_costless_assets_contribute_nothing(db_session): db = db_session owner = await _person(db, dept="Eng", cost_center="C0906") db.add(_asset(cost="36000", pdate=None, assigned_to=owner)) # undated db.add(_asset(cost=None, pdate=date(2026, 1, 1), assigned_to=owner)) # costless db.add(_asset(cost="0", pdate=date(2026, 1, 1), assigned_to=owner)) # zero cost await db.flush() rows = await coord.collect_hardware_rows(db, "2026-06") assert rows == [] @pytest.mark.asyncio async def test_unassigned_asset_is_unallocated(db_session): db = db_session # Office-held / unassigned (assigned_to=None) — real company cost, but # attributed to the unallocated bucket, not fabricated onto a person. db.add(_asset(cost="36000", pdate=date(2026, 1, 15), assigned_to=None, status="held_by_office")) await db.flush() rows = await coord.collect_hardware_rows(db, "2026-06") cons = _rows_by(rows, basis="consumption") assert len(cons) == 1 assert cons[0].department is None assert cons[0].owner_person_id is None assert cons[0].allocation_source == "none" assert cons[0].amount_usd == Decimal("950.000000") @pytest.mark.asyncio async def test_disposed_assets_excluded(db_session): db = db_session owner = await _person(db, dept="Eng", cost_center="C0906") db.add(_asset(cost="36000", pdate=date(2026, 1, 15), assigned_to=owner, status="disposed")) await db.flush() rows = await coord.collect_hardware_rows(db, "2026-06") assert rows == []