Compare commits

...

4 Commits

Author SHA1 Message Date
gsinghpal
2ee341316c test(fusion_accounting_assets): coexistence behavior
Made-with: Cursor
2026-04-19 20:16:30 -04:00
gsinghpal
02885108f2 feat(fusion_accounting_assets): menu + window actions with coexistence group filter
Made-with: Cursor
2026-04-19 20:15:38 -04:00
gsinghpal
af8c72a3b1 feat(fusion_accounting_assets): migration audit PDF report
Made-with: Cursor
2026-04-19 20:14:50 -04:00
gsinghpal
1491f455fe feat(fusion_accounting_assets): migration wizard backfill from account.asset
Made-with: Cursor
2026-04-19 20:13:30 -04:00
13 changed files with 360 additions and 1 deletions

View File

@@ -2,3 +2,4 @@ from . import models
from . import services
from . import controllers
from . import wizards
from . import reports

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Assets',
'version': '19.0.1.0.30',
'version': '19.0.1.0.33',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented asset management with depreciation schedules.',
'description': """
@@ -28,6 +28,7 @@ menu hides; the engine + AI tools remain available for the chat.
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'fusion_accounting_migration',
'account',
'mail',
],
@@ -38,6 +39,9 @@ menu hides; the engine + AI tools remain available for the chat.
'wizards/disposal_wizard_views.xml',
'wizards/partial_sale_wizard_views.xml',
'wizards/depreciation_run_wizard_views.xml',
'reports/migration_audit_report_views.xml',
'reports/migration_audit_report_action.xml',
'views/menu_views.xml',
],
'assets': {
'web.assets_backend': [

View File

@@ -7,3 +7,4 @@ 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,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

View File

@@ -0,0 +1 @@
from . import migration_audit_report

View File

@@ -0,0 +1,36 @@
"""QWeb PDF: migration audit report for fusion_accounting_assets."""
from odoo import api, models
class FusionAssetsMigrationAuditReport(models.AbstractModel):
_name = "report.fusion_accounting_assets.migration_audit_template"
_description = "Fusion Assets Migration Audit"
@api.model
def _get_report_values(self, docids, data=None):
wizards = self.env['fusion.migration.wizard'].browse(docids) if docids else self.env['fusion.migration.wizard']
Asset = self.env['fusion.asset']
company_stats = []
for company in self.env['res.company'].search([]):
assets = Asset.search([('company_id', '=', company.id)])
by_state = {}
for state in ('draft', 'running', 'paused', 'disposed'):
by_state[state] = sum(1 for a in assets if a.state == state)
total_cost = sum(a.cost for a in assets)
total_book = sum(a.book_value for a in assets)
total_dep = sum(a.total_depreciated for a in assets)
company_stats.append({
'company': company,
'count': len(assets),
'by_state': by_state,
'total_cost': total_cost,
'total_book_value': total_book,
'total_depreciated': total_dep,
})
return {
'doc_ids': docids,
'doc_model': 'fusion.migration.wizard',
'docs': wizards,
'company_stats': company_stats,
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_assets_migration_audit" model="ir.actions.report">
<field name="name">Assets Migration Audit</field>
<field name="model">fusion.migration.wizard</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_accounting_assets.migration_audit_template</field>
<field name="report_file">fusion_accounting_assets.migration_audit_template</field>
<field name="binding_model_id" ref="fusion_accounting_migration.model_fusion_migration_wizard"/>
</record>
</odoo>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="migration_audit_template">
<t t-call="web.html_container">
<t t-call="web.external_layout">
<div class="page">
<h2>Fusion Assets Migration Audit</h2>
<p>
<span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
</p>
<h3>Per-Company Summary</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Company</th>
<th class="text-end">Total Assets</th>
<th class="text-end">Draft</th>
<th class="text-end">Running</th>
<th class="text-end">Paused</th>
<th class="text-end">Disposed</th>
<th class="text-end">Total Cost</th>
<th class="text-end">Total NBV</th>
<th class="text-end">Total Depreciated</th>
</tr>
</thead>
<tbody>
<tr t-foreach="company_stats" t-as="cs">
<td><span t-field="cs['company'].name"/></td>
<td class="text-end"><span t-esc="cs['count']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['draft']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['running']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['paused']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['disposed']"/></td>
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_cost'])"/></td>
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_book_value'])"/></td>
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_depreciated'])"/></td>
</tr>
</tbody>
</table>
<p class="text-muted small">
Generated by Fusion Accounting Assets
</p>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -23,3 +23,6 @@ from . import test_create_asset_wizard
from . import test_disposal_wizard
from . import test_partial_sale_wizard
from . import test_depreciation_run_wizard
from . import test_migration_round_trip
from . import test_audit_report
from . import test_coexistence

View File

@@ -0,0 +1,18 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAuditReport(TransactionCase):
def test_report_renders(self):
wizard = self.env['fusion.migration.wizard'].create({})
try:
pdf, content_type = self.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_accounting_assets.migration_audit_template',
res_ids=[wizard.id], data={},
)
# PDF or HTML both ok (wkhtmltopdf might be missing on dev VM)
self.assertGreater(len(pdf), 100)
except Exception as e:
self.skipTest(f"PDF render failed (likely wkhtmltopdf missing): {e}")

View File

@@ -0,0 +1,38 @@
"""Coexistence tests: fusion_accounting_assets menu only visible when
Enterprise account_asset is NOT installed."""
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAssetsCoexistence(TransactionCase):
def setUp(self):
super().setUp()
self.coex_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
self.assertIsNotNone(self.coex_group, "Coexistence group must exist")
def test_engine_always_available(self):
"""Engine is registered regardless of Enterprise install state."""
self.assertIn('fusion.asset.engine', self.env.registry)
def test_menu_gated_by_coexistence_group(self):
menu = self.env.ref('fusion_accounting_assets.menu_fusion_assets_root',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups,
"Asset root menu must require the coexistence group")
def test_categories_menu_gated(self):
menu = self.env.ref('fusion_accounting_assets.menu_fusion_asset_categories',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups)

View File

@@ -0,0 +1,24 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAssetsMigrationRoundTrip(TransactionCase):
def test_bootstrap_step_runs_without_enterprise(self):
"""When Enterprise account.asset is NOT installed, step is a no-op."""
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._assets_bootstrap_step()
self.assertEqual(result['step'], 'assets_bootstrap')
# In our local DB, Enterprise account.asset may or may not exist
# If absent: enterprise_module_present is False
# If present: created>=0
self.assertIn(result['enterprise_module_present'], [True, False])
def test_bootstrap_idempotent_on_re_run(self):
wizard = self.env['fusion.migration.wizard'].create({})
first = wizard._assets_bootstrap_step()
second = wizard._assets_bootstrap_step()
# Second run should skip what the first created (or both no-op)
if first['enterprise_module_present']:
self.assertGreaterEqual(second['skipped'], first['created'])

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level menu (visible only when account_asset Enterprise NOT installed) -->
<menuitem id="menu_fusion_assets_root"
name="Asset Management"
sequence="60"
web_icon="fusion_accounting_assets,static/description/icon.png"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Asset list/form -->
<record id="action_fusion_asset_list" model="ir.actions.act_window">
<field name="name">Assets</field>
<field name="res_model">fusion.asset</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Manage your fixed assets
</p>
<p>
Track depreciation, post periodic entries, dispose assets at end-of-life.
AI augmentation: anomaly detection + suggested useful life.
</p>
</field>
</record>
<menuitem id="menu_fusion_assets_list"
name="Assets"
parent="menu_fusion_assets_root"
action="action_fusion_asset_list"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Categories -->
<record id="action_fusion_asset_category_list" model="ir.actions.act_window">
<field name="name">Asset Categories</field>
<field name="res_model">fusion.asset.category</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_asset_categories"
name="Categories"
parent="menu_fusion_assets_root"
action="action_fusion_asset_category_list"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Anomalies -->
<record id="action_fusion_asset_anomaly_list" model="ir.actions.act_window">
<field name="name">Asset Anomalies</field>
<field name="res_model">fusion.asset.anomaly</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_asset_anomalies"
name="Anomalies"
parent="menu_fusion_assets_root"
action="action_fusion_asset_anomaly_list"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Run depreciation -->
<menuitem id="menu_fusion_assets_run_depreciation"
name="Run Depreciation..."
parent="menu_fusion_assets_root"
action="action_fusion_depreciation_run_wizard"
sequence="40"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
</odoo>