diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..07d3e0e7 Binary files /dev/null and b/.DS_Store differ diff --git a/dropdown-toggle.*New Assessment b/dropdown-toggle.*New Assessment new file mode 100644 index 00000000..0294d88e --- /dev/null +++ b/dropdown-toggle.*New Assessment @@ -0,0 +1,6 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +localhost FALSE / FALSE 1804532345 frontend_lang en_CA +#HttpOnly_localhost FALSE / FALSE 1773601137 session_id 70Wf5yZpnwpU0Izf5wBSbiWNk7UjsoGB1737H73bWDK16z05MJP0SJnNN-NhOfw8GbW6a-d_-y0opbJwcgbq diff --git "a/equipment_type=[^\"]*" "b/equipment_type=[^\"]*" new file mode 100644 index 00000000..0c0c2f49 --- /dev/null +++ "b/equipment_type=[^\"]*" @@ -0,0 +1,230 @@ + + + + + + + + + Internal Server Error + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+

500: Internal Server Error

+
+
+ +
+ +
+
+

+ QWeb +

+
+
+

+ 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 +

+
+
+
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 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_authorizer_portal/views/portal_technician_templates.xml b/fusion_authorizer_portal/views/portal_technician_templates.xml index 072891ba..58ce9bee 100644 --- a/fusion_authorizer_portal/views/portal_technician_templates.xml +++ b/fusion_authorizer_portal/views/portal_technician_templates.xml @@ -1594,7 +1594,7 @@

Technician Locations

