Compare commits

...

6 Commits

Author SHA1 Message Date
gsinghpal
38a6e375e6 feat(fusion_accounting_assets): inherit account.move.line for asset linkage
- fusion_asset_id Many2one on account.move.line (ondelete='set null':
  invoice line preserved if asset is removed)
- fusion_asset_count compute (smart-button friendly)
- action_open_fusion_asset() returns a window action to jump to the asset
- 3 new tests (66 total)

Made-with: Cursor
2026-04-19 16:59:44 -04:00
gsinghpal
8659f51935 feat(fusion_accounting_assets): asset anomaly persisted model
- 3 anomaly types: behind_schedule, ahead_of_schedule, low_utilization
- 3 severity levels: low, medium, high
- expected / actual / variance_pct (mirrors anomaly_detection service output)
- 4-state lifecycle: new -> acknowledged -> resolved (or dismissed)
- action_acknowledge / action_dismiss / action_resolve transitions
- ondelete='cascade' on asset_id (anomalies follow the asset)
- 4 new tests (63 total)

Made-with: Cursor
2026-04-19 16:58:56 -04:00
gsinghpal
5c89763191 feat(fusion_accounting_assets): asset disposal record model
- 4 disposal types: sale, scrap, donation, lost
- mail.thread tracking on type / date / sale amount / partner
- gain_loss_amount computed:
    - sale: sale_amount - book_value_at_disposal
    - scrap / donation / lost: -book_value_at_disposal (full loss)
- ondelete='restrict' on asset_id (cannot delete an asset with disposal)
- move_id placeholder for engine-created journal entry
- 4 new tests (59 total)

Made-with: Cursor
2026-04-19 16:58:12 -04:00
gsinghpal
b68d1b1c66 feat(fusion_accounting_assets): asset category template model
- defaults applied to new assets (method, useful_life, declining rate,
  salvage %, prorate convention)
- GL account hooks: asset_account_id, depreciation_account_id,
  expense_account_id (domain-filtered to relevant account types)
- computed asset_count for kanban / list views
- 3 new tests (55 total)

Made-with: Cursor
2026-04-19 16:57:25 -04:00
gsinghpal
0439d81675 feat(fusion_accounting_assets): depreciation board line model
- period_index, scheduled_date, amount, accumulated, book_value_at_end
- is_posted / posted_date / move_id (set when engine posts the entry)
- action_post() marks the line as posted (idempotent)
- UNIQUE(asset_id, period_index) constraint via models.Constraint
- 5 new tests (52 total)

Made-with: Cursor
2026-04-19 16:56:47 -04:00
gsinghpal
70e4404d9b 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
2026-04-19 16:55:59 -04:00
17 changed files with 724 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.11',
'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,6 @@
from . import fusion_asset_category
from . import fusion_asset
from . import fusion_asset_depreciation_line
from . import fusion_asset_disposal
from . import fusion_asset_anomaly
from . import account_move

View File

@@ -0,0 +1,34 @@
"""Inherit account.move.line to link to fusion.asset records.
Lets us trace assets back to their source invoice line.
"""
from odoo import fields, models
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
fusion_asset_id = fields.Many2one(
'fusion.asset', string='Created Asset',
copy=False, ondelete='set null',
help="Fusion asset record created from this invoice line.",
)
fusion_asset_count = fields.Integer(compute='_compute_fusion_asset_count')
def _compute_fusion_asset_count(self):
for line in self:
line.fusion_asset_count = 1 if line.fusion_asset_id else 0
def action_open_fusion_asset(self):
self.ensure_one()
if not self.fusion_asset_id:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.asset',
'res_id': self.fusion_asset_id.id,
'view_mode': 'form',
'target': 'current',
}

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,42 @@
"""Persisted asset anomaly flags from the engine's variance detection."""
from odoo import fields, models
SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')]
ANOMALY_TYPES = [
('behind_schedule', 'Behind Schedule'),
('ahead_of_schedule', 'Ahead of Schedule'),
('low_utilization', 'Low Utilization'),
]
class FusionAssetAnomaly(models.Model):
_name = "fusion.asset.anomaly"
_description = "Flagged Asset Anomaly"
_order = "detected_at desc, severity desc"
asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade')
company_id = fields.Many2one(related='asset_id.company_id', store=True)
anomaly_type = fields.Selection(ANOMALY_TYPES, required=True)
severity = fields.Selection(SEVERITY, required=True)
expected = fields.Float()
actual = fields.Float()
variance_pct = fields.Float()
detail = fields.Text()
detected_at = fields.Datetime(default=fields.Datetime.now, required=True)
state = fields.Selection([
('new', 'New'),
('acknowledged', 'Acknowledged'),
('resolved', 'Resolved'),
('dismissed', 'Dismissed'),
], default='new', required=True)
def action_acknowledge(self):
self.write({'state': 'acknowledged'})
def action_dismiss(self):
self.write({'state': 'dismissed'})
def action_resolve(self):
self.write({'state': 'resolved'})

