diff --git a/fusion_accounting_assets/__manifest__.py b/fusion_accounting_assets/__manifest__.py index e30f2273..5809f4ff 100644 --- a/fusion_accounting_assets/__manifest__.py +++ b/fusion_accounting_assets/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Assets', - 'version': '19.0.1.0.30', + 'version': '19.0.1.0.31', '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', ], diff --git a/fusion_accounting_assets/models/__init__.py b/fusion_accounting_assets/models/__init__.py index d5183ee1..600dfe1b 100644 --- a/fusion_accounting_assets/models/__init__.py +++ b/fusion_accounting_assets/models/__init__.py @@ -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 diff --git a/fusion_accounting_assets/models/fusion_migration_wizard.py b/fusion_accounting_assets/models/fusion_migration_wizard.py new file mode 100644 index 00000000..c3f6f17a --- /dev/null +++ b/fusion_accounting_assets/models/fusion_migration_wizard.py @@ -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 diff --git a/fusion_accounting_assets/tests/__init__.py b/fusion_accounting_assets/tests/__init__.py index d5544e90..ee6bf715 100644 --- a/fusion_accounting_assets/tests/__init__.py +++ b/fusion_accounting_assets/tests/__init__.py @@ -23,3 +23,4 @@ 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 diff --git a/fusion_accounting_assets/tests/test_migration_round_trip.py b/fusion_accounting_assets/tests/test_migration_round_trip.py new file mode 100644 index 00000000..95dae785 --- /dev/null +++ b/fusion_accounting_assets/tests/test_migration_round_trip.py @@ -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'])