diff --git a/Fusion Accounting/__pycache__/__init__.cpython-310.pyc b/Fusion Accounting/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 79be0ac..0000000 Binary files a/Fusion Accounting/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/controllers/__pycache__/__init__.cpython-310.pyc b/Fusion Accounting/controllers/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 5a22127..0000000 Binary files a/Fusion Accounting/controllers/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/controllers/__pycache__/main.cpython-310.pyc b/Fusion Accounting/controllers/__pycache__/main.cpython-310.pyc deleted file mode 100644 index f2b707b..0000000 Binary files a/Fusion Accounting/controllers/__pycache__/main.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/__init__.cpython-310.pyc b/Fusion Accounting/models/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 4630a10..0000000 Binary files a/Fusion Accounting/models/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_account.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_account.cpython-310.pyc deleted file mode 100644 index e2905a0..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_account.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_aged_partner_balance.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_aged_partner_balance.cpython-310.pyc deleted file mode 100644 index 8fb3e0e..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_aged_partner_balance.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_analytic_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_analytic_report.cpython-310.pyc deleted file mode 100644 index d76b15e..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_analytic_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_asset.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_asset.cpython-310.pyc deleted file mode 100644 index f76270a..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_asset.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_bank_statement.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_bank_statement.cpython-310.pyc deleted file mode 100644 index 7d977e6..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_bank_statement.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_cash_flow_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_cash_flow_report.cpython-310.pyc deleted file mode 100644 index 174e919..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_cash_flow_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_chart_template.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_chart_template.cpython-310.pyc deleted file mode 100644 index 41128ad..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_chart_template.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_deferred_reports.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_deferred_reports.cpython-310.pyc deleted file mode 100644 index 4336285..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_deferred_reports.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_fiscal_position.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_fiscal_position.cpython-310.pyc deleted file mode 100644 index 242b402..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_fiscal_position.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_fiscal_year.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_fiscal_year.cpython-310.pyc deleted file mode 100644 index b511d8e..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_fiscal_year.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_general_ledger.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_general_ledger.cpython-310.pyc deleted file mode 100644 index 471fe84..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_general_ledger.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_generic_tax_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_generic_tax_report.cpython-310.pyc deleted file mode 100644 index 1f072fe..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_generic_tax_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_journal.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_journal.cpython-310.pyc deleted file mode 100644 index bfd9673..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_journal.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_journal_csv.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_journal_csv.cpython-310.pyc deleted file mode 100644 index be2ec20..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_journal_csv.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_journal_dashboard.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_journal_dashboard.cpython-310.pyc deleted file mode 100644 index e0aff93..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_journal_dashboard.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_journal_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_journal_report.cpython-310.pyc deleted file mode 100644 index ecab447..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_journal_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_move.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_move.cpython-310.pyc deleted file mode 100644 index cab7af8..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_move.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_move_line.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_move_line.cpython-310.pyc deleted file mode 100644 index 29a3b91..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_move_line.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_multicurrency_revaluation_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_multicurrency_revaluation_report.cpython-310.pyc deleted file mode 100644 index 0ecd201..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_multicurrency_revaluation_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_partner_ledger.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_partner_ledger.cpython-310.pyc deleted file mode 100644 index e289a3f..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_partner_ledger.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_payment.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_payment.cpython-310.pyc deleted file mode 100644 index 767221e..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_payment.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_reconcile_model.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_reconcile_model.cpython-310.pyc deleted file mode 100644 index 100a22d..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_reconcile_model.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_reconcile_model_line.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_reconcile_model_line.cpython-310.pyc deleted file mode 100644 index 5c9b744..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_reconcile_model_line.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_report.cpython-310.pyc deleted file mode 100644 index c293073..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_sales_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_sales_report.cpython-310.pyc deleted file mode 100644 index 6e86c2f..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_sales_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_tax.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_tax.cpython-310.pyc deleted file mode 100644 index 76c71b6..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_tax.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/account_trial_balance_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/account_trial_balance_report.cpython-310.pyc deleted file mode 100644 index c5b8d23..0000000 Binary files a/Fusion Accounting/models/__pycache__/account_trial_balance_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/balance_sheet.cpython-310.pyc b/Fusion Accounting/models/__pycache__/balance_sheet.cpython-310.pyc deleted file mode 100644 index d673e33..0000000 Binary files a/Fusion Accounting/models/__pycache__/balance_sheet.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/bank_rec_widget.cpython-310.pyc b/Fusion Accounting/models/__pycache__/bank_rec_widget.cpython-310.pyc deleted file mode 100644 index 5e8aea7..0000000 Binary files a/Fusion Accounting/models/__pycache__/bank_rec_widget.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/bank_rec_widget_line.cpython-310.pyc b/Fusion Accounting/models/__pycache__/bank_rec_widget_line.cpython-310.pyc deleted file mode 100644 index 6a3eb6f..0000000 Binary files a/Fusion Accounting/models/__pycache__/bank_rec_widget_line.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/bank_reconciliation_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/bank_reconciliation_report.cpython-310.pyc deleted file mode 100644 index 1617dc0..0000000 Binary files a/Fusion Accounting/models/__pycache__/bank_reconciliation_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/budget.cpython-310.pyc b/Fusion Accounting/models/__pycache__/budget.cpython-310.pyc deleted file mode 100644 index d73e902..0000000 Binary files a/Fusion Accounting/models/__pycache__/budget.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/chart_template.cpython-310.pyc b/Fusion Accounting/models/__pycache__/chart_template.cpython-310.pyc deleted file mode 100644 index 92bedac..0000000 Binary files a/Fusion Accounting/models/__pycache__/chart_template.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/digest.cpython-310.pyc b/Fusion Accounting/models/__pycache__/digest.cpython-310.pyc deleted file mode 100644 index 39b7c55..0000000 Binary files a/Fusion Accounting/models/__pycache__/digest.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/executive_summary_report.cpython-310.pyc b/Fusion Accounting/models/__pycache__/executive_summary_report.cpython-310.pyc deleted file mode 100644 index f58e035..0000000 Binary files a/Fusion Accounting/models/__pycache__/executive_summary_report.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/ir_actions.cpython-310.pyc b/Fusion Accounting/models/__pycache__/ir_actions.cpython-310.pyc deleted file mode 100644 index 698404a..0000000 Binary files a/Fusion Accounting/models/__pycache__/ir_actions.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/ir_ui_menu.cpython-310.pyc b/Fusion Accounting/models/__pycache__/ir_ui_menu.cpython-310.pyc deleted file mode 100644 index 1947d6f..0000000 Binary files a/Fusion Accounting/models/__pycache__/ir_ui_menu.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/mail_activity.cpython-310.pyc b/Fusion Accounting/models/__pycache__/mail_activity.cpython-310.pyc deleted file mode 100644 index 68c2ee0..0000000 Binary files a/Fusion Accounting/models/__pycache__/mail_activity.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/mail_activity_type.cpython-310.pyc b/Fusion Accounting/models/__pycache__/mail_activity_type.cpython-310.pyc deleted file mode 100644 index d3ed791..0000000 Binary files a/Fusion Accounting/models/__pycache__/mail_activity_type.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/res_company.cpython-310.pyc b/Fusion Accounting/models/__pycache__/res_company.cpython-310.pyc deleted file mode 100644 index 8bdde8b..0000000 Binary files a/Fusion Accounting/models/__pycache__/res_company.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/res_config_settings.cpython-310.pyc b/Fusion Accounting/models/__pycache__/res_config_settings.cpython-310.pyc deleted file mode 100644 index 62df4d5..0000000 Binary files a/Fusion Accounting/models/__pycache__/res_config_settings.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/res_currency.cpython-310.pyc b/Fusion Accounting/models/__pycache__/res_currency.cpython-310.pyc deleted file mode 100644 index f620710..0000000 Binary files a/Fusion Accounting/models/__pycache__/res_currency.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/models/__pycache__/res_partner.cpython-310.pyc b/Fusion Accounting/models/__pycache__/res_partner.cpython-310.pyc deleted file mode 100644 index 25cd617..0000000 Binary files a/Fusion Accounting/models/__pycache__/res_partner.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/__init__.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 442ffb6..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/account_auto_reconcile_wizard.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/account_auto_reconcile_wizard.cpython-310.pyc deleted file mode 100644 index 5c3c7d2..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/account_auto_reconcile_wizard.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/account_bank_statement_import_csv.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/account_bank_statement_import_csv.cpython-310.pyc deleted file mode 100644 index ea1b399..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/account_bank_statement_import_csv.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/account_change_lock_date.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/account_change_lock_date.cpython-310.pyc deleted file mode 100644 index d107205..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/account_change_lock_date.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/account_reconcile_wizard.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/account_reconcile_wizard.cpython-310.pyc deleted file mode 100644 index c48d9d4..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/account_reconcile_wizard.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/account_report_file_download_error_wizard.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/account_report_file_download_error_wizard.cpython-310.pyc deleted file mode 100644 index 5e59199..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/account_report_file_download_error_wizard.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/account_report_send.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/account_report_send.cpython-310.pyc deleted file mode 100644 index f988372..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/account_report_send.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/asset_modify.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/asset_modify.cpython-310.pyc deleted file mode 100644 index dbc7133..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/asset_modify.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/fiscal_year.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/fiscal_year.cpython-310.pyc deleted file mode 100644 index 9e7702b..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/fiscal_year.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/multicurrency_revaluation.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/multicurrency_revaluation.cpython-310.pyc deleted file mode 100644 index 925ada8..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/multicurrency_revaluation.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/report_export_wizard.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/report_export_wizard.cpython-310.pyc deleted file mode 100644 index f5cb5ee..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/report_export_wizard.cpython-310.pyc and /dev/null differ diff --git a/Fusion Accounting/wizard/__pycache__/setup_wizards.cpython-310.pyc b/Fusion Accounting/wizard/__pycache__/setup_wizards.cpython-310.pyc deleted file mode 100644 index a686bf1..0000000 Binary files a/Fusion Accounting/wizard/__pycache__/setup_wizards.cpython-310.pyc and /dev/null differ diff --git a/fusion_authorizer_portal/__init__.py b/fusion_authorizer_portal/__init__.py index c3d410e..d95dc36 100644 --- a/fusion_authorizer_portal/__init__.py +++ b/fusion_authorizer_portal/__init__.py @@ -2,3 +2,29 @@ from . import models from . import controllers + + +def _reactivate_views(env): + """Ensure all module views are active after install/update. + + Odoo silently deactivates inherited views when an xpath fails + validation (e.g. parent view structure changed between versions). + Once deactivated, subsequent -u runs never reactivate them. + This hook prevents that from silently breaking the portal. + """ + views = env['ir.ui.view'].sudo().search([ + ('key', 'like', 'fusion_authorizer_portal.%'), + ('active', '=', False), + ]) + if views: + views.write({'active': True}) + env.cr.execute(""" + SELECT key FROM ir_ui_view + WHERE key LIKE 'fusion_authorizer_portal.%%' + AND id = ANY(%s) + """, [views.ids]) + keys = [r[0] for r in env.cr.fetchall()] + import logging + logging.getLogger(__name__).warning( + "Reactivated %d deactivated views: %s", len(keys), keys + ) diff --git a/fusion_authorizer_portal/__manifest__.py b/fusion_authorizer_portal/__manifest__.py index b977607..0dc4352 100644 --- a/fusion_authorizer_portal/__manifest__.py +++ b/fusion_authorizer_portal/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'Fusion Authorizer & Sales Portal', - 'version': '19.0.2.2.0', + 'version': '19.0.2.5.0', 'category': 'Sales/Portal', 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', 'description': """ @@ -50,8 +50,10 @@ This module provides external portal access for: 'website', 'mail', 'calendar', + 'appointment', 'knowledge', 'fusion_claims', + 'fusion_tasks', ], 'data': [ # Security @@ -62,6 +64,7 @@ This module provides external portal access for: 'data/portal_menu_data.xml', 'data/ir_actions_server_data.xml', 'data/welcome_articles.xml', + 'data/appointment_invite_data.xml', # Views 'views/res_partner_views.xml', 'views/sale_order_views.xml', @@ -76,7 +79,8 @@ This module provides external portal access for: 'views/portal_accessibility_forms.xml', 'views/portal_technician_templates.xml', 'views/portal_book_assessment.xml', - 'views/portal_repair_form.xml', + 'views/portal_schedule.xml', + 'views/portal_page11_sign_templates.xml', ], 'assets': { 'web.assets_backend': [ @@ -93,9 +97,11 @@ This module provides external portal access for: 'fusion_authorizer_portal/static/src/js/pdf_field_editor.js', 'fusion_authorizer_portal/static/src/js/technician_push.js', 'fusion_authorizer_portal/static/src/js/technician_location.js', + 'fusion_authorizer_portal/static/src/js/portal_schedule_booking.js', ], }, 'images': ['static/description/icon.png'], + 'post_init_hook': '_reactivate_views', 'installable': True, 'application': False, 'auto_install': False, diff --git a/fusion_authorizer_portal/controllers/__init__.py b/fusion_authorizer_portal/controllers/__init__.py index 25941d4..ce12d22 100644 --- a/fusion_authorizer_portal/controllers/__init__.py +++ b/fusion_authorizer_portal/controllers/__init__.py @@ -3,4 +3,5 @@ from . import portal_main from . import portal_assessment from . import pdf_editor -from . import portal_repair \ 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 8b60fec..cb6c927 100644 --- a/fusion_authorizer_portal/controllers/portal_main.py +++ b/fusion_authorizer_portal/controllers/portal_main.py @@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal): if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal): posting_info = self._get_adp_posting_info() response.qcontext.update(posting_info) + response.qcontext.update(self._get_clock_status_data()) # Add signature count (documents to sign) - only if Sign module is installed sign_count = 0 @@ -724,7 +725,7 @@ class AuthorizerPortal(CustomerPortal): 'sale_type_filter': sale_type, 'status_filter': status, } - + values.update(self._get_clock_status_data()) return request.render('fusion_authorizer_portal.portal_sales_dashboard', values) @http.route(['/my/sales/cases', '/my/sales/cases/page/'], type='http', auth='user', website=True) @@ -1090,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal): _logger.error(f"Error downloading proof of delivery: {e}") return request.redirect('/my/funding-claims') + # ==================== CLOCK STATUS HELPER ==================== + + def _get_clock_status_data(self): + """Get clock in/out status for the current portal user.""" + try: + user = request.env.user + Employee = request.env['hr.employee'].sudo() + employee = Employee.search([('user_id', '=', user.id)], limit=1) + if not employee: + employee = Employee.search([ + ('name', '=', user.partner_id.name), + ('user_id', '=', False), + ], limit=1) + if not employee or not getattr(employee, 'x_fclk_enable_clock', False): + return {'clock_enabled': False} + + is_checked_in = employee.attendance_state == 'checked_in' + check_in_time = '' + location_name = '' + if is_checked_in: + att = request.env['hr.attendance'].sudo().search([ + ('employee_id', '=', employee.id), + ('check_out', '=', False), + ], limit=1) + if att: + check_in_time = att.check_in.isoformat() if att.check_in else '' + location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else '' + + return { + 'clock_enabled': True, + 'clock_checked_in': is_checked_in, + 'clock_check_in_time': check_in_time, + 'clock_location_name': location_name, + } + except Exception as e: + _logger.warning("Clock status check failed: %s", e) + return {'clock_enabled': False} + # ==================== TECHNICIAN PORTAL ==================== def _check_technician_access(self): """Check if current user is a technician portal user.""" partner = request.env.user.partner_id - if not partner.is_technician_portal: - return False - return True + if partner.is_technician_portal: + return True + has_tasks = request.env['fusion.technician.task'].sudo().search_count([ + '|', + ('technician_id', '=', request.env.user.id), + ('additional_technician_ids', 'in', [request.env.user.id]), + ], limit=1) + if has_tasks: + partner.sudo().write({'is_technician_portal': True}) + return True + return False @http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True) def technician_dashboard(self, **kw): @@ -1159,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal): ICP = request.env['ir.config_parameter'].sudo() google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') + clock_data = self._get_clock_status_data() + values = { 'today_tasks': today_tasks, 'current_task': current_task, @@ -1174,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal): 'google_maps_api_key': google_maps_api_key, 'page_name': 'technician_dashboard', } + values.update(clock_data) return request.render('fusion_authorizer_portal.portal_technician_dashboard', values) @http.route(['/my/technician/tasks', '/my/technician/tasks/page/'], type='http', auth='user', website=True) @@ -1423,11 +1473,17 @@ class AuthorizerPortal(CustomerPortal): return {'success': False, 'error': str(e)} @http.route('/my/technician/task//action', type='json', auth='user', website=True) - def technician_task_action(self, task_id, action, **kw): - """Handle task status changes (start, complete, en_route, cancel).""" + def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw): + """Handle task status changes (start, complete, en_route, cancel). + Location is mandatory -- the client must send GPS coordinates.""" if not self._check_technician_access(): return {'success': False, 'error': 'Access denied'} + if not latitude or not longitude: + return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'} + if not (-90 <= latitude <= 90 and -180 <= longitude <= 180): + return {'success': False, 'error': 'Invalid GPS coordinates.'} + user = request.env.user Task = request.env['fusion.technician.task'].sudo() @@ -1439,21 +1495,39 @@ class AuthorizerPortal(CustomerPortal): ): return {'success': False, 'error': 'Task not found or not assigned to you'} + request.env['fusion.technician.location'].sudo().log_location( + latitude=latitude, + longitude=longitude, + 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, + 'action_accuracy': accuracy or 0, + } + if action == 'en_route': - task.action_start_en_route() + task.with_context(**location_ctx).action_start_en_route() elif action == 'start': - task.action_start_task() + task.with_context(**location_ctx).action_start_task() elif action == 'complete': completion_notes = kw.get('completion_notes', '') if completion_notes: task.completion_notes = completion_notes - task.action_complete_task() + task.with_context(**location_ctx).action_complete_task() elif action == 'cancel': - task.action_cancel_task() + task.with_context(**location_ctx).action_cancel_task() else: return {'success': False, 'error': f'Unknown action: {action}'} - # For completion, also return next task info result = { 'success': True, 'status': task.status, @@ -1600,10 +1674,14 @@ class AuthorizerPortal(CustomerPortal): return {'success': False, 'error': str(e)} @http.route('/my/technician/task//voice-complete', type='json', auth='user', website=True) - def technician_voice_complete(self, task_id, transcription, **kw): + def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw): """Format transcription with GPT and complete the task.""" if not self._check_technician_access(): return {'success': False, 'error': 'Access denied'} + if not latitude or not longitude: + return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'} + if not (-90 <= latitude <= 90 and -180 <= longitude <= 180): + return {'success': False, 'error': 'Invalid GPS coordinates.'} user = request.env.user Task = request.env['fusion.technician.task'].sudo() @@ -1675,7 +1753,18 @@ class AuthorizerPortal(CustomerPortal): 'completion_notes': completion_html, 'voice_note_transcription': transcription, }) - task.action_complete_task() + + request.env['fusion.technician.location'].sudo().log_location( + latitude=latitude, + longitude=longitude, + accuracy=accuracy, + ) + location_ctx = { + 'action_latitude': latitude, + 'action_longitude': longitude, + 'action_accuracy': accuracy or 0, + } + task.with_context(**location_ctx).action_complete_task() return { 'success': True, @@ -1788,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.""" @@ -2055,6 +2163,94 @@ class AuthorizerPortal(CustomerPortal): _logger.error(f"Error saving POD signature: {e}") return {'success': False, 'error': str(e)} + # ==================== TASK-LEVEL POD SIGNATURE ==================== + + @http.route('/my/technician/task//pod', type='http', auth='user', website=True) + def task_pod_signature_page(self, task_id, **kw): + """Task-level POD signature capture page (works for all tasks including shadow).""" + if not self._check_technician_access(): + return request.redirect('/my') + + user = request.env.user + Task = request.env['fusion.technician.task'].sudo() + + try: + task = Task.browse(task_id) + if not task.exists() or ( + task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids + ): + raise AccessError(_('You do not have access to this task.')) + except (AccessError, MissingError): + return request.redirect('/my/technician/tasks') + + values = { + 'task': task, + 'has_existing_signature': bool(task.pod_signature), + 'page_name': 'task_pod_signature', + } + return request.render('fusion_authorizer_portal.portal_task_pod_signature', values) + + @http.route('/my/technician/task//pod/sign', type='json', auth='user', methods=['POST']) + def task_pod_save_signature(self, task_id, client_name, signature_data, signature_date=None, **kw): + """Save POD signature directly on a task.""" + if not self._check_technician_access(): + return {'success': False, 'error': 'Access denied'} + + user = request.env.user + Task = request.env['fusion.technician.task'].sudo() + + try: + task = Task.browse(task_id) + if not task.exists() or ( + task.technician_id.id != user.id + and user.id not in task.additional_technician_ids.ids + ): + return {'success': False, 'error': 'Task not found'} + + if not client_name or not client_name.strip(): + return {'success': False, 'error': 'Client name is required'} + if not signature_data: + return {'success': False, 'error': 'Signature is required'} + + if ',' in signature_data: + signature_data = signature_data.split(',')[1] + + from datetime import datetime as dt_datetime + sig_date = None + if signature_date: + try: + sig_date = dt_datetime.strptime(signature_date, '%Y-%m-%d').date() + except ValueError: + pass + + task.write({ + 'pod_signature': signature_data, + 'pod_client_name': client_name.strip(), + 'pod_signature_date': sig_date, + 'pod_signed_by_user_id': user.id, + 'pod_signed_datetime': fields.Datetime.now(), + }) + + if task.sale_order_id: + task.sale_order_id.write({ + 'x_fc_pod_signature': signature_data, + 'x_fc_pod_client_name': client_name.strip(), + 'x_fc_pod_signature_date': sig_date, + 'x_fc_pod_signed_by_user_id': user.id, + 'x_fc_pod_signed_datetime': fields.Datetime.now(), + }) + + return { + 'success': True, + 'message': 'Signature saved successfully', + 'redirect_url': f'/my/technician/task/{task_id}', + } + + except Exception as e: + _logger.error(f"Error saving task POD signature: {e}") + return {'success': False, 'error': str(e)} + def _generate_signed_pod_pdf(self, order, save_to_field=True): """Generate a signed POD PDF with the signature embedded. 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 0000000..ab1dedd --- /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/controllers/portal_schedule.py b/fusion_authorizer_portal/controllers/portal_schedule.py new file mode 100644 index 0000000..49f793e --- /dev/null +++ b/fusion_authorizer_portal/controllers/portal_schedule.py @@ -0,0 +1,327 @@ +# -*- coding: utf-8 -*- + +from odoo import http, _, fields +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal +from odoo.exceptions import AccessError, ValidationError +from datetime import datetime, timedelta +import json +import logging +import pytz + +_logger = logging.getLogger(__name__) + + +class PortalSchedule(CustomerPortal): + """Portal controller for appointment scheduling and calendar management.""" + + def _get_schedule_values(self): + """Common values for schedule pages.""" + ICP = request.env['ir.config_parameter'].sudo() + g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848') + g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7') + g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad') + gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end) + google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') + + return { + 'portal_gradient': gradient, + 'google_maps_api_key': google_maps_api_key, + } + + def _get_user_timezone(self): + tz_name = request.env.user.tz or 'America/Toronto' + try: + return pytz.timezone(tz_name) + except pytz.exceptions.UnknownTimeZoneError: + return pytz.timezone('America/Toronto') + + def _get_appointment_types(self): + """Get appointment types available to the current user.""" + return request.env['appointment.type'].sudo().search([ + ('staff_user_ids', 'in', [request.env.user.id]), + ]) + + @http.route(['/my/schedule'], type='http', auth='user', website=True) + def schedule_page(self, **kw): + """Schedule overview: upcoming appointments and shareable link.""" + partner = request.env.user.partner_id + user = request.env.user + now = fields.Datetime.now() + + upcoming_events = request.env['calendar.event'].sudo().search([ + ('partner_ids', 'in', [partner.id]), + ('start', '>=', now), + ], order='start asc', limit=20) + + today_events = request.env['calendar.event'].sudo().search([ + ('partner_ids', 'in', [partner.id]), + ('start', '>=', now.replace(hour=0, minute=0, second=0)), + ('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)), + ], order='start asc') + + invite = request.env['appointment.invite'].sudo().search([ + ('staff_user_ids', 'in', [user.id]), + ], limit=1) + share_url = invite.book_url if invite else '' + + appointment_types = self._get_appointment_types() + tz = self._get_user_timezone() + + values = self._get_schedule_values() + values.update({ + 'page_name': 'schedule', + 'upcoming_events': upcoming_events, + 'today_events': today_events, + 'share_url': share_url, + 'appointment_types': appointment_types, + 'user_tz': tz, + 'now': now, + }) + return request.render('fusion_authorizer_portal.portal_schedule_page', values) + + @http.route(['/my/schedule/book'], type='http', auth='user', website=True) + def schedule_book(self, appointment_type_id=None, **kw): + """Booking form for a new appointment.""" + appointment_types = self._get_appointment_types() + if not appointment_types: + return request.redirect('/my/schedule') + + if appointment_type_id: + selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id)) + if not selected_type.exists(): + selected_type = appointment_types[0] + else: + selected_type = appointment_types[0] + + values = self._get_schedule_values() + values.update({ + 'page_name': 'schedule_book', + 'appointment_types': appointment_types, + 'selected_type': selected_type, + 'now': fields.Datetime.now(), + 'error': kw.get('error'), + 'success': kw.get('success'), + }) + return request.render('fusion_authorizer_portal.portal_schedule_book', values) + + @http.route('/my/schedule/available-slots', type='json', auth='user', website=True) + def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw): + """JSON-RPC endpoint: return available time slots for a date.""" + appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id)) + if not appointment_type.exists(): + return {'error': 'Appointment type not found', 'slots': []} + + user = request.env.user + tz_name = user.tz or 'America/Toronto' + tz = self._get_user_timezone() + + ref_date = fields.Datetime.now() + slot_data = appointment_type._get_appointment_slots( + timezone=tz_name, + filter_users=request.env['res.users'].sudo().browse(user.id), + asked_capacity=1, + reference_date=ref_date, + ) + + filtered_slots = [] + target_date = None + if selected_date: + try: + target_date = datetime.strptime(selected_date, '%Y-%m-%d').date() + except ValueError: + return {'error': 'Invalid date format', 'slots': []} + + for month_data in slot_data: + for week in month_data.get('weeks', []): + for day_info in week: + if not day_info: + continue + day = day_info.get('day') + if target_date and day != target_date: + continue + for slot in day_info.get('slots', []): + slot_dt_str = slot.get('datetime') + if not slot_dt_str: + continue + filtered_slots.append({ + 'datetime': slot_dt_str, + 'start_hour': slot.get('start_hour', ''), + 'end_hour': slot.get('end_hour', ''), + 'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)), + 'staff_user_id': slot.get('staff_user_id', user.id), + }) + + available_dates = [] + if not target_date: + seen = set() + for month_data in slot_data: + for week in month_data.get('weeks', []): + for day_info in week: + if not day_info: + continue + day = day_info.get('day') + if day and day_info.get('slots') and str(day) not in seen: + seen.add(str(day)) + available_dates.append(str(day)) + + return { + 'slots': filtered_slots, + 'available_dates': sorted(available_dates), + 'duration': appointment_type.appointment_duration, + 'timezone': tz_name, + } + + @http.route('/my/schedule/week-events', type='json', auth='user', website=True) + def schedule_week_events(self, selected_date, **kw): + """Return the user's calendar events for the Mon-Sun week containing selected_date.""" + try: + target = datetime.strptime(selected_date, '%Y-%m-%d').date() + except (ValueError, TypeError): + return {'error': 'Invalid date format', 'events': [], 'week_days': []} + + monday = target - timedelta(days=target.weekday()) + sunday = monday + timedelta(days=6) + + partner = request.env.user.partner_id + tz = self._get_user_timezone() + + monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time())) + sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time())) + monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None) + sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None) + + events = request.env['calendar.event'].sudo().search([ + ('partner_ids', 'in', [partner.id]), + ('start', '>=', monday_start_utc), + ('start', '<=', sunday_end_utc), + ], order='start asc') + + event_list = [] + for ev in events: + start_utc = ev.start + stop_utc = ev.stop + start_local = pytz.utc.localize(start_utc).astimezone(tz) + stop_local = pytz.utc.localize(stop_utc).astimezone(tz) + event_list.append({ + 'name': ev.name or '', + 'start': start_local.strftime('%Y-%m-%d %H:%M'), + 'end': stop_local.strftime('%Y-%m-%d %H:%M'), + 'start_time': start_local.strftime('%I:%M %p'), + 'end_time': stop_local.strftime('%I:%M %p'), + 'day_of_week': start_local.weekday(), + 'date': start_local.strftime('%Y-%m-%d'), + 'location': ev.location or '', + 'duration': ev.duration, + }) + + day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + week_days = [] + for i in range(7): + day = monday + timedelta(days=i) + week_days.append({ + 'label': day_labels[i], + 'date': day.strftime('%Y-%m-%d'), + 'day_num': day.day, + 'is_selected': day == target, + }) + + return { + 'events': event_list, + 'week_days': week_days, + 'selected_date': selected_date, + } + + @http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST']) + def schedule_book_submit(self, **post): + """Process the booking form submission.""" + appointment_type_id = int(post.get('appointment_type_id', 0)) + appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id) + if not appointment_type.exists(): + return request.redirect('/my/schedule/book?error=Invalid+appointment+type') + + client_name = (post.get('client_name') or '').strip() + client_street = (post.get('client_street') or '').strip() + client_city = (post.get('client_city') or '').strip() + client_province = (post.get('client_province') or '').strip() + client_postal = (post.get('client_postal') or '').strip() + notes = (post.get('notes') or '').strip() + slot_datetime = (post.get('slot_datetime') or '').strip() + slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration)) + + if not client_name or not slot_datetime: + return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required') + + user = request.env.user + tz = self._get_user_timezone() + + try: + start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S') + start_dt_local = tz.localize(start_dt_naive) + start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None) + except (ValueError, Exception) as e: + _logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e) + return request.redirect('/my/schedule/book?error=Invalid+time+slot') + + duration = float(slot_duration) + stop_dt_utc = start_dt_utc + timedelta(hours=duration) + + is_valid = appointment_type._check_appointment_is_valid_slot( + staff_user=user, + resources=request.env['appointment.resource'], + asked_capacity=1, + timezone=str(tz), + start_dt=start_dt_utc, + duration=duration, + allday=False, + ) + if not is_valid: + return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.') + + address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p] + location = ', '.join(address_parts) + + description_lines = [] + if client_name: + description_lines.append(f"Client: {client_name}") + if location: + description_lines.append(f"Address: {location}") + if notes: + description_lines.append(f"Notes: {notes}") + description = '\n'.join(description_lines) + + event_name = f"{client_name} - {appointment_type.name}" + + booking_line_values = [{ + 'appointment_user_id': user.id, + 'capacity_reserved': 1, + 'capacity_used': 1, + }] + + try: + event_vals = appointment_type._prepare_calendar_event_values( + asked_capacity=1, + booking_line_values=booking_line_values, + description=description, + duration=duration, + allday=False, + appointment_invite=request.env['appointment.invite'], + guests=request.env['res.partner'], + name=event_name, + customer=user.partner_id, + staff_user=user, + start=start_dt_utc, + stop=stop_dt_utc, + ) + event_vals['location'] = location + event = request.env['calendar.event'].sudo().create(event_vals) + + _logger.info( + "Appointment booked: %s at %s (event ID: %s)", + event_name, start_dt_utc, event.id, + ) + except Exception as e: + _logger.error("Failed to create appointment: %s", e) + return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.') + + return request.redirect('/my/schedule?success=Appointment+booked+successfully') diff --git a/fusion_authorizer_portal/data/appointment_invite_data.xml b/fusion_authorizer_portal/data/appointment_invite_data.xml new file mode 100644 index 0000000..5d3be76 --- /dev/null +++ b/fusion_authorizer_portal/data/appointment_invite_data.xml @@ -0,0 +1,13 @@ + + + + + + + book-appointment + + + + diff --git a/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py b/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py new file mode 100644 index 0000000..8146169 --- /dev/null +++ b/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""Reactivate any views that Odoo silently deactivated. + +Odoo deactivates inherited views when xpath validation fails (e.g. parent +view structure changed between versions). Once deactivated, subsequent +``-u`` runs never reactivate them. This end-migration script catches +that scenario on every version bump. +""" +import logging + +_logger = logging.getLogger(__name__) + +MODULE = 'fusion_authorizer_portal' + + +def migrate(cr, version): + if not version: + return + + cr.execute(""" + UPDATE ir_ui_view v + SET active = true + FROM ir_model_data d + WHERE d.res_id = v.id + AND d.model = 'ir.ui.view' + AND d.module = %s + AND v.active = false + RETURNING v.id, v.name, v.key + """, [MODULE]) + + rows = cr.fetchall() + if rows: + _logger.warning( + "Reactivated %d deactivated views for %s: %s", + len(rows), MODULE, [r[2] or r[1] for r in rows], + ) diff --git a/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py b/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py new file mode 100644 index 0000000..8146169 --- /dev/null +++ b/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""Reactivate any views that Odoo silently deactivated. + +Odoo deactivates inherited views when xpath validation fails (e.g. parent +view structure changed between versions). Once deactivated, subsequent +``-u`` runs never reactivate them. This end-migration script catches +that scenario on every version bump. +""" +import logging + +_logger = logging.getLogger(__name__) + +MODULE = 'fusion_authorizer_portal' + + +def migrate(cr, version): + if not version: + return + + cr.execute(""" + UPDATE ir_ui_view v + SET active = true + FROM ir_model_data d + WHERE d.res_id = v.id + AND d.model = 'ir.ui.view' + AND d.module = %s + AND v.active = false + RETURNING v.id, v.name, v.key + """, [MODULE]) + + rows = cr.fetchall() + if rows: + _logger.warning( + "Reactivated %d deactivated views for %s: %s", + len(rows), MODULE, [r[2] or r[1] for r in rows], + ) diff --git a/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py b/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py new file mode 100644 index 0000000..8146169 --- /dev/null +++ b/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""Reactivate any views that Odoo silently deactivated. + +Odoo deactivates inherited views when xpath validation fails (e.g. parent +view structure changed between versions). Once deactivated, subsequent +``-u`` runs never reactivate them. This end-migration script catches +that scenario on every version bump. +""" +import logging + +_logger = logging.getLogger(__name__) + +MODULE = 'fusion_authorizer_portal' + + +def migrate(cr, version): + if not version: + return + + cr.execute(""" + UPDATE ir_ui_view v + SET active = true + FROM ir_model_data d + WHERE d.res_id = v.id + AND d.model = 'ir.ui.view' + AND d.module = %s + AND v.active = false + RETURNING v.id, v.name, v.key + """, [MODULE]) + + rows = cr.fetchall() + if rows: + _logger.warning( + "Reactivated %d deactivated views for %s: %s", + len(rows), MODULE, [r[2] or r[1] for r in rows], + ) diff --git a/fusion_authorizer_portal/models/assessment.py b/fusion_authorizer_portal/models/assessment.py index be76e66..4f82749 100644 --- a/fusion_authorizer_portal/models/assessment.py +++ b/fusion_authorizer_portal/models/assessment.py @@ -499,6 +499,7 @@ class FusionAssessment(models.Model): 'res_model': 'sale.order', 'res_id': sale_order.id, 'view_mode': 'form', + 'views': [(False, 'form')], 'target': 'current', } @@ -1482,6 +1483,7 @@ class FusionAssessment(models.Model): 'name': _('Documents'), 'res_model': 'fusion.adp.document', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('assessment_id', '=', self.id)], 'context': {'default_assessment_id': self.id}, } @@ -1497,6 +1499,7 @@ class FusionAssessment(models.Model): 'res_model': 'sale.order', 'res_id': self.sale_order_id.id, 'view_mode': 'form', + 'views': [(False, 'form')], 'target': 'current', } diff --git a/fusion_authorizer_portal/models/loaner_checkout.py b/fusion_authorizer_portal/models/loaner_checkout.py index 6330b6b..2f3e0ee 100644 --- a/fusion_authorizer_portal/models/loaner_checkout.py +++ b/fusion_authorizer_portal/models/loaner_checkout.py @@ -23,5 +23,6 @@ class FusionLoanerCheckoutAssessment(models.Model): 'type': 'ir.actions.act_window', 'res_model': 'fusion.assessment', 'view_mode': 'form', + 'views': [(False, 'form')], 'res_id': self.assessment_id.id, } diff --git a/fusion_authorizer_portal/models/res_partner.py b/fusion_authorizer_portal/models/res_partner.py index 129a5a8..82342f5 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') @@ -596,6 +596,7 @@ class ResPartner(models.Model): 'name': _('Assigned Cases'), 'res_model': 'sale.order', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('x_fc_authorizer_id', '=', self.id)], 'context': {'default_x_fc_authorizer_id': self.id}, } @@ -614,6 +615,7 @@ class ResPartner(models.Model): 'name': _('Assessments'), 'res_model': 'fusion.assessment', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': domain, } @@ -697,6 +699,7 @@ class ResPartner(models.Model): 'name': _('Assigned Deliveries'), 'res_model': 'sale.order', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])], } diff --git a/fusion_authorizer_portal/models/sale_order.py b/fusion_authorizer_portal/models/sale_order.py index a216c6a..38f2c0c 100644 --- a/fusion_authorizer_portal/models/sale_order.py +++ b/fusion_authorizer_portal/models/sale_order.py @@ -101,6 +101,7 @@ class SaleOrder(models.Model): 'name': 'Message Authorizer', 'res_model': 'mail.compose.message', 'view_mode': 'form', + 'views': [(False, 'form')], 'target': 'new', 'context': { 'default_model': 'sale.order', @@ -137,6 +138,7 @@ class SaleOrder(models.Model): 'name': _('Portal Comments'), 'res_model': 'fusion.authorizer.comment', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('sale_order_id', '=', self.id)], 'context': {'default_sale_order_id': self.id}, } @@ -149,6 +151,7 @@ class SaleOrder(models.Model): 'name': _('Portal Documents'), 'res_model': 'fusion.adp.document', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('sale_order_id', '=', self.id)], 'context': {'default_sale_order_id': self.id}, } diff --git a/fusion_authorizer_portal/static/src/css/technician_portal.css b/fusion_authorizer_portal/static/src/css/technician_portal.css index 18139f2..a4e3873 100644 --- a/fusion_authorizer_portal/static/src/css/technician_portal.css +++ b/fusion_authorizer_portal/static/src/css/technician_portal.css @@ -14,16 +14,12 @@ .tech-stats-bar { display: flex; gap: 0.5rem; - overflow-x: auto; - padding-bottom: 0.5rem; - scrollbar-width: none; } -.tech-stats-bar::-webkit-scrollbar { display: none; } .tech-stat-card { - flex: 0 0 auto; - min-width: 100px; - padding: 0.75rem 1rem; + flex: 1 1 0; + min-width: 0; + padding: 0.75rem 0.5rem; border-radius: 12px; text-align: center; color: #fff; @@ -42,7 +38,145 @@ .tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); } .tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); } .tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); } -.tech-stat-travel { background: linear-gradient(135deg, #8e44ad, #7d3c98); } + +/* ---- Clock In/Out Card ---- */ +.tech-clock-card { + background: var(--o-main-card-bg, #fff); + border: 1px solid var(--o-main-border-color, #e9ecef); + border-radius: 14px; + padding: 0.875rem 1rem; +} +.tech-clock-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #adb5bd; + flex-shrink: 0; +} +.tech-clock-dot--active { + background: #10b981; + box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); + animation: tech-clock-pulse 2s ease-in-out infinite; +} +@keyframes tech-clock-pulse { + 0%, 100% { box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); } + 50% { box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); } +} +.tech-clock-status { + font-size: 0.85rem; + font-weight: 600; + color: var(--o-main-text-color, #212529); + line-height: 1.2; +} +.tech-clock-timer { + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: #6c757d; +} +.tech-clock-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + border-radius: 10px; + border: none; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.tech-clock-btn:active { transform: scale(0.96); } +.tech-clock-btn--in { + background: #10b981; + color: #fff; +} +.tech-clock-btn--in:hover { background: #059669; } +.tech-clock-btn--out { + background: #ef4444; + color: #fff; +} +.tech-clock-btn--out:hover { background: #dc2626; } +.tech-clock-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.tech-clock-error { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.5rem; + padding: 0.4rem 0.75rem; + border-radius: 8px; + background: #fef2f2; + color: #dc2626; + font-size: 0.8rem; + font-weight: 500; +} + +/* ---- Quick Links (All Tasks / Tomorrow / Repair Form) ---- */ +.tech-quick-links { + display: flex; + gap: 0.5rem; +} + +.tech-quick-link { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.875rem 0.5rem; + border-radius: 12px; + border: 1.5px solid; + text-decoration: none !important; + font-weight: 600; + font-size: 0.8rem; + transition: all 0.15s; + position: relative; +} +.tech-quick-link:active { transform: scale(0.97); } +.tech-quick-link i { font-size: 1.1rem; } + +.tech-quick-link-primary { + border-color: #3498db; + color: #3498db !important; + background: rgba(52, 152, 219, 0.04); +} +.tech-quick-link-primary:hover { background: rgba(52, 152, 219, 0.1); } + +.tech-quick-link-secondary { + border-color: #6c757d; + color: #6c757d !important; + background: rgba(108, 117, 125, 0.04); +} +.tech-quick-link-secondary:hover { background: rgba(108, 117, 125, 0.1); } + +.tech-quick-link-warning { + border-color: #e67e22; + color: #e67e22 !important; + background: rgba(230, 126, 34, 0.04); +} +.tech-quick-link-warning:hover { background: rgba(230, 126, 34, 0.1); } + +.tech-quick-link-badge { + position: absolute; + top: -6px; + right: -6px; + background: #3498db; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + min-width: 18px; + height: 18px; + line-height: 18px; + text-align: center; + border-radius: 9px; + padding: 0 4px; +} /* ---- Hero Card (Dashboard Current Task) ---- */ .tech-hero-card { @@ -475,12 +609,18 @@ gap: 1rem; } .tech-stat-card { - min-width: 130px; padding: 1rem 1.5rem; } .tech-stat-card .stat-number { font-size: 2rem; } + .tech-quick-links { + gap: 1rem; + } + .tech-quick-link { + padding: 1rem 0.75rem; + font-size: 0.85rem; + } .tech-bottom-bar { position: static; box-shadow: none; diff --git a/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js b/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js index 6802395..8626a1b 100644 --- a/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js +++ b/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js @@ -28,6 +28,9 @@ patch(Chatter.prototype, { [thread.id], ); if (result && result.type === "ir.actions.act_window") { + if (!result.views && result.view_mode) { + result.views = result.view_mode.split(",").map(v => [false, v.trim()]); + } this._fapActionService.doAction(result); } } catch (e) { diff --git a/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js b/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js new file mode 100644 index 0000000..40e0c1c --- /dev/null +++ b/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js @@ -0,0 +1,343 @@ +(function () { + 'use strict'; + + var dateInput = document.getElementById('bookingDate'); + var slotsContainer = document.getElementById('slotsContainer'); + var slotsGrid = document.getElementById('slotsGrid'); + var slotsLoading = document.getElementById('slotsLoading'); + var noSlots = document.getElementById('noSlots'); + var slotDatetimeInput = document.getElementById('slotDatetime'); + var slotDurationInput = document.getElementById('slotDuration'); + var submitBtn = document.getElementById('btnSubmitBooking'); + var typeSelect = document.getElementById('appointmentTypeSelect'); + var selectedSlotBtn = null; + + var weekContainer = document.getElementById('weekCalendarContainer'); + var weekLoading = document.getElementById('weekCalendarLoading'); + var weekGrid = document.getElementById('weekCalendarGrid'); + var weekHeader = document.getElementById('weekCalendarHeader'); + var weekBody = document.getElementById('weekCalendarBody'); + var weekEmpty = document.getElementById('weekCalendarEmpty'); + + function getAppointmentTypeId() { + if (typeSelect) return typeSelect.value; + var hidden = document.querySelector('input[name="appointment_type_id"]'); + return hidden ? hidden.value : null; + } + + function escapeHtml(str) { + var div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function truncate(str, max) { + if (!str) return ''; + return str.length > max ? str.substring(0, max) + '...' : str; + } + + function fetchWeekEvents(date) { + if (!weekContainer || !date) return; + + weekContainer.style.display = 'block'; + weekLoading.style.display = 'block'; + weekGrid.style.display = 'none'; + weekEmpty.style.display = 'none'; + + fetch('/my/schedule/week-events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'call', + params: { selected_date: date }, + }), + }) + .then(function (resp) { return resp.json(); }) + .then(function (data) { + weekLoading.style.display = 'none'; + var result = data.result || {}; + var events = result.events || []; + var weekDays = result.week_days || []; + + if (result.error || !weekDays.length) { + weekEmpty.style.display = 'block'; + return; + } + + renderWeekCalendar(weekDays, events, date); + }) + .catch(function () { + weekLoading.style.display = 'none'; + weekEmpty.textContent = 'Failed to load calendar. Please try again.'; + weekEmpty.style.display = 'block'; + }); + } + + function renderWeekCalendar(weekDays, events, selectedDate) { + weekHeader.innerHTML = ''; + weekBody.innerHTML = ''; + + var eventsByDate = {}; + events.forEach(function (ev) { + if (!eventsByDate[ev.date]) eventsByDate[ev.date] = []; + eventsByDate[ev.date].push(ev); + }); + + var hasAnyEvents = events.length > 0; + + weekDays.forEach(function (day) { + var isSelected = day.date === selectedDate; + var isWeekend = day.label === 'Sat' || day.label === 'Sun'; + var dayEvents = eventsByDate[day.date] || []; + + var headerCell = document.createElement('div'); + headerCell.className = 'text-center py-2 flex-fill'; + headerCell.style.cssText = 'min-width: 0; font-size: 12px; border-right: 1px solid #dee2e6;'; + if (isSelected) { + headerCell.style.backgroundColor = '#e8f4fd'; + } + if (isWeekend) { + headerCell.style.opacity = '0.6'; + } + + var labelEl = document.createElement('div'); + labelEl.className = 'fw-semibold text-muted'; + labelEl.textContent = day.label; + + var numEl = document.createElement('div'); + numEl.className = isSelected ? 'fw-bold text-primary' : 'fw-semibold'; + numEl.style.fontSize = '14px'; + numEl.textContent = day.day_num; + + headerCell.appendChild(labelEl); + headerCell.appendChild(numEl); + weekHeader.appendChild(headerCell); + + var bodyCell = document.createElement('div'); + bodyCell.className = 'flex-fill p-1'; + bodyCell.style.cssText = 'min-width: 0; min-height: 70px; border-right: 1px solid #dee2e6; overflow: hidden;'; + if (isSelected) { + bodyCell.style.backgroundColor = '#f0f8ff'; + } + + if (dayEvents.length) { + dayEvents.forEach(function (ev) { + var card = document.createElement('div'); + card.className = 'mb-1 px-1 py-1 rounded'; + card.style.cssText = 'font-size: 11px; background: #eef6ff; border-left: 3px solid #3a8fb7; overflow: hidden; cursor: default;'; + card.title = ev.start_time + ' - ' + ev.end_time + '\n' + ev.name + (ev.location ? '\n' + ev.location : ''); + + var timeEl = document.createElement('div'); + timeEl.className = 'fw-semibold text-primary'; + timeEl.style.fontSize = '10px'; + timeEl.textContent = ev.start_time; + + var nameEl = document.createElement('div'); + nameEl.className = 'text-truncate'; + nameEl.style.fontSize = '10px'; + nameEl.textContent = truncate(ev.name, 18); + + card.appendChild(timeEl); + card.appendChild(nameEl); + bodyCell.appendChild(card); + }); + } + + weekBody.appendChild(bodyCell); + }); + + if (hasAnyEvents) { + weekGrid.style.display = 'block'; + weekEmpty.style.display = 'none'; + } else { + weekGrid.style.display = 'none'; + weekEmpty.style.display = 'block'; + } + } + + function fetchSlots(date) { + var typeId = getAppointmentTypeId(); + if (!typeId || !date) return; + + slotsContainer.style.display = 'block'; + slotsLoading.style.display = 'block'; + slotsGrid.innerHTML = ''; + noSlots.style.display = 'none'; + slotDatetimeInput.value = ''; + if (submitBtn) submitBtn.disabled = true; + selectedSlotBtn = null; + + fetch('/my/schedule/available-slots', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'call', + params: { + appointment_type_id: parseInt(typeId), + selected_date: date, + }, + }), + }) + .then(function (resp) { return resp.json(); }) + .then(function (data) { + slotsLoading.style.display = 'none'; + var result = data.result || {}; + var slots = result.slots || []; + + if (result.error) { + noSlots.textContent = result.error; + noSlots.style.display = 'block'; + return; + } + + if (!slots.length) { + noSlots.style.display = 'block'; + return; + } + + var morningSlots = []; + var afternoonSlots = []; + slots.forEach(function (slot) { + var hour = parseInt(slot.start_hour); + if (isNaN(hour)) { + var match = slot.start_hour.match(/(\d+)/); + hour = match ? parseInt(match[1]) : 0; + if (slot.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12; + if (slot.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0; + } + if (hour < 12) { + morningSlots.push(slot); + } else { + afternoonSlots.push(slot); + } + }); + + function renderGroup(label, icon, groupSlots) { + if (!groupSlots.length) return; + var header = document.createElement('div'); + header.className = 'w-100 mt-2 mb-1'; + header.innerHTML = '' + label + ''; + slotsGrid.appendChild(header); + + groupSlots.forEach(function (slot) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-outline-primary btn-sm slot-btn'; + btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;'; + btn.textContent = slot.start_hour; + btn.dataset.datetime = slot.datetime; + btn.dataset.duration = slot.duration; + btn.addEventListener('click', function () { + if (selectedSlotBtn) { + selectedSlotBtn.classList.remove('btn-primary'); + selectedSlotBtn.classList.add('btn-outline-primary'); + } + btn.classList.remove('btn-outline-primary'); + btn.classList.add('btn-primary'); + selectedSlotBtn = btn; + slotDatetimeInput.value = slot.datetime; + slotDurationInput.value = slot.duration; + if (submitBtn) submitBtn.disabled = false; + }); + slotsGrid.appendChild(btn); + }); + } + + renderGroup('Morning', 'fa-sun-o', morningSlots); + renderGroup('Afternoon', 'fa-cloud', afternoonSlots); + }) + .catch(function (err) { + slotsLoading.style.display = 'none'; + noSlots.textContent = 'Failed to load slots. Please try again.'; + noSlots.style.display = 'block'; + }); + } + + if (dateInput) { + dateInput.addEventListener('change', function () { + var val = this.value; + fetchWeekEvents(val); + fetchSlots(val); + }); + } + + if (typeSelect) { + typeSelect.addEventListener('change', function () { + if (dateInput && dateInput.value) { + fetchSlots(dateInput.value); + } + }); + } + + var bookingForm = document.getElementById('bookingForm'); + if (bookingForm) { + bookingForm.addEventListener('submit', function (e) { + if (!slotDatetimeInput || !slotDatetimeInput.value) { + e.preventDefault(); + alert('Please select a time slot before booking.'); + return false; + } + var clientName = bookingForm.querySelector('input[name="client_name"]'); + if (!clientName || !clientName.value.trim()) { + e.preventDefault(); + alert('Please enter the client name.'); + return false; + } + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = ' Booking...'; + } + }); + } + + window.initScheduleAddressAutocomplete = function () { + var streetInput = document.getElementById('clientStreet'); + if (!streetInput) return; + + var autocomplete = new google.maps.places.Autocomplete(streetInput, { + componentRestrictions: { country: 'ca' }, + types: ['address'], + }); + + autocomplete.addListener('place_changed', function () { + var place = autocomplete.getPlace(); + if (!place.address_components) return; + + var streetNumber = ''; + var streetName = ''; + var city = ''; + var province = ''; + var postalCode = ''; + + for (var i = 0; i < place.address_components.length; i++) { + var component = place.address_components[i]; + var types = component.types; + + if (types.indexOf('street_number') > -1) { + streetNumber = component.long_name; + } else if (types.indexOf('route') > -1) { + streetName = component.long_name; + } else if (types.indexOf('locality') > -1) { + city = component.long_name; + } else if (types.indexOf('administrative_area_level_1') > -1) { + province = component.long_name; + } else if (types.indexOf('postal_code') > -1) { + postalCode = component.long_name; + } + } + + streetInput.value = (streetNumber + ' ' + streetName).trim(); + var cityInput = document.getElementById('clientCity'); + if (cityInput) cityInput.value = city; + var provInput = document.getElementById('clientProvince'); + if (provInput) provInput.value = province; + var postalInput = document.getElementById('clientPostal'); + if (postalInput) postalInput.value = postalCode; + }); + }; + +})(); diff --git a/fusion_authorizer_portal/static/src/js/technician_location.js b/fusion_authorizer_portal/static/src/js/technician_location.js index 25b6e82..648c2be 100644 --- a/fusion_authorizer_portal/static/src/js/technician_location.js +++ b/fusion_authorizer_portal/static/src/js/technician_location.js @@ -1,94 +1,234 @@ /** - * Technician Location Logger - * Logs GPS location every 5 minutes during working hours (9 AM - 6 PM) - * Only logs while the browser tab is visible. + * Technician Location Services + * + * 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. + * 3. Blocking modal -- cannot be dismissed; forces the technician to grant permission. */ (function () { 'use strict'; - var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes - var STORE_OPEN_HOUR = 9; - var STORE_CLOSE_HOUR = 18; + var INTERVAL_MS = 5 * 60 * 1000; + var CLOCK_CHECK_MS = 60 * 1000; // check clock status every 60s var locationTimer = null; + var clockCheckTimer = null; + var isClockedIn = false; + var permissionDenied = false; - function isWorkingHours() { - var now = new Date(); - var hour = now.getHours(); - return hour >= STORE_OPEN_HOUR && hour < STORE_CLOSE_HOUR; + // ===================================================================== + // BLOCKING MODAL + // ===================================================================== + + var modalEl = null; + + function ensureModal() { + if (modalEl) return; + var div = document.createElement('div'); + div.id = 'fusionLocationModal'; + div.innerHTML = + '
' + + '
' + + '
' + + '

