# -*- coding: utf-8 -*- # Copyright 2024-2025 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Claim Assistant product family. import logging import re from markupsafe import Markup from odoo import models, fields, api from odoo.exceptions import ValidationError, UserError _logger = logging.getLogger(__name__) class SaleOrder(models.Model): _name = 'sale.order' _inherit = ['sale.order', 'fusion_claims.adp.posting.schedule.mixin', 'fusion.email.builder.mixin'] _rec_names_search = ['name', 'partner_id.name'] @api.depends('name', 'partner_id.name') def _compute_display_name(self): for order in self: name = order.name or '' if order.partner_id and order.partner_id.name: name = f"{name} -- {order.partner_id.name}" order.display_name = name # ========================================================================== # FIELD FLAGS # ========================================================================== x_fc_is_adp_sale = fields.Boolean( compute='_compute_is_adp_sale', store=True, string='Is ADP Sale', help='True only for ADP or ADP/ODSP sale types', ) # ========================================================================== # INVOICE COUNT FIELDS (Separate ADP and Client invoices) # ========================================================================== x_fc_adp_invoice_count = fields.Integer( compute='_compute_invoice_counts', string='ADP Invoices', ) x_fc_client_invoice_count = fields.Integer( compute='_compute_invoice_counts', string='Client Invoices', ) @api.depends('x_fc_adp_invoice_id', 'x_fc_client_invoice_id') def _compute_invoice_counts(self): """Compute separate counts for ADP and Client invoices. Uses x_fc_source_sale_order_id for direct linking instead of invoice_ids which only works when invoice lines have sale_line_ids. Also includes manually mapped invoices from x_fc_adp_invoice_id and x_fc_client_invoice_id. """ AccountMove = self.env['account.move'].sudo() for order in self: # Search for invoices directly linked to this order via x_fc_source_sale_order_id adp_invoices = AccountMove.search([ ('x_fc_source_sale_order_id', '=', order.id), ('x_fc_adp_invoice_portion', '=', 'adp'), ('state', '!=', 'cancel'), ]) client_invoices = AccountMove.search([ ('x_fc_source_sale_order_id', '=', order.id), ('x_fc_adp_invoice_portion', '=', 'client'), ('state', '!=', 'cancel'), ]) # Also include manually mapped invoices from Invoice Mapping section if order.x_fc_adp_invoice_id and order.x_fc_adp_invoice_id.state != 'cancel': adp_invoices |= order.x_fc_adp_invoice_id if order.x_fc_client_invoice_id and order.x_fc_client_invoice_id.state != 'cancel': client_invoices |= order.x_fc_client_invoice_id order.x_fc_adp_invoice_count = len(adp_invoices) order.x_fc_client_invoice_count = len(client_invoices) # ========================================================================== # MOD INVOICE COUNT FIELDS (Separate MOD and Client invoices) # ========================================================================== x_fc_mod_invoice_count = fields.Integer( compute='_compute_mod_invoice_counts', string='MOD Invoices', ) x_fc_mod_client_invoice_count = fields.Integer( compute='_compute_mod_invoice_counts', string='Client Invoices (MOD)', ) @api.depends('order_line') def _compute_mod_invoice_counts(self): """Compute separate counts for MOD and Client invoices on MOD cases.""" AccountMove = self.env['account.move'].sudo() for order in self: if not order.x_fc_is_mod_sale: order.x_fc_mod_invoice_count = 0 order.x_fc_mod_client_invoice_count = 0 continue # MOD portion invoices (full or adp = MOD's share) mod_invoices = AccountMove.search([ ('x_fc_source_sale_order_id', '=', order.id), ('x_fc_adp_invoice_portion', 'in', ('full', 'adp')), ('state', '!=', 'cancel'), ]) # Client portion invoices client_invoices = AccountMove.search([ ('x_fc_source_sale_order_id', '=', order.id), ('x_fc_adp_invoice_portion', '=', 'client'), ('state', '!=', 'cancel'), ]) order.x_fc_mod_invoice_count = len(mod_invoices) order.x_fc_mod_client_invoice_count = len(client_invoices) def action_view_mod_invoices(self): """Open MOD portion invoices for this order.""" self.ensure_one() invoices = self.env['account.move'].sudo().search([ ('x_fc_source_sale_order_id', '=', self.id), ('x_fc_adp_invoice_portion', 'in', ('full', 'adp')), ('state', '!=', 'cancel'), ]) action = { 'name': 'MOD Invoices', 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [('id', 'in', invoices.ids)], 'context': {'default_move_type': 'out_invoice'}, } if len(invoices) == 1: action['view_mode'] = 'form' action['res_id'] = invoices.id return action def action_view_mod_client_invoices(self): """Open Client portion invoices for MOD cases.""" self.ensure_one() invoices = self.env['account.move'].sudo().search([ ('x_fc_source_sale_order_id', '=', self.id), ('x_fc_adp_invoice_portion', '=', 'client'), ('state', '!=', 'cancel'), ]) action = { 'name': 'Client Invoices', 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [('id', 'in', invoices.ids)], 'context': {'default_move_type': 'out_invoice'}, } if len(invoices) == 1: action['view_mode'] = 'form' action['res_id'] = invoices.id return action # ========================================================================== # VENDOR BILL LINKING (for audit trail) # ========================================================================== x_fc_vendor_bill_ids = fields.Many2many( 'account.move', 'sale_order_vendor_bill_rel', 'sale_order_id', 'move_id', string='Vendor Bills', domain=[('move_type', '=', 'in_invoice')], help='Vendor bills/invoices linked to this sales order for audit trail purposes.', ) x_fc_vendor_bill_count = fields.Integer( compute='_compute_vendor_bill_count', string='Vendor Bills', ) @api.depends('x_fc_vendor_bill_ids') def _compute_vendor_bill_count(self): """Compute count of linked vendor bills.""" for order in self: order.x_fc_vendor_bill_count = len(order.x_fc_vendor_bill_ids) def action_view_vendor_bills(self): """Open view of linked vendor bills.""" self.ensure_one() action = { 'name': 'Vendor Bills', 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [('id', 'in', self.x_fc_vendor_bill_ids.ids)], 'context': {'default_move_type': 'in_invoice'}, } if len(self.x_fc_vendor_bill_ids) == 1: action['view_mode'] = 'form' action['res_id'] = self.x_fc_vendor_bill_ids.id return action # ========================================================================== # SUBMISSION HISTORY (for tracking all submissions/resubmissions to ADP) # ========================================================================== x_fc_submission_history_ids = fields.One2many( 'fusion.submission.history', 'sale_order_id', string='Submission History', help='History of all submissions and resubmissions to ADP', ) x_fc_submission_count = fields.Integer( string='Submissions', compute='_compute_submission_count', ) @api.depends('x_fc_submission_history_ids') def _compute_submission_count(self): """Compute the number of submissions for this order.""" for order in self: order.x_fc_submission_count = len(order.x_fc_submission_history_ids) def action_view_submission_history(self): """Open the submission history for this order.""" self.ensure_one() return { 'name': 'Submission History', 'type': 'ir.actions.act_window', 'res_model': 'fusion.submission.history', 'view_mode': 'list,form', 'domain': [('sale_order_id', '=', self.id)], 'context': {'default_sale_order_id': self.id}, } # ========================================================================== # TECHNICIAN TASKS # ========================================================================== x_fc_technician_task_ids = fields.One2many( 'fusion.technician.task', 'sale_order_id', string='Technician Tasks', ) x_fc_technician_task_count = fields.Integer( string='Tasks', compute='_compute_technician_task_count', ) @api.depends('x_fc_technician_task_ids') def _compute_technician_task_count(self): for order in self: order.x_fc_technician_task_count = len(order.x_fc_technician_task_ids) def action_view_technician_tasks(self): """Open the technician tasks linked to this order.""" self.ensure_one() action = { 'name': 'Technician Tasks', 'type': 'ir.actions.act_window', 'res_model': 'fusion.technician.task', 'view_mode': 'list,form', 'domain': [('sale_order_id', '=', self.id)], 'context': {'default_sale_order_id': self.id}, } if len(self.x_fc_technician_task_ids) == 1: action['view_mode'] = 'form' action['res_id'] = self.x_fc_technician_task_ids.id return action # LOANER EQUIPMENT TRACKING # ========================================================================== x_fc_loaner_checkout_ids = fields.One2many( 'fusion.loaner.checkout', 'sale_order_id', string='Loaner Checkouts', help='Loaner equipment checked out for this order', ) x_fc_loaner_count = fields.Integer( string='Loaners', compute='_compute_loaner_count', ) x_fc_active_loaner_count = fields.Integer( string='Active Loaners', compute='_compute_loaner_count', ) x_fc_has_overdue_loaner = fields.Boolean( string='Has Overdue Loaner', compute='_compute_loaner_count', help='True if any active loaner is past its expected return date', ) @api.depends('x_fc_loaner_checkout_ids', 'x_fc_loaner_checkout_ids.state', 'x_fc_loaner_checkout_ids.expected_return_date') def _compute_loaner_count(self): """Compute loaner counts and overdue status for this order.""" today = fields.Date.today() for order in self: active = order.x_fc_loaner_checkout_ids.filtered( lambda l: l.state in ('checked_out', 'overdue', 'rental_pending') ) order.x_fc_loaner_count = len(order.x_fc_loaner_checkout_ids) order.x_fc_active_loaner_count = len(active) order.x_fc_has_overdue_loaner = any( l.state == 'overdue' or (l.expected_return_date and l.expected_return_date < today) for l in active ) def action_view_loaners(self): """Open the loaner checkouts for this order.""" self.ensure_one() action = { 'name': 'Loaner Checkouts', 'type': 'ir.actions.act_window', 'res_model': 'fusion.loaner.checkout', 'view_mode': 'tree,form', 'domain': [('sale_order_id', '=', self.id)], 'context': {'default_sale_order_id': self.id}, } if len(self.x_fc_loaner_checkout_ids) == 1: action['view_mode'] = 'form' action['res_id'] = self.x_fc_loaner_checkout_ids.id return action def action_checkout_loaner(self): """Open the loaner checkout wizard.""" self.ensure_one() return { 'name': 'Checkout Loaner', 'type': 'ir.actions.act_window', 'res_model': 'fusion.loaner.checkout.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_sale_order_id': self.id, 'default_partner_id': self.partner_id.id, 'default_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False, }, } def action_checkin_loaner(self): """Open the return wizard for the active loaner on this order.""" self.ensure_one() active_loaners = self.x_fc_loaner_checkout_ids.filtered( lambda l: l.state in ('checked_out', 'overdue', 'rental_pending') ) if not active_loaners: raise UserError("No active loaners to check in for this order.") if len(active_loaners) == 1: return active_loaners.action_return() # Multiple active loaners - show the list so user can pick which one to return return { 'name': 'Return Loaner', 'type': 'ir.actions.act_window', 'res_model': 'fusion.loaner.checkout', 'view_mode': 'tree,form', 'domain': [('id', 'in', active_loaners.ids)], 'target': 'current', } def action_ready_for_delivery(self): """Open the task scheduling form to schedule a delivery task. Instead of a separate wizard, this opens the full technician task form pre-filled with delivery defaults. When the task is saved, the sale order is automatically marked as Ready for Delivery. """ self.ensure_one() # Validate the order can be marked ready for delivery if not self._is_adp_sale(): raise UserError("Ready for Delivery is only available for ADP sales.") valid_statuses = ('approved', 'approved_deduction') if self.x_fc_early_delivery: valid_statuses = ('submitted', 'accepted', 'approved', 'approved_deduction') if self.x_fc_adp_application_status not in valid_statuses: if self.x_fc_early_delivery: raise UserError( "For early delivery, the application must be at least Submitted.\n" f"Current status: {dict(self._fields['x_fc_adp_application_status'].selection).get(self.x_fc_adp_application_status)}" ) else: raise UserError( "The application must be Approved before marking Ready for Delivery.\n" "To deliver before approval, check 'Early Delivery' first.\n" f"Current status: {dict(self._fields['x_fc_adp_application_status'].selection).get(self.x_fc_adp_application_status)}" ) return { 'name': 'Schedule Delivery Task', 'type': 'ir.actions.act_window', 'res_model': 'fusion.technician.task', 'view_mode': 'form', 'target': 'new', 'context': { 'default_task_type': 'delivery', 'default_sale_order_id': self.id, 'default_partner_id': self.partner_id.id, 'default_pod_required': True, 'mark_ready_for_delivery': True, }, } @api.depends('x_fc_sale_type') def _compute_is_adp_sale(self): """Compute if this is an ADP sale - only ADP or ADP/ODSP sale types.""" for order in self: order.x_fc_is_adp_sale = order._is_adp_sale() # ========================================================================== # SALE TYPE AND CLIENT TYPE FIELDS # ========================================================================== x_fc_sale_type = fields.Selection( selection=[ ('adp', 'ADP'), ('adp_odsp', 'ADP/ODSP'), ('odsp', 'ODSP'), ('wsib', 'WSIB'), ('direct_private', 'Direct/Private'), ('insurance', 'Insurance'), ('march_of_dimes', 'March of Dimes'), ('muscular_dystrophy', 'Muscular Dystrophy'), ('other', 'Others'), ('rental', 'Rentals'), ('hardship', 'Hardship Funding'), ], string='Sale Type', index=True, copy=True, tracking=True, help='Type of sale for billing purposes. This field determines the workflow and billing rules.', ) x_fc_sale_type_locked = fields.Boolean( string='Sale Type Locked', compute='_compute_sale_type_locked', help='Sale type is locked after application is submitted to ADP', ) @api.depends('x_fc_adp_application_status') def _compute_sale_type_locked(self): """Sale type is locked once application is submitted to ADP.""" locked_statuses = [ 'submitted', 'accepted', 'rejected', 'resubmitted', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', ] for order in self: order.x_fc_sale_type_locked = order.x_fc_adp_application_status in locked_statuses x_fc_client_type = fields.Selection( selection=[ ('REG', 'REG'), ('ODS', 'ODS'), ('OWP', 'OWP'), ('ACS', 'ACS'), ('LTC', 'LTC'), ('SEN', 'SEN'), ('CCA', 'CCA'), ], string='Client Type', tracking=True, help='Client type for ADP portion calculations. REG = 75%/25%, others = 100%/0%', ) # Authorizer Required field - only for certain sale types # For: odsp, direct_private, insurance, other, rental x_fc_authorizer_required = fields.Selection( selection=[ ('yes', 'Yes'), ('no', 'No'), ], string='Authorizer Required?', help='For ODSP, Direct/Private, Insurance, Others, and Rentals - specify if an authorizer is needed.', ) # Computed field to determine if authorizer should be shown x_fc_show_authorizer = fields.Boolean( compute='_compute_show_authorizer', string='Show Authorizer', ) @api.depends('x_fc_sale_type', 'x_fc_authorizer_required') def _compute_show_authorizer(self): """Compute whether to show the authorizer field based on sale type and authorizer_required.""" # Sale types that require the "Authorizer Required?" question optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental') # Sale types where authorizer is always shown/required always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy') for order in self: sale_type = order.x_fc_sale_type if sale_type in always_auth_types: # Always show authorizer for ADP-related types order.x_fc_show_authorizer = True elif sale_type in optional_auth_types: # Show authorizer only if user selected "Yes" order.x_fc_show_authorizer = order.x_fc_authorizer_required == 'yes' else: # No sale type selected - don't show order.x_fc_show_authorizer = False # Computed field to determine if "Authorizer Required?" question should be shown x_fc_show_authorizer_question = fields.Boolean( compute='_compute_show_authorizer_question', string='Show Authorizer Question', ) @api.depends('x_fc_sale_type') def _compute_show_authorizer_question(self): """Compute whether to show the 'Authorizer Required?' field.""" optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental') for order in self: order.x_fc_show_authorizer_question = order.x_fc_sale_type in optional_auth_types # ========================================================================== # MARCH OF DIMES FIELDS # ========================================================================== x_fc_mod_status = fields.Selection( selection=[ ('need_to_schedule', 'Schedule Assessment'), ('assessment_scheduled', 'Assessment Booked'), ('assessment_completed', 'Assessment Done'), ('processing_drawings', 'Processing Drawing'), ('quote_submitted', 'Quote Sent'), ('awaiting_funding', 'Awaiting Funding'), ('funding_approved', 'Approved'), ('funding_denied', 'Denied'), ('contract_received', 'PCA Received'), ('in_production', 'In Production'), ('project_complete', 'Complete'), ('pod_submitted', 'POD Sent'), ('case_closed', 'Closed'), ('on_hold', 'On Hold'), ('cancelled', 'Cancelled'), ], string='MOD Status', default='need_to_schedule', tracking=True, group_expand='_expand_mod_statuses', help='March of Dimes case workflow status', ) @api.model def _expand_mod_statuses(self, states, domain): """Return the main MOD workflow statuses for kanban columns. Always shows core statuses; special statuses (funding_denied, on_hold, cancelled) only appear when records exist in them.""" main = [ 'need_to_schedule', 'assessment_scheduled', 'assessment_completed', 'processing_drawings', 'quote_submitted', 'awaiting_funding', 'funding_approved', 'contract_received', 'in_production', 'project_complete', 'pod_submitted', 'case_closed', ] result = list(main) for s in (states or []): if s and s not in result: result.append(s) return result # --- Case contacts (per-order MOD contacts) --- x_fc_case_handler = fields.Many2one( 'res.partner', string='MOD Case Handler', tracking=True, help='March of Dimes case handler / counsellor (e.g. Barrier Free Design Counsellor)', ) x_fc_case_worker = fields.Many2one( 'res.partner', string='Case Worker', tracking=True, help='Case worker assigned to this order', ) x_fc_mod_contact_name = fields.Char( string='MOD Contact Person', tracking=True, help='Legacy field - kept for backwards compatibility', ) x_fc_mod_contact_email = fields.Char( string='Case Worker Email', tracking=True, help='Case worker email - assigned after funding approval. ' 'Completion photos and POD are sent to this email.', ) x_fc_mod_contact_phone = fields.Char( string='Case Worker Phone', tracking=True, ) # --- Case identifiers --- x_fc_case_reference = fields.Char( string='HVMP Reference Number', tracking=True, help='March of Dimes HVMP Reference Number (e.g. HVW38845)', ) x_fc_mod_vendor_code = fields.Char( string='MOD Vendor Code', tracking=True, help='Vendor code assigned by March of Dimes (e.g. TRD0001662)', ) # --- Key dates --- x_fc_case_submitted = fields.Date( string='Quote Submitted Date', tracking=True, help='Legacy field - kept for backwards compatibility', ) x_fc_case_approved = fields.Date( string='Funding Approved Date', tracking=True, help='Date funding was approved by March of Dimes', ) x_fc_estimated_completion_date = fields.Date( string='Estimated Completion Date', tracking=True, help='Estimated project completion date. Auto-calculated from weeks if funding is approved.', ) x_fc_mod_estimated_weeks = fields.Integer( string='Est. Completion (Weeks)', tracking=True, help='Estimated completion time in weeks from funding approval date.', ) @api.onchange('x_fc_mod_estimated_weeks') def _onchange_mod_estimated_weeks(self): """When weeks change, compute the completion date from approval date.""" if self.x_fc_mod_estimated_weeks and self.x_fc_mod_estimated_weeks > 0: from datetime import timedelta base = self.x_fc_case_approved or fields.Date.today() self.x_fc_estimated_completion_date = base + timedelta(weeks=self.x_fc_mod_estimated_weeks) @api.onchange('x_fc_estimated_completion_date') def _onchange_mod_estimated_completion_date(self): """When date changes, compute weeks from approval date.""" if self.x_fc_estimated_completion_date: base = self.x_fc_case_approved or fields.Date.today() delta = self.x_fc_estimated_completion_date - base weeks = max(1, delta.days // 7) self.x_fc_mod_estimated_weeks = weeks # --- MOD Documents --- x_fc_mod_drawing = fields.Binary( string='Drawing', attachment=True, help='Technical drawing for the accessibility modification', ) x_fc_mod_drawing_filename = fields.Char(string='Drawing Filename') x_fc_mod_initial_photos = fields.Binary( string='Initial Photos', attachment=True, help='Photos taken during the initial assessment', ) x_fc_mod_initial_photos_filename = fields.Char(string='Initial Photos Filename') x_fc_mod_pca_document = fields.Binary( string='PCA Document', attachment=True, help='Payment Commitment Agreement from March of Dimes', ) x_fc_mod_pca_filename = fields.Char(string='PCA Filename') x_fc_mod_proof_of_delivery = fields.Binary( string='Proof of Delivery', attachment=True, help='Signed proof of delivery and installation document', ) x_fc_mod_pod_filename = fields.Char(string='POD Filename') x_fc_mod_initial_payment_amount = fields.Monetary( string='Initial Payment Amount', currency_field='currency_id', help='Amount received as initial payment from March of Dimes', ) x_fc_mod_initial_payment_date = fields.Date( string='Initial Payment Date', help='Date the initial payment was received', ) x_fc_mod_final_payment_amount = fields.Monetary( string='Final Payment Amount', currency_field='currency_id', help='Final payment amount received from March of Dimes', ) x_fc_mod_final_payment_date = fields.Date( string='Final Payment Date', help='Date the final payment was received', ) x_fc_mod_completion_photos = fields.Binary( string='Completion Photos', attachment=True, help='Photos of the completed installation', ) x_fc_mod_completion_photos_filename = fields.Char(string='Completion Photos Filename') # Trail computed fields for MOD documents x_fc_mod_trail_has_drawing = fields.Boolean(compute='_compute_mod_trail', string='Has Drawing') x_fc_mod_trail_has_initial_photos = fields.Boolean(compute='_compute_mod_trail', string='Has Initial Photos') x_fc_mod_trail_has_pca = fields.Boolean(compute='_compute_mod_trail', string='Has PCA') x_fc_mod_trail_has_pod = fields.Boolean(compute='_compute_mod_trail', string='Has POD') x_fc_mod_trail_has_completion_photos = fields.Boolean(compute='_compute_mod_trail', string='Has Completion Photos') @api.depends('x_fc_mod_drawing', 'x_fc_mod_initial_photos', 'x_fc_mod_pca_document', 'x_fc_mod_proof_of_delivery', 'x_fc_mod_completion_photos') def _compute_mod_trail(self): for order in self: order.x_fc_mod_trail_has_drawing = bool(order.x_fc_mod_drawing) order.x_fc_mod_trail_has_initial_photos = bool(order.x_fc_mod_initial_photos) order.x_fc_mod_trail_has_pca = bool(order.x_fc_mod_pca_document) order.x_fc_mod_trail_has_pod = bool(order.x_fc_mod_proof_of_delivery) order.x_fc_mod_trail_has_completion_photos = bool(order.x_fc_mod_completion_photos) # --- PCA terms --- x_fc_mod_project_completion_date = fields.Date( string='PCA Completion Deadline', tracking=True, help='Project Completion Date as stated in the PCA', ) x_fc_mod_payment_commitment = fields.Monetary( string='Payment Commitment', tracking=True, currency_field='currency_id', help='Legacy field - kept for backwards compatibility', ) # --- MOD Funding --- x_fc_mod_approved_amount = fields.Monetary( string='MOD Approved Amount', currency_field='currency_id', tracking=True, help='Amount approved by March of Dimes', ) x_fc_mod_approval_type = fields.Selection( selection=[('full', 'Full Approval'), ('partial', 'Partial Approval')], string='Approval Type', tracking=True, ) # --- Product type and production stage --- x_fc_mod_product_type = fields.Selection( selection=[ ('stairlift', 'Stairlift'), ('vpl', 'Vertical Platform Lift / Porch Lift'), ('ceiling_lift', 'Ceiling Lift'), ('ramp', 'Custom Ramp'), ('bathroom', 'Bathroom Modification'), ('other', 'Other'), ], string='Product Type', tracking=True, help='Type of accessibility product/modification for this project', ) x_fc_mod_production_status = fields.Selection( selection=[ # --- Stairlift stages --- ('sl_photo_survey_booked', 'Photo Survey Booked'), ('sl_photo_survey_done', 'Photo Survey Completed'), ('sl_sent_to_engineering', 'Sent to Engineering'), ('sl_engineering_received', 'Engineering Drawing Received'), ('sl_drawing_signing', 'Drawing Signing & Acceptance'), ('sl_in_production', 'Stairlift in Production'), ('sl_payment_processing', 'Payment Processing for Manufacturer'), ('sl_shipping', 'Stairlift Shipping'), ('sl_received', 'Stairlift Received'), ('sl_install_scheduled', 'Installation Scheduled'), ('sl_install_complete', 'Installation Complete'), # --- VPL / Porch Lift stages --- ('vpl_survey_complete', 'Final Survey & Marking Complete'), ('vpl_lift_ordered', 'Lift Ordered'), ('vpl_concrete_poured', 'Concrete Base Poured'), ('vpl_concrete_curing', 'Concrete Curing'), ('vpl_install_complete', 'Lift & Safety Gate Installed'), # --- Ceiling Lift stages --- ('cl_marking_done', 'Lift Marking Completed'), ('cl_anchors_installed', 'Anchors Installed'), ('cl_curing', 'Epoxy Curing (24 hrs)'), ('cl_track_installed', 'Track & Lift Installed'), ('cl_safety_tested', 'Safety & Deflection Tests Passed'), ('cl_ready_for_use', 'Ready for Use'), # --- Ramp stages --- ('rp_permit_check', 'Checking Municipality Permit'), ('rp_permit_obtained', 'Permit Obtained'), ('rp_ordered', 'Ramp Ordered'), ('rp_received', 'Ramp Received in Warehouse'), ('rp_install_scheduled', 'Installation Scheduled'), ('rp_install_complete', 'Installation Complete'), # --- Bathroom Modification stages --- ('br_demolition', 'Demolition of Existing Bathroom'), ('br_design_changes', 'Final Design Changes Discussed'), ('br_construction', 'Construction in Progress'), ('br_construction_done', 'Construction Finished'), ('br_safety_check', 'Safety Check Complete'), ('br_ready_for_use', 'Ready for Use'), # --- Common --- ('completed', 'Stage Completed'), ('on_hold', 'On Hold'), ], string='Production Stage', tracking=True, help='Detailed production/installation stage for the product', ) # --- Follow-up tracking --- x_fc_mod_last_followup_date = fields.Date( string='Last Follow-up Date', help='Date of the last follow-up call or email', ) x_fc_mod_next_followup_date = fields.Date( string='Next Follow-up Date', help='Scheduled date for the next follow-up', ) x_fc_mod_followup_count = fields.Integer( string='Follow-up Count', default=0, help='Number of follow-up attempts made', ) x_fc_mod_followup_escalated = fields.Boolean( string='Follow-up Escalated', default=False, help='True if an automatic follow-up email was sent because activity was not completed', ) # --- MOD Audit Trail dates --- x_fc_mod_assessment_scheduled_date = fields.Date(string='Assessment Scheduled', tracking=True) x_fc_mod_assessment_completed_date = fields.Date(string='Assessment Completed', tracking=True) x_fc_mod_drawing_submitted_date = fields.Date(string='Drawing Submitted', tracking=True) x_fc_mod_application_submitted_date = fields.Date( string='Application Submitted to MOD', tracking=True, help='Date the application/proposal was submitted to March of Dimes for funding review', ) x_fc_mod_pca_received_date = fields.Date(string='PCA Received', tracking=True) x_fc_mod_production_started_date = fields.Date(string='Production Started', tracking=True) x_fc_mod_project_completed_date = fields.Date(string='Project Completed', tracking=True) x_fc_mod_pod_submitted_date = fields.Date(string='POD Submitted', tracking=True) x_fc_mod_case_closed_date = fields.Date(string='Case Closed', tracking=True) # Trail computed booleans x_fc_mod_trail_assessment_done = fields.Boolean(compute='_compute_mod_audit_trail') x_fc_mod_trail_drawing_done = fields.Boolean(compute='_compute_mod_audit_trail') x_fc_mod_trail_app_submitted = fields.Boolean(compute='_compute_mod_audit_trail') x_fc_mod_trail_funding_approved = fields.Boolean(compute='_compute_mod_audit_trail') x_fc_mod_trail_pca_received = fields.Boolean(compute='_compute_mod_audit_trail') x_fc_mod_trail_production_started = fields.Boolean(compute='_compute_mod_audit_trail') x_fc_mod_trail_project_completed = fields.Boolean(compute='_compute_mod_audit_trail') x_fc_mod_trail_pod_sent = fields.Boolean(compute='_compute_mod_audit_trail') x_fc_mod_trail_case_closed = fields.Boolean(compute='_compute_mod_audit_trail') @api.depends('x_fc_mod_assessment_completed_date', 'x_fc_mod_drawing_submitted_date', 'x_fc_mod_application_submitted_date', 'x_fc_case_approved', 'x_fc_mod_pca_received_date', 'x_fc_mod_production_started_date', 'x_fc_mod_project_completed_date', 'x_fc_mod_pod_submitted_date', 'x_fc_mod_case_closed_date') def _compute_mod_audit_trail(self): for order in self: order.x_fc_mod_trail_assessment_done = bool(order.x_fc_mod_assessment_completed_date) order.x_fc_mod_trail_drawing_done = bool(order.x_fc_mod_drawing_submitted_date) order.x_fc_mod_trail_app_submitted = bool(order.x_fc_mod_application_submitted_date) order.x_fc_mod_trail_funding_approved = bool(order.x_fc_case_approved) order.x_fc_mod_trail_pca_received = bool(order.x_fc_mod_pca_received_date) order.x_fc_mod_trail_production_started = bool(order.x_fc_mod_production_started_date) order.x_fc_mod_trail_project_completed = bool(order.x_fc_mod_project_completed_date) order.x_fc_mod_trail_pod_sent = bool(order.x_fc_mod_pod_submitted_date) order.x_fc_mod_trail_case_closed = bool(order.x_fc_mod_case_closed_date) # --- Computed helpers --- x_fc_show_mod_fields = fields.Boolean( compute='_compute_show_mod_fields', string='Show MOD Fields', ) x_fc_is_mod_sale = fields.Boolean( compute='_compute_is_mod_sale', string='Is MOD Sale', ) @api.depends('x_fc_sale_type') def _compute_show_mod_fields(self): """Compute whether to show March of Dimes case fields.""" for order in self: order.x_fc_show_mod_fields = order.x_fc_sale_type == 'march_of_dimes' @api.depends('x_fc_sale_type') def _compute_is_mod_sale(self): """Compute if this is a March of Dimes sale.""" for order in self: order.x_fc_is_mod_sale = order.x_fc_sale_type == 'march_of_dimes' def _is_mod_sale(self): """Helper: check if this order is a March of Dimes sale.""" self.ensure_one() return self.x_fc_sale_type == 'march_of_dimes' # ========================================================================== # ODSP (Ontario Disability Support Program) FIELDS # ========================================================================== x_fc_odsp_division = fields.Selection( selection=[ ('standard', 'ODSP Standard'), ('sa_mobility', 'SA Mobility'), ('ontario_works', 'Ontario Works'), ], string='ODSP Division', tracking=True, help='ODSP sub-division handling this case', ) x_fc_is_odsp_sale = fields.Boolean( compute='_compute_is_odsp_sale', store=True, string='Is ODSP Sale', help='True when sale type is ODSP or ADP-ODSP', ) x_fc_odsp_member_id = fields.Char( related='partner_id.x_fc_odsp_member_id', string='ODSP Member ID', readonly=False, store=True, help='ODSP Member ID from contact (editable per order)', ) x_fc_odsp_office_id = fields.Many2one( 'res.partner', string='ODSP Office', tracking=True, domain="[('x_fc_contact_type', '=', 'odsp_office')]", help='ODSP office handling this case', ) x_fc_odsp_case_worker_name = fields.Char( string='ODSP Case Worker', tracking=True, help='Case worker name for this order', ) # --- SA Mobility status --- x_fc_sa_status = fields.Selection( selection=[ ('quotation', 'Quotation'), ('form_ready', 'SA Form Ready'), ('submitted_to_sa', 'Submitted to SA Mobility'), ('pre_approved', 'Pre-Approved'), ('ready_delivery', 'Ready for Delivery'), ('delivered', 'Delivered'), ('pod_submitted', 'POD Submitted'), ('payment_received', 'Payment Received'), ('case_closed', 'Case Closed'), ('on_hold', 'On Hold'), ('cancelled', 'Cancelled'), ('denied', 'Denied'), ], string='SA Mobility Status', default='quotation', tracking=True, group_expand='_expand_sa_statuses', ) @api.model def _expand_sa_statuses(self, states, domain): main = [ 'quotation', 'form_ready', 'submitted_to_sa', 'pre_approved', 'ready_delivery', 'delivered', 'pod_submitted', 'payment_received', 'case_closed', ] result = list(main) for s in (states or []): if s and s not in result: result.append(s) return result # --- Standard ODSP status --- x_fc_odsp_std_status = fields.Selection( selection=[ ('quotation', 'Quotation'), ('submitted_to_odsp', 'Submitted to ODSP'), ('pre_approved', 'Pre-Approved'), ('ready_delivery', 'Ready for Delivery'), ('delivered', 'Delivered'), ('pod_submitted', 'POD Submitted'), ('payment_received', 'Payment Received'), ('case_closed', 'Case Closed'), ('on_hold', 'On Hold'), ('cancelled', 'Cancelled'), ('denied', 'Denied'), ], string='ODSP Status', default='quotation', tracking=True, group_expand='_expand_odsp_std_statuses', ) @api.model def _expand_odsp_std_statuses(self, states, domain): main = [ 'quotation', 'submitted_to_odsp', 'pre_approved', 'ready_delivery', 'delivered', 'pod_submitted', 'payment_received', 'case_closed', ] result = list(main) for s in (states or []): if s and s not in result: result.append(s) return result # --- Ontario Works status --- x_fc_ow_status = fields.Selection( selection=[ ('quotation', 'Quotation'), ('documents_ready', 'Documents Ready'), ('submitted_to_ow', 'Submitted to Ontario Works'), ('payment_received', 'Payment Received'), ('ready_delivery', 'Ready for Delivery'), ('delivered', 'Delivered'), ('case_closed', 'Case Closed'), ('on_hold', 'On Hold'), ('cancelled', 'Cancelled'), ('denied', 'Denied'), ], string='Ontario Works Status', default='quotation', tracking=True, group_expand='_expand_ow_statuses', ) @api.model def _expand_ow_statuses(self, states, domain): main = [ 'quotation', 'documents_ready', 'submitted_to_ow', 'payment_received', 'ready_delivery', 'delivered', 'case_closed', ] result = list(main) for s in (states or []): if s and s not in result: result.append(s) return result # --- Division-to-status field mapping --- _ODSP_STATUS_FIELD_MAP = { 'sa_mobility': 'x_fc_sa_status', 'standard': 'x_fc_odsp_std_status', 'ontario_works': 'x_fc_ow_status', } def _get_odsp_status_field(self): """Return the status field name for this order's division.""" self.ensure_one() return self._ODSP_STATUS_FIELD_MAP.get( self.x_fc_odsp_division, 'x_fc_odsp_std_status') def _get_odsp_status(self): """Return the current division-specific status value.""" self.ensure_one() return getattr(self, self._get_odsp_status_field(), '') or '' # --- ODSP computed helpers --- x_fc_show_odsp_fields = fields.Boolean( compute='_compute_show_odsp_fields', string='Show ODSP Fields', ) @api.depends('x_fc_sale_type') def _compute_is_odsp_sale(self): """Compute if this is an ODSP sale.""" for order in self: order.x_fc_is_odsp_sale = order.x_fc_sale_type in ('odsp', 'adp_odsp') @api.depends('x_fc_sale_type') def _compute_show_odsp_fields(self): """Compute whether to show ODSP case fields.""" for order in self: order.x_fc_show_odsp_fields = order.x_fc_sale_type in ('odsp', 'adp_odsp') def _is_odsp_sale(self): """Helper: check if this order is an ODSP sale.""" self.ensure_one() return self.x_fc_sale_type in ('odsp', 'adp_odsp') @api.onchange('partner_id') def _onchange_partner_odsp_case_worker(self): """Auto-populate ODSP case worker from partner when partner changes.""" if self.partner_id and self.partner_id.x_fc_case_worker_id: self.x_fc_odsp_case_worker_name = self.partner_id.x_fc_case_worker_id.name # --- SA Mobility form data (persisted for wizard reuse) --- x_fc_sa_relationship = fields.Selection([ ('self', 'Self'), ('spouse', 'Spouse'), ('dependent', 'Dependent'), ], string='SA Relationship', default='self') x_fc_sa_device_type = fields.Selection([ ('manual_wheelchair', 'Manual Wheelchair'), ('high_tech_wheelchair', 'High Technology Wheelchair'), ('mobility_scooter', 'Mobility Scooter'), ('walker', 'Walker'), ('lifting_device', 'Lifting Device'), ('other', 'Other'), ], string='SA Device Type') x_fc_sa_device_other = fields.Char(string='SA Device Other Description') x_fc_sa_serial_number = fields.Char(string='SA Serial Number') x_fc_sa_year = fields.Char(string='SA Year') x_fc_sa_make = fields.Char(string='SA Make') x_fc_sa_model = fields.Char(string='SA Model') x_fc_sa_warranty = fields.Boolean(string='SA Warranty in Effect') x_fc_sa_warranty_desc = fields.Char(string='SA Warranty Description') x_fc_sa_after_hours = fields.Boolean(string='SA After-hours Work') x_fc_sa_request_type = fields.Selection([ ('batteries', 'Batteries'), ('repair', 'Repair / Maintenance'), ], string='SA Request Type', default='repair') x_fc_sa_notes = fields.Text(string='SA Notes / Comments') # --- SA Mobility signature fields --- x_fc_sa_client_name = fields.Char( string='SA Client Name (Printed)', help='Client printed name on SA Mobility form Page 2', ) x_fc_sa_client_signature = fields.Binary( string='SA Client Signature', help='Client signature image on SA Mobility form Page 2', ) x_fc_sa_client_signed_date = fields.Date( string='SA Signed Date', ) x_fc_sa_signed_form = fields.Binary( string='SA Signed Form', help='Final signed SA Mobility PDF', ) x_fc_sa_signed_form_filename = fields.Char( string='SA Signed Form Filename', ) x_fc_sa_physical_signed_copy = fields.Binary( string='Physical Signed Copy', attachment=True, help='Upload a scanned/photographed copy of the physically signed SA Mobility form. ' 'Use this when the client signs a paper copy instead of the digital e-signature.', ) x_fc_sa_physical_signed_copy_filename = fields.Char( string='Physical Copy Filename', ) # --- SA Mobility approval form fields --- x_fc_sa_approval_form = fields.Binary( string='SA Approval Form', help='ODSP approval PDF uploaded during pre-approval', ) x_fc_sa_approval_form_filename = fields.Char( string='SA Approval Form Filename', ) x_fc_sa_signature_page = fields.Integer( string='Signature Page', default=2, help='Page number in approval form where signature should be placed (1-indexed)', ) # --- Ontario Works document fields --- x_fc_ow_discretionary_form = fields.Binary( string='Discretionary Benefits Form', attachment=True, help='Auto-populated when the Discretionary Benefits form is generated via wizard.', ) x_fc_ow_discretionary_form_filename = fields.Char( string='Discretionary Form Filename', ) x_fc_ow_authorizer_letter = fields.Binary( string='Authorizer Letter', attachment=True, help='Optional authorizer letter for this Ontario Works case.', ) x_fc_ow_authorizer_letter_filename = fields.Char( string='Authorizer Letter Filename', ) # --- Standard ODSP document fields --- x_fc_odsp_approval_document = fields.Binary( string='ODSP Approval Document', attachment=True, help='Upload the approval document received from ODSP.', ) x_fc_odsp_approval_document_filename = fields.Char( string='Approval Document Filename', ) x_fc_odsp_authorizer_letter = fields.Binary( string='Authorizer Letter', attachment=True, help='Optional authorizer letter for this ODSP case.', ) x_fc_odsp_authorizer_letter_filename = fields.Char( string='Authorizer Letter Filename', ) def action_open_sa_mobility_wizard(self): """Open the SA Mobility form filling wizard.""" self.ensure_one() return { 'name': 'SA Mobility Form', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.sa.mobility.wizard', 'view_mode': 'form', 'target': 'new', 'context': {'active_id': self.id}, } def action_open_discretionary_wizard(self): """Open the Discretionary Benefits form filling wizard.""" self.ensure_one() return { 'name': 'Discretionary Benefits Form', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.discretionary.benefit.wizard', 'view_mode': 'form', 'target': 'new', 'context': {'active_id': self.id}, } def action_open_submit_to_odsp_wizard(self): """Open the Submit to ODSP wizard (quotation + authorizer letter).""" self.ensure_one() return { 'name': 'Submit to ODSP', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.submit.to.odsp.wizard', 'view_mode': 'form', 'target': 'new', 'context': {'active_id': self.id}, } # --- ODSP workflow step actions --- def _odsp_advance_status(self, new_status, log_message): """Advance the division-specific ODSP status and log to chatter.""" self.ensure_one() field = self._get_odsp_status_field() setattr(self, field, new_status) self.message_post(body=log_message, message_type='comment') def action_odsp_submitted(self): self.ensure_one() self._odsp_advance_status('submitted_to_odsp', "Application submitted to ODSP.") def action_odsp_submitted_ow(self): self.ensure_one() self._odsp_advance_status('submitted_to_ow', "Application submitted to Ontario Works.") def action_odsp_pre_approved(self): self.ensure_one() if self.x_fc_odsp_division in ('sa_mobility', 'standard'): return { 'type': 'ir.actions.act_window', 'name': 'Upload ODSP Approval Document', 'res_model': 'fusion_claims.odsp.pre.approved.wizard', 'view_mode': 'form', 'target': 'new', 'context': {'active_id': self.id}, } self._odsp_advance_status('pre_approved', "ODSP pre-approval received.") def action_odsp_ready_delivery(self): self.ensure_one() if self.x_fc_odsp_division == 'sa_mobility' and self.x_fc_sa_approval_form: return { 'type': 'ir.actions.act_window', 'name': 'Ready for Delivery - Signature Setup', 'res_model': 'fusion_claims.odsp.ready.delivery.wizard', 'view_mode': 'form', 'target': 'new', 'context': {'active_id': self.id}, } if self.x_fc_odsp_division in ('ontario_works', 'standard'): return { 'name': 'Schedule Delivery Task', 'type': 'ir.actions.act_window', 'res_model': 'fusion.technician.task', 'view_mode': 'form', 'target': 'new', 'context': { 'default_task_type': 'delivery', 'default_sale_order_id': self.id, 'default_partner_id': self.partner_id.id, 'default_pod_required': True, 'mark_odsp_ready_for_delivery': True, }, } self._odsp_advance_status('ready_delivery', "Order is ready for delivery.") def action_odsp_delivered(self): self.ensure_one() if self.x_fc_odsp_division == 'sa_mobility': has_signed_form = self.x_fc_sa_signed_form or self.x_fc_sa_physical_signed_copy if has_signed_form: self._odsp_advance_status('delivered', "Delivery completed. SA form is signed.") else: self._odsp_advance_status('delivered', "Delivery completed.") else: self._odsp_advance_status('delivered', "Delivery completed.") def action_odsp_pod_submitted(self): self.ensure_one() if self.x_fc_odsp_division == 'sa_mobility': self._sa_mobility_submit_documents() elif self.x_fc_odsp_division == 'standard': self._odsp_std_submit_documents() self._odsp_advance_status('pod_submitted', "Proof of Delivery submitted to ODSP.") def _sa_mobility_submit_documents(self): """Collect signed SA form, internal POD, and invoice, then email to SA Mobility.""" self.ensure_one() import base64 Attachment = self.env['ir.attachment'].sudo() att_ids = [] att_names = [] # 1. Signed SA Form -- reuse existing attachment created by attachment=True signed_field = 'x_fc_sa_signed_form' if self.x_fc_sa_signed_form else ( 'x_fc_sa_physical_signed_copy' if self.x_fc_sa_physical_signed_copy else None) if signed_field: att = Attachment.search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('res_field', '=', signed_field), ], order='id desc', limit=1) if att: att_ids.append(att.id) att_names.append('Signed SA Form') # 2. Internal POD -- generate on-the-fly from the standard report try: pod_pdf, pod_fname = self._get_sa_pod_pdf() att = Attachment.create({ 'name': pod_fname, 'type': 'binary', 'datas': base64.b64encode(pod_pdf), 'res_model': 'sale.order', 'res_id': self.id, }) att_ids.append(att.id) att_names.append('Proof of Delivery') except Exception as e: _logger.warning("Could not generate POD PDF for %s: %s", self.name, e) # 3. Invoice PDF -- generate from the latest posted invoice invoices = self.invoice_ids.filtered(lambda inv: inv.state == 'posted') if invoices: invoice = invoices[0] try: report = self.env.ref('account.account_invoices') pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id]) att = Attachment.create({ 'name': f'Invoice_{invoice.name}.pdf', 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'sale.order', 'res_id': self.id, }) att_ids.append(att.id) att_names.append(f'Invoice ({invoice.name})') except Exception as e: _logger.warning("Could not generate invoice PDF for %s: %s", self.name, e) self._send_sa_mobility_completion_email(attachment_ids=att_ids) if att_names: self.message_post( body=Markup( '
' 'Documents submitted to SA Mobility' '
' ), message_type='notification', subtype_xmlid='mail.mt_note', ) def _odsp_std_submit_documents(self): """Standard ODSP: collect approval doc, POD, and invoice, then email to ODSP office.""" self.ensure_one() import base64 Attachment = self.env['ir.attachment'].sudo() att_ids = [] att_names = [] # 1. Approval document if self.x_fc_odsp_approval_document: att = Attachment.search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('res_field', '=', 'x_fc_odsp_approval_document'), ], order='id desc', limit=1) if att: att_ids.append(att.id) att_names.append('ODSP Approval Document') # 2. Internal POD try: pod_pdf, pod_fname = self._get_sa_pod_pdf() att = Attachment.create({ 'name': pod_fname, 'type': 'binary', 'datas': base64.b64encode(pod_pdf), 'res_model': 'sale.order', 'res_id': self.id, }) att_ids.append(att.id) att_names.append('Proof of Delivery') except Exception as e: _logger.warning("Could not generate POD PDF for %s: %s", self.name, e) # 3. Invoice PDF invoices = self.invoice_ids.filtered(lambda inv: inv.state == 'posted') if not invoices: if self.state != 'sale': self.action_confirm() invoices = self._create_invoices() invoices.write({'x_fc_source_sale_order_id': self.id}) if invoices: invoice = invoices[0] try: report = self.env.ref('account.account_invoices') pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id]) att = Attachment.create({ 'name': f'Invoice_{invoice.name}.pdf', 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'sale.order', 'res_id': self.id, }) att_ids.append(att.id) att_names.append(f'Invoice ({invoice.name})') except Exception as e: _logger.warning("Could not generate invoice PDF for %s: %s", self.name, e) self._send_odsp_submission_email(attachment_ids=att_ids) if att_names: self.message_post( body=Markup( '
' 'Documents submitted to ODSP' '
' ), message_type='notification', subtype_xmlid='mail.mt_note', ) def action_odsp_payment_received(self): self.ensure_one() if self.x_fc_odsp_division == 'ontario_works': return self._ow_payment_create_invoice() self._odsp_advance_status('payment_received', "Payment received from ODSP.") def _ow_payment_create_invoice(self): """Ontario Works: create invoice from SO, advance status, open invoice.""" self.ensure_one() if self.state != 'sale': self.action_confirm() invoice = self._create_invoices() invoice.write({'x_fc_source_sale_order_id': self.id}) self._odsp_advance_status('payment_received', "Ontario Works payment confirmed. Invoice %s created." % invoice.name) return { 'type': 'ir.actions.act_window', 'name': 'Invoice', 'res_model': 'account.move', 'view_mode': 'form', 'res_id': invoice.id, 'target': 'current', } def action_odsp_close_case(self): self.ensure_one() self._odsp_advance_status('case_closed', "ODSP case closed.") def action_odsp_on_hold(self): self.ensure_one() self._odsp_advance_status('on_hold', "ODSP case placed on hold.") def action_odsp_resume(self): """Resume from on_hold -- go back to the previous logical status.""" self.ensure_one() self._odsp_advance_status('quotation', "ODSP case resumed.") def action_odsp_denied(self): self.ensure_one() self._odsp_advance_status('denied', "ODSP application denied.") def action_sign_sa_mobility_form(self): """Overlay client signature onto Page 2 of the approved SA Mobility form. Uses the PDFTemplateFiller overlay approach: - Reads the last attached SA Mobility form - Overlays client printed name, signature image, and date on Page 2 - Stores result in x_fc_sa_signed_form """ self.ensure_one() if not self.x_fc_sa_client_signature: from odoo.exceptions import UserError raise UserError("Client signature is required to sign the SA Mobility form.") # Find the most recent SA Mobility form attachment attachment = self.env['ir.attachment'].search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('name', 'like', 'SA_Mobility_Form_'), ], order='create_date desc', limit=1) if not attachment: from odoo.exceptions import UserError raise UserError("No SA Mobility form found. Please fill the form first.") import base64 from io import BytesIO try: from reportlab.pdfgen import canvas as rl_canvas from reportlab.lib.utils import ImageReader from odoo.tools.pdf import PdfFileReader, PdfFileWriter except ImportError: from odoo.exceptions import UserError raise UserError("Required PDF libraries not available.") # Read the existing filled form pdf_bytes = base64.b64decode(attachment.datas) original = PdfFileReader(BytesIO(pdf_bytes)) output = PdfFileWriter() num_pages = original.getNumPages() for page_idx in range(num_pages): page = original.getPage(page_idx) if page_idx == 1: # Page 2 (0-based index) page_w = float(page.mediaBox.getWidth()) page_h = float(page.mediaBox.getHeight()) overlay_buf = BytesIO() c = rl_canvas.Canvas(overlay_buf, pagesize=(page_w, page_h)) # Text103 area - client printed name (upper confirmation line) if self.x_fc_sa_client_name: c.setFont('Helvetica', 11) c.drawString(180, page_h - 180, self.x_fc_sa_client_name) # Text104 area - printed name on lower line if self.x_fc_sa_client_name: c.setFont('Helvetica', 11) c.drawString(72, page_h - 560, self.x_fc_sa_client_name) # Text105 area - date if self.x_fc_sa_client_signed_date: from odoo import fields as odoo_fields date_str = odoo_fields.Date.to_string(self.x_fc_sa_client_signed_date) c.setFont('Helvetica', 11) c.drawString(350, page_h - 560, date_str) # Signature image overlay on the signature line if self.x_fc_sa_client_signature: sig_data = base64.b64decode(self.x_fc_sa_client_signature) sig_image = ImageReader(BytesIO(sig_data)) c.drawImage(sig_image, 72, page_h - 540, width=200, height=50, preserveAspectRatio=True, mask='auto') c.save() overlay_buf.seek(0) overlay_pdf = PdfFileReader(overlay_buf) page.mergePage(overlay_pdf.getPage(0)) output.addPage(page) result_buf = BytesIO() output.write(result_buf) signed_pdf = result_buf.getvalue() filename = f'SA_Mobility_Signed_{self.name}.pdf' self.write({ 'x_fc_sa_signed_form': base64.b64encode(signed_pdf), 'x_fc_sa_signed_form_filename': filename, }) self.message_post( body="SA Mobility form signed by client: %s" % self.x_fc_sa_client_name, message_type='comment', ) return {'type': 'ir.actions.act_window_close'} def _apply_pod_signature_to_approval_form(self): """Auto-overlay POD signature onto the ODSP approval form. Uses the ODSP PDF Template (fusion.pdf.template, category=odsp) for field positions, and the per-case signature page number. """ self.ensure_one() if not all([ self.x_fc_odsp_division == 'sa_mobility', self.x_fc_sa_approval_form, self.x_fc_sa_signature_page, self.x_fc_pod_signature, ]): return import base64 from odoo.addons.fusion_authorizer_portal.utils.pdf_filler import PDFTemplateFiller tpl = self.env['fusion.pdf.template'].search([ ('category', '=', 'odsp'), ('state', '=', 'active'), ], limit=1) if not tpl: _logger.warning("No active ODSP PDF template found for signing %s", self.name) return sig_page = self.x_fc_sa_signature_page or 2 fields_by_page = {} for field in tpl.field_ids.filtered(lambda f: f.is_active): page = sig_page if page not in fields_by_page: fields_by_page[page] = [] fields_by_page[page].append({ 'field_name': field.name, 'field_key': field.field_key or field.name, 'pos_x': field.pos_x, 'pos_y': field.pos_y, 'width': field.width, 'height': field.height, 'field_type': field.field_type, 'font_size': field.font_size, 'font_name': field.font_name or 'Helvetica', 'text_align': field.text_align or 'left', }) client_name = self.x_fc_pod_client_name or self.x_fc_sa_client_name or self.partner_id.name or '' sign_date = self.x_fc_pod_signature_date or self.x_fc_sa_client_signed_date context_data = { 'sa_client_name': client_name, 'sa_sign_date': sign_date.strftime('%b %d, %Y') if sign_date else '', } signatures = { 'sa_signature': base64.b64decode(self.x_fc_pod_signature), } pdf_bytes = base64.b64decode(self.x_fc_sa_approval_form) try: signed_pdf = PDFTemplateFiller.fill_template( pdf_bytes, fields_by_page, context_data, signatures, ) except Exception as e: _logger.error("Failed to apply signature to approval form for %s: %s", self.name, e) return filename = f'SA_Approval_Signed_{self.name}.pdf' self.with_context(skip_pod_signature_hook=True).write({ 'x_fc_sa_signed_form': base64.b64encode(signed_pdf), 'x_fc_sa_signed_form_filename': filename, }) att = self.env['ir.attachment'].create({ 'name': filename, 'type': 'binary', 'datas': base64.b64encode(signed_pdf), 'res_model': 'sale.order', 'res_id': self.id, 'mimetype': 'application/pdf', }) self.message_post( body="POD signature applied to ODSP approval form (page %s)." % sig_page, message_type='comment', attachment_ids=[att.id], ) _logger.info("POD signature applied to approval form for %s", self.name) # ========================================================================== # DELIVERY STATUS FIELDS # ========================================================================== x_fc_delivery_status = fields.Selection( selection=[ ('waiting', 'Waiting'), ('waiting_approval', 'Waiting for Approval'), ('ready', 'Ready for Delivery'), ('scheduled', 'Delivery Scheduled'), ('shipped_warehouse', 'Shipped to Warehouse'), ('received_warehouse', 'Received in Warehouse'), ('delivered', 'Delivered'), ('hold', 'Hold'), ('cancelled', 'Cancelled'), ], string='Delivery Status', tracking=True, help='Current delivery status of the order', ) x_fc_delivery_datetime = fields.Datetime( string='Delivery Date & Time', tracking=True, help='Scheduled or actual delivery date and time', ) # Computed field to show/hide delivery datetime x_fc_show_delivery_datetime = fields.Boolean( compute='_compute_show_delivery_datetime', string='Show Delivery DateTime', ) @api.depends('x_fc_delivery_status') def _compute_show_delivery_datetime(self): """Compute whether to show delivery datetime field.""" for order in self: order.x_fc_show_delivery_datetime = order.x_fc_delivery_status in ('scheduled', 'delivered') # ========================================================================== # ADP CLAIM FIELDS # ========================================================================== x_fc_claim_number = fields.Char( string='Claim Number', tracking=True, copy=False, help='ADP Claim Number assigned after submission', ) x_fc_client_ref_1 = fields.Char( string='Client Reference 1', help='Primary client reference (e.g., Health Card Number)', ) x_fc_client_ref_2 = fields.Char( string='Client Reference 2', help='Secondary client reference', ) x_fc_adp_delivery_date = fields.Date( string='ADP Delivery Date', help='Date the product was delivered to the client (for ADP billing)', ) x_fc_service_start_date = fields.Date( string='Service Start Date', help='Service period start date (optional, for rentals/services)', ) x_fc_service_end_date = fields.Date( string='Service End Date', help='Service period end date (optional, for rentals/services)', ) x_fc_authorizer_id = fields.Many2one( 'res.partner', string='Authorizer', help='Authorizer contact for this order', domain="[('is_company', '=', False)]", ) x_fc_primary_serial = fields.Char( string='Primary Serial Number', help='Primary serial number for the order (header level). ' 'Line-level serials are tracked on individual order lines.', copy=False, ) # ========================================================================== # ADP WORKFLOW STATUS (Legacy - keeping for backward compatibility) # ========================================================================== x_fc_adp_status = fields.Selection( selection=[ ('quote', 'Quote'), ('submitted', 'Submitted to ADP'), ('approved', 'ADP Approved'), ('client_paid', 'Client Paid (25%)'), ('delivered', 'Delivered'), ('billed', 'Billed to ADP (75%)'), ('closed', 'Closed'), ], string='ADP Status (Legacy)', default='quote', tracking=True, help='Legacy status field - use x_fc_adp_application_status instead', ) # ========================================================================== # ADP APPLICATION STATUS (New comprehensive status field) # ========================================================================== x_fc_adp_application_status = fields.Selection( selection=[ ('quotation', 'Quotation Stage'), ('assessment_scheduled', 'Assessment Scheduled'), ('assessment_completed', 'Assessment Completed'), ('waiting_for_application', 'Waiting for Application'), ('application_received', 'Application Received'), ('ready_submission', 'Ready for Submission'), ('submitted', 'Application Submitted'), ('accepted', 'Accepted by ADP'), # New: ADP accepted submission (within 24 hours) ('rejected', 'Rejected by ADP'), # New: ADP rejected submission (errors, need correction) ('resubmitted', 'Application Resubmitted'), ('needs_correction', 'Application Needs Correction'), ('approved', 'Application Approved'), ('approved_deduction', 'Approved with Deduction'), ('ready_delivery', 'Ready for Delivery'), # After approved OR when early delivery ('denied', 'Application Denied'), ('withdrawn', 'Application Withdrawn'), ('ready_bill', 'Ready to Bill'), ('billed', 'Billed to ADP'), ('case_closed', 'Case Closed'), ('on_hold', 'On Hold'), ('cancelled', 'Cancelled'), ('expired', 'Application Expired'), ], string='ADP Application Status', default='quotation', tracking=True, copy=False, group_expand='_expand_adp_application_statuses', help='Comprehensive ADP application workflow status', ) @api.model def _expand_adp_application_statuses(self, states, domain): """Return the main workflow statuses for kanban columns. Always shows core statuses; special statuses (on_hold, denied, etc.) only appear when records exist in them.""" main = [ 'quotation', 'assessment_scheduled', 'waiting_for_application', 'application_received', 'ready_submission', 'submitted', 'needs_correction', 'approved', 'ready_delivery', 'ready_bill', 'billed', 'case_closed', ] # Also include any special status that currently has records result = list(main) for s in (states or []): if s and s not in result: result.append(s) return result x_fc_status_sequence = fields.Integer( string='Status Sequence', compute='_compute_status_sequence', store=True, index=True, help='Numeric workflow order for sorting when grouping by status', ) _STATUS_ORDER = { 'quotation': 10, 'assessment_scheduled': 20, 'assessment_completed': 30, 'waiting_for_application': 40, 'application_received': 50, 'ready_submission': 60, 'submitted': 70, 'accepted': 80, 'rejected': 85, 'resubmitted': 75, 'needs_correction': 65, 'approved': 90, 'approved_deduction': 91, 'ready_delivery': 95, 'ready_bill': 100, 'billed': 110, 'case_closed': 120, 'on_hold': 130, 'denied': 140, 'withdrawn': 150, 'cancelled': 160, 'expired': 170, } @api.depends('x_fc_adp_application_status') def _compute_status_sequence(self): for order in self: order.x_fc_status_sequence = self._STATUS_ORDER.get( order.x_fc_adp_application_status, 999 ) @api.model def _read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None): """Override to sort groups by workflow order when grouping by ADP status.""" result = super()._read_group( domain, groupby=groupby, aggregates=aggregates, having=having, offset=offset, limit=limit, order=order, ) if groupby and groupby[0] == 'x_fc_adp_application_status': status_order = self._STATUS_ORDER result = sorted(result, key=lambda r: status_order.get(r[0], 999)) return result # ========================================================================== # SERVICE FLAG (for service start/end date visibility) # ========================================================================== x_fc_has_service = fields.Boolean( string='Is Service?', default=False, help='Check if this order includes a service component (shows service date fields)', ) # ========================================================================== # ON HOLD TRACKING # ========================================================================== x_fc_on_hold_date = fields.Date( string='On Hold Since', tracking=True, help='Date when the application was put on hold', ) x_fc_previous_status_before_hold = fields.Char( string='Previous Status Before Hold', help='Status before the application was put on hold (for resuming)', ) x_fc_status_before_delivery = fields.Char( string='Status Before Delivery', help='Status before the order was marked Ready for Delivery (for reverting if task cancelled)', ) # ========================================================================== # DELIVERY TECHNICIAN TRACKING # ========================================================================== x_fc_early_delivery = fields.Boolean( string='Early Delivery', default=False, tracking=True, help='Check if delivery will occur before ADP approval (client pays their portion upfront)', ) x_fc_delivery_technician_ids = fields.Many2many( 'res.users', 'sale_order_delivery_technician_rel', 'sale_order_id', 'user_id', string='Delivery Technicians', tracking=True, help='Field technicians assigned to deliver this order', ) x_fc_ready_for_delivery_date = fields.Datetime( string='Ready for Delivery Date', tracking=True, help='Date/time when the order was marked ready for delivery', ) x_fc_scheduled_delivery_datetime = fields.Datetime( string='Scheduled Delivery', tracking=True, help='Scheduled date and time for delivery', ) # ========================================================================== # REJECTION REASON TRACKING (Initial rejection by ADP - within 24 hours) # ========================================================================== x_fc_rejection_reason = fields.Selection( selection=[ ('name_correction', 'Name Correction Needed'), ('healthcard_correction', 'Health Card Correction Needed'), ('duplicate_claim', 'Duplicate Claim Exists'), ('xml_format_error', 'XML Format/Validation Error'), ('missing_info', 'Missing Required Information'), ('other', 'Other'), ], string='Rejection Reason', tracking=True, help='Reason for initial rejection by ADP (within 24 hours of submission)', ) x_fc_rejection_reason_other = fields.Text( string='Rejection Details', tracking=True, help='Additional details when rejection reason is "Other"', ) x_fc_rejection_date = fields.Date( string='Rejection Date', tracking=True, help='Date when ADP rejected the submission', ) x_fc_rejection_count = fields.Integer( string='Rejection Count', default=0, help='Number of times this application has been rejected by ADP', ) # ========================================================================== # DENIAL REASON TRACKING (Funding denied after review - 2-3 weeks) # ========================================================================== x_fc_denial_reason = fields.Selection( selection=[ ('eligibility', 'Client Eligibility Issues'), ('recent_funding', 'Previous Funding Within 5 Years'), ('medical_justification', 'Insufficient Medical Justification'), ('equipment_not_covered', 'Equipment Not Covered by ADP'), ('documentation_incomplete', 'Documentation Incomplete'), ('other', 'Other'), ], string='Denial Reason', tracking=True, help='Reason for denial of funding by ADP (after 2-3 week review)', ) x_fc_denial_reason_other = fields.Text( string='Denial Details', tracking=True, help='Additional details when denial reason is "Other"', ) x_fc_denial_date = fields.Date( string='Denial Date', tracking=True, help='Date when ADP denied the funding', ) # ========================================================================== # EMAIL NOTIFICATION TRACKING # ========================================================================== x_fc_application_reminder_sent = fields.Boolean( string='Application Reminder Sent', default=False, copy=False, help='Whether the first application reminder email has been sent', ) x_fc_application_reminder_2_sent = fields.Boolean( string='Application Reminder 2 Sent', default=False, copy=False, help='Whether the second application reminder email has been sent', ) x_fc_acceptance_reminder_sent = fields.Boolean( string='Acceptance Reminder Sent', default=False, copy=False, help='Whether the acceptance reminder email has been sent for submitted orders', ) # ========================================================================== # VALIDITY & EXPIRY TRACKING # ========================================================================== x_fc_assessment_validity_days = fields.Integer( string='Assessment Validity (Days)', compute='_compute_validity_expiry', help='Days remaining before assessment expires (valid for 3 months)', ) x_fc_assessment_expired = fields.Boolean( string='Assessment Expired', compute='_compute_validity_expiry', help='True if assessment is more than 3 months old', ) x_fc_approval_expiry_days = fields.Integer( string='Approval Expiry (Days)', compute='_compute_validity_expiry', help='Days remaining before approval expires (valid for 6 months)', ) x_fc_approval_expired = fields.Boolean( string='Approval Expired', compute='_compute_validity_expiry', help='True if approval is more than 6 months old', ) x_fc_billing_warning = fields.Boolean( string='Billing Warning', compute='_compute_validity_expiry', help='True if more than 1 year since approval (verbal ADP permission needed)', ) x_fc_show_expiry_card = fields.Boolean( string='Show Expiry Card', compute='_compute_validity_expiry', help='True if expiry card should be shown', ) @api.depends('x_fc_assessment_end_date', 'x_fc_claim_approval_date', 'x_fc_adp_application_status', 'x_fc_claim_number') def _compute_validity_expiry(self): """Compute validity and expiry information for assessments and approvals.""" from datetime import date as date_class today = date_class.today() # Statuses that show expiry card expiry_card_statuses = ['approved', 'approved_deduction', 'on_hold'] for order in self: # Assessment validity (3 months = 90 days) if order.x_fc_assessment_end_date: days_since_assessment = (today - order.x_fc_assessment_end_date).days order.x_fc_assessment_validity_days = max(0, 90 - days_since_assessment) order.x_fc_assessment_expired = days_since_assessment > 90 else: order.x_fc_assessment_validity_days = 0 order.x_fc_assessment_expired = False # Approval expiry (6 months = 180 days) if order.x_fc_claim_approval_date: days_since_approval = (today - order.x_fc_claim_approval_date).days order.x_fc_approval_expiry_days = max(0, 180 - days_since_approval) order.x_fc_approval_expired = days_since_approval > 180 # Billing warning (1 year = 365 days) order.x_fc_billing_warning = days_since_approval > 365 else: order.x_fc_approval_expiry_days = 0 order.x_fc_approval_expired = False order.x_fc_billing_warning = False # Show expiry card for approved/approved_deduction/on_hold (with claim number) status = order.x_fc_adp_application_status if status in expiry_card_statuses and order.x_fc_claim_approval_date: # For on_hold, only show if has claim number if status == 'on_hold': order.x_fc_show_expiry_card = bool(order.x_fc_claim_number) else: order.x_fc_show_expiry_card = True else: order.x_fc_show_expiry_card = False # ========================================================================== # WORKFLOW STAGE FLAGS (computed for view visibility) # ========================================================================== x_fc_stage_after_assessment_initiated = fields.Boolean( compute='_compute_workflow_stages', string='After Assessment Initiated Stage', ) x_fc_stage_after_assessment_completed = fields.Boolean( compute='_compute_workflow_stages', string='After Assessment Completed Stage', ) x_fc_stage_after_application_received = fields.Boolean( compute='_compute_workflow_stages', string='After Application Received Stage', ) x_fc_stage_after_ready_submission = fields.Boolean( compute='_compute_workflow_stages', string='After Ready Submission Stage', ) x_fc_stage_after_submitted = fields.Boolean( compute='_compute_workflow_stages', string='After Submitted Stage', ) x_fc_stage_after_accepted = fields.Boolean( compute='_compute_workflow_stages', string='After Accepted Stage', ) x_fc_stage_after_approved = fields.Boolean( compute='_compute_workflow_stages', string='After Approved Stage', ) x_fc_stage_after_ready_bill = fields.Boolean( compute='_compute_workflow_stages', string='After Ready Bill Stage', ) x_fc_stage_after_billed = fields.Boolean( compute='_compute_workflow_stages', string='After Billed Stage', ) x_fc_requires_previous_funding = fields.Boolean( compute='_compute_workflow_stages', string='Requires Previous Funding Date', ) @api.depends('x_fc_adp_application_status', 'x_fc_reason_for_application') def _compute_workflow_stages(self): """Compute workflow stage flags for conditional visibility in views. Terminal statuses (cancelled, denied, withdrawn, expired) should NOT make later-stage fields required - only 'on_hold' preserves field requirements since the case can resume. """ # Terminal statuses - these end the workflow, no further fields required terminal_statuses = ['cancelled', 'denied', 'withdrawn', 'expired'] # On-hold preserves visibility but we handle it specially # so fields remain visible but not required # Define status groups - each list includes the starting status and all subsequent after_assessment_initiated_statuses = [ 'assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'on_hold', ] after_assessment_completed_statuses = [ 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'on_hold', ] after_application_received_statuses = [ 'application_received', 'ready_submission', 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'on_hold', ] after_ready_submission_statuses = [ 'ready_submission', 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'on_hold', ] after_submitted_statuses = [ 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'on_hold', ] # New: After accepted by ADP (waiting for approval decision) after_accepted_statuses = [ 'accepted', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'on_hold', ] after_approved_statuses = [ 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', 'on_hold', ] after_ready_bill_statuses = [ 'ready_bill', 'billed', 'case_closed', # NOT on_hold here - if on_hold before ready_bill, these shouldn't be required ] after_billed_statuses = [ 'billed', 'case_closed', ] # Reasons that DON'T require previous funding date no_prev_funding_reasons = ['first_access', 'mod_non_adp'] for order in self: status = order.x_fc_adp_application_status or '' reason = order.x_fc_reason_for_application or '' order.x_fc_stage_after_assessment_initiated = status in after_assessment_initiated_statuses order.x_fc_stage_after_assessment_completed = status in after_assessment_completed_statuses order.x_fc_stage_after_application_received = status in after_application_received_statuses order.x_fc_stage_after_ready_submission = status in after_ready_submission_statuses order.x_fc_stage_after_submitted = status in after_submitted_statuses order.x_fc_stage_after_accepted = status in after_accepted_statuses order.x_fc_stage_after_approved = status in after_approved_statuses order.x_fc_stage_after_ready_bill = status in after_ready_bill_statuses order.x_fc_stage_after_billed = status in after_billed_statuses # Previous funding required if reason is set AND not in exempt list order.x_fc_requires_previous_funding = bool(reason) and reason not in no_prev_funding_reasons # ========================================================================== # REASON FOR APPLICATION # ========================================================================== x_fc_reason_for_application = fields.Selection( selection=[ ('first_access', 'First Time Access - NO previous ADP'), ('additions', 'Additions'), ('mod_non_adp', 'Modification/Upgrade - Original NOT through ADP'), ('mod_adp', 'Modification/Upgrade - Original through ADP'), ('replace_status', 'Replacement - Change in Status'), ('replace_size', 'Replacement - Change in Body Size'), ('replace_worn', 'Replacement - Worn out (past useful life)'), ('replace_lost', 'Replacement - Lost'), ('replace_stolen', 'Replacement - Stolen'), ('replace_damaged', 'Replacement - Damaged beyond repair'), ('replace_no_longer_meets', 'Replacement - No longer meets needs'), ('growth', 'Growth/Change in condition'), ], string='Reason for Application', tracking=True, help='Reason for the ADP application - affects invoice creation rules', ) x_fc_previous_funding_date = fields.Date( string='Previous Funding Date', tracking=True, help='Date of previous ADP funding for replacement applications', ) x_fc_years_since_funding = fields.Float( string='Years Since Funding', compute='_compute_years_since_funding', store=True, help='Number of years since previous funding', ) x_fc_under_5_years = fields.Boolean( string='Under 5 Years', compute='_compute_years_since_funding', store=True, help='True if less than 5 years since previous funding (may have deductions)', ) # ========================================================================== # ADP DATE CLASSIFICATIONS (6 dates) # ========================================================================== x_fc_assessment_start_date = fields.Date( string='Assessment Start Date', tracking=True, help='Date when the assessment started', ) x_fc_assessment_end_date = fields.Date( string='Assessment End Date', tracking=True, help='Date when the assessment was completed', ) x_fc_claim_authorization_date = fields.Date( string='Claim Authorization Date', tracking=True, help='Date when the claim was authorized by the OT/Authorizer', ) x_fc_claim_submission_date = fields.Date( string='Claim Submission Date', tracking=True, help='Date when the claim was submitted to ADP', ) x_fc_claim_acceptance_date = fields.Date( string='ADP Acceptance Date', tracking=True, help='Date when ADP accepted the submission (within 24 hours of submission)', ) x_fc_claim_approval_date = fields.Date( string='Claim Approval Date', tracking=True, help='Date when ADP approved the claim', ) x_fc_billing_date = fields.Date( string='Billing Date', tracking=True, help='Date when the ADP invoice was created/billed', ) # ========================================================================== # ADP DOCUMENT ATTACHMENTS # ========================================================================== x_fc_original_application = fields.Binary( string='Original ADP Application', attachment=True, help='The original ADP application document received from the authorizer', ) x_fc_original_application_filename = fields.Char( string='Original Application Filename', ) x_fc_signed_pages_11_12 = fields.Binary( string='Page 11 & 12 (Signed)', attachment=True, help='Signed pages 11 and 12 of the ADP application', ) x_fc_signed_pages_filename = fields.Char( string='Signed Pages Filename', ) # ========================================================================== # PAGE 11 SIGNATURE TRACKING (Client/Agent Signature) # Page 11 must be signed by: Client, Spouse, Parent, Legal Guardian, POA, or Public Trustee # ========================================================================== x_fc_page11_signer_type = fields.Selection( selection=[ ('client', 'Client (Self)'), ('spouse', 'Spouse'), ('parent', 'Parent'), ('legal_guardian', 'Legal Guardian'), ('poa', 'Power of Attorney'), ('public_trustee', 'Public Trustee'), ], string='Page 11 Signed By', tracking=True, help='Who signed Page 11 of the ADP application (client consent page)', ) x_fc_page11_signer_name = fields.Char( string='Page 11 Signer Name', tracking=True, help='Name of the person who signed Page 11', ) x_fc_page11_signer_relationship = fields.Char( string='Relationship to Client', help='Relationship of the signer to the client (if not client self)', ) x_fc_page11_signed_date = fields.Date( string='Page 11 Signed Date', tracking=True, help='Date when Page 11 was signed', ) # ========================================================================== # PAGE 12 SIGNATURE TRACKING (Authorizer + Vendor Signature) # Page 12 must be signed by: Authorizer (OT) and Vendor (our company) # ========================================================================== x_fc_page12_authorizer_signed = fields.Boolean( string='Authorizer Signed Page 12', default=False, tracking=True, help='Whether the authorizer/OT has signed Page 12', ) x_fc_page12_authorizer_signed_date = fields.Date( string='Authorizer Signed Date', tracking=True, help='Date when the authorizer signed Page 12', ) x_fc_page12_vendor_signed = fields.Boolean( string='Vendor Signed Page 12', default=False, tracking=True, help='Whether the vendor (our company) has signed Page 12', ) x_fc_page12_vendor_signer_id = fields.Many2one( 'res.users', string='Vendor Signer', tracking=True, help='The user who signed Page 12 on behalf of the company', ) x_fc_page12_vendor_signed_date = fields.Date( string='Vendor Signed Date', tracking=True, help='Date when the vendor signed Page 12', ) x_fc_final_submitted_application = fields.Binary( string='Final Submitted Application', attachment=True, help='The final ADP application as submitted to ADP', ) x_fc_final_application_filename = fields.Char( string='Final Application Filename', ) x_fc_xml_file = fields.Binary( string='XML File', attachment=True, help='The XML data file submitted to ADP', ) x_fc_xml_filename = fields.Char( string='XML Filename', ) x_fc_approval_letter = fields.Binary( string='ADP Approval Letter', attachment=True, help='ADP approval letter document', ) x_fc_approval_letter_filename = fields.Char( string='Approval Letter Filename', ) x_fc_approval_photo_ids = fields.Many2many( 'ir.attachment', 'sale_order_approval_photo_rel', 'sale_order_id', 'attachment_id', string='Approval Screenshots', help='Upload approval screenshots/photos from ADP portal', ) x_fc_approval_photo_count = fields.Integer( string='Approval Photos', compute='_compute_approval_photo_count', ) @api.depends('x_fc_approval_photo_ids') def _compute_approval_photo_count(self): """Count approval photos.""" for order in self: order.x_fc_approval_photo_count = len(order.x_fc_approval_photo_ids) x_fc_proof_of_delivery = fields.Binary( string='Proof of Delivery', attachment=True, help='Proof of delivery document - required before creating ADP invoice', ) x_fc_proof_of_delivery_filename = fields.Char( string='Proof of Delivery Filename', ) # POD Digital Signature Fields (captured via portal) x_fc_pod_signature = fields.Binary( string='POD Client Signature', attachment=True, help='Digital signature captured from client via portal', ) x_fc_pod_client_name = fields.Char( string='POD Client Name', help='Name of the person who signed the Proof of Delivery', ) x_fc_pod_signature_date = fields.Date( string='POD Signature Date', help='Date specified on the Proof of Delivery (optional)', ) x_fc_pod_signed_by_user_id = fields.Many2one( 'res.users', string='POD Collected By', help='The sales rep or technician who collected the POD signature', ) x_fc_pod_signed_datetime = fields.Datetime( string='POD Collection Timestamp', help='When the POD signature was collected', ) # ========================================================================== # VERIFICATION TRACKING # ========================================================================== x_fc_submission_verified = fields.Boolean( string='Submission Verified', default=False, copy=False, help='True when user has verified device types for submission via the wizard', ) x_fc_submitted_device_types = fields.Text( string='Submitted Device Types (JSON)', copy=False, help='JSON storing which device types were selected for submission', ) # ========================================================================== # COMPUTED TOTALS FOR ADP PORTIONS # ========================================================================== x_fc_adp_portion_total = fields.Monetary( string='Total ADP Portion', compute='_compute_adp_totals', store=True, currency_field='currency_id', help='Total ADP portion for all lines', ) x_fc_client_portion_total = fields.Monetary( string='Total Client Portion', compute='_compute_adp_totals', store=True, currency_field='currency_id', help='Total client portion for all lines', ) # ========================================================================== # COMPUTED FIELDS FOR SPLIT INVOICE TRACKING # ========================================================================== x_fc_has_client_invoice = fields.Boolean( string='Has Client Invoice', compute='_compute_adp_invoice_status', help='Whether a client portion (25%) invoice has been created', ) x_fc_has_adp_invoice = fields.Boolean( string='Has ADP Invoice', compute='_compute_adp_invoice_status', help='Whether an ADP portion (75%/100%) invoice has been created', ) # ========================================================================== # COMPUTED FIELD FOR PRODUCT-ONLY LINES (for ADP Summary) # ========================================================================== x_fc_product_lines = fields.One2many( 'sale.order.line', compute='_compute_product_lines', string='Product Lines Only', help='Only product lines (excludes sections, notes, and empty lines)', ) # ========================================================================== # DEVICE APPROVAL TRACKING # ========================================================================== x_fc_has_unapproved_devices = fields.Boolean( string='Has Unapproved Devices', compute='_compute_device_approval_status', help='True if there are devices that have not been marked as approved by ADP', ) x_fc_device_verification_complete = fields.Boolean( string='Verification Complete', default=False, copy=False, help='True if the user has completed device verification via the wizard. ' 'Set when user clicks Confirm in the Device Approval wizard.', ) x_fc_device_approval_done = fields.Boolean( string='All Devices Approved', compute='_compute_device_approval_status', help='True if ALL ADP devices have been approved. For display purposes only.', ) x_fc_approved_device_count = fields.Integer( string='Approved Device Count', compute='_compute_device_approval_status', ) x_fc_total_device_count = fields.Integer( string='Total Device Count', compute='_compute_device_approval_status', ) # ========================================================================== # CASE LOCK # ========================================================================== x_fc_case_locked = fields.Boolean( string='Case Locked', default=False, copy=False, tracking=True, help='When enabled, all ADP-related fields become read-only and cannot be modified.', ) # ========================================================================== # INVOICE MAPPING (for linking legacy invoices) # ========================================================================== x_fc_adp_invoice_id = fields.Many2one( 'account.move', string='ADP Invoice', domain="[('move_type', 'in', ['out_invoice', 'out_refund'])]", copy=False, help='Link to the ADP invoice for this order', ) x_fc_client_invoice_id = fields.Many2one( 'account.move', string='Client Invoice', domain="[('move_type', 'in', ['out_invoice', 'out_refund'])]", copy=False, help='Link to the client portion invoice for this order', ) # ========================================================================== # (Legacy studio fields removed - all data migrated to x_fc_* fields) # ========================================================================== # ========================================================================== # ORDER TRAIL CHECKLIST (computed for display) # ========================================================================== x_fc_trail_has_assessment_dates = fields.Boolean( string='Assessment Dates Set', compute='_compute_order_trail', ) x_fc_trail_has_authorization = fields.Boolean( string='Authorization Date Set', compute='_compute_order_trail', ) x_fc_trail_has_original_app = fields.Boolean( string='Original Application Uploaded', compute='_compute_order_trail', ) x_fc_trail_has_signed_pages = fields.Boolean( string='Signed Pages 11 & 12 Uploaded', compute='_compute_order_trail', ) x_fc_trail_has_final_app = fields.Boolean( string='Final Application Uploaded', compute='_compute_order_trail', ) x_fc_trail_has_xml = fields.Boolean( string='XML File Uploaded', compute='_compute_order_trail', ) x_fc_trail_has_approval_letter = fields.Boolean( string='Approval Letter Uploaded', compute='_compute_order_trail', ) x_fc_trail_has_pod = fields.Boolean( string='Proof of Delivery Uploaded', compute='_compute_order_trail', ) x_fc_trail_has_vendor_bills = fields.Boolean( string='Vendor Bills Linked', compute='_compute_order_trail', ) x_fc_trail_invoices_posted = fields.Boolean( string='Invoices Posted', compute='_compute_order_trail', ) @api.depends( 'x_fc_assessment_start_date', 'x_fc_assessment_end_date', 'x_fc_claim_authorization_date', 'x_fc_original_application', 'x_fc_signed_pages_11_12', 'x_fc_final_submitted_application', 'x_fc_xml_file', 'x_fc_approval_letter', 'x_fc_proof_of_delivery', 'x_fc_vendor_bill_ids', 'invoice_ids', 'invoice_ids.state' ) def _compute_order_trail(self): for order in self: order.x_fc_trail_has_assessment_dates = bool( order.x_fc_assessment_start_date and order.x_fc_assessment_end_date ) order.x_fc_trail_has_authorization = bool(order.x_fc_claim_authorization_date) order.x_fc_trail_has_original_app = bool(order.x_fc_original_application) order.x_fc_trail_has_signed_pages = bool(order.x_fc_signed_pages_11_12) order.x_fc_trail_has_final_app = bool(order.x_fc_final_submitted_application) order.x_fc_trail_has_xml = bool(order.x_fc_xml_file) order.x_fc_trail_has_approval_letter = bool(order.x_fc_approval_letter) order.x_fc_trail_has_pod = bool(order.x_fc_proof_of_delivery) order.x_fc_trail_has_vendor_bills = bool(order.x_fc_vendor_bill_ids) # Check if there are posted invoices order.x_fc_trail_invoices_posted = any( inv.state == 'posted' for inv in order.invoice_ids ) # ========================================================================== # DEDUCTION TRACKING # ========================================================================== x_fc_has_deductions = fields.Boolean( string='Has Deductions', compute='_compute_has_deductions', help='True if any line has a deduction applied', ) x_fc_total_deduction_amount = fields.Monetary( string='Total Deduction Amount', compute='_compute_has_deductions', currency_field='currency_id', help='Total amount of deductions applied to ADP portion', ) # ========================================================================== # COMPUTED METHODS # ========================================================================== @api.depends('order_line.x_fc_adp_portion', 'order_line.x_fc_client_portion') def _compute_adp_totals(self): for order in self: order.x_fc_adp_portion_total = sum(order.order_line.mapped('x_fc_adp_portion')) order.x_fc_client_portion_total = sum(order.order_line.mapped('x_fc_client_portion')) def _compute_adp_invoice_status(self): """Check if client/ADP split invoices have already been created.""" for order in self: client_invoice_exists = False adp_invoice_exists = False # Check linked invoices for the portion type invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel') for invoice in invoices: if hasattr(invoice, 'x_fc_adp_invoice_portion'): if invoice.x_fc_adp_invoice_portion == 'client': client_invoice_exists = True elif invoice.x_fc_adp_invoice_portion == 'adp': adp_invoice_exists = True order.x_fc_has_client_invoice = client_invoice_exists order.x_fc_has_adp_invoice = adp_invoice_exists @api.depends('order_line', 'order_line.product_id', 'order_line.product_uom_qty', 'order_line.display_type') def _compute_product_lines(self): """Compute filtered list of only actual product lines (no sections, notes, or empty lines).""" for order in self: order.x_fc_product_lines = order.order_line.filtered( lambda l: not l.display_type and l.product_id and l.product_uom_qty > 0 ) @api.depends('order_line.x_fc_adp_approved', 'order_line.product_id', 'order_line.display_type') def _compute_device_approval_status(self): """Compute device approval status for ADP orders. Only counts lines with valid ADP device codes in the database. Non-ADP items are ignored for verification purposes. """ ADPDevice = self.env['fusion.adp.device.code'].sudo() for order in self: # Get lines with device codes (actual ADP billable products) product_lines = order.order_line.filtered( lambda l: not l.display_type and l.product_id and l.product_uom_qty > 0 ) # Filter to only lines with valid ADP device codes in the database device_lines = self.env['sale.order.line'] for line in product_lines: device_code = line._get_adp_device_code() if device_code: # Check if this code exists in ADP database if ADPDevice.search_count([('device_code', '=', device_code), ('active', '=', True)]) > 0: device_lines |= line total_count = len(device_lines) approved_count = len(device_lines.filtered(lambda l: l.x_fc_adp_approved)) order.x_fc_total_device_count = total_count order.x_fc_approved_device_count = approved_count order.x_fc_has_unapproved_devices = approved_count < total_count and total_count > 0 # Verification is "done" only if ALL ADP devices have been approved # If there are no ADP devices, verification is automatically done order.x_fc_device_approval_done = (approved_count == total_count) or total_count == 0 @api.depends('order_line.x_fc_deduction_type', 'order_line.x_fc_deduction_value', 'order_line.x_fc_adp_portion', 'order_line.product_id') def _compute_has_deductions(self): """Compute if order has any deductions and total deduction amount.""" for order in self: product_lines = order.order_line.filtered( lambda l: not l.display_type and l.product_id and l.product_uom_qty > 0 ) # Check if any line has a deduction has_deductions = any( line.x_fc_deduction_type and line.x_fc_deduction_type != 'none' for line in product_lines ) # Calculate total deduction impact (difference from full ADP coverage) total_deduction = 0.0 if has_deductions: for line in product_lines: if line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value: total_deduction += line.x_fc_deduction_value elif line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value: # For percentage, calculate the reduction from normal # If normally 75% ADP, and now 50%, the deduction is 25% of total client_type = order._get_client_type() base_pct = 0.75 if client_type == 'REG' else 1.0 adp_price = line.x_fc_adp_max_price or line.price_unit normal_adp = adp_price * line.product_uom_qty * base_pct actual_adp = line.x_fc_adp_portion total_deduction += max(0, normal_adp - actual_adp) order.x_fc_has_deductions = has_deductions order.x_fc_total_deduction_amount = total_deduction @api.depends('x_fc_previous_funding_date') def _compute_years_since_funding(self): """Compute years since previous funding and 5-year flag.""" from datetime import date today = date.today() for order in self: if order.x_fc_previous_funding_date: delta = today - order.x_fc_previous_funding_date years = delta.days / 365.25 order.x_fc_years_since_funding = round(years, 2) order.x_fc_under_5_years = years < 5 else: order.x_fc_years_since_funding = 0 order.x_fc_under_5_years = False # ========================================================================== # PREVIOUS FUNDING WARNING MESSAGE # ========================================================================== x_fc_funding_warning_message = fields.Char( string='Funding Warning', compute='_compute_funding_warning_message', help='Warning message for recent previous funding', ) x_fc_funding_warning_level = fields.Selection( selection=[ ('none', 'None'), ('warning', 'Warning'), ('danger', 'Danger'), ], string='Warning Level', compute='_compute_funding_warning_message', help='Level of warning for previous funding', ) @api.depends('x_fc_previous_funding_date') def _compute_funding_warning_message(self): """Compute warning message for previous funding with time elapsed.""" from datetime import date today = date.today() for order in self: if order.x_fc_previous_funding_date: delta = today - order.x_fc_previous_funding_date total_months = delta.days / 30.44 # Average days per month years = int(total_months // 12) months = int(total_months % 12) if years == 0: time_str = f"{months} month{'s' if months != 1 else ''}" elif months == 0: time_str = f"{years} year{'s' if years != 1 else ''}" else: time_str = f"{years} year{'s' if years != 1 else ''} and {months} month{'s' if months != 1 else ''}" order.x_fc_funding_warning_message = f"Previous funding was {time_str} ago ({order.x_fc_previous_funding_date.strftime('%B %d, %Y')})" # Set warning level - red if within 1 year if total_months < 12: order.x_fc_funding_warning_level = 'danger' elif total_months < 60: # Less than 5 years order.x_fc_funding_warning_level = 'warning' else: order.x_fc_funding_warning_level = 'none' else: order.x_fc_funding_warning_message = False order.x_fc_funding_warning_level = 'none' # ========================================================================== # FIELD VALIDATION CONSTRAINTS # ========================================================================== @api.constrains('x_fc_claim_number') def _check_claim_number(self): """Validate claim number: 10 digits only, numbers only.""" for order in self: if order.x_fc_claim_number: # Remove any whitespace claim = order.x_fc_claim_number.strip() if not re.match(r'^\d{10}$', claim): raise ValidationError( "Claim Number must be exactly 10 digits (numbers only).\n" f"Current value: '{order.x_fc_claim_number}'" ) @api.constrains('x_fc_client_ref_1') def _check_client_ref_1(self): """Validate client reference 1: up to 4 letters, comma allowed.""" for order in self: if order.x_fc_client_ref_1: # Allow letters and comma only, max 4 characters (excluding comma) ref = order.x_fc_client_ref_1.strip().upper() # Remove commas for letter count letters_only = ref.replace(',', '') if len(letters_only) > 4: raise ValidationError( "Client Reference 1 can only have up to 4 letters.\n" f"Current value: '{order.x_fc_client_ref_1}'" ) if not re.match(r'^[A-Za-z,]+$', ref): raise ValidationError( "Client Reference 1 can only contain letters and comma.\n" f"Current value: '{order.x_fc_client_ref_1}'" ) @api.constrains('x_fc_client_ref_2') def _check_client_ref_2(self): """Validate client reference 2: exactly 4 digits, numbers only.""" for order in self: if order.x_fc_client_ref_2: ref = order.x_fc_client_ref_2.strip() if not re.match(r'^\d{4}$', ref): raise ValidationError( "Client Reference 2 must be exactly 4 digits (numbers only).\n" f"Current value: '{order.x_fc_client_ref_2}'" ) @api.constrains('x_fc_original_application_filename') def _check_original_application_pdf(self): """Validate that Original ADP Application is a PDF file.""" for order in self: if order.x_fc_original_application_filename: if not order.x_fc_original_application_filename.lower().endswith('.pdf'): raise ValidationError( "Original ADP Application must be a PDF file.\n" f"Uploaded file: '{order.x_fc_original_application_filename}'" ) @api.constrains('x_fc_signed_pages_filename') def _check_signed_pages_pdf(self): """Validate that Page 11 & 12 is a PDF file.""" for order in self: if order.x_fc_signed_pages_filename: if not order.x_fc_signed_pages_filename.lower().endswith('.pdf'): raise ValidationError( "Page 11 & 12 (Signed) must be a PDF file.\n" f"Uploaded file: '{order.x_fc_signed_pages_filename}'" ) @api.constrains('x_fc_final_application_filename') def _check_final_application_pdf(self): """Validate that Final Submitted Application is a PDF file.""" for order in self: if order.x_fc_final_application_filename: if not order.x_fc_final_application_filename.lower().endswith('.pdf'): raise ValidationError( "Final Submitted Application must be a PDF file.\n" f"Uploaded file: '{order.x_fc_final_application_filename}'" ) @api.constrains('x_fc_xml_filename') def _check_xml_file(self): """Validate that XML File is an XML file.""" for order in self: if order.x_fc_xml_filename: if not order.x_fc_xml_filename.lower().endswith('.xml'): raise ValidationError( "XML File must be an XML file (.xml).\n" f"Uploaded file: '{order.x_fc_xml_filename}'" ) @api.constrains('x_fc_proof_of_delivery_filename') def _check_proof_of_delivery_pdf(self): """Validate that Proof of Delivery is a PDF file.""" for order in self: if order.x_fc_proof_of_delivery_filename: if not order.x_fc_proof_of_delivery_filename.lower().endswith('.pdf'): raise ValidationError( "Proof of Delivery must be a PDF file.\n" f"Uploaded file: '{order.x_fc_proof_of_delivery_filename}'" ) @api.constrains('x_fc_adp_delivery_date', 'x_fc_claim_approval_date') def _check_delivery_date_after_approval(self): """Validate that delivery date is not before approval date. Per business rule: The delivery date on POD cannot be before the approval date. If client takes delivery before approval (early delivery case), the POD should show the approval date, not the actual delivery date. """ for order in self: if order.x_fc_adp_delivery_date and order.x_fc_claim_approval_date: if order.x_fc_adp_delivery_date < order.x_fc_claim_approval_date: raise ValidationError( "Delivery Date cannot be before Approval Date.\n\n" f"Delivery Date: {order.x_fc_adp_delivery_date.strftime('%B %d, %Y')}\n" f"Approval Date: {order.x_fc_claim_approval_date.strftime('%B %d, %Y')}\n\n" "For early delivery cases (delivery before approval), the Proof of Delivery " "document should show the approval date, not the actual delivery date.\n\n" "Please correct the delivery date and re-upload the Proof of Delivery." ) # ========================================================================== # PDF DOCUMENT PREVIEW ACTIONS (opens in new tab using browser/system PDF handler) # ========================================================================== def _get_document_attachment(self, field_name): """Get the ir.attachment record for a binary field stored as attachment.""" self.ensure_one() # Find the attachment by field name - get most recent one attachment = self.env['ir.attachment'].sudo().search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('res_field', '=', field_name), ], order='create_date desc', limit=1) return attachment def _get_or_create_attachment(self, field_name, document_label): """Get the current attachment for a binary field (attachment=True). For attachment=True fields, Odoo creates attachments automatically. We find the one with res_field set and return it. """ self.ensure_one() data = getattr(self, field_name) if not data: return None # For attachment=True fields, Odoo creates/updates an attachment with res_field set attachment = self.env['ir.attachment'].sudo().search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('res_field', '=', field_name), ], order='id desc', limit=1) if attachment: # If attachment name is the field name (Odoo default), use the actual filename if attachment.name == field_name: filename_mapping = { 'x_fc_original_application': 'x_fc_original_application_filename', 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename', 'x_fc_final_submitted_application': 'x_fc_final_application_filename', 'x_fc_xml_file': 'x_fc_xml_filename', 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename', } filename_field = filename_mapping.get(field_name) if filename_field: filename = getattr(self, filename_field, None) if filename and filename != field_name: attachment.sudo().write({'name': filename}) return attachment # Fallback: create attachment manually (shouldn't happen for attachment=True fields) filename_mapping = { 'x_fc_original_application': 'x_fc_original_application_filename', 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename', 'x_fc_final_submitted_application': 'x_fc_final_application_filename', 'x_fc_xml_file': 'x_fc_xml_filename', 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename', } filename_field = filename_mapping.get(field_name) filename = getattr(self, filename_field) if filename_field else f'{document_label}.pdf' attachment = self.env['ir.attachment'].sudo().create({ 'name': filename or f'{document_label}.pdf', 'datas': data, 'res_model': 'sale.order', 'res_id': self.id, 'res_field': field_name, 'type': 'binary', }) return attachment def action_open_original_application(self): """Open the Original ADP Application PDF.""" self.ensure_one() return self._action_open_document('x_fc_original_application', 'Original ADP Application') def action_open_signed_pages(self): """Open the Page 11 & 12 PDF.""" self.ensure_one() return self._action_open_document('x_fc_signed_pages_11_12', 'Page 11 & 12 (Signed)') def action_open_final_application(self): """Open the Final Submitted Application PDF.""" self.ensure_one() return self._action_open_document('x_fc_final_submitted_application', 'Final Submitted Application') def action_open_xml_file(self): """Open the XML File in viewer.""" self.ensure_one() return self._action_open_document('x_fc_xml_file', 'XML File', is_xml=True) def action_open_proof_of_delivery(self): """Open the Proof of Delivery PDF.""" self.ensure_one() return self._action_open_document('x_fc_proof_of_delivery', 'Proof of Delivery') def action_open_approval_letter(self): """Open the ADP Approval Letter PDF.""" self.ensure_one() return self._action_open_document('x_fc_approval_letter', 'ADP Approval Letter') def action_open_sa_approval_form(self): """Open the SA Mobility ODSP Approval Form PDF.""" self.ensure_one() return self._action_open_document('x_fc_sa_approval_form', 'ODSP Approval Form') def action_open_sa_signed_form(self): """Open the signed SA Mobility form PDF.""" self.ensure_one() return self._action_open_document('x_fc_sa_signed_form', 'SA Signed Form') def action_open_sa_physical_copy(self): """Open the physically signed SA Mobility copy.""" self.ensure_one() return self._action_open_document('x_fc_sa_physical_signed_copy', 'Physical Signed Copy') def action_open_sa_internal_pod(self): """Generate and open the internal POD report on-the-fly.""" self.ensure_one() report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard') return report.report_action(self) def action_open_ow_discretionary_form(self): """Open the Ontario Works Discretionary Benefits form PDF.""" self.ensure_one() return self._action_open_document('x_fc_ow_discretionary_form', 'Discretionary Benefits Form') def action_open_ow_authorizer_letter(self): """Open the Ontario Works Authorizer Letter.""" self.ensure_one() return self._action_open_document('x_fc_ow_authorizer_letter', 'Authorizer Letter') def action_open_ow_internal_pod(self): """Generate and open the internal POD report on-the-fly (Ontario Works).""" self.ensure_one() report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard') return report.report_action(self) def action_open_odsp_std_approval_document(self): """Open the Standard ODSP Approval Document.""" self.ensure_one() return self._action_open_document('x_fc_odsp_approval_document', 'ODSP Approval Document') def action_open_odsp_std_authorizer_letter(self): """Open the Standard ODSP Authorizer Letter.""" self.ensure_one() return self._action_open_document('x_fc_odsp_authorizer_letter', 'Authorizer Letter') def action_open_odsp_std_internal_pod(self): """Generate and open the internal POD report on-the-fly (Standard ODSP).""" self.ensure_one() report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard') return report.report_action(self) def _get_sa_pod_pdf(self): """Generate the standard POD report PDF and return (pdf_bytes, filename).""" self.ensure_one() report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard') pdf_content, _ct = report._render_qweb_pdf(report.id, [self.id]) return pdf_content, f'POD_{self.name}.pdf' def action_view_approval_photos(self): """Open approval photos using Odoo's native attachment viewer.""" self.ensure_one() attachments = self.x_fc_approval_photo_ids if not attachments: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'No Photos', 'message': 'No approval screenshots have been uploaded yet.', 'type': 'warning', 'sticky': False, } } # Use Odoo's native attachment viewer (same as chatter) return { 'type': 'ir.actions.act_url', 'url': f'/web/image/{attachments[0].id}', 'target': 'new', } def _action_open_document(self, field_name, document_label, download=False, is_xml=False): """Open a document in a preview dialog (PDF or XML viewer).""" self.ensure_one() # Check if the field has data if not getattr(self, field_name): return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'No Document', 'message': f'No {document_label} has been uploaded yet.', 'type': 'warning', 'sticky': False, } } # Get or create attachment attachment = self._get_or_create_attachment(field_name, document_label) if attachment: if download: # Open in new tab for download return { 'type': 'ir.actions.act_url', 'url': f'/web/content/{attachment.id}?download=true', 'target': 'new', } elif is_xml: # For XML files, open in XML viewer dialog return { 'type': 'ir.actions.client', 'tag': 'fusion_claims.preview_xml', 'params': { 'attachment_id': attachment.id, 'title': f'{document_label} - {self.name}', } } else: # For PDF files, open in PDF preview dialog return { 'type': 'ir.actions.client', 'tag': 'fusion_claims.preview_document', 'params': { 'attachment_id': attachment.id, 'title': f'{document_label} - {self.name}', } } else: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Error', 'message': f'Failed to load {document_label}.', 'type': 'danger', 'sticky': False, } } @api.onchange('x_fc_sale_type', 'x_fc_client_type') def _onchange_sale_type_client_type(self): """Trigger recalculation when sale type or client type changes.""" for line in self.order_line: line._compute_adp_portions() # ========================================================================== # GETTER METHODS # ========================================================================== def _get_sale_type(self): """Get sale type from x_fc_sale_type.""" self.ensure_one() return self.x_fc_sale_type or '' def _get_client_type(self): """Get client type from x_fc_client_type.""" self.ensure_one() return self.x_fc_client_type or '' def _get_authorizer(self): """Get authorizer from mapped field or built-in field. Returns name as string.""" self.ensure_one() ICP = self.env['ir.config_parameter'].sudo() field_name = ICP.get_param('fusion_claims.field_so_authorizer', 'x_fc_authorizer_id') value = getattr(self, field_name, None) if hasattr(self, field_name) else None if not value and field_name != 'x_fc_authorizer_id': value = self.x_fc_authorizer_id # Return name if it's a record, otherwise return string value if hasattr(value, 'name'): return value.name or '' return str(value) if value else '' def _get_claim_number(self): """Get claim number.""" self.ensure_one() return self.x_fc_claim_number or '' def _get_client_ref_1(self): """Get client reference 1.""" self.ensure_one() return self.x_fc_client_ref_1 or '' def _get_client_ref_2(self): """Get client reference 2.""" self.ensure_one() return self.x_fc_client_ref_2 or '' def _get_adp_delivery_date(self): """Get ADP delivery date.""" self.ensure_one() return self.x_fc_adp_delivery_date def _is_adp_sale(self): """Check if this is an ADP sale type. Returns True only for ADP-related sale types. """ self.ensure_one() sale_type = self.x_fc_sale_type or '' if not sale_type: return False sale_type_lower = str(sale_type).lower().strip() adp_keywords = ('adp',) return any(keyword in sale_type_lower for keyword in adp_keywords) def _get_serial_numbers(self): """Get all serial numbers from order lines.""" self.ensure_one() serial_lines = [] for line in self.order_line: serial = line._get_serial_number() if serial: serial_lines.append({ 'product': line.product_id.name, 'serial': serial, 'adp_code': line._get_adp_device_code(), }) return serial_lines # ========================================================================== # ACTION METHODS # ========================================================================== def action_recalculate_adp_portions(self): """Manually recalculate ADP and Client portions for all lines.""" for order in self: for line in order.order_line: line._compute_adp_portions() order._compute_adp_totals() return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'ADP Portions Recalculated', 'message': 'All line portions have been recalculated.', 'type': 'success', 'sticky': False, } } def action_submit_to_adp(self): """Mark order as submitted to ADP.""" for order in self: if order._is_adp_sale(): order.x_fc_adp_status = 'submitted' return True def action_mark_adp_approved(self): """Mark order as approved by ADP.""" for order in self: if order._is_adp_sale(): order.x_fc_adp_status = 'approved' return True def action_open_device_approval_wizard(self): """Open the device approval wizard to verify which devices were approved by ADP.""" self.ensure_one() return { 'name': 'Verify Device Approval', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.device.approval.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', }, } def action_open_submission_verification_wizard(self): """Open the submission verification wizard to confirm which device types are being submitted.""" self.ensure_one() return { 'name': 'Verify Submission Device Types', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.submission.verification.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', }, } # ========================================================================== # EARLY WORKFLOW STAGE ACTIONS (No wizard required - simple status updates) # ========================================================================== def action_schedule_assessment(self): """Open wizard to schedule assessment with date/time and calendar event.""" self.ensure_one() if self.x_fc_adp_application_status != 'quotation': raise UserError("Can only schedule assessment from 'Quotation' status.") return { 'name': 'Schedule Assessment', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.schedule.assessment.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', }, } def action_complete_assessment(self): """Open wizard to mark assessment as completed with date.""" self.ensure_one() if self.x_fc_adp_application_status != 'assessment_scheduled': raise UserError("Can only complete assessment from 'Assessment Scheduled' status.") return { 'name': 'Assessment Completed', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.assessment.completed.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', }, } def action_application_received(self): """Open wizard to upload ADP application and pages 11 & 12.""" self.ensure_one() if self.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'): raise UserError("Can only mark application received from 'Waiting for Application' status.") return { 'name': 'Application Received', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.application.received.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', }, } def action_ready_for_submission(self): """Open wizard to collect required fields and mark as ready for submission.""" self.ensure_one() if self.x_fc_adp_application_status != 'application_received': raise UserError("Can only mark ready for submission from 'Application Received' status.") return { 'name': 'Ready for Submission', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.ready.for.submission.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', }, } # ========================================================================== # SUBMISSION WORKFLOW ACTIONS # ========================================================================== def action_submit_application(self): """Open submission verification wizard and submit the application. This forces verification of device types before changing status to 'submitted'. """ self.ensure_one() # Validate we're in a status that can be submitted if self.x_fc_adp_application_status not in ('ready_submission', 'needs_correction'): raise UserError( "Application can only be submitted from 'Ready for Submission' or 'Needs Correction' status." ) return { 'name': 'Submit Application - Verify Device Types', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.submission.verification.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', 'submit_application': True, # Flag to set status after verification }, } def action_close_case(self): """Open case close verification wizard to verify audit trail before closing. This forces verification of: - Signed Pages 11 & 12 - Final Application - Proof of Delivery - Vendor Bills """ self.ensure_one() # Validate we're in a status that can be closed if self.x_fc_adp_application_status != 'billed': raise UserError( "Case can only be closed from 'Billed' status." ) return { 'name': 'Close Case - Audit Trail Verification', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.case.close.verification.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', }, } def action_mark_accepted(self): """Mark the application as accepted by ADP. This is called when ADP accepts the submission (within 24 hours). This is a simple status change - no wizard needed. Submission history is updated in the write() method. """ self.ensure_one() # Validate we're in a status that can be accepted if self.x_fc_adp_application_status not in ('submitted', 'resubmitted'): raise UserError( "Application can only be marked as accepted from 'Submitted' or 'Resubmitted' status." ) # Update status - this will trigger the write() method which updates submission history self.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': 'accepted', }) # Post to chatter self.message_post( body=Markup( '' ), message_type='notification', subtype_xmlid='mail.mt_note', ) return True def action_mark_approved(self): """Open device approval wizard and mark as approved. This is called when ADP approval letter is received. The wizard allows verifying which devices were approved. """ self.ensure_one() # Validate we're in a status that can be approved if self.x_fc_adp_application_status not in ('submitted', 'resubmitted', 'accepted'): raise UserError( "Application can only be marked as approved from 'Submitted', 'Resubmitted', or 'Accepted' status." ) return { 'name': 'Mark as Approved - Verify Device Approval', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.device.approval.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', 'mark_as_approved': True, # Flag to set status after verification }, } def action_resume_from_hold(self): """Resume the application from on-hold status. Returns the application to its previous status before being put on hold. """ self.ensure_one() if self.x_fc_adp_application_status != 'on_hold': raise UserError("This action is only available for applications that are On Hold.") # Get the previous status previous_status = self.x_fc_previous_status_before_hold # If no previous status recorded, default to 'approved' if not previous_status: previous_status = 'approved' # Get status labels for message status_labels = dict(self._fields['x_fc_adp_application_status'].selection) prev_label = status_labels.get(previous_status, previous_status) # Update the status self.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': previous_status, 'x_fc_on_hold_date': False, 'x_fc_previous_status_before_hold': False, }) # Post to chatter user_name = self.env.user.name resume_date = fields.Date.today().strftime('%B %d, %Y') message_body = f''' ''' self.message_post( body=Markup(message_body), message_type='notification', subtype_xmlid='mail.mt_note', ) return True def action_set_ready_to_bill(self): """Open the Ready to Bill wizard to collect POD and delivery date. The wizard will: - Collect Proof of Delivery document - Set the delivery date - Validate device verification is complete - Mark the order as Ready to Bill """ self.ensure_one() # Validate we're in a status that can move to ready_bill if self.x_fc_adp_application_status not in ('approved', 'approved_deduction'): raise UserError( "Order can only be marked as 'Ready to Bill' from 'Approved' status." ) # Check device verification first (this can't be done in wizard) if not self.x_fc_device_verification_complete: raise UserError( "Device approval verification must be completed before marking as Ready to Bill.\n\n" "Please verify which devices were approved by ADP using the 'Mark as Approved' button first." ) # Open the wizard to collect POD and delivery date return { 'name': 'Ready to Bill', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.ready.to.bill.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', }, } def action_set_ready_to_bill_direct(self): """Direct method to mark as ready to bill (used when POD already uploaded). This is kept for backward compatibility and for cases where POD is already uploaded. """ self.ensure_one() # Validate we're in a status that can move to ready_bill if self.x_fc_adp_application_status not in ('approved', 'approved_deduction'): raise UserError( "Order can only be marked as 'Ready to Bill' from 'Approved' status." ) # Check POD if not self.x_fc_proof_of_delivery: # Redirect to wizard return self.action_set_ready_to_bill() # Check delivery date if not self.x_fc_adp_delivery_date: # Redirect to wizard return self.action_set_ready_to_bill() # Check device verification if not self.x_fc_device_verification_complete: raise UserError( "Device approval verification must be completed before marking as Ready to Bill.\n\n" "Please verify which devices were approved by ADP using the 'Mark as Approved' button first." ) # All validations passed - set status from markupsafe import Markup from datetime import date self.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': 'ready_bill', }) # Post to chatter with nice card style using Bootstrap classes for dark/light mode self.message_post( body=Markup( '' ), message_type='notification', subtype_xmlid='mail.mt_note', ) return True def action_mark_as_billed(self): """Mark order as billed after validating invoices are posted. Validates: - ADP invoice exists and is posted - For REG clients: Client invoice exists and is posted """ self.ensure_one() # Validate we're in ready_bill status if self.x_fc_adp_application_status != 'ready_bill': raise UserError( "Order can only be marked as 'Billed' from 'Ready to Bill' status." ) # Check ADP invoice AccountMove = self.env['account.move'].sudo() adp_invoices = AccountMove.search([ ('x_fc_source_sale_order_id', '=', self.id), ('x_fc_adp_invoice_portion', '=', 'adp'), ('state', '=', 'posted'), ]) if not adp_invoices: raise UserError( "ADP invoice must be created and posted before marking as Billed.\n\n" "Please create and post the ADP invoice first." ) # For REG clients, check client invoice if self.x_fc_client_type == 'REG': client_invoices = AccountMove.search([ ('x_fc_source_sale_order_id', '=', self.id), ('x_fc_adp_invoice_portion', '=', 'client'), ('state', '=', 'posted'), ]) if not client_invoices: raise UserError( "Client invoice must be created and posted before marking as Billed.\n\n" "For REG clients, both the ADP invoice (75%) and Client invoice (25%) must be posted." ) # All validations passed - set status and billing date from markupsafe import Markup from datetime import date self.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': 'billed', 'x_fc_billing_date': date.today(), }) # Update ADP invoice billing status to 'submitted' for adp_inv in adp_invoices: if adp_inv.x_fc_adp_billing_status in ('waiting', 'not_applicable'): adp_inv.write({'x_fc_adp_billing_status': 'submitted'}) _logger.info(f"Updated ADP invoice {adp_inv.name} billing status to 'submitted'") # Build invoice list invoice_list = ', '.join(adp_invoices.mapped('name')) if self.x_fc_client_type == 'REG': invoice_list += ', ' + ', '.join(client_invoices.mapped('name')) # Calculate total billed total_billed = sum(adp_invoices.mapped('amount_total')) if self.x_fc_client_type == 'REG': total_billed += sum(client_invoices.mapped('amount_total')) # Post to chatter with nice card style using Bootstrap classes for dark/light mode self.message_post( body=Markup( '' ), message_type='notification', subtype_xmlid='mail.mt_note', ) return True def _check_unapproved_devices(self): """Check if there are any unapproved ADP devices in the order. Only checks lines with valid ADP device codes in the database. Non-ADP items are ignored. """ self.ensure_one() ADPDevice = self.env['fusion.adp.device.code'].sudo() unapproved = self.env['sale.order.line'] for line in self.order_line: if line.display_type: continue if not line.product_id or line.product_uom_qty <= 0: continue if line.x_fc_adp_approved: continue # Check if this has a valid ADP device code device_code = line._get_adp_device_code() if device_code and ADPDevice.search_count([('device_code', '=', device_code), ('active', '=', True)]) > 0: unapproved |= line return unapproved def _get_approved_devices_summary(self): """Get a summary of approved vs unapproved devices.""" self.ensure_one() lines_with_codes = self.order_line.filtered( lambda l: not l.display_type and l.product_id and l.product_uom_qty > 0 and l._get_adp_device_code() ) approved = lines_with_codes.filtered(lambda l: l.x_fc_adp_approved) unapproved = lines_with_codes - approved return { 'total': len(lines_with_codes), 'approved': len(approved), 'unapproved': len(unapproved), 'unapproved_lines': unapproved, } def action_mark_client_paid(self): """Mark order as client paid (25%).""" for order in self: if order._is_adp_sale(): order.x_fc_adp_status = 'client_paid' return True def action_mark_delivered(self): """Mark order as delivered.""" for order in self: if order._is_adp_sale(): order.x_fc_adp_status = 'delivered' return True def action_mark_billed(self): """Mark order as billed to ADP (75%).""" for order in self: if order._is_adp_sale(): order.x_fc_adp_status = 'billed' return True def action_mark_closed(self): """Mark order as closed.""" for order in self: if order._is_adp_sale(): order.x_fc_adp_status = 'closed' return True # ========================================================================== # VIEW INVOICES BY TYPE (Smart button actions) # ========================================================================== def action_view_adp_invoices(self): """Open list of ADP portion invoices for this order.""" self.ensure_one() adp_invoices = self.env['account.move'].sudo().search([ ('x_fc_source_sale_order_id', '=', self.id), ('x_fc_adp_invoice_portion', '=', 'adp'), ('state', '!=', 'cancel'), ]) # Include manually mapped ADP invoice if self.x_fc_adp_invoice_id and self.x_fc_adp_invoice_id.state != 'cancel': adp_invoices |= self.x_fc_adp_invoice_id action = { 'name': 'ADP Invoices', 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [('id', 'in', adp_invoices.ids)], 'context': {'default_move_type': 'out_invoice'}, } if len(adp_invoices) == 1: action['view_mode'] = 'form' action['res_id'] = adp_invoices.id return action def action_view_client_invoices(self): """Open list of Client portion invoices for this order.""" self.ensure_one() client_invoices = self.env['account.move'].sudo().search([ ('x_fc_source_sale_order_id', '=', self.id), ('x_fc_adp_invoice_portion', '=', 'client'), ('state', '!=', 'cancel'), ]) # Include manually mapped Client invoice if self.x_fc_client_invoice_id and self.x_fc_client_invoice_id.state != 'cancel': client_invoices |= self.x_fc_client_invoice_id action = { 'name': 'Client Invoices', 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [('id', 'in', client_invoices.ids)], 'context': {'default_move_type': 'out_invoice'}, } if len(client_invoices) == 1: action['view_mode'] = 'form' action['res_id'] = client_invoices.id return action # ========================================================================== # SPLIT INVOICE CREATION (Client 25% and ADP 75%) # ========================================================================== def action_create_client_invoice(self): """Create invoice for client portion (25% for REG clients, 0% for others). NOTE: Client invoice can be created WITHOUT device verification. This allows clients to pay their portion and receive products before ADP approval. Device verification is only required for ADP invoice creation. BLOCKING for Modification reasons: - Modifications to NON-ADP Equipment and Modifications to ADP Equipment require ADP approval before client invoice can be created. WARNING for Replacement reasons: - If previous funding was less than 5 years ago, show warning about possible deductions. """ self.ensure_one() if not self._is_adp_sale(): return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Not an ADP Sale', 'message': 'Client invoices are only for ADP sales.', 'type': 'warning', } } client_type = self._get_client_type() if client_type != 'REG': return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'No Client Portion', 'message': f'{client_type} clients have 100% ADP funding. No client invoice needed.', 'type': 'info', } } # ================================================================= # BLOCKING: Modification reasons require ADP approval first # ================================================================= reason = self.x_fc_reason_for_application status = self.x_fc_adp_application_status if reason in ('mod_non_adp', 'mod_adp'): if status not in ('approved', 'approved_deduction'): reason_label = dict(self._fields['x_fc_reason_for_application'].selection or []).get( reason, reason ) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'ADP Approval Required', 'message': f'Cannot create client invoice for "{reason_label}".\n\n' f'ADP application must be approved before creating invoices for modification requests.\n\n' f'Current status: {status or "Not set"}', 'type': 'danger', 'sticky': True, } } # ================================================================= # WARNING: Replacement reasons with <5 years funding # ================================================================= if reason in ('replace_status', 'replace_size', 'replace_worn') and self.x_fc_under_5_years: years = self.x_fc_years_since_funding # Show warning but allow proceeding self.message_post( body=Markup( '' ), message_type='notification', subtype_xmlid='mail.mt_note', ) # NO VERIFICATION CHECK - Client invoice can be created before ADP approval # User will need to complete verification before creating ADP invoice # Create client invoice (25% portion) invoice = self._create_adp_split_invoice(invoice_type='client') if invoice: result = { 'name': 'Client Invoice (25%)', 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'res_id': invoice.id, 'view_mode': 'form', 'target': 'current', } return result return True def action_create_adp_invoice(self): """Create invoice for ADP portion (75% for REG clients, 100% for others). NOTE: Device verification MUST be completed before creating ADP invoice. Verification can be done from the Sales Order or from the Client Invoice (if created first). Proof of Delivery is REQUIRED before ADP invoice creation. """ self.ensure_one() if not self._is_adp_sale(): return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Not an ADP Sale', 'message': 'ADP invoices are only for ADP sales.', 'type': 'warning', } } # ================================================================= # REQUIREMENT: Proof of Delivery must be uploaded # ================================================================= if not self.x_fc_proof_of_delivery: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Proof of Delivery Required', 'message': 'Please upload the Proof of Delivery document in the ADP Documents tab before creating the ADP invoice.', 'type': 'danger', 'sticky': True, } } # ================================================================= # REQUIREMENT: Device verification MUST be complete for ADP invoice # ================================================================= if not self.x_fc_device_verification_complete: # Check if there's a client invoice - provide appropriate message client_invoice = self.env['account.move'].sudo().search([ ('x_fc_source_sale_order_id', '=', self.id), ('x_fc_adp_invoice_portion', '=', 'client'), ('state', '!=', 'cancel'), ], limit=1) device_count = self.x_fc_total_device_count approved_count = self.x_fc_approved_device_count if device_count > 0: device_info = f'{approved_count}/{device_count} devices verified.' else: device_info = 'No ADP devices detected on this order.' if client_invoice: message = ( f'Cannot create ADP invoice: Device verification is not complete.\n\n' f'{device_info}\n\n' f'Please complete verification from either:\n' f'• This Sales Order: Click "Verify Device Approval"\n' f'• Client Invoice ({client_invoice.name}): Click "Verify Device Approval"' ) else: message = ( f'Cannot create ADP invoice: Device verification is not complete.\n\n' f'{device_info}\n\n' f'Click "Verify Device Approval" to review which devices were approved by ADP.' ) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Device Verification Required', 'message': message, 'type': 'danger', 'sticky': True, } } # ================================================================= # REQUIREMENT: At least one device must be approved for ADP invoice # ================================================================= approved_count = self.x_fc_approved_device_count total_count = self.x_fc_total_device_count if approved_count == 0: if total_count > 0: message = ( f'Cannot create ADP invoice: No devices are approved.\n\n' f'All {total_count} device(s) on this order were marked as NOT approved by ADP.\n\n' f'If this is incorrect, click "Verify Device Approval" to update the approval status.' ) else: message = ( 'Cannot create ADP invoice: No ADP-funded devices found.\n\n' 'This order has no products with ADP device codes. ' 'ADP invoices can only be created for orders with approved ADP devices.' ) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'No Approved Devices', 'message': message, 'type': 'danger', 'sticky': True, } } # Check for unapproved devices - show info message but allow creation # Unapproved items will be excluded from the ADP invoice unapproved = self._check_unapproved_devices() unapproved_message = None if unapproved: device_names = ', '.join(unapproved.mapped('product_id.name')[:3]) if len(unapproved) > 3: device_names += f' and {len(unapproved) - 3} more' unapproved_message = f"Note: {len(unapproved)} unapproved item(s) will be excluded: {device_names}" # Create ADP invoice invoice = self._create_adp_split_invoice(invoice_type='adp') if invoice: return { 'name': 'ADP Invoice', 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'res_id': invoice.id, 'view_mode': 'form', 'target': 'current', } return True def _create_adp_split_invoice(self, invoice_type='client'): """ Create a split invoice for ADP sales. Args: invoice_type: 'client' for client portion invoice, 'adp' for ADP portion invoice Returns: account.move record """ self.ensure_one() # Re-read fresh data from the database to get current values self.invalidate_recordset() # Get client type to determine percentages client_type = self._get_client_type() if client_type == 'REG': client_pct = 0.25 adp_pct = 0.75 else: # ODS, OWP, ACS, etc. - 100% ADP, 0% client client_pct = 0.0 adp_pct = 1.0 if invoice_type == 'client' and client_pct == 0: return False # No client invoice for non-REG clients # Determine invoice label if invoice_type == 'client': invoice_name_suffix = f' (Client {int(client_pct*100)}%)' else: invoice_name_suffix = f' (ADP {int(adp_pct*100)}%)' # Prepare base invoice values invoice_vals = self._prepare_invoice() invoice_vals['invoice_origin'] = f"{self.name}{invoice_name_suffix}" # Add marker for invoice type and link to source sale order invoice_vals['x_fc_adp_invoice_portion'] = invoice_type invoice_vals['x_fc_source_sale_order_id'] = self.id # Copy Studio fields if they exist on the invoice model # Use helper function to safely set values AccountMove = self.env['account.move'] def safe_set_field(field_name, value, target_dict): """Safely set a field value, checking field type and valid options.""" if field_name not in AccountMove._fields: return field = AccountMove._fields[field_name] try: if field.type == 'boolean': # Convert to boolean target_dict[field_name] = bool(value) elif field.type == 'selection': # Check if value is valid selection = field.selection if callable(selection): selection = selection(AccountMove) valid_values = [s[0] for s in selection] if selection else [] if value in valid_values: target_dict[field_name] = value elif str(value).lower() in [v.lower() for v in valid_values if isinstance(v, str)]: # Find the matching case for v in valid_values: if isinstance(v, str) and v.lower() == str(value).lower(): target_dict[field_name] = v break elif field.type == 'many2one': # Handle Many2one - pass record id or False if hasattr(value, 'id'): target_dict[field_name] = value.id elif value: target_dict[field_name] = value else: # Char, Text, etc. - just set directly target_dict[field_name] = value except Exception: pass # Skip if any error # Invoice type will be set based on invoice_type parameter ('adp' or 'client') # and the sale type - handled below when setting x_fc_invoice_type # Copy primary serial to invoice primary_serial = self.x_fc_primary_serial if primary_serial: invoice_vals['x_fc_primary_serial'] = primary_serial # Set invoice type based on invoice_type parameter and sale type if invoice_type == 'client': # Client portion invoice - set to 'adp_client' fc_invoice_type = 'adp_client' else: # ADP/Funder portion invoice - use the sale type directly # This preserves the sale type context (ADP, ADP/ODSP, ODSP, WSIB, etc.) sale_type = self.x_fc_sale_type or 'adp' # For ADP-related sale types, use the sale type as invoice type fc_invoice_type = sale_type # For ADP invoices: Change customer to ADP contact, keep original client as delivery address original_partner = self.partner_id original_delivery = self.partner_shipping_id or self.partner_id # Find the ADP contact (search by name) adp_partner = self.env['res.partner'].sudo().search([ '|', '|', '|', ('name', 'ilike', 'ADP (Assistive Device Program)'), ('name', 'ilike', 'Assistive Device Program'), ('name', '=', 'ADP'), ('name', 'ilike', 'ADP -'), ], limit=1) if adp_partner: # Set ADP as the invoice customer invoice_vals['partner_id'] = adp_partner.id # Keep original client as delivery address invoice_vals['partner_shipping_id'] = original_partner.id invoice_vals.update({ 'x_fc_invoice_type': fc_invoice_type, 'x_fc_client_type': self._get_client_type(), 'x_fc_claim_number': self.x_fc_claim_number, 'x_fc_client_ref_1': self.x_fc_client_ref_1, 'x_fc_client_ref_2': self.x_fc_client_ref_2, 'x_fc_adp_delivery_date': self.x_fc_adp_delivery_date, 'x_fc_service_start_date': self.x_fc_service_start_date, 'x_fc_service_end_date': self.x_fc_service_end_date, 'x_fc_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False, # Set ADP billing status to 'waiting' by default for ADP invoices 'x_fc_adp_billing_status': 'waiting' if invoice_type == 'adp' else 'not_applicable', }) # Create invoice invoice = self.env['account.move'].sudo().create(invoice_vals) # Create invoice lines - include sections, notes, AND products # # PORTION CALCULATION: # - Get ADP price from device codes database (priority) or product field # - Calculate portions based on client type: REG = 75%/25%, Others = 100%/0% # - Client Invoice: price_unit = client portion per unit # - ADP Invoice: price_unit = ADP portion per unit # - Both portions stored on line for reference # ADPDevice = self.env['fusion.adp.device.code'].sudo() invoice_lines = [] price_mismatches = [] # Track products with price mismatches for line in self.order_line: # For section and note lines, create minimal line if line.display_type in ('line_section', 'line_note'): invoice_lines.append({ 'move_id': invoice.id, 'display_type': line.display_type, 'name': line.name, 'sequence': line.sequence, }) continue # Skip lines without products or zero quantity if not line.product_id or line.product_uom_qty <= 0: continue # ================================================================= # CHECK 1: Is this a NON-ADP funded product? # ================================================================= is_non_adp_funded = line.product_id.is_non_adp_funded() # ================================================================= # CHECK 2: Get ADP device info from database # ================================================================= device_code = line._get_adp_device_code() adp_device = None is_adp_device = False db_adp_price = 0 if device_code and not is_non_adp_funded: adp_device = ADPDevice.search([ ('device_code', '=', device_code), ('active', '=', True) ], limit=1) is_adp_device = bool(adp_device) if adp_device: db_adp_price = adp_device.adp_price or 0 # Determine if item is approved is_approved = line.x_fc_adp_approved # ================================================================= # GET ADP PRICE - Priority: DB > Product Field > Line Price # ================================================================= product_tmpl = line.product_id.product_tmpl_id product_adp_price = 0 # Try product fields if hasattr(product_tmpl, 'x_fc_adp_price'): product_adp_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0 # Determine final ADP price to use if db_adp_price > 0: adp_max_price = db_adp_price # Check for price mismatch if product_adp_price > 0 and abs(db_adp_price - product_adp_price) > 0.01: price_mismatches.append({ 'product': line.product_id, 'device_code': device_code, 'db_price': db_adp_price, 'product_price': product_adp_price, }) elif product_adp_price > 0: adp_max_price = product_adp_price else: # Fallback to selling price adp_max_price = line.price_unit # ================================================================= # CALCULATE PORTIONS based on client type # ================================================================= qty = line.product_uom_qty total_adp_base = adp_max_price * qty if is_non_adp_funded or not is_adp_device: # NON-ADP item: Client pays 100% adp_portion = 0 client_portion = line.price_subtotal is_full_client = True elif is_adp_device and not is_approved: # ADP device NOT approved: Client pays 100% adp_portion = 0 client_portion = line.price_subtotal is_full_client = True else: # ADP device APPROVED: Calculate based on client type is_full_client = False if client_type == 'REG': adp_portion = total_adp_base * 0.75 client_portion = total_adp_base * 0.25 else: # ODS, OWP, ACS, etc. = 100% ADP adp_portion = total_adp_base client_portion = 0 # Apply deductions if any if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value: # PCT deduction: ADP pays X% of their normal portion effective_pct = line.x_fc_deduction_value / 100 if client_type == 'REG': adp_portion = total_adp_base * 0.75 * effective_pct else: adp_portion = total_adp_base * effective_pct client_portion = total_adp_base - adp_portion elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value: # AMT deduction: Fixed amount deducted from ADP adp_portion = max(0, adp_portion - line.x_fc_deduction_value) client_portion = total_adp_base - adp_portion # ================================================================= # DETERMINE INVOICE LINE AMOUNT # ================================================================= if invoice_type == 'client': if is_full_client: portion_amount = client_portion line_name = line.name if not (is_adp_device and not is_approved) else f"{line.name} [NOT APPROVED - 100% Client]" else: portion_amount = client_portion line_name = line.name else: # ADP invoice if is_non_adp_funded or not is_adp_device or (is_adp_device and not is_approved): # Skip from ADP invoice continue portion_amount = adp_portion line_name = line.name # Calculate adjusted price per unit adjusted_price = portion_amount / qty if qty else 0 # Build invoice line vals line_vals = { 'move_id': invoice.id, 'product_id': line.product_id.id, 'name': line_name, 'quantity': qty, 'product_uom_id': line.product_uom_id.id, 'price_unit': adjusted_price, 'discount': line.discount, 'tax_ids': [(6, 0, line.tax_ids.ids)], 'sale_line_ids': [(6, 0, [line.id])], 'sequence': line.sequence, } # Copy serial number and other fields if 'x_fc_serial_number' in self.env['account.move.line']._fields: line_vals['x_fc_serial_number'] = line.x_fc_serial_number if 'x_fc_device_placement' in self.env['account.move.line']._fields: line_vals['x_fc_device_placement'] = line.x_fc_device_placement # Store BOTH portions on invoice line (for display) if 'x_fc_adp_portion' in self.env['account.move.line']._fields: line_vals['x_fc_adp_portion'] = adp_portion if 'x_fc_client_portion' in self.env['account.move.line']._fields: line_vals['x_fc_client_portion'] = client_portion if 'x_fc_adp_max_price' in self.env['account.move.line']._fields: line_vals['x_fc_adp_max_price'] = adp_max_price if 'x_fc_adp_approved' in self.env['account.move.line']._fields: line_vals['x_fc_adp_approved'] = is_approved if 'x_fc_adp_device_type' in self.env['account.move.line']._fields and adp_device: line_vals['x_fc_adp_device_type'] = adp_device.device_type or '' invoice_lines.append(line_vals) if invoice_lines: self.env['account.move.line'].sudo().create(invoice_lines) # ================================================================= # POST PRICE MISMATCH WARNINGS # ================================================================= if price_mismatches: mismatch_msg = '

