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