Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View 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