Files
Odoo-Modules/fusion_claims/wizard/adp_export_wizard.py
gsinghpal a839285bd4 feat: ADP Export Files menu with filestore storage, remove Sync All button
- Add fusion_claims.adp.export.record model with filestore-backed Binary field
  for tracking exported ADP claims files organized by Year > Month > Posting Period
- Add tree/form/search views with default group-by hierarchy, latest first
- Add "Export Files" menuitem under ADP menu section
- Add bulk ZIP download server action for multi-select export
- Replace Documents app storage with new model in export wizard
- Remove Documents-related methods (_save_to_documents, folder creation)
- Add migration button in Settings to move existing Documents files
- Fix Export ADP button visibility: only show on ADP portion invoices
- Remove redundant Sync All button from invoice form
- Add ACL entries for billing users (read/create) and managers (full CRUD)
- Bump version to 19.0.7.3.0

Made-with: Cursor
2026-03-15 12:27:06 -04:00

453 lines
19 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_export_records = fields.Boolean(string='Saved to Export Files', 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 ADP Export Records.
Returns:
tuple: (exists: bool, existing_files: list of description strings)
"""
existing_files = []
ExportRecord = self.env['fusion_claims.adp.export.record']
existing = ExportRecord.search([('name', '=', filename)])
if existing:
existing_files = [
f"{rec.name} (exported: {rec.export_date.strftime('%Y-%m-%d %H:%M')})"
for rec in existing
]
return True, existing_files
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. Save to ADP Export Records (filestore-backed)
5. Show download window
"""
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."))
filename = self._get_export_filename()
file_exists, existing_files = self._check_existing_file(filename)
content, line_count, warnings = self._generate_export_content()
file_data = base64.b64encode(content.encode('utf-8'))
if file_exists:
warnings.append(
f"WARNING: A file with the name '{filename}' already exists in Export Files.\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."
)
warnings_text = '\n'.join(warnings) if warnings else ''
saved = self._save_to_export_records(filename, file_data, line_count)
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,
})
summary = _("Exported %d lines from %d invoices.") % (line_count, len(self.invoice_ids))
if saved:
summary += "\n" + _("File saved to Fusion Claims > ADP > Export Files.")
self.write({
'export_file': file_data,
'export_filename': filename,
'state': 'done',
'export_summary': summary,
'saved_to_export_records': 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_export_records(self, filename, file_data, line_count):
"""Save export file to the ADP Export Records model (filestore-backed)."""
try:
ExportRecord = self.env['fusion_claims.adp.export.record']
posting_date = ExportRecord._get_current_posting_date(self.export_date)
ExportRecord.create({
'name': filename,
'filename': filename,
'file_data': file_data,
'export_date': fields.Datetime.now(),
'posting_period_date': posting_date,
'vendor_code': self.vendor_code,
'line_count': line_count,
'invoice_ids': [(6, 0, self.invoice_ids.ids)],
'user_id': self.env.uid,
'company_id': self.env.company.id,
})
_logger.info("Saved ADP export record: %s", filename)
return True
except Exception as e:
_logger.error("Could not save ADP export record: %s", str(e))
return False