feat(fusion_accounting_assets): main fusion.asset model with state machine

- fusion.asset: lifecycle (draft -> running -> paused -> disposed)
- mail.thread + mail.activity.mixin tracking
- 3 depreciation methods + 3 prorate conventions selections
- monetary cost / salvage with check constraints (models.Constraint)
- computed book_value, total_depreciated, last_posted_date
- action_set_running / pause / resume / set_draft transitions
- minimal stubs for fusion.asset.category and
  fusion.asset.depreciation.line so the One2many / Many2one comodels
  resolve at registry build time; expanded in Tasks 9 + 10
- 7 new tests (47 total)

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 16:55:59 -04:00
parent bc7ba27d77
commit 70e4404d9b
9 changed files with 271 additions and 1 deletions

View File

@@ -1 +1,2 @@
from . import models
from . import services

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Assets',
'version': '19.0.1.0.5',
'version': '19.0.1.0.6',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented asset management with depreciation schedules.',
'description': """
@@ -29,6 +29,7 @@ menu hides; the engine + AI tools remain available for the chat.
'fusion_accounting_core',
'fusion_accounting_ai',
'account',
'mail',
],
'data': [
'security/ir.model.access.csv',

View File

@@ -0,0 +1,3 @@
from . import fusion_asset_category
from . import fusion_asset
from . import fusion_asset_depreciation_line

View File

@@ -0,0 +1,164 @@
"""Fusion Asset model.
Lifecycle: draft -> running -> (paused -> running)* -> disposed.
- draft: created, not yet running depreciation
- running: depreciation board active, periodic posts happen
- paused: depreciation suspended (e.g. asset out for repair)
- disposed: sold/scrapped/donated; no further depreciation
"""
import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
METHOD_SELECTION = [
('straight_line', 'Straight Line'),
('declining_balance', 'Declining Balance'),
('units_of_production', 'Units of Production'),
]
PRORATE_SELECTION = [
('full_month', 'Full Month'),
('days_365', 'Days / 365'),
('days_period', 'Days in Period'),
]
STATE_SELECTION = [
('draft', 'Draft'),
('running', 'Running'),
('paused', 'Paused'),
('disposed', 'Disposed'),
]
class FusionAsset(models.Model):
_name = "fusion.asset"
_description = "Fusion Fixed Asset"
_order = "acquisition_date desc, id desc"
_inherit = ['mail.thread', 'mail.activity.mixin']
name = fields.Char(required=True, tracking=True)
code = fields.Char(help="Internal asset code (e.g. tag number).")
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company,
)
category_id = fields.Many2one('fusion.asset.category', tracking=True)
state = fields.Selection(
STATE_SELECTION, default='draft', required=True, tracking=True,
)
cost = fields.Monetary(
required=True, tracking=True,
help="Original acquisition cost.",
)
salvage_value = fields.Monetary(
default=0.0, tracking=True,
help="Estimated end-of-life value.",
)
acquisition_date = fields.Date(
required=True, default=fields.Date.today, tracking=True,
)
in_service_date = fields.Date(
tracking=True,
help="Date depreciation actually begins.",
)
disposed_date = fields.Date(readonly=True, tracking=True)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
method = fields.Selection(
METHOD_SELECTION, required=True, default='straight_line', tracking=True,
)
useful_life_years = fields.Integer(
default=5, tracking=True,
help="For straight_line / declining_balance.",
)
declining_rate_pct = fields.Float(
default=20.0,
help="For declining_balance method, e.g. 20.0 = 20%/year.",
)
total_units_expected = fields.Float(
help="For units_of_production method.",
)
units_used_to_date = fields.Float(
default=0.0,
help="For units_of_production: track usage.",
)
prorate_convention = fields.Selection(
PRORATE_SELECTION, default='days_period', required=True,
)
source_invoice_line_id = fields.Many2one(
'account.move.line', string='Source Invoice Line',
help="The invoice line that originated this asset.",
)
parent_id = fields.Many2one(
'fusion.asset', help='For partial-sale child assets.',
)
depreciation_line_ids = fields.One2many(
'fusion.asset.depreciation.line', 'asset_id',
string='Depreciation Lines',
)
book_value = fields.Monetary(compute='_compute_book_value', store=True)
total_depreciated = fields.Monetary(compute='_compute_book_value', store=True)
last_posted_date = fields.Date(compute='_compute_last_posted_date', store=True)
@api.depends('cost', 'depreciation_line_ids.amount', 'depreciation_line_ids.is_posted')
def _compute_book_value(self):
for asset in self:
posted = sum(l.amount for l in asset.depreciation_line_ids if l.is_posted)
asset.total_depreciated = posted
asset.book_value = asset.cost - posted
@api.depends('depreciation_line_ids.is_posted', 'depreciation_line_ids.scheduled_date')
def _compute_last_posted_date(self):
for asset in self:
posted_dates = [
l.scheduled_date for l in asset.depreciation_line_ids if l.is_posted
]
asset.last_posted_date = max(posted_dates) if posted_dates else False
def action_set_running(self):
for asset in self:
if asset.state != 'draft':
raise ValidationError(_("Only draft assets can be set running."))
if not asset.in_service_date:
asset.in_service_date = fields.Date.today()
asset.state = 'running'
def action_pause(self):
for asset in self:
if asset.state != 'running':
raise ValidationError(_("Only running assets can be paused."))
asset.state = 'paused'
def action_resume(self):
for asset in self:
if asset.state != 'paused':
raise ValidationError(_("Only paused assets can be resumed."))
asset.state = 'running'
def action_set_draft(self):
for asset in self:
if asset.state not in ('draft', 'paused'):
raise ValidationError(
_("Cannot reset to draft from %s.") % asset.state,
)
asset.state = 'draft'
_check_cost_positive = models.Constraint(
'CHECK(cost >= 0)',
'Asset cost must be non-negative.',
)
_check_salvage_lte_cost = models.Constraint(
'CHECK(salvage_value >= 0 AND salvage_value <= cost)',
'Salvage value must be between 0 and cost.',
)

View File

@@ -0,0 +1,16 @@
"""Asset categories with default settings (used as templates).
Stub created with Task 8 (so fusion.asset.category_id Many2one resolves).
Fully elaborated in Task 10.
"""
from odoo import fields, models
class FusionAssetCategory(models.Model):
_name = "fusion.asset.category"
_description = "Fusion Asset Category"
_order = "sequence, name"
name = fields.Char(required=True, translate=True)
sequence = fields.Integer(default=10)

View File

@@ -0,0 +1,19 @@
"""Per-period depreciation board lines for an asset.
Stub created with Task 8 (so fusion.asset One2many resolves). Fully
elaborated in Task 9.
"""
from odoo import fields, models
class FusionAssetDepreciationLine(models.Model):
_name = "fusion.asset.depreciation.line"
_description = "Asset Depreciation Board Line"
_order = "asset_id, scheduled_date"
asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade')
scheduled_date = fields.Date(required=True)
amount = fields.Monetary()
currency_id = fields.Many2one(related='asset_id.currency_id', store=True)
is_posted = fields.Boolean(default=False)

View File

@@ -1 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_asset_user,fusion.asset.user,model_fusion_asset,base.group_user,1,0,0,0
access_fusion_asset_admin,fusion.asset.admin,model_fusion_asset,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_depreciation_line_user,fusion.asset.depreciation.line.user,model_fusion_asset_depreciation_line,base.group_user,1,0,0,0
access_fusion_asset_depreciation_line_admin,fusion.asset.depreciation.line.admin,model_fusion_asset_depreciation_line,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_category_user,fusion.asset.category.user,model_fusion_asset_category,base.group_user,1,0,0,0
access_fusion_asset_category_admin,fusion.asset.category.admin,model_fusion_asset_category,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_asset_user fusion.asset.user model_fusion_asset base.group_user 1 0 0 0
3 access_fusion_asset_admin fusion.asset.admin model_fusion_asset fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
4 access_fusion_asset_depreciation_line_user fusion.asset.depreciation.line.user model_fusion_asset_depreciation_line base.group_user 1 0 0 0
5 access_fusion_asset_depreciation_line_admin fusion.asset.depreciation.line.admin model_fusion_asset_depreciation_line fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
6 access_fusion_asset_category_user fusion.asset.category.user model_fusion_asset_category base.group_user 1 0 0 0
7 access_fusion_asset_category_admin fusion.asset.category.admin model_fusion_asset_category fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1

View File

@@ -3,3 +3,4 @@ from . import test_prorate
from . import test_salvage_value
from . import test_asset_anomaly_detection
from . import test_useful_life_predictor
from . import test_fusion_asset

View File

@@ -0,0 +1,59 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install')
class TestFusionAsset(TransactionCase):
def setUp(self):
super().setUp()
self.asset_vals = {
'name': 'Test Asset',
'cost': 10000,
'salvage_value': 1000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
}
def test_create_minimal(self):
a = self.env['fusion.asset'].create(self.asset_vals)
self.assertEqual(a.state, 'draft')
self.assertEqual(a.book_value, 10000)
def test_state_transitions_draft_to_running(self):
a = self.env['fusion.asset'].create(self.asset_vals)
a.action_set_running()
self.assertEqual(a.state, 'running')
self.assertTrue(a.in_service_date)
def test_pause_resume(self):
a = self.env['fusion.asset'].create(self.asset_vals)
a.action_set_running()
a.action_pause()
self.assertEqual(a.state, 'paused')
a.action_resume()
self.assertEqual(a.state, 'running')
def test_cannot_pause_from_draft(self):
a = self.env['fusion.asset'].create(self.asset_vals)
with self.assertRaises(ValidationError):
a.action_pause()
def test_negative_cost_rejected(self):
with self.assertRaises(Exception):
self.env['fusion.asset'].create({**self.asset_vals, 'cost': -100})
def test_salvage_exceeds_cost_rejected(self):
with self.assertRaises(Exception):
self.env['fusion.asset'].create(
{**self.asset_vals, 'cost': 1000, 'salvage_value': 5000},
)
def test_book_value_starts_at_cost(self):
a = self.env['fusion.asset'].create(self.asset_vals)
self.assertEqual(a.book_value, a.cost)
self.assertEqual(a.total_depreciated, 0)