update
This commit is contained in:
@@ -17,8 +17,6 @@ from . import application_received_wizard
|
||||
from . import ready_for_submission_wizard
|
||||
from . import ready_to_bill_wizard
|
||||
from . import field_mapping_config_wizard
|
||||
from . import loaner_checkout_wizard
|
||||
from . import loaner_return_wizard
|
||||
from . import ready_for_delivery_wizard
|
||||
from . import xml_import_wizard
|
||||
from . import send_to_mod_wizard
|
||||
@@ -30,4 +28,5 @@ from . import odsp_discretionary_wizard
|
||||
from . import odsp_pre_approved_wizard
|
||||
from . import odsp_ready_delivery_wizard
|
||||
from . import odsp_submit_to_odsp_wizard
|
||||
from . import send_page11_wizard
|
||||
from . import send_page11_wizard
|
||||
from . import adp_import_wizard
|
||||
@@ -429,7 +429,7 @@ class FusionCentralExportWizard(models.TransientModel):
|
||||
"""Save export file to the ADP Export Records model (filestore-backed)."""
|
||||
try:
|
||||
ExportRecord = self.env['fusion_claims.adp.export.record']
|
||||
posting_date = ExportRecord._get_current_posting_date(self.export_date)
|
||||
posting_date = ExportRecord._get_posting_period_for_file(self.export_date)
|
||||
|
||||
ExportRecord.create({
|
||||
'name': filename,
|
||||
|
||||
163
fusion_claims/wizard/adp_import_wizard.py
Normal file
163
fusion_claims/wizard/adp_import_wizard.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# -*- 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 base64
|
||||
import io
|
||||
import logging
|
||||
import zipfile
|
||||
from datetime import date
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ADPImportWizard(models.TransientModel):
|
||||
_name = 'fusion_claims.adp.import.wizard'
|
||||
_description = 'Import ADP Export Files'
|
||||
|
||||
txt_files = fields.Many2many(
|
||||
'ir.attachment',
|
||||
string='TXT Files',
|
||||
help='Select one or more ADP export .txt files to import',
|
||||
)
|
||||
zip_file = fields.Binary(string='ZIP File')
|
||||
zip_filename = fields.Char()
|
||||
result_message = fields.Text(string='Import Results', readonly=True)
|
||||
state = fields.Selection([
|
||||
('draft', 'Upload'),
|
||||
('done', 'Done'),
|
||||
], default='draft')
|
||||
|
||||
def action_import(self):
|
||||
"""Process uploaded files and create export records."""
|
||||
self.ensure_one()
|
||||
if not self.txt_files and not self.zip_file:
|
||||
raise UserError(_('Please upload .txt files or a ZIP file.'))
|
||||
|
||||
ExportRecord = self.env['fusion_claims.adp.export.record']
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
file_list, skipped_non_txt = self._collect_files()
|
||||
|
||||
for filename, file_data_b64 in file_list:
|
||||
try:
|
||||
existing = ExportRecord.search([('name', '=', filename)], limit=1)
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
vendor_code, file_date = ExportRecord._parse_export_filename(filename)
|
||||
posting_date = ExportRecord._get_posting_period_for_file(
|
||||
file_date if file_date else date.today()
|
||||
)
|
||||
|
||||
ExportRecord.create({
|
||||
'name': filename,
|
||||
'filename': filename,
|
||||
'file_data': file_data_b64,
|
||||
'export_date': fields.Datetime.now(),
|
||||
'posting_period_date': posting_date,
|
||||
'vendor_code': vendor_code or '',
|
||||
'user_id': self.env.uid,
|
||||
'company_id': self.env.company.id,
|
||||
'notes': 'Imported on %s' % date.today(),
|
||||
})
|
||||
imported += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append('%s: %s' % (filename, str(e)))
|
||||
_logger.exception('Error importing file %s', filename)
|
||||
|
||||
lines = [
|
||||
'Import Complete!',
|
||||
'- Files imported: %d' % imported,
|
||||
'- Files skipped (already exist): %d' % skipped,
|
||||
'- Non-txt files ignored: %d' % skipped_non_txt,
|
||||
]
|
||||
if errors:
|
||||
lines.append('\nErrors (%d):' % len(errors))
|
||||
for err in errors:
|
||||
lines.append(' - %s' % err)
|
||||
|
||||
self.result_message = '\n'.join(lines)
|
||||
self.state = 'done'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'view_mode': 'form',
|
||||
'res_id': self.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def _collect_files(self):
|
||||
"""Collect all .txt files from both individual uploads and ZIP.
|
||||
|
||||
Returns (file_list, skipped_count) where file_list is
|
||||
a list of (filename, base64_data) tuples.
|
||||
"""
|
||||
files = []
|
||||
skipped_types = []
|
||||
|
||||
for attachment in self.txt_files:
|
||||
name = attachment.name or ''
|
||||
if not name.lower().endswith('.txt'):
|
||||
skipped_types.append(name)
|
||||
continue
|
||||
files.append((name, attachment.datas))
|
||||
|
||||
if skipped_types and not files and not self.zip_file:
|
||||
raise UserError(_(
|
||||
'Only .txt files are supported. Skipped: %s',
|
||||
', '.join(skipped_types),
|
||||
))
|
||||
|
||||
if self.zip_file:
|
||||
files.extend(self._extract_txt_from_zip())
|
||||
|
||||
return files, len(skipped_types)
|
||||
|
||||
def _extract_txt_from_zip(self):
|
||||
"""Scan all folders/subfolders in the ZIP and extract .txt files.
|
||||
|
||||
Returns list of (filename, base64_data) tuples.
|
||||
Uses only the base filename (no folder path) as the record name.
|
||||
"""
|
||||
raw = base64.b64decode(self.zip_file)
|
||||
buf = io.BytesIO(raw)
|
||||
|
||||
if not zipfile.is_zipfile(buf):
|
||||
raise UserError(_('The uploaded file is not a valid ZIP archive.'))
|
||||
|
||||
buf.seek(0)
|
||||
results = []
|
||||
seen = set()
|
||||
|
||||
with zipfile.ZipFile(buf, 'r') as zf:
|
||||
for entry in zf.infolist():
|
||||
if entry.is_dir():
|
||||
continue
|
||||
lower = entry.filename.lower()
|
||||
if not lower.endswith('.txt'):
|
||||
continue
|
||||
if lower.startswith('__macosx'):
|
||||
continue
|
||||
|
||||
basename = entry.filename.rsplit('/', 1)[-1]
|
||||
if not basename or basename in seen:
|
||||
continue
|
||||
seen.add(basename)
|
||||
|
||||
data = zf.read(entry.filename)
|
||||
results.append((basename, base64.b64encode(data).decode('ascii')))
|
||||
|
||||
if not results:
|
||||
raise UserError(_('No .txt files found in the ZIP archive.'))
|
||||
|
||||
return results
|
||||
45
fusion_claims/wizard/adp_import_wizard_views.xml
Normal file
45
fusion_claims/wizard/adp_import_wizard_views.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_adp_import_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion_claims.adp.import.wizard.form</field>
|
||||
<field name="model">fusion_claims.adp.import.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Import Claim Submission Files">
|
||||
<group invisible="state != 'draft'">
|
||||
<group string="Individual Files">
|
||||
<field name="txt_files" widget="many2many_binary" string="TXT Files"/>
|
||||
</group>
|
||||
<group string="Folder Upload (ZIP)">
|
||||
<field name="zip_file" filename="zip_filename"/>
|
||||
<field name="zip_filename" invisible="1"/>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Import Claim Submission Files</strong>
|
||||
<p class="mb-0">
|
||||
<b>Option 1:</b> Drag and drop individual .txt files above.<br/>
|
||||
<b>Option 2:</b> ZIP your folder(s) and upload. All subfolders
|
||||
will be scanned for .txt files automatically.<br/>
|
||||
You can use both options at once. Duplicates are skipped.
|
||||
</p>
|
||||
</div>
|
||||
</group>
|
||||
<group invisible="state != 'done'">
|
||||
<field name="result_message" widget="text" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<field name="state" invisible="1"/>
|
||||
<footer>
|
||||
<button string="Import" name="action_import" type="object"
|
||||
class="btn-primary" invisible="state != 'draft'"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_adp_import_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Import Submission Files</field>
|
||||
<field name="res_model">fusion_claims.adp.import.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1,237 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoanerCheckoutWizard(models.TransientModel):
|
||||
"""Wizard to checkout loaner equipment."""
|
||||
_name = 'fusion.loaner.checkout.wizard'
|
||||
_description = 'Loaner Checkout Wizard'
|
||||
|
||||
# =========================================================================
|
||||
# CONTEXT FIELDS
|
||||
# =========================================================================
|
||||
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order',
|
||||
string='Sale Order',
|
||||
readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Client',
|
||||
required=True,
|
||||
)
|
||||
authorizer_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Authorizer',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# PRODUCT SELECTION
|
||||
# =========================================================================
|
||||
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Product',
|
||||
domain="[('x_fc_can_be_loaned', '=', True)]",
|
||||
required=True,
|
||||
)
|
||||
lot_id = fields.Many2one(
|
||||
'stock.lot',
|
||||
string='Serial Number',
|
||||
domain="[('product_id', '=', product_id)]",
|
||||
)
|
||||
available_lot_ids = fields.Many2many(
|
||||
'stock.lot',
|
||||
compute='_compute_available_lots',
|
||||
string='Available Serial Numbers',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# DATES
|
||||
# =========================================================================
|
||||
|
||||
checkout_date = fields.Date(
|
||||
string='Checkout Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
)
|
||||
loaner_period_days = fields.Integer(
|
||||
string='Loaner Period (Days)',
|
||||
default=7,
|
||||
)
|
||||
expected_return_date = fields.Date(
|
||||
string='Expected Return Date',
|
||||
compute='_compute_expected_return',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# CONDITION
|
||||
# =========================================================================
|
||||
|
||||
checkout_condition = fields.Selection([
|
||||
('excellent', 'Excellent'),
|
||||
('good', 'Good'),
|
||||
('fair', 'Fair'),
|
||||
('needs_repair', 'Needs Repair'),
|
||||
], string='Condition', default='excellent', required=True)
|
||||
checkout_notes = fields.Text(
|
||||
string='Notes',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# PHOTOS
|
||||
# =========================================================================
|
||||
|
||||
checkout_photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'loaner_checkout_wizard_photo_rel',
|
||||
'wizard_id',
|
||||
'attachment_id',
|
||||
string='Photos',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# DELIVERY
|
||||
# =========================================================================
|
||||
|
||||
delivery_address = fields.Text(
|
||||
string='Delivery Address',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# COMPUTED
|
||||
# =========================================================================
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_available_lots(self):
|
||||
"""Get available serial numbers for the selected product."""
|
||||
for wizard in self:
|
||||
if wizard.product_id:
|
||||
# Get loaner location
|
||||
loaner_location = self.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
|
||||
if loaner_location:
|
||||
# Find lots with stock in loaner location
|
||||
quants = self.env['stock.quant'].search([
|
||||
('product_id', '=', wizard.product_id.id),
|
||||
('location_id', '=', loaner_location.id),
|
||||
('quantity', '>', 0),
|
||||
])
|
||||
wizard.available_lot_ids = quants.mapped('lot_id')
|
||||
else:
|
||||
# Fallback: all lots for product
|
||||
wizard.available_lot_ids = self.env['stock.lot'].search([
|
||||
('product_id', '=', wizard.product_id.id),
|
||||
])
|
||||
else:
|
||||
wizard.available_lot_ids = False
|
||||
|
||||
@api.depends('checkout_date', 'loaner_period_days')
|
||||
def _compute_expected_return(self):
|
||||
from datetime import timedelta
|
||||
for wizard in self:
|
||||
if wizard.checkout_date and wizard.loaner_period_days:
|
||||
wizard.expected_return_date = wizard.checkout_date + timedelta(days=wizard.loaner_period_days)
|
||||
else:
|
||||
wizard.expected_return_date = False
|
||||
|
||||
# =========================================================================
|
||||
# ONCHANGE
|
||||
# =========================================================================
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
if self.product_id:
|
||||
self.loaner_period_days = self.product_id.x_fc_loaner_period_days or 7
|
||||
self.lot_id = False
|
||||
|
||||
# =========================================================================
|
||||
# DEFAULT GET
|
||||
# =========================================================================
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
|
||||
# Get context
|
||||
active_model = self._context.get('active_model')
|
||||
active_id = self._context.get('active_id')
|
||||
|
||||
if active_model == 'sale.order' and active_id:
|
||||
order = self.env['sale.order'].browse(active_id)
|
||||
res['sale_order_id'] = order.id
|
||||
res['partner_id'] = order.partner_id.id
|
||||
res['authorizer_id'] = order.x_fc_authorizer_id.id if order.x_fc_authorizer_id else False
|
||||
if order.partner_shipping_id:
|
||||
res['delivery_address'] = order.partner_shipping_id.contact_address
|
||||
|
||||
# Get default loaner period from settings
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
default_period = int(ICP.get_param('fusion_claims.default_loaner_period_days', '7'))
|
||||
res['loaner_period_days'] = default_period
|
||||
|
||||
return res
|
||||
|
||||
# =========================================================================
|
||||
# ACTION
|
||||
# =========================================================================
|
||||
|
||||
def action_checkout(self):
|
||||
"""Create and confirm loaner checkout."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.product_id:
|
||||
raise UserError(_("Please select a product."))
|
||||
|
||||
# Create persistent attachments for photos
|
||||
photo_ids = []
|
||||
for photo in self.checkout_photo_ids:
|
||||
new_attachment = self.env['ir.attachment'].create({
|
||||
'name': photo.name,
|
||||
'datas': photo.datas,
|
||||
'res_model': 'fusion.loaner.checkout',
|
||||
'res_id': 0, # Will update after checkout creation
|
||||
})
|
||||
photo_ids.append(new_attachment.id)
|
||||
|
||||
# Create checkout record
|
||||
checkout_vals = {
|
||||
'sale_order_id': self.sale_order_id.id if self.sale_order_id else False,
|
||||
'partner_id': self.partner_id.id,
|
||||
'authorizer_id': self.authorizer_id.id if self.authorizer_id else False,
|
||||
'sales_rep_id': self.env.user.id,
|
||||
'product_id': self.product_id.id,
|
||||
'lot_id': self.lot_id.id if self.lot_id else False,
|
||||
'checkout_date': self.checkout_date,
|
||||
'loaner_period_days': self.loaner_period_days,
|
||||
'checkout_condition': self.checkout_condition,
|
||||
'checkout_notes': self.checkout_notes,
|
||||
'delivery_address': self.delivery_address,
|
||||
}
|
||||
|
||||
checkout = self.env['fusion.loaner.checkout'].create(checkout_vals)
|
||||
|
||||
# Update photo attachments
|
||||
if photo_ids:
|
||||
self.env['ir.attachment'].browse(photo_ids).write({'res_id': checkout.id})
|
||||
checkout.checkout_photo_ids = [(6, 0, photo_ids)]
|
||||
|
||||
# Confirm checkout
|
||||
checkout.action_checkout()
|
||||
|
||||
# Return to checkout record
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Loaner Checkout'),
|
||||
'res_model': 'fusion.loaner.checkout',
|
||||
'res_id': checkout.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoanerReturnWizard(models.TransientModel):
|
||||
"""Wizard to return loaner equipment."""
|
||||
_name = 'fusion.loaner.return.wizard'
|
||||
_description = 'Loaner Return Wizard'
|
||||
|
||||
# =========================================================================
|
||||
# CHECKOUT REFERENCE
|
||||
# =========================================================================
|
||||
|
||||
checkout_id = fields.Many2one(
|
||||
'fusion.loaner.checkout',
|
||||
string='Checkout Record',
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# Display fields
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Product',
|
||||
related='checkout_id.product_id',
|
||||
readonly=True,
|
||||
)
|
||||
lot_id = fields.Many2one(
|
||||
'stock.lot',
|
||||
string='Serial Number',
|
||||
related='checkout_id.lot_id',
|
||||
readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Client',
|
||||
related='checkout_id.partner_id',
|
||||
readonly=True,
|
||||
)
|
||||
checkout_date = fields.Date(
|
||||
string='Checkout Date',
|
||||
related='checkout_id.checkout_date',
|
||||
readonly=True,
|
||||
)
|
||||
days_out = fields.Integer(
|
||||
string='Days Out',
|
||||
related='checkout_id.days_out',
|
||||
readonly=True,
|
||||
)
|
||||
checkout_condition = fields.Selection(
|
||||
related='checkout_id.checkout_condition',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# RETURN DETAILS
|
||||
# =========================================================================
|
||||
|
||||
return_date = fields.Date(
|
||||
string='Return Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
)
|
||||
return_condition = fields.Selection([
|
||||
('excellent', 'Excellent'),
|
||||
('good', 'Good'),
|
||||
('fair', 'Fair'),
|
||||
('needs_repair', 'Needs Repair'),
|
||||
('damaged', 'Damaged'),
|
||||
], string='Condition', required=True)
|
||||
return_notes = fields.Text(
|
||||
string='Notes',
|
||||
help='Any notes about the return condition or issues',
|
||||
)
|
||||
return_photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'loaner_return_wizard_photo_rel',
|
||||
'wizard_id',
|
||||
'attachment_id',
|
||||
string='Photos',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# DEFAULT GET
|
||||
# =========================================================================
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
|
||||
checkout_id = self._context.get('default_checkout_id')
|
||||
if checkout_id:
|
||||
checkout = self.env['fusion.loaner.checkout'].browse(checkout_id)
|
||||
res['checkout_id'] = checkout.id
|
||||
# Default return condition to checkout condition
|
||||
res['return_condition'] = checkout.checkout_condition
|
||||
|
||||
return res
|
||||
|
||||
# =========================================================================
|
||||
# ACTION
|
||||
# =========================================================================
|
||||
|
||||
def action_return(self):
|
||||
"""Process the loaner return."""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.checkout_id:
|
||||
raise UserError(_("No checkout record found."))
|
||||
|
||||
if self.checkout_id.state not in ('checked_out', 'overdue', 'rental_pending'):
|
||||
raise UserError(_("This loaner has already been returned or is not in a returnable state."))
|
||||
|
||||
# Create persistent attachments for photos
|
||||
photo_ids = []
|
||||
for photo in self.return_photo_ids:
|
||||
new_attachment = self.env['ir.attachment'].create({
|
||||
'name': photo.name,
|
||||
'datas': photo.datas,
|
||||
'res_model': 'fusion.loaner.checkout',
|
||||
'res_id': self.checkout_id.id,
|
||||
})
|
||||
photo_ids.append(new_attachment.id)
|
||||
|
||||
# Process return
|
||||
self.checkout_id.action_process_return(
|
||||
return_condition=self.return_condition,
|
||||
return_notes=self.return_notes,
|
||||
return_photos=photo_ids if photo_ids else None,
|
||||
)
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
Reference in New Issue
Block a user