View File

@@ -0,0 +1,53 @@
"""Asset categories with default settings (used as templates)."""
from odoo import api, 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)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)
method = fields.Selection([
('straight_line', 'Straight Line'),
('declining_balance', 'Declining Balance'),
('units_of_production', 'Units of Production'),
], default='straight_line', required=True)
useful_life_years = fields.Integer(default=5)
declining_rate_pct = fields.Float(default=20.0)
salvage_value_pct = fields.Float(
default=0.0,
help="% of cost (used for new assets in this category).",
)
prorate_convention = fields.Selection([
('full_month', 'Full Month'),
('days_365', 'Days / 365'),
('days_period', 'Days in Period'),
], default='days_period', required=True)
asset_account_id = fields.Many2one(
'account.account', string='Asset Account',
domain="[('account_type', 'in', ('asset_fixed', 'asset_non_current'))]",
)
depreciation_account_id = fields.Many2one(
'account.account', string='Depreciation Account',
domain="[('account_type', '=', 'asset_fixed')]",
)
expense_account_id = fields.Many2one(
'account.account', string='Expense Account',
domain="[('account_type', '=', 'expense_depreciation')]",
)
asset_count = fields.Integer(compute='_compute_asset_count')
def _compute_asset_count(self):
for cat in self:
cat.asset_count = self.env['fusion.asset'].search_count([
('category_id', '=', cat.id),
])

View File

@@ -0,0 +1,42 @@
"""Per-period depreciation board lines for an asset."""
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')
company_id = fields.Many2one(related='asset_id.company_id', store=True)
currency_id = fields.Many2one(related='asset_id.currency_id', store=True)
period_index = fields.Integer(required=True)
scheduled_date = fields.Date(required=True)
amount = fields.Monetary(required=True)
accumulated = fields.Monetary()
book_value_at_end = fields.Monetary()
is_posted = fields.Boolean(default=False, copy=False)
posted_date = fields.Date(copy=False)
move_id = fields.Many2one(
'account.move', copy=False,
help="Journal entry created when this line was posted.",
)
def action_post(self):
"""Mark this line as posted (without creating the journal entry yet —
engine method post_depreciation_entry handles the actual entry creation)."""
for line in self:
if line.is_posted:
continue
line.write({
'is_posted': True,
'posted_date': fields.Date.today(),
})
_unique_period_per_asset = models.Constraint(
'UNIQUE(asset_id, period_index)',
'A depreciation line for that period already exists.',
)

View File