⚠️ ADP Price Mismatches Detected

    ' for pm in price_mismatches: mismatch_msg += ( f'
  • {pm["product"].name} ({pm["device_code"]}): ' f'Product price ${pm["product_price"]:.2f} vs Database ${pm["db_price"]:.2f}
  • ' ) mismatch_msg += '

Database prices were used. Consider updating product prices.

' self.message_post(body=mismatch_msg, message_type='notification', subtype_xmlid='mail.mt_note') # Auto-update product prices from database for pm in price_mismatches: product_tmpl = pm['product'].product_tmpl_id if hasattr(product_tmpl, 'x_fc_adp_price'): product_tmpl.sudo().write({'x_fc_adp_price': pm['db_price']}) # ================================================================= # POST INVOICE CREATION MESSAGE TO CHATTER # ================================================================= if invoice and invoice_lines: # Calculate totals from the created invoice invoice.invalidate_recordset() invoice_total = invoice.amount_total # Calculate portion totals from the lines we just created total_adp_portion = sum( line_vals.get('x_fc_adp_portion', 0) for line_vals in invoice_lines if isinstance(line_vals, dict) and 'x_fc_adp_portion' in line_vals ) total_client_portion = sum( line_vals.get('x_fc_client_portion', 0) for line_vals in invoice_lines if isinstance(line_vals, dict) and 'x_fc_client_portion' in line_vals ) user_name = self.env.user.name create_date = fields.Date.today().strftime('%B %d, %Y') client_name = self.partner_id.name or 'N/A' if invoice_type == 'client': # Client Invoice - Blue theme using Bootstrap pct_label = f'{int(client_pct*100)}%' invoice_msg = Markup(f'''''') else: # ADP Invoice - Green theme using Bootstrap pct_label = f'{int(adp_pct*100)}%' invoice_msg = Markup(f'''''') self.message_post( body=invoice_msg, message_type='notification', subtype_xmlid='mail.mt_note', ) return invoice # ========================================================================== # OVERRIDE _get_invoiceable_lines TO INCLUDE ALL SECTIONS AND NOTES # ========================================================================== def _get_invoiceable_lines(self, final=False): """Override to ensure ALL sections and notes are included in invoices. Standard Odoo behavior only includes sections/notes if they have invoiceable product lines AFTER them. This causes warranty, refund policy, and other important sections at the end of the order to be dropped from invoices. This override includes all sections and notes regardless of position. """ # Get the standard invoiceable lines first invoiceable_lines = super()._get_invoiceable_lines(final) # Collect all section and note lines from the order all_display_lines = self.order_line.filtered( lambda l: l.display_type in ('line_section', 'line_subsection', 'line_note') ) # Add any sections/notes that weren't included by the standard method missing_display_lines = all_display_lines - invoiceable_lines if missing_display_lines: # Combine and sort by sequence to maintain order combined = invoiceable_lines | missing_display_lines return combined.sorted(key=lambda l: l.sequence) return invoiceable_lines # ========================================================================== # INVOICE PREPARATION (Copy ADP fields to Invoice) # ========================================================================== def _prepare_invoice(self): """Override to copy ADP fields to the invoice.""" vals = super()._prepare_invoice() if self._is_adp_sale(): # Normalize sale type to match x_fc_invoice_type selection (lowercase) sale_type_raw = self.x_fc_sale_type or '' sale_type_normalized = str(sale_type_raw).lower() if sale_type_raw else 'adp' valid_types = ('adp', 'adp_odsp', 'odsp', 'wsib', 'direct_private', 'insurance', 'march_of_dimes', 'muscular_dystrophy', 'other', 'rental') if sale_type_normalized not in valid_types: if 'adp' in sale_type_normalized: sale_type_normalized = 'adp' else: sale_type_normalized = 'other' vals.update({ 'x_fc_invoice_type': sale_type_normalized, 'x_fc_client_type': self.x_fc_client_type, 'x_fc_claim_number': self.x_fc_claim_number, 'x_fc_client_ref_1': self.x_fc_client_ref_1, 'x_fc_client_ref_2': self.x_fc_client_ref_2, 'x_fc_adp_delivery_date': self.x_fc_adp_delivery_date, 'x_fc_service_start_date': self.x_fc_service_start_date, 'x_fc_service_end_date': self.x_fc_service_end_date, 'x_fc_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False, }) return vals # ========================================================================== # DOCUMENT CHATTER POSTING # ========================================================================== def _post_document_to_chatter(self, field_name, document_label=None): """Post a document attachment to the chatter with a link. Args: field_name: The binary field name (e.g., 'x_fc_final_submitted_application') document_label: Optional label for the document (defaults to field string) """ self.ensure_one() # Map field names to filename fields filename_mapping = { 'x_fc_original_application': 'x_fc_original_application_filename', 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename', 'x_fc_final_submitted_application': 'x_fc_final_application_filename', 'x_fc_xml_file': 'x_fc_xml_filename', 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename', } data_field = field_name filename_field = filename_mapping.get(field_name, field_name + '_filename') data = getattr(self, data_field, None) original_filename = getattr(self, filename_field, None) or 'document' if not data: return # Get document label from field definition if not provided if not document_label: field_obj = self._fields.get(data_field) document_label = field_obj.string if field_obj else data_field # Check for existing attachments with same name for revision numbering existing_count = self.env['ir.attachment'].sudo().search_count([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('name', '=like', original_filename.rsplit('.', 1)[0] + '%'), ]) # Add revision number if this is a replacement if existing_count > 0 and '(replaced)' in (document_label or ''): # This is an old document being replaced - add revision number base_name, ext = original_filename.rsplit('.', 1) if '.' in original_filename else (original_filename, '') filename = f"R{existing_count}_{base_name}.{ext}" if ext else f"R{existing_count}_{base_name}" else: filename = original_filename # Create attachment with the original/revised filename attachment = self.env['ir.attachment'].sudo().create({ 'name': filename, 'datas': data, 'res_model': 'sale.order', 'res_id': self.id, }) # Post message with attachment (shows as native Odoo attachment with preview) user_name = self.env.user.name now = fields.Datetime.now() body = Markup("""

{label} uploaded by {user}

{timestamp}

""").format( label=document_label, user=user_name, timestamp=now.strftime('%Y-%m-%d %H:%M:%S') ) # Use attachment_ids to show as native attachment with preview capability self.message_post( body=body, message_type='notification', subtype_xmlid='mail.mt_note', attachment_ids=[attachment.id], ) return attachment # ========================================================================== # AUTOMATIC EMAIL SENDING # ========================================================================== def _is_email_notifications_enabled(self): """Check if email notifications are enabled in settings.""" ICP = self.env['ir.config_parameter'].sudo() return ICP.get_param('fusion_claims.enable_email_notifications', 'True').lower() in ('true', '1', 'yes') def _get_office_cc_emails(self): """Get office notification emails from company settings.""" company = self.company_id or self.env.company partners = company.sudo().x_fc_office_notification_ids return [p.email for p in partners if p.email] def _get_email_recipients(self, include_client=False, include_authorizer=True, include_sales_rep=True): """Get standard email recipients for ADP notifications. Returns dict with: - 'to': List of primary recipient emails - 'cc': List of CC recipient emails - 'office_cc': List of office CC emails from settings """ self.ensure_one() to_emails = [] cc_emails = [] # Get authorizer authorizer = self.x_fc_authorizer_id # Get sales rep sales_rep = self.user_id # Get client client = self.partner_id # Build recipient lists if include_client and client and client.email: to_emails.append(client.email) if include_authorizer and authorizer and authorizer.email: if to_emails: cc_emails.append(authorizer.email) else: to_emails.append(authorizer.email) if include_sales_rep and sales_rep and sales_rep.email: cc_emails.append(sales_rep.email) # Get office CC emails office_cc = self._get_office_cc_emails() return { 'to': to_emails, 'cc': cc_emails, 'office_cc': office_cc, 'authorizer': authorizer, 'sales_rep': sales_rep, 'client': client, } def _check_authorizer_portal_access(self): """Check if authorizer has logged into portal. Returns True if authorizer has a portal user with a password set. """ self.ensure_one() authorizer = self.x_fc_authorizer_id if not authorizer: return False # Find portal user for this partner portal_user = self.env['res.users'].sudo().search([ ('partner_id', '=', authorizer.id), ('share', '=', True), # Portal users have share=True ], limit=1) if not portal_user: return False # Check if user has logged in (has password and has login date) return bool(portal_user.login_date) def _build_case_detail_rows(self, include_amounts=False): """Build standard case detail rows for email templates.""" self.ensure_one() def fmt(d): return d.strftime('%B %d, %Y') if d else None rows = [ ('Case', self.name), ('Client', self.partner_id.name or 'N/A'), ('Claim Number', self.x_fc_claim_number or None), ('Client Ref 1', self.x_fc_client_ref_1 or None), ('Client Ref 2', self.x_fc_client_ref_2 or None), ('Assessment Date', fmt(self.x_fc_assessment_end_date)), ('Submission Date', fmt(self.x_fc_claim_submission_date)), ('Approval Date', fmt(self.x_fc_claim_approval_date)), ('Delivery Date', fmt(self.x_fc_adp_delivery_date)), ] if include_amounts: rows.extend([ ('ADP Portion', f'${self.x_fc_adp_portion_total or 0:,.2f}'), ('Client Portion', f'${self.x_fc_client_portion_total or 0:,.2f}'), ('Total', f'${self.amount_total:,.2f}'), ]) # Filter out None values return [(l, v) for l, v in rows if v is not None] def _email_chatter_log(self, label, email_to, email_cc=None, extra_lines=None): """Post a concise chatter note confirming an email was sent.""" lines = [f'
  • To: {email_to}
  • '] if email_cc: lines.append(f'
  • CC: {email_cc}
  • ') if extra_lines: for line in extra_lines: lines.append(f'
  • {line}
  • ') body = Markup( '' ) self.message_post(body=body, message_type='notification', subtype_xmlid='mail.mt_note') def _send_submission_email(self): """Send email when application is submitted with PDF and XML attachments.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) if not to_emails and not cc_emails: return False # Reuse existing field attachments (created by Odoo for attachment=True fields) # instead of creating duplicates attachments = [] attachment_names = [] Attachment = self.env['ir.attachment'].sudo() if self.x_fc_final_submitted_application: att = Attachment.search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('res_field', '=', 'x_fc_final_submitted_application'), ], order='id desc', limit=1) if att: attachments.append(att.id) attachment_names.append('Final ADP Application (PDF)') if self.x_fc_xml_file: att = Attachment.search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('res_field', '=', 'x_fc_xml_file'), ], order='id desc', limit=1) if att: attachments.append(att.id) attachment_names.append('XML Data File') client_name = recipients.get('client', self.partner_id).name or 'Client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name submission_date = self.x_fc_claim_submission_date.strftime('%B %d, %Y') if self.x_fc_claim_submission_date else 'Today' body_html = self._email_build( title='Application Submitted', summary=f'The ADP application for {client_name} has been submitted on {submission_date}.', email_type='info', sections=[('Case Details', self._build_case_detail_rows())], note='What happens next: The Assistive Devices Program will review the application. ' 'This typically takes 2-4 weeks. We will notify you as soon as we receive a decision.', attachments_note=', '.join(attachment_names) if attachment_names else None, button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_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: mail = self.env['mail.mail'].sudo().create({ 'subject': f'Application Submitted - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, 'attachment_ids': [(6, 0, attachments)] if attachments else False, }) mail.send() self._email_chatter_log('Application Submitted email sent', email_to, email_cc, [f'Attachments: {", ".join(attachment_names)}'] if attachment_names else None) _logger.info(f"Submission email sent for {self.name}") return True except Exception as e: _logger.error(f"Failed to send submission email for {self.name}: {e}") return False @api.model def _cron_send_application_reminders(self): """Cron job: Find assessments completed X days ago without application and send reminders.""" from datetime import timedelta if not self._is_email_notifications_enabled(): _logger.info("Email notifications disabled, skipping application reminders") return # Get reminder days from settings (default 4) ICP = self.env['ir.config_parameter'].sudo() reminder_days = int(ICP.get_param('fusion_claims.application_reminder_days', '4')) # Calculate target date (X days ago) target_date = fields.Date.today() - timedelta(days=reminder_days) # Find orders where: # - Assessment completed on target date (x_fc_assessment_end_date = target_date) # - Status is still 'waiting_for_application' (no application received yet) # - Not already reminded (we'll track this with x_fc_application_reminder_sent) orders = self.search([ ('x_fc_assessment_end_date', '=', target_date), ('x_fc_adp_application_status', 'in', ['waiting_for_application', 'assessment_completed']), ('x_fc_application_reminder_sent', '=', False), ]) _logger.info(f"Application reminder cron: Found {len(orders)} orders to remind (assessed on {target_date})") for order in orders: try: order._send_application_reminder_email() except Exception as e: _logger.error(f"Failed to send application reminder for {order.name}: {e}") def _send_application_reminder_email(self): """Send reminder to therapist to submit ADP application.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False authorizer = self.x_fc_authorizer_id if not authorizer or not authorizer.email: return False client_name = self.partner_id.name or 'the client' assessment_date = self.x_fc_assessment_end_date.strftime('%B %d, %Y') if self.x_fc_assessment_end_date else 'recently' sales_rep_name = self.user_id.name if self.user_id else 'The Sales Team' body_html = self._email_build( title='Application Reminder', summary=f'The assessment for {client_name} was completed on {assessment_date}. ' f'We have not yet received the ADP application documents.', email_type='attention', sections=[('Case Details', self._build_case_detail_rows())], note='Action needed: Please submit the completed ADP application ' '(including pages 11-12 signed by the client) so we can proceed with the claim submission.', note_color='#d69e2e', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) recipients = self._get_email_recipients(include_client=False, include_authorizer=True, include_sales_rep=True) all_cc = recipients.get('cc', []) + recipients.get('office_cc', []) email_cc = ', '.join(all_cc) if all_cc else '' try: mail = self.env['mail.mail'].sudo().create({ 'subject': f'Application Reminder - {client_name} - {self.name}', 'body_html': body_html, 'email_to': authorizer.email, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }) mail.send() self.with_context(skip_all_validations=True).write({'x_fc_application_reminder_sent': True}) self._email_chatter_log('Application Reminder sent', authorizer.email, email_cc) return True except Exception as e: _logger.error(f"Failed to send application reminder for {self.name}: {e}") return False @api.model def _cron_send_application_reminders_2(self): """Cron job: Send second reminder X days after first reminder was sent.""" from datetime import timedelta if not self._is_email_notifications_enabled(): _logger.info("Email notifications disabled, skipping second application reminders") return # Get reminder days from settings ICP = self.env['ir.config_parameter'].sudo() first_reminder_days = int(ICP.get_param('fusion_claims.application_reminder_days', '4')) second_reminder_days = int(ICP.get_param('fusion_claims.application_reminder_2_days', '4')) # Calculate target date: assessment_end_date + first_reminder_days + second_reminder_days total_days = first_reminder_days + second_reminder_days target_date = fields.Date.today() - timedelta(days=total_days) # Find orders where: # - Assessment completed on target date # - First reminder was sent # - Second reminder not yet sent # - Status still waiting for application orders = self.search([ ('x_fc_assessment_end_date', '=', target_date), ('x_fc_adp_application_status', 'in', ['waiting_for_application', 'assessment_completed']), ('x_fc_application_reminder_sent', '=', True), ('x_fc_application_reminder_2_sent', '=', False), ]) _logger.info(f"Second application reminder cron: Found {len(orders)} orders to remind (assessed on {target_date})") for order in orders: try: order._send_application_reminder_2_email() except Exception as e: _logger.error(f"Failed to send second application reminder for {order.name}: {e}") def _send_application_reminder_2_email(self): """Send second/follow-up reminder to therapist to submit ADP application.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False authorizer = self.x_fc_authorizer_id if not authorizer or not authorizer.email: return False client_name = self.partner_id.name or 'the client' assessment_date = self.x_fc_assessment_end_date.strftime('%B %d, %Y') if self.x_fc_assessment_end_date else 'recently' days_since = (fields.Date.today() - self.x_fc_assessment_end_date).days if self.x_fc_assessment_end_date else 'several' sales_rep_name = self.user_id.name if self.user_id else 'The Sales Team' body_html = self._email_build( title='Follow-up: Application Needed', summary=f'It has been {days_since} days since the assessment for ' f'{client_name} was completed on {assessment_date}. ' f'We have not yet received the ADP application.', email_type='attention', sections=[('Case Details', self._build_case_detail_rows())], note='Assessment validity: ADP assessments are valid for 90 days. ' 'To avoid delays or the need for a new assessment, please submit the application ' 'as soon as possible.', note_color='#d69e2e', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) all_cc = [] if self.user_id and self.user_id.email: all_cc.append(self.user_id.email) all_cc.extend(self._get_office_cc_emails()) email_cc = ', '.join(all_cc) if all_cc else '' try: mail = self.env['mail.mail'].sudo().create({ 'subject': f'Follow-up: Application Needed - {client_name} - {self.name}', 'body_html': body_html, 'email_to': authorizer.email, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }) mail.send() self.with_context(skip_all_validations=True).write({'x_fc_application_reminder_2_sent': True}) self._email_chatter_log('Follow-up Reminder sent', authorizer.email, email_cc, [f'Days since assessment: {days_since}']) return True except Exception as e: _logger.error(f"Failed to send second reminder for {self.name}: {e}") return False def _send_billed_summary_email(self): """Send summary email when order is billed to ADP.""" self.ensure_one() authorizer = self.x_fc_authorizer_id sales_rep = self.user_id email_list = [] if authorizer and authorizer.email: email_list.append(authorizer.email) if sales_rep and sales_rep.email: email_list.append(sales_rep.email) if not email_list: return False client_name = self.partner_id.name or 'Client' body_html = self._email_build( title='Billing Complete', summary=f'The ADP claim for {client_name} has been successfully billed.', email_type='success', sections=[ ('Case Details', self._build_case_detail_rows(include_amounts=True)), ], note='This case has been billed. Thank you for your collaboration.', note_color='#38a169', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=(sales_rep.name if sales_rep else None), ) email_to = ', '.join(email_list) try: mail = self.env['mail.mail'].sudo().create({ 'subject': f'Billing Complete - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'model': 'sale.order', 'res_id': self.id, }) mail.send() self._email_chatter_log('Billing Complete email sent', email_to) return True except Exception as e: _logger.error(f"Failed to send billed email for {self.name}: {e}") return False def _send_approval_email(self): """Send notification when ADP application is approved.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) if not to_emails: return False client_name = (recipients.get('client') or self.partner_id).name or 'Client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name is_deduction = self.x_fc_adp_application_status == 'approved_deduction' status_label = 'Approved with Deduction' if is_deduction else 'Approved' note_text = ( 'Next steps: Our team will be in touch shortly to schedule ' 'the delivery of your equipment.' ) if is_deduction: note_text = ( 'Note: This application was approved with a deduction. ' 'The final amounts may differ from the original request. Our team will ' 'contact you with the details and next steps for delivery.' ) body_html = self._email_build( title='Application Approved', summary=f'The ADP application for {client_name} has been ' f'{status_label.lower()}.', email_type='success', sections=[('Case Details', self._build_case_detail_rows(include_amounts=True))], note=note_text, note_color='#38a169', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) email_to = ', '.join(to_emails) email_cc = ', '.join(cc_emails) if cc_emails else '' try: self.env['mail.mail'].sudo().create({ 'subject': f'Application {status_label} - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }).send() self._email_chatter_log(f'Application {status_label} email sent', email_to, email_cc) return True except Exception as e: _logger.error(f"Failed to send approval email for {self.name}: {e}") return False def _send_denial_email(self): """Send notification when ADP application is denied (funding denied).""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) if not to_emails: return False client_name = (recipients.get('client') or self.partner_id).name or 'Client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name body_html = self._email_build( title='Application Update', summary=f'The ADP application for {client_name} was not approved at this time.', email_type='urgent', sections=[('Case Details', self._build_case_detail_rows())], note='Your options: You may request a detailed explanation, ' 'submit an appeal if you believe the decision was made in error, or explore ' 'alternative funding options. Our team is here to help.', note_color='#c53030', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) email_to = ', '.join(to_emails) email_cc = ', '.join(cc_emails) if cc_emails else '' try: self.env['mail.mail'].sudo().create({ 'subject': f'Application Update - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }).send() self._email_chatter_log('Application Denied email sent', email_to, email_cc) return True except Exception as e: _logger.error(f"Failed to send denial email for {self.name}: {e}") return False def _send_rejection_email(self): """Send notification when ADP rejects the submission (data errors, not funding denial).""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_email_recipients(include_client=False, include_authorizer=True, include_sales_rep=True) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) if not to_emails: return False client_name = self.partner_id.name or 'the client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name rejection_reason_labels = { 'name_correction': 'Name Correction Needed', 'healthcard_correction': 'Health Card Correction Needed', 'duplicate_claim': 'Duplicate Claim Exists', 'xml_format_error': 'XML Format/Validation Error', 'missing_info': 'Missing Required Information', 'other': 'Other', } rejection_reason = self.x_fc_rejection_reason or 'other' rejection_label = rejection_reason_labels.get(rejection_reason, rejection_reason) rejection_details = self.x_fc_rejection_reason_other or '' note_text = f'Reason: {rejection_label}' # PLACEHOLDER_REJECTION_START -- marker removed if rejection_details: note_text += f'
    {rejection_details}' body_html = self._email_build( title='Action Required: Submission Returned', summary=f'The ADP submission for {client_name} has been returned and needs correction.', email_type='urgent', sections=[('Case Details', self._build_case_detail_rows())], note=note_text, note_color='#c53030', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) email_to = ', '.join(to_emails) email_cc = ', '.join(cc_emails) if cc_emails else '' try: self.env['mail.mail'].sudo().create({ 'subject': f'Action Required: Submission Returned - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }).send() self._email_chatter_log('Submission Returned email sent', email_to, email_cc, [f'Reason: {rejection_label}']) return True except Exception as e: _logger.error(f"Failed to send rejection email for {self.name}: {e}") return False def _send_correction_needed_email(self, reason=None): """Send notification when ADP application needs correction.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) if not to_emails: return False client_name = self.partner_id.name or 'the client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name note_text = 'Action needed: Please review the application, make the necessary corrections, and resubmit.' if reason: note_text = f'Reason for correction: {reason}

    {note_text}' body_html = self._email_build( title='Correction Needed', summary=f'The ADP application for {client_name} requires corrections before resubmission.', email_type='attention', sections=[('Case Details', self._build_case_detail_rows())], note=note_text, note_color='#d69e2e', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) email_to = ', '.join(to_emails) email_cc = ', '.join(cc_emails) if cc_emails else '' try: self.env['mail.mail'].sudo().create({ 'subject': f'Correction Needed - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }).send() self._email_chatter_log('Correction Needed email sent', email_to, email_cc) return True except Exception as e: _logger.error(f"Failed to send correction email for {self.name}: {e}") return False def _send_case_closed_email(self): """Send summary email when case is closed.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) if not to_emails and not cc_emails: return False client_name = (recipients.get('client') or self.partner_id).name or 'Client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name body_html = self._email_build( title='Case Closed', summary=f'The ADP case for {client_name} has been completed and closed.', email_type='success', sections=[('Case Summary', self._build_case_detail_rows(include_amounts=True))], note='This case is now closed. All equipment has been delivered and billing is complete. ' 'Thank you for your collaboration throughout the process.', note_color='#38a169', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_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'Case Closed - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }).send() self._email_chatter_log('Case Closed email sent', email_to, email_cc) return True except Exception as e: _logger.error(f"Failed to send case closed email for {self.name}: {e}") return False def _send_withdrawal_email(self, reason=None): """Send notification when application is withdrawn.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) if not to_emails and not cc_emails: return False client_name = (recipients.get('client') or self.partner_id).name or 'Client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name note_text = 'This application has been withdrawn from the Assistive Devices Program.' if reason: note_text += f'
    Reason: {reason}' body_html = self._email_build( title='Application Withdrawn', summary=f'The ADP application for {client_name} has been withdrawn.', email_type='attention', sections=[('Case Details', self._build_case_detail_rows())], note=note_text, note_color='#d69e2e', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_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'Application Withdrawn - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }).send() self._email_chatter_log('Application Withdrawn email sent', email_to, email_cc) return True except Exception as e: _logger.error(f"Failed to send withdrawal email for {self.name}: {e}") return False def _send_ready_for_delivery_email(self, technicians=None, scheduled_datetime=None, notes=None): """Send notification when application is marked Ready for Delivery.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) # Add technician emails to CC if technicians: for tech in technicians: if hasattr(tech, 'email') and tech.email: cc_emails.append(tech.email) if not to_emails and not cc_emails: return False client_name = (recipients.get('client') or self.partner_id).name or 'Client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name # Build extra rows for delivery details detail_rows = self._build_case_detail_rows() if self.partner_shipping_id: addr = self.partner_shipping_id.contact_address or '' detail_rows.append(('Delivery Address', addr.replace('\n', ', '))) if technicians: tech_names = ', '.join(t.name for t in technicians if hasattr(t, 'name')) if tech_names: detail_rows.append(('Technician(s)', tech_names)) if scheduled_datetime: detail_rows.append(('Scheduled', str(scheduled_datetime))) note_text = 'Next steps: Our delivery team will contact you to confirm the delivery schedule.' if self.x_fc_early_delivery: note_text = ('Note: This is an early delivery (before final ADP approval). ' 'Our team will contact you to schedule.') if notes: note_text += f'
    Notes: {notes}' body_html = self._email_build( title='Ready for Delivery', summary=f'The equipment for {client_name} is ready for delivery.', email_type='success', sections=[('Delivery Details', detail_rows)], note=note_text, note_color='#38a169', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_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'Ready for Delivery - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }).send() _logger.info(f"Ready for delivery email sent for {self.name}") return True except Exception as e: _logger.error(f"Failed to send delivery email for {self.name}: {e}") return False def _send_on_hold_email(self, reason=None): """Send notification when application is put on hold.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) if not to_emails and not cc_emails: return False client_name = (recipients.get('client') or self.partner_id).name or 'Client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name note_text = 'This application has been placed on hold. We will resume processing as soon as possible.' if reason: note_text += f'
    Reason: {reason}' body_html = self._email_build( title='Application On Hold', summary=f'The ADP application for {client_name} has been placed on hold.', email_type='attention', sections=[('Case Details', self._build_case_detail_rows())], note=note_text, note_color='#d69e2e', button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_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'Application On Hold - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }).send() self._email_chatter_log('Application On Hold email sent', email_to, email_cc) return True except Exception as e: _logger.error(f"Failed to send on-hold email for {self.name}: {e}") return False # ========================================================================== # OVERRIDE WRITE # ========================================================================== def write(self, vals): """Override write to handle ADP status changes, date auto-population, and document tracking.""" from datetime import date as date_class # ================================================================= # VALIDATION BYPASS (for internal operations like crons, email tracking) # ================================================================= if self.env.context.get('skip_all_validations'): return super().write(vals) # ================================================================= # CASE LOCK CHECK # ================================================================= # If unlocking (setting x_fc_case_locked to False), allow it # Otherwise, if any order is locked, block changes to ADP fields if 'x_fc_case_locked' not in vals or vals.get('x_fc_case_locked') is True: # Fields that are always allowed to be modified even when locked always_allowed = { 'x_fc_case_locked', # Allow toggling the lock itself 'message_main_attachment_id', 'message_follower_ids', 'activity_ids', } # Check if any locked orders would have ADP fields modified adf_fields_being_changed = [k for k in vals.keys() if k.startswith('x_fc_') and k not in always_allowed] if adf_fields_being_changed: for order in self: if order.x_fc_case_locked: raise UserError( f"Cannot modify order {order.name}.\n\n" "This case is locked. Please unlock it first by toggling off the " "'Case Locked' switch in the ADP Order Trail tab." ) # ================================================================= # SALE TYPE LOCK CHECK # ================================================================= # Sale type is locked after application is submitted # Can be overridden by: context flag, sale type override setting, # OR the document lock override (setting + group) if 'x_fc_sale_type' in vals and not self.env.context.get('skip_sale_type_lock'): locked_statuses = [ 'submitted', 'accepted', 'rejected', 'resubmitted', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', ] ICP = self.env['ir.config_parameter'].sudo() allow_sale_type = ICP.get_param('fusion_claims.allow_sale_type_override', 'False').lower() in ('true', '1', 'yes') allow_doc_lock = ICP.get_param('fusion_claims.allow_document_lock_override', 'False').lower() in ('true', '1', 'yes') has_override_group = self.env.user.has_group('fusion_claims.group_document_lock_override') if not allow_sale_type and not (allow_doc_lock and has_override_group): for order in self: if order.x_fc_adp_application_status in locked_statuses: raise UserError( f"Cannot modify Sale Type on order {order.name}.\n\n" f"Sale Type is locked after the application has been submitted to ADP.\n" f"Current status: {order.x_fc_adp_application_status}\n\n" f"To modify, enable 'Allow Document Lock Override' in Settings\n" f"and ensure your user is in the 'Document Lock Override' group." ) # ================================================================= # DOCUMENT LOCKING BASED ON STATUS PROGRESSION # ================================================================= # Documents become locked at specific stages to prevent modification # Lock rules: # - Original Application & Signed Pages 11/12 → Lock when submitted or later # - Final Application & XML File → Lock when approved or later # - Approval Letter & Screenshots → Lock when billed (but tracked on change) # - Proof of Delivery → Lock when billed or later # Define document lock rules: field -> list of statuses where field is locked statuses_after_submitted = [ 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', ] statuses_after_approved = [ 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', ] statuses_after_billed = ['billed', 'case_closed'] document_lock_rules = { # Application documents - lock after submitted 'x_fc_original_application': statuses_after_submitted, 'x_fc_original_application_filename': statuses_after_submitted, 'x_fc_signed_pages_11_12': statuses_after_submitted, 'x_fc_signed_pages_filename': statuses_after_submitted, # Submission documents - lock after approved 'x_fc_final_submitted_application': statuses_after_approved, 'x_fc_final_application_filename': statuses_after_approved, 'x_fc_xml_file': statuses_after_approved, 'x_fc_xml_filename': statuses_after_approved, # Approval documents - lock after billed 'x_fc_approval_letter': statuses_after_billed, 'x_fc_approval_letter_filename': statuses_after_billed, # POD - lock after billed 'x_fc_proof_of_delivery': statuses_after_billed, 'x_fc_proof_of_delivery_filename': statuses_after_billed, } # Check if any locked documents are being modified # Skip check if: # - context has skip_document_lock_validation (for programmatic override) # - BOTH: the "Allow Document Lock Override" setting is ON # AND the user is in the "Document Lock Override" group can_override = False if not self.env.context.get('skip_document_lock_validation'): ICP_lock = self.env['ir.config_parameter'].sudo() override_enabled = ICP_lock.get_param( 'fusion_claims.allow_document_lock_override', 'False' ).lower() in ('true', '1', 'yes') if override_enabled: can_override = self.env.user.has_group('fusion_claims.group_document_lock_override') else: can_override = True if not can_override: for order in self: current_status = order.x_fc_adp_application_status or '' for field_name, locked_statuses in document_lock_rules.items(): if field_name in vals: if current_status in locked_statuses: old_value = getattr(order, field_name, None) new_value = vals.get(field_name) if old_value == new_value: continue if current_status in statuses_after_billed: lock_stage = "billed" elif current_status in statuses_after_approved: lock_stage = "approved" else: lock_stage = "submitted" field_label_map = { 'x_fc_original_application': 'Original ADP Application', 'x_fc_signed_pages_11_12': 'Signed Pages 11 & 12', 'x_fc_final_submitted_application': 'Final Submitted Application', 'x_fc_xml_file': 'XML File', 'x_fc_approval_letter': 'Approval Letter', 'x_fc_proof_of_delivery': 'Proof of Delivery', } field_label = field_label_map.get(field_name, field_name) raise UserError( f"Cannot modify '{field_label}' on order {order.name}.\n\n" f"This document is locked because the application status is '{current_status}'.\n" f"Documents are locked once the application reaches the '{lock_stage}' stage.\n\n" f"To modify this document:\n" f"1. The 'Allow Document Lock Override' setting must be enabled (Fusion Claims > Settings)\n" f"2. Your user must be in the 'Document Lock Override' group" ) # ================================================================= # DOCUMENT AUDIT TRAIL - Track all document changes # ================================================================= document_fields = [ 'x_fc_original_application', 'x_fc_signed_pages_11_12', 'x_fc_final_submitted_application', 'x_fc_xml_file', 'x_fc_proof_of_delivery', 'x_fc_approval_letter', ] doc_changes = {f: vals.get(f) for f in document_fields if f in vals and vals.get(f)} # Preserve old documents in chatter BEFORE they get replaced or deleted # This ensures document history is maintained for audit purposes document_labels = { 'x_fc_original_application': 'Original ADP Application', 'x_fc_signed_pages_11_12': 'Page 11 & 12 (Signed)', 'x_fc_final_submitted_application': 'Final Application', 'x_fc_xml_file': 'XML File', 'x_fc_proof_of_delivery': 'Proof of Delivery', 'x_fc_approval_letter': 'Approval Letter', } user_name = self.env.user.name change_timestamp = fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S') # Fields already handled by the needs_correction flow below (avoid duplicate posts) correction_handled = set() if vals.get('x_fc_adp_application_status') == 'needs_correction': correction_handled = {'x_fc_final_submitted_application', 'x_fc_xml_file'} for order in self: for field_name in document_fields: if field_name in vals and field_name not in correction_handled: old_data = getattr(order, field_name, None) new_data = vals.get(field_name) label = document_labels.get(field_name, field_name) if old_data and new_data: # REPLACEMENT: Old document being replaced with new one # Preserve old document in chatter as attachment order._post_document_to_chatter( field_name, f"{label} (replaced)" ) elif old_data and not new_data: # DELETION: Document is being deleted # Preserve the deleted document in chatter order._post_document_to_chatter( field_name, f"{label} (DELETED)" ) # Post deletion notice deletion_msg = Markup( '
    ' '

    ' ' Document Deleted

    ' '' '' f'' '' f'' '' f'' '
    Document:{label}
    Deleted By:{user_name}
    Time:{change_timestamp}
    ' '

    ' 'The deleted document has been preserved in the message above.

    ' '
    ' ) order.message_post( body=deletion_msg, message_type='notification', subtype_xmlid='mail.mt_note', ) # Track status changes for auto-actions new_app_status = vals.get('x_fc_adp_application_status') new_mod_status = vals.get('x_fc_mod_status') # Handle document correction flow - clear document fields and submission date when needs_correction if new_app_status == 'needs_correction': for order in self: # Post existing final application to chatter before clearing if order.x_fc_final_submitted_application: order._post_document_to_chatter('x_fc_final_submitted_application', 'Final Application (before correction)') if order.x_fc_xml_file: order._post_document_to_chatter('x_fc_xml_file', 'XML File (before correction)') # Clear the document fields AND submission date # Use _correction_cleared to prevent the audit trail from posting duplicates vals['x_fc_final_submitted_application'] = False vals['x_fc_final_application_filename'] = False vals['x_fc_xml_file'] = False vals['x_fc_xml_filename'] = False vals['x_fc_claim_submission_date'] = False # Post correction notice for order in self: order.message_post( body=Markup( '' ), message_type='notification', subtype_xmlid='mail.mt_note', ) # Auto-populate date fields based on status changes today = date_class.today() if new_app_status == 'assessment_scheduled' and 'x_fc_assessment_start_date' not in vals: vals['x_fc_assessment_start_date'] = today elif new_app_status == 'assessment_completed' and 'x_fc_assessment_end_date' not in vals: vals['x_fc_assessment_end_date'] = today # Auto-transition to 'waiting_for_application' vals['x_fc_adp_application_status'] = 'waiting_for_application' new_app_status = 'waiting_for_application' elif new_app_status in ('submitted', 'resubmitted') and 'x_fc_claim_submission_date' not in vals: vals['x_fc_claim_submission_date'] = today elif new_app_status == 'accepted' and 'x_fc_claim_acceptance_date' not in vals: vals['x_fc_claim_acceptance_date'] = today elif new_app_status in ('approved', 'approved_deduction') and 'x_fc_claim_approval_date' not in vals: vals['x_fc_claim_approval_date'] = today elif new_app_status == 'billed' and 'x_fc_billing_date' not in vals: vals['x_fc_billing_date'] = today # ================================================================= # REQUIRED FIELD VALIDATION based on status # ================================================================= # Note: UserError is imported at top of file # Helper to get field value (check vals first, then existing record) def get_val(order, field): if field in vals: return vals[field] return getattr(order, field, None) # Authorizer validation based on sale type and authorizer_required field # Always required for: adp, adp_odsp, wsib, march_of_dimes, muscular_dystrophy # Optional for: odsp, direct_private, insurance, other, rental (depends on x_fc_authorizer_required) # # IMPORTANT: Only validate when changing relevant fields, not on every write. # This prevents blocking unrelated saves when authorizer is missing. authorizer_related_fields = { 'x_fc_sale_type', 'x_fc_authorizer_id', 'x_fc_authorizer_required', 'x_fc_adp_application_status', # Also validate when changing ADP status } should_validate_authorizer = bool(authorizer_related_fields & set(vals.keys())) if should_validate_authorizer: always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy') optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental') for order in self: sale_type = get_val(order, 'x_fc_sale_type') auth_id = get_val(order, 'x_fc_authorizer_id') auth_required = get_val(order, 'x_fc_authorizer_required') if sale_type in always_auth_types: # Always required for these types if not auth_id: raise UserError("Authorizer is required for this sale type.") elif sale_type in optional_auth_types and auth_required == 'yes': # Required only if user selected "Yes" if not auth_id: raise UserError("Authorizer is required. You selected 'Yes' for Authorizer Required.") # Helper to check if previous funding date is required based on reason def requires_previous_funding(reason_val): """Return True if the reason requires previous funding date.""" exempt_reasons = ['first_access', 'mod_non_adp'] return reason_val and reason_val not in exempt_reasons # ================================================================= # STATUS TRANSITION VALIDATIONS # ================================================================= # All status changes to "controlled" statuses must go through dedicated # buttons/wizards. Direct dropdown/statusbar changes are blocked. # Use context flag 'skip_status_validation' to bypass when calling from wizards. if not self.env.context.get('skip_status_validation') and new_app_status: # Statuses that can ONLY be set via buttons/wizards # This ensures proper workflow tracking and validation controlled_statuses = { # Early workflow stages 'assessment_scheduled': 'Schedule Assessment', 'assessment_completed': 'Complete Assessment', 'waiting_for_application': 'Complete Assessment', 'application_received': 'Application Received', 'ready_submission': 'Ready for Submission', # Submission and approval stages 'submitted': 'Submit Application', 'accepted': 'Mark as Accepted', # New: ADP accepted submission 'rejected': 'Mark as Rejected', # New: ADP rejected submission 'resubmitted': 'Submit Application', 'approved': 'Mark as Approved', 'approved_deduction': 'Mark as Approved', # Delivery stage 'ready_delivery': 'Ready for Delivery', # Billing stages 'ready_bill': 'Ready to Bill', 'billed': 'Mark as Billed', 'case_closed': 'Close Case', # Special statuses (require reason wizard) 'on_hold': 'Put On Hold', 'withdrawn': 'Withdraw', 'denied': 'Denied', 'cancelled': 'Cancel', 'needs_correction': 'Needs Correction', } if new_app_status in controlled_statuses: button_name = controlled_statuses[new_app_status] raise UserError( f"To change status to this value, please use the '{button_name}' button.\n\n" f"Direct status changes are not allowed for workflow integrity." ) # ================================================================= # RESUMING FROM ON_HOLD: Check assessment validity (3 months) # ================================================================= for order in self: if order.x_fc_adp_application_status == 'on_hold' and new_app_status and new_app_status != 'on_hold': # Check if assessment is expired (more than 3 months old) if order.x_fc_assessment_expired: days_expired = (today - order.x_fc_assessment_end_date).days - 90 if order.x_fc_assessment_end_date else 0 order.message_post( body=Markup( '' ), message_type='notification', subtype_xmlid='mail.mt_note', ) raise UserError( f"Cannot resume from 'On Hold' - Assessment has expired!\n\n" f"The assessment was completed on {order.x_fc_assessment_end_date} and is now " f"{days_expired} days past the 3-month validity period.\n\n" f"A new assessment must be completed before proceeding." ) # assessment_scheduled: No special requirements (Assessment Start Date auto-populated) if new_app_status == 'assessment_completed': for order in self: missing = [] if not get_val(order, 'x_fc_assessment_start_date'): missing.append('Assessment Start Date') if not get_val(order, 'x_fc_assessment_end_date'): missing.append('Assessment End Date') if missing: raise UserError( f"Cannot change status to 'Assessment Completed'.\n\n" f"Required fields missing:\n• " + "\n• ".join(missing) ) elif new_app_status == 'application_received': for order in self: missing = [] # Only Assessment Start Date required at this stage if not get_val(order, 'x_fc_assessment_start_date'): missing.append('Assessment Start Date') if missing: raise UserError( f"Cannot change status to 'Application Received'.\n\n" f"Required fields missing:\n• " + "\n• ".join(missing) ) elif new_app_status == 'ready_submission': for order in self: missing = [] # Assessment dates if not get_val(order, 'x_fc_assessment_start_date'): missing.append('Assessment Start Date') if not get_val(order, 'x_fc_assessment_end_date'): missing.append('Assessment End Date') # Reason for application if not get_val(order, 'x_fc_reason_for_application'): missing.append('Reason for Application') # Client references and authorization date if not get_val(order, 'x_fc_client_ref_1'): missing.append('Client Reference 1') if not get_val(order, 'x_fc_client_ref_2'): missing.append('Client Reference 2') if not get_val(order, 'x_fc_claim_authorization_date'): missing.append('Claim Authorization Date') # Previous funding date if required by reason reason_val = get_val(order, 'x_fc_reason_for_application') if requires_previous_funding(reason_val): if not get_val(order, 'x_fc_previous_funding_date'): missing.append('Previous Funding Date') # Documents if not order.x_fc_original_application and not vals.get('x_fc_original_application'): missing.append('Original ADP Application') if not order.x_fc_signed_pages_11_12 and not vals.get('x_fc_signed_pages_11_12'): missing.append('Page 11 & 12 (Signed)') if missing: raise UserError( f"Cannot change status to 'Ready for Submission'.\n\n" f"Required fields/documents missing:\n• " + "\n• ".join(missing) ) elif new_app_status in ('submitted', 'resubmitted'): for order in self: missing = [] # Documents if not order.x_fc_final_submitted_application and not vals.get('x_fc_final_submitted_application'): missing.append('Final Submitted Application') if not order.x_fc_xml_file and not vals.get('x_fc_xml_file'): missing.append('XML File') # Fields if not get_val(order, 'x_fc_claim_submission_date'): missing.append('Claim Submission Date') if missing: raise UserError( f"Cannot change status to 'Application Submitted'.\n\n" f"Required fields/documents missing:\n• " + "\n• ".join(missing) ) elif new_app_status in ('approved', 'approved_deduction'): for order in self: missing = [] if not get_val(order, 'x_fc_claim_number'): missing.append('Claim Number') if not get_val(order, 'x_fc_claim_approval_date'): missing.append('Claim Approval Date') if missing: raise UserError( f"Cannot change status to 'Application Approved'.\n\n" f"Required fields missing:\n• " + "\n• ".join(missing) ) elif new_app_status == 'ready_bill': for order in self: missing = [] if not get_val(order, 'x_fc_adp_delivery_date'): missing.append('ADP Delivery Date') if not order.x_fc_proof_of_delivery and not vals.get('x_fc_proof_of_delivery'): missing.append('Proof of Delivery') if missing: raise UserError( f"Cannot change status to 'Ready to Bill'.\n\n" f"Required fields/documents missing:\n• " + "\n• ".join(missing) ) elif new_app_status == 'billed': for order in self: missing = [] if not get_val(order, 'x_fc_billing_date'): missing.append('Billing Date') if missing: raise UserError( f"Cannot change status to 'Billed to ADP'.\n\n" f"Required fields missing:\n• " + "\n• ".join(missing) ) elif new_app_status == 'case_closed': for order in self: missing = [] if not get_val(order, 'x_fc_billing_date'): missing.append('Billing Date') if missing: raise UserError( f"Cannot change status to 'Case Closed'.\n\n" f"Required fields missing:\n• " + "\n• ".join(missing) ) # ================================================================== # MARCH OF DIMES STATUS TRANSITION VALIDATIONS # ================================================================== if new_mod_status: for order in self: if not order._is_mod_sale() and order.x_fc_sale_type != 'march_of_dimes': continue if new_mod_status == 'contract_received': missing = [] if not get_val(order, 'x_fc_case_reference') and not vals.get('x_fc_case_reference'): missing.append('HVMP Reference Number') if missing: raise UserError( "Cannot change status to 'PCA Received'.\n\n" "Required:\n" + "\n".join(f"- {m}" for m in missing) ) elif new_mod_status == 'pod_submitted': if not order.x_fc_mod_proof_of_delivery and not vals.get('x_fc_mod_proof_of_delivery'): raise UserError( "Cannot change status to 'POD Sent'.\n\n" "Please upload the Proof of Delivery document first." ) result = super().write(vals) # Skip additional processing if we're in a sync operation (prevent infinite loops) if self.env.context.get('skip_sync'): return result # Post document uploads to chatter if doc_changes: for order in self: for field_name, data in doc_changes.items(): if data: order._post_document_to_chatter(field_name) # Auto-overlay POD signature onto SA Mobility approval form if 'x_fc_pod_signature' in vals and vals['x_fc_pod_signature'] and not self.env.context.get('skip_pod_signature_hook'): for order in self: try: order._apply_pod_signature_to_approval_form() except Exception as e: _logger.error("Failed to overlay POD signature for %s: %s", order.name, e) # Auto-advance SA Mobility from ready_delivery to delivered when POD is signed if (order.x_fc_odsp_division == 'sa_mobility' and order.x_fc_sa_status == 'ready_delivery'): order._odsp_advance_status( 'delivered', "Delivery completed. POD signature collected and SA form auto-signed.", ) # Handle status-based actions (emails and reminders) # skip_status_emails: suppress all status-triggered emails # (used when reverting status e.g. cancelled delivery task) if self.env.context.get('skip_status_emails'): new_app_status = None # Disable all email triggers below if new_app_status in ('submitted', 'resubmitted'): for order in self: order._send_submission_email() # Create submission history record submission_type = 'resubmission' if new_app_status == 'resubmitted' else 'initial' self.env['fusion.submission.history'].create_from_submission(order, submission_type=submission_type) elif new_app_status in ('approved', 'approved_deduction'): for order in self: order._send_approval_email() order._schedule_delivery_reminder() elif new_app_status == 'accepted': # 'Accepted' is internal tracking - no external email notification # But we record it in submission history for order in self: # Update the most recent pending submission to 'accepted' pending_submission = self.env['fusion.submission.history'].search([ ('sale_order_id', '=', order.id), ('result', '=', 'pending'), ], order='submission_date desc', limit=1) if pending_submission: pending_submission.update_result('accepted') elif new_app_status == 'rejected': # 'Rejected' - ADP rejected the submission, needs correction for order in self: order._send_rejection_email() # Update the most recent pending submission to 'rejected' pending_submission = self.env['fusion.submission.history'].search([ ('sale_order_id', '=', order.id), ('result', '=', 'pending'), ], order='submission_date desc', limit=1) if pending_submission: pending_submission.update_result( 'rejected', rejection_reason=order.x_fc_rejection_reason, rejection_details=order.x_fc_rejection_reason_other, ) elif new_app_status == 'denied': for order in self: order._send_denial_email() elif new_app_status == 'needs_correction': # Email sent from the wizard with the reason text, not here. # If called programmatically without the wizard, send without reason. if not self.env.context.get('skip_correction_email'): for order in self: order._send_correction_needed_email() elif new_app_status == 'case_closed': for order in self: order._send_case_closed_email() # ================================================================== # MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS # ================================================================== if new_mod_status and not self.env.context.get('skip_status_emails'): for order in self: if not order._is_mod_sale(): continue try: if new_mod_status == 'assessment_scheduled': order._send_mod_assessment_scheduled_email() order._send_mod_sms('assessment_scheduled') elif new_mod_status == 'assessment_completed': order._send_mod_assessment_completed_email() elif new_mod_status == 'quote_submitted': order._send_mod_quote_submitted_email() if not order.x_fc_case_submitted: order.with_context(skip_all_validations=True).write({ 'x_fc_case_submitted': fields.Date.today()}) elif new_mod_status == 'funding_approved': order._send_mod_funding_approved_email() order._send_mod_sms('funding_approved') if not order.x_fc_case_approved: order.with_context(skip_all_validations=True).write({ 'x_fc_case_approved': fields.Date.today()}) elif new_mod_status == 'funding_denied': order._send_mod_funding_denied_email() elif new_mod_status == 'contract_received': order._send_mod_contract_received_email() elif new_mod_status == 'in_production': order._send_mod_sms('initial_payment_received') elif new_mod_status == 'project_complete': order._send_mod_project_complete_email() order._send_mod_sms('project_complete') elif new_mod_status == 'pod_submitted': order._send_mod_pod_submitted_email() elif new_mod_status == 'case_closed': order._send_mod_case_closed_email() except Exception as e: _logger.error(f"MOD status email/sms failed for {order.name} ({new_mod_status}): {e}") # Check if we need to recalculate ICP = self.env['ir.config_parameter'].sudo() sale_type_field = ICP.get_param('fusion_claims.field_sale_type', 'x_fc_sale_type') client_type_field = ICP.get_param('fusion_claims.field_so_client_type', 'x_fc_client_type') trigger_fields = { 'x_fc_sale_type', 'x_fc_client_type', sale_type_field, client_type_field, } if trigger_fields & set(vals.keys()): for order in self: # Trigger recomputation of x_fc_is_adp_sale order._compute_is_adp_sale() # Trigger recalculation of ADP portions for line in order.order_line: line._compute_adp_portions() # Sync FC fields to invoices when relevant fields change sync_fields = { 'x_fc_claim_number', 'x_fc_client_ref_1', 'x_fc_client_ref_2', 'x_fc_adp_delivery_date', 'x_fc_authorizer_id', 'x_fc_client_type', 'x_fc_primary_serial', 'x_fc_service_start_date', 'x_fc_service_end_date', } if sync_fields & set(vals.keys()): for order in self: order._sync_fields_to_invoices() return result # ========================================================================== # FIELD SYNCHRONIZATION (SO -> Invoice) # ========================================================================== def _get_field_mappings(self): """Get field mappings from system parameters. Returns dict with SO and Invoice field mappings configured in Settings. """ ICP = self.env['ir.config_parameter'].sudo() return { # Sale Order field mappings 'so_claim_number': ICP.get_param('fusion_claims.field_so_claim_number', 'x_fc_claim_number'), 'so_client_ref_1': ICP.get_param('fusion_claims.field_so_client_ref_1', 'x_fc_client_ref_1'), 'so_client_ref_2': ICP.get_param('fusion_claims.field_so_client_ref_2', 'x_fc_client_ref_2'), 'so_delivery_date': ICP.get_param('fusion_claims.field_so_delivery_date', 'x_fc_adp_delivery_date'), 'so_authorizer': ICP.get_param('fusion_claims.field_so_authorizer', 'x_fc_authorizer_id'), 'so_client_type': ICP.get_param('fusion_claims.field_so_client_type', 'x_fc_client_type'), 'so_service_start': ICP.get_param('fusion_claims.field_so_service_start', 'x_fc_service_start_date'), 'so_service_end': ICP.get_param('fusion_claims.field_so_service_end', 'x_fc_service_end_date'), 'sol_serial': ICP.get_param('fusion_claims.field_sol_serial', 'x_fc_serial_number'), # Invoice field mappings 'inv_claim_number': ICP.get_param('fusion_claims.field_inv_claim_number', 'x_fc_claim_number'), 'inv_client_ref_1': ICP.get_param('fusion_claims.field_inv_client_ref_1', 'x_fc_client_ref_1'), 'inv_client_ref_2': ICP.get_param('fusion_claims.field_inv_client_ref_2', 'x_fc_client_ref_2'), 'inv_delivery_date': ICP.get_param('fusion_claims.field_inv_delivery_date', 'x_fc_adp_delivery_date'), 'inv_authorizer': ICP.get_param('fusion_claims.field_inv_authorizer', 'x_fc_authorizer_id'), 'inv_client_type': ICP.get_param('fusion_claims.field_inv_client_type', 'x_fc_client_type'), 'inv_service_start': ICP.get_param('fusion_claims.field_inv_service_start', 'x_fc_service_start_date'), 'inv_service_end': ICP.get_param('fusion_claims.field_inv_service_end', 'x_fc_service_end_date'), 'aml_serial': ICP.get_param('fusion_claims.field_aml_serial', 'x_fc_serial_number'), } def _get_field_value(self, record, field_name): """Safely get a field value from a record.""" if not field_name or field_name not in record._fields: return None value = getattr(record, field_name, None) # Handle Many2one fields - return id for writing if hasattr(value, 'id'): return value.id if value else False return value def _sync_fields_to_invoices(self): """Sync ADP fields from Sale Order to linked Invoices. Uses dynamic field mappings from Settings. """ mappings = self._get_field_mappings() for order in self: invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel') if not invoices: _logger.debug(f"No invoices found for order {order.name}") continue for invoice in invoices: vals = {} # Get source values from SO claim_number = order.x_fc_claim_number client_ref_1 = order.x_fc_client_ref_1 client_ref_2 = order.x_fc_client_ref_2 delivery_date = order.x_fc_adp_delivery_date authorizer_id = False if order.x_fc_authorizer_id: authorizer_id = order.x_fc_authorizer_id.id client_type = order.x_fc_client_type service_start = order.x_fc_service_start_date service_end = order.x_fc_service_end_date # Write to Invoice FC fields only (no Studio field writes) if claim_number: if 'x_fc_claim_number' in invoice._fields: vals['x_fc_claim_number'] = claim_number if client_ref_1: if 'x_fc_client_ref_1' in invoice._fields: vals['x_fc_client_ref_1'] = client_ref_1 if client_ref_2: if 'x_fc_client_ref_2' in invoice._fields: vals['x_fc_client_ref_2'] = client_ref_2 if delivery_date: if 'x_fc_adp_delivery_date' in invoice._fields: vals['x_fc_adp_delivery_date'] = delivery_date if authorizer_id: if 'x_fc_authorizer_id' in invoice._fields: vals['x_fc_authorizer_id'] = authorizer_id if client_type: if 'x_fc_client_type' in invoice._fields: vals['x_fc_client_type'] = client_type if service_start: if 'x_fc_service_start_date' in invoice._fields: vals['x_fc_service_start_date'] = service_start if service_end: if 'x_fc_service_end_date' in invoice._fields: vals['x_fc_service_end_date'] = service_end # Serial Number - sync from SO header to invoice header primary_serial = order.x_fc_primary_serial if primary_serial: vals['x_fc_primary_serial'] = primary_serial if vals: try: invoice.sudo().with_context(skip_sync=True).write(vals) _logger.debug(f"Synced fields to invoice {invoice.name}: {list(vals.keys())}") except Exception as e: _logger.warning(f"Failed to sync to invoice {invoice.name}: {e}") else: _logger.debug(f"No fields to sync to invoice {invoice.name}") # Sync serial numbers from SO lines to corresponding invoice lines order._sync_serial_numbers_to_invoices() def _sync_serial_numbers_to_invoices(self): """Sync serial numbers from SO lines to linked invoice lines. Uses dynamic field mappings from Settings. """ if self.env.context.get('skip_sync'): _logger.info("_sync_serial_numbers_to_invoices: skipped (skip_sync context)") return mappings = self._get_field_mappings() sol_serial_field = mappings.get('sol_serial', 'x_fc_serial_number') aml_serial_field = mappings.get('aml_serial', 'x_fc_serial_number') _logger.debug(f"_sync_serial_numbers_to_invoices: Starting. sol_field={sol_serial_field}, aml_field={aml_serial_field}") for order in self: _logger.debug(f" Processing SO {order.name}") for so_line in order.order_line: if so_line.display_type in ('line_section', 'line_note'): continue # Get serial from THIS SO line ONLY - no fallback to header # Each line syncs its OWN serial to corresponding invoice lines serial_value = None if sol_serial_field in so_line._fields: serial_value = getattr(so_line, sol_serial_field, None) # Skip if this line has no serial - don't use header fallback if not serial_value: continue _logger.debug(f" SO line {so_line.id}: serial={serial_value}") # Find linked invoice lines invoice_lines = self.env['account.move.line'].sudo().search([ ('sale_line_ids', 'in', so_line.id), ('move_id.state', '!=', 'cancel') ]) for inv_line in invoice_lines: vals = {} # Write to x_fc_serial_number on invoice line if 'x_fc_serial_number' in inv_line._fields: vals['x_fc_serial_number'] = serial_value if vals: try: inv_line.sudo().with_context(skip_sync=True).write(vals) _logger.debug(f" Synced serial '{serial_value}' to invoice line {inv_line.id} (inv {inv_line.move_id.name})") except Exception as e: _logger.error(f" Failed to sync serial to invoice line {inv_line.id}: {e}") def action_sync_adp_fields(self): """Manual action to sync all ADP fields to invoices.""" synced_invoices = 0 for order in self: # First sync Studio fields to FC fields on the SO itself order._sync_studio_to_fc_fields() # Then sync to invoices invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel') if invoices: order._sync_fields_to_invoices() synced_invoices += len(invoices) # Force refresh of the view return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Fields Synchronized', 'message': f'Synced ADP fields from {len(self)} sale order(s) to {synced_invoices} invoice(s). Please refresh the page to see updated values.', 'type': 'success', 'sticky': False, } } @api.model def _cron_sync_adp_fields(self): """Cron job to sync ADP fields from Sale Orders to Invoices. Processes all ADP sales created/modified in the last 7 days. Uses dynamic field mappings from Settings. """ from datetime import timedelta cutoff_date = fields.Datetime.now() - timedelta(days=7) # Get field mappings mappings = self._get_field_mappings() sale_type_field = self.env['ir.config_parameter'].sudo().get_param( 'fusion_claims.field_sale_type', 'x_fc_sale_type' ) # Build domain - check FC sale type fields domain = [('write_date', '>=', cutoff_date)] or_conditions = [] # Check FC sale type field if sale_type_field in self._fields: or_conditions.append((sale_type_field, 'in', ['adp', 'adp_odsp', 'ADP', 'ADP/ODSP'])) # Check claim number fields claim_field = mappings.get('so_claim_number', 'x_fc_claim_number') if claim_field in self._fields: or_conditions.append((claim_field, '!=', False)) # Combine with OR - each '|' must be a separate element in the domain list if or_conditions: # Add (n-1) OR operators for n conditions for _ in range(len(or_conditions) - 1): domain.append('|') # Add all conditions for cond in or_conditions: domain.append(cond) try: orders = self.search(domain) except Exception as e: _logger.error(f"Error searching for ADP orders: {e}") # Fallback to simpler search orders = self.search([ ('write_date', '>=', cutoff_date), ('invoice_ids', '!=', False), ]) synced_count = 0 error_count = 0 for order in orders: try: # Only sync if it's an ADP sale if order._is_adp_sale() or order.x_fc_claim_number: order._sync_studio_to_fc_fields() order._sync_fields_to_invoices() synced_count += 1 except Exception as e: error_count += 1 _logger.warning(f"Failed to sync order {order.name}: {e}") _logger.info(f"Fusion Claims sync complete: {synced_count} orders synced, {error_count} errors") return synced_count # ========================================================================== # EMAIL SEND OVERRIDE (Use ADP templates for ADP sales) # ========================================================================== def action_quotation_send(self): """Override to use ADP email template for ADP sales. When sending a quotation for an ADP sale, automatically selects the ADP landscape template instead of the default template. """ self.ensure_one() # Check if this is an ADP sale if self._is_adp_sale(): # Get the ADP template template_xmlid = 'fusion_claims.email_template_adp_quotation' if self.state in ('sale', 'done'): # Use sales order confirmation template for confirmed orders template_xmlid = 'fusion_claims.email_template_adp_sales_order' try: template = self.env.ref(template_xmlid, raise_if_not_found=False) if template: # Open the mail compose wizard with the ADP template pre-selected ctx = { 'default_model': 'sale.order', 'default_res_ids': self.ids, 'default_template_id': template.id, 'default_email_layout_xmlid': 'mail.mail_notification_layout', 'default_composition_mode': 'comment', 'mark_so_as_sent': True, 'force_email': True, 'model_description': self.with_context(lang=self.partner_id.lang).type_name, } return { 'type': 'ir.actions.act_window', 'res_model': 'mail.compose.message', 'view_mode': 'form', 'views': [(False, 'form')], 'target': 'new', 'context': ctx, } except Exception as e: _logger.warning(f"Could not load ADP email template: {e}") # Fall back to standard behavior for non-ADP sales return super().action_quotation_send() # ========================================================================== # ADP ACTIVITY REMINDER METHODS # ========================================================================== def _schedule_or_renew_adp_activity(self, activity_type_xmlid, user_id, date_deadline, summary, note=False): """Schedule or renew an ADP-related activity. If an activity of the same type for the same user already exists, update its deadline instead of creating a duplicate. Args: activity_type_xmlid: XML ID of the activity type user_id: ID of the user to assign the activity to date_deadline: Deadline date for the activity summary: Activity summary text note: Optional note text """ self.ensure_one() try: activity_type = self.env.ref(activity_type_xmlid) except ValueError: _logger.warning(f"Activity type not found: {activity_type_xmlid}") return # Search for existing activity of this type for this user existing = self.activity_ids.filtered( lambda a: a.activity_type_id.id == activity_type.id and a.user_id.id == user_id ) if existing: # Update existing activity existing[0].write({ 'date_deadline': date_deadline, 'summary': summary, 'note': note or existing[0].note, }) _logger.info(f"Renewed ADP activity for {self.name}: {summary} -> {date_deadline}") else: # Create new activity self.activity_schedule( activity_type_xmlid, date_deadline=date_deadline, summary=summary, note=note, user_id=user_id ) _logger.info(f"Scheduled new ADP activity for {self.name}: {summary} -> {date_deadline}") def _complete_adp_activities(self, activity_type_xmlid): """Complete all activities of a specific type for this record. Args: activity_type_xmlid: XML ID of the activity type to complete """ self.ensure_one() try: activity_type = self.env.ref(activity_type_xmlid) except ValueError: return activities = self.activity_ids.filtered( lambda a: a.activity_type_id.id == activity_type.id ) for activity in activities: activity.action_feedback(feedback='Completed automatically') _logger.info(f"Completed ADP activity for {self.name}: {activity.summary}") def _schedule_delivery_reminder(self): """Schedule a delivery reminder for the salesperson. Triggered when ADP application status changes to 'approved' or 'approved_deduction'. Reminds the salesperson to deliver the order by Tuesday of the next posting week. """ self.ensure_one() if not self._is_adp_sale(): return # Get the salesperson salesperson = self.user_id if not salesperson: _logger.warning(f"No salesperson assigned to {self.name}, cannot schedule delivery reminder") return # Calculate the next posting date and the Tuesday of that week next_posting = self._get_next_posting_date() reminder_date = self._get_posting_week_tuesday(next_posting) # Don't schedule if reminder date is in the past from datetime import date if reminder_date < date.today(): # Schedule for the next posting cycle next_posting = self._get_next_posting_date(next_posting) reminder_date = self._get_posting_week_tuesday(next_posting) summary = f"Deliver ADP order {self.name} for {next_posting.strftime('%b %d')} billing" note = f"Complete delivery by Tuesday to meet the Wednesday 6 PM submission deadline for the {next_posting.strftime('%B %d, %Y')} ADP posting." self._schedule_or_renew_adp_activity( 'fusion_claims.mail_activity_type_adp_delivery', salesperson.id, reminder_date, summary, note ) def _cron_renew_delivery_reminders(self): """Cron job to renew overdue delivery reminders. For sale orders with approved status that have overdue delivery activities, reschedule them to the next posting week's Tuesday. """ from datetime import date today = date.today() # Find approved orders with overdue delivery activities try: activity_type = self.env.ref('fusion_claims.mail_activity_type_adp_delivery') except ValueError: _logger.warning("ADP Delivery activity type not found") return # Find orders that are approved but not yet billed (delivery still pending) approved_orders = self.search([ ('x_fc_is_adp_sale', '=', True), ('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']), ]) for order in approved_orders: # Check if there's an overdue delivery activity overdue_activities = order.activity_ids.filtered( lambda a: a.activity_type_id.id == activity_type.id and a.date_deadline < today ) if overdue_activities: # Reschedule to next posting week order._schedule_delivery_reminder() _logger.info(f"Renewed overdue delivery reminder for {order.name}") def _cron_auto_close_billed_cases(self): """Cron job to automatically close cases 1 month after being billed. Finds all sale orders with 'billed' status where the billing date was more than 30 days ago, and automatically changes them to 'case_closed'. """ from datetime import date, timedelta today = date.today() cutoff_date = today - timedelta(days=30) # Find orders that are billed and have billing date > 30 days ago orders_to_close = self.search([ ('x_fc_is_adp_sale', '=', True), ('x_fc_adp_application_status', '=', 'billed'), ('x_fc_billing_date', '<=', cutoff_date), ]) for order in orders_to_close: try: # Use context to skip status validation for automated process order.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': 'case_closed', }) # Post to chatter days_since_billed = (today - order.x_fc_billing_date).days order.message_post( body=f'

    Case Automatically Closed

    ' f'

    This case has been automatically closed after {days_since_billed} days since billing.

    ' f'

    Billing Date: {order.x_fc_billing_date}

    ', message_type='notification', subtype_xmlid='mail.mt_note', ) _logger.info(f"Auto-closed case {order.name} after {days_since_billed} days since billing") except Exception as e: _logger.error(f"Failed to auto-close case {order.name}: {e}") @api.model def _cron_auto_close_odsp_paid_cases(self): """Auto-close ODSP/SA/OW cases 7 days after their final workflow step. SA Mobility & Standard ODSP: close 7 days after payment_received. Ontario Works: close 7 days after delivered (payment comes before delivery). """ from datetime import timedelta cutoff = fields.Datetime.now() - timedelta(days=7) orders = self.search([ ('x_fc_is_odsp_sale', '=', True), ('write_date', '<=', cutoff), '|', '|', '|', ('x_fc_sa_status', '=', 'payment_received'), ('x_fc_odsp_std_status', '=', 'payment_received'), ('x_fc_ow_status', '=', 'payment_received'), ('x_fc_ow_status', '=', 'delivered'), ]) closeable = {'payment_received', 'delivered'} for order in orders: status = order._get_odsp_status() if status not in closeable: continue if order.x_fc_odsp_division == 'ontario_works' and status != 'delivered': continue if order.x_fc_odsp_division != 'ontario_works' and status != 'payment_received': continue try: order._odsp_advance_status( 'case_closed', "Case automatically closed 7 days after %s." % status.replace('_', ' '), ) _logger.info(f"Auto-closed ODSP case {order.name}") except Exception as e: _logger.error(f"Failed to auto-close ODSP case {order.name}: {e}") @api.model def _cron_send_acceptance_reminders(self): """Cron job: Send reminders for orders still in 'submitted' status next business day. Per business rule: If 'Accepted by ADP' not marked within 1 business day after submission: - First email to Office Notification Recipients - Second email to Office + Sales Rep """ from datetime import timedelta if not self._is_email_notifications_enabled(): _logger.info("Email notifications disabled, skipping acceptance reminders") return today = fields.Date.today() # Find orders where: # - Status is still 'submitted' (not accepted, rejected, or later) # - Submission date was at least 1 business day ago # # For simplicity, we check if submission was 2+ days ago (covers weekends) cutoff_date = today - timedelta(days=2) orders = self.search([ ('x_fc_is_adp_sale', '=', True), ('x_fc_adp_application_status', '=', 'submitted'), ('x_fc_claim_submission_date', '<=', cutoff_date), ('x_fc_acceptance_reminder_sent', '=', False), ]) if not orders: _logger.info("Acceptance reminder cron: No orders require reminders") return _logger.info(f"Acceptance reminder cron: Found {len(orders)} orders to remind") # Get office notification emails from company company = self.env.company office_partners = company.sudo().x_fc_office_notification_ids office_emails = [p.email for p in office_partners if p.email] if not office_emails: _logger.warning("Acceptance reminder cron: No office notification recipients configured") return for order in orders: try: days_since_submission = (today - order.x_fc_claim_submission_date).days client_name = order.partner_id.name or 'Client' claim_number = order.x_fc_claim_number or 'N/A' order_name = order.name submission_date = order.x_fc_claim_submission_date.strftime('%B %d, %Y') sales_rep = order.user_id # Determine recipients if days_since_submission > 3: to_emails = office_emails.copy() if sales_rep and sales_rep.email: to_emails.append(sales_rep.email) reminder_type = "SECOND" else: to_emails = office_emails.copy() reminder_type = "FIRST" # Build email using the mixin builder level = 'Follow-up' if reminder_type == 'SECOND' else 'Pending' subject = f'{level} Review: Acceptance Status - {order_name}' body_html = order._email_build( title='Acceptance Status Pending', summary=f'The application for {client_name} was submitted on ' f'{submission_date} but has not been marked as accepted or rejected ' f'({days_since_submission} days pending).', email_type='attention', sections=[('Details', [ ('Case', order_name), ('Client', client_name), ('Claim Number', claim_number), ('Submitted', submission_date), ('Days Pending', f'{days_since_submission} days'), ])], note='Action needed: Please update the acceptance status in the system.', note_color='#d69e2e', button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form', button_text='Open Case', ) self.env['mail.mail'].sudo().create({ 'subject': subject, 'body_html': body_html, 'email_to': ', '.join(to_emails), 'model': 'sale.order', 'res_id': order.id, }).send() # Mark as sent so it won't resend on next cron run / restart order.with_context(skip_all_validations=True).write({ 'x_fc_acceptance_reminder_sent': True, }) _logger.info(f"Sent {reminder_type.lower()} acceptance reminder for {order.name}") except Exception as e: _logger.error(f"Failed to send acceptance reminder for {order.name}: {e}") # ====================================================================== # MARCH OF DIMES - WORKFLOW ACTION METHODS # ====================================================================== def action_mod_schedule_assessment(self): self.ensure_one() self.write({ 'x_fc_mod_status': 'assessment_scheduled', 'x_fc_mod_assessment_scheduled_date': fields.Date.today(), }) def action_mod_complete_assessment(self): self.ensure_one() self.write({ 'x_fc_mod_status': 'assessment_completed', 'x_fc_mod_assessment_completed_date': fields.Date.today(), }) def action_mod_processing_drawing(self): """Open wizard to attach drawing + photos and send quotation to MOD. If drawing/photos already exist, they are pre-loaded in the wizard. On confirm: saves to order, sets status to quote_submitted, sends email.""" self.ensure_one() # First set to processing_drawings self.with_context(skip_status_emails=True).write({'x_fc_mod_status': 'processing_drawings'}) # Open the wizard in drawing mode return { 'type': 'ir.actions.act_window', 'name': 'Attach Drawing and Send Quotation', 'res_model': 'fusion_claims.send.to.mod.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', 'mod_wizard_mode': 'drawing', }, } def action_mod_awaiting_funding(self): """Open wizard to record Application Submission Date before moving to awaiting funding.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': 'Application Submitted to March of Dimes', 'res_model': 'fusion_claims.mod.awaiting.funding.wizard', 'view_mode': 'form', 'target': 'new', 'context': {'active_id': self.id}, } def action_mod_funding_approved(self): """Open wizard to record case worker and HVMP reference on approval.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': 'Funding Approved', 'res_model': 'fusion_claims.mod.funding.approved.wizard', 'view_mode': 'form', 'target': 'new', 'context': {'active_id': self.id}, } def action_mod_funding_denied(self): self.ensure_one() self.write({'x_fc_mod_status': 'funding_denied'}) def action_mod_contract_received(self): """Open wizard to upload PCA document and record receipt.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': 'PCA Received', 'res_model': 'fusion_claims.mod.pca.received.wizard', 'view_mode': 'form', 'target': 'new', 'context': {'active_id': self.id}, } def action_mod_in_production(self): self.ensure_one() self.write({ 'x_fc_mod_status': 'in_production', 'x_fc_mod_production_started_date': fields.Date.today(), }) def action_mod_project_complete(self): self.ensure_one() self.write({ 'x_fc_mod_status': 'project_complete', 'x_fc_mod_project_completed_date': fields.Date.today(), }) def action_mod_pod_submitted(self): """Open wizard to attach completion photos + POD and send to case worker.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': 'Submit Completion Photos and POD', 'res_model': 'fusion_claims.send.to.mod.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', 'mod_wizard_mode': 'completion', }, } def action_mod_close_case(self): self.ensure_one() self.write({ 'x_fc_mod_status': 'case_closed', 'x_fc_mod_case_closed_date': fields.Date.today(), }) def action_mod_on_hold(self): self.ensure_one() self.write({'x_fc_mod_status': 'on_hold'}) def action_mod_resume(self): """Resume from on_hold - go back to in_production.""" self.ensure_one() self.write({'x_fc_mod_status': 'in_production'}) def action_cancel(self): """Override: also set MOD status to cancelled when order is cancelled.""" res = super().action_cancel() for order in self: if order._is_mod_sale() and order.x_fc_mod_status not in ('cancelled', False): order.with_context(skip_all_validations=True, skip_status_emails=True).write({ 'x_fc_mod_status': 'cancelled', }) return res def _get_mod_partner(self): """Find or create the March of Dimes partner for invoicing.""" ICP = self.env['ir.config_parameter'].sudo() mod_email = ICP.get_param('fusion_claims.mod_default_email', 'hvmp@marchofdimes.ca') partner = self.env['res.partner'].sudo().search([('email', '=', mod_email)], limit=1) if not partner: partner = self.env['res.partner'].sudo().create({ 'name': 'March of Dimes Canada (HVMP)', 'email': mod_email, 'is_company': True, }) return partner def _create_mod_invoice(self, partner_id, invoice_lines, portion_type='full', label=''): """Create a MOD invoice with given lines. Reusable for full/split.""" self.ensure_one() from odoo.fields import Command ICP = self.env['ir.config_parameter'].sudo() vendor_code = ICP.get_param('fusion_claims.mod_vendor_code', '') authorizer = self.x_fc_authorizer_id case_worker = self.x_fc_case_worker invoice = self.env['account.move'].sudo().create({ 'move_type': 'out_invoice', 'partner_id': partner_id, 'partner_shipping_id': self.partner_id.id, 'x_fc_source_sale_order_id': self.id, 'x_fc_invoice_type': 'march_of_dimes', 'x_fc_adp_invoice_portion': portion_type, 'x_fc_authorizer_id': authorizer.id if authorizer else False, 'x_fc_claim_number': self.x_fc_case_reference or '', 'ref': self.x_fc_case_reference or self.name, 'invoice_origin': f'{self.name}{label}', 'invoice_line_ids': invoice_lines, 'narration': Markup( f'HVMP Reference: {self.x_fc_case_reference or "N/A"}
    ' f'Client: {self.partner_id.name}
    ' f'Case Worker: {case_worker.name if case_worker else "N/A"}
    ' f'Sale Order: {self.name}
    ' f'Vendor Code: {vendor_code}' ), }) return invoice def action_send_to_mod(self): """Open the Send to March of Dimes wizard.""" self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': 'Send to March of Dimes', 'res_model': 'fusion_claims.send.to.mod.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'active_id': self.id, 'active_model': 'sale.order', }, } # --- MOD Document Preview Actions --- def action_open_mod_drawing(self): self.ensure_one() return self._action_open_document('x_fc_mod_drawing', 'Drawing') def action_open_mod_initial_photos(self): self.ensure_one() return self._action_open_image('x_fc_mod_initial_photos', 'Initial Photos') def action_open_mod_pca(self): self.ensure_one() return self._action_open_document('x_fc_mod_pca_document', 'PCA Document') def action_open_mod_pod(self): self.ensure_one() return self._action_open_document('x_fc_mod_proof_of_delivery', 'Proof of Delivery') def action_open_mod_completion_photos(self): self.ensure_one() return self._action_open_image('x_fc_mod_completion_photos', 'Completion Photos') def _action_open_image(self, field_name, label): """Open an image attachment in a new browser tab.""" self.ensure_one() if not getattr(self, field_name): return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'No File', 'message': f'No {label} uploaded yet.', 'type': 'warning', 'sticky': False, }, } attachment = self._get_or_create_attachment(field_name, label) if attachment: return { 'type': 'ir.actions.act_url', 'url': f'/web/content/{attachment.id}', 'target': 'new', } return {'type': 'ir.actions.act_window_close'} # ====================================================================== # MARCH OF DIMES - EMAIL METHODS # ====================================================================== def _build_mod_case_detail_rows(self, include_amounts=False): """Build case detail rows for MOD email templates.""" self.ensure_one() def fmt(d): return d.strftime('%B %d, %Y') if d else None status_label = dict(self._fields['x_fc_mod_status'].selection).get( self.x_fc_mod_status, self.x_fc_mod_status or '') rows = [ ('Case', self.name), ('Client', self.partner_id.name or 'N/A'), ('HVMP Reference', self.x_fc_case_reference or None), ('Status', status_label or None), ('Funding Approved', fmt(self.x_fc_case_approved)), ('Est. Completion', fmt(self.x_fc_estimated_completion_date)), ] if include_amounts: approved = self.x_fc_mod_approved_amount or 0 rows.extend([ ('Order Total', f'${self.amount_total:,.2f}'), ]) if approved: rows.append(('MOD Approved', f'${approved:,.2f}')) if approved < self.amount_total: rows.append(('Client Portion', f'${self.amount_total - approved:,.2f}')) return [(l, v) for l, v in rows if v is not None] def _mod_email_build(self, **kwargs): """Wrapper around _email_build that overrides the footer for MOD emails.""" # Build the email normally html = self._email_build(**kwargs) # Replace the footer text html = html.replace( 'This is an automated notification from the ADP Claims Management System.', 'This is an automated notification from the Accessibility Case Management System.', ) return html def _get_mod_email_recipients(self, include_client=True, include_authorizer=True, include_mod_contact=False, include_sales_rep=True): """Get email recipients for MOD notifications.""" self.ensure_one() to_emails = [] cc_emails = [] client = self.partner_id authorizer = self.x_fc_authorizer_id sales_rep = self.user_id if include_client and client and client.email: to_emails.append(client.email) if include_authorizer and authorizer and authorizer.email: if to_emails: cc_emails.append(authorizer.email) else: to_emails.append(authorizer.email) if include_mod_contact and self.x_fc_mod_contact_email: cc_emails.append(self.x_fc_mod_contact_email) if include_sales_rep and sales_rep and sales_rep.email: cc_emails.append(sales_rep.email) office_cc = self._get_office_cc_emails() return { 'to': to_emails, 'cc': cc_emails, 'office_cc': office_cc, 'authorizer': authorizer, 'sales_rep': sales_rep, 'client': client, } def _send_mod_email(self, subject_prefix, title, summary, email_type='info', include_client=True, include_authorizer=True, include_mod_contact=False, include_sales_rep=True, sections=None, note=None, note_color=None, attachments=None, attachment_names=None): """Generic MOD email sender to avoid repeating boilerplate.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False recipients = self._get_mod_email_recipients( include_client=include_client, include_authorizer=include_authorizer, include_mod_contact=include_mod_contact, include_sales_rep=include_sales_rep) to_emails = recipients.get('to', []) cc_emails = recipients.get('cc', []) + recipients.get('office_cc', []) if not to_emails and not cc_emails: return False client_name = (recipients.get('client') or self.partner_id).name or 'Client' sender_name = (recipients.get('sales_rep') or self.env.user).name body_html = self._mod_email_build( title=title, summary=summary, email_type=email_type, sections=sections or [('Case Details', self._build_mod_case_detail_rows())], note=note, note_color=note_color, button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sender_name, attachments_note=', '.join(attachment_names) if attachment_names else None, ) email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1]) email_cc_str = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:]) subject = f'{subject_prefix} - {client_name} - {self.name}' try: mail_vals = { 'subject': subject, 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc_str, 'model': 'sale.order', 'res_id': self.id, } if attachments: mail_vals['attachment_ids'] = [(6, 0, attachments)] self.env['mail.mail'].sudo().create(mail_vals).send() self._email_chatter_log(f'{title} email sent', email_to, email_cc_str, [f'Attachments: {", ".join(attachment_names)}'] if attachment_names else None) _logger.info(f"MOD email '{title}' sent for {self.name}") return True except Exception as e: _logger.error(f"Failed to send MOD email '{title}' for {self.name}: {e}") return False # --- Individual MOD status email methods --- def _send_mod_assessment_scheduled_email(self): """Email: Assessment has been scheduled. To: Client, CC: Authorizer.""" self.ensure_one() client_name = self.partner_id.name or 'Client' assess_date = self.x_fc_assessment_start_date.strftime('%B %d, %Y') if hasattr(self, 'x_fc_assessment_start_date') and self.x_fc_assessment_start_date else 'a date to be confirmed' return self._send_mod_email( subject_prefix='Assessment Scheduled', title='Assessment Scheduled', summary=f'An accessibility assessment for {client_name} has been scheduled for {assess_date}.', email_type='info', include_client=True, include_authorizer=True, note='What to expect: Our assessor will visit your home to evaluate ' 'the accessibility modifications needed. Please ensure someone is available at the ' 'scheduled time. If you need to reschedule, please contact us as soon as possible.', ) def _send_mod_assessment_completed_email(self): """Email: Assessment completed. To: Client.""" self.ensure_one() client_name = self.partner_id.name or 'Client' return self._send_mod_email( subject_prefix='Assessment Completed', title='Assessment Completed', summary=f'The accessibility assessment for {client_name} has been completed.', email_type='success', include_client=True, include_authorizer=False, note='Next steps: Our team is now preparing the drawings and quotation ' 'based on the assessment. We will send you the proposal once it is ready for review.', note_color='#38a169', ) def _send_mod_quote_submitted_email(self): """Email: Quote/drawings submitted. To: Client, CC: Authorizer, MOD contact.""" self.ensure_one() client_name = self.partner_id.name or 'Client' return self._send_mod_email( subject_prefix='Quotation & Drawings Submitted', title='Quotation & Drawings Submitted', summary=f'The quotation and drawings for {client_name} have been submitted for review.', email_type='info', include_client=True, include_authorizer=True, include_mod_contact=True, note='Next steps: The proposal will be reviewed by March of Dimes. ' 'The funding review process typically takes several weeks. We will follow up ' 'regularly and keep you updated on the status.', ) def _send_mod_funding_approved_email(self): """Email: Funding approved by MOD. To: Client, CC: Authorizer, Sales Rep.""" self.ensure_one() client_name = self.partner_id.name or 'Client' commitment = f'${self.x_fc_mod_approved_amount:,.2f}' if self.x_fc_mod_approved_amount else 'TBD' return self._send_mod_email( subject_prefix='Funding Approved', title='Great News - Funding Approved', summary=f'The March of Dimes funding for {client_name} has been approved.', email_type='success', include_client=True, include_authorizer=True, sections=[('Case Details', self._build_mod_case_detail_rows(include_amounts=True))], note=f'Approved Amount: {commitment}

    ' 'Next steps: We will receive the Payment Commitment Agreement (PCA) ' 'from March of Dimes shortly. Once received, we will proceed with the project. ' 'Our team will be in touch to discuss timelines.', note_color='#38a169', ) def _send_mod_funding_denied_email(self): """Email: Funding denied. To: Client, CC: Authorizer, Sales Rep.""" self.ensure_one() client_name = self.partner_id.name or 'Client' return self._send_mod_email( subject_prefix='Funding Update', title='Funding Update', summary=f'Unfortunately, the March of Dimes funding request for {client_name} ' f'was not approved at this time.', email_type='urgent', include_client=True, include_authorizer=True, note='Your options: You may contact March of Dimes directly for more ' 'information about the decision. Alternative funding options or private payment ' 'arrangements may be available. Our team is here to help explore your options.', note_color='#c53030', ) def _send_mod_contract_received_email(self): """Email: PCA/Contract received. To: Client, CC: Sales Rep.""" self.ensure_one() client_name = self.partner_id.name or 'Client' completion_date = self.x_fc_estimated_completion_date.strftime('%B %d, %Y') if self.x_fc_estimated_completion_date else 'TBD' return self._send_mod_email( subject_prefix='Contract Received - Project Starting', title='Contract Received', summary=f'The Payment Commitment Agreement for {client_name} has been received from March of Dimes.', email_type='success', include_client=True, include_authorizer=False, note=f'Project Completion Deadline: {completion_date}

    ' 'Next steps: We will now begin processing your project. ' 'Our team will be in contact to schedule the next steps and keep you updated on progress.', note_color='#38a169', ) def _send_mod_invoice_submitted_email(self): """Email: Invoice submitted to MOD. To: MOD contact.""" self.ensure_one() client_name = self.partner_id.name or 'Client' if not self.x_fc_mod_contact_email: _logger.warning(f"No MOD contact email for {self.name}, skipping invoice email") return False return self._send_mod_email( subject_prefix='Invoice Submitted', title='Invoice Submitted', summary=f'Please find attached the invoice for the accessibility modification project for {client_name}.', email_type='info', include_client=False, include_authorizer=False, include_mod_contact=True, sections=[('Invoice Details', self._build_mod_case_detail_rows(include_amounts=True))], note='Please process the initial payment (90%) as per the Payment Commitment Agreement terms.', ) def _send_mod_initial_payment_email(self): """Email: 90% payment received, project progressing. To: Client.""" self.ensure_one() client_name = self.partner_id.name or 'Client' amount = f'${self.x_fc_mod_initial_payment_amount:,.2f}' if self.x_fc_mod_initial_payment_amount else 'the initial payment' return self._send_mod_email( subject_prefix='Project Update - Payment Received', title='Project Update', summary=f'We have received {amount} for the accessibility project for {client_name}. ' f'Your project is now in active production.', email_type='success', include_client=True, include_authorizer=False, note='What is happening: Your project is being processed and we are ' 'working towards completion. We will keep you updated on key milestones.', note_color='#38a169', ) def _send_mod_project_complete_email(self): """Email: Project/installation complete. To: Client, CC: Authorizer.""" self.ensure_one() client_name = self.partner_id.name or 'Client' return self._send_mod_email( subject_prefix='Project Complete', title='Project Installation Complete', summary=f'The accessibility modification project for {client_name} has been completed.', email_type='success', include_client=True, include_authorizer=True, note='Next steps: We will be submitting the photos and proof of delivery ' 'to March of Dimes for final payment processing. If you have any questions or ' 'concerns about the installation, please contact us.', note_color='#38a169', ) def _send_mod_pod_submitted_email(self): """Email: Photos/POD submitted to MOD. To: MOD contact, CC: Authorizer.""" self.ensure_one() client_name = self.partner_id.name or 'Client' return self._send_mod_email( subject_prefix='Proof of Delivery Submitted', title='Proof of Delivery Submitted', summary=f'Photos and proof of delivery for {client_name} have been submitted.', email_type='info', include_client=False, include_authorizer=True, include_mod_contact=True, note='Please process the final payment (10%) as per the Payment Commitment Agreement terms.', ) def _send_mod_final_payment_email(self): """Email: Final payment received. To: Client.""" self.ensure_one() client_name = self.partner_id.name or 'Client' return self._send_mod_email( subject_prefix='Final Payment Received', title='Final Payment Received', summary=f'The final payment for the accessibility project for {client_name} has been received.', email_type='success', include_client=True, include_authorizer=False, note='Thank you! All payments have been received and your project is ' 'now fully complete. If you need any support or have warranty questions, ' 'please do not hesitate to contact us.', note_color='#38a169', ) def _send_mod_case_closed_email(self): """Email: Case closed. To: Client, CC: Authorizer.""" self.ensure_one() client_name = self.partner_id.name or 'Client' return self._send_mod_email( subject_prefix='Case Closed', title='Case Closed', summary=f'The accessibility modification case for {client_name} has been closed.', email_type='info', include_client=True, include_authorizer=True, note='Important: Your equipment comes with a one-year warranty on ' 'materials, equipment, and workmanship from the date of installation. ' 'If you experience any issues, please contact us immediately.', ) def _send_mod_followup_email(self): """Auto-email to client when follow-up activity is not completed on time.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False client = self.partner_id if not client or not client.email: return False client_name = client.name or 'Client' sender_name = (self.user_id or self.env.user).name followup_count = self.x_fc_mod_followup_count or 0 body_html = self._mod_email_build( title='Project Status Check-In', summary=f'We wanted to check in on the accessibility modification project for ' f'{client_name}.', email_type='info', sections=[('Case Details', self._build_mod_case_detail_rows())], note='We are here to help: If you have received any updates from March of Dimes ' 'regarding your funding application, please let us know so we can proceed accordingly. ' 'If you have any questions about your project, feel free to reach out to us anytime.', button_url=False, sender_name=sender_name, ) try: self.env['mail.mail'].sudo().create({ 'subject': f'Project Update Check-In - {client_name} - {self.name}', 'body_html': body_html, 'email_to': client.email, 'model': 'sale.order', 'res_id': self.id, }).send() self.with_context(skip_all_validations=True).write({ 'x_fc_mod_last_followup_date': fields.Date.today(), 'x_fc_mod_followup_count': followup_count + 1, 'x_fc_mod_followup_escalated': True, }) self._email_chatter_log('MOD Follow-up auto-email sent (activity overdue)', client.email) return True except Exception as e: _logger.error(f"Failed to send MOD follow-up email for {self.name}: {e}") return False # ====================================================================== # MARCH OF DIMES - TWILIO SMS # ====================================================================== def _send_mod_sms(self, trigger): """Send Twilio SMS for key MOD status changes.""" self.ensure_one() ICP = self.env['ir.config_parameter'].sudo() if ICP.get_param('fusion_claims.twilio_enabled', 'False').lower() not in ('true', '1', 'yes'): return False client = self.partner_id phone = client.mobile or client.phone if client else None if not phone: _logger.info(f"No phone number for {self.name}, skipping SMS") return False client_name = client.name or 'Client' company_phone = self.company_id.phone or '' messages = { 'assessment_scheduled': ( f"Hi {client_name}, your accessibility assessment with Westin Healthcare " f"has been scheduled. We will confirm the exact date and time shortly. " f"For questions, call {company_phone}." ), 'funding_approved': ( f"Hi {client_name}, great news! Your March of Dimes funding has been approved. " f"Our team will be in touch with next steps. Questions? Call {company_phone}." ), 'initial_payment_received': ( f"Hi {client_name}, we have received the initial payment for your project. " f"Work is in progress. We will keep you updated. Call {company_phone} for info." ), 'project_complete': ( f"Hi {client_name}, your accessibility modification project is now complete! " f"If you have any questions or concerns, call us at {company_phone}." ), } message = messages.get(trigger) if not message: return False return self._twilio_send_sms(phone, message) def _twilio_send_sms(self, to_number, message): """Send SMS via Twilio REST API.""" import requests as req self.ensure_one() ICP = self.env['ir.config_parameter'].sudo() account_sid = ICP.get_param('fusion_claims.twilio_account_sid', '') auth_token = ICP.get_param('fusion_claims.twilio_auth_token', '') from_number = ICP.get_param('fusion_claims.twilio_phone_number', '') if not all([account_sid, auth_token, from_number]): _logger.warning("Twilio not configured, skipping SMS") return False url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json' try: resp = req.post(url, data={ 'To': to_number, 'From': from_number, 'Body': message, }, auth=(account_sid, auth_token), timeout=10) if resp.status_code in (200, 201): self._email_chatter_log(f'SMS sent to {to_number}', to_number) _logger.info(f"Twilio SMS sent to {to_number} for {self.name}") return True else: _logger.error(f"Twilio SMS failed ({resp.status_code}): {resp.text}") return False except Exception as e: _logger.error(f"Twilio SMS error for {self.name}: {e}") return False # ====================================================================== # MARCH OF DIMES - FOLLOW-UP CRON # ====================================================================== @api.model def _cron_mod_schedule_followups(self): """Cron: Schedule bi-weekly follow-up activities for MOD cases awaiting funding.""" from datetime import timedelta ICP = self.env['ir.config_parameter'].sudo() interval_days = int(ICP.get_param('fusion_claims.mod_followup_interval_days', '14')) # Statuses that need follow-up (waiting for funding decision) followup_statuses = ['quote_submitted', 'awaiting_funding'] orders = self.search([ ('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', 'in', followup_statuses), ]) today = fields.Date.today() for order in orders: try: next_date = order.x_fc_mod_next_followup_date # If no next followup date set, or it's in the past, schedule one if not next_date or next_date <= today: # Calculate from last followup or quote submission date base_date = order.x_fc_mod_last_followup_date or order.x_fc_case_submitted or today new_followup = base_date + timedelta(days=interval_days) if new_followup <= today: new_followup = today + timedelta(days=1) # Schedule for tomorrow at minimum # Create scheduled activity activity_type = self.env.ref( 'fusion_claims.mail_activity_type_mod_followup', raise_if_not_found=False) if activity_type: # Check if there's already an open activity of this type existing = self.env['mail.activity'].search([ ('res_model', '=', 'sale.order'), ('res_id', '=', order.id), ('activity_type_id', '=', activity_type.id), ], limit=1) if not existing: order.activity_schedule( 'fusion_claims.mail_activity_type_mod_followup', date_deadline=new_followup, user_id=(order.user_id or self.env.user).id, summary=f'MOD Follow-up: Call {order.partner_id.name or "client"} for funding update', ) order.with_context(skip_all_validations=True).write({ 'x_fc_mod_next_followup_date': new_followup, }) _logger.info(f"Scheduled MOD follow-up for {order.name} on {new_followup}") except Exception as e: _logger.error(f"Failed to schedule MOD follow-up for {order.name}: {e}") @api.model def _cron_mod_escalate_followups(self): """Cron: Send auto-email if follow-up activity is overdue (not completed within 3 days).""" from datetime import timedelta ICP = self.env['ir.config_parameter'].sudo() escalation_days = int(ICP.get_param('fusion_claims.mod_followup_escalation_days', '3')) activity_type = self.env.ref( 'fusion_claims.mail_activity_type_mod_followup', raise_if_not_found=False) if not activity_type: return # Find overdue follow-up activities cutoff_date = fields.Date.today() - timedelta(days=escalation_days) overdue_activities = self.env['mail.activity'].search([ ('res_model', '=', 'sale.order'), ('activity_type_id', '=', activity_type.id), ('date_deadline', '<=', cutoff_date), ]) for activity in overdue_activities: try: order = self.browse(activity.res_id) if not order.exists() or not order._is_mod_sale(): continue if order.x_fc_mod_status not in ('quote_submitted', 'awaiting_funding'): # Status moved past follow-up phase, clean up the activity activity.unlink() continue # Only escalate once per activity if not order.x_fc_mod_followup_escalated: order._send_mod_followup_email() # Clean up the overdue activity and let the scheduler create a new one activity.unlink() except Exception as e: _logger.error(f"Failed to escalate MOD follow-up for activity {activity.id}: {e}") # ====================================================================== # ODSP EMAIL AUTOMATION # ====================================================================== def _odsp_email_build(self, **kwargs): """Wrapper around _email_build that overrides the footer for ODSP emails.""" html = self._email_build(**kwargs) html = html.replace( 'This is an automated notification from the ADP Claims Management System.', 'This is an automated notification from the ODSP Case Management System.', ) return html def _get_sa_mobility_email(self): """Get the configured SA Mobility email address.""" return self.env['ir.config_parameter'].sudo().get_param( 'fusion_claims.sa_mobility_email', 'samobility@ontario.ca') def _send_sa_mobility_email(self, request_type='repair', device_description='', attachment_ids=None, email_body_notes=None): """Send SA Mobility submission email. Args: request_type: 'batteries' or 'repair' device_description: human-readable device label attachment_ids: list of ir.attachment IDs to attach email_body_notes: optional urgency/priority notes for the email body """ self.ensure_one() client_name = self.partner_id.name or 'Client' member_id = self.x_fc_odsp_member_id or self.partner_id.x_fc_odsp_member_id or '' client_address = self.partner_id.contact_address or '' sa_email = self._get_sa_mobility_email() subject = f'{client_name} - ODSP - {member_id}' if member_id else client_name summary_parts = [] if email_body_notes: summary_parts.append( f'{email_body_notes}' ) if request_type == 'batteries': summary_parts.append( f'Client is getting {device_description or "Electric Wheelchair / Mobility Scooter"} from ADP. ' f'ADP is covering the equipment. We have submitted request for approval to ADP ' f'and client is seeking approval from ODSP for Batteries.' ) else: summary_parts.append( f'Client has {device_description or "mobility equipment"} ' f'and is looking for replacement parts and repairs. ' f'Please find the attached SA Mobility Form and Quotation.' ) summary = '
    '.join(summary_parts) sections = [('Client Details', [ ('Client Name', client_name), ('ODSP Member ID', member_id), ('Address', client_address), ('Order #', self.name), ])] body_html = self._odsp_email_build( title='SA Mobility Request', summary=summary, email_type='info', sections=sections, sender_name=(self.user_id or self.env.user).name, ) cc_emails = [] if self.user_id and self.user_id.email: cc_emails.append(self.user_id.email) mail_vals = { 'subject': subject, 'body_html': body_html, 'email_to': sa_email, 'email_cc': ', '.join(cc_emails) if cc_emails else '', 'model': 'sale.order', 'res_id': self.id, } if attachment_ids: mail_vals['attachment_ids'] = [(6, 0, attachment_ids)] try: self.env['mail.mail'].sudo().create(mail_vals).send() self._email_chatter_log( 'SA Mobility request sent', sa_email, ', '.join(cc_emails) if cc_emails else None) _logger.info(f"SA Mobility email sent for {self.name} to {sa_email}") except Exception as e: _logger.error(f"Failed to send SA Mobility email for {self.name}: {e}") from odoo.exceptions import UserError raise UserError(f"Failed to send email: {e}") def _send_sa_mobility_completion_email(self, attachment_ids=None): """Send SA Mobility completion email with signed form, POD, and invoice.""" self.ensure_one() client_name = self.partner_id.name or 'Client' member_id = self.x_fc_odsp_member_id or '' sa_email = self._get_sa_mobility_email() subject = f'{client_name} - {member_id} - Completed' summary = ( f'Delivery/repair for {client_name} has been completed. ' f'Please find the attached signed SA Mobility approval form, ' f'Proof of Delivery, and Invoice.' ) body_html = self._odsp_email_build( title='SA Mobility - Completed', summary=summary, email_type='success', sections=[('Case Details', [ ('Client Name', client_name), ('ODSP Member ID', member_id), ('Order #', self.name), ])], sender_name=(self.user_id or self.env.user).name, ) cc_emails = [] if self.user_id and self.user_id.email: cc_emails.append(self.user_id.email) mail_vals = { 'subject': subject, 'body_html': body_html, 'email_to': sa_email, 'email_cc': ', '.join(cc_emails) if cc_emails else '', 'model': 'sale.order', 'res_id': self.id, } if attachment_ids: mail_vals['attachment_ids'] = [(6, 0, attachment_ids)] try: self.env['mail.mail'].sudo().create(mail_vals).send() self._email_chatter_log( 'SA Mobility completion sent', sa_email, ', '.join(cc_emails) if cc_emails else None) _logger.info(f"SA Mobility completion email sent for {self.name}") except Exception as e: _logger.error(f"Failed to send SA Mobility completion email for {self.name}: {e}") def _send_odsp_submission_email(self, attachment_ids=None, email_body_notes=None): """Send ODSP submission email to the selected ODSP office.""" self.ensure_one() if not self.x_fc_odsp_office_id or not self.x_fc_odsp_office_id.email: from odoo.exceptions import UserError raise UserError("ODSP Office email is required. Please select an ODSP Office with an email.") client_name = self.partner_id.name or 'Client' member_id = self.x_fc_odsp_member_id or '' office_email = self.x_fc_odsp_office_id.email subject = f'ODSP Application - {client_name} - {member_id}' summary_parts = [] if email_body_notes: summary_parts.append( f'{email_body_notes}' ) summary_parts.append( f'Please find enclosed the ODSP application documents for ' f'{client_name} (Member ID: {member_id}).' ) summary = '
    '.join(summary_parts) body_html = self._odsp_email_build( title='ODSP Application Submission', summary=summary, email_type='info', sections=[('Application Details', [ ('Client Name', client_name), ('ODSP Member ID', member_id), ('Order #', self.name), ])], sender_name=(self.user_id or self.env.user).name, ) cc_emails = [] if self.user_id and self.user_id.email: cc_emails.append(self.user_id.email) mail_vals = { 'subject': subject, 'body_html': body_html, 'email_to': office_email, 'email_cc': ', '.join(cc_emails) if cc_emails else '', 'model': 'sale.order', 'res_id': self.id, } if attachment_ids: mail_vals['attachment_ids'] = [(6, 0, attachment_ids)] try: self.env['mail.mail'].sudo().create(mail_vals).send() self._email_chatter_log('ODSP submission sent', office_email, ', '.join(cc_emails) if cc_emails else None) if self._get_odsp_status() in ('quotation', 'documents_ready'): self._odsp_advance_status('submitted_to_odsp', "Status auto-advanced after ODSP submission email.") _logger.info(f"ODSP submission email sent for {self.name} to {office_email}") except Exception as e: _logger.error(f"Failed to send ODSP submission email for {self.name}: {e}")