# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError 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.', ) # 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.'))