feat: separate fusion field service and LTC into standalone modules, update core modules

- fusion_claims: separated field service logic, updated controllers/views
- fusion_tasks: updated task views and map integration
- fusion_authorizer_portal: added page 11 signing, schedule booking, migrations
- fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator)
- fusion_ltc_management: new standalone LTC management module
This commit is contained in:
2026-03-11 16:19:52 +00:00
parent 1f79cdcaaf
commit 431052920e
274 changed files with 52782 additions and 7302 deletions

View File

@@ -4,7 +4,6 @@
# Part of the Fusion Claim Assistant product family.
from . import models
from . import controllers
from . import wizard

View File

@@ -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',
],

View File

@@ -1,2 +0,0 @@
# -*- coding: utf-8 -*-
from . import portal

View File

@@ -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),
}

View File

@@ -26,7 +26,7 @@ ai['result'] = record._fc_tool_search_clients(search_term, city_filter, conditio
<field name="code">
ai['result'] = record._fc_tool_client_details(profile_id)
</field>
<field name="ai_tool_description">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.</field>
<field name="ai_tool_description">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.</field>
<field name="ai_tool_schema">{"type": "object", "properties": {"profile_id": {"type": "number", "description": "ID of the client profile to get details for"}}, "required": ["profile_id"]}</field>
</record>
@@ -39,75 +39,21 @@ ai['result'] = record._fc_tool_client_details(profile_id)
<field name="code">
ai['result'] = record._fc_tool_claims_stats()
</field>
<field name="ai_tool_description">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.</field>
<field name="ai_tool_description">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.</field>
<field name="ai_tool_schema">{"type": "object", "properties": {}, "required": []}</field>
</record>
<!-- Tool 4: Client Status Lookup (by name) -->
<record id="ai_tool_client_status" model="ir.actions.server">
<field name="name">Fusion: Client Status Lookup</field>
<field name="state">code</field>
<field name="use_in_ai" eval="True"/>
<field name="model_id" ref="ai.model_ai_agent"/>
<field name="code">
ai['result'] = record._fc_tool_client_status(client_name)
</field>
<field name="ai_tool_description">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".</field>
<field name="ai_tool_schema">{"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"]}</field>
</record>
<!-- Tool 5: ADP Billing Period Summary -->
<record id="ai_tool_adp_billing" model="ir.actions.server">
<field name="name">Fusion: ADP Billing Period</field>
<field name="state">code</field>
<field name="use_in_ai" eval="True"/>
<field name="model_id" ref="ai.model_ai_agent"/>
<field name="code">
ai['result'] = record._fc_tool_adp_billing_period(period)
</field>
<field name="ai_tool_description">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.</field>
<field name="ai_tool_schema">{"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": []}</field>
</record>
<!-- Tool 6: Demographics & Analytics -->
<record id="ai_tool_demographics" model="ir.actions.server">
<field name="name">Fusion: Demographics &amp; Analytics</field>
<field name="state">code</field>
<field name="use_in_ai" eval="True"/>
<field name="model_id" ref="ai.model_ai_agent"/>
<field name="code">
ai['result'] = record._fc_tool_demographics(analysis_type, city_filter, sale_type_filter)
</field>
<field name="ai_tool_description">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".</field>
<field name="ai_tool_schema">{"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": []}</field>
</record>
<!-- ================================================================= -->
<!-- AI TOPIC (references all tools) -->
<!-- AI TOPIC (references tools above) -->
<!-- ================================================================= -->
<record id="ai_topic_client_intelligence" model="ai.topic">
<field name="name">Fusion Claims Client Intelligence</field>
<field name="description">Query client profiles, ADP claims, funding history, billing periods, demographics, and device information.</field>
<field name="instructions">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"</field>
<field name="description">Query client profiles, ADP claims, funding history, medical conditions, and device information.</field>
<field name="instructions">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.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('fusion_claims.ai_tool_search_clients'),
ref('fusion_claims.ai_tool_client_details'),
ref('fusion_claims.ai_tool_client_stats'),
ref('fusion_claims.ai_tool_client_status'),
ref('fusion_claims.ai_tool_adp_billing'),
ref('fusion_claims.ai_tool_demographics'),
])]"/>
</record>
@@ -116,108 +62,32 @@ Common questions and which tool to use:
<!-- ================================================================= -->
<record id="ai_agent_fusion_claims" model="ai.agent">
<field name="name">Fusion Claims Intelligence</field>
<field name="subtitle">Ask about clients, ADP claims, funding history, billing periods, and devices.</field>
<field name="subtitle">Ask about clients, ADP claims, funding history, medical conditions, and devices.</field>
<field name="llm_model">gpt-4.1</field>
<field name="response_style">analytical</field>
<field name="restrict_to_sources" eval="False"/>
<field name="system_prompt">You are Fusion Claims Intelligence, an AI assistant for ADP claims management at a mobility equipment company.
<field name="system_prompt">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)</field>
- Sections: 2a = Walkers, 2b = Manual Wheelchairs, 2c = Power Bases/Scooters, 2d = Seating</field>
<field name="topic_ids" eval="[(6, 0, [ref('ai_topic_client_intelligence')])]"/>
</record>
</odoo>

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Server Action: Sync ADP Fields to Invoices -->
<!-- This appears in the Action menu on Sale Orders -->
<record id="action_sync_adp_fields_server" model="ir.actions.server">
<field name="name">Sync to Invoices</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_view_types">form,list</field>
<field name="state">code</field>
<field name="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,
}
}
</field>
</record>
<data/>
</odoo>

View File

@@ -45,26 +45,6 @@
<field name="value">True</field>
</record>
<!-- Technician / Field Service -->
<record id="config_store_open_hour" model="ir.config_parameter">
<field name="key">fusion_claims.store_open_hour</field>
<field name="value">9.0</field>
</record>
<record id="config_store_close_hour" model="ir.config_parameter">
<field name="key">fusion_claims.store_close_hour</field>
<field name="value">18.0</field>
</record>
<!-- Push Notifications -->
<record id="config_push_enabled" model="ir.config_parameter">
<field name="key">fusion_claims.push_enabled</field>
<field name="value">False</field>
</record>
<record id="config_push_advance_minutes" model="ir.config_parameter">
<field name="key">fusion_claims.push_advance_minutes</field>
<field name="value">30</field>
</record>
<!-- Field Mappings (defaults for fresh installs) -->
<record id="config_field_sale_type" model="ir.config_parameter">
<field name="key">fusion_claims.field_sale_type</field>
@@ -147,17 +127,5 @@
<field name="value">1-888-222-5099</field>
</record>
<!-- Cross-instance task sync: unique ID for this Odoo instance -->
<record id="config_sync_instance_id" model="ir.config_parameter">
<field name="key">fusion_claims.sync_instance_id</field>
<field name="value"></field>
</record>
<!-- LTC Portal Form Password -->
<record id="config_ltc_form_password" model="ir.config_parameter">
<field name="key">fusion_claims.ltc_form_password</field>
<field name="value"></field>
</record>
</data>
</odoo>

View File

@@ -6,16 +6,6 @@
-->
<odoo>
<data>
<!-- Cron Job: Sync ADP Fields from Sale Orders to Invoices -->
<record id="ir_cron_sync_adp_fields" model="ir.cron">
<field name="name">Fusion Claims: Sync ADP Fields</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_sync_adp_fields()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
</record>
<!-- Cron Job: Renew ADP Delivery Reminders -->
<record id="ir_cron_renew_delivery_reminders" model="ir.cron">
<field name="name">Fusion Claims: Renew Delivery Reminders</field>
@@ -134,50 +124,17 @@
<field name="nextcall" eval="DateTime.now().replace(hour=10, minute=0, second=0)"/>
</record>
<!-- Cron Job: Calculate Travel Times for Technician Tasks -->
<record id="ir_cron_technician_travel_times" model="ir.cron">
<field name="name">Fusion Claims: Calculate Technician Travel Times</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<!-- Cron Job: Expire Old Page 11 Signing Requests -->
<record id="ir_cron_expire_page11_requests" model="ir.cron">
<field name="name">Fusion Claims: Expire Page 11 Signing Requests</field>
<field name="model_id" ref="model_fusion_page11_sign_request"/>
<field name="state">code</field>
<field name="code">model._cron_calculate_travel_times()</field>
<field name="code">model._cron_expire_requests()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=5, minute=0, second=0)"/>
<field name="nextcall" eval="DateTime.now().replace(hour=2, minute=0, second=0)"/>
</record>
<!-- Cron Job: Send Push Notifications for Upcoming Tasks -->
<record id="ir_cron_technician_push_notifications" model="ir.cron">
<field name="name">Fusion Claims: Technician Push Notifications</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_send_push_notifications()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Pull Remote Technician Tasks (cross-instance sync) -->
<record id="ir_cron_task_sync_pull" model="ir.cron">
<field name="name">Fusion Claims: Sync Remote Tasks (Pull)</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_pull_remote_tasks()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Cleanup Old Shadow Tasks (30+ days) -->
<record id="ir_cron_task_sync_cleanup" model="ir.cron">
<field name="name">Fusion Claims: Cleanup Old Shadow Tasks</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_shadows()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
</record>
</data>
</odoo>

View File

@@ -1,103 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ================================================================== -->
<!-- SEQUENCES -->
<!-- ================================================================== -->
<record id="seq_ltc_facility" model="ir.sequence">
<field name="name">LTC Facility</field>
<field name="code">fusion.ltc.facility</field>
<field name="prefix">LTC-</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_ltc_repair" model="ir.sequence">
<field name="name">LTC Repair</field>
<field name="code">fusion.ltc.repair</field>
<field name="prefix">LTC-RPR-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_ltc_cleanup" model="ir.sequence">
<field name="name">LTC Cleanup</field>
<field name="code">fusion.ltc.cleanup</field>
<field name="prefix">LTC-CLN-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<!-- ================================================================== -->
<!-- DEFAULT LTC REPAIR SERVICE PRODUCT -->
<!-- ================================================================== -->
<record id="product_ltc_repair_service" model="product.template">
<field name="name">REPAIRS AT LTC HOME</field>
<field name="default_code">LTC-REPAIR</field>
<field name="type">service</field>
<field name="list_price">0.00</field>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
</record>
<!-- ================================================================== -->
<!-- DEFAULT REPAIR STAGES -->
<!-- ================================================================== -->
<record id="ltc_repair_stage_new" model="fusion.ltc.repair.stage">
<field name="name">New</field>
<field name="sequence">1</field>
<field name="fold">False</field>
<field name="description">Newly submitted repair requests awaiting review.</field>
</record>
<record id="ltc_repair_stage_in_review" model="fusion.ltc.repair.stage">
<field name="name">In Review</field>
<field name="sequence">2</field>
<field name="fold">False</field>
<field name="description">Under review by office staff before assignment.</field>
</record>
<record id="ltc_repair_stage_in_progress" model="fusion.ltc.repair.stage">
<field name="name">In Progress</field>
<field name="sequence">3</field>
<field name="fold">False</field>
<field name="description">Technician has been assigned and repair is underway.</field>
</record>
<record id="ltc_repair_stage_completed" model="fusion.ltc.repair.stage">
<field name="name">Completed</field>
<field name="sequence">4</field>
<field name="fold">True</field>
<field name="description">Repair has been completed by the technician.</field>
</record>
<record id="ltc_repair_stage_invoiced" model="fusion.ltc.repair.stage">
<field name="name">Invoiced</field>
<field name="sequence">5</field>
<field name="fold">True</field>
<field name="description">Sale order created and invoiced for this repair.</field>
</record>
<record id="ltc_repair_stage_declined" model="fusion.ltc.repair.stage">
<field name="name">Declined/No Response</field>
<field name="sequence">6</field>
<field name="fold">True</field>
<field name="description">Repair was declined or no response was received.</field>
</record>
<!-- ================================================================== -->
<!-- CRON: Cleanup scheduling -->
<!-- ================================================================== -->
<record id="ir_cron_ltc_cleanup_schedule" model="ir.cron">
<field name="name">LTC: Auto-Schedule Cleanups</field>
<field name="model_id" ref="model_fusion_ltc_cleanup"/>
<field name="state">code</field>
<field name="code">model._cron_schedule_cleanups()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Landscape paper format for nursing station report -->
<record id="paperformat_ltc_nursing_station" model="report.paperformat">
<field name="name">LTC Nursing Station (Landscape)</field>
<field name="default">False</field>
<field name="format">Letter</field>
<field name="orientation">Landscape</field>
<field name="margin_top">20</field>
<field name="margin_bottom">15</field>
<field name="margin_left">10</field>
<field name="margin_right">10</field>
<field name="header_line">False</field>
<field name="header_spacing">10</field>
<field name="dpi">90</field>
</record>
<!-- Nursing Station Report action -->
<record id="action_report_ltc_nursing_station" model="ir.actions.report">
<field name="name">Nursing Station Repair Log</field>
<field name="model">fusion.ltc.facility</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_ltc_nursing_station_document</field>
<field name="report_file">fusion_claims.report_ltc_nursing_station_document</field>
<field name="binding_model_id" ref="model_fusion_ltc_facility"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_ltc_nursing_station"/>
</record>
<!-- Repair Summary Report action -->
<record id="action_report_ltc_repairs_summary" model="ir.actions.report">
<field name="name">Repair Summary</field>
<field name="model">fusion.ltc.facility</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_ltc_repairs_summary_document</field>
<field name="report_file">fusion_claims.report_ltc_repairs_summary_document</field>
<field name="binding_model_id" ref="model_fusion_ltc_facility"/>
<field name="binding_type">report</field>
</record>
</odoo>

View File

@@ -20,34 +20,34 @@
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<div style="padding:32px 28px;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">ADP Quotation</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your quotation <strong style="color:#2d3748;"><t t-out="object.name"/></strong>.
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">ADP Quotation</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your quotation <strong><t t-out="object.name"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Quotation Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Quotation Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<t t-if="object.x_fc_authorizer_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Authorizer</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Authorizer</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
</t>
<t t-if="object.x_fc_client_type == 'REG'">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client Portion (25%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">ADP Portion (75%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Client Portion (25%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">ADP Portion (75%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</t>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Total</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;font-weight:600;border-top:2px solid rgba(128,128,128,0.25);">Total</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid rgba(128,128,128,0.25);"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> ADP Quotation (PDF)</p>
<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> ADP Quotation (PDF)</p>
</div>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Please review the attached quotation. If you have any questions or need assistance, do not hesitate to contact us.</p>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">Please review the attached quotation. If you have any questions or need assistance, do not hesitate to contact us.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
@@ -70,34 +70,34 @@
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#38a169;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<div style="padding:32px 28px;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Order Confirmed</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your ADP sales order <strong style="color:#2d3748;"><t t-out="object.name"/></strong> has been confirmed.
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Order Confirmed</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Your ADP sales order <strong><t t-out="object.name"/></strong> has been confirmed.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Order Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Reference</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Order Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.date_order" t-options="{'widget': 'date'}"/></td></tr>
<t t-if="object.x_fc_authorizer_id">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Authorizer</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Authorizer</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_authorizer_id.name"/></td></tr>
</t>
<t t-if="object.x_fc_client_type == 'REG'">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Client Portion (25%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">ADP Portion (75%)</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Client Portion (25%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_client_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">ADP Portion (75%)</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.x_fc_adp_portion_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</t>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Total</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;font-weight:600;border-top:2px solid rgba(128,128,128,0.25);">Total</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:700;border-top:2px solid rgba(128,128,128,0.25);"><t t-out="object.amount_total" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> Sales Order Confirmation (PDF)</p>
<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Sales Order Confirmation (PDF)</p>
</div>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Your order is being processed. We will keep you updated on the delivery status and any updates from the Assistive Devices Program.</p>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">Your order is being processed. We will keep you updated on the delivery status and any updates from the Assistive Devices Program.</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
@@ -120,42 +120,42 @@
<field name="email_from">{{ (object.invoice_user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;color:#2d3748;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">
<div style="padding:32px 28px;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="color:#1a202c;font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Invoice</h2>
<p style="color:#718096;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your invoice <strong style="color:#2d3748;"><t t-out="object.name or 'Draft'"/></strong>.
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Invoice</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Please find attached your invoice <strong><t t-out="object.name or 'Draft'"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;color:#718096;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid #e2e8f0;">Invoice Details</td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;width:35%;">Invoice</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.name or 'Draft'"/></td></tr>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.invoice_date" t-options="{'widget': 'date'}"/></td></tr>
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Invoice Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Invoice</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name or 'Draft'"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.invoice_date" t-options="{'widget': 'date'}"/></td></tr>
<t t-if="object.invoice_date_due">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Due Date</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;"><t t-out="object.invoice_date_due" t-options="{'widget': 'date'}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Due Date</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.invoice_date_due" t-options="{'widget': 'date'}"/></td></tr>
</t>
<t t-if="object.x_fc_adp_invoice_portion">
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;border-bottom:1px solid #f0f0f0;">Type</td><td style="padding:10px 14px;color:#2d3748;font-size:14px;border-bottom:1px solid #f0f0f0;">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Type</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">
<t t-if="object.x_fc_adp_invoice_portion == 'client'">Client Portion</t>
<t t-if="object.x_fc_adp_invoice_portion == 'adp'">ADP Portion</t>
</td></tr>
</t>
<tr><td style="padding:10px 14px;color:#718096;font-size:14px;font-weight:600;border-top:2px solid #e2e8f0;">Amount Due</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid #e2e8f0;"><t t-out="object.amount_residual" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;font-weight:600;border-top:2px solid rgba(128,128,128,0.25);">Amount Due</td><td style="padding:10px 14px;color:#2B6CB0;font-size:14px;font-weight:700;border-top:2px solid rgba(128,128,128,0.25);"><t t-out="object.amount_residual" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></td></tr>
</table>
<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;color:#718096;"><strong style="color:#2d3748;">Attached:</strong> Invoice (PDF)</p>
<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Invoice (PDF)</p>
</div>
<t t-if="object.x_fc_adp_invoice_portion == 'client'">
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">This invoice represents your client portion for the ADP-funded equipment. The remaining amount will be billed directly to the Assistive Devices Program.</p>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">This invoice represents your client portion for the ADP-funded equipment. The remaining amount will be billed directly to the Assistive Devices Program.</p>
</div>
</t>
<t t-else="">
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;background:#f7fafc;">
<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.</p>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.</p>
</div>
</t>
<t t-set="sig" t-value="object.invoice_user_id.signature or object.user_id.signature"/>

View File

@@ -9,7 +9,7 @@
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="sale_ok" eval="True"/>
<field name="purchase_ok" eval="False"/>
<field name="taxes_id" eval="[(6, 0, [ref('account.1_hst_sale_tax_13')])]"/>
<!-- taxes_id set via post_init_hook or manually per database -->
</record>
</data>
</odoo>

View File

@@ -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
from . import page11_sign_request

View File

@@ -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',

View File

@@ -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)

View File

@@ -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 <strong> 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'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
f'max-width:600px;margin:0 auto;color:#2d3748;">'
f'<div style="height:4px;background-color:{accent};"></div>'
f'<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">'
)
# -- Company name
parts.append(
f'<p style="color:{accent};font-size:13px;font-weight:600;letter-spacing:0.5px;'
f'text-transform:uppercase;margin:0 0 24px 0;">{company["name"]}</p>'
)
# -- Title
parts.append(
f'<h2 style="color:#1a202c;font-size:22px;font-weight:700;'
f'margin:0 0 6px 0;line-height:1.3;">{title}</h2>'
)
# -- Summary
parts.append(
f'<p style="color:#718096;font-size:15px;line-height:1.5;'
f'margin:0 0 24px 0;">{summary}</p>'
)
# -- 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'<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
f'Best regards,<br/>'
f'<strong>{signer}</strong><br/>'
f'<span style="color:#718096;">{company["name"]}</span></p>'
)
# -- Close content card
parts.append('</div>')
# -- Footer
footer_parts = [company['name']]
if company['phone']:
footer_parts.append(company['phone'])
if company['email']:
footer_parts.append(company['email'])
footer_text = ' &middot; '.join(footer_parts)
parts.append(
f'<div style="padding:16px 28px;text-align:center;">'
f'<p style="color:#a0aec0;font-size:11px;line-height:1.5;margin:0;">'
f'{footer_text}<br/>'
f'This is an automated notification from the ADP Claims Management System.</p>'
f'</div>'
)
# -- Close wrapper
parts.append('</div>')
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 = (
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
f'color:#718096;text-transform:uppercase;letter-spacing:0.5px;'
f'border-bottom:2px solid #e2e8f0;">{heading}</td></tr>'
)
for label, value in rows:
if value is None or value == '' or value is False:
continue
html += (
f'<tr>'
f'<td style="padding:10px 14px;color:#718096;font-size:14px;'
f'border-bottom:1px solid #f0f0f0;width:35%;">{label}</td>'
f'<td style="padding:10px 14px;color:#2d3748;font-size:14px;'
f'border-bottom:1px solid #f0f0f0;">{value}</td>'
f'</tr>'
)
html += '</table>'
return html
def _email_note(self, text, color='#2B6CB0'):
"""Build a left-border accent note block."""
return (
f'<div style="border-left:3px solid {color};padding:12px 16px;'
f'margin:0 0 24px 0;background:#f7fafc;">'
f'<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">{text}</p>'
f'</div>'
)
def _email_button(self, url, text='View Case Details', color='#2B6CB0'):
"""Build a centered CTA button."""
return (
f'<p style="text-align:center;margin:28px 0;">'
f'<a href="{url}" style="display:inline-block;background:{color};color:#ffffff;'
f'padding:12px 28px;text-decoration:none;border-radius:6px;'
f'font-size:14px;font-weight:600;">{text}</a></p>'
)
def _email_attachment_note(self, description):
"""Build a dashed-border attachment callout.
Args:
description: e.g. "ADP Application (PDF), XML Data File"
"""
return (
f'<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:13px;color:#718096;">'
f'<strong style="color:#2d3748;">Attached:</strong> {description}</p>'
f'</div>'
)
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'<span style="display:inline-block;background:{bg};color:{color};'
f'padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600;">'
f'{label}</span>'
)
# ------------------------------------------------------------------
# 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')

View File

@@ -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)

View File

@@ -1,167 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import timedelta
from odoo import models, fields, api, _
class FusionLTCCleanup(models.Model):
_name = 'fusion.ltc.cleanup'
_description = 'LTC Cleanup Schedule'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'scheduled_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
)
facility_id = fields.Many2one(
'fusion.ltc.facility',
string='LTC Facility',
required=True,
tracking=True,
index=True,
)
scheduled_date = fields.Date(
string='Scheduled Date',
required=True,
tracking=True,
)
completed_date = fields.Date(
string='Completed Date',
tracking=True,
)
state = fields.Selection([
('scheduled', 'Scheduled'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
('rescheduled', 'Rescheduled'),
], string='Status', default='scheduled', required=True, tracking=True)
technician_id = fields.Many2one(
'res.users',
string='Technician',
tracking=True,
)
task_id = fields.Many2one(
'fusion.technician.task',
string='Field Service Task',
)
notes = fields.Text(string='Notes')
items_cleaned = fields.Integer(string='Items Cleaned')
photo_ids = fields.Many2many(
'ir.attachment',
'ltc_cleanup_photo_rel',
'cleanup_id',
'attachment_id',
string='Photos',
)
@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.ltc.cleanup') or _('New')
return super().create(vals_list)
def action_start(self):
self.write({'state': 'in_progress'})
def action_complete(self):
self.write({
'state': 'completed',
'completed_date': fields.Date.context_today(self),
})
for record in self:
record._schedule_next_cleanup()
record.message_post(
body=_("Cleanup completed. Items cleaned: %s", record.items_cleaned or 0),
message_type='comment',
)
def action_cancel(self):
self.write({'state': 'cancelled'})
def action_reschedule(self):
self.write({'state': 'rescheduled'})
def action_reset(self):
self.write({'state': 'scheduled'})
def _schedule_next_cleanup(self):
facility = self.facility_id
interval = facility._get_cleanup_interval_days()
next_date = (self.completed_date or fields.Date.context_today(self)) + timedelta(days=interval)
facility.next_cleanup_date = next_date
next_cleanup = self.env['fusion.ltc.cleanup'].create({
'facility_id': facility.id,
'scheduled_date': next_date,
'technician_id': self.technician_id.id if self.technician_id else False,
})
self.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=next_date - timedelta(days=7),
summary=_('Upcoming cleanup at %s', facility.name),
note=_('Next cleanup is scheduled for %s at %s.', next_date, facility.name),
)
return next_cleanup
def action_create_task(self):
self.ensure_one()
if self.task_id:
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.technician.task',
'view_mode': 'form',
'res_id': self.task_id.id,
}
task = self.env['fusion.technician.task'].create({
'task_type': 'ltc_visit',
'facility_id': self.facility_id.id,
'scheduled_date': self.scheduled_date,
'technician_id': self.technician_id.id if self.technician_id else False,
'description': _('Cleanup visit at %s', self.facility_id.name),
})
self.task_id = task.id
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.technician.task',
'view_mode': 'form',
'res_id': task.id,
}
@api.model
def _cron_schedule_cleanups(self):
today = fields.Date.context_today(self)
week_ahead = today + timedelta(days=7)
facilities = self.env['fusion.ltc.facility'].search([
('active', '=', True),
('cleanup_frequency', '!=', False),
('next_cleanup_date', '<=', week_ahead),
('next_cleanup_date', '>=', today),
])
for facility in facilities:
existing = self.search([
('facility_id', '=', facility.id),
('scheduled_date', '=', facility.next_cleanup_date),
('state', 'not in', ['cancelled', 'rescheduled']),
], limit=1)
if not existing:
cleanup = self.create({
'facility_id': facility.id,
'scheduled_date': facility.next_cleanup_date,
})
cleanup.activity_schedule(
'mail.mail_activity_data_todo',
date_deadline=facility.next_cleanup_date - timedelta(days=3),
summary=_('Cleanup scheduled at %s', facility.name),
note=_('Cleanup is scheduled for %s.', facility.next_cleanup_date),
)

