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

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import loaner_email_mixin
from . import fusion_loaner_checkout
from . import fusion_loaner_history
from . import product_template
from . import sale_order
from . import res_users

View File

@@ -0,0 +1,684 @@
# -*- 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):
_name = 'fusion.loaner.checkout'
_description = 'Loaner Equipment Checkout'
_order = 'checkout_date desc, id desc'
_inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.loaner.email.mixin']
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_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',
)
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,
)
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',
)
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)
delivery_address = fields.Text(string='Delivery Address')
return_location_id = fields.Many2one(
'stock.location',
string='Return Location',
domain="[('usage', '=', 'internal')]",
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 = 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 = 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_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_order_id = fields.Many2one(
'sale.order',
string='Rental Order',
)
rental_conversion_date = fields.Date(string='Rental Conversion Date')
checkout_move_id = fields.Many2one('stock.move', string='Checkout Stock Move')
return_move_id = fields.Many2one('stock.move', string='Return Stock Move')
history_ids = fields.One2many(
'fusion.loaner.history',
'checkout_id',
string='History',
)
history_count = fields.Integer(
compute='_compute_history_count',
string='History Count',
)
@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)
@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
if hasattr(self.sale_order_id, 'x_fc_authorizer_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 ''
@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
def action_checkout(self):
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}')
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()
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):
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):
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()
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):
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):
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):
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,
}
def _get_loaner_location(self):
location = self.env.ref('fusion_loaners_management.stock_location_loaner', raise_if_not_found=False)
if not location:
location = self.env.ref('stock.stock_location_stock')
return location
def _get_customer_location(self):
return self.env.ref('stock.stock_location_customers')
def _create_stock_transfer(self, source_location, dest_location, reference):
picking_type = self.env['stock.picking.type'].sudo().search([
('code', '=', 'internal'),
('company_id', '=', self.company_id.id),
], limit=1)
if not picking_type:
picking_type = self.env['stock.picking.type'].sudo().search([
('code', '=', 'internal'),
], limit=1)
picking_vals = {
'picking_type_id': picking_type.id,
'location_id': source_location.id,
'location_dest_id': dest_location.id,
'origin': reference,
'company_id': self.company_id.id,
'immediate_transfer': True,
}
picking = self.env['stock.picking'].sudo().create(picking_vals)
move_vals = {
'name': reference,
'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,
'picking_id': picking.id,
'company_id': self.company_id.id,
}
move = self.env['stock.move'].sudo().create(move_vals)
picking.action_confirm()
if move.move_line_ids and self.lot_id:
move.move_line_ids.write({
'lot_id': self.lot_id.id,
'quantity': 1,
})
elif not move.move_line_ids:
ml_vals = {
'move_id': move.id,
'product_id': self.product_id.id,
'location_id': source_location.id,
'location_dest_id': dest_location.id,
'quantity': 1,
'picking_id': picking.id,
'company_id': self.company_id.id,
}
if self.lot_id:
ml_vals['lot_id'] = self.lot_id.id
self.env['stock.move.line'].sudo().create(ml_vals)
picking.button_validate()
return move
def _create_checkout_stock_move(self):
try:
source = self._get_loaner_location()
dest = self._get_customer_location()
move = self._create_stock_transfer(
source, dest,
'Loaner Checkout: %s' % self.name,
)
self.checkout_move_id = move.id
except Exception as e:
_logger.warning("Could not create checkout stock move for %s: %s", self.name, e)
def _create_return_stock_move(self):
try:
source = self._get_customer_location()
dest = self.return_location_id or self._get_loaner_location()
move = self._create_stock_transfer(
source, dest,
'Loaner Return: %s' % self.name,
)
self.return_move_id = move.id
except Exception as e:
_logger.warning("Could not create return stock move for %s: %s", self.name, e)
def _log_history(self, action, notes=None):
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,
})
def _get_email_recipients(self):
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': [],
}
company = self.company_id or self.env.company
if hasattr(company, 'x_fc_office_notification_ids'):
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):
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):
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):
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):
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
@api.model
def _cron_check_overdue_loaners(self):
today = fields.Date.today()
active_loaners = self.search([
('state', 'in', ['checked_out', 'overdue', 'rental_pending']),
])
for loaner in active_loaners:
days_out = loaner.days_out
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')
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')
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')
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')
if loaner.state != 'rental_pending':
loaner.action_convert_to_rental()

