"""Followup-specific migration step. Backfills fusion.followup.level from Enterprise's account_followup.followup.line records (if Enterprise account_followup is installed).""" import logging from odoo import api, fields, models _logger = logging.getLogger(__name__) class FusionMigrationWizard(models.TransientModel): _inherit = "fusion.migration.wizard" def _followup_bootstrap_step(self): """Backfill fusion.followup.level from account_followup.followup.line.""" result = { 'step': 'followup_bootstrap', 'enterprise_module_present': False, 'created': 0, 'skipped': 0, 'errors': [], } # Enterprise's followup model — name varies by version EnterpriseLine = self.env.get('account_followup.followup.line') if EnterpriseLine is None: EnterpriseLine = self.env.get('account.followup.line') if EnterpriseLine is None: result['enterprise_module_present'] = False return result result['enterprise_module_present'] = True FusionLevel = self.env['fusion.followup.level'].sudo() try: ee_records = EnterpriseLine.sudo().search([]) except Exception as e: result['errors'].append(f"Enterprise search failed: {e}") return result # Pick a starting offset that doesn't clash with anything already in # fusion_followup_level (seeded defaults at 1..3 plus any prior # migration runs). We allocate a unique sequence per Enterprise line # by max(existing) + 1, ensuring idempotency + within-batch uniqueness. existing_max = max(FusionLevel.search([]).mapped('sequence') or [100]) next_seq = max(existing_max + 1, 101) # Map Enterprise tone-ish fields to ours for ee in ee_records: try: raw_seq = getattr(ee, 'sequence', None) or 50 name = getattr(ee, 'name', None) or f"Migrated Level {raw_seq}" # Idempotency: skip if a level with same name was already # backfilled in a prior migration run. existing = FusionLevel.search([('name', '=', name)], limit=1) if existing: result['skipped'] += 1 continue delay = getattr(ee, 'delay', None) or getattr(ee, 'delay_days', 7) # Enterprise tone heuristic: scale by sequence tone = 'gentle' if raw_seq <= 1 else 'firm' if raw_seq <= 2 else 'legal' with self.env.cr.savepoint(): FusionLevel.create({ 'name': name, 'sequence': next_seq, 'delay_days': delay, 'tone': tone, 'active': True, }) next_seq += 1 result['created'] += 1 except Exception as e: result['errors'].append(f"Line {ee.id}: {e}") _logger.info( "fusion_accounting_followup migration: %d created, %d skipped, %d errors", 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