Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,560 @@
# -*- 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