@@ -0,0 +1,56 @@
"""Asset disposal records (sale, scrap, donation)."""
from odoo import api, fields, models
DISPOSAL_TYPES = [
('sale', 'Sale'),
('scrap', 'Scrap'),
('donation', 'Donation'),
('lost', 'Lost / Stolen'),
]
class FusionAssetDisposal(models.Model):
_name = "fusion.asset.disposal"
_description = "Asset Disposal Record"
_order = "disposal_date desc, id desc"
_inherit = ['mail.thread']
asset_id = fields.Many2one(
'fusion.asset', required=True, ondelete='restrict', tracking=True,
)
company_id = fields.Many2one(related='asset_id.company_id', store=True)
currency_id = fields.Many2one(related='asset_id.currency_id', store=True)
disposal_type = fields.Selection(
DISPOSAL_TYPES, required=True, default='sale', tracking=True,
)
disposal_date = fields.Date(
required=True, default=fields.Date.today, tracking=True,
)
sale_amount = fields.Monetary(
default=0.0, tracking=True,
help="Cash received (for sale disposal type).",
)
sale_partner_id = fields.Many2one('res.partner', tracking=True)
book_value_at_disposal = fields.Monetary(
readonly=True,
help="Asset book value at disposal date.",
)
gain_loss_amount = fields.Monetary(compute='_compute_gain_loss', store=True)
notes = fields.Text()
move_id = fields.Many2one(
'account.move', readonly=True, copy=False,
help="Journal entry created for this disposal.",
)
@api.depends('sale_amount', 'book_value_at_disposal', 'disposal_type')
def _compute_gain_loss(self):
for d in self:
if d.disposal_type == 'sale':
d.gain_loss_amount = d.sale_amount - d.book_value_at_disposal
else:
d.gain_loss_amount = -d.book_value_at_disposal

View File

@@ -1 +1,11 @@
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
access_fusion_asset_disposal_user,fusion.asset.disposal.user,model_fusion_asset_disposal,base.group_user,1,0,0,0
access_fusion_asset_disposal_admin,fusion.asset.disposal.admin,model_fusion_asset_disposal,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_anomaly_user,fusion.asset.anomaly.user,model_fusion_asset_anomaly,base.group_user,1,0,0,0
access_fusion_asset_anomaly_admin,fusion.asset.anomaly.admin,model_fusion_asset_anomaly,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
8 access_fusion_asset_disposal_user fusion.asset.disposal.user model_fusion_asset_disposal base.group_user 1 0 0 0
9 access_fusion_asset_disposal_admin fusion.asset.disposal.admin model_fusion_asset_disposal fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
10 access_fusion_asset_anomaly_user fusion.asset.anomaly.user model_fusion_asset_anomaly base.group_user 1 0 0 0
11 access_fusion_asset_anomaly_admin fusion.asset.anomaly.admin model_fusion_asset_anomaly fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1

View File

@@ -3,3 +3,9 @@ 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
from . import test_fusion_asset_depreciation_line
from . import test_fusion_asset_category
from . import test_fusion_asset_disposal
from . import test_fusion_asset_anomaly
from . import test_account_move_inherit

View File

@@ -0,0 +1,47 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountMoveLineFusionAsset(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Asset From Invoice',
'cost': 8000,
'acquisition_date': date(2026, 1, 1),
})
self.partner = self.env['res.partner'].create({'name': 'Vendor X'})
product = self.env['product.product'].create({'name': 'Test Asset Item'})
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': self.partner.id,
'invoice_date': date(2026, 1, 1),
'invoice_line_ids': [(0, 0, {
'product_id': product.id,
'name': 'Test asset purchase',
'quantity': 1,
'price_unit': 8000,
})],
})
self.invoice_line = bill.invoice_line_ids[0]
def test_line_starts_without_asset_link(self):
self.assertFalse(self.invoice_line.fusion_asset_id)
self.assertEqual(self.invoice_line.fusion_asset_count, 0)
def test_link_invoice_line_to_asset(self):
self.invoice_line.fusion_asset_id = self.asset
self.assertEqual(self.invoice_line.fusion_asset_id, self.asset)
self.invoice_line.invalidate_recordset(['fusion_asset_count'])
self.assertEqual(self.invoice_line.fusion_asset_count, 1)
def test_action_open_fusion_asset_returns_window_action(self):
self.invoice_line.fusion_asset_id = self.asset
action = self.invoice_line.action_open_fusion_asset()
self.assertEqual(action['res_model'], 'fusion.asset')
self.assertEqual(action['res_id'], self.asset.id)
self.assertEqual(action['view_mode'], 'form')

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)

View File

