This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

View File

@@ -0,0 +1,10 @@
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
from . import fusion_asset_engine
from . import fusion_assets_cron
from . import fusion_asset_book_values_mv
from . import fusion_migration_wizard

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,59 @@
"""MV of per-asset book value snapshot. Refresh via cron or model._refresh()."""
import logging
import os
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionAssetBookValuesMV(models.Model):
_name = "fusion.asset.book.values.mv"
_description = "MV of asset book value snapshot"
_auto = False
_table = "fusion_asset_book_values_mv"
_order = "book_value desc"
asset_id = fields.Many2one('fusion.asset', readonly=True)
company_id = fields.Many2one('res.company', readonly=True)
category_id = fields.Many2one('fusion.asset.category', readonly=True)
state = fields.Char(readonly=True)
cost = fields.Float(readonly=True)
salvage_value = fields.Float(readonly=True)
total_depreciated = fields.Float(readonly=True)
book_value = fields.Float(readonly=True)
posted_periods = fields.Integer(readonly=True)
pending_periods = fields.Integer(readonly=True)
acquisition_date = fields.Date(readonly=True)
in_service_date = fields.Date(readonly=True)
def init(self):
sql_path = os.path.join(
os.path.dirname(__file__), '..', 'data', 'sql',
'create_mv_asset_book_values.sql',
)
with open(sql_path, 'r') as f:
self.env.cr.execute(f.read())
_logger.info("fusion_asset_book_values_mv: created/verified MV")
@api.model
def _refresh(self, *, concurrently=True):
# CONCURRENTLY requires a unique index (we have one) and that the MV
# has been populated at least once. Wrap the concurrent attempt in a
# savepoint so a failure (e.g. first-ever refresh before the MV is
# populated) does NOT poison the surrounding transaction; we then
# fall back to a plain REFRESH.
if concurrently:
try:
with self.env.cr.savepoint():
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
"fusion_asset_book_values_mv"
)
return
except Exception as e: # noqa: BLE001
_logger.warning("Concurrent MV refresh failed (%s); fallback", e)
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW fusion_asset_book_values_mv"
)

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

