Compare commits
5 Commits
34e5b46025
...
14fe9ab716
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14fe9ab716 | ||
|
|
3c8f83b8e6 | ||
|
|
4384987b82 | ||
|
|
de8e3a83bb | ||
|
|
3e59f9d5f6 |
@@ -1111,9 +1111,11 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
SaleOrder = request.env['sale.order'].sudo()
|
SaleOrder = request.env['sale.order'].sudo()
|
||||||
today = fields.Date.context_today(request.env['fusion.technician.task'])
|
today = fields.Date.context_today(request.env['fusion.technician.task'])
|
||||||
|
|
||||||
# Today's tasks
|
# Today's tasks (lead or additional technician)
|
||||||
today_tasks = Task.search([
|
today_tasks = Task.search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', today),
|
('scheduled_date', '=', today),
|
||||||
('status', '!=', 'cancelled'),
|
('status', '!=', 'cancelled'),
|
||||||
], order='sequence, time_start, id')
|
], order='sequence, time_start, id')
|
||||||
@@ -1143,7 +1145,9 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
tomorrow = today + timedelta(days=1)
|
tomorrow = today + timedelta(days=1)
|
||||||
tomorrow_count = Task.search_count([
|
tomorrow_count = Task.search_count([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', tomorrow),
|
('scheduled_date', '=', tomorrow),
|
||||||
('status', '!=', 'cancelled'),
|
('status', '!=', 'cancelled'),
|
||||||
])
|
])
|
||||||
@@ -1181,7 +1185,7 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
user = request.env.user
|
user = request.env.user
|
||||||
Task = request.env['fusion.technician.task'].sudo()
|
Task = request.env['fusion.technician.task'].sudo()
|
||||||
|
|
||||||
domain = [('technician_id', '=', user.id)]
|
domain = ['|', ('technician_id', '=', user.id), ('additional_technician_ids', 'in', [user.id])]
|
||||||
|
|
||||||
if filter_status == 'scheduled':
|
if filter_status == 'scheduled':
|
||||||
domain.append(('status', '=', 'scheduled'))
|
domain.append(('status', '=', 'scheduled'))
|
||||||
@@ -1237,14 +1241,19 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
raise AccessError(_('You do not have access to this task.'))
|
raise AccessError(_('You do not have access to this task.'))
|
||||||
except (AccessError, MissingError):
|
except (AccessError, MissingError):
|
||||||
return request.redirect('/my/technician/tasks')
|
return request.redirect('/my/technician/tasks')
|
||||||
|
|
||||||
# Check for earlier uncompleted tasks (sequential enforcement)
|
# Check for earlier uncompleted tasks (sequential enforcement)
|
||||||
earlier_incomplete = Task.search([
|
earlier_incomplete = Task.search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', task.scheduled_date),
|
('scheduled_date', '=', task.scheduled_date),
|
||||||
('time_start', '<', task.time_start),
|
('time_start', '<', task.time_start),
|
||||||
('status', 'not in', ['completed', 'cancelled']),
|
('status', 'not in', ['completed', 'cancelled']),
|
||||||
@@ -1284,7 +1293,10 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
Attachment = request.env['ir.attachment'].sudo()
|
Attachment = request.env['ir.attachment'].sudo()
|
||||||
try:
|
try:
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found'}
|
return {'success': False, 'error': 'Task not found'}
|
||||||
|
|
||||||
from markupsafe import Markup, escape
|
from markupsafe import Markup, escape
|
||||||
@@ -1421,7 +1433,10 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found or not assigned to you'}
|
return {'success': False, 'error': 'Task not found or not assigned to you'}
|
||||||
|
|
||||||
if action == 'en_route':
|
if action == 'en_route':
|
||||||
@@ -1470,7 +1485,10 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found'}
|
return {'success': False, 'error': 'Task not found'}
|
||||||
|
|
||||||
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
||||||
@@ -1532,7 +1550,10 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found'}
|
return {'success': False, 'error': 'Task not found'}
|
||||||
|
|
||||||
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
||||||
@@ -1589,7 +1610,10 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
ICP = request.env['ir.config_parameter'].sudo()
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
task = Task.browse(task_id)
|
task = Task.browse(task_id)
|
||||||
if not task.exists() or task.technician_id.id != user.id:
|
if not task.exists() or (
|
||||||
|
task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids
|
||||||
|
):
|
||||||
return {'success': False, 'error': 'Task not found'}
|
return {'success': False, 'error': 'Task not found'}
|
||||||
|
|
||||||
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
||||||
@@ -1672,7 +1696,9 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
tomorrow = today + timedelta(days=1)
|
tomorrow = today + timedelta(days=1)
|
||||||
|
|
||||||
tomorrow_tasks = Task.search([
|
tomorrow_tasks = Task.search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', tomorrow),
|
('scheduled_date', '=', tomorrow),
|
||||||
('status', '!=', 'cancelled'),
|
('status', '!=', 'cancelled'),
|
||||||
], order='sequence, time_start, id')
|
], order='sequence, time_start, id')
|
||||||
@@ -1711,7 +1737,9 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
return request.redirect('/my/technician')
|
return request.redirect('/my/technician')
|
||||||
|
|
||||||
tasks = Task.search([
|
tasks = Task.search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
('scheduled_date', '=', schedule_date),
|
('scheduled_date', '=', schedule_date),
|
||||||
('status', '!=', 'cancelled'),
|
('status', '!=', 'cancelled'),
|
||||||
], order='sequence, time_start, id')
|
], order='sequence, time_start, id')
|
||||||
@@ -1835,7 +1863,9 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
if not has_access and partner.is_technician_portal:
|
if not has_access and partner.is_technician_portal:
|
||||||
task_count = request.env['fusion.technician.task'].sudo().search_count([
|
task_count = request.env['fusion.technician.task'].sudo().search_count([
|
||||||
('sale_order_id', '=', order.id),
|
('sale_order_id', '=', order.id),
|
||||||
|
'|',
|
||||||
('technician_id', '=', user.id),
|
('technician_id', '=', user.id),
|
||||||
|
('additional_technician_ids', 'in', [user.id]),
|
||||||
])
|
])
|
||||||
if task_count:
|
if task_count:
|
||||||
has_access = True
|
has_access = True
|
||||||
@@ -2490,7 +2520,8 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
not task.exists()
|
not task.exists()
|
||||||
or task.technician_id.id != user.id
|
or (task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids)
|
||||||
or task.task_type != 'pickup'
|
or task.task_type != 'pickup'
|
||||||
):
|
):
|
||||||
return request.redirect('/my')
|
return request.redirect('/my')
|
||||||
@@ -2515,7 +2546,8 @@ class AuthorizerPortal(CustomerPortal):
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
not task.exists()
|
not task.exists()
|
||||||
or task.technician_id.id != user.id
|
or (task.technician_id.id != user.id
|
||||||
|
and user.id not in task.additional_technician_ids.ids)
|
||||||
or task.task_type != 'pickup'
|
or task.task_type != 'pickup'
|
||||||
):
|
):
|
||||||
return {'success': False, 'error': 'Access denied.'}
|
return {'success': False, 'error': 'Access denied.'}
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Portal Groups - grouped under Fusion Claims privilege -->
|
<!-- Portal Groups (auto-assigned from Contact form Portal Access tab) -->
|
||||||
<record id="group_authorizer_portal" model="res.groups">
|
<record id="group_authorizer_portal" model="res.groups">
|
||||||
<field name="name">Authorizer Portal User</field>
|
<field name="name">Authorizer Portal User</field>
|
||||||
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
|
<field name="privilege_id" eval="False"/>
|
||||||
<field name="comment">Portal users who are Authorizers (OTs/Therapists)</field>
|
<field name="comment">Portal users who are Authorizers (OTs/Therapists)</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="group_sales_rep_portal" model="res.groups">
|
<record id="group_sales_rep_portal" model="res.groups">
|
||||||
<field name="name">Sales Rep Portal User</field>
|
<field name="name">Sales Rep Portal User</field>
|
||||||
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
|
<field name="privilege_id" eval="False"/>
|
||||||
<field name="comment">Portal users who are Sales Representatives</field>
|
<field name="comment">Portal users who are Sales Representatives</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="group_technician_portal" model="res.groups">
|
<record id="group_technician_portal" model="res.groups">
|
||||||
<field name="name">Technician Portal User</field>
|
<field name="name">Technician Portal User</field>
|
||||||
<field name="privilege_id" ref="fusion_claims.res_groups_privilege_fusion_claims"/>
|
<field name="privilege_id" eval="False"/>
|
||||||
<field name="comment">Portal users who are Field Technicians for deliveries</field>
|
<field name="comment">Portal users who are Field Technicians for deliveries</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
|||||||
@@ -479,14 +479,14 @@ class SaleOrder(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Authorizer Required field - only for certain sale types
|
# Authorizer Required field - only for certain sale types
|
||||||
# For: odsp, direct_private, insurance, other, rental
|
# For: odsp, direct_private, insurance, other (NOT rental)
|
||||||
x_fc_authorizer_required = fields.Selection(
|
x_fc_authorizer_required = fields.Selection(
|
||||||
selection=[
|
selection=[
|
||||||
('yes', 'Yes'),
|
('yes', 'Yes'),
|
||||||
('no', 'No'),
|
('no', 'No'),
|
||||||
],
|
],
|
||||||
string='Authorizer Required?',
|
string='Authorizer Required?',
|
||||||
help='For ODSP, Direct/Private, Insurance, Others, and Rentals - specify if an authorizer is needed.',
|
help='For ODSP, Direct/Private, Insurance, Others - specify if an authorizer is needed.',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Computed field to determine if authorizer should be shown
|
# Computed field to determine if authorizer should be shown
|
||||||
@@ -498,21 +498,18 @@ class SaleOrder(models.Model):
|
|||||||
@api.depends('x_fc_sale_type', 'x_fc_authorizer_required')
|
@api.depends('x_fc_sale_type', 'x_fc_authorizer_required')
|
||||||
def _compute_show_authorizer(self):
|
def _compute_show_authorizer(self):
|
||||||
"""Compute whether to show the authorizer field based on sale type and authorizer_required."""
|
"""Compute whether to show the authorizer field based on sale type and authorizer_required."""
|
||||||
# Sale types that require the "Authorizer Required?" question
|
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other')
|
||||||
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
|
|
||||||
# Sale types where authorizer is always shown/required
|
|
||||||
always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
|
always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
|
||||||
|
|
||||||
for order in self:
|
for order in self:
|
||||||
sale_type = order.x_fc_sale_type
|
sale_type = order.x_fc_sale_type
|
||||||
if sale_type in always_auth_types:
|
if sale_type == 'rental':
|
||||||
# Always show authorizer for ADP-related types
|
order.x_fc_show_authorizer = False
|
||||||
|
elif sale_type in always_auth_types:
|
||||||
order.x_fc_show_authorizer = True
|
order.x_fc_show_authorizer = True
|
||||||
elif sale_type in optional_auth_types:
|
elif sale_type in optional_auth_types:
|
||||||
# Show authorizer only if user selected "Yes"
|
|
||||||
order.x_fc_show_authorizer = order.x_fc_authorizer_required == 'yes'
|
order.x_fc_show_authorizer = order.x_fc_authorizer_required == 'yes'
|
||||||
else:
|
else:
|
||||||
# No sale type selected - don't show
|
|
||||||
order.x_fc_show_authorizer = False
|
order.x_fc_show_authorizer = False
|
||||||
|
|
||||||
# Computed field to determine if "Authorizer Required?" question should be shown
|
# Computed field to determine if "Authorizer Required?" question should be shown
|
||||||
@@ -524,7 +521,7 @@ class SaleOrder(models.Model):
|
|||||||
@api.depends('x_fc_sale_type')
|
@api.depends('x_fc_sale_type')
|
||||||
def _compute_show_authorizer_question(self):
|
def _compute_show_authorizer_question(self):
|
||||||
"""Compute whether to show the 'Authorizer Required?' field."""
|
"""Compute whether to show the 'Authorizer Required?' field."""
|
||||||
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
|
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other')
|
||||||
for order in self:
|
for order in self:
|
||||||
order.x_fc_show_authorizer_question = order.x_fc_sale_type in optional_auth_types
|
order.x_fc_show_authorizer_question = order.x_fc_sale_type in optional_auth_types
|
||||||
|
|
||||||
@@ -5810,19 +5807,19 @@ class SaleOrder(models.Model):
|
|||||||
|
|
||||||
if should_validate_authorizer:
|
if should_validate_authorizer:
|
||||||
always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
|
always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
|
||||||
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
|
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other')
|
||||||
|
|
||||||
for order in self:
|
for order in self:
|
||||||
sale_type = get_val(order, 'x_fc_sale_type')
|
sale_type = get_val(order, 'x_fc_sale_type')
|
||||||
|
if sale_type == 'rental':
|
||||||
|
continue
|
||||||
auth_id = get_val(order, 'x_fc_authorizer_id')
|
auth_id = get_val(order, 'x_fc_authorizer_id')
|
||||||
auth_required = get_val(order, 'x_fc_authorizer_required')
|
auth_required = get_val(order, 'x_fc_authorizer_required')
|
||||||
|
|
||||||
if sale_type in always_auth_types:
|
if sale_type in always_auth_types:
|
||||||
# Always required for these types
|
|
||||||
if not auth_id:
|
if not auth_id:
|
||||||
raise UserError("Authorizer is required for this sale type.")
|
raise UserError("Authorizer is required for this sale type.")
|
||||||
elif sale_type in optional_auth_types and auth_required == 'yes':
|
elif sale_type in optional_auth_types and auth_required == 'yes':
|
||||||
# Required only if user selected "Yes"
|
|
||||||
if not auth_id:
|
if not auth_id:
|
||||||
raise UserError("Authorizer is required. You selected 'Yes' for Authorizer Required.")
|
raise UserError("Authorizer is required. You selected 'Yes' for Authorizer Required.")
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ from datetime import timedelta
|
|||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SYNC_TASK_FIELDS = [
|
SYNC_TASK_FIELDS = [
|
||||||
'x_fc_sync_uuid', 'name', 'technician_id', 'task_type', 'status',
|
'x_fc_sync_uuid', 'name', 'technician_id', 'additional_technician_ids',
|
||||||
|
'task_type', 'status',
|
||||||
'scheduled_date', 'time_start', 'time_end', 'duration_hours',
|
'scheduled_date', 'time_start', 'time_end', 'duration_hours',
|
||||||
'address_street', 'address_street2', 'address_city', 'address_zip',
|
'address_street', 'address_street2', 'address_city', 'address_zip',
|
||||||
'address_lat', 'address_lng', 'priority', 'partner_id',
|
'address_lat', 'address_lng', 'priority', 'partner_id',
|
||||||
@@ -196,7 +197,11 @@ class FusionTaskSyncConfig(models.Model):
|
|||||||
_logger.exception("Task sync push to %s failed", config.name)
|
_logger.exception("Task sync push to %s failed", config.name)
|
||||||
|
|
||||||
def _push_tasks_to_remote(self, tasks, operation, local_instance_id):
|
def _push_tasks_to_remote(self, tasks, operation, local_instance_id):
|
||||||
"""Push task data to a single remote instance."""
|
"""Push task data to a single remote instance.
|
||||||
|
|
||||||
|
Maps additional_technician_ids via sync IDs so the remote instance
|
||||||
|
also blocks those technicians' schedules.
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
local_map = self._get_local_tech_map()
|
local_map = self._get_local_tech_map()
|
||||||
remote_map = self._get_remote_tech_map()
|
remote_map = self._get_remote_tech_map()
|
||||||
@@ -213,12 +218,22 @@ class FusionTaskSyncConfig(models.Model):
|
|||||||
if not remote_tech_uid:
|
if not remote_tech_uid:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Map additional technicians to remote user IDs
|
||||||
|
remote_additional_ids = []
|
||||||
|
for tech in task.additional_technician_ids:
|
||||||
|
add_sync_id = local_map.get(tech.id)
|
||||||
|
if add_sync_id:
|
||||||
|
remote_add_uid = remote_map.get(add_sync_id)
|
||||||
|
if remote_add_uid:
|
||||||
|
remote_additional_ids.append(remote_add_uid)
|
||||||
|
|
||||||
task_data = {
|
task_data = {
|
||||||
'x_fc_sync_uuid': task.x_fc_sync_uuid,
|
'x_fc_sync_uuid': task.x_fc_sync_uuid,
|
||||||
'x_fc_sync_source': local_instance_id,
|
'x_fc_sync_source': local_instance_id,
|
||||||
'x_fc_sync_remote_id': task.id,
|
'x_fc_sync_remote_id': task.id,
|
||||||
'name': f"[{local_instance_id.upper()}] {task.name}",
|
'name': f"[{local_instance_id.upper()}] {task.name}",
|
||||||
'technician_id': remote_tech_uid,
|
'technician_id': remote_tech_uid,
|
||||||
|
'additional_technician_ids': [(6, 0, remote_additional_ids)],
|
||||||
'task_type': task.task_type,
|
'task_type': task.task_type,
|
||||||
'status': task.status,
|
'status': task.status,
|
||||||
'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False,
|
'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False,
|
||||||
@@ -295,7 +310,9 @@ class FusionTaskSyncConfig(models.Model):
|
|||||||
remote_tasks = self._rpc(
|
remote_tasks = self._rpc(
|
||||||
'fusion.technician.task', 'search_read',
|
'fusion.technician.task', 'search_read',
|
||||||
[[
|
[[
|
||||||
|
'|',
|
||||||
('technician_id', 'in', remote_tech_ids),
|
('technician_id', 'in', remote_tech_ids),
|
||||||
|
('additional_technician_ids', 'in', remote_tech_ids),
|
||||||
('scheduled_date', '>=', str(cutoff)),
|
('scheduled_date', '>=', str(cutoff)),
|
||||||
('x_fc_sync_source', '=', False),
|
('x_fc_sync_source', '=', False),
|
||||||
]],
|
]],
|
||||||
@@ -324,12 +341,24 @@ class FusionTaskSyncConfig(models.Model):
|
|||||||
partner_raw = rt.get('partner_id')
|
partner_raw = rt.get('partner_id')
|
||||||
client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
|
client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
|
||||||
|
|
||||||
|
# Map additional technicians from remote to local
|
||||||
|
local_additional_ids = []
|
||||||
|
remote_add_raw = rt.get('additional_technician_ids', [])
|
||||||
|
if remote_add_raw and isinstance(remote_add_raw, list):
|
||||||
|
for add_uid in remote_add_raw:
|
||||||
|
add_sync_id = remote_syncid_by_uid.get(add_uid)
|
||||||
|
if add_sync_id:
|
||||||
|
local_add_uid = local_syncid_to_uid.get(add_sync_id)
|
||||||
|
if local_add_uid:
|
||||||
|
local_additional_ids.append(local_add_uid)
|
||||||
|
|
||||||
vals = {
|
vals = {
|
||||||
'x_fc_sync_uuid': sync_uuid,
|
'x_fc_sync_uuid': sync_uuid,
|
||||||
'x_fc_sync_source': self.instance_id,
|
'x_fc_sync_source': self.instance_id,
|
||||||
'x_fc_sync_remote_id': rt['id'],
|
'x_fc_sync_remote_id': rt['id'],
|
||||||
'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}",
|
'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}",
|
||||||
'technician_id': local_uid,
|
'technician_id': local_uid,
|
||||||
|
'additional_technician_ids': [(6, 0, local_additional_ids)],
|
||||||
'task_type': rt.get('task_type', 'delivery'),
|
'task_type': rt.get('task_type', 'delivery'),
|
||||||
'status': rt.get('status', 'scheduled'),
|
'status': rt.get('status', 'scheduled'),
|
||||||
'scheduled_date': rt.get('scheduled_date'),
|
'scheduled_date': rt.get('scheduled_date'),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class FusionTechnicianTask(models.Model):
|
|||||||
_rec_name = 'name'
|
_rec_name = 'name'
|
||||||
|
|
||||||
def _compute_display_name(self):
|
def _compute_display_name(self):
|
||||||
"""Richer display name: Client - Type | 9:00 AM - 10:00 AM."""
|
"""Richer display name: Client - Type | 9:00 AM - 10:00 AM [+2 techs]."""
|
||||||
type_labels = dict(self._fields['task_type'].selection)
|
type_labels = dict(self._fields['task_type'].selection)
|
||||||
for task in self:
|
for task in self:
|
||||||
client = task.x_fc_sync_client_name if task.x_fc_sync_source else (task.partner_id.name or '')
|
client = task.x_fc_sync_client_name if task.x_fc_sync_source else (task.partner_id.name or '')
|
||||||
@@ -41,6 +41,9 @@ class FusionTechnicianTask(models.Model):
|
|||||||
label = ' - '.join(p for p in parts if p)
|
label = ' - '.join(p for p in parts if p)
|
||||||
if start and end:
|
if start and end:
|
||||||
label += f' | {start} - {end}'
|
label += f' | {start} - {end}'
|
||||||
|
extra = len(task.additional_technician_ids)
|
||||||
|
if extra:
|
||||||
|
label += f' [+{extra} tech{"s" if extra > 1 else ""}]'
|
||||||
task.display_name = label or task.name
|
task.display_name = label or task.name
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -111,13 +114,45 @@ class FusionTechnicianTask(models.Model):
|
|||||||
required=True,
|
required=True,
|
||||||
tracking=True,
|
tracking=True,
|
||||||
domain="[('x_fc_is_field_staff', '=', True)]",
|
domain="[('x_fc_is_field_staff', '=', True)]",
|
||||||
help='Shows: users marked as Field Staff (technicians and sales reps)',
|
help='Lead technician responsible for this task',
|
||||||
)
|
)
|
||||||
technician_name = fields.Char(
|
technician_name = fields.Char(
|
||||||
related='technician_id.name',
|
related='technician_id.name',
|
||||||
string='Technician Name',
|
string='Technician Name',
|
||||||
store=True,
|
store=True,
|
||||||
)
|
)
|
||||||
|
additional_technician_ids = fields.Many2many(
|
||||||
|
'res.users',
|
||||||
|
'technician_task_additional_tech_rel',
|
||||||
|
'task_id',
|
||||||
|
'user_id',
|
||||||
|
string='Additional Technicians',
|
||||||
|
domain="[('x_fc_is_field_staff', '=', True)]",
|
||||||
|
tracking=True,
|
||||||
|
help='Additional technicians assigned to assist on this task',
|
||||||
|
)
|
||||||
|
all_technician_ids = fields.Many2many(
|
||||||
|
'res.users',
|
||||||
|
compute='_compute_all_technician_ids',
|
||||||
|
string='All Technicians',
|
||||||
|
help='Lead + additional technicians combined',
|
||||||
|
)
|
||||||
|
additional_tech_count = fields.Integer(
|
||||||
|
compute='_compute_all_technician_ids',
|
||||||
|
string='Extra Techs',
|
||||||
|
)
|
||||||
|
all_technician_names = fields.Char(
|
||||||
|
compute='_compute_all_technician_ids',
|
||||||
|
string='All Technician Names',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('technician_id', 'additional_technician_ids')
|
||||||
|
def _compute_all_technician_ids(self):
|
||||||
|
for task in self:
|
||||||
|
all_techs = task.technician_id | task.additional_technician_ids
|
||||||
|
task.all_technician_ids = all_techs
|
||||||
|
task.additional_tech_count = len(task.additional_technician_ids)
|
||||||
|
task.all_technician_names = ', '.join(all_techs.mapped('name'))
|
||||||
|
|
||||||
sale_order_id = fields.Many2one(
|
sale_order_id = fields.Many2one(
|
||||||
'sale.order',
|
'sale.order',
|
||||||
@@ -444,7 +479,9 @@ class FusionTechnicianTask(models.Model):
|
|||||||
return (preferred_start, preferred_start + duration)
|
return (preferred_start, preferred_start + duration)
|
||||||
|
|
||||||
domain = [
|
domain = [
|
||||||
|
'|',
|
||||||
('technician_id', '=', tech_id),
|
('technician_id', '=', tech_id),
|
||||||
|
('additional_technician_ids', 'in', [tech_id]),
|
||||||
('scheduled_date', '=', date),
|
('scheduled_date', '=', date),
|
||||||
('status', 'not in', ['cancelled']),
|
('status', 'not in', ['cancelled']),
|
||||||
]
|
]
|
||||||
@@ -535,6 +572,7 @@ class FusionTechnicianTask(models.Model):
|
|||||||
"""Return a list of available (start, end) gaps for a technician on a date.
|
"""Return a list of available (start, end) gaps for a technician on a date.
|
||||||
|
|
||||||
Used by schedule_info_html to show green "available" badges.
|
Used by schedule_info_html to show green "available" badges.
|
||||||
|
Considers tasks where the tech is either lead or additional.
|
||||||
"""
|
"""
|
||||||
STORE_OPEN, STORE_CLOSE = self._get_store_hours()
|
STORE_OPEN, STORE_CLOSE = self._get_store_hours()
|
||||||
|
|
||||||
@@ -542,7 +580,9 @@ class FusionTechnicianTask(models.Model):
|
|||||||
return [(STORE_OPEN, STORE_CLOSE)]
|
return [(STORE_OPEN, STORE_CLOSE)]
|
||||||
|
|
||||||
domain = [
|
domain = [
|
||||||
|
'|',
|
||||||
('technician_id', '=', tech_id),
|
('technician_id', '=', tech_id),
|
||||||
|
('additional_technician_ids', 'in', [tech_id]),
|
||||||
('scheduled_date', '=', date),
|
('scheduled_date', '=', date),
|
||||||
('status', 'not in', ['cancelled']),
|
('status', 'not in', ['cancelled']),
|
||||||
]
|
]
|
||||||
@@ -673,9 +713,11 @@ class FusionTechnicianTask(models.Model):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
exclude_id = task.id if task.id else 0
|
exclude_id = task.id if task.id else 0
|
||||||
# Find other tasks for the same technician+date
|
# Find other tasks for the same technician+date (lead or additional)
|
||||||
others = self.sudo().search([
|
others = self.sudo().search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', task.technician_id.id),
|
('technician_id', '=', task.technician_id.id),
|
||||||
|
('additional_technician_ids', 'in', [task.technician_id.id]),
|
||||||
('scheduled_date', '=', task.scheduled_date),
|
('scheduled_date', '=', task.scheduled_date),
|
||||||
('status', 'not in', ['cancelled']),
|
('status', 'not in', ['cancelled']),
|
||||||
('id', '!=', exclude_id),
|
('id', '!=', exclude_id),
|
||||||
@@ -749,9 +791,11 @@ class FusionTechnicianTask(models.Model):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
exclude_id = task.id if task.id else 0
|
exclude_id = task.id if task.id else 0
|
||||||
# Find the task that ends just before this one starts
|
# Find the task that ends just before this one starts (lead or additional)
|
||||||
prev_tasks = self.sudo().search([
|
prev_tasks = self.sudo().search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', task.technician_id.id),
|
('technician_id', '=', task.technician_id.id),
|
||||||
|
('additional_technician_ids', 'in', [task.technician_id.id]),
|
||||||
('scheduled_date', '=', task.scheduled_date),
|
('scheduled_date', '=', task.scheduled_date),
|
||||||
('status', 'not in', ['cancelled']),
|
('status', 'not in', ['cancelled']),
|
||||||
('id', '!=', exclude_id),
|
('id', '!=', exclude_id),
|
||||||
@@ -1003,9 +1047,13 @@ class FusionTechnicianTask(models.Model):
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
@api.constrains('technician_id', 'scheduled_date', 'time_start', 'time_end')
|
@api.constrains('technician_id', 'additional_technician_ids',
|
||||||
|
'scheduled_date', 'time_start', 'time_end')
|
||||||
def _check_no_overlap(self):
|
def _check_no_overlap(self):
|
||||||
"""Prevent overlapping bookings for the same technician on the same date."""
|
"""Prevent overlapping bookings for the same technician on the same date.
|
||||||
|
|
||||||
|
Checks both the lead technician and all additional technicians.
|
||||||
|
"""
|
||||||
for task in self:
|
for task in self:
|
||||||
if task.status == 'cancelled':
|
if task.status == 'cancelled':
|
||||||
continue
|
continue
|
||||||
@@ -1032,29 +1080,38 @@ class FusionTechnicianTask(models.Model):
|
|||||||
current_hour = now.hour + now.minute / 60.0
|
current_hour = now.hour + now.minute / 60.0
|
||||||
if task.time_start < current_hour:
|
if task.time_start < current_hour:
|
||||||
pass # Allow editing existing tasks that started earlier today
|
pass # Allow editing existing tasks that started earlier today
|
||||||
# Check overlap with other tasks
|
# Check overlap for lead + additional technicians
|
||||||
overlapping = self.sudo().search([
|
all_tech_ids = (task.technician_id | task.additional_technician_ids).ids
|
||||||
('technician_id', '=', task.technician_id.id),
|
for tech_id in all_tech_ids:
|
||||||
('scheduled_date', '=', task.scheduled_date),
|
tech_name = self.env['res.users'].browse(tech_id).name
|
||||||
('status', 'not in', ['cancelled']),
|
overlapping = self.sudo().search([
|
||||||
('id', '!=', task.id),
|
'|',
|
||||||
('time_start', '<', task.time_end),
|
('technician_id', '=', tech_id),
|
||||||
('time_end', '>', task.time_start),
|
('additional_technician_ids', 'in', [tech_id]),
|
||||||
], limit=1)
|
('scheduled_date', '=', task.scheduled_date),
|
||||||
if overlapping:
|
('status', 'not in', ['cancelled']),
|
||||||
start_str = self._float_to_time_str(overlapping.time_start)
|
('id', '!=', task.id),
|
||||||
end_str = self._float_to_time_str(overlapping.time_end)
|
('time_start', '<', task.time_end),
|
||||||
raise ValidationError(_(
|
('time_end', '>', task.time_start),
|
||||||
"Time slot overlaps with %(task)s (%(start)s - %(end)s). "
|
], limit=1)
|
||||||
"Please choose a different time.",
|
if overlapping:
|
||||||
task=overlapping.name,
|
start_str = self._float_to_time_str(overlapping.time_start)
|
||||||
start=start_str,
|
end_str = self._float_to_time_str(overlapping.time_end)
|
||||||
end=end_str,
|
raise ValidationError(_(
|
||||||
))
|
"%(tech)s has a time conflict with %(task)s "
|
||||||
|
"(%(start)s - %(end)s). Please choose a different time.",
|
||||||
|
tech=tech_name,
|
||||||
|
task=overlapping.name,
|
||||||
|
start=start_str,
|
||||||
|
end=end_str,
|
||||||
|
))
|
||||||
|
|
||||||
# Check travel time gap to the NEXT task on the same day
|
# Check travel time gaps for lead technician only
|
||||||
|
# (additional techs travel with the lead, same destination)
|
||||||
next_task = self.sudo().search([
|
next_task = self.sudo().search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', task.technician_id.id),
|
('technician_id', '=', task.technician_id.id),
|
||||||
|
('additional_technician_ids', 'in', [task.technician_id.id]),
|
||||||
('scheduled_date', '=', task.scheduled_date),
|
('scheduled_date', '=', task.scheduled_date),
|
||||||
('status', 'not in', ['cancelled']),
|
('status', 'not in', ['cancelled']),
|
||||||
('id', '!=', task.id),
|
('id', '!=', task.id),
|
||||||
@@ -1082,9 +1139,10 @@ class FusionTechnicianTask(models.Model):
|
|||||||
travel=travel_min,
|
travel=travel_min,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Check travel time gap FROM the PREVIOUS task on the same day
|
|
||||||
prev_task = self.sudo().search([
|
prev_task = self.sudo().search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', task.technician_id.id),
|
('technician_id', '=', task.technician_id.id),
|
||||||
|
('additional_technician_ids', 'in', [task.technician_id.id]),
|
||||||
('scheduled_date', '=', task.scheduled_date),
|
('scheduled_date', '=', task.scheduled_date),
|
||||||
('status', 'not in', ['cancelled']),
|
('status', 'not in', ['cancelled']),
|
||||||
('id', '!=', task.id),
|
('id', '!=', task.id),
|
||||||
@@ -1151,8 +1209,11 @@ class FusionTechnicianTask(models.Model):
|
|||||||
exclude_id = self._origin.id if self._origin else 0
|
exclude_id = self._origin.id if self._origin else 0
|
||||||
duration = max(self.duration_hours or 1.0, 0.25)
|
duration = max(self.duration_hours or 1.0, 0.25)
|
||||||
|
|
||||||
|
all_tech_ids = (self.technician_id | self.additional_technician_ids).ids
|
||||||
overlapping = self.sudo().search([
|
overlapping = self.sudo().search([
|
||||||
('technician_id', '=', self.technician_id.id),
|
'|',
|
||||||
|
('technician_id', 'in', all_tech_ids),
|
||||||
|
('additional_technician_ids', 'in', all_tech_ids),
|
||||||
('scheduled_date', '=', self.scheduled_date),
|
('scheduled_date', '=', self.scheduled_date),
|
||||||
('status', 'not in', ['cancelled']),
|
('status', 'not in', ['cancelled']),
|
||||||
('id', '!=', exclude_id),
|
('id', '!=', exclude_id),
|
||||||
@@ -1347,14 +1408,22 @@ class FusionTechnicianTask(models.Model):
|
|||||||
|
|
||||||
# Capture old tech+date combos BEFORE write for travel recalc
|
# Capture old tech+date combos BEFORE write for travel recalc
|
||||||
travel_fields = {'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng',
|
travel_fields = {'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng',
|
||||||
'scheduled_date', 'sequence', 'time_start', 'technician_id'}
|
'scheduled_date', 'sequence', 'time_start', 'technician_id',
|
||||||
|
'additional_technician_ids'}
|
||||||
needs_travel_recalc = travel_fields & set(vals.keys())
|
needs_travel_recalc = travel_fields & set(vals.keys())
|
||||||
old_combos = set()
|
old_combos = set()
|
||||||
if needs_travel_recalc:
|
if needs_travel_recalc:
|
||||||
old_combos = {(t.technician_id.id, t.scheduled_date) for t in self}
|
for t in self:
|
||||||
|
old_combos.add((t.technician_id.id, t.scheduled_date))
|
||||||
|
for tech in t.additional_technician_ids:
|
||||||
|
old_combos.add((tech.id, t.scheduled_date))
|
||||||
res = super().write(vals)
|
res = super().write(vals)
|
||||||
if needs_travel_recalc:
|
if needs_travel_recalc:
|
||||||
new_combos = {(t.technician_id.id, t.scheduled_date) for t in self}
|
new_combos = set()
|
||||||
|
for t in self:
|
||||||
|
new_combos.add((t.technician_id.id, t.scheduled_date))
|
||||||
|
for tech in t.additional_technician_ids:
|
||||||
|
new_combos.add((tech.id, t.scheduled_date))
|
||||||
all_combos = old_combos | new_combos
|
all_combos = old_combos | new_combos
|
||||||
self._recalculate_combos_travel(all_combos)
|
self._recalculate_combos_travel(all_combos)
|
||||||
|
|
||||||
@@ -1374,7 +1443,8 @@ class FusionTechnicianTask(models.Model):
|
|||||||
old_end=old['time_end'],
|
old_end=old['time_end'],
|
||||||
)
|
)
|
||||||
# Push updates to remote instances for local tasks
|
# Push updates to remote instances for local tasks
|
||||||
sync_fields = {'technician_id', 'scheduled_date', 'time_start', 'time_end',
|
sync_fields = {'technician_id', 'additional_technician_ids',
|
||||||
|
'scheduled_date', 'time_start', 'time_end',
|
||||||
'duration_hours', 'status', 'task_type', 'address_street',
|
'duration_hours', 'status', 'task_type', 'address_street',
|
||||||
'address_city', 'address_zip', 'address_lat', 'address_lng',
|
'address_city', 'address_zip', 'address_lat', 'address_lng',
|
||||||
'partner_id'}
|
'partner_id'}
|
||||||
@@ -1412,7 +1482,7 @@ class FusionTechnicianTask(models.Model):
|
|||||||
f'<div style="background:#e8f4fd;border-left:4px solid #17a2b8;padding:10px;border-radius:4px;">'
|
f'<div style="background:#e8f4fd;border-left:4px solid #17a2b8;padding:10px;border-radius:4px;">'
|
||||||
f'<strong><i class="fa fa-wrench"></i> Technician Task Scheduled</strong><br/>'
|
f'<strong><i class="fa fa-wrench"></i> Technician Task Scheduled</strong><br/>'
|
||||||
f'<strong>{self.name}</strong> ({task_type_label}) - {date_str} at {time_str}<br/>'
|
f'<strong>{self.name}</strong> ({task_type_label}) - {date_str} at {time_str}<br/>'
|
||||||
f'Technician: {self.technician_id.name}<br/>'
|
f'Technician(s): {self.all_technician_names or self.technician_id.name}<br/>'
|
||||||
f'<a href="{task_url}">View Task</a>'
|
f'<a href="{task_url}">View Task</a>'
|
||||||
f'</div>'
|
f'</div>'
|
||||||
)
|
)
|
||||||
@@ -1441,10 +1511,11 @@ class FusionTechnicianTask(models.Model):
|
|||||||
previous_status = order.x_fc_adp_application_status
|
previous_status = order.x_fc_adp_application_status
|
||||||
|
|
||||||
# Update the sale order status and delivery fields
|
# Update the sale order status and delivery fields
|
||||||
|
all_tech_ids = (task.technician_id | task.additional_technician_ids).ids
|
||||||
order.with_context(skip_status_validation=True).write({
|
order.with_context(skip_status_validation=True).write({
|
||||||
'x_fc_adp_application_status': 'ready_delivery',
|
'x_fc_adp_application_status': 'ready_delivery',
|
||||||
'x_fc_status_before_delivery': previous_status,
|
'x_fc_status_before_delivery': previous_status,
|
||||||
'x_fc_delivery_technician_ids': [(4, task.technician_id.id)],
|
'x_fc_delivery_technician_ids': [(4, tid) for tid in all_tech_ids],
|
||||||
'x_fc_ready_for_delivery_date': fields.Datetime.now(),
|
'x_fc_ready_for_delivery_date': fields.Datetime.now(),
|
||||||
'x_fc_scheduled_delivery_datetime': task.datetime_start,
|
'x_fc_scheduled_delivery_datetime': task.datetime_start,
|
||||||
})
|
})
|
||||||
@@ -1469,7 +1540,7 @@ class FusionTechnicianTask(models.Model):
|
|||||||
f'<h5 class="alert-heading"><i class="fa fa-truck"></i> Ready for Delivery{early_badge}</h5>'
|
f'<h5 class="alert-heading"><i class="fa fa-truck"></i> Ready for Delivery{early_badge}</h5>'
|
||||||
f'<ul>'
|
f'<ul>'
|
||||||
f'<li><strong>Marked By:</strong> {user_name}</li>'
|
f'<li><strong>Marked By:</strong> {user_name}</li>'
|
||||||
f'<li><strong>Technician:</strong> {tech_name}</li>'
|
f'<li><strong>Technician(s):</strong> {task.all_technician_names or tech_name}</li>'
|
||||||
f'{scheduled_str}'
|
f'{scheduled_str}'
|
||||||
f'<li><strong>Delivery Address:</strong> {task.address_display or "N/A"}</li>'
|
f'<li><strong>Delivery Address:</strong> {task.address_display or "N/A"}</li>'
|
||||||
f'</ul>'
|
f'</ul>'
|
||||||
@@ -1485,7 +1556,7 @@ class FusionTechnicianTask(models.Model):
|
|||||||
# Send email notifications
|
# Send email notifications
|
||||||
try:
|
try:
|
||||||
order._send_ready_for_delivery_email(
|
order._send_ready_for_delivery_email(
|
||||||
technicians=task.technician_id,
|
technicians=task.technician_id | task.additional_technician_ids,
|
||||||
scheduled_datetime=task.datetime_start,
|
scheduled_datetime=task.datetime_start,
|
||||||
notes=task.description,
|
notes=task.description,
|
||||||
)
|
)
|
||||||
@@ -1493,8 +1564,18 @@ class FusionTechnicianTask(models.Model):
|
|||||||
_logger.warning("Ready for delivery email failed for %s: %s", order.name, e)
|
_logger.warning("Ready for delivery email failed for %s: %s", order.name, e)
|
||||||
|
|
||||||
def _recalculate_day_travel_chains(self):
|
def _recalculate_day_travel_chains(self):
|
||||||
"""Recalculate travel for all tech+date combos affected by these tasks."""
|
"""Recalculate travel for all tech+date combos affected by these tasks.
|
||||||
combos = {(t.technician_id.id, t.scheduled_date) for t in self if t.technician_id and t.scheduled_date}
|
|
||||||
|
Includes combos for additional technicians so their schedules update too.
|
||||||
|
"""
|
||||||
|
combos = set()
|
||||||
|
for t in self:
|
||||||
|
if not t.scheduled_date:
|
||||||
|
continue
|
||||||
|
if t.technician_id:
|
||||||
|
combos.add((t.technician_id.id, t.scheduled_date))
|
||||||
|
for tech in t.additional_technician_ids:
|
||||||
|
combos.add((tech.id, t.scheduled_date))
|
||||||
self._recalculate_combos_travel(combos)
|
self._recalculate_combos_travel(combos)
|
||||||
|
|
||||||
def _get_technician_start_address(self, tech_id):
|
def _get_technician_start_address(self, tech_id):
|
||||||
@@ -1543,7 +1624,9 @@ class FusionTechnicianTask(models.Model):
|
|||||||
if not tech_id or not date:
|
if not tech_id or not date:
|
||||||
continue
|
continue
|
||||||
all_day_tasks = self.sudo().search([
|
all_day_tasks = self.sudo().search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', tech_id),
|
('technician_id', '=', tech_id),
|
||||||
|
('additional_technician_ids', 'in', [tech_id]),
|
||||||
('scheduled_date', '=', date),
|
('scheduled_date', '=', date),
|
||||||
('status', 'not in', ['cancelled']),
|
('status', 'not in', ['cancelled']),
|
||||||
], order='time_start, sequence, id')
|
], order='time_start, sequence, id')
|
||||||
@@ -1574,10 +1657,15 @@ class FusionTechnicianTask(models.Model):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _check_previous_tasks_completed(self):
|
def _check_previous_tasks_completed(self):
|
||||||
"""Check that all earlier tasks for the same technician+date are completed."""
|
"""Check that all earlier tasks for the same technician+date are completed.
|
||||||
|
|
||||||
|
Considers tasks where the technician is either lead or additional.
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
earlier_incomplete = self.sudo().search([
|
earlier_incomplete = self.sudo().search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', self.technician_id.id),
|
('technician_id', '=', self.technician_id.id),
|
||||||
|
('additional_technician_ids', 'in', [self.technician_id.id]),
|
||||||
('scheduled_date', '=', self.scheduled_date),
|
('scheduled_date', '=', self.scheduled_date),
|
||||||
('time_start', '<', self.time_start),
|
('time_start', '<', self.time_start),
|
||||||
('status', 'not in', ['completed', 'cancelled']),
|
('status', 'not in', ['completed', 'cancelled']),
|
||||||
@@ -1709,6 +1797,13 @@ class FusionTechnicianTask(models.Model):
|
|||||||
),
|
),
|
||||||
user_id=order.user_id.id or self.env.uid,
|
user_id=order.user_id.id or self.env.uid,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
order._send_damage_notification_email()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
"Failed to send damage notification for %s: %s",
|
||||||
|
order.name, e,
|
||||||
|
)
|
||||||
|
|
||||||
def action_cancel_task(self):
|
def action_cancel_task(self):
|
||||||
"""Cancel the task. Sends cancellation email and reverts sale order if delivery."""
|
"""Cancel the task. Sends cancellation email and reverts sale order if delivery."""
|
||||||
@@ -1842,7 +1937,7 @@ class FusionTechnicianTask(models.Model):
|
|||||||
f'<h5><i class="fa fa-wrench"></i> Technician Task Completed</h5>'
|
f'<h5><i class="fa fa-wrench"></i> Technician Task Completed</h5>'
|
||||||
f'<ul>'
|
f'<ul>'
|
||||||
f'<li><strong>Task:</strong> {self.name} ({task_type_label})</li>'
|
f'<li><strong>Task:</strong> {self.name} ({task_type_label})</li>'
|
||||||
f'<li><strong>Technician:</strong> {self.technician_id.name}</li>'
|
f'<li><strong>Technician(s):</strong> {self.all_technician_names or self.technician_id.name}</li>'
|
||||||
f'<li><strong>Completed:</strong> {self.completion_datetime.strftime("%B %d, %Y at %I:%M %p") if self.completion_datetime else "N/A"}</li>'
|
f'<li><strong>Completed:</strong> {self.completion_datetime.strftime("%B %d, %Y at %I:%M %p") if self.completion_datetime else "N/A"}</li>'
|
||||||
f'</ul>'
|
f'</ul>'
|
||||||
f'<hr/>'
|
f'<hr/>'
|
||||||
@@ -1859,7 +1954,7 @@ class FusionTechnicianTask(models.Model):
|
|||||||
"""Send an Odoo notification to whoever created/scheduled the task."""
|
"""Send an Odoo notification to whoever created/scheduled the task."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
# Notify the task creator (scheduler) if they're not the technician
|
# Notify the task creator (scheduler) if they're not the technician
|
||||||
if self.create_uid and self.create_uid != self.technician_id:
|
if self.create_uid and self.create_uid not in self.all_technician_ids:
|
||||||
task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
|
task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
|
||||||
task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
|
task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
|
||||||
client_name = self.partner_id.name or 'N/A'
|
client_name = self.partner_id.name or 'N/A'
|
||||||
@@ -1889,8 +1984,8 @@ class FusionTechnicianTask(models.Model):
|
|||||||
f'<td style="padding:3px 0;">{case_ref or "N/A"}</td></tr>'
|
f'<td style="padding:3px 0;">{case_ref or "N/A"}</td></tr>'
|
||||||
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Task:</td>'
|
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Task:</td>'
|
||||||
f'<td style="padding:3px 0;">{self.name}</td></tr>'
|
f'<td style="padding:3px 0;">{self.name}</td></tr>'
|
||||||
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Technician:</td>'
|
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Technician(s):</td>'
|
||||||
f'<td style="padding:3px 0;">{self.technician_id.name}</td></tr>'
|
f'<td style="padding:3px 0;">{self.all_technician_names or self.technician_id.name}</td></tr>'
|
||||||
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Location:</td>'
|
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Location:</td>'
|
||||||
f'<td style="padding:3px 0;">{address_str}</td></tr>'
|
f'<td style="padding:3px 0;">{address_str}</td></tr>'
|
||||||
f'</table>'
|
f'</table>'
|
||||||
@@ -1927,7 +2022,7 @@ class FusionTechnicianTask(models.Model):
|
|||||||
end_str = self._float_to_time_str(self.time_end)
|
end_str = self._float_to_time_str(self.time_end)
|
||||||
rows.append(('Scheduled', f'{date_str}, {start_str} - {end_str}'))
|
rows.append(('Scheduled', f'{date_str}, {start_str} - {end_str}'))
|
||||||
if self.technician_id:
|
if self.technician_id:
|
||||||
rows.append(('Technician', self.technician_id.name))
|
rows.append(('Technician', self.all_technician_names or self.technician_id.name))
|
||||||
if self.address_display:
|
if self.address_display:
|
||||||
rows.append(('Address', self.address_display))
|
rows.append(('Address', self.address_display))
|
||||||
return rows
|
return rows
|
||||||
@@ -1943,9 +2038,10 @@ class FusionTechnicianTask(models.Model):
|
|||||||
if self.partner_id and self.partner_id.email:
|
if self.partner_id and self.partner_id.email:
|
||||||
to_emails.append(self.partner_id.email)
|
to_emails.append(self.partner_id.email)
|
||||||
|
|
||||||
# Technician email
|
# Technician emails (lead + additional)
|
||||||
if self.technician_id and self.technician_id.email:
|
for tech in (self.technician_id | self.additional_technician_ids):
|
||||||
cc_emails.append(self.technician_id.email)
|
if tech.email:
|
||||||
|
cc_emails.append(tech.email)
|
||||||
|
|
||||||
# Sales rep from the sale order
|
# Sales rep from the sale order
|
||||||
if self.sale_order_id and self.sale_order_id.user_id and \
|
if self.sale_order_id and self.sale_order_id.user_id and \
|
||||||
@@ -2161,10 +2257,15 @@ class FusionTechnicianTask(models.Model):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def get_next_task_for_technician(self):
|
def get_next_task_for_technician(self):
|
||||||
"""Get the next task in sequence for the same technician+date after this one."""
|
"""Get the next task in sequence for the same technician+date after this one.
|
||||||
|
|
||||||
|
Considers tasks where the technician is either lead or additional.
|
||||||
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
return self.sudo().search([
|
return self.sudo().search([
|
||||||
|
'|',
|
||||||
('technician_id', '=', self.technician_id.id),
|
('technician_id', '=', self.technician_id.id),
|
||||||
|
('additional_technician_ids', 'in', [self.technician_id.id]),
|
||||||
('scheduled_date', '=', self.scheduled_date),
|
('scheduled_date', '=', self.scheduled_date),
|
||||||
('time_start', '>=', self.time_start),
|
('time_start', '>=', self.time_start),
|
||||||
('status', 'in', ['scheduled', 'en_route']),
|
('status', 'in', ['scheduled', 'en_route']),
|
||||||
|
|||||||
@@ -49,9 +49,9 @@
|
|||||||
<!-- "Allow Document Lock Override" setting is enabled. -->
|
<!-- "Allow Document Lock Override" setting is enabled. -->
|
||||||
<!-- Not implied by Manager. Must be explicitly assigned. -->
|
<!-- Not implied by Manager. Must be explicitly assigned. -->
|
||||||
<record id="group_document_lock_override" model="res.groups">
|
<record id="group_document_lock_override" model="res.groups">
|
||||||
<field name="name">Document Lock Override</field>
|
<field name="name">Fusion: Document Lock Override</field>
|
||||||
<field name="privilege_id" ref="res_groups_privilege_fusion_claims"/>
|
<field name="privilege_id" eval="False"/>
|
||||||
<field name="comment">Can edit locked documents on old/legacy cases when the override setting is enabled. Assign only to specific trusted users.</field>
|
<field name="comment">Temporary permission for editing locked documents on old/legacy cases. Requires the "Allow Document Lock Override" setting to be enabled in Fusion Claims Settings. Once all legacy cases are handled, disable the setting and remove this permission from users.</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//form" position="inside">
|
<xpath expr="//form" position="inside">
|
||||||
<app data-string="Fusion Claims" string="Fusion Claims" name="fusion_claims">
|
<app data-string="Fusion Claims" string="Fusion Claims" name="fusion_claims"
|
||||||
|
groups="fusion_claims.group_fusion_claims_manager">
|
||||||
<h2>ADP Billing</h2>
|
<h2>ADP Billing</h2>
|
||||||
|
|
||||||
<div class="row mt-4 o_settings_container">
|
<div class="row mt-4 o_settings_container">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<!-- Sale Type -->
|
<!-- Sale Type -->
|
||||||
<field name="x_fc_sale_type" string="Sale Type"/>
|
<field name="x_fc_sale_type" string="Sale Type"/>
|
||||||
|
|
||||||
<!-- Authorizer Required? - only for odsp, direct_private, insurance, other, rental -->
|
<!-- Authorizer Required? - only for odsp, direct_private, insurance, other (not rental) -->
|
||||||
<field name="x_fc_authorizer_required" string="Authorizer Required?"
|
<field name="x_fc_authorizer_required" string="Authorizer Required?"
|
||||||
invisible="not x_fc_show_authorizer_question"/>
|
invisible="not x_fc_show_authorizer_question"/>
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
<filter string="Completed" name="filter_completed" domain="[('status', '=', 'completed')]"/>
|
<filter string="Completed" name="filter_completed" domain="[('status', '=', 'completed')]"/>
|
||||||
<filter string="Active" name="filter_active" domain="[('status', 'not in', ['cancelled', 'completed'])]"/>
|
<filter string="Active" name="filter_active" domain="[('status', 'not in', ['cancelled', 'completed'])]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter string="My Tasks" name="filter_my_tasks" domain="[('technician_id', '=', uid)]"/>
|
<filter string="My Tasks" name="filter_my_tasks"
|
||||||
|
domain="['|', ('technician_id', '=', uid), ('additional_technician_ids', 'in', [uid])]"/>
|
||||||
<filter string="Deliveries" name="filter_deliveries" domain="[('task_type', '=', 'delivery')]"/>
|
<filter string="Deliveries" name="filter_deliveries" domain="[('task_type', '=', 'delivery')]"/>
|
||||||
<filter string="Repairs" name="filter_repairs" domain="[('task_type', '=', 'repair')]"/>
|
<filter string="Repairs" name="filter_repairs" domain="[('task_type', '=', 'repair')]"/>
|
||||||
<filter string="POD Required" name="filter_pod" domain="[('pod_required', '=', True)]"/>
|
<filter string="POD Required" name="filter_pod" domain="[('pod_required', '=', True)]"/>
|
||||||
@@ -162,6 +163,10 @@
|
|||||||
<group string="Assignment">
|
<group string="Assignment">
|
||||||
<field name="technician_id"
|
<field name="technician_id"
|
||||||
domain="[('x_fc_is_field_staff', '=', True)]"/>
|
domain="[('x_fc_is_field_staff', '=', True)]"/>
|
||||||
|
<field name="additional_technician_ids"
|
||||||
|
widget="many2many_tags_avatar"
|
||||||
|
domain="[('x_fc_is_field_staff', '=', True), ('id', '!=', technician_id)]"
|
||||||
|
options="{'color_field': 'color'}"/>
|
||||||
<field name="task_type"/>
|
<field name="task_type"/>
|
||||||
<field name="priority" widget="priority"/>
|
<field name="priority" widget="priority"/>
|
||||||
<field name="facility_id"
|
<field name="facility_id"
|
||||||
@@ -270,6 +275,8 @@
|
|||||||
default_order="scheduled_date, sequence, time_start">
|
default_order="scheduled_date, sequence, time_start">
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="technician_id" widget="many2one_avatar_user"/>
|
<field name="technician_id" widget="many2one_avatar_user"/>
|
||||||
|
<field name="additional_technician_ids" widget="many2many_tags_avatar"
|
||||||
|
optional="show" string="+ Techs"/>
|
||||||
<field name="task_type" decoration-bf="1"/>
|
<field name="task_type" decoration-bf="1"/>
|
||||||
<field name="scheduled_date"/>
|
<field name="scheduled_date"/>
|
||||||
<field name="time_start_display" string="Start"/>
|
<field name="time_start_display" string="Start"/>
|
||||||
@@ -305,6 +312,8 @@
|
|||||||
<field name="color"/>
|
<field name="color"/>
|
||||||
<field name="priority"/>
|
<field name="priority"/>
|
||||||
<field name="technician_id"/>
|
<field name="technician_id"/>
|
||||||
|
<field name="additional_technician_ids"/>
|
||||||
|
<field name="additional_tech_count"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="task_type"/>
|
<field name="task_type"/>
|
||||||
<field name="scheduled_date"/>
|
<field name="scheduled_date"/>
|
||||||
@@ -345,6 +354,10 @@
|
|||||||
<span class="ms-2"><i class="fa fa-car me-1"/><field name="travel_time_minutes"/> min</span>
|
<span class="ms-2"><i class="fa fa-car me-1"/><field name="travel_time_minutes"/> min</span>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
<div t-if="record.additional_tech_count.raw_value > 0" class="text-muted small mb-1">
|
||||||
|
<i class="fa fa-users me-1"/>
|
||||||
|
<span>+<field name="additional_tech_count"/> technician(s)</span>
|
||||||
|
</div>
|
||||||
<div class="o_kanban_record_bottom mt-2">
|
<div class="o_kanban_record_bottom mt-2">
|
||||||
<div class="oe_kanban_bottom_left">
|
<div class="oe_kanban_bottom_left">
|
||||||
<field name="activity_ids" widget="kanban_activity"/>
|
<field name="activity_ids" widget="kanban_activity"/>
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ class ReadyForDeliveryWizard(models.TransientModel):
|
|||||||
return {'type': 'ir.actions.act_window_close'}
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
def _create_technician_tasks(self, order):
|
def _create_technician_tasks(self, order):
|
||||||
"""Create a technician task for each assigned technician.
|
"""Create a single delivery task with lead + additional technicians.
|
||||||
|
|
||||||
|
The first selected technician becomes the lead. Any remaining
|
||||||
|
technicians are assigned as additional technicians on the same task.
|
||||||
|
|
||||||
The task model's create() method auto-populates address fields
|
The task model's create() method auto-populates address fields
|
||||||
from the linked sale order's shipping address when address_street
|
from the linked sale order's shipping address when address_street
|
||||||
@@ -164,7 +167,7 @@ class ReadyForDeliveryWizard(models.TransientModel):
|
|||||||
"""
|
"""
|
||||||
Task = self.env['fusion.technician.task']
|
Task = self.env['fusion.technician.task']
|
||||||
scheduled_date = False
|
scheduled_date = False
|
||||||
time_start = 9.0 # default 9:00 AM
|
time_start = 9.0
|
||||||
|
|
||||||
if self.scheduled_datetime:
|
if self.scheduled_datetime:
|
||||||
scheduled_date = self.scheduled_datetime.date()
|
scheduled_date = self.scheduled_datetime.date()
|
||||||
@@ -172,43 +175,45 @@ class ReadyForDeliveryWizard(models.TransientModel):
|
|||||||
else:
|
else:
|
||||||
scheduled_date = fields.Date.context_today(self)
|
scheduled_date = fields.Date.context_today(self)
|
||||||
|
|
||||||
created_tasks = self.env['fusion.technician.task']
|
techs = self.technician_ids
|
||||||
for tech in self.technician_ids:
|
lead_tech = techs[0]
|
||||||
vals = {
|
additional_techs = techs[1:] if len(techs) > 1 else self.env['res.users']
|
||||||
'technician_id': tech.id,
|
|
||||||
'sale_order_id': order.id,
|
|
||||||
'task_type': 'delivery',
|
|
||||||
'scheduled_date': scheduled_date,
|
|
||||||
'time_start': time_start,
|
|
||||||
'time_end': time_start + 1.0, # 1 hour default
|
|
||||||
'partner_id': order.partner_id.id,
|
|
||||||
'description': self.notes or '',
|
|
||||||
'pod_required': True,
|
|
||||||
}
|
|
||||||
task = Task.create(vals)
|
|
||||||
created_tasks |= task
|
|
||||||
_logger.info("Created delivery task %s for %s on order %s", task.name, tech.name, order.name)
|
|
||||||
|
|
||||||
# Post a summary of created tasks back to the sale order chatter
|
vals = {
|
||||||
if created_tasks:
|
'technician_id': lead_tech.id,
|
||||||
task_lines = ''
|
'additional_technician_ids': [(6, 0, additional_techs.ids)] if additional_techs else False,
|
||||||
for t in created_tasks:
|
'sale_order_id': order.id,
|
||||||
task_url = f'/web#id={t.id}&model=fusion.technician.task&view_type=form'
|
'task_type': 'delivery',
|
||||||
time_str = t.time_start_12h or ''
|
'scheduled_date': scheduled_date,
|
||||||
task_lines += (
|
'time_start': time_start,
|
||||||
f'<li><a href="{task_url}">{t.name}</a> - '
|
'time_end': time_start + 1.0,
|
||||||
f'{t.technician_id.name} '
|
'partner_id': order.partner_id.id,
|
||||||
f'({t.scheduled_date.strftime("%b %d, %Y") if t.scheduled_date else "TBD"}'
|
'description': self.notes or '',
|
||||||
f'{" at " + time_str if time_str else ""})</li>'
|
'pod_required': True,
|
||||||
)
|
}
|
||||||
summary = Markup(
|
task = Task.create(vals)
|
||||||
'<div class="alert alert-info" style="margin:0;">'
|
_logger.info(
|
||||||
'<strong><i class="fa fa-wrench"></i> Delivery Tasks Created</strong>'
|
"Created delivery task %s for %s (+%d additional) on order %s",
|
||||||
'<ul style="margin-bottom:0;">%s</ul>'
|
task.name, lead_tech.name, len(additional_techs), order.name,
|
||||||
'</div>'
|
)
|
||||||
) % Markup(task_lines)
|
|
||||||
order.message_post(
|
task_url = f'/web#id={task.id}&model=fusion.technician.task&view_type=form'
|
||||||
body=summary,
|
time_str = task.time_start_12h or ''
|
||||||
message_type='notification',
|
all_names = ', '.join(techs.mapped('name'))
|
||||||
subtype_xmlid='mail.mt_note',
|
task_line = (
|
||||||
)
|
f'<li><a href="{task_url}">{task.name}</a> - '
|
||||||
|
f'{all_names} '
|
||||||
|
f'({task.scheduled_date.strftime("%b %d, %Y") if task.scheduled_date else "TBD"}'
|
||||||
|
f'{" at " + time_str if time_str else ""})</li>'
|
||||||
|
)
|
||||||
|
summary = Markup(
|
||||||
|
'<div class="alert alert-info" style="margin:0;">'
|
||||||
|
'<strong><i class="fa fa-wrench"></i> Delivery Task Created</strong>'
|
||||||
|
'<ul style="margin-bottom:0;">%s</ul>'
|
||||||
|
'</div>'
|
||||||
|
) % Markup(task_line)
|
||||||
|
order.message_post(
|
||||||
|
body=summary,
|
||||||
|
message_type='notification',
|
||||||
|
subtype_xmlid='mail.mt_note',
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
'description': " ",
|
'description': " ",
|
||||||
'depends': ['payment', 'account_payment', 'sale'],
|
'depends': ['payment', 'account_payment', 'sale'],
|
||||||
'data': [
|
'data': [
|
||||||
|
'security/security.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
|
||||||
'report/poynt_receipt_report.xml',
|
'report/poynt_receipt_report.xml',
|
||||||
|
|||||||
@@ -253,6 +253,53 @@ class PaymentProvider(models.Model):
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# === BUSINESS METHODS - TOKENIZE / CHARGE === #
|
||||||
|
|
||||||
|
def _poynt_tokenize_nonce(self, nonce):
|
||||||
|
"""Exchange a Poynt Collect nonce for a long-lived payment token JWT.
|
||||||
|
|
||||||
|
:param str nonce: The one-time nonce from Poynt Collect JS.
|
||||||
|
:return: The tokenize response containing card details, cardId,
|
||||||
|
paymentToken (JWT), and AVS/CVV verification results.
|
||||||
|
:rtype: dict
|
||||||
|
:raises ValidationError: If the tokenize call fails.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
return self._poynt_make_request(
|
||||||
|
'POST',
|
||||||
|
'cards/tokenize',
|
||||||
|
payload={'nonce': nonce},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _poynt_charge_token(self, payment_jwt, amount, currency,
|
||||||
|
action='SALE', reference=''):
|
||||||
|
"""Charge a stored payment token JWT via the tokenize/charge endpoint.
|
||||||
|
|
||||||
|
:param str payment_jwt: The payment token JWT from _poynt_tokenize_nonce.
|
||||||
|
:param float amount: The charge amount in major currency units.
|
||||||
|
:param recordset currency: The currency record.
|
||||||
|
:param str action: SALE or AUTHORIZE (default SALE).
|
||||||
|
:param str reference: Optional reference note for the transaction.
|
||||||
|
:return: The transaction result dict from Poynt.
|
||||||
|
:rtype: dict
|
||||||
|
:raises ValidationError: If the charge fails.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
payload = poynt_utils.build_token_charge_payload(
|
||||||
|
action=action,
|
||||||
|
amount=amount,
|
||||||
|
currency=currency,
|
||||||
|
payment_jwt=payment_jwt,
|
||||||
|
business_id=self.poynt_business_id,
|
||||||
|
store_id=self.poynt_store_id or '',
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
return self._poynt_make_request(
|
||||||
|
'POST',
|
||||||
|
'cards/tokenize/charge',
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
# === BUSINESS METHODS - INLINE FORM === #
|
# === BUSINESS METHODS - INLINE FORM === #
|
||||||
|
|
||||||
def _poynt_get_inline_form_values(self, amount, currency, partner_id, is_validation,
|
def _poynt_get_inline_form_values(self, amount, currency, partner_id, is_validation,
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ class PaymentToken(models.Model):
|
|||||||
help="The unique card identifier stored on the Poynt platform.",
|
help="The unique card identifier stored on the Poynt platform.",
|
||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
poynt_payment_token = fields.Char(
|
||||||
|
string="Poynt Payment Token (JWT)",
|
||||||
|
help="Long-lived JWT issued by Poynt /cards/tokenize, used for "
|
||||||
|
"recurring charges via /cards/tokenize/charge.",
|
||||||
|
readonly=True,
|
||||||
|
groups='base.group_system',
|
||||||
|
)
|
||||||
|
|
||||||
def _poynt_validate_stored_card(self):
|
def _poynt_validate_stored_card(self):
|
||||||
"""Validate that the stored card is still usable on Poynt.
|
"""Validate that the stored card is still usable on Poynt.
|
||||||
@@ -35,7 +42,7 @@ class PaymentToken(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.provider_id._poynt_make_request(
|
result = self.provider_id.sudo()._poynt_make_request(
|
||||||
'GET',
|
'GET',
|
||||||
f'cards/{self.poynt_card_id}',
|
f'cards/{self.poynt_card_id}',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class PaymentTransaction(models.Model):
|
|||||||
copy=False,
|
copy=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_provider_sudo(self):
|
||||||
|
return self.provider_id.sudo()
|
||||||
|
|
||||||
# === BUSINESS METHODS - PAYMENT FLOW === #
|
# === BUSINESS METHODS - PAYMENT FLOW === #
|
||||||
|
|
||||||
def _get_specific_processing_values(self, processing_values):
|
def _get_specific_processing_values(self, processing_values):
|
||||||
@@ -64,7 +67,8 @@ class PaymentTransaction(models.Model):
|
|||||||
|
|
||||||
poynt_data = self._poynt_create_order_and_authorize()
|
poynt_data = self._poynt_create_order_and_authorize()
|
||||||
|
|
||||||
base_url = self.provider_id.get_base_url()
|
provider = self._get_provider_sudo()
|
||||||
|
base_url = provider.get_base_url()
|
||||||
return_url = url_join(
|
return_url = url_join(
|
||||||
base_url,
|
base_url,
|
||||||
f'{PoyntController._return_url}?{url_encode({"reference": self.reference})}',
|
f'{PoyntController._return_url}?{url_encode({"reference": self.reference})}',
|
||||||
@@ -74,8 +78,8 @@ class PaymentTransaction(models.Model):
|
|||||||
'poynt_order_id': poynt_data.get('order_id', ''),
|
'poynt_order_id': poynt_data.get('order_id', ''),
|
||||||
'poynt_transaction_id': poynt_data.get('transaction_id', ''),
|
'poynt_transaction_id': poynt_data.get('transaction_id', ''),
|
||||||
'return_url': return_url,
|
'return_url': return_url,
|
||||||
'business_id': self.provider_id.poynt_business_id,
|
'business_id': provider.poynt_business_id,
|
||||||
'is_test': self.provider_id.state == 'test',
|
'is_test': provider.state == 'test',
|
||||||
}
|
}
|
||||||
|
|
||||||
def _send_payment_request(self):
|
def _send_payment_request(self):
|
||||||
@@ -104,26 +108,29 @@ class PaymentTransaction(models.Model):
|
|||||||
:rtype: dict
|
:rtype: dict
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
order_payload = poynt_utils.build_order_payload(
|
order_payload = poynt_utils.build_order_payload(
|
||||||
self.reference, self.amount, self.currency_id,
|
self.reference, self.amount, self.currency_id,
|
||||||
business_id=self.provider_id.poynt_business_id,
|
business_id=provider.poynt_business_id,
|
||||||
store_id=self.provider_id.poynt_store_id or '',
|
store_id=provider.poynt_store_id or '',
|
||||||
)
|
)
|
||||||
order_result = self.provider_id._poynt_make_request(
|
order_result = provider._poynt_make_request(
|
||||||
'POST', 'orders', payload=order_payload,
|
'POST', 'orders', payload=order_payload,
|
||||||
)
|
)
|
||||||
order_id = order_result.get('id', '')
|
order_id = order_result.get('id', '')
|
||||||
self.poynt_order_id = order_id
|
self.poynt_order_id = order_id
|
||||||
|
|
||||||
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
action = 'AUTHORIZE' if provider.capture_manually else 'SALE'
|
||||||
txn_payload = poynt_utils.build_transaction_payload(
|
txn_payload = poynt_utils.build_transaction_payload(
|
||||||
action=action,
|
action=action,
|
||||||
amount=self.amount,
|
amount=self.amount,
|
||||||
currency=self.currency_id,
|
currency=self.currency_id,
|
||||||
order_id=order_id,
|
order_id=order_id,
|
||||||
reference=self.reference,
|
reference=self.reference,
|
||||||
|
business_id=provider.poynt_business_id,
|
||||||
|
store_id=provider.poynt_store_id or '',
|
||||||
)
|
)
|
||||||
txn_result = self.provider_id._poynt_make_request(
|
txn_result = provider._poynt_make_request(
|
||||||
'POST', 'transactions', payload=txn_payload,
|
'POST', 'transactions', payload=txn_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -144,46 +151,68 @@ class PaymentTransaction(models.Model):
|
|||||||
def _poynt_process_token_payment(self):
|
def _poynt_process_token_payment(self):
|
||||||
"""Process a payment using a stored token (card on file).
|
"""Process a payment using a stored token (card on file).
|
||||||
|
|
||||||
For token-based payments we send a SALE or AUTHORIZE using the
|
Uses the JWT payment token via POST /cards/tokenize/charge when
|
||||||
stored card ID from the payment token.
|
available. Falls back to the legacy cardId flow for tokens that
|
||||||
|
were created before the JWT migration.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
provider = self._get_provider_sudo()
|
||||||
|
action = 'AUTHORIZE' if provider.capture_manually else 'SALE'
|
||||||
|
payment_jwt = self.token_id.poynt_payment_token
|
||||||
|
|
||||||
funding_source = {
|
if payment_jwt:
|
||||||
'type': 'CREDIT_DEBIT',
|
txn_result = provider._poynt_charge_token(
|
||||||
'card': {
|
payment_jwt=payment_jwt,
|
||||||
'cardId': self.token_id.poynt_card_id,
|
amount=self.amount,
|
||||||
},
|
currency=self.currency_id,
|
||||||
}
|
action=action,
|
||||||
|
reference=self.reference,
|
||||||
order_payload = poynt_utils.build_order_payload(
|
)
|
||||||
self.reference, self.amount, self.currency_id,
|
else:
|
||||||
business_id=self.provider_id.poynt_business_id,
|
funding_source = {
|
||||||
store_id=self.provider_id.poynt_store_id or '',
|
'type': 'CREDIT_DEBIT',
|
||||||
)
|
'card': {
|
||||||
order_result = self.provider_id._poynt_make_request(
|
'cardId': self.token_id.poynt_card_id,
|
||||||
'POST', 'orders', payload=order_payload,
|
},
|
||||||
)
|
'entryDetails': {
|
||||||
order_id = order_result.get('id', '')
|
'customerPresenceStatus': 'MOTO',
|
||||||
self.poynt_order_id = order_id
|
'entryMode': 'KEYED',
|
||||||
|
},
|
||||||
txn_payload = poynt_utils.build_transaction_payload(
|
}
|
||||||
action=action,
|
order_payload = poynt_utils.build_order_payload(
|
||||||
amount=self.amount,
|
self.reference, self.amount, self.currency_id,
|
||||||
currency=self.currency_id,
|
business_id=provider.poynt_business_id,
|
||||||
order_id=order_id,
|
store_id=provider.poynt_store_id or '',
|
||||||
reference=self.reference,
|
)
|
||||||
funding_source=funding_source,
|
order_result = provider._poynt_make_request(
|
||||||
)
|
'POST', 'orders', payload=order_payload,
|
||||||
txn_result = self.provider_id._poynt_make_request(
|
)
|
||||||
'POST', 'transactions', payload=txn_payload,
|
order_id = order_result.get('id', '')
|
||||||
)
|
self.poynt_order_id = order_id
|
||||||
|
txn_payload = poynt_utils.build_transaction_payload(
|
||||||
|
action=action,
|
||||||
|
amount=self.amount,
|
||||||
|
currency=self.currency_id,
|
||||||
|
order_id=order_id,
|
||||||
|
reference=self.reference,
|
||||||
|
funding_source=funding_source,
|
||||||
|
business_id=provider.poynt_business_id,
|
||||||
|
store_id=provider.poynt_store_id or '',
|
||||||
|
)
|
||||||
|
txn_result = provider._poynt_make_request(
|
||||||
|
'POST', 'transactions', payload=txn_payload,
|
||||||
|
)
|
||||||
|
|
||||||
transaction_id = txn_result.get('id', '')
|
transaction_id = txn_result.get('id', '')
|
||||||
self.poynt_transaction_id = transaction_id
|
self.poynt_transaction_id = transaction_id
|
||||||
self.provider_reference = transaction_id
|
self.provider_reference = transaction_id
|
||||||
|
|
||||||
|
order_id = txn_result.get('orderIdFromTransaction', '') or \
|
||||||
|
txn_result.get('orderId', '') or \
|
||||||
|
getattr(self, 'poynt_order_id', '') or ''
|
||||||
|
if order_id:
|
||||||
|
self.poynt_order_id = order_id
|
||||||
|
|
||||||
payment_data = {
|
payment_data = {
|
||||||
'reference': self.reference,
|
'reference': self.reference,
|
||||||
'poynt_order_id': order_id,
|
'poynt_order_id': order_id,
|
||||||
@@ -211,8 +240,9 @@ class PaymentTransaction(models.Model):
|
|||||||
|
|
||||||
parent_txn_id = source_tx.poynt_transaction_id or source_tx.provider_reference
|
parent_txn_id = source_tx.poynt_transaction_id or source_tx.provider_reference
|
||||||
|
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
try:
|
try:
|
||||||
txn_data = self.provider_id._poynt_make_request(
|
txn_data = provider._poynt_make_request(
|
||||||
'GET', f'transactions/{parent_txn_id}',
|
'GET', f'transactions/{parent_txn_id}',
|
||||||
)
|
)
|
||||||
for link in txn_data.get('links', []):
|
for link in txn_data.get('links', []):
|
||||||
@@ -248,7 +278,7 @@ class PaymentTransaction(models.Model):
|
|||||||
'notes': f'Refund for {source_tx.reference}',
|
'notes': f'Refund for {source_tx.reference}',
|
||||||
}
|
}
|
||||||
|
|
||||||
result = self.provider_id._poynt_make_request(
|
result = provider._poynt_make_request(
|
||||||
'POST', 'transactions', payload=refund_payload,
|
'POST', 'transactions', payload=refund_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -287,7 +317,7 @@ class PaymentTransaction(models.Model):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result = self.provider_id._poynt_make_request(
|
result = self._get_provider_sudo()._poynt_make_request(
|
||||||
'POST', 'transactions', payload=capture_payload,
|
'POST', 'transactions', payload=capture_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -313,7 +343,7 @@ class PaymentTransaction(models.Model):
|
|||||||
txn_id = source_tx.provider_reference or source_tx.poynt_transaction_id
|
txn_id = source_tx.provider_reference or source_tx.poynt_transaction_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.provider_id._poynt_make_request(
|
result = self._get_provider_sudo()._poynt_make_request(
|
||||||
'POST', f'transactions/{txn_id}/void',
|
'POST', f'transactions/{txn_id}/void',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -580,17 +610,18 @@ class PaymentTransaction(models.Model):
|
|||||||
return super()._create_payment(**extra_create_values)
|
return super()._create_payment(**extra_create_values)
|
||||||
|
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
reference = f'{self.reference} - {self.provider_reference or ""}'
|
reference = f'{self.reference} - {self.provider_reference or ""}'
|
||||||
payment_method_line = self.provider_id.journal_id.inbound_payment_method_line_ids\
|
payment_method_line = provider.journal_id.inbound_payment_method_line_ids\
|
||||||
.filtered(lambda l: l.payment_provider_id == self.provider_id)
|
.filtered(lambda l: l.payment_provider_id == provider)
|
||||||
payment_values = {
|
payment_values = {
|
||||||
'amount': abs(self.amount),
|
'amount': abs(self.amount),
|
||||||
'payment_type': 'inbound' if self.amount > 0 else 'outbound',
|
'payment_type': 'inbound' if self.amount > 0 else 'outbound',
|
||||||
'currency_id': self.currency_id.id,
|
'currency_id': self.currency_id.id,
|
||||||
'partner_id': self.partner_id.commercial_partner_id.id,
|
'partner_id': self.partner_id.commercial_partner_id.id,
|
||||||
'partner_type': 'customer',
|
'partner_type': 'customer',
|
||||||
'journal_id': self.provider_id.journal_id.id,
|
'journal_id': provider.journal_id.id,
|
||||||
'company_id': self.provider_id.company_id.id,
|
'company_id': provider.company_id.id,
|
||||||
'payment_method_line_id': payment_method_line.id,
|
'payment_method_line_id': payment_method_line.id,
|
||||||
'payment_token_id': self.token_id.id,
|
'payment_token_id': self.token_id.id,
|
||||||
'payment_transaction_id': self.id,
|
'payment_transaction_id': self.id,
|
||||||
@@ -608,7 +639,7 @@ class PaymentTransaction(models.Model):
|
|||||||
|
|
||||||
payment = self.env['account.payment'].create(payment_values)
|
payment = self.env['account.payment'].create(payment_values)
|
||||||
|
|
||||||
bank_account = self.provider_id.journal_id.default_account_id
|
bank_account = provider.journal_id.default_account_id
|
||||||
if bank_account and bank_account.account_type == 'asset_cash':
|
if bank_account and bank_account.account_type == 'asset_cash':
|
||||||
payment.outstanding_account_id = bank_account
|
payment.outstanding_account_id = bank_account
|
||||||
|
|
||||||
@@ -675,7 +706,7 @@ class PaymentTransaction(models.Model):
|
|||||||
fields in :attr:`poynt_receipt_data` as a JSON blob."""
|
fields in :attr:`poynt_receipt_data` as a JSON blob."""
|
||||||
txn_data = {}
|
txn_data = {}
|
||||||
try:
|
try:
|
||||||
txn_data = self.provider_id._poynt_make_request(
|
txn_data = self._get_provider_sudo()._poynt_make_request(
|
||||||
'GET', f'transactions/{self.poynt_transaction_id}',
|
'GET', f'transactions/{self.poynt_transaction_id}',
|
||||||
)
|
)
|
||||||
except (ValidationError, Exception):
|
except (ValidationError, Exception):
|
||||||
@@ -757,7 +788,7 @@ class PaymentTransaction(models.Model):
|
|||||||
if not invoice:
|
if not invoice:
|
||||||
return
|
return
|
||||||
|
|
||||||
receipt_content = self.provider_id._poynt_fetch_receipt(
|
receipt_content = self._get_provider_sudo()._poynt_fetch_receipt(
|
||||||
self.poynt_transaction_id,
|
self.poynt_transaction_id,
|
||||||
)
|
)
|
||||||
if not receipt_content:
|
if not receipt_content:
|
||||||
|
|||||||
@@ -66,17 +66,21 @@ class PoyntTerminal(models.Model):
|
|||||||
|
|
||||||
# === BUSINESS METHODS === #
|
# === BUSINESS METHODS === #
|
||||||
|
|
||||||
|
def _get_provider_sudo(self):
|
||||||
|
return self.provider_id.sudo()
|
||||||
|
|
||||||
def action_refresh_status(self):
|
def action_refresh_status(self):
|
||||||
"""Refresh the terminal status from Poynt Cloud."""
|
"""Refresh the terminal status from Poynt Cloud."""
|
||||||
for terminal in self:
|
for terminal in self:
|
||||||
try:
|
try:
|
||||||
store_id = terminal.store_id_poynt or terminal.provider_id.poynt_store_id
|
provider = terminal._get_provider_sudo()
|
||||||
|
store_id = terminal.store_id_poynt or provider.poynt_store_id
|
||||||
if store_id:
|
if store_id:
|
||||||
endpoint = f'stores/{store_id}/storeDevices/{terminal.device_id}'
|
endpoint = f'stores/{store_id}/storeDevices/{terminal.device_id}'
|
||||||
else:
|
else:
|
||||||
endpoint = f'storeDevices/{terminal.device_id}'
|
endpoint = f'storeDevices/{terminal.device_id}'
|
||||||
|
|
||||||
result = terminal.provider_id._poynt_make_request('GET', endpoint)
|
result = provider._poynt_make_request('GET', endpoint)
|
||||||
poynt_status = result.get('status', 'UNKNOWN')
|
poynt_status = result.get('status', 'UNKNOWN')
|
||||||
|
|
||||||
if poynt_status == 'ACTIVATED':
|
if poynt_status == 'ACTIVATED':
|
||||||
@@ -130,7 +134,8 @@ class PoyntTerminal(models.Model):
|
|||||||
if order_id:
|
if order_id:
|
||||||
payment_request['orderId'] = order_id
|
payment_request['orderId'] = order_id
|
||||||
|
|
||||||
store_id = self.store_id_poynt or self.provider_id.poynt_store_id or ''
|
provider = self._get_provider_sudo()
|
||||||
|
store_id = self.store_id_poynt or provider.poynt_store_id or ''
|
||||||
|
|
||||||
data_str = json.dumps({
|
data_str = json.dumps({
|
||||||
'action': 'sale',
|
'action': 'sale',
|
||||||
@@ -142,12 +147,12 @@ class PoyntTerminal(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.provider_id._poynt_make_request(
|
result = provider._poynt_make_request(
|
||||||
'POST',
|
'POST',
|
||||||
'cloudMessages',
|
'cloudMessages',
|
||||||
business_scoped=False,
|
business_scoped=False,
|
||||||
payload={
|
payload={
|
||||||
'businessId': self.provider_id.poynt_business_id,
|
'businessId': provider.poynt_business_id,
|
||||||
'storeId': store_id,
|
'storeId': store_id,
|
||||||
'deviceId': self.device_id,
|
'deviceId': self.device_id,
|
||||||
'ttl': 300,
|
'ttl': 300,
|
||||||
@@ -173,7 +178,7 @@ class PoyntTerminal(models.Model):
|
|||||||
:return: The full callback URL.
|
:return: The full callback URL.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
base_url = self.provider_id.get_base_url()
|
base_url = self._get_provider_sudo().get_base_url()
|
||||||
return f"{base_url}/payment/poynt/terminal/callback"
|
return f"{base_url}/payment/poynt/terminal/callback"
|
||||||
|
|
||||||
def action_check_terminal_payment_status(self, reference):
|
def action_check_terminal_payment_status(self, reference):
|
||||||
@@ -188,8 +193,9 @@ class PoyntTerminal(models.Model):
|
|||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
try:
|
try:
|
||||||
txn_result = self.provider_id._poynt_make_request(
|
txn_result = provider._poynt_make_request(
|
||||||
'GET',
|
'GET',
|
||||||
'transactions',
|
'transactions',
|
||||||
params={
|
params={
|
||||||
@@ -201,7 +207,7 @@ class PoyntTerminal(models.Model):
|
|||||||
transactions = txn_result.get('transactions', [])
|
transactions = txn_result.get('transactions', [])
|
||||||
|
|
||||||
if not transactions:
|
if not transactions:
|
||||||
txn_result = self.provider_id._poynt_make_request(
|
txn_result = provider._poynt_make_request(
|
||||||
'GET',
|
'GET',
|
||||||
'transactions',
|
'transactions',
|
||||||
params={
|
params={
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
access_poynt_terminal_user,poynt.terminal.user,model_poynt_terminal,base.group_user,1,0,0,0
|
access_poynt_terminal_user,poynt.terminal.user,model_poynt_terminal,group_fusion_poynt_user,1,0,0,0
|
||||||
access_poynt_terminal_admin,poynt.terminal.admin,model_poynt_terminal,base.group_system,1,1,1,1
|
access_poynt_terminal_admin,poynt.terminal.admin,model_poynt_terminal,group_fusion_poynt_admin,1,1,1,1
|
||||||
access_poynt_payment_wizard_user,poynt.payment.wizard.user,model_poynt_payment_wizard,account.group_account_invoice,1,1,1,0
|
access_poynt_payment_wizard_user,poynt.payment.wizard.user,model_poynt_payment_wizard,group_fusion_poynt_user,1,1,1,0
|
||||||
access_poynt_payment_wizard_admin,poynt.payment.wizard.admin,model_poynt_payment_wizard,base.group_system,1,1,1,1
|
access_poynt_payment_wizard_admin,poynt.payment.wizard.admin,model_poynt_payment_wizard,group_fusion_poynt_admin,1,1,1,1
|
||||||
access_poynt_refund_wizard_user,poynt.refund.wizard.user,model_poynt_refund_wizard,account.group_account_invoice,1,1,1,0
|
access_poynt_refund_wizard_user,poynt.refund.wizard.user,model_poynt_refund_wizard,group_fusion_poynt_user,1,1,1,0
|
||||||
access_poynt_refund_wizard_admin,poynt.refund.wizard.admin,model_poynt_refund_wizard,base.group_system,1,1,1,1
|
access_poynt_refund_wizard_admin,poynt.refund.wizard.admin,model_poynt_refund_wizard,group_fusion_poynt_admin,1,1,1,1
|
||||||
|
access_payment_provider_poynt_user,payment.provider.poynt.user,payment.model_payment_provider,group_fusion_poynt_user,1,0,0,0
|
||||||
|
access_payment_transaction_poynt_user,payment.transaction.poynt.user,payment.model_payment_transaction,group_fusion_poynt_user,1,1,1,0
|
||||||
|
access_payment_method_poynt_user,payment.method.poynt.user,payment.model_payment_method,group_fusion_poynt_user,1,0,0,0
|
||||||
|
|||||||
|
49
fusion_poynt/security/security.xml
Normal file
49
fusion_poynt/security/security.xml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- MODULE CATEGORY (required for user settings section rendering) -->
|
||||||
|
<!-- Odoo 19 organizes privileges by ir.module.category. -->
|
||||||
|
<!-- Without this, groups fall into the generic Extra Rights list. -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="module_category_fusion_poynt" model="ir.module.category">
|
||||||
|
<field name="name">Fusion Poynt</field>
|
||||||
|
<field name="sequence">47</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- FUSION POYNT PRIVILEGE (Odoo 19 pattern) -->
|
||||||
|
<!-- Linked to module_category_fusion_poynt so all groups appear -->
|
||||||
|
<!-- under a "FUSION POYNT" section in user settings. -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="res_groups_privilege_fusion_poynt" model="res.groups.privilege">
|
||||||
|
<field name="name">Fusion Poynt</field>
|
||||||
|
<field name="sequence">47</field>
|
||||||
|
<field name="category_id" ref="module_category_fusion_poynt"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- USER GROUP -->
|
||||||
|
<!-- Can view terminals, collect payments, and send receipts. -->
|
||||||
|
<!-- Implies base.group_user and account invoice access. -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="group_fusion_poynt_user" model="res.groups">
|
||||||
|
<field name="name">User</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user')), (4, ref('account.group_account_invoice'))]"/>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_fusion_poynt"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- ADMINISTRATOR GROUP -->
|
||||||
|
<!-- Full access: configure providers, manage terminals, process -->
|
||||||
|
<!-- payments, voids, and refunds. -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<record id="group_fusion_poynt_admin" model="res.groups">
|
||||||
|
<field name="name">Administrator</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
<field name="privilege_id" ref="res_groups_privilege_fusion_poynt"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_fusion_poynt_user'))]"/>
|
||||||
|
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -239,7 +239,8 @@ def build_order_payload(reference, amount, currency, business_id='',
|
|||||||
|
|
||||||
|
|
||||||
def build_transaction_payload(
|
def build_transaction_payload(
|
||||||
action, amount, currency, order_id=None, reference='', funding_source=None
|
action, amount, currency, order_id=None, reference='',
|
||||||
|
funding_source=None, business_id='', store_id='',
|
||||||
):
|
):
|
||||||
"""Build a Poynt transaction payload for charge/auth/capture.
|
"""Build a Poynt transaction payload for charge/auth/capture.
|
||||||
|
|
||||||
@@ -249,11 +250,23 @@ def build_transaction_payload(
|
|||||||
:param str order_id: The Poynt order UUID (optional).
|
:param str order_id: The Poynt order UUID (optional).
|
||||||
:param str reference: The Odoo transaction reference.
|
:param str reference: The Odoo transaction reference.
|
||||||
:param dict funding_source: The funding source / card data (optional).
|
:param dict funding_source: The funding source / card data (optional).
|
||||||
|
:param str business_id: The Poynt business UUID (optional).
|
||||||
|
:param str store_id: The Poynt store UUID (optional).
|
||||||
:return: The Poynt-formatted transaction payload.
|
:return: The Poynt-formatted transaction payload.
|
||||||
:rtype: dict
|
:rtype: dict
|
||||||
"""
|
"""
|
||||||
minor_amount = format_poynt_amount(amount, currency)
|
minor_amount = format_poynt_amount(amount, currency)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'source': 'WEB',
|
||||||
|
'sourceApp': 'odoo.fusion_poynt',
|
||||||
|
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
|
||||||
|
}
|
||||||
|
if business_id:
|
||||||
|
context['businessId'] = business_id
|
||||||
|
if store_id:
|
||||||
|
context['storeId'] = store_id
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'action': action,
|
'action': action,
|
||||||
'amounts': {
|
'amounts': {
|
||||||
@@ -263,11 +276,7 @@ def build_transaction_payload(
|
|||||||
'cashbackAmount': 0,
|
'cashbackAmount': 0,
|
||||||
'currency': currency.name,
|
'currency': currency.name,
|
||||||
},
|
},
|
||||||
'context': {
|
'context': context,
|
||||||
'source': 'WEB',
|
|
||||||
'sourceApp': 'odoo.fusion_poynt',
|
|
||||||
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
|
|
||||||
},
|
|
||||||
'notes': reference,
|
'notes': reference,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,3 +290,44 @@ def build_transaction_payload(
|
|||||||
payload['fundingSource'] = funding_source
|
payload['fundingSource'] = funding_source
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def build_token_charge_payload(
|
||||||
|
action, amount, currency, payment_jwt,
|
||||||
|
business_id='', store_id='', reference='',
|
||||||
|
):
|
||||||
|
"""Build a payload for POST /cards/tokenize/charge.
|
||||||
|
|
||||||
|
:param str action: SALE or AUTHORIZE.
|
||||||
|
:param float amount: Amount in major currency units.
|
||||||
|
:param recordset currency: Currency record.
|
||||||
|
:param str payment_jwt: The payment token JWT from /cards/tokenize.
|
||||||
|
:param str business_id: Poynt business UUID.
|
||||||
|
:param str store_id: Poynt store UUID.
|
||||||
|
:param str reference: Optional reference note.
|
||||||
|
:return: The charge payload dict.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
minor_amount = format_poynt_amount(amount, currency)
|
||||||
|
|
||||||
|
context = {}
|
||||||
|
if business_id:
|
||||||
|
context['businessId'] = business_id
|
||||||
|
if store_id:
|
||||||
|
context['storeId'] = store_id
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'action': action,
|
||||||
|
'context': context,
|
||||||
|
'amounts': {
|
||||||
|
'transactionAmount': minor_amount,
|
||||||
|
'orderAmount': minor_amount,
|
||||||
|
'currency': currency.name,
|
||||||
|
},
|
||||||
|
'fundingSource': {
|
||||||
|
'cardToken': payment_jwt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if reference:
|
||||||
|
payload['notes'] = reference
|
||||||
|
return payload
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
icon="fa-credit-card"
|
icon="fa-credit-card"
|
||||||
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_invoice'"
|
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_invoice'"
|
||||||
groups="account.group_account_invoice"
|
groups="fusion_poynt.group_fusion_poynt_user"
|
||||||
data-hotkey="p"/>
|
data-hotkey="p"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
icon="fa-undo"
|
icon="fa-undo"
|
||||||
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_refund' or poynt_refunded"
|
invisible="state != 'posted' or payment_state not in ('not_paid', 'partial') or move_type != 'out_refund' or poynt_refunded"
|
||||||
groups="account.group_account_invoice"
|
groups="fusion_poynt.group_fusion_poynt_user"
|
||||||
data-hotkey="r"/>
|
data-hotkey="r"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
icon="fa-envelope"
|
icon="fa-envelope"
|
||||||
invisible="state != 'posted' or move_type != 'out_invoice' or not has_poynt_receipt"
|
invisible="state != 'posted' or move_type != 'out_invoice' or not has_poynt_receipt"
|
||||||
groups="account.group_account_invoice"/>
|
groups="fusion_poynt.group_fusion_poynt_user"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
<!-- Resend Receipt button on credit notes (refunded via Poynt) -->
|
<!-- Resend Receipt button on credit notes (refunded via Poynt) -->
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
icon="fa-envelope"
|
icon="fa-envelope"
|
||||||
invisible="state != 'posted' or move_type != 'out_refund' or not poynt_refunded"
|
invisible="state != 'posted' or move_type != 'out_refund' or not poynt_refunded"
|
||||||
groups="account.group_account_invoice"/>
|
groups="fusion_poynt.group_fusion_poynt_user"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
<!-- Refunded banner on credit notes -->
|
<!-- Refunded banner on credit notes -->
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
name="Poynt Terminals"
|
name="Poynt Terminals"
|
||||||
parent="account.root_payment_menu"
|
parent="account.root_payment_menu"
|
||||||
action="action_poynt_terminal"
|
action="action_poynt_terminal"
|
||||||
sequence="15"/>
|
sequence="15"
|
||||||
|
groups="fusion_poynt.group_fusion_poynt_user"/>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
icon="fa-credit-card"
|
icon="fa-credit-card"
|
||||||
invisible="state not in ('sale', 'done')"
|
invisible="state not in ('sale', 'done')"
|
||||||
|
groups="fusion_poynt.group_fusion_poynt_user"
|
||||||
data-hotkey="p"/>
|
data-hotkey="p"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
required=True,
|
required=True,
|
||||||
domain="[('code', '=', 'poynt'), ('state', '!=', 'disabled')]",
|
domain="[('code', '=', 'poynt'), ('state', '!=', 'disabled')]",
|
||||||
)
|
)
|
||||||
|
provider_name = fields.Char(
|
||||||
|
related='provider_id.name',
|
||||||
|
string="Poynt Provider",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
payment_mode = fields.Selection(
|
payment_mode = fields.Selection(
|
||||||
selection=[
|
selection=[
|
||||||
@@ -109,7 +114,7 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
res['amount'] = invoice.amount_residual
|
res['amount'] = invoice.amount_residual
|
||||||
res['currency_id'] = invoice.currency_id.id
|
res['currency_id'] = invoice.currency_id.id
|
||||||
|
|
||||||
provider = self.env['payment.provider'].search([
|
provider = self.env['payment.provider'].sudo().search([
|
||||||
('code', '=', 'poynt'),
|
('code', '=', 'poynt'),
|
||||||
('state', '!=', 'disabled'),
|
('state', '!=', 'disabled'),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
@@ -120,10 +125,15 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def _get_provider_sudo(self):
|
||||||
|
return self.provider_id.sudo()
|
||||||
|
|
||||||
@api.onchange('provider_id')
|
@api.onchange('provider_id')
|
||||||
def _onchange_provider_id(self):
|
def _onchange_provider_id(self):
|
||||||
if self.provider_id and self.provider_id.poynt_default_terminal_id:
|
if self.provider_id:
|
||||||
self.terminal_id = self.provider_id.poynt_default_terminal_id
|
provider = self._get_provider_sudo()
|
||||||
|
if provider.poynt_default_terminal_id:
|
||||||
|
self.terminal_id = provider.poynt_default_terminal_id
|
||||||
|
|
||||||
def action_collect_payment(self):
|
def action_collect_payment(self):
|
||||||
"""Dispatch to the appropriate payment method."""
|
"""Dispatch to the appropriate payment method."""
|
||||||
@@ -213,7 +223,8 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE'
|
provider = self._get_provider_sudo()
|
||||||
|
action = 'AUTHORIZE' if provider.capture_manually else 'SALE'
|
||||||
minor_amount = poynt_utils.format_poynt_amount(
|
minor_amount = poynt_utils.format_poynt_amount(
|
||||||
self.amount, self.currency_id,
|
self.amount, self.currency_id,
|
||||||
)
|
)
|
||||||
@@ -232,7 +243,7 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
'source': 'WEB',
|
'source': 'WEB',
|
||||||
'sourceApp': 'odoo.fusion_poynt',
|
'sourceApp': 'odoo.fusion_poynt',
|
||||||
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
|
'transactionInstruction': 'ONLINE_AUTH_REQUIRED',
|
||||||
'businessId': self.provider_id.poynt_business_id,
|
'businessId': provider.poynt_business_id,
|
||||||
},
|
},
|
||||||
'notes': reference,
|
'notes': reference,
|
||||||
}
|
}
|
||||||
@@ -243,7 +254,7 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
'type': 'POYNT_ORDER',
|
'type': 'POYNT_ORDER',
|
||||||
}]
|
}]
|
||||||
|
|
||||||
result = self.provider_id._poynt_make_request(
|
result = provider._poynt_make_request(
|
||||||
'POST', 'transactions', payload=txn_payload,
|
'POST', 'transactions', payload=txn_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,7 +314,7 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
if not terminal:
|
if not terminal:
|
||||||
raise UserError(_("No terminal associated with this payment."))
|
raise UserError(_("No terminal associated with this payment."))
|
||||||
|
|
||||||
provider = self.provider_id
|
provider = self._get_provider_sudo()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
txn = self._find_terminal_transaction(provider)
|
txn = self._find_terminal_transaction(provider)
|
||||||
@@ -517,14 +528,15 @@ class PoyntPaymentWizard(models.TransientModel):
|
|||||||
|
|
||||||
def _create_poynt_order(self, reference):
|
def _create_poynt_order(self, reference):
|
||||||
"""Create a Poynt order via the API."""
|
"""Create a Poynt order via the API."""
|
||||||
|
provider = self._get_provider_sudo()
|
||||||
order_payload = poynt_utils.build_order_payload(
|
order_payload = poynt_utils.build_order_payload(
|
||||||
reference,
|
reference,
|
||||||
self.amount,
|
self.amount,
|
||||||
self.currency_id,
|
self.currency_id,
|
||||||
business_id=self.provider_id.poynt_business_id,
|
business_id=provider.poynt_business_id,
|
||||||
store_id=self.provider_id.poynt_store_id or '',
|
store_id=provider.poynt_store_id or '',
|
||||||
)
|
)
|
||||||
return self.provider_id._poynt_make_request(
|
return provider._poynt_make_request(
|
||||||
'POST', 'orders', payload=order_payload,
|
'POST', 'orders', payload=order_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<form string="Collect Poynt Payment">
|
<form string="Collect Poynt Payment">
|
||||||
<field name="state" invisible="1"/>
|
<field name="state" invisible="1"/>
|
||||||
<field name="poynt_transaction_ref" invisible="1"/>
|
<field name="poynt_transaction_ref" invisible="1"/>
|
||||||
|
<field name="provider_id" invisible="1"/>
|
||||||
|
|
||||||
<!-- Status banner for waiting / done / error -->
|
<!-- Status banner for waiting / done / error -->
|
||||||
<div class="alert alert-info" role="alert"
|
<div class="alert alert-info" role="alert"
|
||||||
@@ -34,8 +35,7 @@
|
|||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="amount"/>
|
<field name="amount"/>
|
||||||
<field name="currency_id"/>
|
<field name="currency_id"/>
|
||||||
<field name="provider_id"
|
<field name="provider_name"/>
|
||||||
readonly="state != 'draft'"/>
|
|
||||||
</group>
|
</group>
|
||||||
<group string="Payment Mode"
|
<group string="Payment Mode"
|
||||||
invisible="state not in ('draft', 'error')">
|
invisible="state not in ('draft', 'error')">
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ class PoyntRefundWizard(models.TransientModel):
|
|||||||
required=True,
|
required=True,
|
||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
provider_name = fields.Char(
|
||||||
|
related='provider_id.name',
|
||||||
|
string="Poynt Provider",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
original_transaction_id = fields.Many2one(
|
original_transaction_id = fields.Many2one(
|
||||||
'payment.transaction',
|
'payment.transaction',
|
||||||
string="Original Transaction",
|
string="Original Transaction",
|
||||||
@@ -104,6 +109,9 @@ class PoyntRefundWizard(models.TransientModel):
|
|||||||
)
|
)
|
||||||
status_message = fields.Text(string="Status", readonly=True)
|
status_message = fields.Text(string="Status", readonly=True)
|
||||||
|
|
||||||
|
def _get_provider_sudo(self):
|
||||||
|
return self.provider_id.sudo()
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def default_get(self, fields_list):
|
def default_get(self, fields_list):
|
||||||
res = super().default_get(fields_list)
|
res = super().default_get(fields_list)
|
||||||
@@ -130,8 +138,9 @@ class PoyntRefundWizard(models.TransientModel):
|
|||||||
res['original_invoice_id'] = credit_note.reversed_entry_id.id
|
res['original_invoice_id'] = credit_note.reversed_entry_id.id
|
||||||
res['original_poynt_txn_id'] = orig_tx.poynt_transaction_id
|
res['original_poynt_txn_id'] = orig_tx.poynt_transaction_id
|
||||||
|
|
||||||
if orig_tx.provider_id.poynt_default_terminal_id:
|
provider = orig_tx.provider_id.sudo()
|
||||||
res['terminal_id'] = orig_tx.provider_id.poynt_default_terminal_id.id
|
if provider.poynt_default_terminal_id:
|
||||||
|
res['terminal_id'] = provider.poynt_default_terminal_id.id
|
||||||
|
|
||||||
age_days = 0
|
age_days = 0
|
||||||
if orig_tx.create_date:
|
if orig_tx.create_date:
|
||||||
@@ -201,7 +210,7 @@ class PoyntRefundWizard(models.TransientModel):
|
|||||||
still showing ``status: CAPTURED``. We must check the full chain.
|
still showing ``status: CAPTURED``. We must check the full chain.
|
||||||
"""
|
"""
|
||||||
orig_tx = self.original_transaction_id
|
orig_tx = self.original_transaction_id
|
||||||
provider = self.provider_id
|
provider = self._get_provider_sudo()
|
||||||
txn_id = orig_tx.poynt_transaction_id
|
txn_id = orig_tx.poynt_transaction_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -278,7 +287,7 @@ class PoyntRefundWizard(models.TransientModel):
|
|||||||
def _process_referenced_refund(self):
|
def _process_referenced_refund(self):
|
||||||
"""Send a referenced REFUND using the original transaction's parentId."""
|
"""Send a referenced REFUND using the original transaction's parentId."""
|
||||||
orig_tx = self.original_transaction_id
|
orig_tx = self.original_transaction_id
|
||||||
provider = self.provider_id
|
provider = self._get_provider_sudo()
|
||||||
|
|
||||||
parent_txn_id = orig_tx.poynt_transaction_id
|
parent_txn_id = orig_tx.poynt_transaction_id
|
||||||
try:
|
try:
|
||||||
@@ -346,7 +355,7 @@ class PoyntRefundWizard(models.TransientModel):
|
|||||||
"The customer's card must be present on the device."
|
"The customer's card must be present on the device."
|
||||||
))
|
))
|
||||||
|
|
||||||
provider = self.provider_id
|
provider = self._get_provider_sudo()
|
||||||
orig_tx = self.original_transaction_id
|
orig_tx = self.original_transaction_id
|
||||||
minor_amount = poynt_utils.format_poynt_amount(
|
minor_amount = poynt_utils.format_poynt_amount(
|
||||||
self.amount, self.currency_id,
|
self.amount, self.currency_id,
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
<field name="currency_id" invisible="1"/>
|
<field name="currency_id" invisible="1"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Original Payment">
|
<group string="Original Payment">
|
||||||
<field name="provider_id" readonly="1"/>
|
<field name="provider_id" invisible="1"/>
|
||||||
|
<field name="provider_name"/>
|
||||||
<field name="original_transaction_id" readonly="1"/>
|
<field name="original_transaction_id" readonly="1"/>
|
||||||
<field name="original_poynt_txn_id" readonly="1"/>
|
<field name="original_poynt_txn_id" readonly="1"/>
|
||||||
<field name="card_info" readonly="1"
|
<field name="card_info" readonly="1"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Rental Enhancement',
|
'name': 'Fusion Rental',
|
||||||
'version': '19.0.2.0.0',
|
'version': '19.0.2.0.0',
|
||||||
'category': 'Sales/Rental',
|
'category': 'Sales/Rental',
|
||||||
'sequence': 200,
|
'sequence': 200,
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
'wizard/manual_renewal_wizard_views.xml',
|
'wizard/manual_renewal_wizard_views.xml',
|
||||||
'wizard/deposit_deduction_wizard_views.xml',
|
'wizard/deposit_deduction_wizard_views.xml',
|
||||||
|
'wizard/rental_return_wizard_views.xml',
|
||||||
|
|
||||||
'report/report_rental_agreement.xml',
|
'report/report_rental_agreement.xml',
|
||||||
|
|
||||||
@@ -34,8 +35,16 @@
|
|||||||
'views/cancellation_request_views.xml',
|
'views/cancellation_request_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/portal_rental_inspection.xml',
|
'views/portal_rental_inspection.xml',
|
||||||
|
'views/portal_sale_rental_override.xml',
|
||||||
'views/menus.xml',
|
'views/menus.xml',
|
||||||
],
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'fusion_rental/static/src/css/inspection_photos.css',
|
||||||
|
'fusion_rental/static/src/js/inspection_photo_field.js',
|
||||||
|
'fusion_rental/static/src/xml/inspection_photo_field.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
'author': 'Fusion Apps',
|
'author': 'Fusion Apps',
|
||||||
'license': 'OPL-1',
|
'license': 'OPL-1',
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ class FusionRentalController(http.Controller):
|
|||||||
{'error': _("This cancellation link is invalid or has already been used.")},
|
{'error': _("This cancellation link is invalid or has already been used.")},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
order = cancel_request.order_id
|
||||||
|
today = fields.Date.today()
|
||||||
|
if order.rental_next_renewal_date and order.rental_next_renewal_date <= today:
|
||||||
|
return request.render(
|
||||||
|
'fusion_rental.cancellation_invalid_page',
|
||||||
|
{'error': _(
|
||||||
|
"Cancellation is not allowed on or after the renewal date. "
|
||||||
|
"Please contact our office for assistance."
|
||||||
|
)},
|
||||||
|
)
|
||||||
|
|
||||||
if request.httprequest.method == 'POST':
|
if request.httprequest.method == 'POST':
|
||||||
reason = kwargs.get('reason', '')
|
reason = kwargs.get('reason', '')
|
||||||
cancel_request.write({'reason': reason})
|
cancel_request.write({'reason': reason})
|
||||||
@@ -41,7 +52,7 @@ class FusionRentalController(http.Controller):
|
|||||||
return request.render(
|
return request.render(
|
||||||
'fusion_rental.cancellation_success_page',
|
'fusion_rental.cancellation_success_page',
|
||||||
{
|
{
|
||||||
'order': cancel_request.order_id,
|
'order': order,
|
||||||
'partner': cancel_request.partner_id,
|
'partner': cancel_request.partner_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -49,12 +60,51 @@ class FusionRentalController(http.Controller):
|
|||||||
return request.render(
|
return request.render(
|
||||||
'fusion_rental.cancellation_form_page',
|
'fusion_rental.cancellation_form_page',
|
||||||
{
|
{
|
||||||
'order': cancel_request.order_id,
|
'order': order,
|
||||||
'partner': cancel_request.partner_id,
|
'partner': cancel_request.partner_id,
|
||||||
'token': token,
|
'token': token,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# Portal: Confirm Quotation → Redirect to Rental Agreement
|
||||||
|
# =================================================================
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
'/rental/confirm-and-sign/<int:order_id>',
|
||||||
|
type='http',
|
||||||
|
auth='public',
|
||||||
|
website=True,
|
||||||
|
methods=['GET', 'POST'],
|
||||||
|
)
|
||||||
|
def rental_confirm_and_sign(self, order_id, access_token=None, **kwargs):
|
||||||
|
"""Confirm a rental quotation from the portal and redirect to the
|
||||||
|
rental agreement signing page.
|
||||||
|
|
||||||
|
This replaces Odoo's standard 'Sign & Pay' flow for rental orders.
|
||||||
|
Uses the sale order's access_token for authentication so customers
|
||||||
|
don't need a portal account.
|
||||||
|
"""
|
||||||
|
order = request.env['sale.order'].sudo().browse(order_id)
|
||||||
|
if (
|
||||||
|
not order.exists()
|
||||||
|
or not order.is_rental_order
|
||||||
|
or not access_token
|
||||||
|
or order.access_token != access_token
|
||||||
|
):
|
||||||
|
return request.redirect('/my/orders')
|
||||||
|
|
||||||
|
if order.state in ('draft', 'sent'):
|
||||||
|
order.action_confirm()
|
||||||
|
|
||||||
|
if not order.rental_agreement_token:
|
||||||
|
import uuid
|
||||||
|
order.rental_agreement_token = uuid.uuid4().hex
|
||||||
|
|
||||||
|
return request.redirect(
|
||||||
|
f'/rental/agreement/{order.id}/{order.rental_agreement_token}'
|
||||||
|
)
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
# Rental Agreement Signing
|
# Rental Agreement Signing
|
||||||
# =================================================================
|
# =================================================================
|
||||||
@@ -79,6 +129,26 @@ class FusionRentalController(http.Controller):
|
|||||||
{'error': _("This agreement link is invalid or has already been signed.")},
|
{'error': _("This agreement link is invalid or has already been signed.")},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
google_api_key = request.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_claims.google_maps_api_key', ''
|
||||||
|
)
|
||||||
|
if not google_api_key:
|
||||||
|
google_api_key = request.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'fusion_rental.google_maps_api_key', ''
|
||||||
|
)
|
||||||
|
|
||||||
|
poynt_business_id = ''
|
||||||
|
poynt_application_id = ''
|
||||||
|
provider = request.env['payment.provider'].sudo().search([
|
||||||
|
('code', '=', 'poynt'),
|
||||||
|
('state', '!=', 'disabled'),
|
||||||
|
], limit=1)
|
||||||
|
if provider:
|
||||||
|
poynt_business_id = provider.poynt_business_id or ''
|
||||||
|
raw_app_id = provider.poynt_application_id or ''
|
||||||
|
from odoo.addons.fusion_poynt.utils import clean_application_id
|
||||||
|
poynt_application_id = clean_application_id(raw_app_id) or raw_app_id
|
||||||
|
|
||||||
return request.render(
|
return request.render(
|
||||||
'fusion_rental.agreement_signing_page',
|
'fusion_rental.agreement_signing_page',
|
||||||
{
|
{
|
||||||
@@ -86,6 +156,9 @@ class FusionRentalController(http.Controller):
|
|||||||
'partner': order.partner_id,
|
'partner': order.partner_id,
|
||||||
'token': token,
|
'token': token,
|
||||||
'pdf_preview_url': f'/rental/agreement/{order_id}/{token}/pdf',
|
'pdf_preview_url': f'/rental/agreement/{order_id}/{token}/pdf',
|
||||||
|
'google_api_key': google_api_key,
|
||||||
|
'poynt_business_id': poynt_business_id,
|
||||||
|
'poynt_application_id': poynt_application_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,7 +192,10 @@ class FusionRentalController(http.Controller):
|
|||||||
pdf_content,
|
pdf_content,
|
||||||
headers=[
|
headers=[
|
||||||
('Content-Type', 'application/pdf'),
|
('Content-Type', 'application/pdf'),
|
||||||
('Content-Disposition', f'inline; filename="Rental Agreement - {order.name}.pdf"'),
|
('Content-Disposition',
|
||||||
|
f'inline; filename="{order.name} - {order.partner_id.name or ""}'
|
||||||
|
f' - Rental Agreement'
|
||||||
|
f'{" - Signed" if order.rental_agreement_signed else ""}.pdf"'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,7 +206,7 @@ class FusionRentalController(http.Controller):
|
|||||||
methods=['POST'],
|
methods=['POST'],
|
||||||
)
|
)
|
||||||
def rental_agreement_sign(self, order_id, token, **kwargs):
|
def rental_agreement_sign(self, order_id, token, **kwargs):
|
||||||
"""Process the agreement signing: save signature and tokenize card."""
|
"""Process the agreement signing: save signature and tokenize card via nonce."""
|
||||||
order = request.env['sale.order'].sudo().browse(order_id)
|
order = request.env['sale.order'].sudo().browse(order_id)
|
||||||
if (
|
if (
|
||||||
not order.exists()
|
not order.exists()
|
||||||
@@ -141,33 +217,37 @@ class FusionRentalController(http.Controller):
|
|||||||
|
|
||||||
signer_name = kwargs.get('signer_name', '').strip()
|
signer_name = kwargs.get('signer_name', '').strip()
|
||||||
signature_data = kwargs.get('signature_data', '')
|
signature_data = kwargs.get('signature_data', '')
|
||||||
card_number = kwargs.get('card_number', '').replace(' ', '')
|
nonce = kwargs.get('nonce', '').strip()
|
||||||
exp_month = kwargs.get('exp_month', '')
|
billing_address = kwargs.get('billing_address', '').strip()
|
||||||
exp_year = kwargs.get('exp_year', '')
|
billing_city = kwargs.get('billing_city', '').strip()
|
||||||
cvv = kwargs.get('cvv', '')
|
billing_state = kwargs.get('billing_state', '').strip()
|
||||||
cardholder_name = kwargs.get('cardholder_name', '').strip()
|
billing_postal_code = kwargs.get('billing_postal_code', '').strip()
|
||||||
|
|
||||||
if not signer_name:
|
if not signer_name:
|
||||||
return {'success': False, 'error': 'Full name is required.'}
|
return {'success': False, 'error': 'Full name is required.'}
|
||||||
if not signature_data:
|
if not signature_data:
|
||||||
return {'success': False, 'error': 'Signature is required.'}
|
return {'success': False, 'error': 'Signature is required.'}
|
||||||
if not card_number or len(card_number) < 13:
|
if not nonce:
|
||||||
return {'success': False, 'error': 'Valid card number is required.'}
|
return {'success': False, 'error': 'Card authorization is required. Please fill in your card details.'}
|
||||||
if not exp_month or not exp_year:
|
if not billing_postal_code:
|
||||||
return {'success': False, 'error': 'Card expiry is required.'}
|
return {'success': False, 'error': 'Billing postal/zip code is required.'}
|
||||||
if not cvv:
|
|
||||||
return {'success': False, 'error': 'CVV is required.'}
|
|
||||||
|
|
||||||
sig_binary = signature_data
|
sig_binary = signature_data
|
||||||
if ',' in sig_binary:
|
if ',' in sig_binary:
|
||||||
sig_binary = sig_binary.split(',')[1]
|
sig_binary = sig_binary.split(',')[1]
|
||||||
|
|
||||||
|
full_billing = billing_address
|
||||||
|
if billing_city:
|
||||||
|
full_billing += f", {billing_city}"
|
||||||
|
if billing_state:
|
||||||
|
full_billing += f", {billing_state}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payment_token = self._tokenize_card_via_poynt(
|
payment_token = self._tokenize_nonce_via_poynt(
|
||||||
order, card_number, exp_month, exp_year, cvv, cardholder_name,
|
order, nonce, billing_postal_code,
|
||||||
)
|
)
|
||||||
except (UserError, Exception) as e:
|
except (UserError, Exception) as e:
|
||||||
_logger.error("Card tokenization failed for %s: %s", order.name, e)
|
_logger.error("Nonce tokenization failed for %s: %s", order.name, e)
|
||||||
return {'success': False, 'error': str(e)}
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
order.write({
|
order.write({
|
||||||
@@ -176,21 +256,97 @@ class FusionRentalController(http.Controller):
|
|||||||
'rental_agreement_signer_name': signer_name,
|
'rental_agreement_signer_name': signer_name,
|
||||||
'rental_agreement_signed_date': fields.Datetime.now(),
|
'rental_agreement_signed_date': fields.Datetime.now(),
|
||||||
'rental_payment_token_id': payment_token.id if payment_token else False,
|
'rental_payment_token_id': payment_token.id if payment_token else False,
|
||||||
|
'rental_billing_address': full_billing,
|
||||||
|
'rental_billing_postal_code': billing_postal_code,
|
||||||
})
|
})
|
||||||
|
|
||||||
order.message_post(
|
order.message_post(
|
||||||
body=_("Rental agreement signed by %s.", signer_name),
|
body=_("Rental agreement signed by %s.", signer_name),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
order._generate_and_attach_signed_agreement()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
"Signed agreement PDF generation failed for %s: %s",
|
||||||
|
order.name, e,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
order._process_post_signing_payments()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
"Post-signing payment processing failed for %s: %s",
|
||||||
|
order.name, e,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': 'Agreement signed successfully. Thank you!',
|
'message': 'Agreement signed successfully. Thank you!',
|
||||||
}
|
}
|
||||||
|
|
||||||
def _tokenize_card_via_poynt(
|
@http.route(
|
||||||
self, order, card_number, exp_month, exp_year, cvv, cardholder_name,
|
'/rental/agreement/<int:order_id>/<string:token>/decline',
|
||||||
):
|
type='json',
|
||||||
"""Tokenize a card through the Poynt API and create a payment.token."""
|
auth='public',
|
||||||
|
methods=['POST'],
|
||||||
|
)
|
||||||
|
def rental_agreement_decline(self, order_id, token, **kwargs):
|
||||||
|
"""Cancel/decline the rental order from the agreement page."""
|
||||||
|
order = request.env['sale.order'].sudo().browse(order_id)
|
||||||
|
if (
|
||||||
|
not order.exists()
|
||||||
|
or order.rental_agreement_token != token
|
||||||
|
or order.rental_agreement_signed
|
||||||
|
):
|
||||||
|
return {'success': False, 'error': 'Invalid or expired agreement link.'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
order.action_cancel()
|
||||||
|
order.message_post(
|
||||||
|
body=_("Rental order declined by the customer from the agreement page."),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Agreement decline failed for %s: %s", order.name, e)
|
||||||
|
return {'success': False, 'error': 'Unable to cancel the order. Please contact our office.'}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': 'Your rental order has been cancelled. No charges have been applied.',
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
'/rental/agreement/<int:order_id>/<string:token>/thank-you',
|
||||||
|
type='http',
|
||||||
|
auth='public',
|
||||||
|
website=True,
|
||||||
|
methods=['GET'],
|
||||||
|
)
|
||||||
|
def rental_agreement_thank_you(self, order_id, token, **kwargs):
|
||||||
|
"""Render the thank-you page after successful agreement signing."""
|
||||||
|
order = request.env['sale.order'].sudo().browse(order_id)
|
||||||
|
if (
|
||||||
|
not order.exists()
|
||||||
|
or order.rental_agreement_token != token
|
||||||
|
):
|
||||||
|
return request.render(
|
||||||
|
'fusion_rental.cancellation_invalid_page',
|
||||||
|
{'error': _("This link is invalid.")},
|
||||||
|
)
|
||||||
|
|
||||||
|
company = order.company_id or request.env.company
|
||||||
|
return request.render(
|
||||||
|
'fusion_rental.agreement_thank_you_page',
|
||||||
|
{
|
||||||
|
'order': order,
|
||||||
|
'partner': order.partner_id,
|
||||||
|
'company_phone': company.phone or '',
|
||||||
|
'company_email': company.email or '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _tokenize_nonce_via_poynt(self, order, nonce, billing_postal_code=''):
|
||||||
|
"""Exchange a Poynt Collect nonce for a payment JWT and create a payment.token."""
|
||||||
provider = request.env['payment.provider'].sudo().search([
|
provider = request.env['payment.provider'].sudo().search([
|
||||||
('code', '=', 'poynt'),
|
('code', '=', 'poynt'),
|
||||||
('state', '!=', 'disabled'),
|
('state', '!=', 'disabled'),
|
||||||
@@ -198,67 +354,178 @@ class FusionRentalController(http.Controller):
|
|||||||
if not provider:
|
if not provider:
|
||||||
raise UserError(_("Poynt payment provider is not configured."))
|
raise UserError(_("Poynt payment provider is not configured."))
|
||||||
|
|
||||||
from odoo.addons.fusion_poynt import utils as poynt_utils
|
result = provider._poynt_tokenize_nonce(nonce)
|
||||||
|
|
||||||
funding_source = {
|
card_data = result.get('card', {})
|
||||||
'type': 'CREDIT_DEBIT',
|
card_id = card_data.get('id', '') or result.get('cardId', '')
|
||||||
'card': {
|
last_four = card_data.get('numberLast4', '')
|
||||||
'number': card_number,
|
|
||||||
'expirationMonth': int(exp_month),
|
|
||||||
'expirationYear': int(exp_year),
|
|
||||||
'cardHolderFullName': cardholder_name,
|
|
||||||
},
|
|
||||||
'verificationData': {
|
|
||||||
'cvData': cvv,
|
|
||||||
},
|
|
||||||
'entryDetails': {
|
|
||||||
'customerPresenceStatus': 'MOTO',
|
|
||||||
'entryMode': 'KEYED',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
minor_amount = poynt_utils.format_poynt_amount(0.00, order.currency_id)
|
|
||||||
|
|
||||||
txn_payload = {
|
|
||||||
'action': 'VERIFY',
|
|
||||||
'amounts': {
|
|
||||||
'transactionAmount': minor_amount,
|
|
||||||
'orderAmount': minor_amount,
|
|
||||||
'currency': order.currency_id.name,
|
|
||||||
},
|
|
||||||
'fundingSource': funding_source,
|
|
||||||
'context': {
|
|
||||||
'source': 'WEB',
|
|
||||||
'sourceApp': 'odoo.fusion_rental',
|
|
||||||
'businessId': provider.poynt_business_id,
|
|
||||||
},
|
|
||||||
'notes': f"Card tokenization for {order.name}",
|
|
||||||
}
|
|
||||||
|
|
||||||
result = provider._poynt_make_request('POST', 'transactions', payload=txn_payload)
|
|
||||||
|
|
||||||
card_data = result.get('fundingSource', {}).get('card', {})
|
|
||||||
card_id = card_data.get('cardId', '')
|
|
||||||
last_four = card_data.get('numberLast4', card_number[-4:])
|
|
||||||
card_type = card_data.get('type', 'UNKNOWN')
|
card_type = card_data.get('type', 'UNKNOWN')
|
||||||
|
payment_jwt = result.get('paymentToken', '')
|
||||||
|
|
||||||
payment_method = request.env['payment.method'].sudo().search(
|
avs_result = result.get('avsResult', {})
|
||||||
[('code', '=', 'card')], limit=1,
|
if avs_result and billing_postal_code:
|
||||||
|
avs_match = avs_result.get('postalCodeMatch', '')
|
||||||
|
if avs_match and avs_match not in ('MATCH', 'YES', 'Y'):
|
||||||
|
_logger.warning(
|
||||||
|
"AVS postal code mismatch for %s: expected=%s result=%s",
|
||||||
|
order.name, billing_postal_code, avs_match,
|
||||||
|
)
|
||||||
|
|
||||||
|
card_type_lower = card_type.lower() if card_type else ''
|
||||||
|
PaymentMethod = request.env['payment.method'].sudo().with_context(active_test=False)
|
||||||
|
payment_method = PaymentMethod.search(
|
||||||
|
[('code', '=', card_type_lower)], limit=1,
|
||||||
)
|
)
|
||||||
if not payment_method:
|
if not payment_method:
|
||||||
payment_method = request.env['payment.method'].sudo().search(
|
payment_method = PaymentMethod.search(
|
||||||
|
[('code', '=', 'card')], limit=1,
|
||||||
|
)
|
||||||
|
if not payment_method:
|
||||||
|
payment_method = PaymentMethod.search(
|
||||||
[('code', 'in', ('visa', 'mastercard'))], limit=1,
|
[('code', 'in', ('visa', 'mastercard'))], limit=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
token = request.env['payment.token'].sudo().create({
|
token = request.env['payment.token'].sudo().create({
|
||||||
'provider_id': provider.id,
|
'provider_id': provider.id,
|
||||||
'payment_method_id': payment_method.id if payment_method else False,
|
'payment_method_id': payment_method.id,
|
||||||
'partner_id': order.partner_id.id,
|
'partner_id': order.partner_id.id,
|
||||||
|
'provider_ref': card_id or nonce[:40],
|
||||||
'poynt_card_id': card_id,
|
'poynt_card_id': card_id,
|
||||||
|
'poynt_payment_token': payment_jwt,
|
||||||
'payment_details': f"{card_type} ending in {last_four}",
|
'payment_details': f"{card_type} ending in {last_four}",
|
||||||
})
|
})
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
# =================================================================
|
||||||
|
# Card Reauthorization
|
||||||
|
# =================================================================
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
'/rental/reauthorize/<int:order_id>/<string:token>',
|
||||||
|
type='http',
|
||||||
|
auth='public',
|
||||||
|
website=True,
|
||||||
|
methods=['GET'],
|
||||||
|
)
|
||||||
|
def rental_reauthorize_page(self, order_id, token, **kwargs):
|
||||||
|
"""Render the card reauthorization portal page."""
|
||||||
|
order = request.env['sale.order'].sudo().browse(order_id)
|
||||||
|
if (
|
||||||
|
not order.exists()
|
||||||
|
or order.rental_agreement_token != token
|
||||||
|
or not order.is_rental_order
|
||||||
|
):
|
||||||
|
return request.render(
|
||||||
|
'fusion_rental.cancellation_invalid_page',
|
||||||
|
{'error': _("This link is invalid or has expired.")},
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = request.env['payment.provider'].sudo().search([
|
||||||
|
('code', '=', 'poynt'),
|
||||||
|
('state', '!=', 'disabled'),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
return request.render(
|
||||||
|
'fusion_rental.card_reauthorization_page',
|
||||||
|
{
|
||||||
|
'order': order,
|
||||||
|
'partner': order.partner_id,
|
||||||
|
'poynt_business_id': provider.poynt_business_id if provider else '',
|
||||||
|
'poynt_application_id': provider.poynt_application_id if provider else '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@http.route(
|
||||||
|
'/rental/reauthorize/<int:order_id>/<string:token>/submit',
|
||||||
|
type='json',
|
||||||
|
auth='public',
|
||||||
|
methods=['POST'],
|
||||||
|
)
|
||||||
|
def rental_reauthorize_submit(self, order_id, token, **kwargs):
|
||||||
|
"""Process the card reauthorization submission."""
|
||||||
|
order = request.env['sale.order'].sudo().browse(order_id)
|
||||||
|
if (
|
||||||
|
not order.exists()
|
||||||
|
or order.rental_agreement_token != token
|
||||||
|
or not order.is_rental_order
|
||||||
|
):
|
||||||
|
return {'success': False, 'error': 'Invalid or expired link.'}
|
||||||
|
|
||||||
|
cardholder_name = kwargs.get('cardholder_name', '').strip()
|
||||||
|
nonce = kwargs.get('nonce', '').strip()
|
||||||
|
billing_address = kwargs.get('billing_address', '').strip()
|
||||||
|
billing_city = kwargs.get('billing_city', '').strip()
|
||||||
|
billing_state = kwargs.get('billing_state', '').strip()
|
||||||
|
billing_postal_code = kwargs.get('billing_postal_code', '').strip()
|
||||||
|
|
||||||
|
if not cardholder_name:
|
||||||
|
return {'success': False, 'error': 'Cardholder name is required.'}
|
||||||
|
if not nonce:
|
||||||
|
return {'success': False, 'error': 'Card authorization is required.'}
|
||||||
|
if not billing_postal_code:
|
||||||
|
return {'success': False, 'error': 'Billing postal/zip code is required.'}
|
||||||
|
|
||||||
|
full_billing = billing_address
|
||||||
|
if billing_city:
|
||||||
|
full_billing += f", {billing_city}"
|
||||||
|
if billing_state:
|
||||||
|
full_billing += f", {billing_state}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_token = self._tokenize_nonce_via_poynt(
|
||||||
|
order, nonce, billing_postal_code,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
"Card reauthorization tokenization failed for %s: %s",
|
||||||
|
order.name, e,
|
||||||
|
)
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
old_token = order.rental_payment_token_id
|
||||||
|
order.write({
|
||||||
|
'rental_payment_token_id': payment_token.id,
|
||||||
|
'rental_billing_address': full_billing,
|
||||||
|
'rental_billing_postal_code': billing_postal_code,
|
||||||
|
})
|
||||||
|
|
||||||
|
if old_token:
|
||||||
|
try:
|
||||||
|
old_token.active = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
order.message_post(body=_(
|
||||||
|
"Card on file updated by %s. New card: %s",
|
||||||
|
cardholder_name,
|
||||||
|
payment_token.payment_details or 'Card',
|
||||||
|
))
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._send_card_reauthorization_confirmation(order, payment_token, cardholder_name)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
"Card reauth confirmation email failed for %s: %s",
|
||||||
|
order.name, e,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': 'Card authorized successfully. Thank you!',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _send_card_reauthorization_confirmation(self, order, token, cardholder_name):
|
||||||
|
"""Send confirmation email with card authorization details."""
|
||||||
|
template = request.env.ref(
|
||||||
|
'fusion_rental.mail_template_rental_card_reauth_confirmation',
|
||||||
|
raise_if_not_found=False,
|
||||||
|
)
|
||||||
|
if template:
|
||||||
|
template.sudo().with_context(
|
||||||
|
cardholder_name=cardholder_name,
|
||||||
|
card_details=token.payment_details or 'Card',
|
||||||
|
).send_mail(order.id, force_send=True)
|
||||||
|
|
||||||
# =================================================================
|
# =================================================================
|
||||||
# Purchase Interest (from marketing email)
|
# Purchase Interest (from marketing email)
|
||||||
# =================================================================
|
# =================================================================
|
||||||
|
|||||||
@@ -1,41 +1,36 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!--
|
|
||||||
All crons ship INACTIVE to avoid processing existing data on install.
|
|
||||||
Activate from Settings > Technical > Scheduled Actions when ready.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<record id="ir_cron_rental_auto_renewal" model="ir.cron">
|
<record id="ir_cron_rental_auto_renewal" model="ir.cron">
|
||||||
<field name="name">Rental: Auto-Renewal</field>
|
<field name="name">Rental: Auto-Renewal (every 2h for short-term grace)</field>
|
||||||
<field name="model_id" ref="sale.model_sale_order"/>
|
<field name="model_id" ref="sale.model_sale_order"/>
|
||||||
<field name="state">code</field>
|
<field name="state">code</field>
|
||||||
<field name="code">model._cron_rental_auto_renewals()</field>
|
<field name="code">model._cron_rental_auto_renewals()</field>
|
||||||
<field name="interval_number">1</field>
|
<field name="interval_number">2</field>
|
||||||
<field name="interval_type">days</field>
|
<field name="interval_type">hours</field>
|
||||||
<field name="active">False</field>
|
<field name="active">True</field>
|
||||||
<field name="priority">5</field>
|
<field name="priority">5</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="ir_cron_rental_renewal_reminder" model="ir.cron">
|
<record id="ir_cron_rental_renewal_reminder" model="ir.cron">
|
||||||
<field name="name">Rental: Renewal Reminders (3 Days Before)</field>
|
<field name="name">Rental: Renewal Reminders (%-based)</field>
|
||||||
<field name="model_id" ref="sale.model_sale_order"/>
|
<field name="model_id" ref="sale.model_sale_order"/>
|
||||||
<field name="state">code</field>
|
<field name="state">code</field>
|
||||||
<field name="code">model._cron_rental_renewal_reminders()</field>
|
<field name="code">model._cron_rental_renewal_reminders()</field>
|
||||||
<field name="interval_number">1</field>
|
<field name="interval_number">1</field>
|
||||||
<field name="interval_type">days</field>
|
<field name="interval_type">days</field>
|
||||||
<field name="active">False</field>
|
<field name="active">True</field>
|
||||||
<field name="priority">10</field>
|
<field name="priority">10</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="ir_cron_rental_marketing_email" model="ir.cron">
|
<record id="ir_cron_rental_marketing_email" model="ir.cron">
|
||||||
<field name="name">Rental: Day-7 Purchase Marketing Email</field>
|
<field name="name">Rental: Purchase Marketing Email (%-based)</field>
|
||||||
<field name="model_id" ref="sale.model_sale_order"/>
|
<field name="model_id" ref="sale.model_sale_order"/>
|
||||||
<field name="state">code</field>
|
<field name="state">code</field>
|
||||||
<field name="code">model._cron_rental_marketing_emails()</field>
|
<field name="code">model._cron_rental_marketing_emails()</field>
|
||||||
<field name="interval_number">1</field>
|
<field name="interval_number">1</field>
|
||||||
<field name="interval_type">days</field>
|
<field name="interval_type">days</field>
|
||||||
<field name="active">False</field>
|
<field name="active">True</field>
|
||||||
<field name="priority">15</field>
|
<field name="priority">15</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
@@ -46,7 +41,7 @@
|
|||||||
<field name="code">model._cron_rental_deposit_refunds()</field>
|
<field name="code">model._cron_rental_deposit_refunds()</field>
|
||||||
<field name="interval_number">1</field>
|
<field name="interval_number">1</field>
|
||||||
<field name="interval_type">days</field>
|
<field name="interval_type">days</field>
|
||||||
<field name="active">False</field>
|
<field name="active">True</field>
|
||||||
<field name="priority">5</field>
|
<field name="priority">5</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,10 @@ class RentalCancellationRequest(models.Model):
|
|||||||
def action_confirm(self):
|
def action_confirm(self):
|
||||||
"""Confirm the cancellation and stop auto-renewal."""
|
"""Confirm the cancellation and stop auto-renewal."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
self.order_id.write({'rental_auto_renew': False})
|
self.order_id.write({
|
||||||
|
'rental_auto_renew': False,
|
||||||
|
'rental_auto_renew_off_reason': f"Customer requested cancellation ({self.reason or 'No reason provided'})",
|
||||||
|
})
|
||||||
self.write({'state': 'confirmed'})
|
self.write({'state': 'confirmed'})
|
||||||
self._schedule_pickup_activity()
|
self._schedule_pickup_activity()
|
||||||
self._send_cancellation_confirmation()
|
self._send_cancellation_confirmation()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class RentalRenewalLog(models.Model):
|
class RentalRenewalLog(models.Model):
|
||||||
@@ -67,6 +67,7 @@ class RentalRenewalLog(models.Model):
|
|||||||
)
|
)
|
||||||
notes = fields.Text(string="Notes")
|
notes = fields.Text(string="Notes")
|
||||||
|
|
||||||
|
@api.depends('order_id', 'renewal_number')
|
||||||
def _compute_display_name(self):
|
def _compute_display_name(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.display_name = (
|
rec.display_name = (
|
||||||
|
|||||||
@@ -17,3 +17,43 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
help="Number of days to hold the security deposit after pickup before "
|
help="Number of days to hold the security deposit after pickup before "
|
||||||
"processing the refund. Default is 3 days.",
|
"processing the refund. Default is 3 days.",
|
||||||
)
|
)
|
||||||
|
rental_google_maps_api_key = fields.Char(
|
||||||
|
string="Google Maps API Key",
|
||||||
|
config_parameter='fusion_rental.google_maps_api_key',
|
||||||
|
help="API key for Google Places address autocomplete on the rental "
|
||||||
|
"agreement form. If Fusion Claims is installed, its API key is "
|
||||||
|
"used automatically and this field can be left blank.",
|
||||||
|
)
|
||||||
|
|
||||||
|
rental_marketing_email_pct = fields.Integer(
|
||||||
|
string="Marketing Email Timing (%)",
|
||||||
|
config_parameter='fusion_rental.marketing_email_pct',
|
||||||
|
default=23,
|
||||||
|
help="Percentage of rental period after which the purchase marketing "
|
||||||
|
"email is sent. Default 23% (7 days on a 30-day rental). "
|
||||||
|
"Minimum effective offset is 1 day.",
|
||||||
|
)
|
||||||
|
rental_renewal_reminder_pct = fields.Integer(
|
||||||
|
string="Renewal Reminder Timing (%)",
|
||||||
|
config_parameter='fusion_rental.renewal_reminder_pct',
|
||||||
|
default=10,
|
||||||
|
help="Percentage of rental period before renewal date at which the "
|
||||||
|
"reminder is sent. Default 10% (3 days on a 30-day rental). "
|
||||||
|
"Minimum effective offset is 1 day.",
|
||||||
|
)
|
||||||
|
rental_short_term_threshold_days = fields.Integer(
|
||||||
|
string="Short-Term Threshold (Days)",
|
||||||
|
config_parameter='fusion_rental.short_term_threshold_days',
|
||||||
|
default=3,
|
||||||
|
help="Rentals shorter than this duration (in days) are treated as "
|
||||||
|
"short-term. Auto-renewal is delayed until the return window "
|
||||||
|
"plus grace period has passed.",
|
||||||
|
)
|
||||||
|
rental_short_term_grace_hours = fields.Integer(
|
||||||
|
string="Short-Term Grace Period (Hours)",
|
||||||
|
config_parameter='fusion_rental.short_term_grace_hours',
|
||||||
|
default=1,
|
||||||
|
help="Hours after the scheduled return time before auto-renewal "
|
||||||
|
"kicks in for short-term rentals. Gives the customer time to "
|
||||||
|
"return without being charged.",
|
||||||
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!--
|
<!--
|
||||||
Fusion Rental Enhancement
|
Fusion Rental
|
||||||
License OPL-1 (Odoo Proprietary License v1.0)
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
Rental Agreement Document - Compact 2-Page Layout
|
Rental Agreement Document - Compact 2-Page Layout
|
||||||
-->
|
-->
|
||||||
@@ -14,11 +14,11 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.fc-rental { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
|
.fc-rental { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
|
||||||
.fc-rental h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
|
.fc-rental h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 8px 0; }
|
||||||
.fc-rental h2 { color: #0066a1; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
|
.fc-rental h2 { color: #0066a1; font-size: 8.5pt; margin: 6px 0 2px 0; font-weight: bold; }
|
||||||
.fc-rental p { margin: 2px 0; text-align: justify; }
|
.fc-rental p { margin: 2px 0; text-align: justify; }
|
||||||
.fc-rental .parties { font-size: 8pt; margin-bottom: 8px; }
|
.fc-rental .parties { font-size: 8pt; margin-bottom: 6px; }
|
||||||
.fc-rental .intro { margin-bottom: 8px; font-size: 8pt; }
|
.fc-rental .intro { margin-bottom: 6px; font-size: 8pt; }
|
||||||
.fc-rental table { width: 100%; border-collapse: collapse; }
|
.fc-rental table { width: 100%; border-collapse: collapse; }
|
||||||
.fc-rental table.bordered, .fc-rental table.bordered th, .fc-rental table.bordered td { border: 1px solid #000; }
|
.fc-rental table.bordered, .fc-rental table.bordered th, .fc-rental table.bordered td { border: 1px solid #000; }
|
||||||
.fc-rental th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; font-size: 8pt; }
|
.fc-rental th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; font-size: 8pt; }
|
||||||
@@ -27,21 +27,20 @@
|
|||||||
.fc-rental .text-right { text-align: right; }
|
.fc-rental .text-right { text-align: right; }
|
||||||
.fc-rental .info-header { background-color: #f5f5f5; color: #333; font-weight: bold; }
|
.fc-rental .info-header { background-color: #f5f5f5; color: #333; font-weight: bold; }
|
||||||
|
|
||||||
/* Two-column layout for terms */
|
/* Two-column layout for terms (table-based for wkhtmltopdf) */
|
||||||
.fc-rental .terms-container { column-count: 2; column-gap: 20px; margin-top: 10px; }
|
.fc-rental .terms-table { width: 100%; margin-top: 6px; }
|
||||||
.fc-rental .term-section { break-inside: avoid; margin-bottom: 8px; }
|
.fc-rental .terms-table td { width: 50%; vertical-align: top; padding: 4px 12px; }
|
||||||
|
.fc-rental .term-section { margin-bottom: 5px; }
|
||||||
|
|
||||||
/* Credit card section - 15% taller */
|
/* Credit card section */
|
||||||
.fc-rental .cc-section { margin-top: 12px; padding: 12px; border: 2px solid #0066a1; background-color: #f8f9fa; }
|
.fc-rental .cc-section { margin-top: 12px; padding: 12px; border: 2px solid #0066a1; background-color: #f8f9fa; }
|
||||||
.fc-rental .cc-title { font-size: 10pt; font-weight: bold; color: #0066a1; margin-bottom: 10px; text-align: center; }
|
.fc-rental .cc-title { font-size: 10pt; font-weight: bold; color: #0066a1; margin-bottom: 10px; text-align: center; }
|
||||||
.fc-rental .cc-box { border: 1px solid #000; display: inline-block; width: 21px; height: 21px; text-align: center; background: white; }
|
|
||||||
.fc-rental .authorization-text { font-size: 7pt; margin-top: 10px; font-style: italic; }
|
.fc-rental .authorization-text { font-size: 7pt; margin-top: 10px; font-style: italic; }
|
||||||
|
|
||||||
/* Signature - 40% taller */
|
/* Signature */
|
||||||
.fc-rental .signature-section { margin-top: 15px; }
|
.fc-rental .signature-section { margin-top: 15px; }
|
||||||
.fc-rental .signature-box { border: 1px solid #000; padding: 12px; }
|
|
||||||
.fc-rental .signature-line { border-bottom: 1px solid #000; min-height: 35px; margin-bottom: 5px; }
|
.fc-rental .signature-line { border-bottom: 1px solid #000; min-height: 35px; margin-bottom: 5px; }
|
||||||
.fc-rental .signature-label { font-size: 7pt; color: #666; }
|
.fc-rental .signature-label { font-size: 7pt; color: #666; text-transform: uppercase; }
|
||||||
|
|
||||||
.fc-rental .text-end { text-align: right; }
|
.fc-rental .text-end { text-align: right; }
|
||||||
</style>
|
</style>
|
||||||
@@ -66,179 +65,184 @@
|
|||||||
<p><t t-esc="company.name"/> rents to the Renter medical equipment (hospital beds, patient lifts, trapeze, over-bed tables, mobility scooters, electric wheelchairs, manual wheelchairs, stairlifts, ceiling lifts and lift chairs) subject to the terms and conditions set forth in this Rental Agreement.</p>
|
<p><t t-esc="company.name"/> rents to the Renter medical equipment (hospital beds, patient lifts, trapeze, over-bed tables, mobility scooters, electric wheelchairs, manual wheelchairs, stairlifts, ceiling lifts and lift chairs) subject to the terms and conditions set forth in this Rental Agreement.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terms and Conditions in Two Columns -->
|
|
||||||
<div class="terms-container">
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>1. Ownership and Condition of Equipment</h2>
|
|
||||||
<p>The medical equipment is the property of <t t-esc="company.name"/> and is provided in good condition. The Renter shall return the equipment in the same condition as when received, subject to normal wear and tear. <t t-esc="company.name"/> reserves the right to inspect the equipment upon its return and may repossess it without prior notice if it is being used in violation of this agreement.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>2. Cancellation Policy</h2>
|
|
||||||
<p>The Renter may cancel the order before delivery and will be charged twenty-five percent (25%) of the total rental cost. If the order is canceled during the rental period after delivery, no refund will be provided.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>3. Security Deposit</h2>
|
|
||||||
<p>The security deposit will be returned after an inspection of the equipment. If the equipment has any damage, the cost of repairs will be deducted from the security deposit. If the security deposit is insufficient to cover the damages, the credit card on file will be charged for the remaining amount. Security deposit refunds may take 4 to 15 business days to process. <t t-esc="company.name"/> is not responsible for delays caused by the Renter's financial institution.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>4. Liability for Loss or Damage</h2>
|
|
||||||
<p><t t-esc="company.name"/> shall not be liable for any loss of or damage to property left, lost, damaged, stolen, stored, or transported by the Renter or any other person using the medical equipment. The Renter assumes all risks associated with such loss or damage and waives any claims against <t t-esc="company.name"/>. The Renter agrees to defend, indemnify, and hold <t t-esc="company.name"/> harmless against all claims arising from such loss or damage.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>5. Risk and Liability</h2>
|
|
||||||
<p>The Renter assumes all risk and liability for any loss, damage, injury, or death resulting from the use or operation of the medical equipment. <t t-esc="company.name"/> is not responsible for any acts or omissions of the Renter or the Renter's agents, servants, or employees.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>6. Renter Responsibilities</h2>
|
|
||||||
<p>The Renter is responsible for the full cost of replacement for any damage, loss, theft, or destruction of the medical equipment. <t t-esc="company.name"/> may charge the Renter's credit card for repair or replacement costs as deemed necessary. The equipment must not be used by individuals under the age of 18, under the influence of intoxicants or narcotics, or in an unsafe manner.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>7. Indemnification</h2>
|
|
||||||
<p>The Renter shall indemnify, defend, and hold harmless <t t-esc="company.name"/>, its agents, officers, and employees, from any claims, demands, actions, or causes of action arising from the use or operation of the medical equipment, except where caused by <t t-esc="company.name"/>'s gross negligence or willful misconduct.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>8. Accident Notification</h2>
|
|
||||||
<p>The Renter must immediately notify <t t-esc="company.name"/> of any accidents, damages, or incidents involving the medical equipment.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>9. Costs and Expenses</h2>
|
|
||||||
<p>The Renter agrees to cover all costs, expenses, and attorney's fees incurred by <t t-esc="company.name"/> in collecting overdue payments, recovering possession of the equipment, or enforcing claims for damage or loss.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>10. Independent Status</h2>
|
|
||||||
<p>The Renter or any driver of the equipment shall not be considered an agent or employee of <t t-esc="company.name"/>.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>11. Binding Obligations</h2>
|
|
||||||
<p>Any individual signing this agreement on behalf of a corporation or other entity shall be personally liable for all obligations under this agreement. This agreement is binding upon the heirs, executors, administrators, and assigns of the Renter.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>12. Refusal of Service</h2>
|
|
||||||
<p><t t-esc="company.name"/> reserves the right to refuse rental to any individual or entity at its sole discretion.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>13. Governing Law</h2>
|
|
||||||
<p>This Agreement shall be governed by and construed in accordance with the laws of the jurisdiction in which <t t-esc="company.name"/> operates.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="term-section">
|
|
||||||
<h2>14. Entire Agreement</h2>
|
|
||||||
<p>This Agreement constitutes the entire understanding between the parties concerning the rental of medical equipment and supersedes all prior agreements, representations, or understandings, whether written or oral.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- PAGE 2: RENTAL DETAILS, PAYMENT, AND SIGNATURE -->
|
<!-- TERMS - PAGE 1 (two columns) -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
|
<table class="terms-table" style="border: none;">
|
||||||
<div style="page-break-before: always;"></div>
|
|
||||||
|
|
||||||
<h1>RENTAL DETAILS</h1>
|
|
||||||
|
|
||||||
<!-- Customer Info and Rental Period Side by Side -->
|
|
||||||
<table style="width: 100%; margin-bottom: 10px;">
|
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width: 50%; vertical-align: top; padding-right: 10px;">
|
<td style="border: none;">
|
||||||
<table class="bordered" style="width: 100%;">
|
<div class="term-section">
|
||||||
<tr>
|
<h2>1. Ownership & Condition of Equipment</h2>
|
||||||
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTER INFORMATION</th>
|
<p>All equipment remains the sole property of <t t-esc="company.name"/> and is provided in good, sanitized, working condition. The Renter shall not modify, alter, disassemble, relocate, or permit any third party to service or repair the equipment. The Renter shall not sublease, lend, or transfer the equipment to any other person or location without prior written consent from <t t-esc="company.name"/>. <t t-esc="company.name"/> may repossess equipment without notice if used in violation of this agreement.</p>
|
||||||
</tr>
|
</div>
|
||||||
<tr>
|
<div class="term-section">
|
||||||
<td style="width: 35%; font-weight: bold; background-color: #f5f5f5;">Name</td>
|
<h2>2. Safe Use & Renter Responsibilities</h2>
|
||||||
<td><t t-esc="doc.partner_id.name"/></td>
|
<p>The Renter shall use equipment strictly in accordance with the manufacturer's instructions, rated weight capacity, and any guidelines provided by <t t-esc="company.name"/> at the time of delivery. Equipment must only be used for its intended purpose. The Renter shall not permit use by individuals under 18, persons under the influence of alcohol or narcotics, or in any unsafe manner. The Renter must immediately notify <t t-esc="company.name"/> of any malfunction, accident, damage, or incident. Failure to report damage promptly may result in additional liability.</p>
|
||||||
</tr>
|
</div>
|
||||||
<tr>
|
<div class="term-section">
|
||||||
<td style="font-weight: bold; background-color: #f5f5f5;">Address</td>
|
<h2>3. Battery Safety & Maintenance</h2>
|
||||||
<td>
|
<p>For equipment with batteries (power wheelchairs, mobility scooters, patient lifts): (a) The Renter must use ONLY the charger provided by <t t-esc="company.name"/>. Use of any third-party charger is strictly prohibited and may result in equipment damage, fire hazard, and full replacement charges. (b) If the provided charger is lost or damaged, the Renter must notify <t t-esc="company.name"/> immediately and will be charged for a replacement. (c) Batteries must not be routinely discharged below 30% capacity. (d) Batteries must not be left on the charger for extended, unattended periods beyond a full charge. (e) Equipment must not be used in a manner that causes excessive battery overheating. (f) Upon return, all batteries must be charged to at least 80%. Equipment returned with batteries below 80% will incur a conditioning and service charge. (g) The Renter assumes all risk for battery misuse, including but not limited to overcharging, deep discharge, or use of unauthorized charging equipment.</p>
|
||||||
<div t-field="doc.partner_shipping_id"
|
</div>
|
||||||
t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
|
<div class="term-section">
|
||||||
</td>
|
<h2>4. Cleaning, Sanitation & Return Condition</h2>
|
||||||
</tr>
|
<p>The Renter is responsible for keeping all equipment clean and sanitary throughout the rental period. Prior to return, all equipment must be thoroughly cleaned and free of bodily fluids, stains, food residue, pet hair, and debris. <strong>Specific requirements by equipment type:</strong></p>
|
||||||
<tr>
|
<p><strong>Mattresses & Cushions:</strong> Mattresses must be kept clean and dry at all times. If a mattress is soiled with urine, vomit, blood, or any liquid that penetrates the cover, the mattress is deemed unrecoverable and the Renter will be charged the full replacement cost. Use of a waterproof mattress protector is strongly recommended. Wheelchair seat cushion covers must be washed (cold water, no heat drying) prior to return.</p>
|
||||||
<td style="font-weight: bold; background-color: #f5f5f5;">Phone</td>
|
<p><strong>Wheelchairs & Scooters:</strong> All surfaces must be wiped clean. Any liquid spills must be cleaned immediately. Missing parts, accessories, or hardware will result in replacement charges for both the parts and any associated labour.</p>
|
||||||
<td><t t-esc="doc.partner_id.phone or doc.partner_id.mobile or ''"/></td>
|
<p><strong>Patient Lift Slings:</strong> Reusable slings must be washed before return. <t t-esc="company.name"/> does not rent hygiene slings. If a reusable sling is used for toileting or incontinence purposes, the Renter must purchase the sling at full cost and it will not be accepted for return.</p>
|
||||||
</tr>
|
<p>If equipment is returned unclean, a cleaning fee will be assessed and deducted from the security deposit or charged to the card on file. If the item is deemed unrecoverable due to contamination, the full replacement cost applies. <t t-esc="company.name"/> has sole discretion in determining cleaning and replacement charges.</p>
|
||||||
<tr>
|
</div>
|
||||||
<td style="font-weight: bold; background-color: #f5f5f5;">Order Ref</td>
|
|
||||||
<td><t t-esc="doc.name"/></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
<td style="width: 50%; vertical-align: top; padding-left: 10px;">
|
<td style="border: none;">
|
||||||
<table class="bordered" style="width: 100%;">
|
<div class="term-section">
|
||||||
<tr>
|
<h2>5. Delivery, Installation & Site Preparation</h2>
|
||||||
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTAL PERIOD</th>
|
<p>Delivery, installation, and pickup fees are one-time, non-refundable charges. The Renter is responsible for ensuring the following before the technician arrives:</p>
|
||||||
</tr>
|
<p>(a) <strong>Hospital Beds:</strong> The designated room must be cleared of existing furniture to allow adequate space for installation. There must be sufficient clearance for the bed frame, side rails, and technician access on all sides. (b) <strong>All Equipment:</strong> Walkways and entry paths must be clear of obstacles, ice, snow, and tripping hazards. Pets must be secured in a separate room during delivery and pickup. (c) There must be adequate doorway width and turning space for the equipment to pass through.</p>
|
||||||
<tr>
|
<p>If the technician is unable to complete delivery or pickup due to site conditions not meeting these requirements, the visit will be rescheduled and the Renter will be charged fifty percent (50%) of the original delivery fee for each additional visit.</p>
|
||||||
<td style="width: 40%; font-weight: bold; background-color: #f5f5f5;">Start Date</td>
|
</div>
|
||||||
<td>
|
<div class="term-section">
|
||||||
<t t-if="doc.rental_start_date">
|
<h2>6. Pickup & Return</h2>
|
||||||
<span t-field="doc.rental_start_date" t-options="{'widget': 'date'}"/>
|
<p>Upon cancellation or end of rental, <t t-esc="company.name"/> will schedule equipment pickup. The Renter must make all equipment and accessories available at the scheduled time. If the Renter denies or is unavailable for a scheduled pickup, a rebooking fee of fifty percent (50%) of the delivery fee will be charged for each subsequent visit. The Renter must ensure there is adequate room and clear pathways for safe removal of the equipment.</p>
|
||||||
</t>
|
</div>
|
||||||
<t t-else=""><span style="color: #999;">Not specified</span></t>
|
<div class="term-section">
|
||||||
</td>
|
<h2>7. Return Inspection</h2>
|
||||||
</tr>
|
<p>A technician will inspect equipment on-site at pickup for damage, cleanliness, completeness (including all accessories, chargers, remotes, rails, and hardware), and battery charge level. If the equipment is in good condition, the deposit refund process begins. If damage, missing parts, contamination, or unsanitary conditions are identified, the order is flagged for internal review, the Renter is notified, and the deposit is held until the matter is resolved. Both parts and labour will be charged for any missing or damaged components.</p>
|
||||||
<tr>
|
</div>
|
||||||
<td style="font-weight: bold; background-color: #f5f5f5;">Return Date</td>
|
<div class="term-section">
|
||||||
<td>
|
<h2>8. Security Deposit</h2>
|
||||||
<t t-if="doc.rental_return_date">
|
<p>A refundable security deposit is collected at the start of the rental. After return and inspection, the deposit is held for a review period (typically three business days). If the equipment is returned in good, clean condition with all accessories, the full deposit is refunded to the original card. Deductions may be applied for: damage, excessive wear, missing parts, cleaning charges, battery conditioning, and replacement costs. If total charges exceed the deposit, the remaining balance is charged to the card on file and an invoice is issued. Deposit refunds take 1 to 3 business days to process; bank posting times may vary. <t t-esc="company.name"/> is not responsible for delays by the Renter's financial institution.</p>
|
||||||
<span t-field="doc.rental_return_date" t-options="{'widget': 'date'}"/>
|
</div>
|
||||||
</t>
|
<div class="term-section">
|
||||||
<t t-else=""><span style="color: #999;">Not specified</span></t>
|
<h2>9. Automatic Renewal & Recurring Payments</h2>
|
||||||
</td>
|
<p>This rental renews automatically at the end of each rental period for the same duration and rate unless the Renter cancels. The card on file will be charged automatically on each renewal date. A renewal reminder will be sent prior to the renewal date; the timing is proportional to the rental period (e.g. approximately three days before a 30-day rental, or same-day for a short-term rental). To cancel, the Renter must submit a cancellation request before the renewal date. Cancellation requests received on or after the renewal date will not be processed until the following cycle. For short-term rentals (typically under three days), a grace period applies: the auto-renewal charge is deferred for a brief window after the scheduled return time, allowing the Renter to return the equipment without incurring an additional renewal charge. If the equipment is not returned within the grace period, the renewal is processed automatically.</p>
|
||||||
</tr>
|
</div>
|
||||||
<tr>
|
<div class="term-section">
|
||||||
<td style="font-weight: bold; background-color: #f5f5f5;">Duration</td>
|
<h2>10. Cancellation & Early Return</h2>
|
||||||
<td>
|
<p>The Renter may cancel before delivery with a 25% restocking fee. Once delivered, the rental charge for the current period is non-refundable. To stop future renewals, the Renter must cancel at least one (1) day before the next renewal date. Upon cancellation, equipment pickup will be scheduled by <t t-esc="company.name"/>.</p>
|
||||||
<t t-if="doc.duration_days">
|
</div>
|
||||||
<span t-esc="doc.duration_days"/> Day<t t-if="doc.duration_days != 1">s</t>
|
|
||||||
<t t-if="doc.remaining_hours and doc.remaining_hours > 0">
|
|
||||||
, <t t-esc="doc.remaining_hours"/> Hr<t t-if="doc.remaining_hours != 1">s</t>
|
|
||||||
</t>
|
|
||||||
</t>
|
|
||||||
<t t-elif="doc.rental_start_date and doc.rental_return_date"><span>Less than 1 day</span></t>
|
|
||||||
<t t-else=""><span style="color: #999;">Not specified</span></t>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="font-weight: bold; background-color: #f5f5f5;">Total Amount</td>
|
|
||||||
<td><strong><span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></strong></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- TERMS - PAGE 2 (two columns, continued) -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div style="page-break-before: always;"></div>
|
||||||
|
<h1>RENTAL AGREEMENT <span style="font-size: 10pt;">(continued)</span></h1>
|
||||||
|
|
||||||
|
<table class="terms-table" style="border: none;">
|
||||||
|
<tr>
|
||||||
|
<td style="border: none;">
|
||||||
|
<div class="term-section">
|
||||||
|
<h2>11. Card on File & Payment Authorization</h2>
|
||||||
|
<p>By signing this agreement, the Renter authorizes <t t-esc="company.name"/> to securely store payment card details using industry-standard tokenization. The card on file will be used for: initial rental charges, security deposit collection, automatic renewal payments, damage charges, cleaning fees, missing parts charges, battery conditioning fees, rebooking fees, overage invoices, and deposit refunds. <t t-esc="company.name"/> does not store full card numbers. All card data is purged upon rental completion.</p>
|
||||||
|
</div>
|
||||||
|
<div class="term-section">
|
||||||
|
<h2>12. Liability, Risk & Medical Disclaimer</h2>
|
||||||
|
<p>The Renter assumes all risk and liability for any loss, damage, injury, illness, or death arising from the use, storage, or possession of the equipment. The equipment is provided as a mobility or comfort aid only and is <strong>NOT a medical device</strong>, nor a substitute for professional medical advice, diagnosis, or treatment. <t t-esc="company.name"/> makes no representations or warranties regarding the equipment's suitability for any specific medical condition. The Renter confirms they have independently assessed the equipment's suitability for their needs or the needs of the intended user. <t t-esc="company.name"/> is not liable for any property left, stored, or transported with the equipment, nor for any indirect, incidental, special, or consequential damages of any kind. The Renter is responsible for the full replacement cost of any equipment that is damaged, lost, stolen, or destroyed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="term-section">
|
||||||
|
<h2>13. Indemnification</h2>
|
||||||
|
<p>The Renter shall indemnify, defend, and hold harmless <t t-esc="company.name"/>, its owners, directors, agents, officers, employees, contractors, and technicians from any and all claims, demands, liabilities, damages, injuries, losses, and expenses (including legal fees and court costs) arising from the Renter's use, possession, maintenance, or return of the equipment, or from any third party's use of the equipment while in the Renter's possession, except where caused solely by <t t-esc="company.name"/>'s gross negligence.</p>
|
||||||
|
</div>
|
||||||
|
<div class="term-section">
|
||||||
|
<h2>14. Purchase Option</h2>
|
||||||
|
<p>During the rental period, <t t-esc="company.name"/> may offer the Renter an option to purchase the rented or equivalent new equipment at a discounted price. If accepted, the purchase is subject to internal approval by <t t-esc="company.name"/>. The security deposit is always refunded in full and is never applied toward the purchase price. Delivery of the purchased item and pickup of the rental equipment will be coordinated by <t t-esc="company.name"/>.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="border: none;">
|
||||||
|
<div class="term-section">
|
||||||
|
<h2>15. Communication Consent</h2>
|
||||||
|
<p>The Renter consents to receive automated communications from <t t-esc="company.name"/> related to this rental, including but not limited to: renewal reminders, payment confirmations, invoices, delivery and pickup notifications, inspection results, deposit status updates, and promotional offers, via email and SMS to the contact information on file.</p>
|
||||||
|
</div>
|
||||||
|
<div class="term-section">
|
||||||
|
<h2>16. Costs & Expenses</h2>
|
||||||
|
<p>The Renter agrees to cover all costs, expenses, and legal fees incurred by <t t-esc="company.name"/> in collecting overdue payments, recovering equipment, enforcing claims for damage, loss, or contamination, or otherwise enforcing the terms of this agreement.</p>
|
||||||
|
</div>
|
||||||
|
<div class="term-section">
|
||||||
|
<h2>17. General Provisions</h2>
|
||||||
|
<p>The Renter is not an agent or employee of <t t-esc="company.name"/>. Any individual signing this agreement on behalf of a corporation, trust, or other entity shall be personally liable for all obligations under this agreement. This agreement is binding upon the heirs, executors, administrators, and assigns of the Renter. <t t-esc="company.name"/> reserves the right to refuse rental to any individual or entity at its sole discretion. <t t-esc="company.name"/> shall not be liable for any delays or failures in performance caused by events beyond its reasonable control, including but not limited to natural disasters, pandemics, supplier delays, or government orders. If any provision of this agreement is found to be unenforceable, the remaining provisions shall remain in full force and effect. This Agreement is governed by and construed in accordance with the laws of the Province of Ontario, Canada, and constitutes the entire understanding between the parties, superseding all prior agreements, representations, or understandings, whether written or oral.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- PAGE 3: RENTAL DETAILS, PAYMENT, AND SIGNATURE -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div style="page-break-before: always;"></div>
|
||||||
|
|
||||||
|
<h1>RENTAL DETAILS</h1>
|
||||||
|
|
||||||
|
<!-- Renter & Rental Info - Two Column Table with Rows -->
|
||||||
|
<table class="bordered" style="margin-bottom: 10px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 18%;">FIELD</th>
|
||||||
|
<th style="width: 32%;">DETAILS</th>
|
||||||
|
<th style="width: 18%;">FIELD</th>
|
||||||
|
<th style="width: 32%;">DETAILS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight: bold; background-color: #f5f5f5;">Name</td>
|
||||||
|
<td><t t-esc="doc.partner_id.name"/></td>
|
||||||
|
<td style="font-weight: bold; background-color: #f5f5f5;">Order Ref</td>
|
||||||
|
<td><t t-esc="doc.name"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight: bold; background-color: #f5f5f5;">Phone</td>
|
||||||
|
<td><t t-esc="doc.partner_id.phone or doc.partner_id.mobile or ''"/></td>
|
||||||
|
<td style="font-weight: bold; background-color: #f5f5f5;">Start Date</td>
|
||||||
|
<td>
|
||||||
|
<t t-if="doc.rental_start_date"><span t-field="doc.rental_start_date" t-options="{'widget': 'date'}"/></t>
|
||||||
|
<t t-else="">-</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight: bold; background-color: #f5f5f5;">Address</td>
|
||||||
|
<td>
|
||||||
|
<div t-field="doc.partner_shipping_id"
|
||||||
|
t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
|
||||||
|
</td>
|
||||||
|
<td style="font-weight: bold; background-color: #f5f5f5;">Return Date</td>
|
||||||
|
<td>
|
||||||
|
<t t-if="doc.rental_return_date"><span t-field="doc.rental_return_date" t-options="{'widget': 'date'}"/></t>
|
||||||
|
<t t-else="">-</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight: bold; background-color: #f5f5f5;"></td>
|
||||||
|
<td></td>
|
||||||
|
<td style="font-weight: bold; background-color: #f5f5f5;">Duration</td>
|
||||||
|
<td>
|
||||||
|
<t t-if="doc.duration_days">
|
||||||
|
<span t-esc="doc.duration_days"/> Day<t t-if="doc.duration_days != 1">s</t>
|
||||||
|
<t t-if="doc.remaining_hours and doc.remaining_hours > 0">
|
||||||
|
, <t t-esc="doc.remaining_hours"/> Hr<t t-if="doc.remaining_hours != 1">s</t>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
<t t-elif="doc.rental_start_date and doc.rental_return_date">Less than 1 day</t>
|
||||||
|
<t t-else="">-</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<!-- Equipment / Order Lines Table -->
|
<!-- Equipment / Order Lines Table -->
|
||||||
<table class="bordered" style="margin-bottom: 10px;">
|
<table class="bordered" style="margin-bottom: 10px;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 35%;">DESCRIPTION</th>
|
<th style="width: 48%;">DESCRIPTION</th>
|
||||||
<th class="text-center" style="width: 8%;">QTY</th>
|
<th class="text-center" style="width: 7%;">QTY</th>
|
||||||
<th class="text-right" style="width: 17%;">UNIT PRICE</th>
|
<th class="text-right" style="width: 15%;">UNIT PRICE</th>
|
||||||
<th class="text-right" style="width: 20%;">TAXES</th>
|
<th class="text-right" style="width: 15%;">TAXES</th>
|
||||||
<th class="text-right" style="width: 20%;">TOTAL</th>
|
<th class="text-right" style="width: 15%;">TOTAL</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<t t-set="has_taxes" t-value="False"/>
|
|
||||||
<t t-foreach="doc.order_line" t-as="line">
|
<t t-foreach="doc.order_line" t-as="line">
|
||||||
<t t-if="not line.display_type">
|
<t t-if="not line.display_type">
|
||||||
<t t-if="line.tax_ids" t-set="has_taxes" t-value="True"/>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<t t-esc="line.product_id.name"/>
|
<t t-esc="line.product_id.name"/>
|
||||||
@@ -265,8 +269,8 @@
|
|||||||
</t>
|
</t>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Totals - right-aligned bordered table (matching ADP style) -->
|
<!-- Totals - right-aligned bordered table -->
|
||||||
<div style="text-align: right; margin-bottom: 10px;">
|
<div style="text-align: right; margin-bottom: 10px;">
|
||||||
<table class="bordered" style="width: auto; margin-left: auto;">
|
<table class="bordered" style="width: auto; margin-left: auto;">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -283,99 +287,98 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Credit Card Authorization - Compact -->
|
<!-- Credit Card Authorization - outer border only, row lines inside -->
|
||||||
|
<t t-set="deposit_lines" t-value="doc.order_line.filtered(lambda l: l.is_security_deposit)"/>
|
||||||
|
<t t-set="rental_charge_total" t-value="doc.amount_total - sum(deposit_lines.mapped('price_total'))"/>
|
||||||
<div class="cc-section">
|
<div class="cc-section">
|
||||||
<div class="cc-title">CREDIT CARD PAYMENT AUTHORIZATION</div>
|
<div class="cc-title">CREDIT CARD PAYMENT AUTHORIZATION</div>
|
||||||
|
|
||||||
<table style="width: 100%; border: none;">
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width: 20%; padding: 5px 4px; border: none;"><strong>Card #:</strong></td>
|
<td style="width: 18%; padding: 6px 8px; border: none; border-bottom: 1px solid #ccc;"><strong>Card #:</strong></td>
|
||||||
<td style="padding: 5px 4px; border: none;">
|
<td style="padding: 6px 8px; border: none; border-bottom: 1px solid #ccc;">
|
||||||
<t t-if="doc.rental_payment_token_id">
|
<span style="font-size: 12px; letter-spacing: 1px;">**** **** **** <t t-out="doc._get_card_last_four() or '____'">1234</t></span>
|
||||||
<span style="font-size: 14px;">**** **** **** <t t-out="doc._get_card_last_four() or '****'">1234</t></span>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 8px; border: none; border-bottom: 1px solid #ccc;"><strong>Exp Date:</strong></td>
|
||||||
|
<td style="padding: 6px 8px; border: none; border-bottom: 1px solid #ccc;">
|
||||||
|
<span style="font-size: 12px;">**/**</span>
|
||||||
|
<span style="margin-left: 25px;"><strong>CVV:</strong> ***</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 8px; border: none; border-bottom: 1px solid #ccc;"><strong>Cardholder:</strong></td>
|
||||||
|
<td style="padding: 6px 8px; border: none; border-bottom: 1px solid #ccc;">
|
||||||
|
<t t-if="doc.rental_agreement_signer_name">
|
||||||
|
<span t-out="doc.rental_agreement_signer_name">Name</span>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
|
<span style="color: #999;">_________________________________</span>
|
||||||
<span style="margin: 0 3px;">-</span>
|
|
||||||
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
|
|
||||||
<span style="margin: 0 3px;">-</span>
|
|
||||||
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
|
|
||||||
<span style="margin: 0 3px;">-</span>
|
|
||||||
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
|
|
||||||
</t>
|
</t>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 5px 4px; border: none;"><strong>Exp Date:</strong></td>
|
<td style="padding: 6px 8px; border: none; border-bottom: 1px solid #ccc;"><strong>Billing Address:</strong></td>
|
||||||
<td style="padding: 5px 4px; border: none;">
|
<td style="padding: 6px 8px; border: none; border-bottom: 1px solid #ccc;">
|
||||||
<span class="cc-box"></span><span class="cc-box"></span>
|
<t t-if="doc.rental_billing_address">
|
||||||
<span style="margin: 0 2px;">/</span>
|
<span t-out="doc.rental_billing_address">Address</span>
|
||||||
<span class="cc-box"></span><span class="cc-box"></span>
|
<t t-if="doc.rental_billing_postal_code">, <span t-out="doc.rental_billing_postal_code">Postal</span></t>
|
||||||
<span style="margin-left: 20px;"><strong>CVV:</strong></span>
|
</t>
|
||||||
<span>***</span>
|
<t t-else="">
|
||||||
<t t-set="deposit_lines" t-value="doc.order_line.filtered(lambda l: l.is_security_deposit)"/>
|
<span style="color: #999;">_________________________________</span>
|
||||||
<span style="margin-left: 20px;"><strong>Security Deposit:</strong>
|
</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 6px 8px; border: none;"><strong>Rental Charges:</strong></td>
|
||||||
|
<td style="padding: 6px 8px; border: none;">
|
||||||
|
$<t t-out="'%.2f' % rental_charge_total">0.00</t>
|
||||||
|
<span style="margin-left: 40px;"><strong>Security Deposit:</strong>
|
||||||
<t t-if="deposit_lines">
|
<t t-if="deposit_lines">
|
||||||
$<t t-out="'%.2f' % sum(deposit_lines.mapped('price_unit'))">0.00</t>
|
$<t t-out="'%.2f' % sum(deposit_lines.mapped('price_total'))">0.00</t>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">$___________</t>
|
<t t-else="">$___________</t>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="authorization-text">
|
||||||
|
<p>I authorize <t t-esc="company.name"/> to charge the credit card indicated above for: (a) the initial rental charges and security deposit; (b) automatic recurring renewal payments on each renewal date until I cancel; (c) any damage, loss, or overage charges as described in the terms above; and (d) to process refunds to this card. I certify that I am an authorized user of this credit card and will not dispute legitimate charges. By signing this form, I acknowledge that I have read and agree to all terms and conditions of this Rental Agreement, including the automatic renewal, cancellation, security deposit, inspection, and communication provisions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signature Section - outer border only, three equal columns -->
|
||||||
|
<div class="signature-section">
|
||||||
|
<table style="width: 100%; border: 1px solid #000; border-collapse: collapse;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 5px 4px; border: none;"><strong>Cardholder:</strong></td>
|
<td style="width: 33.33%; padding: 10px 14px; border: none; height: 80px; vertical-align: top;">
|
||||||
<td style="padding: 5px 4px; border: none;">
|
<div class="signature-label">FULL NAME (PRINT)</div>
|
||||||
<t t-if="doc.rental_agreement_signer_name">
|
<t t-if="doc.rental_agreement_signer_name">
|
||||||
<span t-out="doc.rental_agreement_signer_name">Name</span>
|
<div style="min-height: 50px; font-size: 14px; padding-top: 8px;" t-out="doc.rental_agreement_signer_name">Name</div>
|
||||||
</t>
|
|
||||||
<t t-else="">
|
|
||||||
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%;"></div>
|
|
||||||
</t>
|
</t>
|
||||||
|
<t t-else=""><div style="border-bottom: 1px solid #000; min-height: 50px; margin-top: 20px;"></div></t>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
<td style="width: 33.33%; padding: 10px 14px; border: none; border-left: 1px solid #ccc; border-right: 1px solid #ccc; height: 80px; vertical-align: top;">
|
||||||
<tr>
|
<div class="signature-label">SIGNATURE</div>
|
||||||
<td colspan="2" style="padding: 5px 4px; border: none;">
|
<t t-if="doc.rental_agreement_signature">
|
||||||
<strong>Billing Address (if different):</strong>
|
<div style="min-height: 50px; padding-top: 8px;">
|
||||||
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%; margin-top: 4px;"></div>
|
<img t-att-src="'data:image/png;base64,' + doc.rental_agreement_signature.decode('utf-8') if doc.rental_agreement_signature else ''" style="max-height: 55px; max-width: 100%;"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else=""><div style="border-bottom: 1px solid #000; min-height: 50px; margin-top: 20px;"></div></t>
|
||||||
|
</td>
|
||||||
|
<td style="width: 33.34%; padding: 10px 14px; border: none; height: 80px; vertical-align: top;">
|
||||||
|
<div class="signature-label">DATE</div>
|
||||||
|
<t t-if="doc.rental_agreement_signed_date">
|
||||||
|
<div style="min-height: 50px; font-size: 14px; padding-top: 8px;" t-out="doc.rental_agreement_signed_date.strftime('%m/%d/%Y')">Date</div>
|
||||||
|
</t>
|
||||||
|
<t t-else=""><div style="border-bottom: 1px solid #000; min-height: 50px; margin-top: 20px;"></div></t>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="authorization-text">
|
|
||||||
<p>I authorize <t t-esc="company.name"/> to charge the credit card indicated in this authorization form according to the terms outlined above. I certify that I am an authorized user of this credit card and will not dispute the payment. By signing this form, I acknowledge that I have read the rental agreement and understand the terms and conditions. I understand that if the rented item is not returned on the agreed return date, additional charges will be incurred. *Payments for monthly rental items will be charged on the re-rental date until the item is returned.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Signature Section - Compact -->
|
|
||||||
<div class="signature-section">
|
|
||||||
<div class="signature-box">
|
|
||||||
<table style="width: 100%; border: none;">
|
|
||||||
<tr>
|
|
||||||
<td style="width: 40%; padding: 5px; border: none;">
|
|
||||||
<div class="signature-label">FULL NAME (PRINT)</div>
|
|
||||||
<t t-if="doc.rental_agreement_signer_name">
|
|
||||||
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signer_name">Name</div>
|
|
||||||
</t>
|
|
||||||
<t t-else=""><div class="signature-line"></div></t>
|
|
||||||
</td>
|
|
||||||
<td style="width: 40%; padding: 5px; border: none;">
|
|
||||||
<div class="signature-label">SIGNATURE</div>
|
|
||||||
<t t-if="doc.rental_agreement_signature">
|
|
||||||
<img t-att-src="'data:image/png;base64,' + doc.rental_agreement_signature.decode('utf-8') if doc.rental_agreement_signature else ''" style="max-height: 50px; max-width: 100%;"/>
|
|
||||||
</t>
|
|
||||||
<t t-else=""><div class="signature-line"></div></t>
|
|
||||||
</td>
|
|
||||||
<td style="width: 20%; padding: 5px; border: none;">
|
|
||||||
<div class="signature-label">DATE</div>
|
|
||||||
<t t-if="doc.rental_agreement_signed_date">
|
|
||||||
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signed_date.strftime('%m/%d/%Y')">Date</div>
|
|
||||||
</t>
|
|
||||||
<t t-else=""><div class="signature-line"></div></t>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -392,7 +395,7 @@
|
|||||||
<field name="report_type">qweb-pdf</field>
|
<field name="report_type">qweb-pdf</field>
|
||||||
<field name="report_name">fusion_rental.report_rental_agreement</field>
|
<field name="report_name">fusion_rental.report_rental_agreement</field>
|
||||||
<field name="report_file">fusion_rental.report_rental_agreement</field>
|
<field name="report_file">fusion_rental.report_rental_agreement</field>
|
||||||
<field name="print_report_name">'Rental Agreement - %s' % object.name</field>
|
<field name="print_report_name">'Rental Agreement - %s%s' % (object.name, ' - Signed' if object.rental_agreement_signed else '')</field>
|
||||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||||
<field name="binding_type">report</field>
|
<field name="binding_type">report</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ access_manual_renewal_wizard_user,manual.renewal.wizard.user,model_manual_renewa
|
|||||||
access_manual_renewal_wizard_manager,manual.renewal.wizard.manager,model_manual_renewal_wizard,fusion_rental.group_rental_manager,1,1,1,1
|
access_manual_renewal_wizard_manager,manual.renewal.wizard.manager,model_manual_renewal_wizard,fusion_rental.group_rental_manager,1,1,1,1
|
||||||
access_deposit_deduction_wizard_user,deposit.deduction.wizard.user,model_deposit_deduction_wizard,sales_team.group_sale_salesman,1,1,1,0
|
access_deposit_deduction_wizard_user,deposit.deduction.wizard.user,model_deposit_deduction_wizard,sales_team.group_sale_salesman,1,1,1,0
|
||||||
access_deposit_deduction_wizard_manager,deposit.deduction.wizard.manager,model_deposit_deduction_wizard,fusion_rental.group_rental_manager,1,1,1,1
|
access_deposit_deduction_wizard_manager,deposit.deduction.wizard.manager,model_deposit_deduction_wizard,fusion_rental.group_rental_manager,1,1,1,1
|
||||||
|
access_rental_return_wizard_user,rental.return.wizard.user,model_rental_return_wizard,sales_team.group_sale_salesman,1,1,1,0
|
||||||
|
access_rental_return_wizard_manager,rental.return.wizard.manager,model_rental_return_wizard,fusion_rental.group_rental_manager,1,1,1,1
|
||||||
|
access_rental_return_wizard_line_user,rental.return.wizard.line.user,model_rental_return_wizard_line,sales_team.group_sale_salesman,1,1,1,0
|
||||||
|
access_rental_return_wizard_line_manager,rental.return.wizard.line.manager,model_rental_return_wizard_line,fusion_rental.group_rental_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -2,12 +2,12 @@
|
|||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<record id="module_category_rental_enhancement" model="ir.module.category">
|
<record id="module_category_rental_enhancement" model="ir.module.category">
|
||||||
<field name="name">Rental Enhancement</field>
|
<field name="name">Fusion Rental</field>
|
||||||
<field name="sequence">50</field>
|
<field name="sequence">50</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="res_groups_privilege_rental_enhancement" model="res.groups.privilege">
|
<record id="res_groups_privilege_rental_enhancement" model="res.groups.privilege">
|
||||||
<field name="name">Rental Enhancement</field>
|
<field name="name">Fusion Rental</field>
|
||||||
<field name="sequence">50</field>
|
<field name="sequence">50</field>
|
||||||
<field name="category_id" ref="module_category_rental_enhancement"/>
|
<field name="category_id" ref="module_category_rental_enhancement"/>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
22
fusion_rental/static/src/css/inspection_photos.css
Normal file
22
fusion_rental/static/src/css/inspection_photos.css
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.o_inspection_photos .o_attachment.o_attachment_many2many {
|
||||||
|
min-width: 140px;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_inspection_photos .o_attachment.o_attachment_many2many .o_image_box {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_inspection_photos .o_attachment.o_attachment_many2many .o_preview_image {
|
||||||
|
width: 120px !important;
|
||||||
|
height: 120px !important;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_inspection_photos .o_attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
43
fusion_rental/static/src/js/inspection_photo_field.js
Normal file
43
fusion_rental/static/src/js/inspection_photo_field.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { Many2ManyBinaryField, many2ManyBinaryField } from "@web/views/fields/many2many_binary/many2many_binary_field";
|
||||||
|
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
|
||||||
|
|
||||||
|
export class InspectionPhotoField extends Many2ManyBinaryField {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.fileViewer = useFileViewer();
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewableFiles() {
|
||||||
|
return this.files
|
||||||
|
.filter((f) => this.isImage(f))
|
||||||
|
.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
isImage: true,
|
||||||
|
isViewable: true,
|
||||||
|
downloadUrl: `/web/content/${f.id}?download=true`,
|
||||||
|
defaultSource: `/web/image/${f.id}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickImage(fileId) {
|
||||||
|
const viewable = this.viewableFiles;
|
||||||
|
const clicked = viewable.find(
|
||||||
|
(vf) => vf.defaultSource === `/web/image/${fileId}`
|
||||||
|
);
|
||||||
|
if (clicked) {
|
||||||
|
this.fileViewer.open(clicked, viewable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
InspectionPhotoField.template = "fusion_rental.InspectionPhotoField";
|
||||||
|
|
||||||
|
export const inspectionPhotoField = {
|
||||||
|
...many2ManyBinaryField,
|
||||||
|
component: InspectionPhotoField,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("fields").add("inspection_photos", inspectionPhotoField);
|
||||||
44
fusion_rental/static/src/xml/inspection_photo_field.xml
Normal file
44
fusion_rental/static/src/xml/inspection_photo_field.xml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_rental.InspectionPhotoField" t-inherit="web.Many2ManyBinaryField" t-inherit-mode="primary">
|
||||||
|
<xpath expr="//t[@t-call='web.Many2ManyBinaryField.attachment_preview']" position="replace">
|
||||||
|
<t t-call="fusion_rental.InspectionPhotoField.attachment_preview"/>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="fusion_rental.InspectionPhotoField.attachment_preview">
|
||||||
|
<t t-set="editable" t-value="!props.readonly"/>
|
||||||
|
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' } #{upload ? 'o_attachment_uploading' : ''}" t-att-title="file.name">
|
||||||
|
<div class="o_attachment_wrap">
|
||||||
|
<t t-set="ext" t-value="getExtension(file)"/>
|
||||||
|
<t t-set="url" t-value="getUrl(file.id)"/>
|
||||||
|
<div class="o_image_box float-start" t-att-data-tooltip="file.name">
|
||||||
|
<a t-if="isImage(file)" href="#" t-on-click.prevent="() => this.onClickImage(file.id)">
|
||||||
|
<img class="o_preview_image o_hover object-fit-cover rounded align-baseline"
|
||||||
|
t-attf-src="/web/image/{{ file.id }}"
|
||||||
|
onerror="this.src = '/web/static/img/mimetypes/image.svg'"
|
||||||
|
style="cursor: zoom-in;"/>
|
||||||
|
</a>
|
||||||
|
<a t-else="" t-att-href="url" aria-label="Download" download="">
|
||||||
|
<span class="o_image o_preview_image o_hover" t-att-data-mimetype="file.mimetype" t-att-data-ext="ext" role="img"/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="caption">
|
||||||
|
<a class="ml4" t-att-data-tooltip="file.name" t-att-href="url" download=""><t t-esc='file.name'/></a>
|
||||||
|
</div>
|
||||||
|
<div class="caption small">
|
||||||
|
<a class="ml4 small text-uppercase" t-att-href="url" download=""><b><t t-esc='ext'/></b></a>
|
||||||
|
<div t-if="editable" class="progress o_attachment_progress_bar">
|
||||||
|
<div class="progress-bar progress-bar-striped active" style="width: 100%">Uploading</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_attachment_uploaded"><i class="text-success fa fa-check" role="img" aria-label="Uploaded" title="Uploaded"/></div>
|
||||||
|
<div t-if="editable" class="o_attachment_delete" t-on-click.stop="() => this.onFileRemove(file.id)"><span role="img" aria-label="Delete" title="Delete">x</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<!-- Top-level menu under Rental app -->
|
<!-- Top-level menu under Rental app -->
|
||||||
<menuitem id="menu_rental_enhancement_root"
|
<menuitem id="menu_rental_enhancement_root"
|
||||||
name="Rental Enhancement"
|
name="Fusion Rental"
|
||||||
parent="sale_renting.rental_menu_root"
|
parent="sale_renting.rental_menu_root"
|
||||||
sequence="30"/>
|
sequence="30"/>
|
||||||
|
|
||||||
|
|||||||
76
fusion_rental/views/portal_sale_rental_override.xml
Normal file
76
fusion_rental/views/portal_sale_rental_override.xml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- Override portal quotation sidebar buttons for rental orders -->
|
||||||
|
<template id="sale_order_portal_template_rental_override"
|
||||||
|
inherit_id="sale.sale_order_portal_template"
|
||||||
|
name="Rental Portal Override">
|
||||||
|
|
||||||
|
<!-- Sidebar: replace Sign & Pay / Accept & Sign with rental agreement button -->
|
||||||
|
<xpath expr="//div[@id='sale_order_sidebar_button']" position="before">
|
||||||
|
<t t-if="sale_order.is_rental_order and not sale_order.rental_agreement_signed">
|
||||||
|
<div class="d-flex flex-column gap-2 mb-3" id="rental_sidebar_buttons">
|
||||||
|
<a t-if="sale_order.state in ('draft', 'sent')"
|
||||||
|
role="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
t-attf-href="/rental/confirm-and-sign/#{sale_order.id}?access_token=#{sale_order.access_token}"
|
||||||
|
>
|
||||||
|
<i class="fa fa-check me-1"/>Confirm & Sign Agreement
|
||||||
|
</a>
|
||||||
|
<a t-elif="sale_order.state == 'sale' and sale_order.rental_agreement_token"
|
||||||
|
role="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
t-attf-href="/rental/agreement/#{sale_order.id}/#{sale_order.rental_agreement_token}"
|
||||||
|
>
|
||||||
|
<i class="fa fa-pencil me-1"/>Sign Rental Agreement
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-if="sale_order.is_rental_order and sale_order.rental_agreement_signed">
|
||||||
|
<div class="d-flex flex-column gap-2 mb-3">
|
||||||
|
<span class="btn btn-success disabled">
|
||||||
|
<i class="fa fa-check me-1"/>Agreement Signed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Hide standard Sign & Pay sidebar button for rental orders -->
|
||||||
|
<xpath expr="//div[@id='sale_order_sidebar_button']" position="attributes">
|
||||||
|
<attribute name="t-if">not sale_order.is_rental_order</attribute>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Bottom actions: replace for rental orders -->
|
||||||
|
<xpath expr="//div[@name='sale_order_actions']" position="before">
|
||||||
|
<div t-if="sale_order.is_rental_order and not sale_order.rental_agreement_signed"
|
||||||
|
class="d-flex justify-content-center gap-1 d-print-none">
|
||||||
|
<div class="col-sm-auto mt8">
|
||||||
|
<a t-if="sale_order.state in ('draft', 'sent')"
|
||||||
|
role="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
t-attf-href="/rental/confirm-and-sign/#{sale_order.id}?access_token=#{sale_order.access_token}">
|
||||||
|
<i class="fa fa-check me-1"/>Confirm & Sign Agreement
|
||||||
|
</a>
|
||||||
|
<a t-elif="sale_order.state == 'sale' and sale_order.rental_agreement_token"
|
||||||
|
role="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
t-attf-href="/rental/agreement/#{sale_order.id}/#{sale_order.rental_agreement_token}">
|
||||||
|
<i class="fa fa-pencil me-1"/>Sign Rental Agreement
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-auto mt8">
|
||||||
|
<a role="button" class="btn btn-light" href="#discussion">
|
||||||
|
<i class="fa fa-comment me-1"/>Feedback
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Hide standard bottom actions for rental orders -->
|
||||||
|
<xpath expr="//div[@name='sale_order_actions']" position="attributes">
|
||||||
|
<attribute name="t-if">(sale_order._has_to_be_signed() or sale_order._has_to_be_paid()) and not sale_order.is_rental_order</attribute>
|
||||||
|
</xpath>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<field name="inherit_id" ref="sale_renting.res_config_settings_view_form"/>
|
<field name="inherit_id" ref="sale_renting.res_config_settings_view_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//app[@name='sale_renting']" position="inside">
|
<xpath expr="//app[@name='sale_renting']" position="inside">
|
||||||
<block title="Rental Enhancement" name="rental_enhancement_settings">
|
<block title="Fusion Rental" name="rental_enhancement_settings">
|
||||||
<setting string="Google Review URL"
|
<setting string="Google Review URL"
|
||||||
help="Google Review link shown in thank-you emails after rental close.">
|
help="Google Review link shown in thank-you emails after rental close.">
|
||||||
<div class="content-group">
|
<div class="content-group">
|
||||||
@@ -31,6 +31,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</setting>
|
</setting>
|
||||||
|
<setting string="Google Maps API Key"
|
||||||
|
help="API key for address autocomplete on the rental agreement form. If Fusion Claims is installed, its key is used automatically.">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="mt-2">
|
||||||
|
<field name="rental_google_maps_api_key"
|
||||||
|
placeholder="Enter Google Maps API Key"
|
||||||
|
password="True"/>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted mt-1">
|
||||||
|
Only needed if Fusion Claims is not installed. Enable the
|
||||||
|
Places API and Geocoding API in Google Cloud Console.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
<block title="Timing & Short-Term Rentals" name="rental_timing_settings">
|
||||||
|
<setting string="Marketing Email Timing"
|
||||||
|
help="Percentage of rental period after start date to send the purchase offer email.">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="row mt-2">
|
||||||
|
<label class="col-lg-4 o_light_label" for="rental_marketing_email_pct"/>
|
||||||
|
<field name="rental_marketing_email_pct" class="col-lg-2"/>
|
||||||
|
<span class="col-lg-5 text-muted">% of rental period (default 23% = day 7 of 30)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
<setting string="Renewal Reminder Timing"
|
||||||
|
help="Percentage of rental period before renewal date to send the reminder.">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="row mt-2">
|
||||||
|
<label class="col-lg-4 o_light_label" for="rental_renewal_reminder_pct"/>
|
||||||
|
<field name="rental_renewal_reminder_pct" class="col-lg-2"/>
|
||||||
|
<span class="col-lg-5 text-muted">% of rental period (default 10% = 3 days before on 30-day)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
<setting string="Short-Term Rental Threshold"
|
||||||
|
help="Rentals shorter than this are considered short-term. Auto-renewal waits for the grace period.">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="row mt-2">
|
||||||
|
<label class="col-lg-4 o_light_label" for="rental_short_term_threshold_days"/>
|
||||||
|
<field name="rental_short_term_threshold_days" class="col-lg-2"/>
|
||||||
|
<span class="col-lg-5 text-muted">days (rentals below this get grace period protection)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
<setting string="Short-Term Grace Period"
|
||||||
|
help="Time after scheduled return before auto-renewal charges a short-term rental.">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="row mt-2">
|
||||||
|
<label class="col-lg-4 o_light_label" for="rental_short_term_grace_hours"/>
|
||||||
|
<field name="rental_short_term_grace_hours" class="col-lg-2"/>
|
||||||
|
<span class="col-lg-5 text-muted">hours after return time (default 1 hour)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
</block>
|
</block>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@@ -7,6 +7,46 @@
|
|||||||
<field name="inherit_id" ref="sale_renting.rental_order_form_view"/>
|
<field name="inherit_id" ref="sale_renting.rental_order_form_view"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
|
|
||||||
|
<!-- Smart buttons in button_box -->
|
||||||
|
<div name="button_box" position="inside">
|
||||||
|
<button name="action_view_deposit_invoice"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-shield"
|
||||||
|
invisible="not is_rental_order or rental_deposit_invoice_count == 0">
|
||||||
|
<field name="rental_deposit_invoice_count"
|
||||||
|
widget="statinfo"
|
||||||
|
string="Security Deposit"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_rental_charges_invoice"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-file-text-o"
|
||||||
|
invisible="not is_rental_order or rental_charges_invoice_count == 0">
|
||||||
|
<field name="rental_charges_invoice_count"
|
||||||
|
widget="statinfo"
|
||||||
|
string="Rental Invoice"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_renewal_invoices"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-refresh"
|
||||||
|
invisible="not is_rental_order or rental_renewal_invoice_count == 0">
|
||||||
|
<field name="rental_renewal_invoice_count"
|
||||||
|
widget="statinfo"
|
||||||
|
string="Renewals"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_refund_invoices"
|
||||||
|
type="object"
|
||||||
|
class="oe_stat_button"
|
||||||
|
icon="fa-undo"
|
||||||
|
invisible="not is_rental_order or rental_refund_invoice_count == 0">
|
||||||
|
<field name="rental_refund_invoice_count"
|
||||||
|
widget="statinfo"
|
||||||
|
string="Refunds"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Header buttons -->
|
<!-- Header buttons -->
|
||||||
<button name="action_open_pickup" position="before">
|
<button name="action_open_pickup" position="before">
|
||||||
<button name="action_send_rental_agreement"
|
<button name="action_send_rental_agreement"
|
||||||
@@ -37,26 +77,12 @@
|
|||||||
string="Mark Deposit Collected"
|
string="Mark Deposit Collected"
|
||||||
invisible="not is_rental_order or rental_deposit_status != 'pending'"
|
invisible="not is_rental_order or rental_deposit_status != 'pending'"
|
||||||
icon="fa-check-circle"/>
|
icon="fa-check-circle"/>
|
||||||
<button name="action_refund_deposit"
|
<button name="action_process_deposit"
|
||||||
type="object"
|
type="object"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
string="Refund Deposit"
|
string="Process Deposit"
|
||||||
invisible="not is_rental_order or rental_deposit_status != 'collected'"
|
invisible="not is_rental_order or rental_deposit_status not in ('collected', 'refund_hold')"
|
||||||
confirm="This will initiate the deposit refund hold period. Continue?"
|
icon="fa-credit-card"/>
|
||||||
icon="fa-undo"/>
|
|
||||||
<button name="action_force_refund_deposit"
|
|
||||||
type="object"
|
|
||||||
class="btn-secondary"
|
|
||||||
string="Process Refund Now"
|
|
||||||
invisible="not is_rental_order or rental_deposit_status != 'refund_hold'"
|
|
||||||
confirm="Skip the hold period and process the refund immediately?"
|
|
||||||
icon="fa-bolt"/>
|
|
||||||
<button name="action_deduct_deposit"
|
|
||||||
type="object"
|
|
||||||
class="btn-danger"
|
|
||||||
string="Deduct Deposit"
|
|
||||||
invisible="not is_rental_order or rental_deposit_status != 'collected'"
|
|
||||||
icon="fa-minus-circle"/>
|
|
||||||
|
|
||||||
<button name="action_close_rental"
|
<button name="action_close_rental"
|
||||||
type="object"
|
type="object"
|
||||||
@@ -68,61 +94,109 @@
|
|||||||
icon="fa-power-off"/>
|
icon="fa-power-off"/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Rental fields -->
|
<!-- Hidden fields (must stay in form, outside notebook) -->
|
||||||
<field name="duration_days" position="after">
|
<field name="duration_days" position="after">
|
||||||
<!-- Renewal settings -->
|
|
||||||
<field name="rental_auto_renew" invisible="not is_rental_order"/>
|
|
||||||
<field name="rental_renewal_count" invisible="not is_rental_order or rental_renewal_count == 0"/>
|
|
||||||
<field name="rental_max_renewals" invisible="not is_rental_order or not rental_auto_renew"/>
|
|
||||||
<field name="rental_payment_token_id" invisible="not is_rental_order"/>
|
|
||||||
<field name="rental_next_renewal_date" invisible="not is_rental_order or not rental_auto_renew"/>
|
|
||||||
<field name="rental_reminder_sent" invisible="1"/>
|
<field name="rental_reminder_sent" invisible="1"/>
|
||||||
<field name="rental_original_duration" invisible="1"/>
|
<field name="rental_original_duration" invisible="1"/>
|
||||||
<field name="rental_agreement_token" invisible="1"/>
|
<field name="rental_agreement_token" invisible="1"/>
|
||||||
|
|
||||||
<!-- Agreement status -->
|
|
||||||
<field name="rental_agreement_signed" invisible="not is_rental_order"
|
|
||||||
widget="boolean_toggle" readonly="1"/>
|
|
||||||
<field name="rental_agreement_signer_name"
|
|
||||||
invisible="not is_rental_order or not rental_agreement_signed" readonly="1"/>
|
|
||||||
<field name="rental_agreement_signed_date"
|
|
||||||
invisible="not is_rental_order or not rental_agreement_signed" readonly="1"/>
|
|
||||||
|
|
||||||
<!-- Rental charges invoice -->
|
|
||||||
<field name="rental_charges_invoice_id"
|
|
||||||
invisible="not is_rental_order or not rental_charges_invoice_id" readonly="1"/>
|
|
||||||
|
|
||||||
<!-- Deposit status -->
|
|
||||||
<field name="rental_deposit_status" invisible="not is_rental_order or not rental_deposit_status"
|
|
||||||
decoration-success="rental_deposit_status == 'refunded'"
|
|
||||||
decoration-warning="rental_deposit_status in ('pending', 'refund_hold', 'collected')"
|
|
||||||
decoration-danger="rental_deposit_status == 'deducted'"
|
|
||||||
widget="badge"/>
|
|
||||||
<field name="rental_deposit_invoice_id"
|
|
||||||
invisible="not is_rental_order or not rental_deposit_invoice_id" readonly="1"/>
|
|
||||||
|
|
||||||
<!-- Inspection status -->
|
|
||||||
<field name="rental_inspection_status"
|
|
||||||
invisible="not is_rental_order or not rental_inspection_status"
|
|
||||||
decoration-success="rental_inspection_status == 'passed'"
|
|
||||||
decoration-danger="rental_inspection_status == 'flagged'"
|
|
||||||
widget="badge"/>
|
|
||||||
|
|
||||||
<!-- Purchase interest -->
|
|
||||||
<field name="rental_purchase_interest"
|
|
||||||
invisible="not is_rental_order or not rental_purchase_interest"
|
|
||||||
widget="boolean_toggle" readonly="1"/>
|
|
||||||
<field name="rental_purchase_coupon_id"
|
|
||||||
invisible="not is_rental_order or not rental_purchase_coupon_id" readonly="1"/>
|
|
||||||
|
|
||||||
<!-- Close status -->
|
|
||||||
<field name="rental_closed"
|
|
||||||
invisible="not is_rental_order or not rental_closed" readonly="1"/>
|
|
||||||
<field name="rental_marketing_email_sent" invisible="1"/>
|
<field name="rental_marketing_email_sent" invisible="1"/>
|
||||||
</field>
|
</field>
|
||||||
|
|
||||||
<!-- Notebook pages -->
|
<!-- Notebook pages -->
|
||||||
<xpath expr="//notebook" position="inside">
|
<xpath expr="//notebook" position="inside">
|
||||||
|
|
||||||
|
<!-- Rental Management -->
|
||||||
|
<page string="Rental Management"
|
||||||
|
name="rental_management"
|
||||||
|
invisible="not is_rental_order">
|
||||||
|
|
||||||
|
<!-- Row 1: Agreement + Payment -->
|
||||||
|
<group>
|
||||||
|
<group string="Agreement">
|
||||||
|
<field name="rental_agreement_signed"
|
||||||
|
widget="boolean_toggle" readonly="1"/>
|
||||||
|
<field name="rental_agreement_signer_name"
|
||||||
|
invisible="not rental_agreement_signed" readonly="1"/>
|
||||||
|
<field name="rental_agreement_signed_date"
|
||||||
|
invisible="not rental_agreement_signed" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Payment">
|
||||||
|
<field name="rental_payment_token_id"/>
|
||||||
|
<div invisible="not is_rental_order or state != 'sale' or rental_closed">
|
||||||
|
<button name="action_send_card_reauthorization"
|
||||||
|
type="object"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
string="Reauthorize Card"
|
||||||
|
icon="fa-credit-card"
|
||||||
|
confirm="This will send a card authorization form to the customer. Continue?"/>
|
||||||
|
</div>
|
||||||
|
<field name="rental_charges_invoice_id"
|
||||||
|
invisible="not rental_charges_invoice_id" readonly="1"/>
|
||||||
|
<field name="rental_deposit_invoice_id"
|
||||||
|
invisible="not rental_deposit_invoice_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Row 2: Renewal + Status -->
|
||||||
|
<group>
|
||||||
|
<group string="Renewal">
|
||||||
|
<field name="rental_auto_renew"/>
|
||||||
|
<field name="rental_auto_renew_off_reason"
|
||||||
|
invisible="rental_auto_renew"
|
||||||
|
required="not rental_auto_renew"
|
||||||
|
placeholder="Reason for disabling auto-renewal..."/>
|
||||||
|
<field name="rental_max_renewals"
|
||||||
|
invisible="not rental_auto_renew"/>
|
||||||
|
<field name="rental_next_renewal_date"
|
||||||
|
invisible="not rental_auto_renew"/>
|
||||||
|
<field name="rental_renewal_count"
|
||||||
|
invisible="rental_renewal_count == 0"/>
|
||||||
|
</group>
|
||||||
|
<group string="Status">
|
||||||
|
<field name="rental_deposit_status"
|
||||||
|
invisible="not rental_deposit_status"
|
||||||
|
decoration-success="rental_deposit_status == 'refunded'"
|
||||||
|
decoration-warning="rental_deposit_status in ('pending', 'refund_hold', 'collected')"
|
||||||
|
decoration-danger="rental_deposit_status == 'deducted'"
|
||||||
|
widget="badge"/>
|
||||||
|
<field name="rental_inspection_status"
|
||||||
|
invisible="not rental_inspection_status"
|
||||||
|
decoration-success="rental_inspection_status == 'passed'"
|
||||||
|
decoration-danger="rental_inspection_status == 'flagged'"
|
||||||
|
widget="badge"/>
|
||||||
|
<field name="rental_purchase_interest"
|
||||||
|
invisible="not rental_purchase_interest"
|
||||||
|
widget="boolean_toggle" readonly="1"/>
|
||||||
|
<field name="rental_purchase_coupon_id"
|
||||||
|
invisible="not rental_purchase_coupon_id" readonly="1"/>
|
||||||
|
<field name="rental_closed"
|
||||||
|
invisible="not rental_closed" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Row 3: Document + Billing (visible after agreement signed) -->
|
||||||
|
<group invisible="not rental_agreement_signed">
|
||||||
|
<group string="Signed Agreement">
|
||||||
|
<div invisible="not rental_agreement_document" class="mb-2">
|
||||||
|
<button name="action_preview_rental_agreement" type="object"
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
icon="fa-file-pdf-o">
|
||||||
|
Preview Signed Agreement
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<field name="rental_agreement_document"
|
||||||
|
filename="rental_agreement_document_filename"
|
||||||
|
widget="binary" readonly="1"/>
|
||||||
|
<field name="rental_agreement_document_filename" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Billing Details">
|
||||||
|
<field name="rental_billing_address" readonly="1"/>
|
||||||
|
<field name="rental_billing_postal_code" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
|
||||||
|
<!-- Renewal History -->
|
||||||
<page string="Renewal History"
|
<page string="Renewal History"
|
||||||
name="renewal_history"
|
name="renewal_history"
|
||||||
invisible="not is_rental_order or rental_renewal_count == 0">
|
invisible="not is_rental_order or rental_renewal_count == 0">
|
||||||
@@ -147,6 +221,8 @@
|
|||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</page>
|
</page>
|
||||||
|
|
||||||
|
<!-- Cancellation Requests -->
|
||||||
<page string="Cancellation Requests"
|
<page string="Cancellation Requests"
|
||||||
name="cancellation_requests"
|
name="cancellation_requests"
|
||||||
invisible="not is_rental_order"
|
invisible="not is_rental_order"
|
||||||
@@ -166,32 +242,37 @@
|
|||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</page>
|
</page>
|
||||||
|
|
||||||
|
<!-- Inspection -->
|
||||||
<page string="Inspection"
|
<page string="Inspection"
|
||||||
name="inspection"
|
name="inspection"
|
||||||
invisible="not is_rental_order or not rental_inspection_status">
|
invisible="not is_rental_order">
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
<field name="rental_inspection_status"/>
|
<field name="rental_inspection_status"
|
||||||
|
decoration-success="rental_inspection_status == 'passed'"
|
||||||
|
decoration-danger="rental_inspection_status == 'flagged'"
|
||||||
|
decoration-info="rental_inspection_status == 'pending'"
|
||||||
|
widget="badge"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group string="Inspection Notes">
|
<div class="alert alert-secondary" role="alert"
|
||||||
|
invisible="rental_inspection_status">
|
||||||
|
No inspection has been performed yet. Use the
|
||||||
|
<strong>Return</strong> button to process the return
|
||||||
|
and complete the inspection.
|
||||||
|
</div>
|
||||||
|
<group string="Inspection Notes"
|
||||||
|
invisible="not rental_inspection_status">
|
||||||
<field name="rental_inspection_notes" nolabel="1"/>
|
<field name="rental_inspection_notes" nolabel="1"/>
|
||||||
</group>
|
</group>
|
||||||
<group string="Inspection Photos">
|
<group string="Inspection Photos"
|
||||||
<field name="rental_inspection_photo_ids" widget="many2many_binary" nolabel="1"/>
|
invisible="not rental_inspection_status">
|
||||||
</group>
|
<field name="rental_inspection_photo_ids"
|
||||||
</page>
|
widget="inspection_photos"
|
||||||
<page string="Agreement"
|
nolabel="1"
|
||||||
name="agreement_tab"
|
class="o_inspection_photos"
|
||||||
invisible="not is_rental_order or not rental_agreement_signed">
|
options="{'accepted_file_extensions': 'image/*'}"/>
|
||||||
<group>
|
|
||||||
<group string="Signature Details">
|
|
||||||
<field name="rental_agreement_signer_name" readonly="1"/>
|
|
||||||
<field name="rental_agreement_signed_date" readonly="1"/>
|
|
||||||
</group>
|
|
||||||
<group string="Signature">
|
|
||||||
<field name="rental_agreement_signature" widget="image" readonly="1"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
</group>
|
||||||
</page>
|
</page>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import manual_renewal_wizard
|
from . import manual_renewal_wizard
|
||||||
from . import deposit_deduction_wizard
|
from . import deposit_deduction_wizard
|
||||||
|
from . import rental_return_wizard
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class DepositDeductionWizard(models.TransientModel):
|
|
||||||
|
class DepositProcessWizard(models.TransientModel):
|
||||||
_name = 'deposit.deduction.wizard'
|
_name = 'deposit.deduction.wizard'
|
||||||
_description = 'Security Deposit Deduction'
|
_description = 'Process Security Deposit'
|
||||||
|
|
||||||
order_id = fields.Many2one(
|
order_id = fields.Many2one(
|
||||||
'sale.order',
|
'sale.order',
|
||||||
@@ -12,53 +16,406 @@ class DepositDeductionWizard(models.TransientModel):
|
|||||||
required=True,
|
required=True,
|
||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
partner_id = fields.Many2one(
|
||||||
|
related='order_id.partner_id',
|
||||||
|
string="Customer",
|
||||||
|
)
|
||||||
deposit_total = fields.Float(
|
deposit_total = fields.Float(
|
||||||
string="Deposit Amount",
|
string="Deposit Amount",
|
||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
action_type = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
('full_refund', "Full Refund"),
|
||||||
|
('partial_refund', "Partial Refund (Deduction for Damages)"),
|
||||||
|
('no_refund', "Full Deduction (Damages Exceed Deposit)"),
|
||||||
|
('sold', "Customer Purchased Rental Product"),
|
||||||
|
],
|
||||||
|
string="Action",
|
||||||
|
required=True,
|
||||||
|
default='full_refund',
|
||||||
|
)
|
||||||
|
|
||||||
deduction_amount = fields.Float(
|
deduction_amount = fields.Float(
|
||||||
string="Deduction Amount",
|
string="Deduction Amount",
|
||||||
required=True,
|
|
||||||
help="Amount to deduct from the security deposit for damages.",
|
help="Amount to deduct from the security deposit for damages.",
|
||||||
)
|
)
|
||||||
reason = fields.Text(
|
reason = fields.Text(
|
||||||
string="Reason for Deduction",
|
string="Reason",
|
||||||
required=True,
|
|
||||||
)
|
)
|
||||||
remaining_preview = fields.Float(
|
remaining_preview = fields.Float(
|
||||||
string="Remaining to Refund",
|
string="Remaining to Refund",
|
||||||
compute='_compute_remaining_preview',
|
compute='_compute_previews',
|
||||||
)
|
)
|
||||||
overage_preview = fields.Float(
|
overage_preview = fields.Float(
|
||||||
string="Additional Invoice Amount",
|
string="Additional Invoice Amount",
|
||||||
compute='_compute_remaining_preview',
|
compute='_compute_previews',
|
||||||
help="Amount exceeding the deposit that will be invoiced to the customer.",
|
)
|
||||||
|
refund_preview = fields.Float(
|
||||||
|
string="Refund Amount",
|
||||||
|
compute='_compute_previews',
|
||||||
|
)
|
||||||
|
has_card_on_file = fields.Boolean(
|
||||||
|
string="Card on File",
|
||||||
|
compute='_compute_has_card',
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends('deposit_total', 'deduction_amount')
|
@api.depends('deposit_total', 'deduction_amount', 'action_type')
|
||||||
def _compute_remaining_preview(self):
|
def _compute_previews(self):
|
||||||
for wizard in self:
|
for wizard in self:
|
||||||
diff = wizard.deposit_total - wizard.deduction_amount
|
if wizard.action_type == 'full_refund':
|
||||||
if diff >= 0:
|
wizard.refund_preview = wizard.deposit_total
|
||||||
wizard.remaining_preview = diff
|
wizard.remaining_preview = wizard.deposit_total
|
||||||
wizard.overage_preview = 0.0
|
wizard.overage_preview = 0.0
|
||||||
else:
|
elif wizard.action_type == 'sold':
|
||||||
|
wizard.refund_preview = wizard.deposit_total
|
||||||
|
wizard.remaining_preview = wizard.deposit_total
|
||||||
|
wizard.overage_preview = 0.0
|
||||||
|
elif wizard.action_type == 'partial_refund':
|
||||||
|
diff = wizard.deposit_total - wizard.deduction_amount
|
||||||
|
if diff >= 0:
|
||||||
|
wizard.remaining_preview = diff
|
||||||
|
wizard.refund_preview = diff
|
||||||
|
wizard.overage_preview = 0.0
|
||||||
|
else:
|
||||||
|
wizard.remaining_preview = 0.0
|
||||||
|
wizard.refund_preview = 0.0
|
||||||
|
wizard.overage_preview = abs(diff)
|
||||||
|
elif wizard.action_type == 'no_refund':
|
||||||
wizard.remaining_preview = 0.0
|
wizard.remaining_preview = 0.0
|
||||||
wizard.overage_preview = abs(diff)
|
wizard.refund_preview = 0.0
|
||||||
|
wizard.overage_preview = max(
|
||||||
|
wizard.deduction_amount - wizard.deposit_total, 0.0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
wizard.refund_preview = 0.0
|
||||||
|
wizard.remaining_preview = 0.0
|
||||||
|
wizard.overage_preview = 0.0
|
||||||
|
|
||||||
def action_confirm_deduction(self):
|
@api.depends('order_id')
|
||||||
|
def _compute_has_card(self):
|
||||||
|
for wizard in self:
|
||||||
|
wizard.has_card_on_file = bool(
|
||||||
|
wizard.order_id and wizard.order_id.rental_payment_token_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_confirm(self):
|
||||||
|
"""Dispatch to the appropriate deposit processing path."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
order = self.order_id
|
||||||
|
|
||||||
|
if self.action_type == 'full_refund':
|
||||||
|
return self._process_full_refund(order)
|
||||||
|
elif self.action_type == 'partial_refund':
|
||||||
|
return self._process_partial_refund(order)
|
||||||
|
elif self.action_type == 'no_refund':
|
||||||
|
return self._process_no_refund(order)
|
||||||
|
elif self.action_type == 'sold':
|
||||||
|
return self._process_sold(order)
|
||||||
|
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _process_full_refund(self, order):
|
||||||
|
"""Full deposit refund: credit note, Poynt refund, close rental."""
|
||||||
|
invoice = order.rental_deposit_invoice_id
|
||||||
|
if not invoice:
|
||||||
|
raise UserError(_("No deposit invoice found."))
|
||||||
|
|
||||||
|
credit_note = self._create_deposit_credit_note(
|
||||||
|
order, invoice, invoice.amount_total,
|
||||||
|
_("Security deposit full refund for %s", order.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
if credit_note:
|
||||||
|
self._process_poynt_refund(order, credit_note)
|
||||||
|
|
||||||
|
order.rental_deposit_status = 'refunded'
|
||||||
|
order._send_deposit_refund_email()
|
||||||
|
|
||||||
|
order.message_post(body=_(
|
||||||
|
"Security deposit fully refunded: %s",
|
||||||
|
self._format_amount(invoice.amount_total, order),
|
||||||
|
))
|
||||||
|
|
||||||
|
self._close_rental(order)
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _process_partial_refund(self, order):
|
||||||
|
"""Partial refund with deduction for damages."""
|
||||||
if self.deduction_amount <= 0:
|
if self.deduction_amount <= 0:
|
||||||
raise UserError(_("Deduction amount must be greater than zero."))
|
raise UserError(_("Deduction amount must be greater than zero."))
|
||||||
|
if not self.reason:
|
||||||
|
raise UserError(_("A reason is required for deductions."))
|
||||||
|
|
||||||
|
invoice = order.rental_deposit_invoice_id
|
||||||
|
if not invoice:
|
||||||
|
raise UserError(_("No deposit invoice found."))
|
||||||
|
|
||||||
|
deposit_total = invoice.amount_total
|
||||||
|
|
||||||
|
if self.deduction_amount >= deposit_total:
|
||||||
|
return self._process_no_refund(order)
|
||||||
|
|
||||||
|
refund_amount = deposit_total - self.deduction_amount
|
||||||
|
|
||||||
|
credit_note = self._create_deposit_credit_note(
|
||||||
|
order, invoice, refund_amount,
|
||||||
|
_("Partial deposit refund for %s (deduction: %s)",
|
||||||
|
order.name,
|
||||||
|
self._format_amount(self.deduction_amount, order)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if credit_note:
|
||||||
|
self._process_poynt_refund(order, credit_note)
|
||||||
|
|
||||||
|
order.rental_deposit_status = 'deducted'
|
||||||
|
order._send_deposit_refund_email()
|
||||||
|
|
||||||
order = self.order_id
|
|
||||||
order._deduct_security_deposit(self.deduction_amount)
|
|
||||||
order.message_post(body=_(
|
order.message_post(body=_(
|
||||||
"Security deposit deduction of %s processed.\nReason: %s",
|
"Security deposit partial refund: %s refunded, %s deducted.\nReason: %s",
|
||||||
self.env['ir.qweb.field.monetary'].value_to_html(
|
self._format_amount(refund_amount, order),
|
||||||
self.deduction_amount,
|
self._format_amount(self.deduction_amount, order),
|
||||||
{'display_currency': order.currency_id},
|
|
||||||
),
|
|
||||||
self.reason,
|
self.reason,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
self._close_rental(order)
|
||||||
return {'type': 'ir.actions.act_window_close'}
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _process_no_refund(self, order):
|
||||||
|
"""Full deduction: no refund, create overage invoice if needed."""
|
||||||
|
if not self.reason:
|
||||||
|
raise UserError(_("A reason is required for deductions."))
|
||||||
|
|
||||||
|
invoice = order.rental_deposit_invoice_id
|
||||||
|
deposit_total = invoice.amount_total if invoice else 0.0
|
||||||
|
overage = self.deduction_amount - deposit_total if self.deduction_amount > deposit_total else 0.0
|
||||||
|
|
||||||
|
order.rental_deposit_status = 'deducted'
|
||||||
|
|
||||||
|
if overage > 0:
|
||||||
|
damage_inv = order._create_damage_invoice(overage)
|
||||||
|
if damage_inv and order.rental_payment_token_id:
|
||||||
|
ok = order._collect_token_payment_for_invoice(damage_inv)
|
||||||
|
if ok:
|
||||||
|
order._send_invoice_with_receipt(damage_inv, 'damage')
|
||||||
|
else:
|
||||||
|
order._notify_staff_manual_payment(damage_inv)
|
||||||
|
elif damage_inv:
|
||||||
|
order._send_invoice_with_receipt(damage_inv, 'damage')
|
||||||
|
|
||||||
|
order.message_post(body=_(
|
||||||
|
"Security deposit fully deducted. Deduction: %s.%s\nReason: %s",
|
||||||
|
self._format_amount(self.deduction_amount, order),
|
||||||
|
_(" Overage invoice: %s", self._format_amount(overage, order)) if overage > 0 else '',
|
||||||
|
self.reason,
|
||||||
|
))
|
||||||
|
|
||||||
|
self._close_rental(order)
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _process_sold(self, order):
|
||||||
|
"""Customer purchased the rental: full deposit refund, mark as sold."""
|
||||||
|
invoice = order.rental_deposit_invoice_id
|
||||||
|
|
||||||
|
if invoice:
|
||||||
|
credit_note = self._create_deposit_credit_note(
|
||||||
|
order, invoice, invoice.amount_total,
|
||||||
|
_("Deposit refund - customer purchased rental product %s", order.name),
|
||||||
|
)
|
||||||
|
if credit_note:
|
||||||
|
self._process_poynt_refund(order, credit_note)
|
||||||
|
|
||||||
|
order.rental_deposit_status = 'refunded'
|
||||||
|
order.rental_purchase_interest = True
|
||||||
|
order._send_deposit_refund_email()
|
||||||
|
|
||||||
|
order.message_post(body=_(
|
||||||
|
"Customer purchased rental product. Security deposit fully refunded."
|
||||||
|
))
|
||||||
|
|
||||||
|
order.activity_schedule(
|
||||||
|
'mail.mail_activity_data_todo',
|
||||||
|
date_deadline=fields.Date.today(),
|
||||||
|
summary=_("Rental product sold - %s", order.name),
|
||||||
|
note=_(
|
||||||
|
"Customer %s purchased the rental product from %s. "
|
||||||
|
"Process the sale order and schedule delivery/pickup.",
|
||||||
|
order.partner_id.name, order.name,
|
||||||
|
),
|
||||||
|
user_id=order.user_id.id or self.env.uid,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._close_rental(order)
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _create_deposit_credit_note(self, order, invoice, amount, ref):
|
||||||
|
"""Create and post a credit note for the deposit invoice."""
|
||||||
|
if not invoice or invoice.payment_state not in ('paid', 'in_payment'):
|
||||||
|
_logger.warning(
|
||||||
|
"Cannot create credit note: invoice %s state=%s payment=%s",
|
||||||
|
invoice.name if invoice else 'None',
|
||||||
|
invoice.state if invoice else 'N/A',
|
||||||
|
invoice.payment_state if invoice else 'N/A',
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
credit_notes = invoice._reverse_moves(
|
||||||
|
default_values_list=[{'ref': ref}],
|
||||||
|
)
|
||||||
|
if not credit_notes:
|
||||||
|
return None
|
||||||
|
|
||||||
|
credit_note = credit_notes[:1]
|
||||||
|
|
||||||
|
if amount != invoice.amount_total:
|
||||||
|
for line in credit_note.invoice_line_ids:
|
||||||
|
line.price_unit = amount / max(line.quantity, 1)
|
||||||
|
|
||||||
|
credit_note.action_post()
|
||||||
|
return credit_note
|
||||||
|
|
||||||
|
def _process_poynt_refund(self, order, credit_note):
|
||||||
|
"""Process refund via Poynt referenced refund for the credit note."""
|
||||||
|
if not credit_note:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
orig_tx = credit_note._get_original_poynt_transaction()
|
||||||
|
except Exception:
|
||||||
|
orig_tx = None
|
||||||
|
|
||||||
|
if not orig_tx:
|
||||||
|
_logger.warning(
|
||||||
|
"No original Poynt transaction for deposit refund on %s. "
|
||||||
|
"Credit note created but refund must be processed manually.",
|
||||||
|
order.name,
|
||||||
|
)
|
||||||
|
order._notify_staff_manual_payment(credit_note)
|
||||||
|
return
|
||||||
|
|
||||||
|
provider = orig_tx.provider_id.sudo()
|
||||||
|
from odoo.addons.fusion_poynt import utils as poynt_utils
|
||||||
|
|
||||||
|
parent_txn_id = orig_tx.poynt_transaction_id
|
||||||
|
try:
|
||||||
|
txn_data = provider._poynt_make_request(
|
||||||
|
'GET', f'transactions/{parent_txn_id}',
|
||||||
|
)
|
||||||
|
for link in txn_data.get('links', []):
|
||||||
|
if link.get('rel') == 'CAPTURE' and link.get('href'):
|
||||||
|
parent_txn_id = link['href']
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
minor_amount = poynt_utils.format_poynt_amount(
|
||||||
|
abs(credit_note.amount_total), credit_note.currency_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
refund_payload = {
|
||||||
|
'action': 'REFUND',
|
||||||
|
'parentId': parent_txn_id,
|
||||||
|
'fundingSource': {'type': 'CREDIT_DEBIT'},
|
||||||
|
'amounts': {
|
||||||
|
'transactionAmount': minor_amount,
|
||||||
|
'orderAmount': minor_amount,
|
||||||
|
'currency': credit_note.currency_id.name,
|
||||||
|
},
|
||||||
|
'context': {
|
||||||
|
'source': 'WEB',
|
||||||
|
'sourceApp': 'odoo.fusion_rental',
|
||||||
|
},
|
||||||
|
'notes': f'Deposit refund for {order.name} via {credit_note.name}',
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = provider._poynt_make_request(
|
||||||
|
'POST', 'transactions', payload=refund_payload,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
"Poynt deposit refund failed for %s: %s", order.name, e,
|
||||||
|
)
|
||||||
|
order._notify_staff_manual_payment(credit_note)
|
||||||
|
return
|
||||||
|
|
||||||
|
refund_status = result.get('status', '')
|
||||||
|
refund_txn_id = result.get('id', '')
|
||||||
|
|
||||||
|
if refund_status in ('DECLINED', 'FAILED'):
|
||||||
|
_logger.warning(
|
||||||
|
"Poynt deposit refund declined for %s: %s",
|
||||||
|
order.name, refund_status,
|
||||||
|
)
|
||||||
|
order._notify_staff_manual_payment(credit_note)
|
||||||
|
return
|
||||||
|
|
||||||
|
PaymentMethod = self.env['payment.method'].sudo().with_context(
|
||||||
|
active_test=False,
|
||||||
|
)
|
||||||
|
payment_method = PaymentMethod.search(
|
||||||
|
[('code', '=', 'card')], limit=1,
|
||||||
|
) or PaymentMethod.search(
|
||||||
|
[('code', 'in', ('visa', 'mastercard'))], limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
refund_tx = self.env['payment.transaction'].sudo().create({
|
||||||
|
'provider_id': provider.id,
|
||||||
|
'payment_method_id': payment_method.id if payment_method else False,
|
||||||
|
'amount': -abs(credit_note.amount_total),
|
||||||
|
'currency_id': credit_note.currency_id.id,
|
||||||
|
'partner_id': order.partner_id.id,
|
||||||
|
'operation': 'refund',
|
||||||
|
'source_transaction_id': orig_tx.id,
|
||||||
|
'provider_reference': refund_txn_id or '',
|
||||||
|
'poynt_transaction_id': refund_txn_id or '',
|
||||||
|
'invoice_ids': [(4, credit_note.id)],
|
||||||
|
})
|
||||||
|
|
||||||
|
if refund_txn_id and refund_status not in ('PENDING',):
|
||||||
|
payment_data = {
|
||||||
|
'reference': refund_tx.reference,
|
||||||
|
'poynt_transaction_id': refund_txn_id,
|
||||||
|
'poynt_status': refund_status,
|
||||||
|
}
|
||||||
|
refund_tx._process('poynt', payment_data)
|
||||||
|
|
||||||
|
if (
|
||||||
|
credit_note.payment_state not in ('paid', 'in_payment')
|
||||||
|
and refund_tx.payment_id
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
(refund_tx.payment_id.move_id.line_ids + credit_note.line_ids).filtered(
|
||||||
|
lambda line: line.account_id == refund_tx.payment_id.destination_account_id
|
||||||
|
and not line.reconciled
|
||||||
|
).reconcile()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Fallback reconciliation failed for %s: %s",
|
||||||
|
order.name, e,
|
||||||
|
)
|
||||||
|
|
||||||
|
credit_note.sudo().poynt_refunded = True
|
||||||
|
credit_note.sudo().message_post(body=_(
|
||||||
|
"Deposit refund processed via Poynt. Amount: %s. "
|
||||||
|
"Transaction ID: %s.",
|
||||||
|
self._format_amount(abs(credit_note.amount_total), order),
|
||||||
|
refund_txn_id or 'N/A',
|
||||||
|
))
|
||||||
|
|
||||||
|
def _close_rental(self, order):
|
||||||
|
"""Close the rental after deposit processing."""
|
||||||
|
if not order.rental_closed:
|
||||||
|
try:
|
||||||
|
order.action_close_rental()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
"Auto-close after deposit processing failed for %s: %s",
|
||||||
|
order.name, e,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _format_amount(self, amount, order):
|
||||||
|
return self.env['ir.qweb.field.monetary'].value_to_html(
|
||||||
|
amount, {'display_currency': order.currency_id},
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,28 +5,70 @@
|
|||||||
<field name="name">deposit.deduction.wizard.form</field>
|
<field name="name">deposit.deduction.wizard.form</field>
|
||||||
<field name="model">deposit.deduction.wizard</field>
|
<field name="model">deposit.deduction.wizard</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Deduct Security Deposit">
|
<form string="Process Security Deposit">
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
<field name="order_id"/>
|
<field name="order_id" readonly="1"/>
|
||||||
<field name="deposit_total" widget="monetary"/>
|
<field name="partner_id" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="deduction_amount" widget="monetary"/>
|
<field name="deposit_total" widget="monetary"/>
|
||||||
<field name="remaining_preview" widget="monetary"/>
|
<field name="has_card_on_file" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<separator string="Action"/>
|
||||||
|
<group>
|
||||||
|
<field name="action_type" widget="radio"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Deduction fields (partial / no refund) -->
|
||||||
|
<group invisible="action_type not in ('partial_refund', 'no_refund')">
|
||||||
|
<group>
|
||||||
|
<field name="deduction_amount" widget="monetary"
|
||||||
|
required="action_type in ('partial_refund', 'no_refund')"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="remaining_preview" widget="monetary"
|
||||||
|
invisible="action_type != 'partial_refund'"/>
|
||||||
<field name="overage_preview" widget="monetary"
|
<field name="overage_preview" widget="monetary"
|
||||||
decoration-danger="overage_preview > 0"/>
|
decoration-danger="overage_preview > 0"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
|
||||||
<field name="reason" placeholder="Describe the damages or reason for deduction..."/>
|
<group invisible="action_type not in ('partial_refund', 'no_refund')">
|
||||||
|
<field name="reason"
|
||||||
|
required="action_type in ('partial_refund', 'no_refund')"
|
||||||
|
placeholder="Describe the damages or reason for deduction..."/>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<!-- Refund preview for full refund / sold -->
|
||||||
|
<group invisible="action_type not in ('full_refund', 'sold')">
|
||||||
|
<field name="refund_preview" widget="monetary" string="Amount to Refund"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Sold note -->
|
||||||
|
<div class="alert alert-info" role="alert"
|
||||||
|
invisible="action_type != 'sold'">
|
||||||
|
<i class="fa fa-info-circle me-2"/>
|
||||||
|
The full security deposit will be refunded to the customer's card.
|
||||||
|
The rental will be marked as sold and an activity will be created for follow-up.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refund method note -->
|
||||||
|
<div class="alert alert-warning" role="alert"
|
||||||
|
invisible="has_card_on_file or action_type in ('no_refund',)">
|
||||||
|
<i class="fa fa-exclamation-triangle me-2"/>
|
||||||
|
No card on file. The refund will need to be processed manually.
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<button name="action_confirm_deduction"
|
<button name="action_confirm"
|
||||||
string="Confirm Deduction"
|
string="Process Deposit"
|
||||||
type="object"
|
type="object"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
icon="fa-check"/>
|
icon="fa-check"
|
||||||
|
confirm="This will process the deposit and close the rental. Continue?"/>
|
||||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -42,6 +42,40 @@ class ManualRenewalWizard(models.TransientModel):
|
|||||||
compute='_compute_amount_preview',
|
compute='_compute_amount_preview',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
payment_token_id = fields.Many2one(
|
||||||
|
'payment.token',
|
||||||
|
string="Card on File",
|
||||||
|
domain="[('partner_id', '=', partner_id)]",
|
||||||
|
help="Select a stored card to charge automatically. "
|
||||||
|
"Leave empty to open the manual payment wizard.",
|
||||||
|
)
|
||||||
|
use_card_on_file = fields.Boolean(
|
||||||
|
string="Charge Card on File",
|
||||||
|
default=False,
|
||||||
|
compute='_compute_use_card_on_file',
|
||||||
|
store=True,
|
||||||
|
readonly=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_start_date = fields.Datetime(
|
||||||
|
string="Previous Start (rollback)",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
previous_return_date = fields.Datetime(
|
||||||
|
string="Previous Return (rollback)",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
renewal_invoice_id = fields.Many2one(
|
||||||
|
'account.move',
|
||||||
|
string="Renewal Invoice",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
renewal_log_id = fields.Many2one(
|
||||||
|
'rental.renewal.log',
|
||||||
|
string="Renewal Log",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
@api.depends('order_id', 'new_start_date', 'new_return_date')
|
@api.depends('order_id', 'new_start_date', 'new_return_date')
|
||||||
def _compute_amount_preview(self):
|
def _compute_amount_preview(self):
|
||||||
for wizard in self:
|
for wizard in self:
|
||||||
@@ -50,8 +84,13 @@ class ManualRenewalWizard(models.TransientModel):
|
|||||||
else:
|
else:
|
||||||
wizard.amount_preview = 0.0
|
wizard.amount_preview = 0.0
|
||||||
|
|
||||||
|
@api.depends('payment_token_id')
|
||||||
|
def _compute_use_card_on_file(self):
|
||||||
|
for wizard in self:
|
||||||
|
wizard.use_card_on_file = bool(wizard.payment_token_id)
|
||||||
|
|
||||||
def action_confirm_renewal(self):
|
def action_confirm_renewal(self):
|
||||||
"""Confirm the manual renewal: extend dates, invoice, and collect payment."""
|
"""Confirm the manual renewal: extend dates, create invoice, and collect payment."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
order = self.order_id
|
order = self.order_id
|
||||||
|
|
||||||
@@ -64,6 +103,11 @@ class ManualRenewalWizard(models.TransientModel):
|
|||||||
old_start = order.rental_start_date
|
old_start = order.rental_start_date
|
||||||
old_return = order.rental_return_date
|
old_return = order.rental_return_date
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'previous_start_date': old_start,
|
||||||
|
'previous_return_date': old_return,
|
||||||
|
})
|
||||||
|
|
||||||
order.write({
|
order.write({
|
||||||
'rental_start_date': self.new_start_date,
|
'rental_start_date': self.new_start_date,
|
||||||
'rental_return_date': self.new_return_date,
|
'rental_return_date': self.new_return_date,
|
||||||
@@ -71,8 +115,8 @@ class ManualRenewalWizard(models.TransientModel):
|
|||||||
order._recompute_rental_prices()
|
order._recompute_rental_prices()
|
||||||
|
|
||||||
invoice = order._create_renewal_invoice()
|
invoice = order._create_renewal_invoice()
|
||||||
if invoice:
|
if not invoice:
|
||||||
invoice.action_post()
|
raise UserError(_("Could not create renewal invoice."))
|
||||||
|
|
||||||
renewal_log = self.env['rental.renewal.log'].create({
|
renewal_log = self.env['rental.renewal.log'].create({
|
||||||
'order_id': order.id,
|
'order_id': order.id,
|
||||||
@@ -81,21 +125,77 @@ class ManualRenewalWizard(models.TransientModel):
|
|||||||
'previous_return_date': old_return,
|
'previous_return_date': old_return,
|
||||||
'new_start_date': self.new_start_date,
|
'new_start_date': self.new_start_date,
|
||||||
'new_return_date': self.new_return_date,
|
'new_return_date': self.new_return_date,
|
||||||
'invoice_id': invoice.id if invoice else False,
|
'invoice_id': invoice.id,
|
||||||
'renewal_type': 'manual',
|
'renewal_type': 'manual',
|
||||||
'state': 'done',
|
'state': 'draft',
|
||||||
'payment_status': 'pending',
|
'payment_status': 'pending',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
self.write({
|
||||||
|
'renewal_invoice_id': invoice.id,
|
||||||
|
'renewal_log_id': renewal_log.id,
|
||||||
|
})
|
||||||
|
|
||||||
order.write({
|
order.write({
|
||||||
'rental_renewal_count': order.rental_renewal_count + 1,
|
'rental_renewal_count': order.rental_renewal_count + 1,
|
||||||
'rental_reminder_sent': False,
|
'rental_reminder_sent': False,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if self.use_card_on_file and self.payment_token_id:
|
||||||
|
invoice.action_post()
|
||||||
|
ok = order._collect_token_payment_for_invoice(invoice)
|
||||||
|
if ok:
|
||||||
|
renewal_log.write({
|
||||||
|
'state': 'done',
|
||||||
|
'payment_status': 'paid',
|
||||||
|
})
|
||||||
|
order._send_invoice_with_receipt(invoice, 'renewal')
|
||||||
|
order._send_renewal_confirmation_email(renewal_log, True)
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
else:
|
||||||
|
renewal_log.write({
|
||||||
|
'state': 'done',
|
||||||
|
'payment_status': 'failed',
|
||||||
|
})
|
||||||
|
order._send_renewal_confirmation_email(renewal_log, False)
|
||||||
|
order._notify_staff_manual_payment(invoice)
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
invoice.action_post()
|
||||||
|
renewal_log.write({'state': 'done'})
|
||||||
order._send_renewal_confirmation_email(renewal_log, False)
|
order._send_renewal_confirmation_email(renewal_log, False)
|
||||||
|
|
||||||
if invoice:
|
inv = invoice.with_user(self.env.user)
|
||||||
inv = invoice.with_user(self.env.uid)
|
return inv.action_open_poynt_payment_wizard()
|
||||||
return inv.action_open_poynt_payment_wizard()
|
|
||||||
|
|
||||||
|
def action_cancel_renewal(self):
|
||||||
|
"""Cancel the renewal: revert dates and void the invoice."""
|
||||||
|
self.ensure_one()
|
||||||
|
order = self.order_id
|
||||||
|
|
||||||
|
if self.renewal_invoice_id:
|
||||||
|
inv = self.renewal_invoice_id
|
||||||
|
if inv.state == 'posted' and inv.payment_state in ('not_paid', 'partial'):
|
||||||
|
inv.button_draft()
|
||||||
|
if inv.state == 'draft':
|
||||||
|
inv.button_cancel()
|
||||||
|
|
||||||
|
if self.previous_start_date and self.previous_return_date:
|
||||||
|
order.write({
|
||||||
|
'rental_start_date': self.previous_start_date,
|
||||||
|
'rental_return_date': self.previous_return_date,
|
||||||
|
})
|
||||||
|
order._recompute_rental_prices()
|
||||||
|
|
||||||
|
if self.renewal_log_id:
|
||||||
|
self.renewal_log_id.write({
|
||||||
|
'state': 'failed',
|
||||||
|
'payment_status': 'failed',
|
||||||
|
'notes': 'Cancelled by user.',
|
||||||
|
})
|
||||||
|
|
||||||
|
if order.rental_renewal_count > 0:
|
||||||
|
order.rental_renewal_count -= 1
|
||||||
|
|
||||||
|
order.message_post(body=_("Manual renewal cancelled and reverted."))
|
||||||
return {'type': 'ir.actions.act_window_close'}
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Manual Renewal Wizard Form -->
|
|
||||||
<record id="manual_renewal_wizard_view_form" model="ir.ui.view">
|
<record id="manual_renewal_wizard_view_form" model="ir.ui.view">
|
||||||
<field name="name">manual.renewal.wizard.form</field>
|
<field name="name">manual.renewal.wizard.form</field>
|
||||||
<field name="model">manual.renewal.wizard</field>
|
<field name="model">manual.renewal.wizard</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Renew Rental">
|
<form string="Renew Rental">
|
||||||
|
<field name="previous_start_date" invisible="1"/>
|
||||||
|
<field name="previous_return_date" invisible="1"/>
|
||||||
|
<field name="renewal_invoice_id" invisible="1"/>
|
||||||
|
<field name="renewal_log_id" invisible="1"/>
|
||||||
|
|
||||||
<group>
|
<group>
|
||||||
<field name="order_id" readonly="1"/>
|
<field name="order_id" readonly="1"/>
|
||||||
<field name="partner_id" readonly="1"/>
|
<field name="partner_id" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<separator string="Current Rental Period"/>
|
<separator string="Current Rental Period"/>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
@@ -20,6 +25,7 @@
|
|||||||
<field name="current_return_date" readonly="1"/>
|
<field name="current_return_date" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<separator string="New Rental Period"/>
|
<separator string="New Rental Period"/>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
@@ -29,15 +35,36 @@
|
|||||||
<field name="new_return_date"/>
|
<field name="new_return_date"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
<group>
|
<group>
|
||||||
<field name="amount_preview" widget="monetary"/>
|
<field name="amount_preview" widget="monetary"/>
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<separator string="Payment"/>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="payment_token_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="use_card_on_file" widget="boolean_toggle"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<button name="action_confirm_renewal"
|
<button name="action_confirm_renewal"
|
||||||
type="object"
|
type="object"
|
||||||
string="Confirm Renewal & Collect Payment"
|
string="Confirm Renewal & Collect Payment"
|
||||||
class="btn-primary"/>
|
class="btn-primary"
|
||||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
invisible="not use_card_on_file"/>
|
||||||
|
<button name="action_confirm_renewal"
|
||||||
|
type="object"
|
||||||
|
string="Confirm Renewal & Open Payment"
|
||||||
|
class="btn-primary"
|
||||||
|
invisible="use_card_on_file"/>
|
||||||
|
<button name="action_cancel_renewal"
|
||||||
|
type="object"
|
||||||
|
string="Cancel"
|
||||||
|
class="btn-secondary"/>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
230
fusion_rental/wizard/rental_return_wizard.py
Normal file
230
fusion_rental/wizard/rental_return_wizard.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
from odoo.tools import float_compare
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RentalReturnWizard(models.TransientModel):
|
||||||
|
_name = 'rental.return.wizard'
|
||||||
|
_description = 'Rental Return & Inspection'
|
||||||
|
|
||||||
|
order_id = fields.Many2one(
|
||||||
|
'sale.order',
|
||||||
|
string="Rental Order",
|
||||||
|
required=True,
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
partner_id = fields.Many2one(related='order_id.partner_id')
|
||||||
|
currency_id = fields.Many2one(related='order_id.currency_id')
|
||||||
|
|
||||||
|
line_ids = fields.One2many(
|
||||||
|
'rental.return.wizard.line',
|
||||||
|
'wizard_id',
|
||||||
|
string="Items to Return",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
inspection_condition = fields.Selection(
|
||||||
|
[
|
||||||
|
('good', 'Good - No Issues'),
|
||||||
|
('fair', 'Fair - Minor Wear'),
|
||||||
|
('damaged', 'Damaged'),
|
||||||
|
],
|
||||||
|
string="Product Condition",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
inspection_notes = fields.Text(
|
||||||
|
string="Inspection Notes",
|
||||||
|
help="Describe the condition of the returned items.",
|
||||||
|
)
|
||||||
|
inspection_photo_ids = fields.Many2many(
|
||||||
|
'ir.attachment',
|
||||||
|
'rental_return_wizard_photo_rel',
|
||||||
|
'wizard_id',
|
||||||
|
'attachment_id',
|
||||||
|
string="Inspection Photos",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields_list):
|
||||||
|
res = super().default_get(fields_list)
|
||||||
|
order_id = res.get('order_id') or self.env.context.get('default_order_id')
|
||||||
|
if not order_id:
|
||||||
|
return res
|
||||||
|
|
||||||
|
order = self.env['sale.order'].browse(order_id)
|
||||||
|
precision = self.env['decimal.precision'].precision_get('Product Unit')
|
||||||
|
|
||||||
|
lines_vals = []
|
||||||
|
for line in order.order_line.filtered(
|
||||||
|
lambda r: r.is_rental
|
||||||
|
and r.product_type != 'combo'
|
||||||
|
and float_compare(
|
||||||
|
r.qty_delivered, r.qty_returned,
|
||||||
|
precision_digits=precision,
|
||||||
|
) > 0
|
||||||
|
):
|
||||||
|
lines_vals.append((0, 0, {
|
||||||
|
'sale_line_id': line.id,
|
||||||
|
'product_id': line.product_id.id,
|
||||||
|
'description': line.name,
|
||||||
|
'qty_delivered': line.qty_delivered,
|
||||||
|
'qty_returned': line.qty_returned,
|
||||||
|
'qty_to_return': line.qty_delivered - line.qty_returned,
|
||||||
|
}))
|
||||||
|
|
||||||
|
res['line_ids'] = lines_vals
|
||||||
|
return res
|
||||||
|
|
||||||
|
def action_confirm(self):
|
||||||
|
"""Validate inspection, apply results, and process the return."""
|
||||||
|
self.ensure_one()
|
||||||
|
order = self.order_id
|
||||||
|
|
||||||
|
if not self.inspection_photo_ids:
|
||||||
|
raise UserError(_(
|
||||||
|
"Inspection photos are required. Please attach at least one "
|
||||||
|
"photo showing the condition of the returned items."
|
||||||
|
))
|
||||||
|
|
||||||
|
if self.inspection_condition in ('fair', 'damaged') and not self.inspection_notes:
|
||||||
|
raise UserError(_(
|
||||||
|
"Please provide inspection notes describing the issue "
|
||||||
|
"when condition is '%s'.",
|
||||||
|
dict(self._fields['inspection_condition'].selection).get(
|
||||||
|
self.inspection_condition, self.inspection_condition,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
self._apply_inspection(order)
|
||||||
|
self._process_return(order)
|
||||||
|
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
|
||||||
|
def _apply_inspection(self, order):
|
||||||
|
"""Write inspection results to the sale order."""
|
||||||
|
if self.inspection_condition == 'good':
|
||||||
|
status = 'passed'
|
||||||
|
else:
|
||||||
|
status = 'flagged'
|
||||||
|
|
||||||
|
order.write({
|
||||||
|
'rental_inspection_status': status,
|
||||||
|
'rental_inspection_notes': self.inspection_notes or '',
|
||||||
|
'rental_inspection_photo_ids': [(6, 0, self.inspection_photo_ids.ids)],
|
||||||
|
})
|
||||||
|
|
||||||
|
if status == 'passed':
|
||||||
|
order.message_post(body=_(
|
||||||
|
"Return inspection completed: condition is good. "
|
||||||
|
"Security deposit refund process initiated."
|
||||||
|
))
|
||||||
|
if order.rental_deposit_status == 'collected':
|
||||||
|
order._refund_security_deposit()
|
||||||
|
else:
|
||||||
|
condition_label = dict(
|
||||||
|
self._fields['inspection_condition'].selection,
|
||||||
|
).get(self.inspection_condition, self.inspection_condition)
|
||||||
|
|
||||||
|
order.message_post(body=_(
|
||||||
|
"Return inspection completed: condition is '%s'. "
|
||||||
|
"Flagged for review.\nNotes: %s",
|
||||||
|
condition_label,
|
||||||
|
self.inspection_notes or '-',
|
||||||
|
))
|
||||||
|
|
||||||
|
order.activity_schedule(
|
||||||
|
'mail.mail_activity_data_todo',
|
||||||
|
date_deadline=fields.Date.today(),
|
||||||
|
summary=_("Return inspection flagged - %s", order.name),
|
||||||
|
note=_(
|
||||||
|
"Returned items from %s inspected as '%s'. "
|
||||||
|
"Review the inspection photos and process the deposit.\n"
|
||||||
|
"Notes: %s",
|
||||||
|
order.partner_id.name,
|
||||||
|
condition_label,
|
||||||
|
self.inspection_notes or '-',
|
||||||
|
),
|
||||||
|
user_id=order.user_id.id or self.env.uid,
|
||||||
|
)
|
||||||
|
order._send_damage_notification_email()
|
||||||
|
|
||||||
|
def _process_return(self, order):
|
||||||
|
"""Mark items as returned via stock picking or rental wizard."""
|
||||||
|
picking = order.picking_ids.filtered(
|
||||||
|
lambda p: p.state == 'assigned'
|
||||||
|
and p.picking_type_code == 'incoming'
|
||||||
|
)
|
||||||
|
|
||||||
|
if picking:
|
||||||
|
picking = picking[:1]
|
||||||
|
for move in picking.move_ids.filtered(
|
||||||
|
lambda m: m.state not in ('done', 'cancel')
|
||||||
|
):
|
||||||
|
if move.product_uom.is_zero(move.quantity):
|
||||||
|
move.quantity = move.product_uom_qty
|
||||||
|
try:
|
||||||
|
picking.with_context(
|
||||||
|
skip_sanity_check=False,
|
||||||
|
cancel_backorder=True,
|
||||||
|
).button_validate()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(
|
||||||
|
"Auto-validate return picking %s for %s failed: %s",
|
||||||
|
picking.name, order.name, e,
|
||||||
|
)
|
||||||
|
raise UserError(_(
|
||||||
|
"Could not automatically process the return picking. "
|
||||||
|
"Please validate the return picking manually.\n\n"
|
||||||
|
"Inspection has been saved successfully.\n\n"
|
||||||
|
"Error: %s", str(e),
|
||||||
|
)) from e
|
||||||
|
return
|
||||||
|
|
||||||
|
precision = self.env['decimal.precision'].precision_get(
|
||||||
|
'Product Unit',
|
||||||
|
)
|
||||||
|
for line in self.line_ids:
|
||||||
|
sol = line.sale_line_id
|
||||||
|
if float_compare(
|
||||||
|
sol.qty_delivered, sol.qty_returned,
|
||||||
|
precision_digits=precision,
|
||||||
|
) > 0:
|
||||||
|
sol.qty_returned = sol.qty_delivered
|
||||||
|
|
||||||
|
|
||||||
|
class RentalReturnWizardLine(models.TransientModel):
|
||||||
|
_name = 'rental.return.wizard.line'
|
||||||
|
_description = 'Rental Return Wizard Line'
|
||||||
|
|
||||||
|
wizard_id = fields.Many2one(
|
||||||
|
'rental.return.wizard',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
)
|
||||||
|
sale_line_id = fields.Many2one(
|
||||||
|
'sale.order.line',
|
||||||
|
string="Order Line",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
product_id = fields.Many2one(
|
||||||
|
'product.product',
|
||||||
|
string="Product",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
description = fields.Char(readonly=True)
|
||||||
|
qty_delivered = fields.Float(
|
||||||
|
string="Qty Delivered",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
qty_returned = fields.Float(
|
||||||
|
string="Already Returned",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
qty_to_return = fields.Float(
|
||||||
|
string="Qty to Return",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
74
fusion_rental/wizard/rental_return_wizard_views.xml
Normal file
74
fusion_rental/wizard/rental_return_wizard_views.xml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="rental_return_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">rental.return.wizard.form</field>
|
||||||
|
<field name="model">rental.return.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Return & Inspection">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="order_id"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<separator string="Items Being Returned"/>
|
||||||
|
<field name="line_ids" nolabel="1" readonly="1">
|
||||||
|
<list>
|
||||||
|
<field name="product_id"/>
|
||||||
|
<field name="description"/>
|
||||||
|
<field name="qty_delivered"/>
|
||||||
|
<field name="qty_returned"/>
|
||||||
|
<field name="qty_to_return"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
|
||||||
|
<separator string="Inspection"/>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="inspection_condition" widget="radio"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="inspection_notes"
|
||||||
|
placeholder="Describe any issues, damage, missing parts..."
|
||||||
|
invisible="inspection_condition == 'good'"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<group string="Inspection Photos (Required)">
|
||||||
|
<field name="inspection_photo_ids"
|
||||||
|
widget="inspection_photos"
|
||||||
|
nolabel="1"
|
||||||
|
class="o_inspection_photos"
|
||||||
|
options="{'accepted_file_extensions': 'image/*'}"/>
|
||||||
|
<div class="text-muted small">
|
||||||
|
Click the attach button to add multiple photos.
|
||||||
|
Click any photo thumbnail to preview full size.
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<div class="alert alert-info" role="alert"
|
||||||
|
invisible="inspection_condition != 'good'">
|
||||||
|
Items are in good condition. The security deposit refund process
|
||||||
|
will be initiated automatically after confirmation.
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning" role="alert"
|
||||||
|
invisible="inspection_condition not in ('fair', 'damaged')">
|
||||||
|
Items have been flagged. A staff activity will be created
|
||||||
|
for review and the customer will be notified.
|
||||||
|
Please provide detailed notes and photos.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button name="action_confirm"
|
||||||
|
type="object"
|
||||||
|
class="btn-primary"
|
||||||
|
string="Confirm Return & Inspection"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
1
fusion_whitelabels/__init__.py
Normal file
1
fusion_whitelabels/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
30
fusion_whitelabels/__manifest__.py
Normal file
30
fusion_whitelabels/__manifest__.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
"name": "Fusion Whitelabels",
|
||||||
|
"version": "19.0.1.3.0",
|
||||||
|
"category": "Website",
|
||||||
|
"summary": "Remove Odoo frontend promotional branding for portal and website pages.",
|
||||||
|
"description": """
|
||||||
|
Fusion Whitelabels
|
||||||
|
==================
|
||||||
|
|
||||||
|
Persistent Odoo 19 whitelabel customizations:
|
||||||
|
- Removes "Connect with your software" portal promotions.
|
||||||
|
- Removes global "Powered by Odoo" website/footer promotions.
|
||||||
|
- Removes login-page "Powered by Odoo" footer link.
|
||||||
|
""",
|
||||||
|
"author": "Fusion",
|
||||||
|
"license": "LGPL-3",
|
||||||
|
"depends": ["portal", "sale", "purchase", "website", "website_sale"],
|
||||||
|
"data": [
|
||||||
|
"views/fusion_whitelabels_templates.xml",
|
||||||
|
],
|
||||||
|
"assets": {
|
||||||
|
"web.assets_frontend": [
|
||||||
|
"fusion_whitelabels/static/src/css/whitelabel.css",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"installable": True,
|
||||||
|
"auto_install": False,
|
||||||
|
"application": False,
|
||||||
|
}
|
||||||
3
fusion_whitelabels/static/src/css/whitelabel.css
Normal file
3
fusion_whitelabels/static/src/css/whitelabel.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.o-mail-Thread-jumpPresent {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
27
fusion_whitelabels/views/fusion_whitelabels_templates.xml
Normal file
27
fusion_whitelabels/views/fusion_whitelabels_templates.xml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="fusion_whitelabels_sale_portal" inherit_id="sale.sale_order_portal_template" priority="999">
|
||||||
|
<xpath expr="//div[@t-if='sale_order._get_edi_builders()']" position="replace"/>
|
||||||
|
<xpath expr="//div[@id='sale_portal_connect_software_modal']" position="replace"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="fusion_whitelabels_sale_report" inherit_id="sale.report_saleorder_document" priority="999">
|
||||||
|
<xpath expr="//div[@t-if="any(u._is_portal() for u in doc.partner_id.user_ids) and doc._get_edi_builders()"]" position="replace"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="fusion_whitelabels_purchase_portal" inherit_id="purchase.portal_my_purchase_order" priority="999">
|
||||||
|
<xpath expr="//div[@t-if='order._get_edi_builders()']" position="replace"/>
|
||||||
|
<xpath expr="//div[@id='portal_connect_software_modal']" position="replace"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="fusion_whitelabels_portal_sidebar" inherit_id="portal.portal_record_sidebar" priority="999">
|
||||||
|
<xpath expr="//div[hasclass('d-none','d-lg-block','mt-5','small','text-center','text-muted')]" position="replace"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="fusion_whitelabels_hide_brand_promotion" inherit_id="web.brand_promotion" priority="999">
|
||||||
|
<xpath expr="//div[hasclass('o_brand_promotion')]" position="attributes">
|
||||||
|
<attribute name="style">display:none !important;</attribute>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user