# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import logging from datetime import date, datetime, timedelta from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) INTAKE_SOURCES = [ ('backend_wizard', 'Backend Wizard (CS)'), ('sales_rep_portal', 'Sales Rep Portal'), ('client_portal', 'Client Self-Service'), ('manual', 'Manual / Other'), ] URGENCY_LEVELS = [ ('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue'), ] class RepairOrder(models.Model): """Extend Odoo Repairs with intake context, dispatch link, warranty determination, and pricing variance tracking for Fusion Repairs.""" _inherit = 'repair.order' # ------------------------------------------------------------------ # CREATE - replace the picking-type default sequence with our # date-based RO-YYYYMM-NN reference. We set vals['name'] BEFORE # super() so Odoo's native create() (which only assigns the picking # type sequence when name is empty or 'New') skips its own numbering. # ------------------------------------------------------------------ @api.model_create_multi def create(self, vals_list): Sequence = self.env['ir.sequence'].sudo() for vals in vals_list: if not vals.get('name') or vals.get('name') == 'New': next_name = Sequence.next_by_code('fusion.repair.order.monthly') if next_name: vals['name'] = next_name return super().create(vals_list) # ------------------------------------------------------------------ # INTAKE METADATA # ------------------------------------------------------------------ x_fc_intake_source = fields.Selection( INTAKE_SOURCES, string='Intake Source', default='manual', tracking=True, help='Which intake surface created this repair (backend CS wizard, ' 'sales rep portal, public client portal, or manual entry).', ) x_fc_intake_user_id = fields.Many2one( 'res.users', string='Intake By', tracking=True, index=True, help='User who took the call / submitted the intake. For client portal, ' 'this is the OdooBot or admin user.', ) x_fc_intake_session_id = fields.Char( string='Intake Session', index=True, copy=False, help='Reference shared by multiple repair orders created during the same call.', ) x_fc_intake_template_id = fields.Many2one( 'fusion.repair.intake.template', string='Intake Template', help='Question template used during intake.', ) x_fc_intake_answer_ids = fields.One2many( 'fusion.repair.intake.answer', 'repair_id', string='Intake Answers', ) # Catalogue match (Phase 2) x_fc_service_catalog_id = fields.Many2one( 'fusion.repair.service.catalog', string='Service Catalogue Match', index=True, help='Auto-matched catalogue entry that pre-fills estimated cost and duration.', ) # C6: quote-only flag (set when intake submitted in quote-only mode). x_fc_is_quote_only = fields.Boolean( string='Quote Only', tracking=True, index=True, help='True when the intake was submitted in "Quote Only" mode - the ' 'office has not yet authorised dispatching a technician.', ) # ------------------------------------------------------------------ # ON-CALL PAGING (CL15) # Set when a safety repair is paged to the on-call manager. Allows # ack and the 15-minute escalation cron to roll forward to the next # priority if not acknowledged. # ------------------------------------------------------------------ x_fc_on_call_token = fields.Char( string='On-Call Ack Token', copy=False, index=True, ) x_fc_on_call_paged_user_id = fields.Many2one( 'res.users', string='On-Call Paged User', copy=False, index=True, ) x_fc_on_call_paged_at = fields.Datetime( string='On-Call Paged At', copy=False, ) x_fc_on_call_acknowledged_user_ids = fields.Many2many( 'res.users', 'fusion_repair_on_call_ack_rel', 'repair_id', 'user_id', string='On-Call Acknowledgements', copy=False, ) x_fc_on_call_acknowledged_at = fields.Datetime( string='Acknowledged At', copy=False, ) _on_call_token_unique = models.Constraint( 'unique(x_fc_on_call_token)', 'On-call acknowledgement tokens must be unique.', ) # ------------------------------------------------------------------ # X4 + M3 - NPS sent flag + loaner offered flag + done-at stamp # (X2 day-before flag now lives on fusion.technician.task per H1) # ------------------------------------------------------------------ x_fc_nps_email_sent = fields.Boolean( string='NPS Email Sent', copy=False, ) x_fc_done_at = fields.Datetime( string='Closed At', copy=False, readonly=True, help='Stamped when the repair first transitions to state=done. ' 'Drives the post-visit NPS cron (24h after close) without ' 'getting pushed forward by every subsequent chatter message.', ) x_fc_loaner_offered = fields.Boolean( string='Loaner Offered', copy=False, help='True once a loaner-offer activity has been posted for this ' 'long-running repair (M3). Avoids re-posting daily.', ) # ------------------------------------------------------------------ # M9 - Margin per repair (revenue - labour cost - parts cost) # All non-stored computes; surfaced in the M7 analytics dashboard. # ------------------------------------------------------------------ x_fc_revenue = fields.Monetary( string='Revenue', currency_field='company_currency_id', compute='_compute_margin', help='Sum of posted invoice totals for the repair sale order.', ) x_fc_labour_cost = fields.Monetary( string='Labour Cost', currency_field='company_currency_id', compute='_compute_margin', help='Sum of (hours x technician cost rate) over all completed visits.', ) x_fc_parts_cost = fields.Monetary( string='Parts Cost', currency_field='company_currency_id', compute='_compute_margin', help='Sum of standard_price for parts consumed via repair operations.', ) x_fc_margin = fields.Monetary( string='Margin', currency_field='company_currency_id', compute='_compute_margin', help='Revenue - labour cost - parts cost.', ) x_fc_margin_pct = fields.Float( string='Margin %', compute='_compute_margin', ) def _compute_margin(self): for r in self: revenue = 0.0 if r.sale_order_id and hasattr(r.sale_order_id, 'invoice_ids'): for inv in r.sale_order_id.invoice_ids.filtered( lambda m: m.state == 'posted' and m.move_type == 'out_invoice' ): revenue += inv.amount_untaxed or 0.0 labour = 0.0 for task in r.x_fc_technician_task_ids: if task.status != 'completed': continue rate = task.technician_id.x_fc_tech_cost_rate or 0.0 labour += (task.duration_hours or 0.0) * rate parts = 0.0 for move in r.move_ids.filtered(lambda m: m.repair_line_type == 'add'): parts += (move.product_id.standard_price or 0.0) * (move.product_uom_qty or 0.0) r.x_fc_revenue = revenue r.x_fc_labour_cost = labour r.x_fc_parts_cost = parts margin = revenue - labour - parts r.x_fc_margin = margin r.x_fc_margin_pct = (margin / revenue * 100) if revenue else 0.0 def write(self, vals): # H2: stamp x_fc_done_at the first time state transitions to 'done' # so the NPS cron has a stable timestamp (write_date moves on every # chatter / invoice / attachment write). if vals.get('state') == 'done': for r in self: if r.state != 'done' and not r.x_fc_done_at: vals = dict(vals) vals['x_fc_done_at'] = fields.Datetime.now() break return super().write(vals) # ------------------------------------------------------------------ # X2 / X4 / M3 crons # ------------------------------------------------------------------ @api.model def cron_send_day_before_reminders(self): """X2: email the client the day before each scheduled tech visit. Per-TASK flag (not per-repair) so multi-visit repairs get a separate reminder for each individual visit. """ if not self._notifications_enabled(): return tomorrow = date.today() + timedelta(days=1) Task = self.env['fusion.technician.task'].sudo() tasks = Task.search([ ('scheduled_date', '=', tomorrow), ('x_fc_day_before_reminder_sent', '=', False), ('x_fc_repair_order_id', '!=', False), ('x_fc_repair_order_id.state', 'in', ('confirmed', 'under_repair')), ]) tpl = self.env.ref( 'fusion_repairs.email_template_visit_day_before', raise_if_not_found=False, ) if not tpl: _logger.warning('X2 day-before cron: email template missing') return for task in tasks: repair = task.x_fc_repair_order_id if not repair.partner_id or not repair.partner_id.email: task.x_fc_day_before_reminder_sent = True # don't keep retrying continue try: # Pass the specific task via context so the template renders # the right scheduled date / technician (H3). tpl.with_context(reminder_task_id=task.id) \ .send_mail(repair.id, force_send=False) except Exception: _logger.exception('X2 day-before reminder failed for task %s', task.name) # Still set the flag - the task's "tomorrow" is gone after midnight # so retrying tomorrow would email about the wrong date. task.x_fc_day_before_reminder_sent = True @api.model def cron_send_post_visit_nps(self): """X4: send NPS / Google review email 24h after state=done. Uses x_fc_done_at (H2) so chatter writes don't push the timestamp forward. """ if not self._notifications_enabled(): return cutoff = datetime.now() - timedelta(hours=24) repairs = self.search([ ('state', '=', 'done'), ('x_fc_nps_email_sent', '=', False), ('x_fc_done_at', '!=', False), ('x_fc_done_at', '<=', cutoff), ]) tpl = self.env.ref( 'fusion_repairs.email_template_post_visit_nps', raise_if_not_found=False, ) if not tpl: _logger.warning('X4 NPS cron: email template missing') return for r in repairs: if not r.partner_id or not r.partner_id.email: r.x_fc_nps_email_sent = True # don't keep retrying continue try: tpl.send_mail(r.id, force_send=False) except Exception: _logger.exception('X4 NPS email failed for repair %s', r.name) r.x_fc_nps_email_sent = True @api.model def cron_offer_loaner_for_long_repairs(self): """M3: post an Offer-Loaner activity when a confirmed/in-repair order has been waiting longer than threshold days. Soft-depends on fusion_loaners_management - silently no-ops when the loaner model isn't installed. Uses schedule_date (or create_date as fallback) so quote-only / draft repairs aren't bothered. """ if 'fusion.loaner.checkout' not in self.env: return ICP = self.env['ir.config_parameter'].sudo() try: threshold = int(ICP.get_param( 'fusion_repairs.loaner_offer_threshold_days', '3' )) except (ValueError, TypeError): threshold = 3 cutoff = datetime.now() - timedelta(days=threshold) activity_type = self.env.ref( 'fusion_repairs.mail_activity_type_loaner_offer', raise_if_not_found=False, ) if not activity_type: _logger.warning('M3 loaner cron: activity type missing, skipping') return repairs = self.search([ ('state', 'in', ('confirmed', 'under_repair')), ('x_fc_is_quote_only', '=', False), ('x_fc_loaner_offered', '=', False), '|', '&', ('schedule_date', '!=', False), ('schedule_date', '<=', cutoff), '&', ('schedule_date', '=', False), ('create_date', '<=', cutoff), ], limit=200, order='create_date desc') for r in repairs: try: r.activity_schedule( activity_type_id=activity_type.id, summary='Offer a loaner unit', note=( 'This repair has been waiting more than %s days. ' 'Consider offering the client a loaner unit while we ' 'complete the repair.' ) % threshold, user_id=r.user_id.id or self.env.uid, ) r.x_fc_loaner_offered = True except Exception: _logger.exception( 'M3 loaner cron: activity_schedule failed for repair %s', r.name, ) @api.model def _notifications_enabled(self): # Delegate to the shared intake-service single source of truth (M2). Service = self.env.get('fusion.repair.intake.service') if Service: return Service._notifications_enabled() return self.env['ir.config_parameter'].sudo().get_param( 'fusion_repairs.enable_email_notifications', 'True' ) == 'True' def action_offer_loaner(self): """Open the fusion_loaners_management checkout wizard pre-filled with this repair's partner. Soft-link - raises if the module is not installed.""" self.ensure_one() if 'fusion.loaner.checkout' not in self.env: raise UserError(_( 'Loaner management is not installed. Install ' 'fusion_loaners_management to enable this feature.' )) return { 'type': 'ir.actions.act_window', 'name': _('Offer Loaner'), 'res_model': 'fusion.loaner.checkout', 'view_mode': 'form', 'target': 'new', 'context': { 'default_partner_id': self.partner_id.id, 'default_sale_order_id': self.sale_order_id.id or False, }, } # Maintenance contract back-link (Phase 3) x_fc_maintenance_contract_id = fields.Many2one( 'fusion.repair.maintenance.contract', string='Maintenance Contract', index=True, help='Set when this repair was spawned from a maintenance reminder booking. ' 'Completing the related technician task rolls the contract to its next cycle.', ) x_fc_intake_answer_count = fields.Integer( compute='_compute_intake_answer_count', ) # ------------------------------------------------------------------ # EQUIPMENT / WARRANTY # ------------------------------------------------------------------ x_fc_repair_category_id = fields.Many2one( 'fusion.repair.product.category', string='Equipment Category', tracking=True, index=True, help='Medical equipment category - drives intake template and tech skills filter.', ) x_fc_third_party_equipment = fields.Boolean( string='Third-Party Equipment', tracking=True, help='True if the equipment was not sold by us. Forces under_warranty=False ' 'and typically triggers a service call-out fee.', ) x_fc_original_sale_order_id = fields.Many2one( 'sale.order', string='Original Purchase SO', tracking=True, index=True, help='Sale order through which the customer originally purchased this unit. ' 'Auto-matched on intake by partner + lot/serial.', ) x_fc_warranty_override_reason = fields.Char( string='Warranty Override Reason', help='Required when CS overrides the auto-detected warranty status.', ) # ------------------------------------------------------------------ # TRIAGE / URGENCY # ------------------------------------------------------------------ x_fc_urgency = fields.Selection( URGENCY_LEVELS, string='Urgency', default='normal', tracking=True, index=True, ) x_fc_issue_category = fields.Char( string='Issue Category', help='Symptom classification (e.g. "battery", "motor", "remote"). Used by ' 'service catalogue matcher and AI prompt context.', ) # ------------------------------------------------------------------ # PHOTOS # ------------------------------------------------------------------ x_fc_photo_ids = fields.Many2many( 'ir.attachment', 'fusion_repair_order_photo_rel', 'repair_id', 'attachment_id', string='Intake Photos / Videos', help='Photos and videos uploaded during intake.', ) x_fc_photo_count = fields.Integer( compute='_compute_photo_count', ) # ------------------------------------------------------------------ # PRICING (estimate vs actual - Phase 2 reconciliation) # ------------------------------------------------------------------ x_fc_estimated_duration = fields.Float( string='Estimated Duration (h)', help='Estimated visit duration from service catalogue, used to size technician slot.', ) x_fc_estimated_cost = fields.Monetary( string='Estimated Cost', currency_field='company_currency_id', help='Estimated total from catalogue match at intake (pre-visit).', ) x_fc_actual_cost = fields.Monetary( string='Actual Cost', currency_field='company_currency_id', help='Actual total recorded from the visit report (post-visit).', ) x_fc_cost_variance_pct = fields.Float( string='Cost Variance %', compute='_compute_cost_variance', store=True, help='(actual - estimated) / estimated * 100', ) x_fc_requires_requote = fields.Boolean( string='Requires Re-Quote', help='Set when actual cost exceeds estimate beyond the configured threshold; ' 'blocks automatic invoicing until manager approves or client re-confirms.', ) company_currency_id = fields.Many2one( 'res.currency', related='company_id.currency_id', readonly=True, ) # ------------------------------------------------------------------ # FIELD SERVICE LINK # ------------------------------------------------------------------ x_fc_technician_task_ids = fields.One2many( 'fusion.technician.task', 'x_fc_repair_order_id', string='Technician Tasks', ) x_fc_technician_task_count = fields.Integer( compute='_compute_technician_task_count', ) # ------------------------------------------------------------------ # AI SUMMARY (Phase 2) # ------------------------------------------------------------------ x_fc_ai_summary = fields.Text( string='AI Pre-Visit Brief', help='AI-generated short brief for the technician based on intake answers. ' 'Optional - never blocks intake submit.', ) # ------------------------------------------------------------------ # COMPUTES # ------------------------------------------------------------------ @api.depends('x_fc_intake_answer_ids') def _compute_intake_answer_count(self): for repair in self: repair.x_fc_intake_answer_count = len(repair.x_fc_intake_answer_ids) @api.depends('x_fc_photo_ids') def _compute_photo_count(self): for repair in self: repair.x_fc_photo_count = len(repair.x_fc_photo_ids) @api.depends('x_fc_technician_task_ids') def _compute_technician_task_count(self): for repair in self: repair.x_fc_technician_task_count = len(repair.x_fc_technician_task_ids) @api.depends('x_fc_estimated_cost', 'x_fc_actual_cost') def _compute_cost_variance(self): for repair in self: if repair.x_fc_estimated_cost: repair.x_fc_cost_variance_pct = ( (repair.x_fc_actual_cost - repair.x_fc_estimated_cost) / repair.x_fc_estimated_cost * 100 ) else: repair.x_fc_cost_variance_pct = 0.0 # ------------------------------------------------------------------ # WARRANTY DETERMINATION # ------------------------------------------------------------------ def _fc_compute_warranty_status(self): """Auto-detect warranty: not third-party AND within warranty window.""" self.ensure_one() if self.x_fc_third_party_equipment: return False if not self.x_fc_original_sale_order_id: return False original = self.x_fc_original_sale_order_id delivery_date = original.commitment_date or original.date_order if not delivery_date: return False warranty_months = ( self.product_id.product_tmpl_id.x_fc_warranty_months if self.product_id else 0 ) if not warranty_months: return False # relativedelta handles month boundaries correctly (28/29/30/31). cutoff = fields.Datetime.from_string(str(delivery_date)) + relativedelta(months=warranty_months) return fields.Datetime.now() <= cutoff # ------------------------------------------------------------------ # SMART BUTTONS # ------------------------------------------------------------------ def action_view_intake_answers(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Intake Answers'), 'res_model': 'fusion.repair.intake.answer', 'view_mode': 'list,form', 'domain': [('repair_id', '=', self.id)], 'context': {'default_repair_id': self.id}, } def action_view_technician_tasks(self): self.ensure_one() if len(self.x_fc_technician_task_ids) == 1: return { 'type': 'ir.actions.act_window', 'name': self.x_fc_technician_task_ids.name, 'res_model': 'fusion.technician.task', 'view_mode': 'form', 'res_id': self.x_fc_technician_task_ids.id, } return { 'type': 'ir.actions.act_window', 'name': _('Technician Tasks'), 'res_model': 'fusion.technician.task', 'view_mode': 'list,form', 'domain': [('x_fc_repair_order_id', '=', self.id)], 'context': {'default_x_fc_repair_order_id': self.id}, } def action_view_original_sale_order(self): self.ensure_one() if not self.x_fc_original_sale_order_id: return False return { 'type': 'ir.actions.act_window', 'name': self.x_fc_original_sale_order_id.name, 'res_model': 'sale.order', 'view_mode': 'form', 'res_id': self.x_fc_original_sale_order_id.id, } # ------------------------------------------------------------------ # WIZARDS / PAYMENT # ------------------------------------------------------------------ def action_open_visit_report(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Visit Report'), 'res_model': 'fusion.repair.visit.report.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_repair_id': self.id, 'default_labour_hours': self.x_fc_estimated_duration or 1.0, }, } def action_collect_payment(self): """Open the Poynt payment wizard for the linked posted invoice.""" self.ensure_one() # Resolve the linked invoice via the standard repair -> SO -> invoice chain. if not self.sale_order_id: raise UserError(_('Confirm a sale order from this repair first.')) invoice = self.sale_order_id.invoice_ids.filtered( lambda m: m.state == 'posted' and m.payment_state in ('not_paid', 'partial') )[:1] if not invoice: raise UserError(_('No posted, unpaid invoice was found for this repair.')) if hasattr(invoice, 'action_open_poynt_payment_wizard'): return invoice.action_open_poynt_payment_wizard() raise UserError(_('Poynt payment is not available - install or configure fusion_poynt.'))