@@ -0,0 +1,398 @@
"""The asset engine — orchestrator for all asset depreciation + lifecycle.
7-method public API. No direct ORM writes to fusion.asset.depreciation.line
or account.move from anywhere else; everything routes through here for
consistent validation, audit, and side-effect handling.
"""
import logging
from datetime import date, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from ..services.depreciation_methods import (
straight_line,
declining_balance,
units_of_production,
)
_logger = logging.getLogger(__name__)
class FusionAssetEngine(models.AbstractModel):
_name = "fusion.asset.engine"
_description = "Fusion Asset Engine"
# ============================================================
# PUBLIC API (7 methods)
# ============================================================
@api.model
def compute_depreciation_schedule(self, asset, *, recompute: bool = False) -> dict:
"""Compute (or re-compute) the depreciation board for an asset.
If recompute=False and posted lines exist, ONLY un-posted future lines
are regenerated. If recompute=True, all unposted lines are wiped and
regenerated from scratch using current asset config.
"""
if not asset:
raise ValidationError(_("asset is required"))
asset.ensure_one()
self._validate_asset_for_schedule(asset)
Line = self.env['fusion.asset.depreciation.line'].sudo()
if recompute:
Line.search([
('asset_id', '=', asset.id),
('is_posted', '=', False),
]).unlink()
existing_posted = Line.search([
('asset_id', '=', asset.id),
('is_posted', '=', True),
], order='period_index')
start_period = max([l.period_index for l in existing_posted], default=-1) + 1
accumulated_so_far = sum(l.amount for l in existing_posted)
steps = self._compute_steps(asset)
new_steps = steps[start_period:]
base_date = asset.in_service_date or asset.acquisition_date
# Accumulated baseline at the boundary between posted and to-be-created
# lines: subtract the accumulated value the algorithm itself reports at
# that boundary, then re-add the actually-posted total. This keeps the
# board's accumulated column monotonic when picking up mid-life.
baseline_offset = 0.0
if start_period > 0 and start_period <= len(steps):
baseline_offset = steps[start_period - 1].accumulated_depreciation
line_vals = []
for s in new_steps:
scheduled_date = self._add_periods(base_date, s.period_index)
running_accumulated = round(
accumulated_so_far + s.accumulated_depreciation - baseline_offset, 2
)
line_vals.append({
'asset_id': asset.id,
'period_index': s.period_index,
'scheduled_date': scheduled_date,
'amount': s.period_amount,
'accumulated': running_accumulated,
'book_value_at_end': s.book_value_at_end,
'is_posted': False,
})
if line_vals:
Line.create(line_vals)
return {
'asset_id': asset.id,
'lines_created': len(line_vals),
'total_lines': len(asset.depreciation_line_ids),
'method': asset.method,
}
@api.model
def post_depreciation_entry(self, asset, *, period_date: date = None) -> dict:
"""Post the next-due un-posted depreciation line.
If period_date provided, post all lines whose scheduled_date <= period_date.
Otherwise, post the single next un-posted line (the earliest one).
"""
asset.ensure_one()
if asset.state != 'running':
raise ValidationError(
_("Cannot post depreciation for asset in state %s") % asset.state
)
Line = self.env['fusion.asset.depreciation.line'].sudo()
domain = [('asset_id', '=', asset.id), ('is_posted', '=', False)]
if period_date:
domain.append(('scheduled_date', '<=', period_date))
unposted = Line.search(domain, order='scheduled_date, period_index')
if not unposted:
return {'posted_count': 0, 'reason': 'no unposted lines due'}
if not period_date:
unposted = unposted[:1]
posted_ids = []
for line in unposted:
self._create_journal_entry(asset, line)
line.action_post()
posted_ids.append(line.id)
return {'posted_count': len(posted_ids), 'posted_line_ids': posted_ids}
@api.model
def dispose_asset(self, asset, *, sale_amount: float = 0.0,
sale_date: date = None, sale_partner=None,
disposal_type: str = 'sale') -> dict:
"""Dispose an asset (sale, scrap, donation, lost)."""
asset.ensure_one()
if asset.state == 'disposed':
raise ValidationError(_("Asset already disposed."))
sale_date = sale_date or fields.Date.today()
Line = self.env['fusion.asset.depreciation.line'].sudo()
future_unposted = Line.search([
('asset_id', '=', asset.id),
('is_posted', '=', False),
('scheduled_date', '>', sale_date),
])
future_unposted.unlink()
asset.invalidate_recordset(['book_value', 'total_depreciated'])
book_value = asset.book_value
Disposal = self.env['fusion.asset.disposal'].sudo()
partner_id = False
if sale_partner:
partner_id = sale_partner.id if hasattr(sale_partner, 'id') else sale_partner
disposal = Disposal.create({
'asset_id': asset.id,
'disposal_type': disposal_type,
'disposal_date': sale_date,
'sale_amount': sale_amount,
'sale_partner_id': partner_id,
'book_value_at_disposal': book_value,
})
asset.write({
'state': 'disposed',
'disposed_date': sale_date,
})
return {
'asset_id': asset.id,
'disposal_id': disposal.id,
'gain_loss_amount': disposal.gain_loss_amount,
'book_value_at_disposal': book_value,
}
@api.model
def partial_sale(self, asset, *, sold_amount: float, sold_qty: float = None,
sale_date: date = None, sale_partner=None) -> dict:
"""Partially dispose: split asset into two — sold child + remaining parent.
sold_amount is cash received for the sold portion.
sold_qty is the ratio of original cost to attribute to the sold portion (0..1).
If sold_qty is None, defaults to sold_amount / cost.
"""
asset.ensure_one()
if asset.state == 'disposed':
raise ValidationError(_("Cannot partially sell a disposed asset."))
if sold_qty is None:
sold_qty = sold_amount / asset.cost if asset.cost else 0
if not (0 < sold_qty < 1):
raise ValidationError(
_("sold_qty must be strictly between 0 and 1; got %s") % sold_qty
)
sale_date = sale_date or fields.Date.today()
Asset = self.env['fusion.asset'].sudo()
sold_cost = round(asset.cost * sold_qty, 2)
sold_salvage = round(asset.salvage_value * sold_qty, 2)
child_vals = {
'name': f"{asset.name} (sold portion)",
'parent_id': asset.id,
'cost': sold_cost,
'salvage_value': sold_salvage,
'acquisition_date': asset.acquisition_date,
'in_service_date': asset.in_service_date,
'method': asset.method,
'useful_life_years': asset.useful_life_years,
'declining_rate_pct': asset.declining_rate_pct,
'prorate_convention': asset.prorate_convention,
'company_id': asset.company_id.id,
'state': 'running',
}
if asset.category_id:
child_vals['category_id'] = asset.category_id.id
child = Asset.create(child_vals)
new_cost = round(asset.cost - sold_cost, 2)
new_salvage = round(asset.salvage_value - sold_salvage, 2)
asset.write({
'cost': new_cost,
'salvage_value': new_salvage,
})
self.compute_depreciation_schedule(asset, recompute=True)
result = self.dispose_asset(
child, sale_amount=sold_amount, sale_date=sale_date,
sale_partner=sale_partner, disposal_type='sale',
)
return {
'parent_asset_id': asset.id,
'child_asset_id': child.id,
'disposal_id': result['disposal_id'],
'gain_loss_amount': result['gain_loss_amount'],
}
@api.model
def pause_asset(self, asset, pause_date: date = None) -> dict:
"""Pause depreciation. Wraps asset.action_pause for API symmetry and
to log the pause date for downstream auditing."""
asset.ensure_one()
asset.action_pause()
return {
'asset_id': asset.id,
'pause_date': pause_date or fields.Date.today(),
'state': 'paused',
}
@api.model
def resume_asset(self, asset, resume_date: date = None) -> dict:
"""Resume a paused asset."""
asset.ensure_one()
asset.action_resume()
return {
'asset_id': asset.id,
'resume_date': resume_date or fields.Date.today(),
'state': 'running',
}
@api.model
def reverse_disposal(self, asset) -> dict:
"""Reverse a disposal (rare — recovery from accidental sale entry)."""
asset.ensure_one()
if asset.state != 'disposed':
raise ValidationError(_("Asset is not disposed."))
Disposal = self.env['fusion.asset.disposal'].sudo()
last_disposal = Disposal.search(
[('asset_id', '=', asset.id)],
order='disposal_date desc, id desc', limit=1,
)
if last_disposal and last_disposal.move_id:
try:
last_disposal.move_id.button_cancel()
except Exception as e: # noqa: BLE001
_logger.warning("Could not cancel disposal move: %s", e)
if last_disposal:
last_disposal.unlink()
asset.write({'state': 'running', 'disposed_date': False})
return {'asset_id': asset.id, 'state': 'running'}
# ============================================================
# PRIVATE HELPERS
# ============================================================
def _validate_asset_for_schedule(self, asset):
if asset.cost <= 0:
raise ValidationError(_("Asset cost must be > 0 to compute schedule."))
if asset.method == 'units_of_production' and not asset.total_units_expected:
raise ValidationError(_(
"Units of Production assets need total_units_expected set."
))
if asset.method in ('straight_line', 'declining_balance'):
if asset.useful_life_years < 1:
raise ValidationError(_("useful_life_years must be >= 1."))
if asset.salvage_value > asset.cost:
raise ValidationError(_("Salvage value cannot exceed cost."))
def _compute_steps(self, asset) -> list:
"""Dispatch to the appropriate depreciation method service."""
if asset.method == 'straight_line':
return straight_line(
cost=asset.cost,
salvage_value=asset.salvage_value,
n_periods=asset.useful_life_years,
)
if asset.method == 'declining_balance':
return declining_balance(
cost=asset.cost,
salvage_value=asset.salvage_value,
n_periods=asset.useful_life_years,
rate=asset.declining_rate_pct / 100.0,
)
if asset.method == 'units_of_production':
# Phase 3 simple: assume even per-period units. Phase 3.5 can read
# from a per-period usage table populated by maintenance/IoT data.
if asset.useful_life_years:
per_period = asset.total_units_expected / asset.useful_life_years
periods = asset.useful_life_years
else:
per_period = asset.total_units_expected
periods = 1
return units_of_production(
cost=asset.cost,
salvage_value=asset.salvage_value,
total_units_expected=asset.total_units_expected,
units_per_period=[per_period] * periods,
)
return []
def _add_periods(self, base_date: date, n_periods: int) -> date:
"""Add (n_periods + 1) yearly increments to base_date and step back one
day, giving the period-end date.
Phase 3.5 can split this into monthly/quarterly variants when the asset
carries a sub-annual frequency.
"""
try:
return base_date.replace(year=base_date.year + n_periods + 1) - timedelta(days=1)
except ValueError:
return base_date.replace(
year=base_date.year + n_periods + 1, day=28,
) - timedelta(days=1)
def _create_journal_entry(self, asset, line):
"""Create the journal entry for a depreciation line.
Phase 3 keeps this minimal: requires the category to have both
depreciation_account_id and expense_account_id wired up. Without that,
the line is still posted (is_posted flag) but no move is created.
Phase 3.5 will add multi-currency, allocation rules, and analytic tags.
"""
category = asset.category_id
if not category or not (category.depreciation_account_id and category.expense_account_id):
_logger.debug(
"No accounts on category for asset %s; skipping journal entry",
asset.id,
)
return None
Move = self.env['account.move'].sudo()
journal = self.env['account.journal'].search([
('type', '=', 'general'),
('company_id', '=', asset.company_id.id),
], limit=1)
if not journal:
_logger.warning(
"No general journal for company %s; skipping move creation",
asset.company_id.name,
)
return None
try:
move = Move.create({
'date': line.scheduled_date,
'journal_id': journal.id,
'ref': f"Depreciation: {asset.name} (P{line.period_index + 1})",
'line_ids': [
(0, 0, {
'name': f"Depreciation expense - {asset.name}",
'account_id': category.expense_account_id.id,
'debit': line.amount,
'credit': 0,
}),
(0, 0, {
'name': f"Accumulated depreciation - {asset.name}",
'account_id': category.depreciation_account_id.id,
'debit': 0,
'credit': line.amount,
}),
],
})
move.action_post()
line.write({'move_id': move.id})
return move
except Exception as e: # noqa: BLE001
_logger.warning(
"Failed to create depreciation move for asset %s line %s: %s",
asset.id, line.id, e,
)
return None

