diff --git a/fusion_accounting_assets/__init__.py b/fusion_accounting_assets/__init__.py index 99464a75..71a02422 100644 --- a/fusion_accounting_assets/__init__.py +++ b/fusion_accounting_assets/__init__.py @@ -1 +1,2 @@ +from . import models from . import services diff --git a/fusion_accounting_assets/__manifest__.py b/fusion_accounting_assets/__manifest__.py index 06853962..5bfa05d0 100644 --- a/fusion_accounting_assets/__manifest__.py +++ b/fusion_accounting_assets/__manifest__.py @@ -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', diff --git a/fusion_accounting_assets/models/__init__.py b/fusion_accounting_assets/models/__init__.py index e69de29b..0daeae31 100644 --- a/fusion_accounting_assets/models/__init__.py +++ b/fusion_accounting_assets/models/__init__.py @@ -0,0 +1,3 @@ +from . import fusion_asset_category +from . import fusion_asset +from . import fusion_asset_depreciation_line diff --git a/fusion_accounting_assets/models/fusion_asset.py b/fusion_accounting_assets/models/fusion_asset.py new file mode 100644 index 00000000..63d8c2a7 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset.py @@ -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.', + ) diff --git a/fusion_accounting_assets/models/fusion_asset_category.py b/fusion_accounting_assets/models/fusion_asset_category.py new file mode 100644 index 00000000..eecade1b --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_category.py @@ -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) diff --git a/fusion_accounting_assets/models/fusion_asset_depreciation_line.py b/fusion_accounting_assets/models/fusion_asset_depreciation_line.py new file mode 100644 index 00000000..2656d898 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_depreciation_line.py @@ -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) diff --git a/fusion_accounting_assets/security/ir.model.access.csv b/fusion_accounting_assets/security/ir.model.access.csv index 97dd8b91..d765dd4e 100644 --- a/fusion_accounting_assets/security/ir.model.access.csv +++ b/fusion_accounting_assets/security/ir.model.access.csv @@ -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 diff --git a/fusion_accounting_assets/tests/__init__.py b/fusion_accounting_assets/tests/__init__.py index a40830c2..18ba4251 100644 --- a/fusion_accounting_assets/tests/__init__.py +++ b/fusion_accounting_assets/tests/__init__.py @@ -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 diff --git a/fusion_accounting_assets/tests/test_fusion_asset.py b/fusion_accounting_assets/tests/test_fusion_asset.py new file mode 100644 index 00000000..4fe5e08a --- /dev/null +++ b/fusion_accounting_assets/tests/test_fusion_asset.py @@ -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)