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:
@@ -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
|
||||
|
||||
344
fusion_claims/models/adp_export_record.py
Normal file
344
fusion_claims/models/adp_export_record.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user