618 lines
25 KiB
Python
618 lines
25 KiB
Python
# -*- 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
|