TorbatYar/backend/services/accounting/app/services/asset_service.py
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 15:26:43 +03:30

176 lines
7.2 KiB
Python

"""Phase 5.8 — Depreciation Engine & Asset accounting."""
from __future__ import annotations
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fixed_assets import Asset, AssetDepreciation, AssetHistory, DepreciationSchedule
from app.models.posting import Voucher, VoucherLine
from app.models.types import AssetStatus, DepreciationMethod, VoucherStatus
from app.repositories.base import TenantBaseRepository
from app.repositories.foundation import FiscalPeriodRepository
from app.repositories.posting import VoucherRepository
from app.services.posting_engine import PostingEngine
from shared.exceptions import AppError, NotFoundError
class AssetRepo(TenantBaseRepository[Asset]):
model = Asset
class DepreciationScheduleRepo(TenantBaseRepository[DepreciationSchedule]):
model = DepreciationSchedule
class AssetDepreciationRepo(TenantBaseRepository[AssetDepreciation]):
model = AssetDepreciation
class AssetAccountingError(AppError):
status_code = 422
error_code = "asset_accounting_error"
class DepreciationEngine:
"""Centralized depreciation calculation engine."""
def calculate(
self,
asset: Asset,
*,
period_months: int = 1,
) -> Decimal:
depreciable = asset.acquisition_cost - asset.residual_value
if depreciable <= 0 or asset.useful_life_months <= 0:
return Decimal("0")
if asset.depreciation_method == DepreciationMethod.STRAIGHT_LINE:
monthly = depreciable / asset.useful_life_months
return (monthly * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if asset.depreciation_method == DepreciationMethod.DECLINING_BALANCE:
rate = Decimal("2") / asset.useful_life_months
return (asset.current_book_value * rate * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
if asset.depreciation_method == DepreciationMethod.DOUBLE_DECLINING:
rate = Decimal("2") / asset.useful_life_months * 2
return (asset.current_book_value * rate * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
return (depreciable / asset.useful_life_months * period_months).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
def generate_schedule(self, asset: Asset) -> list[dict]:
schedule = []
book_value = asset.acquisition_cost
for period in range(1, asset.useful_life_months + 1):
amount = self.calculate(asset, period_months=1)
if book_value - amount < asset.residual_value:
amount = book_value - asset.residual_value
if amount <= 0:
break
schedule.append({"period": period, "amount": amount, "book_value_after": book_value - amount})
book_value -= amount
return schedule
class AssetAccountingService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.asset_repo = AssetRepo(session)
self.schedule_repo = DepreciationScheduleRepo(session)
self.depreciation_repo = AssetDepreciationRepo(session)
self.voucher_repo = VoucherRepository(session)
self.period_repo = FiscalPeriodRepository(session)
self.posting_engine = PostingEngine(session)
self.depreciation_engine = DepreciationEngine()
async def activate_asset(self, tenant_id: UUID, asset_id: UUID, actor_user_id: str) -> Asset:
asset = await self.asset_repo.get(tenant_id, asset_id)
if asset is None:
raise NotFoundError("دارایی یافت نشد", error_code="asset_not_found")
if asset.status != AssetStatus.DRAFT:
raise AssetAccountingError("فقط دارایی‌های پیش‌نویس قابل فعال‌سازی هستند")
asset.status = AssetStatus.ACTIVE
asset.current_book_value = asset.acquisition_cost
self._record_history(tenant_id, asset_id, "activated", asset.acquisition_cost, asset.acquisition_cost)
return asset
async def depreciate_asset(
self,
tenant_id: UUID,
asset_id: UUID,
*,
expense_account_id: UUID,
accumulated_account_id: UUID,
actor_user_id: str,
) -> AssetDepreciation:
asset = await self.asset_repo.get(tenant_id, asset_id)
if asset is None:
raise NotFoundError("دارایی یافت نشد", error_code="asset_not_found")
if asset.status not in (AssetStatus.ACTIVE, AssetStatus.DEPRECIATING):
raise AssetAccountingError("دارایی فعال نیست")
amount = self.depreciation_engine.calculate(asset)
if amount <= 0:
raise AssetAccountingError("مبلغ استهلاک صفر است")
period = await self.period_repo.get_current(tenant_id)
if period is None:
raise NotFoundError("دوره مالی جاری یافت نشد", error_code="period_not_found")
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=period.id,
voucher_number=f"DEP-{asset.code}-{date.today().isoformat()}",
voucher_date=date.today(),
status=VoucherStatus.DRAFT,
description=f"Depreciation {asset.code}",
source_module="assets",
reference_number=str(asset_id),
created_by=actor_user_id,
)
await self.voucher_repo.add(voucher)
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=1,
account_id=expense_account_id, debit=amount, credit=Decimal("0"),
))
self.session.add(VoucherLine(
tenant_id=tenant_id, voucher_id=voucher.id, line_number=2,
account_id=accumulated_account_id, debit=Decimal("0"), credit=amount,
))
await self.session.flush()
posted = await self.posting_engine.post_voucher(
tenant_id, voucher.id, actor_user_id=actor_user_id, source_module="assets"
)
accumulated = asset.acquisition_cost - asset.current_book_value + amount
asset.current_book_value -= amount
asset.status = AssetStatus.DEPRECIATING if asset.current_book_value > asset.residual_value else AssetStatus.FULLY_DEPRECIATED
dep = AssetDepreciation(
tenant_id=tenant_id,
asset_id=asset_id,
fiscal_period_id=period.id,
depreciation_date=date.today(),
amount=amount,
accumulated_depreciation=accumulated,
book_value_after=asset.current_book_value,
voucher_id=posted.id,
)
return await self.depreciation_repo.add(dep)
def _record_history(
self, tenant_id: UUID, asset_id: UUID, event_type: str,
before: Decimal, after: Decimal,
) -> None:
self.session.add(AssetHistory(
tenant_id=tenant_id,
asset_id=asset_id,
event_type=event_type,
event_date=date.today(),
before_value=str(before),
after_value=str(after),
))