From 679dbaa9791b1b8a3fe3b28fa06160b5f50c328d Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 23:48:22 -0400 Subject: [PATCH] feat(fusion_accounting_followup): per-partner state migration from Enterprise Migrates Enterprise account_followup per-partner state to Fusion fields: - res.partner.followup_status -> fusion_followup_status (action_due/no_action) - res.partner.payment_next_action_date -> fusion_followup_paused_until (when future-dated; sets status to 'paused') - res.partner.followup_line_id -> fusion_followup_last_level_id (resolved by name match against migrated levels) Wired into fusion.migration.wizard.action_run_migration after the existing _followup_bootstrap_step. Idempotent: skips partners whose Fusion state is already non-default. Defensive against missing Enterprise fields (each field probed individually before use). Closes the per-partner state migration gap that was blocking Enterprise account_followup uninstall. Made-with: Cursor --- .../models/fusion_migration_wizard.py | 110 ++++++++++++++++++ .../tests/test_migration_round_trip.py | 9 ++ 2 files changed, 119 insertions(+) diff --git a/fusion_accounting_followup/models/fusion_migration_wizard.py b/fusion_accounting_followup/models/fusion_migration_wizard.py index f9ceec36..d40bfce9 100644 --- a/fusion_accounting_followup/models/fusion_migration_wizard.py +++ b/fusion_accounting_followup/models/fusion_migration_wizard.py @@ -78,10 +78,120 @@ class FusionMigrationWizard(models.TransientModel): result['created'], result['skipped'], len(result['errors'])) return result + def _followup_partner_state_bootstrap_step(self): + """Migration step: copy Enterprise account_followup per-partner state + onto Fusion's fields on res.partner. + + Idempotent: only updates partners whose Fusion field is at default + (no_action) and whose Enterprise field has a non-default value. + """ + self.ensure_one() + _logger.info("fusion_accounting_followup partner-state migration starting") + + Partner = self.env['res.partner'].sudo() + has_status = 'followup_status' in Partner._fields + has_next_date = 'payment_next_action_date' in Partner._fields + has_line = 'followup_line_id' in Partner._fields + if not (has_status or has_next_date or has_line): + _logger.info( + "Enterprise account_followup partner fields not present \u2014 skipping") + return { + 'step': 'followup_partner_state', + 'enterprise_module_present': False, + 'updated': 0, 'skipped': 0, 'errors': [], + } + + result = { + 'step': 'followup_partner_state', + 'enterprise_module_present': True, + 'updated': 0, 'skipped': 0, 'errors': [], + } + + domain_terms = [] + if has_status: + domain_terms.append(('followup_status', '!=', 'no_action_needed')) + if has_next_date: + domain_terms.append(('payment_next_action_date', '!=', False)) + if not domain_terms: + _logger.info("No usable Enterprise follow-up fields \u2014 skipping") + return result + if len(domain_terms) > 1: + domain = ['|'] * (len(domain_terms) - 1) + domain_terms + else: + domain = domain_terms + candidates = Partner.search(domain) + _logger.info( + "Found %d partners with non-default Enterprise follow-up state", + len(candidates)) + + Level = self.env['fusion.followup.level'].sudo() + today = fields.Date.today() + + status_map = { + 'in_need_of_action': 'action_due', + 'with_overdue_invoices': 'action_due', + 'no_action_needed': 'no_action', + } + + for partner in candidates: + try: + if partner.fusion_followup_status not in (False, 'no_action'): + result['skipped'] += 1 + continue + + vals = {} + + ent_status = ( + getattr(partner, 'followup_status', None) + if has_status else None) + if ent_status and ent_status in status_map: + vals['fusion_followup_status'] = status_map[ent_status] + + next_date = ( + getattr(partner, 'payment_next_action_date', False) + if has_next_date else False) + if next_date and next_date > today: + vals['fusion_followup_paused_until'] = next_date + vals['fusion_followup_status'] = 'paused' + + ent_line = ( + getattr(partner, 'followup_line_id', None) + if has_line else None) + if ent_line: + fusion_level = Level.search([ + ('name', '=', ent_line.name), + ], limit=1) + if fusion_level: + vals['fusion_followup_last_level_id'] = fusion_level.id + + if vals: + partner.write(vals) + result['updated'] += 1 + _logger.debug( + "Migrated partner %s: %s", partner.name, vals) + else: + result['skipped'] += 1 + + except Exception as e: + result['errors'].append( + f"Partner {partner.id} ({partner.name}): {e}") + _logger.warning( + "Migration failed for partner %s: %s", partner.id, e) + + _logger.info( + "fusion_accounting_followup partner-state migration: " + "updated=%d skipped=%d errors=%d", + result['updated'], result['skipped'], len(result['errors'])) + return result + def action_run_migration(self): result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None try: self._followup_bootstrap_step() except Exception as e: _logger.warning("followup_bootstrap_step failed: %s", e) + try: + self._followup_partner_state_bootstrap_step() + except Exception as e: + _logger.warning("followup_partner_state_bootstrap_step failed: %s", e) return result diff --git a/fusion_accounting_followup/tests/test_migration_round_trip.py b/fusion_accounting_followup/tests/test_migration_round_trip.py index 6dce5510..7a79427c 100644 --- a/fusion_accounting_followup/tests/test_migration_round_trip.py +++ b/fusion_accounting_followup/tests/test_migration_round_trip.py @@ -19,3 +19,12 @@ class TestFollowupMigrationRoundTrip(TransactionCase): # Second run skips what first created (or both no-op) if first['enterprise_module_present']: self.assertGreaterEqual(second['skipped'], first['created']) + + def test_partner_state_bootstrap_step(self): + """Verify the partner-state migration step runs without error.""" + wizard = self.env['fusion.migration.wizard'].create({}) + result = wizard._followup_partner_state_bootstrap_step() + self.assertEqual(result['step'], 'followup_partner_state') + self.assertIn(result['enterprise_module_present'], [True, False]) + self.assertGreaterEqual(result['updated'], 0) + self.assertGreaterEqual(result['skipped'], 0)