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:
@@ -1 +1,2 @@
|
||||
from . import models
|
||||
from . import services
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import fusion_asset_category
|
||||
from . import fusion_asset
|
||||
from . import fusion_asset_depreciation_line
|
||||
|
||||
164
fusion_accounting_assets/models/fusion_asset.py
Normal file
164
fusion_accounting_assets/models/fusion_asset.py
Normal 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.',
|
||||
)
|
||||
16
fusion_accounting_assets/models/fusion_asset_category.py
Normal file
16
fusion_accounting_assets/models/fusion_asset_category.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
59
fusion_accounting_assets/tests/test_fusion_asset.py
Normal file
59
fusion_accounting_assets/tests/test_fusion_asset.py
Normal 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)
|
||||
Reference in New Issue
Block a user