# -*- 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