# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """Backend intake wizard. A simple Phase 1 transient model that captures one-or-many equipment items per call, then delegates to fusion.repair.intake.service to create the repair.order(s). The shared service guarantees identical behaviour to the sales rep portal and the public client portal added in later phases. Multi-equipment per call is supported via the equipment_ids One2many. Includes Phase 1 polish: - C1: duplicate-call detection (yellow banner if the partner has an open repair from the last N days) - C5: outstanding-balance warning (red banner if open invoice total > config) - C6: quote-only mode (creates the repair but does NOT dispatch a tech) """ import logging from datetime import timedelta from odoo import _, api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class RepairIntakeWizard(models.TransientModel): _name = 'fusion.repair.intake.wizard' _description = 'Repair Intake Wizard' # ------------------------------------------------------------------ # CALLER / CLIENT # ------------------------------------------------------------------ intake_user_id = fields.Many2one( 'res.users', string='Taken By', default=lambda self: self.env.user, required=True, ) partner_id = fields.Many2one( 'res.partner', string='Client', required=True, help='Existing client. Use the create-and-edit dialog to add a new contact.', ) partner_phone = fields.Char( related='partner_id.phone', string='Phone', readonly=True, ) # ------------------------------------------------------------------ # CONTEXTUAL BANNERS (C1 + C5) # Computed reactively when the partner is selected. Shown in the form # so CS knows immediately about duplicate calls or unpaid invoices. # ------------------------------------------------------------------ duplicate_repair_ids = fields.Many2many( 'repair.order', compute='_compute_partner_context', string='Open Repairs (last N days)', ) duplicate_count = fields.Integer( compute='_compute_partner_context', string='Duplicate Call Count', ) duplicate_window_days = fields.Integer( compute='_compute_partner_context', string='Duplicate Window (days)', ) currency_id = fields.Many2one( 'res.currency', compute='_compute_partner_context', string='Currency', ) outstanding_balance = fields.Monetary( compute='_compute_partner_context', currency_field='currency_id', string='Open Invoice Balance', ) outstanding_invoice_count = fields.Integer( compute='_compute_partner_context', string='Open Invoices', ) show_outstanding_warning = fields.Boolean( compute='_compute_partner_context', string='Show Outstanding Balance Warning', ) # ------------------------------------------------------------------ # OPTIONS (C6 quote-only mode) # ------------------------------------------------------------------ quote_only = fields.Boolean( string='Quote Only - Do Not Dispatch', help='Create the service request and quote the client, but do NOT ' 'auto-create a technician dispatch task. Use this when the client ' 'is gathering quotes or has not yet authorised the repair.', ) # ------------------------------------------------------------------ # Bundle 8: rush / emergency options + live surcharge preview # ------------------------------------------------------------------ rush_requested = fields.Boolean( string='Rush / Emergency Service', help='Tick when the client needs faster-than-normal turnaround. ' 'Surcharge is calculated automatically from the rate card.', ) rush_tier = fields.Selection( [ ('same_day', 'Same Day (during business hours)'), ('next_day', 'Next Day Priority'), ('after_hours', 'After Hours (5pm-9pm weekday)'), ('weekend', 'Weekend'), ('holiday', 'Statutory Holiday'), ], string='Rush Tier', ) rush_techs_required = fields.Integer( string='Technicians Required', default=1, ) rush_surcharge_preview = fields.Monetary( string='Quoted Surcharge', compute='_compute_rush_surcharge_preview', currency_field='currency_id', ) rush_acknowledged = fields.Boolean( string='Client Agreed to Price', help='Tick this AFTER you have read the surcharge to the client over the ' 'phone and they have said yes. The repair will record the ' 'acknowledgement timestamp + your user id for audit.', ) @api.depends('rush_tier', 'rush_techs_required', 'equipment_ids.repair_category_id') def _compute_rush_surcharge_preview(self): Rates = self.env['fusion.repair.emergency.charge'].sudo() for w in self: if not w.rush_tier or not w.equipment_ids: w.rush_surcharge_preview = 0.0 continue # Use the FIRST equipment's category for the preview - per-equipment # surcharges land on each repair.order after create. cat = w.equipment_ids[:1].repair_category_id w.rush_surcharge_preview = Rates.calculate( cat, w.rush_tier, w.rush_techs_required or 1, ) # ------------------------------------------------------------------ # EQUIPMENT (one-or-many) # ------------------------------------------------------------------ equipment_ids = fields.One2many( 'fusion.repair.intake.wizard.equipment', 'wizard_id', string='Equipment Items', required=True, ) # ------------------------------------------------------------------ # COMPUTES # ------------------------------------------------------------------ @api.depends('partner_id') def _compute_partner_context(self): ICP = self.env['ir.config_parameter'].sudo() try: window_days = int(ICP.get_param( 'fusion_repairs.duplicate_call_window_days', '14' )) except (ValueError, TypeError): window_days = 14 try: threshold = float(ICP.get_param( 'fusion_repairs.outstanding_balance_threshold', '100' )) except (ValueError, TypeError): threshold = 100.0 # Avoid sudo - CS users already have access to their own company's # repairs/invoices via the standard groups + the Repairs Office rule. Repair = self.env['repair.order'] Move = self.env['account.move'] company_ids = self.env.companies.ids default_currency = self.env.company.currency_id cutoff = fields.Datetime.now() - timedelta(days=window_days) for w in self: w.duplicate_window_days = window_days if not w.partner_id: w.duplicate_repair_ids = False w.duplicate_count = 0 w.outstanding_balance = 0.0 w.outstanding_invoice_count = 0 w.show_outstanding_warning = False w.currency_id = default_currency continue # Multi-company scoped duplicate detection. search_count for the # real total + search(limit=5) for the display list - so the banner # never lies about a partner with >5 open calls. dup_domain = [ ('partner_id', '=', w.partner_id.id), ('state', 'not in', ('done', 'cancel')), ('create_date', '>=', cutoff), ('company_id', 'in', company_ids), ] w.duplicate_repair_ids = Repair.search( dup_domain, order='create_date desc', limit=5, ) w.duplicate_count = Repair.search_count(dup_domain) # commercial_partner_id is the canonical "billed-to root" - covers # child contacts AND walks up from a child if the caller IS a child. commercial = w.partner_id.commercial_partner_id or w.partner_id inv_domain = [ ('commercial_partner_id', '=', commercial.id), ('move_type', '=', 'out_invoice'), ('state', '=', 'posted'), ('payment_state', 'in', ('not_paid', 'partial')), ('company_id', 'in', company_ids), ] # _read_group pushes the SUM to Postgres - O(1) load vs O(N) records. rows = Move._read_group( inv_domain, aggregates=['amount_residual:sum', '__count'], ) balance, invoice_count = rows[0] if rows else (0.0, 0) w.currency_id = default_currency w.outstanding_balance = balance or 0.0 w.outstanding_invoice_count = invoice_count or 0 w.show_outstanding_warning = (balance or 0.0) >= threshold # ------------------------------------------------------------------ # SUBMIT # ------------------------------------------------------------------ def action_submit(self): self.ensure_one() if not self.equipment_ids: raise UserError(_('Please add at least one piece of equipment.')) payload = { 'partner_id': self.partner_id.id, 'intake_user_id': self.intake_user_id.id, 'quote_only': self.quote_only, 'rush_requested': self.rush_requested, 'rush_tier': self.rush_tier if self.rush_requested else False, 'rush_techs_required': self.rush_techs_required if self.rush_requested else 1, 'rush_acknowledged': self.rush_acknowledged, 'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids], } # sudo() so sub-operations (mail.activity, mail.mail, fusion.technician.task) # never trip on permission checks - x_fc_intake_user_id preserves audit identity. repairs = self.env['fusion.repair.intake.service'].sudo().create_repair_orders( payload, source='backend_wizard', ) # If CS ticked "rush" and "client agreed", stamp the ack on every spawned repair. if self.rush_requested and self.rush_acknowledged: for r in repairs: r.x_fc_rush_acknowledged_at = fields.Datetime.now() r.x_fc_rush_acknowledged_by_id = self.intake_user_id.id or self.env.uid if len(repairs) == 1: return { 'type': 'ir.actions.act_window', 'name': repairs.name, 'res_model': 'repair.order', 'view_mode': 'form', 'res_id': repairs.id, } return { 'type': 'ir.actions.act_window', 'name': _('Service Calls Created (%(count)s)', count=len(repairs)), 'res_model': 'repair.order', 'view_mode': 'list,form', 'domain': [('id', 'in', repairs.ids)], } def action_open_existing_repair(self): """C1: jump to the most recent duplicate repair so CS can add a note instead of creating a new repair.""" self.ensure_one() if not self.duplicate_repair_ids: return False repair = self.duplicate_repair_ids[0] return { 'type': 'ir.actions.act_window', 'name': repair.name, 'res_model': 'repair.order', 'view_mode': 'form', 'res_id': repair.id, 'target': 'current', } def action_view_outstanding_invoices(self): """C5: open the list of unpaid invoices for context.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Open Invoices - %s', self.partner_id.name or ''), 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [ ('partner_id', 'child_of', self.partner_id.id), ('move_type', '=', 'out_invoice'), ('state', '=', 'posted'), ('payment_state', 'in', ('not_paid', 'partial')), ], 'target': 'current', } def _equipment_payload(self, eq): """Render an equipment record as a dict the intake service expects.""" return { 'product_id': eq.product_id.id or False, 'lot_id': eq.lot_id.id or False, 'repair_category_id': eq.repair_category_id.id or False, 'intake_template_id': eq.intake_template_id.id or False, 'third_party': eq.third_party, 'urgency': eq.urgency, 'issue_summary': eq.issue_summary or '', 'issue_category': eq.issue_category or '', 'internal_notes': eq.internal_notes or '', 'schedule_date': eq.scheduled_date or False, 'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [], 'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet } class RepairIntakeWizardEquipment(models.TransientModel): """A single piece of equipment captured in the wizard. Multiple lines = multi-equipment intake (one repair.order per line). """ _name = 'fusion.repair.intake.wizard.equipment' _description = 'Repair Intake Wizard - Equipment Line' _order = 'sequence, id' wizard_id = fields.Many2one( 'fusion.repair.intake.wizard', string='Wizard', required=True, ondelete='cascade', ) sequence = fields.Integer(default=10) # Equipment identification repair_category_id = fields.Many2one( 'fusion.repair.product.category', string='Category', required=True, ) product_id = fields.Many2one( 'product.product', string='Product', help='Specific product if known. Leave blank for generic equipment.', ) lot_id = fields.Many2one( 'stock.lot', string='Serial Number', domain="[('product_id', '=', product_id)]", help='Lot or serial number if known.', ) third_party = fields.Boolean( string='Not Purchased From Us', help='Tick if this equipment was bought elsewhere - we still service it but ' 'warranty is not honoured and a service call-out fee applies.', ) # Intake context intake_template_id = fields.Many2one( 'fusion.repair.intake.template', string='Question Template', help='Defaults to the template configured on the category if left blank.', ) # Triage urgency = fields.Selection( [('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')], string='Urgency', default='normal', required=True, ) scheduled_date = fields.Datetime( string='Preferred Date', default=fields.Datetime.now, ) issue_summary = fields.Char( string='Issue Summary', help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").', ) issue_category = fields.Char( string='Symptom Category', help='Optional symptom tag for catalogue matching (e.g. "battery", "motor").', ) internal_notes = fields.Text(string='Internal Notes') photo_ids = fields.Many2many( 'ir.attachment', 'fusion_repair_intake_wizard_eq_photo_rel', 'eq_id', 'attachment_id', string='Photos / Videos', ) @api.onchange('repair_category_id') def _onchange_repair_category_id(self): """Pre-fill the intake template from the category default.""" if self.repair_category_id and not self.intake_template_id: self.intake_template_id = self.repair_category_id.intake_template_id @api.onchange('product_id') def _onchange_product_id(self): """Pre-fill the category from the product if defined.""" if self.product_id and not self.repair_category_id: cat = self.product_id.product_tmpl_id.x_fc_repair_category_id if cat: self.repair_category_id = cat