update
This commit is contained in:
29
fusion_loaners_management/__init__.py
Normal file
29
fusion_loaners_management/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import controllers
|
||||
|
||||
|
||||
def _pre_init_hook(env):
|
||||
"""Re-assign loaner data record ownership from fusion_claims before install.
|
||||
|
||||
Prevents duplicate stock locations, sequences, and product categories
|
||||
when migrating loaner functionality from fusion_claims to this module.
|
||||
"""
|
||||
env.cr.execute("""
|
||||
UPDATE ir_model_data SET module = 'fusion_loaners_management'
|
||||
WHERE module = 'fusion_claims'
|
||||
AND name IN (
|
||||
'stock_location_loaner',
|
||||
'seq_loaner_checkout',
|
||||
'product_category_loaner',
|
||||
'product_category_loaner_rollator',
|
||||
'product_category_loaner_wheelchair',
|
||||
'product_category_loaner_powerchair',
|
||||
'model_fusion_loaner_checkout',
|
||||
'model_fusion_loaner_history'
|
||||
)
|
||||
""")
|
||||
58
fusion_loaners_management/__manifest__.py
Normal file
58
fusion_loaners_management/__manifest__.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
{
|
||||
'name': 'Fusion Loaners Management',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Inventory/Equipment',
|
||||
'summary': 'Standalone loaner and demo equipment management with portal access',
|
||||
'description': """
|
||||
Fusion Loaners Management
|
||||
==========================
|
||||
|
||||
Standalone module for managing loaner and demo equipment checkout, return,
|
||||
and location tracking.
|
||||
|
||||
Key Features:
|
||||
- Loaner checkout / return workflow with full audit trail
|
||||
- Automatic inventory transfers on checkout and return
|
||||
- Overdue tracking with automated email reminders
|
||||
- Rental conversion for overdue loaners
|
||||
- Sales rep storage location tracking
|
||||
- Portal access for sales reps and authorizers
|
||||
- Simplified product views for loaner equipment
|
||||
- Serial number (lot) based tracking
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://www.nexasystems.ca',
|
||||
'license': 'OPL-1',
|
||||
'depends': [
|
||||
'base',
|
||||
'sale',
|
||||
'stock',
|
||||
'mail',
|
||||
'product',
|
||||
'portal',
|
||||
'website',
|
||||
'sales_team',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/stock_location_data.xml',
|
||||
'data/ir_cron_loaner_data.xml',
|
||||
'views/fusion_loaner_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/portal_loaner_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'fusion_loaners_management/static/src/js/loaner_portal.js',
|
||||
],
|
||||
},
|
||||
'pre_init_hook': '_pre_init_hook',
|
||||
'images': ['static/description/icon.png'],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': True,
|
||||
}
|
||||
5
fusion_loaners_management/controllers/__init__.py
Normal file
5
fusion_loaners_management/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import loaner_portal
|
||||
269
fusion_loaners_management/controllers/loaner_portal.py
Normal file
269
fusion_loaners_management/controllers/loaner_portal.py
Normal file
@@ -0,0 +1,269 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoanerPortalController(http.Controller):
|
||||
|
||||
@http.route('/my/loaner/categories', type='jsonrpc', auth='user', website=True)
|
||||
def portal_loaner_categories(self, **kw):
|
||||
parent = request.env.ref(
|
||||
'fusion_loaners_management.product_category_loaner',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not parent:
|
||||
return []
|
||||
categories = request.env['product.category'].sudo().search([
|
||||
('parent_id', '=', parent.id),
|
||||
], order='name')
|
||||
return [{'id': c.id, 'name': c.name} for c in categories]
|
||||
|
||||
@http.route('/my/loaner/products', type='jsonrpc', auth='user', website=True)
|
||||
def portal_loaner_products(self, **kw):
|
||||
domain = [('x_fc_can_be_loaned', '=', True)]
|
||||
category_id = kw.get('category_id')
|
||||
if category_id:
|
||||
domain.append(('categ_id', '=', int(category_id)))
|
||||
|
||||
products = request.env['product.product'].sudo().search(domain)
|
||||
loaner_location = request.env.ref(
|
||||
'fusion_loaners_management.stock_location_loaner',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
|
||||
result = []
|
||||
for p in products:
|
||||
lots = []
|
||||
if loaner_location:
|
||||
quants = request.env['stock.quant'].sudo().search([
|
||||
('product_id', '=', p.id),
|
||||
('location_id', '=', loaner_location.id),
|
||||
('quantity', '>', 0),
|
||||
])
|
||||
for q in quants:
|
||||
if q.lot_id:
|
||||
lots.append({'id': q.lot_id.id, 'name': q.lot_id.name})
|
||||
result.append({
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'category_id': p.categ_id.id,
|
||||
'period_days': p.product_tmpl_id.x_fc_loaner_period_days or 7,
|
||||
'lots': lots,
|
||||
})
|
||||
return result
|
||||
|
||||
@http.route('/my/loaner/locations', type='jsonrpc', auth='user', website=True)
|
||||
def portal_loaner_locations(self, **kw):
|
||||
locations = request.env['stock.location'].sudo().search([
|
||||
('usage', '=', 'internal'),
|
||||
('company_id', '=', request.env.company.id),
|
||||
])
|
||||
return [{'id': loc.id, 'name': loc.complete_name} for loc in locations]
|
||||
|
||||
@http.route('/my/loaner/equipment-locations', type='jsonrpc', auth='user', website=True)
|
||||
def portal_equipment_locations(self, **kw):
|
||||
"""Return current location of all loaner equipment for the portal."""
|
||||
partner = request.env.user.partner_id
|
||||
if not (hasattr(partner, 'is_sales_rep_portal') and partner.is_sales_rep_portal) and \
|
||||
not (hasattr(partner, 'is_authorizer') and partner.is_authorizer):
|
||||
return {'error': 'Unauthorized'}
|
||||
|
||||
products = request.env['product.product'].sudo().search([
|
||||
('x_fc_can_be_loaned', '=', True),
|
||||
])
|
||||
loaner_location = request.env.ref(
|
||||
'fusion_loaners_management.stock_location_loaner',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
|
||||
result = []
|
||||
for p in products:
|
||||
quants = request.env['stock.quant'].sudo().search([
|
||||
('product_id', '=', p.id),
|
||||
('quantity', '>', 0),
|
||||
])
|
||||
for q in quants:
|
||||
result.append({
|
||||
'product_id': p.id,
|
||||
'product_name': p.name,
|
||||
'lot_id': q.lot_id.id if q.lot_id else False,
|
||||
'serial_number': q.lot_id.name if q.lot_id else '',
|
||||
'location_id': q.location_id.id,
|
||||
'location_name': q.location_id.complete_name,
|
||||
'quantity': q.quantity,
|
||||
'is_loaner_stock': q.location_id.id == loaner_location.id if loaner_location else False,
|
||||
})
|
||||
return result
|
||||
|
||||
@http.route('/my/loaner/checkout', type='jsonrpc', auth='user', website=True)
|
||||
def portal_loaner_checkout(self, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
if not (hasattr(partner, 'is_sales_rep_portal') and partner.is_sales_rep_portal) and \
|
||||
not (hasattr(partner, 'is_authorizer') and partner.is_authorizer):
|
||||
return {'error': 'Unauthorized'}
|
||||
|
||||
product_id = int(kw.get('product_id', 0))
|
||||
lot_id = int(kw.get('lot_id', 0)) if kw.get('lot_id') else False
|
||||
sale_order_id = int(kw.get('sale_order_id', 0)) if kw.get('sale_order_id') else False
|
||||
client_id = int(kw.get('client_id', 0)) if kw.get('client_id') else False
|
||||
loaner_period = int(kw.get('loaner_period_days', 7))
|
||||
condition = kw.get('checkout_condition', 'good')
|
||||
notes = kw.get('checkout_notes', '')
|
||||
|
||||
if not product_id:
|
||||
return {'error': 'Product is required'}
|
||||
|
||||
vals = {
|
||||
'product_id': product_id,
|
||||
'loaner_period_days': loaner_period,
|
||||
'checkout_condition': condition,
|
||||
'checkout_notes': notes,
|
||||
'sales_rep_id': request.env.user.id,
|
||||
}
|
||||
if lot_id:
|
||||
vals['lot_id'] = lot_id
|
||||
if sale_order_id:
|
||||
so = request.env['sale.order'].sudo().browse(sale_order_id)
|
||||
if so.exists():
|
||||
vals['sale_order_id'] = so.id
|
||||
vals['partner_id'] = so.partner_id.id
|
||||
if hasattr(so, 'x_fc_authorizer_id') and so.x_fc_authorizer_id:
|
||||
vals['authorizer_id'] = so.x_fc_authorizer_id.id
|
||||
vals['delivery_address'] = so.partner_shipping_id.contact_address if so.partner_shipping_id else ''
|
||||
if client_id and not vals.get('partner_id'):
|
||||
vals['partner_id'] = client_id
|
||||
|
||||
if not vals.get('partner_id'):
|
||||
return {'error': 'Client is required'}
|
||||
|
||||
try:
|
||||
checkout = request.env['fusion.loaner.checkout'].sudo().create(vals)
|
||||
checkout.action_checkout()
|
||||
return {
|
||||
'success': True,
|
||||
'checkout_id': checkout.id,
|
||||
'name': checkout.name,
|
||||
'message': f'Loaner {checkout.name} checked out successfully',
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error(f"Loaner checkout error: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
@http.route('/my/loaner/create-product', type='jsonrpc', auth='user', website=True)
|
||||
def portal_loaner_create_product(self, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
if not (hasattr(partner, 'is_sales_rep_portal') and partner.is_sales_rep_portal) and \
|
||||
not (hasattr(partner, 'is_authorizer') and partner.is_authorizer):
|
||||
return {'error': 'Unauthorized'}
|
||||
|
||||
product_name = kw.get('product_name', '').strip()
|
||||
serial_number = kw.get('serial_number', '').strip()
|
||||
|
||||
if not product_name:
|
||||
return {'error': 'Product name is required'}
|
||||
if not serial_number:
|
||||
return {'error': 'Serial number is required'}
|
||||
|
||||
try:
|
||||
category_id = kw.get('category_id')
|
||||
category = None
|
||||
if category_id:
|
||||
category = request.env['product.category'].sudo().browse(int(category_id))
|
||||
if not category.exists():
|
||||
category = None
|
||||
|
||||
if not category:
|
||||
category = request.env.ref(
|
||||
'fusion_loaners_management.product_category_loaner',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not category:
|
||||
category = request.env['product.category'].sudo().search([
|
||||
('name', '=', 'Loaner Equipment'),
|
||||
], limit=1)
|
||||
if not category:
|
||||
category = request.env['product.category'].sudo().create({
|
||||
'name': 'Loaner Equipment',
|
||||
})
|
||||
|
||||
product_tmpl = request.env['product.template'].sudo().create({
|
||||
'name': product_name,
|
||||
'type': 'consu',
|
||||
'tracking': 'serial',
|
||||
'categ_id': category.id,
|
||||
'x_fc_can_be_loaned': True,
|
||||
'x_fc_loaner_period_days': 7,
|
||||
'sale_ok': False,
|
||||
'purchase_ok': False,
|
||||
})
|
||||
product = product_tmpl.product_variant_id
|
||||
|
||||
lot = request.env['stock.lot'].sudo().create({
|
||||
'name': serial_number,
|
||||
'product_id': product.id,
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
|
||||
loaner_location = request.env.ref(
|
||||
'fusion_loaners_management.stock_location_loaner',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if loaner_location:
|
||||
request.env['stock.quant'].sudo().create({
|
||||
'product_id': product.id,
|
||||
'location_id': loaner_location.id,
|
||||
'lot_id': lot.id,
|
||||
'quantity': 1,
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'product_id': product.id,
|
||||
'product_name': product.name,
|
||||
'lot_id': lot.id,
|
||||
'lot_name': lot.name,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error(f"Loaner product creation error: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
@http.route('/my/loaner/return', type='jsonrpc', auth='user', website=True)
|
||||
def portal_loaner_return(self, **kw):
|
||||
partner = request.env.user.partner_id
|
||||
if not (hasattr(partner, 'is_sales_rep_portal') and partner.is_sales_rep_portal) and \
|
||||
not (hasattr(partner, 'is_authorizer') and partner.is_authorizer):
|
||||
return {'error': 'Unauthorized'}
|
||||
|
||||
checkout_id = int(kw.get('checkout_id', 0))
|
||||
return_condition = kw.get('return_condition', 'good')
|
||||
return_notes = kw.get('return_notes', '')
|
||||
return_location_id = int(kw.get('return_location_id', 0)) if kw.get('return_location_id') else None
|
||||
|
||||
if not checkout_id:
|
||||
return {'error': 'Checkout ID is required'}
|
||||
|
||||
try:
|
||||
checkout = request.env['fusion.loaner.checkout'].sudo().browse(checkout_id)
|
||||
if not checkout.exists():
|
||||
return {'error': 'Checkout not found'}
|
||||
if checkout.state not in ('checked_out', 'overdue', 'rental_pending'):
|
||||
return {'error': 'This loaner is not currently checked out'}
|
||||
|
||||
checkout.action_process_return(
|
||||
return_condition=return_condition,
|
||||
return_notes=return_notes,
|
||||
return_location_id=return_location_id,
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Loaner {checkout.name} returned successfully',
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.error(f"Loaner return error: {e}")
|
||||
return {'error': str(e)}
|
||||
14
fusion_loaners_management/data/ir_cron_loaner_data.xml
Normal file
14
fusion_loaners_management/data/ir_cron_loaner_data.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="ir_cron_check_overdue_loaners" model="ir.cron">
|
||||
<field name="name">Loaner Equipment: Check Overdue and Send Reminders</field>
|
||||
<field name="model_id" ref="model_fusion_loaner_checkout"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_check_overdue_loaners()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
34
fusion_loaners_management/data/stock_location_data.xml
Normal file
34
fusion_loaners_management/data/stock_location_data.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="stock_location_loaner" model="stock.location">
|
||||
<field name="name">Loaner Stock</field>
|
||||
<field name="usage">internal</field>
|
||||
<field name="location_id" ref="stock.stock_location_stock"/>
|
||||
</record>
|
||||
|
||||
<record id="seq_loaner_checkout" model="ir.sequence">
|
||||
<field name="name">Loaner Checkout Sequence</field>
|
||||
<field name="code">fusion.loaner.checkout</field>
|
||||
<field name="prefix">LOAN/</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="product_category_loaner" model="product.category">
|
||||
<field name="name">Loaner Equipment</field>
|
||||
</record>
|
||||
<record id="product_category_loaner_rollator" model="product.category">
|
||||
<field name="name">Rollators</field>
|
||||
<field name="parent_id" ref="product_category_loaner"/>
|
||||
</record>
|
||||
<record id="product_category_loaner_wheelchair" model="product.category">
|
||||
<field name="name">Wheelchairs</field>
|
||||
<field name="parent_id" ref="product_category_loaner"/>
|
||||
</record>
|
||||
<record id="product_category_loaner_powerchair" model="product.category">
|
||||
<field name="name">Powerchairs</field>
|
||||
<field name="parent_id" ref="product_category_loaner"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
10
fusion_loaners_management/models/__init__.py
Normal file
10
fusion_loaners_management/models/__init__.py
Normal 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
|
||||
684
fusion_loaners_management/models/fusion_loaner_checkout.py
Normal file
684
fusion_loaners_management/models/fusion_loaner_checkout.py
Normal 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()
|
||||
82
fusion_loaners_management/models/fusion_loaner_history.py
Normal file
82
fusion_loaners_management/models/fusion_loaner_history.py
Normal 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')
|
||||
172
fusion_loaners_management/models/loaner_email_mixin.py
Normal file
172
fusion_loaners_management/models/loaner_email_mixin.py
Normal 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 = ' · '.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')
|
||||
141
fusion_loaners_management/models/product_template.py
Normal file
141
fusion_loaners_management/models/product_template.py
Normal 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
|
||||
16
fusion_loaners_management/models/res_users.py
Normal file
16
fusion_loaners_management/models/res_users.py
Normal 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',
|
||||
)
|
||||
94
fusion_loaners_management/models/sale_order.py
Normal file
94
fusion_loaners_management/models/sale_order.py
Normal 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',
|
||||
}
|
||||
8
fusion_loaners_management/security/ir.model.access.csv
Normal file
8
fusion_loaners_management/security/ir.model.access.csv
Normal file
@@ -0,0 +1,8 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_loaner_checkout_user,fusion.loaner.checkout.user,model_fusion_loaner_checkout,sales_team.group_sale_salesman,1,1,1,0
|
||||
access_fusion_loaner_checkout_manager,fusion.loaner.checkout.manager,model_fusion_loaner_checkout,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_loaner_history_user,fusion.loaner.history.user,model_fusion_loaner_history,sales_team.group_sale_salesman,1,0,0,0
|
||||
access_fusion_loaner_history_manager,fusion.loaner.history.manager,model_fusion_loaner_history,sales_team.group_sale_manager,1,1,1,1
|
||||
access_fusion_loaner_checkout_wizard,fusion.loaner.checkout.wizard.user,model_fusion_loaner_checkout_wizard,sales_team.group_sale_salesman,1,1,1,1
|
||||
access_fusion_loaner_return_wizard,fusion.loaner.return.wizard.user,model_fusion_loaner_return_wizard,sales_team.group_sale_salesman,1,1,1,1
|
||||
access_fusion_loaner_email_mixin,fusion.loaner.email.mixin.user,model_fusion_loaner_email_mixin,base.group_user,1,0,0,0
|
||||
|
BIN
fusion_loaners_management/static/description/icon.png
Normal file
BIN
fusion_loaners_management/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
443
fusion_loaners_management/static/src/js/loaner_portal.js
Normal file
443
fusion_loaners_management/static/src/js/loaner_portal.js
Normal file
@@ -0,0 +1,443 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import publicWidget from "@web/legacy/js/public/public_widget";
|
||||
|
||||
publicWidget.registry.LoanerPortal = publicWidget.Widget.extend({
|
||||
selector: '#loanerSection, #btn_checkout_loaner, .btn-loaner-return',
|
||||
|
||||
start: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this._allProducts = [];
|
||||
this._initLoanerSection();
|
||||
this._initCheckoutButton();
|
||||
this._initReturnButtons();
|
||||
this._initModal();
|
||||
},
|
||||
|
||||
_initModal: function () {
|
||||
var self = this;
|
||||
var modal = document.getElementById('loanerCheckoutModal');
|
||||
if (!modal) return;
|
||||
|
||||
var categorySelect = document.getElementById('modal_category_id');
|
||||
var productSelect = document.getElementById('modal_product_id');
|
||||
var lotSelect = document.getElementById('modal_lot_id');
|
||||
var loanDays = document.getElementById('modal_loan_days');
|
||||
var btnCheckout = document.getElementById('modal_btn_checkout');
|
||||
var btnCreateProduct = document.getElementById('modal_btn_create_product');
|
||||
var newCategorySelect = document.getElementById('modal_new_category_id');
|
||||
var createResult = document.getElementById('modal_create_result');
|
||||
|
||||
modal.addEventListener('show.bs.modal', function () {
|
||||
self._loadCategories(categorySelect, newCategorySelect);
|
||||
self._loadProducts(null, productSelect, lotSelect);
|
||||
});
|
||||
|
||||
if (categorySelect) {
|
||||
categorySelect.addEventListener('change', function () {
|
||||
var catId = this.value ? parseInt(this.value) : null;
|
||||
self._filterProducts(catId, productSelect, lotSelect);
|
||||
});
|
||||
}
|
||||
|
||||
if (productSelect) {
|
||||
productSelect.addEventListener('change', function () {
|
||||
var prodId = this.value ? parseInt(this.value) : null;
|
||||
self._filterLots(prodId, lotSelect, loanDays);
|
||||
});
|
||||
}
|
||||
|
||||
if (btnCreateProduct) {
|
||||
btnCreateProduct.addEventListener('click', function () {
|
||||
var name = document.getElementById('modal_new_product_name').value.trim();
|
||||
var serial = document.getElementById('modal_new_serial').value.trim();
|
||||
var catId = newCategorySelect ? newCategorySelect.value : '';
|
||||
|
||||
if (!name || !serial) {
|
||||
alert('Please enter both product name and serial number.');
|
||||
return;
|
||||
}
|
||||
|
||||
btnCreateProduct.disabled = true;
|
||||
btnCreateProduct.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
|
||||
|
||||
self._rpc('/my/loaner/create-product', {
|
||||
product_name: name,
|
||||
serial_number: serial,
|
||||
category_id: catId || null,
|
||||
}).then(function (result) {
|
||||
if (result.success) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = result.product_id;
|
||||
opt.text = result.product_name;
|
||||
opt.selected = true;
|
||||
productSelect.appendChild(opt);
|
||||
|
||||
lotSelect.innerHTML = '';
|
||||
var lotOpt = document.createElement('option');
|
||||
lotOpt.value = result.lot_id;
|
||||
lotOpt.text = result.lot_name;
|
||||
lotOpt.selected = true;
|
||||
lotSelect.appendChild(lotOpt);
|
||||
|
||||
self._allProducts.push({
|
||||
id: result.product_id,
|
||||
name: result.product_name,
|
||||
category_id: catId ? parseInt(catId) : null,
|
||||
period_days: 7,
|
||||
lots: [{ id: result.lot_id, name: result.lot_name }],
|
||||
});
|
||||
|
||||
if (createResult) {
|
||||
createResult.style.display = '';
|
||||
createResult.innerHTML = '<div class="alert alert-success py-2"><i class="fa fa-check me-1"></i> "' + result.product_name + '" (S/N: ' + result.lot_name + ') created!</div>';
|
||||
}
|
||||
|
||||
document.getElementById('modal_new_product_name').value = '';
|
||||
document.getElementById('modal_new_serial').value = '';
|
||||
} else {
|
||||
if (createResult) {
|
||||
createResult.style.display = '';
|
||||
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
|
||||
}
|
||||
}
|
||||
btnCreateProduct.disabled = false;
|
||||
btnCreateProduct.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (btnCheckout) {
|
||||
btnCheckout.addEventListener('click', function () {
|
||||
var productId = productSelect.value ? parseInt(productSelect.value) : null;
|
||||
var lotId = lotSelect.value ? parseInt(lotSelect.value) : null;
|
||||
var days = parseInt(loanDays.value) || 7;
|
||||
var orderId = document.getElementById('modal_order_id').value;
|
||||
var clientId = document.getElementById('modal_client_id').value;
|
||||
|
||||
if (!productId) {
|
||||
alert('Please select a product.');
|
||||
return;
|
||||
}
|
||||
|
||||
btnCheckout.disabled = true;
|
||||
btnCheckout.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
|
||||
|
||||
self._rpc('/my/loaner/checkout', {
|
||||
product_id: productId,
|
||||
lot_id: lotId,
|
||||
sale_order_id: orderId ? parseInt(orderId) : null,
|
||||
client_id: clientId ? parseInt(clientId) : null,
|
||||
loaner_period_days: days,
|
||||
checkout_condition: 'good',
|
||||
checkout_notes: '',
|
||||
}).then(function (result) {
|
||||
if (result.success) {
|
||||
self._hideModal(modal);
|
||||
alert(result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + (result.error || 'Unknown'));
|
||||
btnCheckout.disabled = false;
|
||||
btnCheckout.innerHTML = '<i class="fa fa-check me-1"></i> Checkout Loaner';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_loadCategories: function (categorySelect, newCategorySelect) {
|
||||
this._rpc('/my/loaner/categories', {}).then(function (categories) {
|
||||
categories = categories || [];
|
||||
if (categorySelect) {
|
||||
categorySelect.innerHTML = '<option value="">All Categories</option>';
|
||||
categories.forEach(function (c) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.text = c.name;
|
||||
categorySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (newCategorySelect) {
|
||||
newCategorySelect.innerHTML = '<option value="">-- Select --</option>';
|
||||
categories.forEach(function (c) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.text = c.name;
|
||||
newCategorySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_loadProducts: function (categoryId, productSelect, lotSelect) {
|
||||
var self = this;
|
||||
var params = {};
|
||||
if (categoryId) params.category_id = categoryId;
|
||||
|
||||
this._rpc('/my/loaner/products', params).then(function (products) {
|
||||
self._allProducts = products || [];
|
||||
self._renderProducts(self._allProducts, productSelect);
|
||||
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
|
||||
});
|
||||
},
|
||||
|
||||
_filterProducts: function (categoryId, productSelect, lotSelect) {
|
||||
var filtered = this._allProducts;
|
||||
if (categoryId) {
|
||||
filtered = this._allProducts.filter(function (p) { return p.category_id === categoryId; });
|
||||
}
|
||||
this._renderProducts(filtered, productSelect);
|
||||
if (lotSelect) lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
|
||||
},
|
||||
|
||||
_renderProducts: function (products, productSelect) {
|
||||
if (!productSelect) return;
|
||||
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
|
||||
products.forEach(function (p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.text = p.name + ' (' + p.lots.length + ' avail)';
|
||||
productSelect.appendChild(opt);
|
||||
});
|
||||
},
|
||||
|
||||
_filterLots: function (productId, lotSelect, loanDays) {
|
||||
if (!lotSelect) return;
|
||||
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
|
||||
if (!productId) return;
|
||||
var product = this._allProducts.find(function (p) { return p.id === productId; });
|
||||
if (product) {
|
||||
product.lots.forEach(function (lot) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = lot.id;
|
||||
opt.text = lot.name;
|
||||
lotSelect.appendChild(opt);
|
||||
});
|
||||
if (loanDays && product.period_days) {
|
||||
loanDays.value = product.period_days;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_initCheckoutButton: function () {
|
||||
var self = this;
|
||||
var btns = document.querySelectorAll('#btn_checkout_loaner');
|
||||
btns.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var orderId = btn.dataset.orderId || '';
|
||||
var clientId = btn.dataset.clientId || '';
|
||||
var modalOrderId = document.getElementById('modal_order_id');
|
||||
var modalClientId = document.getElementById('modal_client_id');
|
||||
if (modalOrderId) modalOrderId.value = orderId;
|
||||
if (modalClientId) modalClientId.value = clientId;
|
||||
var modal = document.getElementById('loanerCheckoutModal');
|
||||
self._showModal(modal);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_initReturnButtons: function () {
|
||||
var self = this;
|
||||
var returnModal = document.getElementById('loanerReturnModal');
|
||||
if (!returnModal) return;
|
||||
|
||||
var btnSubmitReturn = document.getElementById('return_modal_btn_submit');
|
||||
|
||||
document.querySelectorAll('.btn-loaner-return').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var checkoutId = parseInt(btn.dataset.checkoutId);
|
||||
var productName = btn.dataset.productName || 'Loaner';
|
||||
|
||||
document.getElementById('return_modal_checkout_id').value = checkoutId;
|
||||
document.getElementById('return_modal_product_name').textContent = productName;
|
||||
document.getElementById('return_modal_condition').value = 'good';
|
||||
document.getElementById('return_modal_notes').value = '';
|
||||
|
||||
var locSelect = document.getElementById('return_modal_location_id');
|
||||
locSelect.innerHTML = '<option value="">-- Loading... --</option>';
|
||||
self._rpc('/my/loaner/locations', {}).then(function (locations) {
|
||||
locations = locations || [];
|
||||
locSelect.innerHTML = '<option value="">-- Select Location --</option>';
|
||||
locations.forEach(function (l) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = l.id;
|
||||
opt.text = l.name;
|
||||
locSelect.appendChild(opt);
|
||||
});
|
||||
});
|
||||
self._showModal(returnModal);
|
||||
});
|
||||
});
|
||||
|
||||
if (btnSubmitReturn) {
|
||||
btnSubmitReturn.addEventListener('click', function () {
|
||||
var checkoutId = parseInt(document.getElementById('return_modal_checkout_id').value);
|
||||
var condition = document.getElementById('return_modal_condition').value;
|
||||
var notes = document.getElementById('return_modal_notes').value;
|
||||
var locationId = document.getElementById('return_modal_location_id').value;
|
||||
|
||||
btnSubmitReturn.disabled = true;
|
||||
btnSubmitReturn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Processing...';
|
||||
|
||||
self._rpc('/my/loaner/return', {
|
||||
checkout_id: checkoutId,
|
||||
return_condition: condition,
|
||||
return_notes: notes,
|
||||
return_location_id: locationId ? parseInt(locationId) : null,
|
||||
}).then(function (result) {
|
||||
if (result.success) {
|
||||
self._hideModal(returnModal);
|
||||
alert(result.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + (result.error || 'Unknown'));
|
||||
btnSubmitReturn.disabled = false;
|
||||
btnSubmitReturn.innerHTML = '<i class="fa fa-check me-1"></i> Confirm Return';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_initLoanerSection: function () {
|
||||
var self = this;
|
||||
var loanerSection = document.getElementById('loanerSection');
|
||||
if (!loanerSection) return;
|
||||
|
||||
var productSelect = document.getElementById('loaner_product_id');
|
||||
var lotSelect = document.getElementById('loaner_lot_id');
|
||||
var periodInput = document.getElementById('loaner_period_days');
|
||||
var checkoutFlag = document.getElementById('loaner_checkout');
|
||||
var existingFields = document.getElementById('loaner_existing_fields');
|
||||
var newFields = document.getElementById('loaner_new_fields');
|
||||
var modeRadios = document.querySelectorAll('input[name="loaner_mode"]');
|
||||
var btnCreate = document.getElementById('btn_create_loaner_product');
|
||||
var createResult = document.getElementById('loaner_create_result');
|
||||
var productsData = [];
|
||||
|
||||
loanerSection.addEventListener('show.bs.collapse', function () {
|
||||
if (productSelect && productSelect.options.length <= 1) {
|
||||
self._rpc('/my/loaner/products', {}).then(function (data) {
|
||||
productsData = data || [];
|
||||
productSelect.innerHTML = '<option value="">-- Select Product --</option>';
|
||||
productsData.forEach(function (p) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.text = p.name + ' (' + p.lots.length + ' avail)';
|
||||
productSelect.appendChild(opt);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
loanerSection.addEventListener('shown.bs.collapse', function () {
|
||||
if (checkoutFlag) checkoutFlag.value = '1';
|
||||
});
|
||||
loanerSection.addEventListener('hidden.bs.collapse', function () {
|
||||
if (checkoutFlag) checkoutFlag.value = '0';
|
||||
});
|
||||
|
||||
modeRadios.forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
if (this.value === 'existing') {
|
||||
if (existingFields) existingFields.style.display = '';
|
||||
if (newFields) newFields.style.display = 'none';
|
||||
} else {
|
||||
if (existingFields) existingFields.style.display = 'none';
|
||||
if (newFields) newFields.style.display = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (productSelect) {
|
||||
productSelect.addEventListener('change', function () {
|
||||
lotSelect.innerHTML = '<option value="">-- Select Serial --</option>';
|
||||
var product = productsData.find(function (p) { return p.id === parseInt(productSelect.value); });
|
||||
if (product) {
|
||||
product.lots.forEach(function (lot) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = lot.id;
|
||||
opt.text = lot.name;
|
||||
lotSelect.appendChild(opt);
|
||||
});
|
||||
if (periodInput && product.period_days) periodInput.value = product.period_days;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (btnCreate) {
|
||||
btnCreate.addEventListener('click', function () {
|
||||
var name = document.getElementById('loaner_new_product_name').value.trim();
|
||||
var serial = document.getElementById('loaner_new_serial').value.trim();
|
||||
if (!name || !serial) { alert('Enter both name and serial.'); return; }
|
||||
btnCreate.disabled = true;
|
||||
btnCreate.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Creating...';
|
||||
|
||||
self._rpc('/my/loaner/create-product', {
|
||||
product_name: name, serial_number: serial,
|
||||
}).then(function (result) {
|
||||
if (result.success) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = result.product_id;
|
||||
opt.text = result.product_name;
|
||||
opt.selected = true;
|
||||
productSelect.appendChild(opt);
|
||||
lotSelect.innerHTML = '';
|
||||
var lotOpt = document.createElement('option');
|
||||
lotOpt.value = result.lot_id;
|
||||
lotOpt.text = result.lot_name;
|
||||
lotOpt.selected = true;
|
||||
lotSelect.appendChild(lotOpt);
|
||||
document.getElementById('loaner_existing').checked = true;
|
||||
if (existingFields) existingFields.style.display = '';
|
||||
if (newFields) newFields.style.display = 'none';
|
||||
if (createResult) {
|
||||
createResult.style.display = '';
|
||||
createResult.innerHTML = '<div class="alert alert-success py-2">Created "' + result.product_name + '" (S/N: ' + result.lot_name + ')</div>';
|
||||
}
|
||||
} else {
|
||||
if (createResult) {
|
||||
createResult.style.display = '';
|
||||
createResult.innerHTML = '<div class="alert alert-danger py-2">' + (result.error || 'Error') + '</div>';
|
||||
}
|
||||
}
|
||||
btnCreate.disabled = false;
|
||||
btnCreate.innerHTML = '<i class="fa fa-plus me-1"></i> Create Product';
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_rpc: function (url, params) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', id: 1, params: params }),
|
||||
}).then(function (r) { return r.json(); }).then(function (d) { return d.result; });
|
||||
},
|
||||
|
||||
_showModal: function (modalEl) {
|
||||
if (!modalEl) return;
|
||||
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
|
||||
if (Modal) {
|
||||
var inst = Modal.getOrCreateInstance ? Modal.getOrCreateInstance(modalEl) : new Modal(modalEl);
|
||||
inst.show();
|
||||
} else if (window.$ || window.jQuery) {
|
||||
(window.$ || window.jQuery)(modalEl).modal('show');
|
||||
}
|
||||
},
|
||||
|
||||
_hideModal: function (modalEl) {
|
||||
if (!modalEl) return;
|
||||
try {
|
||||
var Modal = window.bootstrap ? window.bootstrap.Modal : null;
|
||||
if (Modal && Modal.getInstance) {
|
||||
var inst = Modal.getInstance(modalEl);
|
||||
if (inst) inst.hide();
|
||||
} else if (window.$ || window.jQuery) {
|
||||
(window.$ || window.jQuery)(modalEl).modal('hide');
|
||||
}
|
||||
} catch (e) { /* non-blocking */ }
|
||||
},
|
||||
});
|
||||
559
fusion_loaners_management/views/fusion_loaner_views.xml
Normal file
559
fusion_loaners_management/views/fusion_loaner_views.xml
Normal file
@@ -0,0 +1,559 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_loaner_checkout_list" model="ir.ui.view">
|
||||
<field name="name">fusion.loaner.checkout.list</field>
|
||||
<field name="model">fusion.loaner.checkout</field>
|
||||
<field name="arch" type="xml">
|
||||
<list decoration-danger="state == 'overdue'"
|
||||
decoration-warning="state == 'rental_pending'"
|
||||
decoration-muted="state in ('returned', 'lost')"
|
||||
default_order="checkout_date desc, id desc">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id" optional="show"/>
|
||||
<field name="checkout_date"/>
|
||||
<field name="expected_return_date"/>
|
||||
<field name="actual_return_date" optional="hide"/>
|
||||
<field name="days_out"/>
|
||||
<field name="days_overdue" optional="hide"/>
|
||||
<field name="sales_rep_id" optional="hide"/>
|
||||
<field name="checkout_condition" optional="hide"/>
|
||||
<field name="return_condition" optional="hide"/>
|
||||
<field name="sale_order_id" optional="hide"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-success="state == 'checked_out'"
|
||||
decoration-danger="state in ('overdue', 'lost')"
|
||||
decoration-warning="state == 'rental_pending'"
|
||||
decoration-muted="state in ('returned', 'converted_rental')"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_loaner_checkout_form" model="ir.ui.view">
|
||||
<field name="name">fusion.loaner.checkout.form</field>
|
||||
<field name="model">fusion.loaner.checkout</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_checkout" type="object" string="Confirm Checkout"
|
||||
class="btn-primary" invisible="state != 'draft'"/>
|
||||
<button name="action_return" type="object" string="Return Loaner"
|
||||
class="btn-success" invisible="state not in ('checked_out', 'overdue', 'rental_pending')"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,checked_out,returned"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sale_order" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="not sale_order_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Sale Order</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_partner" type="object"
|
||||
class="oe_stat_button" icon="fa-user">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_text">Contact</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Client Information">
|
||||
<field name="partner_id"/>
|
||||
<field name="sales_rep_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
</group>
|
||||
<group string="Product">
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id" context="{'default_product_id': product_id}"/>
|
||||
<field name="loaner_period_days"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Dates">
|
||||
<field name="checkout_date"/>
|
||||
<field name="expected_return_date"/>
|
||||
<field name="actual_return_date"/>
|
||||
<field name="days_out"/>
|
||||
</group>
|
||||
<group string="Condition">
|
||||
<field name="checkout_condition"/>
|
||||
<field name="checkout_notes"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_loaner_checkout_search" model="ir.ui.view">
|
||||
<field name="name">fusion.loaner.checkout.search</field>
|
||||
<field name="model">fusion.loaner.checkout</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Loaners">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id" string="Serial Number"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="sales_rep_id"/>
|
||||
<separator/>
|
||||
<filter string="Draft" name="filter_draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Checked Out" name="filter_checked_out" domain="[('state', '=', 'checked_out')]"/>
|
||||
<filter string="Overdue" name="filter_overdue" domain="[('state', '=', 'overdue')]"/>
|
||||
<filter string="Rental Pending" name="filter_rental_pending" domain="[('state', '=', 'rental_pending')]"/>
|
||||
<filter string="Returned" name="filter_returned" domain="[('state', '=', 'returned')]"/>
|
||||
<filter string="Converted to Rental" name="filter_converted" domain="[('state', '=', 'converted_rental')]"/>
|
||||
<filter string="Lost" name="filter_lost" domain="[('state', '=', 'lost')]"/>
|
||||
<separator/>
|
||||
<filter string="Active Loaners" name="filter_active" domain="[('state', 'in', ['checked_out', 'overdue', 'rental_pending'])]"/>
|
||||
<filter string="Needs Attention" name="filter_attention" domain="[('state', 'in', ['overdue', 'rental_pending'])]"/>
|
||||
<filter string="My Loaners" name="filter_my_loaners" domain="[('sales_rep_id', '=', uid)]"/>
|
||||
<separator/>
|
||||
<filter string="Needs Repair (Checkout)" name="filter_checkout_repair" domain="[('checkout_condition', '=', 'needs_repair')]"/>
|
||||
<filter string="Damaged (Return)" name="filter_return_damaged" domain="[('return_condition', 'in', ['needs_repair', 'damaged'])]"/>
|
||||
<separator/>
|
||||
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Client" name="group_client" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="Product" name="group_product" context="{'group_by': 'product_id'}"/>
|
||||
<filter string="Sales Rep" name="group_sales_rep" context="{'group_by': 'sales_rep_id'}"/>
|
||||
<filter string="Checkout Month" name="group_checkout_month" context="{'group_by': 'checkout_date:month'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_loaner_history_list" model="ir.ui.view">
|
||||
<field name="name">fusion.loaner.history.list</field>
|
||||
<field name="model">fusion.loaner.history</field>
|
||||
<field name="arch" type="xml">
|
||||
<list default_order="action_date desc, id desc">
|
||||
<field name="action_date"/>
|
||||
<field name="checkout_id"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="product_id" optional="show"/>
|
||||
<field name="lot_id" optional="hide"/>
|
||||
<field name="action" widget="badge"
|
||||
decoration-info="action in ('create', 'note')"
|
||||
decoration-success="action in ('checkout', 'return')"
|
||||
decoration-warning="action in ('reminder_sent', 'overdue', 'rental_pending')"
|
||||
decoration-danger="action in ('lost', 'condition_update')"/>
|
||||
<field name="user_id"/>
|
||||
<field name="notes" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_loaner_history_search" model="ir.ui.view">
|
||||
<field name="name">fusion.loaner.history.search</field>
|
||||
<field name="model">fusion.loaner.history</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Loaner History">
|
||||
<field name="checkout_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id" string="Serial Number"/>
|
||||
<field name="user_id"/>
|
||||
<separator/>
|
||||
<filter string="Checkouts" name="filter_checkout" domain="[('action', '=', 'checkout')]"/>
|
||||
<filter string="Returns" name="filter_return" domain="[('action', '=', 'return')]"/>
|
||||
<filter string="Reminders" name="filter_reminders" domain="[('action', '=', 'reminder_sent')]"/>
|
||||
<filter string="Overdue" name="filter_overdue" domain="[('action', '=', 'overdue')]"/>
|
||||
<filter string="Rental Conversions" name="filter_rental" domain="[('action', 'in', ['rental_pending', 'rental_converted'])]"/>
|
||||
<filter string="Lost" name="filter_lost" domain="[('action', '=', 'lost')]"/>
|
||||
<separator/>
|
||||
<filter string="Action Type" name="group_action" context="{'group_by': 'action'}"/>
|
||||
<filter string="Client" name="group_client" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="Product" name="group_product" context="{'group_by': 'product_id'}"/>
|
||||
<filter string="Month" name="group_month" context="{'group_by': 'action_date:month'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Wizard Views -->
|
||||
<record id="view_loaner_checkout_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.loaner.checkout.wizard.form</field>
|
||||
<field name="model">fusion.loaner.checkout.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="lot_id" context="{'default_product_id': product_id}"/>
|
||||
<field name="checkout_date"/>
|
||||
<field name="loaner_period_days"/>
|
||||
<field name="checkout_condition" widget="radio"/>
|
||||
<field name="checkout_notes"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_checkout" type="object" string="Checkout Loaner" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_loaner_return_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.loaner.return.wizard.form</field>
|
||||
<field name="model">fusion.loaner.return.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<group>
|
||||
<field name="checkout_id" readonly="1"/>
|
||||
<field name="return_date"/>
|
||||
<field name="return_condition" widget="radio"/>
|
||||
<field name="return_notes"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_return" type="object" string="Confirm Return" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Loaner Products Views -->
|
||||
<record id="view_fusion_loaner_products_list" model="ir.ui.view">
|
||||
<field name="name">product.template.loaner.list</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="default_code" string="Reference" optional="show"/>
|
||||
<field name="x_fc_equipment_type" optional="show"/>
|
||||
<field name="x_fc_wheelchair_category" optional="show"/>
|
||||
<field name="x_fc_seat_width" optional="show"/>
|
||||
<field name="x_fc_seat_depth" optional="show"/>
|
||||
<field name="x_fc_seat_height" optional="hide"/>
|
||||
<field name="x_fc_storage_location" optional="show"/>
|
||||
<field name="x_flm_current_location" optional="show"/>
|
||||
<field name="x_fc_listing_type" optional="show"/>
|
||||
<field name="x_fc_asset_number" optional="hide"/>
|
||||
<field name="active" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Dedicated simplified form for loaner products -->
|
||||
<record id="view_fusion_loaner_products_form" model="ir.ui.view">
|
||||
<field name="name">product.template.loaner.simplified.form</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="id" invisible="1"/>
|
||||
<field name="product_variant_count" invisible="1"/>
|
||||
<field name="x_fc_can_be_loaned" invisible="1"/>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_open_quants" type="object"
|
||||
icon="fa-cubes" class="oe_stat_button">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="qty_available" widget="statinfo" nolabel="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">On Hand</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_serial_numbers" type="object"
|
||||
icon="fa-barcode" class="oe_stat_button"
|
||||
invisible="tracking == 'none'">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="x_flm_serial_count" widget="statinfo" nolabel="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Serial Numbers</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
|
||||
invisible="active"/>
|
||||
<field name="image_1920" widget="image" class="oe_avatar"
|
||||
options="{'preview_image': 'image_128'}"/>
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Product"/>
|
||||
<h1>
|
||||
<field name="name" placeholder="Product Name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Identification">
|
||||
<field name="default_code" string="Reference"/>
|
||||
<field name="barcode"/>
|
||||
<field name="categ_id" string="Category"/>
|
||||
<field name="x_fc_asset_number"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group string="Inventory">
|
||||
<field name="type" string="Product Type"/>
|
||||
<field name="tracking"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Equipment Details" name="equipment_details">
|
||||
<group>
|
||||
<group string="Type & Classification">
|
||||
<field name="x_fc_equipment_type"/>
|
||||
<field name="x_fc_wheelchair_category"/>
|
||||
<field name="x_fc_listing_type"/>
|
||||
</group>
|
||||
<group string="Dimensions">
|
||||
<field name="x_fc_seat_width"/>
|
||||
<field name="x_fc_seat_depth"/>
|
||||
<field name="x_fc_seat_height"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Loaner Settings" name="loaner_settings">
|
||||
<group>
|
||||
<group string="Loaner Period">
|
||||
<field name="x_fc_loaner_period_days"/>
|
||||
</group>
|
||||
<group string="Rental Pricing (if not returned)">
|
||||
<field name="x_fc_rental_price_weekly"/>
|
||||
<field name="x_fc_rental_price_monthly"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Security Deposit">
|
||||
<group>
|
||||
<field name="x_fc_security_deposit_type"/>
|
||||
<field name="x_fc_security_deposit_amount"
|
||||
invisible="x_fc_security_deposit_type != 'fixed'"/>
|
||||
<field name="x_fc_security_deposit_percent"
|
||||
invisible="x_fc_security_deposit_type != 'percentage'"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Location" name="location_tracking">
|
||||
<group>
|
||||
<group string="Storage">
|
||||
<field name="x_fc_storage_location"/>
|
||||
<field name="x_flm_current_location" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Package Info" name="package_info">
|
||||
<group>
|
||||
<field name="x_fc_package_info" nolabel="1"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Internal Notes" name="internal_notes">
|
||||
<field name="description_picking" string="Notes"
|
||||
placeholder="Internal notes about this loaner product..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_loaner_products_search" model="ir.ui.view">
|
||||
<field name="name">product.template.loaner.search</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Loaner Products">
|
||||
<field name="name"/>
|
||||
<field name="default_code" string="Reference"/>
|
||||
<field name="x_fc_equipment_type"/>
|
||||
<field name="x_fc_asset_number"/>
|
||||
<separator/>
|
||||
<filter string="Owned" name="filter_owned" domain="[('x_fc_listing_type', '=', 'owned')]"/>
|
||||
<filter string="Borrowed" name="filter_borrowed" domain="[('x_fc_listing_type', '=', 'borrowed')]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="filter_archived" domain="[('active', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter string="Equipment Type" name="group_equipment_type" context="{'group_by': 'x_fc_equipment_type'}"/>
|
||||
<filter string="Wheelchair Category" name="group_wheelchair_category" context="{'group_by': 'x_fc_wheelchair_category'}"/>
|
||||
<filter string="Storage Location" name="group_storage_location" context="{'group_by': 'x_fc_storage_location'}"/>
|
||||
<filter string="Listing Type" name="group_listing_type" context="{'group_by': 'x_fc_listing_type'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Loaner checkbox on standard product form (global) -->
|
||||
<record id="view_product_template_loaner_checkbox" model="ir.ui.view">
|
||||
<field name="name">product.template.loaner.checkbox</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="product.product_template_form_view"/>
|
||||
<field name="priority">50</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='options']" position="inside">
|
||||
<span class="d-inline-flex">
|
||||
<field name="x_fc_can_be_loaned"/>
|
||||
<label for="x_fc_can_be_loaned" string="Loaner"/>
|
||||
</span>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Loaner Settings tab on standard product form (only visible when loaner checkbox is checked) -->
|
||||
<record id="view_product_template_loaner_form" model="ir.ui.view">
|
||||
<field name="name">product.template.loaner.form</field>
|
||||
<field name="model">product.template</field>
|
||||
<field name="inherit_id" ref="product.product_template_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='sales']" position="after">
|
||||
<page string="Loaner Settings" name="loaner_settings" invisible="not x_fc_can_be_loaned">
|
||||
<group>
|
||||
<group string="Loaner Period">
|
||||
<field name="x_fc_loaner_period_days"/>
|
||||
</group>
|
||||
<group string="Rental Pricing (if not returned)">
|
||||
<field name="x_fc_rental_price_weekly"/>
|
||||
<field name="x_fc_rental_price_monthly"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Equipment Details">
|
||||
<field name="x_fc_equipment_type"/>
|
||||
<field name="x_fc_wheelchair_category"/>
|
||||
<field name="x_fc_listing_type"/>
|
||||
<field name="x_fc_asset_number"/>
|
||||
</group>
|
||||
<group string="Dimensions">
|
||||
<field name="x_fc_seat_width"/>
|
||||
<field name="x_fc_seat_depth"/>
|
||||
<field name="x_fc_seat_height"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Location">
|
||||
<field name="x_fc_storage_location"/>
|
||||
<field name="x_flm_current_location" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Package Information">
|
||||
<field name="x_fc_package_info" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<group string="Security Deposit">
|
||||
<group>
|
||||
<field name="x_fc_security_deposit_type"/>
|
||||
<field name="x_fc_security_deposit_amount" invisible="x_fc_security_deposit_type != 'fixed'"/>
|
||||
<field name="x_fc_security_deposit_percent" invisible="x_fc_security_deposit_type != 'percentage'"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Actions -->
|
||||
<record id="action_fusion_loaner_checkout" model="ir.actions.act_window">
|
||||
<field name="name">Loaner Equipment</field>
|
||||
<field name="res_model">fusion.loaner.checkout</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_loaner_checkout_search"/>
|
||||
<field name="context">{'search_default_filter_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">No loaner checkouts yet</p>
|
||||
<p>Track loaner equipment issued to clients during assessments or trials.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_loaner_all" model="ir.actions.act_window">
|
||||
<field name="name">All Loaners</field>
|
||||
<field name="res_model">fusion.loaner.checkout</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_loaner_checkout_search"/>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_loaner_overdue" model="ir.actions.act_window">
|
||||
<field name="name">Overdue Loaners</field>
|
||||
<field name="res_model">fusion.loaner.checkout</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_loaner_checkout_search"/>
|
||||
<field name="context">{'search_default_filter_attention': 1}</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_loaner_returned" model="ir.actions.act_window">
|
||||
<field name="name">Returned Loaners</field>
|
||||
<field name="res_model">fusion.loaner.checkout</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_loaner_checkout_search"/>
|
||||
<field name="context">{'search_default_filter_returned': 1}</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_loaner_history" model="ir.actions.act_window">
|
||||
<field name="name">Loaner History</field>
|
||||
<field name="res_model">fusion.loaner.history</field>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="search_view_id" ref="view_fusion_loaner_history_search"/>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_loaner_products" model="ir.actions.act_window">
|
||||
<field name="name">Loaner Products</field>
|
||||
<field name="res_model">product.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" eval="False"/>
|
||||
<field name="search_view_id" ref="view_fusion_loaner_products_search"/>
|
||||
<field name="domain">[('x_fc_can_be_loaned', '=', True)]</field>
|
||||
<field name="context">{'default_x_fc_can_be_loaned': True, 'default_sale_ok': False, 'default_purchase_ok': False, 'default_tracking': 'serial'}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">No loaner products configured yet</p>
|
||||
<p>Mark products as "Loaner" in the product form to add them here.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_loaner_products_view_list" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="1"/>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="view_id" ref="view_fusion_loaner_products_list"/>
|
||||
<field name="act_window_id" ref="action_fusion_loaner_products"/>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_loaner_products_view_form" model="ir.actions.act_window.view">
|
||||
<field name="sequence" eval="2"/>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_fusion_loaner_products_form"/>
|
||||
<field name="act_window_id" ref="action_fusion_loaner_products"/>
|
||||
</record>
|
||||
|
||||
<!-- Menus - standalone root -->
|
||||
<menuitem id="menu_loaner_root"
|
||||
name="Loaners"
|
||||
web_icon="fusion_loaners_management,static/description/icon.png"
|
||||
sequence="58"/>
|
||||
|
||||
<menuitem id="menu_loaner_active"
|
||||
name="Active Loaners"
|
||||
parent="menu_loaner_root"
|
||||
action="action_fusion_loaner_checkout"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_loaner_all"
|
||||
name="All Loaners"
|
||||
parent="menu_loaner_root"
|
||||
action="action_fusion_loaner_all"
|
||||
sequence="15"/>
|
||||
|
||||
<menuitem id="menu_loaner_overdue"
|
||||
name="Overdue / Attention"
|
||||
parent="menu_loaner_root"
|
||||
action="action_fusion_loaner_overdue"
|
||||
sequence="18"/>
|
||||
|
||||
<menuitem id="menu_loaner_returned"
|
||||
name="Returned"
|
||||
parent="menu_loaner_root"
|
||||
action="action_fusion_loaner_returned"
|
||||
sequence="19"/>
|
||||
|
||||
<menuitem id="menu_loaner_history"
|
||||
name="Loaner History"
|
||||
parent="menu_loaner_root"
|
||||
action="action_fusion_loaner_history"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_loaner_products"
|
||||
name="Loaner Products"
|
||||
parent="menu_loaner_root"
|
||||
action="action_fusion_loaner_products"
|
||||
sequence="30"/>
|
||||
</odoo>
|
||||
116
fusion_loaners_management/views/portal_loaner_templates.xml
Normal file
116
fusion_loaners_management/views/portal_loaner_templates.xml
Normal file
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- Loaner checkout modal template (can be t-called from portal pages) -->
|
||||
<template id="loaner_checkout_modal" name="Loaner Checkout Modal">
|
||||
<div class="modal fade" id="loanerCheckoutModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Checkout Loaner Equipment</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"/>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="modal_order_id"/>
|
||||
<input type="hidden" id="modal_client_id"/>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-bold">Category</label>
|
||||
<select id="modal_category_id" class="form-select">
|
||||
<option value="">All Categories</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-bold">Product</label>
|
||||
<select id="modal_product_id" class="form-select">
|
||||
<option value="">-- Select Product --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-bold">Serial Number</label>
|
||||
<select id="modal_lot_id" class="form-select">
|
||||
<option value="">-- Select Serial --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-bold">Loan Days</label>
|
||||
<input type="number" id="modal_loan_days" class="form-control" value="7"/>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<h6>Quick Create Product</h6>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-4">
|
||||
<select id="modal_new_category_id" class="form-select form-select-sm">
|
||||
<option value="">-- Category --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" id="modal_new_product_name" class="form-control form-control-sm" placeholder="Product Name"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" id="modal_new_serial" class="form-control form-control-sm" placeholder="Serial Number"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button id="modal_btn_create_product" class="btn btn-sm btn-outline-primary w-100">
|
||||
<i class="fa fa-plus me-1"/>Create Product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal_create_result" style="display:none;"/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button id="modal_btn_checkout" class="btn btn-primary">
|
||||
<i class="fa fa-check me-1"/>Checkout Loaner
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="loaner_return_modal" name="Loaner Return Modal">
|
||||
<div class="modal fade" id="loanerReturnModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Return Loaner Equipment</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"/>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="return_modal_checkout_id"/>
|
||||
<p><strong>Product:</strong> <span id="return_modal_product_name"/></p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Condition</label>
|
||||
<select id="return_modal_condition" class="form-select">
|
||||
<option value="excellent">Excellent</option>
|
||||
<option value="good" selected="selected">Good</option>
|
||||
<option value="fair">Fair</option>
|
||||
<option value="needs_repair">Needs Repair</option>
|
||||
<option value="damaged">Damaged</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Return Location</label>
|
||||
<select id="return_modal_location_id" class="form-select">
|
||||
<option value="">-- Select Location --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Notes</label>
|
||||
<textarea id="return_modal_notes" class="form-control" rows="2"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button id="return_modal_btn_submit" class="btn btn-success">
|
||||
<i class="fa fa-check me-1"/>Confirm Return
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</odoo>
|
||||
17
fusion_loaners_management/views/sale_order_views.xml
Normal file
17
fusion_loaners_management/views/sale_order_views.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_sale_order_loaner_stat_button" model="ir.ui.view">
|
||||
<field name="name">sale.order.loaner.stat.button</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_view_loaners" type="object"
|
||||
class="oe_stat_button" icon="fa-wheelchair"
|
||||
invisible="x_fc_loaner_count == 0">
|
||||
<field name="x_fc_loaner_count" widget="statinfo" string="Loaners"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
6
fusion_loaners_management/wizard/__init__.py
Normal file
6
fusion_loaners_management/wizard/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import loaner_checkout_wizard
|
||||
from . import loaner_return_wizard
|
||||
159
fusion_loaners_management/wizard/loaner_checkout_wizard.py
Normal file
159
fusion_loaners_management/wizard/loaner_checkout_wizard.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoanerCheckoutWizard(models.TransientModel):
|
||||
_name = 'fusion.loaner.checkout.wizard'
|
||||
_description = 'Loaner Checkout Wizard'
|
||||
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Client', required=True)
|
||||
authorizer_id = fields.Many2one('res.partner', string='Authorizer')
|
||||
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Product',
|
||||
domain="[('x_fc_can_be_loaned', '=', True)]",
|
||||
required=True,
|
||||
)
|
||||
lot_id = fields.Many2one(
|
||||
'stock.lot',
|
||||
string='Serial Number',
|
||||
domain="[('product_id', '=', product_id)]",
|
||||
)
|
||||
available_lot_ids = fields.Many2many(
|
||||
'stock.lot',
|
||||
compute='_compute_available_lots',
|
||||
string='Available Serial Numbers',
|
||||
)
|
||||
|
||||
checkout_date = fields.Date(
|
||||
string='Checkout Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
)
|
||||
loaner_period_days = fields.Integer(string='Loaner Period (Days)', default=7)
|
||||
expected_return_date = fields.Date(
|
||||
string='Expected Return Date',
|
||||
compute='_compute_expected_return',
|
||||
)
|
||||
|
||||
checkout_condition = fields.Selection([
|
||||
('excellent', 'Excellent'),
|
||||
('good', 'Good'),
|
||||
('fair', 'Fair'),
|
||||
('needs_repair', 'Needs Repair'),
|
||||
], string='Condition', default='excellent', required=True)
|
||||
checkout_notes = fields.Text(string='Notes')
|
||||
|
||||
checkout_photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'loaner_checkout_wizard_photo_rel',
|
||||
'wizard_id',
|
||||
'attachment_id',
|
||||
string='Photos',
|
||||
)
|
||||
|
||||
delivery_address = fields.Text(string='Delivery Address')
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_available_lots(self):
|
||||
for wizard in self:
|
||||
if wizard.product_id:
|
||||
loaner_location = self.env.ref(
|
||||
'fusion_loaners_management.stock_location_loaner',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if loaner_location:
|
||||
quants = self.env['stock.quant'].search([
|
||||
('product_id', '=', wizard.product_id.id),
|
||||
('location_id', '=', loaner_location.id),
|
||||
('quantity', '>', 0),
|
||||
])
|
||||
wizard.available_lot_ids = quants.mapped('lot_id')
|
||||
else:
|
||||
wizard.available_lot_ids = self.env['stock.lot'].search([
|
||||
('product_id', '=', wizard.product_id.id),
|
||||
])
|
||||
else:
|
||||
wizard.available_lot_ids = False
|
||||
|
||||
@api.depends('checkout_date', 'loaner_period_days')
|
||||
def _compute_expected_return(self):
|
||||
from datetime import timedelta
|
||||
for wizard in self:
|
||||
if wizard.checkout_date and wizard.loaner_period_days:
|
||||
wizard.expected_return_date = wizard.checkout_date + timedelta(days=wizard.loaner_period_days)
|
||||
else:
|
||||
wizard.expected_return_date = False
|
||||
|
||||
@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.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
active_model = self._context.get('active_model')
|
||||
active_id = self._context.get('active_id')
|
||||
if active_model == 'sale.order' and active_id:
|
||||
order = self.env['sale.order'].browse(active_id)
|
||||
res['sale_order_id'] = order.id
|
||||
res['partner_id'] = order.partner_id.id
|
||||
if hasattr(order, 'x_fc_authorizer_id') and order.x_fc_authorizer_id:
|
||||
res['authorizer_id'] = order.x_fc_authorizer_id.id
|
||||
if order.partner_shipping_id:
|
||||
res['delivery_address'] = order.partner_shipping_id.contact_address
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
default_period = int(ICP.get_param('fusion_loaners.default_loaner_period_days', '7'))
|
||||
res['loaner_period_days'] = default_period
|
||||
return res
|
||||
|
||||
def action_checkout(self):
|
||||
self.ensure_one()
|
||||
if not self.product_id:
|
||||
raise UserError(_("Please select a product."))
|
||||
photo_ids = []
|
||||
for photo in self.checkout_photo_ids:
|
||||
new_attachment = self.env['ir.attachment'].create({
|
||||
'name': photo.name,
|
||||
'datas': photo.datas,
|
||||
'res_model': 'fusion.loaner.checkout',
|
||||
'res_id': 0,
|
||||
})
|
||||
photo_ids.append(new_attachment.id)
|
||||
checkout_vals = {
|
||||
'sale_order_id': self.sale_order_id.id if self.sale_order_id else False,
|
||||
'partner_id': self.partner_id.id,
|
||||
'authorizer_id': self.authorizer_id.id if self.authorizer_id else False,
|
||||
'sales_rep_id': self.env.user.id,
|
||||
'product_id': self.product_id.id,
|
||||
'lot_id': self.lot_id.id if self.lot_id else False,
|
||||
'checkout_date': self.checkout_date,
|
||||
'loaner_period_days': self.loaner_period_days,
|
||||
'checkout_condition': self.checkout_condition,
|
||||
'checkout_notes': self.checkout_notes,
|
||||
'delivery_address': self.delivery_address,
|
||||
}
|
||||
checkout = self.env['fusion.loaner.checkout'].create(checkout_vals)
|
||||
if photo_ids:
|
||||
self.env['ir.attachment'].browse(photo_ids).write({'res_id': checkout.id})
|
||||
checkout.checkout_photo_ids = [(6, 0, photo_ids)]
|
||||
checkout.action_checkout()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Loaner Checkout'),
|
||||
'res_model': 'fusion.loaner.checkout',
|
||||
'res_id': checkout.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
107
fusion_loaners_management/wizard/loaner_return_wizard.py
Normal file
107
fusion_loaners_management/wizard/loaner_return_wizard.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoanerReturnWizard(models.TransientModel):
|
||||
_name = 'fusion.loaner.return.wizard'
|
||||
_description = 'Loaner Return Wizard'
|
||||
|
||||
checkout_id = fields.Many2one(
|
||||
'fusion.loaner.checkout',
|
||||
string='Checkout Record',
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Product',
|
||||
related='checkout_id.product_id',
|
||||
readonly=True,
|
||||
)
|
||||
lot_id = fields.Many2one(
|
||||
'stock.lot',
|
||||
string='Serial Number',
|
||||
related='checkout_id.lot_id',
|
||||
readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Client',
|
||||
related='checkout_id.partner_id',
|
||||
readonly=True,
|
||||
)
|
||||
checkout_date = fields.Date(
|
||||
string='Checkout Date',
|
||||
related='checkout_id.checkout_date',
|
||||
readonly=True,
|
||||
)
|
||||
days_out = fields.Integer(
|
||||
string='Days Out',
|
||||
related='checkout_id.days_out',
|
||||
readonly=True,
|
||||
)
|
||||
checkout_condition = fields.Selection(
|
||||
related='checkout_id.checkout_condition',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
return_date = fields.Date(
|
||||
string='Return Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
)
|
||||
return_condition = fields.Selection([
|
||||
('excellent', 'Excellent'),
|
||||
('good', 'Good'),
|
||||
('fair', 'Fair'),
|
||||
('needs_repair', 'Needs Repair'),
|
||||
('damaged', 'Damaged'),
|
||||
], string='Condition', required=True)
|
||||
return_notes = fields.Text(string='Notes')
|
||||
return_photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'loaner_return_wizard_photo_rel',
|
||||
'wizard_id',
|
||||
'attachment_id',
|
||||
string='Photos',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super().default_get(fields_list)
|
||||
checkout_id = self._context.get('default_checkout_id')
|
||||
if checkout_id:
|
||||
checkout = self.env['fusion.loaner.checkout'].browse(checkout_id)
|
||||
res['checkout_id'] = checkout.id
|
||||
res['return_condition'] = checkout.checkout_condition
|
||||
return res
|
||||
|
||||
def action_return(self):
|
||||
self.ensure_one()
|
||||
if not self.checkout_id:
|
||||
raise UserError(_("No checkout record found."))
|
||||
if self.checkout_id.state not in ('checked_out', 'overdue', 'rental_pending'):
|
||||
raise UserError(_("This loaner has already been returned or is not in a returnable state."))
|
||||
photo_ids = []
|
||||
for photo in self.return_photo_ids:
|
||||
new_attachment = self.env['ir.attachment'].create({
|
||||
'name': photo.name,
|
||||
'datas': photo.datas,
|
||||
'res_model': 'fusion.loaner.checkout',
|
||||
'res_id': self.checkout_id.id,
|
||||
})
|
||||
photo_ids.append(new_attachment.id)
|
||||
self.checkout_id.action_process_return(
|
||||
return_condition=self.return_condition,
|
||||
return_notes=self.return_notes,
|
||||
return_photos=photo_ids if photo_ids else None,
|
||||
)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
Reference in New Issue
Block a user