This commit is contained in:
gsinghpal
2026-03-16 08:14:56 -04:00
parent fdca9518ab
commit e56974d46f
196 changed files with 19739 additions and 3471 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.7.3.0',
'version': '19.0.8.0.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -97,7 +97,6 @@
'data/mail_activity_type_data.xml',
'data/ir_cron_data.xml',
'data/ir_actions_server_data.xml',
'data/stock_location_data.xml',
'data/product_labor_data.xml',
'wizard/status_change_reason_wizard_views.xml',
'views/res_company_views.xml',
@@ -129,6 +128,7 @@
'wizard/odsp_pre_approved_wizard_views.xml',
'wizard/odsp_ready_delivery_wizard_views.xml',
'wizard/send_page11_wizard_views.xml',
'wizard/adp_import_wizard_views.xml',
'views/res_partner_views.xml',
'views/pdf_template_inherit_views.xml',
'views/dashboard_views.xml',
@@ -138,7 +138,6 @@
'views/adp_claims_views.xml',
'views/submission_history_views.xml',
'views/product_template_adp_views.xml',
'views/fusion_loaner_views.xml',
'views/page11_sign_request_views.xml',
'views/technician_task_views.xml',
'report/report_actions.xml',

View File

@@ -1,41 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<data noupdate="1">
<!-- Loaner Stock Location -->
<record id="stock_location_loaner" model="stock.location">
<field name="name">Loaner Stock</field>
<field name="usage">internal</field>
<field name="location_id" ref="stock.stock_location_stock"/>
</record>
<!-- Sequence for Loaner Checkout -->
<record id="seq_loaner_checkout" model="ir.sequence">
<field name="name">Loaner Checkout Sequence</field>
<field name="code">fusion.loaner.checkout</field>
<field name="prefix">LOAN/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<!-- Loaner Product Categories -->
<record id="product_category_loaner" model="product.category">
<field name="name">Loaner Equipment</field>
</record>
<record id="product_category_loaner_rollator" model="product.category">
<field name="name">Rollators</field>
<field name="parent_id" ref="product_category_loaner"/>
</record>
<record id="product_category_loaner_wheelchair" model="product.category">
<field name="name">Wheelchairs</field>
<field name="parent_id" ref="product_category_loaner"/>
</record>
<record id="product_category_loaner_powerchair" model="product.category">
<field name="name">Powerchairs</field>
<field name="parent_id" ref="product_category_loaner"/>
</record>
</data>
</odoo>

View File

@@ -18,8 +18,6 @@ from . import account_move_line
from . import account_payment
from . import account_payment_method_line
from . import submission_history
from . import fusion_loaner_checkout
from . import fusion_loaner_history
from . import client_profile
from . import adp_application_data
from . import xml_parser

View File