View File

@@ -0,0 +1,96 @@
"""Cron handlers for fusion_accounting_assets.
- _cron_post_due_depreciation: daily, post due depreciation lines for running assets
- _cron_anomaly_scan: monthly, scan for schedule variance and create anomaly records
"""
import logging
from odoo import api, fields, models
from ..services.anomaly_detection import detect_schedule_variance
_logger = logging.getLogger(__name__)
class FusionAssetsCron(models.AbstractModel):
_name = "fusion.assets.cron"
_description = "Fusion Assets Cron Handlers"
@api.model
def _cron_post_due_depreciation(self):
"""For each running asset, post any due un-posted depreciation lines."""
today = fields.Date.today()
engine = self.env['fusion.asset.engine']
Asset = self.env['fusion.asset']
running_assets = Asset.search([('state', '=', 'running')])
posted_total = 0
for asset in running_assets:
try:
with self.env.cr.savepoint():
result = engine.post_depreciation_entry(asset, period_date=today)
posted_total += result.get('posted_count', 0)
except Exception as e: # noqa: BLE001
_logger.warning("Cron post failed for asset %s: %s", asset.id, e)
_logger.info(
"Cron: posted depreciation on %d lines across %d running assets",
posted_total, len(running_assets),
)
# Keep the book-value MV in sync after posting so the dashboard
# reflects today's numbers without waiting for the dedicated MV cron.
try:
self.env['fusion.asset.book.values.mv']._refresh()
except Exception as e: # noqa: BLE001
_logger.warning("Post-cron MV refresh failed: %s", e)
@api.model
def _cron_refresh_book_values_mv(self):
"""Refresh the per-asset book value MV (hourly)."""
self.env['fusion.asset.book.values.mv']._refresh()
@api.model
def _cron_anomaly_scan(self):
"""For each running asset, compare expected accumulated depreciation
vs posted, and persist any variance flags."""
Asset = self.env['fusion.asset']
Anomaly = self.env['fusion.asset.anomaly']
running_assets = Asset.search([('state', '=', 'running')])
flagged = 0
today = fields.Date.today()
for asset in running_assets:
try:
expected = sum(
l.amount for l in asset.depreciation_line_ids
if l.scheduled_date and l.scheduled_date <= today
)
actual = asset.total_depreciated
anomaly = detect_schedule_variance(
asset_id=asset.id, asset_name=asset.name,
expected_accumulated=expected, actual_accumulated=actual,
)
if anomaly is None:
continue
anomaly_dict = anomaly.to_dict()
existing = Anomaly.search([
('asset_id', '=', asset.id),
('anomaly_type', '=', anomaly_dict['anomaly_type']),
('state', 'in', ('new', 'acknowledged')),
], limit=1)
if existing:
continue
Anomaly.create({
'asset_id': asset.id,
'anomaly_type': anomaly_dict['anomaly_type'],
'severity': anomaly_dict['severity'],
'expected': anomaly_dict['expected'],
'actual': anomaly_dict['actual'],
'variance_pct': anomaly_dict['variance_pct'],
'detail': anomaly_dict['detail'],
})
flagged += 1
except Exception as e: # noqa: BLE001
_logger.warning("Cron anomaly scan failed for asset %s: %s", asset.id, e)
_logger.info(
"Cron: scanned %d assets, flagged %d anomalies",
len(running_assets), flagged,
)