Location Required

' + + '

Your GPS location is mandatory to perform this action. ' + + 'Please allow location access in your browser settings and try again.

' + + '

If you previously denied access, open your browser settings ' + + 'and reset the location permission for this site.

' + + '' + + '
' + + '
'; + document.body.appendChild(div); + modalEl = div; + document.getElementById('fusionLocationRetryBtn').addEventListener('click', function () { + hideModal(); + window.fusionGetLocation().catch(function () { + showModal(); + }); + }); } - function isTechnicianPortal() { - // Check if we're on a technician portal page - return window.location.pathname.indexOf('/my/technician') !== -1; + function showModal() { + ensureModal(); + modalEl.style.display = ''; } - function logLocation() { - if (!isWorkingHours()) { - return; - } - if (document.hidden) { - return; - } - if (!navigator.geolocation) { - return; - } + function hideModal() { + if (modalEl) modalEl.style.display = 'none'; + } - navigator.geolocation.getCurrentPosition( - function (position) { - var data = { - jsonrpc: '2.0', - method: 'call', - params: { + // ===================================================================== + // PERMISSION-DENIED BANNER (persistent warning for background logger) + // ===================================================================== + + var bannerEl = null; + + function showDeniedBanner() { + if (bannerEl) return; + bannerEl = document.createElement('div'); + bannerEl.id = 'fusionLocationBanner'; + bannerEl.style.cssText = + 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#dc3545;color:#fff;' + + 'padding:10px 16px;text-align:center;font-size:0.9rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.2);'; + bannerEl.innerHTML = + '' + + 'Location access is denied. Your location is not being tracked. ' + + 'Please enable location in browser settings.'; + document.body.appendChild(bannerEl); + } + + // ===================================================================== + // getLocation() -- public API + // ===================================================================== + + function getLocation() { + return new Promise(function (resolve, reject) { + if (!navigator.geolocation) { + reject(new Error('Geolocation is not supported by this browser.')); + return; + } + navigator.geolocation.getCurrentPosition( + function (position) { + permissionDenied = false; + if (bannerEl) { bannerEl.remove(); bannerEl = null; } + resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude, accuracy: position.coords.accuracy || 0, + }); + }, + function (error) { + permissionDenied = true; + showDeniedBanner(); + console.error('Fusion Location: GPS error', error.code, error.message); + reject(error); + }, + { enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 } + ); + }); + } + + window.fusionGetLocation = getLocation; + + // ===================================================================== + // NAVIGATE -- opens Google Maps app on iOS/Android, browser fallback + // ===================================================================== + + function openGoogleMapsNav(el) { + var addr = (el.dataset.navAddr || '').trim(); + var fallbackUrl = el.dataset.navUrl || ''; + if (!addr && !fallbackUrl) return; + + var dest = encodeURIComponent(addr) || fallbackUrl.split('destination=')[1]; + var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); + var isAndroid = /Android/i.test(navigator.userAgent); + + if (isIOS) { + window.location.href = 'comgooglemaps://?daddr=' + dest + '&directionsmode=driving'; + } else if (isAndroid) { + window.location.href = 'google.navigation:q=' + dest; + } else { + window.open(fallbackUrl, '_blank'); + } + } + + window.openGoogleMapsNav = openGoogleMapsNav; + + // ===================================================================== + // BACKGROUND LOGGER (tied to clock-in / clock-out status) + // ===================================================================== + + 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 (!isClockedIn || document.hidden || !navigator.geolocation) return; + + getLocation().then(function (coords) { + fetch('/my/technician/location/log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'call', + params: { + latitude: coords.latitude, + longitude: coords.longitude, + accuracy: coords.accuracy, } - }; - fetch('/my/technician/location/log', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }).catch(function () { - // Silently fail - location logging is best-effort - }); - }, - function () { - // Geolocation permission denied or error - silently ignore - }, - { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 } - ); + }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.result && !data.result.success) { + console.warn('Fusion Location: server rejected log', data.result); + } + }) + .catch(function (err) { + console.warn('Fusion Location: network error', err); + }); + }).catch(function () { + /* permission denied -- banner already shown */ + }); + } + + 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; - } + if (!isTechnicianPortal()) return; - // Log immediately on page load - logLocation(); + // Check clock status immediately, then every 60s + checkClockStatus(); + clockCheckTimer = setInterval(checkClockStatus, CLOCK_CHECK_MS); - // Set interval for periodic logging - locationTimer = setInterval(logLocation, INTERVAL_MS); - - // Pause/resume on tab visibility change + // Pause/resume on tab visibility document.addEventListener('visibilitychange', function () { if (document.hidden) { - // Tab hidden - clear interval to save battery - if (locationTimer) { - clearInterval(locationTimer); - locationTimer = null; - } - } else { - // Tab visible again - log immediately and restart interval - logLocation(); - if (!locationTimer) { - locationTimer = setInterval(logLocation, INTERVAL_MS); - } + stopLocationTimer(); + } else if (isClockedIn) { + startLocationTimer(); } }); } - // Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startLocationLogging); } else { diff --git a/fusion_authorizer_portal/utils/pdf_filler.py b/fusion_authorizer_portal/utils/pdf_filler.py index feaca15..f87283c 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 0000000..3e4fa9c --- /dev/null +++ b/fusion_authorizer_portal/views/portal_page11_sign_templates.xml @@ -0,0 +1,413 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_authorizer_portal/views/portal_schedule.xml b/fusion_authorizer_portal/views/portal_schedule.xml new file mode 100644 index 0000000..eed387c --- /dev/null +++ b/fusion_authorizer_portal/views/portal_schedule.xml @@ -0,0 +1,348 @@ + + + + + + + + + + + + diff --git a/fusion_authorizer_portal/views/portal_technician_templates.xml b/fusion_authorizer_portal/views/portal_technician_templates.xml index d89e700..58ce9be 100644 --- a/fusion_authorizer_portal/views/portal_technician_templates.xml +++ b/fusion_authorizer_portal/views/portal_technician_templates.xml @@ -18,6 +18,41 @@ + + +
+
+
+
+
+
+ Clocked In + Not Clocked In +
+
00:00:00
+
+
+ +
+ +
+ +
@@ -32,10 +67,6 @@
Done
-
-
-
Travel min
-
@@ -55,21 +86,24 @@
- -

+

- + Navigate Complete - Call @@ -94,7 +128,7 @@
-

+

min drive @@ -102,8 +136,11 @@

+ + +
@@ -1086,7 +1191,42 @@

Welcome back, !

- + + + +
+
+
+
+
+
+ Clocked In + Not Clocked In +
+
00:00:00
+
+
+ +
+ +
+ +
@@ -1377,6 +1517,113 @@ + + + @@ -3699,4 +3946,232 @@ + + + + + diff --git a/fusion_claims/__init__.py b/fusion_claims/__init__.py index 33ce5b6..5795da1 100644 --- a/fusion_claims/__init__.py +++ b/fusion_claims/__init__.py @@ -4,7 +4,6 @@ # Part of the Fusion Claim Assistant product family. from . import models -from . import controllers from . import wizard diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index d533070..a222f58 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -103,7 +103,6 @@ 'views/res_company_views.xml', 'views/res_config_settings_views.xml', 'views/sale_order_views.xml', - 'views/sale_portal_templates.xml', 'views/account_move_views.xml', 'views/account_journal_views.xml', 'wizard/adp_export_wizard_views.xml', @@ -129,17 +128,12 @@ 'wizard/odsp_submit_to_odsp_wizard_views.xml', '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', 'views/client_profile_views.xml', 'wizard/xml_import_wizard_views.xml', - 'views/ltc_facility_views.xml', - 'views/ltc_repair_views.xml', - 'views/ltc_cleanup_views.xml', - 'views/ltc_form_submission_views.xml', 'views/adp_claims_views.xml', 'views/submission_history_views.xml', 'views/fusion_loaner_views.xml', @@ -149,7 +143,6 @@ 'report/report_templates.xml', 'report/sale_report_portrait.xml', 'report/sale_report_landscape.xml', - 'report/sale_report_ltc_repair.xml', 'report/invoice_report_portrait.xml', 'report/invoice_report_landscape.xml', 'report/report_proof_of_delivery.xml', @@ -161,9 +154,6 @@ 'report/report_accessibility_contract.xml', 'report/report_mod_quotation.xml', 'report/report_mod_invoice.xml', - 'data/ltc_data.xml', - 'report/report_ltc_nursing_station.xml', - 'data/ltc_report_data.xml', 'data/mail_template_data.xml', 'data/ai_agent_data.xml', ], diff --git a/fusion_claims/controllers/__init__.py b/fusion_claims/controllers/__init__.py deleted file mode 100644 index 811abc3..0000000 --- a/fusion_claims/controllers/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -*- coding: utf-8 -*- -from . import portal diff --git a/fusion_claims/controllers/portal.py b/fusion_claims/controllers/portal.py deleted file mode 100644 index 0f3c858..0000000 --- a/fusion_claims/controllers/portal.py +++ /dev/null @@ -1,157 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2024-2025 Nexa Systems Inc. -# License OPL-1 (Odoo Proprietary License v1.0) -# Part of the Fusion Claim Assistant product family. - -import binascii - -from odoo import fields, http, _ -from odoo.exceptions import AccessError, MissingError -from odoo.http import request -from odoo.addons.sale.controllers.portal import CustomerPortal - - -class FusionCustomerPortal(CustomerPortal): - - def _is_adp_order(self, order_sudo): - return ( - hasattr(order_sudo, '_is_adp_sale') - and order_sudo._is_adp_sale() - ) - - def _is_adp_reg(self, order_sudo): - return self._is_adp_order(order_sudo) and order_sudo.x_fc_client_type == 'REG' - - def _get_adp_payable(self, order_sudo): - if self._is_adp_reg(order_sudo): - return order_sudo.x_fc_client_portion_total or 0 - return order_sudo.amount_total - - # ------------------------------------------------------------------ - # View Details: render ADP landscape report for ADP orders - # ------------------------------------------------------------------ - @http.route() - def portal_order_page( - self, order_id, report_type=None, access_token=None, - message=False, download=False, payment_amount=None, - amount_selection=None, **kw - ): - if report_type in ('html', 'pdf', 'text'): - try: - order_sudo = self._document_check_access( - 'sale.order', order_id, access_token=access_token, - ) - except (AccessError, MissingError): - return request.redirect('/my') - - if self._is_adp_order(order_sudo): - return self._show_report( - model=order_sudo, - report_type=report_type, - report_ref='fusion_claims.action_report_saleorder_landscape', - download=download, - ) - - return super().portal_order_page( - order_id, - report_type=report_type, - access_token=access_token, - message=message, - download=download, - payment_amount=payment_amount, - amount_selection=amount_selection, - **kw, - ) - - # ------------------------------------------------------------------ - # Payment amount overrides - # ------------------------------------------------------------------ - def _determine_is_down_payment(self, order_sudo, amount_selection, payment_amount): - if self._is_adp_reg(order_sudo): - payable = self._get_adp_payable(order_sudo) - if amount_selection == 'down_payment': - return True - elif amount_selection == 'full_amount': - return False - return ( - order_sudo.prepayment_percent < 1.0 if payment_amount is None - else payment_amount < payable - ) - return super()._determine_is_down_payment(order_sudo, amount_selection, payment_amount) - - def _get_payment_values(self, order_sudo, is_down_payment=False, payment_amount=None, **kwargs): - values = super()._get_payment_values( - order_sudo, - is_down_payment=is_down_payment, - payment_amount=payment_amount, - **kwargs, - ) - if not self._is_adp_reg(order_sudo): - return values - - client_portion = self._get_adp_payable(order_sudo) - if client_portion <= 0: - return values - - current_amount = values.get('amount', 0) - if current_amount > client_portion: - values['amount'] = order_sudo.currency_id.round(client_portion) - return values - - # ------------------------------------------------------------------ - # Signature: attach ADP report instead of default Odoo quotation - # ------------------------------------------------------------------ - @http.route() - def portal_quote_accept(self, order_id, access_token=None, name=None, signature=None): - access_token = access_token or request.httprequest.args.get('access_token') - try: - order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token) - except (AccessError, MissingError): - return {'error': _('Invalid order.')} - - if not order_sudo._has_to_be_signed(): - return {'error': _('The order is not in a state requiring customer signature.')} - if not signature: - return {'error': _('Signature is missing.')} - - try: - order_sudo.write({ - 'signed_by': name, - 'signed_on': fields.Datetime.now(), - 'signature': signature, - }) - request.env.cr.flush() - except (TypeError, binascii.Error): - return {'error': _('Invalid signature data.')} - - if not order_sudo._has_to_be_paid(): - order_sudo._validate_order() - - if self._is_adp_order(order_sudo): - report_ref = 'fusion_claims.action_report_saleorder_landscape' - else: - report_ref = 'sale.action_report_saleorder' - - pdf = request.env['ir.actions.report'].sudo()._render_qweb_pdf( - report_ref, [order_sudo.id] - )[0] - - order_sudo.message_post( - attachments=[('%s.pdf' % order_sudo.name, pdf)], - author_id=( - order_sudo.partner_id.id - if request.env.user._is_public() - else request.env.user.partner_id.id - ), - body=_('Order signed by %s', name), - message_type='comment', - subtype_xmlid='mail.mt_comment', - ) - - query_string = '&message=sign_ok' - if order_sudo._has_to_be_paid(): - query_string += '&allow_payment=yes' - return { - 'force_refresh': True, - 'redirect_url': order_sudo.get_portal_url(query_string=query_string), - } diff --git a/fusion_claims/data/ai_agent_data.xml b/fusion_claims/data/ai_agent_data.xml index 14fd457..3a09574 100644 --- a/fusion_claims/data/ai_agent_data.xml +++ b/fusion_claims/data/ai_agent_data.xml @@ -26,7 +26,7 @@ ai['result'] = record._fc_tool_search_clients(search_term, city_filter, conditio ai['result'] = record._fc_tool_client_details(profile_id) - Get detailed information about a specific client profile including personal info, medical status, benefits, claims history, ADP application history, funding history with invoice status, and previously funded devices. Requires profile_id from a previous search. + Get detailed information about a specific client profile including personal info, medical status, benefits, claims history, and ADP application history. Requires profile_id from a previous search. {"type": "object", "properties": {"profile_id": {"type": "number", "description": "ID of the client profile to get details for"}}, "required": ["profile_id"]} @@ -39,75 +39,21 @@ ai['result'] = record._fc_tool_client_details(profile_id) ai['result'] = record._fc_tool_claims_stats() - Get aggregated statistics about Fusion Claims data: total profiles, total orders, breakdown by sale type with amounts, breakdown by ADP workflow status, and top cities by client count. No parameters needed. + Get aggregated statistics about Fusion Claims data: total profiles, total orders, breakdown by sale type, breakdown by workflow status, and top cities by client count. No parameters needed. {"type": "object", "properties": {}, "required": []} - - - Fusion: Client Status Lookup - code - - - -ai['result'] = record._fc_tool_client_status(client_name) - - Look up a client's complete status by name. Returns all their orders with current ADP status, invoice details (ADP and client portions, paid/unpaid), document checklist, funding warnings, and recommended next steps for each order. Use this when someone asks "what's the status of [name]" or "how is [name]'s case going". - {"type": "object", "properties": {"client_name": {"type": "string", "description": "The client's name (first name, last name, or full name) to look up"}}, "required": ["client_name"]} - - - - - Fusion: ADP Billing Period - code - - - -ai['result'] = record._fc_tool_adp_billing_period(period) - - Get ADP billing summary for a posting period. Shows total invoiced amount to ADP, paid vs unpaid amounts, number of orders billed, submission deadline, and expected payment date. Use when asked about ADP billing, posting, or invoicing for a period. - {"type": "object", "properties": {"period": {"type": "string", "description": "Which period to query: 'current' (default), 'previous', 'next', or a specific date in YYYY-MM-DD format"}}, "required": []} - - - - - Fusion: Demographics & Analytics - code - - - -ai['result'] = record._fc_tool_demographics(analysis_type, city_filter, sale_type_filter) - - Run demographic and analytical queries on client data. Returns age group breakdowns, device popularity by age, city demographics with average age and funding, benefit type analysis, top devices with average client age, and overall funding summaries. Use for questions like "average applications by age group", "what devices do clients over 75 use", "demographics by city", "how old are our clients on average". - {"type": "object", "properties": {"analysis_type": {"type": "string", "description": "Type of analysis: 'full' (all reports), 'age_groups' (clients/apps by age), 'devices_by_age' (device popularity per age bracket), 'city_demographics' (per-city stats with avg age), 'benefits' (benefit type breakdown), 'top_devices' (most popular devices with avg client age), 'funding_summary' (overall totals and averages)"}, "city_filter": {"type": "string", "description": "Optional: filter city demographics to a specific city"}, "sale_type_filter": {"type": "string", "description": "Optional: filter by sale type (adp, odsp, wsib, etc.)"}}, "required": []} - - - + Fusion Claims Client Intelligence - Query client profiles, ADP claims, funding history, billing periods, demographics, and device information. - You help users find information about ADP clients, claims, medical conditions, devices, funding history, billing periods, and demographics. Use the Fusion tools to query data. - -Common questions and which tool to use: -- "What is the status of [name]?" -> Use Client Status Lookup (Tool 4) -- "What is the ADP billing for this period?" -> Use ADP Billing Period (Tool 5) -- "Tell me about [name]'s funding history" -> Use Client Status Lookup first, then Client Details for more depth -- "How many claims do we have?" -> Use Claims Statistics (Tool 3) -- "Find clients in [city]" -> Use Search Client Profiles (Tool 1) -- "Average applications by age group" -> Use Demographics (Tool 6) with analysis_type="age_groups" -- "What devices do seniors use?" -> Use Demographics (Tool 6) with analysis_type="devices_by_age" -- "What is the average age of our clients?" -> Use Demographics (Tool 6) with analysis_type="funding_summary" -- "Show demographics for Brampton" -> Use Demographics (Tool 6) with analysis_type="city_demographics" and city_filter="Brampton" -- "How many ODSP clients do we have?" -> Use Demographics (Tool 6) with analysis_type="benefits" + Query client profiles, ADP claims, funding history, medical conditions, and device information. + You help users find information about ADP clients, claims, medical conditions, devices, and funding history. Use the Fusion search/details/stats tools to query data. @@ -116,108 +62,32 @@ Common questions and which tool to use: Fusion Claims Intelligence - Ask about clients, ADP claims, funding history, billing periods, and devices. + Ask about clients, ADP claims, funding history, medical conditions, and devices. gpt-4.1 analytical - You are Fusion Claims Intelligence, an AI assistant for ADP claims management at a mobility equipment company. + You are Fusion Claims Intelligence, an AI assistant for ADP claims management. -You help staff find information about clients, their order status, medical conditions, mobility devices, funding history, billing periods, and claim status. - -CRITICAL - Response Formatting Rules: -You are displayed inside a narrow chat panel. Follow these rules strictly: - -1. TABLES: Maximum 3 columns. Never create tables with 4+ columns -- the panel is too narrow. - - For data with many fields, use TWO-COLUMN key-value tables (Label | Value) - - Split wide data across multiple small tables with headings between them - -2. Use ### headings to separate sections -3. Use **bold** for labels and important values -4. Use `code` for order numbers and IDs (e.g., `S30168`) -5. Never output plain unformatted text walls - -CORRECT FORMAT - Client status (multiple small tables, not one wide table): - -### Gurpreet Singh -**City:** Brampton | **Health Card:** 1234-567-890 - -#### Order `S30168` -- ADP -| Detail | Value | -|--------|-------| -| **Status** | Assessment Completed | -| **Total** | $5,624.00 | -| **ADP Portion** | $4,218.00 | -| **Client Portion** | $1,406.00 | -| **Next Step** | Send ADP application | - -CORRECT FORMAT - Demographics (3-column max): - -### Applications by Age Group -| Age Group | Clients | Avg Apps | -|-----------|---------|----------| -| Under 18 | 2 | 2.00 | -| 18-30 | 8 | 1.00 | -| 75+ | 596 | 1.08 | - -For extra columns, add a second table: - -### Funding by Age Group -| Age Group | Avg ADP | Avg Total | -|-----------|---------|-----------| -| Under 18 | $82.00 | $82.00 | -| 75+ | $473.15 | $1,216.15 | - -CORRECT FORMAT - Billing period: - -### ADP Billing: Feb 20 - Mar 5, 2026 -| Metric | Value | -|--------|-------| -| **Total Invoiced** | $29,447.35 | -| **Paid** | $25,000.00 | -| **Unpaid** | $4,447.35 | -| **Invoices** | 20 | -| **Deadline** | Wed, Mar 4 at 6 PM | - -WRONG (too many columns, will look cramped): -| Age | Clients | Apps | Avg Apps | Avg ADP | Avg Total | +You help staff find information about clients, medical conditions, mobility devices, funding history, and claim status. Capabilities: 1. Search client profiles by name, health card number, city, or medical condition -2. Get detailed client information including funding history, invoice status, and previously funded devices +2. Get detailed client information including claims history and ADP applications 3. Provide aggregated statistics about claims, funding types, and demographics -4. Look up a client's complete status by name -- including all orders, invoices, documents, and next steps -5. Query ADP billing period summaries -- total invoiced to ADP, paid vs unpaid, submission deadlines -6. Run demographic analytics -- age group breakdowns, device popularity by age, city demographics, benefit analysis, funding summaries - -How to handle common requests: -- "What is the status of [name]?" -> Use the Client Status Lookup tool with the client's name -- "What is the ADP billing this period?" -> Use the ADP Billing Period tool with period="current" -- "What was the ADP billing last period?" -> Use the ADP Billing Period tool with period="previous" -- "Show me [name]'s funding history" -> Use Client Status Lookup, then Client Details for full history -- "Average applications by age group" -> Use Demographics tool with analysis_type="age_groups" -- "What devices do clients over 75 use?" -> Use Demographics tool with analysis_type="devices_by_age" -- "What is the average age of our clients?" -> Use Demographics tool with analysis_type="funding_summary" -- "Demographics for [city]" -> Use Demographics tool with city_filter -- If a client is not found by profile, the system also searches by contact/partner name Response guidelines: -- ALWAYS keep tables to 3 columns maximum. Use key-value (2-column) tables for summaries. -- Split wide data into multiple narrow tables with headings between them - Be concise and data-driven -- Format monetary values with $ and commas (e.g., $1,250.00) -- When listing orders, show each order as its own key-value table section -- When showing billing summaries, use a key-value table +- Format monetary values with $ and commas - Include key identifiers (name, health card, city) when listing clients -- If asked about a specific client, use Client Status Lookup first (it searches by name) -- Always indicate if invoices are paid or unpaid +- Include order number, status, and amounts when discussing claims +- If asked about a specific client, search first, then get details +- Always provide the profile ID for record lookup Key terminology: -- ADP = Assistive Devices Program (Ontario government funding) -- Client Type REG = Regular (75% ADP / 25% Client split), ODS/OWP/ACS = 100% ADP funded -- Posting Period = 14-day ADP billing cycle; submission deadline is Wednesday 6 PM before posting day +- ADP = Assistive Devices Program (Ontario government) +- Client Type REG = Regular (75% ADP / 25% Client), ODS/OWP/ACS = 100% ADP - Sale Types: ADP, ODSP, WSIB, Insurance, March of Dimes, Muscular Dystrophy, Hardship Funding -- Sections: 2a = Walkers, 2b = Manual Wheelchairs, 2c = Power Bases/Scooters, 2d = Seating -- Previously Funded = devices the client has received ADP funding for before (affects eligibility) +- Sections: 2a = Walkers, 2b = Manual Wheelchairs, 2c = Power Bases/Scooters, 2d = Seating diff --git a/fusion_claims/data/device_codes/adp_mobility_manual.json b/fusion_claims/data/device_codes/adp_mobility_manual.json index be7ff25..042433d 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 6139945..1fd8dc1 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 100f86d..d46c59b 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,17 +127,5 @@ 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 8beb2ef..b0c835f 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() - 5 - 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 572b87d..e17efdf 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/data/product_labor_data.xml b/fusion_claims/data/product_labor_data.xml index 1af8108..1cb8bd8 100644 --- a/fusion_claims/data/product_labor_data.xml +++ b/fusion_claims/data/product_labor_data.xml @@ -9,7 +9,7 @@ - + diff --git a/fusion_claims/models/__init__.py b/fusion_claims/models/__init__.py index 89cae4c..8a8f7c2 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,5 @@ 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 page11_sign_request \ No newline at end of file diff --git a/fusion_claims/models/account_move.py b/fusion_claims/models/account_move.py index 3ed5517..48500ee 100644 --- a/fusion_claims/models/account_move.py +++ b/fusion_claims/models/account_move.py @@ -105,9 +105,11 @@ class AccountMove(models.Model): try: report = self.env.ref('fusion_claims.action_report_mod_invoice') pdf_content, _ = report._render_qweb_pdf(report.id, [self.id]) - client_name = (so.partner_id.name or 'Client').replace(' ', '_').replace(',', '') + name_parts = (so.partner_id.name or 'Client').strip().split() + first = name_parts[0] if name_parts else 'Client' + last = name_parts[-1] if len(name_parts) > 1 else '' att = Attachment.create({ - 'name': f'Invoice - {client_name} - {self.name}.pdf', + 'name': f'{first}_{last}_MOD_Invoice_{self.name}.pdf', 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'account.move', diff --git a/fusion_claims/models/ai_agent_ext.py b/fusion_claims/models/ai_agent_ext.py index e72f056..520c092 100644 --- a/fusion_claims/models/ai_agent_ext.py +++ b/fusion_claims/models/ai_agent_ext.py @@ -4,74 +4,16 @@ import json import logging -from datetime import date, timedelta from odoo import api, models _logger = logging.getLogger(__name__) -PREV_FUNDED_FIELDS = { - 'prev_funded_forearm': 'Forearm Crutches', - 'prev_funded_wheeled': 'Wheeled Walker', - 'prev_funded_manual': 'Manual Wheelchair', - 'prev_funded_power': 'Power Wheelchair', - 'prev_funded_addon': 'Power Add-On Device', - 'prev_funded_scooter': 'Power Scooter', - 'prev_funded_seating': 'Positioning Devices', - 'prev_funded_tilt': 'Power Tilt System', - 'prev_funded_recline': 'Power Recline System', - 'prev_funded_legrests': 'Power Elevating Leg Rests', - 'prev_funded_frame': 'Paediatric Standing Frame', - 'prev_funded_stroller': 'Paediatric Specialty Stroller', -} - -STATUS_NEXT_STEPS = { - 'quotation': 'Schedule assessment with the client', - 'assessment_scheduled': 'Complete the assessment', - 'assessment_completed': 'Prepare and send ADP application to client', - 'waiting_for_application': 'Follow up with client to return signed application', - 'application_received': 'Review application and prepare for submission', - 'ready_submission': 'Submit application to ADP', - 'submitted': 'Wait for ADP acceptance (typically within 24 hours)', - 'accepted': 'Wait for ADP approval decision', - 'rejected': 'Review rejection reason and correct the application', - 'resubmitted': 'Wait for ADP acceptance of resubmission', - 'needs_correction': 'Review and correct the application per ADP feedback', - 'approved': 'Prepare order for delivery', - 'approved_deduction': 'Prepare order for delivery (note: approved with deduction)', - 'ready_delivery': 'Schedule and complete delivery to client', - 'ready_bill': 'Create and submit ADP invoice', - 'billed': 'Monitor for ADP payment', - 'case_closed': 'No further action required', - 'on_hold': 'Check hold reason and follow up when ready to resume', - 'denied': 'Review denial reason; consider appeal or alternative funding', - 'withdrawn': 'No further action unless client wants to reinstate', - 'cancelled': 'No further action required', - 'expired': 'Contact client about reapplication if still needed', -} - -BASE_DEVICE_LABELS = { - 'adultWalkertype1': 'Adult Walker Type 1', - 'adultWalkertype2': 'Adult Walker Type 2', - 'adultWalkertype3': 'Adult Walker Type 3', - 'adultlightwtStdwheelchair': 'Lightweight Standard Wheelchair', - 'adultlightwtPermwheelchair': 'Lightweight Permanent Wheelchair', - 'adultTiltwheelchair': 'Adult Tilt Wheelchair', - 'adultStdwheelchair': 'Adult Standard Wheelchair', - 'adultHighperfwheelchair': 'Adult High Performance Wheelchair', - 'adultType2': 'Adult Power Type 2', - 'adultType3': 'Adult Power Type 3', - 'powerScooter': 'Power Scooter', -} - class AIAgentFusionClaims(models.Model): """Extend ai.agent with Fusion Claims tool methods.""" _inherit = 'ai.agent' - # ------------------------------------------------------------------ - # Tool 1: Search Client Profiles - # ------------------------------------------------------------------ def _fc_tool_search_clients(self, search_term=None, city_filter=None, condition_filter=None): """AI Tool: Search client profiles.""" Profile = self.env['fusion.client.profile'].sudo() @@ -104,37 +46,20 @@ class AIAgentFusionClaims(models.Model): }) return json.dumps({'count': len(results), 'profiles': results}) - # ------------------------------------------------------------------ - # Tool 2: Get Client Details (enriched with funding history) - # ------------------------------------------------------------------ def _fc_tool_client_details(self, profile_id): - """AI Tool: Get detailed client information with funding history.""" + """AI Tool: Get detailed client information.""" Profile = self.env['fusion.client.profile'].sudo() profile = Profile.browse(int(profile_id)) if not profile.exists(): return json.dumps({'error': 'Profile not found'}) + # Get orders orders = [] if profile.partner_id: - Invoice = self.env['account.move'].sudo() for o in self.env['sale.order'].sudo().search([ ('partner_id', '=', profile.partner_id.id), ('x_fc_sale_type', '!=', False), - ], limit=20, order='date_order desc'): - invoices = Invoice.search([ - ('x_fc_source_sale_order_id', '=', o.id), - ('move_type', '=', 'out_invoice'), - ]) - inv_summary = [] - for inv in invoices: - inv_summary.append({ - 'number': inv.name or '', - 'portion': inv.x_fc_adp_invoice_portion or 'full', - 'amount': float(inv.amount_total), - 'paid': inv.payment_state in ('paid', 'in_payment'), - 'date': str(inv.invoice_date) if inv.invoice_date else '', - }) - + ], limit=20): orders.append({ 'name': o.name, 'sale_type': o.x_fc_sale_type, @@ -143,19 +68,11 @@ class AIAgentFusionClaims(models.Model): 'client_total': float(o.x_fc_client_portion_total), 'total': float(o.amount_total), 'date': str(o.date_order.date()) if o.date_order else '', - 'billing_date': str(o.x_fc_billing_date) if o.x_fc_billing_date else '', - 'previous_funding_date': str(o.x_fc_previous_funding_date) if o.x_fc_previous_funding_date else '', - 'funding_warning': o.x_fc_funding_warning_message or '', - 'funding_warning_level': o.x_fc_funding_warning_level or '', - 'invoices': inv_summary, }) + # Get applications apps = [] for a in profile.application_data_ids[:10]: - funded_devices = [ - label for field, label in PREV_FUNDED_FIELDS.items() - if getattr(a, field, False) - ] apps.append({ 'date': str(a.application_date) if a.application_date else '', 'device': a.base_device or '', @@ -163,17 +80,8 @@ class AIAgentFusionClaims(models.Model): 'reason': a.reason_for_application or '', 'condition': (a.medical_condition or '')[:100], 'authorizer': f'{a.authorizer_first_name or ""} {a.authorizer_last_name or ""}'.strip(), - 'previously_funded': funded_devices if funded_devices else ['None'], }) - ai_summary = '' - ai_risk = '' - try: - ai_summary = profile.ai_summary or '' - ai_risk = profile.ai_risk_flags or '' - except Exception: - pass - return json.dumps({ 'profile': { 'id': profile.id, @@ -198,18 +106,12 @@ class AIAgentFusionClaims(models.Model): 'total_adp': float(profile.total_adp_funded), 'total_client': float(profile.total_client_portion), 'total_amount': float(profile.total_amount), - 'applications_count': profile.application_count, 'last_assessment': str(profile.last_assessment_date) if profile.last_assessment_date else '', - 'ai_summary': ai_summary, - 'ai_risk_flags': ai_risk, }, 'orders': orders, 'applications': apps, }) - # ------------------------------------------------------------------ - # Tool 3: Get Aggregated Stats (migrated from read_group) - # ------------------------------------------------------------------ def _fc_tool_claims_stats(self): """AI Tool: Get aggregated claims statistics.""" SO = self.env['sale.order'].sudo() @@ -218,46 +120,40 @@ class AIAgentFusionClaims(models.Model): total_profiles = Profile.search_count([]) total_orders = SO.search_count([('x_fc_sale_type', '!=', False)]) + # By sale type + type_data = SO.read_group( + [('x_fc_sale_type', '!=', False)], + ['x_fc_sale_type', 'amount_total:sum'], + ['x_fc_sale_type'], + ) by_type = {} - try: - type_results = SO._read_group( - [('x_fc_sale_type', '!=', False)], - groupby=['x_fc_sale_type'], - aggregates=['__count', 'amount_total:sum'], - ) - for sale_type, count, total_amount in type_results: - by_type[sale_type or 'unknown'] = { - 'count': count, - 'total': float(total_amount or 0), - } - except Exception as e: - _logger.warning('Stats by_type failed: %s', e) + for r in type_data: + by_type[r['x_fc_sale_type']] = { + 'count': r['x_fc_sale_type_count'], + 'total': float(r['amount_total'] or 0), + } + # By status + status_data = SO.read_group( + [('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)], + ['x_fc_adp_application_status'], + ['x_fc_adp_application_status'], + ) by_status = {} - try: - status_results = SO._read_group( - [('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)], - groupby=['x_fc_adp_application_status'], - aggregates=['__count'], - ) - for status, count in status_results: - by_status[status or 'unknown'] = count - except Exception as e: - _logger.warning('Stats by_status failed: %s', e) + for r in status_data: + by_status[r['x_fc_adp_application_status']] = r['x_fc_adp_application_status_count'] + # By city (top 10) + city_data = Profile.read_group( + [('city', '!=', False)], + ['city'], + ['city'], + limit=10, + orderby='city_count desc', + ) by_city = {} - try: - city_results = Profile._read_group( - [('city', '!=', False)], - groupby=['city'], - aggregates=['__count'], - limit=10, - order='__count desc', - ) - for city, count in city_results: - by_city[city or 'unknown'] = count - except Exception as e: - _logger.warning('Stats by_city failed: %s', e) + for r in city_data: + by_city[r['city']] = r['city_count'] return json.dumps({ 'total_profiles': total_profiles, @@ -266,405 +162,3 @@ class AIAgentFusionClaims(models.Model): 'by_status': by_status, 'top_cities': by_city, }) - - # ------------------------------------------------------------------ - # Tool 4: Client Status Lookup (by name, not order number) - # ------------------------------------------------------------------ - def _fc_tool_client_status(self, client_name): - """AI Tool: Look up a client's complete status by name.""" - if not client_name or not client_name.strip(): - return json.dumps({'error': 'Please provide a client name to search for'}) - - client_name = client_name.strip() - Profile = self.env['fusion.client.profile'].sudo() - SO = self.env['sale.order'].sudo() - Invoice = self.env['account.move'].sudo() - - profiles = Profile.search([ - '|', - ('first_name', 'ilike', client_name), - ('last_name', 'ilike', client_name), - ], limit=5) - - if not profiles: - partners = self.env['res.partner'].sudo().search([ - ('name', 'ilike', client_name), - ], limit=5) - if not partners: - return json.dumps({'error': f'No client found matching "{client_name}"'}) - - results = [] - for partner in partners: - orders = SO.search([ - ('partner_id', '=', partner.id), - ('x_fc_sale_type', '!=', False), - ], order='date_order desc', limit=20) - if not orders: - continue - results.append(self._build_client_status_result( - partner_name=partner.name, - partner_id=partner.id, - profile=None, - orders=orders, - Invoice=Invoice, - )) - if not results: - return json.dumps({'error': f'No orders found for "{client_name}"'}) - return json.dumps({'clients': results}) - - results = [] - for profile in profiles: - orders = SO.search([ - ('partner_id', '=', profile.partner_id.id), - ('x_fc_sale_type', '!=', False), - ], order='date_order desc', limit=20) if profile.partner_id else SO - results.append(self._build_client_status_result( - partner_name=profile.display_name, - partner_id=profile.partner_id.id if profile.partner_id else None, - profile=profile, - orders=orders if profile.partner_id else SO.browse(), - Invoice=Invoice, - )) - - return json.dumps({'clients': results}) - - def _build_client_status_result(self, partner_name, partner_id, profile, orders, Invoice): - """Build a complete status result for one client.""" - order_data = [] - for o in orders: - invoices = Invoice.search([ - ('x_fc_source_sale_order_id', '=', o.id), - ('move_type', '=', 'out_invoice'), - ]) - adp_inv = invoices.filtered(lambda i: i.x_fc_adp_invoice_portion == 'adp') - client_inv = invoices.filtered(lambda i: i.x_fc_adp_invoice_portion == 'client') - - docs = { - 'original_application': bool(o.x_fc_original_application), - 'signed_pages': bool(o.x_fc_signed_pages_11_12), - 'xml_file': bool(o.x_fc_xml_file), - 'proof_of_delivery': bool(o.x_fc_proof_of_delivery), - } - - status = o.x_fc_adp_application_status or '' - next_step = STATUS_NEXT_STEPS.get(status, '') - - order_data.append({ - 'order': o.name, - 'sale_type': o.x_fc_sale_type, - 'status': status, - 'date': str(o.date_order.date()) if o.date_order else '', - 'total': float(o.amount_total), - 'adp_total': float(o.x_fc_adp_portion_total), - 'client_total': float(o.x_fc_client_portion_total), - 'billing_date': str(o.x_fc_billing_date) if o.x_fc_billing_date else '', - 'funding_warning': o.x_fc_funding_warning_message or '', - 'adp_invoice': { - 'number': adp_inv[0].name if adp_inv else '', - 'amount': float(adp_inv[0].amount_total) if adp_inv else 0, - 'paid': adp_inv[0].payment_state in ('paid', 'in_payment') if adp_inv else False, - } if adp_inv else None, - 'client_invoice': { - 'number': client_inv[0].name if client_inv else '', - 'amount': float(client_inv[0].amount_total) if client_inv else 0, - 'paid': client_inv[0].payment_state in ('paid', 'in_payment') if client_inv else False, - } if client_inv else None, - 'documents': docs, - 'next_step': next_step, - }) - - result = { - 'name': partner_name, - 'partner_id': partner_id, - 'orders': order_data, - 'order_count': len(order_data), - } - - if profile: - result['profile_id'] = profile.id - result['health_card'] = profile.health_card_number or '' - result['city'] = profile.city or '' - result['condition'] = (profile.medical_condition or '')[:100] - result['total_adp_funded'] = float(profile.total_adp_funded) - result['total_client_funded'] = float(profile.total_client_portion) - - return result - - # ------------------------------------------------------------------ - # Tool 5: ADP Billing Period Summary - # ------------------------------------------------------------------ - def _fc_tool_adp_billing_period(self, period=None): - """AI Tool: Get ADP billing summary for a posting period.""" - Mixin = self.env['fusion_claims.adp.posting.schedule.mixin'] - Invoice = self.env['account.move'].sudo() - - today = date.today() - frequency = Mixin._get_adp_posting_frequency() - - if not period or period == 'current': - posting_date = Mixin._get_current_posting_date(today) - elif period == 'previous': - current = Mixin._get_current_posting_date(today) - posting_date = current - timedelta(days=frequency) - elif period == 'next': - posting_date = Mixin._get_next_posting_date(today) - else: - try: - ref_date = date.fromisoformat(period) - posting_date = Mixin._get_current_posting_date(ref_date) - except (ValueError, TypeError): - return json.dumps({'error': f'Invalid period: "{period}". Use "current", "previous", "next", or a date (YYYY-MM-DD).'}) - - period_start = posting_date - period_end = posting_date + timedelta(days=frequency - 1) - submission_deadline = Mixin._get_posting_week_wednesday(posting_date) - expected_payment = Mixin._get_expected_payment_date(posting_date) - - adp_invoices = Invoice.search([ - ('x_fc_adp_invoice_portion', '=', 'adp'), - ('move_type', '=', 'out_invoice'), - ('invoice_date', '>=', str(period_start)), - ('invoice_date', '<=', str(period_end)), - ]) - - total_invoiced = sum(adp_invoices.mapped('amount_total')) - total_paid = sum(adp_invoices.filtered( - lambda i: i.payment_state in ('paid', 'in_payment') - ).mapped('amount_total')) - total_unpaid = total_invoiced - total_paid - - source_orders = adp_invoices.mapped('x_fc_source_sale_order_id') - - invoice_details = [] - for inv in adp_invoices[:25]: - so = inv.x_fc_source_sale_order_id - invoice_details.append({ - 'invoice': inv.name or '', - 'order': so.name if so else '', - 'client': inv.partner_id.name or '', - 'amount': float(inv.amount_total), - 'paid': inv.payment_state in ('paid', 'in_payment'), - 'date': str(inv.invoice_date) if inv.invoice_date else '', - }) - - return json.dumps({ - 'period': { - 'posting_date': str(posting_date), - 'start': str(period_start), - 'end': str(period_end), - 'submission_deadline': f'{submission_deadline.strftime("%A, %B %d, %Y")} 6:00 PM', - 'expected_payment_date': str(expected_payment), - }, - 'summary': { - 'total_invoices': len(adp_invoices), - 'total_invoiced': float(total_invoiced), - 'total_paid': float(total_paid), - 'total_unpaid': float(total_unpaid), - 'orders_billed': len(source_orders), - }, - 'invoices': invoice_details, - }) - - # ------------------------------------------------------------------ - # Tool 6: Demographics & Analytics - # ------------------------------------------------------------------ - def _fc_tool_demographics(self, analysis_type=None, city_filter=None, sale_type_filter=None): - """AI Tool: Run demographic and analytical queries on client data.""" - cr = self.env.cr - results = {} - - if not analysis_type: - analysis_type = 'full' - - if analysis_type in ('full', 'age_groups'): - cr.execute(""" - SELECT - CASE - WHEN age < 18 THEN 'Under 18' - WHEN age BETWEEN 18 AND 30 THEN '18-30' - WHEN age BETWEEN 31 AND 45 THEN '31-45' - WHEN age BETWEEN 46 AND 60 THEN '46-60' - WHEN age BETWEEN 61 AND 75 THEN '61-75' - ELSE '75+' - END AS age_group, - COUNT(DISTINCT p.id) AS clients, - COUNT(app.id) AS applications, - ROUND(COUNT(app.id)::numeric / NULLIF(COUNT(DISTINCT p.id), 0), 2) AS avg_applications, - COALESCE(ROUND(SUM(p.total_adp_funded) / NULLIF(COUNT(DISTINCT p.id), 0), 2), 0) AS avg_adp_funded, - COALESCE(ROUND(SUM(p.total_amount) / NULLIF(COUNT(DISTINCT p.id), 0), 2), 0) AS avg_total - FROM fusion_client_profile p - CROSS JOIN LATERAL ( - SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age - ) a - LEFT JOIN fusion_adp_application_data app ON app.profile_id = p.id - WHERE p.date_of_birth IS NOT NULL - GROUP BY age_group - ORDER BY MIN(age) - """) - rows = cr.fetchall() - results['age_groups'] = [ - { - 'age_group': r[0], 'clients': r[1], 'applications': r[2], - 'avg_applications': float(r[3]), 'avg_adp_funded': float(r[4]), - 'avg_total': float(r[5]), - } for r in rows - ] - - if analysis_type in ('full', 'devices_by_age'): - cr.execute(""" - SELECT - CASE - WHEN age < 45 THEN 'Under 45' - WHEN age BETWEEN 45 AND 60 THEN '45-60' - WHEN age BETWEEN 61 AND 75 THEN '61-75' - ELSE '75+' - END AS age_group, - app.base_device, - COUNT(*) AS count - FROM fusion_client_profile p - CROSS JOIN LATERAL ( - SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age - ) a - JOIN fusion_adp_application_data app ON app.profile_id = p.id - WHERE p.date_of_birth IS NOT NULL - AND app.base_device IS NOT NULL AND app.base_device != '' - GROUP BY age_group, app.base_device - ORDER BY age_group, count DESC - """) - rows = cr.fetchall() - devices_by_age = {} - for age_group, device, count in rows: - if age_group not in devices_by_age: - devices_by_age[age_group] = [] - label = BASE_DEVICE_LABELS.get(device, device) - devices_by_age[age_group].append({'device': label, 'count': count}) - results['devices_by_age'] = devices_by_age - - if analysis_type in ('full', 'city_demographics'): - city_clause = "" - params = [] - if city_filter: - city_clause = "AND LOWER(p.city) = LOWER(%s)" - params = [city_filter] - - cr.execute(f""" - SELECT - p.city, - COUNT(DISTINCT p.id) AS clients, - COUNT(app.id) AS applications, - ROUND(AVG(a.age), 1) AS avg_age, - COALESCE(ROUND(SUM(p.total_adp_funded) / NULLIF(COUNT(DISTINCT p.id), 0), 2), 0) AS avg_adp_funded - FROM fusion_client_profile p - CROSS JOIN LATERAL ( - SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age - ) a - LEFT JOIN fusion_adp_application_data app ON app.profile_id = p.id - WHERE p.city IS NOT NULL AND p.city != '' - AND p.date_of_birth IS NOT NULL - {city_clause} - GROUP BY p.city - ORDER BY clients DESC - LIMIT 15 - """, params) - rows = cr.fetchall() - results['city_demographics'] = [ - { - 'city': r[0], 'clients': r[1], 'applications': r[2], - 'avg_age': float(r[3]), 'avg_adp_funded': float(r[4]), - } for r in rows - ] - - if analysis_type in ('full', 'benefits'): - cr.execute(""" - SELECT - CASE - WHEN p.benefit_type = 'odsp' THEN 'ODSP' - WHEN p.benefit_type = 'owp' THEN 'Ontario Works' - WHEN p.benefit_type = 'acsd' THEN 'ACSD' - WHEN p.receives_social_assistance THEN 'Social Assistance (other)' - ELSE 'Regular (no assistance)' - END AS benefit_category, - COUNT(DISTINCT p.id) AS clients, - COUNT(app.id) AS applications, - ROUND(AVG(a.age), 1) AS avg_age, - COALESCE(ROUND(AVG(p.total_adp_funded), 2), 0) AS avg_adp_funded - FROM fusion_client_profile p - CROSS JOIN LATERAL ( - SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age - ) a - LEFT JOIN fusion_adp_application_data app ON app.profile_id = p.id - WHERE p.date_of_birth IS NOT NULL - GROUP BY benefit_category - ORDER BY clients DESC - """) - rows = cr.fetchall() - results['benefits_breakdown'] = [ - { - 'category': r[0], 'clients': r[1], 'applications': r[2], - 'avg_age': float(r[3]), 'avg_adp_funded': float(r[4]), - } for r in rows - ] - - if analysis_type in ('full', 'top_devices'): - cr.execute(""" - SELECT - app.base_device, - COUNT(*) AS count, - COUNT(DISTINCT app.profile_id) AS unique_clients, - ROUND(AVG(a.age), 1) AS avg_age - FROM fusion_adp_application_data app - JOIN fusion_client_profile p ON p.id = app.profile_id - CROSS JOIN LATERAL ( - SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age - ) a - WHERE app.base_device IS NOT NULL AND app.base_device != '' AND app.base_device != 'none' - AND p.date_of_birth IS NOT NULL - GROUP BY app.base_device - ORDER BY count DESC - LIMIT 15 - """) - rows = cr.fetchall() - results['top_devices'] = [ - { - 'device': BASE_DEVICE_LABELS.get(r[0], r[0]), - 'device_code': r[0], - 'applications': r[1], - 'unique_clients': r[2], - 'avg_client_age': float(r[3]), - } for r in rows - ] - - if analysis_type in ('full', 'funding_summary'): - cr.execute(""" - SELECT - COUNT(*) AS total_profiles, - ROUND(AVG(a.age), 1) AS avg_age, - ROUND(SUM(p.total_adp_funded), 2) AS total_adp_funded, - ROUND(SUM(p.total_client_portion), 2) AS total_client_portion, - ROUND(SUM(p.total_amount), 2) AS grand_total, - ROUND(AVG(p.total_adp_funded), 2) AS avg_adp_per_client, - ROUND(AVG(p.total_client_portion), 2) AS avg_client_per_client, - ROUND(AVG(p.claim_count), 2) AS avg_claims_per_client, - MIN(a.age) AS youngest, - MAX(a.age) AS oldest - FROM fusion_client_profile p - CROSS JOIN LATERAL ( - SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age - ) a - WHERE p.date_of_birth IS NOT NULL - """) - r = cr.fetchone() - results['funding_summary'] = { - 'total_profiles': r[0], - 'avg_age': float(r[1]), - 'total_adp_funded': float(r[2]), - 'total_client_portion': float(r[3]), - 'grand_total': float(r[4]), - 'avg_adp_per_client': float(r[5]), - 'avg_client_per_client': float(r[6]), - 'avg_claims_per_client': float(r[7]), - 'youngest_client_age': r[8], - 'oldest_client_age': r[9], - } - - return json.dumps(results) diff --git a/fusion_claims/models/email_builder_mixin.py b/fusion_claims/models/email_builder_mixin.py deleted file mode 100644 index 9fa3023..0000000 --- a/fusion_claims/models/email_builder_mixin.py +++ /dev/null @@ -1,242 +0,0 @@ -# -*- coding: utf-8 -*- -# Fusion Claims - Professional Email Builder Mixin -# Provides consistent, dark/light mode safe email templates across all modules. - -from odoo import models - - -class FusionEmailBuilderMixin(models.AbstractModel): - _name = 'fusion.email.builder.mixin' - _description = 'Fusion Email Builder Mixin' - - # ------------------------------------------------------------------ - # Color constants - # ------------------------------------------------------------------ - _EMAIL_COLORS = { - 'info': '#2B6CB0', - 'success': '#38a169', - 'attention': '#d69e2e', - 'urgent': '#c53030', - } - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def _email_build( - self, - title, - summary, - sections=None, - note=None, - note_color=None, - email_type='info', - attachments_note=None, - button_url=None, - button_text='View Case Details', - sender_name=None, - extra_html='', - ): - """Build a complete professional email HTML string. - - Args: - title: Email heading (e.g. "Application Approved") - summary: One-sentence summary HTML (may contain tags) - sections: list of (heading, rows) where rows is list of (label, value) - e.g. [('Case Details', [('Client', 'John'), ('Case', 'S30073')])] - note: Optional note/next-steps text (plain or HTML) - note_color: Override left-border color for note (default uses email_type) - email_type: 'info' | 'success' | 'attention' | 'urgent' - attachments_note: Optional string listing attached files - button_url: Optional CTA button URL - button_text: CTA button label - sender_name: Name for sign-off (defaults to current user) - extra_html: Any additional HTML to insert before sign-off - """ - accent = self._EMAIL_COLORS.get(email_type, self._EMAIL_COLORS['info']) - company = self._get_company_info() - - parts = [] - # -- Wrapper open + accent bar - parts.append( - f'
' - f'
' - f'
' - ) - - # -- Company name - parts.append( - f'

{company["name"]}

' - ) - - # -- Title - parts.append( - f'

{title}

' - ) - - # -- Summary - parts.append( - f'

{summary}

' - ) - - # -- Sections (details tables) - if sections: - for heading, rows in sections: - parts.append(self._email_section(heading, rows)) - - # -- Note / Next Steps - if note: - nc = note_color or accent - parts.append(self._email_note(note, nc)) - - # -- Extra HTML - if extra_html: - parts.append(extra_html) - - # -- Attachment note - if attachments_note: - parts.append(self._email_attachment_note(attachments_note)) - - # -- CTA Button - if button_url: - parts.append(self._email_button(button_url, button_text, accent)) - - # -- Sign-off - signer = sender_name or (self.env.user.name if self.env.user else '') - parts.append( - f'

' - f'Best regards,
' - f'{signer}
' - f'{company["name"]}

' - ) - - # -- Close content card - parts.append('
') - - # -- Footer - footer_parts = [company['name']] - if company['phone']: - footer_parts.append(company['phone']) - if company['email']: - footer_parts.append(company['email']) - footer_text = ' · '.join(footer_parts) - - parts.append( - f'
' - f'

' - f'{footer_text}
' - f'This is an automated notification from the ADP Claims Management System.

' - f'
' - ) - - # -- Close wrapper - parts.append('
') - - return ''.join(parts) - - # ------------------------------------------------------------------ - # Building blocks - # ------------------------------------------------------------------ - - def _email_section(self, heading, rows): - """Build a labeled details table section. - - Args: - heading: Section title (e.g. "Case Details") - rows: list of (label, value) tuples. Value can be plain text or HTML. - """ - if not rows: - return '' - - html = ( - '' - f'' - ) - - for label, value in rows: - if value is None or value == '' or value is False: - continue - html += ( - f'' - f'' - f'' - f'' - ) - - html += '
{heading}
{label}{value}
' - return html - - def _email_note(self, text, color='#2B6CB0'): - """Build a left-border accent note block.""" - return ( - f'
' - f'

{text}

' - f'
' - ) - - def _email_button(self, url, text='View Case Details', color='#2B6CB0'): - """Build a centered CTA button.""" - return ( - f'

' - f'{text}

' - ) - - def _email_attachment_note(self, description): - """Build a dashed-border attachment callout. - - Args: - description: e.g. "ADP Application (PDF), XML Data File" - """ - return ( - f'
' - f'

' - f'Attached: {description}

' - f'
' - ) - - def _email_status_badge(self, label, color='#2B6CB0'): - """Return an inline status badge/pill HTML snippet.""" - # Pick a light background tint for the badge - bg_map = { - '#38a169': '#f0fff4', - '#2B6CB0': '#ebf4ff', - '#d69e2e': '#fefcbf', - '#c53030': '#fff5f5', - } - bg = bg_map.get(color, '#ebf4ff') - return ( - f'' - f'{label}' - ) - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - def _get_company_info(self): - """Return company name, phone, email for email templates.""" - company = getattr(self, 'company_id', None) or self.env.company - return { - 'name': company.name or 'Our Company', - 'phone': company.phone or '', - 'email': company.email or '', - } - - def _email_is_enabled(self): - """Check if email notifications are enabled in settings.""" - ICP = self.env['ir.config_parameter'].sudo() - val = ICP.get_param('fusion_claims.enable_email_notifications', 'True') - return val.lower() in ('true', '1', 'yes') diff --git a/fusion_claims/models/fusion_adp_device_code.py b/fusion_claims/models/fusion_adp_device_code.py index 0a6c201..0f67f9b 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 0000000..dcf97ae --- /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/push_subscription.py b/fusion_claims/models/push_subscription.py deleted file mode 100644 index 19f9033..0000000 --- a/fusion_claims/models/push_subscription.py +++ /dev/null @@ -1,73 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2024-2026 Nexa Systems Inc. -# License OPL-1 (Odoo Proprietary License v1.0) - -""" -Web Push Subscription model for storing browser push notification subscriptions. -""" - -from odoo import models, fields, api -import logging - -_logger = logging.getLogger(__name__) - - -class FusionPushSubscription(models.Model): - _name = 'fusion.push.subscription' - _description = 'Web Push Subscription' - _order = 'create_date desc' - - user_id = fields.Many2one( - 'res.users', - string='User', - required=True, - ondelete='cascade', - index=True, - ) - endpoint = fields.Text( - string='Endpoint URL', - required=True, - ) - p256dh_key = fields.Text( - string='P256DH Key', - required=True, - ) - auth_key = fields.Text( - string='Auth Key', - required=True, - ) - browser_info = fields.Char( - string='Browser Info', - help='User agent or browser identification', - ) - active = fields.Boolean( - default=True, - ) - - _constraints = [ - models.Constraint( - 'unique(endpoint)', - 'This push subscription endpoint already exists.', - ), - ] - - @api.model - def register_subscription(self, user_id, endpoint, p256dh_key, auth_key, browser_info=None): - """Register or update a push subscription.""" - existing = self.sudo().search([('endpoint', '=', endpoint)], limit=1) - if existing: - existing.write({ - 'user_id': user_id, - 'p256dh_key': p256dh_key, - 'auth_key': auth_key, - 'browser_info': browser_info or existing.browser_info, - 'active': True, - }) - return existing - return self.sudo().create({ - 'user_id': user_id, - 'endpoint': endpoint, - 'p256dh_key': p256dh_key, - 'auth_key': auth_key, - 'browser_info': browser_info, - }) diff --git a/fusion_claims/models/res_config_settings.py b/fusion_claims/models/res_config_settings.py index 32013fd..83e65e5 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 # ------------------------------------------------------------------ @@ -477,16 +411,6 @@ class ResConfigSettings(models.TransientModel): help='Default ODSP office contact for new ODSP cases', ) - # ========================================================================= - # PORTAL FORMS - # ========================================================================= - - fc_ltc_form_password = fields.Char( - string='LTC Form Access Password', - config_parameter='fusion_claims.ltc_form_password', - help='Minimum 4 characters. Share with facility staff to access the repair form.', - ) - # ========================================================================= # PORTAL BRANDING # ========================================================================= @@ -609,15 +533,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 = {} @@ -656,13 +576,6 @@ class ResConfigSettings(models.TransientModel): # Office notification recipients are stored via related field on res.company # No need to store in ir.config_parameter - # Validate LTC form password length - form_pw = self.fc_ltc_form_password or '' - if form_pw and len(form_pw.strip()) < 4: - raise ValidationError( - 'LTC Form Access Password must be at least 4 characters.' - ) - # Store designated vendor signer (Many2one - manual handling) if self.fc_designated_vendor_signer: ICP.set_param('fusion_claims.designated_vendor_signer', diff --git a/fusion_claims/models/res_partner.py b/fusion_claims/models/res_partner.py index efa2d5c..9c60e4a 100644 --- a/fusion_claims/models/res_partner.py +++ b/fusion_claims/models/res_partner.py @@ -8,13 +8,6 @@ from odoo import models, fields, api 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.', - ) - # ========================================================================== # CONTACT TYPE # ========================================================================== @@ -76,25 +69,6 @@ class ResPartner(models.Model): store=True, ) - # ========================================================================== - # LTC FIELDS - # ========================================================================== - x_fc_ltc_facility_id = fields.Many2one( - 'fusion.ltc.facility', - string='LTC Home', - tracking=True, - help='Long-Term Care Home this resident belongs to', - ) - x_fc_ltc_room_number = fields.Char( - string='Room Number', - tracking=True, - ) - x_fc_ltc_family_contact_ids = fields.One2many( - 'fusion.ltc.family.contact', - 'partner_id', - string='Family Contacts', - ) - @api.depends('x_fc_contact_type') def _compute_is_odsp_office(self): for partner in self: diff --git a/fusion_claims/models/res_users.py b/fusion_claims/models/res_users.py deleted file mode 100644 index f17757b..0000000 --- a/fusion_claims/models/res_users.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2024-2026 Nexa Systems Inc. -# License OPL-1 (Odoo Proprietary License v1.0) - -from odoo import models, fields, api - - -class ResUsers(models.Model): - _inherit = 'res.users' - - x_fc_is_field_staff = fields.Boolean( - string='Field Staff', - default=False, - help='Check this to show the user in the Technician/Field Staff dropdown when scheduling tasks.', - ) - x_fc_start_address = fields.Char( - related='partner_id.x_fc_start_address', - readonly=False, - string='Start Location', - ) - x_fc_tech_sync_id = fields.Char( - string='Tech Sync ID', - help='Shared identifier for this technician across Odoo instances. ' - 'Must be the same value on all instances for the same person.', - copy=False, - ) \ No newline at end of file diff --git a/fusion_claims/models/sale_order.py b/fusion_claims/models/sale_order.py index 85dc5ee..3532584 100644 --- a/fusion_claims/models/sale_order.py +++ b/fusion_claims/models/sale_order.py @@ -15,7 +15,9 @@ _logger = logging.getLogger(__name__) class SaleOrder(models.Model): _name = 'sale.order' _inherit = ['sale.order', 'fusion_claims.adp.posting.schedule.mixin', 'fusion.email.builder.mixin'] - _rec_names_search = ['name', 'partner_id.name'] + @property + def _rec_names_search(self): + return ['name', 'partner_id.name'] @api.depends('name', 'partner_id.name') def _compute_display_name(self): @@ -35,22 +37,6 @@ class SaleOrder(models.Model): help='True only for ADP or ADP/ODSP sale types', ) - # ========================================================================== - # LTC REPAIR LINK - # ========================================================================== - x_fc_ltc_repair_id = fields.Many2one( - 'fusion.ltc.repair', - string='LTC Repair', - tracking=True, - ondelete='set null', - index=True, - ) - x_fc_is_ltc_repair_sale = fields.Boolean( - compute='_compute_is_ltc_repair_sale', - store=True, - string='Is LTC Repair Sale', - ) - # ========================================================================== # INVOICE COUNT FIELDS (Separate ADP and Client invoices) # ========================================================================== @@ -418,11 +404,6 @@ class SaleOrder(models.Model): for order in self: order.x_fc_is_adp_sale = order._is_adp_sale() - @api.depends('x_fc_ltc_repair_id') - def _compute_is_ltc_repair_sale(self): - for order in self: - order.x_fc_is_ltc_repair_sale = bool(order.x_fc_ltc_repair_id) - # ========================================================================== # SALE TYPE AND CLIENT TYPE FIELDS # ========================================================================== @@ -1836,8 +1817,7 @@ class SaleOrder(models.Model): domain, groupby=groupby, aggregates=aggregates, having=having, offset=offset, limit=limit, order=order, ) - groupby_list = list(groupby) if not isinstance(groupby, (list, tuple)) else groupby - if groupby_list and groupby_list[0] == 'x_fc_adp_application_status': + if groupby and groupby[0] == 'x_fc_adp_application_status': status_order = self._STATUS_ORDER result = sorted(result, key=lambda r: status_order.get(r[0], 999)) return result @@ -4723,48 +4703,6 @@ class SaleOrder(models.Model): return invoice - - # ========================================================================== - # PORTAL PAYMENT AMOUNT (ADP Client Portion) - # ========================================================================== - def _get_prepayment_required_amount(self): - """Override to return client portion for ADP orders. - - For ADP REG clients, the customer should only prepay their 25% - portion, not the full order amount that includes ADP's 75%. - """ - self.ensure_one() - if self._is_adp_sale() and self.x_fc_client_type == 'REG': - client_portion = self.x_fc_client_portion_total or 0 - if client_portion > 0: - return self.currency_id.round(client_portion * self.prepayment_percent) - return super()._get_prepayment_required_amount() - - def _has_to_be_paid(self): - """Override to use client portion for ADP payment threshold check. - - Standard Odoo checks amount_total > 0. For ADP orders where - the client type is not REG (100% ADP funded), the customer - has nothing to pay and the quotation should auto-confirm. - """ - self.ensure_one() - if self._is_adp_sale(): - client_type = self.x_fc_client_type or '' - if client_type and client_type != 'REG': - return False - if client_type == 'REG': - client_portion = self.x_fc_client_portion_total or 0 - if client_portion <= 0: - return False - return ( - self.state in ['draft', 'sent'] - and not self.is_expired - and self.require_payment - and client_portion > 0 - and not self._is_confirmation_amount_reached() - ) - return super()._has_to_be_paid() - # ========================================================================== # OVERRIDE _get_invoiceable_lines TO INCLUDE ALL SECTIONS AND NOTES # ========================================================================== diff --git a/fusion_claims/models/sale_order_line.py b/fusion_claims/models/sale_order_line.py index c7118ef..5bc61a8 100644 --- a/fusion_claims/models/sale_order_line.py +++ b/fusion_claims/models/sale_order_line.py @@ -29,10 +29,11 @@ class SaleOrderLine(models.Model): @api.depends('product_id', 'product_id.default_code') def _compute_adp_device_type(self): - """Compute ADP device type from the product's device code.""" + """Compute ADP device type and build type from the product's device code.""" ADPDevice = self.env['fusion.adp.device.code'].sudo() for line in self: device_type = '' + build_type = False if line.product_id: # Get the device code from product (default_code or custom field) device_code = line._get_adp_device_code() @@ -44,7 +45,9 @@ class SaleOrderLine(models.Model): ], limit=1) if adp_device: device_type = adp_device.device_type or '' + build_type = adp_device.build_type or False line.x_fc_adp_device_type = device_type + line.x_fc_adp_build_type = build_type # ========================================================================== # SERIAL NUMBER AND DEVICE PLACEMENT @@ -110,6 +113,16 @@ class SaleOrderLine(models.Model): store=True, help='Device type from ADP mobility manual (for approval matching)', ) + x_fc_adp_build_type = fields.Selection( + selection=[ + ('modular', 'Modular'), + ('custom_fabricated', 'Custom Fabricated'), + ], + string='Build Type', + compute='_compute_adp_device_type', + store=True, + help='Build type from ADP mobility manual (Modular or Custom Fabricated)', + ) # ========================================================================== # COMPUTED ADP PORTIONS @@ -306,6 +319,49 @@ class SaleOrderLine(models.Model): # 5. Final fallback - return default_code even if not in ADP database return self.product_id.default_code or '' + def _get_adp_code_for_report(self): + """Return the ADP device code for display on reports. + + Uses the product's x_fc_adp_device_code field (not default_code). + Returns 'NON-FUNDED' for non-ADP products. + """ + self.ensure_one() + if not self.product_id: + return 'NON-FUNDED' + if self.product_id.is_non_adp_funded(): + return 'NON-FUNDED' + product_tmpl = self.product_id.product_tmpl_id + code = '' + if hasattr(product_tmpl, 'x_fc_adp_device_code'): + code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or '' + if not code and hasattr(product_tmpl, 'x_adp_code'): + code = getattr(product_tmpl, 'x_adp_code', '') or '' + if not code: + return 'NON-FUNDED' + ADPDevice = self.env['fusion.adp.device.code'].sudo() + if ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0: + return code + return 'NON-FUNDED' + + def _get_adp_device_type(self): + """Live lookup of device type from the ADP device code table. + + Returns 'No Funding Available' for non-ADP products. + """ + self.ensure_one() + if not self.product_id or self.product_id.is_non_adp_funded(): + return 'No Funding Available' + code = self._get_adp_code_for_report() + if code == 'NON-FUNDED': + return 'No Funding Available' + if self.x_fc_adp_device_type: + return self.x_fc_adp_device_type + adp_device = self.env['fusion.adp.device.code'].sudo().search([ + ('device_code', '=', code), + ('active', '=', True), + ], limit=1) + return adp_device.device_type if adp_device else 'No Funding Available' + def _get_serial_number(self): """Get serial number from mapped field or native field.""" self.ensure_one() diff --git a/fusion_claims/models/task_sync.py b/fusion_claims/models/task_sync.py deleted file mode 100644 index e985119..0000000 --- a/fusion_claims/models/task_sync.py +++ /dev/null @@ -1,438 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2024-2026 Nexa Systems Inc. -# License OPL-1 (Odoo Proprietary License v1.0) - -""" -Cross-instance technician task sync. - -Enables two Odoo instances (e.g. Westin and Mobility) that share the same -field technicians to see each other's delivery tasks, preventing double-booking. - -Remote tasks appear as read-only "shadow" records in the local calendar. -The existing _find_next_available_slot() automatically sees shadow tasks, -so collision detection works without changes to the scheduling algorithm. - -Technicians are matched across instances using the x_fc_tech_sync_id field -on res.users. Set the same value (e.g. "gordy") on both instances for the -same person -- no mapping table needed. -""" - -from odoo import models, fields, api, _ -from odoo.exceptions import UserError -import logging -import requests -from datetime import timedelta - -_logger = logging.getLogger(__name__) - -SYNC_TASK_FIELDS = [ - 'x_fc_sync_uuid', 'name', 'technician_id', 'additional_technician_ids', - 'task_type', 'status', - 'scheduled_date', 'time_start', 'time_end', 'duration_hours', - 'address_street', 'address_street2', 'address_city', 'address_zip', - 'address_lat', 'address_lng', 'priority', 'partner_id', -] - - -class FusionTaskSyncConfig(models.Model): - _name = 'fusion.task.sync.config' - _description = 'Task Sync Remote Instance' - - name = fields.Char('Instance Name', required=True, - help='e.g. Westin Healthcare, Mobility Specialties') - instance_id = fields.Char('Instance ID', required=True, - help='Short identifier, e.g. westin or mobility') - url = fields.Char('Odoo URL', required=True, - help='e.g. http://192.168.1.40:8069') - database = fields.Char('Database', required=True) - username = fields.Char('API Username', required=True) - api_key = fields.Char('API Key', required=True) - active = fields.Boolean(default=True) - last_sync = fields.Datetime('Last Successful Sync', readonly=True) - last_sync_error = fields.Text('Last Error', readonly=True) - - # ------------------------------------------------------------------ - # JSON-RPC helpers - # ------------------------------------------------------------------ - - def _jsonrpc(self, service, method, args): - """Execute a JSON-RPC call against the remote Odoo instance.""" - self.ensure_one() - url = f"{self.url.rstrip('/')}/jsonrpc" - payload = { - 'jsonrpc': '2.0', - 'method': 'call', - 'id': 1, - 'params': { - 'service': service, - 'method': method, - 'args': args, - }, - } - try: - resp = requests.post(url, json=payload, timeout=15) - resp.raise_for_status() - result = resp.json() - if result.get('error'): - err = result['error'].get('data', {}).get('message', str(result['error'])) - raise UserError(f"Remote error: {err}") - return result.get('result') - except requests.exceptions.ConnectionError: - _logger.warning("Task sync: cannot connect to %s", self.url) - return None - except requests.exceptions.Timeout: - _logger.warning("Task sync: timeout connecting to %s", self.url) - return None - - def _authenticate(self): - """Authenticate with the remote instance and return the uid.""" - self.ensure_one() - uid = self._jsonrpc('common', 'authenticate', - [self.database, self.username, self.api_key, {}]) - if not uid: - _logger.error("Task sync: authentication failed for %s", self.name) - return uid - - def _rpc(self, model, method, args, kwargs=None): - """Execute a method on the remote instance via execute_kw. - execute_kw(db, uid, password, model, method, [args], {kwargs}) - """ - self.ensure_one() - uid = self._authenticate() - if not uid: - return None - call_args = [self.database, uid, self.api_key, model, method, args] - if kwargs: - call_args.append(kwargs) - return self._jsonrpc('object', 'execute_kw', call_args) - - # ------------------------------------------------------------------ - # Tech sync ID helpers - # ------------------------------------------------------------------ - - def _get_local_tech_map(self): - """Build {local_user_id: x_fc_tech_sync_id} for all local field staff.""" - techs = self.env['res.users'].sudo().search([ - ('x_fc_is_field_staff', '=', True), - ('x_fc_tech_sync_id', '!=', False), - ('active', '=', True), - ]) - return {u.id: u.x_fc_tech_sync_id for u in techs} - - def _get_remote_tech_map(self): - """Build {x_fc_tech_sync_id: remote_user_id} from the remote instance.""" - self.ensure_one() - remote_users = self._rpc('res.users', 'search_read', [ - [('x_fc_is_field_staff', '=', True), - ('x_fc_tech_sync_id', '!=', False), - ('active', '=', True)], - ], {'fields': ['id', 'x_fc_tech_sync_id']}) - if not remote_users: - return {} - return { - ru['x_fc_tech_sync_id']: ru['id'] - for ru in remote_users - if ru.get('x_fc_tech_sync_id') - } - - def _get_local_syncid_to_uid(self): - """Build {x_fc_tech_sync_id: local_user_id} for local field staff.""" - techs = self.env['res.users'].sudo().search([ - ('x_fc_is_field_staff', '=', True), - ('x_fc_tech_sync_id', '!=', False), - ('active', '=', True), - ]) - return {u.x_fc_tech_sync_id: u.id for u in techs} - - # ------------------------------------------------------------------ - # Connection test - # ------------------------------------------------------------------ - - def action_test_connection(self): - """Test the connection to the remote instance.""" - self.ensure_one() - uid = self._authenticate() - if uid: - remote_map = self._get_remote_tech_map() - local_map = self._get_local_tech_map() - matched = set(local_map.values()) & set(remote_map.keys()) - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': 'Connection Successful', - 'message': f'Connected to {self.name}. ' - f'{len(matched)} technician(s) matched by sync ID.', - 'type': 'success', - 'sticky': False, - }, - } - raise UserError(f"Cannot connect to {self.name}. Check URL, database, and API key.") - - # ------------------------------------------------------------------ - # PUSH: send local task changes to remote instance - # ------------------------------------------------------------------ - - def _get_local_instance_id(self): - """Return this instance's own ID from config parameters.""" - return self.env['ir.config_parameter'].sudo().get_param( - 'fusion_claims.sync_instance_id', '') - - @api.model - def _push_tasks(self, tasks, operation='create'): - """Push local task changes to all active remote instances. - Called from technician_task create/write overrides. - Non-blocking: errors are logged, not raised. - """ - configs = self.sudo().search([('active', '=', True)]) - if not configs: - return - local_id = configs[0]._get_local_instance_id() - if not local_id: - return - for config in configs: - try: - config._push_tasks_to_remote(tasks, operation, local_id) - except Exception: - _logger.exception("Task sync push to %s failed", config.name) - - def _push_tasks_to_remote(self, tasks, operation, local_instance_id): - """Push task data to a single remote instance. - - Maps additional_technician_ids via sync IDs so the remote instance - also blocks those technicians' schedules. - """ - self.ensure_one() - local_map = self._get_local_tech_map() - remote_map = self._get_remote_tech_map() - if not local_map or not remote_map: - return - - ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}} - - for task in tasks: - sync_id = local_map.get(task.technician_id.id) - if not sync_id: - continue - remote_tech_uid = remote_map.get(sync_id) - if not remote_tech_uid: - continue - - # Map additional technicians to remote user IDs - remote_additional_ids = [] - for tech in task.additional_technician_ids: - add_sync_id = local_map.get(tech.id) - if add_sync_id: - remote_add_uid = remote_map.get(add_sync_id) - if remote_add_uid: - remote_additional_ids.append(remote_add_uid) - - task_data = { - 'x_fc_sync_uuid': task.x_fc_sync_uuid, - 'x_fc_sync_source': local_instance_id, - 'x_fc_sync_remote_id': task.id, - 'name': f"[{local_instance_id.upper()}] {task.name}", - 'technician_id': remote_tech_uid, - 'additional_technician_ids': [(6, 0, remote_additional_ids)], - 'task_type': task.task_type, - 'status': task.status, - 'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False, - 'time_start': task.time_start, - 'time_end': task.time_end, - 'duration_hours': task.duration_hours, - 'address_street': task.address_street or '', - 'address_street2': task.address_street2 or '', - 'address_city': task.address_city or '', - 'address_zip': task.address_zip or '', - 'address_lat': float(task.address_lat or 0), - 'address_lng': float(task.address_lng or 0), - 'priority': task.priority or 'normal', - 'x_fc_sync_client_name': task.partner_id.name if task.partner_id else '', - } - - existing = self._rpc( - 'fusion.technician.task', 'search', - [[('x_fc_sync_uuid', '=', task.x_fc_sync_uuid)]], - {'limit': 1}) - - if operation in ('create', 'write'): - if existing: - self._rpc('fusion.technician.task', 'write', - [existing, task_data], ctx) - elif operation == 'create': - task_data['sale_order_id'] = False - self._rpc('fusion.technician.task', 'create', - [[task_data]], ctx) - - elif operation == 'unlink' and existing: - self._rpc('fusion.technician.task', 'write', - [existing, {'status': 'cancelled', 'active': False}], ctx) - - # ------------------------------------------------------------------ - # PULL: cron-based full reconciliation - # ------------------------------------------------------------------ - - @api.model - def _cron_pull_remote_tasks(self): - """Cron job: pull tasks from all active remote instances.""" - configs = self.sudo().search([('active', '=', True)]) - for config in configs: - try: - config._pull_tasks_from_remote() - config.sudo().write({ - 'last_sync': fields.Datetime.now(), - 'last_sync_error': False, - }) - except Exception as e: - _logger.exception("Task sync pull from %s failed", config.name) - config.sudo().write({'last_sync_error': str(e)}) - - def _pull_tasks_from_remote(self): - """Pull all active tasks for matched technicians from the remote instance.""" - self.ensure_one() - local_syncid_to_uid = self._get_local_syncid_to_uid() - if not local_syncid_to_uid: - return - - remote_map = self._get_remote_tech_map() - if not remote_map: - return - - matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys()) - if not matched_sync_ids: - _logger.info("Task sync: no matched technicians between local and %s", self.name) - return - - remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids] - remote_syncid_by_uid = {v: k for k, v in remote_map.items()} - - cutoff = fields.Date.today() - timedelta(days=7) - remote_tasks = self._rpc( - 'fusion.technician.task', 'search_read', - [[ - '|', - ('technician_id', 'in', remote_tech_ids), - ('additional_technician_ids', 'in', remote_tech_ids), - ('scheduled_date', '>=', str(cutoff)), - ('x_fc_sync_source', '=', False), - ]], - {'fields': SYNC_TASK_FIELDS + ['id']}) - - if remote_tasks is None: - return - - Task = self.env['fusion.technician.task'].sudo().with_context( - skip_task_sync=True, skip_travel_recalc=True) - - remote_uuids = set() - for rt in remote_tasks: - sync_uuid = rt.get('x_fc_sync_uuid') - if not sync_uuid: - continue - remote_uuids.add(sync_uuid) - - remote_tech_raw = rt['technician_id'] - remote_uid = remote_tech_raw[0] if isinstance(remote_tech_raw, (list, tuple)) else remote_tech_raw - tech_sync_id = remote_syncid_by_uid.get(remote_uid) - local_uid = local_syncid_to_uid.get(tech_sync_id) if tech_sync_id else None - if not local_uid: - continue - - partner_raw = rt.get('partner_id') - client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else '' - - # Map additional technicians from remote to local - local_additional_ids = [] - remote_add_raw = rt.get('additional_technician_ids', []) - if remote_add_raw and isinstance(remote_add_raw, list): - for add_uid in remote_add_raw: - add_sync_id = remote_syncid_by_uid.get(add_uid) - if add_sync_id: - local_add_uid = local_syncid_to_uid.get(add_sync_id) - if local_add_uid: - local_additional_ids.append(local_add_uid) - - vals = { - 'x_fc_sync_uuid': sync_uuid, - 'x_fc_sync_source': self.instance_id, - 'x_fc_sync_remote_id': rt['id'], - 'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}", - 'technician_id': local_uid, - 'additional_technician_ids': [(6, 0, local_additional_ids)], - 'task_type': rt.get('task_type', 'delivery'), - 'status': rt.get('status', 'scheduled'), - 'scheduled_date': rt.get('scheduled_date'), - 'time_start': rt.get('time_start', 9.0), - 'time_end': rt.get('time_end', 10.0), - 'duration_hours': rt.get('duration_hours', 1.0), - 'address_street': rt.get('address_street', ''), - 'address_street2': rt.get('address_street2', ''), - 'address_city': rt.get('address_city', ''), - 'address_zip': rt.get('address_zip', ''), - 'address_lat': rt.get('address_lat', 0), - 'address_lng': rt.get('address_lng', 0), - 'priority': rt.get('priority', 'normal'), - 'x_fc_sync_client_name': client_name, - } - - existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1) - if existing: - existing.write(vals) - else: - vals['sale_order_id'] = False - Task.create([vals]) - - stale_shadows = Task.search([ - ('x_fc_sync_source', '=', self.instance_id), - ('x_fc_sync_uuid', 'not in', list(remote_uuids)), - ('scheduled_date', '>=', str(cutoff)), - ('active', '=', True), - ]) - if stale_shadows: - stale_shadows.write({'active': False, 'status': 'cancelled'}) - _logger.info("Deactivated %d stale shadow tasks from %s", - len(stale_shadows), self.instance_id) - - # ------------------------------------------------------------------ - # CLEANUP - # ------------------------------------------------------------------ - - @api.model - def _cron_cleanup_old_shadows(self): - """Remove shadow tasks older than 30 days (completed/cancelled).""" - cutoff = fields.Date.today() - timedelta(days=30) - old_shadows = self.env['fusion.technician.task'].sudo().search([ - ('x_fc_sync_source', '!=', False), - ('scheduled_date', '<', str(cutoff)), - ('status', 'in', ['completed', 'cancelled']), - ]) - if old_shadows: - count = len(old_shadows) - old_shadows.unlink() - _logger.info("Cleaned up %d old shadow tasks", count) - - # ------------------------------------------------------------------ - # Manual trigger - # ------------------------------------------------------------------ - - def action_sync_now(self): - """Manually trigger a full sync for this config.""" - self.ensure_one() - self._pull_tasks_from_remote() - self.sudo().write({ - 'last_sync': fields.Datetime.now(), - 'last_sync_error': False, - }) - shadow_count = self.env['fusion.technician.task'].sudo().search_count([ - ('x_fc_sync_source', '=', self.instance_id), - ]) - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': 'Sync Complete', - 'message': f'Synced from {self.name}. {shadow_count} shadow task(s) now visible.', - 'type': 'success', - 'sticky': False, - }, - } diff --git a/fusion_claims/models/technician_location.py b/fusion_claims/models/technician_location.py deleted file mode 100644 index c680fb2..0000000 --- a/fusion_claims/models/technician_location.py +++ /dev/null @@ -1,116 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2024-2026 Nexa Systems Inc. -# License OPL-1 (Odoo Proprietary License v1.0) - -""" -Fusion Technician Location -GPS location logging for field technicians. -""" - -from odoo import models, fields, api, _ -import logging - -_logger = logging.getLogger(__name__) - - -class FusionTechnicianLocation(models.Model): - _name = 'fusion.technician.location' - _description = 'Technician Location Log' - _order = 'logged_at desc' - - user_id = fields.Many2one( - 'res.users', - string='Technician', - required=True, - index=True, - ondelete='cascade', - ) - latitude = fields.Float( - string='Latitude', - digits=(10, 7), - required=True, - ) - longitude = fields.Float( - string='Longitude', - digits=(10, 7), - required=True, - ) - accuracy = fields.Float( - string='Accuracy (m)', - help='GPS accuracy in meters', - ) - logged_at = fields.Datetime( - string='Logged At', - default=fields.Datetime.now, - required=True, - index=True, - ) - source = fields.Selection([ - ('portal', 'Portal'), - ('app', 'Mobile App'), - ], string='Source', default='portal') - - @api.model - def log_location(self, latitude, longitude, accuracy=None): - """Log the current user's location. Called from portal JS.""" - return self.sudo().create({ - 'user_id': self.env.user.id, - 'latitude': latitude, - 'longitude': longitude, - 'accuracy': accuracy or 0, - 'source': 'portal', - }) - - @api.model - def get_latest_locations(self): - """Get the most recent location for each technician (for map view).""" - self.env.cr.execute(""" - SELECT DISTINCT ON (user_id) - user_id, latitude, longitude, accuracy, logged_at - FROM fusion_technician_location - WHERE logged_at > NOW() - INTERVAL '24 hours' - ORDER BY user_id, logged_at DESC - """) - rows = self.env.cr.dictfetchall() - result = [] - for row in rows: - user = self.env['res.users'].sudo().browse(row['user_id']) - result.append({ - 'user_id': row['user_id'], - 'name': user.name, - 'latitude': row['latitude'], - 'longitude': row['longitude'], - 'accuracy': row['accuracy'], - 'logged_at': str(row['logged_at']), - }) - return result - - @api.model - def _cron_cleanup_old_locations(self): - """Remove location logs based on configurable retention setting. - - Setting (fusion_claims.location_retention_days): - - Empty / not set => keep 30 days (default) - - "0" => delete at end of day (keep today only) - - "1" .. "N" => keep for N days - """ - ICP = self.env['ir.config_parameter'].sudo() - raw = (ICP.get_param('fusion_claims.location_retention_days') or '').strip() - - if raw == '': - retention_days = 30 # default: 1 month - else: - try: - retention_days = max(int(raw), 0) - except (ValueError, TypeError): - retention_days = 30 - - cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=retention_days) - old_records = self.search([('logged_at', '<', cutoff)]) - count = len(old_records) - if count: - old_records.unlink() - _logger.info( - "Cleaned up %d technician location records (retention=%d days)", - count, retention_days, - ) diff --git a/fusion_claims/models/technician_task.py b/fusion_claims/models/technician_task.py index 20a263b..3c18d8e 100644 --- a/fusion_claims/models/technician_task.py +++ b/fusion_claims/models/technician_task.py @@ -3,160 +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, 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_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 - - 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', @@ -169,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)', @@ -180,251 +48,8 @@ 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', - tracking=True, - help='LTC Home for this visit', - ) - # ------------------------------------------------------------------ - # 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', - ) - - # 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', - ) - - # 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', - ) - - # ------------------------------------------------------------------ - # 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, - ) - 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'), @@ -448,533 +73,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('partner_id') - def _onchange_partner_id(self): - """Auto-fill address fields from the selected client's address.""" - 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.""" @@ -997,41 +98,8 @@ class FusionTechnicianTask(models.Model): addr = order.dest_address_id or order.partner_id self._fill_address_from_partner(addr) - @api.onchange('facility_id') - def _onchange_facility_id(self): - """Auto-fill address from the LTC facility.""" - if self.facility_id and self.task_type == 'ltc_visit': - fac = self.facility_id - self.address_street = fac.street or '' - self.address_street2 = fac.street2 or '' - self.address_city = fac.city or '' - self.address_state_id = fac.state_id.id if fac.state_id else False - self.address_zip = fac.zip or '' - self.description = self.description or _( - 'LTC Visit at %s', fac.name - ) - - @api.onchange('task_type') - def _onchange_task_type_ltc(self): - if self.task_type == 'ltc_visit': - 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') @@ -1046,427 +114,78 @@ class FusionTechnicianTask(models.Model): "A task must be linked to either a Sale Order (Case) or a Purchase Order." )) + # ------------------------------------------------------------------ + # 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()) - # 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') - return records - def write(self, vals): - if self.env.context.get('skip_travel_recalc'): - return super().write(vals) + 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') - return res - - @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.""" @@ -1491,26 +210,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', @@ -1520,7 +232,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' @@ -1553,7 +264,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, @@ -1563,162 +273,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.""" - 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() - - # Cache geocoded start addresses per technician to avoid repeated API calls - start_coords_cache = {} - - for tech_id, date in combos: - if not tech_id or not date: - continue - 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 - - # 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(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'] = '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 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._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' - 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.""" @@ -1729,40 +353,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." - )) - - task.with_context(skip_travel_recalc=True).write({ - 'status': 'completed', - 'completion_datetime': fields.Datetime.now(), - }) - 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() @@ -1805,263 +395,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._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', - } - 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: + if not self.purchase_order_id: 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 whoever created/scheduled the task.""" - self.ensure_one() - # Notify the task creator (scheduler) if they're not the technician - if self.create_uid and self.create_uid not in self.all_technician_ids: - task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type) - task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form' - client_name = self.partner_id.name or 'N/A' - order = self.sale_order_id or self.purchase_order_id - case_ref = order.name if order else '' - # Build address string - 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' - # Build subject - subject = f'Task Completed: {client_name}' - if case_ref: - subject += f' ({case_ref})' - body = Markup( - f'
' - f'

' - f'{task_type_label} Completed

' - f'' - 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'
' - ) - # Use Odoo's internal notification system - self.env['mail.thread'].sudo().message_notify( - partner_ids=[self.create_uid.partner_id.id], - body=body, - subject=subject, - ) + 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, + } # ------------------------------------------------------------------ - # 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 @@ -2121,8 +536,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 @@ -2184,9 +601,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 @@ -2209,7 +627,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: @@ -2255,330 +672,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_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() - return { - 'api_key': api_key, - 'tasks': tasks, - 'locations': locations, - 'local_instance_id': local_instance, - } - - 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. Uses lat/lng coordinates to - navigate to the exact location (text addresses cause Google to - resolve to nearby business names instead).""" - self.ensure_one() - 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' - elif self.address_display: - return f'https://www.google.com/maps/dir/?api=1&destination={urllib.parse.quote(self.address_display)}&travelmode=driving' - return '' diff --git a/fusion_claims/report/report_actions.xml b/fusion_claims/report/report_actions.xml index ba103ca..b3e2abc 100644 --- a/fusion_claims/report/report_actions.xml +++ b/fusion_claims/report/report_actions.xml @@ -32,7 +32,7 @@ report - + Quotation / Order (Landscape - ADP) sale.order @@ -40,21 +40,6 @@ fusion_claims.report_saleorder_landscape fusion_claims.report_saleorder_landscape '%s - %s' % (object.name, object.partner_id.name) - - report - - - - - - - - LTC Repair Order / Quotation - sale.order - qweb-pdf - fusion_claims.report_saleorder_ltc_repair - fusion_claims.report_saleorder_ltc_repair - 'LTC Repair - %s - %s' % (object.name, object.partner_id.name) report @@ -127,19 +112,6 @@ report - - - - - Rental Agreement - sale.order - qweb-pdf - fusion_claims.report_rental_agreement - fusion_claims.report_rental_agreement - 'Rental Agreement - %s' % object.name - - report - @@ -169,6 +141,21 @@ report + + + + + Approved Items Report + sale.order + qweb-pdf + fusion_claims.report_approved_items + fusion_claims.report_approved_items + 'Approved Items - %s - %s' % (object.name, object.partner_id.name) + + report + + + diff --git a/fusion_claims/report/report_approved_items.xml b/fusion_claims/report/report_approved_items.xml new file mode 100644 index 0000000..3ef899a --- /dev/null +++ b/fusion_claims/report/report_approved_items.xml @@ -0,0 +1,162 @@ + + + + + diff --git a/fusion_claims/report/report_rental_agreement.xml b/fusion_claims/report/report_rental_agreement.xml deleted file mode 100644 index 4b424bd..0000000 --- a/fusion_claims/report/report_rental_agreement.xml +++ /dev/null @@ -1,365 +0,0 @@ - - - - - diff --git a/fusion_claims/report/sale_report_landscape.xml b/fusion_claims/report/sale_report_landscape.xml index 08c9680..8eaecb2 100644 --- a/fusion_claims/report/sale_report_landscape.xml +++ b/fusion_claims/report/sale_report_landscape.xml @@ -285,66 +285,14 @@
- -
-
- Terms of Acceptance + + +
+ Signature
+
+
-
- By signing this document, the undersigned ("Client") acknowledges and agrees: -
    -
  1. The Client has reviewed this quotation in its entirety and accepts all items, pricing, terms, and specifications as stated herein.
  2. -
  3. Upon signing, this quotation becomes a binding Sales Order between the Client and .
  4. -
  5. Any modifications to this order after acceptance must be submitted in writing and may result in revised pricing, terms, or delivery timelines.
  6. -
  7. Payment shall be made in accordance with the payment terms specified in this document.
  8. -
  9. For orders funded through the Ontario Assistive Devices Program (ADP), the Client authorizes to submit claims and documentation to ADP on their behalf.
  10. -
  11. Products are subject to the return and refund policy as outlined in 's standard terms of service.
  12. -
-
- - - - - - - -
-
Client Signature
-
- -
-
-
Printed Name
-
- -
-
-
Date & Time of Acceptance
-
- -
-
-
-
- - - - - - -
-
Client Signature
-
-
-
Printed Name
-
-
-
Date & Time
-
-
-
-
-
+
diff --git a/fusion_claims/report/sale_report_portrait.xml b/fusion_claims/report/sale_report_portrait.xml index d5fe26d..287ce4f 100644 --- a/fusion_claims/report/sale_report_portrait.xml +++ b/fusion_claims/report/sale_report_portrait.xml @@ -203,13 +203,13 @@ - + - + - + @@ -230,26 +230,26 @@ - + + + + + + + + + - - + + - - + + - - - - - - - -
Subtotal
Taxes
Total
ADP PortionTotal ADP Portion
Client PortionTotal Client Portion
Taxes
Grand Total
@@ -262,66 +262,14 @@
- -
-
- Terms of Acceptance + + +
+ Signature
+
+
-
- By signing this document, the undersigned ("Client") acknowledges and agrees: -
    -
  1. The Client has reviewed this quotation in its entirety and accepts all items, pricing, terms, and specifications as stated herein.
  2. -
  3. Upon signing, this quotation becomes a binding Sales Order between the Client and .
  4. -
  5. Any modifications to this order after acceptance must be submitted in writing and may result in revised pricing, terms, or delivery timelines.
  6. -
  7. Payment shall be made in accordance with the payment terms specified in this document.
  8. -
  9. For orders funded through the Ontario Assistive Devices Program (ADP), the Client authorizes to submit claims and documentation to ADP on their behalf.
  10. -
  11. Products are subject to the return and refund policy as outlined in 's standard terms of service.
  12. -
-
- - - - - - - -
-
Client Signature
-
- -
-
-
Printed Name
-
- -
-
-
Date & Time of Acceptance
-
- -
-
-
-
- - - - - - -
-
Client Signature
-
-
-
Printed Name
-
-
-
Date & Time
-
-
-
-
-
+
diff --git a/fusion_claims/security/ir.model.access.csv b/fusion_claims/security/ir.model.access.csv index 2db47b7..ff24424 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,23 +62,8 @@ 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 -access_fusion_ltc_floor_manager,fusion.ltc.floor.manager,model_fusion_ltc_floor,sales_team.group_sale_manager,1,1,1,1 -access_fusion_ltc_station_user,fusion.ltc.station.user,model_fusion_ltc_station,sales_team.group_sale_salesman,1,1,1,0 -access_fusion_ltc_station_manager,fusion.ltc.station.manager,model_fusion_ltc_station,sales_team.group_sale_manager,1,1,1,1 -access_fusion_ltc_repair_user,fusion.ltc.repair.user,model_fusion_ltc_repair,sales_team.group_sale_salesman,1,1,1,0 -access_fusion_ltc_repair_manager,fusion.ltc.repair.manager,model_fusion_ltc_repair,sales_team.group_sale_manager,1,1,1,1 -access_fusion_ltc_repair_stage_user,fusion.ltc.repair.stage.user,model_fusion_ltc_repair_stage,sales_team.group_sale_salesman,1,0,0,0 -access_fusion_ltc_repair_stage_manager,fusion.ltc.repair.stage.manager,model_fusion_ltc_repair_stage,sales_team.group_sale_manager,1,1,1,1 -access_fusion_ltc_cleanup_user,fusion.ltc.cleanup.user,model_fusion_ltc_cleanup,sales_team.group_sale_salesman,1,1,1,0 -access_fusion_ltc_cleanup_manager,fusion.ltc.cleanup.manager,model_fusion_ltc_cleanup,sales_team.group_sale_manager,1,1,1,1 -access_fusion_ltc_family_contact_user,fusion.ltc.family.contact.user,model_fusion_ltc_family_contact,sales_team.group_sale_salesman,1,1,1,0 -access_fusion_ltc_family_contact_manager,fusion.ltc.family.contact.manager,model_fusion_ltc_family_contact,sales_team.group_sale_manager,1,1,1,1 -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_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 4b0801c..9351185 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 5b7318b..31c3d69 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; @@ -320,6 +389,25 @@ $transition-speed: .25s; .fa { opacity: .8; } } +.fc_task_edit_btn { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + color: var(--btn-primary-color, #fff); + background: var(--btn-primary-bg, #{$primary}); + padding: 2px 10px; + border-radius: 4px; + cursor: pointer; + margin-left: auto; + transition: all .15s; + + &:hover { + opacity: .85; + filter: brightness(1.15); + } +} + // ── Map area ──────────────────────────────────────────────────────── .fc_map_area { flex: 1 1 auto; @@ -341,15 +429,21 @@ $transition-speed: .25s; min-height: 400px; } -// ── Google Maps InfoWindow override (always light bg) ─────────────── -// InfoWindow is rendered by Google outside our DOM; we style via -// the .gm-style-iw container that Google injects. +// ── Google Maps InfoWindow override ────────────────────────────────── .gm-style-iw-d { overflow: auto !important; } .gm-style .gm-style-iw-c { padding: 0 !important; border-radius: 10px !important; + overflow: hidden !important; + box-shadow: 0 4px 20px rgba(0,0,0,.15) !important; +} +.gm-style .gm-style-iw-tc { + display: none !important; +} +.gm-style .gm-ui-hover-effect { + display: none !important; } // ── Responsive ────────────────────────────────────────────────────── diff --git a/fusion_claims/static/src/js/debug_required_fields.js b/fusion_claims/static/src/js/debug_required_fields.js new file mode 100644 index 0000000..37cf3cc --- /dev/null +++ b/fusion_claims/static/src/js/debug_required_fields.js @@ -0,0 +1,20 @@ +/** @odoo-module **/ + +import { Record } from "@web/model/relational_model/record"; +import { patch } from "@web/core/utils/patch"; + +patch(Record.prototype, { + _displayInvalidFieldNotification() { + const fieldNames = []; + for (const fieldName of this._invalidFields) { + const fieldDef = this.fields[fieldName]; + const label = fieldDef?.string || fieldName; + fieldNames.push(`${label} (${fieldName})`); + } + const message = fieldNames.length + ? `Missing required fields:\n${fieldNames.join(", ")}` + : "Missing required fields (unknown)"; + console.error("FUSION DEBUG:", message, Array.from(this._invalidFields)); + return this.model.notification.add(message, { type: "danger" }); + }, +}); 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 8ee5923..59ab79e 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) { @@ -203,13 +218,17 @@ function groupTasks(tasksData, localInstanceId) { }; } - let globalIdx = 0; + const dayCounters = {}; for (const task of sorted) { - globalIdx++; + const techId = task.technician_id ? task.technician_id[0] : 0; + if (hasTechFilter && !visibleTechIds[techId]) continue; + const g = classifyTask(task); - task._scheduleNum = globalIdx; + 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"; @@ -227,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); } @@ -255,21 +273,22 @@ export class FusionTaskMapController extends Component { showTasks: true, showTechnicians: true, showTraffic: true, + 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, // hidden by default + [GROUP_YESTERDAY]: false, [GROUP_TODAY]: true, - [GROUP_TOMORROW]: true, - [GROUP_THIS_WEEK]: false, // hidden by default - [GROUP_LATER]: false, // hidden by default + [GROUP_TOMORROW]: false, + [GROUP_THIS_WEEK]: false, + [GROUP_LATER]: false, }, + allTechnicians: [], + visibleTechIds: {}, }); // Yesterday collapsed by default in sidebar list @@ -280,7 +299,11 @@ export class FusionTaskMapController extends Component { this.taskMarkers = []; this.taskMarkerMap = {}; // id → marker this.techMarkers = []; + this.routeLines = []; // route polylines + this.routeLabels = []; // travel time overlay labels + this.routeAnimFrameId = null; this.infoWindow = null; + this.techStartLocations = {}; this.apiKey = ""; this.tasksData = []; this.locationsData = []; @@ -312,6 +335,7 @@ export class FusionTaskMapController extends Component { }); onWillUnmount(() => { this._clearMarkers(); + this._clearRoute(); window.__fusionMapOpenTask = () => {}; }); } @@ -327,17 +351,30 @@ export class FusionTaskMapController extends Component { } // ── Data ───────────────────────────────────────────────────────── + _storeResult(result) { + this.localInstanceId = result.local_instance_id || this.localInstanceId || ""; + this.tasksData = result.tasks || []; + this.locationsData = result.locations || []; + this.techStartLocations = result.tech_start_locations || {}; + 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; + } + async _loadAndRender() { try { const domain = this._getDomain(); const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]); this.apiKey = result.api_key; - this.localInstanceId = result.local_instance_id || ""; - this.tasksData = result.tasks || []; - this.locationsData = result.locations || []; - this.state.taskCount = this.tasksData.length; - this.state.techCount = this.locationsData.length; - this.state.groups = groupTasks(this.tasksData, this.localInstanceId); + this._storeResult(result); if (!this.apiKey) { this.state.error = _t("Google Maps API key not configured. Go to Settings > Fusion Claims."); @@ -345,7 +382,11 @@ export class FusionTaskMapController extends Component { return; } await loadGoogleMaps(this.apiKey); - if (this.mapRef.el) this._initMap(); + if (this.map) { + this._renderMarkers(); + } else if (this.mapRef.el) { + this._initMap(); + } this.state.loading = false; } catch (e) { console.error("FusionTaskMap load error:", e); @@ -354,17 +395,33 @@ export class FusionTaskMapController extends Component { } } + async _softRefresh() { + if (!this.map) return; + try { + const center = this.map.getCenter(); + const zoom = this.map.getZoom(); + + const domain = this._getDomain(); + const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]); + this._storeResult(result); + + this._placeMarkers(); + + if (center && zoom != null) { + this.map.setCenter(center); + this.map.setZoom(zoom); + } + } catch (e) { + console.error("FusionTaskMap soft refresh error:", e); + } + } + async _onModelUpdate() { if (!this.map) return; try { const domain = this._getDomain(); const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]); - this.localInstanceId = result.local_instance_id || this.localInstanceId || ""; - this.tasksData = result.tasks || []; - this.locationsData = result.locations || []; - this.state.taskCount = this.tasksData.length; - this.state.techCount = this.locationsData.length; - this.state.groups = groupTasks(this.tasksData, this.localInstanceId); + this._storeResult(result); this._renderMarkers(); } catch (e) { console.error("FusionTaskMap update error:", e); @@ -407,12 +464,27 @@ export class FusionTaskMapController extends Component { this.techMarkers = []; } - _renderMarkers() { - this._clearMarkers(); + _clearRoute() { + if (this.routeAnimFrameId) { + cancelAnimationFrame(this.routeAnimFrameId); + this.routeAnimFrameId = null; + } + for (const l of this.routeLines) l.setMap(null); + this.routeLines = []; + for (const lb of this.routeLabels) lb.setMap(null); + this.routeLabels = []; + } + + _placeMarkers() { + for (const m of this.taskMarkers) m.setMap(null); + for (const m of this.techMarkers) m.setMap(null); + this.taskMarkers = []; + this.taskMarkerMap = {}; + this.techMarkers = []; + const bounds = new google.maps.LatLngBounds(); let hasBounds = false; - // Task pins: only show groups that are enabled in the day filter if (this.state.showTasks) { for (const group of this.state.groups) { const groupVisible = this.state.visibleGroups[group.key] !== false; @@ -444,21 +516,26 @@ export class FusionTaskMapController extends Component { } } - // Technician markers if (this.state.showTechnicians) { for (const loc of this.locationsData) { if (!loc.latitude || !loc.longitude) continue; const pos = { lat: loc.latitude, lng: loc.longitude }; const initials = initialsOf(loc.name); + const src = loc.sync_instance || this.localInstanceId || ""; + const isRemote = src && src !== this.localInstanceId; + const pinColor = isRemote + ? (SOURCE_COLORS[src] || "#6c757d") + : "#1d4ed8"; + const srcLabel = src ? src.charAt(0).toUpperCase() + src.slice(1) : ""; const svg = `` + - `` + + `` + `${initials}` + ``; const marker = new google.maps.Marker({ position: pos, map: this.map, - title: loc.name, + title: loc.name + (isRemote ? ` [${srcLabel}]` : ""), icon: { url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg), scaledSize: new google.maps.Size(44, 44), @@ -469,8 +546,9 @@ export class FusionTaskMapController extends Component { marker.addListener("click", () => { this.infoWindow.setContent(`
-
+
${loc.name} + ${srcLabel ? `${srcLabel}` : ""}
Last seen: ${loc.logged_at || "Unknown"}
@@ -485,45 +563,410 @@ export class FusionTaskMapController extends Component { } } + const starts = this.techStartLocations || {}; + for (const uid of Object.keys(starts)) { + const sl = starts[uid]; + if (sl && sl.lat && sl.lng) { + bounds.extend({ lat: sl.lat, lng: sl.lng }); + hasBounds = true; + } + } + + return { bounds, hasBounds }; + } + + _renderMarkers() { + this._clearRoute(); + const { bounds, hasBounds } = this._placeMarkers(); + + if (this.state.showRoute && this.state.showTasks) { + this._renderRoute(); + } + if (hasBounds) { - this.map.fitBounds(bounds); - if (this.taskMarkers.length + this.techMarkers.length === 1) { - this.map.setZoom(14); + try { + this.map.fitBounds(bounds); + if (this.taskMarkers.length + this.techMarkers.length === 1) { + this.map.setZoom(14); + } + } catch (_e) { + // bounds not ready yet } } } + _renderRoute() { + this._clearRoute(); + + const routeSegments = {}; + for (const group of this.state.groups) { + if (this.state.visibleGroups[group.key] === false) continue; + for (const task of group.tasks) { + if (!task._hasCoords) continue; + const techId = task.technician_id ? task.technician_id[0] : 0; + if (!techId) continue; + const dayKey = task.scheduled_date || "none"; + const segKey = `${techId}_${dayKey}`; + if (!routeSegments[segKey]) { + routeSegments[segKey] = { + name: task._techName, day: dayKey, + techId, tasks: [], + }; + } + routeSegments[segKey].tasks.push(task); + } + } + + const LEG_COLORS = [ + "#3b82f6", "#f59e0b", "#8b5cf6", "#ec4899", + "#f97316", "#0ea5e9", "#d946ef", "#06b6d4", + "#a855f7", "#6366f1", "#eab308", "#0284c7", + "#c026d3", "#7c3aed", "#2563eb", "#db2777", + "#9333ea", "#0891b2", "#4f46e5", "#be185d", + ]; + let globalLegIdx = 0; + + if (!this._directionsService) { + this._directionsService = new google.maps.DirectionsService(); + } + + const allAnimLines = []; + const starts = this.techStartLocations || {}; + + for (const segKey of Object.keys(routeSegments)) { + const seg = routeSegments[segKey]; + const tasks = seg.tasks; + tasks.sort((a, b) => (a.time_start || 0) - (b.time_start || 0)); + + const startLoc = starts[seg.techId]; + const hasStart = startLoc && startLoc.lat && startLoc.lng; + + if (tasks.length < 2 && !hasStart) continue; + if (tasks.length < 1) continue; + + const segBaseColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length]; + + let origin, destination, waypoints, hasStartLeg; + + if (hasStart) { + origin = { lat: startLoc.lat, lng: startLoc.lng }; + destination = { + lat: tasks[tasks.length - 1].address_lat, + lng: tasks[tasks.length - 1].address_lng, + }; + waypoints = tasks.slice(0, -1).map(t => ({ + location: { lat: t.address_lat, lng: t.address_lng }, + stopover: true, + })); + hasStartLeg = true; + } else { + origin = { lat: tasks[0].address_lat, lng: tasks[0].address_lng }; + destination = { + lat: tasks[tasks.length - 1].address_lat, + lng: tasks[tasks.length - 1].address_lng, + }; + waypoints = tasks.slice(1, -1).map(t => ({ + location: { lat: t.address_lat, lng: t.address_lng }, + stopover: true, + })); + hasStartLeg = false; + } + + if (hasStart) { + const startSvg = + `` + + `` + + `` + + ``; + const startMarker = new google.maps.Marker({ + position: origin, + map: this.map, + title: `${seg.name} - Start`, + icon: { + url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(startSvg), + scaledSize: new google.maps.Size(32, 32), + anchor: new google.maps.Point(16, 16), + }, + zIndex: 5, + }); + startMarker.addListener("click", () => { + this.infoWindow.setContent(` +
+
+ ${seg.name} - Start +
+
+ ${startLoc.address || 'Start location'} +
${startLoc.source === 'clock_in' ? 'Clock-in location' : startLoc.source === 'start_address' ? 'Home address' : 'Company HQ'}
+
+
`); + this.infoWindow.open(this.map, startMarker); + }); + this.routeLines.push(startMarker); + } + + this._directionsService.route({ + origin, + destination, + waypoints, + optimizeWaypoints: false, + travelMode: google.maps.TravelMode.DRIVING, + avoidTolls: true, + drivingOptions: { + departureTime: new Date(), + trafficModel: "bestguess", + }, + }, (result, status) => { + if (status !== "OK" || !result.routes || !result.routes[0]) return; + + const route = result.routes[0]; + + for (let li = 0; li < route.legs.length; li++) { + const leg = route.legs[li]; + const legColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length]; + globalLegIdx++; + + const legPath = []; + for (const step of leg.steps) { + for (const pt of step.path) legPath.push(pt); + } + if (legPath.length < 2) continue; + + const baseLine = new google.maps.Polyline({ + path: legPath, map: this.map, + strokeColor: legColor, strokeOpacity: 0.25, strokeWeight: 6, + zIndex: 1, + }); + this.routeLines.push(baseLine); + + const animLine = new google.maps.Polyline({ + path: legPath, map: this.map, + strokeOpacity: 0, strokeWeight: 0, zIndex: 2, + icons: [{ + icon: { + path: "M 0,-0.5 0,0.5", + strokeOpacity: 0.8, strokeColor: legColor, + strokeWeight: 3, scale: 4, + }, + offset: "0%", repeat: "16px", + }], + }); + this.routeLines.push(animLine); + allAnimLines.push(animLine); + + const arrowLine = new google.maps.Polyline({ + path: legPath, map: this.map, + strokeOpacity: 0, strokeWeight: 0, zIndex: 3, + icons: [{ + icon: { + path: google.maps.SymbolPath.FORWARD_OPEN_ARROW, + scale: 3, strokeColor: legColor, + strokeOpacity: 0.9, strokeWeight: 2.5, + }, + offset: "0%", repeat: "80px", + }], + }); + this.routeLines.push(arrowLine); + allAnimLines.push(arrowLine); + + const dur = leg.duration_in_traffic || leg.duration; + const dist = leg.distance; + if (dur) { + const totalMins = Math.round(dur.value / 60); + const totalKm = dist ? (dist.value / 1000).toFixed(1) : null; + + const destIdx = hasStartLeg ? li : li + 1; + const destTask = destIdx < tasks.length ? tasks[destIdx] : tasks[tasks.length - 1]; + const etaFloat = destTask.time_start || 0; + const etaStr = etaFloat ? floatToTime12(etaFloat) : ""; + + const techName = seg.name; + this.routeLabels.push(this._createTravelLabel( + legPath, totalMins, totalKm, legColor, techName, etaStr, + )); + } + } + + if (!this.routeAnimFrameId) { + this._startRouteAnimation(allAnimLines); + } + }); + } + } + + _pointAlongLeg(leg, fraction) { + const points = []; + for (const step of leg.steps) { + for (const pt of step.path) { + points.push(pt); + } + } + if (points.length < 2) return leg.start_location; + + const segDists = []; + let totalDist = 0; + for (let i = 1; i < points.length; i++) { + const d = google.maps.geometry + ? google.maps.geometry.spherical.computeDistanceBetween(points[i - 1], points[i]) + : this._haversine(points[i - 1], points[i]); + segDists.push(d); + totalDist += d; + } + + const target = totalDist * fraction; + let acc = 0; + for (let i = 0; i < segDists.length; i++) { + if (acc + segDists[i] >= target) { + const remain = target - acc; + const ratio = segDists[i] > 0 ? remain / segDists[i] : 0; + return new google.maps.LatLng( + points[i].lat() + (points[i + 1].lat() - points[i].lat()) * ratio, + points[i].lng() + (points[i + 1].lng() - points[i].lng()) * ratio, + ); + } + acc += segDists[i]; + } + return points[points.length - 1]; + } + + _haversine(a, b) { + const R = 6371000; + const dLat = (b.lat() - a.lat()) * Math.PI / 180; + const dLng = (b.lng() - a.lng()) * Math.PI / 180; + const s = Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat() * Math.PI / 180) * Math.cos(b.lat() * Math.PI / 180) * + Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); + } + + _createTravelLabel(legPath, mins, km, color, techName, eta) { + if (!this._TravelLabel) { + this._TravelLabel = class extends google.maps.OverlayView { + constructor(path, html) { + super(); + this._path = path; + this._html = html; + this._div = null; + } + onAdd() { + this._div = document.createElement("div"); + this._div.style.position = "absolute"; + this._div.style.whiteSpace = "nowrap"; + this._div.style.pointerEvents = "none"; + this._div.style.zIndex = "50"; + this._div.style.transition = "left .3s ease, top .3s ease"; + this._div.innerHTML = this._html; + this.getPanes().floatPane.appendChild(this._div); + } + draw() { + const proj = this.getProjection(); + if (!proj || !this._div) return; + const map = this.getMap(); + if (!map) return; + const bounds = map.getBounds(); + if (!bounds) return; + + const visible = this._path.filter(p => bounds.contains(p)); + if (visible.length === 0) { + this._div.style.display = "none"; + return; + } + this._div.style.display = ""; + + const anchor = visible[Math.floor(visible.length / 2)]; + + const px = proj.fromLatLngToDivPixel(anchor); + if (px) { + this._div.style.left = (px.x - this._div.offsetWidth / 2) + "px"; + this._div.style.top = (px.y - this._div.offsetHeight - 8) + "px"; + } + } + onRemove() { + if (this._div && this._div.parentNode) { + this._div.parentNode.removeChild(this._div); + } + this._div = null; + } + }; + } + + const timeStr = mins < 60 + ? `${mins} min` + : `${Math.floor(mins / 60)}h ${mins % 60}m`; + const distStr = km ? `${km} km` : ""; + + const firstName = techName ? techName.split(" ")[0] : ""; + const html = `
${firstName ? `${firstName}|` : ""}🚗${timeStr}${distStr ? `· ${distStr}` : ""}${eta ? `|ETA ${eta}` : ""}
`; + + const label = new this._TravelLabel(legPath, html); + label.setMap(this.map); + return label; + } + + _startRouteAnimation(animLines) { + let off = 0; + let last = 0; + const animate = (ts) => { + this.routeAnimFrameId = requestAnimationFrame(animate); + if (ts - last < 50) return; + last = ts; + off = (off + 0.08) % 100; + const pct = off + "%"; + for (const line of animLines) { + const icons = line.get("icons"); + if (icons && icons.length > 0) { + icons[0].offset = pct; + line.set("icons", icons); + } + } + }; + this.routeAnimFrameId = requestAnimationFrame(animate); + } + _openTaskPopup(task, marker) { const c = task._dayColor; + const sc = task._statusColor; + const navDest = task.address_lat && task.address_lng + ? `${task.address_lat},${task.address_lng}` + : encodeURIComponent(task.address_display || ""); const html = ` -
-
- #${task._scheduleNum}  ${task.name} -
- ${task._statusLabel} - +
+
+
+ #${task._scheduleNum} ${task.name} + ${task._statusLabel}
+
${task._clientName}
-
-
Client: ${task._clientName}
-
Type: ${task._typeLbl}
-
Technician: ${task._techName}
-
Date: ${task.scheduled_date || ""}
-
Time: ${task._timeRange}
- ${task.address_display ? `
Address: ${task.address_display}
` : ""} - ${task.travel_time_minutes ? `
Travel: ${task.travel_time_minutes} min
` : ""} +
+ + ${task._typeLbl} + + + ${task._timeRange} + + ${task.travel_time_minutes ? `${task.travel_time_minutes} min` : ""}
-
+
+
👤${task._techName}
+
📅${task.scheduled_date || "No date"}
+ ${task.address_display ? `
📍${task.address_display}
` : ""} +
+
- - Navigate → + + Navigate →
`; @@ -590,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; @@ -605,26 +1070,69 @@ export class FusionTaskMapController extends Component { this.state.showTechnicians = !this.state.showTechnicians; this._renderMarkers(); } + toggleRoute() { + this.state.showRoute = !this.state.showRoute; + if (this.state.showRoute) { + this._renderRoute(); + } else { + this._clearRoute(); + } + } onRefresh() { this.state.loading = true; this._loadAndRender(); } - openTask(taskId) { - this.actionService.switchView("form", { resId: taskId }); + async openTask(taskId) { + if (!taskId) return; + try { + await this.actionService.doAction( + { + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + res_id: taskId, + view_mode: "form", + views: [[false, "form"]], + target: "new", + context: { dialog_size: "extra-large" }, + }, + { onClose: () => this._softRefresh() }, + ); + } catch (e) { + console.error("[FusionMap] openTask failed:", e); + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + res_id: taskId, + view_mode: "form", + views: [[false, "form"]], + target: "current", + }); + } } - createNewTask() { - this.actionService.doAction({ - type: "ir.actions.act_window", - res_model: "fusion.technician.task", - views: [[false, "form"]], - target: "new", - context: { default_task_type: "delivery", dialog_size: "extra-large" }, - }, { - onClose: () => { - // Refresh map data after dialog closes (task may have been created) - this.onRefresh(); - }, - }); + async createNewTask() { + try { + await this.actionService.doAction( + { + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + view_mode: "form", + views: [[false, "form"]], + target: "new", + context: { default_task_type: "delivery", dialog_size: "extra-large" }, + }, + { onClose: () => this._softRefresh() }, + ); + } catch (e) { + console.error("[FusionMap] createNewTask failed:", e); + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + view_mode: "form", + views: [[false, "form"]], + target: "current", + context: { default_task_type: "delivery" }, + }); + } } } diff --git a/fusion_claims/static/src/js/google_address_autocomplete.js b/fusion_claims/static/src/js/google_address_autocomplete.js index 178c4a0..9466be8 100644 --- a/fusion_claims/static/src/js/google_address_autocomplete.js +++ b/fusion_claims/static/src/js/google_address_autocomplete.js @@ -1048,331 +1048,7 @@ async function setupSimpleAddressFields(el, orm) { } } -/** - * Setup autocomplete for LTC Facility form. - * Attaches establishment search on the name field and address search on street. - */ -async function setupFacilityAutocomplete(el, model, orm) { - globalOrm = orm; - - const apiKey = await getGoogleMapsApiKey(orm); - if (!apiKey) return; - try { await loadGoogleMapsApi(apiKey); } catch (e) { return; } - - // --- Name field: establishment autocomplete --- - const nameSelectors = [ - '.oe_title [name="name"] input', - 'div[name="name"] input', - '.o_field_widget[name="name"] input', - '[name="name"] input', - ]; - - let nameInput = null; - for (const sel of nameSelectors) { - nameInput = el.querySelector(sel); - if (nameInput) break; - } - - if (nameInput && !autocompleteInstances.has('facility_name_' + (nameInput.id || 'default'))) { - _attachFacilityNameAutocomplete(nameInput, el, model); - } - - // --- Street field: address autocomplete --- - const streetSelectors = [ - 'div[name="street"] input', - '.o_field_widget[name="street"] input', - '[name="street"] input', - ]; - - let streetInput = null; - for (const sel of streetSelectors) { - streetInput = el.querySelector(sel); - if (streetInput) break; - } - - if (streetInput && !autocompleteInstances.has(streetInput)) { - _attachFacilityAddressAutocomplete(streetInput, el, model); - } -} - -/** - * Attach establishment (business) autocomplete on facility name field. - * Selecting a business fills name, address, phone, email, and website. - */ -function _attachFacilityNameAutocomplete(input, el, model) { - if (!input || !window.google?.maps?.places) return; - - const instanceKey = 'facility_name_' + (input.id || 'default'); - if (autocompleteInstances.has(instanceKey)) return; - - const autocomplete = new google.maps.places.Autocomplete(input, { - componentRestrictions: { country: 'ca' }, - types: ['establishment'], - fields: [ - 'place_id', 'name', 'address_components', 'formatted_address', - 'formatted_phone_number', 'international_phone_number', 'website', - ], - }); - - autocomplete.addListener('place_changed', async () => { - let place = autocomplete.getPlace(); - if (!place.name && !place.place_id) return; - - if (place.place_id && !place.formatted_phone_number && !place.website) { - try { - const service = new google.maps.places.PlacesService(document.createElement('div')); - const details = await new Promise((resolve, reject) => { - service.getDetails( - { - placeId: place.place_id, - fields: ['formatted_phone_number', 'international_phone_number', 'website'], - }, - (result, status) => { - if (status === google.maps.places.PlacesServiceStatus.OK) resolve(result); - else reject(new Error(status)); - } - ); - }); - if (details.formatted_phone_number) place.formatted_phone_number = details.formatted_phone_number; - if (details.international_phone_number) place.international_phone_number = details.international_phone_number; - if (details.website) place.website = details.website; - } catch (_) { /* ignore */ } - } - - let streetNumber = '', streetName = '', unitNumber = ''; - let city = '', province = '', postalCode = '', countryCode = ''; - - if (place.address_components) { - for (const c of place.address_components) { - const t = c.types; - if (t.includes('street_number')) streetNumber = c.long_name; - else if (t.includes('route')) streetName = c.long_name; - else if (t.includes('subpremise')) unitNumber = c.long_name; - else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name; - else if (t.includes('locality')) city = c.long_name; - else if (t.includes('sublocality_level_1') && !city) city = c.long_name; - else if (t.includes('administrative_area_level_1')) province = c.short_name; - else if (t.includes('postal_code')) postalCode = c.long_name; - else if (t.includes('country')) countryCode = c.short_name; - } - } - - const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; - const phone = place.formatted_phone_number || place.international_phone_number || ''; - - if (!model?.root) return; - const record = model.root; - - let countryId = null, stateId = null; - if (globalOrm && countryCode) { - try { - const [countries, states] = await Promise.all([ - globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }), - province - ? globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id.code', '=', countryCode]], ['id'], { limit: 1 }) - : Promise.resolve([]), - ]); - if (countries.length) countryId = countries[0].id; - if (states.length) stateId = states[0].id; - } catch (_) { /* ignore */ } - } - - if (record.resId && globalOrm) { - try { - const firstWrite = {}; - if (place.name) firstWrite.name = place.name; - if (street) firstWrite.street = street; - if (unitNumber) firstWrite.street2 = unitNumber; - if (city) firstWrite.city = city; - if (postalCode) firstWrite.zip = postalCode; - if (phone) firstWrite.phone = phone; - if (place.website) firstWrite.website = place.website; - if (countryId) firstWrite.country_id = countryId; - - await globalOrm.write('fusion.ltc.facility', [record.resId], firstWrite); - - if (stateId) { - await new Promise(r => setTimeout(r, 100)); - await globalOrm.write('fusion.ltc.facility', [record.resId], { state_id: stateId }); - } - - await record.load(); - } catch (err) { - console.error('[GooglePlaces Facility] Name autocomplete ORM write failed:', err); - } - } else { - try { - const textUpdate = {}; - if (place.name) textUpdate.name = place.name; - if (street) textUpdate.street = street; - if (unitNumber) textUpdate.street2 = unitNumber; - if (city) textUpdate.city = city; - if (postalCode) textUpdate.zip = postalCode; - if (phone) textUpdate.phone = phone; - if (place.website) textUpdate.website = place.website; - - await record.update(textUpdate); - - if (countryId && globalOrm) { - const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body; - const [countryData, stateData] = await Promise.all([ - globalOrm.read('res.country', [countryId], ['display_name']), - stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]), - ]); - - await simulateMany2OneSelection(formEl, 'country_id', countryId, countryData[0]?.display_name || 'Canada'); - await new Promise(r => setTimeout(r, 300)); - - if (stateId && stateData.length) { - await simulateMany2OneSelection(formEl, 'state_id', stateId, stateData[0]?.display_name || province); - } - } - } catch (err) { - console.error('[GooglePlaces Facility] Name autocomplete update failed:', err); - } - } - }); - - autocompleteInstances.set(instanceKey, autocomplete); - - input.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%232196F3\'%3E%3Cpath d=\'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z\'/%3E%3C/svg%3E")'; - input.style.backgroundRepeat = 'no-repeat'; - input.style.backgroundPosition = 'right 8px center'; - input.style.backgroundSize = '20px'; - input.style.paddingRight = '35px'; -} - -/** - * Attach address autocomplete on facility street field. - * Fills street, street2, city, state, zip, and country. - */ -function _attachFacilityAddressAutocomplete(input, el, model) { - if (!input || !window.google?.maps?.places) return; - if (autocompleteInstances.has(input)) return; - - const autocomplete = new google.maps.places.Autocomplete(input, { - componentRestrictions: { country: 'ca' }, - types: ['address'], - fields: ['address_components', 'formatted_address'], - }); - - autocomplete.addListener('place_changed', async () => { - const place = autocomplete.getPlace(); - if (!place.address_components) return; - - let streetNumber = '', streetName = '', unitNumber = ''; - let city = '', province = '', postalCode = '', countryCode = ''; - - for (const c of place.address_components) { - const t = c.types; - if (t.includes('street_number')) streetNumber = c.long_name; - else if (t.includes('route')) streetName = c.long_name; - else if (t.includes('subpremise')) unitNumber = c.long_name; - else if (t.includes('floor') && !unitNumber) unitNumber = 'Floor ' + c.long_name; - else if (t.includes('locality')) city = c.long_name; - else if (t.includes('sublocality_level_1') && !city) city = c.long_name; - else if (t.includes('administrative_area_level_1')) province = c.short_name; - else if (t.includes('postal_code')) postalCode = c.long_name; - else if (t.includes('country')) countryCode = c.short_name; - } - - const street = streetNumber ? `${streetNumber} ${streetName}` : streetName; - - if (!model?.root) return; - const record = model.root; - - let countryId = null, stateId = null; - if (globalOrm && countryCode) { - try { - const [countries, states] = await Promise.all([ - globalOrm.searchRead('res.country', [['code', '=', countryCode]], ['id'], { limit: 1 }), - province - ? globalOrm.searchRead('res.country.state', [['code', '=', province], ['country_id.code', '=', countryCode]], ['id'], { limit: 1 }) - : Promise.resolve([]), - ]); - if (countries.length) countryId = countries[0].id; - if (states.length) stateId = states[0].id; - } catch (_) { /* ignore */ } - } - - if (record.resId && globalOrm) { - try { - const firstWrite = {}; - if (street) firstWrite.street = street; - if (unitNumber) firstWrite.street2 = unitNumber; - if (city) firstWrite.city = city; - if (postalCode) firstWrite.zip = postalCode; - if (countryId) firstWrite.country_id = countryId; - - await globalOrm.write('fusion.ltc.facility', [record.resId], firstWrite); - - if (stateId) { - await new Promise(r => setTimeout(r, 100)); - await globalOrm.write('fusion.ltc.facility', [record.resId], { state_id: stateId }); - } - - await record.load(); - } catch (err) { - console.error('[GooglePlaces Facility] Address ORM write failed:', err); - } - } else { - try { - const textUpdate = {}; - if (street) textUpdate.street = street; - if (unitNumber) textUpdate.street2 = unitNumber; - if (city) textUpdate.city = city; - if (postalCode) textUpdate.zip = postalCode; - - await record.update(textUpdate); - - if (countryId && globalOrm) { - const formEl = input.closest('.o_form_view') || input.closest('.o_content') || document.body; - const [countryData, stateData] = await Promise.all([ - globalOrm.read('res.country', [countryId], ['display_name']), - stateId ? globalOrm.read('res.country.state', [stateId], ['display_name']) : Promise.resolve([]), - ]); - - await simulateMany2OneSelection(formEl, 'country_id', countryId, countryData[0]?.display_name || 'Canada'); - await new Promise(r => setTimeout(r, 300)); - - if (stateId && stateData.length) { - await simulateMany2OneSelection(formEl, 'state_id', stateId, stateData[0]?.display_name || province); - } - } - } catch (err) { - console.error('[GooglePlaces Facility] Address autocomplete update failed:', err); - } - } - - setTimeout(() => { _reattachFacilityAutocomplete(el, model); }, 400); - }); - - autocompleteInstances.set(input, autocomplete); - - input.style.backgroundImage = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 24 24\' fill=\'%234CAF50\'%3E%3Cpath d=\'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z\'/%3E%3C/svg%3E")'; - input.style.backgroundRepeat = 'no-repeat'; - input.style.backgroundPosition = 'right 8px center'; - input.style.backgroundSize = '20px'; - input.style.paddingRight = '35px'; -} - -/** - * Re-attach facility autocomplete after OWL re-renders inputs. - */ -function _reattachFacilityAutocomplete(el, model) { - const streetSelectors = [ - 'div[name="street"] input', - '.o_field_widget[name="street"] input', - '[name="street"] input', - ]; - for (const sel of streetSelectors) { - const inp = el.querySelector(sel); - if (inp && !autocompleteInstances.has(inp)) { - _attachFacilityAddressAutocomplete(inp, el, model); - break; - } - } -} +/** REMOVED: LTC Facility autocomplete functions moved to fusion_ltc_management */ /** * Patch FormController to add Google autocomplete for partner forms and dialog detection @@ -1490,35 +1166,6 @@ patch(FormController.prototype, { } } - // LTC Facility form - if (this.props.resModel === 'fusion.ltc.facility') { - setTimeout(() => { - if (this.rootRef && this.rootRef.el) { - setupFacilityAutocomplete(this.rootRef.el, this.model, this.orm); - } - }, 800); - - if (this.rootRef && this.rootRef.el) { - this._facilityAddrObserver = new MutationObserver((mutations) => { - const hasNewInputs = mutations.some(m => - m.addedNodes.length > 0 && - Array.from(m.addedNodes).some(n => - n.nodeType === 1 && (n.tagName === 'INPUT' || n.querySelector?.('input')) - ) - ); - if (hasNewInputs) { - setTimeout(() => { - setupFacilityAutocomplete(this.rootRef.el, this.model, this.orm); - }, 300); - } - }); - this._facilityAddrObserver.observe(this.rootRef.el, { - childList: true, - subtree: true, - }); - } - } - // Simple address autocomplete: res.partner, res.users, res.config.settings if (this.props.resModel === 'res.partner' || this.props.resModel === 'res.users' || this.props.resModel === 'res.config.settings') { setTimeout(() => { @@ -1556,9 +1203,6 @@ patch(FormController.prototype, { if (this._taskAddressObserver) { this._taskAddressObserver.disconnect(); } - if (this._facilityAddrObserver) { - this._facilityAddrObserver.disconnect(); - } if (this._simpleAddrObserver) { this._simpleAddrObserver.disconnect(); } diff --git a/fusion_claims/static/src/scss/fusion_claims.scss b/fusion_claims/static/src/scss/fusion_claims.scss index 08ae70d..6c56a18 100644 --- a/fusion_claims/static/src/scss/fusion_claims.scss +++ b/fusion_claims/static/src/scss/fusion_claims.scss @@ -928,99 +928,3 @@ html.dark, .o_dark { } - -// ======================================================================== -// AI CHAT: Table and response styling for Fusion Claims Intelligence -// ======================================================================== -.o-mail-Message-body, -.o-mail-Message-textContent, -.o_mail_body_content { - table { - width: 100%; - border-collapse: collapse; - margin: 8px 0; - font-size: 12px; - line-height: 1.4; - - th, td { - border: 1px solid rgba(150, 150, 150, 0.4); - padding: 5px 8px; - text-align: left; - vertical-align: top; - } - - th { - background-color: rgba(100, 100, 100, 0.15); - font-weight: 600; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.3px; - white-space: nowrap; - } - - td { - white-space: nowrap; - } - - tr:nth-child(even) td { - background-color: rgba(100, 100, 100, 0.05); - } - - tr:hover td { - background-color: rgba(100, 100, 100, 0.1); - } - } - - h3 { - font-size: 14px; - font-weight: 700; - margin: 12px 0 6px 0; - padding-bottom: 4px; - border-bottom: 1px solid rgba(150, 150, 150, 0.3); - } - - h4 { - font-size: 13px; - font-weight: 600; - margin: 10px 0 4px 0; - } - - strong { - font-weight: 600; - } - - code { - background-color: rgba(100, 100, 100, 0.1); - padding: 1px 4px; - border-radius: 3px; - font-size: 11px; - } - - ul, ol { - margin: 4px 0; - padding-left: 20px; - } - - li { - margin-bottom: 2px; - } -} - -html.dark .o-mail-Message-body, -html.dark .o-mail-Message-textContent, -html.dark .o_mail_body_content, -.o_dark .o-mail-Message-body, -.o_dark .o-mail-Message-textContent, -.o_dark .o_mail_body_content { - table { - th, td { - border-color: rgba(200, 200, 200, 0.2); - } - th { - background-color: rgba(200, 200, 200, 0.1); - } - tr:nth-child(even) td { - background-color: rgba(200, 200, 200, 0.04); - } - } -} 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 f41cd92..cd031da 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 @@
+ + + +
+ + + + +
+
@@ -113,6 +129,11 @@ + + Edit +
@@ -170,6 +191,11 @@ Upcoming Yesterday +
-

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

@@ -501,25 +370,6 @@
- -

Portal Forms

-
-
-
- LTC Form Access Password -
- Set a password to protect the public LTC repair form. - Share this with facility staff so they can submit repair requests. - Minimum 4 characters. Leave empty to allow unrestricted access. -
-
- -
-
-
-
-

Portal Branding

diff --git a/fusion_claims/views/res_partner_views.xml b/fusion_claims/views/res_partner_views.xml index 44f74af..824238d 100644 --- a/fusion_claims/views/res_partner_views.xml +++ b/fusion_claims/views/res_partner_views.xml @@ -16,32 +16,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/fusion_claims/views/sale_order_views.xml b/fusion_claims/views/sale_order_views.xml index 0ce3ba4..bcde3d2 100644 --- a/fusion_claims/views/sale_order_views.xml +++ b/fusion_claims/views/sale_order_views.xml @@ -2467,28 +2467,6 @@ sale.order - - - - - - - - - - - - - - - - - diff --git a/fusion_claims/views/sale_portal_templates.xml b/fusion_claims/views/sale_portal_templates.xml deleted file mode 100644 index e1e6be9..0000000 --- a/fusion_claims/views/sale_portal_templates.xml +++ /dev/null @@ -1,416 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/fusion_claims/views/task_sync_views.xml b/fusion_claims/views/task_sync_views.xml deleted file mode 100644 index 5d8862f..0000000 --- a/fusion_claims/views/task_sync_views.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - fusion.task.sync.config.form - fusion.task.sync.config - -
-
-
- -
-

-
- - - - - - - - - - - - - - -
- - Technicians are matched across instances by their - Tech Sync ID field (Settings > Users). - Set the same ID (e.g. "gordy") on both instances for each shared technician. -
-
- -
-
- - - - - - fusion.task.sync.config.list - fusion.task.sync.config - - - - - - - - - - - - - - - - - Task Sync Instances - fusion.task.sync.config - list,form - - - - -
diff --git a/fusion_claims/views/technician_location_views.xml b/fusion_claims/views/technician_location_views.xml deleted file mode 100644 index 9d0e24e..0000000 --- a/fusion_claims/views/technician_location_views.xml +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - - fusion.technician.location.list - fusion.technician.location - - - - - - - - - - - - - - - - - fusion.technician.location.form - fusion.technician.location - -
- - - - - - - - - - - - - - -
-
-
- - - - - - fusion.technician.location.search - fusion.technician.location - - - - - - - - - - - - - - - - - - - - Location History - fusion.technician.location - list,form - - { - 'search_default_filter_today': 1, - 'search_default_group_user': 1, - } - -

- No location data logged yet. -

-

Technician locations are automatically logged when they use the portal.

-
-
- - - - - - Technician Map - /my/technician/admin/map - self - - - - - - - - - - - - Cleanup Old Technician Locations - - code - model._cron_cleanup_old_locations() - 1 - days - True - - -
diff --git a/fusion_claims/views/technician_task_views.xml b/fusion_claims/views/technician_task_views.xml index b2261e6..c9cb4ee 100644 --- a/fusion_claims/views/technician_task_views.xml +++ b/fusion_claims/views/technician_task_views.xml @@ -1,540 +1,97 @@ + - + - - 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} - - - - - - - - - - - - - - - - - - - - - - + diff --git a/fusion_claims/wizard/__init__.py b/fusion_claims/wizard/__init__.py index b5f91b1..90d36dd 100644 --- a/fusion_claims/wizard/__init__.py +++ b/fusion_claims/wizard/__init__.py @@ -30,4 +30,4 @@ 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 send_page11_wizard \ No newline at end of file diff --git a/fusion_claims/wizard/application_received_wizard.py b/fusion_claims/wizard/application_received_wizard.py index 5cffd36..610e5f7 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 063362b..5eb8980 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/assessment_completed_wizard.py b/fusion_claims/wizard/assessment_completed_wizard.py index 5d7fd82..14dd29d 100644 --- a/fusion_claims/wizard/assessment_completed_wizard.py +++ b/fusion_claims/wizard/assessment_completed_wizard.py @@ -11,7 +11,6 @@ _logger = logging.getLogger(__name__) class AssessmentCompletedWizard(models.TransientModel): - """Wizard to record assessment completion date.""" _name = 'fusion_claims.assessment.completed.wizard' _description = 'Assessment Completed Wizard' @@ -21,18 +20,49 @@ class AssessmentCompletedWizard(models.TransientModel): required=True, readonly=True, ) - + + is_override = fields.Boolean( + string='Scheduling Override', + compute='_compute_is_override', + store=False, + ) + + assessment_start_date = fields.Date( + string='Assessment Start Date', + required=True, + help='Date the assessment was conducted', + ) + completion_date = fields.Date( string='Assessment Completion Date', required=True, default=fields.Date.context_today, ) - + notes = fields.Text( - string='Assessment Notes', - help='Any notes from the assessment', + string='Notes', + help='Notes from the assessment', ) + override_reason = fields.Text( + string='Override Reason', + help='Mandatory when skipping the scheduling step. Explain why the assessment was completed without scheduling through the system.', + ) + + notify_authorizer = fields.Boolean( + string='Notify Authorizer', + default=True, + help='Send email to the authorizer about assessment completion', + ) + + @api.depends('sale_order_id') + def _compute_is_override(self): + for rec in self: + rec.is_override = ( + rec.sale_order_id + and rec.sale_order_id.x_fc_adp_application_status == 'quotation' + ) + @api.model def default_get(self, fields_list): res = super().default_get(fields_list) @@ -40,43 +70,174 @@ class AssessmentCompletedWizard(models.TransientModel): if active_id: order = self.env['sale.order'].browse(active_id) res['sale_order_id'] = order.id + if order.x_fc_assessment_start_date: + res['assessment_start_date'] = order.x_fc_assessment_start_date + else: + res['assessment_start_date'] = fields.Date.context_today(self) return res def action_complete(self): """Mark assessment as completed.""" self.ensure_one() - + order = self.sale_order_id - - if order.x_fc_adp_application_status != 'assessment_scheduled': - raise UserError("Can only complete assessment from 'Assessment Scheduled' status.") - - # Validate completion date is not before start date - if order.x_fc_assessment_start_date and self.completion_date < order.x_fc_assessment_start_date: + current_status = order.x_fc_adp_application_status + is_override = current_status == 'quotation' + + if current_status not in ('quotation', 'assessment_scheduled'): raise UserError( - f"Completion date ({self.completion_date}) cannot be before " - f"assessment start date ({order.x_fc_assessment_start_date})." + _("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.") ) - - # Update sale order - order.with_context(skip_status_validation=True).write({ + + if is_override and not (self.override_reason or '').strip(): + raise UserError( + _("Override Reason is mandatory when skipping the assessment scheduling step. " + "Please explain why this assessment was completed without being scheduled through the system.") + ) + + if self.completion_date < self.assessment_start_date: + raise UserError( + _("Completion date (%s) cannot be before assessment start date (%s).") + % (self.completion_date, self.assessment_start_date) + ) + + write_vals = { 'x_fc_adp_application_status': 'assessment_completed', 'x_fc_assessment_end_date': self.completion_date, - }) - - # Post to chatter - notes_html = f'

Notes: {self.notes}

' if self.notes else '' - - order.message_post( - body=Markup( - '
' - '

Assessment Completed

' - f'

Completion Date: {self.completion_date.strftime("%B %d, %Y")}

' - f'{notes_html}' + } + if is_override or not order.x_fc_assessment_start_date: + write_vals['x_fc_assessment_start_date'] = self.assessment_start_date + + order.with_context(skip_status_validation=True).write(write_vals) + + if is_override: + override_html = Markup( + '
' + '

' + ' Assessment Scheduling Override

' + '

Override by: %s

' + '

Reason: %s

' + '

Assessment Date: %s to %s

' + '%s' '
' - ), - message_type='notification', - subtype_xmlid='mail.mt_note', - ) - + ) % ( + self.env.user.name, + self.override_reason.strip(), + self.assessment_start_date.strftime("%B %d, %Y"), + self.completion_date.strftime("%B %d, %Y"), + Markup('

Notes: %s

') % self.notes if self.notes else Markup(''), + ) + order.message_post( + body=override_html, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + else: + notes_html = ( + Markup('

Notes: %s

') % self.notes + ) if self.notes else Markup('') + + order.message_post( + body=Markup( + '
' + '

' + ' Assessment Completed

' + '

Completion Date: %s

' + '%s' + '
' + ) % (self.completion_date.strftime("%B %d, %Y"), notes_html), + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + + if self.notify_authorizer: + self._send_backend_completion_email(order, is_override) + return {'type': 'ir.actions.act_window_close'} + + def _send_backend_completion_email(self, order, is_override): + """Send assessment completion email when done from backend.""" + self.ensure_one() + + if not order._email_is_enabled(): + return + + authorizer = order.x_fc_authorizer_id + if not authorizer or not authorizer.email: + _logger.info("No authorizer email for %s, skipping notification", order.name) + return + + to_email = authorizer.email + cc_emails = [] + if order.user_id and order.user_id.email: + cc_emails.append(order.user_id.email) + company = self.env.company + office_partners = company.sudo().x_fc_office_notification_ids + cc_emails.extend([p.email for p in office_partners if p.email]) + + client_name = order.partner_id.name or 'Client' + override_note = '' + if is_override: + override_note = ( + '
' + 'Note: This assessment was completed without being scheduled ' + 'through the system. ' + f'Reason: {self.override_reason.strip()}' + '
' + ) + + sections = [ + ('Assessment Details', [ + ('Client', client_name), + ('Case', order.name), + ('Assessment Date', f"{self.assessment_start_date.strftime('%B %d, %Y')} to {self.completion_date.strftime('%B %d, %Y')}"), + ('Completed by', self.env.user.name), + ]), + ] + if self.notes: + sections.append(('Notes', [('', self.notes)])) + + summary = ( + f'The assessment for {client_name} ({order.name}) ' + f'has been completed on {self.completion_date.strftime("%B %d, %Y")}.' + ) + if is_override: + summary += f' {override_note}' + + email_body = order._email_build( + title='Assessment Completed', + summary=summary, + email_type='success', + sections=sections, + note='Next step: Please submit the ADP application ' + '(including pages 11-12 signed by the client) so we can proceed.', + button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form', + button_text='View Case', + sender_name=order.user_id.name if order.user_id else 'The Team', + ) + + try: + self.env['mail.mail'].sudo().create({ + 'subject': f'Assessment Completed - {client_name} - {order.name}', + 'body_html': email_body, + 'email_to': to_email, + 'email_cc': ', '.join(cc_emails) if cc_emails else False, + 'model': 'sale.order', + 'res_id': order.id, + 'auto_delete': True, + }).send() + + order.message_post( + body=Markup( + '' + ) % (to_email, ', '.join(cc_emails) or 'None'), + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + _logger.info("Sent backend assessment completed email for %s", order.name) + except Exception as e: + _logger.error("Failed to send assessment completed email for %s: %s", order.name, e) diff --git a/fusion_claims/wizard/assessment_completed_wizard_views.xml b/fusion_claims/wizard/assessment_completed_wizard_views.xml index 6b86a6f..cd5e074 100644 --- a/fusion_claims/wizard/assessment_completed_wizard_views.xml +++ b/fusion_claims/wizard/assessment_completed_wizard_views.xml @@ -1,18 +1,41 @@ - fusion_claims.assessment.completed.wizard.form fusion_claims.assessment.completed.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 50d3a55..21397b8 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 d903cc2..8cd2b83 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 @@ + + +
    +
    +
    +
    +
    + + + + + + diff --git a/fusion_shipping/views/res_partner_views.xml b/fusion_shipping/views/res_partner_views.xml new file mode 100644 index 0000000..ee35afa --- /dev/null +++ b/fusion_shipping/views/res_partner_views.xml @@ -0,0 +1,15 @@ + + + + res.partner.form.inherit.fusion.shipping + res.partner + + + + + + + + diff --git a/fusion_shipping/views/sale_order_views.xml b/fusion_shipping/views/sale_order_views.xml new file mode 100644 index 0000000..88ff3f2 --- /dev/null +++ b/fusion_shipping/views/sale_order_views.xml @@ -0,0 +1,49 @@ + + + + sale.order.form.fusion.shipping + sale.order + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_shipping/views/stock_picking_views.xml b/fusion_shipping/views/stock_picking_views.xml new file mode 100644 index 0000000..eb9f173 --- /dev/null +++ b/fusion_shipping/views/stock_picking_views.xml @@ -0,0 +1,18 @@ + + + + stock.picking.form.fusion.shipping + stock.picking + + + + + + + + diff --git a/fusion_shipping/wizard/__init__.py b/fusion_shipping/wizard/__init__.py new file mode 100644 index 0000000..d412c17 --- /dev/null +++ b/fusion_shipping/wizard/__init__.py @@ -0,0 +1 @@ +from . import choose_delivery_fusion_rate diff --git a/fusion_shipping/wizard/choose_delivery_fusion_rate.py b/fusion_shipping/wizard/choose_delivery_fusion_rate.py new file mode 100644 index 0000000..dba0557 --- /dev/null +++ b/fusion_shipping/wizard/choose_delivery_fusion_rate.py @@ -0,0 +1,446 @@ +import json + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class ChooseDeliveryFusionPackage(models.TransientModel): + """One package row in the Add Shipping wizard.""" + + _name = 'choose.delivery.fusion.package' + _description = 'Shipping Package (Wizard)' + _order = 'sequence, id' + + wizard_id = fields.Many2one( + 'choose.delivery.carrier', + string='Wizard', + ondelete='cascade', + ) + sequence = fields.Integer(default=10) + package_type_id = fields.Many2one( + 'stock.package.type', + string='Box Type', + domain="[('package_carrier_type', '=', 'fusion_canada_post')]", + ) + package_length = fields.Float(string='Length') + package_width = fields.Float(string='Width') + package_height = fields.Float(string='Height') + weight = fields.Float(string='Weight') + + # Per-package cost for the selected service (updated on service select) + selected_price = fields.Float( + string='Cost', digits='Product Price', readonly=True) + currency_id = fields.Many2one( + 'res.currency', + related='wizard_id.currency_id', + ) + + @api.onchange('package_type_id') + def _onchange_package_type_id(self): + """Pre-fill dimensions from selected box type.""" + if self.package_type_id: + self.package_length = self.package_type_id.packaging_length + self.package_width = self.package_type_id.width + self.package_height = self.package_type_id.height + + +class ChooseDeliveryFusionRate(models.TransientModel): + _name = 'choose.delivery.fusion.rate' + _description = 'Shipping Rate Option' + + wizard_id = fields.Many2one( + 'choose.delivery.carrier', + string='Wizard', + ondelete='cascade', + ) + service_code = fields.Char(string='Service Code') + service_name = fields.Char(string='Service') + price = fields.Float(string='Shipping Cost', digits='Product Price') + expected_delivery = fields.Char(string='Expected Delivery Date') + is_selected = fields.Boolean(string='Selected', default=False) + currency_id = fields.Many2one( + 'res.currency', + related='wizard_id.currency_id', + ) + # JSON: [{"pkg_id": , "price": }, ...] + per_package_prices = fields.Text(string='Per-Package Prices') + + def action_select(self): + """Select this rate and deselect others. Update per-package costs.""" + self.ensure_one() + # Deselect all, then select this one + self.wizard_id.fusion_rate_ids.write({'is_selected': False}) + self.is_selected = True + + # Update per-package costs from stored JSON + if self.per_package_prices: + try: + pkg_prices = json.loads(self.per_package_prices) + for pp in pkg_prices: + pkg = self.env['choose.delivery.fusion.package'].browse( + pp['pkg_id']) + if pkg.exists(): + pkg.selected_price = pp['price'] + except (json.JSONDecodeError, KeyError): + pass + + # Apply margin from the carrier + carrier = self.wizard_id.carrier_id + price = self.price + if carrier: + price = carrier._apply_margins(price, self.wizard_id.order_id) + # Check free_over + if carrier.free_over: + order = self.wizard_id.order_id + amount = order.currency_id._convert( + order.amount_untaxed, + order.company_id.currency_id, + order.company_id, + fields.Date.today(), + ) + if amount >= carrier.amount: + price = 0.0 + + self.wizard_id.write({ + 'delivery_price': price, + 'display_price': price, + 'fusion_selected_service': self.service_code, + 'fusion_selected_service_name': self.service_name, + 'fusion_selected_expected_delivery': self.expected_delivery, + }) + # Re-open the wizard to show updated selection + return { + 'name': _('Add a shipping method'), + 'type': 'ir.actions.act_window', + 'res_model': 'choose.delivery.carrier', + 'res_id': self.wizard_id.id, + 'view_mode': 'form', + 'target': 'new', + } + + +class ChooseDeliveryCarrier(models.TransientModel): + _inherit = 'choose.delivery.carrier' + + fusion_rate_ids = fields.One2many( + 'choose.delivery.fusion.rate', + 'wizard_id', + string='Available Services', + ) + fusion_selected_service = fields.Char( + string='Selected Service Code', + ) + fusion_selected_service_name = fields.Char( + string='Selected Service Name', + ) + fusion_selected_expected_delivery = fields.Char( + string='Selected Expected Delivery', + ) + + # -- Package list -- + fusion_package_ids = fields.One2many( + 'choose.delivery.fusion.package', + 'wizard_id', + string='Packages', + ) + + # -- Unit labels -- + fusion_dimension_unit_label = fields.Char( + string='Dimension Unit', + compute='_compute_fusion_dimension_unit_label', + ) + fusion_weight_unit_label = fields.Char( + string='Weight Unit', + compute='_compute_fusion_weight_unit_label', + ) + + @api.depends('carrier_id') + def _compute_fusion_dimension_unit_label(self): + for rec in self: + if (rec.carrier_id + and rec.carrier_id.delivery_type == 'fusion_canada_post'): + rec.fusion_dimension_unit_label = ( + rec.carrier_id.fusion_cp_dimension_unit or 'cm') + else: + rec.fusion_dimension_unit_label = '' + + @api.depends('carrier_id') + def _compute_fusion_weight_unit_label(self): + for rec in self: + if (rec.carrier_id + and rec.carrier_id.delivery_type == 'fusion_canada_post'): + uom = (rec.order_id.company_id + .weight_unit_of_measurement_id) + rec.fusion_weight_unit_label = uom.name if uom else 'kg' + else: + rec.fusion_weight_unit_label = '' + + @api.onchange('carrier_id') + def _onchange_carrier_id_fusion_packages(self): + """When a CP carrier is selected, create one default package.""" + if (self.carrier_id + and self.carrier_id.delivery_type == 'fusion_canada_post' + and not self.fusion_package_ids): + vals = { + 'sequence': 10, + 'weight': self.total_weight or 0.0, + } + # Pre-fill from default package type if set on carrier + if self.carrier_id.product_packaging_id: + pkg = self.carrier_id.product_packaging_id + vals['package_type_id'] = pkg.id + vals['package_length'] = pkg.packaging_length + vals['package_width'] = pkg.width + vals['package_height'] = pkg.height + self.fusion_package_ids = [(5, 0, 0), (0, 0, vals)] + + # -- Rate fetching -- + + def update_price(self): + """Override: for Canada Post, fetch all rates for all packages.""" + if self.carrier_id.delivery_type == 'fusion_canada_post': + return self._update_fusion_rates() + return super().update_price() + + def _get_fusion_package_info_for_pkg(self, pkg): + """Build package_info dict for a single package, converted to cm.""" + carrier = self.carrier_id + return { + 'length': round(carrier._fusion_cp_convert_dimension_to_cm( + pkg.package_length), 1), + 'width': round(carrier._fusion_cp_convert_dimension_to_cm( + pkg.package_width), 1), + 'height': round(carrier._fusion_cp_convert_dimension_to_cm( + pkg.package_height), 1), + } + + def _update_fusion_rates(self): + """Fetch shipping service rates for every package and aggregate.""" + carrier = self.carrier_id + packages = self.fusion_package_ids + + if not packages: + raise UserError(_( + "Please add at least one package with dimensions.")) + + from_unit = (self.order_id.company_id + .weight_unit_of_measurement_id) + + # -- Validate every package -- + for pkg in packages: + if not (pkg.package_length and pkg.package_width + and pkg.package_height): + raise UserError(_( + "Please enter dimensions (L x W x H) " + "for all packages.")) + if not pkg.weight: + raise UserError(_( + "Please enter weight for all packages.")) + package_info = self._get_fusion_package_info_for_pkg(pkg) + weight_kg = pkg.weight + if from_unit: + weight_kg = round(carrier.convert_weight( + from_unit, carrier.weight_uom_id, weight_kg), 2) + carrier._fusion_cp_validate_package(weight_kg, package_info) + + # -- Clear old rates -- + self.fusion_rate_ids.unlink() + + # -- Fetch rates per package -- + # {service_code: {service_name, packages: [{pkg_id, price, exp}]}} + all_service_rates = {} + + for pkg in packages: + package_info = self._get_fusion_package_info_for_pkg(pkg) + carrier_ctx = carrier.with_context( + order_weight=pkg.weight, # raw, in company UOM + cp_package_info=package_info, + ) + rates = carrier_ctx.fusion_canada_post_rate_shipment_all( + self.order_id) + + if isinstance(rates, dict) and rates.get('error_message'): + raise UserError(rates['error_message']) + + if not rates: + raise UserError(_( + "No shipping prices available for this order.")) + + for rate in rates: + code = rate['service_code'] + if code not in all_service_rates: + all_service_rates[code] = { + 'service_name': rate['service_name'], + 'packages': [], + } + all_service_rates[code]['packages'].append({ + 'pkg_id': pkg.id, + 'price': rate['price'], + 'expected_delivery': rate.get( + 'expected_delivery', ''), + }) + + # -- Keep only services available for ALL packages -- + num_packages = len(packages) + available = { + code: data + for code, data in all_service_rates.items() + if len(data['packages']) == num_packages + } + + if not available: + raise UserError(_( + "No single shipping service covers all packages. " + "Try adjusting package dimensions or weight.")) + + # -- Find cheapest service -- + cheapest_code = min( + available.keys(), + key=lambda c: sum( + p['price'] for p in available[c]['packages'])) + + # -- Create combined rate lines -- + vals_list = [] + for code, data in available.items(): + total_price = sum(p['price'] for p in data['packages']) + expected_dates = [ + p['expected_delivery'] for p in data['packages'] + if p['expected_delivery'] + ] + expected = max(expected_dates) if expected_dates else '' + is_sel = (code == cheapest_code) + vals_list.append({ + 'wizard_id': self.id, + 'service_code': code, + 'service_name': data['service_name'], + 'price': total_price, + 'expected_delivery': expected, + 'is_selected': is_sel, + 'per_package_prices': json.dumps(data['packages']), + }) + + self.env['choose.delivery.fusion.rate'].create(vals_list) + + # -- Auto-select cheapest: update packages and wizard -- + cheapest_data = available[cheapest_code] + selected_price = sum( + p['price'] for p in cheapest_data['packages']) + + for pkg_data in cheapest_data['packages']: + pkg_rec = packages.filtered( + lambda p, pid=pkg_data['pkg_id']: p.id == pid) + if pkg_rec: + pkg_rec.selected_price = pkg_data['price'] + + # Apply carrier margins + price = selected_price + if self.carrier_id: + price = self.carrier_id._apply_margins( + price, self.order_id) + if self.carrier_id.free_over: + amount = self.order_id.currency_id._convert( + self.order_id.amount_untaxed, + self.order_id.company_id.currency_id, + self.order_id.company_id, + fields.Date.today(), + ) + if amount >= self.carrier_id.amount: + price = 0.0 + + expected_dates = [ + p['expected_delivery'] for p in cheapest_data['packages'] + if p['expected_delivery'] + ] + + self.write({ + 'delivery_price': price, + 'display_price': price, + 'fusion_selected_service': cheapest_code, + 'fusion_selected_service_name': ( + cheapest_data['service_name']), + 'fusion_selected_expected_delivery': ( + max(expected_dates) if expected_dates else ''), + }) + + return { + 'name': _('Add a shipping method'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'choose.delivery.carrier', + 'res_id': self.id, + 'target': 'new', + } + + # -- Confirm -- + + def button_confirm(self): + """Override: store per-package info on the sale order and enhance + the delivery line description.""" + if self.carrier_id.delivery_type == 'fusion_canada_post': + order = self.order_id + + # Clear previous package records + order.fusion_package_ids.unlink() + + selected_code = self.fusion_selected_service or '' + selected_name = self.fusion_selected_service_name or '' + selected_delivery = ( + self.fusion_selected_expected_delivery or '') + + pkg_vals = [] + for idx, pkg in enumerate( + self.fusion_package_ids.sorted('sequence')): + pkg_vals.append((0, 0, { + 'sequence': (idx + 1) * 10, + 'package_type_id': ( + pkg.package_type_id.id + if pkg.package_type_id else False), + 'package_length': pkg.package_length, + 'package_width': pkg.package_width, + 'package_height': pkg.package_height, + 'weight': pkg.weight, + 'service_code': selected_code, + 'service_name': selected_name, + 'price': pkg.selected_price, + 'expected_delivery': selected_delivery, + })) + + write_vals = { + 'fusion_package_ids': pkg_vals, + 'fusion_cp_service_code': selected_code, + } + + # Backward compat: first-package dims in legacy fields + first_pkg = self.fusion_package_ids.sorted('sequence')[:1] + if first_pkg: + write_vals['fusion_cp_package_length'] = ( + first_pkg.package_length) + write_vals['fusion_cp_package_width'] = ( + first_pkg.package_width) + write_vals['fusion_cp_package_height'] = ( + first_pkg.package_height) + + order.write(write_vals) + + res = super().button_confirm() + + # Enhance delivery line description with service details + if (self.carrier_id.delivery_type == 'fusion_canada_post' + and self.fusion_selected_service_name): + delivery_line = self.order_id.order_line.filtered( + 'is_delivery') + if delivery_line: + line = delivery_line[-1] + parts = [line.name] + parts.append( + "Service: %s" + % self.fusion_selected_service_name) + num_pkgs = len(self.fusion_package_ids) + if num_pkgs > 1: + parts.append("Packages: %d" % num_pkgs) + if self.fusion_selected_expected_delivery: + parts.append( + "Expected Delivery: %s" + % self.fusion_selected_expected_delivery) + line.name = '\n'.join(parts) + return res diff --git a/fusion_tasks/static/description/icon.png b/fusion_tasks/static/description/icon.png new file mode 100644 index 0000000..5738185 Binary files /dev/null and b/fusion_tasks/static/description/icon.png differ diff --git a/fusion_tasks/static/src/js/fusion_task_map_view.js b/fusion_tasks/static/src/js/fusion_task_map_view.js index 9114aef..c575e12 100644 --- a/fusion_tasks/static/src/js/fusion_task_map_view.js +++ b/fusion_tasks/static/src/js/fusion_task_map_view.js @@ -106,10 +106,7 @@ function numberedPinUri(fill, number) { // ── Helpers ───────────────────────────────────────────────────────── let _gmapsPromise = null; function loadGoogleMaps(apiKey) { - if (window.google && window.google.maps) { - console.info("[FusionMap] Google Maps JS already loaded, reusing existing instance."); - return Promise.resolve(); - } + if (window.google && window.google.maps) return Promise.resolve(); if (_gmapsPromise) return _gmapsPromise; _gmapsPromise = new Promise((resolve, reject) => { const cb = "_fc_gmap_" + Date.now(); @@ -720,10 +717,7 @@ export class FusionTaskMapController extends Component { trafficModel: "bestguess", }, }, (result, status) => { - if (status !== "OK" || !result.routes || !result.routes[0]) { - console.warn("[FusionMap] Directions API returned:", status, "for", seg.name); - return; - } + if (status !== "OK" || !result.routes || !result.routes[0]) return; const route = result.routes[0]; diff --git a/fusion_tasks/views/technician_task_views.xml b/fusion_tasks/views/technician_task_views.xml index 054333a..0406458 100644 --- a/fusion_tasks/views/technician_task_views.xml +++ b/fusion_tasks/views/technician_task_views.xml @@ -473,7 +473,15 @@ groups="fusion_tasks.group_field_technician" sequence="45"/> - + + + + - - -