')
+
+ task.schedule_info_html = Markup(''.join(html_parts))
+
+ @api.depends('technician_id', 'scheduled_date', 'time_start',
+ 'address_lat', 'address_lng', 'address_street')
+ def _compute_prev_task_summary(self):
+ """Show previous task info + travel time warning with color coding."""
+ for task in self:
+ if not task.technician_id or not task.scheduled_date:
+ task.prev_task_summary_html = ''
+ continue
+
+ exclude_id = task.id if task.id else 0
+ # Find the task that ends just before this one starts (lead or additional)
+ prev_tasks = self.sudo().search([
+ '|',
+ ('technician_id', '=', task.technician_id.id),
+ ('additional_technician_ids', 'in', [task.technician_id.id]),
+ ('scheduled_date', '=', task.scheduled_date),
+ ('status', 'not in', ['cancelled']),
+ ('id', '!=', exclude_id),
+ ('time_end', '<=', task.time_start or 99.0),
+ ], order='time_end desc', limit=1)
+
+ if not prev_tasks:
+ # Check if this is the first task of the day -- show start location info
+ task.prev_task_summary_html = Markup(
+ '
'
+ ' First task of the day -- '
+ 'travel calculated from start location.
'
+ )
+ continue
+
+ prev = prev_tasks[0]
+ prev_start = self._float_to_time_str(prev.time_start)
+ prev_end = self._float_to_time_str(prev.time_end)
+ type_label = dict(self._fields['task_type'].selection).get(
+ prev.task_type, prev.task_type or '')
+ client_name = prev.partner_id.name or ''
+ prev_addr = prev.address_display or 'No address'
+
+ # Calculate gap between prev task end and this task start
+ s_open, _s_close = self._get_store_hours()
+ gap_hours = (task.time_start or s_open) - (prev.time_end or s_open)
+ gap_minutes = int(gap_hours * 60)
+
+ # Try to get travel time if both have coordinates
+ travel_minutes = 0
+ travel_text = ''
+ if (prev.address_lat and prev.address_lng and
+ task.address_lat and task.address_lng):
+ travel_minutes = self._quick_travel_time(
+ prev.address_lat, prev.address_lng,
+ task.address_lat, task.address_lng,
+ )
+ if travel_minutes > 0:
+ travel_text = f'{travel_minutes} min drive'
+ else:
+ travel_text = 'Could not calculate travel time'
+ elif prev.address_street and task.address_street:
+ travel_text = 'Save to calculate travel time'
+ else:
+ travel_text = 'Address missing -- cannot calculate travel'
+
+ # Determine color coding
+ if travel_minutes > 0 and gap_minutes >= travel_minutes:
+ bg_class = 'alert-success' # Green -- enough time
+ icon = 'fa-check-circle'
+ status_text = (
+ f'{gap_minutes} min gap -- enough travel time '
+ f'(~{travel_minutes} min drive)'
+ )
+ elif travel_minutes > 0 and gap_minutes > 0:
+ bg_class = 'alert-warning' # Yellow -- tight
+ icon = 'fa-exclamation-triangle'
+ status_text = (
+ f'{gap_minutes} min gap -- tight! '
+ f'Travel is ~{travel_minutes} min drive'
+ )
+ elif travel_minutes > 0 and gap_minutes <= 0:
+ bg_class = 'alert-danger' # Red -- impossible
+ icon = 'fa-times-circle'
+ status_text = (
+ f'No gap! Previous task ends at {prev_end}. '
+ f'Travel is ~{travel_minutes} min drive'
+ )
+ else:
+ bg_class = 'alert-info' # Blue -- no travel data yet
+ icon = 'fa-info-circle'
+ status_text = travel_text
+
+ html = (
+ f'
'
+ f' '
+ f'Previous: {prev.name} '
+ f'({type_label}) {prev_start} - {prev_end}'
+ f'{" -- " + client_name if client_name else ""}'
+ f' '
+ f' {prev_addr}'
+ f' '
+ f' {status_text}'
+ f'
'
+ )
+ task.prev_task_summary_html = Markup(html)
+
+ def _quick_travel_time(self, from_lat, from_lng, to_lat, to_lng):
+ """Quick inline travel time calculation using Google Distance Matrix API.
+ Returns travel time in minutes, or 0 if unavailable."""
+ try:
+ api_key = self.env['ir.config_parameter'].sudo().get_param(
+ 'fusion_claims.google_maps_api_key', '')
+ if not api_key:
+ return 0
+
+ url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
+ params = {
+ 'origins': f'{from_lat},{from_lng}',
+ 'destinations': f'{to_lat},{to_lng}',
+ 'mode': 'driving',
+ 'avoid': 'tolls',
+ 'departure_time': 'now',
+ 'key': api_key,
+ }
+ resp = requests.get(url, params=params, timeout=5)
+ data = resp.json()
+ if data.get('status') == 'OK':
+ elements = data['rows'][0]['elements'][0]
+ if elements.get('status') == 'OK':
+ # Use duration_in_traffic if available, else duration
+ duration = elements.get(
+ 'duration_in_traffic', elements.get('duration', {}))
+ seconds = duration.get('value', 0)
+ return max(1, int(seconds / 60))
+ except Exception:
+ _logger.warning('Failed to calculate travel time', exc_info=True)
+ return 0
+
+ @api.depends('status')
+ def _compute_color(self):
+ color_map = {
+ 'pending': 5, # purple
+ 'scheduled': 0, # grey
+ 'en_route': 4, # blue
+ 'in_progress': 2, # orange
+ 'completed': 10, # green
+ 'cancelled': 1, # red
+ 'rescheduled': 3, # yellow
+ }
+ for task in self:
+ task.color = color_map.get(task.status, 0)
+
+ @api.depends('address_street', 'address_street2', 'address_city',
+ 'address_state_id', 'address_zip')
+ def _compute_address_display(self):
+ for task in self:
+ street = task.address_street or ''
+ # If the street field already contains a full address (has a comma),
+ # use it directly -- Google Places stores the formatted address here.
+ if ',' in street and (
+ (task.address_city and task.address_city in street) or
+ (task.address_zip and task.address_zip in street)
+ ):
+ # Street already has full address; just append unit if separate
+ if task.address_street2 and task.address_street2 not in street:
+ task.address_display = f"{street}, {task.address_street2}"
+ else:
+ task.address_display = street
+ else:
+ # Build from components (manual entry or legacy data)
+ parts = [
+ street,
+ task.address_street2,
+ task.address_city,
+ task.address_state_id.name if task.address_state_id else '',
+ task.address_zip,
+ ]
+ task.address_display = ', '.join([p for p in parts if p])
+
+ # ------------------------------------------------------------------
+ # ONCHANGE - Auto-fill address from client
+ # ------------------------------------------------------------------
+
+ @api.onchange('is_in_store')
+ def _onchange_is_in_store(self):
+ """Auto-fill company address when task is marked as in-store."""
+ if self.is_in_store:
+ company_partner = self.env.company.partner_id
+ if company_partner and company_partner.street:
+ self._fill_address_from_partner(company_partner)
+ else:
+ self.address_street = self.env.company.name or 'In Store'
+
+ @api.onchange('partner_id')
+ def _onchange_partner_id(self):
+ """Auto-fill address fields from the selected client's address."""
+ if self.is_in_store:
+ return
+ if self.partner_id:
+ addr = self.partner_id
+ self.address_partner_id = addr.id
+ self.address_street = addr.street or ''
+ self.address_street2 = addr.street2 or ''
+ self.address_city = addr.city or ''
+ self.address_state_id = addr.state_id.id if addr.state_id else False
+ self.address_zip = addr.zip or ''
+ self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0
+ self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0
+
+ def _fill_address_from_partner(self, addr):
+ """Populate address fields from a partner record."""
+ if not addr:
+ return
+ self.address_partner_id = addr.id
+ self.address_street = addr.street or ''
+ self.address_street2 = addr.street2 or ''
+ self.address_city = addr.city or ''
+ self.address_state_id = addr.state_id.id if addr.state_id else False
+ self.address_zip = addr.zip or ''
+ self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0
+ self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0
+
+ # ------------------------------------------------------------------
+ # CONSTRAINTS + VALIDATION
+ # ------------------------------------------------------------------
+
+ @api.constrains('address_street', 'address_lat', 'address_lng', 'is_in_store')
+ def _check_address_required(self):
+ """Non-in-store tasks must have a geocoded address."""
+ for task in self:
+ if task.x_fc_sync_source:
+ continue
+ if task.is_in_store:
+ continue
+ if not task.address_street:
+ raise ValidationError(_(
+ "A valid address is required. If this task is at the store, "
+ "please check the 'In Store' option."
+ ))
+
+ @api.constrains('technician_id', 'additional_technician_ids',
+ 'scheduled_date', 'time_start', 'time_end')
+ def _check_no_overlap(self):
+ """Prevent overlapping bookings for the same technician on the same date.
+
+ Checks both the lead technician and all additional technicians.
+ """
+ for task in self:
+ if task.status == 'cancelled':
+ continue
+ if task.x_fc_sync_source:
+ continue
+ # Validate time range
+ if task.time_start >= task.time_end:
+ raise ValidationError(_("Start time must be before end time."))
+ # Validate store hours
+ s_open, s_close = self._get_store_hours()
+ if task.time_start < s_open or task.time_end > s_close:
+ open_str = self._float_to_time_str(s_open)
+ close_str = self._float_to_time_str(s_close)
+ raise ValidationError(_(
+ "Tasks must be scheduled within store hours (%s - %s)."
+ ) % (open_str, close_str))
+ # Validate not in the past (only for new/scheduled local tasks)
+ if task.status == 'scheduled' and task.scheduled_date and not task.x_fc_sync_source:
+ local_now = self._local_now()
+ today = local_now.date()
+ if task.scheduled_date < today:
+ raise ValidationError(_("Cannot schedule tasks in the past."))
+ if task.scheduled_date == today:
+ current_hour = local_now.hour + local_now.minute / 60.0
+ if task.time_start < current_hour:
+ pass # Allow editing existing tasks that started earlier today
+ # Check overlap for lead + additional technicians
+ all_tech_ids = (task.technician_id | task.additional_technician_ids).ids
+ for tech_id in all_tech_ids:
+ tech_name = self.env['res.users'].browse(tech_id).name
+ overlapping = self.sudo().search([
+ '|',
+ ('technician_id', '=', tech_id),
+ ('additional_technician_ids', 'in', [tech_id]),
+ ('scheduled_date', '=', task.scheduled_date),
+ ('status', 'not in', ['cancelled']),
+ ('id', '!=', task.id),
+ ('time_start', '<', task.time_end),
+ ('time_end', '>', task.time_start),
+ ], limit=1)
+ if overlapping:
+ start_str = self._float_to_time_str(overlapping.time_start)
+ end_str = self._float_to_time_str(overlapping.time_end)
+ raise ValidationError(_(
+ "%(tech)s has a time conflict with %(task)s "
+ "(%(start)s - %(end)s). Please choose a different time.",
+ tech=tech_name,
+ task=overlapping.name,
+ start=start_str,
+ end=end_str,
+ ))
+
+ # Check travel time gaps for lead technician only
+ # (additional techs travel with the lead, same destination)
+ next_task = self.sudo().search([
+ '|',
+ ('technician_id', '=', task.technician_id.id),
+ ('additional_technician_ids', 'in', [task.technician_id.id]),
+ ('scheduled_date', '=', task.scheduled_date),
+ ('status', 'not in', ['cancelled']),
+ ('id', '!=', task.id),
+ ('time_start', '>=', task.time_end),
+ ], order='time_start', limit=1)
+ if next_task and task.address_lat and task.address_lng and \
+ next_task.address_lat and next_task.address_lng:
+ travel_min = self._quick_travel_time(
+ task.address_lat, task.address_lng,
+ next_task.address_lat, next_task.address_lng,
+ )
+ if travel_min > 0:
+ gap_min = int((next_task.time_start - task.time_end) * 60)
+ if gap_min < travel_min:
+ raise ValidationError(_(
+ "Not enough travel time to the next task!\n\n"
+ "This task ends at %(end)s, and %(next)s starts "
+ "at %(next_start)s (%(gap)d min gap).\n"
+ "Travel time is ~%(travel)d minutes.\n\n"
+ "Please allow at least %(travel)d minutes between tasks.",
+ end=self._float_to_time_str(task.time_end),
+ next=next_task.name,
+ next_start=self._float_to_time_str(next_task.time_start),
+ gap=gap_min,
+ travel=travel_min,
+ ))
+
+ prev_task = self.sudo().search([
+ '|',
+ ('technician_id', '=', task.technician_id.id),
+ ('additional_technician_ids', 'in', [task.technician_id.id]),
+ ('scheduled_date', '=', task.scheduled_date),
+ ('status', 'not in', ['cancelled']),
+ ('id', '!=', task.id),
+ ('time_end', '<=', task.time_start),
+ ], order='time_end desc', limit=1)
+ if prev_task and task.address_lat and task.address_lng and \
+ prev_task.address_lat and prev_task.address_lng:
+ travel_min = self._quick_travel_time(
+ prev_task.address_lat, prev_task.address_lng,
+ task.address_lat, task.address_lng,
+ )
+ if travel_min > 0:
+ gap_min = int((task.time_start - prev_task.time_end) * 60)
+ if gap_min < travel_min:
+ raise ValidationError(_(
+ "Not enough travel time from the previous task!\n\n"
+ "%(prev)s ends at %(prev_end)s, and this task starts "
+ "at %(start)s (%(gap)d min gap).\n"
+ "Travel time is ~%(travel)d minutes.\n\n"
+ "Please allow at least %(travel)d minutes between tasks.",
+ prev=prev_task.name,
+ prev_end=self._float_to_time_str(prev_task.time_end),
+ start=self._float_to_time_str(task.time_start),
+ gap=gap_min,
+ travel=travel_min,
+ ))
+
+ @api.onchange('technician_id', 'scheduled_date')
+ def _onchange_technician_date_autoset(self):
+ """Auto-set start/end time to the first available slot when tech+date change."""
+ if not self.technician_id or not self.scheduled_date:
+ return
+ exclude_id = self._origin.id if self._origin else False
+ duration = self.duration_hours or 1.0
+ s_open, _s_close = self._get_store_hours()
+ preferred = self.time_start or s_open
+ start, end = self._find_next_available_slot(
+ self.technician_id.id,
+ self.scheduled_date,
+ preferred_start=preferred,
+ duration=duration,
+ exclude_task_id=exclude_id,
+ dest_lat=self.address_lat or 0,
+ dest_lng=self.address_lng or 0,
+ )
+ if start is not False:
+ self.time_start = start
+ self.time_end = end
+ self.duration_hours = end - start
+ else:
+ return {'warning': {
+ 'title': _('Fully Booked'),
+ 'message': _(
+ '%s is fully booked on %s. No available slots.'
+ ) % (self.technician_id.name,
+ self.scheduled_date.strftime('%B %d, %Y')),
+ }}
+
+ def _snap_if_overlap(self):
+ """Check if current time_start/time_end overlaps with another task.
+ If so, auto-snap to the next available slot and return a warning dict."""
+ if not self.technician_id or not self.scheduled_date or not self.time_start:
+ return None
+ exclude_id = self._origin.id if self._origin else 0
+ duration = max(self.duration_hours or 1.0, 0.25)
+
+ all_tech_ids = (self.technician_id | self.additional_technician_ids).ids
+ overlapping = self.sudo().search([
+ '|',
+ ('technician_id', 'in', all_tech_ids),
+ ('additional_technician_ids', 'in', all_tech_ids),
+ ('scheduled_date', '=', self.scheduled_date),
+ ('status', 'not in', ['cancelled']),
+ ('id', '!=', exclude_id),
+ ('time_start', '<', self.time_end),
+ ('time_end', '>', self.time_start),
+ ], limit=1)
+ if overlapping:
+ conflict_name = overlapping.name
+ conflict_start = self._float_to_time_str(overlapping.time_start)
+ conflict_end = self._float_to_time_str(overlapping.time_end)
+ start, end = self._find_next_available_slot(
+ self.technician_id.id,
+ self.scheduled_date,
+ preferred_start=self.time_start,
+ duration=duration,
+ exclude_task_id=exclude_id,
+ dest_lat=self.address_lat or 0,
+ dest_lng=self.address_lng or 0,
+ )
+ if start is not False:
+ new_start_str = self._float_to_time_str(start)
+ new_end_str = self._float_to_time_str(end)
+ self.time_start = start
+ self.time_end = end
+ self.duration_hours = end - start
+ return {'warning': {
+ 'title': _('Moved to Available Slot'),
+ 'message': _(
+ 'The selected time conflicts with %s (%s - %s).\n'
+ 'Automatically moved to: %s - %s.'
+ ) % (conflict_name, conflict_start, conflict_end,
+ new_start_str, new_end_str),
+ }}
+ else:
+ return {'warning': {
+ 'title': _('No Available Slots'),
+ 'message': _(
+ 'The selected time conflicts with %s (%s - %s) '
+ 'and no other slots are available on this day.'
+ ) % (conflict_name, conflict_start, conflict_end),
+ }}
+ return None
+
+ # ------------------------------------------------------------------
+ # DEFAULT_GET - Calendar pre-fill
+ # ------------------------------------------------------------------
+
+ def _snap_to_quarter(self, hour_float):
+ """Round a float hour to the nearest 15-minute slot and clamp to store hours."""
+ s_open, s_close = self._get_store_hours()
+ snapped = round(hour_float * 4) / 4
+ return max(s_open, min(s_close, snapped))
+
+ @api.model
+ def default_get(self, fields_list):
+ """Handle calendar time range selection: pre-fill date + times from context."""
+ res = super().default_get(fields_list)
+ ctx = self.env.context
+
+ # Set duration default based on task type from context
+ task_type = ctx.get('default_task_type', res.get('task_type', 'delivery'))
+ if 'duration_hours' not in res or not res.get('duration_hours'):
+ res['duration_hours'] = self.TASK_TYPE_DURATIONS.get(task_type, 1.0)
+
+ # When user clicks a time range on the calendar, Odoo passes
+ # default_datetime_start/end in UTC
+ dt_start_utc = None
+ dt_end_utc = None
+ if ctx.get('default_datetime_start'):
+ try:
+ dt_start_utc = fields.Datetime.from_string(ctx['default_datetime_start'])
+ except (ValueError, TypeError):
+ pass
+ if ctx.get('default_datetime_end'):
+ try:
+ dt_end_utc = fields.Datetime.from_string(ctx['default_datetime_end'])
+ except (ValueError, TypeError):
+ pass
+
+ if dt_start_utc or dt_end_utc:
+ import pytz
+ user_tz = pytz.timezone(self.env.user.tz or 'UTC')
+
+ if dt_start_utc:
+ dt_start_local = pytz.utc.localize(dt_start_utc).astimezone(user_tz)
+ res['scheduled_date'] = dt_start_local.date()
+ start_float = self._snap_to_quarter(
+ dt_start_local.hour + dt_start_local.minute / 60.0)
+ res['time_start'] = start_float
+
+ if dt_end_utc:
+ dt_end_local = pytz.utc.localize(dt_end_utc).astimezone(user_tz)
+ end_float = self._snap_to_quarter(
+ dt_end_local.hour + dt_end_local.minute / 60.0)
+ if 'time_start' in res and end_float <= res['time_start']:
+ end_float = res['time_start'] + 1.0
+ res['time_end'] = end_float
+ # Compute duration from the calendar drag
+ if 'time_start' in res:
+ res['duration_hours'] = end_float - res['time_start']
+
+ # Always compute end from start + duration if not already set
+ if 'time_end' not in res and 'time_start' in res and 'duration_hours' in res:
+ _open, close = self._get_store_hours()
+ res['time_end'] = min(
+ res['time_start'] + res['duration_hours'], close)
+
+ return res
+
+ # ------------------------------------------------------------------
+ # CRUD OVERRIDES
+ # ------------------------------------------------------------------
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if vals.get('name', _('New')) == _('New'):
+ vals['name'] = self.env['ir.sequence'].next_by_code('fusion.technician.task') or _('New')
+ if not vals.get('x_fc_sync_uuid') and not vals.get('x_fc_sync_source'):
+ vals['x_fc_sync_uuid'] = str(uuid.uuid4())
+ # In-store tasks: auto-fill company address
+ if vals.get('is_in_store') and not vals.get('address_street'):
+ company_partner = self.env.company.partner_id
+ if company_partner and company_partner.street:
+ self._fill_address_vals(vals, company_partner)
+ else:
+ vals['address_street'] = self.env.company.name or 'In Store'
+ # Hook: fill address from linked records (overridden by fusion_claims)
+ self._create_vals_fill(vals)
+ records = super().create(vals_list)
+ # Hook: post-create actions for linked records
+ records._on_create_post_actions()
+ # Auto-calculate travel times for the full day chain
+ if not self.env.context.get('skip_travel_recalc'):
+ records._recalculate_day_travel_chains()
+ # Send "Appointment Scheduled" email
+ for rec in records:
+ rec._send_task_scheduled_email()
+ # Push new local tasks to remote instances
+ local_records = records.filtered(lambda r: not r.x_fc_sync_source)
+ if local_records and not self.env.context.get('skip_task_sync'):
+ self.env['fusion.task.sync.config']._push_tasks(local_records, 'create')
+ # Sync to calendar for external calendar integrations
+ records._sync_calendar_event()
+ return records
+
+ def _create_vals_fill(self, vals):
+ """Hook: fill address from linked records during create.
+
+ Base implementation fills from partner_id. Override in fusion_claims
+ to also fill from sale_order_id or purchase_order_id.
+ """
+ if vals.get('partner_id') and not vals.get('address_street'):
+ partner = self.env['res.partner'].browse(vals['partner_id'])
+ if partner.street:
+ self._fill_address_vals(vals, partner)
+
+ def _on_create_post_actions(self):
+ """Hook: post-create side-effects for linked records.
+
+ Override in fusion_claims to post chatter messages to linked orders,
+ mark sale orders as ready for delivery, etc.
+ """
+ pass
+
+ def write(self, vals):
+ if self.env.context.get('skip_travel_recalc'):
+ res = super().write(vals)
+ if ('status' in vals and vals['status'] in ('completed', 'cancelled')
+ and not self.env.context.get('skip_task_sync')):
+ shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
+ if shadow_records:
+ self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
+ local_records = self.filtered(lambda r: not r.x_fc_sync_source)
+ if local_records:
+ self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
+ return res
+
+ # Safety: ensure time_end is consistent when start/duration change
+ # but time_end wasn't sent (readonly field in view may not save)
+ if ('time_start' in vals or 'duration_hours' in vals) and 'time_end' not in vals:
+ _open, close = self._get_store_hours()
+ start = vals.get('time_start', self[:1].time_start if len(self) == 1 else 9.0)
+ dur = vals.get('duration_hours', self[:1].duration_hours if len(self) == 1 else 1.0) or 1.0
+ vals['time_end'] = min(start + dur, close)
+
+ # Detect reschedule mode: capture old values BEFORE write
+ reschedule_mode = self.env.context.get('reschedule_mode')
+ old_schedule = {}
+ schedule_fields = {'scheduled_date', 'time_start', 'time_end',
+ 'duration_hours', 'technician_id'}
+ schedule_changed = schedule_fields & set(vals.keys())
+ if reschedule_mode and schedule_changed:
+ for task in self:
+ old_schedule[task.id] = {
+ 'date': task.scheduled_date,
+ 'time_start': task.time_start,
+ 'time_end': task.time_end,
+ }
+
+ # Capture old tech+date combos BEFORE write for travel recalc
+ travel_fields = {'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng',
+ 'scheduled_date', 'sequence', 'time_start', 'technician_id',
+ 'additional_technician_ids'}
+ needs_travel_recalc = travel_fields & set(vals.keys())
+ old_combos = set()
+ if needs_travel_recalc:
+ for t in self:
+ old_combos.add((t.technician_id.id, t.scheduled_date))
+ for tech in t.additional_technician_ids:
+ old_combos.add((tech.id, t.scheduled_date))
+ res = super().write(vals)
+ if needs_travel_recalc:
+ new_combos = set()
+ for t in self:
+ new_combos.add((t.technician_id.id, t.scheduled_date))
+ for tech in t.additional_technician_ids:
+ new_combos.add((tech.id, t.scheduled_date))
+ all_combos = old_combos | new_combos
+ self._recalculate_combos_travel(all_combos)
+
+ # After write: send reschedule email if schedule actually changed
+ if reschedule_mode and old_schedule:
+ for task in self:
+ old = old_schedule.get(task.id, {})
+ if old and (
+ old['date'] != task.scheduled_date
+ or abs(old['time_start'] - task.time_start) > 0.01
+ or abs(old['time_end'] - task.time_end) > 0.01
+ ):
+ task._post_status_message('rescheduled')
+ task._send_task_rescheduled_email(
+ old_date=old['date'],
+ old_start=old['time_start'],
+ old_end=old['time_end'],
+ )
+ # Push updates to remote instances for local tasks
+ sync_fields = {'technician_id', 'additional_technician_ids',
+ 'scheduled_date', 'time_start', 'time_end',
+ 'duration_hours', 'status', 'task_type', 'address_street',
+ 'address_city', 'address_zip', 'address_lat', 'address_lng',
+ 'partner_id'}
+ if sync_fields & set(vals.keys()) and not self.env.context.get('skip_task_sync'):
+ local_records = self.filtered(lambda r: not r.x_fc_sync_source)
+ if local_records:
+ self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
+ if 'status' in vals and vals['status'] in ('completed', 'cancelled'):
+ shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
+ if shadow_records:
+ self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
+ # Re-sync calendar event when schedule fields change
+ cal_fields = {'scheduled_date', 'time_start', 'time_end',
+ 'duration_hours', 'technician_id', 'task_type',
+ 'partner_id', 'address_street', 'address_city', 'notes'}
+ if cal_fields & set(vals.keys()):
+ self._sync_calendar_event()
+ return res
+
+ def _sync_calendar_event(self):
+ """Create or update a linked calendar.event for external calendar sync.
+
+ Only syncs tasks that have a scheduled date and an assigned technician.
+ Uses sudo() because portal users should not need calendar write access.
+ Falls back gracefully if external calendar validation fails (e.g.
+ Microsoft Calendar requires the organizer to have Outlook synced).
+ """
+ CalendarEvent = self.env['calendar.event'].sudo()
+ for task in self:
+ if not task.datetime_start or not task.datetime_end or not task.technician_id:
+ if task.calendar_event_id:
+ task.calendar_event_id.unlink()
+ task.with_context(skip_travel_recalc=True).write({'calendar_event_id': False})
+ continue
+
+ order = task._get_linked_order()
+ partner = task.partner_id or (order.partner_id if order else False)
+ client_name = partner.name if partner else ''
+ type_label = dict(self._fields['task_type'].selection).get(task.task_type, task.task_type or '')
+
+ event_name = f"{type_label}: {client_name}" if client_name else f"{type_label} - {task.name}"
+ location_parts = [task.address_street, task.address_city]
+ location = ', '.join(p for p in location_parts if p) or ''
+
+ description_parts = []
+ if order:
+ description_parts.append(f"Ref: {order.name}")
+ if task.description:
+ description_parts.append(task.description)
+
+ vals = {
+ 'name': event_name,
+ 'start': task.datetime_start,
+ 'stop': task.datetime_end,
+ 'user_id': task.technician_id.id,
+ 'location': location,
+ 'partner_ids': [(6, 0, [task.technician_id.partner_id.id])],
+ 'show_as': 'busy',
+ 'description': '\n'.join(description_parts),
+ }
+
+ try:
+ if task.calendar_event_id:
+ task.calendar_event_id.write(vals)
+ else:
+ event = CalendarEvent.create(vals)
+ task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id})
+ except Exception as e:
+ _logger.warning(
+ "Calendar sync skipped for task %s (tech=%s): %s",
+ task.name, task.technician_id.name, e,
+ )
+ if not task.calendar_event_id:
+ try:
+ vals['user_id'] = self.env.uid
+ event = CalendarEvent.create(vals)
+ task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id})
+ except Exception:
+ pass
+
+ @api.model
+ def _fill_address_vals(self, vals, partner):
+ """Helper to fill address vals dict from a partner record."""
+ vals.update({
+ 'address_partner_id': partner.id,
+ 'address_street': partner.street or '',
+ 'address_street2': partner.street2 or '',
+ 'address_city': partner.city or '',
+ 'address_state_id': partner.state_id.id if partner.state_id else False,
+ 'address_zip': partner.zip or '',
+ 'address_lat': partner.x_fc_latitude if hasattr(partner, 'x_fc_latitude') else 0,
+ 'address_lng': partner.x_fc_longitude if hasattr(partner, 'x_fc_longitude') else 0,
+ })
+
+ def _post_task_created_to_linked_order(self):
+ """Hook: post task creation notice to linked order chatter.
+ Override in fusion_claims."""
+ pass
+
+ def _mark_sale_order_ready_for_delivery(self):
+ """Hook: mark linked sale orders as ready for delivery.
+ Override in fusion_claims."""
+ pass
+
+ def _recalculate_day_travel_chains(self):
+ """Recalculate travel for all tech+date combos affected by these tasks.
+
+ Includes combos for additional technicians so their schedules update too.
+ """
+ combos = set()
+ for t in self:
+ if not t.scheduled_date:
+ continue
+ if t.technician_id:
+ combos.add((t.technician_id.id, t.scheduled_date))
+ for tech in t.additional_technician_ids:
+ combos.add((tech.id, t.scheduled_date))
+ self._recalculate_combos_travel(combos)
+
+ def _get_technician_start_address(self, tech_id):
+ """Get the start address for a technician.
+
+ Priority:
+ 1. Technician's personal x_fc_start_address (if set)
+ 2. Company default HQ address (fusion_claims.technician_start_address)
+ Returns the address string or ''.
+ """
+ tech_user = self.env['res.users'].sudo().browse(tech_id)
+ if tech_user.exists() and tech_user.x_fc_start_address:
+ return tech_user.x_fc_start_address.strip()
+ # Fallback to company default
+ return (self.env['ir.config_parameter'].sudo()
+ .get_param('fusion_claims.technician_start_address', '') or '').strip()
+
+ def _geocode_address_string(self, address, api_key):
+ """Geocode an address string and return (lat, lng) or (0.0, 0.0)."""
+ if not address or not api_key:
+ return 0.0, 0.0
+ try:
+ url = 'https://maps.googleapis.com/maps/api/geocode/json'
+ params = {'address': address, 'key': api_key, 'region': 'ca'}
+ resp = requests.get(url, params=params, timeout=10)
+ data = resp.json()
+ if data.get('status') == 'OK' and data.get('results'):
+ loc = data['results'][0]['geometry']['location']
+ return loc['lat'], loc['lng']
+ except Exception as e:
+ _logger.warning("Address geocoding failed for '%s': %s", address, e)
+ return 0.0, 0.0
+
+ def _recalculate_combos_travel(self, combos):
+ """Recalculate travel for a set of (tech_id, date) combinations.
+
+ Start-point priority per technician (for today only):
+ 1. Latest GPS location (from periodic tracking or task actions)
+ 2. Actual GPS from today's fusion_clock check-in
+ 3. Personal start address (x_fc_start_address)
+ 4. Company default HQ address
+ For future dates, only 3 and 4 apply.
+ """
+ ICP = self.env['ir.config_parameter'].sudo()
+ enabled = ICP.get_param('fusion_claims.google_distance_matrix_enabled', False)
+ if not enabled:
+ return
+ api_key = self._get_google_maps_api_key()
+
+ start_coords_cache = {}
+ today = self._local_now().date()
+ today_str = str(today)
+
+ today_tech_ids = {tid for tid, d in combos
+ if tid and str(d) == today_str}
+ clock_locations = {}
+ if today_tech_ids:
+ clock_locations = self._get_clock_in_locations(today_tech_ids, today)
+
+ for tech_id, date in combos:
+ if not tech_id or not date:
+ continue
+
+ cache_key = (tech_id, str(date))
+ if cache_key not in start_coords_cache:
+ if str(date) == today_str:
+ # Try latest GPS first (most accurate real-time position)
+ lat, lng = self._get_tech_current_location(tech_id)
+ if lat and lng:
+ start_coords_cache[cache_key] = (lat, lng)
+ elif tech_id in clock_locations:
+ cl = clock_locations[tech_id]
+ start_coords_cache[cache_key] = (cl['lat'], cl['lng'])
+ else:
+ addr = self._get_technician_start_address(tech_id)
+ start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
+ else:
+ addr = self._get_technician_start_address(tech_id)
+ start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
+
+ all_day_tasks = self.sudo().search([
+ '|',
+ ('technician_id', '=', tech_id),
+ ('additional_technician_ids', 'in', [tech_id]),
+ ('scheduled_date', '=', date),
+ ('status', 'not in', ['cancelled']),
+ ], order='time_start, sequence, id')
+ if not all_day_tasks:
+ continue
+
+ prev_lat, prev_lng = start_coords_cache[cache_key]
+ for i, task in enumerate(all_day_tasks):
+ if not (task.address_lat and task.address_lng):
+ task._geocode_address()
+ travel_vals = {}
+ if prev_lat and prev_lng and task.address_lat and task.address_lng:
+ task.with_context(skip_travel_recalc=True)._calculate_travel_time(prev_lat, prev_lng)
+ travel_vals['previous_task_id'] = all_day_tasks[i - 1].id if i > 0 else False
+ travel_vals['travel_origin'] = 'Clock-In Location' if i == 0 and str(date) == today_str and tech_id in clock_locations else ('Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}')
+ if travel_vals:
+ task.with_context(skip_travel_recalc=True).write(travel_vals)
+ prev_lat = task.address_lat or prev_lat
+ prev_lng = task.address_lng or prev_lng
+
+ # ------------------------------------------------------------------
+ # LIVE TRAVEL RECALCULATION (uses tech's current GPS position)
+ # ------------------------------------------------------------------
+
+ def _get_tech_current_location(self, tech_id):
+ """Get the technician's most recent GPS location.
+
+ Priority:
+ 1. Latest fusion.technician.location record from last 30 min
+ 2. Latest action_latitude/longitude from today's tasks
+ 3. Clock-in location
+ 4. None (caller falls back to start address)
+ """
+ Location = self.env['fusion.technician.location'].sudo()
+ cutoff = fields.Datetime.subtract(fields.Datetime.now(), minutes=30)
+ latest = Location.search([
+ ('user_id', '=', tech_id),
+ ('logged_at', '>', cutoff),
+ ('source', '!=', 'sync'),
+ ], order='logged_at desc', limit=1)
+ if latest and latest.latitude and latest.longitude:
+ return latest.latitude, latest.longitude
+
+ # Fallback: last completed task's location today
+ today = self._local_now().date()
+ last_completed = self.sudo().search([
+ ('technician_id', '=', tech_id),
+ ('scheduled_date', '=', today),
+ ('status', '=', 'completed'),
+ ('completed_latitude', '!=', 0),
+ ('completed_longitude', '!=', 0),
+ ], order='completion_datetime desc', limit=1)
+ if last_completed:
+ return last_completed.completed_latitude, last_completed.completed_longitude
+
+ # Fallback: clock-in location
+ clock_locs = self._get_clock_in_locations({tech_id}, today)
+ if tech_id in clock_locs:
+ cl = clock_locs[tech_id]
+ return cl['lat'], cl['lng']
+
+ return None, None
+
+ def _recalculate_travel_from_current_location(self):
+ """Recalculate travel time for THIS task from the tech's current GPS.
+
+ Called when tech starts en_route to get a live ETA.
+ """
+ self.ensure_one()
+ ICP = self.env['ir.config_parameter'].sudo()
+ if not ICP.get_param('fusion_claims.google_distance_matrix_enabled', False):
+ return
+ tech_id = self.technician_id.id
+ if not tech_id:
+ return
+ lat, lng = self._get_tech_current_location(tech_id)
+ if lat and lng and self.address_lat and self.address_lng:
+ self.with_context(skip_travel_recalc=True)._calculate_travel_time(lat, lng)
+ self.with_context(skip_travel_recalc=True).write({
+ 'travel_origin': 'Current Location (Live)',
+ })
+
+ def _recalculate_remaining_tasks_travel(self):
+ """After completing a task, recalculate travel for all remaining tasks
+ in the chain using the completion location as the new origin.
+
+ This ensures ETAs update in real-time as the tech progresses through
+ their schedule, and the route reflects their actual position.
+ """
+ self.ensure_one()
+ ICP = self.env['ir.config_parameter'].sudo()
+ if not ICP.get_param('fusion_claims.google_distance_matrix_enabled', False):
+ return
+
+ tech_id = self.technician_id.id
+ if not tech_id or not self.scheduled_date:
+ return
+
+ # Use completion GPS as origin for next task
+ origin_lat = self.completed_latitude or self.action_latitude
+ origin_lng = self.completed_longitude or self.action_longitude
+
+ # If no GPS from completion, try task address (tech was physically there)
+ if not origin_lat or not origin_lng:
+ origin_lat = self.address_lat
+ origin_lng = self.address_lng
+
+ if not origin_lat or not origin_lng:
+ return
+
+ remaining = self.sudo().search([
+ '|',
+ ('technician_id', '=', tech_id),
+ ('additional_technician_ids', 'in', [tech_id]),
+ ('scheduled_date', '=', self.scheduled_date),
+ ('status', 'not in', ['completed', 'cancelled']),
+ ('time_start', '>=', self.time_start),
+ ], order='time_start, sequence, id')
+
+ if not remaining:
+ return
+
+ prev_lat, prev_lng = origin_lat, origin_lng
+ for i, task in enumerate(remaining):
+ if not (task.address_lat and task.address_lng):
+ task._geocode_address()
+ if prev_lat and prev_lng and task.address_lat and task.address_lng:
+ task.with_context(skip_travel_recalc=True)._calculate_travel_time(
+ prev_lat, prev_lng)
+ origin_label = (f'Completed: {self.name}' if i == 0
+ else f'Task {remaining[i - 1].name}')
+ task.with_context(skip_travel_recalc=True).write({
+ 'previous_task_id': self.id if i == 0 else remaining[i - 1].id,
+ 'travel_origin': origin_label,
+ })
+ prev_lat = task.address_lat or prev_lat
+ prev_lng = task.address_lng or prev_lng
+
+ # ------------------------------------------------------------------
+ # STATUS ACTIONS
+ # ------------------------------------------------------------------
+
+ def _check_previous_tasks_completed(self):
+ """Check that all earlier tasks for the same technician+date are completed.
+
+ Considers tasks where the technician is either lead or additional.
+ """
+ self.ensure_one()
+ earlier_incomplete = self.sudo().search([
+ '|',
+ ('technician_id', '=', self.technician_id.id),
+ ('additional_technician_ids', 'in', [self.technician_id.id]),
+ ('scheduled_date', '=', self.scheduled_date),
+ ('time_start', '<', self.time_start),
+ ('status', 'not in', ['completed', 'cancelled']),
+ ('id', '!=', self.id),
+ ], limit=1)
+ if earlier_incomplete:
+ raise UserError(_(
+ "Please complete previous task %s first before starting this one."
+ ) % earlier_incomplete.name)
+
+ def _write_action_location(self, extra_vals=None):
+ """Write GPS coordinates from context onto the task record."""
+ ctx = self.env.context
+ lat = ctx.get('action_latitude', 0)
+ lng = ctx.get('action_longitude', 0)
+ acc = ctx.get('action_accuracy', 0)
+ vals = {
+ 'action_latitude': lat,
+ 'action_longitude': lng,
+ 'action_location_accuracy': acc,
+ }
+ if extra_vals:
+ vals.update(extra_vals)
+ if lat and lng:
+ self.with_context(skip_travel_recalc=True).write(vals)
+
+ def action_start_en_route(self):
+ """Mark task as En Route."""
+ for task in self:
+ if task.status != 'scheduled':
+ raise UserError(_("Only scheduled tasks can be marked as En Route."))
+ task._check_previous_tasks_completed()
+ task.status = 'en_route'
+ task._write_action_location()
+ task._post_status_message('en_route')
+ task._send_task_en_route_email()
+ # Recalculate travel from tech's current location to THIS task
+ task._recalculate_travel_from_current_location()
+ if task.x_fc_sync_source:
+ try:
+ self.env['fusion.task.sync.config']._push_shadow_status(task)
+ except Exception:
+ _logger.exception(
+ "Failed to push en_route for shadow %s", task.name)
+ try:
+ remaining = self.sudo().search_count([
+ ('technician_id', '=', task.technician_id.id),
+ ('scheduled_date', '=', task.scheduled_date),
+ ('status', 'in', ['scheduled', 'en_route']),
+ ('id', '!=', task.id),
+ ])
+ client = task.client_display_name or 'your next client'
+ ttype = dict(self._fields['task_type'].selection).get(
+ task.task_type, task.task_type or 'Task')
+ task._send_push_notification(
+ f'En Route to {client}',
+ f'{ttype} at {task.address_display or "scheduled location"}. '
+ f'{remaining} more task(s) today.',
+ )
+ except Exception:
+ pass
+
+ def action_start_task(self):
+ """Mark task as In Progress."""
+ for task in self:
+ if task.status not in ('scheduled', 'en_route'):
+ raise UserError(_("Task must be scheduled or en route to start."))
+ task._check_previous_tasks_completed()
+ task.status = 'in_progress'
+ ctx = self.env.context
+ task._write_action_location({
+ 'started_latitude': ctx.get('action_latitude', 0),
+ 'started_longitude': ctx.get('action_longitude', 0),
+ })
+ task._post_status_message('in_progress')
+
+ def action_complete_task(self):
+ """Mark task as Completed."""
+ for task in self:
+ if task.status not in ('in_progress', 'en_route', 'scheduled'):
+ raise UserError(_("Task must be in progress to complete."))
+
+ task._check_completion_requirements()
+
+ ctx = self.env.context
+ task.with_context(skip_travel_recalc=True).write({
+ 'status': 'completed',
+ 'completion_datetime': fields.Datetime.now(),
+ 'completed_latitude': ctx.get('action_latitude', 0),
+ 'completed_longitude': ctx.get('action_longitude', 0),
+ 'action_latitude': ctx.get('action_latitude', 0),
+ 'action_longitude': ctx.get('action_longitude', 0),
+ 'action_location_accuracy': ctx.get('action_accuracy', 0),
+ })
+ task._post_status_message('completed')
+ task._post_completion_to_linked_order()
+ task._notify_scheduler_on_completion()
+ task._send_task_completion_email()
+
+ # Recalculate travel for remaining tasks from this completion location
+ task._recalculate_remaining_tasks_travel()
+
+ task._on_complete_extra()
+
+ def _check_completion_requirements(self):
+ """Hook: check additional requirements before task completion.
+ Override in fusion_claims for rental inspection checks."""
+ pass
+
+ def _on_complete_extra(self):
+ """Hook: additional side-effects after task completion.
+ Override in fusion_claims for ODSP advancement and rental inspection."""
+ pass
+
+ def action_cancel_task(self):
+ """Cancel the task. Sends cancellation email and runs cancel hooks."""
+ for task in self:
+ if task.status == 'completed':
+ raise UserError(_("Cannot cancel a completed task."))
+ task.status = 'cancelled'
+ task._write_action_location()
+ task._post_status_message('cancelled')
+ if task.x_fc_sync_source:
+ try:
+ self.env['fusion.task.sync.config']._push_shadow_status(task)
+ except Exception:
+ _logger.exception(
+ "Failed to push cancel for shadow %s", task.name)
+ else:
+ task._on_cancel_extra()
+
+ def _on_cancel_extra(self):
+ """Hook: additional side-effects after task cancellation.
+ Override in fusion_claims for sale order revert and email."""
+ self._send_task_cancelled_email()
+
+ def action_reschedule(self):
+ """Open the reschedule form for this task.
+ Saves old schedule info, then opens the same task form for editing.
+ On save, the write() method detects the reschedule and sends emails."""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'fusion.technician.task',
+ 'res_id': self.id,
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'reschedule_mode': True,
+ 'old_date': str(self.scheduled_date) if self.scheduled_date else '',
+ 'old_time_start': self.time_start,
+ 'old_time_end': self.time_end,
+ },
+ }
+
+ def action_reset_to_scheduled(self):
+ """Reset task back to scheduled."""
+ for task in self:
+ task.status = 'scheduled'
+
+ # ------------------------------------------------------------------
+ # CHATTER / NOTIFICATIONS
+ # ------------------------------------------------------------------
+
+ def _post_status_message(self, new_status):
+ """Post a status change message to the task chatter."""
+ self.ensure_one()
+ status_labels = dict(self._fields['status'].selection)
+ label = status_labels.get(new_status, new_status)
+ icons = {
+ 'en_route': 'fa-road',
+ 'in_progress': 'fa-wrench',
+ 'completed': 'fa-check-circle',
+ 'cancelled': 'fa-times-circle',
+ 'rescheduled': 'fa-calendar',
+ }
+ icon = icons.get(new_status, 'fa-info-circle')
+ body = Markup(
+ f'
Task status changed to '
+ f'{label} by {self.env.user.name}
'
+ )
+ self.message_post(body=body, message_type='notification', subtype_xmlid='mail.mt_note')
+
+ def _post_completion_to_linked_order(self):
+ """Hook: post completion notes to linked order chatter.
+ Override in fusion_claims."""
+ pass
+
+ def _notify_scheduler_on_completion(self):
+ """Send an Odoo notification to the person who scheduled the task.
+
+ Shadow tasks skip this -- the push-back to the source instance
+ triggers the notification there where the real scheduler exists.
+ """
+ self.ensure_one()
+ if self.x_fc_sync_source:
+ return
+
+ recipient = None
+ order = self._get_linked_order()
+ if order and order.user_id:
+ recipient = order.user_id
+ elif self.create_uid:
+ recipient = self.create_uid
+
+ if not recipient or recipient in self.all_technician_ids:
+ return
+
+ task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
+ task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
+ client_name = self.client_display_name or 'N/A'
+ order = self._get_linked_order()
+ case_ref = order.name if order else ''
+ addr_parts = [p for p in [
+ self.address_street,
+ self.address_street2,
+ self.address_city,
+ self.address_state_id.name if self.address_state_id else '',
+ self.address_zip,
+ ] if p]
+ address_str = ', '.join(addr_parts) or 'No address'
+ subject = f'Task Completed: {client_name}'
+ if case_ref:
+ subject += f' ({case_ref})'
+ body = Markup(
+ f'
'
+ f'
'
+ f'{task_type_label} Completed
'
+ f'
'
+ f'
Client:
'
+ f'
{client_name}
'
+ f'
Case:
'
+ f'
{case_ref or "N/A"}
'
+ f'
Task:
'
+ f'
{self.name}
'
+ f'
Technician(s):
'
+ f'
{self.all_technician_names or self.technician_id.name}
'
+ )
+ self.env['mail.thread'].sudo().message_notify(
+ partner_ids=[recipient.partner_id.id],
+ body=body,
+ subject=subject,
+ )
+
+ # ------------------------------------------------------------------
+ # TASK EMAIL NOTIFICATIONS
+ # ------------------------------------------------------------------
+
+ def _get_task_email_details(self):
+ """Build common detail rows for task emails."""
+ self.ensure_one()
+ type_label = dict(self._fields['task_type'].selection).get(
+ self.task_type, self.task_type or '')
+ rows = [
+ ('Task', f'{self.name} ({type_label})'),
+ ('Client', self.partner_id.name or 'N/A'),
+ ]
+ if self.scheduled_date:
+ date_str = self.scheduled_date.strftime('%B %d, %Y')
+ start_str = self._float_to_time_str(self.time_start)
+ end_str = self._float_to_time_str(self.time_end)
+ rows.append(('Scheduled', f'{date_str}, {start_str} - {end_str}'))
+ if self.technician_id:
+ rows.append(('Technician', self.all_technician_names or self.technician_id.name))
+ if self.address_display:
+ rows.append(('Address', self.address_display))
+ return rows
+
+ def _get_task_email_recipients(self):
+ """Get email recipients for task notifications.
+ Returns dict with 'to' (client), 'cc' (technician, sales rep, office)."""
+ self.ensure_one()
+ to_emails = []
+ cc_emails = []
+
+ # Client email
+ if self.partner_id and self.partner_id.email:
+ to_emails.append(self.partner_id.email)
+
+ # Technician emails (lead + additional)
+ for tech in (self.technician_id | self.additional_technician_ids):
+ if tech.email:
+ cc_emails.append(tech.email)
+
+ return {'to': to_emails, 'cc': list(set(cc_emails))}
+
+ def _send_task_cancelled_email(self):
+ """Send cancellation email. Base: no-op. Override in fusion_claims."""
+ return False
+
+ def _send_task_scheduled_email(self):
+ """Send scheduled email. Base: no-op. Override in fusion_claims."""
+ return False
+
+ def _send_task_rescheduled_email(self, old_date=None, old_start=None, old_end=None):
+ """Send rescheduled email. Base: no-op. Override in fusion_claims."""
+ return False
+
+ # ------------------------------------------------------------------
+ # CLIENT UPDATE EMAILS (en-route + completion)
+ # ------------------------------------------------------------------
+
+ def _get_email_builder(self):
+ """Return a record that has the _email_build mixin.
+
+ Base: returns self (task model inherits mixin).
+ Override in fusion_claims to prefer linked sale order.
+ """
+ return self
+
+ def _is_email_notifications_enabled(self):
+ """Check if email notifications are enabled.
+
+ Base: always True. Override in fusion_claims to check
+ linked sale order's notification settings.
+ """
+ return True
+
+ def _get_linked_order(self):
+ """Return the linked order record (SO or PO), or False.
+
+ Base: always False. Override in fusion_claims to return
+ sale_order_id or purchase_order_id.
+ """
+ return False
+
+ def _send_task_en_route_email(self):
+ """Email the client that the technician is on the way."""
+ self.ensure_one()
+ if self.x_fc_sync_source or not self.x_fc_send_client_updates:
+ return False
+ if not self.partner_id or not self.partner_id.email:
+ return False
+ if not self._is_email_notifications_enabled():
+ return False
+
+ client_name = self.client_display_name or self.partner_id.name or 'Client'
+ tech_name = self.all_technician_names or (self.technician_id.name if self.technician_id else 'Our technician')
+ type_label = dict(self._fields['task_type'].selection).get(
+ self.task_type, self.task_type or 'service')
+ company = self.env.company
+
+ detail_rows = self._get_task_email_details()
+ builder = self._get_email_builder()
+
+ time_range = ''
+ if self.scheduled_date and self.time_start is not None:
+ time_range = (
+ f'{self.scheduled_date.strftime("%B %d, %Y")}, '
+ f'{self._float_to_time_str(self.time_start)} - '
+ f'{self._float_to_time_str(self.time_end or self.time_start + 1.0)}'
+ )
+
+ preparation_note = (
+ 'Please prepare for the visit: '
+ '
'
+ '
Ensure the area where service is needed is accessible and clear.
'
+ '
If applicable, secure pets away from the work area.
'
+ '
Have any relevant documents or information ready for the technician.
'
+ '
An adult (18+) must be present during the visit.
'
+ '
'
+ 'Important: Our technicians will not ask for credit card '
+ 'details or request payment directly unless payment on arrival has been '
+ 'previously agreed upon. If a cash payment was arranged, please have '
+ 'the payment ready in an envelope.'
+ )
+
+ body_html = builder._email_build(
+ title='Your Technician Is On The Way',
+ summary=(
+ f'Our technician {tech_name} is heading to your '
+ f'location and is expected to arrive between '
+ f'{time_range or "the scheduled time"}.'
+ ),
+ email_type='info',
+ sections=[('Visit Details', detail_rows)],
+ note=preparation_note,
+ note_color='#2B6CB0',
+ sender_name=f'The {company.name} Team',
+ )
+
+ recipients = self._get_task_email_recipients()
+ to_emails = recipients.get('to', [])
+ cc_emails = recipients.get('cc', [])
+ if not to_emails:
+ return False
+
+ email_to = ', '.join(to_emails)
+ email_cc = ', '.join(cc_emails)
+ order = self._get_linked_order()
+ case_ref = order.name if order else self.name
+
+ try:
+ mail_vals = {
+ 'subject': f'Your Technician Is On The Way - {client_name} - {case_ref}',
+ 'body_html': body_html,
+ 'email_to': email_to,
+ 'email_cc': email_cc,
+ }
+ if order:
+ mail_vals['model'] = order._name
+ mail_vals['res_id'] = order.id
+ self.env['mail.mail'].sudo().create(mail_vals).send()
+ _logger.info("Sent en-route email for task %s to %s", self.name, email_to)
+ return True
+ except Exception as e:
+ _logger.error("Failed to send en-route email for %s: %s", self.name, e)
+ return False
+
+ def _send_task_completion_email(self):
+ """Email the client that the visit is complete.
+
+ Sends one of two variants depending on x_fc_ask_google_review:
+ - With Google review request (default)
+ - Standard thank-you without review request
+ """
+ self.ensure_one()
+ if self.x_fc_sync_source or not self.x_fc_send_client_updates:
+ return False
+ if not self.partner_id or not self.partner_id.email:
+ return False
+ if not self._is_email_notifications_enabled():
+ return False
+
+ client_name = self.client_display_name or self.partner_id.name or 'Client'
+ tech_name = self.all_technician_names or (self.technician_id.name if self.technician_id else 'Our technician')
+ type_label = dict(self._fields['task_type'].selection).get(
+ self.task_type, self.task_type or 'service')
+ company = self.env.company
+ builder = self._get_email_builder()
+
+ summary_rows = [
+ ('Client', client_name),
+ ('Service', type_label.title()),
+ ]
+ if self.scheduled_date:
+ summary_rows.append(('Date', self.scheduled_date.strftime('%B %d, %Y')))
+ if self.technician_id:
+ summary_rows.append(('Technician', tech_name))
+
+ contact_note = (
+ 'If you have any questions or concerns about the service provided, '
+ f'please don\'t hesitate to contact us at '
+ f'{company.phone or company.email or "our office"}.'
+ )
+
+ google_url = company.x_fc_google_review_url or ''
+ include_review = self.x_fc_ask_google_review and google_url
+
+ extra_html = ''
+ button_url = ''
+ button_text = ''
+
+ if include_review:
+ extra_html = builder._email_note(
+ 'We Value Your Feedback
'
+ 'We hope you had a great experience! Your feedback helps us '
+ 'improve and serve you better. We would truly appreciate it '
+ 'if you could take a moment to share your experience.',
+ '#38a169',
+ )
+ button_url = google_url
+ button_text = 'Leave a Review'
+ else:
+ company_website = company.website or ''
+ if company_website:
+ button_url = company_website
+ button_text = 'Visit Our Website'
+
+ body_html = builder._email_build(
+ title='Service Visit Completed',
+ summary=(
+ f'Our technician {tech_name} has completed the '
+ f'{type_label.lower()} at your location. '
+ f'Thank you for choosing {company.name}.'
+ ),
+ email_type='success',
+ sections=[('Visit Summary', summary_rows)],
+ extra_html=extra_html,
+ note=contact_note,
+ note_color='#38a169',
+ button_url=button_url,
+ button_text=button_text,
+ sender_name=f'The {company.name} Team',
+ )
+
+ recipients = self._get_task_email_recipients()
+ to_emails = recipients.get('to', [])
+ cc_emails = recipients.get('cc', [])
+ if not to_emails:
+ return False
+
+ email_to = ', '.join(to_emails)
+ email_cc = ', '.join(cc_emails)
+ order = self._get_linked_order()
+ case_ref = order.name if order else self.name
+
+ try:
+ mail_vals = {
+ 'subject': f'Service Visit Completed - {client_name} - {case_ref}',
+ 'body_html': body_html,
+ 'email_to': email_to,
+ 'email_cc': email_cc,
+ }
+ if order:
+ mail_vals['model'] = order._name
+ mail_vals['res_id'] = order.id
+ self.env['mail.mail'].sudo().create(mail_vals).send()
+ _logger.info("Sent completion email for task %s to %s", self.name, email_to)
+ return True
+ except Exception as e:
+ _logger.error("Failed to send completion email for %s: %s", self.name, e)
+ return False
+
+ def get_next_task_for_technician(self):
+ """Get the next task in sequence for the same technician+date after this one.
+
+ Considers tasks where the technician is either lead or additional.
+ """
+ self.ensure_one()
+ return self.sudo().search([
+ '|',
+ ('technician_id', '=', self.technician_id.id),
+ ('additional_technician_ids', 'in', [self.technician_id.id]),
+ ('scheduled_date', '=', self.scheduled_date),
+ ('time_start', '>=', self.time_start),
+ ('status', 'in', ['scheduled', 'en_route']),
+ ('id', '!=', self.id),
+ ], order='time_start, sequence, id', limit=1)
+
+ # ------------------------------------------------------------------
+ # GOOGLE MAPS INTEGRATION
+ # ------------------------------------------------------------------
+
+ def _get_google_maps_api_key(self):
+ """Get the Google Maps API key from config."""
+ return self.env['ir.config_parameter'].sudo().get_param(
+ 'fusion_claims.google_maps_api_key', ''
+ )
+
+ @api.model
+ def get_map_data(self, domain=None):
+ """Return task data, technician locations, and Google Maps API key.
+
+ Args:
+ domain: optional extra domain from the search bar filters.
+ """
+ api_key = self.env['ir.config_parameter'].sudo().get_param(
+ 'fusion_claims.google_maps_api_key', '')
+ local_instance = self.env['ir.config_parameter'].sudo().get_param(
+ 'fusion_claims.sync_instance_id', '')
+ base_domain = [
+ ('status', 'not in', ['cancelled']),
+ ]
+ if domain:
+ base_domain = expression.AND([base_domain, domain])
+ tasks = self.sudo().search_read(
+ base_domain,
+ ['name', 'partner_id', 'technician_id', 'task_type',
+ 'address_lat', 'address_lng', 'address_display',
+ 'time_start', 'time_end', 'time_start_display', 'time_end_display',
+ 'status', 'scheduled_date', 'travel_time_minutes',
+ 'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'],
+ order='scheduled_date asc NULLS LAST, time_start asc',
+ limit=500,
+ )
+ locations = self.env['fusion.technician.location'].get_latest_locations()
+ tech_starts = self._get_tech_start_locations(tasks, api_key)
+ return {
+ 'api_key': api_key,
+ 'tasks': tasks,
+ 'locations': locations,
+ 'local_instance_id': local_instance,
+ 'tech_start_locations': tech_starts,
+ }
+
+ @api.model
+ def _get_tech_start_locations(self, tasks, api_key):
+ """Build a dict of technician start locations for route origins.
+
+ Priority per technician:
+ 1. Today's fusion_clock check-in location (if module installed)
+ 2. Personal start address (x_fc_start_address with cached lat/lng)
+ 3. Company default HQ address
+ """
+ tech_ids = {
+ t['technician_id'][0]
+ for t in tasks
+ if t.get('technician_id')
+ }
+ if not tech_ids:
+ return {}
+
+ result = {}
+ today = self._local_now().date()
+
+ clock_locations = self._get_clock_in_locations(tech_ids, today)
+
+ hq_address = (
+ self.env['ir.config_parameter'].sudo()
+ .get_param('fusion_claims.technician_start_address', '') or ''
+ ).strip()
+ hq_lat, hq_lng = 0.0, 0.0
+
+ for uid in tech_ids:
+ if uid in clock_locations:
+ result[uid] = clock_locations[uid]
+ continue
+
+ user = self.env['res.users'].sudo().browse(uid)
+ if not user.exists():
+ continue
+ partner = user.partner_id
+
+ if partner.x_fc_start_address and partner.x_fc_start_address.strip():
+ lat = partner.x_fc_start_address_lat
+ lng = partner.x_fc_start_address_lng
+ if not lat or not lng:
+ lat, lng = self._geocode_address_string(
+ partner.x_fc_start_address, api_key)
+ if lat and lng:
+ partner.sudo().write({
+ 'x_fc_start_address_lat': lat,
+ 'x_fc_start_address_lng': lng,
+ })
+ if lat and lng:
+ result[uid] = {
+ 'lat': lat, 'lng': lng,
+ 'address': partner.x_fc_start_address.strip(),
+ 'source': 'start_address',
+ }
+ continue
+
+ if hq_address:
+ if not hq_lat and not hq_lng:
+ hq_lat, hq_lng = self._geocode_address_string(
+ hq_address, api_key)
+ if hq_lat and hq_lng:
+ result[uid] = {
+ 'lat': hq_lat, 'lng': hq_lng,
+ 'address': hq_address,
+ 'source': 'company_hq',
+ }
+
+ return result
+
+ @api.model
+ def _get_clock_in_locations(self, tech_ids, today):
+ """Get today's clock-in lat/lng from fusion_clock if installed.
+
+ Uses the technician's actual GPS position at the moment they clocked
+ in (from the activity log), not the geofenced location's fixed
+ coordinates. Falls back to the geofence center if no activity-log
+ GPS is available.
+ """
+ result = {}
+ try:
+ module = self.env['ir.module.module'].sudo().search([
+ ('name', '=', 'fusion_clock'),
+ ('state', '=', 'installed'),
+ ], limit=1)
+ if not module:
+ return result
+ except Exception:
+ return result
+
+ try:
+ Attendance = self.env['hr.attendance'].sudo()
+ Employee = self.env['hr.employee'].sudo()
+ ActivityLog = self.env['fusion.clock.activity.log'].sudo()
+ except KeyError:
+ return result
+
+ employees = Employee.search([
+ ('user_id', 'in', list(tech_ids)),
+ ])
+ emp_to_user = {e.id: e.user_id.id for e in employees}
+
+ if not employees:
+ return result
+
+ today_start = dt_datetime.combine(today, dt_datetime.min.time())
+ today_end = today_start + timedelta(days=1)
+
+ attendances = Attendance.search([
+ ('employee_id', 'in', employees.ids),
+ ('check_in', '>=', today_start),
+ ('check_in', '<', today_end),
+ ], order='check_in asc')
+
+ for att in attendances:
+ uid = emp_to_user.get(att.employee_id.id)
+ if not uid or uid in result:
+ continue
+
+ lat, lng, address = 0, 0, ''
+
+ log = ActivityLog.search([
+ ('attendance_id', '=', att.id),
+ ('log_type', '=', 'clock_in'),
+ ('latitude', '!=', 0),
+ ('longitude', '!=', 0),
+ ], limit=1)
+ if log:
+ lat, lng = log.latitude, log.longitude
+ loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
+ address = (loc.address or loc.name) if loc else ''
+
+ if not lat or not lng:
+ loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
+ if loc and loc.latitude and loc.longitude:
+ lat, lng = loc.latitude, loc.longitude
+ address = loc.address or loc.name or ''
+
+ if lat and lng:
+ result[uid] = {
+ 'lat': lat,
+ 'lng': lng,
+ 'address': address,
+ 'source': 'clock_in',
+ }
+
+ return result
+
+ def _geocode_address(self):
+ """Geocode the task address using Google Geocoding API."""
+ self.ensure_one()
+ api_key = self._get_google_maps_api_key()
+ if not api_key or not self.address_display:
+ return False
+
+ try:
+ url = 'https://maps.googleapis.com/maps/api/geocode/json'
+ params = {
+ 'address': self.address_display,
+ 'key': api_key,
+ 'region': 'ca',
+ }
+ resp = requests.get(url, params=params, timeout=10)
+ data = resp.json()
+ if data.get('status') == 'OK' and data.get('results'):
+ location = data['results'][0]['geometry']['location']
+ self.write({
+ 'address_lat': location['lat'],
+ 'address_lng': location['lng'],
+ })
+ return True
+ except Exception as e:
+ _logger.warning(f"Geocoding failed for task {self.name}: {e}")
+ return False
+
+ def _calculate_travel_time(self, origin_lat, origin_lng):
+ """Calculate travel time from origin to this task using Distance Matrix API."""
+ self.ensure_one()
+ api_key = self._get_google_maps_api_key()
+ if not api_key:
+ return False
+ if not (origin_lat and origin_lng and self.address_lat and self.address_lng):
+ return False
+
+ try:
+ url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
+ params = {
+ 'origins': f'{origin_lat},{origin_lng}',
+ 'destinations': f'{self.address_lat},{self.address_lng}',
+ 'key': api_key,
+ 'mode': 'driving',
+ 'avoid': 'tolls',
+ 'traffic_model': 'best_guess',
+ 'departure_time': 'now',
+ }
+ resp = requests.get(url, params=params, timeout=10)
+ data = resp.json()
+ if data.get('status') == 'OK':
+ element = data['rows'][0]['elements'][0]
+ if element.get('status') == 'OK':
+ duration_seconds = element['duration_in_traffic']['value'] if 'duration_in_traffic' in element else element['duration']['value']
+ distance_meters = element['distance']['value']
+ self.write({
+ 'travel_time_minutes': round(duration_seconds / 60),
+ 'travel_distance_km': round(distance_meters / 1000, 1),
+ })
+ return True
+ except Exception as e:
+ _logger.warning(f"Travel time calculation failed for task {self.name}: {e}")
+ return False
+
+ def action_calculate_travel_times(self):
+ """Calculate travel times for a day's schedule. Called from backend button or cron."""
+ self._do_calculate_travel_times()
+ # Return False to stay on the current form without navigation
+ return False
+
+ def _do_calculate_travel_times(self):
+ """Internal: calculate travel times for tasks. Does not return an action.
+
+ For today's tasks: uses the tech's current GPS location as origin
+ for the first non-completed task, so ETAs reflect reality.
+ For future tasks: uses personal start address or company HQ.
+ """
+ # Group tasks by technician and date
+ task_groups = {}
+ for task in self:
+ key = (task.technician_id.id, task.scheduled_date)
+ if key not in task_groups:
+ task_groups[key] = self.env['fusion.technician.task']
+ task_groups[key] |= task
+
+ api_key = self._get_google_maps_api_key()
+ today = self._local_now().date()
+
+ for (tech_id, date), tasks in task_groups.items():
+ sorted_tasks = tasks.sorted(lambda t: (t.sequence, t.time_start))
+
+ # For today: try current GPS, then clock-in, then start address
+ # For future: use start address
+ if date == today:
+ lat, lng = self._get_tech_current_location(tech_id)
+ if lat and lng:
+ prev_lat, prev_lng = lat, lng
+ origin_label = 'Current Location'
+ else:
+ clock_locs = self._get_clock_in_locations({tech_id}, today)
+ if tech_id in clock_locs:
+ cl = clock_locs[tech_id]
+ prev_lat, prev_lng = cl['lat'], cl['lng']
+ origin_label = 'Clock-In Location'
+ else:
+ addr = self._get_technician_start_address(tech_id)
+ prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
+ origin_label = 'Start Location'
+ else:
+ addr = self._get_technician_start_address(tech_id)
+ prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
+ origin_label = 'Start Location'
+
+ # Skip already-completed tasks for today (chain starts from
+ # last completed task's location instead)
+ first_pending_idx = 0
+ if date == today:
+ for idx, task in enumerate(sorted_tasks):
+ if task.status == 'completed':
+ if task.completed_latitude and task.completed_longitude:
+ prev_lat = task.completed_latitude
+ prev_lng = task.completed_longitude
+ elif task.address_lat and task.address_lng:
+ prev_lat = task.address_lat
+ prev_lng = task.address_lng
+ origin_label = f'Completed: {task.name}'
+ first_pending_idx = idx + 1
+ else:
+ break
+
+ for i, task in enumerate(sorted_tasks):
+ if i < first_pending_idx:
+ continue
+ # Geocode task if needed
+ if not (task.address_lat and task.address_lng):
+ task._geocode_address()
+
+ if prev_lat and prev_lng and task.address_lat and task.address_lng:
+ task._calculate_travel_time(prev_lat, prev_lng)
+ task.previous_task_id = sorted_tasks[i - 1].id if i > 0 else False
+ task.travel_origin = origin_label if i == first_pending_idx else f'Task {sorted_tasks[i - 1].name}'
+
+ prev_lat = task.address_lat or prev_lat
+ prev_lng = task.address_lng or prev_lng
+
+ @api.model
+ def _cron_calculate_travel_times(self):
+ """Cron job: Calculate travel times for today and tomorrow.
+
+ Runs every 15 minutes. For today's tasks, uses the tech's latest
+ GPS location so ETAs stay accurate as technicians move.
+ Includes completed tasks in the search so the chain can skip
+ them and use their completion location as origin.
+ """
+ today = fields.Date.context_today(self)
+ tomorrow = today + timedelta(days=1)
+ tasks = self.search([
+ ('scheduled_date', 'in', [today, tomorrow]),
+ ('status', 'not in', ['cancelled']),
+ ])
+ if tasks:
+ tasks._do_calculate_travel_times()
+ _logger.info(f"Calculated travel times for {len(tasks)} tasks")
+
+ @api.model
+ def _cron_check_late_arrivals(self):
+ """Cron: detect tasks where the technician hasn't started and the
+ scheduled start time has passed. Sends a push notification to the
+ tech and an in-app notification to the office (once per task).
+ """
+ ICP = self.env['ir.config_parameter'].sudo()
+ push_enabled = ICP.get_param('fusion_claims.push_enabled', 'False')
+ if push_enabled.lower() not in ('true', '1', 'yes'):
+ return
+
+ local_now = self._local_now()
+ today = local_now.date()
+ current_hour = local_now.hour + local_now.minute / 60.0
+
+ late_tasks = self.sudo().search([
+ ('scheduled_date', '=', today),
+ ('status', '=', 'scheduled'),
+ ('time_start', '<', current_hour),
+ ('x_fc_late_notified', '=', False),
+ ('x_fc_sync_source', '=', False),
+ ('technician_id', '!=', False),
+ ])
+
+ for task in late_tasks:
+ minutes_late = int((current_hour - task.time_start) * 60)
+ if minutes_late < 5:
+ continue
+
+ client = task.client_display_name or 'Client'
+ try:
+ task._send_push_notification(
+ f'Running Late - {client}',
+ f'Your {task._float_to_time_str(task.time_start)} '
+ f'{dict(self._fields["task_type"].selection).get(task.task_type, "task")} '
+ f'is {minutes_late} min overdue. Please update your status.',
+ )
+ except Exception:
+ pass
+
+ try:
+ order = task._get_linked_order()
+ if order and order.user_id:
+ recipient = order.user_id
+ elif task.create_uid:
+ recipient = task.create_uid
+ else:
+ recipient = None
+ if recipient:
+ self.env['mail.thread'].sudo().message_notify(
+ partner_ids=[recipient.partner_id.id],
+ subject=f'Late: {task.name} - {client}',
+ body=Markup(
+ f'
'
+ f'{task.technician_id.name} has not started '
+ f'{task.name} for {client}, '
+ f'scheduled at {task._float_to_time_str(task.time_start)}. '
+ f'Currently {minutes_late} min overdue.