# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) """ Fusion Technician Task - Claims Extension Adds sale order, purchase order, and rental inspection features to the base fusion.technician.task model. """ from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError from markupsafe import Markup import logging _logger = logging.getLogger(__name__) class FusionTechnicianTaskClaims(models.Model): _inherit = 'fusion.technician.task' # ------------------------------------------------------------------ # LINKED ORDER FIELDS # ------------------------------------------------------------------ sale_order_id = fields.Many2one( 'sale.order', string='Related SO', tracking=True, ondelete='restrict', help='Sale order / case linked to this task', ) sale_order_name = fields.Char( related='sale_order_id.name', string='Case Reference', store=True, ) purchase_order_id = fields.Many2one( 'purchase.order', string='Related PO', tracking=True, ondelete='restrict', help='Purchase order linked to this task (e.g. manufacturer pickup)', ) purchase_order_name = fields.Char( related='purchase_order_id.name', string='PO Reference', store=True, ) # ------------------------------------------------------------------ # RENTAL INSPECTION # ------------------------------------------------------------------ rental_inspection_condition = fields.Selection([ ('excellent', 'Excellent'), ('good', 'Good'), ('fair', 'Fair'), ('damaged', 'Damaged'), ], string='Inspection Condition') rental_inspection_notes = fields.Text( string='Inspection Notes', ) rental_inspection_photo_ids = fields.Many2many( 'ir.attachment', 'technician_task_inspection_photo_rel', 'task_id', 'attachment_id', string='Inspection Photos', ) rental_inspection_completed = fields.Boolean( string='Inspection Completed', default=False, ) # ------------------------------------------------------------------ # ONCHANGES # ------------------------------------------------------------------ @api.onchange('sale_order_id') def _onchange_sale_order_id(self): """Auto-fill client and address from the sale order's shipping address.""" if self.sale_order_id: self.purchase_order_id = False order = self.sale_order_id if not self.partner_id: self.partner_id = order.partner_id addr = order.partner_shipping_id or order.partner_id self._fill_address_from_partner(addr) @api.onchange('purchase_order_id') def _onchange_purchase_order_id(self): """Auto-fill client and address from the purchase order's vendor.""" if self.purchase_order_id: self.sale_order_id = False order = self.purchase_order_id if not self.partner_id: self.partner_id = order.partner_id addr = order.dest_address_id or order.partner_id self._fill_address_from_partner(addr) # ------------------------------------------------------------------ # CONSTRAINTS # ------------------------------------------------------------------ @api.constrains('sale_order_id', 'purchase_order_id') def _check_order_link(self): for task in self: if task.x_fc_sync_source: continue if task.task_type == 'ltc_visit': continue if not task.sale_order_id and not task.purchase_order_id: raise ValidationError(_( "A task must be linked to either a Sale Order (Case) or a Purchase Order." )) # ------------------------------------------------------------------ # HOOK OVERRIDES # ------------------------------------------------------------------ def _get_linked_order(self): """Return the linked sale or purchase order.""" return self.sale_order_id or self.purchase_order_id or False def _create_vals_fill(self, vals): """Fill address from sale order or purchase order during create.""" if vals.get('sale_order_id') and not vals.get('address_street'): order = self.env['sale.order'].browse(vals['sale_order_id']) addr = order.partner_shipping_id or order.partner_id if addr: self._fill_address_vals(vals, addr) if not vals.get('partner_id'): vals['partner_id'] = order.partner_id.id elif vals.get('purchase_order_id') and not vals.get('address_street'): po = self.env['purchase.order'].browse(vals['purchase_order_id']) addr = po.dest_address_id or po.partner_id if addr: self._fill_address_vals(vals, addr) if not vals.get('partner_id'): vals['partner_id'] = po.partner_id.id else: super()._create_vals_fill(vals) def _on_create_post_actions(self): """Post-create actions: chatter notices, delivery marking, ODSP.""" for rec in self: rec._post_task_created_to_linked_order() if self.env.context.get('mark_ready_for_delivery'): self._mark_sale_order_ready_for_delivery() if self.env.context.get('mark_odsp_ready_for_delivery'): for rec in self: order = rec.sale_order_id if order and order.x_fc_is_odsp_sale and order._get_odsp_status() != 'ready_delivery': order._odsp_advance_status('ready_delivery', "Order is ready for delivery. Delivery task scheduled.") def _check_completion_requirements(self): """Check rental inspection requirement before completing pickup tasks.""" if self._is_rental_pickup_task() and not self.rental_inspection_completed: raise UserError(_( "Rental pickup tasks require a security inspection before " "completion. Please complete the inspection from the " "technician portal first." )) def _on_complete_extra(self): """ODSP advancement and rental inspection on task completion.""" if (self.task_type == 'delivery' and self.sale_order_id and self.sale_order_id.x_fc_is_odsp_sale and self.sale_order_id._get_odsp_status() == 'ready_delivery'): self.sale_order_id._odsp_advance_status( 'delivered', "Delivery task completed by technician. Order marked as delivered.", ) if self._is_rental_pickup_task(): self._apply_rental_inspection_results() def _on_cancel_extra(self): """Revert sale order on delivery cancellation, send email otherwise.""" if self.task_type == 'delivery': self._revert_sale_order_on_cancel() else: self._send_task_cancelled_email() # ------------------------------------------------------------------ # ORDER LINKING METHODS # ------------------------------------------------------------------ def _post_task_created_to_linked_order(self): """Post a brief task creation notice to the linked order's chatter.""" self.ensure_one() order = self.sale_order_id or self.purchase_order_id if not order: return task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type) date_str = self.scheduled_date.strftime('%B %d, %Y') if self.scheduled_date else 'TBD' time_str = self._float_to_time_str(self.time_start) task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form' body = Markup( f'
' f' Technician Task Scheduled
' f'{self.name} ({task_type_label}) - {date_str} at {time_str}
' f'Technician(s): {self.all_technician_names or self.technician_id.name}
' f'View Task' f'
' ) order.message_post( body=body, message_type='notification', subtype_xmlid='mail.mt_note', ) def _mark_sale_order_ready_for_delivery(self): """Mark linked sale orders as Ready for Delivery.""" for task in self: order = task.sale_order_id if not order: continue if order.x_fc_adp_application_status == 'ready_delivery': continue user_name = self.env.user.name tech_name = task.technician_id.name or '' previous_status = order.x_fc_adp_application_status all_tech_ids = (task.technician_id | task.additional_technician_ids).ids order.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': 'ready_delivery', 'x_fc_status_before_delivery': previous_status, 'x_fc_delivery_technician_ids': [(4, tid) for tid in all_tech_ids], 'x_fc_ready_for_delivery_date': fields.Datetime.now(), 'x_fc_scheduled_delivery_datetime': task.datetime_start, }) early_badge = '' if order.x_fc_early_delivery: early_badge = ' Early Delivery' scheduled_str = '' if task.scheduled_date: time_str = task._float_to_time_str(task.time_start) if task.time_start else '' date_str = task.scheduled_date.strftime('%B %d, %Y') scheduled_str = f'
  • Scheduled: {date_str} at {time_str}
  • ' notes_str = '' if task.description: notes_str = f'

    Delivery Notes: {task.description}

    ' chatter_body = Markup( f'' ) order.message_post( body=chatter_body, message_type='notification', subtype_xmlid='mail.mt_note', ) try: order._send_ready_for_delivery_email( technicians=task.technician_id | task.additional_technician_ids, scheduled_datetime=task.datetime_start, notes=task.description, ) except Exception as e: _logger.warning("Ready for delivery email failed for %s: %s", order.name, e) def _post_completion_to_linked_order(self): """Post the completion notes to the linked order's chatter.""" self.ensure_one() order = self.sale_order_id or self.purchase_order_id if not order or not self.completion_notes: return task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type) body = Markup( f'
    ' f'
    Technician Task Completed
    ' f'' f'
    ' f'{self.completion_notes}' f'
    ' ) order.message_post( body=body, message_type='notification', subtype_xmlid='mail.mt_note', ) def _revert_sale_order_on_cancel(self): """When a delivery task is cancelled, revert the sale order status.""" self.ensure_one() if self.task_type != 'delivery' or not self.sale_order_id: return order = self.sale_order_id if order.x_fc_adp_application_status != 'ready_delivery': return other_delivery_tasks = self.sudo().search([ ('sale_order_id', '=', order.id), ('task_type', '=', 'delivery'), ('status', 'not in', ['cancelled']), ('id', '!=', self.id), ], limit=1) if other_delivery_tasks: return prev_status = order.x_fc_status_before_delivery or 'approved' status_labels = dict(order._fields['x_fc_adp_application_status'].selection) prev_label = status_labels.get(prev_status, prev_status) order.with_context( skip_status_validation=True, skip_status_emails=True, ).write({ 'x_fc_adp_application_status': prev_status, 'x_fc_status_before_delivery': False, }) body = Markup( f'' ) order.message_post( body=body, message_type='notification', subtype_xmlid='mail.mt_note', ) self._send_task_cancelled_email() def _is_rental_pickup_task(self): """Check if this is a pickup task for a rental order.""" self.ensure_one() return ( self.task_type == 'pickup' and self.sale_order_id and self.sale_order_id.is_rental_order ) def _apply_rental_inspection_results(self): """Write inspection results from the task back to the rental order.""" self.ensure_one() order = self.sale_order_id if not order or not order.is_rental_order: return inspection_status = 'passed' if self.rental_inspection_condition in ('fair', 'damaged'): inspection_status = 'flagged' vals = { 'rental_inspection_status': inspection_status, 'rental_inspection_notes': self.rental_inspection_notes or '', } if self.rental_inspection_photo_ids: vals['rental_inspection_photo_ids'] = [(6, 0, self.rental_inspection_photo_ids.ids)] order.write(vals) if inspection_status == 'passed': order._refund_security_deposit() elif inspection_status == 'flagged': order.activity_schedule( 'mail.mail_activity_data_todo', date_deadline=fields.Date.today(), summary=_("Review rental inspection: %s", order.name), note=_( "Technician flagged rental pickup for %s. " "Condition: %s. Please review inspection photos and notes.", order.partner_id.name, self.rental_inspection_condition or 'Unknown', ), user_id=order.user_id.id or self.env.uid, ) try: order._send_damage_notification_email() except Exception as e: _logger.error( "Failed to send damage notification for %s: %s", order.name, e, ) # ------------------------------------------------------------------ # VIEW ACTIONS # ------------------------------------------------------------------ def action_view_sale_order(self): """Open the linked sale order / case.""" self.ensure_one() if not self.sale_order_id: return return { 'name': self.sale_order_id.name, 'type': 'ir.actions.act_window', 'res_model': 'sale.order', 'view_mode': 'form', 'res_id': self.sale_order_id.id, } def action_view_purchase_order(self): """Open the linked purchase order.""" self.ensure_one() if not self.purchase_order_id: return return { 'name': self.purchase_order_id.name, 'type': 'ir.actions.act_window', 'res_model': 'purchase.order', 'view_mode': 'form', 'res_id': self.purchase_order_id.id, } # ------------------------------------------------------------------ # EMAIL OVERRIDES # ------------------------------------------------------------------ def _get_email_builder(self): """Prefer the linked sale order for email building.""" if self.sale_order_id: return self.sale_order_id return super()._get_email_builder() def _is_email_notifications_enabled(self): """Check linked sale order's notification settings.""" if self.sale_order_id: try: return self.sale_order_id._is_email_notifications_enabled() except Exception: return True return super()._is_email_notifications_enabled() def _get_task_email_details(self): """Add SO/PO reference rows to email details.""" rows = super()._get_task_email_details() # Insert after Client row (index 1) insert_idx = 2 if self.sale_order_id: rows.insert(insert_idx, ('Case', self.sale_order_id.name)) insert_idx += 1 if self.purchase_order_id: rows.insert(insert_idx, ('Purchase Order', self.purchase_order_id.name)) return rows def _get_task_email_recipients(self): """Add sales rep and office CC from linked sale order.""" result = super()._get_task_email_recipients() if self.sale_order_id and self.sale_order_id.user_id and \ self.sale_order_id.user_id.email: result['cc'].append(self.sale_order_id.user_id.email) if self.sale_order_id: try: office_cc = self.sale_order_id._get_email_recipients( include_client=False).get('office_cc', []) result['cc'].extend(office_cc) except Exception: pass result['cc'] = list(set(result['cc'])) return result def _send_task_cancelled_email(self): """Send cancellation email using linked sale order's email builder.""" self.ensure_one() if self.x_fc_sync_source: return False order = self.sale_order_id if not order: return False try: if not order._is_email_notifications_enabled(): return False except Exception: return False recipients = self._get_task_email_recipients() to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) if not to_emails and not cc_emails: return False client_name = self.partner_id.name or 'Client' type_label = dict(self._fields['task_type'].selection).get( self.task_type, self.task_type or 'Task') sender_name = self.env.user.name detail_rows = self._get_task_email_details() detail_rows.append(('Cancelled By', sender_name)) body_html = order._email_build( title=f'{type_label.title()} Cancelled', summary=( f'The scheduled {type_label.lower()} for ' f'{client_name} has been cancelled.' ), email_type='urgent', sections=[('Cancellation Details', detail_rows)], note=( 'What happens next: If you need to reschedule, ' 'please contact our office and we will arrange a new appointment.' ), note_color='#e53e3e', button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form', sender_name=sender_name, ) email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1]) email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:]) try: self.env['mail.mail'].sudo().create({ 'subject': f'{type_label.title()} Cancelled - {client_name} - {order.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': order.id, }).send() order._email_chatter_log( f'{type_label.title()} Cancelled email sent', email_to, email_cc) return True except Exception as e: _logger.error("Failed to send task cancelled email for %s: %s", self.name, e) return False def _send_task_scheduled_email(self): """Send appointment scheduled email using linked sale order.""" self.ensure_one() if self.x_fc_sync_source: return False order = self.sale_order_id if not order: return False try: if not order._is_email_notifications_enabled(): return False except Exception: return False recipients = self._get_task_email_recipients() to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) if not to_emails and not cc_emails: return False client_name = self.partner_id.name or 'Client' type_label = dict(self._fields['task_type'].selection).get( self.task_type, self.task_type or 'Task') sender_name = self.env.user.name detail_rows = self._get_task_email_details() if self.description: detail_rows.append(('Notes', self.description)) body_html = order._email_build( title=f'{type_label.title()} Scheduled', summary=( f'A {type_label.lower()} has been scheduled for ' f'{client_name}.' ), email_type='success', sections=[('Appointment Details', detail_rows)], note=( 'Please note: If you need to change this appointment, ' 'please contact our office as soon as possible so we can accommodate ' 'the change.' ), note_color='#38a169', button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form', sender_name=sender_name, ) email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1]) email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:]) try: self.env['mail.mail'].sudo().create({ 'subject': f'{type_label.title()} Scheduled - {client_name} - {order.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': order.id, }).send() order._email_chatter_log( f'{type_label.title()} Scheduled email sent', email_to, email_cc) return True except Exception as e: _logger.error("Failed to send task scheduled email for %s: %s", self.name, e) return False def _send_task_rescheduled_email(self, old_date=None, old_start=None, old_end=None): """Send reschedule email using linked sale order.""" self.ensure_one() if self.x_fc_sync_source: return False order = self.sale_order_id if not order: return False try: if not order._is_email_notifications_enabled(): return False except Exception: return False recipients = self._get_task_email_recipients() to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) if not to_emails and not cc_emails: return False client_name = self.partner_id.name or 'Client' type_label = dict(self._fields['task_type'].selection).get( self.task_type, self.task_type or 'Task') sender_name = self.env.user.name detail_rows = self._get_task_email_details() if old_date or old_start is not None: old_parts = [] if old_date: old_parts.append(old_date.strftime('%B %d, %Y')) if old_start is not None: old_parts.append( f'{self._float_to_time_str(old_start)} - ' f'{self._float_to_time_str(old_end or old_start + 1.0)}') detail_rows.insert(3, ('Previous Schedule', ', '.join(old_parts))) body_html = order._email_build( title=f'{type_label.title()} Rescheduled', summary=( f'The {type_label.lower()} for ' f'{client_name} has been rescheduled.' ), email_type='attention', sections=[('Updated Appointment Details', detail_rows)], note=( 'Please note: The appointment has been updated ' 'to the new date and time shown above. If you have any questions, ' 'please contact our office.' ), note_color='#d69e2e', button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form', sender_name=sender_name, ) email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1]) email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:]) try: self.env['mail.mail'].sudo().create({ 'subject': f'{type_label.title()} Rescheduled - {client_name} - {order.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': order.id, }).send() order._email_chatter_log( f'{type_label.title()} Rescheduled email sent', email_to, email_cc) return True except Exception as e: _logger.error("Failed to send rescheduled email for %s: %s", self.name, e) return False