diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py
index 08f40911..a5023039 100644
--- a/fusion_claims/__manifest__.py
+++ b/fusion_claims/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
- 'version': '19.0.7.2.0',
+ 'version': '19.0.7.3.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -134,6 +134,7 @@
'views/dashboard_views.xml',
'views/client_profile_views.xml',
'wizard/xml_import_wizard_views.xml',
+ 'views/adp_export_record_views.xml',
'views/adp_claims_views.xml',
'views/submission_history_views.xml',
'views/product_template_adp_views.xml',
diff --git a/fusion_claims/models/__init__.py b/fusion_claims/models/__init__.py
index 8a8f7c21..41892a7a 100644
--- a/fusion_claims/models/__init__.py
+++ b/fusion_claims/models/__init__.py
@@ -4,6 +4,7 @@
# Part of the Fusion Claim Assistant product family.
from . import adp_posting_schedule
+from . import adp_export_record
from . import res_company
from . import res_config_settings
from . import fusion_central_config
diff --git a/fusion_claims/models/adp_export_record.py b/fusion_claims/models/adp_export_record.py
new file mode 100644
index 00000000..d1f816cf
--- /dev/null
+++ b/fusion_claims/models/adp_export_record.py
@@ -0,0 +1,344 @@
+# -*- 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 io
+import re
+import base64
+import logging
+import zipfile
+from datetime import date, datetime
+
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class ADPExportRecord(models.Model):
+ _name = 'fusion_claims.adp.export.record'
+ _description = 'ADP Export File Record'
+ _inherit = ['fusion_claims.adp.posting.schedule.mixin']
+ _order = 'export_date desc, id desc'
+
+ name = fields.Char(
+ string='Filename',
+ required=True,
+ index=True,
+ )
+ export_date = fields.Datetime(
+ string='Export Date',
+ required=True,
+ default=fields.Datetime.now,
+ index=True,
+ )
+ posting_period_date = fields.Date(
+ string='Posting Period',
+ required=True,
+ index=True,
+ help='The ADP posting date this export targets',
+ )
+ posting_period_label = fields.Char(
+ string='Posting Period Label',
+ compute='_compute_period_fields',
+ store=True,
+ )
+ year = fields.Char(
+ string='Year',
+ compute='_compute_period_fields',
+ store=True,
+ index=True,
+ )
+ month = fields.Char(
+ string='Month',
+ compute='_compute_period_fields',
+ store=True,
+ )
+ month_number = fields.Integer(
+ string='Month #',
+ compute='_compute_period_fields',
+ store=True,
+ help='Numeric month for proper ordering',
+ )
+
+ file_data = fields.Binary(
+ string='Export File',
+ attachment=True,
+ help='The exported ADP claims file (stored in filestore)',
+ )
+ filename = fields.Char(
+ string='Download Filename',
+ )
+ invoice_ids = fields.Many2many(
+ 'account.move',
+ 'adp_export_record_invoice_rel',
+ 'export_id',
+ 'invoice_id',
+ string='Exported Invoices',
+ )
+ invoice_count = fields.Integer(
+ string='Invoice Count',
+ compute='_compute_invoice_count',
+ )
+ line_count = fields.Integer(
+ string='Lines Exported',
+ default=0,
+ )
+ vendor_code = fields.Char(
+ string='Vendor Code',
+ )
+ user_id = fields.Many2one(
+ 'res.users',
+ string='Exported By',
+ default=lambda self: self.env.uid,
+ index=True,
+ )
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ index=True,
+ )
+ notes = fields.Text(
+ string='Notes',
+ )
+
+ @api.depends('posting_period_date')
+ def _compute_period_fields(self):
+ for record in self:
+ ppd = record.posting_period_date
+ if ppd:
+ record.year = str(ppd.year)
+ record.month = ppd.strftime('%B')
+ record.month_number = ppd.month
+ record.posting_period_label = ppd.strftime('%b %d, %Y')
+ else:
+ record.year = ''
+ record.month = ''
+ record.month_number = 0
+ record.posting_period_label = ''
+
+ @api.depends('invoice_ids')
+ def _compute_invoice_count(self):
+ for record in self:
+ record.invoice_count = len(record.invoice_ids)
+
+ def action_download(self):
+ """Download the export file."""
+ self.ensure_one()
+ if not self.file_data:
+ raise UserError(_("No file data available for download."))
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': f'/web/content?model={self._name}&id={self.id}'
+ f'&field=file_data&filename_field=filename&download=true',
+ 'target': 'self',
+ }
+
+ def action_view_invoices(self):
+ """Open the list of invoices included in this export."""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Exported Invoices'),
+ 'res_model': 'account.move',
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', self.invoice_ids.ids)],
+ }
+
+ def action_download_zip(self):
+ """Download selected export records as a single ZIP file.
+
+ Works as a multi-record action from the list view.
+ """
+ if not self:
+ raise UserError(_("Please select at least one export record."))
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
+ for record in self:
+ if not record.file_data:
+ continue
+ file_content = base64.b64decode(record.file_data)
+ archive_name = record.filename or record.name
+ zf.writestr(archive_name, file_content)
+
+ zip_data = base64.b64encode(buf.getvalue())
+ buf.close()
+
+ # Create a transient attachment for download
+ today_str = date.today().strftime('%Y-%m-%d')
+ zip_filename = f"ADP_Export_Files_{today_str}.zip"
+ attachment = self.env['ir.attachment'].sudo().create({
+ 'name': zip_filename,
+ 'type': 'binary',
+ 'datas': zip_data,
+ 'mimetype': 'application/zip',
+ })
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': f'/web/content/{attachment.id}?download=true',
+ 'target': 'self',
+ }
+
+ # ======================================================================
+ # MIGRATION: Documents app -> ADP Export Records
+ # ======================================================================
+ @api.model
+ def migrate_from_documents(self):
+ """Migrate existing ADP export files from Documents app to this model.
+
+ Searches for binary files under 'ADP Billing Files' folder hierarchy,
+ parses filenames to determine vendor code and date, calculates the
+ posting period, and creates export records.
+
+ Returns a notification dict for the UI.
+ """
+ if 'documents.document' not in self.env:
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('Documents App Not Installed'),
+ 'message': _('The Documents app is not installed. Nothing to migrate.'),
+ 'type': 'warning',
+ 'sticky': False,
+ },
+ }
+
+ Document = self.env['documents.document'].sudo()
+
+ root_folders = Document.search([
+ ('name', 'ilike', 'ADP Billing Files'),
+ ('type', '=', 'folder'),
+ ])
+
+ if not root_folders:
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('No Files Found'),
+ 'message': _('No "ADP Billing Files" folder found in Documents.'),
+ 'type': 'info',
+ 'sticky': False,
+ },
+ }
+
+ all_folder_ids = self._collect_subfolder_ids(Document, root_folders.ids)
+ all_folder_ids.extend(root_folders.ids)
+
+ files = Document.search([
+ ('folder_id', 'in', all_folder_ids),
+ ('type', '=', 'binary'),
+ ])
+
+ if not files:
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('No Files Found'),
+ 'message': _('No export files found in ADP Billing Files folders.'),
+ 'type': 'info',
+ 'sticky': False,
+ },
+ }
+
+ migrated = 0
+ skipped = 0
+ errors = 0
+
+ for doc in files:
+ existing = self.search([('name', '=', doc.name)], limit=1)
+ if existing:
+ skipped += 1
+ continue
+
+ try:
+ vendor_code, file_date = self._parse_export_filename(doc.name)
+ if file_date:
+ posting_date = self._get_current_posting_date(file_date)
+ else:
+ posting_date = self._get_current_posting_date(
+ doc.create_date.date() if doc.create_date else date.today()
+ )
+
+ self.create({
+ 'name': doc.name,
+ 'filename': doc.name,
+ 'file_data': doc.datas,
+ 'export_date': doc.create_date or fields.Datetime.now(),
+ 'posting_period_date': posting_date,
+ 'vendor_code': vendor_code or '',
+ 'user_id': doc.create_uid.id if doc.create_uid else self.env.uid,
+ 'company_id': self.env.company.id,
+ 'notes': f'Migrated from Documents app on {date.today()}',
+ })
+ migrated += 1
+ _logger.info("Migrated ADP export file: %s", doc.name)
+ except Exception as e:
+ errors += 1
+ _logger.error("Failed to migrate document %s: %s", doc.name, e)
+
+ # Archive migrated documents so they don't clutter the Documents app
+ if migrated > 0:
+ try:
+ migrated_docs = files.filtered(
+ lambda d: not self.search([('name', '=', d.name)], limit=1) is None
+ )
+ if hasattr(Document, 'active'):
+ files.write({'active': False})
+ _logger.info("Archived %d documents after migration", len(files))
+ except Exception as e:
+ _logger.warning("Could not archive old documents: %s", e)
+
+ msg = _(
+ "Migration complete: %d files migrated, %d skipped (already exist), %d errors."
+ ) % (migrated, skipped, errors)
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('Migration Complete'),
+ 'message': msg,
+ 'type': 'success' if errors == 0 else 'warning',
+ 'sticky': True,
+ },
+ }
+
+ @api.model
+ def _collect_subfolder_ids(self, Document, parent_ids):
+ """Recursively collect all subfolder IDs under the given parent folders."""
+ all_ids = []
+ children = Document.search([
+ ('folder_id', 'in', parent_ids),
+ ('type', '=', 'folder'),
+ ])
+ if children:
+ all_ids.extend(children.ids)
+ all_ids.extend(self._collect_subfolder_ids(Document, children.ids))
+ return all_ids
+
+ @api.model
+ def _parse_export_filename(self, filename):
+ """Parse an ADP export filename to extract vendor code and date.
+
+ Expected format: VENDORCODE_YYYY-MM-DD.txt
+ Returns: (vendor_code, date_obj) or (None, None) if unparseable.
+ """
+ if not filename:
+ return None, None
+
+ match = re.match(r'^(.+?)_(\d{4}-\d{2}-\d{2})\.\w+$', filename)
+ if match:
+ vendor_code = match.group(1)
+ try:
+ file_date = date.fromisoformat(match.group(2))
+ return vendor_code, file_date
+ except ValueError:
+ return vendor_code, None
+
+ return None, None
diff --git a/fusion_claims/models/res_config_settings.py b/fusion_claims/models/res_config_settings.py
index 83e65e51..c81a3788 100644
--- a/fusion_claims/models/res_config_settings.py
+++ b/fusion_claims/models/res_config_settings.py
@@ -641,3 +641,7 @@ class ResConfigSettings(models.TransientModel):
def action_set_gradient_dark_slate(self):
self._apply_gradient_preset('dark_slate')
+ def action_migrate_adp_export_files(self):
+ """Migrate ADP export files from Documents app to the new Export Files menu."""
+ return self.env['fusion_claims.adp.export.record'].migrate_from_documents()
+
diff --git a/fusion_claims/security/ir.model.access.csv b/fusion_claims/security/ir.model.access.csv
index ff244243..1f2b32f9 100644
--- a/fusion_claims/security/ir.model.access.csv
+++ b/fusion_claims/security/ir.model.access.csv
@@ -2,6 +2,8 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_claims_config_user,fusion.central.config.user,model_fusion_claims_config,base.group_user,1,1,1,1
access_fusion_claims_export_wizard_user,fusion.central.export.wizard.user,model_fusion_claims_export_wizard,account.group_account_invoice,1,1,1,1
access_fusion_claims_export_wizard_manager,fusion.central.export.wizard.manager,model_fusion_claims_export_wizard,account.group_account_manager,1,1,1,1
+access_fusion_adp_export_record_user,fusion.adp.export.record.user,model_fusion_claims_adp_export_record,account.group_account_invoice,1,0,1,0
+access_fusion_adp_export_record_manager,fusion.adp.export.record.manager,model_fusion_claims_adp_export_record,account.group_account_manager,1,1,1,1
access_fusion_adp_device_code_user,fusion.adp.device.code.user,model_fusion_adp_device_code,base.group_user,1,0,0,0
access_fusion_adp_device_code_sales,fusion.adp.device.code.sales,model_fusion_adp_device_code,sales_team.group_sale_salesman,1,1,1,0
access_fusion_adp_device_code_manager,fusion.adp.device.code.manager,model_fusion_adp_device_code,sales_team.group_sale_manager,1,1,1,1
diff --git a/fusion_claims/views/account_move_views.xml b/fusion_claims/views/account_move_views.xml
index 76e54492..211268c4 100644
--- a/fusion_claims/views/account_move_views.xml
+++ b/fusion_claims/views/account_move_views.xml
@@ -59,10 +59,11 @@
52
-
+
+
-
+ invisible="move_type not in ['out_invoice', 'out_refund'] or state != 'posted' or not x_fc_is_adp_invoice or x_fc_adp_invoice_portion != 'adp'"/>
diff --git a/fusion_claims/views/adp_claims_views.xml b/fusion_claims/views/adp_claims_views.xml
index e1d624d0..3f83646e 100644
--- a/fusion_claims/views/adp_claims_views.xml
+++ b/fusion_claims/views/adp_claims_views.xml
@@ -1739,6 +1739,8 @@ else:
action="action_adp_invoices" sequence="2"/>
+