# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) from urllib.parse import quote_plus from markupsafe import Markup from odoo import _, fields, models from odoo.exceptions import UserError class FusionTechnicianTaskRepairs(models.Model): """Adds the back-link from fusion.technician.task to repair.order so repairs and tasks share one timeline. Also hooks task completion to roll a linked maintenance contract to its next cycle. """ _inherit = 'fusion.technician.task' x_fc_repair_order_id = fields.Many2one( 'repair.order', string='Repair Order', ondelete='set null', index=True, tracking=True, help='Repair order this task fulfils. Set automatically when the intake ' 'wizard auto-creates a draft task for urgent / safety calls.', ) x_fc_repair_intake_session_id = fields.Char( related='x_fc_repair_order_id.x_fc_intake_session_id', string='Intake Session', store=True, index=True, ) # X2: per-task day-before reminder flag. Per-task (not per-repair) so # a repair with multiple visits gets a separate reminder for each one. x_fc_day_before_reminder_sent = fields.Boolean( string='Day-Before Reminder Sent', copy=False, ) # ------------------------------------------------------------------ # T3 - Labour timer. The tech taps Start when they begin work and # Stop when done; the accumulated minutes feeds the visit-report # actual hours field. Multiple start/stop cycles are accumulated. # ------------------------------------------------------------------ x_fc_timer_running_since = fields.Datetime( string='Timer Running Since', copy=False, ) x_fc_timer_accumulated_minutes = fields.Float( string='Accumulated Minutes', default=0.0, copy=False, help='Total labour minutes captured by the tech timer. ' 'Divide by 60 for the hours that prefill the visit report.', ) def action_timer_start(self): for t in self: if t.x_fc_timer_running_since: continue # already running t.x_fc_timer_running_since = fields.Datetime.now() t.message_post(body=Markup(_('Labour timer started.'))) def action_timer_stop(self): for t in self: if not t.x_fc_timer_running_since: continue from datetime import datetime elapsed_minutes = ( datetime.now() - t.x_fc_timer_running_since ).total_seconds() / 60.0 t.x_fc_timer_accumulated_minutes = ( t.x_fc_timer_accumulated_minutes or 0.0 ) + elapsed_minutes t.x_fc_timer_running_since = False t.message_post(body=Markup(_( 'Labour timer stopped. Added %(mins).1f min, total %(tot).1f min.' )) % { 'mins': elapsed_minutes, 'tot': t.x_fc_timer_accumulated_minutes or 0.0, }) def write(self, vals): """When a maintenance task transitions to 'completed', roll the linked contract to its next cycle. Failure to roll never blocks the underlying task write. """ res = super().write(vals) if vals.get('status') == 'completed': for task in self: if task.task_type != 'maintenance': continue repair = task.x_fc_repair_order_id contract = repair.x_fc_maintenance_contract_id if repair else False if not contract: continue try: contract.last_service_date = fields.Date.context_today(task) contract.roll_next_due_date() contract.message_post(body=Markup( 'Rolled forward after maintenance task ' '%s completed. Next due %s.' ) % (task.name or '', str(contract.next_due_date or ''))) except Exception: # Never let a contract roll failure block the task write. pass return res def action_view_repair_order(self): self.ensure_one() if not self.x_fc_repair_order_id: return False return { 'type': 'ir.actions.act_window', 'name': self.x_fc_repair_order_id.name, 'res_model': 'repair.order', 'view_mode': 'form', 'res_id': self.x_fc_repair_order_id.id, } # ------------------------------------------------------------------ # T1: Open in Maps - returns an act_url action that opens the device's # default maps app (Apple Maps on iOS, Google Maps on Android, browser # otherwise). Address is built from the task's address fields with the # partner address as a fallback. # ------------------------------------------------------------------ def action_open_in_maps(self): self.ensure_one() # Prefer fusion_tasks.address_display because in real data address_street # often contains the full Google-Places-formatted address; concatenating # the other address_* fields would duplicate city/zip. addr = (getattr(self, 'address_display', '') or '').strip() if not addr and self.partner_id: p = self.partner_id parts = [ p.street, p.street2, p.city, p.state_id.name if p.state_id else False, p.zip, p.country_id.name if p.country_id else False, ] addr = ', '.join(str(x) for x in parts if x) if not addr: raise UserError(_('No address on this task or its client.')) return { 'type': 'ir.actions.act_url', 'url': f'https://www.google.com/maps?q={quote_plus(addr)}', 'target': 'new', }