Files
Odoo-Modules/fusion_claims/wizard/adp_export_wizard.py
2026-02-22 01:22:18 -05:00

618 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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