View File

@@ -0,0 +1,82 @@
# -*- 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):
_name = 'fusion.loaner.history'
_description = 'Loaner History Log'
_order = 'action_date desc, id desc'
checkout_id = fields.Many2one(
'fusion.loaner.checkout',
string='Checkout Record',
ondelete='cascade',
required=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
)
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 = 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')
def _get_action_label(self):
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
@api.model
def get_history_by_serial(self, lot_id):
return self.search([('lot_id', '=', lot_id)], order='action_date desc')
@api.model
def get_history_by_product(self, product_id):
return self.search([('product_id', '=', product_id)], order='action_date desc')

View File

@@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models
class LoanerEmailMixin(models.AbstractModel):
_name = 'fusion.loaner.email.mixin'
_description = 'Loaner Email Builder Mixin'
_EMAIL_COLORS = {
'info': '#2B6CB0',
'success': '#38a169',
'attention': '#d69e2e',
'urgent': '#c53030',
}
def _email_build(
self,
title,
summary,
sections=None,
note=None,
note_color=None,
email_type='info',
attachments_note=None,
button_url=None,
button_text='View Details',
sender_name=None,
extra_html='',
):
accent = self._EMAIL_COLORS.get(email_type, self._EMAIL_COLORS['info'])
company = self._get_company_info()
parts = []
parts.append(
f'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
f'max-width:600px;margin:0 auto;">'
f'<div style="height:4px;background-color:{accent};"></div>'
f'<div style="padding:32px 28px;">'
)
parts.append(
f'<p style="color:{accent};font-size:13px;font-weight:600;letter-spacing:0.5px;'
f'text-transform:uppercase;margin:0 0 24px 0;">{company["name"]}</p>'
)
parts.append(
f'<h2 style="font-size:22px;font-weight:700;'
f'margin:0 0 6px 0;line-height:1.3;">{title}</h2>'
)
parts.append(
f'<p style="opacity:0.65;font-size:15px;line-height:1.5;'
f'margin:0 0 24px 0;">{summary}</p>'
)
if sections:
for heading, rows in sections:
parts.append(self._email_section(heading, rows))
if note:
nc = note_color or accent
parts.append(self._email_note(note, nc))
if extra_html:
parts.append(extra_html)
if attachments_note:
parts.append(self._email_attachment_note(attachments_note))
if button_url:
parts.append(self._email_button(button_url, button_text, accent))
signer = sender_name or (self.env.user.name if self.env.user else '')
parts.append(
f'<p style="font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
f'Best regards,<br/>'
f'<strong>{signer}</strong><br/>'
f'<span style="opacity:0.6;">{company["name"]}</span></p>'
)
parts.append('</div>')
footer_parts = [company['name']]
if company['phone']:
footer_parts.append(company['phone'])
if company['email']:
footer_parts.append(company['email'])
footer_text = ' &middot; '.join(footer_parts)
parts.append(
f'<div style="padding:16px 28px;text-align:center;">'
f'<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">'
f'{footer_text}<br/>'
f'This is an automated notification from the Loaner Management System.</p>'
f'</div>'
)
parts.append('</div>')
return ''.join(parts)
def _email_section(self, heading, rows):
if not rows:
return ''
html = (
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
f'opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;'
f'border-bottom:2px solid rgba(128,128,128,0.25);">{heading}</td></tr>'
)
for label, value in rows:
if value is None or value == '' or value is False:
continue
html += (
f'<tr>'
f'<td style="padding:10px 14px;opacity:0.6;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">{label}</td>'
f'<td style="padding:10px 14px;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);">{value}</td>'
f'</tr>'
)
html += '</table>'
return html
def _email_note(self, text, color='#2B6CB0'):
return (
f'<div style="border-left:3px solid {color};padding:12px 16px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:14px;line-height:1.5;">{text}</p>'
f'</div>'
)
def _email_button(self, url, text='View Details', color='#2B6CB0'):
return (
f'<p style="text-align:center;margin:28px 0;">'
f'<a href="{url}" style="display:inline-block;background:{color};color:#ffffff;'
f'padding:12px 28px;text-decoration:none;border-radius:6px;'
f'font-size:14px;font-weight:600;">{text}</a></p>'
)
def _email_attachment_note(self, description):
return (
f'<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:13px;opacity:0.65;">'
f'<strong style="opacity:1;">Attached:</strong> {description}</p>'
f'</div>'
)
def _email_status_badge(self, label, color='#2B6CB0'):
bg_map = {
'#38a169': 'rgba(56,161,105,0.12)',
'#2B6CB0': 'rgba(43,108,176,0.12)',
'#d69e2e': 'rgba(214,158,46,0.12)',
'#c53030': 'rgba(197,48,48,0.12)',
}
bg = bg_map.get(color, 'rgba(43,108,176,0.12)')
return (
f'<span style="display:inline-block;background:{bg};color:{color};'
f'padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600;">'
f'{label}</span>'
)
def _get_company_info(self):
company = getattr(self, 'company_id', None) or self.env.company
return {
'name': company.name or 'Our Company',
'phone': company.phone or '',
'email': company.email or '',
}
def _email_is_enabled(self):
ICP = self.env['ir.config_parameter'].sudo()
val = ICP.get_param('fusion_loaners.enable_email_notifications', 'True')
return val.lower() in ('true', '1', 'yes')

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
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',
)
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')
x_fc_security_deposit_type = fields.Selection(
[
('fixed', 'Fixed Amount'),
('percentage', 'Percentage of Rental Price'),
],
string='Security Deposit Type',
)
x_fc_security_deposit_amount = fields.Float(
string='Security Deposit Amount',
digits='Product Price',
)
x_fc_security_deposit_percent = fields.Float(
string='Security Deposit (%)',
)
x_flm_current_location = fields.Char(
string='Current Location',
compute='_compute_current_location',
help='Current stock location of this loaner product based on inventory quants',
)
x_flm_serial_count = fields.Integer(
string='Serial Numbers',
compute='_compute_serial_count',
)
@api.depends('product_variant_ids')
def _compute_serial_count(self):
for tmpl in self:
tmpl.x_flm_serial_count = self.env['stock.lot'].sudo().search_count([
('product_id', 'in', tmpl.product_variant_ids.ids),
])
def action_view_serial_numbers(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Serial Numbers',
'res_model': 'stock.lot',
'view_mode': 'list,form',
'domain': [('product_id', 'in', self.product_variant_ids.ids)],
'context': {'default_product_id': self.product_variant_ids[:1].id},
}
@api.depends('product_variant_ids')
def _compute_current_location(self):
for tmpl in self:
if not tmpl.x_fc_can_be_loaned:
tmpl.x_flm_current_location = False
continue
quants = self.env['stock.quant'].sudo().search([
('product_id', 'in', tmpl.product_variant_ids.ids),
('quantity', '>', 0),
('location_id.usage', '=', 'internal'),
], limit=5)
if quants:
locations = quants.mapped('location_id.complete_name')
tmpl.x_flm_current_location = ', '.join(set(locations))
else:
tmpl.x_flm_current_location = False

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models
class ResUsers(models.Model):
_inherit = 'res.users'
x_flm_home_location_id = fields.Many2one(
'stock.location',
string='Home Storage Location',
domain="[('usage', '=', 'internal')]",
help='Default storage location for this sales rep\'s demo/loaner equipment',
)

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = 'sale.order'
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',
)
@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):
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):
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):
self.ensure_one()
ctx = {
'default_sale_order_id': self.id,
'default_partner_id': self.partner_id.id,
}
if hasattr(self, 'x_fc_authorizer_id') and self.x_fc_authorizer_id:
ctx['default_authorizer_id'] = self.x_fc_authorizer_id.id
return {
'name': 'Checkout Loaner',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout.wizard',
'view_mode': 'form',
'target': 'new',
'context': ctx,
}
def action_checkin_loaner(self):
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()
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',
}