update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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
|
||||
# ==========================================================================
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user