View File

@@ -1,314 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from dateutil.relativedelta import relativedelta
from odoo import models, fields, api, _
class FusionLTCFacility(models.Model):
_name = 'fusion.ltc.facility'
_description = 'LTC Facility'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'name'
name = fields.Char(
string='Facility Name',
required=True,
tracking=True,
)
code = fields.Char(
string='Code',
copy=False,
readonly=True,
default=lambda self: _('New'),
)
active = fields.Boolean(default=True)
image_1920 = fields.Image(string='Image', max_width=1920, max_height=1920)
partner_id = fields.Many2one(
'res.partner',
string='Contact Record',
help='The facility as a contact in the system',
tracking=True,
)
# Address
street = fields.Char(string='Street')
street2 = fields.Char(string='Street2')
city = fields.Char(string='City')
state_id = fields.Many2one(
'res.country.state',
string='Province',
domain="[('country_id', '=', country_id)]",
)
zip = fields.Char(string='Postal Code')
country_id = fields.Many2one('res.country', string='Country')
phone = fields.Char(string='Phone')
email = fields.Char(string='Email')
website = fields.Char(string='Website')
# Key contacts
director_of_care_id = fields.Many2one(
'res.partner',
string='Director of Care',
tracking=True,
)
service_supervisor_id = fields.Many2one(
'res.partner',
string='Service Supervisor',
tracking=True,
)
physiotherapist_ids = fields.Many2many(
'res.partner',
'ltc_facility_physiotherapist_rel',
'facility_id',
'partner_id',
string='Physiotherapists',
help='Primary contacts for equipment recommendations and communication',
)
# Structure
number_of_floors = fields.Integer(string='Number of Floors')
floor_ids = fields.One2many(
'fusion.ltc.floor',
'facility_id',
string='Floors',
)
# Contract
contract_start_date = fields.Date(string='Contract Start Date', tracking=True)
contract_end_date = fields.Date(string='Contract End Date', tracking=True)
contract_file = fields.Binary(
string='Contract Document',
attachment=True,
)
contract_file_filename = fields.Char(string='Contract Filename')
contract_notes = fields.Text(string='Contract Notes')
# Cleanup scheduling
cleanup_frequency = fields.Selection([
('quarterly', 'Quarterly (Every 3 Months)'),
('semi_annual', 'Semi-Annual (Every 6 Months)'),
('annual', 'Annual (Yearly)'),
('custom', 'Custom Interval'),
], string='Cleanup Frequency', default='quarterly')
cleanup_interval_days = fields.Integer(
string='Custom Interval (Days)',
help='Number of days between cleanups when using custom interval',
)
next_cleanup_date = fields.Date(
string='Next Cleanup Date',
compute='_compute_next_cleanup_date',
store=True,
readonly=False,
tracking=True,
)
# Related records
repair_ids = fields.One2many('fusion.ltc.repair', 'facility_id', string='Repairs')
cleanup_ids = fields.One2many('fusion.ltc.cleanup', 'facility_id', string='Cleanups')
# Computed counts
repair_count = fields.Integer(compute='_compute_repair_count', string='Total Repairs')
active_repair_count = fields.Integer(compute='_compute_repair_count', string='Active Repairs')
cleanup_count = fields.Integer(compute='_compute_cleanup_count', string='Cleanups')
notes = fields.Html(string='Notes')
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('code', _('New')) == _('New'):
vals['code'] = self.env['ir.sequence'].next_by_code('fusion.ltc.facility') or _('New')
return super().create(vals_list)
@api.depends('contract_start_date', 'cleanup_frequency', 'cleanup_interval_days')
def _compute_next_cleanup_date(self):
today = fields.Date.context_today(self)
for facility in self:
start = facility.contract_start_date
freq = facility.cleanup_frequency
if not start or not freq:
if not facility.next_cleanup_date:
facility.next_cleanup_date = False
continue
interval = facility._get_cleanup_interval_days()
delta = relativedelta(days=interval)
candidate = start + delta
while candidate < today:
candidate += delta
facility.next_cleanup_date = candidate
def action_preview_contract(self):
self.ensure_one()
if not self.contract_file:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('No Document'),
'message': _('No contract document has been uploaded yet.'),
'type': 'warning',
'sticky': False,
},
}
attachment = self.env['ir.attachment'].search([
('res_model', '=', self._name),
('res_id', '=', self.id),
('res_field', '=', 'contract_file'),
], limit=1)
if not attachment:
attachment = self.env['ir.attachment'].search([
('res_model', '=', self._name),
('res_id', '=', self.id),
('name', '=', self.contract_file_filename or 'contract_file'),
], limit=1, order='id desc')
if attachment:
return {
'type': 'ir.actions.client',
'tag': 'fusion_claims.preview_document',
'params': {
'attachment_id': attachment.id,
'title': _('Contract - %s', self.name),
},
}
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Error'),
'message': _('Could not load contract document.'),
'type': 'danger',
'sticky': False,
},
}
def _compute_repair_count(self):
for facility in self:
repairs = facility.repair_ids
facility.repair_count = len(repairs)
facility.active_repair_count = len(repairs.filtered(
lambda r: r.stage_id and not r.stage_id.fold
))
def _compute_cleanup_count(self):
for facility in self:
facility.cleanup_count = len(facility.cleanup_ids)
def action_view_repairs(self):
self.ensure_one()
return {
'name': _('Repairs - %s', self.name),
'type': 'ir.actions.act_window',
'res_model': 'fusion.ltc.repair',
'view_mode': 'kanban,list,form',
'domain': [('facility_id', '=', self.id)],
'context': {'default_facility_id': self.id},
}
def action_view_cleanups(self):
self.ensure_one()
return {
'name': _('Cleanups - %s', self.name),
'type': 'ir.actions.act_window',
'res_model': 'fusion.ltc.cleanup',
'view_mode': 'list,kanban,form',
'domain': [('facility_id', '=', self.id)],
'context': {'default_facility_id': self.id},
}
def _get_cleanup_interval_days(self):
mapping = {
'quarterly': 90,
'semi_annual': 180,
'annual': 365,
}
if self.cleanup_frequency == 'custom':
return self.cleanup_interval_days or 90
return mapping.get(self.cleanup_frequency, 90)
class FusionLTCFloor(models.Model):
_name = 'fusion.ltc.floor'
_description = 'LTC Facility Floor'
_order = 'sequence, name'
facility_id = fields.Many2one(
'fusion.ltc.facility',
string='Facility',
required=True,
ondelete='cascade',
)
name = fields.Char(string='Floor Name', required=True)
sequence = fields.Integer(string='Sequence', default=10)
station_ids = fields.One2many(
'fusion.ltc.station',
'floor_id',
string='Nursing Stations',
)
head_nurse_id = fields.Many2one(
'res.partner',
string='Head Nurse',
)
physiotherapist_id = fields.Many2one(
'res.partner',
string='Physiotherapist',
help='Floor-level physiotherapist if different from facility level',
)
class FusionLTCStation(models.Model):
_name = 'fusion.ltc.station'
_description = 'LTC Nursing Station'
_order = 'sequence, name'
floor_id = fields.Many2one(
'fusion.ltc.floor',
string='Floor',
required=True,
ondelete='cascade',
)
name = fields.Char(string='Station Name', required=True)
sequence = fields.Integer(string='Sequence', default=10)
head_nurse_id = fields.Many2one(
'res.partner',
string='Head Nurse',
)
phone = fields.Char(string='Phone')
class FusionLTCFamilyContact(models.Model):
_name = 'fusion.ltc.family.contact'
_description = 'LTC Resident Family Contact'
_order = 'is_poa desc, name'
partner_id = fields.Many2one(
'res.partner',
string='Resident',
required=True,
ondelete='cascade',
)
name = fields.Char(string='Contact Name', required=True)
relationship = fields.Selection([
('spouse', 'Spouse'),
('child', 'Child'),
('sibling', 'Sibling'),
('parent', 'Parent'),
('guardian', 'Guardian'),
('poa', 'Power of Attorney'),
('other', 'Other'),
], string='Relationship')
phone = fields.Char(string='Phone')
phone2 = fields.Char(string='Phone 2')
email = fields.Char(string='Email')
is_poa = fields.Boolean(string='Is POA', help='Is this person the Power of Attorney?')
notes = fields.Char(string='Notes')

View File

@@ -1,68 +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 FusionLTCFormSubmission(models.Model):
_name = 'fusion.ltc.form.submission'
_description = 'LTC Form Submission'
_order = 'submitted_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
)
form_type = fields.Selection([
('repair', 'Repair Request'),
], string='Form Type', default='repair', required=True, index=True)
repair_id = fields.Many2one(
'fusion.ltc.repair',
string='Repair Request',
ondelete='set null',
index=True,
)
facility_id = fields.Many2one(
'fusion.ltc.facility',
string='Facility',
index=True,
)
client_name = fields.Char(string='Client Name')
room_number = fields.Char(string='Room Number')
product_serial = fields.Char(string='Product Serial #')
is_emergency = fields.Boolean(string='Emergency')
submitted_date = fields.Datetime(
string='Submitted Date',
default=fields.Datetime.now,
readonly=True,
)
ip_address = fields.Char(string='IP Address', readonly=True)
status = fields.Selection([
('submitted', 'Submitted'),
('processed', 'Processed'),
('rejected', 'Rejected'),
], string='Status', default='submitted', tracking=True)
@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.ltc.form.submission'
) or _('New')
return super().create(vals_list)
def action_view_repair(self):
self.ensure_one()
if not self.repair_id:
return
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.ltc.repair',
'view_mode': 'form',
'res_id': self.repair_id.id,
}

View File

@@ -1,376 +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, _
from odoo.exceptions import UserError
class FusionLTCRepairStage(models.Model):
_name = 'fusion.ltc.repair.stage'
_description = 'LTC Repair Stage'
_order = 'sequence, id'
name = fields.Char(string='Stage Name', required=True, translate=True)
sequence = fields.Integer(string='Sequence', default=10)
fold = fields.Boolean(
string='Folded in Kanban',
help='Folded stages are hidden by default in the kanban view',
)
color = fields.Char(
string='Stage Color',
help='CSS color class for stage badge (e.g. info, success, warning, danger)',
default='secondary',
)
description = fields.Text(string='Description')
class FusionLTCRepair(models.Model):
_name = 'fusion.ltc.repair'
_description = 'LTC Repair Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'issue_reported_date desc, id desc'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
)
facility_id = fields.Many2one(
'fusion.ltc.facility',
string='LTC Facility',
required=True,
tracking=True,
index=True,
)
client_id = fields.Many2one(
'res.partner',
string='Client/Resident',
tracking=True,
help='Link to the resident contact record',
)
client_name = fields.Char(
string='Client Name',
help='Quick entry name when no contact record exists',
)
display_client_name = fields.Char(
compute='_compute_display_client_name',
string='Client',
store=True,
)
room_number = fields.Char(string='Room Number')
stage_id = fields.Many2one(
'fusion.ltc.repair.stage',
string='Stage',
tracking=True,
group_expand='_read_group_stage_ids',
default=lambda self: self._default_stage_id(),
index=True,
)
kanban_state = fields.Selection([
('normal', 'In Progress'),
('done', 'Ready'),
('blocked', 'Blocked'),
], string='Kanban State', default='normal', tracking=True)
color = fields.Integer(string='Color Index')
is_emergency = fields.Boolean(
string='Emergency Repair',
tracking=True,
help='Emergency visits may be chargeable at an extra rate',
)
priority = fields.Selection([
('0', 'Normal'),
('1', 'High'),
], string='Priority', default='0')
product_serial = fields.Char(string='Product Serial #')
product_id = fields.Many2one(
'product.product',
string='Product',
help='Link to product record if applicable',
)
issue_description = fields.Text(
string='Issue Description',
required=True,
)
issue_reported_date = fields.Date(
string='Issue Reported Date',
required=True,
default=fields.Date.context_today,
tracking=True,
)
issue_fixed_date = fields.Date(
string='Issue Fixed Date',
tracking=True,
)
resolution_description = fields.Text(string='Resolution Description')
assigned_technician_id = fields.Many2one(
'res.users',
string='Assigned Technician',
tracking=True,
)
task_id = fields.Many2one(
'fusion.technician.task',
string='Field Service Task',
tracking=True,
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
tracking=True,
help='Sale order created for this repair if applicable',
)
sale_order_name = fields.Char(
related='sale_order_id.name',
string='SO Reference',
)
poa_name = fields.Char(string='Family/POA Name')
poa_phone = fields.Char(string='Family/POA Phone')
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
currency_id = fields.Many2one(
'res.currency',
related='company_id.currency_id',
)
repair_value = fields.Monetary(
string='Repair Value',
currency_field='currency_id',
)
photo_ids = fields.Many2many(
'ir.attachment',
'ltc_repair_photo_rel',
'repair_id',
'attachment_id',
string='Photos (Legacy)',
)
before_photo_ids = fields.Many2many(
'ir.attachment',
'ltc_repair_before_photo_rel',
'repair_id',
'attachment_id',
string='Before Photos',
)
after_photo_ids = fields.Many2many(
'ir.attachment',
'ltc_repair_after_photo_rel',
'repair_id',
'attachment_id',
string='After Photos',
)
notes = fields.Text(string='Internal Notes')
source = fields.Selection([
('portal_form', 'Portal Form'),
('manual', 'Manual Entry'),
('phone', 'Phone Call'),
('migrated', 'Migrated'),
], string='Source', default='manual', tracking=True)
stage_color = fields.Char(
related='stage_id.color',
string='Stage Color',
store=True,
)
@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.ltc.repair') or _('New')
records = super().create(vals_list)
for record in records:
record._post_creation_message()
return records
def _post_creation_message(self):
body = _(
"Repair request submitted for <b>%(client)s</b> in Room <b>%(room)s</b>"
" at <b>%(facility)s</b>.<br/>"
"Issue: %(issue)s",
client=self.display_client_name or 'N/A',
room=self.room_number or 'N/A',
facility=self.facility_id.name,
issue=self.issue_description or '',
)
self.message_post(body=body, message_type='comment')
def _default_stage_id(self):
return self.env['fusion.ltc.repair.stage'].search([], order='sequence', limit=1).id
@api.model
def _read_group_stage_ids(self, stages, domain):
return self.env['fusion.ltc.repair.stage'].search([], order='sequence')
@api.depends('client_id', 'client_name')
def _compute_display_client_name(self):
for repair in self:
if repair.client_id:
repair.display_client_name = repair.client_id.name
else:
repair.display_client_name = repair.client_name or ''
@api.onchange('client_id')
def _onchange_client_id(self):
if self.client_id:
self.client_name = self.client_id.name
if hasattr(self.client_id, 'x_fc_ltc_room_number') and self.client_id.x_fc_ltc_room_number:
self.room_number = self.client_id.x_fc_ltc_room_number
if hasattr(self.client_id, 'x_fc_ltc_facility_id') and self.client_id.x_fc_ltc_facility_id:
self.facility_id = self.client_id.x_fc_ltc_facility_id
def action_view_sale_order(self):
self.ensure_one()
if not self.sale_order_id:
return
return {
'name': self.sale_order_id.name,
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': self.sale_order_id.id,
}
def action_view_task(self):
self.ensure_one()
if not self.task_id:
return
return {
'name': self.task_id.name,
'type': 'ir.actions.act_window',
'res_model': 'fusion.technician.task',
'view_mode': 'form',
'res_id': self.task_id.id,
}
def action_create_sale_order(self):
self.ensure_one()
if self.sale_order_id:
raise UserError(_('A sale order already exists for this repair.'))
if not self.client_id and self.client_name:
return {
'name': _('Link Contact'),
'type': 'ir.actions.act_window',
'res_model': 'fusion.ltc.repair.create.so.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_repair_id': self.id,
'default_client_name': self.client_name,
},
}
if not self.client_id:
raise UserError(_('Please set a client before creating a sale order.'))
return self._create_linked_sale_order()
def _create_linked_sale_order(self):
self.ensure_one()
SaleOrder = self.env['sale.order']
OrderLine = self.env['sale.order.line']
so_vals = {
'partner_id': self.client_id.id,
'x_fc_ltc_repair_id': self.id,
}
sale_order = SaleOrder.create(so_vals)
seq = 10
OrderLine.create({
'order_id': sale_order.id,
'display_type': 'line_section',
'name': 'PRODUCTS & REPAIRS',
'sequence': seq,
})
seq += 10
repair_tmpl = self.env.ref(
'fusion_claims.product_ltc_repair_service', raise_if_not_found=False
)
repair_product = (
repair_tmpl.product_variant_id if repair_tmpl else False
)
line_vals = {
'order_id': sale_order.id,
'sequence': seq,
'name': 'Repairs at LTC Home - %s' % (self.facility_id.name or ''),
}
if repair_product:
line_vals['product_id'] = repair_product.id
else:
line_vals['display_type'] = 'line_note'
OrderLine.create(line_vals)
seq += 10
OrderLine.create({
'order_id': sale_order.id,
'display_type': 'line_section',
'name': 'REPORTED ISSUES',
'sequence': seq,
})
seq += 10
if self.issue_description:
OrderLine.create({
'order_id': sale_order.id,
'display_type': 'line_note',
'name': self.issue_description,
'sequence': seq,
})
seq += 10
if self.issue_reported_date:
OrderLine.create({
'order_id': sale_order.id,
'display_type': 'line_note',
'name': 'Reported Date: %s' % self.issue_reported_date,
'sequence': seq,
})
seq += 10
OrderLine.create({
'order_id': sale_order.id,
'display_type': 'line_section',
'name': 'PROPOSED RESOLUTION',
'sequence': seq,
})
seq += 10
if self.resolution_description:
OrderLine.create({
'order_id': sale_order.id,
'display_type': 'line_note',
'name': self.resolution_description,
'sequence': seq,
})
seq += 10
if self.product_serial:
OrderLine.create({
'order_id': sale_order.id,
'display_type': 'line_note',
'name': 'Serial Number: %s' % self.product_serial,
'sequence': seq,
})
self.sale_order_id = sale_order.id
return {
'name': sale_order.name,
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': sale_order.id,
}

View File

@@ -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 = '<br/><br/>'.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 <strong>{client_name}</strong>.'
),
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 <strong>%s</strong> (%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 <strong>%s</strong> (%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 <strong>%s</strong> (%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))

View File

@@ -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,
})

View File

@@ -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',

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -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
# ==========================================================================

View File

@@ -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()

View File

@@ -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,
},
}

View File

