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>
176 lines
7.2 KiB
Python
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),
|
|
))
|