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:
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -59,10 +59,11 @@
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="priority">52</field>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Add Export and Sync buttons to header -->
|
||||
<!-- Add Export and Verify buttons to header -->
|
||||
<xpath expr="//button[@name='button_draft']" position="after">
|
||||
<field name="x_fc_is_adp_invoice" invisible="1"/>
|
||||
<field name="x_fc_needs_device_verification" invisible="1"/>
|
||||
<field name="x_fc_adp_invoice_portion" invisible="1"/>
|
||||
<!-- Verify Device Approval button - only on client invoices that need verification -->
|
||||
<button name="action_open_device_approval_wizard"
|
||||
string="Verify Device Approval"
|
||||
@@ -71,19 +72,12 @@
|
||||
icon="fa-check-square-o"
|
||||
invisible="not x_fc_needs_device_verification"
|
||||
help="Complete device verification to enable ADP invoice creation"/>
|
||||
<button name="action_sync_to_sale_order"
|
||||
string="Sync All"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-refresh"
|
||||
invisible="move_type not in ['out_invoice', 'out_refund'] or not x_fc_is_adp_invoice"
|
||||
help="Sync ADP fields from this Invoice to the Sale Order and all linked invoices"/>
|
||||
<button name="action_export_adp_claim"
|
||||
string="Export ADP"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-file-text-o"
|
||||
invisible="move_type not in ['out_invoice', 'out_refund'] or state != 'posted' or not x_fc_is_adp_invoice"/>
|
||||
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'"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -1739,6 +1739,8 @@ else:
|
||||
action="action_adp_invoices" sequence="2"/>
|
||||
<menuitem id="menu_adp_client_invoices" name="ADP Client Invoices" parent="menu_fc_adp"
|
||||
action="action_adp_client_invoices" sequence="3"/>
|
||||
<menuitem id="menu_adp_export_files" name="Export Files" parent="menu_fc_adp"
|
||||
action="action_adp_export_records" sequence="4"/>
|
||||
|
||||
<menuitem id="menu_adp_quotations"
|
||||
name="Quotation Stage"
|
||||
|
||||
142
fusion_claims/views/adp_export_record_views.xml
Normal file
142
fusion_claims/views/adp_export_record_views.xml
Normal file
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2024-2025 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Claim Assistant product family.
|
||||
-->
|
||||
<odoo>
|
||||
<!-- ===================================================================== -->
|
||||
<!-- ADP EXPORT RECORD: Tree View -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_adp_export_record_list" model="ir.ui.view">
|
||||
<field name="name">fusion_claims.adp.export.record.list</field>
|
||||
<field name="model">fusion_claims.adp.export.record</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="ADP Export Files" default_order="export_date desc">
|
||||
<field name="name"/>
|
||||
<field name="export_date"/>
|
||||
<field name="posting_period_label" string="Posting Period"/>
|
||||
<field name="vendor_code"/>
|
||||
<field name="line_count" string="Lines"/>
|
||||
<field name="invoice_count" string="Invoices"/>
|
||||
<field name="user_id" string="Exported By" widget="many2one_avatar_user"/>
|
||||
<field name="year" column_invisible="True"/>
|
||||
<field name="month" column_invisible="True"/>
|
||||
<field name="month_number" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- ADP EXPORT RECORD: Form View -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_adp_export_record_form" model="ir.ui.view">
|
||||
<field name="name">fusion_claims.adp.export.record.form</field>
|
||||
<field name="model">fusion_claims.adp.export.record</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="ADP Export File">
|
||||
<header>
|
||||
<button name="action_download" string="Download File"
|
||||
type="object" class="btn-primary" icon="fa-download"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Export Details">
|
||||
<field name="export_date" readonly="1"/>
|
||||
<field name="posting_period_date" readonly="1"/>
|
||||
<field name="posting_period_label" readonly="1"/>
|
||||
<field name="vendor_code" readonly="1"/>
|
||||
<field name="line_count" readonly="1"/>
|
||||
</group>
|
||||
<group string="File">
|
||||
<field name="file_data" filename="filename" readonly="1"/>
|
||||
<field name="filename" invisible="1"/>
|
||||
<field name="user_id" readonly="1"/>
|
||||
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Exported Invoices" name="invoices">
|
||||
<field name="invoice_ids" readonly="1" nolabel="1">
|
||||
<list string="Invoices" create="0" delete="0">
|
||||
<field name="name" string="Invoice"/>
|
||||
<field name="partner_id" string="Customer"/>
|
||||
<field name="invoice_date"/>
|
||||
<field name="amount_total" string="Total"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'posted'"
|
||||
decoration-info="state == 'draft'"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Notes" name="notes" invisible="not notes">
|
||||
<field name="notes" readonly="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- ADP EXPORT RECORD: Search View -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="view_adp_export_record_search" model="ir.ui.view">
|
||||
<field name="name">fusion_claims.adp.export.record.search</field>
|
||||
<field name="model">fusion_claims.adp.export.record</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="ADP Export Files">
|
||||
<field name="name"/>
|
||||
<field name="vendor_code"/>
|
||||
<field name="user_id"/>
|
||||
<separator/>
|
||||
<group expand="1" string="Group By">
|
||||
<filter string="Year" name="group_year"
|
||||
context="{'group_by': 'year'}"/>
|
||||
<filter string="Month" name="group_month"
|
||||
context="{'group_by': 'month'}"/>
|
||||
<filter string="Posting Period" name="group_posting"
|
||||
context="{'group_by': 'posting_period_date'}"/>
|
||||
<filter string="Exported By" name="group_user"
|
||||
context="{'group_by': 'user_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- ADP EXPORT RECORD: Action -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="action_adp_export_records" model="ir.actions.act_window">
|
||||
<field name="name">Export Files</field>
|
||||
<field name="res_model">fusion_claims.adp.export.record</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_adp_export_record_search"/>
|
||||
<field name="context">{'search_default_group_year': 1, 'search_default_group_month': 1, 'search_default_group_posting': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No export files yet
|
||||
</p>
|
||||
<p>
|
||||
ADP export files will appear here after you export invoices using the
|
||||
<strong>Export ADP</strong> button on ADP portion invoices.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===================================================================== -->
|
||||
<!-- ADP EXPORT RECORD: Server Action for Bulk ZIP Download -->
|
||||
<!-- ===================================================================== -->
|
||||
<record id="action_adp_export_download_zip" model="ir.actions.server">
|
||||
<field name="name">Download as ZIP</field>
|
||||
<field name="model_id" ref="model_fusion_claims_adp_export_record"/>
|
||||
<field name="binding_model_id" ref="model_fusion_claims_adp_export_record"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_download_zip()</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -330,6 +330,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- DATA MIGRATION -->
|
||||
<!-- ============================================================= -->
|
||||
<h2>Data Migration</h2>
|
||||
<div class="row mt-4 o_settings_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Migrate Export Files from Documents</span>
|
||||
<div class="text-muted">
|
||||
Move existing ADP export files from the Documents app into the
|
||||
new ADP Export Files menu. Only needs to be run once after upgrading.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button name="action_migrate_adp_export_files"
|
||||
string="Migrate Export Files"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
icon="fa-exchange"
|
||||
confirm="This will migrate all ADP export files from Documents to the new Export Files menu. Continue?"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- ODSP CONFIGURATION -->
|
||||
<!-- ============================================================= -->
|
||||
|
||||
@@ -39,7 +39,7 @@ class FusionCentralExportWizard(models.TransientModel):
|
||||
('done', 'Done'),
|
||||
], default='draft')
|
||||
export_summary = fields.Text(string='Export Summary', readonly=True)
|
||||
saved_to_documents = fields.Boolean(string='Saved to Documents', readonly=True)
|
||||
saved_to_export_records = fields.Boolean(string='Saved to Export Files', readonly=True)
|
||||
warnings = fields.Text(string='Warnings', readonly=True)
|
||||
|
||||
@api.model
|
||||
@@ -343,110 +343,80 @@ class FusionCentralExportWizard(models.TransientModel):
|
||||
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.
|
||||
|
||||
"""Check if a file with the same name already exists in ADP Export Records.
|
||||
|
||||
Returns:
|
||||
tuple: (exists: bool, existing_files: list of names)
|
||||
tuple: (exists: bool, existing_files: list of description strings)
|
||||
"""
|
||||
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))
|
||||
|
||||
ExportRecord = self.env['fusion_claims.adp.export.record']
|
||||
existing = ExportRecord.search([('name', '=', filename)])
|
||||
if existing:
|
||||
existing_files = [
|
||||
f"{rec.name} (exported: {rec.export_date.strftime('%Y-%m-%d %H:%M')})"
|
||||
for rec in existing
|
||||
]
|
||||
return True, existing_files
|
||||
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
|
||||
4. Save to ADP Export Records (filestore-backed)
|
||||
5. Show download window
|
||||
"""
|
||||
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"WARNING: A file with the name '{filename}' already exists in Export Files.\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
|
||||
|
||||
saved = self._save_to_export_records(filename, file_data, line_count)
|
||||
|
||||
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
|
||||
summary += "\n" + _("File saved to Fusion Claims > ADP > Export Files.")
|
||||
|
||||
self.write({
|
||||
'export_file': file_data,
|
||||
'export_filename': filename,
|
||||
'state': 'done',
|
||||
'export_summary': summary,
|
||||
'saved_to_documents': saved,
|
||||
'saved_to_export_records': saved,
|
||||
'warnings': warnings_text,
|
||||
})
|
||||
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
@@ -455,163 +425,28 @@ class FusionCentralExportWizard(models.TransientModel):
|
||||
'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
|
||||
|
||||
def _save_to_export_records(self, filename, file_data, line_count):
|
||||
"""Save export file to the ADP Export Records model (filestore-backed)."""
|
||||
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({
|
||||
ExportRecord = self.env['fusion_claims.adp.export.record']
|
||||
posting_date = ExportRecord._get_current_posting_date(self.export_date)
|
||||
|
||||
ExportRecord.create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(content.encode('utf-8')),
|
||||
'folder_id': folder.id,
|
||||
'access_internal': 'edit', # Allow internal users to access
|
||||
'filename': filename,
|
||||
'file_data': file_data,
|
||||
'export_date': fields.Datetime.now(),
|
||||
'posting_period_date': posting_date,
|
||||
'vendor_code': self.vendor_code,
|
||||
'line_count': line_count,
|
||||
'invoice_ids': [(6, 0, self.invoice_ids.ids)],
|
||||
'user_id': self.env.uid,
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
|
||||
_logger.info("Saved ADP export to Documents: %s", filename)
|
||||
|
||||
_logger.info("Saved ADP export record: %s", filename)
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning("Could not save to Documents: %s", str(e))
|
||||
_logger.error("Could not save ADP export record: %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
|
||||
|
||||
@@ -40,13 +40,13 @@
|
||||
<field name="export_file" filename="export_filename" readonly="1"/>
|
||||
</group>
|
||||
|
||||
<div class="text-muted small" invisible="not saved_to_documents">
|
||||
<i class="fa fa-folder-open"/> File also saved to Documents app
|
||||
<div class="text-muted small" invisible="not saved_to_export_records">
|
||||
<i class="fa fa-folder-open"/> File saved to Fusion Claims > ADP > Export Files
|
||||
</div>
|
||||
</group>
|
||||
|
||||
<field name="state" invisible="1"/>
|
||||
<field name="saved_to_documents" invisible="1"/>
|
||||
<field name="saved_to_export_records" invisible="1"/>
|
||||
<field name="warnings" invisible="1"/>
|
||||
|
||||
<footer>
|
||||
|
||||
Reference in New Issue
Block a user