- + View History
diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 27f5d439..d8bc3c7f 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -84,6 +84,7 @@ 'calendar', 'ai', 'fusion_ringcentral', + 'fusion_tasks', ], 'external_dependencies': { 'python': ['pdf2image', 'PIL'], @@ -128,6 +129,7 @@ 'wizard/odsp_pre_approved_wizard_views.xml', 'wizard/odsp_ready_delivery_wizard_views.xml', 'wizard/ltc_repair_create_so_wizard_views.xml', + 'wizard/send_page11_wizard_views.xml', 'views/res_partner_views.xml', 'views/pdf_template_inherit_views.xml', 'views/dashboard_views.xml', @@ -140,9 +142,8 @@ 'views/adp_claims_views.xml', 'views/submission_history_views.xml', 'views/fusion_loaner_views.xml', + 'views/page11_sign_request_views.xml', 'views/technician_task_views.xml', - 'views/task_sync_views.xml', - 'views/technician_location_views.xml', 'report/report_actions.xml', 'report/report_templates.xml', 'report/sale_report_portrait.xml', @@ -168,7 +169,6 @@ 'assets': { 'web.assets_backend': [ 'fusion_claims/static/src/scss/fusion_claims.scss', - 'fusion_claims/static/src/css/fusion_task_map_view.scss', 'fusion_claims/static/src/js/chatter_resize.js', 'fusion_claims/static/src/js/document_preview.js', 'fusion_claims/static/src/js/preview_button_widget.js', @@ -177,11 +177,9 @@ 'fusion_claims/static/src/js/tax_totals_patch.js', 'fusion_claims/static/src/js/google_address_autocomplete.js', 'fusion_claims/static/src/js/calendar_store_hours.js', - 'fusion_claims/static/src/js/fusion_task_map_view.js', 'fusion_claims/static/src/js/attachment_image_compress.js', 'fusion_claims/static/src/js/debug_required_fields.js', 'fusion_claims/static/src/xml/document_preview.xml', - 'fusion_claims/static/src/xml/fusion_task_map_view.xml', ], }, 'images': ['static/description/icon.png'], diff --git a/fusion_claims/__pycache__/__init__.cpython-312.pyc b/fusion_claims/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..625fe6dd Binary files /dev/null and b/fusion_claims/__pycache__/__init__.cpython-312.pyc differ diff --git a/fusion_claims/__pycache__/__manifest__.cpython-312.pyc b/fusion_claims/__pycache__/__manifest__.cpython-312.pyc new file mode 100644 index 00000000..d51a34a6 Binary files /dev/null and b/fusion_claims/__pycache__/__manifest__.cpython-312.pyc differ diff --git a/fusion_claims/data/device_codes/adp_mobility_manual.json b/fusion_claims/data/device_codes/adp_mobility_manual.json index be7ff251..042433d1 100644 --- a/fusion_claims/data/device_codes/adp_mobility_manual.json +++ b/fusion_claims/data/device_codes/adp_mobility_manual.json @@ -1914,7 +1914,8 @@ "Device Code": "SEAND0025", "Quantity": 2, "ADP Price": 138.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Arm Support Hardware", @@ -1923,7 +1924,8 @@ "Device Code": "SEICF301L", "Quantity": 2, "ADP Price": 60.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Arm Support Options", @@ -1932,7 +1934,8 @@ "Device Code": "SEAND0030", "Quantity": 2, "ADP Price": 65.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Arm Support Options", @@ -1941,7 +1944,8 @@ "Device Code": "SEMCF601L", "Quantity": 2, "ADP Price": 52.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Arm Support Options", @@ -1950,7 +1954,8 @@ "Device Code": "SEMCF602L", "Quantity": 2, "ADP Price": 35.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Arm Support(s)", @@ -1959,7 +1964,8 @@ "Device Code": "SEAND0005", "Quantity": 2, "ADP Price": 237.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Arm Support(s)", @@ -1968,7 +1974,8 @@ "Device Code": "SEAND0010", "Quantity": 2, "ADP Price": 91.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Arm Support(s)", @@ -1977,7 +1984,8 @@ "Device Code": "SEAND0015", "Quantity": 2, "ADP Price": 65.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Arm Support(s)", @@ -1986,7 +1994,8 @@ "Device Code": "SEACF101L", "Quantity": 2, "ADP Price": 129.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Arm Support(s)", @@ -1995,7 +2004,8 @@ "Device Code": "SEACF102L", "Quantity": 2, "ADP Price": 216.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Arm Support(s)", @@ -2004,7 +2014,8 @@ "Device Code": "SEACF103L", "Quantity": 2, "ADP Price": 65.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Arm Support(s)", @@ -2013,7 +2024,8 @@ "Device Code": "SEACF104L", "Quantity": 2, "ADP Price": 259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Auto Correction System*", @@ -2031,7 +2043,8 @@ "Device Code": "SEMCF101L", "Quantity": 2, "ADP Price": 104.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Cover", @@ -2040,7 +2053,8 @@ "Device Code": "SEMCF102L", "Quantity": 2, "ADP Price": 155.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Cover", @@ -2049,7 +2063,8 @@ "Device Code": "SEMCF103L", "Quantity": 2, "ADP Price": 198.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Cover", @@ -2058,7 +2073,8 @@ "Device Code": "SEMCF104L", "Quantity": 2, "ADP Price": 233.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Hardware", @@ -2067,7 +2083,8 @@ "Device Code": "SEBND0010", "Quantity": 2, "ADP Price": 345.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Back Hardware", @@ -2076,7 +2093,8 @@ "Device Code": "SEICF160L", "Quantity": 1, "ADP Price": 168.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support", @@ -2373,7 +2391,8 @@ "Device Code": "SEBCF110L", "Quantity": 1, "ADP Price": 259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support", @@ -2382,7 +2401,8 @@ "Device Code": "SEBCF112L", "Quantity": 1, "ADP Price": 431.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support", @@ -2391,7 +2411,8 @@ "Device Code": "SEBCF113L", "Quantity": 1, "ADP Price": 673.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support", @@ -2400,7 +2421,8 @@ "Device Code": "SEBCF114L", "Quantity": 1, "ADP Price": 561.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support", @@ -2409,7 +2431,8 @@ "Device Code": "SEBCF115L", "Quantity": 1, "ADP Price": 819.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support", @@ -2418,7 +2441,8 @@ "Device Code": "SEBCF116L", "Quantity": 1, "ADP Price": 810.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support", @@ -2427,7 +2451,8 @@ "Device Code": "SEBCF117L", "Quantity": 1, "ADP Price": 1122.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support", @@ -2436,7 +2461,8 @@ "Device Code": "SEBCF118L", "Quantity": 1, "ADP Price": 1259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support", @@ -5460,7 +5486,8 @@ "Device Code": "SEBND0015", "Quantity": 6, "ADP Price": 47.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Back Support Options", @@ -5469,7 +5496,8 @@ "Device Code": "SEBND0020", "Quantity": 6, "ADP Price": 116.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Back Support Options", @@ -5478,7 +5506,8 @@ "Device Code": "SEMCF200L", "Quantity": 1, "ADP Price": 194.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support Options", @@ -5487,7 +5516,8 @@ "Device Code": "SEMCF201L", "Quantity": 1, "ADP Price": 129.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support Options", @@ -5496,7 +5526,8 @@ "Device Code": "SEMCF202L", "Quantity": 1, "ADP Price": 86.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Back Support Options", @@ -5505,7 +5536,8 @@ "Device Code": "SEUCF003L", "Quantity": 1, "ADP Price": 138.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Caster Pin Locks (pair)", @@ -5586,7 +5618,8 @@ "Device Code": "SEMCF900L", "Quantity": 1, "ADP Price": 431.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Complete Assembly", @@ -5775,7 +5808,8 @@ "Device Code": "SEFND0030", "Quantity": 2, "ADP Price": 86.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Foot/Leg Support Hardware", @@ -5784,7 +5818,8 @@ "Device Code": "SEICF501L", "Quantity": 2, "ADP Price": 104.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Foot/Leg Support Options", @@ -5793,7 +5828,8 @@ "Device Code": "SEFND0035", "Quantity": 2, "ADP Price": 43.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Foot/Leg Support Options", @@ -5802,7 +5838,8 @@ "Device Code": "SE0000322", "Quantity": 2, "ADP Price": 65.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Foot/Leg Support Options", @@ -5811,7 +5848,8 @@ "Device Code": "SEMCF801L", "Quantity": 1, "ADP Price": 69.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Foot/Leg Support Options", @@ -5820,7 +5858,8 @@ "Device Code": "SEMCF802L", "Quantity": 2, "ADP Price": 121.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Foot/Leg Support Options", @@ -5829,7 +5868,8 @@ "Device Code": "SEMCF803L", "Quantity": 2, "ADP Price": 52.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Foot/Leg Support(s)", @@ -5838,7 +5878,8 @@ "Device Code": "SEFND0005", "Quantity": 1, "ADP Price": 246.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Foot/Leg Support(s)", @@ -5847,7 +5888,8 @@ "Device Code": "SEFND0010", "Quantity": 2, "ADP Price": 43.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Foot/Leg Support(s)", @@ -5856,7 +5898,8 @@ "Device Code": "SEFND0015", "Quantity": 1, "ADP Price": 95.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Foot/Leg Support(s)", @@ -5865,7 +5908,8 @@ "Device Code": "SEFND0020", "Quantity": 2, "ADP Price": 207.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Foot/Leg Support(s)", @@ -5874,7 +5918,8 @@ "Device Code": "SEFCF101L", "Quantity": 1, "ADP Price": 129.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Foot/Leg Support(s)", @@ -5883,7 +5928,8 @@ "Device Code": "SEFCF102L", "Quantity": 1, "ADP Price": 380.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Foot/Leg Support(s)", @@ -5892,7 +5938,8 @@ "Device Code": "SEFCF103L", "Quantity": 1, "ADP Price": 256.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Foot/Leg Support(s)", @@ -5901,7 +5948,8 @@ "Device Code": "SEFCF106L", "Quantity": 2, "ADP Price": 173.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Foot/Leg Support(s)", @@ -5910,7 +5958,8 @@ "Device Code": "SEFCF107L", "Quantity": 2, "ADP Price": 168.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Forearm Crutches", @@ -6243,7 +6292,8 @@ "Device Code": "SEHCF040L", "Quantity": 1, "ADP Price": 108.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest", @@ -6252,7 +6302,8 @@ "Device Code": "SEHCF050L", "Quantity": 1, "ADP Price": 173.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest", @@ -6261,7 +6312,8 @@ "Device Code": "SEHCF060L", "Quantity": 1, "ADP Price": 267.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest", @@ -6270,7 +6322,8 @@ "Device Code": "SEHCF070L", "Quantity": 1, "ADP Price": 164.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest", @@ -6279,7 +6332,8 @@ "Device Code": "SEHCF080L", "Quantity": 1, "ADP Price": 362.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest", @@ -6288,7 +6342,8 @@ "Device Code": "SEHCF090L", "Quantity": 1, "ADP Price": 267.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest", @@ -7674,7 +7729,8 @@ "Device Code": "SEHND0005", "Quantity": 1, "ADP Price": 121.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Headrest/Neckrest Hardware", @@ -7683,7 +7739,8 @@ "Device Code": "SEHND0010", "Quantity": 1, "ADP Price": 138.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Headrest/Neckrest Hardware", @@ -7692,7 +7749,8 @@ "Device Code": "SEHND0015", "Quantity": 1, "ADP Price": 259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Headrest/Neckrest Hardware", @@ -7701,7 +7759,8 @@ "Device Code": "SEHND0020", "Quantity": 1, "ADP Price": 86.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Headrest/Neckrest Hardware", @@ -7710,7 +7769,8 @@ "Device Code": "SEICF006L", "Quantity": 1, "ADP Price": 60.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Hardware", @@ -7719,7 +7779,8 @@ "Device Code": "SEICF007L", "Quantity": 1, "ADP Price": 134.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Hardware", @@ -7728,7 +7789,8 @@ "Device Code": "SEICF008L", "Quantity": 1, "ADP Price": 225.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Hardware", @@ -7737,7 +7799,8 @@ "Device Code": "SEICF009L", "Quantity": 1, "ADP Price": 190.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Hardware", @@ -8187,7 +8250,8 @@ "Device Code": "SE0002008", "Quantity": 1, "ADP Price": 145.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8196,7 +8260,8 @@ "Device Code": "SE0002009", "Quantity": 1, "ADP Price": 59.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8205,7 +8270,8 @@ "Device Code": "SE0002010", "Quantity": 1, "ADP Price": 112.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8214,7 +8280,8 @@ "Device Code": "SE0002011", "Quantity": 1, "ADP Price": 59.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8223,7 +8290,8 @@ "Device Code": "SE0002012", "Quantity": 1, "ADP Price": 83.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8232,7 +8300,8 @@ "Device Code": "SE0002013", "Quantity": 1, "ADP Price": 424.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8241,7 +8310,8 @@ "Device Code": "SE0002014", "Quantity": 1, "ADP Price": 629.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8250,7 +8320,8 @@ "Device Code": "SE0002015", "Quantity": 1, "ADP Price": 109.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8259,7 +8330,8 @@ "Device Code": "SE0002016", "Quantity": 1, "ADP Price": 94.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8268,7 +8340,8 @@ "Device Code": "SE0002037", "Quantity": 1, "ADP Price": 94.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8277,7 +8350,8 @@ "Device Code": "SEMCF001L", "Quantity": 2, "ADP Price": 104.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8286,7 +8360,8 @@ "Device Code": "SEMCF002L", "Quantity": 2, "ADP Price": 155.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8295,7 +8370,8 @@ "Device Code": "SEMCF005L", "Quantity": 2, "ADP Price": 173.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8304,7 +8380,8 @@ "Device Code": "SEMCF006L", "Quantity": 2, "ADP Price": 259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Headrest/Neckrest Options", @@ -8466,7 +8543,8 @@ "Device Code": "SEICF201L", "Quantity": 2, "ADP Price": 52.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Lateral Support Hardware", @@ -8475,7 +8553,8 @@ "Device Code": "SEICF202L", "Quantity": 2, "ADP Price": 173.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Lateral Support Hardware", @@ -8484,7 +8563,8 @@ "Device Code": "SEICF203L", "Quantity": 2, "ADP Price": 86.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Lateral Support Options", @@ -8493,7 +8573,8 @@ "Device Code": "SEMCF651L", "Quantity": 2, "ADP Price": 52.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Lateral Support(s)", @@ -8502,7 +8583,8 @@ "Device Code": "SELND0005", "Quantity": 4, "ADP Price": 104.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Lateral Support(s)", @@ -8511,7 +8593,8 @@ "Device Code": "SELND0010", "Quantity": 4, "ADP Price": 155.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Lateral Support(s)", @@ -8520,7 +8603,8 @@ "Device Code": "SELND0015", "Quantity": 4, "ADP Price": 166.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Lateral Support(s)", @@ -8529,7 +8613,8 @@ "Device Code": "SELND0020", "Quantity": 2, "ADP Price": 47.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Lateral Support(s)", @@ -8538,7 +8623,8 @@ "Device Code": "SELCF101L", "Quantity": 2, "ADP Price": 69.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Lateral Support(s)", @@ -8547,7 +8633,8 @@ "Device Code": "SELCF102L", "Quantity": 2, "ADP Price": 151.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Lateral Support(s)", @@ -8556,7 +8643,8 @@ "Device Code": "SELCF103L", "Quantity": 2, "ADP Price": 82.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Manual Elevating Legrests (pair)", @@ -10185,7 +10273,8 @@ "Device Code": "SESND1043", "Quantity": 1, "ADP Price": 259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Pommel/Adductors", @@ -10194,7 +10283,8 @@ "Device Code": "SEMCF701L", "Quantity": 1, "ADP Price": 138.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Pommel/Adductors", @@ -10203,7 +10293,8 @@ "Device Code": "SEMCF702L", "Quantity": 2, "ADP Price": 104.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Pommel/Adductors", @@ -10212,7 +10303,8 @@ "Device Code": "SEMCF703L", "Quantity": 2, "ADP Price": 164.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Pommel Hardware", @@ -10221,7 +10313,8 @@ "Device Code": "SEICF401L", "Quantity": 1, "ADP Price": 73.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Pommel Hardware", @@ -10230,7 +10323,8 @@ "Device Code": "SEICF402L", "Quantity": 1, "ADP Price": 151.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Pommel Hardware", @@ -10239,7 +10333,8 @@ "Device Code": "SEICF403L", "Quantity": 1, "ADP Price": 259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Positioning Belts", @@ -10248,7 +10343,8 @@ "Device Code": "SERND0001", "Quantity": 1, "ADP Price": 121.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Positioning Belts", @@ -10257,7 +10353,8 @@ "Device Code": "SERND0010", "Quantity": 2, "ADP Price": 52.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Positioning Belts", @@ -10266,7 +10363,8 @@ "Device Code": "SERND0020", "Quantity": 2, "ADP Price": 129.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Positioning Belts", @@ -10275,7 +10373,8 @@ "Device Code": "SERND0030", "Quantity": 6, "ADP Price": 56.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Positioning Belts", @@ -10284,7 +10383,8 @@ "Device Code": "SERND0035", "Quantity": 1, "ADP Price": 397.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Positioning Belts", @@ -10293,7 +10393,8 @@ "Device Code": "SERCF101L", "Quantity": 1, "ADP Price": 52.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Positioning Belts", @@ -10302,7 +10403,8 @@ "Device Code": "SERCF103L", "Quantity": 1, "ADP Price": 129.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Positioning Belts", @@ -10311,7 +10413,8 @@ "Device Code": "SERCF104L", "Quantity": 1, "ADP Price": 181.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Positioning Belts", @@ -10320,7 +10423,8 @@ "Device Code": "SERCF105L", "Quantity": 12, "ADP Price": 47.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Positioning Belts Options", @@ -10329,7 +10433,8 @@ "Device Code": "SEMCF501L", "Quantity": 12, "ADP Price": 47.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Positioning Belts Options", @@ -10338,7 +10443,8 @@ "Device Code": "SEMCF502L", "Quantity": 12, "ADP Price": 47.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Positioning Belts Options", @@ -10347,7 +10453,8 @@ "Device Code": "SEMCF503L", "Quantity": 4, "ADP Price": 142.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Power Add-On Device", @@ -12066,7 +12173,8 @@ "Device Code": "SESCF101L", "Quantity": 1, "ADP Price": 259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Cushion", @@ -12075,7 +12183,8 @@ "Device Code": "SESCF102L", "Quantity": 1, "ADP Price": 388.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Cushion", @@ -12084,7 +12193,8 @@ "Device Code": "SESCF103L", "Quantity": 1, "ADP Price": 518.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Cushion", @@ -12093,7 +12203,8 @@ "Device Code": "SESCF104L", "Quantity": 1, "ADP Price": 621.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Cushion", @@ -12102,7 +12213,8 @@ "Device Code": "SESCF105L", "Quantity": 1, "ADP Price": 1259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Cushion", @@ -16350,7 +16462,8 @@ "Device Code": "SESND1050", "Quantity": 2, "ADP Price": 65.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Cushion Cover(s)", @@ -16359,7 +16472,8 @@ "Device Code": "SESND1055", "Quantity": 2, "ADP Price": 99.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Cushion Cover(s)", @@ -16368,7 +16482,8 @@ "Device Code": "SESND1060", "Quantity": 2, "ADP Price": 134.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Cushion Cover(s)", @@ -16377,7 +16492,8 @@ "Device Code": "SEMCF105L", "Quantity": 2, "ADP Price": 108.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Cushion Cover(s)", @@ -16386,7 +16502,8 @@ "Device Code": "SEMCF106L", "Quantity": 2, "ADP Price": 155.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Cushion Cover(s)", @@ -16395,7 +16512,8 @@ "Device Code": "SEMCF107L", "Quantity": 2, "ADP Price": 216.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Hardware", @@ -16404,7 +16522,8 @@ "Device Code": "SESND1010", "Quantity": 1, "ADP Price": 259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Hardware", @@ -16413,7 +16532,8 @@ "Device Code": "SEICF140L", "Quantity": 2, "ADP Price": 86.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Hardware", @@ -16422,7 +16542,8 @@ "Device Code": "SEICF150L", "Quantity": 2, "ADP Price": 168.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Hardware", @@ -16431,7 +16552,8 @@ "Device Code": "SEICF170L", "Quantity": 1, "ADP Price": 302.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Hardware", @@ -16440,7 +16562,8 @@ "Device Code": "SEICF180L", "Quantity": 1, "ADP Price": 492.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Hardware", @@ -16449,7 +16572,8 @@ "Device Code": "SEICF190L", "Quantity": 1, "ADP Price": 86.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Hardware", @@ -16458,7 +16582,8 @@ "Device Code": "SEICF191L", "Quantity": 8, "ADP Price": 22.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Hardware", @@ -16467,7 +16592,8 @@ "Device Code": "SEICF192L", "Quantity": 1, "ADP Price": 52.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16476,7 +16602,8 @@ "Device Code": "SESND1015", "Quantity": 1, "ADP Price": 69.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Options", @@ -16485,7 +16612,8 @@ "Device Code": "SESND1020", "Quantity": 1, "ADP Price": 216.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Options", @@ -16494,7 +16622,8 @@ "Device Code": "SESND1035", "Quantity": 1, "ADP Price": 155.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Options", @@ -16503,7 +16632,8 @@ "Device Code": "SESND1040", "Quantity": 6, "ADP Price": 69.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Options", @@ -16512,7 +16642,8 @@ "Device Code": "SESND1041", "Quantity": 1, "ADP Price": 173.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Options", @@ -16521,7 +16652,8 @@ "Device Code": "SESND1045", "Quantity": 1, "ADP Price": 358.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Options", @@ -16530,7 +16662,8 @@ "Device Code": "SEMCF203L", "Quantity": 2, "ADP Price": 129.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16539,7 +16672,8 @@ "Device Code": "SEMCF204L", "Quantity": 2, "ADP Price": 185.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16548,7 +16682,8 @@ "Device Code": "SEMCF205L", "Quantity": 1, "ADP Price": 259.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16557,7 +16692,8 @@ "Device Code": "SEMCF206L", "Quantity": 2, "ADP Price": 194.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16566,7 +16702,8 @@ "Device Code": "SEMCF207L", "Quantity": 1, "ADP Price": 65.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16575,7 +16712,8 @@ "Device Code": "SEMCF208L", "Quantity": 1, "ADP Price": 173.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16584,7 +16722,8 @@ "Device Code": "SEMCF209L", "Quantity": 1, "ADP Price": 328.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16593,7 +16732,8 @@ "Device Code": "SEMCF210L", "Quantity": 2, "ADP Price": 86.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16602,7 +16742,8 @@ "Device Code": "SEMCF212L", "Quantity": 1, "ADP Price": 194.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16611,7 +16752,8 @@ "Device Code": "SEMCF401L", "Quantity": 2, "ADP Price": 52.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16620,7 +16762,8 @@ "Device Code": "SEMCF402L", "Quantity": 2, "ADP Price": 129.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16629,7 +16772,8 @@ "Device Code": "SEMCF403L", "Quantity": 2, "ADP Price": 35.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16638,7 +16782,8 @@ "Device Code": "SEMCF404L", "Quantity": 2, "ADP Price": 39.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16647,7 +16792,8 @@ "Device Code": "SEMCF405L", "Quantity": 2, "ADP Price": 43.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16656,7 +16802,8 @@ "Device Code": "SEMCF406L", "Quantity": 2, "ADP Price": 26.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16665,7 +16812,8 @@ "Device Code": "SEMCF407L", "Quantity": 2, "ADP Price": 30.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Options", @@ -16674,7 +16822,8 @@ "Device Code": "SEUCF005L", "Quantity": 1, "ADP Price": 138.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Seat Package 1 for Power Bases", @@ -16683,7 +16832,8 @@ "Device Code": "WEPN", "Quantity": 1, "ADP Price": 663.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Seat Package 2 for Power Bases", @@ -16692,7 +16842,8 @@ "Device Code": "WEPO", "Quantity": 1, "ADP Price": 682.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "SE - Custom Modifications", @@ -16701,7 +16852,8 @@ "Device Code": "SEMCF990L", "Quantity": 1, "ADP Price": 40.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "SE - Custom Modifications", @@ -16710,7 +16862,8 @@ "Device Code": "SEMND1005", "Quantity": 1, "ADP Price": 69.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "SE - Custom Modifications", @@ -16719,7 +16872,8 @@ "Device Code": "SEMND2005", "Quantity": 1, "ADP Price": 40.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "SE - Custom Modifications", @@ -16728,7 +16882,8 @@ "Device Code": "SEUCF200L", "Quantity": 1, "ADP Price": 69.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Specialty Controls 1 Non Standard Joystick*", @@ -16836,7 +16991,8 @@ "Device Code": "SETND0005", "Quantity": 1, "ADP Price": 164.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Tray", @@ -16845,7 +17001,8 @@ "Device Code": "SETND0010", "Quantity": 1, "ADP Price": 280.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Tray", @@ -16854,7 +17011,8 @@ "Device Code": "SETND0015", "Quantity": 2, "ADP Price": 203.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Tray", @@ -16863,7 +17021,8 @@ "Device Code": "SETND0020", "Quantity": 2, "ADP Price": 272.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Tray", @@ -16872,7 +17031,8 @@ "Device Code": "SETCF101L", "Quantity": 1, "ADP Price": 302.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Tray", @@ -16881,7 +17041,8 @@ "Device Code": "SETCF102L", "Quantity": 1, "ADP Price": 220.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Tray", @@ -16890,7 +17051,8 @@ "Device Code": "SETCF103L", "Quantity": 1, "ADP Price": 362.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Tray", @@ -16899,7 +17061,8 @@ "Device Code": "SETCF104L", "Quantity": 1, "ADP Price": 311.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Tray Options", @@ -16908,7 +17071,8 @@ "Device Code": "SETND0030", "Quantity": 4, "ADP Price": 56.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Tray Options", @@ -16917,7 +17081,8 @@ "Device Code": "SETND0035", "Quantity": 2, "ADP Price": 65.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Modular" }, { "Device Type": "Tray Options", @@ -16926,7 +17091,8 @@ "Device Code": "SEMCF301L", "Quantity": 1, "ADP Price": 69.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Tray Options", @@ -16935,7 +17101,8 @@ "Device Code": "SEMCF303L", "Quantity": 2, "ADP Price": 69.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Tray Options", @@ -16944,7 +17111,8 @@ "Device Code": "SEMCF304L", "Quantity": 2, "ADP Price": 125.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Tray Options", @@ -16953,7 +17121,8 @@ "Device Code": "SEMCF305L", "Quantity": 2, "ADP Price": 73.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Tray Options", @@ -16962,7 +17131,8 @@ "Device Code": "SEMCF306L", "Quantity": 2, "ADP Price": 52.0, - "SN Required": "No" + "SN Required": "No", + "Build Type": "Custom Fabricated" }, { "Device Type": "Unilateral Hand Brake", diff --git a/fusion_claims/data/ir_actions_server_data.xml b/fusion_claims/data/ir_actions_server_data.xml index 61399455..1fd8dc14 100644 --- a/fusion_claims/data/ir_actions_server_data.xml +++ b/fusion_claims/data/ir_actions_server_data.xml @@ -1,30 +1,4 @@ - - - - Sync to Invoices - - - form,list - code - -if records: - # Filter to only ADP sales - adp_records = records.filtered(lambda r: r.x_fc_is_adp_sale and r.state == 'sale') - if adp_records: - action = adp_records.action_sync_adp_fields() - else: - action = { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': 'No ADP Sales', - 'message': 'Selected orders are not confirmed ADP sales.', - 'type': 'warning', - 'sticky': False, - } - } - - + diff --git a/fusion_claims/data/ir_config_parameter_data.xml b/fusion_claims/data/ir_config_parameter_data.xml index 100f86d2..05090b2a 100644 --- a/fusion_claims/data/ir_config_parameter_data.xml +++ b/fusion_claims/data/ir_config_parameter_data.xml @@ -45,26 +45,6 @@ True - - - fusion_claims.store_open_hour - 9.0 - - - fusion_claims.store_close_hour - 18.0 - - - - - fusion_claims.push_enabled - False - - - fusion_claims.push_advance_minutes - 30 - - fusion_claims.field_sale_type @@ -147,12 +127,6 @@ 1-888-222-5099 - - - fusion_claims.sync_instance_id - - - fusion_claims.ltc_form_password diff --git a/fusion_claims/data/ir_cron_data.xml b/fusion_claims/data/ir_cron_data.xml index 819b6794..b0c835f7 100644 --- a/fusion_claims/data/ir_cron_data.xml +++ b/fusion_claims/data/ir_cron_data.xml @@ -6,16 +6,6 @@ --> - - - Fusion Claims: Sync ADP Fields - - code - model._cron_sync_adp_fields() - 1 - hours - - Fusion Claims: Renew Delivery Reminders @@ -134,50 +124,17 @@ - - - Fusion Claims: Calculate Technician Travel Times - + + + Fusion Claims: Expire Page 11 Signing Requests + code - model._cron_calculate_travel_times() + model._cron_expire_requests() 1 days True - + - - - Fusion Claims: Technician Push Notifications - - code - model._cron_send_push_notifications() - 15 - minutes - True - - - - - Fusion Claims: Sync Remote Tasks (Pull) - - code - model._cron_pull_remote_tasks() - 2 - minutes - True - - - - - Fusion Claims: Cleanup Old Shadow Tasks - - code - model._cron_cleanup_old_shadows() - 1 - days - True - - diff --git a/fusion_claims/data/mail_template_data.xml b/fusion_claims/data/mail_template_data.xml index 572b87d8..e17efdf7 100644 --- a/fusion_claims/data/mail_template_data.xml +++ b/fusion_claims/data/mail_template_data.xml @@ -20,34 +20,34 @@ {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} {{ object.partner_id.id }} -
+
-
+

-

ADP Quotation

-

- Please find attached your quotation . +

ADP Quotation

+

+ Please find attached your quotation .

- - - + + + - + - - + + - +
Quotation Details
Reference
Date
Quotation Details
Reference
Date
Authorizer
Authorizer
Client Portion (25%)
ADP Portion (75%)
Client Portion (25%)
ADP Portion (75%)
Total
Total
-
-

Attached: ADP Quotation (PDF)

+
+

Attached: ADP Quotation (PDF)

-
-

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 Portion ADP 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''' + + ''' + + 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 = ( '
' - f'

Approved Items

' - '' + '
' '' f'' f'' @@ -5241,13 +5338,13 @@ class SaleOrder(models.Model): colspan = 5 total_style = ( 'style="padding:8px 10px;font-size:12px;font-weight:700;' - 'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"' + 'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"' ) total_label_style = ( - f'style="padding:8px 10px;font-size:12px;font-weight:700;' - f'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"' + 'style="padding:8px 10px;font-size:12px;font-weight:700;' + 'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"' ) - html += f'' + html += '' html += f'' html += f'' html += f'' @@ -5529,8 +5626,13 @@ class SaleOrder(models.Model): _logger.error(f"Failed to send case closed email for {self.name}: {e}") return False - def _send_withdrawal_email(self, reason=None): - """Send notification when application is withdrawn.""" + def _send_withdrawal_email(self, reason=None, intent=None): + """Send notification when application is withdrawn. + + Args: + reason: Free-text reason for withdrawal. + intent: 'cancel' or 'resubmit' — determines email wording. + """ self.ensure_one() if not self._is_email_notifications_enabled(): return False @@ -5542,17 +5644,34 @@ class SaleOrder(models.Model): client_name = (recipients.get('client') or self.partner_id).name or 'Client' sales_rep_name = (recipients.get('sales_rep') or self.env.user).name - note_text = 'This application has been withdrawn from the Assistive Devices Program.' + if intent == 'cancel': + note_text = ('This application has been permanently withdrawn and cancelled. ' + 'The sale order and all related invoices have been cancelled.') + title = 'Application Withdrawn & Cancelled' + subject_suffix = 'Withdrawn & Cancelled' + note_color = '#dc3545' + elif intent == 'resubmit': + note_text = ('This application has been withdrawn for correction and will be resubmitted. ' + 'The application has been returned to Ready for Submission status.') + title = 'Application Withdrawn for Correction' + subject_suffix = 'Withdrawn for Correction' + note_color = '#d69e2e' + else: + note_text = 'This application has been withdrawn from the Assistive Devices Program.' + title = 'Application Withdrawn' + subject_suffix = 'Withdrawn' + note_color = '#d69e2e' + if reason: note_text += f'
Reason: {reason}' body_html = self._email_build( - title='Application Withdrawn', + title=title, summary=f'The ADP application for {client_name} has been withdrawn.', email_type='attention', sections=[('Case Details', self._build_case_detail_rows())], note=note_text, - note_color='#d69e2e', + note_color=note_color, button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) @@ -5560,12 +5679,12 @@ class SaleOrder(models.Model): email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:]) try: self.env['mail.mail'].sudo().create({ - 'subject': f'Application Withdrawn - {client_name} - {self.name}', + 'subject': f'Application {subject_suffix} - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, }).send() - self._email_chatter_log('Application Withdrawn email sent', email_to, email_cc) + self._email_chatter_log(f'{title} email sent', email_to, email_cc) return True except Exception as e: _logger.error(f"Failed to send withdrawal email for {self.name}: {e}") @@ -5862,7 +5981,10 @@ class SaleOrder(models.Model): 'x_fc_proof_of_delivery', 'x_fc_approval_letter', ] - doc_changes = {f: vals.get(f) for f in document_fields if f in vals and vals.get(f)} + if self.env.context.get('skip_document_chatter'): + doc_changes = {} + else: + doc_changes = {f: vals.get(f) for f in document_fields if f in vals and vals.get(f)} # Preserve old documents in chatter BEFORE they get replaced or deleted # This ensures document history is maintained for audit purposes @@ -5885,7 +6007,7 @@ class SaleOrder(models.Model): for order in self: for field_name in document_fields: - if field_name in vals and field_name not in correction_handled: + if field_name in vals and field_name not in correction_handled and not self.env.context.get('skip_document_chatter'): old_data = getattr(order, field_name, None) new_data = vals.get(field_name) label = document_labels.get(field_name, field_name) @@ -6584,96 +6706,6 @@ class SaleOrder(models.Model): except Exception as e: _logger.error(f" Failed to sync serial to invoice line {inv_line.id}: {e}") - def action_sync_adp_fields(self): - """Manual action to sync all ADP fields to invoices.""" - synced_invoices = 0 - for order in self: - # First sync Studio fields to FC fields on the SO itself - order._sync_studio_to_fc_fields() - - # Then sync to invoices - invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel') - if invoices: - order._sync_fields_to_invoices() - synced_invoices += len(invoices) - - # Force refresh of the view - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': 'Fields Synchronized', - 'message': f'Synced ADP fields from {len(self)} sale order(s) to {synced_invoices} invoice(s). Please refresh the page to see updated values.', - 'type': 'success', - 'sticky': False, - } - } - - @api.model - def _cron_sync_adp_fields(self): - """Cron job to sync ADP fields from Sale Orders to Invoices. - - Processes all ADP sales created/modified in the last 7 days. - Uses dynamic field mappings from Settings. - """ - from datetime import timedelta - cutoff_date = fields.Datetime.now() - timedelta(days=7) - - # Get field mappings - mappings = self._get_field_mappings() - sale_type_field = self.env['ir.config_parameter'].sudo().get_param( - 'fusion_claims.field_sale_type', 'x_fc_sale_type' - ) - - # Build domain - check FC sale type fields - domain = [('write_date', '>=', cutoff_date)] - or_conditions = [] - - # Check FC sale type field - if sale_type_field in self._fields: - or_conditions.append((sale_type_field, 'in', ['adp', 'adp_odsp', 'ADP', 'ADP/ODSP'])) - - # Check claim number fields - claim_field = mappings.get('so_claim_number', 'x_fc_claim_number') - if claim_field in self._fields: - or_conditions.append((claim_field, '!=', False)) - - # Combine with OR - each '|' must be a separate element in the domain list - if or_conditions: - # Add (n-1) OR operators for n conditions - for _ in range(len(or_conditions) - 1): - domain.append('|') - # Add all conditions - for cond in or_conditions: - domain.append(cond) - - try: - orders = self.search(domain) - except Exception as e: - _logger.error(f"Error searching for ADP orders: {e}") - # Fallback to simpler search - orders = self.search([ - ('write_date', '>=', cutoff_date), - ('invoice_ids', '!=', False), - ]) - - synced_count = 0 - error_count = 0 - - for order in orders: - try: - # Only sync if it's an ADP sale - if order._is_adp_sale() or order.x_fc_claim_number: - order._sync_studio_to_fc_fields() - order._sync_fields_to_invoices() - synced_count += 1 - except Exception as e: - error_count += 1 - _logger.warning(f"Failed to sync order {order.name}: {e}") - - _logger.info(f"Fusion Claims sync complete: {synced_count} orders synced, {error_count} errors") - return synced_count - # ========================================================================== # EMAIL SEND OVERRIDE (Use ADP templates for ADP sales) # ========================================================================== diff --git a/fusion_claims/models/technician_task.py b/fusion_claims/models/technician_task.py index 774dd1b4..8fb9c020 100644 --- a/fusion_claims/models/technician_task.py +++ b/fusion_claims/models/technician_task.py @@ -3,182 +3,28 @@ # License OPL-1 (Odoo Proprietary License v1.0) """ -Fusion Technician Task -Scheduling and task management for field technicians. -Replaces Monday.com for technician schedule tracking. +Fusion Technician Task - Claims Extension +Adds sale order, purchase order, LTC facility, and rental inspection +features to the base fusion.technician.task model. """ from odoo import models, fields, api, _ from odoo.exceptions import UserError, ValidationError -from odoo.osv import expression from markupsafe import Markup import logging -import json -import uuid -import requests -from datetime import datetime as dt_datetime, timedelta -import urllib.parse _logger = logging.getLogger(__name__) -class FusionTechnicianTask(models.Model): - _name = 'fusion.technician.task' - _description = 'Technician Task' - _order = 'scheduled_date, sequence, time_start, id' - _inherit = ['mail.thread', 'mail.activity.mixin'] - _rec_name = 'name' - - def _compute_display_name(self): - """Richer display name: Client - Type | 9:00 AM - 10:00 AM [+2 techs].""" - type_labels = dict(self._fields['task_type'].selection) - for task in self: - client = task.x_fc_sync_client_name if task.x_fc_sync_source else (task.partner_id.name or '') - ttype = type_labels.get(task.task_type, task.task_type or '') - start = self._float_to_time_str(task.time_start) - end = self._float_to_time_str(task.time_end) - parts = [client, ttype] - label = ' - '.join(p for p in parts if p) - if start and end: - label += f' | {start} - {end}' - extra = len(task.additional_technician_ids) - if extra: - label += f' [+{extra} tech{"s" if extra > 1 else ""}]' - task.display_name = label or task.name +class FusionTechnicianTaskClaims(models.Model): + _inherit = 'fusion.technician.task' # ------------------------------------------------------------------ - # STORE HOURS HELPER + # LINKED ORDER FIELDS # ------------------------------------------------------------------ - def _get_store_hours(self): - """Return (open_hour, close_hour) from settings. Defaults 9.0 / 18.0.""" - ICP = self.env['ir.config_parameter'].sudo() - try: - open_h = float(ICP.get_param('fusion_claims.store_open_hour', '9.0') or '9.0') - except (ValueError, TypeError): - open_h = 9.0 - try: - close_h = float(ICP.get_param('fusion_claims.store_close_hour', '18.0') or '18.0') - except (ValueError, TypeError): - close_h = 18.0 - return (open_h, close_h) - - # ------------------------------------------------------------------ - # CORE FIELDS - # ------------------------------------------------------------------ - name = fields.Char( - string='Task Reference', - required=True, - copy=False, - readonly=True, - default=lambda self: _('New'), - ) - active = fields.Boolean(default=True) - - # Cross-instance sync fields - x_fc_sync_source = fields.Char( - 'Source Instance', readonly=True, index=True, - help='Origin instance ID if this is a synced shadow task (e.g. westin, mobility)', - ) - x_fc_sync_remote_id = fields.Integer( - 'Remote Task ID', readonly=True, - help='ID of the task on the remote instance', - ) - x_fc_sync_uuid = fields.Char( - 'Sync UUID', readonly=True, index=True, copy=False, - help='Unique ID for cross-instance deduplication', - ) - x_fc_is_shadow = fields.Boolean( - 'Shadow Task', compute='_compute_is_shadow', store=True, - help='True if this task was synced from another instance', - ) - x_fc_sync_client_name = fields.Char( - 'Synced Client Name', readonly=True, - help='Client name from the remote instance (shadow tasks only)', - ) - x_fc_sync_client_phone = fields.Char( - 'Synced Client Phone', readonly=True, - help='Client phone from the remote instance (shadow tasks only)', - ) - - client_display_name = fields.Char( - compute='_compute_client_display', string='Client Name (Display)', - ) - client_display_phone = fields.Char( - compute='_compute_client_display', string='Client Phone (Display)', - ) - - x_fc_source_label = fields.Char( - 'Source', compute='_compute_is_shadow', store=True, - ) - - @api.depends('x_fc_sync_source') - def _compute_is_shadow(self): - local_id = self.env['ir.config_parameter'].sudo().get_param( - 'fusion_claims.sync_instance_id', '') - for task in self: - task.x_fc_is_shadow = bool(task.x_fc_sync_source) - task.x_fc_source_label = task.x_fc_sync_source or local_id - - @api.depends('x_fc_sync_source', 'x_fc_sync_client_name', - 'x_fc_sync_client_phone', 'partner_id') - def _compute_client_display(self): - for task in self: - if task.x_fc_sync_source: - task.client_display_name = task.x_fc_sync_client_name or task.name or '' - task.client_display_phone = task.x_fc_sync_client_phone or '' - else: - task.client_display_name = task.partner_id.name if task.partner_id else '' - task.client_display_phone = task.partner_id.phone if task.partner_id else '' - - technician_id = fields.Many2one( - 'res.users', - string='Technician', - required=True, - tracking=True, - domain="[('x_fc_is_field_staff', '=', True)]", - help='Lead technician responsible for this task', - ) - technician_name = fields.Char( - related='technician_id.name', - string='Technician Name', - store=True, - ) - additional_technician_ids = fields.Many2many( - 'res.users', - 'technician_task_additional_tech_rel', - 'task_id', - 'user_id', - string='Additional Technicians', - domain="[('x_fc_is_field_staff', '=', True)]", - tracking=True, - help='Additional technicians assigned to assist on this task', - ) - all_technician_ids = fields.Many2many( - 'res.users', - compute='_compute_all_technician_ids', - string='All Technicians', - help='Lead + additional technicians combined', - ) - additional_tech_count = fields.Integer( - compute='_compute_all_technician_ids', - string='Extra Techs', - ) - all_technician_names = fields.Char( - compute='_compute_all_technician_ids', - string='All Technician Names', - ) - - @api.depends('technician_id', 'additional_technician_ids') - def _compute_all_technician_ids(self): - for task in self: - all_techs = task.technician_id | task.additional_technician_ids - task.all_technician_ids = all_techs - task.additional_tech_count = len(task.additional_technician_ids) - task.all_technician_names = ', '.join(all_techs.mapped('name')) - sale_order_id = fields.Many2one( 'sale.order', - string='Related Case', + string='Related SO', tracking=True, ondelete='restrict', help='Sale order / case linked to this task', @@ -191,7 +37,7 @@ class FusionTechnicianTask(models.Model): purchase_order_id = fields.Many2one( 'purchase.order', - string='Related Purchase Order', + string='Related PO', tracking=True, ondelete='restrict', help='Purchase order linked to this task (e.g. manufacturer pickup)', @@ -202,18 +48,6 @@ class FusionTechnicianTask(models.Model): store=True, ) - task_type = fields.Selection([ - ('delivery', 'Delivery'), - ('repair', 'Repair'), - ('pickup', 'Pickup'), - ('troubleshoot', 'Troubleshooting'), - ('assessment', 'Assessment'), - ('installation', 'Installation'), - ('maintenance', 'Maintenance'), - ('ltc_visit', 'LTC Visit'), - ('other', 'Other'), - ], string='Task Type', required=True, default='delivery', tracking=True) - facility_id = fields.Many2one( 'fusion.ltc.facility', string='LTC Facility', @@ -222,281 +56,7 @@ class FusionTechnicianTask(models.Model): ) # ------------------------------------------------------------------ - # SCHEDULING - # ------------------------------------------------------------------ - scheduled_date = fields.Date( - string='Scheduled Date', - tracking=True, - default=fields.Date.context_today, - index=True, - ) - time_start = fields.Float( - string='Start Time', - help='Start time in hours (e.g. 9.5 = 9:30 AM)', - default=9.0, - ) - time_end = fields.Float( - string='End Time', - help='End time in hours (e.g. 10.5 = 10:30 AM)', - default=10.0, - ) - time_start_display = fields.Char( - string='Start', - compute='_compute_time_displays', - ) - time_end_display = fields.Char( - string='End', - compute='_compute_time_displays', - ) - # Legacy 12h selection fields -- kept for DB compatibility, hidden on form - time_start_12h = fields.Selection( - selection='_get_time_selection', - string='Start Time (12h)', - compute='_compute_time_12h', - inverse='_inverse_time_start_12h', - store=True, - ) - time_end_12h = fields.Selection( - selection='_get_time_selection', - string='End Time (12h)', - compute='_compute_time_12h', - inverse='_inverse_time_end_12h', - store=True, - ) - sequence = fields.Integer( - string='Sequence', - default=10, - help='Order of task within the day', - ) - duration_hours = fields.Float( - string='Duration', - default=1.0, - help='Task duration in hours. Auto-calculates end time.', - ) - - # Task type -> default duration mapping - TASK_TYPE_DURATIONS = { - 'delivery': 1.0, - 'repair': 2.0, - 'pickup': 0.5, - 'troubleshoot': 1.5, - 'assessment': 1.5, - 'installation': 2.0, - 'maintenance': 1.5, - 'ltc_visit': 3.0, - 'other': 1.0, - } - - # Previous task travel warning banner - prev_task_summary_html = fields.Html( - string='Previous Task', - compute='_compute_prev_task_summary', - sanitize=False, - ) - - # Datetime fields for calendar view (computed from date + float time) - datetime_start = fields.Datetime( - string='Start', - compute='_compute_datetimes', - inverse='_inverse_datetime_start', - store=True, - help='Combined start datetime for calendar display', - ) - datetime_end = fields.Datetime( - string='End', - compute='_compute_datetimes', - inverse='_inverse_datetime_end', - store=True, - help='Combined end datetime for calendar display', - ) - - calendar_event_id = fields.Many2one( - 'calendar.event', - string='Calendar Event', - copy=False, - ondelete='set null', - help='Linked calendar event for external calendar sync', - ) - - # Schedule info helper for the form - schedule_info_html = fields.Html( - string='Schedule Info', - compute='_compute_schedule_info', - sanitize=False, - ) - - # ------------------------------------------------------------------ - # STATUS - # ------------------------------------------------------------------ - status = fields.Selection([ - ('pending', 'Pending'), - ('scheduled', 'Scheduled'), - ('en_route', 'En Route'), - ('in_progress', 'In Progress'), - ('completed', 'Completed'), - ('cancelled', 'Cancelled'), - ('rescheduled', 'Rescheduled'), - ], string='Status', default='scheduled', required=True, tracking=True, index=True) - - priority = fields.Selection([ - ('0', 'Normal'), - ('1', 'Urgent'), - ('2', 'Emergency'), - ], string='Priority', default='0') - - color = fields.Integer( - string='Color Index', - compute='_compute_color', - ) - - # ------------------------------------------------------------------ - # CLIENT / ADDRESS - # ------------------------------------------------------------------ - partner_id = fields.Many2one( - 'res.partner', - string='Client', - tracking=True, - help='Client for this task', - ) - partner_phone = fields.Char( - related='partner_id.phone', - string='Client Phone', - ) - - # Address fields - computed from shipping address or manually set - address_partner_id = fields.Many2one( - 'res.partner', - string='Task Address', - help='Partner record containing the task address (usually shipping address)', - ) - address_street = fields.Char(string='Street') - address_street2 = fields.Char(string='Unit/Suite #') - address_city = fields.Char(string='City') - address_state_id = fields.Many2one('res.country.state', string='Province') - address_zip = fields.Char(string='Postal Code') - address_buzz_code = fields.Char(string='Buzz Code', help='Building buzzer code for entry') - address_display = fields.Text( - string='Full Address', - compute='_compute_address_display', - ) - - # In-store flag -- uses company address instead of client address - is_in_store = fields.Boolean( - string='In Store', - default=False, - help='Task takes place at the store/office. Uses company address automatically.', - ) - - # Geocoding - address_lat = fields.Float(string='Latitude', digits=(10, 7)) - address_lng = fields.Float(string='Longitude', digits=(10, 7)) - - # ------------------------------------------------------------------ - # TASK DETAILS - # ------------------------------------------------------------------ - description = fields.Text( - string='Task Description', - help='What needs to be done', - ) - equipment_needed = fields.Text( - string='Equipment / Materials Needed', - help='Tools and materials the technician should bring', - ) - pod_required = fields.Boolean( - string='POD Required', - default=False, - help='Proof of Delivery signature required', - ) - pod_signature = fields.Binary( - string='POD Signature', attachment=True, - ) - pod_client_name = fields.Char(string='POD Signer Name') - pod_signature_date = fields.Date(string='POD Signature Date') - pod_signed_by_user_id = fields.Many2one( - 'res.users', string='POD Collected By', readonly=True, - ) - pod_signed_datetime = fields.Datetime( - string='POD Collected At', readonly=True, - ) - - # ------------------------------------------------------------------ - # COMPLETION - # ------------------------------------------------------------------ - completion_notes = fields.Html( - string='Completion Notes', - help='Notes from the technician about what was done', - ) - completion_datetime = fields.Datetime( - string='Completed At', - tracking=True, - ) - - # GPS location captured at task actions - started_latitude = fields.Float( - string='Started Latitude', digits=(10, 7), readonly=True, - ) - started_longitude = fields.Float( - string='Started Longitude', digits=(10, 7), readonly=True, - ) - completed_latitude = fields.Float( - string='Completed Latitude', digits=(10, 7), readonly=True, - ) - completed_longitude = fields.Float( - string='Completed Longitude', digits=(10, 7), readonly=True, - ) - action_latitude = fields.Float( - string='Last Action Latitude', digits=(10, 7), readonly=True, - ) - action_longitude = fields.Float( - string='Last Action Longitude', digits=(10, 7), readonly=True, - ) - action_location_accuracy = fields.Float( - string='Location Accuracy (m)', readonly=True, - ) - - voice_note_audio = fields.Binary( - string='Voice Recording', - attachment=True, - ) - voice_note_transcription = fields.Text( - string='Voice Transcription', - ) - - # ------------------------------------------------------------------ - # TRAVEL - # ------------------------------------------------------------------ - travel_time_minutes = fields.Integer( - string='Travel Time (min)', - help='Estimated travel time from previous task in minutes', - ) - travel_distance_km = fields.Float( - string='Travel Distance (km)', - digits=(8, 1), - ) - travel_origin = fields.Char( - string='Travel From', - help='Origin address for travel calculation', - ) - previous_task_id = fields.Many2one( - 'fusion.technician.task', - string='Previous Task', - help='The task before this one in the schedule (for travel calculation)', - ) - - # ------------------------------------------------------------------ - # PUSH NOTIFICATION TRACKING - # ------------------------------------------------------------------ - push_notified = fields.Boolean( - string='Push Notified', - default=False, - help='Whether a push notification was sent for this task', - ) - push_notified_datetime = fields.Datetime( - string='Notified At', - ) - - # ------------------------------------------------------------------ - # RENTAL INSPECTION (added by fusion_rental) + # RENTAL INSPECTION # ------------------------------------------------------------------ rental_inspection_condition = fields.Selection([ ('excellent', 'Excellent'), @@ -520,545 +80,9 @@ class FusionTechnicianTask(models.Model): ) # ------------------------------------------------------------------ - # COMPUTED FIELDS + # ONCHANGES # ------------------------------------------------------------------ - # ------------------------------------------------------------------ - # SLOT AVAILABILITY HELPERS - # ------------------------------------------------------------------ - - def _find_next_available_slot(self, tech_id, date, preferred_start=9.0, - duration=1.0, exclude_task_id=False, - dest_lat=0, dest_lng=0): - """Find the next available time slot for a technician on a given date. - - Scans all non-cancelled tasks for that tech+date, sorts them, and - walks through the day (9 AM - 6 PM) looking for a gap that fits - the requested duration PLUS travel time from the previous task. - - :param tech_id: res.users id of the technician - :param date: date object for the day to check - :param preferred_start: float hour to start looking from (default 9.0) - :param duration: required slot length in hours (default 1.0) - :param exclude_task_id: task id to exclude (when editing an existing task) - :param dest_lat: latitude of the destination (new task location) - :param dest_lng: longitude of the destination (new task location) - :returns: (start_float, end_float) or (False, False) if fully booked - """ - STORE_OPEN, STORE_CLOSE = self._get_store_hours() - - if not tech_id or not date: - return (preferred_start, preferred_start + duration) - - domain = [ - '|', - ('technician_id', '=', tech_id), - ('additional_technician_ids', 'in', [tech_id]), - ('scheduled_date', '=', date), - ('status', 'not in', ['cancelled']), - ] - if exclude_task_id: - domain.append(('id', '!=', exclude_task_id)) - - booked = self.sudo().search(domain, order='time_start') - - # Build sorted list of (start, end, lat, lng) intervals - intervals = [] - for b in booked: - intervals.append(( - max(b.time_start, STORE_OPEN), - min(b.time_end, STORE_CLOSE), - b.address_lat or 0, - b.address_lng or 0, - )) - - def _travel_hours(from_lat, from_lng, to_lat, to_lng): - """Calculate travel time in hours between two locations. - Returns 0 if coordinates are missing. Rounds up to 15-min.""" - if not from_lat or not from_lng or not to_lat or not to_lng: - return 0 - travel_min = self._quick_travel_time( - from_lat, from_lng, to_lat, to_lng) - if travel_min > 0: - import math - return math.ceil(travel_min / 15.0) * 0.25 - return 0 - - def _travel_from_prev(iv_lat, iv_lng): - """Travel from a previous booked task TO the new task.""" - return _travel_hours(iv_lat, iv_lng, dest_lat, dest_lng) - - def _travel_to_next(next_lat, next_lng): - """Travel FROM the new task TO the next booked task.""" - return _travel_hours(dest_lat, dest_lng, next_lat, next_lng) - - def _check_gap_fits(cursor, dur, idx): - """Check if a slot at 'cursor' for 'dur' hours fits before - the interval at index 'idx' (accounting for travel TO that task).""" - if idx >= len(intervals): - return cursor + dur <= STORE_CLOSE - next_start, _ne, next_lat, next_lng = intervals[idx] - travel_fwd = _travel_to_next(next_lat, next_lng) - return cursor + dur + travel_fwd <= next_start - - # Walk through gaps, starting from preferred_start - cursor = max(preferred_start, STORE_OPEN) - - for i, (iv_start, iv_end, iv_lat, iv_lng) in enumerate(intervals): - if cursor + duration <= iv_start: - # Check travel time from new task end TO next booked task - if _check_gap_fits(cursor, duration, i): - return (cursor, cursor + duration) - # Not enough travel time -- try pushing start earlier or skip - # If we can't fit here, fall through to jump past this interval - # Jump past this booked interval + travel buffer from prev to new - new_cursor = max(cursor, iv_end) - travel = _travel_from_prev(iv_lat, iv_lng) - new_cursor += travel - # Snap to nearest 15 min - new_cursor = round(new_cursor * 4) / 4 - cursor = new_cursor - - # Check gap after last interval (no next task, so no forward travel needed) - if cursor + duration <= STORE_CLOSE: - return (cursor, cursor + duration) - - # No gap found from preferred_start onward -- wrap and try from start - if preferred_start > STORE_OPEN: - cursor = STORE_OPEN - for i, (iv_start, iv_end, iv_lat, iv_lng) in enumerate(intervals): - if cursor + duration <= iv_start: - if _check_gap_fits(cursor, duration, i): - return (cursor, cursor + duration) - new_cursor = max(cursor, iv_end) - travel = _travel_from_prev(iv_lat, iv_lng) - new_cursor += travel - new_cursor = round(new_cursor * 4) / 4 - cursor = new_cursor - if cursor + duration <= STORE_CLOSE: - return (cursor, cursor + duration) - - return (False, False) - - def _get_available_gaps(self, tech_id, date, exclude_task_id=False): - """Return a list of available (start, end) gaps for a technician on a date. - - Used by schedule_info_html to show green "available" badges. - Considers tasks where the tech is either lead or additional. - """ - STORE_OPEN, STORE_CLOSE = self._get_store_hours() - - if not tech_id or not date: - return [(STORE_OPEN, STORE_CLOSE)] - - domain = [ - '|', - ('technician_id', '=', tech_id), - ('additional_technician_ids', 'in', [tech_id]), - ('scheduled_date', '=', date), - ('status', 'not in', ['cancelled']), - ] - if exclude_task_id: - domain.append(('id', '!=', exclude_task_id)) - - booked = self.sudo().search(domain, order='time_start') - intervals = [(max(b.time_start, STORE_OPEN), min(b.time_end, STORE_CLOSE)) - for b in booked] - - gaps = [] - cursor = STORE_OPEN - for iv_start, iv_end in intervals: - if cursor < iv_start: - gaps.append((cursor, iv_start)) - cursor = max(cursor, iv_end) - if cursor < STORE_CLOSE: - gaps.append((cursor, STORE_CLOSE)) - return gaps - - @api.model - def _get_time_selection(self): - """Generate 12-hour time slots every 15 minutes, store hours only (9 AM - 6 PM).""" - times = [] - for hour in range(9, 18): # 9 AM to 5:45 PM - for minute in (0, 15, 30, 45): - float_val = hour + minute / 60.0 - key = f'{float_val:.2f}' - period = 'AM' if hour < 12 else 'PM' - display_hour = hour % 12 or 12 - label = f'{display_hour}:{minute:02d} {period}' - times.append((key, label)) - # Add 6:00 PM as end-time option - times.append(('18.00', '6:00 PM')) - return times - - @api.depends('time_start', 'time_end') - def _compute_time_12h(self): - """Sync the 12h selection fields from the raw float values.""" - for task in self: - task.time_start_12h = f'{(task.time_start or 9.0):.2f}' - task.time_end_12h = f'{(task.time_end or 10.0):.2f}' - - def _inverse_time_start_12h(self): - for task in self: - if task.time_start_12h: - task.time_start = float(task.time_start_12h) - - def _inverse_time_end_12h(self): - for task in self: - if task.time_end_12h: - task.time_end = float(task.time_end_12h) - - @api.depends('time_start', 'time_end') - def _compute_time_displays(self): - """Convert float hours to readable time strings.""" - for task in self: - task.time_start_display = self._float_to_time_str(task.time_start) - task.time_end_display = self._float_to_time_str(task.time_end) - - @api.onchange('task_type') - def _onchange_task_type_duration(self): - """Set default duration based on task type.""" - if self.task_type: - self.duration_hours = self.TASK_TYPE_DURATIONS.get(self.task_type, 1.0) - # Also recalculate end time - if self.time_start: - _open, close = self._get_store_hours() - self.time_end = min(self.time_start + self.duration_hours, close) - - @api.onchange('time_start', 'duration_hours') - def _onchange_compute_end_time(self): - """Auto-compute end time from start + duration. Also run overlap check.""" - if self.time_start and self.duration_hours: - _open, close = self._get_store_hours() - new_end = min(self.time_start + self.duration_hours, close) - self.time_end = new_end - # Run overlap snap if we have enough data - if self.technician_id and self.scheduled_date and self.time_start and self.time_end: - result = self._snap_if_overlap() - if result: - return result - - @api.depends('scheduled_date', 'time_start', 'time_end') - def _compute_datetimes(self): - """Combine date + float time into proper Datetime fields for calendar. - time_start/time_end are LOCAL hours; datetime_start/end must be UTC for Odoo.""" - import pytz - user_tz = pytz.timezone(self.env.user.tz or 'UTC') - for task in self: - if task.scheduled_date: - # Build local datetime, then convert to UTC - base = dt_datetime.combine(task.scheduled_date, dt_datetime.min.time()) - store_open, _close = task._get_store_hours() - local_start = user_tz.localize(base + timedelta(hours=task.time_start or store_open)) - local_end = user_tz.localize(base + timedelta(hours=task.time_end or (store_open + 1.0))) - task.datetime_start = local_start.astimezone(pytz.utc).replace(tzinfo=None) - task.datetime_end = local_end.astimezone(pytz.utc).replace(tzinfo=None) - else: - task.datetime_start = False - task.datetime_end = False - - def _inverse_datetime_start(self): - """When datetime_start is changed (e.g. from calendar drag), update date + time.""" - import pytz - user_tz = pytz.timezone(self.env.user.tz or 'UTC') - for task in self: - if task.datetime_start: - local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz) - task.scheduled_date = local_dt.date() - task.time_start = local_dt.hour + local_dt.minute / 60.0 - - def _inverse_datetime_end(self): - """When datetime_end is changed (e.g. from calendar resize), update time_end.""" - import pytz - user_tz = pytz.timezone(self.env.user.tz or 'UTC') - for task in self: - if task.datetime_end: - local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz) - task.time_end = local_dt.hour + local_dt.minute / 60.0 - - @api.depends('technician_id', 'scheduled_date') - def _compute_schedule_info(self): - """Show booked + available time slots for the technician on the selected date.""" - for task in self: - if not task.technician_id or not task.scheduled_date: - task.schedule_info_html = '' - continue - - exclude_id = task.id if task.id else 0 - # Find other tasks for the same technician+date (lead or additional) - others = 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), - ], order='time_start') - - if not others: - s_open, s_close = self._get_store_hours() - open_str = self._float_to_time_str(s_open) - close_str = self._float_to_time_str(s_close) - task.schedule_info_html = Markup( - f'
' - f' All slots available ({open_str} - {close_str})
' - ) - continue - - # Booked badges - booked_lines = [] - for o in others: - start_str = self._float_to_time_str(o.time_start) - end_str = self._float_to_time_str(o.time_end) - type_label = dict(self._fields['task_type'].selection).get(o.task_type, o.task_type) - client_name = o.partner_id.name or '' - booked_lines.append( - f'' - f'{start_str} - {end_str} ({type_label}{" - " + client_name if client_name else ""})' - f'' - ) - - # Available gaps badges - gaps = self._get_available_gaps( - task.technician_id.id, task.scheduled_date, - exclude_task_id=exclude_id, - ) - avail_lines = [] - for g_start, g_end in gaps: - # Only show gaps >= 15 min - if g_end - g_start >= 0.25: - avail_lines.append( - f'' - f'{self._float_to_time_str(g_start)} - {self._float_to_time_str(g_end)}' - f'' - ) - - html_parts = [ - '
', - ' Booked: ', - ' '.join(booked_lines), - ] - if avail_lines: - html_parts.append( - '
' - 'Available: ' - + ' '.join(avail_lines) - ) - elif not avail_lines: - html_parts.append( - '
' - 'Fully booked' - ) - html_parts.append('
') - - 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'' + ) + 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'' - ) - 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'
S/NADP Code
Total${total_adp:,.2f}${total_client:,.2f}
' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'
Client:{client_name}
Case:{case_ref or "N/A"}
Task:{self.name}
Technician(s):{self.all_technician_names or self.technician_id.name}
Location:{address_str}
' - f'

View Task

' - f'
' - ) - self.env['mail.thread'].sudo().message_notify( - partner_ids=[recipient.partner_id.id], - body=body, - subject=subject, - ) # ------------------------------------------------------------------ - # TASK EMAIL NOTIFICATIONS + # EMAIL OVERRIDES # ------------------------------------------------------------------ + def _get_email_builder(self): + """Prefer the linked sale order for email building.""" + if self.sale_order_id: + return self.sale_order_id + return super()._get_email_builder() + + def _is_email_notifications_enabled(self): + """Check linked sale order's notification settings.""" + if self.sale_order_id: + try: + return self.sale_order_id._is_email_notifications_enabled() + except Exception: + return True + return super()._is_email_notifications_enabled() + def _get_task_email_details(self): - """Build common detail rows for task emails.""" - self.ensure_one() - type_label = dict(self._fields['task_type'].selection).get( - self.task_type, self.task_type or '') - rows = [ - ('Task', f'{self.name} ({type_label})'), - ('Client', self.partner_id.name or 'N/A'), - ] + """Add SO/PO reference rows to email details.""" + rows = super()._get_task_email_details() + # Insert after Client row (index 1) + insert_idx = 2 if self.sale_order_id: - rows.append(('Case', self.sale_order_id.name)) + rows.insert(insert_idx, ('Case', self.sale_order_id.name)) + insert_idx += 1 if self.purchase_order_id: - rows.append(('Purchase Order', self.purchase_order_id.name)) - if self.scheduled_date: - date_str = self.scheduled_date.strftime('%B %d, %Y') - start_str = self._float_to_time_str(self.time_start) - end_str = self._float_to_time_str(self.time_end) - rows.append(('Scheduled', f'{date_str}, {start_str} - {end_str}')) - if self.technician_id: - rows.append(('Technician', self.all_technician_names or self.technician_id.name)) - if self.address_display: - rows.append(('Address', self.address_display)) + rows.insert(insert_idx, ('Purchase Order', self.purchase_order_id.name)) return rows def _get_task_email_recipients(self): - """Get email recipients for task notifications. - Returns dict with 'to' (client), 'cc' (technician, sales rep, office).""" - self.ensure_one() - to_emails = [] - cc_emails = [] - - # Client email - if self.partner_id and self.partner_id.email: - to_emails.append(self.partner_id.email) - - # Technician emails (lead + additional) - for tech in (self.technician_id | self.additional_technician_ids): - if tech.email: - cc_emails.append(tech.email) - - # Sales rep from the sale order + """Add sales rep and office CC from linked sale order.""" + result = super()._get_task_email_recipients() if self.sale_order_id and self.sale_order_id.user_id and \ self.sale_order_id.user_id.email: - cc_emails.append(self.sale_order_id.user_id.email) - - # Office notification recipients + result['cc'].append(self.sale_order_id.user_id.email) if self.sale_order_id: try: office_cc = self.sale_order_id._get_email_recipients( include_client=False).get('office_cc', []) - cc_emails.extend(office_cc) + result['cc'].extend(office_cc) except Exception: pass - - return {'to': to_emails, 'cc': list(set(cc_emails))} + result['cc'] = list(set(result['cc'])) + return result def _send_task_cancelled_email(self): - """Send cancellation email for a task/delivery/appointment.""" + """Send cancellation email using linked sale order's email builder.""" self.ensure_one() + if self.x_fc_sync_source: + return False order = self.sale_order_id if not order: return False @@ -2353,8 +563,10 @@ class FusionTechnicianTask(models.Model): return False def _send_task_scheduled_email(self): - """Send appointment scheduled email to client, technician, and sales rep.""" + """Send appointment scheduled email using linked sale order.""" self.ensure_one() + if self.x_fc_sync_source: + return False order = self.sale_order_id if not order: return False @@ -2416,9 +628,10 @@ class FusionTechnicianTask(models.Model): return False def _send_task_rescheduled_email(self, old_date=None, old_start=None, old_end=None): - """Send reschedule email to client, technician, and sales rep. - Shows old vs new schedule for clarity.""" + """Send reschedule email using linked sale order.""" self.ensure_one() + if self.x_fc_sync_source: + return False order = self.sale_order_id if not order: return False @@ -2441,7 +654,6 @@ class FusionTechnicianTask(models.Model): detail_rows = self._get_task_email_details() - # Show old schedule if provided if old_date or old_start is not None: old_parts = [] if old_date: @@ -2487,482 +699,3 @@ class FusionTechnicianTask(models.Model): except Exception as e: _logger.error("Failed to send rescheduled email for %s: %s", self.name, e) return False - - def get_next_task_for_technician(self): - """Get the next task in sequence for the same technician+date after this one. - - Considers tasks where the technician is either lead or additional. - """ - self.ensure_one() - return 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', 'in', ['scheduled', 'en_route']), - ('id', '!=', self.id), - ], order='time_start, sequence, id', limit=1) - - # ------------------------------------------------------------------ - # GOOGLE MAPS INTEGRATION - # ------------------------------------------------------------------ - - def _get_google_maps_api_key(self): - """Get the Google Maps API key from config.""" - return self.env['ir.config_parameter'].sudo().get_param( - 'fusion_claims.google_maps_api_key', '' - ) - - @api.model - def get_map_data(self, domain=None): - """Return task data, technician locations, and Google Maps API key. - - Args: - domain: optional extra domain from the search bar filters. - """ - api_key = self.env['ir.config_parameter'].sudo().get_param( - 'fusion_claims.google_maps_api_key', '') - local_instance = self.env['ir.config_parameter'].sudo().get_param( - 'fusion_claims.sync_instance_id', '') - base_domain = [ - ('status', 'not in', ['cancelled']), - ] - if domain: - base_domain = expression.AND([base_domain, domain]) - tasks = self.sudo().search_read( - base_domain, - ['name', 'partner_id', 'technician_id', 'task_type', - 'address_lat', 'address_lng', 'address_display', - 'time_start', 'time_end', 'time_start_display', 'time_end_display', - 'status', 'scheduled_date', 'travel_time_minutes', - 'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'], - order='scheduled_date asc NULLS LAST, time_start asc', - limit=500, - ) - locations = self.env['fusion.technician.location'].get_latest_locations() - tech_starts = self._get_tech_start_locations(tasks, api_key) - return { - 'api_key': api_key, - 'tasks': tasks, - 'locations': locations, - 'local_instance_id': local_instance, - 'tech_start_locations': tech_starts, - } - - @api.model - def _get_tech_start_locations(self, tasks, api_key): - """Build a dict of technician start locations for route origins. - - Priority per technician: - 1. Today's fusion_clock check-in location (if module installed) - 2. Personal start address (x_fc_start_address with cached lat/lng) - 3. Company default HQ address - """ - tech_ids = { - t['technician_id'][0] - for t in tasks - if t.get('technician_id') - } - if not tech_ids: - return {} - - result = {} - today = fields.Date.today() - - clock_locations = self._get_clock_in_locations(tech_ids, today) - - hq_address = ( - self.env['ir.config_parameter'].sudo() - .get_param('fusion_claims.technician_start_address', '') or '' - ).strip() - hq_lat, hq_lng = 0.0, 0.0 - - for uid in tech_ids: - if uid in clock_locations: - result[uid] = clock_locations[uid] - continue - - user = self.env['res.users'].sudo().browse(uid) - if not user.exists(): - continue - partner = user.partner_id - - if partner.x_fc_start_address and partner.x_fc_start_address.strip(): - lat = partner.x_fc_start_address_lat - lng = partner.x_fc_start_address_lng - if not lat or not lng: - lat, lng = self._geocode_address_string( - partner.x_fc_start_address, api_key) - if lat and lng: - partner.sudo().write({ - 'x_fc_start_address_lat': lat, - 'x_fc_start_address_lng': lng, - }) - if lat and lng: - result[uid] = { - 'lat': lat, 'lng': lng, - 'address': partner.x_fc_start_address.strip(), - 'source': 'start_address', - } - continue - - if hq_address: - if not hq_lat and not hq_lng: - hq_lat, hq_lng = self._geocode_address_string( - hq_address, api_key) - if hq_lat and hq_lng: - result[uid] = { - 'lat': hq_lat, 'lng': hq_lng, - 'address': hq_address, - 'source': 'company_hq', - } - - return result - - @api.model - def _get_clock_in_locations(self, tech_ids, today): - """Get today's clock-in lat/lng from fusion_clock if installed. - - Uses the technician's actual GPS position at the moment they clocked - in (from the activity log), not the geofenced location's fixed - coordinates. Falls back to the geofence center if no activity-log - GPS is available. - """ - result = {} - try: - module = self.env['ir.module.module'].sudo().search([ - ('name', '=', 'fusion_clock'), - ('state', '=', 'installed'), - ], limit=1) - if not module: - return result - except Exception: - return result - - try: - Attendance = self.env['hr.attendance'].sudo() - Employee = self.env['hr.employee'].sudo() - ActivityLog = self.env['fusion.clock.activity.log'].sudo() - except KeyError: - return result - - employees = Employee.search([ - ('user_id', 'in', list(tech_ids)), - ]) - emp_to_user = {e.id: e.user_id.id for e in employees} - - if not employees: - return result - - today_start = dt_datetime.combine(today, dt_datetime.min.time()) - today_end = today_start + timedelta(days=1) - - attendances = Attendance.search([ - ('employee_id', 'in', employees.ids), - ('check_in', '>=', today_start), - ('check_in', '<', today_end), - ], order='check_in asc') - - for att in attendances: - uid = emp_to_user.get(att.employee_id.id) - if not uid or uid in result: - continue - - lat, lng, address = 0, 0, '' - - log = ActivityLog.search([ - ('attendance_id', '=', att.id), - ('log_type', '=', 'clock_in'), - ('latitude', '!=', 0), - ('longitude', '!=', 0), - ], limit=1) - if log: - lat, lng = log.latitude, log.longitude - loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False - address = (loc.address or loc.name) if loc else '' - - if not lat or not lng: - loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False - if loc and loc.latitude and loc.longitude: - lat, lng = loc.latitude, loc.longitude - address = loc.address or loc.name or '' - - if lat and lng: - result[uid] = { - 'lat': lat, - 'lng': lng, - 'address': address, - 'source': 'clock_in', - } - - return result - - def _geocode_address(self): - """Geocode the task address using Google Geocoding API.""" - self.ensure_one() - api_key = self._get_google_maps_api_key() - if not api_key or not self.address_display: - return False - - try: - url = 'https://maps.googleapis.com/maps/api/geocode/json' - params = { - 'address': self.address_display, - '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'): - location = data['results'][0]['geometry']['location'] - self.write({ - 'address_lat': location['lat'], - 'address_lng': location['lng'], - }) - return True - except Exception as e: - _logger.warning(f"Geocoding failed for task {self.name}: {e}") - return False - - def _calculate_travel_time(self, origin_lat, origin_lng): - """Calculate travel time from origin to this task using Distance Matrix API.""" - self.ensure_one() - api_key = self._get_google_maps_api_key() - if not api_key: - return False - if not (origin_lat and origin_lng and self.address_lat and self.address_lng): - return False - - try: - url = 'https://maps.googleapis.com/maps/api/distancematrix/json' - params = { - 'origins': f'{origin_lat},{origin_lng}', - 'destinations': f'{self.address_lat},{self.address_lng}', - 'key': api_key, - 'mode': 'driving', - 'avoid': 'tolls', - 'traffic_model': 'best_guess', - 'departure_time': 'now', - } - resp = requests.get(url, params=params, timeout=10) - data = resp.json() - if data.get('status') == 'OK': - element = data['rows'][0]['elements'][0] - if element.get('status') == 'OK': - duration_seconds = element['duration_in_traffic']['value'] if 'duration_in_traffic' in element else element['duration']['value'] - distance_meters = element['distance']['value'] - self.write({ - 'travel_time_minutes': round(duration_seconds / 60), - 'travel_distance_km': round(distance_meters / 1000, 1), - }) - return True - except Exception as e: - _logger.warning(f"Travel time calculation failed for task {self.name}: {e}") - return False - - def action_calculate_travel_times(self): - """Calculate travel times for a day's schedule. Called from backend button or cron.""" - self._do_calculate_travel_times() - # Return False to stay on the current form without navigation - return False - - def _do_calculate_travel_times(self): - """Internal: calculate travel times for tasks. Does not return an action.""" - # Group tasks by technician and date - task_groups = {} - for task in self: - key = (task.technician_id.id, task.scheduled_date) - if key not in task_groups: - task_groups[key] = self.env['fusion.technician.task'] - task_groups[key] |= task - - api_key = self._get_google_maps_api_key() - start_coords_cache = {} - - for (tech_id, date), tasks in task_groups.items(): - sorted_tasks = tasks.sorted(lambda t: (t.sequence, t.time_start)) - - # Get this technician's start location (personal or company default) - if tech_id not in start_coords_cache: - addr = self._get_technician_start_address(tech_id) - start_coords_cache[tech_id] = self._geocode_address_string(addr, api_key) - - prev_lat, prev_lng = start_coords_cache[tech_id] - - for i, task in enumerate(sorted_tasks): - # Geocode task if needed - if not (task.address_lat and task.address_lng): - task._geocode_address() - - if prev_lat and prev_lng and task.address_lat and task.address_lng: - task._calculate_travel_time(prev_lat, prev_lng) - task.previous_task_id = sorted_tasks[i - 1].id if i > 0 else False - task.travel_origin = 'Start Location' if i == 0 else f'Task {sorted_tasks[i - 1].name}' - - prev_lat = task.address_lat - prev_lng = task.address_lng - - @api.model - def _cron_calculate_travel_times(self): - """Cron job: Calculate travel times for today and tomorrow.""" - today = fields.Date.context_today(self) - tomorrow = today + timedelta(days=1) - tasks = self.search([ - ('scheduled_date', 'in', [today, tomorrow]), - ('status', 'in', ['scheduled', 'en_route']), - ]) - if tasks: - tasks._do_calculate_travel_times() - _logger.info(f"Calculated travel times for {len(tasks)} tasks") - - # ------------------------------------------------------------------ - # PORTAL HELPERS - # ------------------------------------------------------------------ - - def get_technician_tasks_for_date(self, user_id, date): - """Get all tasks for a technician on a given date, ordered by sequence.""" - return self.sudo().search([ - ('technician_id', '=', user_id), - ('scheduled_date', '=', date), - ('status', '!=', 'cancelled'), - ], order='sequence, time_start, id') - - def get_next_task(self, user_id): - """Get the next upcoming task for a technician.""" - today = fields.Date.context_today(self) - return self.sudo().search([ - ('technician_id', '=', user_id), - ('scheduled_date', '>=', today), - ('status', 'in', ['scheduled', 'en_route']), - ], order='scheduled_date, sequence, time_start', limit=1) - - def get_current_task(self, user_id): - """Get the current in-progress task for a technician.""" - today = fields.Date.context_today(self) - return self.sudo().search([ - ('technician_id', '=', user_id), - ('scheduled_date', '=', today), - ('status', '=', 'in_progress'), - ], limit=1) - - # ------------------------------------------------------------------ - # PUSH NOTIFICATIONS - # ------------------------------------------------------------------ - - def _send_push_notification(self, title, body_text, url=None): - """Send a web push notification for this task.""" - self.ensure_one() - PushSub = self.env['fusion.push.subscription'].sudo() - subscriptions = PushSub.search([ - ('user_id', '=', self.technician_id.id), - ('active', '=', True), - ]) - if not subscriptions: - return - - ICP = self.env['ir.config_parameter'].sudo() - vapid_private = ICP.get_param('fusion_claims.vapid_private_key', '') - vapid_public = ICP.get_param('fusion_claims.vapid_public_key', '') - if not vapid_private or not vapid_public: - _logger.warning("VAPID keys not configured, cannot send push notification") - return - - try: - from pywebpush import webpush, WebPushException - except ImportError: - _logger.warning("pywebpush not installed, cannot send push notifications") - return - - payload = json.dumps({ - 'title': title, - 'body': body_text, - 'url': url or f'/my/technician/task/{self.id}', - 'task_id': self.id, - 'task_type': self.task_type, - }) - - for sub in subscriptions: - try: - webpush( - subscription_info={ - 'endpoint': sub.endpoint, - 'keys': { - 'p256dh': sub.p256dh_key, - 'auth': sub.auth_key, - }, - }, - data=payload, - vapid_private_key=vapid_private, - vapid_claims={'sub': 'mailto:support@nexasystems.ca'}, - ) - except Exception as e: - _logger.warning(f"Push notification failed for subscription {sub.id}: {e}") - # Deactivate invalid subscriptions - if 'gone' in str(e).lower() or '410' in str(e): - sub.active = False - - self.write({ - 'push_notified': True, - 'push_notified_datetime': fields.Datetime.now(), - }) - - @api.model - def _cron_send_push_notifications(self): - """Cron: Send push notifications for upcoming tasks.""" - ICP = self.env['ir.config_parameter'].sudo() - if not ICP.get_param('fusion_claims.push_enabled', False): - return - - advance_minutes = int(ICP.get_param('fusion_claims.push_advance_minutes', '30')) - now = fields.Datetime.now() - - # Find tasks starting within advance_minutes that haven't been notified - tasks = self.search([ - ('scheduled_date', '=', now.date()), - ('status', '=', 'scheduled'), - ('push_notified', '=', False), - ]) - - for task in tasks: - # Check if task is within the notification window - task_start_hour = int(task.time_start) - task_start_min = int((task.time_start % 1) * 60) - task_start_dt = now.replace(hour=task_start_hour, minute=task_start_min, second=0) - - minutes_until = (task_start_dt - now).total_seconds() / 60 - if 0 <= minutes_until <= advance_minutes: - task_type_label = dict(self._fields['task_type'].selection).get(task.task_type, task.task_type) - title = f'Upcoming: {task_type_label}' - body_text = f'{task.partner_id.name or "Task"} - {task.time_start_display}' - if task.travel_time_minutes: - body_text += f' ({task.travel_time_minutes} min drive)' - task._send_push_notification(title, body_text) - - # ------------------------------------------------------------------ - # HELPERS - # ------------------------------------------------------------------ - - @staticmethod - def _float_to_time_str(value): - """Convert float hours to time string like '9:30 AM'.""" - if not value and value != 0: - return '' - hours = int(value) - minutes = int(round((value % 1) * 60)) - period = 'AM' if hours < 12 else 'PM' - display_hour = hours % 12 or 12 - return f'{display_hour}:{minutes:02d} {period}' - - def get_google_maps_url(self): - """Get Google Maps navigation URL using the text address so the - destination shows a proper street name instead of raw coordinates. - Returns a google.com/maps URL that Android auto-opens in the app; - iOS handling is done client-side via JS to launch comgooglemaps://.""" - self.ensure_one() - if self.address_display: - addr = urllib.parse.quote(self.address_display) - return f'https://www.google.com/maps/dir/?api=1&destination={addr}&travelmode=driving' - if self.address_lat and self.address_lng: - return f'https://www.google.com/maps/dir/?api=1&destination={self.address_lat},{self.address_lng}&travelmode=driving' - return '' diff --git a/fusion_claims/scripts/__pycache__/cleanup_demo_pool.cpython-312.pyc b/fusion_claims/scripts/__pycache__/cleanup_demo_pool.cpython-312.pyc new file mode 100644 index 00000000..4b9c9043 Binary files /dev/null and b/fusion_claims/scripts/__pycache__/cleanup_demo_pool.cpython-312.pyc differ diff --git a/fusion_claims/scripts/__pycache__/import_adp_mobility_manual.cpython-312.pyc b/fusion_claims/scripts/__pycache__/import_adp_mobility_manual.cpython-312.pyc new file mode 100644 index 00000000..160e6db5 Binary files /dev/null and b/fusion_claims/scripts/__pycache__/import_adp_mobility_manual.cpython-312.pyc differ diff --git a/fusion_claims/scripts/__pycache__/import_demo_pool.cpython-312.pyc b/fusion_claims/scripts/__pycache__/import_demo_pool.cpython-312.pyc new file mode 100644 index 00000000..fe76e4ea Binary files /dev/null and b/fusion_claims/scripts/__pycache__/import_demo_pool.cpython-312.pyc differ diff --git a/fusion_claims/security/ir.model.access.csv b/fusion_claims/security/ir.model.access.csv index 2db47b78..b4f2140d 100644 --- a/fusion_claims/security/ir.model.access.csv +++ b/fusion_claims/security/ir.model.access.csv @@ -36,15 +36,6 @@ access_fusion_client_chat_message_user,fusion.client.chat.message.user,model_fus access_fusion_client_chat_message_manager,fusion.client.chat.message.manager,model_fusion_client_chat_message,sales_team.group_sale_manager,1,1,1,1 access_fusion_xml_import_wizard,fusion.xml.import.wizard.user,model_fusion_xml_import_wizard,sales_team.group_sale_manager,1,1,1,1 access_fusion_claims_dashboard_user,fusion.claims.dashboard.user,model_fusion_claims_dashboard,sales_team.group_sale_salesman,1,1,1,1 -access_fusion_technician_task_user,fusion.technician.task.user,model_fusion_technician_task,sales_team.group_sale_salesman,1,1,1,0 -access_fusion_technician_task_manager,fusion.technician.task.manager,model_fusion_technician_task,sales_team.group_sale_manager,1,1,1,1 -access_fusion_technician_task_technician,fusion.technician.task.technician,model_fusion_technician_task,fusion_claims.group_field_technician,1,1,0,0 -access_fusion_technician_task_portal,fusion.technician.task.portal,model_fusion_technician_task,base.group_portal,1,0,0,0 -access_fusion_push_subscription_user,fusion.push.subscription.user,model_fusion_push_subscription,base.group_user,1,1,1,0 -access_fusion_push_subscription_portal,fusion.push.subscription.portal,model_fusion_push_subscription,base.group_portal,1,1,1,0 -access_fusion_technician_location_manager,fusion.technician.location.manager,model_fusion_technician_location,sales_team.group_sale_manager,1,1,1,1 -access_fusion_technician_location_user,fusion.technician.location.user,model_fusion_technician_location,sales_team.group_sale_salesman,1,0,0,0 -access_fusion_technician_location_portal,fusion.technician.location.portal,model_fusion_technician_location,base.group_portal,0,0,1,0 access_fusion_send_to_mod_wizard_user,fusion_claims.send.to.mod.wizard.user,model_fusion_claims_send_to_mod_wizard,sales_team.group_sale_salesman,1,1,1,0 access_fusion_send_to_mod_wizard_manager,fusion_claims.send.to.mod.wizard.manager,model_fusion_claims_send_to_mod_wizard,sales_team.group_sale_manager,1,1,1,1 access_fusion_mod_awaiting_wizard_user,fusion_claims.mod.awaiting.funding.wizard.user,model_fusion_claims_mod_awaiting_funding_wizard,sales_team.group_sale_salesman,1,1,1,0 @@ -71,8 +62,6 @@ access_fusion_odsp_ready_delivery_wizard_user,fusion_claims.odsp.ready.delivery. access_fusion_odsp_ready_delivery_wizard_manager,fusion_claims.odsp.ready.delivery.wizard.manager,model_fusion_claims_odsp_ready_delivery_wizard,sales_team.group_sale_manager,1,1,1,1 access_fusion_submit_to_odsp_wizard_user,fusion_claims.submit.to.odsp.wizard.user,model_fusion_claims_submit_to_odsp_wizard,sales_team.group_sale_salesman,1,1,1,0 access_fusion_submit_to_odsp_wizard_manager,fusion_claims.submit.to.odsp.wizard.manager,model_fusion_claims_submit_to_odsp_wizard,sales_team.group_sale_manager,1,1,1,1 -access_fusion_task_sync_config_manager,fusion.task.sync.config.manager,model_fusion_task_sync_config,sales_team.group_sale_manager,1,1,1,1 -access_fusion_task_sync_config_user,fusion.task.sync.config.user,model_fusion_task_sync_config,sales_team.group_sale_salesman,1,0,0,0 access_fusion_ltc_facility_user,fusion.ltc.facility.user,model_fusion_ltc_facility,sales_team.group_sale_salesman,1,1,1,0 access_fusion_ltc_facility_manager,fusion.ltc.facility.manager,model_fusion_ltc_facility,sales_team.group_sale_manager,1,1,1,1 access_fusion_ltc_floor_user,fusion.ltc.floor.user,model_fusion_ltc_floor,sales_team.group_sale_salesman,1,1,1,0 @@ -90,4 +79,9 @@ access_fusion_ltc_family_contact_manager,fusion.ltc.family.contact.manager,model access_fusion_ltc_form_submission_user,fusion.ltc.form.submission.user,model_fusion_ltc_form_submission,sales_team.group_sale_salesman,1,1,0,0 access_fusion_ltc_form_submission_manager,fusion.ltc.form.submission.manager,model_fusion_ltc_form_submission,sales_team.group_sale_manager,1,1,1,1 access_fusion_ltc_repair_create_so_wizard_user,fusion.ltc.repair.create.so.wizard.user,model_fusion_ltc_repair_create_so_wizard,sales_team.group_sale_salesman,1,1,1,1 -access_fusion_ltc_repair_create_so_wizard_manager,fusion.ltc.repair.create.so.wizard.manager,model_fusion_ltc_repair_create_so_wizard,sales_team.group_sale_manager,1,1,1,1 \ No newline at end of file +access_fusion_ltc_repair_create_so_wizard_manager,fusion.ltc.repair.create.so.wizard.manager,model_fusion_ltc_repair_create_so_wizard,sales_team.group_sale_manager,1,1,1,1 +access_fusion_page11_sign_request_user,fusion.page11.sign.request.user,model_fusion_page11_sign_request,sales_team.group_sale_salesman,1,1,1,0 +access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,model_fusion_page11_sign_request,sales_team.group_sale_manager,1,1,1,1 +access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0 +access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1 +access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1 \ No newline at end of file diff --git a/fusion_claims/security/security.xml b/fusion_claims/security/security.xml index 4b0801c9..93511856 100644 --- a/fusion_claims/security/security.xml +++ b/fusion_claims/security/security.xml @@ -54,88 +54,5 @@ Temporary permission for editing locked documents on old/legacy cases. Requires the "Allow Document Lock Override" setting to be enabled in Fusion Claims Settings. Once all legacy cases are handled, disable the setting and remove this permission from users. - - - - - - - - - Field Technician - - - - - - - - - - Technician Task: Manager Full Access - - [(1, '=', 1)] - - - - - - - - - - Technician Task: Sales User Access - - [(1, '=', 1)] - - - - - - - - - - Technician Task: Technician Own Tasks - - [('technician_id', '=', user.id)] - - - - - - - - - - Technician Task: Portal Technician Access - - [('technician_id', '=', user.id)] - - - - - - - - - - - - - - Push Subscription: Own Only - - [('user_id', '=', user.id)] - - - - - - Push Subscription: Portal Own Only - - [('user_id', '=', user.id)] - - diff --git a/fusion_claims/static/src/css/fusion_task_map_view.scss b/fusion_claims/static/src/css/fusion_task_map_view.scss index 9f1351bf..31c3d696 100644 --- a/fusion_claims/static/src/css/fusion_task_map_view.scss +++ b/fusion_claims/static/src/css/fusion_task_map_view.scss @@ -138,6 +138,75 @@ $transition-speed: .25s; font-weight: 500; } +// ── Technician filter chips ───────────────────────────────────────── +.fc_tech_filters { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.fc_tech_chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px 3px 4px; + font-size: 11px; + font-weight: 600; + border: 1px solid $border-color; + border-radius: 14px; + background: transparent; + color: $text-muted; + cursor: pointer; + transition: all .15s; + line-height: 18px; + max-width: 100%; + overflow: hidden; + + &:hover { + border-color: rgba($primary, .35); + color: $body-color; + background: rgba($primary, .06); + } + + &--active { + background: $primary !important; + color: #fff !important; + border-color: $primary !important; + + .fc_tech_chip_avatar { + background: rgba(#fff, .25); + color: #fff; + } + } + + &--all { + padding: 3px 10px; + color: $body-color; + font-weight: 500; + &:hover { background: rgba($primary, .1); } + } +} + +.fc_tech_chip_avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba($secondary, .15); + color: $body-color; + font-size: 9px; + font-weight: 700; + flex-shrink: 0; +} + +.fc_tech_chip_name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + // Collapsed toggle button (floating) .fc_sidebar_toggle_btn { position: absolute; diff --git a/fusion_claims/static/src/js/fusion_task_map_view.js b/fusion_claims/static/src/js/fusion_task_map_view.js index 9dbd43cc..59ab79e3 100644 --- a/fusion_claims/static/src/js/fusion_task_map_view.js +++ b/fusion_claims/static/src/js/fusion_task_map_view.js @@ -180,9 +180,22 @@ const SOURCE_COLORS = { mobility: "#198754", }; +/** Extract unique technicians from task data, sorted by name */ +function extractTechnicians(tasksData) { + const map = {}; + for (const t of tasksData) { + if (t.technician_id) { + const [id, name] = t.technician_id; + if (!map[id]) { + map[id] = { id, name, initials: initialsOf(name) }; + } + } + } + return Object.values(map).sort((a, b) => a.name.localeCompare(b.name)); +} + /** Group + sort tasks, returning { groupKey: { label, tasks[], count } } */ -function groupTasks(tasksData, localInstanceId) { - // Sort by date ASC, time ASC +function groupTasks(tasksData, localInstanceId, visibleTechIds) { const sorted = [...tasksData].sort((a, b) => { const da = a.scheduled_date || ""; const db = b.scheduled_date || ""; @@ -190,6 +203,8 @@ function groupTasks(tasksData, localInstanceId) { return (a.time_start || 0) - (b.time_start || 0); }); + const hasTechFilter = visibleTechIds && Object.keys(visibleTechIds).length > 0; + const groups = {}; const order = [GROUP_PENDING, GROUP_YESTERDAY, GROUP_TODAY, GROUP_TOMORROW, GROUP_THIS_WEEK, GROUP_LATER]; for (const key of order) { @@ -205,12 +220,15 @@ function groupTasks(tasksData, localInstanceId) { const dayCounters = {}; for (const task of sorted) { + const techId = task.technician_id ? task.technician_id[0] : 0; + if (hasTechFilter && !visibleTechIds[techId]) continue; + const g = classifyTask(task); const dayKey = task.scheduled_date || "none"; dayCounters[dayKey] = (dayCounters[dayKey] || 0) + 1; task._scheduleNum = dayCounters[dayKey]; task._group = g; - task._dayColor = DAY_COLORS[g] || "#6b7280"; // Pin colour by day + task._dayColor = DAY_COLORS[g] || "#6b7280"; task._statusColor = STATUS_COLORS[task.status] || "#6b7280"; task._statusLabel = STATUS_LABELS[task.status] || task.status || ""; task._statusIcon = STATUS_ICONS[task.status] || "fa-circle"; @@ -228,7 +246,6 @@ function groupTasks(tasksData, localInstanceId) { groups[g].count++; } - // Return only non-empty groups in order return order.map((k) => groups[k]).filter((g) => g.count > 0); } @@ -259,12 +276,10 @@ export class FusionTaskMapController extends Component { showRoute: true, taskCount: 0, techCount: 0, - // Sidebar sidebarOpen: true, - groups: [], // [{key, label, tasks[], count}] - collapsedGroups: {}, // {groupKey: true} - activeTaskId: null, // Highlighted task - // Day filters for map pins (which groups show on map) + groups: [], + collapsedGroups: {}, + activeTaskId: null, visibleGroups: { [GROUP_YESTERDAY]: false, [GROUP_TODAY]: true, @@ -272,6 +287,8 @@ export class FusionTaskMapController extends Component { [GROUP_THIS_WEEK]: false, [GROUP_LATER]: false, }, + allTechnicians: [], + visibleTechIds: {}, }); // Yesterday collapsed by default in sidebar list @@ -339,9 +356,17 @@ export class FusionTaskMapController extends Component { this.tasksData = result.tasks || []; this.locationsData = result.locations || []; this.techStartLocations = result.tech_start_locations || {}; - this.state.taskCount = this.tasksData.length; + this.state.allTechnicians = extractTechnicians(this.tasksData); + this._rebuildGroups(); + } + + _rebuildGroups() { + this.state.groups = groupTasks( + this.tasksData, this.localInstanceId, this.state.visibleTechIds, + ); + const filteredCount = this.state.groups.reduce((s, g) => s + g.count, 0); + this.state.taskCount = filteredCount; this.state.techCount = this.locationsData.length; - this.state.groups = groupTasks(this.tasksData, this.localInstanceId); } async _loadAndRender() { @@ -1008,6 +1033,28 @@ export class FusionTaskMapController extends Component { this._renderMarkers(); } + // ── Technician filter ───────────────────────────────────────────── + toggleTechFilter(techId) { + if (this.state.visibleTechIds[techId]) { + delete this.state.visibleTechIds[techId]; + } else { + this.state.visibleTechIds[techId] = true; + } + this._rebuildGroups(); + this._renderMarkers(); + } + + isTechVisible(techId) { + const hasFilter = Object.keys(this.state.visibleTechIds).length > 0; + return !hasFilter || !!this.state.visibleTechIds[techId]; + } + + showAllTechs() { + this.state.visibleTechIds = {}; + this._rebuildGroups(); + this._renderMarkers(); + } + // ── Top bar actions ───────────────────────────────────────────── toggleTraffic() { this.state.showTraffic = !this.state.showTraffic; diff --git a/fusion_claims/static/src/xml/fusion_task_map_view.xml b/fusion_claims/static/src/xml/fusion_task_map_view.xml index 957edfea..cd031da0 100644 --- a/fusion_claims/static/src/xml/fusion_task_map_view.xml +++ b/fusion_claims/static/src/xml/fusion_task_map_view.xml @@ -52,6 +52,22 @@
+ + + +
+ + + + +
+
diff --git a/fusion_claims/views/account_move_views.xml b/fusion_claims/views/account_move_views.xml index 7cc70dc2..76e54492 100644 --- a/fusion_claims/views/account_move_views.xml +++ b/fusion_claims/views/account_move_views.xml @@ -341,6 +341,27 @@ + + + + + account.move.list.fusion.central + account.move + + 80 + + + + + + + + + + + + + @@ -350,24 +371,59 @@ + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_claims/views/adp_claims_views.xml b/fusion_claims/views/adp_claims_views.xml index 871f5ce4..76a0ff8a 100644 --- a/fusion_claims/views/adp_claims_views.xml +++ b/fusion_claims/views/adp_claims_views.xml @@ -18,6 +18,7 @@ + @@ -44,6 +45,7 @@ + @@ -77,9 +79,12 @@ + + + @@ -778,6 +783,348 @@

No Ontario Works cases yet

+ + + + + Quotation + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'quotation')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + Submitted to ODSP + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'submitted_to_odsp')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + Pre-Approved + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'pre_approved')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + Ready for Delivery + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'ready_delivery')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + Delivered + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'delivered')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + POD Submitted + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'pod_submitted')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + Payment Received + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'payment_received')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + Case Closed + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'case_closed')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + On Hold + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'on_hold')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + Denied + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'denied')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + Cancelled + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'standard'), ('x_fc_odsp_std_status', '=', 'cancelled')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'} + + + + + + + Quotation + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'quotation')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + SA Form Ready + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'form_ready')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + Submitted to SA Mobility + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'submitted_to_sa')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + Pre-Approved + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'pre_approved')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + Ready for Delivery + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'ready_delivery')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + Delivered + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'delivered')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + POD Submitted + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'pod_submitted')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + Payment Received + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'payment_received')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + Case Closed + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'case_closed')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + On Hold + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'on_hold')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + Denied + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'denied')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + Cancelled + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'sa_mobility'), ('x_fc_sa_status', '=', 'cancelled')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'sa_mobility'} + + + + + + + Quotation + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'quotation')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + + Documents Ready + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'documents_ready')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + + Submitted to Ontario Works + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'submitted_to_ow')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + + Payment Received + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'payment_received')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + + Ready for Delivery + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'ready_delivery')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + + Delivered + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'delivered')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + + Case Closed + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'case_closed')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + + On Hold + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'on_hold')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + + Denied + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'denied')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + + Cancelled + sale.order + list,form,kanban + + + [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']), ('x_fc_odsp_division', '=', 'ontario_works'), ('x_fc_ow_status', '=', 'cancelled')] + {'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'ontario_works'} + + @@ -947,10 +1294,10 @@ - + - March of Dimes Cases + All MOD Cases sale.order list,kanban,form [('x_fc_sale_type', '=', 'march_of_dimes')] {'default_x_fc_sale_type': 'march_of_dimes'} -

No March of Dimes cases yet

+

No MOD cases yet

+
+ + + Schedule Assessment + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'need_to_schedule')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Assessment Booked + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'assessment_scheduled')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Assessment Done + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'assessment_completed')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Processing Drawing + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'processing_drawings')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Quote Sent + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'quote_submitted')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Awaiting Funding + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'awaiting_funding')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Approved + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'funding_approved')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + PCA Received + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'contract_received')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + In Production + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'in_production')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Complete + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'project_complete')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + POD Sent + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'pod_submitted')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Closed + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'case_closed')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + + On Hold + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'on_hold')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Denied + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'funding_denied')] + {'default_x_fc_sale_type': 'march_of_dimes'} + + + + Cancelled + sale.order + list,kanban,form + + + [('x_fc_sale_type', '=', 'march_of_dimes'), ('x_fc_mod_status', '=', 'cancelled')] + {'default_x_fc_sale_type': 'march_of_dimes'} @@ -1070,6 +1598,90 @@ {'default_move_type': 'out_invoice'} + + + + + + ADP Client Invoices + account.move + list,form + [('x_fc_invoice_type', '=', 'adp_client'), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + + + ODSP Invoices + account.move + list,form + [('x_fc_invoice_type', 'in', ['odsp', 'adp_odsp']), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + + + MOD Invoices + account.move + list,form + [('x_fc_invoice_type', '=', 'march_of_dimes'), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + + + WSIB Invoices + account.move + list,form + [('x_fc_invoice_type', '=', 'wsib'), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + + + Insurance Invoices + account.move + list,form + [('x_fc_invoice_type', '=', 'insurance'), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + + + Direct/Private Invoices + account.move + list,form + [('x_fc_invoice_type', '=', 'direct_private'), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + + + Hardship Invoices + account.move + list,form + [('x_fc_invoice_type', '=', 'hardship'), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + + + Rental Invoices + account.move + list,form + [('x_fc_invoice_type', '=', 'rental'), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + + + Muscular Dystrophy Invoices + account.move + list,form + [('x_fc_invoice_type', '=', 'muscular_dystrophy'), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + + + Other Invoices + account.move + list,form + [('x_fc_invoice_type', '=', 'other'), ('move_type', 'in', ['out_invoice', 'out_refund'])] + {'default_move_type': 'out_invoice'} + + Ask Fusion Claims AI @@ -1102,11 +1714,15 @@ else: name="Fusion Claims" web_icon="fusion_claims,static/description/icon.png" sequence="30" - groups="group_fusion_claims_user,group_field_technician"/> + groups="group_fusion_claims_user,fusion_tasks.group_field_technician"/> + + + + + + + + sequence="10"/> + + + + + + + + + + + + + + + + sequence="20"/> + + + + + + + + + + + + + + + + - + sequence="30"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1292,6 +2035,24 @@ else: + + + + + + + + + + diff --git a/fusion_claims/views/page11_sign_request_views.xml b/fusion_claims/views/page11_sign_request_views.xml new file mode 100644 index 00000000..eb8d0672 --- /dev/null +++ b/fusion_claims/views/page11_sign_request_views.xml @@ -0,0 +1,89 @@ + + + + fusion.page11.sign.request.list + fusion.page11.sign.request + + + + + + + + + + + + + + + + fusion.page11.sign.request.form + fusion.page11.sign.request + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/fusion_claims/views/res_config_settings_views.xml b/fusion_claims/views/res_config_settings_views.xml index 1953c231..56bd5b1e 100644 --- a/fusion_claims/views/res_config_settings_views.xml +++ b/fusion_claims/views/res_config_settings_views.xml @@ -194,26 +194,6 @@
-

External APIs

- -
- -
-
- Google Maps API -
- API key for Google Maps Places autocomplete in address fields (accessibility assessments, etc.) -
-
- -
- -
-
-
-

AI Client Intelligence

@@ -256,117 +236,6 @@
-

Technician Management

- -
- -
-
- Store / Scheduling Hours -
- Operating hours for technician task scheduling. Tasks can only be booked - within these hours. Calendar view is also restricted to this range. -
-
- - to - -
-
-
- -
-
- -
-
-
-
- -
-
- Default HQ / Fallback Address -
- Company default start location used when a technician has no personal - start address set. Each technician can set their own start location - in their user profile or from the portal. -
-
- -
-
-
- -
-
- Location History Retention -
- How many days to keep technician GPS location history before automatic cleanup. -
-
- - days -
-
- Leave empty = 30 days. Enter 0 = delete at end of each day. 1+ = keep that many days. -
-
-
-
- -

Push Notifications

- -
- -
-
- -
-
-
-
- -
-
- Notification Advance Time -
- Send push notification this many minutes before a scheduled task. -
-
- minutes -
-
-
- -
-
- VAPID Public Key -
- -
-
-
- -
-
- VAPID Private Key -
- -
-
-
-
-

March of Dimes

diff --git a/fusion_claims/views/sale_order_views.xml b/fusion_claims/views/sale_order_views.xml index a47cbcaf..8cbdae35 100644 --- a/fusion_claims/views/sale_order_views.xml +++ b/fusion_claims/views/sale_order_views.xml @@ -1088,6 +1088,13 @@ invisible="x_fc_technician_task_count == 0"> + + + @@ -1201,6 +1208,18 @@ invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('quotation', 'assessment_scheduled')" help="Mark assessment as completed (override available from Quotation stage)"/> + +
+
@@ -2105,22 +2143,6 @@
- - - diff --git a/fusion_claims/views/technician_task_views.xml b/fusion_claims/views/technician_task_views.xml index 1545f7d4..6254554e 100644 --- a/fusion_claims/views/technician_task_views.xml +++ b/fusion_claims/views/technician_task_views.xml @@ -1,541 +1,156 @@ + - + - - Technician Task - fusion.technician.task - TASK- - 5 - 1 - - - - - - - res.users.form.field.staff - res.users - + + fusion.technician.task.search.claims + fusion.technician.task + - - - - + + - + - - fusion.technician.task.search + + fusion.technician.task.form.claims fusion.technician.task + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - fusion.technician.task.form + + fusion.technician.task.list.claims fusion.technician.task + -
- - -
-
- - - -
- - -
- - - -
-

- -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - - - - fusion.technician.task.list - fusion.technician.task - - - - - - - - - - - - - - - + - - + - - - - fusion.technician.task.kanban - fusion.technician.task - - - - - - - - - - - - - - - - - - -
-
-
-
- - - -
- -
-
- - - -
-
- - - - - - - -
-
- - - min - -
-
- - + technician(s) -
-
-
- -
-
- -
-
-
-
-
-
-
-
-
- - - - - - fusion.technician.task.calendar - fusion.technician.task - - - - - - - - - - - - - - - - - - - - - - - - fusion.technician.task.map - fusion.technician.task - - - - - - - - - - - - - - - + - - - Technician Tasks - fusion.technician.task - list,kanban,form,calendar,map - - {'search_default_filter_active': 1} - -

- Create your first technician task -

-

Schedule deliveries, repairs, and other field tasks for your technicians.

-
-
- - - - Schedule - fusion.technician.task - map,calendar,list,kanban,form - - {'search_default_filter_active': 1} - - - - - Delivery Map - fusion.technician.task - map,list,kanban,form,calendar - - {'search_default_filter_active': 1} - - - - - Today's Tasks - fusion.technician.task - kanban,list,form,map - - {'search_default_filter_today': 1, 'search_default_filter_active': 1} - - - - - My Tasks - fusion.technician.task - list,kanban,form,calendar,map - - {'search_default_filter_my_tasks': 1, 'search_default_filter_active': 1} - - - - - Pending Tasks - fusion.technician.task - list,kanban,form - - {'search_default_filter_pending': 1} - - - - - - - + + groups="fusion_claims.group_fusion_claims_user,fusion_tasks.group_field_technician"/> - + + groups="fusion_claims.group_fusion_claims_user,fusion_tasks.group_field_technician"/> + groups="fusion_tasks.group_field_technician"/> + +
diff --git a/fusion_claims/wizard/__init__.py b/fusion_claims/wizard/__init__.py index b5f91b12..82e87d50 100644 --- a/fusion_claims/wizard/__init__.py +++ b/fusion_claims/wizard/__init__.py @@ -30,4 +30,5 @@ from . import odsp_discretionary_wizard from . import odsp_pre_approved_wizard from . import odsp_ready_delivery_wizard from . import odsp_submit_to_odsp_wizard -from . import ltc_repair_create_so_wizard \ No newline at end of file +from . import ltc_repair_create_so_wizard +from . import send_page11_wizard \ No newline at end of file diff --git a/fusion_claims/wizard/__pycache__/__init__.cpython-312.pyc b/fusion_claims/wizard/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..44a0d267 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/__init__.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/account_payment_register.cpython-312.pyc b/fusion_claims/wizard/__pycache__/account_payment_register.cpython-312.pyc new file mode 100644 index 00000000..7e626c32 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/account_payment_register.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/adp_export_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/adp_export_wizard.cpython-312.pyc new file mode 100644 index 00000000..0b28b4cb Binary files /dev/null and b/fusion_claims/wizard/__pycache__/adp_export_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/application_received_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/application_received_wizard.cpython-312.pyc new file mode 100644 index 00000000..00892d16 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/application_received_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/assessment_completed_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/assessment_completed_wizard.cpython-312.pyc new file mode 100644 index 00000000..41093817 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/assessment_completed_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/case_close_verification_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/case_close_verification_wizard.cpython-312.pyc new file mode 100644 index 00000000..0a106b0a Binary files /dev/null and b/fusion_claims/wizard/__pycache__/case_close_verification_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/device_approval_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/device_approval_wizard.cpython-312.pyc new file mode 100644 index 00000000..56976cf9 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/device_approval_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/device_import_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/device_import_wizard.cpython-312.pyc new file mode 100644 index 00000000..2ce7431a Binary files /dev/null and b/fusion_claims/wizard/__pycache__/device_import_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/field_mapping_config_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/field_mapping_config_wizard.cpython-312.pyc new file mode 100644 index 00000000..8f35db94 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/field_mapping_config_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/loaner_checkout_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/loaner_checkout_wizard.cpython-312.pyc new file mode 100644 index 00000000..04dbef21 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/loaner_checkout_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/loaner_return_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/loaner_return_wizard.cpython-312.pyc new file mode 100644 index 00000000..e218da58 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/loaner_return_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/ltc_repair_create_so_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/ltc_repair_create_so_wizard.cpython-312.pyc new file mode 100644 index 00000000..91a35145 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/ltc_repair_create_so_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/mod_awaiting_funding_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/mod_awaiting_funding_wizard.cpython-312.pyc new file mode 100644 index 00000000..2feab895 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/mod_awaiting_funding_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/mod_funding_approved_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/mod_funding_approved_wizard.cpython-312.pyc new file mode 100644 index 00000000..87fa6284 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/mod_funding_approved_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/mod_pca_received_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/mod_pca_received_wizard.cpython-312.pyc new file mode 100644 index 00000000..1dff1a56 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/mod_pca_received_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/odsp_discretionary_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/odsp_discretionary_wizard.cpython-312.pyc new file mode 100644 index 00000000..16d9da0d Binary files /dev/null and b/fusion_claims/wizard/__pycache__/odsp_discretionary_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/odsp_pre_approved_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/odsp_pre_approved_wizard.cpython-312.pyc new file mode 100644 index 00000000..3626482d Binary files /dev/null and b/fusion_claims/wizard/__pycache__/odsp_pre_approved_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/odsp_ready_delivery_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/odsp_ready_delivery_wizard.cpython-312.pyc new file mode 100644 index 00000000..37ecee15 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/odsp_ready_delivery_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/odsp_sa_mobility_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/odsp_sa_mobility_wizard.cpython-312.pyc new file mode 100644 index 00000000..c183a929 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/odsp_sa_mobility_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/odsp_submit_to_odsp_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/odsp_submit_to_odsp_wizard.cpython-312.pyc new file mode 100644 index 00000000..e84f7bac Binary files /dev/null and b/fusion_claims/wizard/__pycache__/odsp_submit_to_odsp_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/ready_for_delivery_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/ready_for_delivery_wizard.cpython-312.pyc new file mode 100644 index 00000000..42c1fb1e Binary files /dev/null and b/fusion_claims/wizard/__pycache__/ready_for_delivery_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/ready_for_submission_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/ready_for_submission_wizard.cpython-312.pyc new file mode 100644 index 00000000..baeca4db Binary files /dev/null and b/fusion_claims/wizard/__pycache__/ready_for_submission_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/ready_to_bill_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/ready_to_bill_wizard.cpython-312.pyc new file mode 100644 index 00000000..4b1c2edd Binary files /dev/null and b/fusion_claims/wizard/__pycache__/ready_to_bill_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/sale_advance_payment_inv.cpython-312.pyc b/fusion_claims/wizard/__pycache__/sale_advance_payment_inv.cpython-312.pyc new file mode 100644 index 00000000..4a3cbeb8 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/sale_advance_payment_inv.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/schedule_assessment_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/schedule_assessment_wizard.cpython-312.pyc new file mode 100644 index 00000000..3e5926fd Binary files /dev/null and b/fusion_claims/wizard/__pycache__/schedule_assessment_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/send_page11_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/send_page11_wizard.cpython-312.pyc new file mode 100644 index 00000000..3f5153fa Binary files /dev/null and b/fusion_claims/wizard/__pycache__/send_page11_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/send_to_mod_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/send_to_mod_wizard.cpython-312.pyc new file mode 100644 index 00000000..f9e3564a Binary files /dev/null and b/fusion_claims/wizard/__pycache__/send_to_mod_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/status_change_reason_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/status_change_reason_wizard.cpython-312.pyc new file mode 100644 index 00000000..011890e1 Binary files /dev/null and b/fusion_claims/wizard/__pycache__/status_change_reason_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/submission_verification_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/submission_verification_wizard.cpython-312.pyc new file mode 100644 index 00000000..14c0db9c Binary files /dev/null and b/fusion_claims/wizard/__pycache__/submission_verification_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/__pycache__/xml_import_wizard.cpython-312.pyc b/fusion_claims/wizard/__pycache__/xml_import_wizard.cpython-312.pyc new file mode 100644 index 00000000..65a9194f Binary files /dev/null and b/fusion_claims/wizard/__pycache__/xml_import_wizard.cpython-312.pyc differ diff --git a/fusion_claims/wizard/application_received_wizard.py b/fusion_claims/wizard/application_received_wizard.py index 5cffd36c..610e5f71 100644 --- a/fusion_claims/wizard/application_received_wizard.py +++ b/fusion_claims/wizard/application_received_wizard.py @@ -34,18 +34,42 @@ class ApplicationReceivedWizard(models.TransientModel): signed_pages_11_12 = fields.Binary( string='Signed Pages 11 & 12', - required=True, - help='Upload the signed pages 11 and 12 from the application', + help='Upload the signed pages 11 and 12 from the application. ' + 'Not required if a remote signing request has been sent.', ) signed_pages_filename = fields.Char( string='Pages Filename', ) + + has_pending_page11_request = fields.Boolean( + compute='_compute_has_pending_page11_request', + ) + has_signed_page11 = fields.Boolean( + compute='_compute_has_pending_page11_request', + ) notes = fields.Text( string='Notes', help='Any notes about the received application', ) + @api.depends('sale_order_id') + def _compute_has_pending_page11_request(self): + for wiz in self: + order = wiz.sale_order_id + if order: + requests = order.page11_sign_request_ids + wiz.has_pending_page11_request = bool( + requests.filtered(lambda r: r.state in ('draft', 'sent')) + ) + wiz.has_signed_page11 = bool( + order.x_fc_signed_pages_11_12 + or requests.filtered(lambda r: r.state == 'signed') + ) + else: + wiz.has_pending_page11_request = False + wiz.has_signed_page11 = False + @api.model def default_get(self, fields_list): res = super().default_get(fields_list) @@ -53,7 +77,6 @@ class ApplicationReceivedWizard(models.TransientModel): if active_id: order = self.env['sale.order'].browse(active_id) res['sale_order_id'] = order.id - # Pre-fill if documents already exist if order.x_fc_original_application: res['original_application'] = order.x_fc_original_application res['original_application_filename'] = order.x_fc_original_application_filename @@ -91,20 +114,33 @@ class ApplicationReceivedWizard(models.TransientModel): if order.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'): raise UserError("Can only receive application from 'Waiting for Application' status.") - # Validate files are uploaded if not self.original_application: raise UserError("Please upload the Original ADP Application.") - if not self.signed_pages_11_12: - raise UserError("Please upload the Signed Pages 11 & 12.") - - # Update sale order with documents - order.with_context(skip_status_validation=True).write({ + + page11_covered = bool( + self.signed_pages_11_12 + or order.x_fc_signed_pages_11_12 + or order.page11_sign_request_ids.filtered( + lambda r: r.state in ('sent', 'signed') + ) + ) + if not page11_covered: + raise UserError( + "Signed Pages 11 & 12 are required.\n\n" + "You can either upload the file here, or use the " + "'Request Page 11 Signature' button on the sale order " + "to send it for remote signing before confirming." + ) + + vals = { 'x_fc_adp_application_status': 'application_received', 'x_fc_original_application': self.original_application, 'x_fc_original_application_filename': self.original_application_filename, - 'x_fc_signed_pages_11_12': self.signed_pages_11_12, - 'x_fc_signed_pages_filename': self.signed_pages_filename, - }) + } + if self.signed_pages_11_12: + vals['x_fc_signed_pages_11_12'] = self.signed_pages_11_12 + vals['x_fc_signed_pages_filename'] = self.signed_pages_filename + order.with_context(skip_status_validation=True).write(vals) # Post to chatter from datetime import date @@ -128,3 +164,15 @@ class ApplicationReceivedWizard(models.TransientModel): ) return {'type': 'ir.actions.act_window_close'} + + def action_request_page11_signature(self): + """Open the Page 11 remote signing wizard from within the Application Received wizard.""" + 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.sale_order_id.id}, + } diff --git a/fusion_claims/wizard/application_received_wizard_views.xml b/fusion_claims/wizard/application_received_wizard_views.xml index 063362bb..5eb89808 100644 --- a/fusion_claims/wizard/application_received_wizard_views.xml +++ b/fusion_claims/wizard/application_received_wizard_views.xml @@ -10,10 +10,12 @@ Upload Required Documents

Please upload the ADP application documents received from the client.

- + + + + + - - @@ -24,6 +26,28 @@ + +
+ Don't have signed pages? +
+ +
+
+ A remote signing request has been sent. + You can proceed without uploading signed pages -- they will be auto-filled when signed. +
+
+ +
+
+ Page 11 has been signed remotely. +
+
diff --git a/fusion_claims/wizard/send_page11_wizard.py b/fusion_claims/wizard/send_page11_wizard.py new file mode 100644 index 00000000..3dad2cc1 --- /dev/null +++ b/fusion_claims/wizard/send_page11_wizard.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from datetime import timedelta + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class SendPage11Wizard(models.TransientModel): + _name = 'fusion_claims.send.page11.wizard' + _description = 'Send Page 11 for Remote Signing' + + sale_order_id = fields.Many2one( + 'sale.order', string='Sale Order', + required=True, readonly=True, + ) + signer_email = fields.Char(string='Recipient Email', required=True) + signer_type = fields.Selection([ + ('client', 'Client (Self)'), + ('spouse', 'Spouse'), + ('parent', 'Parent'), + ('legal_guardian', 'Legal Guardian'), + ('poa', 'Power of Attorney'), + ('public_trustee', 'Public Trustee'), + ], string='Signer Type', default='client', required=True) + signer_name = fields.Char(string='Signer Name', required=True) + custom_message = fields.Text( + string='Personal Message', + help='Optional message to include in the signing request email.', + ) + expiry_days = fields.Integer( + string='Link Valid For (days)', default=7, required=True, + ) + + client_name = fields.Char(string='Client', readonly=True) + case_ref = fields.Char(string='Case Reference', readonly=True) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_id = self.env.context.get('active_id') + if not active_id: + return res + + order = self.env['sale.order'].browse(active_id) + res['sale_order_id'] = order.id + res['client_name'] = order.partner_id.name or '' + res['case_ref'] = order.name or '' + res['signer_name'] = order.partner_id.name or '' + res['signer_email'] = order.partner_id.email or '' + return res + + def action_send(self): + """Create a signing request and send the email.""" + self.ensure_one() + + if not self.signer_email: + raise UserError(_("Please enter the recipient's email address.")) + if self.expiry_days < 1: + raise UserError(_("Expiry must be at least 1 day.")) + + request = self.env['fusion.page11.sign.request'].create({ + 'sale_order_id': self.sale_order_id.id, + 'signer_email': self.signer_email, + 'signer_type': self.signer_type, + 'signer_name': self.signer_name, + 'custom_message': self.custom_message, + 'expiry_date': fields.Datetime.now() + timedelta(days=self.expiry_days), + 'consent_signed_by': 'applicant' if self.signer_type == 'client' else 'agent', + 'signer_relationship': dict(self._fields['signer_type'].selection).get( + self.signer_type, '' + ) if self.signer_type != 'client' else '', + }) + + request._send_signing_email() + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Page 11 Signing Request Sent'), + 'message': _( + 'Signing request has been sent to %s.', + self.signer_email, + ), + 'type': 'success', + 'sticky': False, + 'next': {'type': 'ir.actions.act_window_close'}, + }, + } diff --git a/fusion_claims/wizard/send_page11_wizard_views.xml b/fusion_claims/wizard/send_page11_wizard_views.xml new file mode 100644 index 00000000..ebfe8cc5 --- /dev/null +++ b/fusion_claims/wizard/send_page11_wizard_views.xml @@ -0,0 +1,39 @@ + + + + fusion_claims.send.page11.wizard.form + fusion_claims.send.page11.wizard + +
+ + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Request Page 11 Signature + fusion_claims.send.page11.wizard + form + new + {'default_sale_order_id': active_id} + +
diff --git a/fusion_claims/wizard/status_change_reason_wizard.py b/fusion_claims/wizard/status_change_reason_wizard.py index 50d3a555..21397b83 100644 --- a/fusion_claims/wizard/status_change_reason_wizard.py +++ b/fusion_claims/wizard/status_change_reason_wizard.py @@ -61,6 +61,18 @@ class StatusChangeReasonWizard(models.TransientModel): help='Select the reason ADP denied the funding', ) + # ========================================================================== + # WITHDRAWAL INTENT (for 'withdrawn' status) + # ========================================================================== + withdrawal_intent = fields.Selection( + selection=[ + ('cancel', 'Cancel Application'), + ('resubmit', 'Withdraw for Correction & Resubmission'), + ], + string='What would you like to do after withdrawal?', + default='resubmit', + ) + reason = fields.Text( string='Reason / Additional Details', help='Please provide additional details for this status change.', @@ -181,8 +193,10 @@ class StatusChangeReasonWizard(models.TransientModel): } header_color, bg_color, border_color = status_colors.get(new_status, ('#17a2b8', '#f0f9ff', '#bee5eb')) - # For on_hold, also store the previous status and hold date + # Build initial update vals update_vals = {'x_fc_adp_application_status': new_status} + if new_status == 'withdrawn': + update_vals['x_fc_previous_status_before_withdrawal'] = self.previous_status # ================================================================= # REJECTED: ADP rejected submission (within 24 hours) @@ -261,7 +275,7 @@ class StatusChangeReasonWizard(models.TransientModel): # Don't post message here - _send_on_hold_email() will post the message message_body = None elif new_status == 'withdrawn': - # Don't post message here - _send_withdrawal_email() will post the message + # Handled entirely below based on withdrawal_intent message_body = None elif new_status == 'cancelled': # Cancelled has its own detailed message posted later @@ -302,10 +316,129 @@ class StatusChangeReasonWizard(models.TransientModel): order._send_correction_needed_email(reason=reason) # ================================================================= - # WITHDRAWN: Send email notification to all parties + # WITHDRAWN: Branch based on withdrawal intent # ================================================================= if new_status == 'withdrawn': - order._send_withdrawal_email(reason=reason) + intent = self.withdrawal_intent + + if intent == 'cancel': + # --------------------------------------------------------- + # WITHDRAW & CANCEL: Cancel invoices + SO + # --------------------------------------------------------- + cancelled_invoices = [] + cancelled_so = False + + # Cancel related invoices first + invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel') + for invoice in invoices: + try: + inv_msg = Markup(f''' + + ''') + invoice.message_post( + body=inv_msg, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + if invoice.state == 'posted': + invoice.button_draft() + invoice.button_cancel() + cancelled_invoices.append(invoice.name) + except Exception as e: + warn_msg = Markup(f''' + + ''') + order.message_post( + body=warn_msg, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + + # Cancel the sale order itself + if order.state not in ('cancel', 'done'): + try: + order._action_cancel() + cancelled_so = True + except Exception as e: + warn_msg = Markup(f''' + + ''') + order.message_post( + body=warn_msg, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + + # Build cancellation summary + invoice_list_html = '' + if cancelled_invoices: + invoice_items = ''.join([f'
  • {inv}
  • ' for inv in cancelled_invoices]) + invoice_list_html = f'
  • Invoices Cancelled:
      {invoice_items}
  • ' + + so_status = 'Cancelled' if cancelled_so else 'Not applicable' + summary_msg = Markup(f''' + + ''') + order.message_post( + body=summary_msg, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + order._send_withdrawal_email(reason=reason, intent='cancel') + + else: + # --------------------------------------------------------- + # WITHDRAW & RESUBMIT: Return to ready_submission + # --------------------------------------------------------- + order.with_context(skip_status_validation=True).write({ + 'x_fc_adp_application_status': 'ready_submission', + 'x_fc_previous_status_before_withdrawal': self.previous_status, + }) + + resubmit_msg = Markup(f''' + + ''') + order.message_post( + body=resubmit_msg, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + order._send_withdrawal_email(reason=reason, intent='resubmit') # ================================================================= # ON HOLD: Send email notification to all parties diff --git a/fusion_claims/wizard/status_change_reason_wizard_views.xml b/fusion_claims/wizard/status_change_reason_wizard_views.xml index d903cc25..8cd2b837 100644 --- a/fusion_claims/wizard/status_change_reason_wizard_views.xml +++ b/fusion_claims/wizard/status_change_reason_wizard_views.xml @@ -26,7 +26,7 @@