# -*- 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 markupsafe import Markup 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.', ) # ------------------------------------------------------------------ # Bundle 8: RUSH / EMERGENCY SERVICE # ------------------------------------------------------------------ x_fc_rush_requested = fields.Boolean( string='Rush / Emergency Service', tracking=True, copy=False, ) x_fc_rush_tier = fields.Selection( [ ('same_day', 'Same Day'), ('next_day', 'Next Day Priority'), ('after_hours', 'After Hours'), ('weekend', 'Weekend'), ('holiday', 'Statutory Holiday'), ], string='Rush Tier', tracking=True, copy=False, ) x_fc_rush_techs_required = fields.Integer( string='Technicians Required', default=1, copy=False, help='Some calls need 2+ techs (e.g. heavy lifting, controller programming ' 'plus mechanical). Surcharge scales accordingly.', ) x_fc_rush_surcharge = fields.Monetary( string='Rush Surcharge', currency_field='company_currency_id', compute='_compute_rush_surcharge', store=True, tracking=True, ) x_fc_rush_acknowledged_at = fields.Datetime( string='Rush Surcharge Acknowledged', copy=False, readonly=True, help='Stamped when CS records that the client agreed to the rush price.', ) x_fc_rush_acknowledged_by_id = fields.Many2one( 'res.users', string='Acknowledged By', copy=False, readonly=True, help='The CS rep who got the verbal OK from the client.', ) # ------------------------------------------------------------------ # Bundle 8: PARTS AWAITING (when tech can't fix on the first visit) # ------------------------------------------------------------------ x_fc_parts_awaiting = fields.Boolean( string='Awaiting Parts', tracking=True, copy=False, index=True, help='Tech could not complete the repair without ordering parts. ' 'Repair stays open; clears automatically when the last linked ' 'fusion.repair.part.order moves to "received".', ) x_fc_parts_eta_date = fields.Date( string='Parts ETA', copy=False, tracking=True, ) x_fc_part_order_ids = fields.One2many( 'fusion.repair.part.order', 'repair_order_id', string='Part Orders', copy=False, ) x_fc_part_order_count = fields.Integer( compute='_compute_part_order_count', string='# Part Orders', ) # ------------------------------------------------------------------ # Bundle 9: SERVICE CALLOUT PRICING + LABOR WARRANTY # ------------------------------------------------------------------ x_fc_callout_tier = fields.Selection( [ ('regular', 'Regular Business Hours'), ('rush', 'Rush Service'), ('after_hours', 'After Hours'), ('weekend', 'Weekend'), ('holiday', 'Statutory Holiday'), ], string='Callout Tier', default='regular', tracking=True, help='Which rate-card tier applies. Set by CS at intake; can be changed ' 'by dispatcher if the schedule moves into after-hours / weekend.', ) x_fc_callout_distance_km = fields.Float( string='One-Way Distance (km)', tracking=True, help='Distance from the shop to the client. Travel beyond the rate-card ' "threshold is billed BOTH WAYS at the rate's per-km fee.", ) x_fc_callout_techs = fields.Integer( string='Technicians on Callout', default=1, tracking=True, ) x_fc_callout_labor_hours = fields.Float( string='Billable Labor Hours', default=0.0, tracking=True, help='Hours of repair work above the 30 min included in the callout fee. ' 'Billing applies the minimum_labor_hours floor from the rate card ' '(default 1.0) AND rounds up to the next 30-min increment - ' '20 minutes bills 1.0 h, 75 minutes bills 1.5 h.', ) # Bundle 10: in-shop work uses a different (lower) hourly rate AND # waives both the callout fee and the travel charge - client brought # the unit to the store. x_fc_in_shop = fields.Boolean( string='In-Shop Repair', tracking=True, help='Work done in the store (no callout, no travel). Uses ' 'in_shop_labor_rate from the rate card.', ) # Labor warranty link + status (resolved at visit time) x_fc_labor_warranty_id = fields.Many2one( 'fusion.repair.labor.warranty', string='Store Labor Warranty', tracking=True, help='Auto-resolved when the visit-report wizard runs - links to the ' 'active store labor warranty for this client + product if any.', ) x_fc_labor_warranty_status = fields.Selection( [ ('not_checked', 'Not Yet Checked'), ('eligible', 'Covered - Labor Free'), ('not_covered', 'No Warranty on File'), ('expired', 'Warranty Expired'), ('void_misuse', 'Void - Misuse / Negligence'), ('waived', 'Manually Waived'), ], string='Labor Warranty Status', default='not_checked', tracking=True, ) # Manual labor-fee waiver (manager / sales rep only) x_fc_labor_waived = fields.Boolean( string='Labor Fee Waived', tracking=True, copy=False, readonly=True, ) x_fc_labor_waived_by_id = fields.Many2one( 'res.users', string='Labor Waived By', tracking=True, copy=False, readonly=True, ) x_fc_labor_waived_at = fields.Datetime( string='Labor Waived At', tracking=True, copy=False, readonly=True, ) x_fc_labor_waived_reason = fields.Char( string='Labor Waiver Reason', copy=False, ) # Computed quote breakdown (all non-stored - depend on the rate-card) x_fc_quote_callout_base = fields.Monetary( string='Base Callout Fee', currency_field='company_currency_id', compute='_compute_callout_quote', ) x_fc_quote_extra_techs = fields.Monetary( string='Extra Tech Fees', currency_field='company_currency_id', compute='_compute_callout_quote', ) x_fc_quote_labor = fields.Monetary( string='Labor Charge', currency_field='company_currency_id', compute='_compute_callout_quote', ) x_fc_quote_travel = fields.Monetary( string='Travel Charge', currency_field='company_currency_id', compute='_compute_callout_quote', ) x_fc_quote_waived = fields.Monetary( string='Less: Waived', currency_field='company_currency_id', compute='_compute_callout_quote', ) x_fc_quote_total = fields.Monetary( string='Quote Total (excl. parts)', currency_field='company_currency_id', compute='_compute_callout_quote', store=True, # stored so we can show it on list views and search ) x_fc_quote_breakdown_text = fields.Text( string='Quote Breakdown', compute='_compute_callout_quote', help='Human-readable line-by-line breakdown - used in the quote email.', ) @api.depends('x_fc_callout_tier', 'x_fc_callout_distance_km', 'x_fc_callout_techs', 'x_fc_callout_labor_hours', 'x_fc_labor_warranty_status', 'x_fc_labor_waived', 'x_fc_in_shop', 'x_fc_repair_category_id') def _compute_callout_quote(self): import math Rate = self.env['fusion.repair.callout.rate'].sudo() for r in self: tier = r.x_fc_callout_tier or 'regular' cls = (r.x_fc_repair_category_id.equipment_class or 'standard') if r.x_fc_repair_category_id else 'standard' rate = Rate.get_for_tier(tier, equipment_class=cls) techs = max(r.x_fc_callout_techs or 1, 1) hours = max(r.x_fc_callout_labor_hours or 0.0, 0.0) distance = r.x_fc_callout_distance_km or 0.0 in_shop = bool(r.x_fc_in_shop) if in_shop: # In-shop: no callout, no extra-tech, no travel. Only labour # at the lower in_shop_labor_rate. base = 0.0 extra_techs = 0.0 travel = 0.0 else: base = rate.base_callout_fee if rate else 0.0 extra_techs = 0.0 if rate and techs >= 2 and rate.second_tech_fee: extra_techs += rate.second_tech_fee if rate and techs >= 3: per_extra = rate.additional_tech_fee or rate.second_tech_fee or 0.0 extra_techs += per_extra * (techs - 2) # Travel - both ways, per tech, for distance over threshold. travel = 0.0 if rate: over = max(distance - rate.travel_distance_threshold_km, 0.0) travel = over * 2.0 * rate.travel_per_km_fee * techs # Labour: per the published rate card - # * minimum_labor_hours floor (default 1.0) # * beyond the floor, pro-rated in 30-min increments # (i.e. round UP to the next 0.5 h) # * per tech (footnote: 'If multiple technicians are required, # rates will apply per technician') # * in-shop uses in_shop_labor_rate labor = 0.0 if rate and hours > 0: min_hours = rate.minimum_labor_hours or 1.0 # ceil(actual * 2) / 2 -> rounds up to next 0.5 increment rounded_up = math.ceil(hours * 2) / 2.0 billable_h = max(rounded_up, min_hours) hourly = (rate.in_shop_labor_rate if in_shop else rate.hourly_labor_rate) labor = billable_h * hourly * techs waived = 0.0 if (r.x_fc_labor_warranty_status in ('eligible', 'waived') or r.x_fc_labor_waived): waived = labor total = base + extra_techs + labor + travel - waived r.x_fc_quote_callout_base = base r.x_fc_quote_extra_techs = extra_techs r.x_fc_quote_labor = labor r.x_fc_quote_travel = travel r.x_fc_quote_waived = waived r.x_fc_quote_total = total # Human-readable breakdown for chatter / quote emails. class_label = 'Lift & Elevating' if cls == 'lift_elevating' else 'Standard' hourly_used = ( (rate.in_shop_labor_rate if in_shop else rate.hourly_labor_rate) if rate else 0.0 ) mode_label = 'IN-SHOP' if in_shop else f'on-site ({tier})' lines = [] if not in_shop: lines.append(f'Service Call ({class_label}, {tier}, incl. 30 min): ${base:.2f}') if extra_techs: lines.append(f'Additional technicians ({techs - 1}): ${extra_techs:.2f}') if labor: lines.append( f'Labour {mode_label}: ' f'{hours:.2f} h actual -> billed ' f'{max(math.ceil(hours * 2) / 2.0, rate.minimum_labor_hours if rate else 1.0):.2f} h ' f'x {techs} tech x ${hourly_used:.2f}/h = ${labor:.2f}' ) if travel: over_km = max(distance - (rate.travel_distance_threshold_km if rate else 25), 0) lines.append( f'Travel: {distance:.1f} km, {over_km:.1f} km over threshold, ' f'both ways x {techs} tech x ${rate.travel_per_km_fee if rate else 0}/km = ${travel:.2f}' ) if waived: lines.append(f'Less labour waived: -${waived:.2f}') lines.append('-' * 50) lines.append(f'TOTAL (excl. parts): ${total:.2f}') r.x_fc_quote_breakdown_text = '\n'.join(lines) def action_check_labor_warranty(self): """Look up the active store labor warranty for this repair's partner + product. Updates x_fc_labor_warranty_id and x_fc_labor_warranty_status. Called from the visit-report wizard AND from the dashboard's "check warranty" button.""" Warr = self.env['fusion.repair.labor.warranty'].sudo() for r in self: w = Warr.find_active_for( r.partner_id, r.product_id, r.lot_id or False, ) r.x_fc_labor_warranty_id = w.id if w else False if not w: r.x_fc_labor_warranty_status = 'not_covered' elif w.state == 'expired': r.x_fc_labor_warranty_status = 'expired' elif w.state == 'void': r.x_fc_labor_warranty_status = 'void_misuse' else: r.x_fc_labor_warranty_status = 'eligible' def action_waive_labor_fee(self): """Manager / sales rep only. CS rep cannot waive.""" Group = self.env.ref user = self.env.user can_waive = ( user.has_group('fusion_repairs.group_fusion_repairs_manager') or user.has_group('fusion_repairs.group_fusion_repairs_sales_rep') ) if not can_waive: raise UserError(_( 'Only Repairs Managers and Sales Reps can waive the labor fee. ' 'CS staff must escalate to a manager.' )) for r in self: r.write({ 'x_fc_labor_waived': True, 'x_fc_labor_waived_by_id': user.id, 'x_fc_labor_waived_at': fields.Datetime.now(), 'x_fc_labor_warranty_status': 'waived', }) r.message_post(body=Markup(_( 'Labor fee waived by %(user)s. (Reason: %(reason)s)' )) % { 'user': user.name, 'reason': r.x_fc_labor_waived_reason or 'goodwill', }) @api.depends('x_fc_rush_tier', 'x_fc_rush_techs_required', 'x_fc_repair_category_id') def _compute_rush_surcharge(self): Rates = self.env['fusion.repair.emergency.charge'].sudo() for r in self: if not r.x_fc_rush_tier or not r.x_fc_repair_category_id: r.x_fc_rush_surcharge = 0.0 continue r.x_fc_rush_surcharge = Rates.calculate( r.x_fc_repair_category_id, r.x_fc_rush_tier, r.x_fc_rush_techs_required or 1, ) @api.depends('x_fc_part_order_ids') def _compute_part_order_count(self): for r in self: r.x_fc_part_order_count = len(r.x_fc_part_order_ids) def action_view_part_orders(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': _('Part Orders'), 'res_model': 'fusion.repair.part.order', 'view_mode': 'list,form', 'domain': [('repair_order_id', '=', self.id)], 'context': {'default_repair_order_id': self.id}, } def action_acknowledge_rush(self): """CS clicks this AFTER getting verbal OK from the client on the rush price.""" for r in self: r.x_fc_rush_acknowledged_at = fields.Datetime.now() r.x_fc_rush_acknowledged_by_id = self.env.user r.message_post(body=Markup(_( 'Rush surcharge of %(amt).2f acknowledged by client ' '(verbal OK to %(rep)s).' )) % { 'amt': r.x_fc_rush_surcharge, 'rep': self.env.user.name, }) def action_squeeze_into_today(self): """Squeeze this repair into a field tech's existing route today. Picks the lightest-loaded skilled tech, finds the first free 1-hour slot in their day, creates / updates the dispatch task to that slot, and pushes a live bus.bus notification + email so the tech knows mid-shift. """ from datetime import date as _date today = _date.today() Task = self.env['fusion.technician.task'].sudo() for r in self: tech_id = self._fc_find_lightest_today_tech() if not tech_id: raise UserError(_( 'No field-staff users available - mark someone as Field ' 'Staff under Settings > Users and try again.' )) slot_start, slot_end = self._fc_find_free_slot_today(tech_id) if slot_start is None: raise UserError(_( "%s has no free hour left today. Either bump an existing " "task or schedule for tomorrow instead." ) % self.env['res.users'].sudo().browse(tech_id).name) existing = r.x_fc_technician_task_ids.filtered( lambda t: t.status not in ('completed', 'cancelled') ) vals = { 'technician_id': tech_id, 'scheduled_date': today, 'time_start': slot_start, 'time_end': slot_end, } if existing: task = existing[0] task.write(vals) else: self.env['fusion.repair.intake.service'].sudo() \ .with_context( force_tech_id=tech_id, force_schedule={ 'scheduled_date': today, 'time_start': slot_start, 'time_end': slot_end, }, ) \ ._create_dispatch_task(r) task = r.x_fc_technician_task_ids[:1] self._notify_tech_of_rush(task) r.message_post(body=Markup(_( 'Squeezed into %(name)s\'s route today at ' '%(start).0f:00 - %(end).0f:00; tech notified.' )) % { 'name': task.technician_id.name or '?', 'start': slot_start, 'end': slot_end, }) def _fc_find_free_slot_today(self, tech_id): """Return (start_float, end_float) for the first free 1-hour window in this tech's day between 9 AM and 6 PM, or (None, None).""" from datetime import date as _date today = _date.today() Task = self.env['fusion.technician.task'].sudo() existing = Task.search([ ('technician_id', '=', tech_id), ('scheduled_date', '=', today), ('status', 'not in', ('completed', 'cancelled')), ]) # Build a set of busy hours (rounded down to integer hours). busy = set() for t in existing: s = int(t.time_start or 0) e = int(t.time_end or s + 1) for h in range(s, max(s + 1, e)): busy.add(h) # Scan 9 AM - 5 PM (last slot is 17:00-18:00 inclusive). for hour in range(9, 18): if hour not in busy: return float(hour), float(hour + 1) return None, None def _fc_find_lightest_today_tech(self): """Return the field-staff user with the fewest scheduled tasks today. Honors skills filter if this repair has a category. """ Users = self.env['res.users'].sudo() Task = self.env['fusion.technician.task'].sudo() from datetime import date as _date today = _date.today() domain = [ ('x_fc_is_field_staff', '=', True), ('active', '=', True), ] if self.x_fc_repair_category_id: domain.append( ('x_fc_repair_skills', 'in', [self.x_fc_repair_category_id.id]) ) candidates = Users.search(domain) if not candidates: # Fallback: any active field staff (skills filter relaxed). candidates = Users.search([ ('x_fc_is_field_staff', '=', True), ('active', '=', True), ]) if not candidates: return False # Pick the one with the fewest scheduled tasks today. ranked = [] for u in candidates: count = Task.search_count([ ('technician_id', '=', u.id), ('scheduled_date', '=', today), ('status', 'not in', ('completed', 'cancelled')), ]) ranked.append((count, u.id)) ranked.sort() return ranked[0][1] def _notify_tech_of_rush(self, task): """Send a real-time bus push + email so the tech sees it mid-shift.""" for r in self: tech = task.technician_id if not tech: continue # 1) bus.bus live push (shows as a sticky in-app notification). try: # bus.bus.sendone goes to a specific user channel; the # web client displays it via the simple_notification service. self.env['bus.bus']._sendone( tech.partner_id, 'simple_notification', { 'type': 'warning', 'title': _('RUSH service added to your route'), 'message': (_('Stairlift / urgent stop at %(client)s. ' 'Repair %(name)s. See your tasks.') % { 'client': r.partner_id.name or '', 'name': r.name, }), 'sticky': True, }, ) except Exception: _logger.warning('bus.bus push failed for tech %s', tech.login) # 2) email (matters if the tech is offline at the moment of squeeze). tpl = self.env.ref( 'fusion_repairs.email_template_rush_tech_alert', raise_if_not_found=False, ) if tpl: try: tpl.with_context(tech_email=tech.email or tech.partner_id.email or '') \ .send_mail(r.id, force_send=True, email_values={ 'email_to': tech.email or tech.partner_id.email or '', }) except Exception: _logger.warning('Rush-alert email failed for repair %s', r.name) # 3) chatter on the task itself so the tech sees it inline. task.message_post(body=Markup(_( 'RUSH ADDED to your day: %(client)s - %(name)s. ' 'Office squeezed it in.' )) % { 'client': r.partner_id.name or '?', 'name': r.name, }) # ------------------------------------------------------------------ # 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.'))