The orchestrator AbstractModel for asset depreciation lifecycle. compute_depreciation_schedule, post_depreciation_entry, dispose_asset, partial_sale, pause_asset, resume_asset, reverse_disposal. All controllers, AI tools, wizards, and cron must route through these methods; no direct ORM writes to fusion.asset.depreciation.line or account.move from anywhere else. Made-with: Cursor
399 lines
15 KiB
Python
399 lines
15 KiB
Python
"""The asset engine — orchestrator for all asset depreciation + lifecycle.
|
|
|
|
7-method public API. No direct ORM writes to fusion.asset.depreciation.line
|
|
or account.move from anywhere else; everything routes through here for
|
|
consistent validation, audit, and side-effect handling.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import date, timedelta
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import ValidationError
|
|
|
|
from ..services.depreciation_methods import (
|
|
straight_line,
|
|
declining_balance,
|
|
units_of_production,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FusionAssetEngine(models.AbstractModel):
|
|
_name = "fusion.asset.engine"
|
|
_description = "Fusion Asset Engine"
|
|
|
|
# ============================================================
|
|
# PUBLIC API (7 methods)
|
|
# ============================================================
|
|
|
|
@api.model
|
|
def compute_depreciation_schedule(self, asset, *, recompute: bool = False) -> dict:
|
|
"""Compute (or re-compute) the depreciation board for an asset.
|
|
|
|
If recompute=False and posted lines exist, ONLY un-posted future lines
|
|
are regenerated. If recompute=True, all unposted lines are wiped and
|
|
regenerated from scratch using current asset config.
|
|
"""
|
|
if not asset:
|
|
raise ValidationError(_("asset is required"))
|
|
asset.ensure_one()
|
|
|
|
self._validate_asset_for_schedule(asset)
|
|
|
|
Line = self.env['fusion.asset.depreciation.line'].sudo()
|
|
if recompute:
|
|
Line.search([
|
|
('asset_id', '=', asset.id),
|
|
('is_posted', '=', False),
|
|
]).unlink()
|
|
|
|
existing_posted = Line.search([
|
|
('asset_id', '=', asset.id),
|
|
('is_posted', '=', True),
|
|
], order='period_index')
|
|
start_period = max([l.period_index for l in existing_posted], default=-1) + 1
|
|
accumulated_so_far = sum(l.amount for l in existing_posted)
|
|
|
|
steps = self._compute_steps(asset)
|
|
new_steps = steps[start_period:]
|
|
|
|
base_date = asset.in_service_date or asset.acquisition_date
|
|
|
|
# Accumulated baseline at the boundary between posted and to-be-created
|
|
# lines: subtract the accumulated value the algorithm itself reports at
|
|
# that boundary, then re-add the actually-posted total. This keeps the
|
|
# board's accumulated column monotonic when picking up mid-life.
|
|
baseline_offset = 0.0
|
|
if start_period > 0 and start_period <= len(steps):
|
|
baseline_offset = steps[start_period - 1].accumulated_depreciation
|
|
|
|
line_vals = []
|
|
for s in new_steps:
|
|
scheduled_date = self._add_periods(base_date, s.period_index)
|
|
running_accumulated = round(
|
|
accumulated_so_far + s.accumulated_depreciation - baseline_offset, 2
|
|
)
|
|
line_vals.append({
|
|
'asset_id': asset.id,
|
|
'period_index': s.period_index,
|
|
'scheduled_date': scheduled_date,
|
|
'amount': s.period_amount,
|
|
'accumulated': running_accumulated,
|
|
'book_value_at_end': s.book_value_at_end,
|
|
'is_posted': False,
|
|
})
|
|
if line_vals:
|
|
Line.create(line_vals)
|
|
|
|
return {
|
|
'asset_id': asset.id,
|
|
'lines_created': len(line_vals),
|
|
'total_lines': len(asset.depreciation_line_ids),
|
|
'method': asset.method,
|
|
}
|
|
|
|
@api.model
|
|
def post_depreciation_entry(self, asset, *, period_date: date = None) -> dict:
|
|
"""Post the next-due un-posted depreciation line.
|
|
|
|
If period_date provided, post all lines whose scheduled_date <= period_date.
|
|
Otherwise, post the single next un-posted line (the earliest one).
|
|
"""
|
|
asset.ensure_one()
|
|
if asset.state != 'running':
|
|
raise ValidationError(
|
|
_("Cannot post depreciation for asset in state %s") % asset.state
|
|
)
|
|
|
|
Line = self.env['fusion.asset.depreciation.line'].sudo()
|
|
domain = [('asset_id', '=', asset.id), ('is_posted', '=', False)]
|
|
if period_date:
|
|
domain.append(('scheduled_date', '<=', period_date))
|
|
unposted = Line.search(domain, order='scheduled_date, period_index')
|
|
if not unposted:
|
|
return {'posted_count': 0, 'reason': 'no unposted lines due'}
|
|
|
|
if not period_date:
|
|
unposted = unposted[:1]
|
|
|
|
posted_ids = []
|
|
for line in unposted:
|
|
self._create_journal_entry(asset, line)
|
|
line.action_post()
|
|
posted_ids.append(line.id)
|
|
|
|
return {'posted_count': len(posted_ids), 'posted_line_ids': posted_ids}
|
|
|
|
@api.model
|
|
def dispose_asset(self, asset, *, sale_amount: float = 0.0,
|
|
sale_date: date = None, sale_partner=None,
|
|
disposal_type: str = 'sale') -> dict:
|
|
"""Dispose an asset (sale, scrap, donation, lost)."""
|
|
asset.ensure_one()
|
|
if asset.state == 'disposed':
|
|
raise ValidationError(_("Asset already disposed."))
|
|
sale_date = sale_date or fields.Date.today()
|
|
|
|
Line = self.env['fusion.asset.depreciation.line'].sudo()
|
|
future_unposted = Line.search([
|
|
('asset_id', '=', asset.id),
|
|
('is_posted', '=', False),
|
|
('scheduled_date', '>', sale_date),
|
|
])
|
|
future_unposted.unlink()
|
|
|
|
asset.invalidate_recordset(['book_value', 'total_depreciated'])
|
|
book_value = asset.book_value
|
|
|
|
Disposal = self.env['fusion.asset.disposal'].sudo()
|
|
partner_id = False
|
|
if sale_partner:
|
|
partner_id = sale_partner.id if hasattr(sale_partner, 'id') else sale_partner
|
|
disposal = Disposal.create({
|
|
'asset_id': asset.id,
|
|
'disposal_type': disposal_type,
|
|
'disposal_date': sale_date,
|
|
'sale_amount': sale_amount,
|
|
'sale_partner_id': partner_id,
|
|
'book_value_at_disposal': book_value,
|
|
})
|
|
|
|
asset.write({
|
|
'state': 'disposed',
|
|
'disposed_date': sale_date,
|
|
})
|
|
|
|
return {
|
|
'asset_id': asset.id,
|
|
'disposal_id': disposal.id,
|
|
'gain_loss_amount': disposal.gain_loss_amount,
|
|
'book_value_at_disposal': book_value,
|
|
}
|
|
|
|
@api.model
|
|
def partial_sale(self, asset, *, sold_amount: float, sold_qty: float = None,
|
|
sale_date: date = None, sale_partner=None) -> dict:
|
|
"""Partially dispose: split asset into two — sold child + remaining parent.
|
|
|
|
sold_amount is cash received for the sold portion.
|
|
sold_qty is the ratio of original cost to attribute to the sold portion (0..1).
|
|
If sold_qty is None, defaults to sold_amount / cost.
|
|
"""
|
|
asset.ensure_one()
|
|
if asset.state == 'disposed':
|
|
raise ValidationError(_("Cannot partially sell a disposed asset."))
|
|
if sold_qty is None:
|
|
sold_qty = sold_amount / asset.cost if asset.cost else 0
|
|
if not (0 < sold_qty < 1):
|
|
raise ValidationError(
|
|
_("sold_qty must be strictly between 0 and 1; got %s") % sold_qty
|
|
)
|
|
|
|
sale_date = sale_date or fields.Date.today()
|
|
|
|
Asset = self.env['fusion.asset'].sudo()
|
|
sold_cost = round(asset.cost * sold_qty, 2)
|
|
sold_salvage = round(asset.salvage_value * sold_qty, 2)
|
|
child_vals = {
|
|
'name': f"{asset.name} (sold portion)",
|
|
'parent_id': asset.id,
|
|
'cost': sold_cost,
|
|
'salvage_value': sold_salvage,
|
|
'acquisition_date': asset.acquisition_date,
|
|
'in_service_date': asset.in_service_date,
|
|
'method': asset.method,
|
|
'useful_life_years': asset.useful_life_years,
|
|
'declining_rate_pct': asset.declining_rate_pct,
|
|
'prorate_convention': asset.prorate_convention,
|
|
'company_id': asset.company_id.id,
|
|
'state': 'running',
|
|
}
|
|
if asset.category_id:
|
|
child_vals['category_id'] = asset.category_id.id
|
|
child = Asset.create(child_vals)
|
|
|
|
new_cost = round(asset.cost - sold_cost, 2)
|
|
new_salvage = round(asset.salvage_value - sold_salvage, 2)
|
|
asset.write({
|
|
'cost': new_cost,
|
|
'salvage_value': new_salvage,
|
|
})
|
|
self.compute_depreciation_schedule(asset, recompute=True)
|
|
|
|
result = self.dispose_asset(
|
|
child, sale_amount=sold_amount, sale_date=sale_date,
|
|
sale_partner=sale_partner, disposal_type='sale',
|
|
)
|
|
return {
|
|
'parent_asset_id': asset.id,
|
|
'child_asset_id': child.id,
|
|
'disposal_id': result['disposal_id'],
|
|
'gain_loss_amount': result['gain_loss_amount'],
|
|
}
|
|
|
|
@api.model
|
|
def pause_asset(self, asset, pause_date: date = None) -> dict:
|
|
"""Pause depreciation. Wraps asset.action_pause for API symmetry and
|
|
to log the pause date for downstream auditing."""
|
|
asset.ensure_one()
|
|
asset.action_pause()
|
|
return {
|
|
'asset_id': asset.id,
|
|
'pause_date': pause_date or fields.Date.today(),
|
|
'state': 'paused',
|
|
}
|
|
|
|
@api.model
|
|
def resume_asset(self, asset, resume_date: date = None) -> dict:
|
|
"""Resume a paused asset."""
|
|
asset.ensure_one()
|
|
asset.action_resume()
|
|
return {
|
|
'asset_id': asset.id,
|
|
'resume_date': resume_date or fields.Date.today(),
|
|
'state': 'running',
|
|
}
|
|
|
|
@api.model
|
|
def reverse_disposal(self, asset) -> dict:
|
|
"""Reverse a disposal (rare — recovery from accidental sale entry)."""
|
|
asset.ensure_one()
|
|
if asset.state != 'disposed':
|
|
raise ValidationError(_("Asset is not disposed."))
|
|
|
|
Disposal = self.env['fusion.asset.disposal'].sudo()
|
|
last_disposal = Disposal.search(
|
|
[('asset_id', '=', asset.id)],
|
|
order='disposal_date desc, id desc', limit=1,
|
|
)
|
|
if last_disposal and last_disposal.move_id:
|
|
try:
|
|
last_disposal.move_id.button_cancel()
|
|
except Exception as e: # noqa: BLE001
|
|
_logger.warning("Could not cancel disposal move: %s", e)
|
|
if last_disposal:
|
|
last_disposal.unlink()
|
|
asset.write({'state': 'running', 'disposed_date': False})
|
|
return {'asset_id': asset.id, 'state': 'running'}
|
|
|
|
# ============================================================
|
|
# PRIVATE HELPERS
|
|
# ============================================================
|
|
|
|
def _validate_asset_for_schedule(self, asset):
|
|
if asset.cost <= 0:
|
|
raise ValidationError(_("Asset cost must be > 0 to compute schedule."))
|
|
if asset.method == 'units_of_production' and not asset.total_units_expected:
|
|
raise ValidationError(_(
|
|
"Units of Production assets need total_units_expected set."
|
|
))
|
|
if asset.method in ('straight_line', 'declining_balance'):
|
|
if asset.useful_life_years < 1:
|
|
raise ValidationError(_("useful_life_years must be >= 1."))
|
|
if asset.salvage_value > asset.cost:
|
|
raise ValidationError(_("Salvage value cannot exceed cost."))
|
|
|
|
def _compute_steps(self, asset) -> list:
|
|
"""Dispatch to the appropriate depreciation method service."""
|
|
if asset.method == 'straight_line':
|
|
return straight_line(
|
|
cost=asset.cost,
|
|
salvage_value=asset.salvage_value,
|
|
n_periods=asset.useful_life_years,
|
|
)
|
|
if asset.method == 'declining_balance':
|
|
return declining_balance(
|
|
cost=asset.cost,
|
|
salvage_value=asset.salvage_value,
|
|
n_periods=asset.useful_life_years,
|
|
rate=asset.declining_rate_pct / 100.0,
|
|
)
|
|
if asset.method == 'units_of_production':
|
|
# Phase 3 simple: assume even per-period units. Phase 3.5 can read
|
|
# from a per-period usage table populated by maintenance/IoT data.
|
|
if asset.useful_life_years:
|
|
per_period = asset.total_units_expected / asset.useful_life_years
|
|
periods = asset.useful_life_years
|
|
else:
|
|
per_period = asset.total_units_expected
|
|
periods = 1
|
|
return units_of_production(
|
|
cost=asset.cost,
|
|
salvage_value=asset.salvage_value,
|
|
total_units_expected=asset.total_units_expected,
|
|
units_per_period=[per_period] * periods,
|
|
)
|
|
return []
|
|
|
|
def _add_periods(self, base_date: date, n_periods: int) -> date:
|
|
"""Add (n_periods + 1) yearly increments to base_date and step back one
|
|
day, giving the period-end date.
|
|
|
|
Phase 3.5 can split this into monthly/quarterly variants when the asset
|
|
carries a sub-annual frequency.
|
|
"""
|
|
try:
|
|
return base_date.replace(year=base_date.year + n_periods + 1) - timedelta(days=1)
|
|
except ValueError:
|
|
return base_date.replace(
|
|
year=base_date.year + n_periods + 1, day=28,
|
|
) - timedelta(days=1)
|
|
|
|
def _create_journal_entry(self, asset, line):
|
|
"""Create the journal entry for a depreciation line.
|
|
|
|
Phase 3 keeps this minimal: requires the category to have both
|
|
depreciation_account_id and expense_account_id wired up. Without that,
|
|
the line is still posted (is_posted flag) but no move is created.
|
|
Phase 3.5 will add multi-currency, allocation rules, and analytic tags.
|
|
"""
|
|
category = asset.category_id
|
|
if not category or not (category.depreciation_account_id and category.expense_account_id):
|
|
_logger.debug(
|
|
"No accounts on category for asset %s; skipping journal entry",
|
|
asset.id,
|
|
)
|
|
return None
|
|
Move = self.env['account.move'].sudo()
|
|
journal = self.env['account.journal'].search([
|
|
('type', '=', 'general'),
|
|
('company_id', '=', asset.company_id.id),
|
|
], limit=1)
|
|
if not journal:
|
|
_logger.warning(
|
|
"No general journal for company %s; skipping move creation",
|
|
asset.company_id.name,
|
|
)
|
|
return None
|
|
try:
|
|
move = Move.create({
|
|
'date': line.scheduled_date,
|
|
'journal_id': journal.id,
|
|
'ref': f"Depreciation: {asset.name} (P{line.period_index + 1})",
|
|
'line_ids': [
|
|
(0, 0, {
|
|
'name': f"Depreciation expense - {asset.name}",
|
|
'account_id': category.expense_account_id.id,
|
|
'debit': line.amount,
|
|
'credit': 0,
|
|
}),
|
|
(0, 0, {
|
|
'name': f"Accumulated depreciation - {asset.name}",
|
|
'account_id': category.depreciation_account_id.id,
|
|
'debit': 0,
|
|
'credit': line.amount,
|
|
}),
|
|
],
|
|
})
|
|
move.action_post()
|
|
line.write({'move_id': move.id})
|
|
return move
|
|
except Exception as e: # noqa: BLE001
|
|
_logger.warning(
|
|
"Failed to create depreciation move for asset %s line %s: %s",
|
|
asset.id, line.id, e,
|
|
)
|
|
return None
|