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

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