Compare commits
6 Commits
bc7ba27d77
...
38a6e375e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a6e375e6 | ||
|
|
8659f51935 | ||
|
|
5c89763191 | ||
|
|
b68d1b1c66 | ||
|
|
0439d81675 | ||
|
|
70e4404d9b |
@@ -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.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',
|
||||
|
||||
@@ -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
|
||||
|
||||
34
fusion_accounting_assets/models/account_move.py
Normal file
34
fusion_accounting_assets/models/account_move.py
Normal 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',
|
||||
}
|
||||
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.',
|
||||
)
|
||||
42
fusion_accounting_assets/models/fusion_asset_anomaly.py
Normal file
42
fusion_accounting_assets/models/fusion_asset_anomaly.py
Normal 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'})
|
||||
53
fusion_accounting_assets/models/fusion_asset_category.py
Normal file
53
fusion_accounting_assets/models/fusion_asset_category.py
Normal 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),
|
||||
])
|
||||
@@ -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.',
|
||||
)
|
||||
56
fusion_accounting_assets/models/fusion_asset_disposal.py
Normal file
56
fusion_accounting_assets/models/fusion_asset_disposal.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
47
fusion_accounting_assets/tests/test_account_move_inherit.py
Normal file
47
fusion_accounting_assets/tests/test_account_move_inherit.py
Normal 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')
|
||||
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)
|
||||
49
fusion_accounting_assets/tests/test_fusion_asset_anomaly.py
Normal file
49
fusion_accounting_assets/tests/test_fusion_asset_anomaly.py
Normal 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')
|
||||
35
fusion_accounting_assets/tests/test_fusion_asset_category.py
Normal file
35
fusion_accounting_assets/tests/test_fusion_asset_category.py
Normal 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',
|
||||
})
|
||||
@@ -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)
|
||||
56
fusion_accounting_assets/tests/test_fusion_asset_disposal.py
Normal file
56
fusion_accounting_assets/tests/test_fusion_asset_disposal.py
Normal 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)
|
||||
Reference in New Issue
Block a user