# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) from odoo import models, fields, api, _ from odoo.exceptions import UserError import base64 import io import os import logging _logger = logging.getLogger(__name__) try: import pdfrw except ImportError: pdfrw = None _logger.warning("pdfrw not installed. SA Mobility PDF filling will not work.") class SAMobilityPartLine(models.TransientModel): _name = 'fusion_claims.sa.mobility.part.line' _description = 'SA Mobility Parts Line' _order = 'sequence' wizard_id = fields.Many2one('fusion_claims.sa.mobility.wizard', ondelete='cascade') sequence = fields.Integer(default=10) qty = fields.Float(string='Qty', digits=(12, 2)) description = fields.Char(string='Description') unit_price = fields.Float(string='Unit Price', digits=(12, 2)) tax_id = fields.Many2one('account.tax', string='Tax Type', domain=[('type_tax_use', '=', 'sale')]) taxes = fields.Float(string='Taxes', digits=(12, 2), compute='_compute_taxes', store=True) amount = fields.Float(string='Amount', compute='_compute_amount', store=True) @api.depends('qty', 'unit_price', 'tax_id') def _compute_taxes(self): for line in self: subtotal = line.qty * line.unit_price line.taxes = subtotal * line.tax_id.amount / 100 if line.tax_id else 0.0 @api.depends('qty', 'unit_price', 'taxes') def _compute_amount(self): for line in self: line.amount = (line.qty * line.unit_price) + line.taxes class SAMobilityLabourLine(models.TransientModel): _name = 'fusion_claims.sa.mobility.labour.line' _description = 'SA Mobility Labour Line' _order = 'sequence' wizard_id = fields.Many2one('fusion_claims.sa.mobility.wizard', ondelete='cascade') sequence = fields.Integer(default=10) hours = fields.Float(string='Hours', digits=(12, 2)) rate = fields.Float(string='Rate', digits=(12, 2)) tax_id = fields.Many2one('account.tax', string='Tax Type', domain=[('type_tax_use', '=', 'sale')]) taxes = fields.Float(string='Taxes', digits=(12, 2), compute='_compute_taxes', store=True) amount = fields.Float(string='Amount', compute='_compute_amount', store=True) @api.depends('hours', 'rate', 'tax_id') def _compute_taxes(self): for line in self: subtotal = line.hours * line.rate line.taxes = subtotal * line.tax_id.amount / 100 if line.tax_id else 0.0 @api.depends('hours', 'rate', 'taxes') def _compute_amount(self): for line in self: line.amount = (line.hours * line.rate) + line.taxes class SAMobilityFeeLine(models.TransientModel): _name = 'fusion_claims.sa.mobility.fee.line' _description = 'SA Mobility Additional Fee Line' _order = 'sequence' wizard_id = fields.Many2one('fusion_claims.sa.mobility.wizard', ondelete='cascade') sequence = fields.Integer(default=10) description = fields.Char(string='Description') rate = fields.Float(string='Rate', digits=(12, 2)) tax_id = fields.Many2one('account.tax', string='Tax Type', domain=[('type_tax_use', '=', 'sale')]) taxes = fields.Float(string='Taxes', digits=(12, 2), compute='_compute_taxes', store=True) amount = fields.Float(string='Amount', compute='_compute_amount', store=True) @api.depends('rate', 'tax_id') def _compute_taxes(self): for line in self: line.taxes = line.rate * line.tax_id.amount / 100 if line.tax_id else 0.0 @api.depends('rate', 'taxes') def _compute_amount(self): for line in self: line.amount = line.rate + line.taxes class SAMobilityWizard(models.TransientModel): _name = 'fusion_claims.sa.mobility.wizard' _description = 'SA Mobility Form Filling Wizard' sale_order_id = fields.Many2one('sale.order', required=True, readonly=True) # --- Vendor section (auto-populated, read-only) --- vendor_name = fields.Char(string='Vendor Name', readonly=True) order_number = fields.Char(string='Order #', readonly=True) vendor_address = fields.Char(string='Vendor Address', readonly=True) primary_email = fields.Char(string='Primary Email', readonly=True) phone = fields.Char(string='Phone', readonly=True) secondary_email = fields.Char(string='Secondary Email', readonly=True) form_date = fields.Date(string='Date', default=fields.Date.today) # --- Salesperson --- salesperson_name = fields.Char(string='Salesperson/Technician', readonly=True) service_date = fields.Date(string='Date of Service', default=fields.Date.today) # --- Client section (auto-populated, read-only) --- client_last_name = fields.Char(string='Last Name', readonly=True) client_first_name = fields.Char(string='First Name', readonly=True) member_id = fields.Char(string='ODSP Member ID', size=9, readonly=True) client_address = fields.Char(string='Client Address', readonly=True) client_phone = fields.Char(string='Client Phone', readonly=True) # --- User-editable fields --- relationship = fields.Selection([ ('self', 'Self'), ('spouse', 'Spouse'), ('dependent', 'Dependent'), ], string='Relationship to Recipient', default='self', required=True) 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='Device Type', required=True) device_other_description = fields.Char( string='Other Device Description', help='e.g. power chair, batteries, stairlift, ceiling lift', ) serial_number = fields.Char(string='Serial Number') year = fields.Char(string='Year') make = fields.Char(string='Make') model_name = fields.Char(string='Model') warranty_in_effect = fields.Boolean(string='Warranty in Effect') warranty_description = fields.Char(string='Warranty Description') after_hours = fields.Boolean(string='After-hours/Weekend Work') notes = fields.Text( string='Notes / Comments', help='Additional details about the request (filled into Notes/Comments area on Page 2)', ) sa_request_type = fields.Selection([ ('batteries', 'Batteries'), ('repair', 'Repair / Maintenance'), ], string='Request Type', required=True, default='repair', help='Controls email body template when sending to SA Mobility') email_body_notes = fields.Text( string='Email Body Notes', help='Urgency or priority notes that appear at the top of the email body, ' 'right below the title. Use this for time-sensitive requests.', ) # --- Line items --- part_line_ids = fields.One2many( 'fusion_claims.sa.mobility.part.line', 'wizard_id', string='Parts') labour_line_ids = fields.One2many( 'fusion_claims.sa.mobility.labour.line', 'wizard_id', string='Labour') fee_line_ids = fields.One2many( 'fusion_claims.sa.mobility.fee.line', 'wizard_id', string='Additional Fees') # --- Computed totals --- parts_total = fields.Float(compute='_compute_totals', string='Parts Total') labour_total = fields.Float(compute='_compute_totals', string='Labour Total') fees_total = fields.Float(compute='_compute_totals', string='Fees Total') grand_total = fields.Float(compute='_compute_totals', string='Grand Total') @api.depends('part_line_ids.amount', 'labour_line_ids.amount', 'fee_line_ids.amount') def _compute_totals(self): for wiz in self: wiz.parts_total = sum(wiz.part_line_ids.mapped('amount')) wiz.labour_total = sum(wiz.labour_line_ids.mapped('amount')) wiz.fees_total = sum(wiz.fee_line_ids.mapped('amount')) wiz.grand_total = wiz.parts_total + wiz.labour_total + wiz.fees_total @api.model def default_get(self, fields_list): """Pre-populate wizard from sale order context.""" res = super().default_get(fields_list) order_id = self.env.context.get('active_id') if not order_id: return res order = self.env['sale.order'].browse(order_id) company = order.company_id or self.env.company # Vendor info res['sale_order_id'] = order.id res['vendor_name'] = company.name or '' res['order_number'] = order.name or '' addr_parts = [company.street or '', company.city or '', company.zip or ''] res['vendor_address'] = ', '.join(p for p in addr_parts if p) sa_email = self.env['ir.config_parameter'].sudo().get_param( 'fusion_claims.sa_mobility_email', 'samobility@ontario.ca') res['primary_email'] = company.email or sa_email res['phone'] = company.phone or '' res['secondary_email'] = order.user_id.email or '' # Salesperson res['salesperson_name'] = order.user_id.name or '' # Client info partner = order.partner_id if partner: name_parts = (partner.name or '').split(' ', 1) res['client_first_name'] = name_parts[0] if name_parts else '' res['client_last_name'] = name_parts[1] if len(name_parts) > 1 else '' addr_parts = [partner.street or '', partner.city or '', partner.zip or ''] res['client_address'] = ', '.join(p for p in addr_parts if p) res['client_phone'] = partner.phone or '' res['member_id'] = order.x_fc_odsp_member_id or partner.x_fc_odsp_member_id or '' # Restore saved device/form data from sale order (if previously filled) if order.x_fc_sa_device_type: res['relationship'] = order.x_fc_sa_relationship or 'self' res['device_type'] = order.x_fc_sa_device_type res['device_other_description'] = order.x_fc_sa_device_other or '' res['serial_number'] = order.x_fc_sa_serial_number or '' res['year'] = order.x_fc_sa_year or '' res['make'] = order.x_fc_sa_make or '' res['model_name'] = order.x_fc_sa_model or '' res['warranty_in_effect'] = order.x_fc_sa_warranty res['warranty_description'] = order.x_fc_sa_warranty_desc or '' res['after_hours'] = order.x_fc_sa_after_hours res['sa_request_type'] = order.x_fc_sa_request_type or 'repair' res['notes'] = order.x_fc_sa_notes or '' # Pre-populate parts and labour from order lines part_lines = [] labour_lines = [] part_seq = 10 labour_seq = 10 for line in order.order_line.filtered(lambda l: not l.display_type): tax = line.tax_ids[:1] # Route LABOR product to labour tab if line.product_id and line.product_id.default_code == 'LABOR': labour_lines.append((0, 0, { 'sequence': labour_seq, 'hours': line.product_uom_qty, 'rate': line.price_unit, 'tax_id': tax.id if tax else False, })) labour_seq += 10 else: part_lines.append((0, 0, { 'sequence': part_seq, 'qty': line.product_uom_qty, 'description': line.product_id.name or line.name or '', 'unit_price': line.price_unit, 'tax_id': tax.id if tax else False, })) part_seq += 10 if part_lines: res['part_line_ids'] = part_lines[:6] if labour_lines: res['labour_line_ids'] = labour_lines[:5] return res def _get_template_path(self): """Get the path to the SA Mobility form template PDF.""" module_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.join(module_path, 'static', 'src', 'pdf', 'sa_mobility_form_template.pdf') def _build_field_mapping(self): """Build a dictionary mapping PDF form field names to values.""" self.ensure_one() mapping = {} # Vendor section mapping['Text 1'] = self.vendor_name or '' mapping['Text2'] = self.order_number or '' mapping['Text3'] = self.vendor_address or '' mapping['Text4'] = self.primary_email or '' mapping['Text5'] = self.phone or '' mapping['Text6'] = self.secondary_email or '' mapping['Text7'] = fields.Date.to_string(self.form_date) if self.form_date else '' # Salesperson mapping['Text8'] = self.salesperson_name or '' mapping['Text9'] = fields.Date.to_string(self.service_date) if self.service_date else '' # Client mapping['Text10'] = self.client_last_name or '' mapping['Text11'] = self.client_first_name or '' # Member ID - 9 individual digit boxes (Text12-Text20) member = (self.member_id or '').ljust(9) for i in range(9): mapping[f'Text{12 + i}'] = member[i] if i < len(self.member_id or '') else '' mapping['Text21'] = self.client_address or '' mapping['Text22'] = self.client_phone or '' # Relationship checkboxes mapping['Check Box16'] = self.relationship == 'self' mapping['Check Box17'] = self.relationship == 'spouse' mapping['Check Box18'] = self.relationship == 'dependent' # Device type checkboxes device_map = { 'manual_wheelchair': 'Check Box19', 'high_tech_wheelchair': 'Check Box20', 'mobility_scooter': 'Check Box21', 'walker': 'Check Box22', 'lifting_device': 'Check Box23', 'other': 'Check Box24', } for dtype, cb_name in device_map.items(): mapping[cb_name] = self.device_type == dtype mapping['Text23'] = self.device_other_description or '' mapping['Text24'] = self.serial_number or '' mapping['Text25'] = self.year or '' mapping['Text26'] = self.make or '' mapping['Text27'] = self.model_name or '' # Warranty mapping['Check Box26'] = bool(self.warranty_in_effect) mapping['Check Box28'] = not self.warranty_in_effect mapping['Text28'] = self.warranty_description or '' # After hours mapping['Check Box27'] = bool(self.after_hours) mapping['Check Box29'] = not self.after_hours # Parts lines (up to 6 rows): Text30-Text59 for idx, line in enumerate(self.part_line_ids[:6]): base = 30 + (idx * 5) mapping[f'Text{base}'] = str(int(line.qty)) if line.qty == int(line.qty) else str(line.qty) mapping[f'Text{base + 1}'] = line.description or '' mapping[f'Text{base + 2}'] = f'${line.unit_price:.2f}' mapping[f'Text{base + 3}'] = f'${line.taxes:.2f}' if line.taxes else '' mapping[f'Text{base + 4}'] = f'${line.amount:.2f}' mapping['Text60'] = f'${self.parts_total:.2f}' # Labour lines (up to 5 rows): Text61-Text80 for idx, line in enumerate(self.labour_line_ids[:5]): base = 61 + (idx * 4) mapping[f'Text{base}'] = str(line.hours) mapping[f'Text{base + 1}'] = f'${line.rate:.2f}' mapping[f'Text{base + 2}'] = f'${line.taxes:.2f}' if line.taxes else '' mapping[f'Text{base + 3}'] = f'${line.amount:.2f}' mapping['Text81'] = f'${self.labour_total:.2f}' # Additional fees (up to 4 rows): Text82-Text97 for idx, line in enumerate(self.fee_line_ids[:4]): base = 82 + (idx * 4) mapping[f'Text{base}'] = line.description or '' mapping[f'Text{base + 1}'] = f'${line.rate:.2f}' mapping[f'Text{base + 2}'] = f'${line.taxes:.2f}' if line.taxes else '' mapping[f'Text{base + 3}'] = f'${line.amount:.2f}' mapping['Text98'] = f'${self.fees_total:.2f}' # Estimated totals summary mapping['Text99'] = f'${self.parts_total:.2f}' mapping['Text100'] = f'${self.labour_total:.2f}' mapping['Text101'] = f'${self.fees_total:.2f}' mapping['Text102'] = f'${self.grand_total:.2f}' # Page 2 - Notes/Comments area mapping['Text1'] = self.notes or '' return mapping def _fill_pdf(self): """Fill the SA Mobility PDF template using pdfrw AcroForm field filling.""" self.ensure_one() if not pdfrw: raise UserError(_("pdfrw library is not installed. Cannot fill PDF forms.")) template_path = self._get_template_path() if not os.path.exists(template_path): raise UserError(_("SA Mobility form template not found at %s") % template_path) mapping = self._build_field_mapping() template = pdfrw.PdfReader(template_path) for page in template.pages: annotations = page.get('/Annots') if not annotations: continue for annot in annotations: if annot.get('/Subtype') != '/Widget': continue field_name = annot.get('/T') if not field_name: continue # pdfrw wraps field names in parentheses clean_name = field_name.strip('()') if clean_name not in mapping: continue value = mapping[clean_name] if isinstance(value, bool): # Checkbox field if value: annot.update(pdfrw.PdfDict( V=pdfrw.PdfName('Yes'), AS=pdfrw.PdfName('Yes'), )) else: annot.update(pdfrw.PdfDict( V=pdfrw.PdfName('Off'), AS=pdfrw.PdfName('Off'), )) else: # Text field annot.update(pdfrw.PdfDict( V=pdfrw.PdfString.encode(str(value)), AP='', )) # Mark form as not needing appearance regeneration if template.Root.AcroForm: template.Root.AcroForm.update(pdfrw.PdfDict(NeedAppearances=pdfrw.PdfObject('true'))) output = io.BytesIO() writer = pdfrw.PdfWriter() writer.trailer = template writer.write(output) return output.getvalue() def _save_form_data(self): """Persist user-editable wizard data to sale order for future sessions.""" self.ensure_one() self.sale_order_id.with_context(skip_all_validations=True).write({ 'x_fc_sa_relationship': self.relationship, 'x_fc_sa_device_type': self.device_type, 'x_fc_sa_device_other': self.device_other_description or '', 'x_fc_sa_serial_number': self.serial_number or '', 'x_fc_sa_year': self.year or '', 'x_fc_sa_make': self.make or '', 'x_fc_sa_model': self.model_name or '', 'x_fc_sa_warranty': self.warranty_in_effect, 'x_fc_sa_warranty_desc': self.warranty_description or '', 'x_fc_sa_after_hours': self.after_hours, 'x_fc_sa_request_type': self.sa_request_type, 'x_fc_sa_notes': self.notes or '', }) def action_fill_and_attach(self): """Fill the SA Mobility PDF and attach to the sale order via chatter.""" self.ensure_one() order = self.sale_order_id self._save_form_data() pdf_data = self._fill_pdf() filename = f'SA_Mobility_Form_{order.name}.pdf' attachment = self.env['ir.attachment'].create({ 'name': filename, 'type': 'binary', 'datas': base64.b64encode(pdf_data), 'res_model': 'sale.order', 'res_id': order.id, 'mimetype': 'application/pdf', }) # Update ODSP status if appropriate if order.x_fc_sa_status == 'quotation': order.x_fc_sa_status = 'form_ready' order.message_post( body=_("SA Mobility form filled and attached."), message_type='comment', attachment_ids=[attachment.id], ) return {'type': 'ir.actions.act_window_close'} def action_fill_attach_and_send(self): """Fill PDF, attach to order, and send email to SA Mobility.""" self.ensure_one() order = self.sale_order_id self._save_form_data() pdf_data = self._fill_pdf() filename = f'SA_Mobility_Form_{order.name}.pdf' # Attach to sale order attachment = self.env['ir.attachment'].create({ 'name': filename, 'type': 'binary', 'datas': base64.b64encode(pdf_data), 'res_model': 'sale.order', 'res_id': order.id, 'mimetype': 'application/pdf', }) # Generate quotation PDF and attach att_ids = [attachment.id] try: report = self.env.ref('sale.action_report_saleorder') pdf_content, _ct = report._render_qweb_pdf(report.id, [order.id]) quot_att = self.env['ir.attachment'].create({ 'name': f'Quotation_{order.name}.pdf', 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'sale.order', 'res_id': order.id, 'mimetype': 'application/pdf', }) att_ids.append(quot_att.id) except Exception as e: _logger.warning(f"Could not generate quotation PDF for {order.name}: {e}") # Build and send email order._send_sa_mobility_email( request_type=self.sa_request_type, device_description=self._get_device_label(), attachment_ids=att_ids, email_body_notes=self.email_body_notes, ) # Update ODSP status if order.x_fc_sa_status in ('quotation', 'form_ready'): order.x_fc_sa_status = 'submitted_to_sa' sa_email = order._get_sa_mobility_email() return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Email Sent'), 'message': _('SA Mobility form emailed to %s.') % sa_email, 'type': 'success', 'sticky': False, 'next': {'type': 'ir.actions.act_window_close'}, }, } def _get_device_label(self): """Get human-readable device description.""" self.ensure_one() labels = dict(self._fields['device_type'].selection) label = labels.get(self.device_type, '') if self.device_type == 'other' and self.device_other_description: label = self.device_other_description return label