View File

@@ -0,0 +1,105 @@
"""Assets-specific migration step.
Backfills fusion.asset from existing account.asset rows (Enterprise) so users
get all their existing assets in the Fusion namespace after switchover."""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# Map Enterprise method names to Fusion method names
ENTERPRISE_METHOD_MAP = {
'linear': 'straight_line',
'degressive': 'declining_balance',
'degressive_then_linear': 'declining_balance', # simplified
'manual': 'straight_line',
'unit_of_production': 'units_of_production',
'units_of_production': 'units_of_production',
}
class FusionMigrationWizard(models.TransientModel):
_inherit = "fusion.migration.wizard"
def _assets_bootstrap_step(self):
"""Backfill fusion.asset from account.asset (Enterprise) if it exists."""
result = {
'step': 'assets_bootstrap',
'enterprise_module_present': False,
'created': 0, 'skipped': 0, 'errors': [],
}
# Check if Enterprise account.asset exists
AccountAsset = self.env.get('account.asset')
if AccountAsset is None:
result['enterprise_module_present'] = False
return result
result['enterprise_module_present'] = True
FusionAsset = self.env['fusion.asset'].sudo()
# Iterate Enterprise records
company_id = self.company_id.id if 'company_id' in self._fields and self.company_id else None
domain = []
if company_id:
domain.append(('company_id', '=', company_id))
try:
ea_records = AccountAsset.sudo().search(domain, limit=10000)
except Exception as e:
result['errors'].append(f"Enterprise search failed: {e}")
return result
for ea in ea_records:
try:
# Idempotent: skip if a fusion asset with same source name exists
existing = FusionAsset.search([
('name', '=', ea.name),
('cost', '=', getattr(ea, 'original_value', 0) or 0),
('company_id', '=', ea.company_id.id),
], limit=1)
if existing:
result['skipped'] += 1
continue
# Map state — Enterprise has 'draft', 'open' (running), 'paused', 'close' (disposed)
ea_state = getattr(ea, 'state', 'draft')
state_map = {'draft': 'draft', 'open': 'running',
'paused': 'paused', 'close': 'disposed',
'model': 'draft'}
state = state_map.get(ea_state, 'draft')
method = ENTERPRISE_METHOD_MAP.get(
getattr(ea, 'method', 'linear'), 'straight_line')
FusionAsset.create({
'name': ea.name,
'cost': getattr(ea, 'original_value', 0) or 0,
'salvage_value': getattr(ea, 'salvage_value', 0) or 0,
'acquisition_date': getattr(ea, 'acquisition_date', False) or fields.Date.today(),
'in_service_date': getattr(ea, 'prorata_date', False) or False,
'method': method,
'useful_life_years': getattr(ea, 'method_number', 5) or 5,
'declining_rate_pct': getattr(ea, 'method_progress_factor', 0.2) * 100 if hasattr(ea, 'method_progress_factor') else 20.0,
'company_id': ea.company_id.id,
'state': state,
})
result['created'] += 1
except Exception as e:
result['errors'].append(f"Asset {ea.id}: {e}")
_logger.info(
"fusion_accounting_assets migration: %d created, %d skipped, %d errors",
result['created'], result['skipped'], len(result['errors']))
return result
def action_run_migration(self):
"""Override to add assets-bootstrap step."""
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
try:
self._assets_bootstrap_step()
except Exception as e:
_logger.warning("assets_bootstrap_step failed: %s", e)
return result