@@ -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,
)

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@
<field name="binding_type">report</field>
</record>
<!-- Landscape report - REMOVED FROM MENU (no binding) -->
<!-- Landscape ADP report - also attached to quotation/order emails -->
<record id="action_report_saleorder_landscape" model="ir.actions.report">
<field name="name">Quotation / Order (Landscape - ADP)</field>
<field name="model">sale.order</field>
@@ -40,21 +40,6 @@
<field name="report_name">fusion_claims.report_saleorder_landscape</field>
<field name="report_file">fusion_claims.report_saleorder_landscape</field>
<field name="print_report_name">'%s - %s' % (object.name, object.partner_id.name)</field>
<!-- No binding_model_id - removed from print menu -->
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_a4_landscape"/>
</record>
<!-- =============================================================== -->
<!-- LTC Repair Order / Quotation Report (Landscape) -->
<!-- =============================================================== -->
<record id="action_report_saleorder_ltc_repair" model="ir.actions.report">
<field name="name">LTC Repair Order / Quotation</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_saleorder_ltc_repair</field>
<field name="report_file">fusion_claims.report_saleorder_ltc_repair</field>
<field name="print_report_name">'LTC Repair - %s - %s' % (object.name, object.partner_id.name)</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_a4_landscape"/>
@@ -127,19 +112,6 @@
<field name="binding_type">report</field>
</record>
<!-- =============================================================== -->
<!-- Rental Agreement Report -->
<!-- =============================================================== -->
<record id="action_report_rental_agreement" model="ir.actions.report">
<field name="name">Rental Agreement</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_rental_agreement</field>
<field name="report_file">fusion_claims.report_rental_agreement</field>
<field name="print_report_name">'Rental Agreement - %s' % object.name</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
</record>
<!-- =============================================================== -->
<!-- Grab Bar Installation Waiver Report -->
@@ -169,6 +141,21 @@
<field name="binding_type">report</field>
</record>
<!-- =============================================================== -->
<!-- Approved Items Report (Landscape) -->
<!-- =============================================================== -->
<record id="action_report_approved_items" model="ir.actions.report">
<field name="name">Approved Items Report</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_claims.report_approved_items</field>
<field name="report_file">fusion_claims.report_approved_items</field>
<field name="print_report_name">'Approved Items - %s - %s' % (object.name, object.partner_id.name)</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_a4_landscape"/>
</record>
<!-- =============================================================== -->
<!-- March of Dimes Quotation Report -->
<!-- =============================================================== -->

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
Approved Items Report - Landscape
-->
<odoo>
<template id="report_approved_items">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id"/>
<t t-set="is_deduction" t-value="doc.x_fc_adp_application_status == 'approved_deduction'"/>
<t t-set="lines" t-value="doc.order_line.filtered(lambda l: l.product_id and l.display_type not in ('line_section', 'line_note'))"/>
<t t-set="has_deduction" t-value="any(l.x_fc_deduction_type and l.x_fc_deduction_type != 'none' for l in lines)"/>
<style>
.fc-ai { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-ai h2 { color: #0066a1; font-size: 16pt; text-align: center; margin: 25px 0 20px 0; }
.fc-ai table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-ai table.bordered, .fc-ai table.bordered th, .fc-ai table.bordered td { border: 1px solid #000; }
.fc-ai th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-ai td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-ai .text-center { text-align: center; }
.fc-ai .text-end { text-align: right; }
.fc-ai .info-label { font-weight: bold; background-color: #f5f5f5; width: 18%; }
.fc-ai .alt-row { background-color: #f7fafc; }
.fc-ai .total-row { background-color: #edf2f7; font-weight: bold; }
.fc-ai .status-approved { color: #38a169; font-weight: bold; }
.fc-ai .status-deduction { color: #d69e2e; font-weight: bold; }
</style>
<div class="fc-ai">
<div class="page">
<div style="height: 30px;"></div>
<h2>APPROVED ITEMS REPORT</h2>
<!-- Case Info -->
<table class="bordered" style="margin-bottom: 15px;">
<thead>
<tr>
<th colspan="4">CASE DETAILS</th>
</tr>
</thead>
<tbody>
<tr>
<td class="info-label">Case</td>
<td><t t-esc="doc.name"/></td>
<td class="info-label">Client</td>
<td><t t-esc="doc.partner_id.name"/></td>
</tr>
<tr>
<td class="info-label">Claim Number</td>
<td><t t-esc="doc.x_fc_claim_number or 'N/A'"/></td>
<td class="info-label">Status</td>
<td>
<t t-if="is_deduction">
<span class="status-deduction">Approved with Deduction</span>
</t>
<t t-else="">
<span class="status-approved">Approved</span>
</t>
</td>
</tr>
<tr>
<td class="info-label">Assessment Date</td>
<td>
<t t-if="doc.x_fc_assessment_end_date"><span t-field="doc.x_fc_assessment_end_date" t-options="{'widget': 'date'}"/></t>
<t t-else="">N/A</t>
</td>
<td class="info-label">Approval Date</td>
<td>
<t t-if="doc.x_fc_claim_approval_date"><span t-field="doc.x_fc_claim_approval_date" t-options="{'widget': 'date'}"/></t>
<t t-else="">N/A</t>
</td>
</tr>
<tr>
<td class="info-label">Client Ref 1</td>
<td><t t-esc="doc.x_fc_client_ref_1 or 'N/A'"/></td>
<td class="info-label">Client Ref 2</td>
<td><t t-esc="doc.x_fc_client_ref_2 or 'N/A'"/></td>
</tr>
</tbody>
</table>
<!-- Items Table -->
<table class="bordered">
<thead>
<tr>
<th class="text-center" style="width: 5%;">S/N</th>
<th style="width: 12%;">ADP CODE</th>
<th style="width: 15%;">DEVICE TYPE</th>
<th style="width: 28%;">PRODUCT NAME</th>
<th class="text-end" style="width: 5%;">QTY</th>
<th class="text-end" style="width: 12%;">ADP PORTION</th>
<th class="text-end" style="width: 13%;">CLIENT PORTION</th>
<t t-if="has_deduction">
<th class="text-end" style="width: 10%;">DEDUCTION</th>
</t>
</tr>
</thead>
<tbody>
<t t-set="total_adp" t-value="0"/>
<t t-set="total_client" t-value="0"/>
<t t-set="idx" t-value="0"/>
<t t-foreach="lines" t-as="line">
<t t-set="idx" t-value="idx + 1"/>
<t t-set="total_adp" t-value="total_adp + (line.x_fc_adp_portion or 0)"/>
<t t-set="total_client" t-value="total_client + (line.x_fc_client_portion or 0)"/>
<tr t-attf-class="#{ 'alt-row' if idx % 2 == 0 else '' }">
<td class="text-center"><t t-esc="idx"/></td>
<td><t t-esc="line._get_adp_code_for_report()"/></td>
<td><t t-esc="line._get_adp_device_type()"/></td>
<td><t t-esc="line.product_id.name or '-'"/></td>
<td class="text-end">
<t t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
<td class="text-end">
$<t t-esc="'%.2f' % (line.x_fc_adp_portion or 0)"/>
</td>
<td class="text-end">
$<t t-esc="'%.2f' % (line.x_fc_client_portion or 0)"/>
</td>
<t t-if="has_deduction">
<td class="text-end">
<t t-if="line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value">
<t t-esc="'%.0f' % line.x_fc_deduction_value"/>%
</t>
<t t-elif="line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value">
$<t t-esc="'%.2f' % line.x_fc_deduction_value"/>
</t>
<t t-else="">-</t>
</td>
</t>
</tr>
</t>
<!-- Totals -->
<tr class="total-row">
<td colspan="5" class="text-end" style="border-top: 2px solid #000;">TOTAL</td>
<td class="text-end" style="border-top: 2px solid #000;">
$<t t-esc="'%.2f' % total_adp"/>
</td>
<td class="text-end" style="border-top: 2px solid #000;">
$<t t-esc="'%.2f' % total_client"/>
</td>
<t t-if="has_deduction">
<td style="border-top: 2px solid #000;"></td>
</t>
</tr>
</tbody>
</table>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -1,139 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_ltc_nursing_station_document">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="facility">
<t t-call="web.external_layout">
<div class="page" style="font-size: 11px;">
<h3 class="text-center mb-3">
Equipment Repair Log - <t t-esc="facility.name"/>
</h3>
<p class="text-center text-muted mb-4">
Generated: <t t-esc="context_timestamp(datetime.datetime.now()).strftime('%B %d, %Y')"/>
</p>
<table class="table table-bordered" style="border: 1px solid #000;">
<thead>
<tr style="background-color: #f0f0f0; height: 20px;">
<th style="border: 1px solid #000; width: 5%; text-align: center;">S/N</th>
<th style="border: 1px solid #000; width: 18%;">Client Name</th>
<th style="border: 1px solid #000; width: 8%; text-align: center;">Room #</th>
<th style="border: 1px solid #000; width: 27%;">Issue Description</th>
<th style="border: 1px solid #000; width: 12%; text-align: center;">Reported Date</th>
<th style="border: 1px solid #000; width: 20%;">Resolution</th>
<th style="border: 1px solid #000; width: 10%; text-align: center;">Fixed Date</th>
</tr>
</thead>
<tbody>
<t t-set="counter" t-value="0"/>
<t t-foreach="facility.repair_ids.sorted(key=lambda r: r.issue_reported_date or '', reverse=True)"
t-as="repair">
<t t-set="counter" t-value="counter + 1"/>
<tr style="height: 20px;">
<td style="border: 1px solid #000; text-align: center;">
<t t-esc="counter"/>
</td>
<td style="border: 1px solid #000;">
<t t-esc="repair.display_client_name"/>
</td>
<td style="border: 1px solid #000; text-align: center;">
<t t-esc="repair.room_number"/>
</td>
<td style="border: 1px solid #000; font-size: 10px;">
<t t-esc="repair.issue_description"/>
</td>
<td style="border: 1px solid #000; text-align: center;">
<t t-if="repair.issue_reported_date">
<t t-esc="repair.issue_reported_date" t-options='{"widget": "date"}'/>
</t>
</td>
<td style="border: 1px solid #000; font-size: 10px;">
<t t-esc="repair.resolution_description or ''"/>
</td>
<td style="border: 1px solid #000; text-align: center;">
<t t-if="repair.issue_fixed_date">
<t t-esc="repair.issue_fixed_date" t-options='{"widget": "date"}'/>
</t>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
<template id="report_ltc_repairs_summary_document">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="facility">
<t t-call="web.external_layout">
<div class="page">
<h3 class="text-center mb-3">
Repair Summary - <t t-esc="facility.name"/>
</h3>
<p class="text-center text-muted mb-4">
Total Repairs: <t t-esc="len(facility.repair_ids)"/>
</p>
<h5>Repairs by Stage</h5>
<table class="table table-bordered">
<thead>
<tr>
<th>Stage</th>
<th class="text-end">Count</th>
</tr>
</thead>
<tbody>
<t t-set="stages" t-value="{}"/>
<t t-foreach="facility.repair_ids" t-as="r">
<t t-set="sname" t-value="r.stage_id.name or 'No Stage'"/>
<t t-if="sname not in stages">
<t t-set="_" t-value="stages.__setitem__(sname, 0)"/>
</t>
<t t-set="_" t-value="stages.__setitem__(sname, stages[sname] + 1)"/>
</t>
<t t-foreach="stages" t-as="stage_name">
<tr>
<td><t t-esc="stage_name"/></td>
<td class="text-end"><t t-esc="stages[stage_name]"/></td>
</tr>
</t>
</tbody>
</table>
<h5 class="mt-4">Recent Repairs</h5>
<table class="table table-bordered">
<thead>
<tr>
<th>Reference</th>
<th>Client</th>
<th>Room</th>
<th>Reported</th>
<th>Fixed</th>
<th>Stage</th>
</tr>
</thead>
<tbody>
<t t-foreach="facility.repair_ids.sorted(key=lambda r: r.issue_reported_date or '', reverse=True)[:50]"
t-as="repair">
<tr>
<td><t t-esc="repair.name"/></td>
<td><t t-esc="repair.display_client_name"/></td>
<td><t t-esc="repair.room_number"/></td>
<td><t t-esc="repair.issue_reported_date" t-options='{"widget": "date"}'/></td>
<td><t t-esc="repair.issue_fixed_date" t-options='{"widget": "date"}'/></td>
<td><t t-esc="repair.stage_id.name"/></td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -1,365 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
Rental Agreement Document - Compact 2-Page Layout
-->
<odoo>
<template id="report_rental_agreement">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id"/>
<style>
.fc-rental { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
.fc-rental h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
.fc-rental h2 { color: #0066a1; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
.fc-rental p { margin: 2px 0; text-align: justify; }
.fc-rental .parties { font-size: 8pt; margin-bottom: 8px; }
.fc-rental .intro { margin-bottom: 8px; font-size: 8pt; }
.fc-rental table { width: 100%; border-collapse: collapse; }
.fc-rental table.bordered, .fc-rental table.bordered th, .fc-rental table.bordered td { border: 1px solid #000; }
.fc-rental th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; font-size: 8pt; }
.fc-rental td { padding: 3px 5px; vertical-align: top; font-size: 8pt; }
.fc-rental .text-center { text-align: center; }
.fc-rental .info-header { background-color: #f5f5f5; color: #333; font-weight: bold; }
/* Two-column layout for terms */
.fc-rental .terms-container { column-count: 2; column-gap: 20px; margin-top: 10px; }
.fc-rental .term-section { break-inside: avoid; margin-bottom: 8px; }
/* Credit card section - 15% taller */
.fc-rental .cc-section { margin-top: 12px; padding: 12px; border: 2px solid #0066a1; background-color: #f8f9fa; }
.fc-rental .cc-title { font-size: 10pt; font-weight: bold; color: #0066a1; margin-bottom: 10px; text-align: center; }
.fc-rental .cc-box { border: 1px solid #000; display: inline-block; width: 21px; height: 21px; text-align: center; background: white; }
.fc-rental .authorization-text { font-size: 7pt; margin-top: 10px; font-style: italic; }
/* Signature - 40% taller */
.fc-rental .signature-section { margin-top: 15px; }
.fc-rental .signature-box { border: 1px solid #000; padding: 12px; }
.fc-rental .signature-line { border-bottom: 1px solid #000; min-height: 35px; margin-bottom: 5px; }
.fc-rental .signature-label { font-size: 7pt; color: #666; }
</style>
<div class="fc-rental">
<div class="page">
<!-- ============================================================ -->
<!-- PAGE 1: TERMS AND CONDITIONS -->
<!-- ============================================================ -->
<h1>RENTAL AGREEMENT</h1>
<!-- Parties - Compact -->
<div class="parties">
<strong>BETWEEN:</strong> <t t-esc="company.name"/> ("Company")
<strong style="margin-left: 20px;">AND:</strong> <t t-esc="doc.partner_id.name"/> ("Renter")
</div>
<!-- Introduction -->
<div class="intro">
<p><t t-esc="company.name"/> rents to the Renter medical equipment (hospital beds, patient lifts, trapeze, over-bed tables, mobility scooters, electric wheelchairs, manual wheelchairs, stairlifts, ceiling lifts and lift chairs) subject to the terms and conditions set forth in this Rental Agreement.</p>
</div>
<!-- Terms and Conditions in Two Columns -->
<div class="terms-container">
<div class="term-section">
<h2>1. Ownership and Condition of Equipment</h2>
<p>The medical equipment is the property of <t t-esc="company.name"/> and is provided in good condition. The Renter shall return the equipment in the same condition as when received, subject to normal wear and tear. <t t-esc="company.name"/> reserves the right to inspect the equipment upon its return and may repossess it without prior notice if it is being used in violation of this agreement.</p>
</div>
<div class="term-section">
<h2>2. Cancellation Policy</h2>
<p>The Renter may cancel the order before delivery and will be charged twenty-five percent (25%) of the total rental cost. If the order is canceled during the rental period after delivery, no refund will be provided.</p>
</div>
<div class="term-section">
<h2>3. Security Deposit</h2>
<p>The security deposit will be returned after an inspection of the equipment. If the equipment has any damage, the cost of repairs will be deducted from the security deposit. If the security deposit is insufficient to cover the damages, the credit card on file will be charged for the remaining amount. Security deposit refunds may take 4 to 15 business days to process. <t t-esc="company.name"/> is not responsible for delays caused by the Renter's financial institution.</p>
</div>
<div class="term-section">
<h2>4. Liability for Loss or Damage</h2>
<p><t t-esc="company.name"/> shall not be liable for any loss of or damage to property left, lost, damaged, stolen, stored, or transported by the Renter or any other person using the medical equipment. The Renter assumes all risks associated with such loss or damage and waives any claims against <t t-esc="company.name"/>. The Renter agrees to defend, indemnify, and hold <t t-esc="company.name"/> harmless against all claims arising from such loss or damage.</p>
</div>
<div class="term-section">
<h2>5. Risk and Liability</h2>
<p>The Renter assumes all risk and liability for any loss, damage, injury, or death resulting from the use or operation of the medical equipment. <t t-esc="company.name"/> is not responsible for any acts or omissions of the Renter or the Renter's agents, servants, or employees.</p>
</div>
<div class="term-section">
<h2>6. Renter Responsibilities</h2>
<p>The Renter is responsible for the full cost of replacement for any damage, loss, theft, or destruction of the medical equipment. <t t-esc="company.name"/> may charge the Renter's credit card for repair or replacement costs as deemed necessary. The equipment must not be used by individuals under the age of 18, under the influence of intoxicants or narcotics, or in an unsafe manner.</p>
</div>
<div class="term-section">
<h2>7. Indemnification</h2>
<p>The Renter shall indemnify, defend, and hold harmless <t t-esc="company.name"/>, its agents, officers, and employees, from any claims, demands, actions, or causes of action arising from the use or operation of the medical equipment, except where caused by <t t-esc="company.name"/>'s gross negligence or willful misconduct.</p>
</div>
<div class="term-section">
<h2>8. Accident Notification</h2>
<p>The Renter must immediately notify <t t-esc="company.name"/> of any accidents, damages, or incidents involving the medical equipment.</p>
</div>
<div class="term-section">
<h2>9. Costs and Expenses</h2>
<p>The Renter agrees to cover all costs, expenses, and attorney's fees incurred by <t t-esc="company.name"/> in collecting overdue payments, recovering possession of the equipment, or enforcing claims for damage or loss.</p>
</div>
<div class="term-section">
<h2>10. Independent Status</h2>
<p>The Renter or any driver of the equipment shall not be considered an agent or employee of <t t-esc="company.name"/>.</p>
</div>
<div class="term-section">
<h2>11. Binding Obligations</h2>
<p>Any individual signing this agreement on behalf of a corporation or other entity shall be personally liable for all obligations under this agreement. This agreement is binding upon the heirs, executors, administrators, and assigns of the Renter.</p>
</div>
<div class="term-section">
<h2>12. Refusal of Service</h2>
<p><t t-esc="company.name"/> reserves the right to refuse rental to any individual or entity at its sole discretion.</p>
</div>
<div class="term-section">
<h2>13. Governing Law</h2>
<p>This Agreement shall be governed by and construed in accordance with the laws of the jurisdiction in which <t t-esc="company.name"/> operates.</p>
</div>
<div class="term-section">
<h2>14. Entire Agreement</h2>
<p>This Agreement constitutes the entire understanding between the parties concerning the rental of medical equipment and supersedes all prior agreements, representations, or understandings, whether written or oral.</p>
</div>
</div>
<!-- ============================================================ -->
<!-- PAGE 2: RENTAL DETAILS, PAYMENT, AND SIGNATURE -->
<!-- ============================================================ -->
<div style="page-break-before: always;"></div>
<h1>RENTAL DETAILS</h1>
<!-- Customer Info and Rental Period Side by Side -->
<table style="width: 100%; margin-bottom: 10px;">
<tr>
<td style="width: 50%; vertical-align: top; padding-right: 10px;">
<table class="bordered" style="width: 100%;">
<tr>
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTER INFORMATION</th>
</tr>
<tr>
<td style="width: 35%; font-weight: bold; background-color: #f5f5f5;">Name</td>
<td><t t-esc="doc.partner_id.name"/></td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Address</td>
<td>
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['address'], 'no_marker': True}"/>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Phone</td>
<td><t t-esc="doc.partner_id.phone or doc.partner_id.mobile or ''"/></td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Order Ref</td>
<td><t t-esc="doc.name"/></td>
</tr>
</table>
</td>
<td style="width: 50%; vertical-align: top; padding-left: 10px;">
<table class="bordered" style="width: 100%;">
<tr>
<th colspan="2" class="info-header" style="background-color: #0066a1; color: white;">RENTAL PERIOD</th>
</tr>
<tr>
<td style="width: 40%; font-weight: bold; background-color: #f5f5f5;">Start Date</td>
<td>
<t t-if="doc.rental_start_date">
<span t-field="doc.rental_start_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Return Date</td>
<td>
<t t-if="doc.rental_return_date">
<span t-field="doc.rental_return_date" t-options="{'widget': 'date'}"/>
</t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Duration</td>
<td>
<t t-if="doc.duration_days">
<span t-esc="doc.duration_days"/> Day<t t-if="doc.duration_days != 1">s</t>
<t t-if="doc.remaining_hours and doc.remaining_hours > 0">
, <t t-esc="doc.remaining_hours"/> Hr<t t-if="doc.remaining_hours != 1">s</t>
</t>
</t>
<t t-elif="doc.rental_start_date and doc.rental_return_date"><span>Less than 1 day</span></t>
<t t-else=""><span style="color: #999;">Not specified</span></t>
</td>
</tr>
<tr>
<td style="font-weight: bold; background-color: #f5f5f5;">Total Amount</td>
<td><strong><span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></strong></td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Equipment List - Compact -->
<table class="bordered" style="margin-bottom: 10px;">
<thead>
<tr>
<th class="text-center" style="width: 15%;">PRODUCT CODE</th>
<th style="width: 55%;">DESCRIPTION</th>
<th class="text-center" style="width: 15%;">SERIAL #</th>
<th class="text-center" style="width: 15%;">QTY</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.order_line" t-as="line">
<t t-if="not line.display_type">
<tr>
<td class="text-center">
<span t-esc="line.product_id.default_code or ''"/>
</td>
<td>
<t t-if="line.name">
<t t-set="clean_name" t-value="line.name"/>
<t t-if="'] ' in clean_name">
<t t-set="clean_name" t-value="clean_name.split('] ', 1)[1]"/>
</t>
<t t-if="' to ' in clean_name and '\n' in clean_name">
<t t-set="clean_name" t-value="clean_name.split('\n')[0]"/>
</t>
<t t-esc="clean_name"/>
</t>
</td>
<td class="text-center">
<span t-esc="line.x_fc_serial_number or ''"/>
</td>
<td class="text-center">
<span t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Credit Card Authorization - Compact -->
<div class="cc-section">
<div class="cc-title">CREDIT CARD PAYMENT AUTHORIZATION</div>
<table style="width: 100%; border: none;">
<tr>
<td style="width: 20%; padding: 5px 4px; border: none;"><strong>Card #:</strong></td>
<td style="padding: 5px 4px; border: none;">
<t t-if="doc.rental_payment_token_id">
<span style="font-size: 14px;">**** **** **** <t t-out="doc._get_card_last_four() or '****'">1234</t></span>
</t>
<t t-else="">
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 3px;">-</span>
<span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span><span class="cc-box"></span>
</t>
</td>
</tr>
<tr>
<td style="padding: 5px 4px; border: none;"><strong>Exp Date:</strong></td>
<td style="padding: 5px 4px; border: none;">
<span class="cc-box"></span><span class="cc-box"></span>
<span style="margin: 0 2px;">/</span>
<span class="cc-box"></span><span class="cc-box"></span>
<span style="margin-left: 20px;"><strong>CVV:</strong></span>
<span>***</span>
<t t-set="deposit_lines" t-value="doc.order_line.filtered(lambda l: l.is_security_deposit)"/>
<span style="margin-left: 20px;"><strong>Security Deposit:</strong>
<t t-if="deposit_lines">
$<t t-out="'%.2f' % sum(deposit_lines.mapped('price_unit'))">0.00</t>
</t>
<t t-else="">$___________</t>
</span>
</td>
</tr>
<tr>
<td style="padding: 5px 4px; border: none;"><strong>Cardholder:</strong></td>
<td style="padding: 5px 4px; border: none;">
<t t-if="doc.rental_agreement_signer_name">
<span t-out="doc.rental_agreement_signer_name">Name</span>
</t>
<t t-else="">
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%;"></div>
</t>
</td>
</tr>
<tr>
<td colspan="2" style="padding: 5px 4px; border: none;">
<strong>Billing Address (if different):</strong>
<div style="border-bottom: 1px solid #000; min-height: 18px; width: 100%; margin-top: 4px;"></div>
</td>
</tr>
</table>
<div class="authorization-text">
<p>I authorize <t t-esc="company.name"/> to charge the credit card indicated in this authorization form according to the terms outlined above. I certify that I am an authorized user of this credit card and will not dispute the payment. By signing this form, I acknowledge that I have read the rental agreement and understand the terms and conditions. I understand that if the rented item is not returned on the agreed return date, additional charges will be incurred. *Payments for monthly rental items will be charged on the re-rental date until the item is returned.</p>
</div>
</div>
<!-- Signature Section - Compact -->
<div class="signature-section">
<div class="signature-box">
<table style="width: 100%; border: none;">
<tr>
<td style="width: 40%; padding: 5px; border: none;">
<div class="signature-label">FULL NAME (PRINT)</div>
<t t-if="doc.rental_agreement_signer_name">
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signer_name">Name</div>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
<td style="width: 40%; padding: 5px; border: none;">
<div class="signature-label">SIGNATURE</div>
<t t-if="doc.rental_agreement_signature">
<img t-att-src="'data:image/png;base64,' + doc.rental_agreement_signature.decode('utf-8') if doc.rental_agreement_signature else ''" style="max-height: 50px; max-width: 100%;"/>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
<td style="width: 20%; padding: 5px; border: none;">
<div class="signature-label">DATE</div>
<t t-if="doc.rental_agreement_signed_date">
<div style="min-height: 18px; font-size: 14px;" t-out="doc.rental_agreement_signed_date.strftime('%m/%d/%Y')">Date</div>
</t>
<t t-else=""><div class="signature-line"></div></t>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -285,66 +285,14 @@
</div>
</t>
<!-- Acceptance & Signature Section -->
<div style="margin-top: 25px; border: 2px solid #000; padding: 15px; page-break-inside: avoid;">
<div style="font-weight: bold; font-size: 10pt; text-transform: uppercase; border-bottom: 2px solid #000; padding-bottom: 5px; margin-bottom: 10px;">
Terms of Acceptance
<!-- Signature -->
<t t-if="doc.signature">
<div style="margin-top: 20px; text-align: right;">
<strong>Signature</strong><br/>
<img t-att-src="image_data_uri(doc.signature)" style="max-height: 4cm; max-width: 8cm;"/><br/>
<span t-field="doc.signed_by"/>
</div>
<div style="font-size: 8pt; line-height: 1.4; margin-bottom: 12px;">
By signing this document, the undersigned ("Client") acknowledges and agrees:
<ol style="margin: 5px 0 0 0; padding-left: 18px;">
<li>The Client has reviewed this quotation in its entirety and accepts all items, pricing, terms, and specifications as stated herein.</li>
<li>Upon signing, this quotation becomes a binding Sales Order between the Client and <t t-esc="doc.company_id.name"/>.</li>
<li>Any modifications to this order after acceptance must be submitted in writing and may result in revised pricing, terms, or delivery timelines.</li>
<li>Payment shall be made in accordance with the payment terms specified in this document.</li>
<li t-if="is_adp">For orders funded through the Ontario Assistive Devices Program (ADP), the Client authorizes <t t-esc="doc.company_id.name"/> to submit claims and documentation to ADP on their behalf.</li>
<li>Products are subject to the return and refund policy as outlined in <t t-esc="doc.company_id.name"/>'s standard terms of service.</li>
</ol>
</div>
<t t-if="doc.signature">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="width: 50%; padding: 5px 10px 5px 0; vertical-align: top;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Client Signature</div>
<div style="min-height: 55px; border-bottom: 1px solid #000; padding: 3px 0;">
<img t-att-src="image_data_uri(doc.signature)" style="max-height: 3cm; max-width: 7cm;"/>
</div>
</td>
<td style="width: 50%; padding: 5px 0 5px 10px; vertical-align: top;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Printed Name</div>
<div style="min-height: 25px; border-bottom: 1px solid #000; padding: 3px 0; font-size: 11pt; font-weight: bold;">
<span t-field="doc.signed_by"/>
</div>
<div style="margin-top: 12px;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Date &amp; Time of Acceptance</div>
<div style="min-height: 25px; border-bottom: 1px solid #000; padding: 3px 0; font-size: 10pt;">
<span t-field="doc.signed_on" t-options="{'widget': 'datetime'}"/>
</div>
</div>
</td>
</tr>
</table>
</t>
<t t-else="">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="width: 50%; padding: 5px 10px 5px 0; vertical-align: top;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Client Signature</div>
<div style="min-height: 55px; border-bottom: 1px solid #000;"></div>
</td>
<td style="width: 50%; padding: 5px 0 5px 10px; vertical-align: top;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Printed Name</div>
<div style="min-height: 25px; border-bottom: 1px solid #000;"></div>
<div style="margin-top: 12px;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Date &amp; Time</div>
<div style="min-height: 25px; border-bottom: 1px solid #000;"></div>
</div>
</td>
</tr>
</table>
</t>
</div>
</t>
</div>
</div>

View File

@@ -1,280 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
Landscape LTC Repair Order / Quotation Report Template
-->
<odoo>
<template id="report_saleorder_ltc_repair">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="repair" t-value="doc.x_fc_ltc_repair_id"/>
<style>
.fc-ltc { font-family: Arial, sans-serif; font-size: 11pt; }
.fc-ltc table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
.fc-ltc table.bordered, .fc-ltc table.bordered th, .fc-ltc table.bordered td { border: 1px solid #000; }
.fc-ltc th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fc-ltc td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
.fc-ltc .text-center { text-align: center; }
.fc-ltc .text-end { text-align: right; }
.fc-ltc .text-start { text-align: left; }
.fc-ltc .repair-bg { background-color: #e8f5e9; }
.fc-ltc .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-ltc .note-row { font-style: italic; }
.fc-ltc h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
.fc-ltc .info-table td { padding: 8px 12px; font-size: 11pt; }
.fc-ltc .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
.fc-ltc .totals-table { border: 1px solid #000; }
.fc-ltc .totals-table td { border: 1px solid #000; padding: 8px 12px; font-size: 11pt; }
.fc-ltc .photo-grid { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.fc-ltc .photo-grid img { max-width: 220px; max-height: 180px; border: 1px solid #ccc; }
.fc-ltc .photo-section { margin-top: 20px; page-break-inside: avoid; }
.fc-ltc .photo-section h3 { color: #0066a1; font-size: 14pt; border-bottom: 2px solid #0066a1; padding-bottom: 4px; }
</style>
<div class="fc-ltc">
<div class="page">
<!-- Document Title -->
<h2 style="text-align: left;">
<span t-if="doc.state in ['draft','sent']">LTC Repair Quotation </span>
<span t-else="">LTC Repair Order </span>
<span t-field="doc.name"/>
</h2>
<!-- Address Table -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">DELIVERY ADDRESS</th>
</tr>
</thead>
<tbody>
<tr>
<td style="height: 70px; font-size: 12pt;">
<div t-field="doc.partner_invoice_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address'], 'no_marker': True}"/>
</td>
<td style="height: 70px; font-size: 12pt;">
<div t-field="doc.partner_shipping_id"
t-options="{'widget': 'contact', 'fields': ['name', 'address'], 'no_marker': True}"/>
</td>
</tr>
</tbody>
</table>
<!-- Order Info Table -->
<table class="bordered info-table">
<thead>
<tr>
<th>ORDER DATE</th>
<th>SALES REP</th>
<th>VALIDITY</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center">
<span t-field="doc.date_order" t-options="{'widget': 'date'}"/>
</td>
<td class="text-center">
<span t-field="doc.user_id"/>
</td>
<td class="text-center">
<span t-field="doc.validity_date"/>
</td>
</tr>
</tbody>
</table>
<!-- LTC Repair Info Table -->
<t t-if="repair">
<table class="bordered info-table">
<thead>
<tr class="repair-bg">
<th style="background-color: #e8f5e9; color: #333;">REPAIR REF</th>
<th style="background-color: #e8f5e9; color: #333;">TECHNICIAN</th>
<th style="background-color: #e8f5e9; color: #333;">REPORTED DATE</th>
<th style="background-color: #e8f5e9; color: #333;">SERIAL #</th>
<th style="background-color: #e8f5e9; color: #333;">LTC LOCATION</th>
<th style="background-color: #e8f5e9; color: #333;">ROOM #</th>
</tr>
</thead>
<tbody>
<tr class="repair-bg">
<td class="text-center">
<span t-esc="repair.name or '-'"/>
</td>
<td class="text-center">
<span t-if="repair.assigned_technician_id"
t-esc="repair.assigned_technician_id.name"/>
<span t-else="">-</span>
</td>
<td class="text-center">
<t t-if="repair.issue_reported_date">
<span t-field="repair.issue_reported_date"/>
</t>
<t t-else="">-</t>
</td>
<td class="text-center">
<span t-esc="repair.product_serial or '-'"/>
</td>
<td class="text-center">
<span t-if="repair.facility_id"
t-esc="repair.facility_id.name"/>
<span t-else="">-</span>
</td>
<td class="text-center">
<span t-esc="repair.room_number or '-'"/>
</td>
</tr>
</tbody>
</table>
</t>
<!-- Order Lines Table -->
<table class="bordered">
<thead>
<tr>
<th class="text-start" style="width: 40%;">DESCRIPTION</th>
<th class="text-center" style="width: 10%;">QTY</th>
<th class="text-center" style="width: 15%;">UNIT PRICE</th>
<th class="text-center" style="width: 15%;">TAX</th>
<th class="text-center" style="width: 20%;">TOTAL</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.order_line" t-as="line">
<!-- Section Header -->
<t t-if="line.display_type == 'line_section'">
<tr class="section-row">
<td colspan="5">
<strong><span t-field="line.name"/></strong>
</td>
</tr>
</t>
<!-- Note Line -->
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row">
<td colspan="5">
<span t-field="line.name"/>
</td>
</tr>
</t>
<!-- Product Line -->
<t t-elif="not line.display_type">
<tr>
<td>
<t t-if="line.name">
<t t-set="clean_name" t-value="line.name"/>
<t t-if="'] ' in line.name">
<t t-set="clean_name" t-value="line.name.split('] ', 1)[1]"/>
</t>
<t t-esc="clean_name"/>
</t>
</td>
<td class="text-center">
<span t-esc="int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty"/>
</td>
<td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or 'NO TAX SALE'"/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<!-- Payment Terms and Totals Row -->
<div class="row" style="margin-top: 15px;">
<div class="col-7">
<t t-if="doc.payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.payment_term_id.note"/>
</t>
</div>
<div class="col-5" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 200px;">Subtotal</td>
<td class="text-end" style="min-width: 150px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td><strong>Grand Total</strong></td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
</table>
</div>
</div>
<!-- Terms and Conditions -->
<t t-if="doc.note">
<div style="margin-top: 15px;">
<strong>Terms and Conditions:</strong>
<div t-field="doc.note"/>
</div>
</t>
<!-- Before Photos -->
<t t-if="repair and repair.before_photo_ids">
<div class="photo-section">
<h3>Before Photos (Reported Condition)</h3>
<div class="photo-grid">
<t t-foreach="repair.before_photo_ids" t-as="photo">
<img t-att-src="image_data_uri(photo.datas)"
t-att-alt="photo.name"/>
</t>
</div>
</div>
</t>
<!-- After Photos -->
<t t-if="repair and repair.after_photo_ids">
<div class="photo-section">
<h3>After Photos (Completed Repair)</h3>
<div class="photo-grid">
<t t-foreach="repair.after_photo_ids" t-as="photo">
<img t-att-src="image_data_uri(photo.datas)"
t-att-alt="photo.name"/>
</t>
</div>
</div>
</t>
<!-- Signature -->
<t t-if="doc.signature">
<div style="margin-top: 20px; text-align: right;">
<strong>Signature</strong><br/>
<img t-att-src="image_data_uri(doc.signature)" style="max-height: 4cm; max-width: 8cm;"/><br/>
<span t-field="doc.signed_by"/>
</div>
</t>
</div>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -203,13 +203,13 @@
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or 'NO TAX'"/>
</td>
<td t-if="is_adp" class="text-end adp-bg">
<span t-field="line.x_fc_adp_portion" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
<span t-field="line.x_fc_adp_portion"/>
</td>
<td t-if="is_adp" class="text-end client-bg">
<span t-field="line.x_fc_client_portion" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
<span t-field="line.x_fc_client_portion"/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
<span t-field="line.price_subtotal"/>
</td>
</tr>
</t>
@@ -230,26 +230,26 @@
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 140px;">Subtotal</td>
<td class="text-end" style="min-width: 100px;"><span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></td>
<td class="text-end" style="min-width: 100px;"><span t-field="doc.amount_untaxed"/></td>
</tr>
<tr>
<td>Taxes</td>
<td class="text-end"><span t-field="doc.amount_tax"/></td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td class="text-end"><strong><span t-field="doc.amount_total"/></strong></td>
</tr>
<t t-if="is_adp">
<tr class="adp-bg">
<td><strong>ADP Portion</strong></td>
<td class="text-end"><span t-field="doc.x_fc_adp_portion_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></td>
<td><strong>Total ADP Portion</strong></td>
<td class="text-end"><span t-field="doc.x_fc_adp_portion_total"/></td>
</tr>
<tr class="client-bg">
<td><strong>Client Portion</strong></td>
<td class="text-end"><span t-field="doc.x_fc_client_portion_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></td>
<td><strong>Total Client Portion</strong></td>
<td class="text-end"><span t-field="doc.x_fc_client_portion_total"/></td>
</tr>
</t>
<tr>
<td>Taxes</td>
<td class="text-end"><span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></td>
</tr>
<tr style="border-top: 2px solid #000;">
<td><strong>Grand Total</strong></td>
<td class="text-end"><strong><span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/></strong></td>
</tr>
</table>
</div>
</div>
@@ -262,66 +262,14 @@
</div>
</t>
<!-- Acceptance & Signature Section -->
<div style="margin-top: 25px; border: 2px solid #000; padding: 15px; page-break-inside: avoid;">
<div style="font-weight: bold; font-size: 10pt; text-transform: uppercase; border-bottom: 2px solid #000; padding-bottom: 5px; margin-bottom: 10px;">
Terms of Acceptance
<!-- Signature -->
<t t-if="doc.signature">
<div style="margin-top: 20px; text-align: right;">
<strong>Signature</strong><br/>
<img t-att-src="image_data_uri(doc.signature)" style="max-height: 4cm; max-width: 8cm;"/><br/>
<span t-field="doc.signed_by"/>
</div>
<div style="font-size: 8pt; line-height: 1.4; margin-bottom: 12px;">
By signing this document, the undersigned ("Client") acknowledges and agrees:
<ol style="margin: 5px 0 0 0; padding-left: 18px;">
<li>The Client has reviewed this quotation in its entirety and accepts all items, pricing, terms, and specifications as stated herein.</li>
<li>Upon signing, this quotation becomes a binding Sales Order between the Client and <t t-esc="doc.company_id.name"/>.</li>
<li>Any modifications to this order after acceptance must be submitted in writing and may result in revised pricing, terms, or delivery timelines.</li>
<li>Payment shall be made in accordance with the payment terms specified in this document.</li>
<li t-if="is_adp">For orders funded through the Ontario Assistive Devices Program (ADP), the Client authorizes <t t-esc="doc.company_id.name"/> to submit claims and documentation to ADP on their behalf.</li>
<li>Products are subject to the return and refund policy as outlined in <t t-esc="doc.company_id.name"/>'s standard terms of service.</li>
</ol>
</div>
<t t-if="doc.signature">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="width: 50%; padding: 5px 10px 5px 0; vertical-align: top;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Client Signature</div>
<div style="min-height: 55px; border-bottom: 1px solid #000; padding: 3px 0;">
<img t-att-src="image_data_uri(doc.signature)" style="max-height: 3cm; max-width: 7cm;"/>
</div>
</td>
<td style="width: 50%; padding: 5px 0 5px 10px; vertical-align: top;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Printed Name</div>
<div style="min-height: 25px; border-bottom: 1px solid #000; padding: 3px 0; font-size: 11pt; font-weight: bold;">
<span t-field="doc.signed_by"/>
</div>
<div style="margin-top: 12px;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Date &amp; Time of Acceptance</div>
<div style="min-height: 25px; border-bottom: 1px solid #000; padding: 3px 0; font-size: 10pt;">
<span t-field="doc.signed_on" t-options="{'widget': 'datetime'}"/>
</div>
</div>
</td>
</tr>
</table>
</t>
<t t-else="">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="width: 50%; padding: 5px 10px 5px 0; vertical-align: top;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Client Signature</div>
<div style="min-height: 55px; border-bottom: 1px solid #000;"></div>
</td>
<td style="width: 50%; padding: 5px 0 5px 10px; vertical-align: top;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Printed Name</div>
<div style="min-height: 25px; border-bottom: 1px solid #000;"></div>
<div style="margin-top: 12px;">
<div style="font-size: 7pt; color: #666; text-transform: uppercase; margin-bottom: 3px;">Date &amp; Time</div>
<div style="min-height: 25px; border-bottom: 1px solid #000;"></div>
</div>
</td>
</tr>
</table>
</t>
</div>
</t>
</div>
</div>

View File

@@ -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
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
36 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
37 access_fusion_xml_import_wizard fusion.xml.import.wizard.user model_fusion_xml_import_wizard sales_team.group_sale_manager 1 1 1 1
38 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
39 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
40 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
41 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
62 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
63 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
64 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
65 access_fusion_task_sync_config_manager access_fusion_page11_sign_request_user fusion.task.sync.config.manager fusion.page11.sign.request.user model_fusion_task_sync_config model_fusion_page11_sign_request sales_team.group_sale_manager sales_team.group_sale_salesman 1 1 1 1 0
66 access_fusion_task_sync_config_user access_fusion_page11_sign_request_manager fusion.task.sync.config.user fusion.page11.sign.request.manager model_fusion_task_sync_config model_fusion_page11_sign_request sales_team.group_sale_salesman sales_team.group_sale_manager 1 0 1 0 1 0 1
67 access_fusion_ltc_facility_user access_fusion_page11_sign_request_public fusion.ltc.facility.user fusion.page11.sign.request.public model_fusion_ltc_facility model_fusion_page11_sign_request sales_team.group_sale_salesman base.group_public 1 1 0 1 0 0
68 access_fusion_ltc_facility_manager access_fusion_send_page11_wizard_user fusion.ltc.facility.manager fusion_claims.send.page11.wizard.user model_fusion_ltc_facility model_fusion_claims_send_page11_wizard sales_team.group_sale_manager sales_team.group_sale_salesman 1 1 1 1
69 access_fusion_ltc_floor_user access_fusion_send_page11_wizard_manager fusion.ltc.floor.user fusion_claims.send.page11.wizard.manager model_fusion_ltc_floor model_fusion_claims_send_page11_wizard sales_team.group_sale_salesman sales_team.group_sale_manager 1 1 1 0 1
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

View File

@@ -54,88 +54,5 @@
<field name="comment">Temporary permission for editing locked documents on old/legacy cases. Requires the "Allow Document Lock Override" setting to be enabled in Fusion Claims Settings. Once all legacy cases are handled, disable the setting and remove this permission from users.</field>
</record>
<!-- ================================================================== -->
<!-- FIELD TECHNICIAN GROUP -->
<!-- Standalone group safe for both portal and internal users. -->
<!-- Do NOT imply group_fusion_claims_user — that chain leads to -->
<!-- base.group_user which conflicts with portal users (share=True). -->
<!-- Menu visibility is handled via comma-separated groups= on menus. -->
<!-- ================================================================== -->
<record id="group_field_technician" model="res.groups">
<field name="name">Field Technician</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_claims"/>
</record>
<!-- ================================================================== -->
<!-- TECHNICIAN TASK RECORD RULES -->
<!-- ================================================================== -->
<!-- Managers: full access to all tasks -->
<record id="rule_technician_task_manager" model="ir.rule">
<field name="name">Technician Task: Manager Full Access</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Sales users: read/write all tasks, create tasks -->
<record id="rule_technician_task_sales_user" model="ir.rule">
<field name="name">Technician Task: Sales User Access</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Field Technicians (internal): own tasks only -->
<record id="rule_technician_task_technician" model="ir.rule">
<field name="name">Technician Task: Technician Own Tasks</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[('technician_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_field_technician'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal technicians: own tasks only, read + limited write -->
<record id="rule_technician_task_portal" model="ir.rule">
<field name="name">Technician Task: Portal Technician Access</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[('technician_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ================================================================== -->
<!-- PUSH SUBSCRIPTION RECORD RULES -->
<!-- ================================================================== -->
<!-- Users: own subscriptions only -->
<record id="rule_push_subscription_user" model="ir.rule">
<field name="name">Push Subscription: Own Only</field>
<field name="model_id" ref="model_fusion_push_subscription"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Portal: own subscriptions only -->
<record id="rule_push_subscription_portal" model="ir.rule">
<field name="name">Push Subscription: Portal Own Only</field>
<field name="model_id" ref="model_fusion_push_subscription"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
</odoo>

View File

@@ -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 ──────────────────────────────────────────────────────

View File

@@ -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" });
},
});

View File

@@ -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 =
`<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">` +
`<rect x="2" y="2" width="44" height="44" rx="12" ry="12" fill="#1d4ed8" stroke="#fff" stroke-width="3"/>` +
`<rect x="2" y="2" width="44" height="44" rx="12" ry="12" fill="${pinColor}" stroke="#fff" stroke-width="3"/>` +
`<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="17" font-family="Arial,Helvetica,sans-serif" font-weight="bold">${initials}</text>` +
`</svg>`;
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(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:200px;color:#1f2937;">
<div style="background:#1d4ed8;color:#fff;padding:10px 14px;">
<div style="background:${pinColor};color:#fff;padding:10px 14px;">
<strong><i class="fa fa-user" style="margin-right:6px;"></i>${loc.name}</strong>
${srcLabel ? `<span style="float:right;font-size:10px;font-weight:600;background:rgba(255,255,255,.2);padding:2px 8px;border-radius:8px;">${srcLabel}</span>` : ""}
</div>
<div style="padding:12px 14px;font-size:13px;line-height:1.8;color:#1f2937;">
<div><strong style="color:#374151;">Last seen:</strong> <span style="color:#111827;">${loc.logged_at || "Unknown"}</span></div>
@@ -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 =
`<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">` +
`<circle cx="18" cy="18" r="16" fill="${segBaseColor}" stroke="#fff" stroke-width="3"/>` +
`<text x="18" y="23" text-anchor="middle" fill="#fff" font-size="16" font-family="Arial,sans-serif">&#x2302;</text>` +
`</svg>`;
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(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:180px;">
<div style="background:${segBaseColor};color:#fff;padding:8px 12px;border-radius:6px 6px 0 0;">
<strong>${seg.name} - Start</strong>
</div>
<div style="padding:8px 12px;font-size:13px;">
${startLoc.address || 'Start location'}
<div style="color:#6b7280;margin-top:4px;font-size:11px;">${startLoc.source === 'clock_in' ? 'Clock-in location' : startLoc.source === 'start_address' ? 'Home address' : 'Company HQ'}</div>
</div>
</div>`);
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 = `<div style="
display:inline-flex;align-items:center;gap:5px;
background:#fff;border:2px solid ${color};
border-radius:16px;padding:3px 10px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
font-size:11px;font-weight:700;color:#1f2937;
box-shadow:0 2px 8px rgba(0,0,0,.18);
">${firstName ? `<span style="color:${color};font-weight:600;">${firstName}</span><span style="color:#d1d5db;">|</span>` : ""}<span style="color:${color};">&#x1F697;</span><span>${timeStr}</span>${distStr ? `<span style="color:#9ca3af;font-weight:500;">&#183; ${distStr}</span>` : ""}${eta ? `<span style="color:#d1d5db;">|</span><span style="color:#059669;font-weight:700;">ETA ${eta}</span>` : ""}</div>`;
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 = `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:270px;max-width:360px;color:#1f2937;position:relative;">
<div style="background:${c};color:#fff;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:14px;">#${task._scheduleNum} &nbsp;${task.name}</strong>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:11px;background:rgba(255,255,255,.2);padding:2px 8px;border-radius:10px;">${task._statusLabel}</span>
<button onclick="document.querySelector('.gm-ui-hover-effect')?.click()" title="Close"
style="background:rgba(255,255,255,.2);border:none;color:#fff;width:24px;height:24px;border-radius:50%;cursor:pointer;font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;">
&times;
</button>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:290px;max-width:360px;color:#1f2937;">
<div style="background:${c};padding:14px 16px 12px;border-radius:0;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
<span style="color:rgba(255,255,255,.75);font-size:11px;font-weight:600;letter-spacing:.3px;">#${task._scheduleNum} ${task.name}</span>
<span style="font-size:10px;font-weight:600;background:${sc};color:#fff;padding:2px 10px;border-radius:10px;">${task._statusLabel}</span>
</div>
<div style="color:#fff;font-size:16px;font-weight:700;line-height:1.25;">${task._clientName}</div>
</div>
<div style="padding:12px 14px;font-size:13px;line-height:1.9;color:#1f2937;">
<div><strong style="color:#374151;">Client:</strong> <span style="color:#111827;">${task._clientName}</span></div>
<div><strong style="color:#374151;">Type:</strong> <span style="color:#111827;">${task._typeLbl}</span></div>
<div><strong style="color:#374151;">Technician:</strong> <span style="color:#111827;">${task._techName}</span></div>
<div><strong style="color:#374151;">Date:</strong> <span style="color:#111827;">${task.scheduled_date || ""}</span></div>
<div><strong style="color:#374151;">Time:</strong> <span style="color:#111827;">${task._timeRange}</span></div>
${task.address_display ? `<div><strong style="color:#374151;">Address:</strong> <span style="color:#111827;">${task.address_display}</span></div>` : ""}
${task.travel_time_minutes ? `<div><strong style="color:#374151;">Travel:</strong> <span style="color:#111827;">${task.travel_time_minutes} min</span></div>` : ""}
<div style="padding:10px 16px 6px;display:flex;gap:6px;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;">
<span style="opacity:.5;">&#xf02b;</span>${task._typeLbl}
</span>
<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;">
<span style="opacity:.5;">&#xf017;</span>${task._timeRange}
</span>
${task.travel_time_minutes ? `<span style="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;background:#f1f5f9;color:#334155;padding:3px 10px;border-radius:4px;"><span style="opacity:.5;">&#xf1b9;</span>${task.travel_time_minutes} min</span>` : ""}
</div>
<div style="padding:8px 14px 12px;border-top:1px solid #e5e7eb;display:flex;gap:10px;">
<div style="padding:8px 16px 12px;font-size:12px;line-height:1.7;color:#374151;">
<div style="display:flex;align-items:center;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;">&#x1F464;</span><span>${task._techName}</span></div>
<div style="display:flex;align-items:center;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;">&#x1F4C5;</span><span>${task.scheduled_date || "No date"}</span></div>
${task.address_display ? `<div style="display:flex;align-items:flex-start;gap:6px;"><span style="color:#9ca3af;width:14px;text-align:center;flex-shrink:0;">&#x1F4CD;</span><span>${task.address_display}</span></div>` : ""}
</div>
<div style="padding:6px 16px 14px;display:flex;gap:8px;align-items:center;">
<button onclick="window.__fusionMapOpenTask(${task.id})"
style="background:${c};color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;">
style="background:${c};color:#fff;border:none;padding:7px 20px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;transition:opacity .15s;">
Open Task
</button>
<a href="https://www.google.com/maps/dir/?api=1&destination=${task.address_lat && task.address_lng ? task.address_lat + ',' + task.address_lng : encodeURIComponent(task.address_display || "")}"
target="_blank" style="color:${c};text-decoration:none;font-size:13px;font-weight:600;line-height:32px;">
Navigate &rarr;
<a href="https://www.google.com/maps/dir/?api=1&destination=${navDest}"
target="_blank" style="color:${c};text-decoration:none;font-size:12px;font-weight:600;padding:7px 4px;">
Navigate &#x2192;
</a>
</div>
</div>`;
@@ -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" },
});
}
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}
}

View File

@@ -52,6 +52,22 @@
<button class="fc_day_chip fc_day_chip--all" t-on-click="showAllDays"
title="Show all">All</button>
</div>
<!-- Technician filter -->
<t t-if="state.allTechnicians.length > 1">
<div class="fc_tech_filters mt-2">
<t t-foreach="state.allTechnicians" t-as="tech" t-key="tech.id">
<button t-att-class="'fc_tech_chip' + (isTechVisible(tech.id) ? ' fc_tech_chip--active' : '')"
t-on-click="() => this.toggleTechFilter(tech.id)"
t-att-title="tech.name">
<span class="fc_tech_chip_avatar" t-esc="tech.initials"/>
<span class="fc_tech_chip_name" t-esc="tech.name"/>
</button>
</t>
<button class="fc_tech_chip fc_tech_chip--all" t-on-click="showAllTechs"
title="Show all technicians">All</button>
</div>
</t>
</div>
<!-- Sidebar body: grouped task list -->
@@ -113,6 +129,11 @@
<i class="fa fa-building-o me-1"/>
<t t-esc="task._sourceLabel"/>
</span>
<span class="fc_task_edit_btn"
t-on-click.stop="() => this.openTask(task.id)"
title="Edit task">
<i class="fa fa-pencil me-1"/>Edit
</span>
</div>
</div>
</t>
@@ -170,6 +191,11 @@
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#a855f7;"/>Upcoming</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#9ca3af;"/>Yesterday</span>
<span class="flex-grow-1"/>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showRoute ? 'btn-info' : 'btn-outline-secondary'"
t-on-click="toggleRoute" title="Toggle route animation">
<i class="fa fa-road"/>Route
</button>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTraffic ? 'btn-warning' : 'btn-outline-secondary'"
t-on-click="toggleTraffic" title="Toggle traffic layer">

View File

@@ -341,6 +341,27 @@
</field>
</record>
<!-- ===================================================================== -->
<!-- INVOICE LIST: Custom Columns -->
<!-- ===================================================================== -->
<record id="view_out_invoice_tree_fusion_claims" model="ir.ui.view">
<field name="name">account.move.list.fusion.central</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_invoice_tree"/>
<field name="priority">80</field>
<field name="arch" type="xml">
<xpath expr="//field[@name='amount_untaxed_in_currency_signed']" position="before">
<field name="x_fc_invoice_type" string="Invoice Type" optional="hide"/>
<field name="x_fc_client_type" string="Client Type" optional="hide"/>
<field name="x_fc_claim_number" string="Claim #" optional="hide"/>
<field name="x_fc_client_ref_1" string="Client Ref 1" optional="hide"/>
<field name="x_fc_client_ref_2" string="Client Ref 2" optional="hide"/>
<field name="partner_shipping_id" string="Delivery Address" optional="hide"/>
<field name="x_fc_adp_invoice_portion" string="Portion" optional="hide" widget="badge"/>
</xpath>
</field>
</record>
<!-- ===================================================================== -->
<!-- INVOICE SEARCH: Filters -->
<!-- ===================================================================== -->
@@ -350,24 +371,59 @@
<field name="inherit_id" ref="account.view_account_invoice_filter"/>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<!-- Search Fields -->
<field name="partner_shipping_id" string="Delivery Address"/>
<field name="x_fc_claim_number" string="Claim Number"/>
<field name="x_fc_client_ref_1" string="Client Reference 1"/>
<field name="x_fc_client_ref_2" string="Client Reference 2"/>
<field name="x_fc_invoice_type" string="Invoice Type"/>
<field name="x_fc_client_type" string="Client Type"/>
<separator/>
<filter string="ADP Invoices" name="adp_invoices"
<!-- Sale Type Filters -->
<filter string="ADP" name="type_adp"
domain="[('x_fc_invoice_type', 'in', ['adp', 'adp_odsp'])]"/>
<filter string="ADP Client" name="type_adp_client"
domain="[('x_fc_invoice_type', '=', 'adp_client')]"/>
<filter string="ODSP" name="type_odsp"
domain="[('x_fc_invoice_type', 'in', ['odsp', 'adp_odsp'])]"/>
<filter string="MOD" name="type_mod"
domain="[('x_fc_invoice_type', '=', 'march_of_dimes')]"/>
<filter string="WSIB" name="type_wsib"
domain="[('x_fc_invoice_type', '=', 'wsib')]"/>
<filter string="Insurance" name="type_insurance"
domain="[('x_fc_invoice_type', '=', 'insurance')]"/>
<filter string="Direct/Private" name="type_direct_private"
domain="[('x_fc_invoice_type', '=', 'direct_private')]"/>
<filter string="Hardship" name="type_hardship"
domain="[('x_fc_invoice_type', '=', 'hardship')]"/>
<filter string="Rentals" name="type_rental"
domain="[('x_fc_invoice_type', '=', 'rental')]"/>
<filter string="Muscular Dystrophy" name="type_muscular_dystrophy"
domain="[('x_fc_invoice_type', '=', 'muscular_dystrophy')]"/>
<filter string="Others" name="type_other"
domain="[('x_fc_invoice_type', '=', 'other')]"/>
<filter string="Regular" name="type_regular"
domain="[('x_fc_invoice_type', '=', 'regular')]"/>
<separator/>
<!-- ADP Export Filters -->
<filter string="ADP Exported" name="adp_exported"
domain="[('adp_exported', '=', True)]"/>
<filter string="Not ADP Exported" name="not_adp_exported"
domain="[('adp_exported', '=', False), ('x_fc_invoice_type', 'in', ['adp', 'adp_odsp']), ('move_type', 'in', ['out_invoice', 'out_refund'])]"/>
<separator/>
<!-- Client Type Filters -->
<filter string="REG Clients" name="reg_clients"
domain="[('x_fc_client_type', '=', 'REG')]"/>
<filter string="ODS/OWP/ACS" name="full_funding"
domain="[('x_fc_client_type', 'in', ['ODS', 'OWP', 'ACS'])]"/>
<separator/>
<!-- Invoice Portion Filters -->
<filter string="Client Invoices (25%)" name="client_invoices"
domain="[('x_fc_adp_invoice_portion', '=', 'client')]"/>
<filter string="ADP Invoices (75%)" name="adp_portion_invoices"
domain="[('x_fc_adp_invoice_portion', '=', 'adp')]"/>
<separator/>
<!-- ADP Billing Status Filters -->
<filter string="Billing: Waiting" name="billing_waiting"
domain="[('x_fc_adp_billing_status', '=', 'waiting')]"/>
<filter string="Billing: Submitted" name="billing_submitted"
@@ -376,6 +432,16 @@
domain="[('x_fc_adp_billing_status', '=', 'need_correction')]"/>
<filter string="Billing: Payment Issued" name="billing_payment_issued"
domain="[('x_fc_adp_billing_status', '=', 'payment_issued')]"/>
<separator/>
<!-- Group By -->
<filter string="Invoice Type" name="group_invoice_type"
context="{'group_by': 'x_fc_invoice_type'}"/>
<filter string="Client Type" name="group_client_type"
context="{'group_by': 'x_fc_client_type'}"/>
<filter string="Invoice Portion" name="group_invoice_portion"
context="{'group_by': 'x_fc_adp_invoice_portion'}"/>
<filter string="Billing Status" name="group_billing_status"
context="{'group_by': 'x_fc_adp_billing_status'}"/>
</xpath>
</field>
</record>

View File

@@ -238,21 +238,13 @@
<field name="model">sale.order</field>
<field name="arch" type="xml">
<search string="Search ADP Orders">
<field name="partner_id" string="Customer"/>
<field name="name" string="Order Reference"/>
<field name="name" string="Order"
filter_domain="['|', '|', ('name', 'ilike', self), ('client_order_ref', 'ilike', self), ('partner_id', 'child_of', self)]"/>
<field name="partner_id"/>
<field name="x_fc_claim_number"/>
<field name="x_fc_authorizer_id" string="Authorizer"/>
<field name="x_fc_authorizer_id"/>
<field name="x_fc_client_ref_1" string="Client Reference 1"/>
<field name="x_fc_client_ref_2" string="Client Reference 2"/>
<field name="x_fc_adp_application_status" string="ADP Status"/>
<field name="state" string="Order Status"/>
<field name="tag_ids" string="Tags"/>
<field name="partner_shipping_id" string="Delivery Name/Address"
filter_domain="['|', '|', '|',
('partner_shipping_id.name', 'ilike', self),
('partner_shipping_id.street', 'ilike', self),
('partner_shipping_id.street2', 'ilike', self),
('partner_shipping_id.city', 'ilike', self)]"/>
<separator/>
<!-- Status Filters - Active Workflow -->
<filter string="Quotation" name="filter_quotation"
@@ -687,22 +679,12 @@
<field name="model">sale.order</field>
<field name="arch" type="xml">
<search string="Search ODSP Cases">
<field name="partner_id" string="Customer"/>
<field name="name" string="Order Reference"/>
<field name="name" string="Order"
filter_domain="['|', '|', ('name', 'ilike', self), ('client_order_ref', 'ilike', self), ('partner_id', 'child_of', self)]"/>
<field name="partner_id"/>
<field name="x_fc_odsp_member_id"/>
<field name="x_fc_odsp_office_id"/>
<field name="x_fc_odsp_case_worker_name"/>
<field name="x_fc_odsp_std_status" string="ODSP Status"/>
<field name="x_fc_sa_status" string="SA Mobility Status"/>
<field name="x_fc_ow_status" string="Ontario Works Status"/>
<field name="state" string="Order Status"/>
<field name="tag_ids" string="Tags"/>
<field name="partner_shipping_id" string="Delivery Name/Address"
filter_domain="['|', '|', '|',
('partner_shipping_id.name', 'ilike', self),
('partner_shipping_id.street', 'ilike', self),
('partner_shipping_id.street2', 'ilike', self),
('partner_shipping_id.city', 'ilike', self)]"/>
<separator/>
<!-- ODSP Standard Status -->
<filter string="Quotation" name="filter_quotation"
@@ -1261,21 +1243,11 @@
<field name="model">sale.order</field>
<field name="arch" type="xml">
<search string="Search March of Dimes Cases">
<field name="partner_id" string="Customer"/>
<field name="name" string="Order Reference"/>
<field name="x_fc_case_reference" string="HVMP Reference"/>
<field name="x_fc_case_handler" string="Case Handler"/>
<field name="x_fc_case_worker" string="Case Worker"/>
<field name="x_fc_mod_status" string="MOD Status"/>
<field name="x_fc_mod_production_status" string="Production Stage"/>
<field name="state" string="Order Status"/>
<field name="tag_ids" string="Tags"/>
<field name="partner_shipping_id" string="Delivery Name/Address"
filter_domain="['|', '|', '|',
('partner_shipping_id.name', 'ilike', self),
('partner_shipping_id.street', 'ilike', self),
('partner_shipping_id.street2', 'ilike', self),
('partner_shipping_id.city', 'ilike', self)]"/>
<field name="name" string="Order"
filter_domain="['|', '|', ('name', 'ilike', self), ('client_order_ref', 'ilike', self), ('partner_id', 'child_of', self)]"/>
<field name="partner_id"/>
<field name="x_fc_case_reference"/>
<field name="x_fc_case_handler"/>
<separator/>
<!-- Status Filters - Main Workflow -->
<filter string="Schedule Assessment" name="filter_need_to_schedule"
@@ -1747,58 +1719,13 @@ else:
sequence="30"
groups="group_fusion_claims_user,fusion_tasks.group_field_technician"/>
<!-- ===== ALL INVOICES ===== -->
<menuitem id="menu_fc_all_invoices" name="All Invoices" parent="menu_adp_claims_root"
action="action_fc_all_invoices" sequence="3"/>
<!-- ===== LTC MANAGEMENT ===== -->
<menuitem id="menu_fc_ltc"
name="LTC"
parent="menu_adp_claims_root"
sequence="5"/>
<menuitem id="menu_ltc_overview"
name="Overview"
parent="menu_fc_ltc"
action="action_ltc_repairs_kanban"
sequence="1"/>
<menuitem id="menu_ltc_repairs"
name="Repair Requests"
parent="menu_fc_ltc"
sequence="10"/>
<menuitem id="menu_ltc_repairs_all"
name="All Repairs"
parent="menu_ltc_repairs"
action="action_ltc_repairs_all"
sequence="1"/>
<menuitem id="menu_ltc_repairs_new"
name="New / Pending"
parent="menu_ltc_repairs"
action="action_ltc_repairs_new"
sequence="2"/>
<menuitem id="menu_ltc_repairs_progress"
name="In Progress"
parent="menu_ltc_repairs"
action="action_ltc_repairs_in_progress"
sequence="3"/>
<menuitem id="menu_ltc_repairs_completed"
name="Completed"
parent="menu_ltc_repairs"
action="action_ltc_repairs_completed"
sequence="4"/>
<menuitem id="menu_ltc_cleanup"
name="Cleanup Schedule"
parent="menu_fc_ltc"
action="action_ltc_cleanups"
sequence="20"/>
<menuitem id="menu_ltc_locations"
name="Locations"
parent="menu_fc_ltc"
sequence="30"/>
<menuitem id="menu_ltc_facilities"
name="Facilities"
parent="menu_ltc_locations"
action="action_ltc_facilities"
sequence="1"/>
<!-- ===== ALL ORDERS (parent) ===== -->
<menuitem id="menu_fc_all_orders" name="All Orders" parent="menu_adp_claims_root"
action="sale.action_orders" sequence="2"/>
<menuitem id="menu_fc_all_sales_orders" name="All Sales Orders" parent="menu_fc_all_orders"
action="sale.action_orders" sequence="1"/>
<menuitem id="menu_fc_all_invoices" name="All Invoices" parent="menu_fc_all_orders"
action="action_fc_all_invoices" sequence="2"/>
<!-- ===== ADP SUBMENU (full workflow) ===== -->
<menuitem id="menu_fc_adp"
@@ -2108,22 +2035,6 @@ else:
action="action_device_import_wizard" sequence="20"/>
<menuitem id="menu_import_xml_files" name="Import XML Files" parent="menu_adp_config"
action="action_xml_import_wizard" sequence="30"/>
<menuitem id="menu_ltc_repair_stages" name="LTC Repair Stages" parent="menu_adp_config"
action="action_ltc_repair_stages" sequence="40"/>
<menuitem id="menu_forms_management"
name="Forms Management"
parent="menu_adp_config"
sequence="50"/>
<menuitem id="menu_form_submissions"
name="Form Submissions"
parent="menu_forms_management"
action="action_ltc_form_submissions"
sequence="1"/>
<menuitem id="menu_forms_settings"
name="Forms Settings"
parent="menu_forms_management"
action="action_fusion_claims_settings"
sequence="2"/>
<menuitem id="menu_fusion_claims_settings" name="Settings" parent="menu_adp_config"
action="action_fusion_claims_settings" sequence="90"/>

View File

@@ -409,7 +409,7 @@
<!-- ===================================================================== -->
<menuitem id="menu_loaner_root"
name="Loaner Management"
name="Loaners"
parent="menu_adp_claims_root"
sequence="58"/>

View File

@@ -1,180 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- CLEANUP - FORM VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_cleanup_form" model="ir.ui.view">
<field name="name">fusion.ltc.cleanup.form</field>
<field name="model">fusion.ltc.cleanup</field>
<field name="arch" type="xml">
<form string="Cleanup Schedule">
<header>
<button name="action_start" type="object" string="Start"
class="btn-primary"
invisible="state != 'scheduled'"/>
<button name="action_complete" type="object" string="Complete"
class="btn-primary"
invisible="state != 'in_progress'"/>
<button name="action_cancel" type="object" string="Cancel"
invisible="state in ('completed', 'cancelled')"/>
<button name="action_reset" type="object" string="Reset to Scheduled"
invisible="state not in ('cancelled', 'rescheduled')"/>
<field name="state" widget="statusbar"
statusbar_visible="scheduled,in_progress,completed"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_create_task" type="object"
class="oe_stat_button" icon="fa-tasks">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Task</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group string="Schedule">
<field name="facility_id"/>
<field name="scheduled_date"/>
<field name="completed_date"/>
<field name="technician_id"/>
</group>
<group string="Details">
<field name="items_cleaned"/>
<field name="task_id" readonly="1"/>
</group>
</group>
<notebook>
<page string="Notes" name="notes">
<field name="notes" placeholder="Cleanup notes..."/>
</page>
<page string="Photos" name="photos">
<field name="photo_ids" widget="many2many_binary"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- CLEANUP - LIST VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_cleanup_list" model="ir.ui.view">
<field name="name">fusion.ltc.cleanup.list</field>
<field name="model">fusion.ltc.cleanup</field>
<field name="arch" type="xml">
<list string="Cleanup Schedule" default_order="scheduled_date desc"
decoration-success="state == 'completed'"
decoration-muted="state in ('cancelled', 'rescheduled')">
<field name="name"/>
<field name="facility_id"/>
<field name="scheduled_date"/>
<field name="completed_date" optional="show"/>
<field name="technician_id" widget="many2one_avatar_user" optional="show"/>
<field name="items_cleaned" optional="show"/>
<field name="state" widget="badge"
decoration-info="state == 'scheduled'"
decoration-warning="state == 'in_progress'"
decoration-success="state == 'completed'"
decoration-danger="state == 'cancelled'"
decoration-muted="state == 'rescheduled'"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- CLEANUP - KANBAN VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_cleanup_kanban" model="ir.ui.view">
<field name="name">fusion.ltc.cleanup.kanban</field>
<field name="model">fusion.ltc.cleanup</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_kanban_small_column">
<templates>
<t t-name="card">
<div class="d-flex justify-content-between">
<field name="name" class="fw-bold"/>
</div>
<div>
<field name="facility_id" class="fw-bold"/>
</div>
<div class="text-muted">
Scheduled: <field name="scheduled_date"/>
</div>
<div t-if="record.completed_date.raw_value" class="text-muted">
Completed: <field name="completed_date"/>
</div>
<footer class="mt-2">
<field name="technician_id" widget="many2one_avatar_user"/>
</footer>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ================================================================== -->
<!-- CLEANUP - SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_cleanup_search" model="ir.ui.view">
<field name="name">fusion.ltc.cleanup.search</field>
<field name="model">fusion.ltc.cleanup</field>
<field name="arch" type="xml">
<search string="Search Cleanups">
<field name="facility_id"/>
<field name="technician_id"/>
<separator/>
<filter string="Scheduled" name="filter_scheduled"
domain="[('state', '=', 'scheduled')]"/>
<filter string="In Progress" name="filter_in_progress"
domain="[('state', '=', 'in_progress')]"/>
<filter string="Completed" name="filter_completed"
domain="[('state', '=', 'completed')]"/>
<separator/>
<filter string="Upcoming (7 Days)" name="filter_upcoming"
domain="[('scheduled_date', '&lt;=', (context_today() + datetime.timedelta(days=7)).strftime('%Y-%m-%d')),
('scheduled_date', '>=', context_today().strftime('%Y-%m-%d')),
('state', '=', 'scheduled')]"/>
<filter string="Overdue" name="filter_overdue"
domain="[('scheduled_date', '&lt;', context_today().strftime('%Y-%m-%d')),
('state', '=', 'scheduled')]"/>
<separator/>
<filter string="Facility" name="group_facility"
context="{'group_by': 'facility_id'}"/>
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Technician" name="group_technician"
context="{'group_by': 'technician_id'}"/>
<filter string="Scheduled Month" name="group_month"
context="{'group_by': 'scheduled_date:month'}"/>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTIONS -->
<!-- ================================================================== -->
<record id="action_ltc_cleanups" model="ir.actions.act_window">
<field name="name">Cleanup Schedule</field>
<field name="res_model">fusion.ltc.cleanup</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_ltc_cleanup_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No cleanups scheduled yet
</p>
<p>Cleanups are auto-scheduled based on facility settings, or can be created manually.</p>
</field>
</record>
</odoo>

View File

@@ -1,299 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- FACILITY - FORM VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_facility_form" model="ir.ui.view">
<field name="name">fusion.ltc.facility.form</field>
<field name="model">fusion.ltc.facility</field>
<field name="arch" type="xml">
<form string="LTC Facility">
<header/>
<sheet>
<widget name="web_ribbon" text="Archived" bg_color="text-bg-danger"
invisible="active"/>
<field name="active" invisible="1"/>
<div class="oe_button_box" name="button_box">
<button name="action_view_repairs" type="object"
class="oe_stat_button" icon="fa-wrench">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="active_repair_count"/>
</span>
<span class="o_stat_text">Active Repairs</span>
</div>
</button>
<button name="action_view_repairs" type="object"
class="oe_stat_button" icon="fa-list">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="repair_count"/>
</span>
<span class="o_stat_text">Total Repairs</span>
</div>
</button>
<button name="action_view_cleanups" type="object"
class="oe_stat_button" icon="fa-refresh">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="cleanup_count"/>
</span>
<span class="o_stat_text">Cleanups</span>
</div>
</button>
</div>
<field name="image_1920" widget="image" class="oe_avatar"
options="{'preview_image': 'image_128'}"/>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" placeholder="Facility Name"/>
</h1>
<field name="code" readonly="1"/>
</div>
<group>
<group string="Facility Details">
<field name="partner_id"/>
<field name="phone" widget="phone"/>
<field name="email" widget="email"/>
<field name="website" widget="url"/>
</group>
<group string="Address">
<field name="street"/>
<field name="street2"/>
<field name="city"/>
<field name="state_id"/>
<field name="zip"/>
<field name="country_id"/>
</group>
</group>
<notebook>
<page string="Key Contacts" name="contacts">
<group>
<group>
<field name="director_of_care_id"/>
<field name="service_supervisor_id"/>
</group>
<group>
<field name="physiotherapist_ids" widget="many2many_tags"/>
</group>
</group>
</page>
<page string="Floors &amp; Stations" name="structure">
<group>
<field name="number_of_floors"/>
</group>
<field name="floor_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="head_nurse_id"/>
<field name="physiotherapist_id"/>
</list>
</field>
</page>
<page string="Nursing Stations" name="stations">
<field name="floor_ids" mode="list">
<list>
<field name="name" string="Floor"/>
<field name="station_ids" widget="many2many_tags" string="Stations"/>
<field name="head_nurse_id"/>
</list>
</field>
</page>
<page string="Contract" name="contract">
<group>
<group>
<field name="contract_start_date"/>
<field name="contract_end_date"/>
</group>
<group>
<field name="cleanup_frequency"/>
<field name="cleanup_interval_days"
invisible="cleanup_frequency != 'custom'"/>
<field name="next_cleanup_date"/>
</group>
</group>
<separator string="Contract Document"/>
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center gap-2">
<field name="contract_file"
filename="contract_file_filename"
widget="binary" nolabel="1"/>
<field name="contract_file_filename" invisible="1"/>
<button name="action_preview_contract" type="object"
class="btn btn-secondary"
icon="fa-eye"
string="Preview"
invisible="not contract_file"/>
</div>
</div>
</div>
<field name="contract_notes" placeholder="Contract details and notes..."/>
</page>
<page string="Notes" name="notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- FACILITY - LIST VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_facility_list" model="ir.ui.view">
<field name="name">fusion.ltc.facility.list</field>
<field name="model">fusion.ltc.facility</field>
<field name="arch" type="xml">
<list default_order="name">
<field name="code" optional="show"/>
<field name="name"/>
<field name="city"/>
<field name="phone" optional="show"/>
<field name="director_of_care_id" optional="show"/>
<field name="service_supervisor_id" optional="hide"/>
<field name="number_of_floors" optional="hide"/>
<field name="contract_start_date" optional="hide"/>
<field name="contract_end_date" optional="hide"/>
<field name="cleanup_frequency" optional="show"/>
<field name="next_cleanup_date" optional="show"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- FACILITY - SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_facility_search" model="ir.ui.view">
<field name="name">fusion.ltc.facility.search</field>
<field name="model">fusion.ltc.facility</field>
<field name="arch" type="xml">
<search string="Search Facilities">
<field name="name" string="Facility"/>
<field name="code"/>
<field name="city"/>
<field name="director_of_care_id"/>
<separator/>
<filter string="Active" name="filter_active"
domain="[('active', '=', True)]"/>
<filter string="Archived" name="filter_archived"
domain="[('active', '=', False)]"/>
<separator/>
<filter string="Has Repairs" name="filter_has_repairs"
domain="[('repair_ids', '!=', False)]"/>
<filter string="Cleanup Due" name="filter_cleanup_due"
domain="[('next_cleanup_date', '&lt;=', (context_today() + datetime.timedelta(days=7)).strftime('%Y-%m-%d')),
('next_cleanup_date', '!=', False)]"/>
<separator/>
<filter string="City" name="group_city" context="{'group_by': 'city'}"/>
<filter string="Cleanup Frequency" name="group_frequency"
context="{'group_by': 'cleanup_frequency'}"/>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- STATION - FORM VIEW (for inline editing within floors) -->
<!-- ================================================================== -->
<record id="view_ltc_station_form" model="ir.ui.view">
<field name="name">fusion.ltc.station.form</field>
<field name="model">fusion.ltc.station</field>
<field name="arch" type="xml">
<form string="Nursing Station">
<sheet>
<group>
<group>
<field name="name"/>
<field name="sequence"/>
</group>
<group>
<field name="head_nurse_id"/>
<field name="phone" widget="phone"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- STATION - LIST VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_station_list" model="ir.ui.view">
<field name="name">fusion.ltc.station.list</field>
<field name="model">fusion.ltc.station</field>
<field name="arch" type="xml">
<list string="Nursing Stations" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="floor_id"/>
<field name="name"/>
<field name="head_nurse_id"/>
<field name="phone"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- FLOOR - FORM VIEW (with embedded stations) -->
<!-- ================================================================== -->
<record id="view_ltc_floor_form" model="ir.ui.view">
<field name="name">fusion.ltc.floor.form</field>
<field name="model">fusion.ltc.floor</field>
<field name="arch" type="xml">
<form string="Floor">
<sheet>
<group>
<group>
<field name="name"/>
<field name="sequence"/>
<field name="facility_id" invisible="1"/>
</group>
<group>
<field name="head_nurse_id"/>
<field name="physiotherapist_id"/>
</group>
</group>
<field name="station_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="head_nurse_id"/>
<field name="phone"/>
</list>
</field>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTIONS -->
<!-- ================================================================== -->
<record id="action_ltc_facilities" model="ir.actions.act_window">
<field name="name">LTC Facilities</field>
<field name="res_model">fusion.ltc.facility</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_ltc_facility_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add your first LTC Facility
</p>
<p>Create a facility record to start managing repairs and cleanups.</p>
</field>
</record>
</odoo>

View File

@@ -1,148 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- FORM SUBMISSION - LIST VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_form_submission_list" model="ir.ui.view">
<field name="name">fusion.ltc.form.submission.list</field>
<field name="model">fusion.ltc.form.submission</field>
<field name="arch" type="xml">
<list string="Form Submissions" default_order="submitted_date desc"
decoration-danger="is_emergency"
decoration-info="status == 'submitted'"
decoration-success="status == 'processed'">
<field name="name"/>
<field name="form_type"/>
<field name="facility_id"/>
<field name="client_name"/>
<field name="room_number"/>
<field name="product_serial" optional="show"/>
<field name="is_emergency"/>
<field name="submitted_date"/>
<field name="ip_address" optional="hide"/>
<field name="repair_id" optional="show"/>
<field name="status" widget="badge"
decoration-info="status == 'submitted'"
decoration-success="status == 'processed'"
decoration-danger="status == 'rejected'"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- FORM SUBMISSION - FORM VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_form_submission_form" model="ir.ui.view">
<field name="name">fusion.ltc.form.submission.form</field>
<field name="model">fusion.ltc.form.submission</field>
<field name="arch" type="xml">
<form string="Form Submission">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_repair" type="object"
class="oe_stat_button" icon="fa-wrench"
invisible="not repair_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Repair</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="form_type"/>
<field name="facility_id"/>
<field name="client_name"/>
<field name="room_number"/>
<field name="product_serial"/>
<field name="is_emergency"/>
</group>
<group>
<field name="status"/>
<field name="submitted_date"/>
<field name="ip_address"/>
<field name="repair_id"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- FORM SUBMISSION - SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_form_submission_search" model="ir.ui.view">
<field name="name">fusion.ltc.form.submission.search</field>
<field name="model">fusion.ltc.form.submission</field>
<field name="arch" type="xml">
<search string="Search Submissions">
<field name="name"/>
<field name="client_name"/>
<field name="facility_id"/>
<field name="room_number"/>
<separator/>
<filter string="Emergency" name="filter_emergency"
domain="[('is_emergency', '=', True)]"/>
<separator/>
<filter string="Submitted" name="filter_submitted"
domain="[('status', '=', 'submitted')]"/>
<filter string="Processed" name="filter_processed"
domain="[('status', '=', 'processed')]"/>
<filter string="Rejected" name="filter_rejected"
domain="[('status', '=', 'rejected')]"/>
<separator/>
<filter string="Today" name="filter_today"
domain="[('submitted_date', '>=', (context_today()).strftime('%Y-%m-%d'))]"/>
<filter string="This Week" name="filter_week"
domain="[('submitted_date', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter string="This Month" name="filter_month"
domain="[('submitted_date', '>=', (context_today()).strftime('%Y-%m-01'))]"/>
<separator/>
<filter string="Facility" name="group_facility"
context="{'group_by': 'facility_id'}"/>
<filter string="Form Type" name="group_type"
context="{'group_by': 'form_type'}"/>
<filter string="Status" name="group_status"
context="{'group_by': 'status'}"/>
<filter string="Submitted Date" name="group_date"
context="{'group_by': 'submitted_date:day'}"/>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTIONS -->
<!-- ================================================================== -->
<record id="action_ltc_form_submissions" model="ir.actions.act_window">
<field name="name">Form Submissions</field>
<field name="res_model">fusion.ltc.form.submission</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_ltc_form_submission_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No form submissions yet
</p>
<p>Submissions from portal repair forms will appear here.</p>
</field>
</record>
<!-- SEQUENCE -->
<data noupdate="1">
<record id="seq_ltc_form_submission" model="ir.sequence">
<field name="name">LTC Form Submission</field>
<field name="code">fusion.ltc.form.submission</field>
<field name="prefix">LTC-SUB-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -1,375 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- REPAIR - FORM VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_repair_form" model="ir.ui.view">
<field name="name">fusion.ltc.repair.form</field>
<field name="model">fusion.ltc.repair</field>
<field name="arch" type="xml">
<form string="Repair Request">
<header>
<button name="action_create_sale_order" type="object"
string="Create Sale Order" class="btn-primary"
invisible="sale_order_id"/>
<field name="stage_id" widget="statusbar"
options="{'clickable': '1'}"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not sale_order_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="sale_order_name"/>
</span>
<span class="o_stat_text">Sale Order</span>
</div>
</button>
<button name="action_view_task" type="object"
class="oe_stat_button" icon="fa-tasks"
invisible="not task_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Task</span>
</div>
</button>
</div>
<widget name="web_ribbon" text="Emergency" bg_color="text-bg-danger"
invisible="not is_emergency"/>
<div class="oe_title">
<label for="name"/>
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group string="Repair Details">
<field name="facility_id"/>
<field name="client_id"/>
<field name="client_name" invisible="client_id"/>
<field name="room_number"/>
<field name="is_emergency"/>
<field name="priority" widget="priority"/>
</group>
<group string="Product &amp; Assignment">
<field name="product_serial"/>
<field name="product_id"/>
<field name="assigned_technician_id"/>
<field name="source"/>
</group>
</group>
<notebook>
<page string="Issue" name="issue">
<group>
<group>
<field name="issue_reported_date"/>
</group>
<group>
<field name="issue_fixed_date"/>
</group>
</group>
<label for="issue_description"/>
<field name="issue_description"
placeholder="Describe the issue in detail..."/>
<label for="resolution_description"/>
<field name="resolution_description"
placeholder="How was the issue resolved?"/>
</page>
<page string="Family/POA" name="poa">
<group>
<group>
<field name="poa_name"/>
<field name="poa_phone" widget="phone"/>
</group>
</group>
</page>
<page string="Financial" name="financial">
<group>
<group>
<field name="sale_order_id"/>
<field name="task_id"/>
</group>
<group>
<field name="currency_id" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="repair_value"/>
</group>
</group>
</page>
<page string="Photos &amp; Notes" name="photos">
<div class="fc-gallery-content">
<separator string="Before Photos (Reported Condition)"/>
<field name="before_photo_ids" widget="many2many_binary"/>
<separator string="After Photos (Completed Repair)"/>
<field name="after_photo_ids" widget="many2many_binary"/>
</div>
<field name="photo_ids" invisible="1"/>
<separator string="Internal Notes"/>
<field name="notes" placeholder="Internal notes..."/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- REPAIR - KANBAN VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_repair_kanban" model="ir.ui.view">
<field name="name">fusion.ltc.repair.kanban</field>
<field name="model">fusion.ltc.repair</field>
<field name="arch" type="xml">
<kanban default_group_by="stage_id"
default_order="is_emergency desc, issue_reported_date desc"
highlight_color="color"
class="o_kanban_small_column">
<field name="color"/>
<field name="is_emergency"/>
<field name="stage_id"/>
<field name="kanban_state"/>
<field name="stage_color"/>
<progressbar field="kanban_state"
colors='{"normal": "200", "done": "success", "blocked": "danger"}'/>
<templates>
<t t-name="menu">
<a t-if="widget.editable" role="menuitem" type="open"
class="dropdown-item">Edit</a>
<a t-if="widget.deletable" role="menuitem" type="delete"
class="dropdown-item">Delete</a>
<field widget="kanban_color_picker" name="color"/>
</t>
<t t-name="card" class="flex-row">
<main t-att-data-stage="record.stage_color.raw_value || 'secondary'"
t-att-data-emergency="record.is_emergency.raw_value ? '1' : '0'"
t-att-data-priority="record.priority.raw_value">
<div class="d-flex justify-content-between align-items-center">
<field name="name" class="fw-bold text-primary"/>
<span t-attf-style="#{record.priority.raw_value == '1' ? 'background: rgba(255,152,0,0.1); border-radius: 4px; padding: 1px 5px;' : ''}">
<field name="priority" widget="priority"/>
</span>
</div>
<div class="mt-1">
<span class="fw-bold">
<field name="display_client_name"/>
</span>
<span t-if="record.room_number.raw_value"
class="ms-1 badge text-bg-light">
Rm <field name="room_number"/>
</span>
</div>
<div class="text-muted small">
<field name="facility_id"/>
</div>
<div t-if="record.is_emergency.raw_value"
class="mt-1">
<span class="badge text-bg-danger">
<i class="fa fa-exclamation-triangle me-1"/>Emergency
</span>
</div>
<div t-if="record.product_serial.raw_value"
class="text-muted small mt-1">
<i class="fa fa-barcode me-1"/>S/N: <field name="product_serial"/>
</div>
<footer class="mt-2 pt-1 border-top">
<span class="text-muted small">
<i class="fa fa-calendar me-1"/><field name="issue_reported_date"/>
</span>
<div class="ms-auto d-flex align-items-center">
<field name="kanban_state" widget="state_selection"/>
<field name="assigned_technician_id"
widget="many2one_avatar_user"
class="ms-1"/>
</div>
</footer>
</main>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ================================================================== -->
<!-- REPAIR - LIST VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_repair_list" model="ir.ui.view">
<field name="name">fusion.ltc.repair.list</field>
<field name="model">fusion.ltc.repair</field>
<field name="arch" type="xml">
<list string="Repair Requests" default_order="issue_reported_date desc"
decoration-danger="is_emergency">
<field name="stage_color" column_invisible="1"/>
<field name="name"/>
<field name="facility_id"/>
<field name="display_client_name" string="Client"/>
<field name="room_number"/>
<field name="product_serial" optional="show"/>
<field name="issue_reported_date"/>
<field name="issue_fixed_date" optional="show"/>
<field name="stage_id" widget="badge"
decoration-info="stage_color == 'info'"
decoration-warning="stage_color == 'warning'"
decoration-success="stage_color == 'success'"
decoration-danger="stage_color == 'danger'"
optional="show"/>
<field name="is_emergency" string="Emergency" optional="show"
widget="boolean_toggle"/>
<field name="assigned_technician_id" widget="many2one_avatar_user"
optional="show"/>
<field name="sale_order_id" optional="hide"/>
<field name="repair_value" optional="hide"/>
<field name="source" optional="hide"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- REPAIR - SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_ltc_repair_search" model="ir.ui.view">
<field name="name">fusion.ltc.repair.search</field>
<field name="model">fusion.ltc.repair</field>
<field name="arch" type="xml">
<search string="Search Repairs">
<field name="name"/>
<field name="display_client_name" string="Client"/>
<field name="room_number"/>
<field name="product_serial"/>
<field name="facility_id"/>
<field name="assigned_technician_id"/>
<separator/>
<filter string="Emergency" name="filter_emergency"
domain="[('is_emergency', '=', True)]"/>
<separator/>
<filter string="New" name="filter_new"
domain="[('stage_id.sequence', '=', 1)]"/>
<filter string="In Progress" name="filter_in_progress"
domain="[('stage_id.fold', '=', False), ('stage_id.sequence', '>', 1)]"/>
<filter string="Completed" name="filter_completed"
domain="[('stage_id.fold', '=', True)]"/>
<separator/>
<filter string="Has Sale Order" name="filter_has_so"
domain="[('sale_order_id', '!=', False)]"/>
<filter string="No Sale Order" name="filter_no_so"
domain="[('sale_order_id', '=', False)]"/>
<separator/>
<filter string="This Month" name="filter_this_month"
domain="[('issue_reported_date', '>=', (context_today()).strftime('%Y-%m-01'))]"/>
<filter string="Last 30 Days" name="filter_30d"
domain="[('issue_reported_date', '>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<filter string="This Year" name="filter_this_year"
domain="[('issue_reported_date', '>=', (context_today()).strftime('%Y-01-01'))]"/>
<separator/>
<filter string="Facility" name="group_facility"
context="{'group_by': 'facility_id'}"/>
<filter string="Stage" name="group_stage"
context="{'group_by': 'stage_id'}"/>
<filter string="Technician" name="group_technician"
context="{'group_by': 'assigned_technician_id'}"/>
<filter string="Source" name="group_source"
context="{'group_by': 'source'}"/>
<filter string="Reported Month" name="group_month"
context="{'group_by': 'issue_reported_date:month'}"/>
<filter string="Fixed Month" name="group_fixed_month"
context="{'group_by': 'issue_fixed_date:month'}"/>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- REPAIR STAGE - LIST + FORM (Configuration) -->
<!-- ================================================================== -->
<record id="view_ltc_repair_stage_list" model="ir.ui.view">
<field name="name">fusion.ltc.repair.stage.list</field>
<field name="model">fusion.ltc.repair.stage</field>
<field name="arch" type="xml">
<list string="Repair Stages" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="color"/>
<field name="fold"/>
<field name="description" optional="hide"/>
</list>
</field>
</record>
<record id="view_ltc_repair_stage_form" model="ir.ui.view">
<field name="name">fusion.ltc.repair.stage.form</field>
<field name="model">fusion.ltc.repair.stage</field>
<field name="arch" type="xml">
<form string="Repair Stage">
<sheet>
<group>
<field name="name"/>
<field name="sequence"/>
<field name="color"/>
<field name="fold"/>
<field name="description"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTIONS -->
<!-- ================================================================== -->
<record id="action_ltc_repairs_kanban" model="ir.actions.act_window">
<field name="name">Repair Overview</field>
<field name="res_model">fusion.ltc.repair</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_ltc_repair_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No repair requests yet
</p>
<p>Repair requests submitted via the portal form or manually will appear here.</p>
</field>
</record>
<record id="action_ltc_repairs_all" model="ir.actions.act_window">
<field name="name">All Repairs</field>
<field name="res_model">fusion.ltc.repair</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_ltc_repair_search"/>
</record>
<record id="action_ltc_repairs_new" model="ir.actions.act_window">
<field name="name">New / Pending</field>
<field name="res_model">fusion.ltc.repair</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_ltc_repair_search"/>
<field name="domain">[('stage_id.sequence', '&lt;=', 2)]</field>
</record>
<record id="action_ltc_repairs_in_progress" model="ir.actions.act_window">
<field name="name">In Progress</field>
<field name="res_model">fusion.ltc.repair</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_ltc_repair_search"/>
<field name="domain">[('stage_id.fold', '=', False), ('stage_id.sequence', '>', 2)]</field>
</record>
<record id="action_ltc_repairs_completed" model="ir.actions.act_window">
<field name="name">Completed</field>
<field name="res_model">fusion.ltc.repair</field>
<field name="view_mode">kanban,list,form</field>
<field name="search_view_id" ref="view_ltc_repair_search"/>
<field name="domain">[('stage_id.fold', '=', True)]</field>
</record>
<record id="action_ltc_repair_stages" model="ir.actions.act_window">
<field name="name">Repair Stages</field>
<field name="res_model">fusion.ltc.repair.stage</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_page11_sign_request_list" model="ir.ui.view">
<field name="name">fusion.page11.sign.request.list</field>
<field name="model">fusion.page11.sign.request</field>
<field name="arch" type="xml">
<list>
<field name="sale_order_id"/>
<field name="signer_name"/>
<field name="signer_email"/>
<field name="signer_type"/>
<field name="sent_date"/>
<field name="signed_date"/>
<field name="expiry_date"/>
<field name="state" widget="badge"
decoration-success="state == 'signed'"
decoration-info="state == 'sent'"
decoration-warning="state == 'expired'"
decoration-danger="state == 'cancelled'"/>
</list>
</field>
</record>
<record id="view_page11_sign_request_form" model="ir.ui.view">
<field name="name">fusion.page11.sign.request.form</field>
<field name="model">fusion.page11.sign.request</field>
<field name="arch" type="xml">
<form string="Page 11 Signing Request">
<header>
<button name="action_resend" type="object" string="Resend Email"
class="btn-primary" invisible="state not in ('sent', 'expired')"/>
<button name="action_request_new_signature" type="object"
string="Request New Signature"
class="btn-warning" invisible="state not in ('signed', 'cancelled')"
confirm="This will cancel the current signed version and open a new signing request. Continue?"/>
<button name="action_cancel" type="object" string="Cancel"
class="btn-secondary" invisible="state not in ('draft', 'sent')"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,sent,signed"/>
</header>
<sheet>
<group>
<group string="Request Details">
<field name="sale_order_id" readonly="1"/>
<field name="signer_name"/>
<field name="signer_email"/>
<field name="signer_type"/>
<field name="signer_relationship"
invisible="signer_type == 'client'"/>
</group>
<group string="Dates">
<field name="sent_date" readonly="1"/>
<field name="expiry_date"/>
<field name="signed_date" readonly="1"/>
</group>
</group>
<group string="Consent" invisible="state != 'signed'">
<field name="consent_signed_by"/>
<field name="consent_declaration_accepted"/>
</group>
<group string="Agent Details"
invisible="consent_signed_by != 'agent' or state != 'signed'">
<group>
<field name="agent_first_name"/>
<field name="agent_last_name"/>
<field name="agent_phone"/>
</group>
<group>
<field name="agent_street"/>
<field name="agent_city"/>
<field name="agent_province"/>
<field name="agent_postal_code"/>
</group>
</group>
<group string="Signature" invisible="state != 'signed'">
<field name="signature_data" widget="image" readonly="1"/>
</group>
<group string="Signed PDF" invisible="state != 'signed' or not signed_pdf">
<field name="signed_pdf" filename="signed_pdf_filename"/>
<field name="signed_pdf_filename" invisible="1"/>
</group>
<group string="Custom Message" invisible="not custom_message">
<field name="custom_message" readonly="1" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
</odoo>

View File

@@ -194,26 +194,6 @@
</div>
</div>
<h2>External APIs</h2>
<div class="row mt-4 o_settings_container">
<!-- Google Maps API Key -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Google Maps API</span>
<div class="text-muted">
API key for Google Maps Places autocomplete in address fields (accessibility assessments, etc.)
</div>
<div class="mt-2">
<field name="fc_google_maps_api_key" placeholder="Enter your Google Maps API Key" password="True"/>
</div>
<div class="alert alert-info mt-2" role="alert">
<i class="fa fa-info-circle"/> Enable the "Places API" in your Google Cloud Console for address autocomplete.
</div>
</div>
</div>
</div>
<h2>AI Client Intelligence</h2>
<div class="row mt-4 o_settings_container">
@@ -256,117 +236,6 @@
</div>
</div>
<h2>Technician Management</h2>
<div class="row mt-4 o_settings_container">
<!-- Store Hours -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Store / Scheduling Hours</span>
<div class="text-muted">
Operating hours for technician task scheduling. Tasks can only be booked
within these hours. Calendar view is also restricted to this range.
</div>
<div class="mt-2 d-flex align-items-center gap-2">
<field name="fc_store_open_hour" widget="float_time" style="max-width: 100px;"/>
<span>to</span>
<field name="fc_store_close_hour" widget="float_time" style="max-width: 100px;"/>
</div>
</div>
</div>
<!-- Distance Matrix Toggle -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fc_google_distance_matrix_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="fc_google_distance_matrix_enabled"/>
<div class="text-muted">
Calculate travel time between technician tasks using Google Distance Matrix API.
Requires Google Maps API key above with Distance Matrix API enabled.
</div>
</div>
</div>
<!-- Start Address (Company Default / Fallback) -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Default HQ / Fallback Address</span>
<div class="text-muted">
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.
</div>
<div class="mt-2">
<field name="fc_technician_start_address" placeholder="e.g. 123 Main St, Brampton, ON"/>
</div>
</div>
</div>
<!-- Location History Retention -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Location History Retention</span>
<div class="text-muted">
How many days to keep technician GPS location history before automatic cleanup.
</div>
<div class="mt-2 d-flex align-items-center gap-2">
<field name="fc_location_retention_days" placeholder="30" style="max-width: 80px;"/>
<span class="text-muted">days</span>
</div>
<div class="text-muted small mt-1">
Leave empty = 30 days. Enter 0 = delete at end of each day. 1+ = keep that many days.
</div>
</div>
</div>
</div>
<h2>Push Notifications</h2>
<div class="row mt-4 o_settings_container">
<!-- Push Enable -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fc_push_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="fc_push_enabled"/>
<div class="text-muted">
Send web push notifications to technicians about upcoming tasks.
Requires VAPID keys (auto-generated on first save if empty).
</div>
</div>
</div>
<!-- Advance Minutes -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Notification Advance Time</span>
<div class="text-muted">
Send push notification this many minutes before a scheduled task.
</div>
<div class="mt-2">
<field name="fc_push_advance_minutes"/> minutes
</div>
</div>
</div>
<!-- VAPID Public Key -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">VAPID Public Key</span>
<div class="mt-2">
<field name="fc_vapid_public_key" placeholder="Auto-generated"/>
</div>
</div>
</div>
<!-- VAPID Private Key -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">VAPID Private Key</span>
<div class="mt-2">
<field name="fc_vapid_private_key" password="True" placeholder="Auto-generated"/>
</div>
</div>
</div>
</div>
<h2>March of Dimes</h2>
<div class="row mt-4 o_settings_container">
@@ -501,25 +370,6 @@
</div>
</div>
<!-- ===== PORTAL FORMS ===== -->
<h2>Portal Forms</h2>
<div class="row mt16 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">LTC Form Access Password</span>
<div class="text-muted">
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.
</div>
<div class="mt-2">
<field name="fc_ltc_form_password"
placeholder="e.g. 1234"/>
</div>
</div>
</div>
</div>
<!-- ===== PORTAL BRANDING ===== -->
<h2>Portal Branding</h2>
<div class="row mt16 o_settings_container">

View File

@@ -16,32 +16,6 @@
<field name="x_fc_contact_type" placeholder="Select contact type..."/>
</xpath>
<!-- LTC section in notebook -->
<xpath expr="//notebook" position="inside">
<page string="LTC Home" name="ltc_info"
invisible="x_fc_contact_type not in ('long_term_care_home',)">
<group string="LTC Facility Information">
<group>
<field name="x_fc_ltc_facility_id"/>
<field name="x_fc_ltc_room_number"/>
</group>
</group>
<group string="Family Contacts">
<field name="x_fc_ltc_family_contact_ids" nolabel="1">
<list editable="bottom">
<field name="name"/>
<field name="relationship"/>
<field name="phone"/>
<field name="phone2"/>
<field name="email"/>
<field name="is_poa"/>
<field name="notes" optional="hide"/>
</list>
</field>
</group>
</page>
</xpath>
<!-- ODSP section in notebook -->
<xpath expr="//notebook" position="inside">
<page string="ODSP" name="odsp_info"
@@ -97,8 +71,6 @@
domain="[('x_fc_contact_type', 'in', ['odsp_customer', 'adp_odsp_customer'])]"/>
<filter name="filter_odsp_office" string="ODSP Offices"
domain="[('x_fc_contact_type', '=', 'odsp_office')]"/>
<filter name="filter_ltc_home" string="LTC Homes"
domain="[('x_fc_contact_type', '=', 'long_term_care_home')]"/>
</xpath>
</field>
</record>

View File

@@ -2467,28 +2467,6 @@
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_sales_order_filter"/>
<field name="arch" type="xml">
<!-- Reorder: Customer first -->
<xpath expr="//field[@name='name']" position="before">
<field name="partner_id" string="Customer"/>
</xpath>
<xpath expr="//field[@name='partner_id'][@operator='child_of']" position="replace"/>
<!-- Add Fusion-specific search fields after base fields -->
<xpath expr="//field[@name='activity_type_id']" position="after">
<field name="x_fc_claim_number" string="Claim Number"/>
<field name="x_fc_authorizer_id" string="Authorizer"/>
<field name="x_fc_sale_type" string="Sale Type"/>
<field name="x_fc_adp_application_status" string="ADP Status"/>
<field name="state" string="Order Status"/>
<field name="tag_ids" string="Tags"/>
<field name="partner_shipping_id" string="Delivery Name/Address"
filter_domain="['|', '|', '|',
('partner_shipping_id.name', 'ilike', self),
('partner_shipping_id.street', 'ilike', self),
('partner_shipping_id.street2', 'ilike', self),
('partner_shipping_id.city', 'ilike', self)]"/>
</xpath>
<xpath expr="//search" position="inside">
<separator/>
<!-- ADP Status Filters (using x_fc_adp_application_status) -->

View File

@@ -1,416 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
Portal template overrides for ADP orders:
- Shows client portion in sidebar instead of full order total
- Displays ADP claim details (Client Type, Authorizer, Dates, etc.)
- Payment amount selector shows client portion
-->
<odoo>
<!-- ================================================================
1. SIDEBAR: Show client portion instead of full amount
================================================================ -->
<template id="sale_order_portal_sidebar_adp"
inherit_id="sale.sale_order_portal_template"
name="Fusion: ADP Sidebar Amount">
<!-- Replace the data attribute with client portion for ADP -->
<xpath expr="//div[hasclass('o_portal_sale_sidebar')]" position="attributes">
<attribute name="t-att-data-order-amount-total">
sale_order.x_fc_client_portion_total if sale_order.x_fc_is_adp_sale and sale_order.x_fc_client_type == 'REG' else sale_order.amount_total
</attribute>
</xpath>
<!-- Replace the sidebar title (amount display) -->
<xpath expr="//div[hasclass('o_portal_sale_sidebar')]//t[@t-set='title']" position="replace">
<t t-set="title">
<!-- ADP REG: show client portion -->
<t t-if="sale_order.x_fc_is_adp_sale and sale_order.x_fc_client_type == 'REG'">
<small class="text-muted d-block">Your Portion</small>
<h2 class="mb-0 text-break">
<t t-out="sale_order.x_fc_client_portion_total"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>
</h2>
</t>
<!-- ADP non-REG (100% funded): no payment needed -->
<t t-elif="sale_order.x_fc_is_adp_sale and sale_order.x_fc_client_type and sale_order.x_fc_client_type != 'REG'">
<small class="text-muted d-block">Fully ADP Funded</small>
<h2 class="mb-0 text-break text-success">$ 0.00</h2>
</t>
<!-- Standard Odoo behavior for non-ADP -->
<t t-elif="sale_order._has_to_be_paid() and sale_order.prepayment_percent != 1.0">
<h4>Down payment</h4>
<h2>
<t t-out="sale_order._get_prepayment_required_amount()"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>
(<t t-out="sale_order.prepayment_percent * 100"/>%)
</h2>
</t>
<h2 t-else="" t-field="sale_order.amount_total" class="mb-0 text-break"/>
</t>
</xpath>
</template>
<!-- ================================================================
2. CONTENT: Show ADP claim details + funding breakdown
================================================================ -->
<template id="sale_order_portal_content_adp"
inherit_id="sale.sale_order_portal_content"
name="Fusion: ADP Details on Portal">
<!-- Add ADP details section after sale_info -->
<xpath expr="//div[@id='sale_info']" position="after">
<t t-if="sale_order.x_fc_is_adp_sale">
<div class="col-12 col-lg-6 mb-4">
<h5 class="mb-1">ADP Claim Details</h5>
<hr class="mt-1 mb-2"/>
<table class="table table-borderless table-sm">
<tbody style="white-space:nowrap">
<tr t-if="sale_order.x_fc_client_type">
<th class="ps-0 pb-0">Client Type:</th>
<td class="w-100 pb-0 text-wrap">
<span t-field="sale_order.x_fc_client_type"/>
</td>
</tr>
<tr t-if="sale_order.x_fc_claim_number">
<th class="ps-0 pb-0">Claim Number:</th>
<td class="w-100 pb-0 text-wrap">
<span t-field="sale_order.x_fc_claim_number"/>
</td>
</tr>
<tr t-if="sale_order.x_fc_authorizer_id">
<th class="ps-0 pb-0">Authorizer:</th>
<td class="w-100 pb-0 text-wrap">
<span t-field="sale_order.x_fc_authorizer_id"/>
</td>
</tr>
<tr t-if="sale_order.x_fc_client_ref_1">
<th class="ps-0 pb-0">Client Ref 1:</th>
<td class="w-100 pb-0 text-wrap">
<span t-field="sale_order.x_fc_client_ref_1"/>
</td>
</tr>
<tr t-if="sale_order.x_fc_client_ref_2">
<th class="ps-0 pb-0">Client Ref 2:</th>
<td class="w-100 pb-0 text-wrap">
<span t-field="sale_order.x_fc_client_ref_2"/>
</td>
</tr>
<tr t-if="sale_order.x_fc_assessment_end_date">
<th class="ps-0 pb-0">Assessment Date:</th>
<td class="w-100 pb-0 text-wrap">
<span t-field="sale_order.x_fc_assessment_end_date"
t-options="{'widget': 'date'}"/>
</td>
</tr>
<tr t-if="sale_order.x_fc_claim_authorization_date">
<th class="ps-0 pb-0">Authorization Date:</th>
<td class="w-100 pb-0 text-wrap">
<span t-field="sale_order.x_fc_claim_authorization_date"
t-options="{'widget': 'date'}"/>
</td>
</tr>
<tr t-if="sale_order.x_fc_claim_approval_date">
<th class="ps-0 pb-0">Approval Date:</th>
<td class="w-100 pb-0 text-wrap">
<span t-field="sale_order.x_fc_claim_approval_date"
t-options="{'widget': 'date'}"/>
</td>
</tr>
<tr t-if="sale_order.x_fc_adp_delivery_date">
<th class="ps-0 pb-0">Delivery Date:</th>
<td class="w-100 pb-0 text-wrap">
<span t-field="sale_order.x_fc_adp_delivery_date"
t-options="{'widget': 'date'}"/>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</xpath>
<!-- Add funding breakdown before totals -->
<xpath expr="//div[@id='total']" position="before">
<t t-if="sale_order.x_fc_is_adp_sale and sale_order.x_fc_client_type == 'REG'">
<div class="alert alert-info mb-3 py-2">
<strong>ADP Assisted Devices Program Order</strong><br/>
This order is partially funded through ADP.<br/>
<strong>Your portion:</strong>
<span t-esc="sale_order.x_fc_client_portion_total"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>
<span class="text-muted ms-2">
(ADP covers
<span t-esc="sale_order.x_fc_adp_portion_total"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>)
</span>
</div>
</t>
<t t-if="sale_order.x_fc_is_adp_sale and sale_order.x_fc_client_type and sale_order.x_fc_client_type != 'REG'">
<div class="alert alert-success mb-3 py-2">
<strong>ADP Assisted Devices Program Order</strong><br/>
This order is fully funded through ADP. No payment is required from you.
</div>
</t>
</xpath>
</template>
<!-- ================================================================
3. PAYMENT MODAL: Show client portion in amount selector
================================================================ -->
<template id="sale_order_portal_pay_amount_adp"
inherit_id="sale.sale_order_portal_pay_modal_amount_selector"
name="Fusion: ADP Payment Amount Selector">
<xpath expr="//button[@name='o_sale_portal_amount_total_button']" position="replace">
<button name="o_sale_portal_amount_total_button" class="btn btn-light">
<t t-if="sale_order.x_fc_is_adp_sale and sale_order.x_fc_client_type == 'REG'">
Client Portion <br/>
<span
t-out="sale_order.x_fc_client_portion_total"
t-options="{
'widget': 'monetary',
'display_currency': sale_order.currency_id,
}"
class="fw-bold"
/>
</t>
<t t-else="">
Full amount <br/>
<span
t-out="sale_order.amount_total"
t-options="{
'widget': 'monetary',
'display_currency': sale_order.currency_id,
}"
class="fw-bold"
/>
</t>
</button>
</xpath>
</template>
<!-- ================================================================
4. PAY MODAL: Fix confirmation text for ADP orders
================================================================ -->
<template id="sale_order_portal_pay_modal_adp"
inherit_id="sale.sale_order_portal_pay_modal"
name="Fusion: ADP Pay Modal Confirmation Text">
<xpath expr="//div[hasclass('mb-3')][.//b[@data-id='total_amount']]" position="replace">
<div t-if="sale_order._has_to_be_paid()" class="mb-3">
<t t-if="sale_order.x_fc_is_adp_sale and sale_order.x_fc_client_type == 'REG'">
By paying
<span t-out="sale_order.x_fc_client_portion_total"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"
class="fw-bold"/>,
you confirm acceptance on behalf of
<b t-field="sale_order.partner_id.commercial_partner_id"/>
for quotation <b t-field="sale_order.name"/>.
This payment covers your client portion. The remaining
<span t-out="sale_order.x_fc_adp_portion_total"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"
class="fw-bold"/>
is funded by ADP.
</t>
<t t-else="">
<span t-if="sale_order.prepayment_percent and sale_order.prepayment_percent != 1.0">
<span id="o_sale_portal_use_amount_prepayment">
By paying a <u>down payment</u> of
<span t-out="amount"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"
class="fw-bold"/>
<t t-if="sale_order._get_prepayment_required_amount() == amount">
(<b t-esc="sale_order.prepayment_percent * 100"/>%),
</t>
</span>
<span id="o_sale_portal_use_amount_total">By paying,</span>
</span>
<span t-else="">
By paying
<span t-out="amount"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"
class="fw-bold"/>,
</span>
you confirm acceptance on behalf of
<b t-field="sale_order.partner_id.commercial_partner_id"/>
for the <b data-id="total_amount" t-field="sale_order.amount_total"/> quote.
</t>
<b t-if="sale_order.payment_term_id"
t-field="sale_order.payment_term_id.note"
class="o_sale_payment_terms"/>
</div>
</xpath>
</template>
<!-- ================================================================
5. SIGN MODAL: Fix confirmation text for ADP orders
================================================================ -->
<template id="sale_order_portal_sign_modal_adp"
inherit_id="sale.sale_order_portal_sign_modal"
name="Fusion: ADP Sign Modal Confirmation Text">
<xpath expr="//main[@id='sign-dialog']/span[.//b[@data-id='total_amount']]" position="replace">
<span>
<t t-if="sale_order.x_fc_is_adp_sale and sale_order.x_fc_client_type == 'REG'">
By signing, you confirm acceptance on behalf of
<b t-field="sale_order.partner_id.commercial_partner_id"/>
for quotation <b t-field="sale_order.name"/>.
Your client portion is
<b t-out="sale_order.x_fc_client_portion_total"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>.
The remaining
<span t-out="sale_order.x_fc_adp_portion_total"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>
is funded by ADP.
</t>
<t t-elif="sale_order.x_fc_is_adp_sale and sale_order.x_fc_client_type and sale_order.x_fc_client_type != 'REG'">
By signing, you confirm acceptance on behalf of
<b t-field="sale_order.partner_id.commercial_partner_id"/>
for quotation <b t-field="sale_order.name"/>.
This order is fully funded by ADP.
</t>
<t t-else="">
By signing, you confirm acceptance on behalf of
<b t-field="sale_order.partner_id.commercial_partner_id"/>
for the <b data-id="total_amount" t-field="sale_order.amount_total"/> quote.
</t>
</span>
</xpath>
</template>
<!-- ================================================================
6. PORTAL TABLE: ADP table with borders, device code, portions
================================================================ -->
<template id="sale_order_portal_table_adp"
inherit_id="sale.sale_order_portal_content"
name="Fusion: ADP Portal Table with Borders and Columns">
<!-- Hide original table for ADP orders -->
<xpath expr="//div[@name='sol_table']" position="attributes">
<attribute name="t-if">not sale_order.x_fc_is_adp_sale</attribute>
</xpath>
<!-- Add ADP-specific table after the original -->
<xpath expr="//div[@name='sol_table']" position="after">
<div t-if="sale_order.x_fc_is_adp_sale" name="sol_table_adp" class="table-responsive">
<style>
.fc-adp-portal th { font-size: 0.78rem !important; white-space: nowrap; }
.fc-adp-portal td { font-size: 0.82rem !important; }
.fc-adp-portal .adp-col-bg { background-color: #f5faff; }
.fc-adp-portal .client-col-bg { background-color: #fffaf0; }
</style>
<table class="table table-sm table-bordered fc-adp-portal"
id="sales_order_table"
t-att-data-order-id="sale_order.id"
t-att-data-token="sale_order.access_token">
<thead class="table-light">
<tr>
<th class="text-center" style="width: 9%;">ADP Code</th>
<th class="text-start" id="product_name_header">Products</th>
<th class="text-end" id="product_qty_header">Quantity</th>
<th class="text-end d-none d-sm-table-cell" id="product_unit_price_header">Unit Price</th>
<th t-if="display_taxes" class="text-end d-none d-md-table-cell">Taxes</th>
<th class="text-end" style="background-color: #e3f2fd;">ADP Portion</th>
<th class="text-end" style="background-color: #fff3e0;">Client Portion</th>
<th class="text-end" id="product_total_header">Amount</th>
</tr>
</thead>
<tbody>
<t t-set="adp_total_cols" t-value="8 - (0 if display_taxes else 1)"/>
<t t-foreach="lines_to_report" t-as="line">
<!-- Section Header -->
<tr t-if="line.display_type == 'line_section'" name="tr_section" class="bg-200 fw-bold">
<td t-att-colspan="adp_total_cols" name="td_section_line">
<span t-field="line.name"/>
</td>
</tr>
<!-- Note Line -->
<tr t-elif="line.display_type == 'line_note'" name="tr_note" class="fst-italic text-muted">
<td t-att-colspan="adp_total_cols" name="td_note_line">
<span t-field="line.name"/>
</td>
</tr>
<!-- Product Line -->
<tr t-elif="not line.display_type" name="tr_product">
<td class="text-center">
<span t-esc="line.product_id.x_fc_adp_device_code or ''"/>
</td>
<td name="td_product_name">
<span t-field="line.name"/>
</td>
<td name="td_product_quantity" class="text-end">
<div id="quote_qty">
<span t-field="line.product_uom_qty"/>
<span t-field="line.product_uom_id"/>
</div>
</td>
<td class="text-end d-none d-sm-table-cell">
<span t-field="line.price_unit"
t-options="{'widget': 'monetary', 'display_currency': line.currency_id}"/>
</td>
<td t-if="display_taxes" class="text-end d-none d-md-table-cell">
<span t-out="', '.join(map(lambda x: (x.name), line.tax_ids))"/>
</td>
<td class="text-end adp-col-bg">
<span t-field="line.x_fc_adp_portion"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>
</td>
<td class="text-end client-col-bg">
<span t-field="line.x_fc_client_portion"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>
</td>
<td t-if="not line.is_downpayment" name="td_product_subtotal" class="text-end">
<span t-field="line.price_subtotal"/>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</xpath>
</template>
<!-- ================================================================
7. PORTAL TOTALS: ADP/Client portion subtotals
================================================================ -->
<template id="sale_order_portal_totals_adp"
inherit_id="sale.sale_order_portal_content_totals_table"
name="Fusion: ADP Portal Totals">
<xpath expr="//table[@name='sale_order_totals_table']" position="before">
<t t-if="sale_order.x_fc_is_adp_sale">
<table class="table table-sm table-bordered mb-1">
<tr style="background-color: #e3f2fd;">
<td class="ps-2"><strong>Total ADP Portion</strong></td>
<td class="text-end pe-2">
<strong>
<span t-field="sale_order.x_fc_adp_portion_total"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>
</strong>
</td>
</tr>
<tr style="background-color: #fff3e0;">
<td class="ps-2"><strong>Total Client Portion</strong></td>
<td class="text-end pe-2">
<strong>
<span t-field="sale_order.x_fc_client_portion_total"
t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}"/>
</strong>
</td>
</tr>
</table>
</t>
</xpath>
<xpath expr="//table[@name='sale_order_totals_table']" position="attributes">
<attribute name="t-att-class">'table table-sm' + (' table-bordered' if sale_order.x_fc_is_adp_sale else '')</attribute>
</xpath>
</template>
</odoo>

View File

@@ -1,80 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- SYNC CONFIG - FORM VIEW -->
<!-- ================================================================== -->
<record id="view_task_sync_config_form" model="ir.ui.view">
<field name="name">fusion.task.sync.config.form</field>
<field name="model">fusion.task.sync.config</field>
<field name="arch" type="xml">
<form string="Task Sync Configuration">
<header>
<button name="action_test_connection" type="object"
string="Test Connection" class="btn-secondary" icon="fa-plug"/>
<button name="action_sync_now" type="object"
string="Sync Now" class="btn-success" icon="fa-sync"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Westin Healthcare"/></h1>
</div>
<group>
<group string="Connection">
<field name="instance_id" placeholder="e.g. westin"/>
<field name="url" placeholder="http://192.168.1.40:8069"/>
<field name="database" placeholder="e.g. westin-v19"/>
<field name="username" placeholder="e.g. admin"/>
<field name="api_key" password="True"/>
<field name="active"/>
</group>
<group string="Status">
<field name="last_sync"/>
<field name="last_sync_error" readonly="1"/>
</group>
</group>
<div class="alert alert-info mt-3">
<i class="fa fa-info-circle"/>
Technicians are matched across instances by their
<strong>Tech Sync ID</strong> field (Settings &gt; Users).
Set the same ID (e.g. "gordy") on both instances for each shared technician.
</div>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- SYNC CONFIG - LIST VIEW -->
<!-- ================================================================== -->
<record id="view_task_sync_config_list" model="ir.ui.view">
<field name="name">fusion.task.sync.config.list</field>
<field name="model">fusion.task.sync.config</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="instance_id"/>
<field name="url"/>
<field name="database"/>
<field name="active"/>
<field name="last_sync"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- SYNC CONFIG - ACTION + MENU -->
<!-- ================================================================== -->
<record id="action_task_sync_config" model="ir.actions.act_window">
<field name="name">Task Sync Instances</field>
<field name="res_model">fusion.task.sync.config</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_task_sync_config"
name="Task Sync"
parent="fusion_claims.menu_technician_schedule"
action="action_task_sync_config"
sequence="99"/>
</odoo>

View File

@@ -1,128 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- LIST VIEW -->
<!-- ================================================================== -->
<record id="view_technician_location_list" model="ir.ui.view">
<field name="name">fusion.technician.location.list</field>
<field name="model">fusion.technician.location</field>
<field name="arch" type="xml">
<list string="Technician Locations" create="0" edit="0"
default_order="logged_at desc">
<field name="user_id" widget="many2one_avatar_user"/>
<field name="logged_at" string="Time"/>
<field name="latitude" optional="hide"/>
<field name="longitude" optional="hide"/>
<field name="accuracy" string="Accuracy (m)" optional="hide"/>
<field name="source"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- FORM VIEW (read-only) -->
<!-- ================================================================== -->
<record id="view_technician_location_form" model="ir.ui.view">
<field name="name">fusion.technician.location.form</field>
<field name="model">fusion.technician.location</field>
<field name="arch" type="xml">
<form string="Location Log" create="0" edit="0">
<sheet>
<group>
<group>
<field name="user_id"/>
<field name="logged_at"/>
<field name="source"/>
</group>
<group>
<field name="latitude"/>
<field name="longitude"/>
<field name="accuracy"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_technician_location_search" model="ir.ui.view">
<field name="name">fusion.technician.location.search</field>
<field name="model">fusion.technician.location</field>
<field name="arch" type="xml">
<search string="Search Location Logs">
<field name="user_id" string="Technician"/>
<separator/>
<filter string="Today" name="filter_today"
domain="[('logged_at', '>=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Last 7 Days" name="filter_7d"
domain="[('logged_at', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter string="Last 30 Days" name="filter_30d"
domain="[('logged_at', '>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<separator/>
<filter string="Technician" name="group_user" context="{'group_by': 'user_id'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'logged_at:day'}"/>
<filter string="Source" name="group_source" context="{'group_by': 'source'}"/>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTION -->
<!-- ================================================================== -->
<record id="action_technician_locations" model="ir.actions.act_window">
<field name="name">Location History</field>
<field name="res_model">fusion.technician.location</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_technician_location_search"/>
<field name="context">{
'search_default_filter_today': 1,
'search_default_group_user': 1,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No location data logged yet.
</p>
<p>Technician locations are automatically logged when they use the portal.</p>
</field>
</record>
<!-- ================================================================== -->
<!-- MAP VIEW (QWeb HTML with Google Maps) -->
<!-- ================================================================== -->
<record id="action_technician_location_map" model="ir.actions.act_url">
<field name="name">Technician Map</field>
<field name="url">/my/technician/admin/map</field>
<field name="target">self</field>
</record>
<!-- ================================================================== -->
<!-- MENU ITEMS (under Technician Management) -->
<!-- ================================================================== -->
<menuitem id="menu_technician_locations"
name="Location History"
parent="menu_technician_management"
action="action_technician_locations"
sequence="40"/>
<menuitem id="menu_technician_map"
name="Live Map"
parent="menu_technician_management"
action="action_technician_location_map"
sequence="45"/>
<!-- CRON: Cleanup old location records (runs daily) -->
<record id="ir_cron_cleanup_technician_locations" model="ir.cron">
<field name="name">Cleanup Old Technician Locations</field>
<field name="model_id" ref="model_fusion_technician_location"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_locations()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
</record>
</odoo>

View File

@@ -1,540 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Claims-specific extensions to the base technician task views
defined in fusion_tasks. Adds SO/PO/rental fields.
-->
<odoo>
<!-- ================================================================== -->
<!-- SEQUENCE -->
<!-- SEARCH VIEW EXTENSION -->
<!-- ================================================================== -->
<record id="seq_technician_task" model="ir.sequence">
<field name="name">Technician Task</field>
<field name="code">fusion.technician.task</field>
<field name="prefix">TASK-</field>
<field name="padding">5</field>
<field name="number_increment">1</field>
</record>
<!-- ================================================================== -->
<!-- RES.USERS FORM EXTENSION - Field Staff toggle -->
<!-- ================================================================== -->
<record id="view_users_form_field_staff" model="ir.ui.view">
<field name="name">res.users.form.field.staff</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<record id="view_technician_task_search_claims" model="ir.ui.view">
<field name="name">fusion.technician.task.search.claims</field>
<field name="model">fusion.technician.task</field>
<field name="inherit_id" ref="fusion_tasks.view_technician_task_search"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='share']" position="after">
<field name="x_fc_is_field_staff"/>
<field name="x_fc_start_address"
invisible="not x_fc_is_field_staff"
placeholder="e.g. 123 Main St, Brampton, ON"/>
<field name="x_fc_tech_sync_id"
invisible="not x_fc_is_field_staff"
placeholder="e.g. gordy, manpreet"/>
<xpath expr="//filter[@name='filter_pod']" position="after">
<filter string="Has Purchase Order" name="has_po"
domain="[('purchase_order_id', '!=', False)]"/>
</xpath>
</field>
</record>
<!-- ================================================================== -->
<!-- SEARCH VIEW -->
<!-- FORM VIEW EXTENSION -->
<!-- ================================================================== -->
<record id="view_technician_task_search" model="ir.ui.view">
<field name="name">fusion.technician.task.search</field>
<record id="view_technician_task_form_claims" model="ir.ui.view">
<field name="name">fusion.technician.task.form.claims</field>
<field name="model">fusion.technician.task</field>
<field name="inherit_id" ref="fusion_tasks.view_technician_task_form"/>
<field name="arch" type="xml">
<search string="Search Tasks">
<!-- Quick Filters -->
<filter string="Today" name="filter_today"
domain="[('scheduled_date', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Tomorrow" name="filter_tomorrow"
domain="[('scheduled_date', '=', (context_today() + datetime.timedelta(days=1)).strftime('%Y-%m-%d'))]"/>
<filter string="This Week" name="filter_this_week"
domain="[('scheduled_date', '>=', (context_today() - datetime.timedelta(days=context_today().weekday())).strftime('%Y-%m-%d')),
('scheduled_date', '&lt;=', (context_today() + datetime.timedelta(days=6-context_today().weekday())).strftime('%Y-%m-%d'))]"/>
<separator/>
<filter string="Pending" name="filter_pending" domain="[('status', '=', 'pending')]"/>
<filter string="Scheduled" name="filter_scheduled" domain="[('status', '=', 'scheduled')]"/>
<filter string="En Route" name="filter_en_route" domain="[('status', '=', 'en_route')]"/>
<filter string="In Progress" name="filter_in_progress" domain="[('status', '=', 'in_progress')]"/>
<filter string="Completed" name="filter_completed" domain="[('status', '=', 'completed')]"/>
<filter string="Active" name="filter_active" domain="[('status', 'not in', ['cancelled', 'completed'])]"/>
<separator/>
<filter string="My Tasks" name="filter_my_tasks"
domain="['|', ('technician_id', '=', uid), ('additional_technician_ids', 'in', [uid])]"/>
<filter string="Deliveries" name="filter_deliveries" domain="[('task_type', '=', 'delivery')]"/>
<filter string="Repairs" name="filter_repairs" domain="[('task_type', '=', 'repair')]"/>
<filter string="POD Required" name="filter_pod" domain="[('pod_required', '=', True)]"/>
<filter string="Has Purchase Order" name="has_po"
domain="[('purchase_order_id', '!=', False)]"/>
<separator/>
<filter string="Local Tasks" name="filter_local"
domain="[('x_fc_sync_source', '=', False)]"/>
<filter string="Synced Tasks" name="filter_synced"
domain="[('x_fc_sync_source', '!=', False)]"/>
<separator/>
<!-- Group By -->
<filter string="Technician" name="group_technician" context="{'group_by': 'technician_id'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'scheduled_date'}"/>
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
<filter string="Task Type" name="group_type" context="{'group_by': 'task_type'}"/>
<filter string="Client" name="group_client" context="{'group_by': 'partner_id'}"/>
</search>
<!-- Stat buttons: View Case + Purchase Order -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not sale_order_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">View Case</span>
</div>
</button>
<button name="action_view_purchase_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not purchase_order_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Purchase Order</span>
</div>
</button>
</xpath>
<!-- Add sale_order_id, purchase_order_id after priority -->
<xpath expr="//field[@name='priority']" position="after">
<field name="sale_order_id"/>
<field name="purchase_order_id"/>
</xpath>
<!-- Add Rental Inspection tab after Completion tab -->
<xpath expr="//page[@name='completion']" position="after">
<page string="Rental Inspection" name="rental_inspection"
invisible="task_type != 'pickup'">
<group>
<group string="Condition">
<field name="rental_inspection_condition"/>
<field name="rental_inspection_completed"/>
</group>
</group>
<group string="Inspection Notes">
<field name="rental_inspection_notes" nolabel="1"/>
</group>
<group string="Inspection Photos">
<field name="rental_inspection_photo_ids"
widget="many2many_binary" nolabel="1"/>
</group>
</page>
</xpath>
</field>
</record>
<!-- ================================================================== -->
<!-- FORM VIEW -->
<!-- LIST VIEW EXTENSION -->
<!-- ================================================================== -->
<record id="view_technician_task_form" model="ir.ui.view">
<field name="name">fusion.technician.task.form</field>
<record id="view_technician_task_list_claims" model="ir.ui.view">
<field name="name">fusion.technician.task.list.claims</field>
<field name="model">fusion.technician.task</field>
<field name="inherit_id" ref="fusion_tasks.view_technician_task_list"/>
<field name="arch" type="xml">
<form string="Technician Task">
<field name="x_fc_is_shadow" invisible="1"/>
<field name="x_fc_sync_source" invisible="1"/>
<header>
<button name="action_start_en_route" type="object" string="En Route"
class="btn-primary" invisible="status != 'scheduled' or x_fc_is_shadow"/>
<button name="action_start_task" type="object" string="Start Task"
class="btn-primary" invisible="status not in ('scheduled', 'en_route') or x_fc_is_shadow"/>
<button name="action_complete_task" type="object" string="Complete"
class="btn-success" invisible="status not in ('in_progress', 'en_route') or x_fc_is_shadow"/>
<button name="action_reschedule" type="object" string="Reschedule"
class="btn-warning" invisible="status not in ('scheduled', 'en_route') or x_fc_is_shadow"/>
<button name="action_cancel_task" type="object" string="Cancel"
class="btn-danger" invisible="status in ('completed', 'cancelled') or x_fc_is_shadow"
confirm="Are you sure you want to cancel this task?"/>
<button name="action_reset_to_scheduled" type="object" string="Reset to Scheduled"
invisible="status not in ('cancelled', 'rescheduled') or x_fc_is_shadow"/>
<button string="Calculate Travel"
class="btn-secondary o_fc_calculate_travel" icon="fa-car"
invisible="x_fc_is_shadow"/>
<field name="status" widget="statusbar"
statusbar_visible="pending,scheduled,en_route,in_progress,completed"/>
</header>
<sheet>
<!-- Shadow task banner -->
<div class="alert alert-info text-center" role="alert"
invisible="not x_fc_is_shadow">
<strong><i class="fa fa-link"/> This task is synced from
<field name="x_fc_sync_source" readonly="1" nolabel="1" class="d-inline"/>
— view only.</strong>
</div>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not sale_order_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">View Case</span>
</div>
</button>
<button name="action_view_purchase_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="not purchase_order_id">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Purchase Order</span>
</div>
</button>
</div>
<widget name="web_ribbon" title="Completed" bg_color="text-bg-success"
invisible="status != 'completed'"/>
<widget name="web_ribbon" title="Cancelled" bg_color="text-bg-danger"
invisible="status != 'cancelled'"/>
<widget name="web_ribbon" title="Synced" bg_color="text-bg-info"
invisible="not x_fc_is_shadow or status in ('completed', 'cancelled')"/>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Schedule Info Banner -->
<field name="schedule_info_html" nolabel="1" colspan="2"
invisible="not technician_id or not scheduled_date"/>
<!-- Previous Task / Travel Warning Banner -->
<field name="prev_task_summary_html" nolabel="1" colspan="2"
invisible="not technician_id or not scheduled_date"/>
<!-- Hidden fields for calendar sync and legacy -->
<field name="datetime_start" invisible="1"/>
<field name="datetime_end" invisible="1"/>
<field name="time_start_12h" invisible="1"/>
<field name="time_end_12h" invisible="1"/>
<group>
<group string="Assignment">
<field name="technician_id"
domain="[('x_fc_is_field_staff', '=', True)]"/>
<field name="additional_technician_ids"
widget="many2many_tags_avatar"
domain="[('x_fc_is_field_staff', '=', True), ('id', '!=', technician_id)]"
options="{'color_field': 'color'}"/>
<field name="task_type"/>
<field name="priority" widget="priority"/>
<field name="facility_id"
invisible="task_type != 'ltc_visit'"/>
<field name="sale_order_id"
invisible="task_type == 'ltc_visit'"/>
<field name="purchase_order_id"
invisible="task_type == 'ltc_visit'"/>
</group>
<group string="Schedule">
<field name="scheduled_date"/>
<field name="time_start" widget="float_time"
string="Start Time"/>
<field name="duration_hours" widget="float_time"
string="Duration"/>
<field name="time_end" widget="float_time"
string="End Time" readonly="1"
force_save="1"/>
</group>
</group>
<group>
<group string="Client">
<field name="partner_id"/>
<field name="partner_phone" widget="phone"/>
</group>
<group string="Location">
<field name="address_partner_id"/>
<field name="address_street"/>
<field name="address_street2" string="Unit/Suite #"/>
<field name="address_buzz_code"/>
<field name="address_city" invisible="1"/>
<field name="address_state_id" invisible="1"/>
<field name="address_zip" invisible="1"/>
<field name="address_lat" invisible="1"/>
<field name="address_lng" invisible="1"/>
</group>
</group>
<group>
<group string="Travel (Auto-Calculated)">
<field name="travel_time_minutes" readonly="1"/>
<field name="travel_distance_km" readonly="1"/>
<field name="travel_origin" readonly="1"/>
<field name="previous_task_id" readonly="1"/>
</group>
<group string="Options">
<field name="pod_required"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<group>
<field name="description" placeholder="What needs to be done..."/>
</group>
<group>
<field name="equipment_needed" placeholder="Tools, parts, materials..."/>
</group>
</page>
<page string="Completion" name="completion">
<group>
<field name="completion_datetime"/>
<field name="completion_notes"/>
</group>
<group>
<field name="voice_note_transcription"/>
</group>
</page>
<page string="Rental Inspection" name="rental_inspection"
invisible="task_type != 'pickup'">
<group>
<group string="Condition">
<field name="rental_inspection_condition"/>
<field name="rental_inspection_completed"/>
</group>
</group>
<group string="Inspection Notes">
<field name="rental_inspection_notes" nolabel="1"/>
</group>
<group string="Inspection Photos">
<field name="rental_inspection_photo_ids"
widget="many2many_binary" nolabel="1"/>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- LIST VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_list" model="ir.ui.view">
<field name="name">fusion.technician.task.list</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<list string="Technician Tasks" decoration-success="status == 'completed'"
decoration-warning="status == 'in_progress'"
decoration-info="status == 'en_route'"
decoration-danger="status == 'cancelled'"
decoration-muted="status == 'rescheduled'"
default_order="scheduled_date, sequence, time_start">
<field name="name"/>
<field name="technician_id" widget="many2one_avatar_user"/>
<field name="additional_technician_ids" widget="many2many_tags_avatar"
optional="show" string="+ Techs"/>
<field name="task_type" decoration-bf="1"/>
<field name="scheduled_date"/>
<field name="time_start_display" string="Start"/>
<field name="time_end_display" string="End"/>
<field name="partner_id"/>
<field name="address_city"/>
<field name="travel_time_minutes" string="Travel (min)" optional="show"/>
<field name="status" widget="badge"
decoration-success="status == 'completed'"
decoration-warning="status == 'in_progress'"
decoration-info="status in ('scheduled', 'en_route')"
decoration-danger="status == 'cancelled'"/>
<field name="priority" widget="priority" optional="hide"/>
<field name="pod_required" optional="hide"/>
<xpath expr="//field[@name='pod_required']" position="after">
<field name="sale_order_id" optional="hide"/>
<field name="purchase_order_id" optional="hide"/>
<field name="x_fc_source_label" string="Source" optional="show"
widget="badge" decoration-info="x_fc_is_shadow"
decoration-success="not x_fc_is_shadow"/>
</list>
</xpath>
</field>
</record>
<!-- ================================================================== -->
<!-- KANBAN VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_kanban" model="ir.ui.view">
<field name="name">fusion.technician.task.kanban</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<kanban default_group_by="status" class="o_kanban_small_column"
records_draggable="1" group_create="0">
<field name="color"/>
<field name="priority"/>
<field name="technician_id"/>
<field name="additional_technician_ids"/>
<field name="additional_tech_count"/>
<field name="partner_id"/>
<field name="task_type"/>
<field name="scheduled_date"/>
<field name="time_start_display"/>
<field name="address_city"/>
<field name="travel_time_minutes"/>
<field name="status"/>
<field name="x_fc_is_shadow"/>
<field name="x_fc_sync_client_name"/>
<templates>
<t t-name="card">
<div t-attf-class="oe_kanban_color_#{record.color.raw_value} oe_kanban_card oe_kanban_global_click">
<div class="oe_kanban_content">
<div class="o_kanban_record_top mb-1">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title">
<field name="name"/>
</strong>
</div>
<field name="priority" widget="priority"/>
</div>
<div class="mb-1">
<span class="badge bg-primary me-1"><field name="task_type"/></span>
<span class="text-muted"><field name="scheduled_date"/> - <field name="time_start_display"/></span>
</div>
<div class="mb-1">
<i class="fa fa-user me-1"/>
<t t-if="record.x_fc_is_shadow.raw_value">
<span t-out="record.x_fc_sync_client_name.value"/>
</t>
<t t-else="">
<field name="partner_id"/>
</t>
</div>
<div class="text-muted small" t-if="record.address_city.raw_value">
<i class="fa fa-map-marker me-1"/><field name="address_city"/>
<t t-if="record.travel_time_minutes.raw_value">
<span class="ms-2"><i class="fa fa-car me-1"/><field name="travel_time_minutes"/> min</span>
</t>
</div>
<div t-if="record.additional_tech_count.raw_value > 0" class="text-muted small mb-1">
<i class="fa fa-users me-1"/>
<span>+<field name="additional_tech_count"/> technician(s)</span>
</div>
<div class="o_kanban_record_bottom mt-2">
<div class="oe_kanban_bottom_left">
<field name="activity_ids" widget="kanban_activity"/>
</div>
<div class="oe_kanban_bottom_right">
<field name="technician_id" widget="many2one_avatar_user"/>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ================================================================== -->
<!-- CALENDAR VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_calendar" model="ir.ui.view">
<field name="name">fusion.technician.task.calendar</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<calendar string="Technician Schedule"
date_start="datetime_start" date_stop="datetime_end"
color="technician_id" mode="week" event_open_popup="1"
quick_create="0">
<!-- Displayed on the calendar card -->
<field name="partner_id"/>
<field name="x_fc_sync_client_name"/>
<field name="task_type"/>
<field name="time_start_display" string="Start"/>
<field name="time_end_display" string="End"/>
<!-- Popover (hover/click) details -->
<field name="name"/>
<field name="technician_id" avatar_field="image_128"/>
<field name="address_display" string="Address"/>
<field name="travel_time_minutes" string="Travel (min)"/>
<field name="status"/>
<field name="duration_hours" widget="float_time" string="Duration"/>
</calendar>
</field>
</record>
<!-- ================================================================== -->
<!-- MAP VIEW (Enterprise web_map) -->
<!-- ================================================================== -->
<record id="view_technician_task_map" model="ir.ui.view">
<field name="name">fusion.technician.task.map</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<map res_partner="address_partner_id" default_order="time_start"
routing="1" js_class="fusion_task_map">
<field name="partner_id" string="Client"/>
<field name="task_type" string="Type"/>
<field name="technician_id" string="Technician"/>
<field name="time_start_display" string="Start"/>
<field name="time_end_display" string="End"/>
<field name="status" string="Status"/>
<field name="travel_time_minutes" string="Travel (min)"/>
</map>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTIONS -->
<!-- ================================================================== -->
<!-- Main Tasks Action (List/Kanban) -->
<record id="action_technician_tasks" model="ir.actions.act_window">
<field name="name">Technician Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">list,kanban,form,calendar,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first technician task
</p>
<p>Schedule deliveries, repairs, and other field tasks for your technicians.</p>
</field>
</record>
<!-- Schedule Action (Map default) -->
<record id="action_technician_schedule" model="ir.actions.act_window">
<field name="name">Schedule</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">map,calendar,list,kanban,form</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
<!-- Map View Action (for app landing page) -->
<record id="action_technician_map_view" model="ir.actions.act_window">
<field name="name">Delivery Map</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">map,list,kanban,form,calendar</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
<!-- Today's Tasks Action -->
<record id="action_technician_tasks_today" model="ir.actions.act_window">
<field name="name">Today's Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">kanban,list,form,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_today': 1, 'search_default_filter_active': 1}</field>
</record>
<!-- My Tasks Action -->
<record id="action_technician_my_tasks" model="ir.actions.act_window">
<field name="name">My Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">list,kanban,form,calendar,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_my_tasks': 1, 'search_default_filter_active': 1}</field>
</record>
<!-- Pending Tasks Action -->
<record id="action_technician_tasks_pending" model="ir.actions.act_window">
<field name="name">Pending Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_pending': 1}</field>
</record>
<!-- ================================================================== -->
<!-- MENU ITEMS -->
<!-- ================================================================== -->
<!-- Field Service - top-level menu (sequence 3 = first child = app default) -->
<menuitem id="menu_technician_management"
name="Field Service"
parent="fusion_claims.menu_adp_claims_root"
sequence="3"
groups="fusion_claims.group_fusion_claims_user,fusion_claims.group_field_technician"/>
<!-- Delivery Map - first item under Field Service = default landing view -->
<menuitem id="menu_fc_delivery_map"
name="Delivery Map"
parent="menu_technician_management"
action="action_technician_map_view"
sequence="5"
groups="fusion_claims.group_fusion_claims_user,fusion_claims.group_field_technician"/>
<menuitem id="menu_technician_schedule"
name="Schedule"
parent="menu_technician_management"
action="action_technician_schedule"
sequence="10"/>
<menuitem id="menu_technician_tasks"
name="Tasks"
parent="menu_technician_management"
action="action_technician_tasks"
sequence="20"/>
<menuitem id="menu_technician_tasks_pending"
name="Pending Tasks"
parent="menu_technician_management"
action="action_technician_tasks_pending"
sequence="13"/>
<menuitem id="menu_technician_tasks_today"
name="Today's Tasks"
parent="menu_technician_management"
action="action_technician_tasks_today"
sequence="15"/>
<menuitem id="menu_technician_my_tasks"
name="My Tasks"
parent="menu_technician_management"
action="action_technician_my_tasks"
sequence="25"
groups="fusion_claims.group_field_technician"/>
<!-- Field Service menus moved to fusion_tasks standalone app -->
</odoo>

View File

@@ -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
from . import send_page11_wizard

View File

@@ -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},
}

View File

@@ -10,10 +10,12 @@
<strong><i class="fa fa-info-circle"/> Upload Required Documents</strong>
<p class="mb-0">Please upload the ADP application documents received from the client.</p>
</div>
<field name="sale_order_id" invisible="1"/>
<field name="has_pending_page11_request" invisible="1"/>
<field name="has_signed_page11" invisible="1"/>
<group>
<field name="sale_order_id" invisible="1"/>
<group string="Original ADP Application">
<field name="original_application" filename="original_application_filename"
widget="binary" class="oe_inline"/>
@@ -24,6 +26,28 @@
<field name="signed_pages_11_12" filename="signed_pages_filename"
widget="binary" class="oe_inline"/>
<field name="signed_pages_filename" invisible="1"/>
<div invisible="has_signed_page11" class="mt-2">
<span class="text-muted small">Don't have signed pages? </span>
<button name="action_request_page11_signature" type="object"
string="Request Remote Signature"
class="btn btn-sm btn-outline-warning"
icon="fa-pencil-square-o"
help="Send Page 11 to a family member or agent for digital signing"/>
</div>
<div invisible="not has_pending_page11_request" class="mt-2">
<div class="alert alert-warning mb-0 py-2 px-3">
<i class="fa fa-clock-o"/> A remote signing request has been sent.
You can proceed without uploading signed pages -- they will be auto-filled when signed.
</div>
</div>
<div invisible="not has_signed_page11 or signed_pages_11_12" class="mt-2">
<div class="alert alert-success mb-0 py-2 px-3">
<i class="fa fa-check-circle"/> Page 11 has been signed remotely.
</div>
</div>
</group>
</group>

View File

@@ -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'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>' if self.notes else ''
order.message_post(
body=Markup(
'<div style="background: #d4edda; border-left: 4px solid #28a745; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #28a745; margin: 0 0 8px 0;"><i class="fa fa-check-square-o"/> Assessment Completed</h4>'
f'<p style="margin: 0;"><strong>Completion Date:</strong> {self.completion_date.strftime("%B %d, %Y")}</p>'
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(
'<div style="background:#fff3cd;border-left:4px solid #ffc107;padding:12px;margin:8px 0;border-radius:4px;">'
'<h4 style="color:#856404;margin:0 0 8px 0;">'
'<i class="fa fa-exclamation-triangle"/> Assessment Scheduling Override</h4>'
'<p style="margin:0;"><strong>Override by:</strong> %s</p>'
'<p style="margin:4px 0 0 0;"><strong>Reason:</strong> %s</p>'
'<p style="margin:4px 0 0 0;"><strong>Assessment Date:</strong> %s to %s</p>'
'%s'
'</div>'
),
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('<p style="margin:4px 0 0 0;"><strong>Notes:</strong> %s</p>') % 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('<p style="margin:4px 0 0 0;"><strong>Notes:</strong> %s</p>') % self.notes
) if self.notes else Markup('')
order.message_post(
body=Markup(
'<div style="background:#d4edda;border-left:4px solid #28a745;padding:12px;margin:8px 0;border-radius:4px;">'
'<h4 style="color:#28a745;margin:0 0 8px 0;">'
'<i class="fa fa-check-square-o"/> Assessment Completed</h4>'
'<p style="margin:0;"><strong>Completion Date:</strong> %s</p>'
'%s'
'</div>'
) % (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 = (
'<div style="background:#fff3cd;border-left:3px solid #ffc107;padding:8px 12px;'
'margin:12px 0;border-radius:4px;">'
'<strong>Note:</strong> This assessment was completed without being scheduled '
'through the system. '
f'<strong>Reason:</strong> {self.override_reason.strip()}'
'</div>'
)
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 <strong>{client_name}</strong> ({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='<strong>Next step:</strong> 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(
'<div class="alert alert-info" role="alert">'
'<strong>Assessment Completed email sent</strong>'
'<ul class="mb-0 mt-1"><li>To: %s</li>'
'<li>CC: %s</li></ul></div>'
) % (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)

View File

@@ -1,18 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Assessment Completed Wizard Form View -->
<record id="view_assessment_completed_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.assessment.completed.wizard.form</field>
<field name="model">fusion_claims.assessment.completed.wizard</field>
<field name="arch" type="xml">
<form string="Assessment Completed">
<field name="sale_order_id" invisible="1"/>
<field name="is_override" invisible="1"/>
<div invisible="not is_override"
class="alert alert-warning mb-3" role="alert">
<strong>Scheduling Override:</strong>
This assessment was not scheduled through the system.
A reason is required to proceed.
</div>
<group>
<field name="sale_order_id" invisible="1"/>
<field name="completion_date"/>
<field name="notes" placeholder="Enter any notes from the assessment..."/>
<group string="Assessment Dates">
<field name="assessment_start_date"/>
<field name="completion_date"/>
</group>
<group string="Details">
<field name="notes"
placeholder="Enter any notes from the assessment..."/>
<field name="notify_authorizer"/>
</group>
</group>
<group invisible="not is_override">
<field name="override_reason"
required="is_override"
placeholder="e.g., Authorizer completed the assessment independently and sent us the application directly..."
widget="text"/>
</group>
<footer>
<button name="action_complete" type="object"
<button name="action_complete" type="object"
string="Mark Complete" class="btn-primary"
icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
@@ -21,7 +44,6 @@
</field>
</record>
<!-- Action for the wizard -->
<record id="action_assessment_completed_wizard" model="ir.actions.act_window">
<field name="name">Assessment Completed</field>
<field name="res_model">fusion_claims.assessment.completed.wizard</field>

View File

@@ -1,48 +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, _
from odoo.exceptions import UserError
class LTCRepairCreateSOWizard(models.TransientModel):
_name = 'fusion.ltc.repair.create.so.wizard'
_description = 'LTC Repair - Link Contact & Create Sale Order'
repair_id = fields.Many2one(
'fusion.ltc.repair',
string='Repair Request',
required=True,
readonly=True,
)
client_name = fields.Char(
string='Client Name',
readonly=True,
)
action_type = fields.Selection([
('create_new', 'Create New Contact'),
('link_existing', 'Link to Existing Contact'),
], string='Action', default='create_new', required=True)
partner_id = fields.Many2one(
'res.partner',
string='Existing Contact',
)
def action_confirm(self):
self.ensure_one()
repair = self.repair_id
if self.action_type == 'create_new':
if not self.client_name:
raise UserError(_('Client name is required to create a new contact.'))
partner = self.env['res.partner'].create({
'name': self.client_name,
})
repair.client_id = partner
elif self.action_type == 'link_existing':
if not self.partner_id:
raise UserError(_('Please select an existing contact.'))
repair.client_id = self.partner_id
return repair._create_linked_sale_order()

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_ltc_repair_create_so_wizard_form" model="ir.ui.view">
<field name="name">fusion.ltc.repair.create.so.wizard.form</field>
<field name="model">fusion.ltc.repair.create.so.wizard</field>
<field name="arch" type="xml">
<form string="Link Contact">
<group>
<field name="repair_id" invisible="1"/>
<field name="client_name"/>
<field name="action_type" widget="radio"/>
<field name="partner_id"
invisible="action_type != 'link_existing'"
required="action_type == 'link_existing'"/>
</group>
<footer>
<button name="action_confirm" type="object"
string="Confirm &amp; Create Sale Order"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -34,11 +34,11 @@ class ReadyForSubmissionWizard(models.TransientModel):
# Client References (may already be filled)
client_ref_1 = fields.Char(
string='Client Reference 1',
help='First client reference number (e.g., PO number)',
help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO',
)
client_ref_2 = fields.Char(
string='Client Reference 2',
help='Second client reference number',
help='Last four digits of the client\'s health card number. Example: 1234',
)
# Reason for Application

View File

@@ -59,7 +59,7 @@
<field name="claim_authorization_date"/>
</group>
<group string="Client References">
<field name="client_ref_1" placeholder="e.g., DOJO"/>
<field name="client_ref_1" placeholder="e.g., JODO"/>
<field name="client_ref_2" placeholder="e.g., 1234"/>
</group>
</group>

View File

@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import timedelta
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class SendPage11Wizard(models.TransientModel):
_name = 'fusion_claims.send.page11.wizard'
_description = 'Send Page 11 for Remote Signing'
sale_order_id = fields.Many2one(
'sale.order', string='Sale Order',
required=True, readonly=True,
)
signer_email = fields.Char(string='Recipient Email', required=True)
signer_type = fields.Selection([
('client', 'Client (Self)'),
('spouse', 'Spouse'),
('parent', 'Parent'),
('legal_guardian', 'Legal Guardian'),
('poa', 'Power of Attorney'),
('public_trustee', 'Public Trustee'),
], string='Signer Type', default='client', required=True)
signer_name = fields.Char(string='Signer Name', required=True)
custom_message = fields.Text(
string='Personal Message',
help='Optional message to include in the signing request email.',
)
expiry_days = fields.Integer(
string='Link Valid For (days)', default=7, required=True,
)
client_name = fields.Char(string='Client', readonly=True)
case_ref = fields.Char(string='Case Reference', readonly=True)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self.env.context.get('active_id')
if not active_id:
return res
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
res['client_name'] = order.partner_id.name or ''
res['case_ref'] = order.name or ''
res['signer_name'] = order.partner_id.name or ''
res['signer_email'] = order.partner_id.email or ''
return res
def action_send(self):
"""Create a signing request and send the email."""
self.ensure_one()
if not self.signer_email:
raise UserError(_("Please enter the recipient's email address."))
if self.expiry_days < 1:
raise UserError(_("Expiry must be at least 1 day."))
request = self.env['fusion.page11.sign.request'].create({
'sale_order_id': self.sale_order_id.id,
'signer_email': self.signer_email,
'signer_type': self.signer_type,
'signer_name': self.signer_name,
'custom_message': self.custom_message,
'expiry_date': fields.Datetime.now() + timedelta(days=self.expiry_days),
'consent_signed_by': 'applicant' if self.signer_type == 'client' else 'agent',
'signer_relationship': dict(self._fields['signer_type'].selection).get(
self.signer_type, ''
) if self.signer_type != 'client' else '',
})
request._send_signing_email()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Page 11 Signing Request Sent'),
'message': _(
'Signing request has been sent to %s.',
self.signer_email,
),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_send_page11_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.send.page11.wizard.form</field>
<field name="model">fusion_claims.send.page11.wizard</field>
<field name="arch" type="xml">
<form string="Send Page 11 for Signing">
<group>
<group string="Case Information">
<field name="sale_order_id" invisible="1"/>
<field name="client_name"/>
<field name="case_ref"/>
</group>
<group string="Recipient">
<field name="signer_name"/>
<field name="signer_email" widget="email"/>
<field name="signer_type"/>
<field name="expiry_days"/>
</group>
</group>
<group string="Personal Message (Optional)">
<field name="custom_message" nolabel="1" placeholder="Add a personal note to include in the email..."/>
</group>
<footer>
<button name="action_send" type="object" string="Send Signing Request" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_send_page11_wizard" model="ir.actions.act_window">
<field name="name">Request Page 11 Signature</field>
<field name="res_model">fusion_claims.send.page11.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_sale_order_id': active_id}</field>
</record>
</odoo>

View File

@@ -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'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa fa-ban"></i> Invoice Cancelled (Withdrawal)</h5>
<ul>
<li><strong>Related Order:</strong> {order.name}</li>
<li><strong>Cancelled By:</strong> {user_name}</li>
<li><strong>Date:</strong> {change_date}</li>
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
</div>
''')
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'''
<div class="alert alert-warning" role="alert">
<p class="mb-0"><i class="fa fa-exclamation-triangle"></i> <strong>Warning:</strong> Could not cancel invoice {invoice.name}</p>
<p class="mb-0 small">{str(e)}</p>
</div>
''')
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'''
<div class="alert alert-warning" role="alert">
<p class="mb-0"><i class="fa fa-exclamation-triangle"></i> <strong>Warning:</strong> Could not cancel sale order</p>
<p class="mb-0 small">{str(e)}</p>
</div>
''')
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'<li>{inv}</li>' for inv in cancelled_invoices])
invoice_list_html = f'<li><strong>Invoices Cancelled:</strong><ul>{invoice_items}</ul></li>'
so_status = 'Cancelled' if cancelled_so else 'Not applicable'
summary_msg = Markup(f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa fa-ban"></i> Application Withdrawn &amp; Cancelled</h5>
<ul>
<li><strong>Withdrawn By:</strong> {user_name}</li>
<li><strong>Date:</strong> {change_date}</li>
<li><strong>Sale Order:</strong> {so_status}</li>
{invoice_list_html}
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
</div>
''')
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'''
<div class="alert alert-info" role="alert">
<h5 class="alert-heading"><i class="fa fa-undo"></i> Application Withdrawn for Correction</h5>
<ul>
<li><strong>Withdrawn By:</strong> {user_name}</li>
<li><strong>Date:</strong> {change_date}</li>
<li><strong>Status Returned To:</strong> Ready for Submission</li>
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
<p class="mb-0 mt-2"><i class="fa fa-info-circle"></i> Make corrections and click <strong>Submit Application</strong> to resubmit.</p>
</div>
''')
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

View File

@@ -26,7 +26,7 @@
<div class="alert alert-info mb-3 rounded-0" role="alert" invisible="new_status != 'withdrawn'"
style="margin: -16px -16px 16px -16px; padding: 12px 16px;">
<i class="fa fa-undo me-2"/> You are about to <strong>Withdraw</strong> this application. Please provide the reason for withdrawal.
<i class="fa fa-undo me-2"/> You are about to <strong>Withdraw</strong> this application. Please select what you would like to do after withdrawal.
</div>
<div class="alert alert-secondary mb-3 rounded-0" role="alert" invisible="new_status != 'on_hold'"
@@ -45,6 +45,26 @@
</div>
<div class="px-3 pb-3">
<!-- WITHDRAWAL INTENT (for 'withdrawn' status) -->
<group invisible="new_status != 'withdrawn'">
<group>
<field name="withdrawal_intent"
required="new_status == 'withdrawn'"
widget="radio"
options="{'horizontal': false}"/>
</group>
</group>
<div class="alert alert-danger mt-2 mb-3" role="alert"
invisible="new_status != 'withdrawn' or withdrawal_intent != 'cancel'">
<i class="fa fa-exclamation-triangle me-2"/>
This will <strong>permanently cancel</strong> the sale order and all related invoices. This action cannot be undone.
</div>
<div class="alert alert-info mt-2 mb-3" role="alert"
invisible="new_status != 'withdrawn' or withdrawal_intent != 'resubmit'">
<i class="fa fa-info-circle me-2"/>
The application will return to <strong>Ready for Submission</strong> status. You can make corrections and resubmit.
</div>
<!-- REJECTION REASON (for 'rejected' status) -->
<group invisible="new_status != 'rejected'">
<group>