+ The error occurred while rendering the template fusion_quotations.portal_quotation_list
+ and evaluating the following expression: <t t-out="dict(a._fields[\'equipment_type\'].selection).get(a.equipment_type, a.equipment_type or \'\')"/>
+
+
Error while rendering the template:
+ ValueError: dictionary update sequence element #0 has length 1; 2 is required
+ Template: fusion_quotations.portal_quotation_list
+ Reference: 13488
+ Path: /t/t/div/t[2]/table/tbody/t/tr/td[3]/t
+ Element: <t t-out="dict(a._fields[\'equipment_type\'].selection).get(a.equipment_type, a.equipment_type or \'\')"/>
+ From: (13488, '/t/t', '<t t-call="portal.portal_layout"/>')
+ (13488, '/t/t/div/t[2]/table/tbody/t/tr/td[3]/t', '<t t-out="dict(a._fields[\\\'equipment_type\\\'].selection).get(a.equipment_type, a.equipment_type or \\\'\\\')"/>')
Traceback (most recent call last):
+ File "/usr/lib/python3/dist-packages/odoo/addons/base/models/ir_qweb.py", line 753, in _render_iterall
+ for item in frame.iterator:
+ File "<13488>", line 152, in template_fusion_quotations_portal_quotation_list_13488_t_call_0
+ValueError: dictionary update sequence element #0 has length 1; 2 is required
+
+The above exception was the direct cause of the following exception:
+
+Traceback (most recent call last):
+ File "/usr/lib/python3/dist-packages/odoo/http.py", line 2275, in _serve_db
+ return service_model.retrying(serve_func, env=self.env)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/service/model.py", line 184, in retrying
+ result = func()
+ ^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/http.py", line 2330, in _serve_ir_http
+ response = self.dispatcher.dispatch(rule.endpoint, args)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/http.py", line 2452, in dispatch
+ return self.request.registry['ir.http']._dispatch(endpoint)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/addons/base/models/ir_http.py", line 357, in _dispatch
+ result.flatten()
+ File "/usr/lib/python3/dist-packages/odoo/tools/facade.py", line 83, in wrap_func
+ func(self._wrapped__, *args, **kwargs)
+ File "/usr/lib/python3/dist-packages/odoo/http.py", line 1546, in flatten
+ self.response.append(self.render())
+ ^^^^^^^^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/http.py", line 1538, in render
+ return request.env["ir.ui.view"]._render_template(self.template, self.qcontext)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/addons/website/models/ir_ui_view.py", line 456, in _render_template
+ return super()._render_template(template, values=values)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/addons/base/models/ir_ui_view.py", line 2531, in _render_template
+ return self.env['ir.qweb']._render(template, values)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/mnt/enterprise-addons/web_studio/models/ir_qweb.py", line 14, in _render
+ return super()._render(template, values, **options)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/addons/base/models/ir_qweb.py", line 725, in _render
+ return Markup(''.join(iterator))
+ ^^^^^^^^^^^^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/addons/base/models/ir_qweb.py", line 753, in _render_iterall
+ for item in frame.iterator:
+ File "<13488>", line 264, in template_fusion_quotations_portal_quotation_list_13488
+ File "<13488>", line 250, in template_fusion_quotations_portal_quotation_list_13488_content
+ File "/usr/lib/python3/dist-packages/odoo/addons/base/models/ir_qweb.py", line 616, in __str__
+ self.html = ''.join(self.irQweb._render_iterall(
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ File "/usr/lib/python3/dist-packages/odoo/addons/base/models/ir_qweb.py", line 847, in _render_iterall
+ raise QWebError(qweb_error_info) from error
+odoo.addons.base.models.ir_qweb.QWebError: Error while rendering the template:
+ ValueError: dictionary update sequence element #0 has length 1; 2 is required
+ Template: fusion_quotations.portal_quotation_list
+ Reference: 13488
+ Path: /t/t/div/t[2]/table/tbody/t/tr/td[3]/t
+ Element: <t t-out="dict(a._fields[\'equipment_type\'].selection).get(a.equipment_type, a.equipment_type or \'\')"/>
+ From: (13488, '/t/t', '<t t-call="portal.portal_layout"/>')
+ (13488, '/t/t/div/t[2]/table/tbody/t/tr/td[3]/t', '<t t-out="dict(a._fields[\\\'equipment_type\\\'].selection).get(a.equipment_type, a.equipment_type or \\\'\\\')"/>')
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/fusion_accounts/static/description/icon.png b/fusion_accounts/static/description/icon.png
index 71436f5e..49b08904 100644
Binary files a/fusion_accounts/static/description/icon.png and b/fusion_accounts/static/description/icon.png differ
diff --git a/fusion_authorizer_portal/__manifest__.py b/fusion_authorizer_portal/__manifest__.py
index 2e3431b7..6b732bc8 100644
--- a/fusion_authorizer_portal/__manifest__.py
+++ b/fusion_authorizer_portal/__manifest__.py
@@ -53,6 +53,7 @@ This module provides external portal access for:
'appointment',
'knowledge',
'fusion_claims',
+ 'fusion_tasks',
],
'data': [
# Security
@@ -80,6 +81,7 @@ This module provides external portal access for:
'views/portal_book_assessment.xml',
'views/portal_repair_form.xml',
'views/portal_schedule.xml',
+ 'views/portal_page11_sign_templates.xml',
],
'assets': {
'web.assets_backend': [
diff --git a/fusion_authorizer_portal/controllers/__init__.py b/fusion_authorizer_portal/controllers/__init__.py
index 92e669f9..c8d40284 100644
--- a/fusion_authorizer_portal/controllers/__init__.py
+++ b/fusion_authorizer_portal/controllers/__init__.py
@@ -4,4 +4,5 @@ from . import portal_main
from . import portal_assessment
from . import pdf_editor
from . import portal_repair
-from . import portal_schedule
\ No newline at end of file
+from . import portal_schedule
+from . import portal_page11_sign
\ No newline at end of file
diff --git a/fusion_authorizer_portal/controllers/portal_main.py b/fusion_authorizer_portal/controllers/portal_main.py
index 0e62b4a0..cb6c9276 100644
--- a/fusion_authorizer_portal/controllers/portal_main.py
+++ b/fusion_authorizer_portal/controllers/portal_main.py
@@ -1501,6 +1501,13 @@ class AuthorizerPortal(CustomerPortal):
accuracy=accuracy,
)
+ # Push location to remote instances for cross-instance visibility
+ try:
+ request.env['fusion.task.sync.config'].sudo()._push_technician_location(
+ user.id, latitude, longitude, accuracy or 0)
+ except Exception:
+ pass # Non-blocking: sync failure should not block task action
+
location_ctx = {
'action_latitude': latitude,
'action_longitude': longitude,
@@ -1870,6 +1877,25 @@ class AuthorizerPortal(CustomerPortal):
_logger.warning(f"Location log error: {e}")
return {'success': False}
+ @http.route('/my/technician/clock-status', type='json', auth='user', website=True)
+ def technician_clock_status(self, **kw):
+ """Check if the current technician is clocked in.
+
+ Returns {clocked_in: bool} so the JS background logger can decide
+ whether to track location. Replaces the fixed 9-6 hour window.
+ """
+ if not self._check_technician_access():
+ return {'clocked_in': False}
+ try:
+ emp = request.env['hr.employee'].sudo().search([
+ ('user_id', '=', request.env.user.id),
+ ], limit=1)
+ if emp and emp.attendance_state == 'checked_in':
+ return {'clocked_in': True}
+ except Exception:
+ pass
+ return {'clocked_in': False}
+
@http.route('/my/technician/settings/start-location', type='json', auth='user', website=True)
def technician_save_start_location(self, address='', **kw):
"""Save the technician's personal start location."""
diff --git a/fusion_authorizer_portal/controllers/portal_page11_sign.py b/fusion_authorizer_portal/controllers/portal_page11_sign.py
new file mode 100644
index 00000000..ab1deddd
--- /dev/null
+++ b/fusion_authorizer_portal/controllers/portal_page11_sign.py
@@ -0,0 +1,206 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+import base64
+import json
+import logging
+
+from odoo import http, fields, _
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+class Page11PublicSignController(http.Controller):
+
+ def _get_sign_request(self, token):
+ """Look up and validate a signing request by token."""
+ req = request.env['fusion.page11.sign.request'].sudo().search([
+ ('access_token', '=', token),
+ ], limit=1)
+ if not req:
+ return None, 'not_found'
+ if req.state == 'signed':
+ return req, 'already_signed'
+ if req.state == 'cancelled':
+ return req, 'cancelled'
+ if req.state == 'expired' or (
+ req.expiry_date and req.expiry_date < fields.Datetime.now()
+ ):
+ if req.state != 'expired':
+ req.state = 'expired'
+ return req, 'expired'
+ return req, 'ok'
+
+ @http.route('/page11/sign/', type='http', auth='public',
+ website=True, sitemap=False)
+ def page11_sign_form(self, token, **kw):
+ """Display the Page 11 signing form."""
+ sign_req, status = self._get_sign_request(token)
+
+ if status == 'not_found':
+ return request.render(
+ 'fusion_authorizer_portal.portal_page11_sign_invalid', {}
+ )
+
+ if status in ('expired', 'cancelled'):
+ return request.render(
+ 'fusion_authorizer_portal.portal_page11_sign_expired',
+ {'sign_request': sign_req},
+ )
+
+ if status == 'already_signed':
+ return request.render(
+ 'fusion_authorizer_portal.portal_page11_sign_success',
+ {'sign_request': sign_req, 'token': token},
+ )
+
+ order = sign_req.sale_order_id
+ partner = order.partner_id
+
+ assessment = request.env['fusion.assessment'].sudo().search([
+ ('sale_order_id', '=', order.id),
+ ], limit=1, order='create_date desc')
+
+ ICP = request.env['ir.config_parameter'].sudo()
+ google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
+
+ client_first_name = ''
+ client_last_name = ''
+ client_middle_name = ''
+ client_health_card = ''
+ client_health_card_version = ''
+
+ if assessment:
+ client_first_name = assessment.client_first_name or ''
+ client_last_name = assessment.client_last_name or ''
+ client_middle_name = assessment.client_middle_name or ''
+ client_health_card = assessment.client_health_card or ''
+ client_health_card_version = assessment.client_health_card_version or ''
+ else:
+ first, last = order._get_client_name_parts()
+ client_first_name = first
+ client_last_name = last
+
+ values = {
+ 'sign_request': sign_req,
+ 'order': order,
+ 'partner': partner,
+ 'assessment': assessment,
+ 'company': order.company_id,
+ 'token': token,
+ 'signer_type': sign_req.signer_type,
+ 'is_agent': sign_req.signer_type != 'client',
+ 'google_maps_api_key': google_maps_api_key,
+ 'client_first_name': client_first_name,
+ 'client_last_name': client_last_name,
+ 'client_middle_name': client_middle_name,
+ 'client_health_card': client_health_card,
+ 'client_health_card_version': client_health_card_version,
+ }
+ return request.render(
+ 'fusion_authorizer_portal.portal_page11_public_sign', values,
+ )
+
+ @http.route('/page11/sign//submit', type='http',
+ auth='public', methods=['POST'], website=True,
+ csrf=True, sitemap=False)
+ def page11_sign_submit(self, token, **post):
+ """Process the submitted Page 11 signature."""
+ sign_req, status = self._get_sign_request(token)
+
+ if status != 'ok':
+ return request.redirect(f'/page11/sign/{token}')
+
+ signature_data = post.get('signature_data', '')
+ if not signature_data:
+ return request.redirect(f'/page11/sign/{token}?error=no_signature')
+
+ if signature_data.startswith('data:image'):
+ signature_data = signature_data.split(',', 1)[1]
+
+ consent_accepted = post.get('consent_declaration', '') == 'on'
+ if not consent_accepted:
+ return request.redirect(f'/page11/sign/{token}?error=no_consent')
+
+ signer_name = post.get('signer_name', sign_req.signer_name or '')
+ chosen_signer_type = post.get('signer_type', sign_req.signer_type or 'client')
+ consent_signed_by = 'applicant' if chosen_signer_type == 'client' else 'agent'
+
+ signer_type_labels = {
+ 'spouse': 'Spouse', 'parent': 'Parent',
+ 'legal_guardian': 'Legal Guardian',
+ 'poa': 'Power of Attorney',
+ 'public_trustee': 'Public Trustee',
+ }
+
+ vals = {
+ 'signature_data': signature_data,
+ 'signer_name': signer_name,
+ 'signer_type': chosen_signer_type,
+ 'consent_declaration_accepted': True,
+ 'consent_signed_by': consent_signed_by,
+ 'signed_date': fields.Datetime.now(),
+ 'state': 'signed',
+ 'client_first_name': post.get('client_first_name', ''),
+ 'client_last_name': post.get('client_last_name', ''),
+ 'client_health_card': post.get('client_health_card', ''),
+ 'client_health_card_version': post.get('client_health_card_version', ''),
+ }
+
+ if consent_signed_by == 'agent':
+ vals.update({
+ 'agent_first_name': post.get('agent_first_name', ''),
+ 'agent_last_name': post.get('agent_last_name', ''),
+ 'agent_middle_initial': post.get('agent_middle_initial', ''),
+ 'agent_phone': post.get('agent_phone', ''),
+ 'agent_unit': post.get('agent_unit', ''),
+ 'agent_street_number': post.get('agent_street_number', ''),
+ 'agent_street': post.get('agent_street', ''),
+ 'agent_city': post.get('agent_city', ''),
+ 'agent_province': post.get('agent_province', 'Ontario'),
+ 'agent_postal_code': post.get('agent_postal_code', ''),
+ 'signer_relationship': signer_type_labels.get(chosen_signer_type, chosen_signer_type),
+ })
+
+ sign_req.sudo().write(vals)
+
+ try:
+ sign_req.sudo()._generate_signed_pdf()
+ except Exception as e:
+ _logger.error("PDF generation failed for sign request %s: %s", sign_req.id, e)
+
+ try:
+ sign_req.sudo()._update_sale_order()
+ except Exception as e:
+ _logger.error("Sale order update failed for sign request %s: %s", sign_req.id, e)
+
+ return request.render(
+ 'fusion_authorizer_portal.portal_page11_sign_success',
+ {'sign_request': sign_req, 'token': token},
+ )
+
+ @http.route('/page11/sign//download', type='http',
+ auth='public', website=True, sitemap=False)
+ def page11_download_pdf(self, token, **kw):
+ """Download the signed Page 11 PDF."""
+ sign_req = request.env['fusion.page11.sign.request'].sudo().search([
+ ('access_token', '=', token),
+ ('state', '=', 'signed'),
+ ], limit=1)
+
+ if not sign_req or not sign_req.signed_pdf:
+ return request.redirect(f'/page11/sign/{token}')
+
+ pdf_content = base64.b64decode(sign_req.signed_pdf)
+ filename = sign_req.signed_pdf_filename or 'Page11_Signed.pdf'
+
+ return request.make_response(
+ pdf_content,
+ headers=[
+ ('Content-Type', 'application/pdf'),
+ ('Content-Disposition', f'attachment; filename="{filename}"'),
+ ('Content-Length', str(len(pdf_content))),
+ ],
+ )
diff --git a/fusion_authorizer_portal/models/res_partner.py b/fusion_authorizer_portal/models/res_partner.py
index 5dfc571e..82342f5c 100644
--- a/fusion_authorizer_portal/models/res_partner.py
+++ b/fusion_authorizer_portal/models/res_partner.py
@@ -160,7 +160,7 @@ class ResPartner(models.Model):
if self.is_technician_portal:
# Add Field Technician group
- g = self.env.ref('fusion_claims.group_field_technician', raise_if_not_found=False)
+ g = self.env.ref('fusion_tasks.group_field_technician', raise_if_not_found=False)
if g and g not in internal_user.group_ids:
internal_user.sudo().write({'group_ids': [(4, g.id)]})
added.append('Field Technician')
diff --git a/fusion_authorizer_portal/static/src/js/technician_location.js b/fusion_authorizer_portal/static/src/js/technician_location.js
index 2464b4c0..648c2be0 100644
--- a/fusion_authorizer_portal/static/src/js/technician_location.js
+++ b/fusion_authorizer_portal/static/src/js/technician_location.js
@@ -1,7 +1,7 @@
/**
* Technician Location Services
*
- * 1. Background logger -- logs GPS every 5 minutes during working hours.
+ * 1. Background logger -- logs GPS every 5 minutes while the tech is clocked in.
* 2. getLocation() -- returns a Promise that resolves to {latitude, longitude, accuracy}.
* If the user denies permission or the request times out a blocking modal is shown
* and the promise is rejected.
@@ -11,9 +11,10 @@
'use strict';
var INTERVAL_MS = 5 * 60 * 1000;
- var STORE_OPEN_HOUR = 9;
- var STORE_CLOSE_HOUR = 18;
+ var CLOCK_CHECK_MS = 60 * 1000; // check clock status every 60s
var locationTimer = null;
+ var clockCheckTimer = null;
+ var isClockedIn = false;
var permissionDenied = false;
// =====================================================================
@@ -137,21 +138,38 @@
window.openGoogleMapsNav = openGoogleMapsNav;
// =====================================================================
- // BACKGROUND LOGGER
+ // BACKGROUND LOGGER (tied to clock-in / clock-out status)
// =====================================================================
- function isWorkingHours() {
- var now = new Date();
- var hour = now.getHours();
- return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR;
- }
-
function isTechnicianPortal() {
return window.location.pathname.indexOf('/my/technician') !== -1;
}
+ function checkClockStatus() {
+ fetch('/my/technician/clock-status', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: {} }),
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ var wasClocked = isClockedIn;
+ isClockedIn = !!(data.result && data.result.clocked_in);
+ if (isClockedIn && !wasClocked) {
+ // Just clocked in — start tracking immediately
+ startLocationTimer();
+ } else if (!isClockedIn && wasClocked) {
+ // Just clocked out — stop tracking
+ stopLocationTimer();
+ }
+ })
+ .catch(function () {
+ /* network error: keep current state */
+ });
+ }
+
function logLocation() {
- if (!isWorkingHours() || document.hidden || !navigator.geolocation) return;
+ if (!isClockedIn || document.hidden || !navigator.geolocation) return;
getLocation().then(function (coords) {
fetch('/my/technician/location/log', {
@@ -181,16 +199,32 @@
});
}
+ function startLocationTimer() {
+ if (locationTimer) return; // already running
+ logLocation(); // immediate first log
+ locationTimer = setInterval(logLocation, INTERVAL_MS);
+ }
+
+ function stopLocationTimer() {
+ if (locationTimer) {
+ clearInterval(locationTimer);
+ locationTimer = null;
+ }
+ }
+
function startLocationLogging() {
if (!isTechnicianPortal()) return;
- logLocation();
- locationTimer = setInterval(logLocation, INTERVAL_MS);
+
+ // Check clock status immediately, then every 60s
+ checkClockStatus();
+ clockCheckTimer = setInterval(checkClockStatus, CLOCK_CHECK_MS);
+
+ // Pause/resume on tab visibility
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
- if (locationTimer) { clearInterval(locationTimer); locationTimer = null; }
- } else {
- logLocation();
- if (!locationTimer) { locationTimer = setInterval(logLocation, INTERVAL_MS); }
+ stopLocationTimer();
+ } else if (isClockedIn) {
+ startLocationTimer();
}
});
}
diff --git a/fusion_authorizer_portal/utils/pdf_filler.py b/fusion_authorizer_portal/utils/pdf_filler.py
index feaca15b..f87283c0 100644
--- a/fusion_authorizer_portal/utils/pdf_filler.py
+++ b/fusion_authorizer_portal/utils/pdf_filler.py
@@ -51,19 +51,25 @@ class PDFTemplateFiller:
for page_idx in range(num_pages):
page = original.getPage(page_idx)
page_num = page_idx + 1 # 1-based page number
- page_w = float(page.mediaBox.getWidth())
- page_h = float(page.mediaBox.getHeight())
+ mb = page.mediaBox
+ page_w = float(mb.getWidth())
+ page_h = float(mb.getHeight())
+ origin_x = float(mb.getLowerLeft_x())
+ origin_y = float(mb.getLowerLeft_y())
fields = fields_by_page.get(page_num, [])
if fields:
- # Create a transparent overlay for this page
overlay_buf = BytesIO()
- c = canvas.Canvas(overlay_buf, pagesize=(page_w, page_h))
+ c = canvas.Canvas(
+ overlay_buf,
+ pagesize=(origin_x + page_w, origin_y + page_h),
+ )
for field in fields:
PDFTemplateFiller._draw_field(
- c, field, context, signatures, page_w, page_h
+ c, field, context, signatures,
+ page_w, page_h, origin_x, origin_y,
)
c.save()
@@ -80,7 +86,8 @@ class PDFTemplateFiller:
return result.getvalue()
@staticmethod
- def _draw_field(c, field, context, signatures, page_w, page_h):
+ def _draw_field(c, field, context, signatures,
+ page_w, page_h, origin_x=0, origin_y=0):
"""Draw a single field onto the reportlab canvas.
Args:
@@ -90,6 +97,8 @@ class PDFTemplateFiller:
signatures: dict of {field_key: binary} for signature fields
page_w: page width in PDF points
page_h: page height in PDF points
+ origin_x: mediaBox lower-left X (accounts for non-zero origin)
+ origin_y: mediaBox lower-left Y (accounts for non-zero origin)
"""
field_key = field.get('field_key') or field.get('field_name', '')
field_type = field.get('field_type', 'text')
@@ -98,11 +107,12 @@ class PDFTemplateFiller:
if not value and field_type != 'signature':
return
- # Convert percentage positions to absolute PDF coordinates
- # pos_x/pos_y are 0.0-1.0 ratios from top-left
- # PDF coordinate system: origin at bottom-left, Y goes up
- abs_x = field['pos_x'] * page_w
- abs_y = page_h - (field['pos_y'] * page_h) # flip Y axis
+ # Convert percentage positions to absolute PDF coordinates.
+ # pos_x/pos_y are 0.0-1.0 ratios from top-left of the visible page.
+ # PDF coordinate system: origin at bottom-left, Y goes up.
+ # origin_x/origin_y account for PDFs whose mediaBox doesn't start at (0,0).
+ abs_x = field['pos_x'] * page_w + origin_x
+ abs_y = (origin_y + page_h) - (field['pos_y'] * page_h)
font_name = field.get('font_name', 'Helvetica')
font_size = field.get('font_size', 10.0)
@@ -124,10 +134,22 @@ class PDFTemplateFiller:
elif field_type == 'checkbox':
if value:
- c.setFont('ZapfDingbats', font_size)
+ # Draw a cross mark (✗) that fills the checkbox box
+ cb_w = field.get('width', 0.015) * page_w
cb_h = field.get('height', 0.018) * page_h
- cb_y = abs_y - cb_h + (cb_h - font_size) / 2
- c.drawString(abs_x, cb_y, '4')
+ # Inset slightly so the cross doesn't touch the box edges
+ pad = min(cb_w, cb_h) * 0.15
+ x1 = abs_x + pad
+ y1 = abs_y - cb_h + pad
+ x2 = abs_x + cb_w - pad
+ y2 = abs_y - pad
+ c.saveState()
+ c.setStrokeColorRGB(0, 0, 0)
+ c.setLineWidth(1.5)
+ # Draw X (two diagonal lines)
+ c.line(x1, y1, x2, y2)
+ c.line(x1, y2, x2, y1)
+ c.restoreState()
elif field_type == 'signature':
sig_data = signatures.get(field_key)
diff --git a/fusion_authorizer_portal/views/portal_page11_sign_templates.xml b/fusion_authorizer_portal/views/portal_page11_sign_templates.xml
new file mode 100644
index 00000000..3e4fa9c6
--- /dev/null
+++ b/fusion_authorizer_portal/views/portal_page11_sign_templates.xml
@@ -0,0 +1,413 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
ADP Consent and Declaration
+
Page 11 - Assistive Devices Program
+
+
+
+
Please draw your signature before submitting.
+
+
+
You must accept the consent declaration before signing.
+
+
+
+
+
+
+ ·
+ ·
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Signature Submitted Successfully
+
+ Thank you for signing the ADP Consent and Declaration form.
+ Your signature has been recorded and the document has been updated.
+
Please review the attached quotation. If you have any questions or need assistance, do not hesitate to contact us.
+
+
Please review the attached quotation. If you have any questions or need assistance, do not hesitate to contact us.
--
@@ -70,34 +70,34 @@
{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}{{ object.partner_id.id }}
-
+
-
+
-
Order Confirmed
-
- Your ADP sales order has been confirmed.
+
Order Confirmed
+
+ Your ADP sales order has been confirmed.
-
Order Details
-
Reference
-
Date
+
Order Details
+
Reference
+
Date
-
Authorizer
+
Authorizer
-
Client Portion (25%)
-
ADP Portion (75%)
+
Client Portion (25%)
+
ADP Portion (75%)
-
Total
+
Total
-
-
Attached: Sales Order Confirmation (PDF)
+
+
Attached: Sales Order Confirmation (PDF)
-
-
Your order is being processed. We will keep you updated on the delivery status and any updates from the Assistive Devices Program.
+
+
Your order is being processed. We will keep you updated on the delivery status and any updates from the Assistive Devices Program.
--
@@ -120,42 +120,42 @@
{{ (object.invoice_user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}{{ object.partner_id.id }}
-
+
-
+
-
Invoice
-
- Please find attached your invoice .
+
Invoice
+
+ Please find attached your invoice .
-
Invoice Details
-
Invoice
-
Date
+
Invoice Details
+
Invoice
+
Date
-
Due Date
+
Due Date
-
Type
+
Type
Client PortionADP Portion
-
Amount Due
+
Amount Due
-
-
Attached: Invoice (PDF)
+
+
Attached: Invoice (PDF)
-
-
This invoice represents your client portion for the ADP-funded equipment. The remaining amount will be billed directly to the Assistive Devices Program.
+
+
This invoice represents your client portion for the ADP-funded equipment. The remaining amount will be billed directly to the Assistive Devices Program.
-
-
Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.
+
+
Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.
diff --git a/fusion_claims/models/__init__.py b/fusion_claims/models/__init__.py
index 89cae4cd..29e77727 100644
--- a/fusion_claims/models/__init__.py
+++ b/fusion_claims/models/__init__.py
@@ -3,7 +3,6 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
-from . import email_builder_mixin
from . import adp_posting_schedule
from . import res_company
from . import res_config_settings
@@ -27,12 +26,9 @@ from . import client_chat
from . import ai_agent_ext
from . import dashboard
from . import res_partner
-from . import res_users
from . import technician_task
-from . import task_sync
-from . import technician_location
-from . import push_subscription
from . import ltc_facility
from . import ltc_repair
from . import ltc_cleanup
-from . import ltc_form_submission
\ No newline at end of file
+from . import ltc_form_submission
+from . import page11_sign_request
\ No newline at end of file
diff --git a/fusion_claims/models/__pycache__/__init__.cpython-312.pyc b/fusion_claims/models/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 00000000..2d5b176d
Binary files /dev/null and b/fusion_claims/models/__pycache__/__init__.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/account_move.cpython-312.pyc b/fusion_claims/models/__pycache__/account_move.cpython-312.pyc
new file mode 100644
index 00000000..bdc52c6b
Binary files /dev/null and b/fusion_claims/models/__pycache__/account_move.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/account_move_line.cpython-312.pyc b/fusion_claims/models/__pycache__/account_move_line.cpython-312.pyc
new file mode 100644
index 00000000..322243ff
Binary files /dev/null and b/fusion_claims/models/__pycache__/account_move_line.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/account_payment.cpython-312.pyc b/fusion_claims/models/__pycache__/account_payment.cpython-312.pyc
new file mode 100644
index 00000000..62fdca9d
Binary files /dev/null and b/fusion_claims/models/__pycache__/account_payment.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/account_payment_method_line.cpython-312.pyc b/fusion_claims/models/__pycache__/account_payment_method_line.cpython-312.pyc
new file mode 100644
index 00000000..2575b2f7
Binary files /dev/null and b/fusion_claims/models/__pycache__/account_payment_method_line.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/adp_application_data.cpython-312.pyc b/fusion_claims/models/__pycache__/adp_application_data.cpython-312.pyc
new file mode 100644
index 00000000..cc1757d2
Binary files /dev/null and b/fusion_claims/models/__pycache__/adp_application_data.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/adp_posting_schedule.cpython-312.pyc b/fusion_claims/models/__pycache__/adp_posting_schedule.cpython-312.pyc
new file mode 100644
index 00000000..28ea0ad9
Binary files /dev/null and b/fusion_claims/models/__pycache__/adp_posting_schedule.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/ai_agent_ext.cpython-312.pyc b/fusion_claims/models/__pycache__/ai_agent_ext.cpython-312.pyc
new file mode 100644
index 00000000..cf210b01
Binary files /dev/null and b/fusion_claims/models/__pycache__/ai_agent_ext.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/client_chat.cpython-312.pyc b/fusion_claims/models/__pycache__/client_chat.cpython-312.pyc
new file mode 100644
index 00000000..84240960
Binary files /dev/null and b/fusion_claims/models/__pycache__/client_chat.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/client_profile.cpython-312.pyc b/fusion_claims/models/__pycache__/client_profile.cpython-312.pyc
new file mode 100644
index 00000000..03afaaed
Binary files /dev/null and b/fusion_claims/models/__pycache__/client_profile.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/dashboard.cpython-312.pyc b/fusion_claims/models/__pycache__/dashboard.cpython-312.pyc
new file mode 100644
index 00000000..1af3c4cf
Binary files /dev/null and b/fusion_claims/models/__pycache__/dashboard.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/fusion_adp_device_code.cpython-312.pyc b/fusion_claims/models/__pycache__/fusion_adp_device_code.cpython-312.pyc
new file mode 100644
index 00000000..e7cc35de
Binary files /dev/null and b/fusion_claims/models/__pycache__/fusion_adp_device_code.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/fusion_central_config.cpython-312.pyc b/fusion_claims/models/__pycache__/fusion_central_config.cpython-312.pyc
new file mode 100644
index 00000000..4ca723bc
Binary files /dev/null and b/fusion_claims/models/__pycache__/fusion_central_config.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/fusion_loaner_checkout.cpython-312.pyc b/fusion_claims/models/__pycache__/fusion_loaner_checkout.cpython-312.pyc
new file mode 100644
index 00000000..a211adf3
Binary files /dev/null and b/fusion_claims/models/__pycache__/fusion_loaner_checkout.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/fusion_loaner_history.cpython-312.pyc b/fusion_claims/models/__pycache__/fusion_loaner_history.cpython-312.pyc
new file mode 100644
index 00000000..7ea6b7f0
Binary files /dev/null and b/fusion_claims/models/__pycache__/fusion_loaner_history.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/ltc_cleanup.cpython-312.pyc b/fusion_claims/models/__pycache__/ltc_cleanup.cpython-312.pyc
new file mode 100644
index 00000000..dd026258
Binary files /dev/null and b/fusion_claims/models/__pycache__/ltc_cleanup.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/ltc_facility.cpython-312.pyc b/fusion_claims/models/__pycache__/ltc_facility.cpython-312.pyc
new file mode 100644
index 00000000..49965702
Binary files /dev/null and b/fusion_claims/models/__pycache__/ltc_facility.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/ltc_form_submission.cpython-312.pyc b/fusion_claims/models/__pycache__/ltc_form_submission.cpython-312.pyc
new file mode 100644
index 00000000..72c0a4e4
Binary files /dev/null and b/fusion_claims/models/__pycache__/ltc_form_submission.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/ltc_repair.cpython-312.pyc b/fusion_claims/models/__pycache__/ltc_repair.cpython-312.pyc
new file mode 100644
index 00000000..d8e04f9b
Binary files /dev/null and b/fusion_claims/models/__pycache__/ltc_repair.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/page11_sign_request.cpython-312.pyc b/fusion_claims/models/__pycache__/page11_sign_request.cpython-312.pyc
new file mode 100644
index 00000000..9399beb1
Binary files /dev/null and b/fusion_claims/models/__pycache__/page11_sign_request.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/product_product.cpython-312.pyc b/fusion_claims/models/__pycache__/product_product.cpython-312.pyc
new file mode 100644
index 00000000..1241d0f4
Binary files /dev/null and b/fusion_claims/models/__pycache__/product_product.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/product_template.cpython-312.pyc b/fusion_claims/models/__pycache__/product_template.cpython-312.pyc
new file mode 100644
index 00000000..42e5326d
Binary files /dev/null and b/fusion_claims/models/__pycache__/product_template.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/push_subscription.cpython-312.pyc b/fusion_claims/models/__pycache__/push_subscription.cpython-312.pyc
new file mode 100644
index 00000000..a3f3dd25
Binary files /dev/null and b/fusion_claims/models/__pycache__/push_subscription.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/res_company.cpython-312.pyc b/fusion_claims/models/__pycache__/res_company.cpython-312.pyc
new file mode 100644
index 00000000..03e0ddd6
Binary files /dev/null and b/fusion_claims/models/__pycache__/res_company.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/res_config_settings.cpython-312.pyc b/fusion_claims/models/__pycache__/res_config_settings.cpython-312.pyc
new file mode 100644
index 00000000..be4875c5
Binary files /dev/null and b/fusion_claims/models/__pycache__/res_config_settings.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/res_partner.cpython-312.pyc b/fusion_claims/models/__pycache__/res_partner.cpython-312.pyc
new file mode 100644
index 00000000..357e2277
Binary files /dev/null and b/fusion_claims/models/__pycache__/res_partner.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/sale_order.cpython-312.pyc b/fusion_claims/models/__pycache__/sale_order.cpython-312.pyc
new file mode 100644
index 00000000..edaa4247
Binary files /dev/null and b/fusion_claims/models/__pycache__/sale_order.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/sale_order_line.cpython-312.pyc b/fusion_claims/models/__pycache__/sale_order_line.cpython-312.pyc
new file mode 100644
index 00000000..7ba76afb
Binary files /dev/null and b/fusion_claims/models/__pycache__/sale_order_line.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/submission_history.cpython-312.pyc b/fusion_claims/models/__pycache__/submission_history.cpython-312.pyc
new file mode 100644
index 00000000..1be86e28
Binary files /dev/null and b/fusion_claims/models/__pycache__/submission_history.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/task_sync.cpython-312.pyc b/fusion_claims/models/__pycache__/task_sync.cpython-312.pyc
new file mode 100644
index 00000000..0129576c
Binary files /dev/null and b/fusion_claims/models/__pycache__/task_sync.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/technician_location.cpython-312.pyc b/fusion_claims/models/__pycache__/technician_location.cpython-312.pyc
new file mode 100644
index 00000000..d465ecb6
Binary files /dev/null and b/fusion_claims/models/__pycache__/technician_location.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/technician_task.cpython-312.pyc b/fusion_claims/models/__pycache__/technician_task.cpython-312.pyc
new file mode 100644
index 00000000..118e4a27
Binary files /dev/null and b/fusion_claims/models/__pycache__/technician_task.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/technician_task_new.cpython-312.pyc b/fusion_claims/models/__pycache__/technician_task_new.cpython-312.pyc
new file mode 100644
index 00000000..4c1da858
Binary files /dev/null and b/fusion_claims/models/__pycache__/technician_task_new.cpython-312.pyc differ
diff --git a/fusion_claims/models/__pycache__/xml_parser.cpython-312.pyc b/fusion_claims/models/__pycache__/xml_parser.cpython-312.pyc
new file mode 100644
index 00000000..6dd80b69
Binary files /dev/null and b/fusion_claims/models/__pycache__/xml_parser.cpython-312.pyc differ
diff --git a/fusion_claims/models/fusion_adp_device_code.py b/fusion_claims/models/fusion_adp_device_code.py
index 0a6c2019..0f67f9b9 100644
--- a/fusion_claims/models/fusion_adp_device_code.py
+++ b/fusion_claims/models/fusion_adp_device_code.py
@@ -57,6 +57,12 @@ class FusionADPDeviceCode(models.Model):
index=True,
help='Device manufacturer',
)
+ build_type = fields.Selection(
+ [('modular', 'Modular'), ('custom_fabricated', 'Custom Fabricated')],
+ string='Build Type',
+ index=True,
+ help='Build type for positioning/seating devices: Modular or Custom Fabricated',
+ )
device_description = fields.Char(
string='Device Description',
help='Detailed device description from mobility manual',
@@ -242,6 +248,16 @@ class FusionADPDeviceCode(models.Model):
device_type = self._clean_text(item.get('Device Type', '') or item.get('device_type', ''))
manufacturer = self._clean_text(item.get('Manufacturer', '') or item.get('manufacturer', ''))
device_description = self._clean_text(item.get('Device Description', '') or item.get('device_description', ''))
+
+ # Parse build type (Modular / Custom Fabricated)
+ build_type_raw = self._clean_text(item.get('Build Type', '') or item.get('build_type', ''))
+ build_type = False
+ if build_type_raw:
+ bt_lower = build_type_raw.lower().strip()
+ if bt_lower in ('modular', 'mod'):
+ build_type = 'modular'
+ elif bt_lower in ('custom fabricated', 'custom_fabricated', 'custom'):
+ build_type = 'custom_fabricated'
# Parse quantity
qty_val = item.get('Quantity', 1) or item.get('Qty', 1) or item.get('quantity', 1)
@@ -277,6 +293,8 @@ class FusionADPDeviceCode(models.Model):
'last_updated': fields.Datetime.now(),
'active': True,
}
+ if build_type:
+ vals['build_type'] = build_type
if existing:
existing.write(vals)
diff --git a/fusion_claims/models/page11_sign_request.py b/fusion_claims/models/page11_sign_request.py
new file mode 100644
index 00000000..dcf97ae8
--- /dev/null
+++ b/fusion_claims/models/page11_sign_request.py
@@ -0,0 +1,389 @@
+# -*- coding: utf-8 -*-
+# Copyright 2024-2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+import base64
+import logging
+import uuid
+from datetime import timedelta
+
+from markupsafe import Markup
+
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+SIGNER_TYPE_SELECTION = [
+ ('client', 'Client (Self)'),
+ ('spouse', 'Spouse'),
+ ('parent', 'Parent'),
+ ('legal_guardian', 'Legal Guardian'),
+ ('poa', 'Power of Attorney'),
+ ('public_trustee', 'Public Trustee'),
+]
+
+SIGNER_TYPE_TO_RELATIONSHIP = {
+ 'spouse': 'Spouse',
+ 'parent': 'Parent',
+ 'legal_guardian': 'Legal Guardian',
+ 'poa': 'Power of Attorney',
+ 'public_trustee': 'Public Trustee',
+}
+
+
+class Page11SignRequest(models.Model):
+ _name = 'fusion.page11.sign.request'
+ _description = 'ADP Page 11 Remote Signing Request'
+ _inherit = ['fusion.email.builder.mixin']
+ _order = 'create_date desc'
+
+ sale_order_id = fields.Many2one(
+ 'sale.order', string='Sale Order',
+ required=True, ondelete='cascade', index=True,
+ )
+ access_token = fields.Char(
+ string='Access Token', required=True, copy=False,
+ default=lambda self: str(uuid.uuid4()), index=True,
+ )
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('sent', 'Sent'),
+ ('signed', 'Signed'),
+ ('expired', 'Expired'),
+ ('cancelled', 'Cancelled'),
+ ], string='Status', default='draft', required=True, tracking=True)
+
+ signer_email = fields.Char(string='Recipient Email', required=True)
+ signer_type = fields.Selection(
+ SIGNER_TYPE_SELECTION, string='Signer Type',
+ default='client', required=True,
+ )
+ signer_name = fields.Char(string='Signer Name')
+ signer_relationship = fields.Char(string='Relationship to Client')
+
+ signature_data = fields.Binary(string='Signature', attachment=True)
+ signed_pdf = fields.Binary(string='Signed PDF', attachment=True)
+ signed_pdf_filename = fields.Char(string='Signed PDF Filename')
+ signed_date = fields.Datetime(string='Signed Date')
+ sent_date = fields.Datetime(string='Sent Date')
+ expiry_date = fields.Datetime(string='Expiry Date')
+
+ consent_declaration_accepted = fields.Boolean(string='Declaration Accepted')
+ consent_signed_by = fields.Selection([
+ ('applicant', 'Applicant'),
+ ('agent', 'Agent'),
+ ], string='Signed By')
+
+ client_first_name = fields.Char(string='Client First Name')
+ client_last_name = fields.Char(string='Client Last Name')
+ client_health_card = fields.Char(string='Health Card Number')
+ client_health_card_version = fields.Char(string='Health Card Version')
+
+ agent_first_name = fields.Char(string='Agent First Name')
+ agent_last_name = fields.Char(string='Agent Last Name')
+ agent_middle_initial = fields.Char(string='Agent Middle Initial')
+ agent_phone = fields.Char(string='Agent Phone')
+ agent_unit = fields.Char(string='Agent Unit Number')
+ agent_street_number = fields.Char(string='Agent Street Number')
+ agent_street = fields.Char(string='Agent Street Name')
+ agent_city = fields.Char(string='Agent City')
+ agent_province = fields.Char(string='Agent Province', default='Ontario')
+ agent_postal_code = fields.Char(string='Agent Postal Code')
+
+ custom_message = fields.Text(string='Custom Message')
+
+ company_id = fields.Many2one(
+ 'res.company', string='Company',
+ related='sale_order_id.company_id', store=True,
+ )
+
+ def name_get(self):
+ return [
+ (r.id, f"Page 11 - {r.sale_order_id.name} ({r.state})")
+ for r in self
+ ]
+
+ def _send_signing_email(self):
+ """Build and send the signing request email."""
+ self.ensure_one()
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ sign_url = f'{base_url}/page11/sign/{self.access_token}'
+ order = self.sale_order_id
+
+ client_name = order.partner_id.name or 'N/A'
+ sections = [
+ ('Case Details', [
+ ('Client', client_name),
+ ('Case Reference', order.name),
+ ]),
+ ]
+
+ if order.x_fc_authorizer_id:
+ sections[0][1].append(('Authorizer', order.x_fc_authorizer_id.name))
+
+ if order.x_fc_assessment_start_date:
+ sections[0][1].append((
+ 'Assessment Date',
+ order.x_fc_assessment_start_date.strftime('%B %d, %Y'),
+ ))
+
+ note_parts = []
+ if self.custom_message:
+ note_parts.append(self.custom_message)
+ days_left = 7
+ if self.expiry_date:
+ delta = self.expiry_date - fields.Datetime.now()
+ days_left = max(1, delta.days)
+ note_parts.append(
+ f'This link will expire in {days_left} days. '
+ 'Please complete the signing at your earliest convenience.'
+ )
+ note_text = '
'.join(note_parts)
+
+ body_html = self._email_build(
+ title='Page 11 Signature Required',
+ summary=(
+ f'{order.company_id.name} requires your signature on the '
+ f'ADP Consent and Declaration form for {client_name}.'
+ ),
+ sections=sections,
+ note=note_text,
+ email_type='info',
+ button_url=sign_url,
+ button_text='Sign Now',
+ sender_name=self.env.user.name,
+ )
+
+ mail_values = {
+ 'subject': f'{order.company_id.name} - Page 11 Signature Required ({order.name})',
+ 'body_html': body_html,
+ 'email_to': self.signer_email,
+ 'email_from': (
+ self.env.user.email_formatted
+ or order.company_id.email_formatted
+ ),
+ 'auto_delete': True,
+ }
+ mail = self.env['mail.mail'].sudo().create(mail_values)
+ mail.send()
+
+ self.write({
+ 'state': 'sent',
+ 'sent_date': fields.Datetime.now(),
+ })
+
+ signer_display = self.signer_name or self.signer_email
+ order.message_post(
+ body=Markup(
+ 'Page 11 signing request sent to %s (%s).'
+ ) % (signer_display, self.signer_email),
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+
+ def _generate_signed_pdf(self):
+ """Generate the signed Page 11 PDF using the PDF template engine."""
+ self.ensure_one()
+ order = self.sale_order_id
+
+ assessment = self.env['fusion.assessment'].search([
+ ('sale_order_id', '=', order.id),
+ ], limit=1, order='create_date desc')
+
+ if assessment:
+ ctx = assessment._get_pdf_context()
+ else:
+ ctx = self._build_pdf_context_from_order()
+
+ if self.client_first_name:
+ ctx['client_first_name'] = self.client_first_name
+ if self.client_last_name:
+ ctx['client_last_name'] = self.client_last_name
+ if self.client_health_card:
+ ctx['client_health_card'] = self.client_health_card
+ if self.client_health_card_version:
+ ctx['client_health_card_version'] = self.client_health_card_version
+
+ ctx.update({
+ 'consent_signed_by': self.consent_signed_by or '',
+ 'consent_applicant': self.consent_signed_by == 'applicant',
+ 'consent_agent': self.consent_signed_by == 'agent',
+ 'consent_declaration_accepted': self.consent_declaration_accepted,
+ 'consent_date': str(fields.Date.today()),
+ })
+
+ if self.consent_signed_by == 'agent':
+ ctx.update({
+ 'agent_first_name': self.agent_first_name or '',
+ 'agent_last_name': self.agent_last_name or '',
+ 'agent_middle_initial': self.agent_middle_initial or '',
+ 'agent_unit': self.agent_unit or '',
+ 'agent_street_number': self.agent_street_number or '',
+ 'agent_street_name': self.agent_street or '',
+ 'agent_city': self.agent_city or '',
+ 'agent_province': self.agent_province or '',
+ 'agent_postal_code': self.agent_postal_code or '',
+ 'agent_home_phone': self.agent_phone or '',
+ 'agent_relationship': self.signer_relationship or '',
+ 'agent_rel_spouse': self.signer_type == 'spouse',
+ 'agent_rel_parent': self.signer_type == 'parent',
+ 'agent_rel_poa': self.signer_type == 'poa',
+ 'agent_rel_guardian': self.signer_type in ('legal_guardian', 'public_trustee'),
+ })
+
+ signatures = {}
+ if self.signature_data:
+ signatures['signature_page_11'] = base64.b64decode(self.signature_data)
+
+ template = self.env['fusion.pdf.template'].search([
+ ('state', '=', 'active'),
+ ('name', 'ilike', 'adp_page_11'),
+ ], limit=1)
+
+ if not template:
+ template = self.env['fusion.pdf.template'].search([
+ ('state', '=', 'active'),
+ ('name', 'ilike', 'page 11'),
+ ], limit=1)
+
+ if not template:
+ _logger.warning("No active PDF template found for Page 11")
+ return None
+
+ try:
+ pdf_bytes = template.generate_filled_pdf(ctx, signatures)
+ if pdf_bytes:
+ first, last = order._get_client_name_parts()
+ filename = f'{first}_{last}_Page11_Signed.pdf'
+ self.write({
+ 'signed_pdf': base64.b64encode(pdf_bytes),
+ 'signed_pdf_filename': filename,
+ })
+ return pdf_bytes
+ except Exception as e:
+ _logger.error("Failed to generate Page 11 PDF: %s", e)
+ return None
+
+ def _build_pdf_context_from_order(self):
+ """Build a PDF context dict from the sale order when no assessment exists."""
+ order = self.sale_order_id
+ partner = order.partner_id
+ first, last = order._get_client_name_parts()
+ return {
+ 'client_first_name': first,
+ 'client_last_name': last,
+ 'client_name': partner.name or '',
+ 'client_street': partner.street or '',
+ 'client_city': partner.city or '',
+ 'client_state': partner.state_id.name if partner.state_id else 'Ontario',
+ 'client_postal_code': partner.zip or '',
+ 'client_phone': partner.phone or partner.mobile or '',
+ 'client_email': partner.email or '',
+ 'client_type': order.x_fc_client_type or '',
+ 'client_type_reg': order.x_fc_client_type == 'REG',
+ 'client_type_ods': order.x_fc_client_type == 'ODS',
+ 'client_type_acs': order.x_fc_client_type == 'ACS',
+ 'client_type_owp': order.x_fc_client_type == 'OWP',
+ 'reference': order.name or '',
+ 'authorizer_name': order.x_fc_authorizer_id.name if order.x_fc_authorizer_id else '',
+ 'authorizer_phone': order.x_fc_authorizer_id.phone if order.x_fc_authorizer_id else '',
+ 'authorizer_email': order.x_fc_authorizer_id.email if order.x_fc_authorizer_id else '',
+ 'claim_authorization_date': str(order.x_fc_claim_authorization_date) if order.x_fc_claim_authorization_date else '',
+ 'assessment_start_date': str(order.x_fc_assessment_start_date) if order.x_fc_assessment_start_date else '',
+ 'assessment_end_date': str(order.x_fc_assessment_end_date) if order.x_fc_assessment_end_date else '',
+ }
+
+ def _update_sale_order(self):
+ """Copy signing data from this request to the sale order."""
+ self.ensure_one()
+ order = self.sale_order_id
+ vals = {
+ 'x_fc_page11_signer_type': self.signer_type,
+ 'x_fc_page11_signer_name': self.signer_name,
+ 'x_fc_page11_signed_date': fields.Date.today(),
+ }
+ if self.signer_type != 'client':
+ vals['x_fc_page11_signer_relationship'] = (
+ self.signer_relationship
+ or SIGNER_TYPE_TO_RELATIONSHIP.get(self.signer_type, '')
+ )
+ if self.signed_pdf:
+ vals['x_fc_signed_pages_11_12'] = self.signed_pdf
+ vals['x_fc_signed_pages_filename'] = self.signed_pdf_filename
+
+ order.with_context(
+ skip_page11_check=True,
+ skip_document_chatter=True,
+ ).write(vals)
+
+ signer_display = self.signer_name or 'N/A'
+ if self.signed_pdf:
+ att = self.env['ir.attachment'].sudo().create({
+ 'name': self.signed_pdf_filename or 'Page11_Signed.pdf',
+ 'datas': self.signed_pdf,
+ 'res_model': 'sale.order',
+ 'res_id': order.id,
+ 'mimetype': 'application/pdf',
+ })
+ order.message_post(
+ body=Markup(
+ 'Page 11 has been signed by %s (%s).'
+ ) % (signer_display, self.signer_email),
+ attachment_ids=[att.id],
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+ else:
+ order.message_post(
+ body=Markup(
+ 'Page 11 has been signed by %s (%s). '
+ 'PDF generation was not available.'
+ ) % (signer_display, self.signer_email),
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+
+ def action_cancel(self):
+ """Cancel a pending signing request."""
+ for rec in self:
+ if rec.state in ('draft', 'sent'):
+ rec.state = 'cancelled'
+
+ def action_resend(self):
+ """Resend the signing email."""
+ for rec in self:
+ if rec.state in ('sent', 'expired'):
+ rec.expiry_date = fields.Datetime.now() + timedelta(days=7)
+ rec.access_token = str(uuid.uuid4())
+ rec._send_signing_email()
+
+ def action_request_new_signature(self):
+ """Create a new signing request (e.g. to re-sign after corrections)."""
+ self.ensure_one()
+ if self.state == 'signed':
+ self.state = 'cancelled'
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Request Page 11 Signature',
+ 'res_model': 'fusion_claims.send.page11.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_sale_order_id': self.sale_order_id.id,
+ 'default_signer_email': self.signer_email,
+ 'default_signer_name': self.signer_name,
+ 'default_signer_type': self.signer_type,
+ },
+ }
+
+ @api.model
+ def _cron_expire_requests(self):
+ """Mark expired unsigned requests."""
+ expired = self.search([
+ ('state', '=', 'sent'),
+ ('expiry_date', '<', fields.Datetime.now()),
+ ])
+ if expired:
+ expired.write({'state': 'expired'})
+ _logger.info("Expired %d Page 11 signing requests", len(expired))
diff --git a/fusion_claims/models/res_config_settings.py b/fusion_claims/models/res_config_settings.py
index 32013fd9..85149702 100644
--- a/fusion_claims/models/res_config_settings.py
+++ b/fusion_claims/models/res_config_settings.py
@@ -317,16 +317,6 @@ class ResConfigSettings(models.TransientModel):
help='The user who signs Page 12 on behalf of the company',
)
- # =========================================================================
- # GOOGLE MAPS API SETTINGS
- # =========================================================================
-
- fc_google_maps_api_key = fields.Char(
- string='Google Maps API Key',
- config_parameter='fusion_claims.google_maps_api_key',
- help='API key for Google Maps Places autocomplete in address fields',
- )
-
# ------------------------------------------------------------------
# AI CLIENT INTELLIGENCE
# ------------------------------------------------------------------
@@ -349,62 +339,6 @@ class ResConfigSettings(models.TransientModel):
help='Automatically parse ADP XML files when uploaded and create/update client profiles',
)
- # ------------------------------------------------------------------
- # TECHNICIAN MANAGEMENT
- # ------------------------------------------------------------------
- fc_store_open_hour = fields.Float(
- string='Store Open Time',
- config_parameter='fusion_claims.store_open_hour',
- help='Store opening time for technician scheduling (e.g. 9.0 = 9:00 AM)',
- )
- fc_store_close_hour = fields.Float(
- string='Store Close Time',
- config_parameter='fusion_claims.store_close_hour',
- help='Store closing time for technician scheduling (e.g. 18.0 = 6:00 PM)',
- )
- fc_google_distance_matrix_enabled = fields.Boolean(
- string='Enable Distance Matrix',
- config_parameter='fusion_claims.google_distance_matrix_enabled',
- help='Enable Google Distance Matrix API for travel time calculations between technician tasks',
- )
- fc_technician_start_address = fields.Char(
- string='Technician Start Address',
- config_parameter='fusion_claims.technician_start_address',
- help='Default start location for technician travel calculations (e.g. warehouse/office address)',
- )
- fc_location_retention_days = fields.Char(
- string='Location History Retention (Days)',
- config_parameter='fusion_claims.location_retention_days',
- help='How many days to keep technician location history. '
- 'Leave empty = 30 days (1 month). '
- '0 = delete at end of each day. '
- '1+ = keep for that many days.',
- )
-
- # ------------------------------------------------------------------
- # WEB PUSH NOTIFICATIONS
- # ------------------------------------------------------------------
- fc_push_enabled = fields.Boolean(
- string='Enable Push Notifications',
- config_parameter='fusion_claims.push_enabled',
- help='Enable web push notifications for technician tasks',
- )
- fc_vapid_public_key = fields.Char(
- string='VAPID Public Key',
- config_parameter='fusion_claims.vapid_public_key',
- help='Public key for Web Push VAPID authentication (auto-generated)',
- )
- fc_vapid_private_key = fields.Char(
- string='VAPID Private Key',
- config_parameter='fusion_claims.vapid_private_key',
- help='Private key for Web Push VAPID authentication (auto-generated)',
- )
- fc_push_advance_minutes = fields.Integer(
- string='Notification Advance (min)',
- config_parameter='fusion_claims.push_advance_minutes',
- help='Send push notifications this many minutes before a scheduled task',
- )
-
# ------------------------------------------------------------------
# TWILIO SMS SETTINGS
# ------------------------------------------------------------------
@@ -609,15 +543,11 @@ class ResConfigSettings(models.TransientModel):
# an existing non-empty value (e.g. API keys, user-customized settings).
_protected_keys = [
'fusion_claims.ai_api_key',
- 'fusion_claims.google_maps_api_key',
'fusion_claims.vendor_code',
'fusion_claims.ai_model',
'fusion_claims.adp_posting_base_date',
'fusion_claims.application_reminder_days',
'fusion_claims.application_reminder_2_days',
- 'fusion_claims.store_open_hour',
- 'fusion_claims.store_close_hour',
- 'fusion_claims.technician_start_address',
]
# Snapshot existing values BEFORE super().set_values() runs
_existing = {}
diff --git a/fusion_claims/models/res_partner.py b/fusion_claims/models/res_partner.py
index a8892fe0..ef92eab7 100644
--- a/fusion_claims/models/res_partner.py
+++ b/fusion_claims/models/res_partner.py
@@ -2,82 +2,12 @@
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
-import logging
-import requests
from odoo import models, fields, api
-_logger = logging.getLogger(__name__)
-
class ResPartner(models.Model):
_inherit = 'res.partner'
- x_fc_start_address = fields.Char(
- string='Start Location',
- help='Technician daily start location (home, warehouse, etc.). '
- 'Used as origin for first travel time calculation. '
- 'If empty, the company default HQ address is used.',
- )
- x_fc_start_address_lat = fields.Float(
- string='Start Latitude', digits=(10, 7),
- )
- x_fc_start_address_lng = fields.Float(
- string='Start Longitude', digits=(10, 7),
- )
-
- def _geocode_start_address(self, address):
- if not address or not address.strip():
- return 0.0, 0.0
- api_key = self.env['ir.config_parameter'].sudo().get_param(
- 'fusion_claims.google_maps_api_key', '')
- if not api_key:
- return 0.0, 0.0
- try:
- resp = requests.get(
- 'https://maps.googleapis.com/maps/api/geocode/json',
- params={'address': address.strip(), 'key': api_key, 'region': 'ca'},
- timeout=10,
- )
- data = resp.json()
- if data.get('status') == 'OK' and data.get('results'):
- loc = data['results'][0]['geometry']['location']
- return loc['lat'], loc['lng']
- except Exception as e:
- _logger.warning("Start address geocoding failed for '%s': %s", address, e)
- return 0.0, 0.0
-
- @api.model_create_multi
- def create(self, vals_list):
- records = super().create(vals_list)
- for rec, vals in zip(records, vals_list):
- addr = vals.get('x_fc_start_address')
- if addr:
- lat, lng = rec._geocode_start_address(addr)
- if lat and lng:
- rec.write({
- 'x_fc_start_address_lat': lat,
- 'x_fc_start_address_lng': lng,
- })
- return records
-
- def write(self, vals):
- res = super().write(vals)
- if 'x_fc_start_address' in vals:
- addr = vals['x_fc_start_address']
- if addr and addr.strip():
- lat, lng = self._geocode_start_address(addr)
- if lat and lng:
- super().write({
- 'x_fc_start_address_lat': lat,
- 'x_fc_start_address_lng': lng,
- })
- else:
- super().write({
- 'x_fc_start_address_lat': 0.0,
- 'x_fc_start_address_lng': 0.0,
- })
- return res
-
# ==========================================================================
# CONTACT TYPE
# ==========================================================================
diff --git a/fusion_claims/models/sale_order.py b/fusion_claims/models/sale_order.py
index 5886aa58..17e66b87 100644
--- a/fusion_claims/models/sale_order.py
+++ b/fusion_claims/models/sale_order.py
@@ -1862,6 +1862,10 @@ class SaleOrder(models.Model):
string='Previous Status Before Hold',
help='Status before the application was put on hold (for resuming)',
)
+ x_fc_previous_status_before_withdrawal = fields.Char(
+ string='Status Before Withdrawal',
+ help='Records the status before withdrawal for audit trail.',
+ )
x_fc_status_before_delivery = fields.Char(
string='Status Before Delivery',
@@ -2327,6 +2331,20 @@ class SaleOrder(models.Model):
help='Date when Page 11 was signed',
)
+ page11_sign_request_ids = fields.One2many(
+ 'fusion.page11.sign.request', 'sale_order_id',
+ string='Page 11 Signing Requests',
+ )
+ page11_sign_request_count = fields.Integer(
+ compute='_compute_page11_sign_request_count',
+ string='Signing Requests',
+ )
+ page11_sign_status = fields.Selection([
+ ('none', 'Not Requested'),
+ ('sent', 'Pending Signature'),
+ ('signed', 'Signed'),
+ ], compute='_compute_page11_sign_request_count', string='Page 11 Remote Status')
+
# ==========================================================================
# PAGE 12 SIGNATURE TRACKING (Authorizer + Vendor Signature)
# Page 12 must be signed by: Authorizer (OT) and Vendor (our company)
@@ -3120,11 +3138,49 @@ class SaleOrder(models.Model):
self.ensure_one()
return self._action_open_document('x_fc_original_application', 'Original ADP Application')
+ @api.depends('page11_sign_request_ids', 'page11_sign_request_ids.state')
+ def _compute_page11_sign_request_count(self):
+ for order in self:
+ requests = order.page11_sign_request_ids
+ order.page11_sign_request_count = len(requests)
+ signed = requests.filtered(lambda r: r.state == 'signed')
+ pending = requests.filtered(lambda r: r.state == 'sent')
+ if signed:
+ order.page11_sign_status = 'signed'
+ elif pending:
+ order.page11_sign_status = 'sent'
+ else:
+ order.page11_sign_status = 'none'
+
def action_open_signed_pages(self):
"""Open the Page 11 & 12 PDF."""
self.ensure_one()
return self._action_open_document('x_fc_signed_pages_11_12', 'Page 11 & 12 (Signed)')
-
+
+ def action_request_page11_signature(self):
+ """Open the wizard to send Page 11 for remote signing."""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Request Page 11 Signature',
+ 'res_model': 'fusion_claims.send.page11.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {'default_sale_order_id': self.id},
+ }
+
+ def action_view_page11_requests(self):
+ """Open the list of Page 11 signing requests."""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Page 11 Signing Requests',
+ 'res_model': 'fusion.page11.sign.request',
+ 'view_mode': 'list,form',
+ 'domain': [('sale_order_id', '=', self.id)],
+ 'context': {'default_sale_order_id': self.id},
+ }
+
def action_open_final_application(self):
"""Open the Final Submitted Application PDF."""
self.ensure_one()
@@ -3686,6 +3742,41 @@ class SaleOrder(models.Model):
return True
+ def action_resubmit_from_withdrawn(self):
+ """Return a withdrawn application to Ready for Submission for correction and resubmission."""
+ self.ensure_one()
+
+ if self.x_fc_adp_application_status != 'withdrawn':
+ raise UserError("This action is only available for withdrawn applications.")
+
+ self.with_context(skip_status_validation=True).write({
+ 'x_fc_adp_application_status': 'ready_submission',
+ })
+
+ user_name = self.env.user.name
+ resubmit_date = fields.Date.today().strftime('%B %d, %Y')
+
+ message_body = f'''
+
+
Application Returned for Resubmission
+
+
Returned By: {user_name}
+
Date: {resubmit_date}
+
Status Returned To: Ready for Submission
+
+
+
Make corrections and click Submit Application to resubmit.
+
+ '''
+
+ self.message_post(
+ body=Markup(message_body),
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+
+ return True
+
def action_set_ready_to_bill(self):
"""Open the Ready to Bill wizard to collect POD and delivery date.
@@ -4520,6 +4611,12 @@ class SaleOrder(models.Model):
if 'x_fc_device_placement' in self.env['account.move.line']._fields:
line_vals['x_fc_device_placement'] = line.x_fc_device_placement
+ # Copy deduction fields so export verification can recalculate correctly
+ if 'x_fc_deduction_type' in self.env['account.move.line']._fields:
+ line_vals['x_fc_deduction_type'] = line.x_fc_deduction_type or 'none'
+ if 'x_fc_deduction_value' in self.env['account.move.line']._fields:
+ line_vals['x_fc_deduction_value'] = line.x_fc_deduction_value or 0
+
# Store BOTH portions on invoice line (for display)
if 'x_fc_adp_portion' in self.env['account.move.line']._fields:
line_vals['x_fc_adp_portion'] = adp_portion
@@ -5170,13 +5267,13 @@ class SaleOrder(models.Model):
f'border-bottom:2px solid #4a5568;{font}"'
)
cell_style = (
- 'style="padding:7px 10px;font-size:12px;color:#2d3748;'
- 'border-bottom:1px solid #e2e8f0;"'
+ 'style="padding:7px 10px;font-size:12px;'
+ 'border-bottom:1px solid rgba(128,128,128,0.15);"'
)
- alt_row = 'style="background:#f7fafc;"'
+ alt_row = 'style="background:rgba(128,128,128,0.06);"'
amt_style = (
- 'style="padding:7px 10px;font-size:12px;color:#2d3748;'
- 'border-bottom:1px solid #e2e8f0;text-align:right;"'
+ 'style="padding:7px 10px;font-size:12px;'
+ 'border-bottom:1px solid rgba(128,128,128,0.15);text-align:right;"'
)
hdr_r = hdr_style.replace('text-align:left', 'text-align:right')
@@ -5187,9 +5284,9 @@ class SaleOrder(models.Model):
html = (
'
')
-
- task.schedule_info_html = Markup(''.join(html_parts))
-
- @api.depends('technician_id', 'scheduled_date', 'time_start',
- 'address_lat', 'address_lng', 'address_street')
- def _compute_prev_task_summary(self):
- """Show previous task info + travel time warning with color coding."""
- for task in self:
- if not task.technician_id or not task.scheduled_date:
- task.prev_task_summary_html = ''
- continue
-
- exclude_id = task.id if task.id else 0
- # Find the task that ends just before this one starts (lead or additional)
- prev_tasks = self.sudo().search([
- '|',
- ('technician_id', '=', task.technician_id.id),
- ('additional_technician_ids', 'in', [task.technician_id.id]),
- ('scheduled_date', '=', task.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', exclude_id),
- ('time_end', '<=', task.time_start or 99.0),
- ], order='time_end desc', limit=1)
-
- if not prev_tasks:
- # Check if this is the first task of the day -- show start location info
- task.prev_task_summary_html = Markup(
- '
'
- ' First task of the day -- '
- 'travel calculated from start location.
'
- )
- continue
-
- prev = prev_tasks[0]
- prev_start = self._float_to_time_str(prev.time_start)
- prev_end = self._float_to_time_str(prev.time_end)
- type_label = dict(self._fields['task_type'].selection).get(
- prev.task_type, prev.task_type or '')
- client_name = prev.partner_id.name or ''
- prev_addr = prev.address_display or 'No address'
-
- # Calculate gap between prev task end and this task start
- s_open, _s_close = self._get_store_hours()
- gap_hours = (task.time_start or s_open) - (prev.time_end or s_open)
- gap_minutes = int(gap_hours * 60)
-
- # Try to get travel time if both have coordinates
- travel_minutes = 0
- travel_text = ''
- if (prev.address_lat and prev.address_lng and
- task.address_lat and task.address_lng):
- travel_minutes = self._quick_travel_time(
- prev.address_lat, prev.address_lng,
- task.address_lat, task.address_lng,
- )
- if travel_minutes > 0:
- travel_text = f'{travel_minutes} min drive'
- else:
- travel_text = 'Could not calculate travel time'
- elif prev.address_street and task.address_street:
- travel_text = 'Save to calculate travel time'
- else:
- travel_text = 'Address missing -- cannot calculate travel'
-
- # Determine color coding
- if travel_minutes > 0 and gap_minutes >= travel_minutes:
- bg_class = 'alert-success' # Green -- enough time
- icon = 'fa-check-circle'
- status_text = (
- f'{gap_minutes} min gap -- enough travel time '
- f'(~{travel_minutes} min drive)'
- )
- elif travel_minutes > 0 and gap_minutes > 0:
- bg_class = 'alert-warning' # Yellow -- tight
- icon = 'fa-exclamation-triangle'
- status_text = (
- f'{gap_minutes} min gap -- tight! '
- f'Travel is ~{travel_minutes} min drive'
- )
- elif travel_minutes > 0 and gap_minutes <= 0:
- bg_class = 'alert-danger' # Red -- impossible
- icon = 'fa-times-circle'
- status_text = (
- f'No gap! Previous task ends at {prev_end}. '
- f'Travel is ~{travel_minutes} min drive'
- )
- else:
- bg_class = 'alert-info' # Blue -- no travel data yet
- icon = 'fa-info-circle'
- status_text = travel_text
-
- html = (
- f'
'
- f' '
- f'Previous: {prev.name} '
- f'({type_label}) {prev_start} - {prev_end}'
- f'{" -- " + client_name if client_name else ""}'
- f' '
- f' {prev_addr}'
- f' '
- f' {status_text}'
- f'
'
- )
- task.prev_task_summary_html = Markup(html)
-
- def _quick_travel_time(self, from_lat, from_lng, to_lat, to_lng):
- """Quick inline travel time calculation using Google Distance Matrix API.
- Returns travel time in minutes, or 0 if unavailable."""
- try:
- api_key = self.env['ir.config_parameter'].sudo().get_param(
- 'fusion_claims.google_maps_api_key', '')
- if not api_key:
- return 0
-
- url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
- params = {
- 'origins': f'{from_lat},{from_lng}',
- 'destinations': f'{to_lat},{to_lng}',
- 'mode': 'driving',
- 'avoid': 'tolls',
- 'departure_time': 'now',
- 'key': api_key,
- }
- resp = requests.get(url, params=params, timeout=5)
- data = resp.json()
- if data.get('status') == 'OK':
- elements = data['rows'][0]['elements'][0]
- if elements.get('status') == 'OK':
- # Use duration_in_traffic if available, else duration
- duration = elements.get(
- 'duration_in_traffic', elements.get('duration', {}))
- seconds = duration.get('value', 0)
- return max(1, int(seconds / 60))
- except Exception:
- _logger.warning('Failed to calculate travel time', exc_info=True)
- return 0
-
- @api.depends('status')
- def _compute_color(self):
- color_map = {
- 'pending': 5, # purple
- 'scheduled': 0, # grey
- 'en_route': 4, # blue
- 'in_progress': 2, # orange
- 'completed': 10, # green
- 'cancelled': 1, # red
- 'rescheduled': 3, # yellow
- }
- for task in self:
- task.color = color_map.get(task.status, 0)
-
- @api.depends('address_street', 'address_street2', 'address_city',
- 'address_state_id', 'address_zip')
- def _compute_address_display(self):
- for task in self:
- street = task.address_street or ''
- # If the street field already contains a full address (has a comma),
- # use it directly -- Google Places stores the formatted address here.
- if ',' in street and (
- (task.address_city and task.address_city in street) or
- (task.address_zip and task.address_zip in street)
- ):
- # Street already has full address; just append unit if separate
- if task.address_street2 and task.address_street2 not in street:
- task.address_display = f"{street}, {task.address_street2}"
- else:
- task.address_display = street
- else:
- # Build from components (manual entry or legacy data)
- parts = [
- street,
- task.address_street2,
- task.address_city,
- task.address_state_id.name if task.address_state_id else '',
- task.address_zip,
- ]
- task.address_display = ', '.join([p for p in parts if p])
-
- # ------------------------------------------------------------------
- # ONCHANGE - Auto-fill address from client
- # ------------------------------------------------------------------
-
- @api.onchange('is_in_store')
- def _onchange_is_in_store(self):
- """Auto-fill company address when task is marked as in-store."""
- if self.is_in_store:
- company_partner = self.env.company.partner_id
- if company_partner and company_partner.street:
- self._fill_address_from_partner(company_partner)
- else:
- self.address_street = self.env.company.name or 'In Store'
-
- @api.onchange('partner_id')
- def _onchange_partner_id(self):
- """Auto-fill address fields from the selected client's address."""
- if self.is_in_store:
- return
- if self.partner_id:
- addr = self.partner_id
- self.address_partner_id = addr.id
- self.address_street = addr.street or ''
- self.address_street2 = addr.street2 or ''
- self.address_city = addr.city or ''
- self.address_state_id = addr.state_id.id if addr.state_id else False
- self.address_zip = addr.zip or ''
- self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0
- self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0
-
@api.onchange('sale_order_id')
def _onchange_sale_order_id(self):
"""Auto-fill client and address from the sale order's shipping address."""
@@ -1101,21 +125,8 @@ class FusionTechnicianTask(models.Model):
self.sale_order_id = False
self.purchase_order_id = False
- def _fill_address_from_partner(self, addr):
- """Populate address fields from a partner record."""
- if not addr:
- return
- self.address_partner_id = addr.id
- self.address_street = addr.street or ''
- self.address_street2 = addr.street2 or ''
- self.address_city = addr.city or ''
- self.address_state_id = addr.state_id.id if addr.state_id else False
- self.address_zip = addr.zip or ''
- self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0
- self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0
-
# ------------------------------------------------------------------
- # CONSTRAINTS + VALIDATION
+ # CONSTRAINTS
# ------------------------------------------------------------------
@api.constrains('sale_order_id', 'purchase_order_id')
@@ -1130,514 +141,78 @@ class FusionTechnicianTask(models.Model):
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
))
- @api.constrains('address_street', 'address_lat', 'address_lng', 'is_in_store')
- def _check_address_required(self):
- """Non-in-store tasks must have a geocoded address."""
- for task in self:
- if task.x_fc_sync_source:
- continue
- if task.is_in_store:
- continue
- if not task.address_street:
- raise ValidationError(_(
- "A valid address is required. If this task is at the store, "
- "please check the 'In Store' option."
- ))
+ # ------------------------------------------------------------------
+ # HOOK OVERRIDES
+ # ------------------------------------------------------------------
- @api.constrains('technician_id', 'additional_technician_ids',
- 'scheduled_date', 'time_start', 'time_end')
- def _check_no_overlap(self):
- """Prevent overlapping bookings for the same technician on the same date.
+ def _get_linked_order(self):
+ """Return the linked sale or purchase order."""
+ return self.sale_order_id or self.purchase_order_id or False
- Checks both the lead technician and all additional technicians.
- """
- for task in self:
- if task.status == 'cancelled':
- continue
- if task.x_fc_sync_source:
- continue
- # Validate time range
- if task.time_start >= task.time_end:
- raise ValidationError(_("Start time must be before end time."))
- # Validate store hours
- s_open, s_close = self._get_store_hours()
- if task.time_start < s_open or task.time_end > s_close:
- open_str = self._float_to_time_str(s_open)
- close_str = self._float_to_time_str(s_close)
- raise ValidationError(_(
- "Tasks must be scheduled within store hours (%s - %s)."
- ) % (open_str, close_str))
- # Validate not in the past (only for new/scheduled local tasks)
- if task.status == 'scheduled' and task.scheduled_date and not task.x_fc_sync_source:
- today = fields.Date.context_today(self)
- if task.scheduled_date < today:
- raise ValidationError(_("Cannot schedule tasks in the past."))
- if task.scheduled_date == today:
- now = fields.Datetime.now()
- current_hour = now.hour + now.minute / 60.0
- if task.time_start < current_hour:
- pass # Allow editing existing tasks that started earlier today
- # Check overlap for lead + additional technicians
- all_tech_ids = (task.technician_id | task.additional_technician_ids).ids
- for tech_id in all_tech_ids:
- tech_name = self.env['res.users'].browse(tech_id).name
- overlapping = self.sudo().search([
- '|',
- ('technician_id', '=', tech_id),
- ('additional_technician_ids', 'in', [tech_id]),
- ('scheduled_date', '=', task.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', task.id),
- ('time_start', '<', task.time_end),
- ('time_end', '>', task.time_start),
- ], limit=1)
- if overlapping:
- start_str = self._float_to_time_str(overlapping.time_start)
- end_str = self._float_to_time_str(overlapping.time_end)
- 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 gaps for lead technician only
- # (additional techs travel with the lead, same destination)
- next_task = self.sudo().search([
- '|',
- ('technician_id', '=', task.technician_id.id),
- ('additional_technician_ids', 'in', [task.technician_id.id]),
- ('scheduled_date', '=', task.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', task.id),
- ('time_start', '>=', task.time_end),
- ], order='time_start', limit=1)
- if next_task and task.address_lat and task.address_lng and \
- next_task.address_lat and next_task.address_lng:
- travel_min = self._quick_travel_time(
- task.address_lat, task.address_lng,
- next_task.address_lat, next_task.address_lng,
- )
- if travel_min > 0:
- gap_min = int((next_task.time_start - task.time_end) * 60)
- if gap_min < travel_min:
- raise ValidationError(_(
- "Not enough travel time to the next task!\n\n"
- "This task ends at %(end)s, and %(next)s starts "
- "at %(next_start)s (%(gap)d min gap).\n"
- "Travel time is ~%(travel)d minutes.\n\n"
- "Please allow at least %(travel)d minutes between tasks.",
- end=self._float_to_time_str(task.time_end),
- next=next_task.name,
- next_start=self._float_to_time_str(next_task.time_start),
- gap=gap_min,
- travel=travel_min,
- ))
-
- prev_task = self.sudo().search([
- '|',
- ('technician_id', '=', task.technician_id.id),
- ('additional_technician_ids', 'in', [task.technician_id.id]),
- ('scheduled_date', '=', task.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', task.id),
- ('time_end', '<=', task.time_start),
- ], order='time_end desc', limit=1)
- if prev_task and task.address_lat and task.address_lng and \
- prev_task.address_lat and prev_task.address_lng:
- travel_min = self._quick_travel_time(
- prev_task.address_lat, prev_task.address_lng,
- task.address_lat, task.address_lng,
- )
- if travel_min > 0:
- gap_min = int((task.time_start - prev_task.time_end) * 60)
- if gap_min < travel_min:
- raise ValidationError(_(
- "Not enough travel time from the previous task!\n\n"
- "%(prev)s ends at %(prev_end)s, and this task starts "
- "at %(start)s (%(gap)d min gap).\n"
- "Travel time is ~%(travel)d minutes.\n\n"
- "Please allow at least %(travel)d minutes between tasks.",
- prev=prev_task.name,
- prev_end=self._float_to_time_str(prev_task.time_end),
- start=self._float_to_time_str(task.time_start),
- gap=gap_min,
- travel=travel_min,
- ))
-
- @api.onchange('technician_id', 'scheduled_date')
- def _onchange_technician_date_autoset(self):
- """Auto-set start/end time to the first available slot when tech+date change."""
- if not self.technician_id or not self.scheduled_date:
- return
- exclude_id = self._origin.id if self._origin else False
- duration = self.duration_hours or 1.0
- s_open, _s_close = self._get_store_hours()
- preferred = self.time_start or s_open
- start, end = self._find_next_available_slot(
- self.technician_id.id,
- self.scheduled_date,
- preferred_start=preferred,
- duration=duration,
- exclude_task_id=exclude_id,
- dest_lat=self.address_lat or 0,
- dest_lng=self.address_lng or 0,
- )
- if start is not False:
- self.time_start = start
- self.time_end = end
- self.duration_hours = end - start
+ def _create_vals_fill(self, vals):
+ """Fill address from sale order or purchase order during create."""
+ if vals.get('sale_order_id') and not vals.get('address_street'):
+ order = self.env['sale.order'].browse(vals['sale_order_id'])
+ addr = order.partner_shipping_id or order.partner_id
+ if addr:
+ self._fill_address_vals(vals, addr)
+ if not vals.get('partner_id'):
+ vals['partner_id'] = order.partner_id.id
+ elif vals.get('purchase_order_id') and not vals.get('address_street'):
+ po = self.env['purchase.order'].browse(vals['purchase_order_id'])
+ addr = po.dest_address_id or po.partner_id
+ if addr:
+ self._fill_address_vals(vals, addr)
+ if not vals.get('partner_id'):
+ vals['partner_id'] = po.partner_id.id
else:
- return {'warning': {
- 'title': _('Fully Booked'),
- 'message': _(
- '%s is fully booked on %s. No available slots.'
- ) % (self.technician_id.name,
- self.scheduled_date.strftime('%B %d, %Y')),
- }}
+ super()._create_vals_fill(vals)
- def _snap_if_overlap(self):
- """Check if current time_start/time_end overlaps with another task.
- If so, auto-snap to the next available slot and return a warning dict."""
- if not self.technician_id or not self.scheduled_date or not self.time_start:
- return None
- exclude_id = self._origin.id if self._origin else 0
- 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([
- '|',
- ('technician_id', 'in', all_tech_ids),
- ('additional_technician_ids', 'in', all_tech_ids),
- ('scheduled_date', '=', self.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', exclude_id),
- ('time_start', '<', self.time_end),
- ('time_end', '>', self.time_start),
- ], limit=1)
- if overlapping:
- conflict_name = overlapping.name
- conflict_start = self._float_to_time_str(overlapping.time_start)
- conflict_end = self._float_to_time_str(overlapping.time_end)
- start, end = self._find_next_available_slot(
- self.technician_id.id,
- self.scheduled_date,
- preferred_start=self.time_start,
- duration=duration,
- exclude_task_id=exclude_id,
- dest_lat=self.address_lat or 0,
- dest_lng=self.address_lng or 0,
- )
- if start is not False:
- new_start_str = self._float_to_time_str(start)
- new_end_str = self._float_to_time_str(end)
- self.time_start = start
- self.time_end = end
- self.duration_hours = end - start
- return {'warning': {
- 'title': _('Moved to Available Slot'),
- 'message': _(
- 'The selected time conflicts with %s (%s - %s).\n'
- 'Automatically moved to: %s - %s.'
- ) % (conflict_name, conflict_start, conflict_end,
- new_start_str, new_end_str),
- }}
- else:
- return {'warning': {
- 'title': _('No Available Slots'),
- 'message': _(
- 'The selected time conflicts with %s (%s - %s) '
- 'and no other slots are available on this day.'
- ) % (conflict_name, conflict_start, conflict_end),
- }}
- return None
-
- # ------------------------------------------------------------------
- # DEFAULT_GET - Calendar pre-fill
- # ------------------------------------------------------------------
-
- def _snap_to_quarter(self, hour_float):
- """Round a float hour to the nearest 15-minute slot and clamp to store hours."""
- s_open, s_close = self._get_store_hours()
- snapped = round(hour_float * 4) / 4
- return max(s_open, min(s_close, snapped))
-
- @api.model
- def default_get(self, fields_list):
- """Handle calendar time range selection: pre-fill date + times from context."""
- res = super().default_get(fields_list)
- ctx = self.env.context
-
- # Set duration default based on task type from context
- task_type = ctx.get('default_task_type', res.get('task_type', 'delivery'))
- if 'duration_hours' not in res or not res.get('duration_hours'):
- res['duration_hours'] = self.TASK_TYPE_DURATIONS.get(task_type, 1.0)
-
- # When user clicks a time range on the calendar, Odoo passes
- # default_datetime_start/end in UTC
- dt_start_utc = None
- dt_end_utc = None
- if ctx.get('default_datetime_start'):
- try:
- dt_start_utc = fields.Datetime.from_string(ctx['default_datetime_start'])
- except (ValueError, TypeError):
- pass
- if ctx.get('default_datetime_end'):
- try:
- dt_end_utc = fields.Datetime.from_string(ctx['default_datetime_end'])
- except (ValueError, TypeError):
- pass
-
- if dt_start_utc or dt_end_utc:
- import pytz
- user_tz = pytz.timezone(self.env.user.tz or 'UTC')
-
- if dt_start_utc:
- dt_start_local = pytz.utc.localize(dt_start_utc).astimezone(user_tz)
- res['scheduled_date'] = dt_start_local.date()
- start_float = self._snap_to_quarter(
- dt_start_local.hour + dt_start_local.minute / 60.0)
- res['time_start'] = start_float
-
- if dt_end_utc:
- dt_end_local = pytz.utc.localize(dt_end_utc).astimezone(user_tz)
- end_float = self._snap_to_quarter(
- dt_end_local.hour + dt_end_local.minute / 60.0)
- if 'time_start' in res and end_float <= res['time_start']:
- end_float = res['time_start'] + 1.0
- res['time_end'] = end_float
- # Compute duration from the calendar drag
- if 'time_start' in res:
- res['duration_hours'] = end_float - res['time_start']
-
- # Always compute end from start + duration if not already set
- if 'time_end' not in res and 'time_start' in res and 'duration_hours' in res:
- _open, close = self._get_store_hours()
- res['time_end'] = min(
- res['time_start'] + res['duration_hours'], close)
-
- return res
-
- # ------------------------------------------------------------------
- # CRUD OVERRIDES
- # ------------------------------------------------------------------
-
- @api.model_create_multi
- def create(self, vals_list):
- for vals in vals_list:
- if vals.get('name', _('New')) == _('New'):
- vals['name'] = self.env['ir.sequence'].next_by_code('fusion.technician.task') or _('New')
- if not vals.get('x_fc_sync_uuid') and not vals.get('x_fc_sync_source'):
- vals['x_fc_sync_uuid'] = str(uuid.uuid4())
- # In-store tasks: auto-fill company address
- if vals.get('is_in_store') and not vals.get('address_street'):
- company_partner = self.env.company.partner_id
- if company_partner and company_partner.street:
- self._fill_address_vals(vals, company_partner)
- else:
- vals['address_street'] = self.env.company.name or 'In Store'
-
- # Auto-populate address from sale order if not provided
- if vals.get('sale_order_id') and not vals.get('address_street'):
- order = self.env['sale.order'].browse(vals['sale_order_id'])
- addr = order.partner_shipping_id or order.partner_id
- if addr:
- self._fill_address_vals(vals, addr)
- if not vals.get('partner_id'):
- vals['partner_id'] = order.partner_id.id
- # Auto-populate address from purchase order if not provided
- elif vals.get('purchase_order_id') and not vals.get('address_street'):
- po = self.env['purchase.order'].browse(vals['purchase_order_id'])
- addr = po.dest_address_id or po.partner_id
- if addr:
- self._fill_address_vals(vals, addr)
- if not vals.get('partner_id'):
- vals['partner_id'] = po.partner_id.id
- # Auto-populate address from partner if no order set
- elif vals.get('partner_id') and not vals.get('address_street'):
- partner = self.env['res.partner'].browse(vals['partner_id'])
- if partner.street:
- self._fill_address_vals(vals, partner)
- records = super().create(vals_list)
- # Post creation notice to linked order chatter
- for rec in records:
+ def _on_create_post_actions(self):
+ """Post-create actions: chatter notices, delivery marking, ODSP."""
+ for rec in self:
rec._post_task_created_to_linked_order()
- # If created from "Ready for Delivery" flow, mark the sale order
if self.env.context.get('mark_ready_for_delivery'):
- records._mark_sale_order_ready_for_delivery()
+ self._mark_sale_order_ready_for_delivery()
if self.env.context.get('mark_odsp_ready_for_delivery'):
- for rec in records:
+ for rec in self:
order = rec.sale_order_id
if order and order.x_fc_is_odsp_sale and order._get_odsp_status() != 'ready_delivery':
order._odsp_advance_status('ready_delivery',
"Order is ready for delivery. Delivery task scheduled.")
- # Auto-calculate travel times for the full day chain
- if not self.env.context.get('skip_travel_recalc'):
- records._recalculate_day_travel_chains()
- # Send "Appointment Scheduled" email
- for rec in records:
- rec._send_task_scheduled_email()
- # Push new local tasks to remote instances
- local_records = records.filtered(lambda r: not r.x_fc_sync_source)
- if local_records and not self.env.context.get('skip_task_sync'):
- self.env['fusion.task.sync.config']._push_tasks(local_records, 'create')
- # Sync to calendar for external calendar integrations
- records._sync_calendar_event()
- return records
- def write(self, vals):
- if self.env.context.get('skip_travel_recalc'):
- res = super().write(vals)
- if ('status' in vals and vals['status'] in ('completed', 'cancelled')
- and not self.env.context.get('skip_task_sync')):
- shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
- if shadow_records:
- self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
- local_records = self.filtered(lambda r: not r.x_fc_sync_source)
- if local_records:
- self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
- return res
+ def _check_completion_requirements(self):
+ """Check rental inspection requirement before completing pickup tasks."""
+ if self._is_rental_pickup_task() and not self.rental_inspection_completed:
+ raise UserError(_(
+ "Rental pickup tasks require a security inspection before "
+ "completion. Please complete the inspection from the "
+ "technician portal first."
+ ))
- # Safety: ensure time_end is consistent when start/duration change
- # but time_end wasn't sent (readonly field in view may not save)
- if ('time_start' in vals or 'duration_hours' in vals) and 'time_end' not in vals:
- _open, close = self._get_store_hours()
- start = vals.get('time_start', self[:1].time_start if len(self) == 1 else 9.0)
- dur = vals.get('duration_hours', self[:1].duration_hours if len(self) == 1 else 1.0) or 1.0
- vals['time_end'] = min(start + dur, close)
+ def _on_complete_extra(self):
+ """ODSP advancement and rental inspection on task completion."""
+ if (self.task_type == 'delivery'
+ and self.sale_order_id
+ and self.sale_order_id.x_fc_is_odsp_sale
+ and self.sale_order_id._get_odsp_status() == 'ready_delivery'):
+ self.sale_order_id._odsp_advance_status(
+ 'delivered',
+ "Delivery task completed by technician. Order marked as delivered.",
+ )
+ if self._is_rental_pickup_task():
+ self._apply_rental_inspection_results()
- # Detect reschedule mode: capture old values BEFORE write
- reschedule_mode = self.env.context.get('reschedule_mode')
- old_schedule = {}
- schedule_fields = {'scheduled_date', 'time_start', 'time_end',
- 'duration_hours', 'technician_id'}
- schedule_changed = schedule_fields & set(vals.keys())
- if reschedule_mode and schedule_changed:
- for task in self:
- old_schedule[task.id] = {
- 'date': task.scheduled_date,
- 'time_start': task.time_start,
- 'time_end': task.time_end,
- }
+ def _on_cancel_extra(self):
+ """Revert sale order on delivery cancellation, send email otherwise."""
+ if self.task_type == 'delivery':
+ self._revert_sale_order_on_cancel()
+ else:
+ self._send_task_cancelled_email()
- # Capture old tech+date combos BEFORE write for travel recalc
- travel_fields = {'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng',
- 'scheduled_date', 'sequence', 'time_start', 'technician_id',
- 'additional_technician_ids'}
- needs_travel_recalc = travel_fields & set(vals.keys())
- old_combos = set()
- if needs_travel_recalc:
- 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)
- if needs_travel_recalc:
- 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
- self._recalculate_combos_travel(all_combos)
-
- # After write: send reschedule email if schedule actually changed
- if reschedule_mode and old_schedule:
- for task in self:
- old = old_schedule.get(task.id, {})
- if old and (
- old['date'] != task.scheduled_date
- or abs(old['time_start'] - task.time_start) > 0.01
- or abs(old['time_end'] - task.time_end) > 0.01
- ):
- task._post_status_message('rescheduled')
- task._send_task_rescheduled_email(
- old_date=old['date'],
- old_start=old['time_start'],
- old_end=old['time_end'],
- )
- # Push updates to remote instances for local tasks
- sync_fields = {'technician_id', 'additional_technician_ids',
- 'scheduled_date', 'time_start', 'time_end',
- 'duration_hours', 'status', 'task_type', 'address_street',
- 'address_city', 'address_zip', 'address_lat', 'address_lng',
- 'partner_id'}
- if sync_fields & set(vals.keys()) and not self.env.context.get('skip_task_sync'):
- local_records = self.filtered(lambda r: not r.x_fc_sync_source)
- if local_records:
- self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
- if 'status' in vals and vals['status'] in ('completed', 'cancelled'):
- shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
- if shadow_records:
- self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
- # Re-sync calendar event when schedule fields change
- cal_fields = {'scheduled_date', 'time_start', 'time_end',
- 'duration_hours', 'technician_id', 'task_type',
- 'partner_id', 'address_street', 'address_city', 'notes'}
- if cal_fields & set(vals.keys()):
- self._sync_calendar_event()
- return res
-
- def _sync_calendar_event(self):
- """Create or update a linked calendar.event for external calendar sync.
-
- Only syncs tasks that have a scheduled date and an assigned technician.
- Uses sudo() because portal users should not need calendar write access.
- """
- CalendarEvent = self.env['calendar.event'].sudo()
- for task in self:
- if not task.datetime_start or not task.datetime_end or not task.technician_id:
- if task.calendar_event_id:
- task.calendar_event_id.unlink()
- task.with_context(skip_travel_recalc=True).write({'calendar_event_id': False})
- continue
-
- partner = task.partner_id or task.sale_order_id.partner_id if task.sale_order_id else task.partner_id
- client_name = partner.name if partner else ''
- type_label = dict(self._fields['task_type'].selection).get(task.task_type, task.task_type or '')
-
- event_name = f"{type_label}: {client_name}" if client_name else f"{type_label} - {task.name}"
- location_parts = [task.address_street, task.address_city]
- location = ', '.join(p for p in location_parts if p) or ''
-
- description_parts = []
- if task.sale_order_id:
- description_parts.append(f"SO: {task.sale_order_id.name}")
- if task.notes:
- description_parts.append(task.notes)
-
- vals = {
- 'name': event_name,
- 'start': task.datetime_start,
- 'stop': task.datetime_end,
- 'user_id': task.technician_id.id,
- 'location': location,
- 'partner_ids': [(6, 0, [task.technician_id.partner_id.id])],
- 'show_as': 'busy',
- 'description': '\n'.join(description_parts),
- }
-
- if task.calendar_event_id:
- task.calendar_event_id.write(vals)
- else:
- event = CalendarEvent.create(vals)
- task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id})
-
- @api.model
- def _fill_address_vals(self, vals, partner):
- """Helper to fill address vals dict from a partner record."""
- vals.update({
- 'address_partner_id': partner.id,
- 'address_street': partner.street or '',
- 'address_street2': partner.street2 or '',
- 'address_city': partner.city or '',
- 'address_state_id': partner.state_id.id if partner.state_id else False,
- 'address_zip': partner.zip or '',
- 'address_lat': partner.x_fc_latitude if hasattr(partner, 'x_fc_latitude') else 0,
- 'address_lng': partner.x_fc_longitude if hasattr(partner, 'x_fc_longitude') else 0,
- })
+ # ------------------------------------------------------------------
+ # ORDER LINKING METHODS
+ # ------------------------------------------------------------------
def _post_task_created_to_linked_order(self):
"""Post a brief task creation notice to the linked order's chatter."""
@@ -1662,26 +237,19 @@ class FusionTechnicianTask(models.Model):
)
def _mark_sale_order_ready_for_delivery(self):
- """Mark linked sale orders as Ready for Delivery.
-
- Called when a delivery task is created from the "Ready for Delivery"
- button on the sale order. This replaces the old wizard workflow.
- """
+ """Mark linked sale orders as Ready for Delivery."""
for task in self:
order = task.sale_order_id
if not order:
continue
- # Only update if not already marked
if order.x_fc_adp_application_status == 'ready_delivery':
continue
user_name = self.env.user.name
tech_name = task.technician_id.name or ''
- # Save current status so we can revert if task is cancelled
previous_status = order.x_fc_adp_application_status
- # 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({
'x_fc_adp_application_status': 'ready_delivery',
@@ -1691,7 +259,6 @@ class FusionTechnicianTask(models.Model):
'x_fc_scheduled_delivery_datetime': task.datetime_start,
})
- # Post chatter message
early_badge = ''
if order.x_fc_early_delivery:
early_badge = ' Early Delivery'
@@ -1724,7 +291,6 @@ class FusionTechnicianTask(models.Model):
subtype_xmlid='mail.mt_note',
)
- # Send email notifications
try:
order._send_ready_for_delivery_email(
technicians=task.technician_id | task.additional_technician_ids,
@@ -1734,203 +300,76 @@ class FusionTechnicianTask(models.Model):
except Exception as e:
_logger.warning("Ready for delivery email failed for %s: %s", order.name, e)
- def _recalculate_day_travel_chains(self):
- """Recalculate travel for all tech+date combos affected by these tasks.
-
- 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)
-
- def _get_technician_start_address(self, tech_id):
- """Get the start address for a technician.
-
- Priority:
- 1. Technician's personal x_fc_start_address (if set)
- 2. Company default HQ address (fusion_claims.technician_start_address)
- Returns the address string or ''.
- """
- tech_user = self.env['res.users'].sudo().browse(tech_id)
- if tech_user.exists() and tech_user.x_fc_start_address:
- return tech_user.x_fc_start_address.strip()
- # Fallback to company default
- return (self.env['ir.config_parameter'].sudo()
- .get_param('fusion_claims.technician_start_address', '') or '').strip()
-
- def _geocode_address_string(self, address, api_key):
- """Geocode an address string and return (lat, lng) or (0.0, 0.0)."""
- if not address or not api_key:
- return 0.0, 0.0
- try:
- url = 'https://maps.googleapis.com/maps/api/geocode/json'
- params = {'address': address, 'key': api_key, 'region': 'ca'}
- resp = requests.get(url, params=params, timeout=10)
- data = resp.json()
- if data.get('status') == 'OK' and data.get('results'):
- loc = data['results'][0]['geometry']['location']
- return loc['lat'], loc['lng']
- except Exception as e:
- _logger.warning("Address geocoding failed for '%s': %s", address, e)
- return 0.0, 0.0
-
- def _recalculate_combos_travel(self, combos):
- """Recalculate travel for a set of (tech_id, date) combinations.
-
- Start-point priority per technician (for today only):
- 1. Actual GPS from today's fusion_clock check-in
- 2. Personal start address (x_fc_start_address)
- 3. Company default HQ address
- For future dates, only 2 and 3 apply.
- """
- ICP = self.env['ir.config_parameter'].sudo()
- enabled = ICP.get_param('fusion_claims.google_distance_matrix_enabled', False)
- if not enabled:
- return
- api_key = self._get_google_maps_api_key()
-
- start_coords_cache = {}
- today = fields.Date.today()
- today_str = str(today)
-
- today_tech_ids = {tid for tid, d in combos
- if tid and str(d) == today_str}
- clock_locations = {}
- if today_tech_ids:
- clock_locations = self._get_clock_in_locations(today_tech_ids, today)
-
- for tech_id, date in combos:
- if not tech_id or not date:
- continue
-
- cache_key = (tech_id, str(date))
- if cache_key not in start_coords_cache:
- if str(date) == today_str and tech_id in clock_locations:
- cl = clock_locations[tech_id]
- start_coords_cache[cache_key] = (cl['lat'], cl['lng'])
- else:
- addr = self._get_technician_start_address(tech_id)
- start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
-
- all_day_tasks = self.sudo().search([
- '|',
- ('technician_id', '=', tech_id),
- ('additional_technician_ids', 'in', [tech_id]),
- ('scheduled_date', '=', date),
- ('status', 'not in', ['cancelled']),
- ], order='time_start, sequence, id')
- if not all_day_tasks:
- continue
-
- prev_lat, prev_lng = start_coords_cache[cache_key]
- for i, task in enumerate(all_day_tasks):
- if not (task.address_lat and task.address_lng):
- task._geocode_address()
- travel_vals = {}
- if prev_lat and prev_lng and task.address_lat and task.address_lng:
- task.with_context(skip_travel_recalc=True)._calculate_travel_time(prev_lat, prev_lng)
- travel_vals['previous_task_id'] = all_day_tasks[i - 1].id if i > 0 else False
- travel_vals['travel_origin'] = 'Clock-In Location' if i == 0 and str(date) == today_str and tech_id in clock_locations else ('Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}')
- if travel_vals:
- task.with_context(skip_travel_recalc=True).write(travel_vals)
- prev_lat = task.address_lat or prev_lat
- prev_lng = task.address_lng or prev_lng
-
- # ------------------------------------------------------------------
- # STATUS ACTIONS
- # ------------------------------------------------------------------
-
- def _check_previous_tasks_completed(self):
- """Check that all earlier tasks for the same technician+date are completed.
-
- Considers tasks where the technician is either lead or additional.
- """
+ def _post_completion_to_linked_order(self):
+ """Post the completion notes to the linked order's chatter."""
self.ensure_one()
- earlier_incomplete = self.sudo().search([
- '|',
- ('technician_id', '=', self.technician_id.id),
- ('additional_technician_ids', 'in', [self.technician_id.id]),
- ('scheduled_date', '=', self.scheduled_date),
- ('time_start', '<', self.time_start),
- ('status', 'not in', ['completed', 'cancelled']),
+ order = self.sale_order_id or self.purchase_order_id
+ if not order or not self.completion_notes:
+ return
+ task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
+ body = Markup(
+ f'
'
+ f'
Technician Task Completed
'
+ f'
'
+ f'
Task: {self.name} ({task_type_label})
'
+ f'
Technician(s): {self.all_technician_names or self.technician_id.name}
'
+ f'
Completed: {self._utc_to_local(self.completion_datetime).strftime("%B %d, %Y at %I:%M %p") if self.completion_datetime else "N/A"}
'
+ f'
'
+ f''
+ f'{self.completion_notes}'
+ f'
'
+ )
+ order.message_post(
+ body=body,
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+
+ def _revert_sale_order_on_cancel(self):
+ """When a delivery task is cancelled, revert the sale order status."""
+ self.ensure_one()
+ if self.task_type != 'delivery' or not self.sale_order_id:
+ return
+ order = self.sale_order_id
+ if order.x_fc_adp_application_status != 'ready_delivery':
+ return
+
+ other_delivery_tasks = self.sudo().search([
+ ('sale_order_id', '=', order.id),
+ ('task_type', '=', 'delivery'),
+ ('status', 'not in', ['cancelled']),
('id', '!=', self.id),
], limit=1)
- if earlier_incomplete:
- raise UserError(_(
- "Please complete previous task %s first before starting this one."
- ) % earlier_incomplete.name)
-
- def _write_action_location(self, extra_vals=None):
- """Write GPS coordinates from context onto the task record."""
- ctx = self.env.context
- lat = ctx.get('action_latitude', 0)
- lng = ctx.get('action_longitude', 0)
- acc = ctx.get('action_accuracy', 0)
- vals = {
- 'action_latitude': lat,
- 'action_longitude': lng,
- 'action_location_accuracy': acc,
- }
- if extra_vals:
- vals.update(extra_vals)
- if lat and lng:
- self.with_context(skip_travel_recalc=True).write(vals)
-
- def action_start_en_route(self):
- """Mark task as En Route."""
- for task in self:
- if task.status != 'scheduled':
- raise UserError(_("Only scheduled tasks can be marked as En Route."))
- task._check_previous_tasks_completed()
- task.status = 'en_route'
- task._write_action_location()
- task._post_status_message('en_route')
-
- def action_start_task(self):
- """Mark task as In Progress."""
- for task in self:
- if task.status not in ('scheduled', 'en_route'):
- raise UserError(_("Task must be scheduled or en route to start."))
- task._check_previous_tasks_completed()
- task.status = 'in_progress'
- ctx = self.env.context
- task._write_action_location({
- 'started_latitude': ctx.get('action_latitude', 0),
- 'started_longitude': ctx.get('action_longitude', 0),
- })
- task._post_status_message('in_progress')
-
- def action_view_sale_order(self):
- """Open the linked sale order / case."""
- self.ensure_one()
- if not self.sale_order_id:
+ if other_delivery_tasks:
return
- return {
- 'name': self.sale_order_id.name,
- 'type': 'ir.actions.act_window',
- 'res_model': 'sale.order',
- 'view_mode': 'form',
- 'res_id': self.sale_order_id.id,
- }
- def action_view_purchase_order(self):
- """Open the linked purchase order."""
- self.ensure_one()
- if not self.purchase_order_id:
- return
- return {
- 'name': self.purchase_order_id.name,
- 'type': 'ir.actions.act_window',
- 'res_model': 'purchase.order',
- 'view_mode': 'form',
- 'res_id': self.purchase_order_id.id,
- }
+ prev_status = order.x_fc_status_before_delivery or 'approved'
+ status_labels = dict(order._fields['x_fc_adp_application_status'].selection)
+ prev_label = status_labels.get(prev_status, prev_status)
+
+ order.with_context(
+ skip_status_validation=True,
+ skip_status_emails=True,
+ ).write({
+ 'x_fc_adp_application_status': prev_status,
+ 'x_fc_status_before_delivery': False,
+ })
+
+ body = Markup(
+ f'
'
+ f'
Delivery Task Cancelled
'
+ f'
Delivery task {self.name} was cancelled by '
+ f'{self.env.user.name}.
'
+ f'
Order status reverted to {prev_label}.
'
+ f'
'
+ )
+ order.message_post(
+ body=body,
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
+ )
+
+ self._send_task_cancelled_email()
def _is_rental_pickup_task(self):
"""Check if this is a pickup task for a rental order."""
@@ -1941,46 +380,6 @@ class FusionTechnicianTask(models.Model):
and self.sale_order_id.is_rental_order
)
- def action_complete_task(self):
- """Mark task as Completed."""
- for task in self:
- if task.status not in ('in_progress', 'en_route', 'scheduled'):
- raise UserError(_("Task must be in progress to complete."))
-
- if task._is_rental_pickup_task() and not task.rental_inspection_completed:
- raise UserError(_(
- "Rental pickup tasks require a security inspection before "
- "completion. Please complete the inspection from the "
- "technician portal first."
- ))
-
- ctx = self.env.context
- task.with_context(skip_travel_recalc=True).write({
- 'status': 'completed',
- 'completion_datetime': fields.Datetime.now(),
- 'completed_latitude': ctx.get('action_latitude', 0),
- 'completed_longitude': ctx.get('action_longitude', 0),
- 'action_latitude': ctx.get('action_latitude', 0),
- 'action_longitude': ctx.get('action_longitude', 0),
- 'action_location_accuracy': ctx.get('action_accuracy', 0),
- })
- task._post_status_message('completed')
- if task.completion_notes and (task.sale_order_id or task.purchase_order_id):
- task._post_completion_to_linked_order()
- task._notify_scheduler_on_completion()
-
- if (task.task_type == 'delivery'
- and task.sale_order_id
- and task.sale_order_id.x_fc_is_odsp_sale
- and task.sale_order_id._get_odsp_status() == 'ready_delivery'):
- task.sale_order_id._odsp_advance_status(
- 'delivered',
- "Delivery task completed by technician. Order marked as delivered.",
- )
-
- if task._is_rental_pickup_task():
- task._apply_rental_inspection_results()
-
def _apply_rental_inspection_results(self):
"""Write inspection results from the task back to the rental order."""
self.ensure_one()
@@ -2023,277 +422,88 @@ class FusionTechnicianTask(models.Model):
order.name, e,
)
- def action_cancel_task(self):
- """Cancel the task. Sends cancellation email and reverts sale order if delivery."""
- for task in self:
- if task.status == 'completed':
- raise UserError(_("Cannot cancel a completed task."))
- task.status = 'cancelled'
- task._write_action_location()
- task._post_status_message('cancelled')
- # If this was a delivery task linked to a sale order that is
- # currently in "Ready for Delivery" -- revert the order back.
- # _revert_sale_order_on_cancel also sends the cancellation email
- # for delivery tasks.
- if task.task_type == 'delivery':
- task._revert_sale_order_on_cancel()
- else:
- # Non-delivery tasks: still send a cancellation email
- task._send_task_cancelled_email()
+ # ------------------------------------------------------------------
+ # VIEW ACTIONS
+ # ------------------------------------------------------------------
- def _revert_sale_order_on_cancel(self):
- """When a delivery task is cancelled, check if the linked sale order
- should revert to its previous status. Only reverts if:
- - Task is a delivery type
- - Sale order is currently 'ready_delivery'
- - No other active (non-cancelled) delivery tasks exist for this order
- """
+ def action_view_sale_order(self):
+ """Open the linked sale order / case."""
self.ensure_one()
- if self.task_type != 'delivery' or not self.sale_order_id:
+ if not self.sale_order_id:
return
- order = self.sale_order_id
- if order.x_fc_adp_application_status != 'ready_delivery':
- return
-
- # Check if any other non-cancelled delivery tasks exist for this order
- other_delivery_tasks = self.sudo().search([
- ('sale_order_id', '=', order.id),
- ('task_type', '=', 'delivery'),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', self.id),
- ], limit=1)
- if other_delivery_tasks:
- return # Other active delivery tasks still exist, don't revert
-
- # Revert to the status saved before Ready for Delivery
- prev_status = order.x_fc_status_before_delivery or 'approved'
- status_labels = dict(order._fields['x_fc_adp_application_status'].selection)
- prev_label = status_labels.get(prev_status, prev_status)
-
- # skip_status_emails prevents the "Approved" email from re-firing
- order.with_context(
- skip_status_validation=True,
- skip_status_emails=True,
- ).write({
- 'x_fc_adp_application_status': prev_status,
- 'x_fc_status_before_delivery': False,
- })
-
- # Post chatter message about the revert
- body = Markup(
- f'
'
- f'
Delivery Task Cancelled
'
- f'
Delivery task {self.name} was cancelled by '
- f'{self.env.user.name}.
'
- f'
Order status reverted to {prev_label}.
'
- f'
'
- )
- order.message_post(
- body=body,
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- # Send a "Delivery Cancelled" email instead
- self._send_task_cancelled_email()
-
- def action_reschedule(self):
- """Open the reschedule form for this task.
- Saves old schedule info, then opens the same task form for editing.
- On save, the write() method detects the reschedule and sends emails."""
- self.ensure_one()
return {
+ 'name': self.sale_order_id.name,
'type': 'ir.actions.act_window',
- 'res_model': 'fusion.technician.task',
- 'res_id': self.id,
+ 'res_model': 'sale.order',
'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'reschedule_mode': True,
- 'old_date': str(self.scheduled_date) if self.scheduled_date else '',
- 'old_time_start': self.time_start,
- 'old_time_end': self.time_end,
- },
+ 'res_id': self.sale_order_id.id,
}
- def action_reset_to_scheduled(self):
- """Reset task back to scheduled."""
- for task in self:
- task.status = 'scheduled'
-
- # ------------------------------------------------------------------
- # CHATTER / NOTIFICATIONS
- # ------------------------------------------------------------------
-
- def _post_status_message(self, new_status):
- """Post a status change message to the task chatter."""
+ def action_view_purchase_order(self):
+ """Open the linked purchase order."""
self.ensure_one()
- status_labels = dict(self._fields['status'].selection)
- label = status_labels.get(new_status, new_status)
- icons = {
- 'en_route': 'fa-road',
- 'in_progress': 'fa-wrench',
- 'completed': 'fa-check-circle',
- 'cancelled': 'fa-times-circle',
- 'rescheduled': 'fa-calendar',
+ if not self.purchase_order_id:
+ return
+ return {
+ 'name': self.purchase_order_id.name,
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'purchase.order',
+ 'view_mode': 'form',
+ 'res_id': self.purchase_order_id.id,
}
- icon = icons.get(new_status, 'fa-info-circle')
- body = Markup(
- f'
Task status changed to '
- f'{label} by {self.env.user.name}
'
- )
- self.message_post(body=body, message_type='notification', subtype_xmlid='mail.mt_note')
-
- def _post_completion_to_linked_order(self):
- """Post the completion notes to the linked order's chatter."""
- self.ensure_one()
- order = self.sale_order_id or self.purchase_order_id
- if not order or not self.completion_notes:
- return
- task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
- body = Markup(
- f'
'
- f'
Technician Task Completed
'
- f'
'
- f'
Task: {self.name} ({task_type_label})
'
- f'
Technician(s): {self.all_technician_names or self.technician_id.name}
'
- f'
Completed: {self.completion_datetime.strftime("%B %d, %Y at %I:%M %p") if self.completion_datetime else "N/A"}
'
- f'
'
- f''
- f'{self.completion_notes}'
- f'
'
- )
- order.message_post(
- body=body,
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- def _notify_scheduler_on_completion(self):
- """Send an Odoo notification to the person who scheduled the task.
-
- Shadow tasks skip this -- the push-back to the source instance
- triggers the notification there where the real scheduler exists.
- """
- self.ensure_one()
- if self.x_fc_sync_source:
- return
-
- recipient = None
- if self.sale_order_id and self.sale_order_id.user_id:
- recipient = self.sale_order_id.user_id
- elif self.purchase_order_id and self.purchase_order_id.user_id:
- recipient = self.purchase_order_id.user_id
- elif self.create_uid:
- recipient = self.create_uid
-
- if not recipient or recipient in self.all_technician_ids:
- return
-
- 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'
- client_name = self.client_display_name or 'N/A'
- order = self.sale_order_id or self.purchase_order_id
- case_ref = order.name if order else ''
- addr_parts = [p for p in [
- self.address_street,
- self.address_street2,
- self.address_city,
- self.address_state_id.name if self.address_state_id else '',
- self.address_zip,
- ] if p]
- address_str = ', '.join(addr_parts) or 'No address'
- subject = f'Task Completed: {client_name}'
- if case_ref:
- subject += f' ({case_ref})'
- body = Markup(
- f'
'
- f'
'
- f'{task_type_label} Completed
'
- f'
'
- f'
Client:
'
- f'
{client_name}
'
- f'
Case:
'
- f'
{case_ref or "N/A"}
'
- f'
Task:
'
- f'
{self.name}
'
- f'
Technician(s):
'
- f'
{self.all_technician_names or self.technician_id.name}