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,32 @@
# -*- 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.
from . import adp_export_wizard
from . import device_import_wizard
from . import device_approval_wizard
from . import submission_verification_wizard
from . import sale_advance_payment_inv
from . import account_payment_register
from . import status_change_reason_wizard
from . import case_close_verification_wizard
from . import schedule_assessment_wizard
from . import assessment_completed_wizard
from . import application_received_wizard
from . import ready_for_submission_wizard
from . import ready_to_bill_wizard
from . import field_mapping_config_wizard
from . import loaner_checkout_wizard
from . import loaner_return_wizard
from . import ready_for_delivery_wizard
from . import xml_import_wizard
from . import send_to_mod_wizard
from . import mod_awaiting_funding_wizard
from . import mod_funding_approved_wizard
from . import mod_pca_received_wizard
from . import odsp_sa_mobility_wizard
from . import odsp_discretionary_wizard
from . import odsp_pre_approved_wizard
from . import odsp_ready_delivery_wizard
from . import odsp_submit_to_odsp_wizard

View File

@@ -0,0 +1,79 @@
# -*- 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.
from odoo import models, fields, api
from odoo.exceptions import UserError
class AccountPaymentRegister(models.TransientModel):
_inherit = 'account.payment.register'
x_fc_card_last_four = fields.Char(
string='Card Last 4 Digits',
size=4,
help='Enter last 4 digits of the card used for payment (required for card payments)',
)
x_fc_payment_note = fields.Char(
string='Payment Note',
help='Additional note for this payment',
)
x_fc_is_card_payment = fields.Boolean(
string='Is Card Payment',
compute='_compute_is_card_payment',
help='True if payment method is Credit Card or Debit Card',
)
@api.depends('payment_method_line_id', 'payment_method_line_id.x_fc_requires_card_digits')
def _compute_is_card_payment(self):
"""Check if the selected payment method requires card digits.
Uses the x_fc_requires_card_digits flag on payment method line.
Falls back to keyword matching if field not set.
"""
card_keywords = ['credit', 'debit', 'visa', 'mastercard', 'amex']
for wizard in self:
is_card = False
if wizard.payment_method_line_id:
# First check the explicit flag
if hasattr(wizard.payment_method_line_id, 'x_fc_requires_card_digits'):
is_card = wizard.payment_method_line_id.x_fc_requires_card_digits
# Fallback to keyword matching if flag not explicitly set
if not is_card and wizard.payment_method_line_id.name:
method_name = wizard.payment_method_line_id.name.lower()
is_card = any(keyword in method_name for keyword in card_keywords)
wizard.x_fc_is_card_payment = is_card
def action_create_payments(self):
"""Override to validate card number is entered for card payments."""
# Validate card number for card payments
if self.x_fc_is_card_payment and not self.x_fc_card_last_four:
raise UserError(
"Card Last 4 Digits is required for Credit Card and Debit Card payments.\n\n"
"Please enter the last 4 digits of the card used for this payment."
)
# Validate card number format (must be exactly 4 digits)
if self.x_fc_card_last_four:
if not self.x_fc_card_last_four.isdigit() or len(self.x_fc_card_last_four) != 4:
raise UserError(
"Card Last 4 Digits must be exactly 4 numeric digits.\n\n"
"Example: 1234"
)
return super().action_create_payments()
def _create_payment_vals_from_wizard(self, batch_result):
"""Override to add card info to payment values."""
vals = super()._create_payment_vals_from_wizard(batch_result)
# Add our custom fields
if self.x_fc_card_last_four:
vals['x_fc_card_last_four'] = self.x_fc_card_last_four
if self.x_fc_payment_note:
vals['x_fc_payment_note'] = self.x_fc_payment_note
return vals

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!-- Extend the payment register wizard form to add card info -->
<record id="view_account_payment_register_form_fc" model="ir.ui.view">
<field name="name">account.payment.register.form.fc</field>
<field name="model">account.payment.register</field>
<field name="inherit_id" ref="account.view_account_payment_register_form"/>
<field name="arch" type="xml">
<!-- Add hidden field for card payment detection -->
<xpath expr="//field[@name='communication']" position="after">
<field name="x_fc_is_card_payment" invisible="1"/>
<field name="x_fc_card_last_four"
placeholder="e.g., 1234"
required="x_fc_is_card_payment"
invisible="not can_edit_wizard or (can_group_payments and not group_payment)"/>
<field name="x_fc_payment_note"
placeholder="Transaction reference or note"
invisible="not can_edit_wizard or (can_group_payments and not group_payment)"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,617 @@
# -*- 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 base64
from datetime import datetime, date
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class FusionCentralExportWizard(models.TransientModel):
_name = 'fusion_claims.export.wizard'
_description = 'Fusion Central ADP Export Wizard'
invoice_ids = fields.Many2many(
'account.move',
string='Invoices',
domain=[('move_type', 'in', ['out_invoice', 'out_refund'])],
)
vendor_code = fields.Char(
string='Vendor Code',
required=True,
)
export_date = fields.Date(
string='Export Date',
default=fields.Date.today,
required=True,
)
# Export result
export_file = fields.Binary(string='Export File', readonly=True)
export_filename = fields.Char(string='Filename', readonly=True)
state = fields.Selection([
('draft', 'Draft'),
('done', 'Done'),
], default='draft')
export_summary = fields.Text(string='Export Summary', readonly=True)
saved_to_documents = fields.Boolean(string='Saved to Documents', readonly=True)
warnings = fields.Text(string='Warnings', readonly=True)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
# Get vendor code from settings
ICP = self.env['ir.config_parameter'].sudo()
res['vendor_code'] = ICP.get_param('fusion_claims.vendor_code', '')
# Get invoices from context
if self._context.get('active_ids'):
invoices = self.env['account.move'].browse(self._context['active_ids'])
# Filter to only customer invoices/refunds
invoices = invoices.filtered(lambda m: m.move_type in ['out_invoice', 'out_refund'])
res['invoice_ids'] = [(6, 0, invoices.ids)]
return res
def _get_field_value(self, record, field_name, default=''):
"""Get field value safely."""
return getattr(record, field_name, default) or default
def _format_date(self, date_val):
"""Format date as ddmmyyyy."""
if not date_val:
return ''
if isinstance(date_val, str):
try:
date_val = datetime.strptime(date_val, '%Y-%m-%d').date()
except ValueError:
return ''
return date_val.strftime('%d%m%Y')
def _should_skip_line(self, line):
"""Check if line should be skipped based on device code."""
code = line._get_adp_device_code().upper() if hasattr(line, '_get_adp_device_code') else ''
skip_codes = ['FUNDING', 'NON-FUNDED', 'N/A', 'NA', 'NON-ADP', 'LABOUR', 'DELIVERY', '']
return code in skip_codes or not code
def _validate_dates(self, invoice):
"""Validate dates and return warnings."""
warnings = []
today = date.today()
invoice_date = invoice.invoice_date
delivery_date = invoice.x_fc_adp_delivery_date
# Check for future dates
if invoice_date and invoice_date > today:
warnings.append(f"Invoice {invoice.name}: Invoice date is in the future")
if delivery_date and delivery_date > today:
warnings.append(f"Invoice {invoice.name}: Delivery date is in the future")
# Check delivery date vs invoice date
if delivery_date and invoice_date and delivery_date > invoice_date:
warnings.append(f"Invoice {invoice.name}: Delivery date is after invoice date")
return warnings
def _verify_portions(self, adp_device_price, client_type, deduction_type, deduction_value,
stored_adp_portion, stored_client_portion, quantity=1, tolerance=0.01):
"""Verify stored portions against calculated portions.
Recalculates portions using the ADP database price and compares with stored values.
This ensures the invoice calculations match the ADP database.
IMPORTANT: Invoice lines store TOTAL portions (qty × unit portion).
This method calculates unit portions and multiplies by quantity for comparison.
Args:
adp_device_price: ADP approved price from database (per unit)
client_type: Client type (REG, ODS, OWP, ACS, LTC, SEN, CCA)
deduction_type: 'pct', 'amt', 'none', or None
deduction_value: Deduction value (per unit for AMT type)
stored_adp_portion: Pre-calculated TOTAL ADP portion from invoice line
stored_client_portion: Pre-calculated TOTAL client portion from invoice line
quantity: Line quantity (used to calculate expected totals)
tolerance: Acceptable difference for floating point comparison (default $0.01)
Returns:
tuple: (is_valid, expected_adp_total, expected_client_total, error_message)
"""
# Determine base percentage by client type
if client_type == 'REG':
base_adp_pct = 0.75
else:
# ODS, OWP, ACS, LTC, SEN, CCA: 100% ADP, 0% Client
base_adp_pct = 1.0
# Calculate expected PER-UNIT portions (same logic as invoice)
if deduction_type == 'pct' and deduction_value:
# PCT: ADP covers (deduction_value)% of their normal portion
effective_pct = base_adp_pct * (deduction_value / 100)
unit_adp = adp_device_price * effective_pct
elif deduction_type == 'amt' and deduction_value:
# AMT: Subtract fixed amount from base ADP portion (per unit)
base_adp = adp_device_price * base_adp_pct
unit_adp = max(0, base_adp - deduction_value)
else:
# No deduction
unit_adp = adp_device_price * base_adp_pct
unit_client = adp_device_price - unit_adp
# Calculate expected TOTALS (unit × quantity)
expected_adp_total = unit_adp * quantity
expected_client_total = unit_client * quantity
# Compare with stored values (allow small tolerance for rounding)
# Tolerance scales with quantity to account for accumulated rounding
scaled_tolerance = tolerance * max(1, quantity)
adp_diff = abs(stored_adp_portion - expected_adp_total)
client_diff = abs(stored_client_portion - expected_client_total)
if adp_diff > scaled_tolerance or client_diff > scaled_tolerance:
error_msg = (
f"Calculation mismatch!\n"
f" ADP Device Price: ${adp_device_price:.2f} × {quantity} = ${adp_device_price * quantity:.2f}\n"
f" Client Type: {client_type} ({int(base_adp_pct*100)}% ADP)\n"
f" Deduction: {deduction_type or 'None'}"
f"{f' = {deduction_value}' if deduction_value else ''}\n"
f" Per Unit: ADP=${unit_adp:.2f}, Client=${unit_client:.2f}\n"
f" Expected Total (×{quantity}): ADP=${expected_adp_total:.2f}, Client=${expected_client_total:.2f}\n"
f" Invoice has: ADP=${stored_adp_portion:.2f}, Client=${stored_client_portion:.2f}\n"
f" Difference: ADP=${adp_diff:.2f}, Client=${client_diff:.2f}"
)
return False, expected_adp_total, expected_client_total, error_msg
return True, expected_adp_total, expected_client_total, None
def _generate_claim_lines(self, invoice):
"""Generate claim lines for an invoice.
Uses PRE-CALCULATED values from invoice lines (x_fc_adp_portion, x_fc_client_portion)
but VERIFIES them against the ADP database before export.
This ensures:
1. Single source of truth - values from invoice
2. Verification - calculations match ADP database
3. Deductions included in verification
"""
lines = []
verification_errors = []
ADPDevice = self.env['fusion.adp.device.code'].sudo()
client_type = invoice._get_client_type() or 'REG'
invoice_date = invoice.invoice_date or invoice.date
delivery_date = invoice.x_fc_adp_delivery_date
claim_number = invoice.x_fc_claim_number or ''
client_ref_2 = invoice.x_fc_client_ref_2 or ''
for line in invoice.invoice_line_ids:
# Skip non-product lines
if not line.product_id or line.display_type in ('line_section', 'line_note'):
continue
# Skip lines with excluded device codes
if self._should_skip_line(line):
continue
# Get device code
device_code = line._get_adp_device_code()
if not device_code:
continue
# Get ADP approved device price from database for verification
adp_device = ADPDevice.search([
('device_code', '=', device_code),
('active', '=', True)
], limit=1)
if not adp_device:
_logger.warning(f"ADP device code {device_code} not found in database, skipping line")
continue
adp_device_price = adp_device.adp_price or 0
if not adp_device_price:
_logger.warning(f"ADP device {device_code} has no price, skipping line")
continue
# Get quantity first (needed for verification)
qty = int(line.quantity) if line.quantity else 1
# Get PRE-CALCULATED TOTAL portions from invoice line
# These are TOTALS (unit × qty), calculated when the invoice was created
stored_adp_portion = line.x_fc_adp_portion or 0
stored_client_portion = line.x_fc_client_portion or 0
# Get deduction info for verification
deduction_type = line.x_fc_deduction_type or 'none'
deduction_value = line.x_fc_deduction_value or 0
# VERIFY: Recalculate and compare with stored values
# Pass quantity so verification compares totals correctly
is_valid, expected_adp, expected_client, error_msg = self._verify_portions(
adp_device_price=adp_device_price,
client_type=client_type,
deduction_type=deduction_type if deduction_type != 'none' else None,
deduction_value=deduction_value,
stored_adp_portion=stored_adp_portion,
stored_client_portion=stored_client_portion,
quantity=qty,
)
if not is_valid:
verification_errors.append(
f"Invoice {invoice.name}, Line: {line.product_id.name} ({device_code})\n{error_msg}"
)
continue # Skip lines with mismatched calculations
serial_number = line._get_serial_number()
# Calculate PER-UNIT portions for export (each export line is qty=1)
unit_adp_portion = stored_adp_portion / qty if qty > 0 else stored_adp_portion
unit_client_portion = stored_client_portion / qty if qty > 0 else stored_client_portion
# Export one line per unit (ADP expects qty=1 per line)
for i in range(qty):
lines.append({
'vendor_code': self.vendor_code,
'claim_number': claim_number,
'client_ref_2': client_ref_2,
'invoice_number': invoice.name,
'invoice_date': self._format_date(invoice_date),
'delivery_date': self._format_date(delivery_date),
'device_code': device_code,
'serial_number': serial_number,
'qty': '1',
'device_price': f"{adp_device_price:.2f}",
'adp_portion': f"{unit_adp_portion:.2f}",
'client_portion': f"{unit_client_portion:.2f}",
'client_type': client_type,
})
# If there were verification errors, raise them
if verification_errors:
raise UserError(_(
"ADP Export Verification Failed!\n\n"
"The following invoice lines have calculation mismatches between "
"the invoice and the ADP device database:\n\n%s\n\n"
"Please verify the invoice calculations and ADP device prices."
) % '\n\n'.join(verification_errors))
return lines
def _generate_export_content(self):
"""Generate the full export content."""
all_lines = []
all_warnings = []
for invoice in self.invoice_ids:
if not invoice._is_adp_invoice():
continue
# Validate dates
all_warnings.extend(self._validate_dates(invoice))
# Generate lines
all_lines.extend(self._generate_claim_lines(invoice))
if not all_lines:
raise UserError(_("No valid lines to export. Make sure invoices are ADP type and have valid products."))
# Build CSV content (no header)
# ADP Format: vendor_code,claim_number,client_ref_2,invoice_number,invoice_date,delivery_date,
# ,,device_code,serial_number,,qty,device_price,adp_portion,client_portion,client_type
# Note: device_price is the ADP approved price from fusion.adp.device.code database
content_lines = []
for line in all_lines:
row = ','.join([
line['vendor_code'],
line['claim_number'],
line['client_ref_2'],
line['invoice_number'],
line['invoice_date'],
line['delivery_date'],
'', # Reserved field 1 (empty)
'', # Reserved field 2 (empty)
line['device_code'],
line['serial_number'],
'', # Reserved field 3 (empty)
line['qty'],
line['device_price'],
line['adp_portion'],
line['client_portion'],
line['client_type'],
])
content_lines.append(row)
return '\n'.join(content_lines), len(all_lines), all_warnings
def _get_export_filename(self):
"""Generate filename for ADP export.
ADP requires a specific filename format: VENDORCODE_YYYY-MM-DD.txt
We do NOT add submission numbers because ADP won't accept renamed files.
User must manually rename if submitting multiple times on same day.
"""
return f"{self.vendor_code}_{self.export_date.strftime('%Y-%m-%d')}.txt"
def _check_existing_file(self, filename):
"""Check if a file with the same name already exists in Documents.
Returns:
tuple: (exists: bool, existing_files: list of names)
"""
existing_files = []
if 'documents.document' not in self.env:
return False, existing_files
try:
# Get the folder where we save files
folder = self._get_or_create_documents_folder()
if not folder:
return False, existing_files
# Search for files with the same name in our folder
existing = self.env['documents.document'].search([
('name', '=', filename),
('type', '=', 'binary'),
('folder_id', '=', folder.id),
])
if existing:
existing_files = [f"{doc.name} (created: {doc.create_date.strftime('%Y-%m-%d %H:%M')})"
for doc in existing]
return True, existing_files
except Exception as e:
_logger.warning("Error checking existing files: %s", str(e))
return False, existing_files
def action_export(self):
"""Perform the export.
Flow:
1. Validate inputs
2. Generate content (includes verification - may raise UserError)
3. Check for existing files (warn but don't block)
4. Show download window
5. Save to Documents ONLY after successful generation
"""
self.ensure_one()
if not self.invoice_ids:
raise UserError(_("Please select at least one invoice to export."))
if not self.vendor_code:
raise UserError(_("Please enter a vendor code."))
# Generate filename first
filename = self._get_export_filename()
# Check for existing file with same name BEFORE generating content
file_exists, existing_files = self._check_existing_file(filename)
# Generate content - this includes all validation and verification
# If verification fails, UserError is raised and we don't save anything
content, line_count, warnings = self._generate_export_content()
# If we got here, content generation was successful (no errors)
file_data = base64.b64encode(content.encode('utf-8'))
# Add warning if file already exists
if file_exists:
warnings.append(
f"WARNING: A file with the name '{filename}' already exists in Documents.\n"
f"Existing files: {', '.join(existing_files)}\n"
f"ADP does not accept renamed files. You will need to manually rename "
f"before submitting if this is a resubmission."
)
# Build warnings text BEFORE saving (so user sees the warning)
warnings_text = '\n'.join(warnings) if warnings else ''
# Now save to Documents (only after successful generation)
saved = self._save_to_documents(filename, content)
# Update invoices as exported
for invoice in self.invoice_ids:
invoice.write({
'adp_exported': True,
'adp_export_date': fields.Datetime.now(),
'adp_export_count': invoice.adp_export_count + 1,
})
# Build summary
summary = _("Exported %d lines from %d invoices.") % (line_count, len(self.invoice_ids))
if saved:
summary += "\n" + _("File saved to Documents: ADP Billing Files/%s/%s/") % (
date.today().year, date.today().strftime('%B')
)
# Update wizard with results
self.write({
'export_file': file_data,
'export_filename': filename,
'state': 'done',
'export_summary': summary,
'saved_to_documents': saved,
'warnings': warnings_text,
})
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def _save_to_documents(self, filename, content):
"""Save export file to Documents app if available."""
if 'documents.document' not in self.env:
return False
try:
Documents = self.env['documents.document']
# Get or create folder structure (in Company workspace)
folder = self._get_or_create_documents_folder()
if not folder:
return False
# Create document in the Company workspace folder
# Don't set company_id or owner_id - inherits from folder (Company workspace)
Documents.sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(content.encode('utf-8')),
'folder_id': folder.id,
'access_internal': 'edit', # Allow internal users to access
})
_logger.info("Saved ADP export to Documents: %s", filename)
return True
except Exception as e:
_logger.warning("Could not save to Documents: %s", str(e))
return False
def _get_or_create_documents_folder(self):
"""Get or create the ADP Billing Files folder structure."""
if 'documents.document' not in self.env:
return False
try:
# Odoo 18/19 stores folders as documents.document with type='folder'
# The documents.folder model doesn't exist in newer versions
return self._get_or_create_folder_v18()
except Exception as e:
_logger.warning("Could not create folder: %s", str(e))
return False
def _get_or_create_folder_v18(self):
"""Get or create folders for Odoo 18+.
In Odoo 18/19, folders are stored as documents.document records with type='folder'.
To make folders appear in Company workspace (not My Drive), we need:
- company_id = False (not set)
- owner_id = False (not set)
- access_internal = 'edit' (allows internal users to access)
"""
Document = self.env['documents.document']
today = date.today()
# Root folder: ADP Billing Files (in Company workspace)
root_folder = Document.search([
('name', 'ilike', 'ADP Billing Files'),
('type', '=', 'folder'),
('folder_id', '=', False), # Root level folder
], limit=1)
if not root_folder:
root_folder = Document.sudo().create({
'name': 'ADP Billing Files',
'type': 'folder',
'access_internal': 'edit', # Company workspace access
'access_via_link': 'none',
# Don't set company_id or owner_id - makes it a Company folder
})
# Year folder
year_name = str(today.year)
year_folder = Document.search([
('name', 'ilike', year_name),
('type', '=', 'folder'),
('folder_id', '=', root_folder.id),
], limit=1)
if not year_folder:
year_folder = Document.sudo().create({
'name': year_name,
'type': 'folder',
'folder_id': root_folder.id,
'access_internal': 'edit',
'access_via_link': 'none',
})
# Month folder
month_name = today.strftime('%B')
month_folder = Document.search([
('name', 'ilike', month_name),
('type', '=', 'folder'),
('folder_id', '=', year_folder.id),
], limit=1)
if not month_folder:
month_folder = Document.sudo().create({
'name': month_name,
'type': 'folder',
'folder_id': year_folder.id,
'access_internal': 'edit',
'access_via_link': 'none',
})
return month_folder
def _get_or_create_folder_legacy(self):
"""Get or create folders for older Odoo versions."""
Documents = self.env['documents.document']
company = self.env.company
today = date.today()
# Root folder
root_folder = Documents.search([
('name', '=', 'ADP Billing Files'),
('type', '=', 'folder'),
('company_id', '=', company.id),
], limit=1)
if not root_folder:
root_folder = Documents.create({
'name': 'ADP Billing Files',
'type': 'folder',
'company_id': company.id,
})
# Year folder
year_name = str(today.year)
year_folder = Documents.search([
('name', '=', year_name),
('type', '=', 'folder'),
('folder_id', '=', root_folder.id),
], limit=1)
if not year_folder:
year_folder = Documents.create({
'name': year_name,
'type': 'folder',
'folder_id': root_folder.id,
'company_id': company.id,
})
# Month folder
month_name = today.strftime('%B')
month_folder = Documents.search([
('name', '=', month_name),
('type', '=', 'folder'),
('folder_id', '=', year_folder.id),
], limit=1)
if not month_folder:
month_folder = Documents.create({
'name': month_name,
'type': 'folder',
'folder_id': year_folder.id,
'company_id': company.id,
})
return month_folder

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!-- ADP Export Wizard Form -->
<record id="view_fusion_claims_export_wizard_form" model="ir.ui.view">
<field name="name">fusion.central.export.wizard.form</field>
<field name="model">fusion_claims.export.wizard</field>
<field name="arch" type="xml">
<form string="Export ADP Claims">
<!-- Draft State: Configuration -->
<group invisible="state == 'done'">
<group string="Export Configuration">
<field name="vendor_code" placeholder="e.g., 1234567"/>
<field name="export_date"/>
</group>
<group string="Invoices to Export">
<field name="invoice_ids" widget="many2many_tags" readonly="1" nolabel="1"/>
</group>
</group>
<!-- Done State: Results -->
<group invisible="state != 'done'">
<div class="alert alert-success" role="alert">
<h5><i class="fa fa-check-circle"/> Export Complete!</h5>
<field name="export_summary" nolabel="1"/>
</div>
<!-- Warnings if any -->
<div class="alert alert-warning" role="alert" invisible="not warnings">
<h5><i class="fa fa-exclamation-triangle"/> Warnings</h5>
<field name="warnings" nolabel="1"/>
</div>
<group string="Download Export File">
<field name="export_filename" readonly="1"/>
<field name="export_file" filename="export_filename" readonly="1"/>
</group>
<div class="text-muted small" invisible="not saved_to_documents">
<i class="fa fa-folder-open"/> File also saved to Documents app
</div>
</group>
<field name="state" invisible="1"/>
<field name="saved_to_documents" invisible="1"/>
<field name="warnings" invisible="1"/>
<footer>
<button string="Export" name="action_export" type="object"
class="btn-primary" invisible="state == 'done'"
icon="fa-download"/>
<button string="Download" class="btn-primary" invisible="state != 'done'"
special="cancel"/>
<button string="Close" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Menu Action -->
<record id="action_fusion_claims_export_wizard" model="ir.actions.act_window">
<field name="name">Export ADP Claims</field>
<field name="res_model">fusion_claims.export.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,130 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
import logging
_logger = logging.getLogger(__name__)
class ApplicationReceivedWizard(models.TransientModel):
"""Wizard to upload ADP application documents when application is received."""
_name = 'fusion_claims.application.received.wizard'
_description = 'Application Received Wizard'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
readonly=True,
)
# Document uploads
original_application = fields.Binary(
string='Original ADP Application',
required=True,
help='Upload the original ADP application PDF received from the client',
)
original_application_filename = fields.Char(
string='Application Filename',
)
signed_pages_11_12 = fields.Binary(
string='Signed Pages 11 & 12',
required=True,
help='Upload the signed pages 11 and 12 from the application',
)
signed_pages_filename = fields.Char(
string='Pages Filename',
)
notes = fields.Text(
string='Notes',
help='Any notes about the received application',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self._context.get('active_id')
if active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
# Pre-fill if documents already exist
if order.x_fc_original_application:
res['original_application'] = order.x_fc_original_application
res['original_application_filename'] = order.x_fc_original_application_filename
if order.x_fc_signed_pages_11_12:
res['signed_pages_11_12'] = order.x_fc_signed_pages_11_12
res['signed_pages_filename'] = order.x_fc_signed_pages_filename
return res
@api.constrains('original_application_filename')
def _check_application_file_type(self):
for wizard in self:
if wizard.original_application_filename:
if not wizard.original_application_filename.lower().endswith('.pdf'):
raise UserError(
"Original Application must be a PDF file.\n"
f"Uploaded file: '{wizard.original_application_filename}'"
)
@api.constrains('signed_pages_filename')
def _check_pages_file_type(self):
for wizard in self:
if wizard.signed_pages_filename:
if not wizard.signed_pages_filename.lower().endswith('.pdf'):
raise UserError(
"Signed Pages 11 & 12 must be a PDF file.\n"
f"Uploaded file: '{wizard.signed_pages_filename}'"
)
def action_confirm(self):
"""Save documents and mark application as received."""
self.ensure_one()
order = self.sale_order_id
if order.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'):
raise UserError("Can only receive application from 'Waiting for Application' status.")
# Validate files are uploaded
if not self.original_application:
raise UserError("Please upload the Original ADP Application.")
if not self.signed_pages_11_12:
raise UserError("Please upload the Signed Pages 11 & 12.")
# Update sale order with documents
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'application_received',
'x_fc_original_application': self.original_application,
'x_fc_original_application_filename': self.original_application_filename,
'x_fc_signed_pages_11_12': self.signed_pages_11_12,
'x_fc_signed_pages_filename': self.signed_pages_filename,
})
# Post to chatter
from datetime import date
notes_html = f'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>' if self.notes else ''
order.message_post(
body=Markup(
'<div style="background: #e8f4fd; border-left: 4px solid #17a2b8; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #17a2b8; margin: 0 0 8px 0;"><i class="fa fa-file-text-o"/> Application Received</h4>'
f'<p style="margin: 0;"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>'
'<p style="margin: 8px 0 4px 0;"><strong>Documents Uploaded:</strong></p>'
'<ul style="margin: 0; padding-left: 20px;">'
f'<li><i class="fa fa-check text-success"/> Original ADP Application: {self.original_application_filename}</li>'
f'<li><i class="fa fa-check text-success"/> Signed Pages 11 & 12: {self.signed_pages_filename}</li>'
'</ul>'
f'{notes_html}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Application Received Wizard Form View -->
<record id="view_application_received_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.application.received.wizard.form</field>
<field name="model">fusion_claims.application.received.wizard</field>
<field name="arch" type="xml">
<form string="Application Received">
<div class="alert alert-info mb-3" role="alert">
<strong><i class="fa fa-info-circle"/> Upload Required Documents</strong>
<p class="mb-0">Please upload the ADP application documents received from the client.</p>
</div>
<group>
<field name="sale_order_id" invisible="1"/>
<group string="Original ADP Application">
<field name="original_application" filename="original_application_filename"
widget="binary" class="oe_inline"/>
<field name="original_application_filename" invisible="1"/>
</group>
<group string="Signed Pages 11 &amp; 12">
<field name="signed_pages_11_12" filename="signed_pages_filename"
widget="binary" class="oe_inline"/>
<field name="signed_pages_filename" invisible="1"/>
</group>
</group>
<group>
<field name="notes" placeholder="Any notes about the received application..."/>
</group>
<footer>
<button name="action_confirm" type="object"
string="Confirm Application Received" class="btn-primary"
icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action for the wizard -->
<record id="action_application_received_wizard" model="ir.actions.act_window">
<field name="name">Application Received</field>
<field name="res_model">fusion_claims.application.received.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
import logging
_logger = logging.getLogger(__name__)
class AssessmentCompletedWizard(models.TransientModel):
"""Wizard to record assessment completion date."""
_name = 'fusion_claims.assessment.completed.wizard'
_description = 'Assessment Completed Wizard'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
readonly=True,
)
completion_date = fields.Date(
string='Assessment Completion Date',
required=True,
default=fields.Date.context_today,
)
notes = fields.Text(
string='Assessment Notes',
help='Any notes from the assessment',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self._context.get('active_id')
if active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
return res
def action_complete(self):
"""Mark assessment as completed."""
self.ensure_one()
order = self.sale_order_id
if order.x_fc_adp_application_status != 'assessment_scheduled':
raise UserError("Can only complete assessment from 'Assessment Scheduled' status.")
# Validate completion date is not before start date
if order.x_fc_assessment_start_date and self.completion_date < order.x_fc_assessment_start_date:
raise UserError(
f"Completion date ({self.completion_date}) cannot be before "
f"assessment start date ({order.x_fc_assessment_start_date})."
)
# Update sale order
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'assessment_completed',
'x_fc_assessment_end_date': self.completion_date,
})
# Post to chatter
notes_html = f'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>' if self.notes else ''
order.message_post(
body=Markup(
'<div style="background: #d4edda; border-left: 4px solid #28a745; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #28a745; margin: 0 0 8px 0;"><i class="fa fa-check-square-o"/> Assessment Completed</h4>'
f'<p style="margin: 0;"><strong>Completion Date:</strong> {self.completion_date.strftime("%B %d, %Y")}</p>'
f'{notes_html}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Assessment Completed Wizard Form View -->
<record id="view_assessment_completed_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.assessment.completed.wizard.form</field>
<field name="model">fusion_claims.assessment.completed.wizard</field>
<field name="arch" type="xml">
<form string="Assessment Completed">
<group>
<field name="sale_order_id" invisible="1"/>
<field name="completion_date"/>
<field name="notes" placeholder="Enter any notes from the assessment..."/>
</group>
<footer>
<button name="action_complete" type="object"
string="Mark Complete" class="btn-primary"
icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action for the wizard -->
<record id="action_assessment_completed_wizard" model="ir.actions.act_window">
<field name="name">Assessment Completed</field>
<field name="res_model">fusion_claims.assessment.completed.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,211 @@
# -*- 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.
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
import logging
_logger = logging.getLogger(__name__)
class CaseCloseVerificationWizard(models.TransientModel):
"""Wizard to verify audit trail documents before closing an ADP case.
This wizard checks:
- Signed Pages 11 & 12 uploaded
- Final Application uploaded
- Proof of Delivery uploaded
- Vendor bills linked
"""
_name = 'fusion_claims.case.close.verification.wizard'
_description = 'Case Close Verification Wizard'
# ==========================================================================
# MAIN FIELDS
# ==========================================================================
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
readonly=True,
)
# Document verification fields (computed)
has_signed_pages = fields.Boolean(
string='Signed Pages 11 & 12',
compute='_compute_document_status',
)
has_final_application = fields.Boolean(
string='Final Application',
compute='_compute_document_status',
)
has_proof_of_delivery = fields.Boolean(
string='Proof of Delivery',
compute='_compute_document_status',
)
has_vendor_bills = fields.Boolean(
string='Vendor Bills Linked',
compute='_compute_document_status',
)
vendor_bill_count = fields.Integer(
string='Vendor Bills Count',
compute='_compute_document_status',
)
# Overall status
all_verified = fields.Boolean(
string='All Verified',
compute='_compute_all_verified',
)
missing_items = fields.Text(
string='Missing Items',
compute='_compute_all_verified',
)
# User notes (optional)
closing_notes = fields.Text(
string='Closing Notes',
help='Optional notes to record when closing the case.',
)
# ==========================================================================
# COMPUTED METHODS
# ==========================================================================
@api.depends('sale_order_id')
def _compute_document_status(self):
for wizard in self:
order = wizard.sale_order_id
wizard.has_signed_pages = bool(order.x_fc_signed_pages_11_12)
wizard.has_final_application = bool(order.x_fc_final_submitted_application)
wizard.has_proof_of_delivery = bool(order.x_fc_proof_of_delivery)
wizard.has_vendor_bills = len(order.x_fc_vendor_bill_ids) > 0
wizard.vendor_bill_count = len(order.x_fc_vendor_bill_ids)
@api.depends('has_signed_pages', 'has_final_application', 'has_proof_of_delivery', 'has_vendor_bills')
def _compute_all_verified(self):
for wizard in self:
missing = []
if not wizard.has_signed_pages:
missing.append('Signed Pages 11 & 12')
if not wizard.has_final_application:
missing.append('Final Application')
if not wizard.has_proof_of_delivery:
missing.append('Proof of Delivery')
if not wizard.has_vendor_bills:
missing.append('Vendor Bills (at least one)')
wizard.all_verified = len(missing) == 0
wizard.missing_items = '\n'.join(f'{item}' for item in missing) if missing else ''
# ==========================================================================
# DEFAULT GET
# ==========================================================================
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self._context.get('active_id')
if not active_id:
return res
res['sale_order_id'] = active_id
return res
# ==========================================================================
# ACTIONS
# ==========================================================================
def action_close_case(self):
"""Close the case after verification."""
self.ensure_one()
order = self.sale_order_id
# Check if everything is verified
if not self.all_verified:
raise UserError(
"Cannot close case - the following items are missing:\n\n"
f"{self.missing_items}\n\n"
"Please upload all required documents and link vendor bills before closing."
)
# Close the case
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'case_closed',
})
# Build summary message with consistent card style
from datetime import date
vendor_bills_list = ', '.join(order.x_fc_vendor_bill_ids.mapped('name')) if order.x_fc_vendor_bill_ids else 'N/A'
message_body = f"""
<div style="background: #d4edda; border-left: 4px solid #28a745; padding: 12px; margin: 8px 0; border-radius: 4px;">
<h4 style="color: #28a745; margin: 0 0 8px 0;">
<i class="fa fa-check-circle"/> Case Closed
</h4>
<p style="margin: 0;"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>
<p style="margin: 8px 0 4px 0;"><strong>Audit Trail Verified:</strong></p>
<ul style="margin: 0; padding-left: 20px;">
<li><i class="fa fa-check text-success"/> Signed Pages 11 & 12</li>
<li><i class="fa fa-check text-success"/> Final Application</li>
<li><i class="fa fa-check text-success"/> Proof of Delivery</li>
<li><i class="fa fa-check text-success"/> Vendor Bills: {self.vendor_bill_count} ({vendor_bills_list})</li>
</ul>
{f'<p style="margin: 8px 0 0 0;"><strong>Notes:</strong> {self.closing_notes}</p>' if self.closing_notes else ''}
<p style="margin: 8px 0 0 0; color: #666; font-size: 0.9em;">
Closed by {self.env.user.name}
</p>
</div>
"""
order.message_post(
body=Markup(message_body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.act_window_close',
}
def action_close_anyway(self):
"""Close the case even if some items are missing (with warning)."""
self.ensure_one()
order = self.sale_order_id
# Close the case
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'case_closed',
})
# Build warning message with consistent card style
from datetime import date
missing_items_html = self.missing_items.replace('\n', '<br/>') if self.missing_items else 'None'
message_body = f"""
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; margin: 8px 0; border-radius: 4px;">
<h4 style="color: #856404; margin: 0 0 8px 0;">
<i class="fa fa-exclamation-triangle"/> Case Closed (Incomplete)
</h4>
<p style="margin: 0;"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>
<p style="margin: 8px 0 4px 0; color: #856404;"><strong>Warning:</strong> Case closed without complete audit trail.</p>
<p style="margin: 4px 0;"><strong>Missing Items:</strong><br/>{missing_items_html}</p>
{f'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.closing_notes}</p>' if self.closing_notes else ''}
<p style="margin: 8px 0 0 0; color: #666; font-size: 0.9em;">
Closed by {self.env.user.name}
</p>
</div>
"""
order.message_post(
body=Markup(message_body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.act_window_close',
}

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!-- ===================================================================== -->
<!-- CASE CLOSE VERIFICATION WIZARD FORM -->
<!-- ===================================================================== -->
<record id="view_case_close_verification_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.case.close.verification.wizard.form</field>
<field name="model">fusion_claims.case.close.verification.wizard</field>
<field name="arch" type="xml">
<form string="Close Case - Audit Trail Verification">
<field name="sale_order_id" invisible="1"/>
<field name="all_verified" invisible="1"/>
<!-- Header Banner -->
<div class="alert alert-info mb-3" role="alert">
<h5 class="mb-2"><i class="fa fa-clipboard-check"/> Audit Trail Verification</h5>
<p class="mb-0">
Before closing this case, please verify all required documents are uploaded
and vendor bills are linked for audit purposes.
</p>
</div>
<!-- Missing Items Warning -->
<div class="alert alert-warning mb-3" role="alert" invisible="all_verified">
<h5 class="mb-2"><i class="fa fa-exclamation-triangle"/> Missing Items</h5>
<field name="missing_items" nolabel="1" readonly="1"
widget="text" style="white-space: pre-wrap;"/>
</div>
<!-- All Verified Success -->
<div class="alert alert-success mb-3" role="alert" invisible="not all_verified">
<h5 class="mb-0"><i class="fa fa-check-circle"/> All Items Verified</h5>
<p class="mb-0">All required documents and vendor bills are present.</p>
</div>
<!-- Verification Checklist -->
<group string="Document Checklist">
<group>
<div class="d-flex align-items-center mb-2">
<field name="has_signed_pages" widget="boolean" readonly="1" class="me-2"/>
<span>Signed Pages 11 &amp; 12</span>
</div>
<div class="d-flex align-items-center mb-2">
<field name="has_final_application" widget="boolean" readonly="1" class="me-2"/>
<span>Final Application</span>
</div>
</group>
<group>
<div class="d-flex align-items-center mb-2">
<field name="has_proof_of_delivery" widget="boolean" readonly="1" class="me-2"/>
<span>Proof of Delivery</span>
</div>
<div class="d-flex align-items-center mb-2">
<field name="has_vendor_bills" widget="boolean" readonly="1" class="me-2"/>
<span>Vendor Bills (<field name="vendor_bill_count" readonly="1" class="oe_inline"/> linked)</span>
</div>
</group>
</group>
<!-- Closing Notes -->
<group string="Closing Notes (Optional)">
<field name="closing_notes" nolabel="1" placeholder="Enter any notes for this case closure..."
colspan="2"/>
</group>
<footer>
<button name="action_close_case" type="object"
string="Close Case" class="btn-primary"
invisible="not all_verified"/>
<button name="action_close_case" type="object"
string="Close Case" class="btn-secondary"
invisible="all_verified"
confirm="Some items are missing. Are you sure you want to close this case?"/>
<button name="action_close_anyway" type="object"
string="Close Anyway (Incomplete)" class="btn-warning"
invisible="all_verified"
confirm="This will close the case with an incomplete audit trail. Continue?"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- ===================================================================== -->
<!-- CASE CLOSE VERIFICATION WIZARD ACTION -->
<!-- ===================================================================== -->
<record id="action_case_close_verification_wizard" model="ir.actions.act_window">
<field name="name">Close Case</field>
<field name="res_model">fusion_claims.case.close.verification.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
</record>
</odoo>

View File

@@ -0,0 +1,716 @@
# -*- 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.
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class DeviceApprovalWizard(models.TransientModel):
"""Wizard to confirm which device types were approved by ADP and set deductions.
This is Stage 2 of the two-stage verification system:
- Stage 1 (Submission): What device types were submitted (stored in x_fc_submitted_device_types)
- Stage 2 (Approval): What device types were approved by ADP (this wizard)
"""
_name = 'fusion_claims.device.approval.wizard'
_description = 'ADP Device Approval Wizard'
# ==========================================================================
# MAIN FIELDS
# ==========================================================================
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
readonly=True,
)
line_ids = fields.One2many(
'fusion_claims.device.approval.wizard.line',
'wizard_id',
string='Device Lines',
)
all_approved = fields.Boolean(
string='All Approved',
default=True,
help='Check this to approve all devices at once',
)
has_unapproved = fields.Boolean(
string='Has Unapproved Devices',
compute='_compute_has_unapproved',
)
has_deductions = fields.Boolean(
string='Has Deductions',
compute='_compute_has_deductions',
help='True if any device has a deduction applied',
)
has_invoices = fields.Boolean(
string='Has Invoices',
compute='_compute_has_invoices',
help='True if invoices already exist for this order',
)
# Stage 1 comparison fields
submitted_device_types = fields.Text(
string='Submitted Device Types',
compute='_compute_submitted_device_types',
help='Device types that were verified during submission (Stage 1)',
)
has_submission_data = fields.Boolean(
string='Has Submission Data',
compute='_compute_submitted_device_types',
)
# Claim Number - required for Mark as Approved
claim_number = fields.Char(
string='Claim Number',
help='ADP Claim Number from the approval letter',
)
# Approval Documents - for Mark as Approved mode
is_mark_approved_mode = fields.Boolean(
string='Mark Approved Mode',
compute='_compute_is_mark_approved_mode',
)
approval_letter = fields.Binary(
string='ADP Approval Letter',
help='Upload the ADP approval letter PDF',
)
approval_letter_filename = fields.Char(
string='Approval Letter Filename',
)
# For multiple approval photos, we'll use a Many2many to ir.attachment
approval_photo_ids = fields.Many2many(
'ir.attachment',
'device_approval_wizard_attachment_rel',
'wizard_id',
'attachment_id',
string='Approval Screenshots',
help='Upload screenshots from the ADP approval document',
)
@api.depends_context('mark_as_approved')
def _compute_is_mark_approved_mode(self):
for wizard in self:
wizard.is_mark_approved_mode = self.env.context.get('mark_as_approved', False)
# ==========================================================================
# COMPUTED FIELDS
# ==========================================================================
@api.depends('line_ids.approved')
def _compute_has_unapproved(self):
for wizard in self:
wizard.has_unapproved = any(not line.approved for line in wizard.line_ids)
@api.depends('line_ids.deduction_type')
def _compute_has_deductions(self):
for wizard in self:
wizard.has_deductions = any(
line.deduction_type and line.deduction_type != 'none'
for line in wizard.line_ids
)
@api.depends('sale_order_id', 'sale_order_id.invoice_ids')
def _compute_has_invoices(self):
for wizard in self:
if wizard.sale_order_id:
wizard.has_invoices = bool(wizard.sale_order_id.invoice_ids.filtered(
lambda inv: inv.state != 'cancel'
))
else:
wizard.has_invoices = False
@api.depends('sale_order_id', 'sale_order_id.x_fc_submitted_device_types')
def _compute_submitted_device_types(self):
"""Compute the submitted device types from Stage 1 for comparison display."""
import json
for wizard in self:
if wizard.sale_order_id and wizard.sale_order_id.x_fc_submitted_device_types:
try:
data = json.loads(wizard.sale_order_id.x_fc_submitted_device_types)
# Format as readable list
submitted = [dt for dt, selected in data.items() if selected]
wizard.submitted_device_types = '\n'.join([f'{dt}' for dt in sorted(submitted)])
wizard.has_submission_data = bool(submitted)
except (json.JSONDecodeError, TypeError):
wizard.submitted_device_types = ''
wizard.has_submission_data = False
else:
wizard.submitted_device_types = ''
wizard.has_submission_data = False
@api.onchange('all_approved')
def _onchange_all_approved(self):
"""Toggle all lines when 'All Approved' is changed.
Only triggers when toggling ON - sets all devices to approved.
When toggling OFF, users manually uncheck individual items.
"""
if self.all_approved:
# Iterate and set approved flag - avoid replacing the entire line
for line in self.line_ids:
if not line.approved:
line.approved = True
# ==========================================================================
# DEFAULT GET - Populate with order lines
# ==========================================================================
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self._context.get('active_id')
if not active_id:
return res
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
# Build line data from order lines that have ADP device codes ONLY
# Non-ADP items are excluded from the verification list
ADPDevice = self.env['fusion.adp.device.code'].sudo()
lines_data = []
for so_line in order.order_line:
# Skip non-product lines
if so_line.display_type in ('line_section', 'line_note'):
continue
if not so_line.product_id or so_line.product_uom_qty <= 0:
continue
# Get device code and look up device type
device_code = so_line._get_adp_device_code()
# SKIP items without a valid ADP device code in the database
# These are non-ADP items and don't need verification
if not device_code:
continue
adp_device = ADPDevice.search([('device_code', '=', device_code), ('active', '=', True)], limit=1)
# Skip if device code not found in ADP database (non-ADP item)
if not adp_device:
continue
device_type = adp_device.device_type or ''
device_description = adp_device.device_description or ''
# Default to NOT approved - user must actively check each item
# Unless it was already approved in a previous verification
is_approved = so_line.x_fc_adp_approved if so_line.x_fc_adp_approved else False
lines_data.append((0, 0, {
'sale_line_id': so_line.id,
'product_name': so_line.product_id.display_name,
'device_code': device_code,
'device_type': device_type or so_line.x_fc_adp_device_type or '',
'device_description': device_description,
'serial_number': so_line.x_fc_serial_number or '',
'quantity': so_line.product_uom_qty,
'unit_price': so_line.price_unit,
'adp_portion': so_line.x_fc_adp_portion,
'client_portion': so_line.x_fc_client_portion,
'approved': is_approved,
'deduction_type': so_line.x_fc_deduction_type or 'none',
'deduction_value': so_line.x_fc_deduction_value or 0,
}))
res['line_ids'] = lines_data
# All Approved checkbox - only true if ALL lines are already approved
if lines_data:
all_approved = all(line[2].get('approved', False) for line in lines_data)
res['all_approved'] = all_approved
else:
res['all_approved'] = True # No ADP items = nothing to verify
return res
# ==========================================================================
# ACTION METHODS
# ==========================================================================
def action_confirm_approval(self):
"""Confirm the approval status and deductions, update order lines and invoices."""
self.ensure_one()
approved_count = 0
unapproved_count = 0
deduction_count = 0
updated_lines = self.env['sale.order.line']
for wiz_line in self.line_ids:
if wiz_line.sale_line_id:
# NOTE: Do NOT write x_fc_adp_device_type here - it's a computed field
# that should be computed from the product's device code
vals = {
'x_fc_adp_approved': wiz_line.approved,
'x_fc_deduction_type': wiz_line.deduction_type or 'none',
'x_fc_deduction_value': wiz_line.deduction_value or 0,
}
wiz_line.sale_line_id.write(vals)
updated_lines |= wiz_line.sale_line_id
if wiz_line.approved:
approved_count += 1
else:
unapproved_count += 1
if wiz_line.deduction_type and wiz_line.deduction_type != 'none':
deduction_count += 1
# MARK VERIFICATION AS COMPLETE on the sale order
# This allows invoice creation even if some items are unapproved
if self.sale_order_id:
self.sale_order_id.write({'x_fc_device_verification_complete': True})
# FORCE RECALCULATION of ADP/Client portions on all order lines
# This ensures unapproved items get 100% assigned to client portion
for line in self.sale_order_id.order_line:
line._compute_adp_portions()
# Recalculate order totals
self.sale_order_id._compute_adp_totals()
# If we're in "mark_as_approved" mode, also update the status and save approval documents
if self.env.context.get('mark_as_approved') and self.sale_order_id:
# Determine status based on whether there are deductions
new_status = 'approved_deduction' if deduction_count > 0 else 'approved'
update_vals = {
'x_fc_adp_application_status': new_status,
}
# Save claim number if provided
if self.claim_number:
update_vals['x_fc_claim_number'] = self.claim_number
# Save approval letter if uploaded
if self.approval_letter:
update_vals['x_fc_approval_letter'] = self.approval_letter
update_vals['x_fc_approval_letter_filename'] = self.approval_letter_filename
self.sale_order_id.with_context(skip_status_validation=True).write(update_vals)
# Collect attachment IDs for chatter post
chatter_attachment_ids = []
# IMPORTANT: When files are uploaded via many2many_binary to a transient model,
# they are linked to the wizard and may be garbage collected when the wizard closes.
# We need to COPY the attachment data to create persistent attachments linked to the sale order.
photos_attached = 0
if self.approval_photo_ids:
photo_ids_to_link = []
IrAttachment = self.env['ir.attachment'].sudo()
for attachment in self.approval_photo_ids:
# Create a NEW attachment linked to the sale order (copy the data)
# This ensures the attachment persists after the wizard is deleted
new_attachment = IrAttachment.create({
'name': attachment.name or f'approval_screenshot_{photos_attached + 1}',
'datas': attachment.datas,
'mimetype': attachment.mimetype,
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'type': 'binary',
})
chatter_attachment_ids.append(new_attachment.id)
photo_ids_to_link.append(new_attachment.id)
photos_attached += 1
# Link photos to the Many2many field for easy access in ADP Documents tab
if photo_ids_to_link:
existing_photo_ids = self.sale_order_id.x_fc_approval_photo_ids.ids
self.sale_order_id.write({
'x_fc_approval_photo_ids': [(6, 0, existing_photo_ids + photo_ids_to_link)]
})
# Create attachment for approval letter if uploaded
if self.approval_letter:
letter_attachment = self.env['ir.attachment'].create({
'name': self.approval_letter_filename or 'ADP_Approval_Letter.pdf',
'datas': self.approval_letter,
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
})
chatter_attachment_ids.append(letter_attachment.id)
# Post approval to chatter with all documents in ONE message
from markupsafe import Markup
from datetime import date
device_details = f'{approved_count} approved'
if unapproved_count > 0:
device_details += f', {unapproved_count} not approved'
if deduction_count > 0:
device_details += f', {deduction_count} with deductions'
# Build documents list - show individual file names for screenshots
docs_items = ''
if self.approval_letter:
docs_items += f'<li>Approval Letter: {self.approval_letter_filename}</li>'
if photos_attached > 0:
docs_items += f'<li>{photos_attached} approval screenshot(s) attached below</li>'
docs_html = ''
if docs_items:
docs_html = f'<p class="mb-1"><strong>Documents:</strong></p><ul class="mb-0">{docs_items}</ul>'
# Post to chatter with all attachments in one message
if chatter_attachment_ids:
self.sale_order_id.message_post(
body=Markup(
'<div class="alert alert-success" role="alert">'
'<h5 class="alert-heading"><i class="fa fa-check-circle"/> Application Approved</h5>'
f'<p class="mb-1"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>'
f'<p class="mb-1"><strong>Devices:</strong> {device_details}</p>'
f'{docs_html}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
attachment_ids=chatter_attachment_ids,
)
else:
# No attachments, just post the status update
self.sale_order_id.message_post(
body=Markup(
'<div class="alert alert-success" role="alert">'
'<h5 class="alert-heading"><i class="fa fa-check-circle"/> Application Approved</h5>'
f'<p class="mb-1"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>'
f'<p class="mb-1"><strong>Devices:</strong> {device_details}</p>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Sync deductions and approval status to existing invoices
invoices_updated = self._sync_approval_to_invoices(updated_lines)
# Build notification message
parts = []
if unapproved_count > 0:
parts.append(_("%d approved, %d NOT approved (will bill to client)") % (approved_count, unapproved_count))
msg_type = 'warning'
else:
parts.append(_("All %d devices approved") % approved_count)
msg_type = 'success'
if deduction_count > 0:
parts.append(_("%d deduction(s) applied") % deduction_count)
if invoices_updated > 0:
parts.append(_("%d invoice(s) updated") % invoices_updated)
# Add status update note if applicable
if self.env.context.get('mark_as_approved'):
parts.append(_("Status updated to Approved"))
message = ". ".join(parts) + "."
# Close the wizard and show notification
return {
'type': 'ir.actions.act_window_close',
'infos': {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Application Approved') if self.env.context.get('mark_as_approved') else _('Device Verification Complete'),
'message': message,
'type': msg_type,
'sticky': False,
}
}
}
def _sync_approval_to_invoices(self, sale_lines):
"""Sync approval status and deductions to existing invoices.
When approval status or deductions change on SO lines:
- Client Invoice: unapproved items get 100% price, approved items get normal client portion
- ADP Invoice: unapproved items get removed (price = 0), approved items get normal ADP portion
Returns number of invoices updated.
"""
if not sale_lines:
return 0
invoices_updated = set()
order = self.sale_order_id
ADPDevice = self.env['fusion.adp.device.code'].sudo()
# Get all non-cancelled invoices for this order
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
if not invoices:
return 0
for so_line in sale_lines:
# =================================================================
# CHECK 1: Is this a NON-ADP funded product?
# =================================================================
is_non_adp_funded = so_line.product_id.is_non_adp_funded() if so_line.product_id else False
# =================================================================
# CHECK 2: Does this product have a valid ADP device code?
# =================================================================
device_code = so_line._get_adp_device_code()
is_adp_device = False
if device_code and not is_non_adp_funded:
is_adp_device = ADPDevice.search_count([
('device_code', '=', device_code),
('active', '=', True)
]) > 0
is_approved = so_line.x_fc_adp_approved
# Find linked invoice lines
invoice_lines = self.env['account.move.line'].sudo().search([
('sale_line_ids', 'in', so_line.id),
('move_id', 'in', invoices.ids),
])
for inv_line in invoice_lines:
invoice = inv_line.move_id
# Update approval and deduction fields on invoice line
inv_line_vals = {
'x_fc_deduction_type': so_line.x_fc_deduction_type,
'x_fc_deduction_value': so_line.x_fc_deduction_value,
'x_fc_adp_approved': is_approved,
}
# Check invoice portion type
portion_type = getattr(invoice, 'x_fc_adp_invoice_portion', '') or ''
# =================================================================
# INVOICE LINE LOGIC:
# - Non-ADP items (NON-ADP code OR not in ADP database):
# -> Client Invoice: 100% price
# -> ADP Invoice: $0 (should not be there)
# - Unapproved ADP items:
# -> Client Invoice: 100% price
# -> ADP Invoice: $0 (excluded)
# - Approved ADP items:
# -> Client Invoice: client portion %
# -> ADP Invoice: ADP portion %
# =================================================================
if portion_type == 'client':
if is_non_adp_funded or not is_adp_device:
# NON-ADP item: Client pays 100%
new_portion = so_line.price_subtotal
inv_line_vals['name'] = so_line.name
elif is_adp_device and not is_approved:
# UNAPPROVED ADP device: Client pays 100%
new_portion = so_line.price_subtotal
inv_line_vals['name'] = f"{so_line.name} [NOT APPROVED - 100% Client]"
else:
# Normal client portion (approved ADP item)
new_portion = so_line.x_fc_client_portion
inv_line_vals['name'] = so_line.name
elif portion_type == 'adp':
if is_non_adp_funded or not is_adp_device:
# NON-ADP item: Remove from ADP invoice (set to 0)
new_portion = 0
inv_line_vals['name'] = f"{so_line.name} [NON-ADP - Excluded]"
elif is_adp_device and not is_approved:
# UNAPPROVED ADP device: Remove from ADP invoice (set to 0)
new_portion = 0
inv_line_vals['name'] = f"{so_line.name} [NOT APPROVED - Excluded]"
else:
# Normal ADP portion
new_portion = so_line.x_fc_adp_portion
inv_line_vals['name'] = so_line.name
else:
# Unknown type - just update fields, skip price recalc
inv_line.write(inv_line_vals)
invoices_updated.add(invoice.id)
continue
# Calculate new unit price
if so_line.product_uom_qty > 0:
new_unit_price = new_portion / so_line.product_uom_qty
else:
new_unit_price = 0
inv_line_vals['price_unit'] = new_unit_price
# Need to handle draft vs posted invoices differently
if invoice.state == 'draft':
inv_line.write(inv_line_vals)
else:
# For posted invoices, reset to draft first
try:
invoice.button_draft()
inv_line.write(inv_line_vals)
invoice.action_post()
_logger.info(f"Reset and updated invoice {invoice.name} for approval/deduction change")
except Exception as e:
_logger.warning(f"Could not update posted invoice {invoice.name}: {e}")
invoices_updated.add(invoice.id)
return len(invoices_updated)
def action_approve_all(self):
"""Approve all devices."""
self.ensure_one()
# Write to lines to ensure proper update
self.line_ids.write({'approved': True})
self.write({'all_approved': True})
# Return action to refresh the wizard view
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
'context': self.env.context,
}
class DeviceApprovalWizardLine(models.TransientModel):
"""Lines for the device approval wizard."""
_name = 'fusion_claims.device.approval.wizard.line'
_description = 'Device Approval Wizard Line'
wizard_id = fields.Many2one(
'fusion_claims.device.approval.wizard',
string='Wizard',
required=True,
ondelete='cascade',
)
sale_line_id = fields.Many2one(
'sale.order.line',
string='Sale Line',
required=True,
)
product_name = fields.Char(
string='Product',
readonly=True,
)
device_code = fields.Char(
string='Device Code',
readonly=True,
)
device_type = fields.Char(
string='Device Type',
readonly=True,
)
device_description = fields.Char(
string='Description',
readonly=True,
)
serial_number = fields.Char(
string='Serial Number',
help='Serial number from the sale order line',
)
quantity = fields.Float(
string='Qty',
readonly=True,
)
unit_price = fields.Float(
string='Unit Price',
readonly=True,
digits='Product Price',
)
adp_portion = fields.Float(
string='ADP Portion',
readonly=True,
digits='Product Price',
)
client_portion = fields.Float(
string='Client Portion',
readonly=True,
digits='Product Price',
)
approved = fields.Boolean(
string='Approved',
default=True,
help='Check if this device type was approved by ADP',
)
# ==========================================================================
# DEDUCTION FIELDS
# ==========================================================================
deduction_type = fields.Selection(
selection=[
('none', 'No Deduction'),
('pct', 'Percentage (%)'),
('amt', 'Amount ($)'),
],
string='Deduction Type',
default='none',
help='Type of ADP deduction. PCT = ADP covers X% of normal. AMT = Fixed $ deducted.',
)
deduction_value = fields.Float(
string='Deduction',
digits='Product Price',
help='For PCT: enter percentage (e.g., 75 means ADP covers 75%). For AMT: enter dollar amount.',
)
# ==========================================================================
# COMPUTED FIELDS FOR PREVIEW
# ==========================================================================
estimated_adp = fields.Float(
string='Est. ADP',
compute='_compute_estimated_portions',
digits='Product Price',
help='Estimated ADP portion after deduction',
)
estimated_client = fields.Float(
string='Est. Client',
compute='_compute_estimated_portions',
digits='Product Price',
help='Estimated client portion after deduction',
)
@api.depends('deduction_type', 'deduction_value', 'adp_portion', 'client_portion',
'unit_price', 'quantity', 'approved')
def _compute_estimated_portions(self):
"""Compute estimated portions based on current deduction settings."""
for line in self:
if not line.approved:
# If not approved, show 0 for ADP portion
line.estimated_adp = 0
line.estimated_client = line.unit_price * line.quantity
continue
# Get base values from the sale line
so_line = line.sale_line_id
if not so_line or not so_line.order_id:
line.estimated_adp = line.adp_portion
line.estimated_client = line.client_portion
continue
# Get client type for base percentages
client_type = so_line.order_id._get_client_type()
if client_type == 'REG':
base_adp_pct = 0.75
else:
base_adp_pct = 1.0
# Get ADP price
adp_price = so_line.x_fc_adp_max_price or line.unit_price
total = adp_price * line.quantity
# Apply deduction
if line.deduction_type == 'pct' and line.deduction_value:
# PCT: ADP only covers deduction_value% of their portion
effective_pct = base_adp_pct * (line.deduction_value / 100)
line.estimated_adp = total * effective_pct
line.estimated_client = total - line.estimated_adp
elif line.deduction_type == 'amt' and line.deduction_value:
# AMT: Subtract fixed amount from ADP portion
base_adp = total * base_adp_pct
line.estimated_adp = max(0, base_adp - line.deduction_value)
line.estimated_client = total - line.estimated_adp
else:
# No deduction
line.estimated_adp = total * base_adp_pct
line.estimated_client = total * (1 - base_adp_pct)

View File

@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_device_approval_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.device.approval.wizard.form</field>
<field name="model">fusion_claims.device.approval.wizard</field>
<field name="arch" type="xml">
<form string="Device Approval Verification">
<field name="has_unapproved" invisible="1"/>
<field name="has_deductions" invisible="1"/>
<field name="has_invoices" invisible="1"/>
<field name="has_submission_data" invisible="1"/>
<field name="is_mark_approved_mode" invisible="1"/>
<!-- Instructions -->
<div class="alert alert-info" role="alert">
<strong>Stage 2: Device Approval Verification</strong>
<p class="mb-0">
1. Uncheck devices that were NOT approved by ADP.<br/>
2. If approved with deduction, set the type and value.<br/>
<small class="text-muted">
PCT = ADP covers X% of normal portion. AMT = Fixed $ deducted from ADP.
</small>
</p>
</div>
<!-- Stage 1 Reference -->
<div class="alert alert-secondary" role="alert" invisible="not has_submission_data">
<strong>Stage 1 Reference:</strong>
<field name="submitted_device_types" nolabel="1" class="d-inline"/>
<br/>
<small class="text-muted">Compare with ADP approval letter.</small>
</div>
<!-- Invoices Warning -->
<div class="alert alert-warning" role="alert" invisible="not has_invoices">
<i class="fa fa-exclamation-triangle"/>
<strong>Invoices exist.</strong> Changes will automatically update existing invoices.
</div>
<!-- Claim Number - Required for Mark as Approved -->
<group invisible="not is_mark_approved_mode">
<group>
<field name="claim_number" required="is_mark_approved_mode"
placeholder="Enter ADP Claim Number from approval letter"/>
</group>
</group>
<!-- Header: Order and All Approved -->
<group>
<group string="Order">
<field name="sale_order_id" readonly="1"/>
</group>
<group string="Quick Actions">
<field name="all_approved"/>
</group>
</group>
<!-- Device Table -->
<group string="Devices for Approval">
<field name="line_ids" nolabel="1" colspan="2">
<list editable="bottom" create="0" delete="0"
decoration-danger="not approved"
decoration-success="approved and (not deduction_type or deduction_type == 'none')"
decoration-warning="approved and deduction_type and deduction_type != 'none'">
<field name="approved" string="Approved"/>
<field name="device_type" string="Device Type"/>
<field name="product_name" string="Product"/>
<field name="device_code" string="ADP Code"/>
<field name="serial_number" string="S/N"/>
<field name="quantity" string="Qty"/>
<field name="unit_price" string="Unit $"/>
<field name="deduction_type" string="Deduction"/>
<field name="deduction_value" string="Ded. $"/>
<field name="estimated_adp" string="Est. ADP" decoration-danger="estimated_adp == 0"/>
<field name="estimated_client" string="Est. Client"/>
<field name="sale_line_id" column_invisible="1"/>
</list>
</field>
</group>
<!-- Unapproved Warning -->
<div class="alert alert-danger" role="alert" invisible="not has_unapproved">
<strong><i class="fa fa-times-circle"/> Unapproved Items</strong>
<p class="mb-0">
Devices marked as NOT approved will be billed to client at 100% and removed from ADP invoice.
</p>
</div>
<!-- Deductions Info -->
<div class="alert alert-info" role="alert" invisible="not has_deductions">
<strong><i class="fa fa-calculator"/> Deductions Applied</strong>
<p class="mb-0">
One or more devices have deductions. The client will be responsible for the difference.
</p>
</div>
<!-- Approval Documents Section - Only visible in Mark as Approved mode -->
<group string="Approval Documents" invisible="not is_mark_approved_mode">
<div class="alert alert-info mb-3" role="alert" colspan="2">
<strong><i class="fa fa-upload"/> Upload Approval Documents</strong>
<p class="mb-0">Upload the ADP approval letter and any screenshots from the approval.</p>
</div>
<group string="Approval Letter (PDF)">
<field name="approval_letter" filename="approval_letter_filename"
widget="binary"/>
<field name="approval_letter_filename" invisible="1"/>
</group>
<group string="Approval Screenshots (Drag &amp; Drop Multiple)">
<field name="approval_photo_ids" widget="many2many_binary"
string="Approval Screenshots"
options="{'accepted_file_extensions': 'image/*,.pdf'}"/>
</group>
</group>
<footer>
<button name="action_confirm_approval" type="object"
string="Confirm &amp; Apply" class="btn-primary"/>
<button name="action_approve_all" type="object"
string="Approve All" class="btn-success"
invisible="all_approved"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_device_approval_wizard" model="ir.actions.act_window">
<field name="name">Device Approval Verification</field>
<field name="res_model">fusion_claims.device.approval.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_view_types">form</field>
<field name="context">{'active_id': active_id}</field>
</record>
</odoo>

View File

@@ -0,0 +1,191 @@
# -*- 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 base64
import csv
import json
import re
import io
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class DeviceCodeImportWizard(models.TransientModel):
_name = 'fusion_claims.device.import.wizard'
_description = 'Import ADP Device Codes from JSON/CSV'
file = fields.Binary(
string='File',
required=True,
help='Upload a JSON or CSV file containing device codes',
)
filename = fields.Char(string='Filename')
file_type = fields.Selection([
('json', 'JSON'),
('csv', 'CSV (ADP Mobility Manual)'),
], string='File Type', default='json', required=True)
# Results
state = fields.Selection([
('draft', 'Draft'),
('done', 'Done'),
], default='draft')
result_created = fields.Integer(string='Created', readonly=True)
result_updated = fields.Integer(string='Updated', readonly=True)
result_errors = fields.Text(string='Errors', readonly=True)
@api.onchange('filename')
def _onchange_filename(self):
"""Auto-detect file type from filename."""
if self.filename:
if self.filename.lower().endswith('.csv'):
self.file_type = 'csv'
elif self.filename.lower().endswith('.json'):
self.file_type = 'json'
def _parse_csv_content(self, content):
"""Parse CSV content to data list."""
data = []
# Try to decode as UTF-8 with BOM, then fallback
try:
text = content.decode('utf-8-sig')
except UnicodeDecodeError:
try:
text = content.decode('latin-1')
except Exception:
text = content.decode('utf-8', errors='ignore')
reader = csv.DictReader(io.StringIO(text))
# Log the column names for debugging
_logger.info("CSV columns detected: %s", reader.fieldnames)
for row in reader:
device_code = (row.get('Device Code', '') or '').strip()
if not device_code:
continue
# Find the price column - check multiple possible names
price = 0.0
price_column_names = [
'ADP Price', 'adp_price', 'Approved Price', ' Approved Price ',
'Price', 'price', 'ADP_Price', 'adp price'
]
# First try exact matches
for col_name in price_column_names:
if col_name in row:
price_str = row.get(col_name, '')
price_str = re.sub(r'[\$,"\'\s]', '', str(price_str))
try:
price = float(price_str)
if price > 0:
break
except ValueError:
pass
# If still 0, try partial match on column names
if price == 0:
for key in row.keys():
key_lower = key.lower().strip()
if 'price' in key_lower:
price_str = row.get(key, '')
price_str = re.sub(r'[\$,"\'\s]', '', str(price_str))
try:
price = float(price_str)
if price > 0:
_logger.info("Found price in column '%s': %s", key, price)
break
except ValueError:
pass
# Find the serial/SN required column - check multiple possible names
sn_required = 'No'
sn_column_names = [
'SN Required', 'sn_required', 'Serial', 'serial',
'SN', 'sn', 'Serial Number Required', 'Requires SN'
]
# First try exact matches
for col_name in sn_column_names:
if col_name in row:
sn_required = row.get(col_name, 'No')
break
# If not found, try partial match
if sn_required == 'No':
for key in row.keys():
key_lower = key.lower().strip()
if 'serial' in key_lower or 'sn' in key_lower:
sn_required = row.get(key, 'No')
_logger.info("Found SN in column '%s': %s", key, sn_required)
break
data.append({
'Device Type': row.get('Device Type', ''),
'Manufacturer': row.get('Manufacturer', ''),
'Device Description': row.get('Device Description', ''),
'Device Code': device_code,
'Quantity': row.get('Qty', 1) or row.get('Quantity', 1),
'ADP Price': price,
'SN Required': sn_required,
})
_logger.info("Parsed %d device codes from CSV", len(data))
return data
def action_import(self):
"""Import device codes from uploaded file."""
self.ensure_one()
if not self.file:
raise UserError(_("Please upload a file."))
try:
# Decode file
file_content = base64.b64decode(self.file)
if self.file_type == 'csv':
# Parse CSV
data = self._parse_csv_content(file_content)
else:
# Parse JSON
try:
text = file_content.decode('utf-8-sig')
except UnicodeDecodeError:
text = file_content.decode('utf-8')
data = json.loads(text)
except json.JSONDecodeError as e:
raise UserError(_("Invalid JSON file: %s") % str(e))
except Exception as e:
raise UserError(_("Error reading file: %s") % str(e))
if not data:
raise UserError(_("No valid data found in file."))
# Import using the model method
DeviceCode = self.env['fusion.adp.device.code']
result = DeviceCode.import_from_json(data)
# Update wizard with results
self.write({
'state': 'done',
'result_created': result['created'],
'result_updated': result['updated'],
'result_errors': '\n'.join(result['errors']) if result['errors'] else '',
})
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!-- Device Import Wizard Form -->
<record id="view_device_import_wizard_form" model="ir.ui.view">
<field name="name">fusion.central.device.import.wizard.form</field>
<field name="model">fusion_claims.device.import.wizard</field>
<field name="arch" type="xml">
<form string="Import ADP Device Codes">
<!-- Draft State: Upload -->
<group invisible="state == 'done'">
<div class="alert alert-info" role="alert">
<h5>Import Device Codes from Mobility Manual</h5>
<p>Upload a <strong>CSV</strong> file (ADP Mobility Manual format) or a <strong>JSON</strong> file.</p>
<p><strong>CSV columns:</strong> Device Type, Manufacturer, Device Description, Device Code, Qty, Approved Price, Serial</p>
</div>
<group>
<field name="file_type" widget="radio" options="{'horizontal': True}"/>
<field name="file" filename="filename"/>
<field name="filename" invisible="1"/>
</group>
</group>
<!-- Done State: Results -->
<group invisible="state != 'done'">
<div class="alert alert-success" role="alert">
<h5><i class="fa fa-check-circle"/> Import Complete!</h5>
</div>
<group string="Results">
<field name="result_created"/>
<field name="result_updated"/>
</group>
<group string="Errors" invisible="not result_errors">
<div class="alert alert-warning" role="alert">
<field name="result_errors" nolabel="1"/>
</div>
</group>
</group>
<field name="state" invisible="1"/>
<footer>
<button string="Import" name="action_import" type="object"
class="btn-primary" invisible="state == 'done'"
icon="fa-upload"/>
<button string="Close" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Menu Action -->
<record id="action_device_import_wizard" model="ir.actions.act_window">
<field name="name">Import Device Codes</field>
<field name="res_model">fusion_claims.device.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,460 @@
# -*- 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
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# =============================================================================
# DEFAULT FIELD MAPPINGS
# =============================================================================
# These define the default FC field mappings and their config parameter keys
DEFAULT_FIELD_MAPPINGS = [
# Sale Order Header Fields
{
'model_name': 'sale.order',
'label': 'Sale Type',
'default_fc_field': 'x_fc_sale_type',
'config_param_key': 'fusion_claims.field_sale_type',
},
{
'model_name': 'sale.order',
'label': 'Client Type',
'default_fc_field': 'x_fc_client_type',
'config_param_key': 'fusion_claims.field_so_client_type',
},
{
'model_name': 'sale.order',
'label': 'Authorizer',
'default_fc_field': 'x_fc_authorizer_id',
'config_param_key': 'fusion_claims.field_so_authorizer',
},
{
'model_name': 'sale.order',
'label': 'Claim Number',
'default_fc_field': 'x_fc_claim_number',
'config_param_key': 'fusion_claims.field_so_claim_number',
},
{
'model_name': 'sale.order',
'label': 'Client Reference 1',
'default_fc_field': 'x_fc_client_ref_1',
'config_param_key': 'fusion_claims.field_so_client_ref_1',
},
{
'model_name': 'sale.order',
'label': 'Client Reference 2',
'default_fc_field': 'x_fc_client_ref_2',
'config_param_key': 'fusion_claims.field_so_client_ref_2',
},
{
'model_name': 'sale.order',
'label': 'Delivery Date',
'default_fc_field': 'x_fc_adp_delivery_date',
'config_param_key': 'fusion_claims.field_so_delivery_date',
},
{
'model_name': 'sale.order',
'label': 'Service Start Date',
'default_fc_field': 'x_fc_service_start_date',
'config_param_key': 'fusion_claims.field_so_service_start',
},
{
'model_name': 'sale.order',
'label': 'Service End Date',
'default_fc_field': 'x_fc_service_end_date',
'config_param_key': 'fusion_claims.field_so_service_end',
},
{
'model_name': 'sale.order',
'label': 'ADP Status',
'default_fc_field': 'x_fc_adp_status',
'config_param_key': 'fusion_claims.field_so_adp_status',
},
{
'model_name': 'sale.order',
'label': 'Primary Serial Number',
'default_fc_field': 'x_fc_primary_serial',
'config_param_key': 'fusion_claims.field_so_primary_serial',
},
# Sale Order Line Fields
{
'model_name': 'sale.order.line',
'label': 'Serial Number',
'default_fc_field': 'x_fc_serial_number',
'config_param_key': 'fusion_claims.field_sol_serial',
},
{
'model_name': 'sale.order.line',
'label': 'Device Placement',
'default_fc_field': 'x_fc_device_placement',
'config_param_key': 'fusion_claims.field_sol_placement',
},
# Invoice Header Fields
{
'model_name': 'account.move',
'label': 'Invoice Type',
'default_fc_field': 'x_fc_invoice_type',
'config_param_key': 'fusion_claims.field_invoice_type',
},
{
'model_name': 'account.move',
'label': 'Client Type',
'default_fc_field': 'x_fc_client_type',
'config_param_key': 'fusion_claims.field_inv_client_type',
},
{
'model_name': 'account.move',
'label': 'Authorizer',
'default_fc_field': 'x_fc_authorizer_id',
'config_param_key': 'fusion_claims.field_inv_authorizer',
},
{
'model_name': 'account.move',
'label': 'Claim Number',
'default_fc_field': 'x_fc_claim_number',
'config_param_key': 'fusion_claims.field_inv_claim_number',
},
{
'model_name': 'account.move',
'label': 'Client Reference 1',
'default_fc_field': 'x_fc_client_ref_1',
'config_param_key': 'fusion_claims.field_inv_client_ref_1',
},
{
'model_name': 'account.move',
'label': 'Client Reference 2',
'default_fc_field': 'x_fc_client_ref_2',
'config_param_key': 'fusion_claims.field_inv_client_ref_2',
},
{
'model_name': 'account.move',
'label': 'Delivery Date',
'default_fc_field': 'x_fc_adp_delivery_date',
'config_param_key': 'fusion_claims.field_inv_delivery_date',
},
{
'model_name': 'account.move',
'label': 'Service Start Date',
'default_fc_field': 'x_fc_service_start_date',
'config_param_key': 'fusion_claims.field_inv_service_start',
},
{
'model_name': 'account.move',
'label': 'Service End Date',
'default_fc_field': 'x_fc_service_end_date',
'config_param_key': 'fusion_claims.field_inv_service_end',
},
{
'model_name': 'account.move',
'label': 'Primary Serial Number',
'default_fc_field': 'x_fc_primary_serial',
'config_param_key': 'fusion_claims.field_inv_primary_serial',
},
# Invoice Line Fields
{
'model_name': 'account.move.line',
'label': 'Serial Number',
'default_fc_field': 'x_fc_serial_number',
'config_param_key': 'fusion_claims.field_aml_serial',
},
{
'model_name': 'account.move.line',
'label': 'Device Placement',
'default_fc_field': 'x_fc_device_placement',
'config_param_key': 'fusion_claims.field_aml_placement',
},
# Product Fields
{
'model_name': 'product.template',
'label': 'ADP Device Code',
'default_fc_field': 'x_fc_adp_device_code',
'config_param_key': 'fusion_claims.field_product_code',
},
{
'model_name': 'product.template',
'label': 'ADP Price',
'default_fc_field': 'x_fc_adp_price',
'config_param_key': 'fusion_claims.field_product_adp_price',
},
]
# =============================================================================
# AUTO-DETECT PATTERNS
# =============================================================================
# Keywords to look for when auto-detecting existing custom fields
AUTO_DETECT_PATTERNS = {
# Sale Order Header
'fusion_claims.field_sale_type': ['sale_type', 'saletype', 'type_of_sale', 'order_type'],
'fusion_claims.field_so_client_type': ['client_type', 'clienttype', 'customer_type', 'cust_type'],
'fusion_claims.field_so_authorizer': ['authorizer', 'authorized', 'approver', 'authorizer_name'],
'fusion_claims.field_so_claim_number': ['claim_number', 'claimnumber', 'claim_no', 'adp_claim', 'claim_num'],
'fusion_claims.field_so_client_ref_1': ['client_ref_1', 'clientref1', 'reference_1', 'client_reference_1', 'ref1', 'ref_1'],
'fusion_claims.field_so_client_ref_2': ['client_ref_2', 'clientref2', 'reference_2', 'client_reference_2', 'ref2', 'ref_2'],
'fusion_claims.field_so_delivery_date': ['delivery_date', 'deliverydate', 'adp_delivery', 'deliver_date', 'date_delivery'],
'fusion_claims.field_so_service_start': ['service_start', 'servicestart', 'start_date', 'service_start_date', 'svc_start'],
'fusion_claims.field_so_service_end': ['service_end', 'serviceend', 'end_date', 'service_end_date', 'svc_end'],
'fusion_claims.field_so_adp_status': ['adp_status', 'adpstatus', 'claim_status', 'status'],
'fusion_claims.field_so_primary_serial': ['primary_serial', 'primaryserial', 'main_serial', 'serial_primary'],
# Sale Order Line
'fusion_claims.field_sol_serial': ['serial_number', 'serial', 'sn', 'serialno'],
'fusion_claims.field_sol_placement': ['placement', 'device_placement', 'place'],
# Invoice Header
'fusion_claims.field_invoice_type': ['invoice_type', 'invoicetype', 'inv_type', 'type_of_invoice', 'bill_type'],
'fusion_claims.field_inv_client_type': ['client_type', 'clienttype', 'customer_type', 'cust_type'],
'fusion_claims.field_inv_authorizer': ['authorizer', 'authorized', 'approver', 'authorizer_name'],
'fusion_claims.field_inv_claim_number': ['claim_number', 'claimnumber', 'claim_no', 'claim_num'],
'fusion_claims.field_inv_client_ref_1': ['client_ref_1', 'clientref1', 'reference_1', 'client_reference_1', 'ref1', 'ref_1'],
'fusion_claims.field_inv_client_ref_2': ['client_ref_2', 'clientref2', 'reference_2', 'client_reference_2', 'ref2', 'ref_2'],
'fusion_claims.field_inv_delivery_date': ['delivery_date', 'deliverydate', 'adp_delivery', 'deliver_date', 'date_delivery'],
'fusion_claims.field_inv_service_start': ['service_start', 'servicestart', 'start_date', 'service_start_date', 'svc_start'],
'fusion_claims.field_inv_service_end': ['service_end', 'serviceend', 'end_date', 'service_end_date', 'svc_end'],
'fusion_claims.field_inv_primary_serial': ['primary_serial', 'primaryserial', 'main_serial', 'serial_primary'],
# Invoice Line
'fusion_claims.field_aml_serial': ['serial_number', 'serial', 'sn', 'serialno'],
'fusion_claims.field_aml_placement': ['placement', 'device_placement', 'place'],
# Product
'fusion_claims.field_product_code': ['adp_code', 'adp_device', 'device_code', 'adp_sku', 'product_code'],
'fusion_claims.field_product_adp_price': ['adp_price', 'adp_retail_price', 'retail_price'],
}
class FieldMappingLine(models.TransientModel):
"""Individual field mapping configuration line."""
_name = 'fusion_claims.field_mapping_line'
_description = 'Field Mapping Line'
_order = 'sequence, id'
wizard_id = fields.Many2one(
'fusion_claims.field_mapping_config',
string='Wizard',
required=True,
ondelete='cascade',
)
sequence = fields.Integer(default=10)
model_name = fields.Selection(
selection=[
('sale.order', 'Sale Order'),
('sale.order.line', 'Sale Order Line'),
('account.move', 'Invoice'),
('account.move.line', 'Invoice Line'),
('product.template', 'Product'),
],
string='Model',
)
label = fields.Char(string='Field Label')
field_name = fields.Char(string='Field Name', help='The field name to use on the model')
default_fc_field = fields.Char(string='Default FC Field')
config_param_key = fields.Char(string='Config Parameter')
field_exists = fields.Boolean(
string='Valid',
compute='_compute_field_exists',
help='Does this field exist on the model?',
)
@api.depends('field_name', 'model_name')
def _compute_field_exists(self):
"""Check if the configured field exists on the model."""
IrModelFields = self.env['ir.model.fields'].sudo()
for line in self:
line.field_exists = False
if not line.field_name or not line.model_name:
continue
# Check database for field existence (more reliable for custom fields)
field_count = IrModelFields.search_count([
('model', '=', line.model_name),
('name', '=', line.field_name),
])
line.field_exists = field_count > 0
@api.onchange('field_name')
def _onchange_field_name(self):
"""Force recomputation of field_exists when field_name changes."""
# This triggers the compute to run when user edits the field
pass
class FieldMappingConfigWizard(models.TransientModel):
"""Wizard for configuring field mappings."""
_name = 'fusion_claims.field_mapping_config'
_description = 'Field Mapping Configuration Wizard'
# =========================================================================
# FIELD MAPPINGS
# =========================================================================
mapping_ids = fields.One2many(
'fusion_claims.field_mapping_line',
'wizard_id',
string='Field Mappings',
)
# =========================================================================
# SUMMARY FIELDS
# =========================================================================
total_mappings = fields.Integer(
string='Total Mappings',
compute='_compute_summary',
)
valid_mappings = fields.Integer(
string='Valid Mappings',
compute='_compute_summary',
)
invalid_mappings = fields.Integer(
string='Invalid Mappings',
compute='_compute_summary',
)
@api.depends('mapping_ids.field_exists')
def _compute_summary(self):
"""Compute summary statistics."""
for wizard in self:
wizard.total_mappings = len(wizard.mapping_ids)
wizard.valid_mappings = len(wizard.mapping_ids.filtered('field_exists'))
wizard.invalid_mappings = wizard.total_mappings - wizard.valid_mappings
# =========================================================================
# DEFAULT VALUES
# =========================================================================
@api.model
def default_get(self, fields_list):
"""Load field mappings from ir.config_parameter when wizard opens."""
res = super().default_get(fields_list)
if 'mapping_ids' in fields_list:
ICP = self.env['ir.config_parameter'].sudo()
mappings = []
seq = 10
for mapping_def in DEFAULT_FIELD_MAPPINGS:
# Get current value from config, fall back to default
current_value = ICP.get_param(
mapping_def['config_param_key'],
mapping_def['default_fc_field']
)
mappings.append((0, 0, {
'sequence': seq,
'model_name': mapping_def['model_name'],
'label': mapping_def['label'],
'field_name': current_value,
'default_fc_field': mapping_def['default_fc_field'],
'config_param_key': mapping_def['config_param_key'],
}))
seq += 10
res['mapping_ids'] = mappings
return res
# =========================================================================
# ACTION METHODS
# =========================================================================
def action_save(self):
"""Save all field mappings to ir.config_parameter."""
ICP = self.env['ir.config_parameter'].sudo()
saved_count = 0
for line in self.mapping_ids:
if line.config_param_key and line.field_name:
ICP.set_param(line.config_param_key, line.field_name)
saved_count += 1
_logger.info("Saved %d field mapping configurations", saved_count)
# Return False to indicate no follow-up action needed
return False
def action_save_and_close(self):
"""Save mappings and close the wizard."""
self.action_save()
return {'type': 'ir.actions.act_window_close'}
def action_reset_defaults(self):
"""Reset all mappings to their default FC field values."""
ICP = self.env['ir.config_parameter'].sudo()
reset_count = 0
for line in self.mapping_ids:
if line.config_param_key and line.default_fc_field:
# Update the transient record so the form shows the new value
line.field_name = line.default_fc_field
# Also save to ir.config_parameter so it persists
ICP.set_param(line.config_param_key, line.default_fc_field)
reset_count += 1
_logger.info("Reset %d field mappings to defaults", reset_count)
# Return False to indicate no follow-up action needed
return False
def action_auto_detect(self):
"""Auto-detect existing custom fields and update mappings."""
IrModelFields = self.env['ir.model.fields'].sudo()
ICP = self.env['ir.config_parameter'].sudo()
detected_count = 0
detected_fields = []
# Get all custom fields
models_to_search = ['sale.order', 'sale.order.line', 'account.move', 'account.move.line', 'product.template']
all_custom_fields = IrModelFields.search([
('model', 'in', models_to_search),
('name', '=like', 'x_%'),
('state', '=', 'manual'),
])
for line in self.mapping_ids:
if not line.config_param_key:
continue
patterns = AUTO_DETECT_PATTERNS.get(line.config_param_key, [])
if not patterns:
continue
# Get fields for this model
model_fields = all_custom_fields.filtered(lambda f: f.model == line.model_name)
model_fields_sorted = sorted(model_fields, key=lambda f: f.name)
# Find matching field
matched_field = None
for field in model_fields_sorted:
# Skip our own x_fc_* fields
if field.name.startswith('x_fc_'):
continue
field_name_lower = field.name.lower()
for pattern in patterns:
if pattern in field_name_lower:
matched_field = field
break
if matched_field:
break
if matched_field and matched_field.name != line.field_name:
# Update the transient record so the form shows the new value
line.field_name = matched_field.name
# Also save to ir.config_parameter so it persists across sessions
ICP.set_param(line.config_param_key, matched_field.name)
detected_fields.append(f"{line.label}: {matched_field.name}")
detected_count += 1
# Log results
_logger.info("Auto-detected %d Studio field mappings: %s", detected_count, detected_fields)
# Return False to indicate no follow-up action needed
return False
def action_close(self):
"""Close the wizard without saving."""
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ===================================================================== -->
<!-- Field Mapping Configuration Wizard Form View -->
<!-- ===================================================================== -->
<record id="view_field_mapping_config_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.field_mapping_config.form</field>
<field name="model">fusion_claims.field_mapping_config</field>
<field name="arch" type="xml">
<form string="Field Mapping Configuration">
<header>
<button name="action_save_and_close" type="object"
string="Save &amp; Close" class="btn-primary"/>
<button name="action_auto_detect" type="object"
string="Auto-Detect Fields" class="btn-secondary"/>
<button name="action_reset_defaults" type="object"
string="Reset to Defaults" class="btn-secondary"
confirm="This will reset all mappings to the default FC field names. Continue?"/>
</header>
<sheet>
<div class="oe_title">
<h1>Field Mapping Configuration</h1>
</div>
<!-- Summary Section -->
<group>
<group>
<field name="total_mappings" readonly="1"/>
<field name="valid_mappings" readonly="1"/>
</group>
<group>
<field name="invalid_mappings" readonly="1"
decoration-danger="invalid_mappings > 0"/>
</group>
</group>
<!-- Instructions -->
<div class="alert alert-info" role="alert">
<strong>How to Use:</strong>
<ul class="mb-0">
<li>Click <strong>Auto-Detect Fields</strong> to find existing custom fields</li>
<li>Edit the <strong>Field Name</strong> column to use any custom field</li>
<li>The <strong>Valid</strong> column shows if the field exists on the model</li>
<li>Click <strong>Save &amp; Close</strong> to apply your configuration</li>
<li>Use <strong>Reset to Defaults</strong> to restore default FC field names</li>
</ul>
</div>
<!-- All Field Mappings in one list -->
<field name="mapping_ids" nolabel="1">
<list editable="bottom" create="false" delete="false"
decoration-danger="not field_exists and field_name">
<field name="model_name" readonly="1" string="Model"/>
<field name="label" readonly="1" string="Field"/>
<field name="field_name" string="Source Field Name"/>
<field name="default_fc_field" readonly="1" string="Default FC Field"/>
<field name="field_exists" widget="boolean" readonly="1" string="Valid"/>
<field name="config_param_key" column_invisible="1"/>
<field name="wizard_id" column_invisible="1"/>
<field name="sequence" column_invisible="1"/>
</list>
</field>
<!-- Help Section -->
<div class="alert alert-secondary mt-3" role="alert">
<h5>Help</h5>
<ul class="mb-0">
<li><strong>Source Field Name</strong>: The custom field to read data from (e.g., x_fc_sale_type)</li>
<li><strong>Default FC Field</strong>: The standard Fusion Central field name</li>
<li><strong>Valid</strong>: Shows if the source field exists on the model</li>
<li>Auto-Detect will find existing custom fields and save them. Close and reopen to see results.</li>
</ul>
</div>
</sheet>
<footer>
<button name="action_save_and_close" type="object"
string="Save &amp; Close" class="btn-primary"/>
<button name="action_save" type="object"
string="Save" class="btn-secondary"/>
<button name="action_close" type="object"
string="Cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<!-- ===================================================================== -->
<!-- Action to open the wizard -->
<!-- ===================================================================== -->
<record id="action_field_mapping_config_wizard" model="ir.actions.act_window">
<field name="name">Field Mapping Configuration</field>
<field name="res_model">fusion_claims.field_mapping_config</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{}</field>
</record>
</odoo>

View File

@@ -0,0 +1,237 @@
# -*- 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 logging
_logger = logging.getLogger(__name__)
class LoanerCheckoutWizard(models.TransientModel):
"""Wizard to checkout loaner equipment."""
_name = 'fusion.loaner.checkout.wizard'
_description = 'Loaner Checkout Wizard'
# =========================================================================
# CONTEXT FIELDS
# =========================================================================
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
readonly=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
required=True,
)
authorizer_id = fields.Many2one(
'res.partner',
string='Authorizer',
)
# =========================================================================
# PRODUCT SELECTION
# =========================================================================
product_id = fields.Many2one(
'product.product',
string='Product',
domain="[('x_fc_can_be_loaned', '=', True)]",
required=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
domain="[('product_id', '=', product_id)]",
)
available_lot_ids = fields.Many2many(
'stock.lot',
compute='_compute_available_lots',
string='Available Serial Numbers',
)
# =========================================================================
# DATES
# =========================================================================
checkout_date = fields.Date(
string='Checkout Date',
required=True,
default=fields.Date.context_today,
)
loaner_period_days = fields.Integer(
string='Loaner Period (Days)',
default=7,
)
expected_return_date = fields.Date(
string='Expected Return Date',
compute='_compute_expected_return',
)
# =========================================================================
# CONDITION
# =========================================================================
checkout_condition = fields.Selection([
('excellent', 'Excellent'),
('good', 'Good'),
('fair', 'Fair'),
('needs_repair', 'Needs Repair'),
], string='Condition', default='excellent', required=True)
checkout_notes = fields.Text(
string='Notes',
)
# =========================================================================
# PHOTOS
# =========================================================================
checkout_photo_ids = fields.Many2many(
'ir.attachment',
'loaner_checkout_wizard_photo_rel',
'wizard_id',
'attachment_id',
string='Photos',
)
# =========================================================================
# DELIVERY
# =========================================================================
delivery_address = fields.Text(
string='Delivery Address',
)
# =========================================================================
# COMPUTED
# =========================================================================
@api.depends('product_id')
def _compute_available_lots(self):
"""Get available serial numbers for the selected product."""
for wizard in self:
if wizard.product_id:
# Get loaner location
loaner_location = self.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
if loaner_location:
# Find lots with stock in loaner location
quants = self.env['stock.quant'].search([
('product_id', '=', wizard.product_id.id),
('location_id', '=', loaner_location.id),
('quantity', '>', 0),
])
wizard.available_lot_ids = quants.mapped('lot_id')
else:
# Fallback: all lots for product
wizard.available_lot_ids = self.env['stock.lot'].search([
('product_id', '=', wizard.product_id.id),
])
else:
wizard.available_lot_ids = False
@api.depends('checkout_date', 'loaner_period_days')
def _compute_expected_return(self):
from datetime import timedelta
for wizard in self:
if wizard.checkout_date and wizard.loaner_period_days:
wizard.expected_return_date = wizard.checkout_date + timedelta(days=wizard.loaner_period_days)
else:
wizard.expected_return_date = False
# =========================================================================
# ONCHANGE
# =========================================================================
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.loaner_period_days = self.product_id.x_fc_loaner_period_days or 7
self.lot_id = False
# =========================================================================
# DEFAULT GET
# =========================================================================
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
# Get context
active_model = self._context.get('active_model')
active_id = self._context.get('active_id')
if active_model == 'sale.order' and active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
res['partner_id'] = order.partner_id.id
res['authorizer_id'] = order.x_fc_authorizer_id.id if order.x_fc_authorizer_id else False
if order.partner_shipping_id:
res['delivery_address'] = order.partner_shipping_id.contact_address
# Get default loaner period from settings
ICP = self.env['ir.config_parameter'].sudo()
default_period = int(ICP.get_param('fusion_claims.default_loaner_period_days', '7'))
res['loaner_period_days'] = default_period
return res
# =========================================================================
# ACTION
# =========================================================================
def action_checkout(self):
"""Create and confirm loaner checkout."""
self.ensure_one()
if not self.product_id:
raise UserError(_("Please select a product."))
# Create persistent attachments for photos
photo_ids = []
for photo in self.checkout_photo_ids:
new_attachment = self.env['ir.attachment'].create({
'name': photo.name,
'datas': photo.datas,
'res_model': 'fusion.loaner.checkout',
'res_id': 0, # Will update after checkout creation
})
photo_ids.append(new_attachment.id)
# Create checkout record
checkout_vals = {
'sale_order_id': self.sale_order_id.id if self.sale_order_id else False,
'partner_id': self.partner_id.id,
'authorizer_id': self.authorizer_id.id if self.authorizer_id else False,
'sales_rep_id': self.env.user.id,
'product_id': self.product_id.id,
'lot_id': self.lot_id.id if self.lot_id else False,
'checkout_date': self.checkout_date,
'loaner_period_days': self.loaner_period_days,
'checkout_condition': self.checkout_condition,
'checkout_notes': self.checkout_notes,
'delivery_address': self.delivery_address,
}
checkout = self.env['fusion.loaner.checkout'].create(checkout_vals)
# Update photo attachments
if photo_ids:
self.env['ir.attachment'].browse(photo_ids).write({'res_id': checkout.id})
checkout.checkout_photo_ids = [(6, 0, photo_ids)]
# Confirm checkout
checkout.action_checkout()
# Return to checkout record
return {
'type': 'ir.actions.act_window',
'name': _('Loaner Checkout'),
'res_model': 'fusion.loaner.checkout',
'res_id': checkout.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,139 @@
# -*- 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 logging
_logger = logging.getLogger(__name__)
class LoanerReturnWizard(models.TransientModel):
"""Wizard to return loaner equipment."""
_name = 'fusion.loaner.return.wizard'
_description = 'Loaner Return Wizard'
# =========================================================================
# CHECKOUT REFERENCE
# =========================================================================
checkout_id = fields.Many2one(
'fusion.loaner.checkout',
string='Checkout Record',
required=True,
readonly=True,
)
# Display fields
product_id = fields.Many2one(
'product.product',
string='Product',
related='checkout_id.product_id',
readonly=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
related='checkout_id.lot_id',
readonly=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
related='checkout_id.partner_id',
readonly=True,
)
checkout_date = fields.Date(
string='Checkout Date',
related='checkout_id.checkout_date',
readonly=True,
)
days_out = fields.Integer(
string='Days Out',
related='checkout_id.days_out',
readonly=True,
)
checkout_condition = fields.Selection(
related='checkout_id.checkout_condition',
readonly=True,
)
# =========================================================================
# RETURN DETAILS
# =========================================================================
return_date = fields.Date(
string='Return Date',
required=True,
default=fields.Date.context_today,
)
return_condition = fields.Selection([
('excellent', 'Excellent'),
('good', 'Good'),
('fair', 'Fair'),
('needs_repair', 'Needs Repair'),
('damaged', 'Damaged'),
], string='Condition', required=True)
return_notes = fields.Text(
string='Notes',
help='Any notes about the return condition or issues',
)
return_photo_ids = fields.Many2many(
'ir.attachment',
'loaner_return_wizard_photo_rel',
'wizard_id',
'attachment_id',
string='Photos',
)
# =========================================================================
# DEFAULT GET
# =========================================================================
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
checkout_id = self._context.get('default_checkout_id')
if checkout_id:
checkout = self.env['fusion.loaner.checkout'].browse(checkout_id)
res['checkout_id'] = checkout.id
# Default return condition to checkout condition
res['return_condition'] = checkout.checkout_condition
return res
# =========================================================================
# ACTION
# =========================================================================
def action_return(self):
"""Process the loaner return."""
self.ensure_one()
if not self.checkout_id:
raise UserError(_("No checkout record found."))
if self.checkout_id.state not in ('checked_out', 'overdue', 'rental_pending'):
raise UserError(_("This loaner has already been returned or is not in a returnable state."))
# Create persistent attachments for photos
photo_ids = []
for photo in self.return_photo_ids:
new_attachment = self.env['ir.attachment'].create({
'name': photo.name,
'datas': photo.datas,
'res_model': 'fusion.loaner.checkout',
'res_id': self.checkout_id.id,
})
photo_ids.append(new_attachment.id)
# Process return
self.checkout_id.action_process_return(
return_condition=self.return_condition,
return_notes=self.return_notes,
return_photos=photo_ids if photo_ids else None,
)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
class ModAwaitingFundingWizard(models.TransientModel):
_name = 'fusion_claims.mod.awaiting.funding.wizard'
_description = 'MOD - Record Application Submission'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
application_submitted_date = fields.Date(
string='Application Submitted Date',
required=True,
default=fields.Date.context_today,
help='Date the application/proposal was submitted to March of Dimes',
)
notes = fields.Text(string='Notes', help='Optional notes about the submission')
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if self.env.context.get('active_id'):
order = self.env['sale.order'].browse(self.env.context['active_id'])
res['sale_order_id'] = order.id
if order.x_fc_mod_application_submitted_date:
res['application_submitted_date'] = order.x_fc_mod_application_submitted_date
return res
def action_confirm(self):
self.ensure_one()
order = self.sale_order_id
order.write({
'x_fc_mod_status': 'awaiting_funding',
'x_fc_mod_application_submitted_date': self.application_submitted_date,
})
# Log to chatter
date_str = self.application_submitted_date.strftime('%B %d, %Y')
note_html = (
f'<strong>Application submitted to March of Dimes</strong> on {date_str}'
)
if self.notes:
note_html += f'<br/>{self.notes}'
order.message_post(
body=Markup(
f'<div class="alert alert-info" role="alert">'
f'{note_html}</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_mod_awaiting_funding_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.mod.awaiting.funding.wizard.form</field>
<field name="model">fusion_claims.mod.awaiting.funding.wizard</field>
<field name="arch" type="xml">
<form string="Application Submitted to March of Dimes">
<field name="sale_order_id" invisible="1"/>
<div class="alert alert-info mb-3">
<i class="fa fa-clock-o"/>
Record the date the application was submitted to March of Dimes for funding review.
This will move the case to <strong>Awaiting Funding</strong> status.
</div>
<group>
<field name="application_submitted_date"/>
<field name="notes" placeholder="Optional notes..."/>
</group>
<footer>
<button name="action_confirm" type="object"
string="Confirm" class="btn-primary" icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
import logging
_logger = logging.getLogger(__name__)
class ModFundingApprovedWizard(models.TransientModel):
_name = 'fusion_claims.mod.funding.approved.wizard'
_description = 'MOD - Record Funding Approval'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
# Case details
case_worker_id = fields.Many2one(
'res.partner', string='Case Worker',
help='March of Dimes case worker assigned to this case',
)
hvmp_reference = fields.Char(string='HVMP Reference #')
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if self.env.context.get('active_id'):
order = self.env['sale.order'].browse(self.env.context['active_id'])
res['sale_order_id'] = order.id
if order.x_fc_case_worker:
res['case_worker_id'] = order.x_fc_case_worker.id
if order.x_fc_case_reference:
res['hvmp_reference'] = order.x_fc_case_reference
return res
def action_confirm(self):
"""Record funding approval - just case worker and reference."""
self.ensure_one()
order = self.sale_order_id
vals = {
'x_fc_mod_status': 'funding_approved',
'x_fc_case_approved': fields.Date.today(),
}
if self.case_worker_id:
vals['x_fc_case_worker'] = self.case_worker_id.id
if self.hvmp_reference:
vals['x_fc_case_reference'] = self.hvmp_reference
order.write(vals)
# Log to chatter
parts = ['<strong>Funding Approved by March of Dimes</strong>']
parts.append(f'Date: {fields.Date.today().strftime("%B %d, %Y")}')
if self.case_worker_id:
parts.append(f'Case Worker: {self.case_worker_id.name}')
if self.hvmp_reference:
parts.append(f'HVMP Reference: {self.hvmp_reference}')
order.message_post(
body=Markup('<div class="alert alert-success">' + '<br/>'.join(parts) + '</div>'),
message_type='notification', subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}
class ModFundingApprovedWizardLine(models.TransientModel):
_name = 'fusion_claims.mod.funding.approved.wizard.line'
_description = 'MOD PCA - Line Preview'
wizard_id = fields.Many2one('fusion_claims.mod.pca.received.wizard', ondelete='cascade')
product_name = fields.Char(string='Product', readonly=True)
quantity = fields.Float(string='Qty', readonly=True)
line_total = fields.Float(string='Line Total', readonly=True)
mod_amount = fields.Float(string='MOD Pays', readonly=True)
client_amount = fields.Float(string='Client Pays', readonly=True)

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_mod_funding_approved_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.mod.funding.approved.wizard.form</field>
<field name="model">fusion_claims.mod.funding.approved.wizard</field>
<field name="arch" type="xml">
<form string="Funding Approved">
<field name="sale_order_id" invisible="1"/>
<div class="alert alert-success mb-3">
<i class="fa fa-check"/>
Record the funding approval. Case worker and HVMP reference can be updated when PCA is received.
</div>
<group>
<group string="Case Details">
<field name="case_worker_id"
options="{'no_create': False, 'no_quick_create': False}"
placeholder="Select or create case worker..."/>
<field name="hvmp_reference"
placeholder="e.g. HVW38845 (optional)"/>
</group>
</group>
<footer>
<button name="action_confirm" type="object"
string="Confirm Approval" class="btn-success" icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,304 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from odoo.fields import Command
from markupsafe import Markup
import logging
_logger = logging.getLogger(__name__)
class ModPcaReceivedWizard(models.TransientModel):
_name = 'fusion_claims.mod.pca.received.wizard'
_description = 'MOD - Record PCA Receipt and Create Invoice(s)'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
currency_id = fields.Many2one('res.currency', related='sale_order_id.currency_id')
order_total = fields.Monetary(
related='sale_order_id.amount_untaxed', string='Order Subtotal', readonly=True)
# PCA Document
pca_document = fields.Binary(string='PCA Document', required=True)
pca_filename = fields.Char(string='PCA Filename')
# Case details (pre-filled from order, editable)
hvmp_reference = fields.Char(string='HVMP Reference #')
case_worker_id = fields.Many2one(
'res.partner', string='Case Worker',
help='March of Dimes case worker assigned to this case',
)
# Approval details
approval_type = fields.Selection([
('full', 'Full Approval'),
('partial', 'Partial Approval'),
], string='Approval Type', required=True, default='full')
approved_amount = fields.Monetary(
string='MOD Approved Amount',
currency_field='currency_id',
help='Total amount approved by March of Dimes (before taxes)',
)
# Preview lines (computed when partial)
preview_line_ids = fields.One2many(
'fusion_claims.mod.funding.approved.wizard.line', 'wizard_id',
string='Line Split Preview',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if self.env.context.get('active_id'):
order = self.env['sale.order'].browse(self.env.context['active_id'])
res['sale_order_id'] = order.id
if order.x_fc_case_reference:
res['hvmp_reference'] = order.x_fc_case_reference
if order.x_fc_case_worker:
res['case_worker_id'] = order.x_fc_case_worker.id
if order.x_fc_mod_pca_document:
res['pca_document'] = order.x_fc_mod_pca_document
res['pca_filename'] = order.x_fc_mod_pca_filename
if order.x_fc_mod_approved_amount:
res['approved_amount'] = order.x_fc_mod_approved_amount
res['approval_type'] = order.x_fc_mod_approval_type or 'full'
else:
res['approved_amount'] = order.amount_untaxed
return res
@api.onchange('approval_type', 'approved_amount')
def _onchange_compute_preview(self):
"""Compute the proportional split preview when partial approval."""
order = self.sale_order_id
if not order:
return
lines = []
product_lines = order.order_line.filtered(
lambda l: not l.display_type and l.product_uom_qty > 0 and l.product_id)
if self.approval_type == 'partial' and self.approved_amount and self.approved_amount > 0:
subtotal = order.amount_untaxed
if subtotal <= 0:
return
for line in product_lines:
ratio = line.price_subtotal / subtotal if subtotal else 0
mod_amount = round(self.approved_amount * ratio, 2)
client_amount = round(line.price_subtotal - mod_amount, 2)
lines.append((0, 0, {
'product_name': line.product_id.display_name,
'quantity': line.product_uom_qty,
'line_total': line.price_subtotal,
'mod_amount': mod_amount,
'client_amount': client_amount,
}))
elif self.approval_type == 'full':
for line in product_lines:
lines.append((0, 0, {
'product_name': line.product_id.display_name,
'quantity': line.product_uom_qty,
'line_total': line.price_subtotal,
'mod_amount': line.price_subtotal,
'client_amount': 0,
}))
self.preview_line_ids = [(5, 0, 0)] + lines
def action_confirm(self):
"""Record PCA, set approval amounts, and create invoice(s)."""
self.ensure_one()
order = self.sale_order_id
if not self.pca_document:
raise UserError(_("Please attach the PCA document."))
if self.approval_type == 'partial':
if not self.approved_amount or self.approved_amount <= 0:
raise UserError(_("Please enter the approved amount for partial approval."))
if self.approved_amount >= order.amount_untaxed:
raise UserError(_(
"Approved amount is equal to or greater than the order subtotal. "
"Use 'Full Approval' instead."))
client_name = order.partner_id.name or 'Client'
# Update sale order
vals = {
'x_fc_mod_status': 'contract_received',
'x_fc_mod_pca_received_date': fields.Date.today(),
'x_fc_mod_pca_document': self.pca_document,
'x_fc_mod_pca_filename': self.pca_filename or f'PCA - {client_name}.pdf',
'x_fc_mod_approval_type': self.approval_type,
}
if self.approval_type == 'partial':
vals['x_fc_mod_approved_amount'] = self.approved_amount
vals['x_fc_mod_payment_commitment'] = self.approved_amount
else:
vals['x_fc_mod_approved_amount'] = order.amount_untaxed
vals['x_fc_mod_payment_commitment'] = order.amount_total
if self.case_worker_id:
vals['x_fc_case_worker'] = self.case_worker_id.id
if self.hvmp_reference:
vals['x_fc_case_reference'] = self.hvmp_reference
order.write(vals)
# Create invoices
mod_partner = order._get_mod_partner()
client = order.partner_id
if self.approval_type == 'full':
mod_invoice = self._create_full_invoice(order, mod_partner)
self._log_pca_and_invoices(order, mod_invoice)
return self._open_invoice(mod_invoice)
else:
mod_invoice = self._create_split_mod_invoice(order, mod_partner)
client_invoice = self._create_split_client_invoice(order, client)
self._log_pca_and_invoices(order, mod_invoice, client_invoice)
return self._open_invoice(mod_invoice)
# ------------------------------------------------------------------
# Invoice creation helpers
# ------------------------------------------------------------------
def _create_full_invoice(self, order, mod_partner):
"""Create a single MOD invoice for the full order amount."""
line_vals = []
for line in order.order_line:
if line.display_type in ('line_section', 'line_note'):
line_vals.append(Command.create({
'display_type': line.display_type,
'name': line.name,
'sequence': line.sequence,
}))
elif not line.display_type and line.product_uom_qty > 0:
inv_line = line._prepare_invoice_line()
inv_line['quantity'] = line.product_uom_qty
inv_line['sequence'] = line.sequence
line_vals.append(Command.create(inv_line))
return order._create_mod_invoice(
partner_id=mod_partner.id,
invoice_lines=line_vals,
portion_type='full',
label=' (March of Dimes - Full)',
)
def _create_split_mod_invoice(self, order, mod_partner):
"""Create MOD invoice with proportionally reduced amounts."""
subtotal = order.amount_untaxed
approved = self.approved_amount
line_vals = []
for line in order.order_line:
if line.display_type in ('line_section', 'line_note'):
line_vals.append(Command.create({
'display_type': line.display_type,
'name': line.name,
'sequence': line.sequence,
}))
elif not line.display_type and line.product_uom_qty > 0:
ratio = line.price_subtotal / subtotal if subtotal else 0
mod_line_amount = round(approved * ratio, 2)
mod_price_unit = round(
mod_line_amount / line.product_uom_qty, 2
) if line.product_uom_qty else 0
inv_line = line._prepare_invoice_line()
inv_line['quantity'] = line.product_uom_qty
inv_line['price_unit'] = mod_price_unit
inv_line['sequence'] = line.sequence
line_vals.append(Command.create(inv_line))
return order._create_mod_invoice(
partner_id=mod_partner.id,
invoice_lines=line_vals,
portion_type='adp',
label=' (March of Dimes Portion)',
)
def _create_split_client_invoice(self, order, client):
"""Create Client invoice with the difference amounts."""
subtotal = order.amount_untaxed
approved = self.approved_amount
line_vals = []
for line in order.order_line:
if line.display_type in ('line_section', 'line_note'):
line_vals.append(Command.create({
'display_type': line.display_type,
'name': line.name,
'sequence': line.sequence,
}))
elif not line.display_type and line.product_uom_qty > 0:
ratio = line.price_subtotal / subtotal if subtotal else 0
mod_line_amount = round(approved * ratio, 2)
client_line_amount = round(line.price_subtotal - mod_line_amount, 2)
client_price_unit = round(
client_line_amount / line.product_uom_qty, 2
) if line.product_uom_qty else 0
inv_line = line._prepare_invoice_line()
inv_line['quantity'] = line.product_uom_qty
inv_line['price_unit'] = client_price_unit
inv_line['sequence'] = line.sequence
if client_line_amount <= 0:
inv_line['price_unit'] = 0
inv_line['name'] = f'{line.name}\n[Covered by March of Dimes]'
line_vals.append(Command.create(inv_line))
return order._create_mod_invoice(
partner_id=client.id,
invoice_lines=line_vals,
portion_type='client',
label=' (Client Portion)',
)
# ------------------------------------------------------------------
# Logging and navigation
# ------------------------------------------------------------------
def _log_pca_and_invoices(self, order, mod_invoice, client_invoice=None):
"""Log PCA receipt and invoice creation to chatter."""
parts = [f'<strong>PCA Received and Invoice(s) Created</strong>']
parts.append(f'Date: {fields.Date.today().strftime("%B %d, %Y")}')
if self.hvmp_reference:
parts.append(f'HVMP Reference: {self.hvmp_reference}')
if self.case_worker_id:
parts.append(f'Case Worker: {self.case_worker_id.name}')
type_label = 'Full Approval' if self.approval_type == 'full' else 'Partial Approval'
parts.append(f'Approval Type: {type_label}')
if self.approval_type == 'partial':
parts.append(f'MOD Approved: ${self.approved_amount:,.2f}')
parts.append(
f'Client Portion: ${order.amount_untaxed - self.approved_amount:,.2f}')
inv_links = [
f'MOD Invoice: <a href="/web#id={mod_invoice.id}'
f'&amp;model=account.move&amp;view_type=form">'
f'{mod_invoice.name or "Draft"}</a>'
]
if client_invoice:
inv_links.append(
f'Client Invoice: <a href="/web#id={client_invoice.id}'
f'&amp;model=account.move&amp;view_type=form">'
f'{client_invoice.name or "Draft"}</a>'
)
parts.extend(inv_links)
order.message_post(
body=Markup('<div class="alert alert-success">' + '<br/>'.join(parts) + '</div>'),
message_type='notification', subtype_xmlid='mail.mt_note',
)
def _open_invoice(self, invoice):
return {
'type': 'ir.actions.act_window',
'name': 'Invoice',
'res_model': 'account.move',
'view_mode': 'form',
'res_id': invoice.id,
}

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_mod_pca_received_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.mod.pca.received.wizard.form</field>
<field name="model">fusion_claims.mod.pca.received.wizard</field>
<field name="arch" type="xml">
<form string="PCA Received">
<field name="sale_order_id" invisible="1"/>
<field name="currency_id" invisible="1"/>
<div class="alert alert-success mb-3">
<i class="fa fa-file-text"/>
Upload the PCA document, confirm approval amounts, and create invoice(s).
</div>
<!-- PCA Document + Case Details -->
<group>
<group string="PCA Document">
<field name="pca_document" filename="pca_filename" required="1"/>
<field name="pca_filename" invisible="1"/>
</group>
<group string="Case Details">
<field name="case_worker_id"
options="{'no_create': False, 'no_quick_create': False}"
placeholder="Select or create case worker..."/>
<field name="hvmp_reference"
placeholder="e.g. HVW38845"/>
</group>
</group>
<!-- Approval Details -->
<group>
<group string="Approval">
<field name="approval_type" widget="radio"/>
<field name="approved_amount" string="Approved Amount"
invisible="approval_type != 'partial'"
required="approval_type == 'partial'"/>
<field name="order_total" string="Order Subtotal" readonly="1"/>
</group>
</group>
<!-- Split Preview Table (partial only) -->
<group invisible="approval_type != 'partial' or not approved_amount">
<separator string="Split Preview"/>
</group>
<field name="preview_line_ids"
invisible="approval_type != 'partial' or not approved_amount"
readonly="1" nolabel="1">
<list editable="bottom" create="0" delete="0">
<field name="product_name" string="Product"/>
<field name="quantity" string="Qty"/>
<field name="line_total" string="Line Total" widget="monetary"/>
<field name="mod_amount" string="MOD Pays" widget="monetary"
decoration-success="mod_amount > 0"/>
<field name="client_amount" string="Client Pays" widget="monetary"
decoration-danger="client_amount > 0"/>
</list>
</field>
<footer>
<button name="action_confirm" type="object"
string="Confirm PCA and Create Invoice(s)"
class="btn-success" icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,395 @@
# -*- 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. Discretionary Benefits PDF filling will not work.")
class DiscretionaryBenefitWizard(models.TransientModel):
_name = 'fusion_claims.discretionary.benefit.wizard'
_description = 'ODSP Discretionary Benefits Form Wizard'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
# --- Auto-populated from partner ---
client_name = fields.Char(string='Client Name', readonly=True)
address = fields.Char(string='Address', readonly=True)
city = fields.Char(string='City', readonly=True)
province = fields.Char(string='Province', default='ON')
postal_code = fields.Char(string='Postal Code', readonly=True)
phone_number = fields.Char(string='Phone', readonly=True)
alt_phone = fields.Char(string='Alternate Phone', readonly=True)
email = fields.Char(string='Email', readonly=True)
member_id = fields.Char(string='ODSP Member ID', readonly=True)
form_date = fields.Date(string='Date', default=fields.Date.today)
odsp_office_id = fields.Many2one(
'res.partner',
string='ODSP Office',
domain="[('x_fc_contact_type', '=', 'odsp_office')]",
help='Override the ODSP office for this submission',
)
# --- User-editable ---
item_type = fields.Selection([
('medical_equipment', 'Medical Equipment'),
('vision_care', 'Vision Care'),
('dentures', 'Dentures'),
('other', 'Other'),
], string='Item Type', default='medical_equipment', required=True)
other_description = fields.Text(
string='Description / Request Details',
help='Details about the request (visible for all types, required for Other)',
)
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.',
)
@api.model
def default_get(self, fields_list):
"""Pre-populate from sale order and partner."""
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)
partner = order.partner_id
res['sale_order_id'] = order.id
if partner:
res['client_name'] = partner.name or ''
res['address'] = partner.street or ''
res['city'] = partner.city or ''
res['province'] = partner.state_id.code if partner.state_id else 'ON'
res['postal_code'] = partner.zip or ''
res['phone_number'] = partner.phone or ''
res['alt_phone'] = ''
res['email'] = partner.email or ''
res['member_id'] = order.x_fc_odsp_member_id or (partner and partner.x_fc_odsp_member_id) or ''
if order.x_fc_odsp_office_id:
res['odsp_office_id'] = order.x_fc_odsp_office_id.id
return res
def _get_template_path(self):
"""Get the path to the Discretionary Benefits form template PDF."""
module_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(module_path, 'static', 'src', 'pdf', 'discretionary_benefits_form_template.pdf')
def _build_field_mapping(self):
"""Build a dictionary mapping PDF field names to values."""
self.ensure_one()
mapping = {}
mapping['txt_First[0]'] = self.client_name or ''
# txt_CITY[1] is physically the Member ID field (despite the name)
mapping['txt_CITY[1]'] = self.member_id or ''
mapping['txt_add[0]'] = self.address or ''
mapping['txt_CITY[0]'] = self.city or ''
mapping['txt_prov[0]'] = self.province or 'ON'
mapping['txt_postalcodes[0]'] = self.postal_code or ''
# txt_email[0] is physically the Phone field (despite the name)
mapping['txt_email[0]'] = self.phone_number or ''
mapping['txt_bphone[0]'] = self.alt_phone or ''
# txt_emp_phone[0] is physically the Email field (despite the name)
mapping['txt_emp_phone[0]'] = self.email or ''
# txt_clientnumber[0] is physically the Date field (despite the name)
date_str = ''
if self.form_date:
date_str = self.form_date.strftime('%b %d, %Y')
mapping['txt_clientnumber[0]'] = date_str
# Item type checkboxes (mapped by physical position on the form)
mapping['CheckBox15[0]'] = self.item_type == 'medical_equipment'
mapping['CheckBox11[0]'] = self.item_type == 'dentures'
mapping['CheckBox11[1]'] = self.item_type == 'vision_care'
mapping['CheckBox13[0]'] = self.item_type == 'other'
# Description / request details
mapping['TextField1[0]'] = self.other_description or ''
return mapping
def _fill_pdf(self):
"""Fill the Discretionary Benefits PDF using PyPDF2.
This PDF is AES-encrypted, so we use PyPDF2 which handles
decryption and form filling natively (pdfrw cannot).
"""
self.ensure_one()
from PyPDF2 import PdfReader, PdfWriter
from PyPDF2.generic import NameObject, BooleanObject
template_path = self._get_template_path()
if not os.path.exists(template_path):
raise UserError(_("Discretionary Benefits form template not found at %s") % template_path)
mapping = self._build_field_mapping()
reader = PdfReader(template_path)
if reader.is_encrypted:
reader.decrypt('')
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
# Preserve AcroForm from original
if '/AcroForm' in reader.trailer['/Root']:
writer._root_object[NameObject('/AcroForm')] = reader.trailer['/Root']['/AcroForm']
writer._root_object['/AcroForm'][NameObject('/NeedAppearances')] = BooleanObject(True)
# Split text fields and checkbox fields
text_fields = {}
checkbox_fields = {}
for key, value in mapping.items():
if isinstance(value, bool):
checkbox_fields[key] = value
else:
text_fields[key] = str(value)
# Fill text fields via PyPDF2 bulk method
writer.update_page_form_field_values(writer.pages[0], text_fields)
# Fill checkboxes by directly updating each annotation
page = writer.pages[0]
for annot_ref in page['/Annots']:
annot = annot_ref.get_object()
if annot.get('/FT') != '/Btn':
continue
field_name = str(annot.get('/T', ''))
if field_name not in checkbox_fields:
continue
if checkbox_fields[field_name]:
annot[NameObject('/V')] = NameObject('/1')
annot[NameObject('/AS')] = NameObject('/1')
else:
annot[NameObject('/V')] = NameObject('/Off')
annot[NameObject('/AS')] = NameObject('/Off')
output = io.BytesIO()
writer.write(output)
return output.getvalue()
def _sync_odsp_office(self):
"""Sync ODSP office back to sale order if changed in wizard."""
if self.odsp_office_id and self.odsp_office_id != self.sale_order_id.x_fc_odsp_office_id:
self.sale_order_id.x_fc_odsp_office_id = self.odsp_office_id
def _generate_and_attach(self):
"""Generate filled PDF and quotation, attach both to sale order.
Returns (disc_attachment, quote_attachment) ir.attachment records.
"""
order = self.sale_order_id
pdf_data = self._fill_pdf()
disc_filename = f'Discretionary_Benefits_{order.name}.pdf'
encoded_pdf = base64.b64encode(pdf_data)
disc_attachment = self.env['ir.attachment'].create({
'name': disc_filename,
'type': 'binary',
'datas': encoded_pdf,
'res_model': 'sale.order',
'res_id': order.id,
'mimetype': 'application/pdf',
})
if order.x_fc_odsp_division == 'ontario_works':
order.write({
'x_fc_ow_discretionary_form': encoded_pdf,
'x_fc_ow_discretionary_form_filename': disc_filename,
})
report = self.env.ref('sale.action_report_saleorder')
quote_pdf, _ct = report._render_qweb_pdf(report.id, [order.id])
quote_filename = f'{order.name}.pdf'
quote_attachment = self.env['ir.attachment'].create({
'name': quote_filename,
'type': 'binary',
'datas': base64.b64encode(quote_pdf),
'res_model': 'sale.order',
'res_id': order.id,
'mimetype': 'application/pdf',
})
return disc_attachment, quote_attachment
def action_fill_and_attach(self):
"""Fill the Discretionary Benefits PDF and attach to sale order via chatter."""
self.ensure_one()
self._sync_odsp_office()
order = self.sale_order_id
disc_att, quote_att = self._generate_and_attach()
if order._get_odsp_status() == 'quotation':
order._odsp_advance_status('documents_ready',
"Documents ready after Discretionary Benefits form attached.")
order.message_post(
body=_("Discretionary Benefits form filled and attached."),
message_type='comment',
attachment_ids=[disc_att.id, quote_att.id],
)
return {'type': 'ir.actions.act_window_close'}
def _advance_status_on_submit(self, order):
"""Advance status when form is submitted (sent via email/fax)."""
current = order._get_odsp_status()
if order.x_fc_odsp_division == 'ontario_works' and current in ('quotation', 'documents_ready'):
order._odsp_advance_status('submitted_to_ow',
"Discretionary Benefits form submitted to Ontario Works.")
elif current == 'quotation':
order._odsp_advance_status('documents_ready',
"Documents ready after Discretionary Benefits form attached.")
def action_send_fax(self):
"""Fill form, generate quotation, and open the fax wizard with both attached.
Chatter posting is handled by the fax module when the fax is actually sent.
"""
self.ensure_one()
self._sync_odsp_office()
order = self.sale_order_id
office = self.odsp_office_id or order.x_fc_odsp_office_id
disc_att, quote_att = self._generate_and_attach()
self._advance_status_on_submit(order)
ctx = {
'default_sale_order_id': order.id,
'default_partner_id': office.id if office else False,
'default_generate_pdf': False,
'forward_attachment_ids': [disc_att.id, quote_att.id],
}
if office and hasattr(office, 'x_ff_fax_number') and office.x_ff_fax_number:
ctx['default_fax_number'] = office.x_ff_fax_number
return {
'type': 'ir.actions.act_window',
'name': _('Send Fax - Discretionary Benefits'),
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}
def action_send_email(self):
"""Fill form, generate quotation, and email both to ODSP office."""
self.ensure_one()
self._sync_odsp_office()
order = self.sale_order_id
office = self.odsp_office_id or order.x_fc_odsp_office_id
if not office or not office.email:
raise UserError(_(
"No ODSP Office with an email address is set. "
"Please select an ODSP Office before sending."
))
disc_att, quote_att = self._generate_and_attach()
order._send_odsp_submission_email(
attachment_ids=[disc_att.id, quote_att.id],
email_body_notes=self.email_body_notes,
)
self._advance_status_on_submit(order)
order.message_post(
body=_("Discretionary Benefits form and quotation emailed to %s.") % office.name,
message_type='comment',
attachment_ids=[disc_att.id, quote_att.id],
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Email Sent'),
'message': _('Discretionary Benefits form emailed to %s.') % office.email,
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}
def action_send_fax_and_email(self):
"""Fill form, generate quotation, send email, then open fax wizard.
Email is sent first with a notification, then fax wizard opens.
"""
self.ensure_one()
self._sync_odsp_office()
order = self.sale_order_id
office = self.odsp_office_id or order.x_fc_odsp_office_id
if not office or not office.email:
raise UserError(_(
"No ODSP Office with an email address is set. "
"Please select an ODSP Office before sending."
))
disc_att, quote_att = self._generate_and_attach()
order._send_odsp_submission_email(
attachment_ids=[disc_att.id, quote_att.id],
email_body_notes=self.email_body_notes,
)
self._advance_status_on_submit(order)
order.message_post(
body=_("Discretionary Benefits form and quotation emailed to %s.") % office.name,
message_type='comment',
attachment_ids=[disc_att.id, quote_att.id],
)
ctx = {
'default_sale_order_id': order.id,
'default_partner_id': office.id,
'default_generate_pdf': False,
'forward_attachment_ids': [disc_att.id, quote_att.id],
}
if hasattr(office, 'x_ff_fax_number') and office.x_ff_fax_number:
ctx['default_fax_number'] = office.x_ff_fax_number
fax_action = {
'type': 'ir.actions.act_window',
'name': _('Send Fax - Discretionary Benefits'),
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Email Sent'),
'message': _('Email sent to %s. Now proceeding to fax...') % office.email,
'type': 'success',
'sticky': False,
'next': fax_action,
},
}

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<record id="view_discretionary_benefit_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.discretionary.benefit.wizard.form</field>
<field name="model">fusion_claims.discretionary.benefit.wizard</field>
<field name="arch" type="xml">
<form string="Discretionary Benefits Form">
<sheet>
<group string="Client Information">
<group>
<field name="sale_order_id" invisible="1"/>
<field name="client_name"/>
<field name="address"/>
<field name="city"/>
<field name="province"/>
<field name="postal_code"/>
</group>
<group>
<field name="phone_number"/>
<field name="alt_phone"/>
<field name="email"/>
<field name="member_id"/>
<field name="form_date"/>
</group>
</group>
<group string="Submission Details">
<group>
<field name="item_type"/>
</group>
<group>
<field name="odsp_office_id"/>
</group>
</group>
<group>
<field name="other_description" placeholder="Description of the request..."/>
</group>
<group>
<field name="email_body_notes" placeholder="e.g. URGENT: Client needs equipment by March 1st..."
help="These notes appear at the top of the email body, below the title. Use for urgency or priority information."/>
</group>
</sheet>
<footer>
<button name="action_fill_and_attach" type="object"
string="Fill &amp; Attach Form" class="btn-primary"/>
<button name="action_send_fax" type="object"
string="Send Fax" class="btn-primary"/>
<button name="action_send_email" type="object"
string="Send Email" class="btn-primary"/>
<button name="action_send_fax_and_email" type="object"
string="Send Fax &amp; Email" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_discretionary_benefit_wizard" model="ir.actions.act_window">
<field name="name">Discretionary Benefits Form</field>
<field name="res_model">fusion_claims.discretionary.benefit.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
import base64
import logging
from odoo import models, fields, api
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class OdspPreApprovedWizard(models.TransientModel):
_name = 'fusion_claims.odsp.pre.approved.wizard'
_description = 'ODSP Pre-Approved - Upload Approval Form'
sale_order_id = fields.Many2one(
'sale.order', string='Sale Order', readonly=True, required=True,
)
approval_form = fields.Binary(
string='ODSP Approval Form (PDF)',
required=True,
help='Upload the PDF received from ODSP containing the approval letter and SA Mobility form.',
)
approval_form_filename = fields.Char(string='Filename')
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self.env.context.get('active_id')
if active_id:
res['sale_order_id'] = active_id
return res
def action_confirm(self):
self.ensure_one()
if not self.approval_form:
raise UserError("Please upload the ODSP approval form PDF.")
order = self.sale_order_id
filename = self.approval_form_filename or f'ODSP_Approval_{order.name}.pdf'
if order.x_fc_odsp_division == 'sa_mobility':
order.write({
'x_fc_sa_approval_form': self.approval_form,
'x_fc_sa_approval_form_filename': filename,
})
elif order.x_fc_odsp_division == 'standard':
order.write({
'x_fc_odsp_approval_document': self.approval_form,
'x_fc_odsp_approval_document_filename': filename,
})
att = self.env['ir.attachment'].create({
'name': filename,
'type': 'binary',
'datas': self.approval_form,
'res_model': 'sale.order',
'res_id': order.id,
'mimetype': 'application/pdf',
})
from markupsafe import Markup
order.message_post(
body=Markup("ODSP approval document uploaded: <strong>%s</strong>") % filename,
message_type='comment',
attachment_ids=[att.id],
)
order._odsp_advance_status('pre_approved', "ODSP pre-approval received.")
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_odsp_pre_approved_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.odsp.pre.approved.wizard.form</field>
<field name="model">fusion_claims.odsp.pre.approved.wizard</field>
<field name="arch" type="xml">
<form string="Upload ODSP Approval Form">
<group>
<field name="sale_order_id" invisible="1"/>
<field name="approval_form" filename="approval_form_filename"
help="Upload the PDF received from ODSP (may include approval letter + SA Mobility form)"/>
<field name="approval_form_filename" invisible="1"/>
</group>
<footer>
<button name="action_confirm" type="object"
string="Confirm &amp; Upload" class="btn-primary"
icon="fa-upload"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,258 @@
# -*- coding: utf-8 -*-
import base64
import logging
from io import BytesIO
from odoo import models, fields, api
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class OdspReadyDeliveryWizard(models.TransientModel):
_name = 'fusion_claims.odsp.ready.delivery.wizard'
_description = 'Ready for Delivery - Signature Position Setup'
sale_order_id = fields.Many2one(
'sale.order', string='Sale Order', readonly=True, required=True,
)
approval_form = fields.Binary(
string='Approval Form', readonly=True,
)
approval_form_filename = fields.Char(string='Approval Form Filename')
signature_page = fields.Integer(
string='Signature Page', required=True, default=2,
help='Page number containing the signature area (1-indexed)',
)
total_pages = fields.Integer(
string='Total Pages', readonly=True, compute='_compute_total_pages',
)
signature_offset_x = fields.Integer(
string='X Offset (pts)', default=0,
help='Per-case horizontal fine-tune in points (positive = right)',
)
signature_offset_y = fields.Integer(
string='Y Offset (pts)', default=0,
help='Per-case vertical fine-tune in points (positive = up)',
)
preview_image = fields.Binary(
string='Preview', readonly=True,
compute='_compute_preview_image',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self.env.context.get('active_id')
if not active_id:
return res
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
res['approval_form'] = order.x_fc_sa_approval_form
res['approval_form_filename'] = order.x_fc_sa_approval_form_filename
tpl = self.env['fusion.sa.signature.template'].search([
('active', '=', True),
], limit=1)
default_page = tpl.sa_default_sig_page if tpl else 2
res['signature_page'] = order.x_fc_sa_signature_page or default_page
res['signature_offset_x'] = order.x_fc_sa_signature_offset_x or 0
res['signature_offset_y'] = order.x_fc_sa_signature_offset_y or 0
return res
@api.depends('approval_form')
def _compute_total_pages(self):
for wiz in self:
if wiz.approval_form:
try:
from odoo.tools.pdf import PdfFileReader
pdf_bytes = base64.b64decode(wiz.approval_form)
reader = PdfFileReader(BytesIO(pdf_bytes))
wiz.total_pages = reader.getNumPages()
except Exception:
wiz.total_pages = 0
else:
wiz.total_pages = 0
@api.depends('approval_form', 'signature_page',
'signature_offset_x', 'signature_offset_y')
def _compute_preview_image(self):
for wiz in self:
if not wiz.approval_form or not wiz.signature_page:
wiz.preview_image = False
continue
try:
wiz.preview_image = wiz._render_preview()
except Exception as e:
_logger.warning("Preview render failed: %s", e)
wiz.preview_image = False
def _get_template_coords(self, page_h=792):
"""Load coordinates from SA Signature Template with per-case offsets."""
tpl = self.env['fusion.sa.signature.template'].search([
('active', '=', True),
], limit=1)
if tpl:
coords = tpl.get_sa_coordinates(page_h)
else:
coords = {
'name_x': 105, 'name_y': page_h - 97,
'date_x': 430, 'date_y': page_h - 97,
'sig_x': 72, 'sig_y': page_h - 72 - 25,
'sig_w': 190, 'sig_h': 25,
}
ox = self.signature_offset_x or 0
oy = self.signature_offset_y or 0
if ox or oy:
for k in ('name_x', 'date_x', 'sig_x'):
if k in coords:
coords[k] += ox
for k in ('name_y', 'date_y', 'sig_y'):
if k in coords:
coords[k] += oy
return coords
def _render_preview(self):
"""Render the selected page as a PNG with a red rectangle showing signature placement."""
from odoo.tools.pdf import PdfFileReader
pdf_bytes = base64.b64decode(self.approval_form)
reader = PdfFileReader(BytesIO(pdf_bytes))
num_pages = reader.getNumPages()
page_idx = (self.signature_page or 2) - 1
if page_idx < 0 or page_idx >= num_pages:
return False
try:
from pdf2image import convert_from_bytes
except ImportError:
_logger.warning("pdf2image not installed, cannot generate preview.")
return False
images = convert_from_bytes(
pdf_bytes, first_page=page_idx + 1, last_page=page_idx + 1, dpi=150,
)
if not images:
return False
from PIL import ImageDraw, ImageFont
img = images[0]
draw = ImageDraw.Draw(img)
page = reader.getPage(page_idx)
page_w_pts = float(page.mediaBox.getWidth())
page_h_pts = float(page.mediaBox.getHeight())
img_w, img_h = img.size
scale_x = img_w / page_w_pts
scale_y = img_h / page_h_pts
coords = self._get_template_coords(page_h_pts)
try:
font_b = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
font_sm = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10)
except Exception:
font_b = font_sm = ImageFont.load_default()
# Signature box (red) -- sig_y is bottom-left in ReportLab
# top edge of box in from-top coords = page_h - (sig_y + sig_h)
sig_from_top = page_h_pts - coords['sig_y'] - coords['sig_h']
px_x = int(coords['sig_x'] * scale_x)
px_y = int(sig_from_top * scale_y)
px_w = int(coords['sig_w'] * scale_x)
px_h = int(coords['sig_h'] * scale_y)
for off in range(3):
draw.rectangle(
[px_x - off, px_y - off, px_x + px_w + off, px_y + px_h + off],
outline='red',
)
draw.text((px_x + 4, px_y + 4), "Signature", fill='red', font=font_sm)
# Name (blue) -- convert ReportLab bottom-origin back to top-origin for PIL
if 'name_x' in coords:
name_from_top = page_h_pts - coords['name_y']
nx = int(coords['name_x'] * scale_x)
ny = int(name_from_top * scale_y)
draw.text((nx, ny - 16), "John Smith", fill='blue', font=font_b)
draw.text((nx, ny + 2), "Name", fill='blue', font=font_sm)
# Date (purple)
if 'date_x' in coords:
date_from_top = page_h_pts - coords['date_y']
dx = int(coords['date_x'] * scale_x)
dy = int(date_from_top * scale_y)
draw.text((dx, dy - 16), "2026-02-17", fill='purple', font=font_b)
draw.text((dx, dy + 2), "Date", fill='purple', font=font_sm)
buf = BytesIO()
img.save(buf, format='PNG')
return base64.b64encode(buf.getvalue())
def action_confirm(self):
"""Save signature settings, advance status, and open the delivery task form."""
self.ensure_one()
order = self.sale_order_id
if self.signature_page < 1 or (self.total_pages and self.signature_page > self.total_pages):
raise UserError(
"Invalid signature page. Must be between 1 and %s." % self.total_pages
)
order.write({
'x_fc_sa_signature_page': self.signature_page,
'x_fc_sa_signature_offset_x': self.signature_offset_x,
'x_fc_sa_signature_offset_y': self.signature_offset_y,
})
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': order.id,
'default_partner_id': order.partner_id.id,
'default_pod_required': True,
'mark_odsp_ready_for_delivery': True,
},
}
def action_preview_full(self):
"""Open the full approval PDF for preview."""
self.ensure_one()
if not self.approval_form:
raise UserError("No approval form available to preview.")
att = self.env['ir.attachment'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.sale_order_id.id),
('name', '=', self.approval_form_filename),
], order='create_date desc', limit=1)
if not att:
att = self.env['ir.attachment'].create({
'name': self.approval_form_filename or 'ODSP_Approval.pdf',
'type': 'binary',
'datas': self.approval_form,
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'mimetype': 'application/pdf',
})
return {
'type': 'ir.actions.client',
'tag': 'fusion_claims.preview_document',
'params': {
'attachment_id': att.id,
'title': att.name,
},
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_odsp_ready_delivery_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.odsp.ready.delivery.wizard.form</field>
<field name="model">fusion_claims.odsp.ready.delivery.wizard</field>
<field name="arch" type="xml">
<form string="Ready for Delivery - Signature Setup">
<field name="sale_order_id" invisible="1"/>
<field name="approval_form" invisible="1"/>
<field name="approval_form_filename" invisible="1"/>
<field name="total_pages" invisible="1"/>
<sheet>
<group>
<group string="Signature Settings">
<div class="text-muted mb-2" colspan="2">
Select the page containing the signature area. Position defaults are loaded from Settings.
Use X/Y offsets to fine-tune for this specific case if needed.
</div>
<label for="signature_page"/>
<div class="d-flex align-items-center">
<field name="signature_page" class="oe_inline" style="width: 60px;"/>
<span class="ms-2 text-muted">of <field name="total_pages" class="oe_inline" widget="integer" readonly="1"/> pages</span>
</div>
<field name="signature_offset_x" string="Fine-tune X Offset"/>
<field name="signature_offset_y" string="Fine-tune Y Offset"/>
<div colspan="2" class="mt-2">
<button name="action_preview_full" type="object"
string="Preview Full PDF" class="btn-link"
icon="fa-file-pdf-o"/>
</div>
</group>
<group string="Signature Preview">
<div colspan="2">
<field name="preview_image" widget="image"
class="o_kanban_image" style="max-height: 500px; max-width: 100%;"/>
</div>
</group>
</group>
</sheet>
<footer>
<button name="action_confirm" type="object"
string="Confirm &amp; Schedule Delivery" class="btn-primary"
icon="fa-truck"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

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

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<record id="view_sa_mobility_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.sa.mobility.wizard.form</field>
<field name="model">fusion_claims.sa.mobility.wizard</field>
<field name="arch" type="xml">
<form string="SA Mobility Form">
<sheet>
<group string="Vendor Information">
<group>
<field name="sale_order_id" invisible="1"/>
<field name="vendor_name"/>
<field name="order_number"/>
<field name="vendor_address"/>
<field name="primary_email"/>
</group>
<group>
<field name="phone"/>
<field name="secondary_email"/>
<field name="form_date"/>
<field name="salesperson_name"/>
<field name="service_date"/>
</group>
</group>
<group string="Client Information">
<group>
<field name="client_first_name"/>
<field name="client_last_name"/>
<field name="member_id"/>
</group>
<group>
<field name="client_address"/>
<field name="client_phone"/>
<field name="relationship"/>
</group>
</group>
<group string="Device Information">
<group>
<field name="device_type"/>
<field name="device_other_description"
invisible="device_type != 'other'"/>
<field name="serial_number"/>
<field name="year"/>
</group>
<group>
<field name="make"/>
<field name="model_name"/>
<field name="warranty_in_effect"/>
<field name="warranty_description"
invisible="not warranty_in_effect"/>
<field name="after_hours"/>
</group>
</group>
<group string="Request Details">
<field name="sa_request_type"/>
</group>
<group string="Notes / Comments">
<field name="notes" nolabel="1" colspan="2"
placeholder="Additional details about the request..."/>
</group>
<group string="Email Body Notes">
<field name="email_body_notes" nolabel="1" colspan="2"
placeholder="e.g. URGENT: Client needs equipment by March 1st..."
help="These notes appear at the top of the email body, below the title. Use for urgency or priority information."/>
</group>
<notebook>
<page string="Parts" name="parts">
<field name="part_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="qty"/>
<field name="description"/>
<field name="unit_price"/>
<field name="tax_id" string="Tax Type"/>
<field name="taxes" readonly="1"/>
<field name="amount" readonly="1"/>
</list>
</field>
<group class="oe_subtotal_footer">
<field name="parts_total" class="oe_subtotal_footer_separator"/>
</group>
</page>
<page string="Labour" name="labour">
<field name="labour_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="hours"/>
<field name="rate"/>
<field name="tax_id" string="Tax Type"/>
<field name="taxes" readonly="1"/>
<field name="amount" readonly="1"/>
</list>
</field>
<group class="oe_subtotal_footer">
<field name="labour_total" class="oe_subtotal_footer_separator"/>
</group>
</page>
<page string="Additional Fees" name="fees">
<field name="fee_line_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="description"/>
<field name="rate"/>
<field name="tax_id" string="Tax Type"/>
<field name="taxes" readonly="1"/>
<field name="amount" readonly="1"/>
</list>
</field>
<group class="oe_subtotal_footer">
<field name="fees_total" class="oe_subtotal_footer_separator"/>
</group>
</page>
</notebook>
<group string="Totals Summary" class="oe_subtotal_footer">
<field name="parts_total" string="Parts"/>
<field name="labour_total" string="Labour"/>
<field name="fees_total" string="Additional Fees"/>
<field name="grand_total" class="oe_subtotal_footer_separator"/>
</group>
</sheet>
<footer>
<button name="action_fill_and_attach" type="object"
string="Fill &amp; Attach Form" class="btn-primary"/>
<button name="action_fill_attach_and_send" type="object"
string="Fill, Attach &amp; Send" class="btn-secondary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_sa_mobility_wizard" model="ir.actions.act_window">
<field name="name">SA Mobility Form</field>
<field name="res_model">fusion_claims.sa.mobility.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,233 @@
# -*- 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 logging
_logger = logging.getLogger(__name__)
class OdspSubmitToOdspWizard(models.TransientModel):
_name = 'fusion_claims.submit.to.odsp.wizard'
_description = 'Submit Quotation & Authorizer Letter to ODSP'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
client_name = fields.Char(string='Client Name', readonly=True)
member_id = fields.Char(string='ODSP Member ID', readonly=True)
order_name = fields.Char(string='Order #', readonly=True)
odsp_office_id = fields.Many2one(
'res.partner',
string='ODSP Office',
domain="[('x_fc_contact_type', '=', 'odsp_office')]",
help='Override the ODSP office for this submission',
)
authorizer_letter = fields.Binary(
string='Authorizer Letter',
help='Upload the authorizer letter to include in the submission',
)
authorizer_letter_filename = fields.Char(string='Authorizer Letter Filename')
email_body_notes = fields.Text(
string='Email Body Notes',
help='Notes to include at the top of the email body (e.g. urgency, special instructions)',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
order_id = self.env.context.get('active_id')
if order_id:
order = self.env['sale.order'].browse(order_id)
res.update({
'sale_order_id': order.id,
'client_name': order.partner_id.name or '',
'member_id': order.x_fc_odsp_member_id or '',
'order_name': order.name or '',
'odsp_office_id': order.x_fc_odsp_office_id.id if order.x_fc_odsp_office_id else False,
})
if order.x_fc_odsp_authorizer_letter:
res['authorizer_letter'] = order.x_fc_odsp_authorizer_letter
res['authorizer_letter_filename'] = order.x_fc_odsp_authorizer_letter_filename
return res
def _sync_odsp_office(self):
"""Sync the selected ODSP office back to the sale order."""
if self.odsp_office_id and self.odsp_office_id != self.sale_order_id.x_fc_odsp_office_id:
self.sale_order_id.x_fc_odsp_office_id = self.odsp_office_id
def _generate_attachments(self):
"""Generate quotation PDF and authorizer letter attachment, return list of ir.attachment IDs."""
order = self.sale_order_id
Attachment = self.env['ir.attachment'].sudo()
att_ids = []
report = self.env.ref('sale.action_report_saleorder')
quote_pdf, _ct = report._render_qweb_pdf(report.id, [order.id])
quote_filename = f'{order.name}.pdf'
quote_att = Attachment.create({
'name': quote_filename,
'type': 'binary',
'datas': base64.b64encode(quote_pdf),
'res_model': 'sale.order',
'res_id': order.id,
'mimetype': 'application/pdf',
})
att_ids.append(quote_att.id)
if self.authorizer_letter:
letter_filename = self.authorizer_letter_filename or 'Authorizer_Letter.pdf'
order.write({
'x_fc_odsp_authorizer_letter': self.authorizer_letter,
'x_fc_odsp_authorizer_letter_filename': letter_filename,
})
letter_att = Attachment.create({
'name': letter_filename,
'type': 'binary',
'datas': self.authorizer_letter,
'res_model': 'sale.order',
'res_id': order.id,
'mimetype': 'application/pdf',
})
att_ids.append(letter_att.id)
return att_ids
def _advance_status(self, order):
"""Advance status to submitted_to_odsp."""
current = order._get_odsp_status()
if current in ('quotation', 'documents_ready'):
order._odsp_advance_status('submitted_to_odsp',
"Quotation and authorizer letter submitted to ODSP.")
def action_send_email(self):
"""Generate quotation PDF, attach authorizer letter, and email to ODSP office."""
self.ensure_one()
self._sync_odsp_office()
order = self.sale_order_id
office = self.odsp_office_id or order.x_fc_odsp_office_id
if not office or not office.email:
raise UserError(_(
"No ODSP Office with an email address is set. "
"Please select an ODSP Office before sending."
))
att_ids = self._generate_attachments()
order._send_odsp_submission_email(
attachment_ids=att_ids,
email_body_notes=self.email_body_notes,
)
self._advance_status(order)
order.message_post(
body=_("Quotation and authorizer letter emailed to %s.") % office.name,
message_type='comment',
attachment_ids=att_ids,
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Email Sent'),
'message': _('Documents emailed to %s.') % office.email,
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}
def action_send_fax(self):
"""Generate quotation PDF, attach authorizer letter, and open fax wizard."""
self.ensure_one()
self._sync_odsp_office()
order = self.sale_order_id
office = self.odsp_office_id or order.x_fc_odsp_office_id
att_ids = self._generate_attachments()
self._advance_status(order)
ctx = {
'default_sale_order_id': order.id,
'default_partner_id': office.id if office else False,
'default_generate_pdf': False,
'forward_attachment_ids': att_ids,
}
if office and hasattr(office, 'x_ff_fax_number') and office.x_ff_fax_number:
ctx['default_fax_number'] = office.x_ff_fax_number
return {
'type': 'ir.actions.act_window',
'name': _('Send Fax - ODSP Submission'),
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}
def action_send_fax_and_email(self):
"""Generate documents, send email first, then open fax wizard."""
self.ensure_one()
self._sync_odsp_office()
order = self.sale_order_id
office = self.odsp_office_id or order.x_fc_odsp_office_id
if not office or not office.email:
raise UserError(_(
"No ODSP Office with an email address is set. "
"Please select an ODSP Office before sending."
))
att_ids = self._generate_attachments()
order._send_odsp_submission_email(
attachment_ids=att_ids,
email_body_notes=self.email_body_notes,
)
self._advance_status(order)
order.message_post(
body=_("Quotation and authorizer letter emailed to %s.") % office.name,
message_type='comment',
attachment_ids=att_ids,
)
ctx = {
'default_sale_order_id': order.id,
'default_partner_id': office.id,
'default_generate_pdf': False,
'forward_attachment_ids': att_ids,
}
if hasattr(office, 'x_ff_fax_number') and office.x_ff_fax_number:
ctx['default_fax_number'] = office.x_ff_fax_number
fax_action = {
'type': 'ir.actions.act_window',
'name': _('Send Fax - ODSP Submission'),
'res_model': 'fusion_faxes.send.fax.wizard',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Email Sent'),
'message': _('Email sent to %s. Now proceeding to fax...') % office.email,
'type': 'success',
'sticky': False,
'next': fax_action,
},
}

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<record id="view_submit_to_odsp_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.submit.to.odsp.wizard.form</field>
<field name="model">fusion_claims.submit.to.odsp.wizard</field>
<field name="arch" type="xml">
<form string="Submit to ODSP">
<sheet>
<div class="alert alert-info" role="alert">
<i class="fa fa-info-circle"/> This will generate the quotation PDF and send it
along with the authorizer letter to the selected ODSP office.
</div>
<group string="Submission Details">
<group>
<field name="sale_order_id" invisible="1"/>
<field name="client_name"/>
<field name="member_id"/>
<field name="order_name"/>
</group>
<group>
<field name="odsp_office_id"/>
</group>
</group>
<group string="Authorizer Letter">
<field name="authorizer_letter" filename="authorizer_letter_filename" widget="binary"/>
<field name="authorizer_letter_filename" invisible="1"/>
</group>
<group>
<field name="email_body_notes" placeholder="e.g. URGENT: Client needs equipment by March 1st..."
help="These notes appear at the top of the email body."/>
</group>
</sheet>
<footer>
<button name="action_send_email" type="object"
string="Send Email" class="btn-primary" icon="fa-envelope"/>
<button name="action_send_fax" type="object"
string="Send Fax" class="btn-primary" icon="fa-fax"/>
<button name="action_send_fax_and_email" type="object"
string="Send Fax &amp; Email" class="btn-primary" icon="fa-paper-plane"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_submit_to_odsp_wizard" model="ir.actions.act_window">
<field name="name">Submit to ODSP</field>
<field name="res_model">fusion_claims.submit.to.odsp.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
"""
Ready for Delivery Wizard
Wizard to assign delivery technicians and mark order as Ready for Delivery.
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
import logging
_logger = logging.getLogger(__name__)
class ReadyForDeliveryWizard(models.TransientModel):
_name = 'fusion.ready.for.delivery.wizard'
_description = 'Ready for Delivery Wizard'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
readonly=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
related='sale_order_id.partner_id',
readonly=True,
)
claim_number = fields.Char(
string='Claim Number',
related='sale_order_id.x_fc_claim_number',
readonly=True,
)
is_early_delivery = fields.Boolean(
string='Early Delivery',
related='sale_order_id.x_fc_early_delivery',
readonly=True,
help='This delivery is before ADP approval',
)
current_status = fields.Selection(
related='sale_order_id.x_fc_adp_application_status',
string='Current Status',
readonly=True,
)
# Technician Assignment
technician_ids = fields.Many2many(
'res.users',
'ready_delivery_wizard_technician_rel',
'wizard_id',
'user_id',
string='Delivery Technicians',
required=True,
help='Select one or more field technicians for this delivery',
)
# Scheduling
scheduled_datetime = fields.Datetime(
string='Scheduled Delivery Date/Time',
help='Optional: When is the delivery scheduled?',
)
# Delivery Address
delivery_address = fields.Text(
string='Delivery Address',
compute='_compute_delivery_address',
readonly=True,
)
# Notes
notes = fields.Text(
string='Delivery Notes',
help='Any special instructions for the delivery technicians',
)
@api.depends('sale_order_id')
def _compute_delivery_address(self):
"""Compute delivery address from partner shipping address."""
for wizard in self:
order = wizard.sale_order_id
if order and order.partner_shipping_id:
addr = order.partner_shipping_id
parts = [addr.street, addr.street2, addr.city, addr.state_id.name if addr.state_id else '', addr.zip]
wizard.delivery_address = ', '.join([p for p in parts if p])
elif order and order.partner_id:
addr = order.partner_id
parts = [addr.street, addr.street2, addr.city, addr.state_id.name if addr.state_id else '', addr.zip]
wizard.delivery_address = ', '.join([p for p in parts if p])
else:
wizard.delivery_address = ''
def action_confirm(self):
"""Confirm and mark as Ready for Delivery."""
self.ensure_one()
order = self.sale_order_id
if not self.technician_ids:
raise UserError(_("Please select at least one delivery technician."))
# Get technician names for chatter message
technician_names = ', '.join(self.technician_ids.mapped('name'))
user_name = self.env.user.name
# Update the sale order
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'ready_delivery',
'x_fc_delivery_technician_ids': [(6, 0, self.technician_ids.ids)],
'x_fc_ready_for_delivery_date': fields.Datetime.now(),
'x_fc_scheduled_delivery_datetime': self.scheduled_datetime,
})
# Build chatter message
scheduled_str = ''
if self.scheduled_datetime:
scheduled_str = f'<li><strong>Scheduled:</strong> {self.scheduled_datetime.strftime("%B %d, %Y at %I:%M %p")}</li>'
notes_str = ''
if self.notes:
notes_str = f'<hr><p class="mb-0"><strong>Delivery Notes:</strong> {self.notes}</p>'
early_badge = ''
if self.is_early_delivery:
early_badge = ' <span class="badge bg-warning text-dark">Early Delivery</span>'
chatter_body = Markup(f'''
<div class="alert alert-success" role="alert">
<h5 class="alert-heading"><i class="fa fa-truck"></i> Ready for Delivery{early_badge}</h5>
<ul>
<li><strong>Marked By:</strong> {user_name}</li>
<li><strong>Technician(s):</strong> {technician_names}</li>
{scheduled_str}
<li><strong>Delivery Address:</strong> {self.delivery_address or 'N/A'}</li>
</ul>
{notes_str}
</div>
''')
order.message_post(
body=chatter_body,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Send email notifications
order._send_ready_for_delivery_email(
technicians=self.technician_ids,
scheduled_datetime=self.scheduled_datetime,
notes=self.notes,
)
# Auto-create technician tasks for each assigned technician
self._create_technician_tasks(order)
return {'type': 'ir.actions.act_window_close'}
def _create_technician_tasks(self, order):
"""Create a technician task for each assigned technician.
The task model's create() method auto-populates address fields
from the linked sale order's shipping address when address_street
is not explicitly provided, so we intentionally omit address here.
"""
Task = self.env['fusion.technician.task']
scheduled_date = False
time_start = 9.0 # default 9:00 AM
if self.scheduled_datetime:
scheduled_date = self.scheduled_datetime.date()
time_start = self.scheduled_datetime.hour + (self.scheduled_datetime.minute / 60.0)
else:
scheduled_date = fields.Date.context_today(self)
created_tasks = self.env['fusion.technician.task']
for tech in self.technician_ids:
vals = {
'technician_id': tech.id,
'sale_order_id': order.id,
'task_type': 'delivery',
'scheduled_date': scheduled_date,
'time_start': time_start,
'time_end': time_start + 1.0, # 1 hour default
'partner_id': order.partner_id.id,
'description': self.notes or '',
'pod_required': True,
}
task = Task.create(vals)
created_tasks |= task
_logger.info("Created delivery task %s for %s on order %s", task.name, tech.name, order.name)
# Post a summary of created tasks back to the sale order chatter
if created_tasks:
task_lines = ''
for t in created_tasks:
task_url = f'/web#id={t.id}&model=fusion.technician.task&view_type=form'
time_str = t.time_start_12h or ''
task_lines += (
f'<li><a href="{task_url}">{t.name}</a> - '
f'{t.technician_id.name} '
f'({t.scheduled_date.strftime("%b %d, %Y") if t.scheduled_date else "TBD"}'
f'{" at " + time_str if time_str else ""})</li>'
)
summary = Markup(
'<div class="alert alert-info" style="margin:0;">'
'<strong><i class="fa fa-wrench"></i> Delivery Tasks Created</strong>'
'<ul style="margin-bottom:0;">%s</ul>'
'</div>'
) % Markup(task_lines)
order.message_post(
body=summary,
message_type='notification',
subtype_xmlid='mail.mt_note',
)

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Ready for Delivery Wizard Form View -->
<record id="view_ready_for_delivery_wizard_form" model="ir.ui.view">
<field name="name">fusion.ready.for.delivery.wizard.form</field>
<field name="model">fusion.ready.for.delivery.wizard</field>
<field name="arch" type="xml">
<form string="Ready for Delivery">
<group>
<group string="Order Information">
<field name="sale_order_id" readonly="1"/>
<field name="partner_id"/>
<field name="claim_number"/>
<field name="current_status" widget="badge"/>
<field name="is_early_delivery" widget="boolean_toggle"/>
</group>
<group string="Delivery Details">
<field name="delivery_address" readonly="1"/>
</group>
</group>
<separator string="Technician Assignment"/>
<group>
<field name="technician_ids" widget="many2many_tags"
domain="[('x_fc_is_field_staff', '=', True)]"
options="{'color_field': 'color'}"
placeholder="Select delivery technician(s)..."/>
</group>
<separator string="Scheduling"/>
<group>
<field name="scheduled_datetime"/>
<field name="notes" placeholder="Any special delivery instructions..."/>
</group>
<footer>
<button name="action_confirm" type="object"
string="Mark Ready for Delivery" class="btn-primary"
icon="fa-truck"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action to open the wizard -->
<record id="action_ready_for_delivery_wizard" model="ir.actions.act_window">
<field name="name">Ready for Delivery</field>
<field name="res_model">fusion.ready.for.delivery.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
from datetime import date
import logging
_logger = logging.getLogger(__name__)
class ReadyForSubmissionWizard(models.TransientModel):
"""Wizard to collect required fields before marking as Ready for Submission."""
_name = 'fusion_claims.ready.for.submission.wizard'
_description = 'Ready for Submission Wizard'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
readonly=True,
)
# Authorization Details
claim_authorization_date = fields.Date(
string='Claim Authorization Date',
required=True,
default=fields.Date.context_today,
help='Date when the claim was authorized by the OT/Authorizer',
)
# Client References (may already be filled)
client_ref_1 = fields.Char(
string='Client Reference 1',
help='First client reference number (e.g., PO number)',
)
client_ref_2 = fields.Char(
string='Client Reference 2',
help='Second client reference number',
)
# Reason for Application
reason_for_application = fields.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')
# Previous Funding (required for some reasons)
requires_previous_funding = fields.Boolean(
string='Requires Previous Funding Date',
compute='_compute_requires_previous_funding',
)
previous_funding_date = fields.Date(
string='Previous Funding Date',
help='Date of previous ADP funding (required for modifications/replacements)',
)
# Show which fields are already filled
has_authorization_date = fields.Boolean(compute='_compute_field_status')
has_client_refs = fields.Boolean(compute='_compute_field_status')
has_reason = fields.Boolean(compute='_compute_field_status')
has_documents = fields.Boolean(compute='_compute_field_status')
notes = fields.Text(
string='Notes',
help='Any notes about the submission preparation',
)
@api.depends('reason_for_application')
def _compute_requires_previous_funding(self):
no_prev_funding_reasons = ['first_access', 'mod_non_adp']
for wizard in self:
reason = wizard.reason_for_application
wizard.requires_previous_funding = bool(reason) and reason not in no_prev_funding_reasons
@api.depends('sale_order_id')
def _compute_field_status(self):
for wizard in self:
order = wizard.sale_order_id
wizard.has_authorization_date = bool(order.x_fc_claim_authorization_date)
wizard.has_client_refs = bool(order.x_fc_client_ref_1 and order.x_fc_client_ref_2)
wizard.has_reason = bool(order.x_fc_reason_for_application)
wizard.has_documents = bool(order.x_fc_original_application and order.x_fc_signed_pages_11_12)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self._context.get('active_id')
if active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
# Pre-fill from existing values
if order.x_fc_claim_authorization_date:
res['claim_authorization_date'] = order.x_fc_claim_authorization_date
if order.x_fc_client_ref_1:
res['client_ref_1'] = order.x_fc_client_ref_1
if order.x_fc_client_ref_2:
res['client_ref_2'] = order.x_fc_client_ref_2
if order.x_fc_reason_for_application:
res['reason_for_application'] = order.x_fc_reason_for_application
if order.x_fc_previous_funding_date:
res['previous_funding_date'] = order.x_fc_previous_funding_date
return res
def action_confirm(self):
"""Validate and mark as ready for submission."""
self.ensure_one()
order = self.sale_order_id
if order.x_fc_adp_application_status != 'application_received':
raise UserError("Can only mark ready for submission from 'Application Received' status.")
# Validate required fields
missing = []
if not self.claim_authorization_date:
missing.append('Claim Authorization Date')
if not self.client_ref_1 and not order.x_fc_client_ref_1:
missing.append('Client Reference 1')
if not self.client_ref_2 and not order.x_fc_client_ref_2:
missing.append('Client Reference 2')
if not self.reason_for_application and not order.x_fc_reason_for_application:
missing.append('Reason for Application')
# Check previous funding if required
reason = self.reason_for_application or order.x_fc_reason_for_application
no_prev_funding_reasons = ['first_access', 'mod_non_adp']
if reason and reason not in no_prev_funding_reasons:
if not self.previous_funding_date and not order.x_fc_previous_funding_date:
missing.append('Previous Funding Date')
# Check documents
if not order.x_fc_original_application:
missing.append('Original ADP Application (upload in Application Received step)')
if not order.x_fc_signed_pages_11_12:
missing.append('Page 11 & 12 Signed (upload in Application Received step)')
if missing:
raise UserError(
"Cannot mark as Ready for Submission.\n\n"
"Required fields/documents missing:\n" + "\n".join(missing)
)
# Build update values
update_vals = {
'x_fc_adp_application_status': 'ready_submission',
'x_fc_claim_authorization_date': self.claim_authorization_date,
}
# Only update if new values provided
if self.client_ref_1:
update_vals['x_fc_client_ref_1'] = self.client_ref_1
if self.client_ref_2:
update_vals['x_fc_client_ref_2'] = self.client_ref_2
if self.reason_for_application:
update_vals['x_fc_reason_for_application'] = self.reason_for_application
if self.previous_funding_date:
update_vals['x_fc_previous_funding_date'] = self.previous_funding_date
# Update sale order
order.with_context(skip_status_validation=True).write(update_vals)
# Post to chatter
reason_label = ''
if self.reason_for_application:
reason_label = dict(self._fields['reason_for_application'].selection or []).get(
self.reason_for_application, ''
)
order.message_post(
body=Markup(
'<div style="background: #d4edda; border-left: 4px solid #28a745; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #28a745; margin: 0 0 8px 0;"><i class="fa fa-check"/> Ready for Submission</h4>'
f'<p style="margin: 0;"><strong>Authorization Date:</strong> {self.claim_authorization_date.strftime("%B %d, %Y")}</p>'
f'{f"<p style=\'margin: 4px 0 0 0;\'><strong>Reason:</strong> {reason_label}</p>" if reason_label else ""}'
'<p style="margin: 8px 0 0 0; color: #666;">Application is ready to be submitted to ADP. Use "Submit Application" to proceed.</p>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Ready for Submission Wizard Form View -->
<record id="view_ready_for_submission_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.ready.for.submission.wizard.form</field>
<field name="model">fusion_claims.ready.for.submission.wizard</field>
<field name="arch" type="xml">
<form string="Ready for Submission">
<field name="sale_order_id" invisible="1"/>
<field name="has_authorization_date" invisible="1"/>
<field name="has_client_refs" invisible="1"/>
<field name="has_reason" invisible="1"/>
<field name="has_documents" invisible="1"/>
<field name="requires_previous_funding" invisible="1"/>
<div class="alert alert-info mb-3" role="alert">
<strong><i class="fa fa-clipboard-check"/> Submission Checklist</strong>
<p class="mb-0">Please verify all required information before marking as ready for submission.</p>
</div>
<!-- Status indicators -->
<div class="row mb-3">
<div class="col-md-3">
<span class="badge bg-success" invisible="not has_documents">
<i class="fa fa-check"/> Documents Uploaded
</span>
<span class="badge bg-danger" invisible="has_documents">
<i class="fa fa-times"/> Documents Missing
</span>
</div>
<div class="col-md-3">
<span class="badge bg-success" invisible="not has_reason">
<i class="fa fa-check"/> Reason Set
</span>
<span class="badge bg-warning" invisible="has_reason">
<i class="fa fa-exclamation"/> Reason Needed
</span>
</div>
<div class="col-md-3">
<span class="badge bg-success" invisible="not has_client_refs">
<i class="fa fa-check"/> Client Refs
</span>
<span class="badge bg-warning" invisible="has_client_refs">
<i class="fa fa-exclamation"/> Client Refs Needed
</span>
</div>
<div class="col-md-3">
<span class="badge bg-success" invisible="not has_authorization_date">
<i class="fa fa-check"/> Authorized
</span>
<span class="badge bg-warning" invisible="has_authorization_date">
<i class="fa fa-exclamation"/> Auth Date Needed
</span>
</div>
</div>
<group>
<group string="Authorization">
<field name="claim_authorization_date"/>
</group>
<group string="Client References">
<field name="client_ref_1" placeholder="e.g., DOJO"/>
<field name="client_ref_2" placeholder="e.g., 1234"/>
</group>
</group>
<group>
<group string="Application Details">
<field name="reason_for_application"/>
<field name="previous_funding_date"
invisible="not requires_previous_funding"
required="requires_previous_funding"/>
</group>
<group string="Notes">
<field name="notes" placeholder="Any notes about the submission..."/>
</group>
</group>
<footer>
<button name="action_confirm" type="object"
string="Mark Ready for Submission" class="btn-primary"
icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action for the wizard -->
<record id="action_ready_for_submission_wizard" model="ir.actions.act_window">
<field name="name">Ready for Submission</field>
<field name="res_model">fusion_claims.ready.for.submission.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from datetime import date
import logging
_logger = logging.getLogger(__name__)
class ReadyToBillWizard(models.TransientModel):
"""Wizard to collect Proof of Delivery and delivery date before marking as Ready to Bill."""
_name = 'fusion_claims.ready.to.bill.wizard'
_description = 'Ready to Bill Wizard'
# ==========================================================================
# FIELDS
# ==========================================================================
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
readonly=True,
)
# Delivery Information
delivery_date = fields.Date(
string='Delivery Date',
required=True,
default=fields.Date.today,
help='Date the product was delivered to the client',
)
# Proof of Delivery
proof_of_delivery = fields.Binary(
string='Proof of Delivery',
required=True,
help='Upload the Proof of Delivery document (PDF)',
)
proof_of_delivery_filename = fields.Char(
string='POD Filename',
)
# Optional notes
notes = fields.Text(
string='Notes',
help='Optional notes about the delivery',
)
# Info fields
has_existing_pod = fields.Boolean(
string='Has Existing POD',
compute='_compute_existing_data',
)
has_existing_date = fields.Boolean(
string='Has Existing Date',
compute='_compute_existing_data',
)
existing_pod_filename = fields.Char(
string='Existing POD Filename',
compute='_compute_existing_data',
)
existing_delivery_date = fields.Date(
string='Existing Delivery Date',
compute='_compute_existing_data',
)
# ==========================================================================
# COMPUTED METHODS
# ==========================================================================
@api.depends('sale_order_id')
def _compute_existing_data(self):
for wizard in self:
order = wizard.sale_order_id
wizard.has_existing_pod = bool(order.x_fc_proof_of_delivery)
wizard.has_existing_date = bool(order.x_fc_adp_delivery_date)
wizard.existing_pod_filename = order.x_fc_proof_of_delivery_filename or ''
wizard.existing_delivery_date = order.x_fc_adp_delivery_date
# ==========================================================================
# DEFAULT GET
# ==========================================================================
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self._context.get('active_id')
if not active_id:
return res
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
# Pre-fill delivery date if already set
if order.x_fc_adp_delivery_date:
res['delivery_date'] = order.x_fc_adp_delivery_date
# Pre-fill POD if already uploaded
if order.x_fc_proof_of_delivery:
res['proof_of_delivery'] = order.x_fc_proof_of_delivery
res['proof_of_delivery_filename'] = order.x_fc_proof_of_delivery_filename
return res
# ==========================================================================
# ACTION METHODS
# ==========================================================================
def action_mark_ready_to_bill(self):
"""Validate and mark the order as Ready to Bill."""
self.ensure_one()
order = self.sale_order_id
# Validate status
if order.x_fc_adp_application_status not in ('approved', 'approved_deduction'):
raise UserError(
"Order can only be marked as 'Ready to Bill' from 'Approved' status."
)
# Validate POD file type
if self.proof_of_delivery_filename:
if not self.proof_of_delivery_filename.lower().endswith('.pdf'):
raise UserError(
f"Proof of Delivery must be a PDF file.\n"
f"Uploaded: '{self.proof_of_delivery_filename}'"
)
# Check device verification
if not order.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."
)
# Update the order
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'ready_bill',
'x_fc_adp_delivery_date': self.delivery_date,
'x_fc_proof_of_delivery': self.proof_of_delivery,
'x_fc_proof_of_delivery_filename': self.proof_of_delivery_filename,
})
# Create attachment for POD to post in chatter
pod_attachment = self.env['ir.attachment'].create({
'name': self.proof_of_delivery_filename or 'Proof_of_Delivery.pdf',
'datas': self.proof_of_delivery,
'res_model': 'sale.order',
'res_id': order.id,
})
# Post to chatter
from markupsafe import Markup
notes_html = ''
if self.notes:
notes_html = f'<p style="margin: 8px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>'
order.message_post(
body=Markup(
'<div style="background: #d4edda; border-left: 4px solid #28a745; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #28a745; margin: 0 0 8px 0;"><i class="fa fa-dollar"/> Ready to Bill</h4>'
f'<p style="margin: 0;"><strong>Delivery Date:</strong> {self.delivery_date.strftime("%B %d, %Y")}</p>'
f'<p style="margin: 4px 0 0 0;"><strong>Proof of Delivery:</strong> {self.proof_of_delivery_filename}</p>'
f'{notes_html}'
f'<p style="margin: 8px 0 0 0; color: #666; font-size: 0.9em;">Marked by {self.env.user.name}</p>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
attachment_ids=[pod_attachment.id],
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Ready to Bill'),
'message': _('Order marked as Ready to Bill. POD attached.'),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_ready_to_bill_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.ready.to.bill.wizard.form</field>
<field name="model">fusion_claims.ready.to.bill.wizard</field>
<field name="arch" type="xml">
<form string="Ready to Bill">
<sheet>
<h2>Mark as Ready to Bill</h2>
<!-- Instructions -->
<div class="alert alert-info" role="alert">
<strong><i class="fa fa-info-circle"/> Instructions:</strong>
<p class="mb-0">
Upload the Proof of Delivery document and confirm the delivery date
to mark this order as ready for billing.
</p>
</div>
<!-- Existing Data Info -->
<div class="alert alert-secondary" role="alert" invisible="not has_existing_pod">
<i class="fa fa-file-pdf-o"/>
<strong>Existing POD:</strong> <field name="existing_pod_filename" nolabel="1" class="d-inline"/>
<span class="text-muted ms-2">(will be replaced)</span>
</div>
<field name="has_existing_pod" invisible="1"/>
<field name="has_existing_date" invisible="1"/>
<field name="existing_pod_filename" invisible="1"/>
<field name="existing_delivery_date" invisible="1"/>
<group>
<group string="Delivery Information">
<field name="sale_order_id" readonly="1"/>
<field name="delivery_date" required="1"/>
</group>
<group string="Proof of Delivery">
<field name="proof_of_delivery"
filename="proof_of_delivery_filename"
widget="binary"
required="1"/>
<field name="proof_of_delivery_filename" invisible="1"/>
</group>
</group>
<group string="Notes (Optional)">
<field name="notes" nolabel="1" colspan="2"
placeholder="Any additional notes about the delivery..."/>
</group>
</sheet>
<footer>
<button name="action_mark_ready_to_bill" type="object"
string="Mark Ready to Bill" class="btn-primary"
icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,125 @@
# -*- 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.
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class SaleAdvancePaymentInv(models.TransientModel):
_inherit = 'sale.advance.payment.inv'
# Add new invoice type options for ADP
advance_payment_method = fields.Selection(
selection_add=[
('adp_client', 'ADP Client Invoice (25%)'),
('adp_portion', 'ADP Invoice (75%/100%)'),
],
ondelete={
'adp_client': 'set default',
'adp_portion': 'set default',
}
)
def _is_adp_sale_order(self):
"""Check if the current sale order(s) are ADP sales."""
sale_orders = self.env['sale.order'].browse(self._context.get('active_ids', []))
for order in sale_orders:
if hasattr(order, '_is_adp_sale') and order._is_adp_sale():
return True
return False
def _get_adp_client_type(self):
"""Get client type from the first ADP sale order."""
sale_orders = self.env['sale.order'].browse(self._context.get('active_ids', []))
for order in sale_orders:
if hasattr(order, '_get_client_type'):
return order._get_client_type()
return 'REG'
def create_invoices(self):
"""Override to handle ADP split invoices."""
if self.advance_payment_method == 'adp_client':
return self._create_adp_client_invoice()
elif self.advance_payment_method == 'adp_portion':
return self._create_adp_portion_invoice()
return super().create_invoices()
def _create_adp_client_invoice(self):
"""Create 25% client invoice for REG clients."""
sale_orders = self.env['sale.order'].browse(self._context.get('active_ids', []))
invoices = self.env['account.move']
for order in sale_orders:
if not hasattr(order, '_is_adp_sale') or not order._is_adp_sale():
raise UserError(_("Order %s is not an ADP sale. Cannot create ADP client invoice.") % order.name)
client_type = order._get_client_type() if hasattr(order, '_get_client_type') else 'REG'
if client_type != 'REG':
raise UserError(_(
"Order %s has client type '%s'. Only REG clients have a 25%% client portion. "
"Use 'ADP Invoice (75%%/100%%)' for this order."
) % (order.name, client_type))
invoice = order._create_adp_split_invoice(invoice_type='client')
if invoice:
invoices |= invoice
if not invoices:
raise UserError(_("No invoices were created. Check if the orders have a client portion."))
# Return action to view created invoices
if len(invoices) == 1:
return {
'name': _('Client Invoice (25%)'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'res_id': invoices.id,
'view_mode': 'form',
'target': 'current',
}
return {
'name': _('Client Invoices (25%)'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'domain': [('id', 'in', invoices.ids)],
'view_mode': 'list,form',
'target': 'current',
}
def _create_adp_portion_invoice(self):
"""Create 75%/100% ADP invoice."""
sale_orders = self.env['sale.order'].browse(self._context.get('active_ids', []))
invoices = self.env['account.move']
for order in sale_orders:
if not hasattr(order, '_is_adp_sale') or not order._is_adp_sale():
raise UserError(_("Order %s is not an ADP sale. Cannot create ADP invoice.") % order.name)
invoice = order._create_adp_split_invoice(invoice_type='adp')
if invoice:
invoices |= invoice
if not invoices:
raise UserError(_("No invoices were created."))
# Return action to view created invoices
if len(invoices) == 1:
return {
'name': _('ADP Invoice'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'res_id': invoices.id,
'view_mode': 'form',
'target': 'current',
}
return {
'name': _('ADP Invoices'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'domain': [('id', 'in', invoices.ids)],
'view_mode': 'list,form',
'target': 'current',
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from datetime import datetime, timedelta
from markupsafe import Markup
import logging
_logger = logging.getLogger(__name__)
class ScheduleAssessmentWizard(models.TransientModel):
"""Wizard to schedule an assessment and create a calendar event."""
_name = 'fusion_claims.schedule.assessment.wizard'
_description = 'Schedule Assessment Wizard'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
readonly=True,
)
# Assessment Details
assessment_date = fields.Date(
string='Assessment Date',
required=True,
default=fields.Date.context_today,
)
assessment_time = fields.Float(
string='Assessment Time',
required=True,
default=9.0, # 9:00 AM
help='Time in 24-hour format (e.g., 14.5 = 2:30 PM)',
)
duration = fields.Float(
string='Duration (hours)',
required=True,
default=2.0,
)
# Location and Notes
location = fields.Char(
string='Location',
help='Assessment location (client address, etc.)',
)
notes = fields.Text(
string='Notes',
help='Additional notes for the assessment',
)
# Attendees
assessor_id = fields.Many2one(
'res.users',
string='Assessor',
default=lambda self: self.env.user,
required=True,
help='User who will conduct the assessment',
)
# Calendar options
create_calendar_event = fields.Boolean(
string='Create Calendar Event',
default=True,
help='Create an event in Odoo calendar',
)
send_reminder = fields.Boolean(
string='Send Reminder',
default=True,
help='Send email reminder before the assessment',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self._context.get('active_id')
if active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
# Pre-fill location with client address if available
if order.partner_id:
partner = order.partner_id
address_parts = [
partner.street,
partner.street2,
partner.city,
partner.state_id.name if partner.state_id else None,
partner.zip,
]
res['location'] = ', '.join(filter(None, address_parts))
return res
def action_schedule(self):
"""Schedule the assessment and optionally create a calendar event."""
self.ensure_one()
order = self.sale_order_id
# Validate status
if order.x_fc_adp_application_status != 'quotation':
raise UserError("Can only schedule assessment from 'Quotation' status.")
# Calculate datetime
assessment_datetime = datetime.combine(
self.assessment_date,
datetime.min.time()
) + timedelta(hours=self.assessment_time)
# Create calendar event if requested
calendar_event = None
if self.create_calendar_event:
calendar_event = self._create_calendar_event(assessment_datetime)
# Update sale order
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'assessment_scheduled',
'x_fc_assessment_start_date': self.assessment_date,
})
# Post to chatter
time_str = self._format_time(self.assessment_time)
event_link = ''
if calendar_event:
event_link = f'<p style="margin: 4px 0 0 0;"><a href="/web#id={calendar_event.id}&model=calendar.event&view_type=form" target="_blank"><i class="fa fa-calendar"/> View Calendar Event</a></p>'
order.message_post(
body=Markup(
'<div style="background: #e8f4fd; border-left: 4px solid #17a2b8; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #17a2b8; margin: 0 0 8px 0;"><i class="fa fa-calendar"/> Assessment Scheduled</h4>'
f'<p style="margin: 0;"><strong>Date:</strong> {self.assessment_date.strftime("%B %d, %Y")}</p>'
f'<p style="margin: 4px 0 0 0;"><strong>Time:</strong> {time_str}</p>'
f'<p style="margin: 4px 0 0 0;"><strong>Duration:</strong> {self.duration} hour(s)</p>'
f'<p style="margin: 4px 0 0 0;"><strong>Assessor:</strong> {self.assessor_id.name}</p>'
f'{f"<p style=\'margin: 4px 0 0 0;\'><strong>Location:</strong> {self.location}</p>" if self.location else ""}'
f'{f"<p style=\'margin: 4px 0 0 0;\'><strong>Notes:</strong> {self.notes}</p>" if self.notes else ""}'
f'{event_link}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}
def _create_calendar_event(self, start_datetime):
"""Create a calendar event for the assessment."""
order = self.sale_order_id
end_datetime = start_datetime + timedelta(hours=self.duration)
# Build attendee list
partner_ids = [self.assessor_id.partner_id.id]
if order.partner_id:
partner_ids.append(order.partner_id.id)
event_vals = {
'name': f'ADP Assessment - {order.partner_id.name} ({order.name})',
'start': start_datetime,
'stop': end_datetime,
'allday': False,
'location': self.location or '',
'description': self._build_event_description(),
'partner_ids': [(6, 0, partner_ids)],
'user_id': self.assessor_id.id,
}
# Add alarm if reminder requested
if self.send_reminder:
# Find or create a 1-day email reminder
alarm = self.env['calendar.alarm'].search([
('alarm_type', '=', 'email'),
('duration', '=', 1),
('interval', '=', 'days'),
], limit=1)
if not alarm:
alarm = self.env['calendar.alarm'].create({
'name': '1 Day Before',
'alarm_type': 'email',
'duration': 1,
'interval': 'days',
})
event_vals['alarm_ids'] = [(6, 0, [alarm.id])]
return self.env['calendar.event'].create(event_vals)
def _build_event_description(self):
"""Build the calendar event description."""
order = self.sale_order_id
lines = [
f"ADP Assessment for {order.partner_id.name}",
f"Sale Order: {order.name}",
"",
]
if order.x_fc_reason_for_application:
reason_label = dict(order._fields['x_fc_reason_for_application'].selection or []).get(
order.x_fc_reason_for_application, 'N/A'
)
lines.append(f"Reason: {reason_label}")
if self.notes:
lines.append("")
lines.append(f"Notes: {self.notes}")
return '\n'.join(lines)
def _format_time(self, time_float):
"""Convert float time (e.g., 14.5) to readable format (2:30 PM)."""
hours = int(time_float)
minutes = int((time_float - hours) * 60)
period = 'AM' if hours < 12 else 'PM'
display_hours = hours if hours <= 12 else hours - 12
if display_hours == 0:
display_hours = 12
return f"{display_hours}:{minutes:02d} {period}"

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Schedule Assessment Wizard Form View -->
<record id="view_schedule_assessment_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.schedule.assessment.wizard.form</field>
<field name="model">fusion_claims.schedule.assessment.wizard</field>
<field name="arch" type="xml">
<form string="Schedule Assessment">
<group>
<group string="Assessment Details">
<field name="sale_order_id" invisible="1"/>
<field name="assessment_date"/>
<field name="assessment_time" widget="float_time"/>
<field name="duration" widget="float_time"/>
<field name="assessor_id"/>
</group>
<group string="Location &amp; Notes">
<field name="location"/>
<field name="notes"/>
</group>
</group>
<group>
<group string="Calendar Options">
<field name="create_calendar_event"/>
<field name="send_reminder" invisible="not create_calendar_event"/>
</group>
</group>
<footer>
<button name="action_schedule" type="object"
string="Schedule Assessment" class="btn-primary"
icon="fa-calendar-check-o"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action for the wizard -->
<record id="action_schedule_assessment_wizard" model="ir.actions.act_window">
<field name="name">Schedule Assessment</field>
<field name="res_model">fusion_claims.schedule.assessment.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,335 @@
# -*- 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 logging
_logger = logging.getLogger(__name__)
_DOC_NAMES = {
'x_fc_mod_drawing': 'Drawing',
'x_fc_mod_initial_photos': 'Assessment Photos',
'x_fc_mod_pca_document': 'Payment Commitment Agreement',
'x_fc_mod_proof_of_delivery': 'Proof of Delivery',
'x_fc_mod_completion_photos': 'Completion Photos',
}
class SendToModWizard(models.TransientModel):
_name = 'fusion_claims.send.to.mod.wizard'
_description = 'Send to March of Dimes Wizard'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
send_mode = fields.Selection([
('drawing', 'Submit Drawing and Quotation'),
('quotation', 'Re-send Quotation'),
('completion', 'Submit POD'),
], required=True, readonly=True)
# --- Recipients as contacts ---
recipient_ids = fields.Many2many(
'res.partner', 'send_mod_wizard_recipient_rel',
'wizard_id', 'partner_id', string='Send To',
)
cc_ids = fields.Many2many(
'res.partner', 'send_mod_wizard_cc_rel',
'wizard_id', 'partner_id', string='CC',
)
# --- Drawing mode uploads ---
drawing_file = fields.Binary(string='Drawing')
drawing_filename = fields.Char()
initial_photos_file = fields.Binary(string='Initial Photos')
initial_photos_filename = fields.Char()
# --- Quotation mode toggles ---
include_quotation = fields.Boolean(string='Quotation PDF', default=True)
include_drawing = fields.Boolean(string='Drawing', default=True)
include_initial_photos = fields.Boolean(string='Initial Photos', default=True)
# --- Completion mode uploads ---
completion_photos_file = fields.Binary(string='Completion Photos')
completion_photos_filename = fields.Char()
pod_file = fields.Binary(string='Proof of Delivery')
pod_filename = fields.Char()
additional_notes = fields.Text(string='Notes')
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if not self.env.context.get('active_id'):
return res
order = self.env['sale.order'].browse(self.env.context['active_id'])
res['sale_order_id'] = order.id
ICP = self.env['ir.config_parameter'].sudo()
mod_default_email = ICP.get_param('fusion_claims.mod_default_email', 'hvmp@marchofdimes.ca')
mode = self.env.context.get('mod_wizard_mode', 'quotation')
client = order.partner_id
authorizer = order.x_fc_authorizer_id
case_worker = order.x_fc_case_worker
# Find or create a MOD partner for the default email
mod_partner = self._get_mod_partner(mod_default_email)
if mode == 'drawing':
res['send_mode'] = 'drawing'
# To: Client. CC: MOD + Authorizer
res['recipient_ids'] = [(6, 0, [client.id])] if client else [(6, 0, [])]
cc_ids = []
if mod_partner:
cc_ids.append(mod_partner.id)
if authorizer:
cc_ids.append(authorizer.id)
res['cc_ids'] = [(6, 0, cc_ids)]
# Pre-load files
if order.x_fc_mod_drawing:
res['drawing_file'] = order.x_fc_mod_drawing
res['drawing_filename'] = order.x_fc_mod_drawing_filename
if order.x_fc_mod_initial_photos:
res['initial_photos_file'] = order.x_fc_mod_initial_photos
res['initial_photos_filename'] = order.x_fc_mod_initial_photos_filename
elif mode == 'completion':
res['send_mode'] = 'completion'
# To: Case worker. CC: Authorizer
to_ids = []
if case_worker:
to_ids.append(case_worker.id)
elif mod_partner:
to_ids.append(mod_partner.id)
res['recipient_ids'] = [(6, 0, to_ids)]
cc_ids = []
if authorizer:
cc_ids.append(authorizer.id)
res['cc_ids'] = [(6, 0, cc_ids)]
if order.x_fc_mod_completion_photos:
res['completion_photos_file'] = order.x_fc_mod_completion_photos
res['completion_photos_filename'] = order.x_fc_mod_completion_photos_filename
if order.x_fc_mod_proof_of_delivery:
res['pod_file'] = order.x_fc_mod_proof_of_delivery
res['pod_filename'] = order.x_fc_mod_pod_filename
else:
res['send_mode'] = 'quotation'
res['recipient_ids'] = [(6, 0, [client.id])] if client else [(6, 0, [])]
cc_ids = []
if mod_partner:
cc_ids.append(mod_partner.id)
if authorizer:
cc_ids.append(authorizer.id)
res['cc_ids'] = [(6, 0, cc_ids)]
return res
def _get_mod_partner(self, email):
"""Find or create a partner for the MOD default email."""
if not email:
return None
partner = self.env['res.partner'].sudo().search([('email', '=', email)], limit=1)
if not partner:
partner = self.env['res.partner'].sudo().create({
'name': 'March of Dimes Canada (HVMP)',
'email': email,
'is_company': True,
'company_type': 'company',
})
return partner
def action_preview_quotation(self):
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': f'/report/pdf/fusion_claims.report_mod_quotation/{self.sale_order_id.id}',
'target': 'new',
}
def _pro_name(self, field_name, order, orig_filename=None):
"""Professional attachment name."""
client = order.partner_id.name or 'Client'
client_clean = client.replace(' ', '_').replace(',', '')
base = _DOC_NAMES.get(field_name, field_name)
ext = 'pdf'
if orig_filename and '.' in orig_filename:
ext = orig_filename.rsplit('.', 1)[-1].lower()
return f'{base} - {client_clean} - {order.name}.{ext}'
def _get_field_att(self, order, field_name):
att = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', order.id),
('res_field', '=', field_name),
], order='id desc', limit=1)
if att:
att.sudo().write({'name': self._pro_name(field_name, order, att.name)})
return att
def action_send(self):
self.ensure_one()
order = self.sale_order_id
client_name = order.partner_id.name or 'Client'
client_clean = client_name.replace(' ', '_').replace(',', '')
to_emails = [p.email for p in self.recipient_ids if p.email]
cc_emails = [p.email for p in self.cc_ids if p.email]
if order.user_id and order.user_id.email:
sr_email = order.user_id.email
if sr_email not in to_emails and sr_email not in cc_emails:
cc_emails.append(sr_email)
if not to_emails:
raise UserError(_("Please add at least one recipient."))
# --- Save files and change status ---
if self.send_mode == 'drawing':
if not self.drawing_file:
raise UserError(_("Drawing is required."))
save = {
'x_fc_mod_drawing': self.drawing_file,
'x_fc_mod_drawing_filename': self.drawing_filename or f'Drawing - {client_name}.pdf',
}
if self.initial_photos_file:
save['x_fc_mod_initial_photos'] = self.initial_photos_file
save['x_fc_mod_initial_photos_filename'] = (
self.initial_photos_filename or f'Assessment Photos - {client_name}.jpg')
order.with_context(skip_all_validations=True).write(save)
order.write({
'x_fc_mod_status': 'quote_submitted',
'x_fc_mod_drawing_submitted_date': fields.Date.today(),
})
elif self.send_mode == 'completion':
if not self.completion_photos_file:
raise UserError(_("Completion Photos are required."))
if not self.pod_file:
raise UserError(_("Proof of Delivery is required."))
order.with_context(skip_all_validations=True).write({
'x_fc_mod_completion_photos': self.completion_photos_file,
'x_fc_mod_completion_photos_filename': (
self.completion_photos_filename or f'Completion Photos - {client_name}.jpg'),
'x_fc_mod_proof_of_delivery': self.pod_file,
'x_fc_mod_pod_filename': (
self.pod_filename or f'Proof of Delivery - {client_name}.pdf'),
})
order.write({
'x_fc_mod_status': 'pod_submitted',
'x_fc_mod_pod_submitted_date': fields.Date.today(),
})
# --- Collect attachments ---
att_ids = []
att_names = []
Att = self.env['ir.attachment'].sudo()
if self.send_mode in ('drawing', 'quotation'):
try:
report = self.env.ref('fusion_claims.action_report_mod_quotation')
pdf, _ = report._render_qweb_pdf(report.id, [order.id])
a = Att.create({
'name': f'Quotation - {client_clean} - {order.name}.pdf',
'type': 'binary', 'datas': base64.b64encode(pdf),
'res_model': 'sale.order', 'res_id': order.id,
'mimetype': 'application/pdf',
})
att_ids.append(a.id)
att_names.append(a.name)
except Exception as e:
_logger.error(f"Quotation PDF failed: {e}")
if self.send_mode == 'drawing' or self.include_drawing:
a = self._get_field_att(order, 'x_fc_mod_drawing')
if a:
att_ids.append(a.id)
att_names.append(a.name)
if self.send_mode == 'drawing' or self.include_initial_photos:
a = self._get_field_att(order, 'x_fc_mod_initial_photos')
if a:
att_ids.append(a.id)
att_names.append(a.name)
elif self.send_mode == 'completion':
a = self._get_field_att(order, 'x_fc_mod_completion_photos')
if a:
att_ids.append(a.id)
att_names.append(a.name)
a = self._get_field_att(order, 'x_fc_mod_proof_of_delivery')
if a:
att_ids.append(a.id)
att_names.append(a.name)
if not att_ids:
raise UserError(_("No documents to send."))
# --- Build email ---
sender = (order.user_id or self.env.user).name
if self.send_mode in ('drawing', 'quotation'):
title = 'Accessibility Modification Proposal'
summary = (
f'Dear <strong>{client_name}</strong>,<br/><br/>'
f'Please find attached the quotation, drawing and assessment photos for your '
f'accessibility modification project. Please review and let us know if you '
f'have any questions.')
else:
title = 'Completion Photos and Proof of Delivery'
summary = (
f'Please find attached the completion photos and signed proof of delivery '
f'for <strong>{client_name}</strong>.')
body = order._mod_email_build(
title=title, summary=summary,
email_type='info' if self.send_mode != 'completion' else 'success',
sections=[('Project Details', order._build_mod_case_detail_rows(
include_amounts=self.send_mode in ('drawing', 'quotation')))],
note=self.additional_notes or None,
attachments_note=', '.join(att_names),
sender_name=sender,
)
prefixes = {
'drawing': 'Quotation and Drawing',
'quotation': 'Quotation and Documents',
'completion': 'Completion Photos and Proof of Delivery',
}
ref = order.x_fc_case_reference
subj = f'{prefixes[self.send_mode]} - {client_name} - {order.name}'
if ref:
subj = f'{prefixes[self.send_mode]} - {ref} - {client_name}'
try:
self.env['mail.mail'].sudo().create({
'subject': subj,
'body_html': body,
'email_to': ', '.join(to_emails),
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'sale.order',
'res_id': order.id,
'attachment_ids': [(6, 0, att_ids)],
}).send()
to_str = ', '.join(to_emails)
cc_str = ', '.join(cc_emails) if cc_emails else None
order._email_chatter_log(
f'{prefixes[self.send_mode]} sent', to_str, cc_str,
[f'Documents: {", ".join(att_names)}'],
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Sent',
'message': f'Documents sent to {to_str}',
'type': 'success', 'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}
except Exception as e:
_logger.error(f"Send failed for {order.name}: {e}")
raise UserError(_(f"Failed to send: {e}"))

View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_send_to_mod_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.send.to.mod.wizard.form</field>
<field name="model">fusion_claims.send.to.mod.wizard</field>
<field name="arch" type="xml">
<form string="Send to March of Dimes">
<field name="send_mode" invisible="1"/>
<field name="sale_order_id" invisible="1"/>
<!-- Mode banners -->
<div class="alert alert-info mb-3" invisible="send_mode != 'drawing'">
<i class="fa fa-pencil-square-o"/>
Attach drawing and photos, preview the quotation, then send to the client.
</div>
<div class="alert alert-info mb-3" invisible="send_mode != 'quotation'">
<i class="fa fa-paper-plane"/> Re-send quotation documents.
</div>
<div class="alert alert-success mb-3" invisible="send_mode != 'completion'">
<i class="fa fa-camera"/>
Attach completion photos and proof of delivery, then send to the case worker.
</div>
<!-- Recipients -->
<group>
<field name="recipient_ids" widget="many2many_tags"
string="To"
options="{'no_create': False, 'no_quick_create': False}"
placeholder="Select recipients..."/>
<field name="cc_ids" widget="many2many_tags"
string="CC"
options="{'no_create': False, 'no_quick_create': False}"
placeholder="Select CC contacts..."/>
</group>
<separator string="Documents"/>
<!-- DRAWING MODE -->
<group invisible="send_mode != 'drawing'" col="2">
<group string="Drawing (required)">
<field name="drawing_file" filename="drawing_filename"
required="send_mode == 'drawing'" string="File"/>
<field name="drawing_filename" invisible="1"/>
</group>
<group string="Initial Photos">
<field name="initial_photos_file" filename="initial_photos_filename"
string="File"/>
<field name="initial_photos_filename" invisible="1"/>
</group>
</group>
<!-- QUOTATION MODE -->
<group invisible="send_mode != 'quotation'" col="4">
<field name="include_quotation" widget="boolean_toggle"/>
<field name="include_drawing" widget="boolean_toggle"/>
<field name="include_initial_photos" widget="boolean_toggle"/>
</group>
<!-- COMPLETION MODE -->
<group invisible="send_mode != 'completion'" col="2">
<group string="Completion Photos (required)">
<field name="completion_photos_file" filename="completion_photos_filename"
required="send_mode == 'completion'" string="File"/>
<field name="completion_photos_filename" invisible="1"/>
</group>
<group string="Proof of Delivery (required)">
<field name="pod_file" filename="pod_filename"
required="send_mode == 'completion'" string="File"/>
<field name="pod_filename" invisible="1"/>
</group>
</group>
<group>
<field name="additional_notes" placeholder="Optional notes..."/>
</group>
<footer>
<button name="action_preview_quotation" type="object"
class="btn-secondary" icon="fa-eye"
string="Preview Quotation"
invisible="send_mode == 'completion'"/>
<button name="action_send" type="object"
class="btn-primary" icon="fa-paper-plane">
<span invisible="send_mode != 'drawing'">Send Quotation</span>
<span invisible="send_mode != 'quotation'">Send</span>
<span invisible="send_mode != 'completion'">Submit POD</span>
</button>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_send_to_mod_wizard" model="ir.actions.act_window">
<field name="name">Send to March of Dimes</field>
<field name="res_model">fusion_claims.send.to.mod.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,410 @@
# -*- coding: utf-8 -*-
from markupsafe import Markup
from odoo import api, fields, models
class StatusChangeReasonWizard(models.TransientModel):
"""Wizard to capture reason when changing to specific statuses."""
_name = 'fusion.status.change.reason.wizard'
_description = 'Status Change Reason Wizard'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
ondelete='cascade',
)
new_status = fields.Selection(
selection=[
('rejected', 'Rejected by ADP'), # New: Initial rejection (within 24 hours)
('denied', 'Application Denied'), # Funding denied (after 2-3 weeks)
('withdrawn', 'Application Withdrawn'),
('on_hold', 'On Hold'),
('cancelled', 'Cancelled'),
('needs_correction', 'Application Needs Correction'),
],
string='New Status',
required=True,
)
# ==========================================================================
# REJECTION REASON FIELDS (for 'rejected' status - initial submission rejection)
# ==========================================================================
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',
help='Select the reason ADP rejected the submission',
)
# ==========================================================================
# DENIAL REASON FIELDS (for 'denied' status - funding denial after review)
# ==========================================================================
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',
help='Select the reason ADP denied the funding',
)
reason = fields.Text(
string='Reason / Additional Details',
help='Please provide additional details for this status change.',
)
# For on_hold: track the previous status
previous_status = fields.Char(
string='Previous Status',
readonly=True,
)
# Computed field to determine if reason is required
reason_required = fields.Boolean(
compute='_compute_reason_required',
)
@api.depends('new_status', 'rejection_reason', 'denial_reason')
def _compute_reason_required(self):
"""Reason text is required for 'other' selections or non-rejection/denial statuses."""
for wizard in self:
if wizard.new_status == 'rejected':
# Reason required if rejection_reason is 'other'
wizard.reason_required = wizard.rejection_reason == 'other'
elif wizard.new_status == 'denied':
# Reason required if denial_reason is 'other'
wizard.reason_required = wizard.denial_reason == 'other'
else:
# For other statuses (on_hold, cancelled, etc.), reason is always required
wizard.reason_required = True
@api.model
def default_get(self, fields_list):
"""Set defaults from context."""
res = super().default_get(fields_list)
if self.env.context.get('active_model') == 'sale.order':
order_id = self.env.context.get('active_id')
if order_id:
order = self.env['sale.order'].browse(order_id)
res['sale_order_id'] = order_id
res['previous_status'] = order.x_fc_adp_application_status
if self.env.context.get('default_new_status'):
res['new_status'] = self.env.context.get('default_new_status')
return res
def _get_status_label(self, status):
"""Get human-readable label for status."""
labels = {
'rejected': 'Rejected by ADP',
'denied': 'Application Denied',
'withdrawn': 'Application Withdrawn',
'on_hold': 'On Hold',
'cancelled': 'Cancelled',
'needs_correction': 'Application Needs Correction',
}
return labels.get(status, status)
def _get_status_icon(self, status):
"""Get FontAwesome icon for status."""
icons = {
'rejected': 'fa-times',
'denied': 'fa-times-circle',
'withdrawn': 'fa-undo',
'on_hold': 'fa-pause-circle',
'cancelled': 'fa-ban',
'needs_correction': 'fa-exclamation-triangle',
}
return icons.get(status, 'fa-info-circle')
def _get_rejection_reason_label(self, reason):
"""Get human-readable label for 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',
}
return labels.get(reason, reason)
def _get_denial_reason_label(self, reason):
"""Get human-readable label for denial reason."""
labels = {
'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',
}
return labels.get(reason, reason)
def action_confirm(self):
"""Confirm status change and post reason to chatter."""
self.ensure_one()
order = self.sale_order_id
new_status = self.new_status
reason = self.reason or ''
# Build chatter message
status_label = self._get_status_label(new_status)
icon = self._get_status_icon(new_status)
user_name = self.env.user.name
change_date = fields.Date.today().strftime('%B %d, %Y')
# Color scheme for different status types
status_colors = {
'rejected': ('#e74c3c', '#fff5f5', '#f5c6cb'), # Red (lighter)
'denied': ('#dc3545', '#fff5f5', '#f5c6cb'), # Red
'withdrawn': ('#6c757d', '#f8f9fa', '#dee2e6'), # Gray
'on_hold': ('#fd7e14', '#fff8f0', '#ffecd0'), # Orange
'cancelled': ('#dc3545', '#fff5f5', '#f5c6cb'), # Red
'needs_correction': ('#ffc107', '#fffbf0', '#ffeeba'), # Yellow
}
header_color, bg_color, border_color = status_colors.get(new_status, ('#17a2b8', '#f0f9ff', '#bee5eb'))
# For on_hold, also store the previous status and hold date
update_vals = {'x_fc_adp_application_status': new_status}
# =================================================================
# REJECTED: ADP rejected submission (within 24 hours)
# =================================================================
if new_status == 'rejected':
rejection_reason = self.rejection_reason
rejection_label = self._get_rejection_reason_label(rejection_reason)
# Store rejection details in sale order
current_count = order.x_fc_rejection_count or 0
update_vals.update({
'x_fc_rejection_reason': rejection_reason,
'x_fc_rejection_reason_other': reason if rejection_reason == 'other' else False,
'x_fc_rejection_date': fields.Date.today(),
'x_fc_rejection_count': current_count + 1,
})
# Build rejection message
details_html = ''
if rejection_reason == 'other' and reason:
details_html = f'<p><strong>Details:</strong> {reason}</p>'
message_body = f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa {icon}"></i> Submission Rejected by ADP</h5>
<ul>
<li><strong>Rejection #:</strong> {current_count + 1}</li>
<li><strong>Reason:</strong> {rejection_label}</li>
<li><strong>Date:</strong> {change_date}</li>
<li><strong>Recorded By:</strong> {user_name}</li>
</ul>
{details_html}
<hr>
<p class="mb-0"><i class="fa fa-info-circle"></i> <strong>Next Step:</strong> Correct the issue and resubmit the application.</p>
</div>
'''
# =================================================================
# DENIED: Funding denied by ADP (after 2-3 weeks review)
# =================================================================
elif new_status == 'denied':
denial_reason = self.denial_reason
denial_label = self._get_denial_reason_label(denial_reason)
# Store denial details in sale order
update_vals.update({
'x_fc_denial_reason': denial_reason,
'x_fc_denial_reason_other': reason if denial_reason == 'other' else False,
'x_fc_denial_date': fields.Date.today(),
})
# Build denial message
details_html = ''
if denial_reason == 'other' and reason:
details_html = f'<p><strong>Details:</strong> {reason}</p>'
message_body = f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa {icon}"></i> Funding Denied by ADP</h5>
<ul>
<li><strong>Denial Reason:</strong> {denial_label}</li>
<li><strong>Date:</strong> {change_date}</li>
<li><strong>Recorded By:</strong> {user_name}</li>
</ul>
{details_html}
</div>
'''
# =================================================================
# ON HOLD: Application put on hold
# Message is posted by _send_on_hold_email() to avoid duplicates
# =================================================================
elif new_status == 'on_hold':
update_vals['x_fc_on_hold_date'] = fields.Date.today()
update_vals['x_fc_previous_status_before_hold'] = self.previous_status
# Don't post message here - _send_on_hold_email() will post the message
message_body = None
elif new_status == 'withdrawn':
# Don't post message here - _send_withdrawal_email() will post the message
message_body = None
elif new_status == 'cancelled':
# Cancelled has its own detailed message posted later
message_body = None
else:
message_body = f'''
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading"><i class="fa {icon}"></i> Status Changed: {status_label}</h5>
<ul>
<li><strong>Changed By:</strong> {user_name}</li>
<li><strong>Date:</strong> {change_date}</li>
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
</div>
'''
# Post to chatter (except for cancelled which has its own detailed message)
if message_body:
order.message_post(
body=Markup(message_body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Update the status (this will trigger the write() method)
# Use context to skip the wizard trigger and skip auto-email for needs_correction
# (we send it below with the reason text)
write_ctx = {'skip_status_validation': True}
if new_status == 'needs_correction':
write_ctx['skip_correction_email'] = True
order.with_context(**write_ctx).write(update_vals)
# =================================================================
# NEEDS CORRECTION: Send email with the reason from this wizard
# =================================================================
if new_status == 'needs_correction':
order._send_correction_needed_email(reason=reason)
# =================================================================
# WITHDRAWN: Send email notification to all parties
# =================================================================
if new_status == 'withdrawn':
order._send_withdrawal_email(reason=reason)
# =================================================================
# ON HOLD: Send email notification to all parties
# =================================================================
if new_status == 'on_hold':
order._send_on_hold_email(reason=reason)
# =================================================================
# CANCELLED: Also cancel the sale order and all related invoices
# =================================================================
if new_status == 'cancelled':
cancelled_invoices = []
cancelled_so = False
user_name = self.env.user.name
cancel_date = fields.Date.today().strftime('%B %d, %Y')
# Cancel related invoices first
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
for invoice in invoices:
try:
# Post cancellation reason to invoice chatter
inv_msg = Markup(f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa fa-ban"></i> Invoice Cancelled</h5>
<ul>
<li><strong>Related Order:</strong> {order.name}</li>
<li><strong>Cancelled By:</strong> {user_name}</li>
<li><strong>Date:</strong> {cancel_date}</li>
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
</div>
''')
invoice.message_post(
body=inv_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Cancel the invoice (button_cancel or button_draft then cancel)
if invoice.state == 'posted':
invoice.button_draft()
invoice.button_cancel()
cancelled_invoices.append(invoice.name)
except Exception as e:
warn_msg = Markup(f'''
<div class="alert alert-warning" role="alert">
<p class="mb-0"><i class="fa fa-exclamation-triangle"></i> <strong>Warning:</strong> Could not cancel invoice {invoice.name}</p>
<p class="mb-0 small">{str(e)}</p>
</div>
''')
order.message_post(
body=warn_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Cancel the sale order itself
if order.state not in ('cancel', 'done'):
try:
order._action_cancel()
cancelled_so = True
except Exception as e:
warn_msg = Markup(f'''
<div class="alert alert-warning" role="alert">
<p class="mb-0"><i class="fa fa-exclamation-triangle"></i> <strong>Warning:</strong> Could not cancel sale order</p>
<p class="mb-0 small">{str(e)}</p>
</div>
''')
order.message_post(
body=warn_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Build cancellation summary
invoice_list_html = ''
if cancelled_invoices:
invoice_items = ''.join([f'<li>{inv}</li>' for inv in cancelled_invoices])
invoice_list_html = f'<li><strong>Invoices Cancelled:</strong><ul>{invoice_items}</ul></li>'
# Post comprehensive summary to chatter
so_status = 'Cancelled' if cancelled_so else 'Not applicable'
summary_msg = Markup(f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa fa-ban"></i> Application Cancelled</h5>
<ul>
<li><strong>Cancelled By:</strong> {user_name}</li>
<li><strong>Date:</strong> {cancel_date}</li>
<li><strong>Sale Order:</strong> {so_status}</li>
{invoice_list_html}
</ul>
<hr>
<p class="mb-0"><strong>Reason for Cancellation:</strong> {reason}</p>
</div>
''')
order.message_post(
body=summary_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Status Change Reason Wizard Form View -->
<record id="view_status_change_reason_wizard_form" model="ir.ui.view">
<field name="name">fusion.status.change.reason.wizard.form</field>
<field name="model">fusion.status.change.reason.wizard</field>
<field name="arch" type="xml">
<form string="Status Change Reason">
<!-- Hidden fields -->
<field name="sale_order_id" invisible="1"/>
<field name="previous_status" invisible="1"/>
<field name="new_status" readonly="1" invisible="1"/>
<field name="reason_required" invisible="1"/>
<!-- Warning banner - REJECTED (new) -->
<div class="alert alert-danger mb-3 rounded-0" role="alert" invisible="new_status != 'rejected'"
style="margin: -16px -16px 16px -16px; padding: 12px 16px;">
<i class="fa fa-times me-2"/> ADP has <strong>Rejected</strong> this submission. Please select the rejection reason.
</div>
<!-- Warning banner - DENIED -->
<div class="alert alert-warning mb-3 rounded-0" role="alert" invisible="new_status != 'denied'"
style="margin: -16px -16px 16px -16px; padding: 12px 16px;">
<i class="fa fa-times-circle me-2"/> ADP has <strong>Denied</strong> funding for this application. Please select the denial reason.
</div>
<div class="alert alert-info mb-3 rounded-0" role="alert" invisible="new_status != 'withdrawn'"
style="margin: -16px -16px 16px -16px; padding: 12px 16px;">
<i class="fa fa-undo me-2"/> You are about to <strong>Withdraw</strong> this application. Please provide the reason for withdrawal.
</div>
<div class="alert alert-secondary mb-3 rounded-0" role="alert" invisible="new_status != 'on_hold'"
style="margin: -16px -16px 16px -16px; padding: 12px 16px;">
<i class="fa fa-pause-circle me-2"/> You are about to put this application <strong>On Hold</strong>. The application can be resumed later from the same point.
</div>
<div class="alert alert-danger mb-3 rounded-0" role="alert" invisible="new_status != 'cancelled'"
style="margin: -16px -16px 16px -16px; padding: 12px 16px;">
<i class="fa fa-ban me-2"/> You are about to <strong>Cancel</strong> this application. This is a terminal status and will also cancel the sale order and all related invoices.
</div>
<div class="alert alert-warning mb-3 rounded-0" role="alert" invisible="new_status != 'needs_correction'"
style="margin: -16px -16px 16px -16px; padding: 12px 16px;">
<i class="fa fa-exclamation-triangle me-2"/> This application <strong>Needs Correction</strong>. The submitted documents will be cleared.
</div>
<div class="px-3 pb-3">
<!-- REJECTION REASON (for 'rejected' status) -->
<group invisible="new_status != 'rejected'">
<group>
<field name="rejection_reason"
required="new_status == 'rejected'"
widget="radio"
options="{'horizontal': false}"/>
</group>
</group>
<!-- DENIAL REASON (for 'denied' status) -->
<group invisible="new_status != 'denied'">
<group>
<field name="denial_reason"
required="new_status == 'denied'"
widget="radio"
options="{'horizontal': false}"/>
</group>
</group>
<!-- Additional Details / Reason field -->
<label for="reason" class="fw-bold mb-2"
invisible="new_status in ('rejected', 'denied')">Reason</label>
<label for="reason" class="fw-bold mb-2"
invisible="new_status not in ('rejected', 'denied') or (rejection_reason != 'other' and denial_reason != 'other')">Additional Details (Required for "Other")</label>
<label for="reason" class="fw-bold mb-2"
invisible="new_status not in ('rejected', 'denied') or rejection_reason == 'other' or denial_reason == 'other'">Additional Details (Optional)</label>
<field name="reason"
placeholder="Please describe the reason or provide additional details..."
widget="text"
required="reason_required"
nolabel="1"
class="w-100"
style="min-height: 100px; width: 100%;"/>
</div>
<footer>
<button name="action_confirm" string="Confirm" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Server Actions to open wizard for each status -->
<!-- NEW: Rejected by ADP -->
<record id="action_set_status_rejected" model="ir.actions.act_window">
<field name="name">Mark as Rejected by ADP</field>
<field name="res_model">fusion.status.change.reason.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_new_status': 'rejected'}</field>
</record>
<record id="action_set_status_denied" model="ir.actions.act_window">
<field name="name">Mark as Denied</field>
<field name="res_model">fusion.status.change.reason.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_new_status': 'denied'}</field>
</record>
<record id="action_set_status_withdrawn" model="ir.actions.act_window">
<field name="name">Withdraw Application</field>
<field name="res_model">fusion.status.change.reason.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_new_status': 'withdrawn'}</field>
</record>
<record id="action_set_status_on_hold" model="ir.actions.act_window">
<field name="name">Put On Hold</field>
<field name="res_model">fusion.status.change.reason.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_new_status': 'on_hold'}</field>
</record>
<record id="action_set_status_cancelled" model="ir.actions.act_window">
<field name="name">Cancel Application</field>
<field name="res_model">fusion.status.change.reason.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_new_status': 'cancelled'}</field>
</record>
<record id="action_set_status_needs_correction" model="ir.actions.act_window">
<field name="name">Request Correction</field>
<field name="res_model">fusion.status.change.reason.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_new_status': 'needs_correction'}</field>
</record>
</odoo>

View File

@@ -0,0 +1,397 @@
# -*- 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.
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import json
import logging
_logger = logging.getLogger(__name__)
class SubmissionVerificationWizard(models.TransientModel):
"""Wizard to verify which device types are being submitted in the ADP application.
This is Stage 1 of the two-stage verification system:
- Stage 1 (Submission): Verify what device types are being applied for
- Stage 2 (Approval): Verify what device types were approved by ADP
"""
_name = 'fusion_claims.submission.verification.wizard'
_description = 'ADP Submission Verification Wizard'
# ==========================================================================
# MAIN FIELDS
# ==========================================================================
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
readonly=True,
)
line_ids = fields.One2many(
'fusion_claims.submission.verification.wizard.line',
'wizard_id',
string='Device Type Lines',
)
# Store device type mapping as JSON - needed because readonly fields aren't sent back
# Format: {"line_index": "device_type_name", ...}
device_type_mapping = fields.Text(
string='Device Type Mapping (JSON)',
help='Internal field to store device type names by line index',
)
# Summary fields
total_device_types = fields.Integer(
string='Total Device Types',
compute='_compute_summary',
)
selected_device_types = fields.Integer(
string='Selected Device Types',
compute='_compute_summary',
)
# ==========================================================================
# DOCUMENT UPLOAD FIELDS (for Submit Application mode)
# ==========================================================================
is_submit_mode = fields.Boolean(
string='Submit Mode',
default=False,
help='True if this wizard is being used to submit the application (not just verify)',
)
final_application = fields.Binary(
string='Final Submitted Application',
help='Upload the final application PDF being submitted to ADP',
)
final_application_filename = fields.Char(
string='Final Application Filename',
)
xml_file = fields.Binary(
string='XML Data File',
help='Upload the XML data file for ADP submission',
)
xml_filename = fields.Char(
string='XML Filename',
)
# ==========================================================================
# COMPUTED METHODS
# ==========================================================================
@api.depends('line_ids', 'line_ids.selected')
def _compute_summary(self):
for wizard in self:
wizard.total_device_types = len(wizard.line_ids)
wizard.selected_device_types = len(wizard.line_ids.filtered(lambda l: l.selected))
# ==========================================================================
# DEFAULT GET - Populate with device types from order
# ==========================================================================
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self._context.get('active_id')
if not active_id:
return res
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
# Set submit mode based on context
res['is_submit_mode'] = self._context.get('submit_application', False)
# Group order lines by device type
ADPDevice = self.env['fusion.adp.device.code'].sudo()
device_type_data = {} # {device_type: {'products': [], 'total_amount': 0}}
for so_line in order.order_line:
# Skip non-product lines
if so_line.display_type in ('line_section', 'line_note'):
continue
if not so_line.product_id or so_line.product_uom_qty <= 0:
continue
# Get device code and look up device type
device_code = so_line._get_adp_device_code()
if not device_code:
continue
adp_device = ADPDevice.search([
('device_code', '=', device_code),
('active', '=', True)
], limit=1)
if not adp_device:
continue
device_type = adp_device.device_type or 'Unknown'
if device_type not in device_type_data:
device_type_data[device_type] = {
'products': [],
'total_amount': 0,
'adp_portion': 0,
}
device_type_data[device_type]['products'].append({
'name': so_line.product_id.display_name,
'code': device_code,
'qty': so_line.product_uom_qty,
'price': so_line.price_subtotal,
})
device_type_data[device_type]['total_amount'] += so_line.price_subtotal
device_type_data[device_type]['adp_portion'] += so_line.x_fc_adp_portion
# Check if there are previously submitted device types
previous_selection = {}
if order.x_fc_submitted_device_types:
try:
previous_selection = json.loads(order.x_fc_submitted_device_types)
_logger.info(f"Loaded previous selection from order {order.id}: {previous_selection}")
except (json.JSONDecodeError, TypeError) as e:
_logger.warning(f"Failed to parse submitted_device_types for order {order.id}: {e}")
else:
_logger.info(f"No previous selection found for order {order.id}")
# Build wizard lines and device type mapping
lines_data = []
device_type_mapping = {} # {line_index: device_type_name}
for idx, (device_type, data) in enumerate(sorted(device_type_data.items())):
product_list = ', '.join([
f"{p['name']} ({p['code']}) x{p['qty']}"
for p in data['products']
])
# Check if previously selected
was_selected = previous_selection.get(device_type, False)
_logger.info(f"Device type '{device_type}' - was_selected: {was_selected} (previous_selection keys: {list(previous_selection.keys())})")
# Store mapping by index
device_type_mapping[str(idx)] = device_type
lines_data.append((0, 0, {
'device_type': device_type,
'product_details': product_list,
'product_count': len(data['products']),
'total_amount': data['total_amount'],
'adp_portion': data['adp_portion'],
'selected': was_selected, # Default to previously selected state
}))
res['line_ids'] = lines_data
res['device_type_mapping'] = json.dumps(device_type_mapping)
_logger.info(f"Created device_type_mapping: {device_type_mapping}")
return res
# ==========================================================================
# ACTION METHODS
# ==========================================================================
def action_confirm_submission(self):
"""Confirm the selected device types and store them for Stage 2 comparison."""
self.ensure_one()
# Load the device type mapping (stored during default_get)
# This is needed because readonly fields aren't sent back from the form
device_type_mapping = {}
if self.device_type_mapping:
try:
device_type_mapping = json.loads(self.device_type_mapping)
except (json.JSONDecodeError, TypeError):
pass
_logger.info(f"Loaded device_type_mapping: {device_type_mapping}")
# Get selected lines and map them to device types using the stored mapping
selected_device_types = []
for idx, line in enumerate(self.line_ids):
if line.selected:
# Get device type from mapping (use index as key)
device_type = device_type_mapping.get(str(idx))
if device_type:
selected_device_types.append(device_type)
_logger.info(f"Line {idx} selected - device_type from mapping: {device_type}")
else:
# Fallback to line.device_type (might be False due to readonly issue)
_logger.warning(f"Line {idx} selected but no mapping found, line.device_type={line.device_type}")
if line.device_type:
selected_device_types.append(line.device_type)
if not selected_device_types:
raise UserError(
"Please select at least one device type that is being submitted.\n\n"
"If none of these device types are being applied for, "
"please verify the order lines have the correct ADP device codes."
)
# Store the selection as JSON
selection_data = {
device_type: True for device_type in selected_device_types
}
_logger.info(f"Saving selection for order {self.sale_order_id.id}: {selection_data}")
# Update the sale order
self.sale_order_id.write({
'x_fc_submission_verified': True,
'x_fc_submitted_device_types': json.dumps(selection_data),
})
_logger.info(f"Saved x_fc_submitted_device_types: {json.dumps(selection_data)}")
# Post to chatter with nice card style
from markupsafe import Markup
from datetime import date
# Build HTML list from selected device types
device_list_html = ''.join([
f'<li>{device_type}</li>'
for device_type in selected_device_types
])
self.sale_order_id.message_post(
body=Markup(
'<div style="background: #e8f4fd; border-left: 4px solid #17a2b8; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #17a2b8; margin: 0 0 8px 0;"><i class="fa fa-check-square"/> Submission Verification Complete</h4>'
f'<p style="margin: 0;"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>'
'<p style="margin: 8px 0 4px 0;"><strong>Device Types:</strong></p>'
f'<ul style="margin: 0; padding-left: 20px;">{device_list_html}</ul>'
f'<p style="margin: 8px 0 0 0; color: #666; font-size: 0.9em;">Verified by {self.env.user.name}</p>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# If we're in submit mode (changing status to submitted), update status and save documents
if self.is_submit_mode:
# Validate documents are uploaded
if not self.final_application:
raise UserError("Please upload the Final Submitted Application PDF.")
if not self.xml_file:
raise UserError("Please upload the XML Data File.")
# Validate file types
if self.final_application_filename and not self.final_application_filename.lower().endswith('.pdf'):
raise UserError(f"Final Application must be a PDF file. Uploaded: '{self.final_application_filename}'")
if self.xml_filename and not self.xml_filename.lower().endswith('.xml'):
raise UserError(f"XML file must have .xml extension. Uploaded: '{self.xml_filename}'")
# Determine target status (submitted or resubmitted)
current_status = self.sale_order_id.x_fc_adp_application_status
new_status = 'resubmitted' if current_status == 'needs_correction' else 'submitted'
# Update status and save documents with skip flag
self.sale_order_id.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': new_status,
'x_fc_final_submitted_application': self.final_application,
'x_fc_final_application_filename': self.final_application_filename,
'x_fc_xml_file': self.xml_file,
'x_fc_xml_filename': self.xml_filename,
})
# Post status change to chatter with nice card style
status_label = 'Resubmitted' if new_status == 'resubmitted' else 'Submitted'
self.sale_order_id.message_post(
body=Markup(
'<div style="background: #d4edda; border-left: 4px solid #28a745; padding: 12px; margin: 8px 0; border-radius: 4px;">'
f'<h4 style="color: #28a745; margin: 0 0 8px 0;"><i class="fa fa-paper-plane"/> Application {status_label}</h4>'
f'<p style="margin: 0;"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>'
'<p style="margin: 8px 0 4px 0;"><strong>Device Types Submitted:</strong></p>'
f'<ul style="margin: 0; padding-left: 20px;">{device_list_html}</ul>'
f'<p style="margin: 8px 0 0 0; color: #666; font-size: 0.9em;">Submitted by {self.env.user.name}</p>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {
'type': 'ir.actions.act_window_close',
'infos': {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Application Submitted'),
'message': _('Application submitted with %d device type(s).') % len(selected_device_types),
'type': 'success',
'sticky': False,
}
}
}
return {
'type': 'ir.actions.act_window_close',
'infos': {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Submission Verified'),
'message': _('%d device type(s) confirmed for submission.') % len(selected_device_types),
'type': 'success',
'sticky': False,
}
}
}
def action_select_all(self):
"""Select all device types."""
self.ensure_one()
for line in self.line_ids:
line.selected = True
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
class SubmissionVerificationWizardLine(models.TransientModel):
"""Lines for the submission verification wizard - grouped by device type."""
_name = 'fusion_claims.submission.verification.wizard.line'
_description = 'Submission Verification Wizard Line'
wizard_id = fields.Many2one(
'fusion_claims.submission.verification.wizard',
string='Wizard',
required=True,
ondelete='cascade',
)
device_type = fields.Char(
string='Device Type',
readonly=True,
)
product_details = fields.Text(
string='Products',
readonly=True,
help='List of products in this device type',
)
product_count = fields.Integer(
string='# Products',
readonly=True,
)
total_amount = fields.Monetary(
string='Total Amount',
readonly=True,
currency_field='currency_id',
)
adp_portion = fields.Monetary(
string='ADP Portion',
readonly=True,
currency_field='currency_id',
)
currency_id = fields.Many2one(
'res.currency',
related='wizard_id.sale_order_id.currency_id',
readonly=True,
)
selected = fields.Boolean(
string='Submitting',
default=False,
help='Check if this device type is being submitted in the ADP application',
)

View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!-- ===================================================================== -->
<!-- SUBMISSION VERIFICATION WIZARD FORM -->
<!-- ===================================================================== -->
<record id="view_submission_verification_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.submission.verification.wizard.form</field>
<field name="model">fusion_claims.submission.verification.wizard</field>
<field name="arch" type="xml">
<form string="ADP Submission Verification">
<field name="sale_order_id" invisible="1"/>
<sheet>
<div class="oe_title mb-3">
<h2>
<i class="fa fa-clipboard-check text-primary"/>
Stage 1: Submission Verification
</h2>
</div>
<div class="alert alert-info mb-4" role="alert">
<p class="mb-0">
<strong>Instructions:</strong> Select which device types are being submitted
in the ADP application. This selection will be compared against the
approval letter during Stage 2 verification.
</p>
</div>
<group col="4" class="mb-4">
<group colspan="2">
<div class="d-flex align-items-center">
<span class="badge bg-secondary fs-5 me-2">
<field name="total_device_types" nolabel="1"/>
</span>
<span class="text-muted">Total Device Types</span>
</div>
</group>
<group colspan="2">
<div class="d-flex align-items-center">
<span class="badge bg-success fs-5 me-2">
<field name="selected_device_types" nolabel="1"/>
</span>
<span class="text-muted">Selected for Submission</span>
</div>
</group>
</group>
<field name="is_submit_mode" invisible="1"/>
<field name="device_type_mapping" invisible="1"/>
<notebook>
<page string="Device Types" name="device_types">
<field name="line_ids" nolabel="1">
<list string="Device Types" editable="bottom" create="0" delete="0"
decoration-success="selected" decoration-muted="not selected">
<field name="selected" string="Submit" widget="boolean_toggle"/>
<field name="device_type" string="Device Type" readonly="1" force_save="1"/>
<field name="product_count" string="Products" readonly="1" force_save="1"/>
<field name="adp_portion" string="ADP Portion" widget="monetary" readonly="1" force_save="1"/>
<field name="total_amount" string="Total Amount" widget="monetary" readonly="1" force_save="1"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
</page>
<page string="Product Details" name="product_details">
<field name="line_ids" nolabel="1" readonly="1">
<list string="Product Details" create="0" delete="0"
decoration-success="selected" decoration-muted="not selected">
<field name="selected" string="✓" readonly="1"/>
<field name="device_type" string="Device Type"/>
<field name="product_details" string="Products Included"/>
<field name="currency_id" column_invisible="1"/>
</list>
</field>
</page>
<!-- Document Upload tab - only visible in Submit Application mode -->
<page string="Upload Documents" name="documents" invisible="not is_submit_mode">
<div class="alert alert-warning mb-3" role="alert">
<strong><i class="fa fa-upload"/> Upload Required Documents</strong>
<p class="mb-0">Please upload the final application and XML file for ADP submission.</p>
</div>
<group>
<group string="Final Application (PDF)">
<field name="final_application" filename="final_application_filename"
widget="binary" required="is_submit_mode"/>
<field name="final_application_filename" invisible="1"/>
</group>
<group string="XML Data File">
<field name="xml_file" filename="xml_filename"
widget="binary" required="is_submit_mode"/>
<field name="xml_filename" invisible="1"/>
</group>
</group>
</page>
</notebook>
</sheet>
<footer>
<button name="action_select_all" type="object"
string="Select All" class="btn-outline-secondary"
icon="fa-check-square-o"/>
<button name="action_confirm_submission" type="object"
string="Confirm Submission" class="btn-primary"
icon="fa-paper-plane"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- ===================================================================== -->
<!-- ACTION TO OPEN WIZARD -->
<!-- ===================================================================== -->
<record id="action_submission_verification_wizard" model="ir.actions.act_window">
<field name="name">Verify Submission</field>
<field name="res_model">fusion_claims.submission.verification.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_view_types">form</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FusionXmlImportWizard(models.TransientModel):
_name = 'fusion.xml.import.wizard'
_description = 'Import ADP XML Files'
xml_files = fields.Many2many(
'ir.attachment', string='XML Files',
help='Select one or more ADP XML data files to import',
)
result_message = fields.Text(string='Import Results', readonly=True)
state = fields.Selection([
('draft', 'Upload'),
('done', 'Done'),
], default='draft')
def action_import(self):
"""Process uploaded XML files and create client profiles."""
self.ensure_one()
if not self.xml_files:
raise UserError('Please upload at least one XML file.')
parser = self.env['fusion.xml.parser']
created = 0
updated = 0
errors = []
for attachment in self.xml_files:
try:
xml_content = base64.b64decode(attachment.datas).decode('utf-8')
# Check if profile already exists by parsing health card from XML
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(xml_content)
except ET.ParseError:
errors.append(f'{attachment.name}: Could not parse XML')
continue
form = root.find('Form')
if form is None:
errors.append(f'{attachment.name}: No Form element in XML')
continue
s1 = form.find('section1')
health_card = ''
if s1 is not None:
hc_el = s1.find('healthNo')
if hc_el is not None and hc_el.text:
health_card = hc_el.text.strip()
existing = False
if health_card:
existing = self.env['fusion.client.profile'].search([
('health_card_number', '=', health_card),
], limit=1)
profile, app_data = parser.parse_and_create(xml_content)
if not profile:
errors.append(f'{attachment.name}: Could not create profile')
continue
if existing:
updated += 1
else:
created += 1
_logger.info(
'Imported XML %s -> Profile: %s (ID: %s)',
attachment.name, profile.display_name, profile.id,
)
except Exception as e:
errors.append(f'{attachment.name}: {str(e)}')
_logger.exception('Error importing XML file %s', attachment.name)
# Build result message
lines = [
f'Import Complete!',
f'- Profiles created: {created}',
f'- Profiles updated: {updated}',
f'- Total files processed: {created + updated}',
]
if errors:
lines.append(f'\nErrors ({len(errors)}):')
for err in errors:
lines.append(f' - {err}')
self.result_message = '\n'.join(lines)
self.state = 'done'
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.xml.import.wizard',
'view_mode': 'form',
'res_id': self.id,
'target': 'new',
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fusion_xml_import_wizard_form" model="ir.ui.view">
<field name="name">fusion.xml.import.wizard.form</field>
<field name="model">fusion.xml.import.wizard</field>
<field name="arch" type="xml">
<form string="Import ADP XML Files">
<group invisible="state != 'draft'">
<group>
<field name="xml_files" widget="many2many_binary" string="XML Files"/>
</group>
<div class="alert alert-info" role="alert">
<strong>Import ADP XML Data Files</strong>
<p class="mb-0">
Upload one or more XML data files exported from ADP applications.
The system will parse each file and create or update client profiles
with personal information, medical conditions, device details, and
authorizer information.
</p>
</div>
</group>
<group invisible="state != 'done'">
<field name="result_message" widget="text" nolabel="1" colspan="2"/>
</group>
<field name="state" invisible="1"/>
<footer>
<button string="Import" name="action_import" type="object"
class="btn-primary" invisible="state != 'draft'"/>
<button string="Close" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_xml_import_wizard" model="ir.actions.act_window">
<field name="name">Import XML Files</field>
<field name="res_model">fusion.xml.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>