@@ -0,0 +1,49 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetAnomaly(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Watched Asset',
'cost': 5000,
'acquisition_date': date(2026, 1, 1),
})
def _make_anomaly(self, **kw):
vals = {
'asset_id': self.asset.id,
'anomaly_type': 'behind_schedule',
'severity': 'medium',
'expected': 1000.0,
'actual': 700.0,
'variance_pct': -30.0,
}
vals.update(kw)
return self.env['fusion.asset.anomaly'].create(vals)
def test_create_defaults_state_new(self):
a = self._make_anomaly()
self.assertEqual(a.state, 'new')
self.assertTrue(a.detected_at)
self.assertEqual(a.company_id, self.asset.company_id)
def test_acknowledge_transitions(self):
a = self._make_anomaly()
a.action_acknowledge()
self.assertEqual(a.state, 'acknowledged')
def test_dismiss_transitions(self):
a = self._make_anomaly()
a.action_dismiss()
self.assertEqual(a.state, 'dismissed')
def test_resolve_transitions(self):
a = self._make_anomaly(anomaly_type='low_utilization', severity='high')
a.action_resolve()
self.assertEqual(a.state, 'resolved')

View File

@@ -0,0 +1,35 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetCategory(TransactionCase):
def test_create_with_defaults(self):
cat = self.env['fusion.asset.category'].create({'name': 'Computers'})
self.assertEqual(cat.method, 'straight_line')
self.assertEqual(cat.useful_life_years, 5)
self.assertEqual(cat.prorate_convention, 'days_period')
self.assertEqual(cat.asset_count, 0)
def test_asset_count_reflects_linked_assets(self):
cat = self.env['fusion.asset.category'].create({'name': 'Vehicles'})
for i in range(3):
self.env['fusion.asset'].create({
'name': f'Truck {i}',
'cost': 50000,
'acquisition_date': date(2026, 1, 1),
'method': 'declining_balance',
'category_id': cat.id,
})
cat.invalidate_recordset(['asset_count'])
self.assertEqual(cat.asset_count, 3)
def test_method_must_be_in_selection(self):
with self.assertRaises(Exception):
self.env['fusion.asset.category'].create({
'name': 'Bogus',
'method': 'not_a_method',
})

View File

@@ -0,0 +1,62 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetDepreciationLine(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Asset for Lines',
'cost': 12000,
'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 1,
})
def _make_line(self, period_index, amount=1000.0, scheduled_date=None):
return self.env['fusion.asset.depreciation.line'].create({
'asset_id': self.asset.id,
'period_index': period_index,
'scheduled_date': scheduled_date or date(2026, period_index, 28),
'amount': amount,
})
def test_create_line_defaults_unposted(self):
line = self._make_line(1)
self.assertFalse(line.is_posted)
self.assertFalse(line.posted_date)
self.assertFalse(line.move_id)
self.assertEqual(line.company_id, self.asset.company_id)
self.assertEqual(line.currency_id, self.asset.currency_id)
def test_action_post_marks_line_posted(self):
line = self._make_line(2)
line.action_post()
self.assertTrue(line.is_posted)
self.assertTrue(line.posted_date)
def test_action_post_idempotent_keeps_first_date(self):
line = self._make_line(3)
line.action_post()
first_date = line.posted_date
line.action_post()
self.assertEqual(line.posted_date, first_date)
def test_unique_period_per_asset(self):
self._make_line(4)
with self.assertRaises(Exception):
self._make_line(4)
def test_book_value_reflects_posted_lines_only(self):
l1 = self._make_line(5, amount=1000)
self._make_line(6, amount=1500)
self.assertEqual(self.asset.book_value, 12000)
l1.action_post()
self.asset.invalidate_recordset(['book_value', 'total_depreciated'])
self.assertEqual(self.asset.total_depreciated, 1000)
self.assertEqual(self.asset.book_value, 11000)

View File

@@ -0,0 +1,56 @@
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionAssetDisposal(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Disposable Asset',
'cost': 10000,
'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
def test_create_minimal_sale(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 7000,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, 1000)
self.assertEqual(d.company_id, self.asset.company_id)
def test_sale_at_loss(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 4000,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, -2000)
def test_scrap_full_loss(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'scrap',
'sale_amount': 0,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, -6000)
def test_donation_ignores_sale_amount(self):
d = self.env['fusion.asset.disposal'].create({
'asset_id': self.asset.id,
'disposal_type': 'donation',
'sale_amount': 999,
'book_value_at_disposal': 6000,
})
self.assertEqual(d.gain_loss_amount, -6000)