# -*- 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( '
' f'Loaner Checked Out
' f'Product: {self.product_id.name}
' f'Serial: {self.lot_id.name if self.lot_id else "N/A"}
' f'Expected Return: {self.expected_return_date}' '
' ), 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( '
' f'Loaner Returned
' f'Condition: {return_condition}
' f'Days Out: {self.days_out}' '
' ), 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( '
' 'Loaner Marked as Lost
' f'Product: {self.product_id.name}
' f'Serial: {self.lot_id.name if self.lot_id else "N/A"}' '
' ), 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 {client_name}.', 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='Important: 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, {client_name}.', 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 {client_name} 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='Action required: 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()