Initial commit
This commit is contained in:
32
fusion_claims/wizard/__init__.py
Normal file
32
fusion_claims/wizard/__init__.py
Normal 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
|
||||
79
fusion_claims/wizard/account_payment_register.py
Normal file
79
fusion_claims/wizard/account_payment_register.py
Normal 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
|
||||
27
fusion_claims/wizard/account_payment_register_views.xml
Normal file
27
fusion_claims/wizard/account_payment_register_views.xml
Normal 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>
|
||||
617
fusion_claims/wizard/adp_export_wizard.py
Normal file
617
fusion_claims/wizard/adp_export_wizard.py
Normal 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
|
||||
71
fusion_claims/wizard/adp_export_wizard_views.xml
Normal file
71
fusion_claims/wizard/adp_export_wizard_views.xml
Normal 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>
|
||||
130
fusion_claims/wizard/application_received_wizard.py
Normal file
130
fusion_claims/wizard/application_received_wizard.py
Normal 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'}
|
||||
51
fusion_claims/wizard/application_received_wizard_views.xml
Normal file
51
fusion_claims/wizard/application_received_wizard_views.xml
Normal 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 & 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>
|
||||
82
fusion_claims/wizard/assessment_completed_wizard.py
Normal file
82
fusion_claims/wizard/assessment_completed_wizard.py
Normal 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'}
|
||||
31
fusion_claims/wizard/assessment_completed_wizard_views.xml
Normal file
31
fusion_claims/wizard/assessment_completed_wizard_views.xml
Normal 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>
|
||||
211
fusion_claims/wizard/case_close_verification_wizard.py
Normal file
211
fusion_claims/wizard/case_close_verification_wizard.py
Normal 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',
|
||||
}
|
||||
@@ -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 & 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>
|
||||
716
fusion_claims/wizard/device_approval_wizard.py
Normal file
716
fusion_claims/wizard/device_approval_wizard.py
Normal 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)
|
||||
136
fusion_claims/wizard/device_approval_wizard_views.xml
Normal file
136
fusion_claims/wizard/device_approval_wizard_views.xml
Normal 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 & 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 & 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>
|
||||
191
fusion_claims/wizard/device_import_wizard.py
Normal file
191
fusion_claims/wizard/device_import_wizard.py
Normal 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',
|
||||
}
|
||||
|
||||
64
fusion_claims/wizard/device_import_wizard_views.xml
Normal file
64
fusion_claims/wizard/device_import_wizard_views.xml
Normal 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>
|
||||
|
||||
460
fusion_claims/wizard/field_mapping_config_wizard.py
Normal file
460
fusion_claims/wizard/field_mapping_config_wizard.py
Normal 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'}
|
||||
97
fusion_claims/wizard/field_mapping_config_wizard_views.xml
Normal file
97
fusion_claims/wizard/field_mapping_config_wizard_views.xml
Normal 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 & 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 & 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 & 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>
|
||||
237
fusion_claims/wizard/loaner_checkout_wizard.py
Normal file
237
fusion_claims/wizard/loaner_checkout_wizard.py
Normal 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',
|
||||
}
|
||||
139
fusion_claims/wizard/loaner_return_wizard.py
Normal file
139
fusion_claims/wizard/loaner_return_wizard.py
Normal 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'}
|
||||
52
fusion_claims/wizard/mod_awaiting_funding_wizard.py
Normal file
52
fusion_claims/wizard/mod_awaiting_funding_wizard.py
Normal 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'}
|
||||
26
fusion_claims/wizard/mod_awaiting_funding_wizard_views.xml
Normal file
26
fusion_claims/wizard/mod_awaiting_funding_wizard_views.xml
Normal 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>
|
||||
74
fusion_claims/wizard/mod_funding_approved_wizard.py
Normal file
74
fusion_claims/wizard/mod_funding_approved_wizard.py
Normal 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)
|
||||
33
fusion_claims/wizard/mod_funding_approved_wizard_views.xml
Normal file
33
fusion_claims/wizard/mod_funding_approved_wizard_views.xml
Normal 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>
|
||||
304
fusion_claims/wizard/mod_pca_received_wizard.py
Normal file
304
fusion_claims/wizard/mod_pca_received_wizard.py
Normal 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'&model=account.move&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'&model=account.move&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,
|
||||
}
|
||||
69
fusion_claims/wizard/mod_pca_received_wizard_views.xml
Normal file
69
fusion_claims/wizard/mod_pca_received_wizard_views.xml
Normal 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>
|
||||
395
fusion_claims/wizard/odsp_discretionary_wizard.py
Normal file
395
fusion_claims/wizard/odsp_discretionary_wizard.py
Normal 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,
|
||||
},
|
||||
}
|
||||
67
fusion_claims/wizard/odsp_discretionary_wizard_views.xml
Normal file
67
fusion_claims/wizard/odsp_discretionary_wizard_views.xml
Normal 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 & 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 & 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>
|
||||
70
fusion_claims/wizard/odsp_pre_approved_wizard.py
Normal file
70
fusion_claims/wizard/odsp_pre_approved_wizard.py
Normal 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'}
|
||||
23
fusion_claims/wizard/odsp_pre_approved_wizard_views.xml
Normal file
23
fusion_claims/wizard/odsp_pre_approved_wizard_views.xml
Normal 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 & Upload" class="btn-primary"
|
||||
icon="fa-upload"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
258
fusion_claims/wizard/odsp_ready_delivery_wizard.py
Normal file
258
fusion_claims/wizard/odsp_ready_delivery_wizard.py
Normal 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,
|
||||
},
|
||||
}
|
||||
49
fusion_claims/wizard/odsp_ready_delivery_wizard_views.xml
Normal file
49
fusion_claims/wizard/odsp_ready_delivery_wizard_views.xml
Normal 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 & Schedule Delivery" class="btn-primary"
|
||||
icon="fa-truck"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
560
fusion_claims/wizard/odsp_sa_mobility_wizard.py
Normal file
560
fusion_claims/wizard/odsp_sa_mobility_wizard.py
Normal 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
|
||||
142
fusion_claims/wizard/odsp_sa_mobility_wizard_views.xml
Normal file
142
fusion_claims/wizard/odsp_sa_mobility_wizard_views.xml
Normal 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 & Attach Form" class="btn-primary"/>
|
||||
<button name="action_fill_attach_and_send" type="object"
|
||||
string="Fill, Attach & 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>
|
||||
233
fusion_claims/wizard/odsp_submit_to_odsp_wizard.py
Normal file
233
fusion_claims/wizard/odsp_submit_to_odsp_wizard.py
Normal 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,
|
||||
},
|
||||
}
|
||||
56
fusion_claims/wizard/odsp_submit_to_odsp_wizard_views.xml
Normal file
56
fusion_claims/wizard/odsp_submit_to_odsp_wizard_views.xml
Normal 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 & 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>
|
||||
214
fusion_claims/wizard/ready_for_delivery_wizard.py
Normal file
214
fusion_claims/wizard/ready_for_delivery_wizard.py
Normal 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',
|
||||
)
|
||||
55
fusion_claims/wizard/ready_for_delivery_wizard_views.xml
Normal file
55
fusion_claims/wizard/ready_for_delivery_wizard_views.xml
Normal 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>
|
||||
196
fusion_claims/wizard/ready_for_submission_wizard.py
Normal file
196
fusion_claims/wizard/ready_for_submission_wizard.py
Normal 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'}
|
||||
96
fusion_claims/wizard/ready_for_submission_wizard_views.xml
Normal file
96
fusion_claims/wizard/ready_for_submission_wizard_views.xml
Normal 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>
|
||||
185
fusion_claims/wizard/ready_to_bill_wizard.py
Normal file
185
fusion_claims/wizard/ready_to_bill_wizard.py
Normal 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'},
|
||||
},
|
||||
}
|
||||
61
fusion_claims/wizard/ready_to_bill_wizard_views.xml
Normal file
61
fusion_claims/wizard/ready_to_bill_wizard_views.xml
Normal 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>
|
||||
125
fusion_claims/wizard/sale_advance_payment_inv.py
Normal file
125
fusion_claims/wizard/sale_advance_payment_inv.py
Normal 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',
|
||||
}
|
||||
|
||||
1
fusion_claims/wizard/sale_advance_payment_inv_views.xml
Normal file
1
fusion_claims/wizard/sale_advance_payment_inv_views.xml
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
214
fusion_claims/wizard/schedule_assessment_wizard.py
Normal file
214
fusion_claims/wizard/schedule_assessment_wizard.py
Normal 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}"
|
||||
45
fusion_claims/wizard/schedule_assessment_wizard_views.xml
Normal file
45
fusion_claims/wizard/schedule_assessment_wizard_views.xml
Normal 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 & 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>
|
||||
335
fusion_claims/wizard/send_to_mod_wizard.py
Normal file
335
fusion_claims/wizard/send_to_mod_wizard.py
Normal 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}"))
|
||||
100
fusion_claims/wizard/send_to_mod_wizard_views.xml
Normal file
100
fusion_claims/wizard/send_to_mod_wizard_views.xml
Normal 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>
|
||||
410
fusion_claims/wizard/status_change_reason_wizard.py
Normal file
410
fusion_claims/wizard/status_change_reason_wizard.py
Normal 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'}
|
||||
143
fusion_claims/wizard/status_change_reason_wizard_views.xml
Normal file
143
fusion_claims/wizard/status_change_reason_wizard_views.xml
Normal 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>
|
||||
397
fusion_claims/wizard/submission_verification_wizard.py
Normal file
397
fusion_claims/wizard/submission_verification_wizard.py
Normal 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',
|
||||
)
|
||||
127
fusion_claims/wizard/submission_verification_wizard_views.xml
Normal file
127
fusion_claims/wizard/submission_verification_wizard_views.xml
Normal 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>
|
||||
1
fusion_claims/wizard/temp_serial_migration_views.xml
Normal file
1
fusion_claims/wizard/temp_serial_migration_views.xml
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
107
fusion_claims/wizard/xml_import_wizard.py
Normal file
107
fusion_claims/wizard/xml_import_wizard.py
Normal 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',
|
||||
}
|
||||
41
fusion_claims/wizard/xml_import_wizard_views.xml
Normal file
41
fusion_claims/wizard/xml_import_wizard_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user