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
This commit is contained in:
gsinghpal
2026-03-15 12:27:06 -04:00
parent 0e04f4ecc6
commit a839285bd4
11 changed files with 578 additions and 229 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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()