@@ -8,7 +8,7 @@ import re
import base64
import logging
import zipfile
from datetime import date, datetime
from datetime import date, datetime, timedelta
from odoo import models, fields, api, _
from odoo.exceptions import UserError
@@ -20,7 +20,30 @@ 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'
_order = 'year desc, month asc, posting_period_date desc, export_date desc, id desc'
_GROUPBY_ORDER = {
'year': 'year desc',
'month': 'month asc',
'posting_period_label': 'posting_period_label asc',
'posting_period_date': 'posting_period_date desc',
'user_id': 'user_id asc',
}
@api.model
def _read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None):
if not order and groupby:
parts = []
for field in groupby:
fname = field.split(':')[0] if isinstance(field, str) else field
if fname in self._GROUPBY_ORDER:
parts.append(self._GROUPBY_ORDER[fname])
if parts:
order = ', '.join(parts)
return super()._read_group(
domain, groupby=groupby, aggregates=aggregates,
having=having, offset=offset, limit=limit, order=order,
)
name = fields.Char(
string='Filename',
@@ -61,6 +84,12 @@ class ADPExportRecord(models.Model):
store=True,
help='Numeric month for proper ordering',
)
payment_date = fields.Date(
string='Payment Date',
compute='_compute_period_fields',
store=True,
help='Expected payment date (posting day + 10 calendar days = Monday)',
)
file_data = fields.Binary(
string='Export File',
@@ -100,6 +129,9 @@ class ADPExportRecord(models.Model):
default=lambda self: self.env.company,
index=True,
)
file_preview = fields.Text(
string='File Content',
)
notes = fields.Text(
string='Notes',
)
@@ -110,20 +142,93 @@ class ADPExportRecord(models.Model):
ppd = record.posting_period_date
if ppd:
record.year = str(ppd.year)
record.month = ppd.strftime('%B')
record.month = '%02d - %s' % (ppd.month, ppd.strftime('%B'))
record.month_number = ppd.month
record.posting_period_label = ppd.strftime('%b %d, %Y')
record.posting_period_label = 'Posting Schedule - %s' % ppd.strftime('%d/%m/%Y')
record.payment_date = ppd + timedelta(days=10)
else:
record.year = ''
record.month = ''
record.month_number = 0
record.posting_period_label = ''
record.payment_date = False
@api.depends('invoice_ids')
def _compute_invoice_count(self):
for record in self:
record.invoice_count = len(record.invoice_ids)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for record in records:
record._process_file_data()
return records
def _process_file_data(self):
"""Extract preview text and auto-link invoices from file content."""
self.ensure_one()
if not self.file_data:
return
try:
raw = base64.b64decode(self.file_data)
text = raw.decode('utf-8', errors='replace')
except Exception:
return
vals = {'file_preview': text}
lines = text.strip().splitlines()
if lines and not self.line_count:
vals['line_count'] = len(lines)
if not self.invoice_ids:
invoice_numbers = set()
for line in lines:
parts = line.split(',')
if len(parts) > 3 and parts[3].strip():
invoice_numbers.add(parts[3].strip())
if invoice_numbers:
invoices = self.env['account.move'].search([
('name', 'in', list(invoice_numbers)),
])
if invoices:
vals['invoice_ids'] = [(6, 0, invoices.ids)]
self.write(vals)
@api.model
def _get_posting_period_for_file(self, file_date):
"""Determine which posting period a file belongs to.
If the file was created/exported ON a posting date, it belongs to that
period. If it was created AFTER the posting date (even one day later),
it falls into the NEXT posting period because that posting has already
been submitted.
Handles dates before the configured base date correctly using Python's
floor division for negative offsets.
"""
if file_date is None:
file_date = date.today()
elif hasattr(file_date, 'date'):
file_date = file_date.date()
base_date = self._get_adp_posting_base_date()
frequency = self._get_adp_posting_frequency()
if frequency <= 0:
frequency = 14
days_diff = (file_date - base_date).days
cycles_passed = days_diff // frequency
current_posting = base_date + timedelta(days=cycles_passed * frequency)
if file_date <= current_posting:
return current_posting
return current_posting + timedelta(days=frequency)
def action_download(self):
"""Download the export file."""
self.ensure_one()
@@ -259,9 +364,9 @@ class ADPExportRecord(models.Model):
try:
vendor_code, file_date = self._parse_export_filename(doc.name)
if file_date:
posting_date = self._get_current_posting_date(file_date)
posting_date = self._get_posting_period_for_file(file_date)
else:
posting_date = self._get_current_posting_date(
posting_date = self._get_posting_period_for_file(
doc.create_date.date() if doc.create_date else date.today()
)

View File

@@ -1,823 +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, ValidationError
from markupsafe import Markup
from datetime import timedelta
import logging
_logger = logging.getLogger(__name__)
class FusionLoanerCheckout(models.Model):
"""Track loaner equipment checkouts and returns."""
_name = 'fusion.loaner.checkout'
_description = 'Loaner Equipment Checkout'
_order = 'checkout_date desc, id desc'
_inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.email.builder.mixin']
# =========================================================================
# REFERENCE FIELDS
# =========================================================================
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
ondelete='set null',
tracking=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
required=True,
tracking=True,
)
authorizer_id = fields.Many2one(
'res.partner',
string='Authorizer',
help='Therapist/Authorizer associated with this loaner',
)
sales_rep_id = fields.Many2one(
'res.users',
string='Sales Rep',
default=lambda self: self.env.user,
tracking=True,
)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
# =========================================================================
# PRODUCT & SERIAL
# =========================================================================
product_id = fields.Many2one(
'product.product',
string='Product',
required=True,
domain="[('x_fc_can_be_loaned', '=', True)]",
tracking=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
domain="[('product_id', '=', product_id)]",
tracking=True,
)
product_description = fields.Text(
string='Product Description',
related='product_id.description_sale',
)
# =========================================================================
# DATES
# =========================================================================
checkout_date = fields.Date(
string='Checkout Date',
required=True,
default=fields.Date.context_today,
tracking=True,
)
loaner_period_days = fields.Integer(
string='Loaner Period (Days)',
default=7,
help='Number of free loaner days before rental conversion',
)
expected_return_date = fields.Date(
string='Expected Return Date',
compute='_compute_expected_return_date',
store=True,
)
actual_return_date = fields.Date(
string='Actual Return Date',
tracking=True,
)
days_out = fields.Integer(
string='Days Out',
compute='_compute_days_out',
)
days_overdue = fields.Integer(
string='Days Overdue',
compute='_compute_days_overdue',
)
# =========================================================================
# STATUS
# =========================================================================
state = fields.Selection([
('draft', 'Draft'),
('checked_out', 'Checked Out'),
('overdue', 'Overdue'),
('rental_pending', 'Rental Conversion Pending'),
('returned', 'Returned'),
('converted_rental', 'Converted to Rental'),
('lost', 'Lost/Write-off'),
], string='Status', default='draft', tracking=True, required=True)
# =========================================================================
# LOCATION
# =========================================================================
delivery_address = fields.Text(
string='Delivery Address',
help='Where the loaner was delivered',
)
return_location_id = fields.Many2one(
'stock.location',
string='Return Location',
domain="[('usage', '=', 'internal')]",
help='Where the loaner was returned to (store, warehouse, etc.)',
tracking=True,
)
checked_out_by_id = fields.Many2one(
'res.users',
string='Checked Out By',
default=lambda self: self.env.user,
)
returned_to_id = fields.Many2one(
'res.users',
string='Returned To',
)
# =========================================================================
# CHECKOUT CONDITION
# =========================================================================
checkout_condition = fields.Selection([
('excellent', 'Excellent'),
('good', 'Good'),
('fair', 'Fair'),
('needs_repair', 'Needs Repair'),
], string='Checkout Condition', default='excellent')
checkout_notes = fields.Text(
string='Checkout Notes',
)
checkout_photo_ids = fields.Many2many(
'ir.attachment',
'fusion_loaner_checkout_photo_rel',
'checkout_id',
'attachment_id',
string='Checkout Photos',
)
# =========================================================================
# RETURN CONDITION
# =========================================================================
return_condition = fields.Selection([
('excellent', 'Excellent'),
('good', 'Good'),
('fair', 'Fair'),
('needs_repair', 'Needs Repair'),
('damaged', 'Damaged'),
], string='Return Condition')
return_notes = fields.Text(
string='Return Notes',
)
return_photo_ids = fields.Many2many(
'ir.attachment',
'fusion_loaner_return_photo_rel',
'checkout_id',
'attachment_id',
string='Return Photos',
)
# =========================================================================
# REMINDER TRACKING
# =========================================================================
reminder_day5_sent = fields.Boolean(
string='Day 5 Reminder Sent',
default=False,
)
reminder_day8_sent = fields.Boolean(
string='Day 8 Warning Sent',
default=False,
)
reminder_day10_sent = fields.Boolean(
string='Day 10 Final Notice Sent',
default=False,
)
# =========================================================================
# RENTAL CONVERSION
# =========================================================================
rental_order_id = fields.Many2one(
'sale.order',
string='Rental Order',
help='Sale order created when loaner converted to rental',
)
rental_conversion_date = fields.Date(
string='Rental Conversion Date',
)
# =========================================================================
# STOCK MOVES
# =========================================================================
checkout_move_id = fields.Many2one(
'stock.move',
string='Checkout Stock Move',
)
return_move_id = fields.Many2one(
'stock.move',
string='Return Stock Move',
)
# =========================================================================
# HISTORY
# =========================================================================
history_ids = fields.One2many(
'fusion.loaner.history',
'checkout_id',
string='History',
)
history_count = fields.Integer(
compute='_compute_history_count',
string='History Count',
)
# =========================================================================
# COMPUTED FIELDS
# =========================================================================
@api.depends('checkout_date', 'loaner_period_days')
def _compute_expected_return_date(self):
for record in self:
if record.checkout_date and record.loaner_period_days:
record.expected_return_date = record.checkout_date + timedelta(days=record.loaner_period_days)
else:
record.expected_return_date = False
@api.depends('checkout_date', 'actual_return_date')
def _compute_days_out(self):
today = fields.Date.today()
for record in self:
if record.checkout_date:
end_date = record.actual_return_date or today
record.days_out = (end_date - record.checkout_date).days
else:
record.days_out = 0
@api.depends('expected_return_date', 'actual_return_date', 'state')
def _compute_days_overdue(self):
today = fields.Date.today()
for record in self:
if record.state in ('returned', 'converted_rental', 'lost'):
record.days_overdue = 0
elif record.expected_return_date:
end_date = record.actual_return_date or today
overdue = (end_date - record.expected_return_date).days
record.days_overdue = max(0, overdue)
else:
record.days_overdue = 0
def _compute_history_count(self):
for record in self:
record.history_count = len(record.history_ids)
# =========================================================================
# 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
@api.onchange('sale_order_id')
def _onchange_sale_order_id(self):
if self.sale_order_id:
self.partner_id = self.sale_order_id.partner_id
self.authorizer_id = self.sale_order_id.x_fc_authorizer_id
self.sales_rep_id = self.sale_order_id.user_id
self.delivery_address = self.sale_order_id.partner_shipping_id.contact_address if self.sale_order_id.partner_shipping_id else ''
# =========================================================================
# CRUD
# =========================================================================
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('fusion.loaner.checkout') or _('New')
records = super().create(vals_list)
for record in records:
record._log_history('create', 'Loaner checkout created')
return records
# =========================================================================
# ACTIONS
# =========================================================================
def action_checkout(self):
"""Confirm the loaner checkout."""
self.ensure_one()
if self.state != 'draft':
raise UserError(_("Can only checkout from draft state."))
if not self.product_id:
raise UserError(_("Please select a product."))
self.write({'state': 'checked_out'})
self._log_history('checkout', f'Loaner checked out to {self.partner_id.name}')
# Stock move is non-blocking -- use savepoint so failure doesn't roll back checkout
try:
with self.env.cr.savepoint():
self._create_checkout_stock_move()
except Exception as e:
_logger.warning(f"Stock move failed for checkout {self.name} (non-blocking): {e}")
self._send_checkout_email()
# Post to chatter
self.message_post(
body=Markup(
'<div class="alert alert-success">'
f'<strong>Loaner Checked Out</strong><br/>'
f'Product: {self.product_id.name}<br/>'
f'Serial: {self.lot_id.name if self.lot_id else "N/A"}<br/>'
f'Expected Return: {self.expected_return_date}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def action_return(self):
"""Open return wizard."""
self.ensure_one()
return {
'name': _('Return Loaner'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.return.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_checkout_id': self.id,
},
}
def action_process_return(self, return_condition, return_notes=None, return_photos=None, return_location_id=None):
"""Process the loaner return."""
self.ensure_one()
if self.state not in ('checked_out', 'overdue', 'rental_pending'):
raise UserError(_("Cannot return a loaner that is not checked out."))
vals = {
'state': 'returned',
'actual_return_date': fields.Date.today(),
'return_condition': return_condition,
'return_notes': return_notes,
'returned_to_id': self.env.user.id,
}
if return_location_id:
vals['return_location_id'] = return_location_id
if return_photos:
vals['return_photo_ids'] = [(6, 0, return_photos)]
self.write(vals)
self._log_history('return', f'Loaner returned in {return_condition} condition')
try:
with self.env.cr.savepoint():
self._create_return_stock_move()
except Exception as e:
_logger.warning(f"Stock move failed for return {self.name} (non-blocking): {e}")
self._send_return_email()
# Post to chatter
self.message_post(
body=Markup(
'<div class="alert alert-info">'
f'<strong>Loaner Returned</strong><br/>'
f'Condition: {return_condition}<br/>'
f'Days Out: {self.days_out}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def action_mark_lost(self):
"""Mark loaner as lost."""
self.ensure_one()
self.write({'state': 'lost'})
self._log_history('lost', 'Loaner marked as lost/write-off')
self.message_post(
body=Markup(
'<div class="alert alert-danger">'
'<strong>Loaner Marked as Lost</strong><br/>'
f'Product: {self.product_id.name}<br/>'
f'Serial: {self.lot_id.name if self.lot_id else "N/A"}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def action_convert_to_rental(self):
"""Flag for rental conversion."""
self.ensure_one()
self.write({
'state': 'rental_pending',
'rental_conversion_date': fields.Date.today(),
})
self._log_history('rental_pending', 'Loaner flagged for rental conversion')
self._send_rental_conversion_email()
def action_view_history(self):
"""View loaner history."""
self.ensure_one()
return {
'name': _('Loaner History'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.history',
'view_mode': 'tree,form',
'domain': [('checkout_id', '=', self.id)],
'context': {'default_checkout_id': self.id},
}
def action_view_sale_order(self):
self.ensure_one()
if not self.sale_order_id:
return
return {
'name': self.sale_order_id.name,
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': self.sale_order_id.id,
}
def action_view_partner(self):
self.ensure_one()
if not self.partner_id:
return
return {
'name': self.partner_id.name,
'type': 'ir.actions.act_window',
'res_model': 'res.partner',
'view_mode': 'form',
'res_id': self.partner_id.id,
}
# =========================================================================
# STOCK MOVES
# =========================================================================
def _get_loaner_location(self):
"""Get the loaner stock location."""
location = self.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
if not location:
# Fallback to main stock
location = self.env.ref('stock.stock_location_stock')
return location
def _get_customer_location(self):
"""Get customer location for stock moves."""
return self.env.ref('stock.stock_location_customers')
def _create_checkout_stock_move(self):
"""Create stock move for checkout. Non-blocking -- checkout proceeds even if move fails."""
if not self.lot_id:
return # No serial tracking
try:
source_location = self._get_loaner_location()
dest_location = self._get_customer_location()
move_vals = {
'name': f'Loaner Checkout: {self.name}',
'product_id': self.product_id.id,
'product_uom_qty': 1,
'product_uom': self.product_id.uom_id.id,
'location_id': source_location.id,
'location_dest_id': dest_location.id,
'origin': self.name,
'company_id': self.company_id.id,
'procure_method': 'make_to_stock',
}
move = self.env['stock.move'].sudo().create(move_vals)
move._action_confirm()
move._action_assign()
# Set the lot on move line
if move.move_line_ids:
move.move_line_ids.write({'lot_id': self.lot_id.id})
move._action_done()
self.checkout_move_id = move.id
except Exception as e:
_logger.warning(f"Could not create checkout stock move (non-blocking): {e}")
def _create_return_stock_move(self):
"""Create stock move for return. Uses return_location_id if set, otherwise Loaner Stock."""
if not self.lot_id:
return
try:
source_location = self._get_customer_location()
dest_location = self.return_location_id or self._get_loaner_location()
move_vals = {
'name': f'Loaner Return: {self.name}',
'product_id': self.product_id.id,
'product_uom_qty': 1,
'product_uom': self.product_id.uom_id.id,
'location_id': source_location.id,
'location_dest_id': dest_location.id,
'origin': self.name,
'company_id': self.company_id.id,
'procure_method': 'make_to_stock',
}
move = self.env['stock.move'].sudo().create(move_vals)
move._action_confirm()
move._action_assign()
if move.move_line_ids:
move.move_line_ids.write({'lot_id': self.lot_id.id})
move._action_done()
self.return_move_id = move.id
except Exception as e:
_logger.warning(f"Could not create return stock move: {e}")
# =========================================================================
# HISTORY LOGGING
# =========================================================================
def _log_history(self, action, notes=None):
"""Log action to history."""
self.ensure_one()
self.env['fusion.loaner.history'].create({
'checkout_id': self.id,
'lot_id': self.lot_id.id if self.lot_id else False,
'action': action,
'notes': notes,
})
# =========================================================================
# EMAIL METHODS
# =========================================================================
def _get_email_recipients(self):
"""Get all email recipients for loaner notifications."""
recipients = {
'client_email': self.partner_id.email if self.partner_id else None,
'authorizer_email': self.authorizer_id.email if self.authorizer_id else None,
'sales_rep_email': self.sales_rep_id.email if self.sales_rep_id else None,
'office_emails': [],
}
# Get office emails from company
company = self.company_id or self.env.company
office_partners = company.sudo().x_fc_office_notification_ids
recipients['office_emails'] = [p.email for p in office_partners if p.email]
return recipients
def _send_checkout_email(self):
"""Send checkout confirmation email to all parties."""
self.ensure_one()
recipients = self._get_email_recipients()
to_emails = [e for e in [recipients['client_email'], recipients['authorizer_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
if not to_emails:
return False
client_name = self.partner_id.name or 'Client'
product_name = self.product_id.name or 'Product'
expected_return = self.expected_return_date.strftime('%B %d, %Y') if self.expected_return_date else 'N/A'
body_html = self._email_build(
title='Loaner Equipment Checkout',
summary=f'Loaner equipment has been checked out for <strong>{client_name}</strong>.',
email_type='info',
sections=[('Loaner Details', [
('Reference', self.name),
('Product', product_name),
('Serial Number', self.lot_id.name if self.lot_id else None),
('Checkout Date', self.checkout_date.strftime('%B %d, %Y') if self.checkout_date else None),
('Expected Return', expected_return),
('Loaner Period', f'{self.loaner_period_days} days'),
])],
note='<strong>Important:</strong> Please return the loaner equipment by the expected return date. '
'If not returned on time, rental charges may apply.',
note_color='#d69e2e',
)
try:
self.env['mail.mail'].sudo().create({
'subject': f'Loaner Checkout - {product_name} - {self.name}',
'body_html': body_html,
'email_to': ', '.join(to_emails),
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'fusion.loaner.checkout', 'res_id': self.id,
}).send()
return True
except Exception as e:
_logger.error(f"Failed to send checkout email for {self.name}: {e}")
return False
def _send_return_email(self):
"""Send return confirmation email."""
self.ensure_one()
recipients = self._get_email_recipients()
to_emails = [e for e in [recipients['client_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e]
if not to_emails:
return False
client_name = self.partner_id.name or 'Client'
product_name = self.product_id.name or 'Product'
body_html = self._email_build(
title='Loaner Equipment Returned',
summary=f'Thank you for returning the loaner equipment, <strong>{client_name}</strong>.',
email_type='success',
sections=[('Return Details', [
('Reference', self.name),
('Product', product_name),
('Return Date', self.actual_return_date.strftime('%B %d, %Y') if self.actual_return_date else None),
('Condition', self.return_condition or None),
('Days Out', str(self.days_out)),
])],
)
try:
self.env['mail.mail'].sudo().create({
'subject': f'Loaner Returned - {product_name} - {self.name}',
'body_html': body_html,
'email_to': ', '.join(to_emails),
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'fusion.loaner.checkout', 'res_id': self.id,
}).send()
return True
except Exception as e:
_logger.error(f"Failed to send return email for {self.name}: {e}")
return False
def _send_rental_conversion_email(self):
"""Send rental conversion notification."""
self.ensure_one()
recipients = self._get_email_recipients()
to_emails = [e for e in [recipients['client_email'], recipients['authorizer_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
if not to_emails and not cc_emails:
return False
client_name = self.partner_id.name or 'Client'
product_name = self.product_id.name or 'Product'
weekly_rate = self.product_id.x_fc_rental_price_weekly or 0
monthly_rate = self.product_id.x_fc_rental_price_monthly or 0
body_html = self._email_build(
title='Loaner Rental Conversion Notice',
summary=f'The loaner equipment for <strong>{client_name}</strong> has exceeded the free loaner period.',
email_type='urgent',
sections=[('Equipment Details', [
('Reference', self.name),
('Product', product_name),
('Days Out', str(self.days_out)),
('Days Overdue', str(self.days_overdue)),
('Weekly Rental Rate', f'${weekly_rate:.2f}'),
('Monthly Rental Rate', f'${monthly_rate:.2f}'),
])],
note='<strong>Action required:</strong> Please return the equipment or contact us to arrange '
'a rental agreement. Rental charges will apply until the equipment is returned.',
note_color='#c53030',
)
email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
try:
self.env['mail.mail'].sudo().create({
'subject': f'Loaner Rental Conversion - {product_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'fusion.loaner.checkout', 'res_id': self.id,
}).send()
return True
except Exception as e:
_logger.error(f"Failed to send rental conversion email for {self.name}: {e}")
return False
def _send_reminder_email(self, reminder_type):
"""Send reminder email based on type (day5, day8, day10)."""
self.ensure_one()
recipients = self._get_email_recipients()
client_name = self.partner_id.name or 'Client'
product_name = self.product_id.name or 'Product'
expected_return = self.expected_return_date.strftime('%B %d, %Y') if self.expected_return_date else 'N/A'
if reminder_type == 'day5':
to_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
cc_emails = []
subject = f'Loaner Reminder: {product_name} - Day 5'
email_type = 'attention'
message = (f'The loaner equipment for {client_name} has been out for 5 days. '
f'Please follow up to arrange return.')
elif reminder_type == 'day8':
to_emails = [e for e in [recipients['client_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
subject = f'Loaner Return Reminder - {product_name}'
email_type = 'attention'
message = (f'Your loaner equipment has been out for 8 days. '
f'Please return it soon or it may be converted to a rental.')
else:
to_emails = [e for e in [recipients['client_email'], recipients['authorizer_email']] if e]
cc_emails = [e for e in [recipients['sales_rep_email']] if e] + recipients['office_emails']
subject = f'Loaner Return Required - {product_name}'
email_type = 'urgent'
message = (f'Your loaner equipment has been out for {self.days_out} days. '
f'If not returned, rental charges will apply.')
if not to_emails:
return False
body_html = self._email_build(
title='Loaner Equipment Reminder',
summary=message,
email_type=email_type,
sections=[('Loaner Details', [
('Reference', self.name),
('Client', client_name),
('Product', product_name),
('Days Out', str(self.days_out)),
('Expected Return', expected_return),
])],
)
try:
self.env['mail.mail'].sudo().create({
'subject': subject,
'body_html': body_html,
'email_to': ', '.join(to_emails),
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'fusion.loaner.checkout', 'res_id': self.id,
}).send()
return True
except Exception as e:
_logger.error(f"Failed to send {reminder_type} reminder for {self.name}: {e}")
return False
# =========================================================================
# CRON METHODS
# =========================================================================
@api.model
def _cron_check_overdue_loaners(self):
"""Daily cron to check for overdue loaners and send reminders."""
today = fields.Date.today()
# Find all active loaners
active_loaners = self.search([
('state', 'in', ['checked_out', 'overdue', 'rental_pending']),
])
for loaner in active_loaners:
days_out = loaner.days_out
# Update overdue status
if loaner.state == 'checked_out' and loaner.expected_return_date and today > loaner.expected_return_date:
loaner.write({'state': 'overdue'})
loaner._log_history('overdue', f'Loaner is now overdue by {loaner.days_overdue} days')
# Day 5 reminder
if days_out >= 5 and not loaner.reminder_day5_sent:
loaner._send_reminder_email('day5')
loaner.reminder_day5_sent = True
loaner._log_history('reminder_sent', 'Day 5 reminder sent')
# Day 8 warning
if days_out >= 8 and not loaner.reminder_day8_sent:
loaner._send_reminder_email('day8')
loaner.reminder_day8_sent = True
loaner._log_history('reminder_sent', 'Day 8 rental warning sent')
# Day 10 final notice
if days_out >= 10 and not loaner.reminder_day10_sent:
loaner._send_reminder_email('day10')
loaner.reminder_day10_sent = True
loaner._log_history('reminder_sent', 'Day 10 final notice sent')
# Flag for rental conversion
if loaner.state != 'rental_pending':
loaner.action_convert_to_rental()

View File

@@ -1,105 +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, _
import logging
_logger = logging.getLogger(__name__)
class FusionLoanerHistory(models.Model):
"""Audit trail for loaner equipment actions."""
_name = 'fusion.loaner.history'
_description = 'Loaner History Log'
_order = 'action_date desc, id desc'
# =========================================================================
# REFERENCE FIELDS
# =========================================================================
checkout_id = fields.Many2one(
'fusion.loaner.checkout',
string='Checkout Record',
ondelete='cascade',
required=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
help='The serial number this action relates to',
)
product_id = fields.Many2one(
'product.product',
string='Product',
related='checkout_id.product_id',
store=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
related='checkout_id.partner_id',
store=True,
)
# =========================================================================
# ACTION DETAILS
# =========================================================================
action = fields.Selection([
('create', 'Created'),
('checkout', 'Checked Out'),
('return', 'Returned'),
('condition_update', 'Condition Updated'),
('reminder_sent', 'Reminder Sent'),
('overdue', 'Marked Overdue'),
('rental_pending', 'Rental Conversion Pending'),
('rental_converted', 'Converted to Rental'),
('lost', 'Marked as Lost'),
('note', 'Note Added'),
], string='Action', required=True)
action_date = fields.Datetime(
string='Date/Time',
default=fields.Datetime.now,
required=True,
)
user_id = fields.Many2one(
'res.users',
string='User',
default=lambda self: self.env.user,
required=True,
)
notes = fields.Text(
string='Notes',
)
# =========================================================================
# DISPLAY
# =========================================================================
def _get_action_label(self):
"""Get human-readable action label."""
action_labels = dict(self._fields['action'].selection)
return action_labels.get(self.action, self.action)
def name_get(self):
result = []
for record in self:
name = f"{record.checkout_id.name} - {record._get_action_label()}"
result.append((record.id, name))
return result
# =========================================================================
# SEARCH BY SERIAL
# =========================================================================
@api.model
def get_history_by_serial(self, lot_id):
"""Get all history for a specific serial number."""
return self.search([('lot_id', '=', lot_id)], order='action_date desc')
@api.model
def get_history_by_product(self, product_id):
"""Get all history for a specific product."""
return self.search([('product_id', '=', product_id)], order='action_date desc')

View File

@@ -65,86 +65,6 @@ class ProductTemplate(models.Model):
readonly=True,
)
# ==========================================================================
# LOANER PRODUCT FIELDS
# ==========================================================================
x_fc_can_be_loaned = fields.Boolean(
string='Can be Loaned',
default=False,
help='If checked, this product can be loaned out to clients',
)
x_fc_loaner_period_days = fields.Integer(
string='Loaner Period (Days)',
default=7,
help='Default number of free loaner days before rental conversion',
)
x_fc_rental_price_weekly = fields.Float(
string='Weekly Rental Price',
digits='Product Price',
help='Rental price per week if loaner converts to rental',
)
x_fc_rental_price_monthly = fields.Float(
string='Monthly Rental Price',
digits='Product Price',
help='Rental price per month if loaner converts to rental',
)
# ==========================================================================
# LOANER EQUIPMENT FIELDS
# ==========================================================================
x_fc_equipment_type = fields.Selection([
('type_1_walker', 'Type 1 Walker'),
('type_2_mw', 'Type 2 MW'),
('type_2_pw', 'Type 2 PW'),
('type_2_walker', 'Type 2 Walker'),
('type_3_mw', 'Type 3 MW'),
('type_3_pw', 'Type 3 PW'),
('type_3_walker', 'Type 3 Walker'),
('type_4_mw', 'Type 4 MW'),
('type_5_mw', 'Type 5 MW'),
('ceiling_lift', 'Ceiling Lift'),
('mobility_scooter', 'Mobility Scooter'),
('patient_lift', 'Patient Lift'),
('transport_wheelchair', 'Transport Wheelchair'),
('standard_wheelchair', 'Standard Wheelchair'),
('power_wheelchair', 'Power Wheelchair'),
('cushion', 'Cushion'),
('backrest', 'Backrest'),
('stairlift', 'Stairlift'),
('others', 'Others'),
], string='Equipment Type')
x_fc_wheelchair_category = fields.Selection([
('type_1', 'Type 1'),
('type_2', 'Type 2'),
('type_3', 'Type 3'),
('type_4', 'Type 4'),
('type_5', 'Type 5'),
], string='Wheelchair Category')
x_fc_seat_width = fields.Char(string='Seat Width')
x_fc_seat_depth = fields.Char(string='Seat Depth')
x_fc_seat_height = fields.Char(string='Seat Height')
x_fc_storage_location = fields.Selection([
('warehouse', 'Warehouse'),
('westin_brampton', 'Westin Brampton'),
('mobility_etobicoke', 'Mobility Etobicoke'),
('scarborough_storage', 'Scarborough Storage'),
('client_loaned', 'Client/Loaned'),
('rented_out', 'Rented Out'),
], string='Storage Location')
x_fc_listing_type = fields.Selection([
('owned', 'Owned'),
('borrowed', 'Borrowed'),
], string='Listing Type')
x_fc_asset_number = fields.Char(string='Asset Number')
x_fc_package_info = fields.Text(string='Package Information')
# ==========================================================================
# ONCHANGE / CONSTRAINTS
# ==========================================================================

View File

@@ -263,96 +263,6 @@ class SaleOrder(models.Model):
action['res_id'] = self.x_fc_technician_task_ids.id
return action
# LOANER EQUIPMENT TRACKING
# ==========================================================================
x_fc_loaner_checkout_ids = fields.One2many(
'fusion.loaner.checkout',
'sale_order_id',
string='Loaner Checkouts',
help='Loaner equipment checked out for this order',
)
x_fc_loaner_count = fields.Integer(
string='Loaners',
compute='_compute_loaner_count',
)
x_fc_active_loaner_count = fields.Integer(
string='Active Loaners',
compute='_compute_loaner_count',
)
x_fc_has_overdue_loaner = fields.Boolean(
string='Has Overdue Loaner',
compute='_compute_loaner_count',
help='True if any active loaner is past its expected return date',
)
@api.depends('x_fc_loaner_checkout_ids', 'x_fc_loaner_checkout_ids.state',
'x_fc_loaner_checkout_ids.expected_return_date')
def _compute_loaner_count(self):
"""Compute loaner counts and overdue status for this order."""
today = fields.Date.today()
for order in self:
active = order.x_fc_loaner_checkout_ids.filtered(
lambda l: l.state in ('checked_out', 'overdue', 'rental_pending')
)
order.x_fc_loaner_count = len(order.x_fc_loaner_checkout_ids)
order.x_fc_active_loaner_count = len(active)
order.x_fc_has_overdue_loaner = any(
l.state == 'overdue' or (l.expected_return_date and l.expected_return_date < today)
for l in active
)
def action_view_loaners(self):
"""Open the loaner checkouts for this order."""
self.ensure_one()
action = {
'name': 'Loaner Checkouts',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout',
'view_mode': 'tree,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
if len(self.x_fc_loaner_checkout_ids) == 1:
action['view_mode'] = 'form'
action['res_id'] = self.x_fc_loaner_checkout_ids.id
return action
def action_checkout_loaner(self):
"""Open the loaner checkout wizard."""
self.ensure_one()
return {
'name': 'Checkout Loaner',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_sale_order_id': self.id,
'default_partner_id': self.partner_id.id,
'default_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False,
},
}
def action_checkin_loaner(self):
"""Open the return wizard for the active loaner on this order."""
self.ensure_one()
active_loaners = self.x_fc_loaner_checkout_ids.filtered(
lambda l: l.state in ('checked_out', 'overdue', 'rental_pending')
)
if not active_loaners:
raise UserError("No active loaners to check in for this order.")
if len(active_loaners) == 1:
return active_loaners.action_return()
# Multiple active loaners - show the list so user can pick which one to return
return {
'name': 'Return Loaner',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout',
'view_mode': 'tree,form',
'domain': [('id', 'in', active_loaners.ids)],
'target': 'current',
}
def action_ready_for_delivery(self):
"""Open the task scheduling form to schedule a delivery task.

View File

@@ -21,12 +21,6 @@ access_fusion_ready_for_submission_wizard,fusion.ready.for.submission.wizard.use
access_fusion_ready_to_bill_wizard,fusion.ready.to.bill.wizard.user,model_fusion_claims_ready_to_bill_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_submission_history_user,fusion.submission.history.user,model_fusion_submission_history,sales_team.group_sale_salesman,1,1,1,0
access_fusion_submission_history_manager,fusion.submission.history.manager,model_fusion_submission_history,sales_team.group_sale_manager,1,1,1,1
access_fusion_loaner_checkout_user,fusion.loaner.checkout.user,model_fusion_loaner_checkout,sales_team.group_sale_salesman,1,1,1,0
access_fusion_loaner_checkout_manager,fusion.loaner.checkout.manager,model_fusion_loaner_checkout,sales_team.group_sale_manager,1,1,1,1
access_fusion_loaner_history_user,fusion.loaner.history.user,model_fusion_loaner_history,sales_team.group_sale_salesman,1,0,0,0
access_fusion_loaner_history_manager,fusion.loaner.history.manager,model_fusion_loaner_history,sales_team.group_sale_manager,1,1,1,1
access_fusion_loaner_checkout_wizard,fusion.loaner.checkout.wizard.user,model_fusion_loaner_checkout_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_loaner_return_wizard,fusion.loaner.return.wizard.user,model_fusion_loaner_return_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_ready_for_delivery_wizard,fusion.ready.for.delivery.wizard.user,model_fusion_ready_for_delivery_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_client_profile_user,fusion.client.profile.user,model_fusion_client_profile,sales_team.group_sale_salesman,1,1,1,0
access_fusion_client_profile_manager,fusion.client.profile.manager,model_fusion_client_profile,sales_team.group_sale_manager,1,1,1,1
@@ -68,4 +62,5 @@ access_fusion_page11_sign_request_user,fusion.page11.sign.request.user,model_fus
access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,model_fusion_page11_sign_request,sales_team.group_sale_manager,1,1,1,1
access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0
access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1
access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
21 access_fusion_ready_to_bill_wizard fusion.ready.to.bill.wizard.user model_fusion_claims_ready_to_bill_wizard sales_team.group_sale_salesman 1 1 1 1
22 access_fusion_submission_history_user fusion.submission.history.user model_fusion_submission_history sales_team.group_sale_salesman 1 1 1 0
23 access_fusion_submission_history_manager fusion.submission.history.manager model_fusion_submission_history sales_team.group_sale_manager 1 1 1 1
access_fusion_loaner_checkout_user fusion.loaner.checkout.user model_fusion_loaner_checkout sales_team.group_sale_salesman 1 1 1 0
access_fusion_loaner_checkout_manager fusion.loaner.checkout.manager model_fusion_loaner_checkout sales_team.group_sale_manager 1 1 1 1
access_fusion_loaner_history_user fusion.loaner.history.user model_fusion_loaner_history sales_team.group_sale_salesman 1 0 0 0
access_fusion_loaner_history_manager fusion.loaner.history.manager model_fusion_loaner_history sales_team.group_sale_manager 1 1 1 1
access_fusion_loaner_checkout_wizard fusion.loaner.checkout.wizard.user model_fusion_loaner_checkout_wizard sales_team.group_sale_salesman 1 1 1 1
access_fusion_loaner_return_wizard fusion.loaner.return.wizard.user model_fusion_loaner_return_wizard sales_team.group_sale_salesman 1 1 1 1
24 access_fusion_ready_for_delivery_wizard fusion.ready.for.delivery.wizard.user model_fusion_ready_for_delivery_wizard sales_team.group_sale_salesman 1 1 1 1
25 access_fusion_client_profile_user fusion.client.profile.user model_fusion_client_profile sales_team.group_sale_salesman 1 1 1 0
26 access_fusion_client_profile_manager fusion.client.profile.manager model_fusion_client_profile sales_team.group_sale_manager 1 1 1 1
62 access_fusion_page11_sign_request_manager fusion.page11.sign.request.manager model_fusion_page11_sign_request sales_team.group_sale_manager 1 1 1 1
63 access_fusion_page11_sign_request_public fusion.page11.sign.request.public model_fusion_page11_sign_request base.group_public 1 0 0 0
64 access_fusion_send_page11_wizard_user fusion_claims.send.page11.wizard.user model_fusion_claims_send_page11_wizard sales_team.group_sale_salesman 1 1 1 1
65 access_fusion_send_page11_wizard_manager fusion_claims.send.page11.wizard.manager model_fusion_claims_send_page11_wizard sales_team.group_sale_manager 1 1 1 1
66 access_fusion_adp_import_wizard_user fusion_claims.adp.import.wizard.user model_fusion_claims_adp_import_wizard account.group_account_invoice 1 1 1 1

View File

@@ -756,4 +756,16 @@ html.dark, .o_dark {
}
}
.adp_file_preview {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre;
overflow-x: auto;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 12px;
max-height: 500px;
overflow-y: auto;
}

View File

@@ -220,8 +220,7 @@
<field name="x_fc_client_portion_total" widget="monetary" sum="Total Client" optional="show"/>
<field name="amount_total" widget="monetary" sum="Grand Total" optional="show"/>
<!-- Loaner / Status -->
<field name="x_fc_loaner_count" string="Loaners" optional="hide"/>
<!-- Status -->
<field name="x_fc_on_hold_date" optional="hide"/>
<field name="x_fc_case_locked" optional="hide"/>
<field name="state" widget="badge" decoration-success="state == 'sale'"
@@ -1733,105 +1732,73 @@ else:
parent="menu_adp_claims_root"
sequence="20"/>
<!-- Top-level quick access -->
<menuitem id="menu_adp_all_orders" name="All ADP Orders" parent="menu_fc_adp"
action="action_adp_orders_all" sequence="1"/>
<menuitem id="menu_adp_invoices" name="ADP Invoices" parent="menu_fc_adp"
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"
<!-- Orders & Billing -->
<menuitem id="menu_adp_orders_billing"
name="Orders &amp; Billing"
parent="menu_fc_adp"
sequence="5"/>
<menuitem id="menu_adp_invoices" name="ADP Invoices" parent="menu_adp_orders_billing"
action="action_adp_invoices" sequence="1"/>
<menuitem id="menu_adp_client_invoices" name="Client Invoices" parent="menu_adp_orders_billing"
action="action_adp_client_invoices" sequence="2"/>
<menuitem id="menu_adp_ready_billing" name="Ready for Billing" parent="menu_adp_orders_billing"
action="action_adp_ready_billing" sequence="3"/>
<menuitem id="menu_adp_billed" name="Billed to ADP" parent="menu_adp_orders_billing"
action="action_adp_billed" sequence="4"/>
<menuitem id="menu_adp_export_files" name="Claim Submission Files" parent="menu_adp_orders_billing"
action="action_adp_export_records" sequence="5"/>
<!-- Pre-Submission Pipeline -->
<menuitem id="menu_adp_pre_submission"
name="Pre-Submission"
parent="menu_fc_adp"
action="action_adp_quotations"
sequence="10"/>
<menuitem id="menu_adp_quotations" name="Quotation Stage" parent="menu_adp_pre_submission"
action="action_adp_quotations" sequence="1"/>
<menuitem id="menu_adp_assessment_scheduled" name="Assessment Scheduled" parent="menu_adp_pre_submission"
action="action_adp_assessment_scheduled" sequence="2"/>
<menuitem id="menu_adp_assessment_completed" name="Waiting for Application" parent="menu_adp_pre_submission"
action="action_adp_assessment_completed" sequence="3"/>
<menuitem id="menu_adp_application_received" name="Application Received" parent="menu_adp_pre_submission"
action="action_adp_application_received" sequence="4"/>
<menuitem id="menu_adp_ready_submission" name="Ready for Submission" parent="menu_adp_pre_submission"
action="action_adp_ready_submission" sequence="5"/>
<menuitem id="menu_adp_assessment_scheduled"
name="Assessment Scheduled"
<!-- ADP Review -->
<menuitem id="menu_adp_review"
name="ADP Review"
parent="menu_fc_adp"
action="action_adp_assessment_scheduled"
sequence="12"/>
<menuitem id="menu_adp_assessment_completed"
name="Waiting for Application"
parent="menu_fc_adp"
action="action_adp_assessment_completed"
sequence="14"/>
<menuitem id="menu_adp_application_received"
name="Application Received"
parent="menu_fc_adp"
action="action_adp_application_received"
sequence="16"/>
<menuitem id="menu_adp_ready_submission"
name="Ready for Submission"
parent="menu_fc_adp"
action="action_adp_ready_submission"
sequence="18"/>
<menuitem id="menu_adp_pending_approval"
name="Application Submitted"
parent="menu_fc_adp"
action="action_adp_pending_approval"
sequence="20"/>
<menuitem id="menu_adp_pending_approval" name="Application Submitted" parent="menu_adp_review"
action="action_adp_pending_approval" sequence="1"/>
<menuitem id="menu_adp_accepted" name="Accepted by ADP" parent="menu_adp_review"
action="action_adp_accepted" sequence="2"/>
<menuitem id="menu_adp_approved" name="Application Approved" parent="menu_adp_review"
action="action_adp_approved" sequence="3"/>
<menuitem id="menu_adp_rejected" name="Rejected by ADP" parent="menu_adp_review"
action="action_adp_rejected" sequence="4"/>
<menuitem id="menu_adp_needs_correction" name="Needs Correction" parent="menu_adp_review"
action="action_adp_needs_correction" sequence="5"/>
<menuitem id="menu_adp_accepted"
name="Accepted by ADP"
<!-- Fulfillment -->
<menuitem id="menu_adp_fulfillment"
name="Fulfillment"
parent="menu_fc_adp"
action="action_adp_accepted"
sequence="21"/>
<menuitem id="menu_adp_rejected"
name="Rejected by ADP"
parent="menu_fc_adp"
action="action_adp_rejected"
sequence="22"/>
<menuitem id="menu_adp_needs_correction"
name="Needs Correction"
parent="menu_fc_adp"
action="action_adp_needs_correction"
sequence="23"/>
<menuitem id="menu_adp_approved"
name="Application Approved"
parent="menu_fc_adp"
action="action_adp_approved"
sequence="25"/>
<menuitem id="menu_adp_ready_delivery"
name="Ready for Delivery"
parent="menu_fc_adp"
action="action_adp_ready_delivery"
sequence="27"/>
<menuitem id="menu_adp_ready_billing"
name="Ready for Billing"
parent="menu_fc_adp"
action="action_adp_ready_billing"
sequence="30"/>
<menuitem id="menu_adp_ready_delivery" name="Ready for Delivery" parent="menu_adp_fulfillment"
action="action_adp_ready_delivery" sequence="1"/>
<menuitem id="menu_adp_closed" name="Case Closed" parent="menu_adp_fulfillment"
action="action_adp_closed" sequence="2"/>
<menuitem id="menu_adp_billed"
name="Billed to ADP"
parent="menu_fc_adp"
action="action_adp_billed"
sequence="35"/>
<menuitem id="menu_adp_closed"
name="Case Closed"
parent="menu_fc_adp"
action="action_adp_closed"
sequence="40"/>
<!-- ADP Special Statuses -->
<!-- Special Statuses -->
<menuitem id="menu_adp_special_statuses"
name="Special Statuses"
parent="menu_fc_adp"
sequence="50"/>
<menuitem id="menu_adp_on_hold" name="On Hold" parent="menu_adp_special_statuses"
action="action_adp_on_hold" sequence="10"/>
<menuitem id="menu_adp_withdrawn" name="Withdrawn" parent="menu_adp_special_statuses"
@@ -2037,6 +2004,8 @@ else:
action="action_device_import_wizard" sequence="20"/>
<menuitem id="menu_import_xml_files" name="Import XML Files" parent="menu_adp_config"
action="action_xml_import_wizard" sequence="30"/>
<menuitem id="menu_adp_import_files" name="Import Submission Files" parent="menu_adp_config"
action="action_adp_import_wizard" sequence="35"/>
<menuitem id="menu_fusion_claims_settings" name="Settings" parent="menu_adp_config"
action="action_fusion_claims_settings" sequence="90"/>

View File

@@ -12,10 +12,12 @@
<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">
<list string="ADP Claim Submission Files"
default_order="year desc, month asc, posting_period_date desc, export_date desc">
<field name="name"/>
<field name="export_date"/>
<field name="posting_period_label" string="Posting Period"/>
<field name="payment_date" string="Payment Date"/>
<field name="vendor_code"/>
<field name="line_count" string="Lines"/>
<field name="invoice_count" string="Invoices"/>
@@ -27,6 +29,44 @@
</field>
</record>
<!-- ===================================================================== -->
<!-- ADP EXPORT RECORD: Kanban View -->
<!-- ===================================================================== -->
<record id="view_adp_export_record_kanban" model="ir.ui.view">
<field name="name">fusion_claims.adp.export.record.kanban</field>
<field name="model">fusion_claims.adp.export.record</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile" default_order="year desc, month asc, posting_period_date desc, export_date desc">
<templates>
<t t-name="card">
<div class="d-flex justify-content-between mb-1">
<strong><field name="name"/></strong>
<field name="vendor_code" class="text-muted"/>
</div>
<div class="text-muted small mb-1">
<field name="posting_period_label"/>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="small">
<span class="me-3"><i class="fa fa-list-ol me-1"/>
<field name="line_count"/> lines</span>
<span><i class="fa fa-file-text-o me-1"/>
<field name="invoice_count"/> invoices</span>
</div>
<field name="user_id" widget="many2one_avatar_user"/>
</div>
<div class="text-muted small mt-1">
<i class="fa fa-calendar me-1"/><field name="export_date"/>
</div>
<field name="year" invisible="1"/>
<field name="month" invisible="1"/>
<field name="month_number" invisible="1"/>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================================================================== -->
<!-- ADP EXPORT RECORD: Form View -->
<!-- ===================================================================== -->
@@ -34,20 +74,29 @@
<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">
<form string="ADP Claim Submission File">
<header>
<button name="action_download" string="Download File"
type="object" class="btn-primary" icon="fa-download"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-pencil-square-o"
invisible="invoice_count == 0">
<field name="invoice_count" widget="statinfo"
string="Invoices"/>
</button>
</div>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
<h1><field name="name"/></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="payment_date" readonly="1"/>
<field name="vendor_code" readonly="1"/>
<field name="line_count" readonly="1"/>
</group>
@@ -59,13 +108,20 @@
</group>
</group>
<notebook>
<page string="File Preview" name="preview"
invisible="not file_preview">
<field name="file_preview" readonly="1" nolabel="1"
widget="text"/>
</page>
<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="partner_id" string="Customer"
widget="many2one"/>
<field name="invoice_date"/>
<field name="amount_total" string="Total"/>
<field name="currency_id" column_invisible="True"/>
<field name="amount_total" string="Total" sum="Grand Total"/>
<field name="state" widget="badge"
decoration-success="state == 'posted'"
decoration-info="state == 'draft'"/>
@@ -88,7 +144,7 @@
<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">
<search string="ADP Claim Submission Files">
<field name="name"/>
<field name="vendor_code"/>
<field name="user_id"/>
@@ -98,7 +154,7 @@
<filter string="Month" name="group_month"
context="{'group_by': 'month'}"/>
<filter string="Posting Period" name="group_posting"
context="{'group_by': 'posting_period_date'}"/>
context="{'group_by': 'posting_period_label'}"/>
<filter string="Exported By" name="group_user"
context="{'group_by': 'user_id'}"/>
</search>
@@ -109,17 +165,17 @@
<!-- ADP EXPORT RECORD: Action -->
<!-- ===================================================================== -->
<record id="action_adp_export_records" model="ir.actions.act_window">
<field name="name">Export Files</field>
<field name="name">Claim Submission Files</field>
<field name="res_model">fusion_claims.adp.export.record</field>
<field name="view_mode">list,form</field>
<field name="view_mode">list,kanban,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
No claim submission files yet
</p>
<p>
ADP export files will appear here after you export invoices using the
ADP claim submission files will appear here after you export invoices using the
<strong>Export ADP</strong> button on ADP portion invoices.
</p>
</field>

View File

@@ -1,525 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<!-- ===================================================================== -->
<!-- LOANER CHECKOUT VIEWS -->
<!-- ===================================================================== -->
<!-- List View -->
<record id="view_fusion_loaner_checkout_list" model="ir.ui.view">
<field name="name">fusion.loaner.checkout.list</field>
<field name="model">fusion.loaner.checkout</field>
<field name="arch" type="xml">
<list decoration-danger="state == 'overdue'"
decoration-warning="state == 'rental_pending'"
decoration-muted="state in ('returned', 'lost')"
default_order="checkout_date desc, id desc">
<field name="name"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="lot_id" optional="show"/>
<field name="checkout_date"/>
<field name="expected_return_date"/>
<field name="actual_return_date" optional="hide"/>
<field name="days_out"/>
<field name="days_overdue" optional="hide"/>
<field name="sales_rep_id" optional="hide"/>
<field name="checkout_condition" optional="hide"/>
<field name="return_condition" optional="hide"/>
<field name="sale_order_id" optional="hide"/>
<field name="state" widget="badge"
decoration-info="state == 'draft'"
decoration-success="state == 'checked_out'"
decoration-danger="state in ('overdue', 'lost')"
decoration-warning="state == 'rental_pending'"
decoration-muted="state in ('returned', 'converted_rental')"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_fusion_loaner_checkout_form" model="ir.ui.view">
<field name="name">fusion.loaner.checkout.form</field>
<field name="model">fusion.loaner.checkout</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_checkout" type="object" string="Confirm Checkout"
class="btn-primary" invisible="state != 'draft'"/>
<button name="action_return" type="object" string="Return Loaner"
class="btn-success" invisible="state not in ('checked_out', 'overdue', 'rental_pending')"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,checked_out,returned"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not sale_order_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Sale Order</span>
</div>
</button>
<button name="action_view_partner" type="object"
class="oe_stat_button" icon="fa-user">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Contact</span>
</div>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group string="Client Information">
<field name="partner_id"/>
<field name="sales_rep_id"/>
<field name="sale_order_id"/>
</group>
<group string="Product">
<field name="product_id"/>
<field name="lot_id"/>
<field name="loaner_period_days"/>
</group>
</group>
<group>
<group string="Dates">
<field name="checkout_date"/>
<field name="expected_return_date"/>
<field name="actual_return_date"/>
<field name="days_out"/>
</group>
<group string="Condition">
<field name="checkout_condition"/>
<field name="checkout_notes"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Search View -->
<record id="view_fusion_loaner_checkout_search" model="ir.ui.view">
<field name="name">fusion.loaner.checkout.search</field>
<field name="model">fusion.loaner.checkout</field>
<field name="arch" type="xml">
<search string="Search Loaners">
<field name="name"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="lot_id" string="Serial Number"/>
<field name="sale_order_id"/>
<field name="sales_rep_id"/>
<separator/>
<!-- Status Filters -->
<filter string="Draft" name="filter_draft"
domain="[('state', '=', 'draft')]"/>
<filter string="Checked Out" name="filter_checked_out"
domain="[('state', '=', 'checked_out')]"/>
<filter string="Overdue" name="filter_overdue"
domain="[('state', '=', 'overdue')]"/>
<filter string="Rental Pending" name="filter_rental_pending"
domain="[('state', '=', 'rental_pending')]"/>
<filter string="Returned" name="filter_returned"
domain="[('state', '=', 'returned')]"/>
<filter string="Converted to Rental" name="filter_converted"
domain="[('state', '=', 'converted_rental')]"/>
<filter string="Lost" name="filter_lost"
domain="[('state', '=', 'lost')]"/>
<separator/>
<!-- Quick Filters -->
<filter string="Active Loaners" name="filter_active"
domain="[('state', 'in', ['checked_out', 'overdue', 'rental_pending'])]"/>
<filter string="Needs Attention" name="filter_attention"
domain="[('state', 'in', ['overdue', 'rental_pending'])]"/>
<filter string="My Loaners" name="filter_my_loaners"
domain="[('sales_rep_id', '=', uid)]"/>
<separator/>
<!-- Condition Filters -->
<filter string="Needs Repair (Checkout)" name="filter_checkout_repair"
domain="[('checkout_condition', '=', 'needs_repair')]"/>
<filter string="Damaged (Return)" name="filter_return_damaged"
domain="[('return_condition', 'in', ['needs_repair', 'damaged'])]"/>
<separator/>
<!-- Group By -->
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Client" name="group_client"
context="{'group_by': 'partner_id'}"/>
<filter string="Product" name="group_product"
context="{'group_by': 'product_id'}"/>
<filter string="Sales Rep" name="group_sales_rep"
context="{'group_by': 'sales_rep_id'}"/>
<filter string="Checkout Condition" name="group_checkout_condition"
context="{'group_by': 'checkout_condition'}"/>
<filter string="Return Condition" name="group_return_condition"
context="{'group_by': 'return_condition'}"/>
<filter string="Checkout Month" name="group_checkout_month"
context="{'group_by': 'checkout_date:month'}"/>
<filter string="Return Month" name="group_return_month"
context="{'group_by': 'actual_return_date:month'}"/>
</search>
</field>
</record>
<!-- ===================================================================== -->
<!-- LOANER HISTORY VIEWS -->
<!-- ===================================================================== -->
<record id="view_fusion_loaner_history_list" model="ir.ui.view">
<field name="name">fusion.loaner.history.list</field>
<field name="model">fusion.loaner.history</field>
<field name="arch" type="xml">
<list default_order="action_date desc, id desc">
<field name="action_date"/>
<field name="checkout_id"/>
<field name="partner_id" optional="show"/>
<field name="product_id" optional="show"/>
<field name="lot_id" optional="hide"/>
<field name="action" widget="badge"
decoration-info="action in ('create', 'note')"
decoration-success="action in ('checkout', 'return')"
decoration-warning="action in ('reminder_sent', 'overdue', 'rental_pending')"
decoration-danger="action in ('lost', 'condition_update')"/>
<field name="user_id"/>
<field name="notes" optional="show"/>
</list>
</field>
</record>
<!-- History Search View -->
<record id="view_fusion_loaner_history_search" model="ir.ui.view">
<field name="name">fusion.loaner.history.search</field>
<field name="model">fusion.loaner.history</field>
<field name="arch" type="xml">
<search string="Search Loaner History">
<field name="checkout_id"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="lot_id" string="Serial Number"/>
<field name="user_id"/>
<separator/>
<!-- Action Type Filters -->
<filter string="Checkouts" name="filter_checkout"
domain="[('action', '=', 'checkout')]"/>
<filter string="Returns" name="filter_return"
domain="[('action', '=', 'return')]"/>
<filter string="Reminders" name="filter_reminders"
domain="[('action', '=', 'reminder_sent')]"/>
<filter string="Overdue" name="filter_overdue"
domain="[('action', '=', 'overdue')]"/>
<filter string="Rental Conversions" name="filter_rental"
domain="[('action', 'in', ['rental_pending', 'rental_converted'])]"/>
<filter string="Lost" name="filter_lost"
domain="[('action', '=', 'lost')]"/>
<separator/>
<!-- Group By -->
<filter string="Action Type" name="group_action"
context="{'group_by': 'action'}"/>
<filter string="Client" name="group_client"
context="{'group_by': 'partner_id'}"/>
<filter string="Product" name="group_product"
context="{'group_by': 'product_id'}"/>
<filter string="User" name="group_user"
context="{'group_by': 'user_id'}"/>
<filter string="Month" name="group_month"
context="{'group_by': 'action_date:month'}"/>
</search>
</field>
</record>
<!-- ===================================================================== -->
<!-- WIZARD VIEWS -->
<!-- ===================================================================== -->
<!-- Checkout Wizard -->
<record id="view_loaner_checkout_wizard_form" model="ir.ui.view">
<field name="name">fusion.loaner.checkout.wizard.form</field>
<field name="model">fusion.loaner.checkout.wizard</field>
<field name="arch" type="xml">
<form>
<group>
<field name="partner_id"/>
<field name="product_id"/>
<field name="lot_id"/>
<field name="checkout_date"/>
<field name="loaner_period_days"/>
<field name="checkout_condition" widget="radio"/>
<field name="checkout_notes"/>
</group>
<footer>
<button name="action_checkout" type="object" string="Checkout Loaner" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Return Wizard -->
<record id="view_loaner_return_wizard_form" model="ir.ui.view">
<field name="name">fusion.loaner.return.wizard.form</field>
<field name="model">fusion.loaner.return.wizard</field>
<field name="arch" type="xml">
<form>
<group>
<field name="checkout_id" readonly="1"/>
<field name="return_date"/>
<field name="return_condition" widget="radio"/>
<field name="return_notes"/>
</group>
<footer>
<button name="action_return" type="object" string="Confirm Return" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- ===================================================================== -->
<!-- LOANER PRODUCTS VIEWS -->
<!-- ===================================================================== -->
<!-- Loaner Products List View -->
<record id="view_fusion_loaner_products_list" model="ir.ui.view">
<field name="name">product.template.loaner.list</field>
<field name="model">product.template</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="x_fc_equipment_type" optional="show"/>
<field name="x_fc_wheelchair_category" optional="show"/>
<field name="x_fc_seat_width" optional="show"/>
<field name="x_fc_seat_depth" optional="show"/>
<field name="x_fc_seat_height" optional="hide"/>
<field name="x_fc_storage_location" optional="show"/>
<field name="x_fc_listing_type" optional="show"/>
<field name="x_fc_asset_number" optional="hide"/>
<field name="active" column_invisible="True"/>
</list>
</field>
</record>
<!-- Loaner Products Search View -->
<record id="view_fusion_loaner_products_search" model="ir.ui.view">
<field name="name">product.template.loaner.search</field>
<field name="model">product.template</field>
<field name="arch" type="xml">
<search string="Search Loaner Products">
<field name="name"/>
<field name="x_fc_equipment_type"/>
<field name="x_fc_asset_number"/>
<separator/>
<filter string="Owned" name="filter_owned"
domain="[('x_fc_listing_type', '=', 'owned')]"/>
<filter string="Borrowed" name="filter_borrowed"
domain="[('x_fc_listing_type', '=', 'borrowed')]"/>
<separator/>
<filter string="Archived" name="filter_archived"
domain="[('active', '=', False)]"/>
<separator/>
<filter string="Equipment Type" name="group_equipment_type"
context="{'group_by': 'x_fc_equipment_type'}"/>
<filter string="Wheelchair Category" name="group_wheelchair_category"
context="{'group_by': 'x_fc_wheelchair_category'}"/>
<filter string="Storage Location" name="group_storage_location"
context="{'group_by': 'x_fc_storage_location'}"/>
<filter string="Listing Type" name="group_listing_type"
context="{'group_by': 'x_fc_listing_type'}"/>
</search>
</field>
</record>
<!-- ===================================================================== -->
<!-- ACTIONS -->
<!-- ===================================================================== -->
<record id="action_fusion_loaner_checkout" model="ir.actions.act_window">
<field name="name">Loaner Equipment</field>
<field name="res_model">fusion.loaner.checkout</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_loaner_checkout_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No loaner checkouts yet</p>
<p>Track loaner equipment issued to clients during assessments or trials.</p>
</field>
</record>
<record id="action_fusion_loaner_all" model="ir.actions.act_window">
<field name="name">All Loaners</field>
<field name="res_model">fusion.loaner.checkout</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_loaner_checkout_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No loaner checkouts yet</p>
</field>
</record>
<record id="action_fusion_loaner_overdue" model="ir.actions.act_window">
<field name="name">Overdue Loaners</field>
<field name="res_model">fusion.loaner.checkout</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_loaner_checkout_search"/>
<field name="context">{'search_default_filter_attention': 1}</field>
</record>
<record id="action_fusion_loaner_returned" model="ir.actions.act_window">
<field name="name">Returned Loaners</field>
<field name="res_model">fusion.loaner.checkout</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fusion_loaner_checkout_search"/>
<field name="context">{'search_default_filter_returned': 1}</field>
</record>
<record id="action_fusion_loaner_history" model="ir.actions.act_window">
<field name="name">Loaner History</field>
<field name="res_model">fusion.loaner.history</field>
<field name="view_mode">list</field>
<field name="search_view_id" ref="view_fusion_loaner_history_search"/>
</record>
<!-- Action: Loaner Products (products that can be loaned) -->
<record id="action_fusion_loaner_products" model="ir.actions.act_window">
<field name="name">Loaner Products</field>
<field name="res_model">product.template</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_fusion_loaner_products_list"/>
<field name="search_view_id" ref="view_fusion_loaner_products_search"/>
<field name="domain">[('x_fc_can_be_loaned', '=', True)]</field>
<field name="context">{'default_x_fc_can_be_loaned': True, 'default_sale_ok': False, 'default_purchase_ok': False, 'default_rent_ok': True}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No loaner products configured yet
</p>
<p>
Mark products as "Loaner" in the product form to add them here.
</p>
</field>
</record>
<!-- ===================================================================== -->
<!-- MENUS -->
<!-- ===================================================================== -->
<menuitem id="menu_loaner_root"
name="Loaners"
parent="menu_adp_claims_root"
sequence="58"/>
<menuitem id="menu_loaner_active"
name="Active Loaners"
parent="menu_loaner_root"
action="action_fusion_loaner_checkout"
sequence="10"/>
<menuitem id="menu_loaner_all"
name="All Loaners"
parent="menu_loaner_root"
action="action_fusion_loaner_all"
sequence="15"/>
<menuitem id="menu_loaner_overdue"
name="Overdue / Attention"
parent="menu_loaner_root"
action="action_fusion_loaner_overdue"
sequence="18"/>
<menuitem id="menu_loaner_returned"
name="Returned"
parent="menu_loaner_root"
action="action_fusion_loaner_returned"
sequence="19"/>
<menuitem id="menu_loaner_history"
name="Loaner History"
parent="menu_loaner_root"
action="action_fusion_loaner_history"
sequence="20"/>
<menuitem id="menu_loaner_products"
name="Loaner Products"
parent="menu_loaner_root"
action="action_fusion_loaner_products"
sequence="30"/>
<!-- ===================================================================== -->
<!-- PRODUCT TEMPLATE LOANER FIELDS -->
<!-- ===================================================================== -->
<!-- Add "Can be Loaned" checkbox next to other product type checkboxes -->
<record id="view_product_template_loaner_checkbox" model="ir.ui.view">
<field name="name">product.template.loaner.checkbox</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view"/>
<field name="priority">50</field>
<field name="arch" type="xml">
<xpath expr="//div[@name='options']" position="inside">
<span class="d-inline-flex">
<field name="x_fc_can_be_loaned"/>
<label for="x_fc_can_be_loaned" string="Loaner"/>
</span>
</xpath>
</field>
</record>
<!-- Loaner Settings tab (only visible when Can be Loaned is checked) -->
<record id="view_product_template_loaner_form" model="ir.ui.view">
<field name="name">product.template.loaner.form</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='sales']" position="after">
<page string="Loaner Settings" name="loaner_settings" invisible="not x_fc_can_be_loaned">
<group>
<group string="Loaner Period">
<field name="x_fc_loaner_period_days"/>
</group>
<group string="Rental Pricing (if not returned)">
<field name="x_fc_rental_price_weekly"/>
<field name="x_fc_rental_price_monthly"/>
</group>
</group>
<group>
<group string="Equipment Details">
<field name="x_fc_equipment_type"/>
<field name="x_fc_wheelchair_category"/>
<field name="x_fc_listing_type"/>
<field name="x_fc_asset_number"/>
</group>
<group string="Dimensions">
<field name="x_fc_seat_width"/>
<field name="x_fc_seat_depth"/>
<field name="x_fc_seat_height"/>
</group>
</group>
<group>
<group string="Location">
<field name="x_fc_storage_location"/>
</group>
</group>
<group string="Package Information">
<field name="x_fc_package_info" nolabel="1" colspan="2"/>
</group>
<group string="Security Deposit">
<group>
<field name="x_fc_security_deposit_type"/>
<field name="x_fc_security_deposit_amount"
invisible="x_fc_security_deposit_type != 'fixed'"/>
<field name="x_fc_security_deposit_percent"
invisible="x_fc_security_deposit_type != 'percentage'"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1074,13 +1074,6 @@
invisible="not x_fc_is_mod_sale or x_fc_mod_client_invoice_count == 0">
<field name="x_fc_mod_client_invoice_count" widget="statinfo" string="Client Invoice"/>
</button>
<!-- Loaner Equipment Button -->
<button name="action_view_loaners" type="object"
class="oe_stat_button" icon="fa-wheelchair"
invisible="x_fc_loaner_count == 0">
<field name="x_fc_loaner_count" widget="statinfo" string="Loaners"/>
</button>
<!-- Technician Tasks Button -->
<button name="action_view_technician_tasks" type="object"
@@ -1186,7 +1179,6 @@
<field name="x_fc_device_verification_complete" invisible="1"/>
<field name="x_fc_submission_verified" invisible="1"/>
<field name="x_fc_adp_application_status" invisible="1"/>
<field name="x_fc_active_loaner_count" invisible="1"/>
<field name="x_fc_early_delivery" invisible="1"/>
<!-- ============================================================ -->
@@ -1340,36 +1332,6 @@
</xpath>
<!-- ============================================================ -->
<!-- LOANER BUTTONS - Positioned AFTER all standard Odoo buttons -->
<!-- (after Cancel, before the statusbar widget) -->
<!-- ============================================================ -->
<xpath expr="//header/field[@name='state']" position="before">
<field name="x_fc_is_adp_sale" invisible="1"/>
<field name="x_fc_active_loaner_count" invisible="1"/>
<field name="x_fc_has_overdue_loaner" invisible="1"/>
<!-- Checkout Loaner: only when NO active loaners -->
<button name="action_checkout_loaner" type="object"
string="Checkout Loaner" class="btn-secondary"
icon="fa-wheelchair"
invisible="not x_fc_is_adp_sale or x_fc_active_loaner_count &gt; 0 or x_fc_adp_application_status not in ('assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'submitted', 'resubmitted', 'accepted', 'approved', 'approved_deduction', 'ready_delivery')"
help="Checkout loaner equipment for the client"/>
<!-- Checkin Loaner: GREEN when within return period -->
<button name="action_checkin_loaner" type="object"
string="Checkin Loaner" class="fc-btn-status-good"
icon="fa-clock-o"
invisible="not x_fc_is_adp_sale or x_fc_active_loaner_count == 0 or x_fc_has_overdue_loaner"
help="Loaner within return period - click to check in"/>
<!-- Checkin Loaner: RED when past return period -->
<button name="action_checkin_loaner" type="object"
string="Checkin Loaner" class="fc-btn-status-bad"
icon="fa-exclamation-circle"
invisible="not x_fc_is_adp_sale or x_fc_active_loaner_count == 0 or not x_fc_has_overdue_loaner"
help="Loaner OVERDUE - click to check in"/>
</xpath>
</field>
</record>

View File

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

View File

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

View 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

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

View File

@@ -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',
}

View File

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