This commit is contained in:
gsinghpal
2026-04-17 17:31:12 -04:00
parent e07002d550
commit b09538b4e2
26 changed files with 1996 additions and 173 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -168,5 +168,26 @@
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
</record>
<!-- Cron Job: ADP Hold Expiry Reminders (2026-04).
For each on-hold ADP case:
- Sends monthly reminder to the CLIENT (authorizer excluded per
2026-04 authorizer email policy). Cadence:
fusion_claims.adp_hold_reminder_interval_days (default 30).
- Sends ONE final warning to client + authorizer when funding
expires within fusion_claims.adp_hold_final_warning_days_before_expiry
(default 30 days before expiry).
- Silently skips cases where the client has no email on file.
Flags reset automatically when the case resumes from hold. -->
<record id="ir_cron_adp_hold_expiry_reminders" model="ir.cron">
<field name="name">Fusion Claims: ADP Hold Expiry Reminders</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_adp_hold_expiry_reminders()</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=9, minute=30, second=0)"/>
</record>
</data>
</odoo>

File diff suppressed because it is too large Load Diff

View File

@@ -12,12 +12,16 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_invoice"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
.fc-landscape table.bordered, .fc-landscape table.bordered th, .fc-landscape table.bordered td { border: 1px solid #000; }
.fc-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fc-landscape th { background-color: <t t-out="primary"/>; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fc-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
.fc-landscape .text-center { text-align: center; }
.fc-landscape .text-end { text-align: right; }
@@ -26,7 +30,7 @@
.fc-landscape .client-bg { background-color: #fff3e0; }
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-landscape .note-row { font-style: italic; }
.fc-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
.fc-landscape h2 { color: <t t-out="primary"/>; margin: 10px 0; font-size: 18pt; }
.fc-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
.fc-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
.fc-landscape .totals-table { border: 1px solid #000; }

View File

@@ -11,6 +11,10 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_invoice"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#005a83'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
@@ -25,7 +29,7 @@
.fc-report .client-bg { background-color: #fff3e0; }
.fc-report .section-row { background-color: #f8f8f8; font-weight: bold; }
.fc-report .note-row { font-style: italic; }
.fc-report h4 { color: #005a83; margin: 0 0 15px 0; }
.fc-report h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; }
.fc-report .totals-table { border: 1px solid #000; border-collapse: collapse; }
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
</style>

View File

@@ -12,19 +12,22 @@
<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"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
<style>
.fc-contract { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
.fc-contract h1 { color: #0066a1; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
.fc-contract h2 { color: #0066a1; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
.fc-contract h4 { color: #0066a1; margin: 0 0 10px 0; font-size: 13pt; }
.fc-contract h1 { color: <t t-out="primary"/>; font-size: 14pt; text-align: center; margin: 5px 0 10px 0; }
.fc-contract h2 { color: <t t-out="primary"/>; font-size: 9pt; margin: 6px 0 3px 0; font-weight: bold; }
.fc-contract h4 { color: <t t-out="primary"/>; margin: 0 0 10px 0; font-size: 13pt; }
.fc-contract p { margin: 2px 0; text-align: justify; }
.fc-contract .intro { margin-bottom: 8px; font-size: 8pt; }
.fc-contract ul { margin: 2px 0 2px 15px; padding: 0; }
.fc-contract li { margin-bottom: 1px; }
.fc-contract table { width: 100%; border-collapse: collapse; }
.fc-contract table.bordered, .fc-contract table.bordered th, .fc-contract table.bordered td { border: 1px solid #000; }
.fc-contract th { background-color: #0066a1; color: white; padding: 4px 6px; font-weight: bold; text-align: center; font-size: 8pt; }
.fc-contract th { background-color: <t t-out="primary"/>; color: white; padding: 4px 6px; font-weight: bold; text-align: center; font-size: 8pt; }
.fc-contract td { padding: 3px 5px; vertical-align: top; font-size: 8pt; }
.fc-contract .text-center { text-align: center; }
.fc-contract .text-end { text-align: right; }
@@ -48,7 +51,7 @@
.fc-contract .sig-row { display: table; width: 100%; margin-bottom: 20px; }
.fc-contract .sig-col { display: table-cell; width: 48%; vertical-align: top; }
.fc-contract .sig-spacer { display: table-cell; width: 4%; }
.fc-contract .sig-title { font-weight: bold; font-size: 9pt; color: #0066a1; margin-bottom: 8px; border-bottom: 2px solid #0066a1; padding-bottom: 3px; }
.fc-contract .sig-title { font-weight: bold; font-size: 9pt; color: <t t-out="primary"/>; margin-bottom: 8px; border-bottom: 2px solid <t t-out="primary"/>; padding-bottom: 3px; }
.fc-contract .sig-field { margin-bottom: 12px; }
.fc-contract .sig-line { border-bottom: 1px solid #000; min-height: 25px; }
.fc-contract .sig-label { font-size: 7pt; color: #666; margin-top: 2px; }

View File

@@ -15,13 +15,16 @@
<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)"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
<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 h2 { color: <t t-out="primary"/>; 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 th { background-color: <t t-out="primary"/>; 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; }

View File

@@ -12,10 +12,13 @@
<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"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="primary" t-value="(company.primary_color if company else False) or '#0066a1'"/>
<t t-set="secondary" t-value="(company.secondary_color if company else False) or '#90be6d'"/>
<style>
.fc-waiver { font-family: Arial, sans-serif; font-size: 10pt; line-height: 1.5; }
.fc-waiver h1 { color: #0066a1; font-size: 16pt; text-align: center; margin: 10px 0 20px 0; }
.fc-waiver h1 { color: <t t-out="primary"/>; font-size: 16pt; text-align: center; margin: 10px 0 20px 0; }
.fc-waiver h2 { color: #333; font-size: 11pt; margin: 15px 0 8px 0; }
.fc-waiver p { margin: 8px 0; text-align: justify; }
.fc-waiver .intro { margin-bottom: 15px; font-style: italic; }

View File

@@ -17,20 +17,24 @@
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<!-- Get sale order for MOD fields -->
<t t-set="so" t-value="doc.x_fc_source_sale_order_id"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#1a5276'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-mod-inv { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-mod-inv table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-mod-inv table.bordered, .fc-mod-inv table.bordered th, .fc-mod-inv table.bordered td { border: 1px solid #000; }
.fc-mod-inv th { background-color: #1a5276; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-mod-inv th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-mod-inv td { padding: 6px 8px; vertical-align: top; }
.fc-mod-inv .text-center { text-align: center; }
.fc-mod-inv .text-end { text-align: right; }
.fc-mod-inv .text-start { text-align: left; }
.fc-mod-inv .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-mod-inv .note-row { font-style: italic; color: #555; font-size: 9pt; }
.fc-mod-inv h4 { color: #1a5276; margin: 0 0 15px 0; font-size: 16pt; }
.fc-mod-inv .req-box { border: 2px solid #1a5276; padding: 8px 12px; margin: 6px 0; background-color: #fafafa; }
.fc-mod-inv h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
.fc-mod-inv .req-box { border: 2px solid <t t-out="primary"/>; padding: 8px 12px; margin: 6px 0; background-color: #fafafa; }
</style>
<div class="fc-mod-inv">

View File

@@ -9,21 +9,25 @@
<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)"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#1a5276'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-mod { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-mod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-mod table.bordered, .fc-mod table.bordered th, .fc-mod table.bordered td { border: 1px solid #000; }
.fc-mod th { background-color: #1a5276; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-mod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-mod td { padding: 6px 8px; vertical-align: top; }
.fc-mod .text-center { text-align: center; }
.fc-mod .text-end { text-align: right; }
.fc-mod .text-start { text-align: left; }
.fc-mod .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-mod h4 { color: #1a5276; margin: 0 0 15px 0; font-size: 16pt; }
.fc-mod h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
.fc-mod .info-header { background-color: #eaf2f8; color: #333; }
.fc-mod .mod-accent { color: #1a5276; font-weight: bold; }
.fc-mod .highlight-box { border: 2px solid #1a5276; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
.fc-mod .mod-accent { color: <t t-out="primary"/>; font-weight: bold; }
.fc-mod .highlight-box { border: 2px solid <t t-out="primary"/>; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
</style>
<div class="fc-mod">

View File

@@ -12,12 +12,16 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-pod table.bordered, .fc-pod table.bordered th, .fc-pod table.bordered td { border: 1px solid #000; }
.fc-pod th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pod td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-pod .text-center { text-align: center; }
.fc-pod .text-end { text-align: right; }
@@ -25,7 +29,7 @@
.fc-pod .adp-bg { background-color: #e3f2fd; }
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-pod .note-row { font-style: italic; }
.fc-pod h2 { color: #0066a1; margin: 8px 0; font-size: 16pt; }
.fc-pod h2 { color: <t t-out="primary"/>; margin: 8px 0; font-size: 16pt; }
.fc-pod .info-table td { padding: 6px 10px; font-size: 10pt; }
.fc-pod .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
.fc-pod .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }

View File

@@ -11,19 +11,23 @@
<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)"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-pod table.bordered, .fc-pod table.bordered th, .fc-pod table.bordered td { border: 1px solid #000; }
.fc-pod th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pod th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pod td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-pod .text-center { text-align: center; }
.fc-pod .text-end { text-align: right; }
.fc-pod .text-start { text-align: left; }
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-pod .note-row { font-style: italic; }
.fc-pod h2 { color: #0066a1; margin: 8px 0; font-size: 16pt; }
.fc-pod h2 { color: <t t-out="primary"/>; margin: 8px 0; font-size: 16pt; }
.fc-pod .info-table td { padding: 6px 10px; font-size: 10pt; }
.fc-pod .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
.fc-pod .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }

View File

@@ -11,19 +11,25 @@
<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)"/>
<!-- Pickup uses the company's secondary brand colour (green by convention,
distinct from the delivery report's primary). Falls back to legacy
green if the company has not set a secondary colour. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#2e7d32'"/>
<style>
.fc-pop { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-pop table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-pop table.bordered, .fc-pop table.bordered th, .fc-pop table.bordered td { border: 1px solid #000; }
.fc-pop th { background-color: #2e7d32; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pop th { background-color: <t t-out="secondary"/>; color: white; padding: 6px 8px; font-weight: bold; font-size: 9pt; }
.fc-pop td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
.fc-pop .text-center { text-align: center; }
.fc-pop .text-end { text-align: right; }
.fc-pop .text-start { text-align: left; }
.fc-pop .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-pop .note-row { font-style: italic; }
.fc-pop h2 { color: #2e7d32; margin: 8px 0; font-size: 16pt; }
.fc-pop h2 { color: <t t-out="secondary"/>; margin: 8px 0; font-size: 16pt; }
.fc-pop .info-table td { padding: 6px 10px; font-size: 10pt; }
.fc-pop .info-table th { background-color: #f5f5f5; color: #333; font-size: 9pt; padding: 5px 10px; }
.fc-pop .signature-section { margin-top: 20px; border: 1px solid #000; padding: 15px; }

View File

@@ -5,6 +5,21 @@
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!--
Colour convention used across fusion_claims reports (2026-04):
Each report should set `primary` and `secondary` near the top of
its template body, drawing from the company's brand colours
configured via the document-layout wizard:
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
Then reference `<t t-out="primary"/>` inside the <style> block and
inline `style="..."` attributes. Fallbacks preserve legacy rendering
on databases that have never set a colour.
-->
<!-- Shared Report Header Template -->
<template id="report_header_fusion_claims">
<div class="fc-header">
@@ -117,20 +132,21 @@
</div>
</template>
<!-- Report Styles -->
<!-- Report Styles (callers should set `primary` before t-call). -->
<template id="report_styles_fusion_claims">
<t t-set="primary" t-value="primary or ((company.primary_color if company else False) or '#0077b6')"/>
<style>
.fc-header { margin-bottom: 20px; }
.fc-footer { margin-top: 20px; }
.fc-table { width: 100%; border-collapse: collapse; }
.fc-table th { background-color: #0077b6; color: white; padding: 8px; text-align: left; }
.fc-table th { background-color: <t t-out="primary"/>; color: white; padding: 8px; text-align: left; }
.fc-table td { padding: 6px; border-bottom: 1px solid #ddd; }
.fc-table .section-header { background-color: #f0f0f0; font-weight: bold; }
.fc-table .text-end { text-align: right; }
.fc-totals { margin-top: 20px; }
.fc-totals table { width: 300px; float: right; }
.fc-totals td { padding: 4px 8px; }
.fc-totals .total-row { font-weight: bold; background-color: #0077b6; color: white; }
.fc-totals .total-row { font-weight: bold; background-color: <t t-out="primary"/>; color: white; }
</style>
</template>

View File

@@ -12,12 +12,16 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
.fc-landscape table.bordered, .fc-landscape table.bordered th, .fc-landscape table.bordered td { border: 1px solid #000; }
.fc-landscape th { background-color: #0066a1; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fc-landscape th { background-color: <t t-out="primary"/>; color: white; padding: 8px 10px; font-weight: bold; font-size: 10pt; }
.fc-landscape td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
.fc-landscape .text-center { text-align: center; }
.fc-landscape .text-end { text-align: right; }
@@ -26,7 +30,7 @@
.fc-landscape .client-bg { background-color: #fff3e0; }
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-landscape .note-row { font-style: italic; }
.fc-landscape h2 { color: #0066a1; margin: 10px 0; font-size: 18pt; }
.fc-landscape h2 { color: <t t-out="primary"/>; margin: 10px 0; font-size: 18pt; }
.fc-landscape .info-table td { padding: 8px 12px; font-size: 11pt; }
.fc-landscape .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
.fc-landscape .totals-table { border: 1px solid #000; }

View File

@@ -11,12 +11,16 @@
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="is_adp" t-value="doc.x_fc_is_adp_sale"/>
<!-- Brand colours from the company's document-layout config. -->
<t t-set="_co" t-value="doc.company_id or env.company"/>
<t t-set="primary" t-value="_co.primary_color or '#0066a1'"/>
<t t-set="secondary" t-value="_co.secondary_color or '#90be6d'"/>
<style>
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
.fc-report table.bordered, .fc-report table.bordered th, .fc-report table.bordered td { border: 1px solid #000; }
.fc-report th { background-color: #0066a1; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-report th { background-color: <t t-out="primary"/>; color: white; padding: 6px 8px; font-weight: bold; text-align: center; }
.fc-report td { padding: 6px 8px; vertical-align: top; }
.fc-report .text-center { text-align: center; }
.fc-report .text-end { text-align: right; }
@@ -25,7 +29,7 @@
.fc-report .client-bg { background-color: #fff3e0; }
.fc-report .section-row { background-color: #f0f0f0; font-weight: bold; }
.fc-report .note-row { font-style: italic; }
.fc-report h4 { color: #0066a1; margin: 0 0 15px 0; font-size: 16pt; }
.fc-report h4 { color: <t t-out="primary"/>; margin: 0 0 15px 0; font-size: 16pt; }
.fc-report .totals-table { border: 1px solid #000; border-collapse: collapse; }
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
.fc-report .info-header { background-color: #f5f5f5; color: #333; }

View File

@@ -1385,12 +1385,12 @@
confirm="Reopen this cancelled application at the Quotation stage?"
help="Return a cancelled application to Quotation"/>
<button name="action_adp_reopen_expired" type="object"
string="Reopen" class="btn-info"
icon="fa-refresh"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'expired'"
confirm="Reopen this expired application at the Quotation stage?"
help="Return an expired application to Quotation"/>
<button name="action_adp_duplicate_for_reassessment" type="object"
string="Create Reassessment Order" class="btn-primary"
icon="fa-copy"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('expired', 'cancelled')"
confirm="Create a new sale order for reassessment? The old order stays as a historical record. The authorizer will need to complete a new assessment before resubmission."
help="Create a new order linked to this one so the authorizer can reassess the client's needs"/>
<button name="action_adp_resubmit_from_denied" type="object"
string="Resubmit" class="btn-primary"
@@ -1604,11 +1604,11 @@
icon="fa-refresh"
invisible="x_fc_adp_application_status != 'cancelled'"
confirm="Reopen this cancelled application at the Quotation stage?"/>
<button name="action_adp_reopen_expired" type="object"
string="Reopen" class="btn-info btn-sm me-1"
icon="fa-refresh"
invisible="x_fc_adp_application_status != 'expired'"
confirm="Reopen this expired application at the Quotation stage?"/>
<button name="action_adp_duplicate_for_reassessment" type="object"
string="Create Reassessment Order" class="btn-primary btn-sm me-1"
icon="fa-copy"
invisible="x_fc_adp_application_status not in ('expired', 'cancelled')"
confirm="Create a new sale order for reassessment? The authorizer will need to complete a new assessment before resubmission."/>
<button name="action_adp_resubmit_from_denied" type="object"
string="Resubmit" class="btn-primary btn-sm me-1"
icon="fa-repeat"
@@ -2641,4 +2641,128 @@
</field>
</record>
<!-- ===================================================================== -->
<!-- SALE ORDER FORM: WSIB / Insurance / MDC / Hardship Case Details -->
<!-- ===================================================================== -->
<record id="view_order_form_fusion_claims_funder_workflows" model="ir.ui.view">
<field name="name">sale.order.form.fusion.claims.funder.workflows</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="priority">48</field>
<field name="arch" type="xml">
<xpath expr="//group[@name='sale_header']" position="after">
<field name="x_fc_show_wsib_fields" invisible="1"/>
<field name="x_fc_show_insurance_fields" invisible="1"/>
<field name="x_fc_show_mdc_fields" invisible="1"/>
<field name="x_fc_show_hardship_fields" invisible="1"/>
<field name="x_fc_is_wsib_sale" invisible="1"/>
<field name="x_fc_is_insurance_sale" invisible="1"/>
<field name="x_fc_is_mdc_sale" invisible="1"/>
<field name="x_fc_is_hardship_sale" invisible="1"/>
<!-- ================== WSIB ================== -->
<group name="wsib_case_details" string="WSIB Case"
invisible="not x_fc_show_wsib_fields">
<group>
<field name="x_fc_wsib_status" string="Status"
required="x_fc_sale_type == 'wsib'"/>
<field name="x_fc_wsib_claim_number"/>
<field name="x_fc_wsib_adjudicator_name"/>
</group>
<group>
<field name="x_fc_wsib_form_7_date"/>
<field name="x_fc_wsib_approval_date"
invisible="x_fc_wsib_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'documents_ready', 'submitted_to_wsib')"/>
<field name="x_fc_wsib_approval_letter"
filename="x_fc_wsib_approval_letter_filename"
invisible="x_fc_wsib_status in ('quotation', 'assessment_scheduled', 'assessment_completed', 'documents_ready', 'submitted_to_wsib')"/>
<field name="x_fc_wsib_approval_letter_filename" invisible="1"/>
</group>
</group>
<!-- ================== INSURANCE ================== -->
<group name="insurance_case_details" string="Insurance Case"
invisible="not x_fc_show_insurance_fields">
<group>
<field name="x_fc_insurance_status" string="Status"
required="x_fc_sale_type == 'insurance'"/>
<field name="x_fc_insurance_submission_mode"/>
<field name="x_fc_insurance_company_id"/>
<field name="x_fc_insurance_letter_source"/>
<field name="x_fc_insurance_home_assessment_required"/>
</group>
<group>
<field name="x_fc_insurance_policy_number"/>
<field name="x_fc_insurance_claim_number"/>
<field name="x_fc_insurance_pre_auth_amount"
invisible="x_fc_insurance_submission_mode != 'direct_bill'"/>
<field name="x_fc_insurance_pre_auth_expiry"
invisible="x_fc_insurance_submission_mode != 'direct_bill'"/>
<field name="x_fc_insurance_approval_letter"
filename="x_fc_insurance_approval_letter_filename"
invisible="x_fc_insurance_status in ('quotation', 'home_assessment_scheduled', 'home_assessment_completed', 'documents_ready', 'submitted_by_client', 'pre_auth_submitted')"/>
<field name="x_fc_insurance_approval_letter_filename" invisible="1"/>
</group>
</group>
<!-- ================== MDC ================== -->
<group name="mdc_case_details" string="Muscular Dystrophy Case"
invisible="not x_fc_show_mdc_fields">
<group>
<field name="x_fc_mdc_status" string="Status"
required="x_fc_sale_type == 'muscular_dystrophy'"/>
<field name="x_fc_mdc_client_id_number"/>
<field name="x_fc_mdc_enrollment_verified"/>
<field name="x_fc_mdc_enrollment_verified_date"
invisible="not x_fc_mdc_enrollment_verified"/>
<field name="x_fc_mdc_letter_source"/>
<field name="x_fc_mdc_submitted_by"/>
</group>
<group>
<field name="x_fc_mdc_po_number"
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
<field name="x_fc_mdc_po_date"
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
<field name="x_fc_mdc_po_amount"
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
<field name="x_fc_mdc_payment_due_date" readonly="1"
invisible="not x_fc_mdc_po_date"/>
<field name="x_fc_mdc_po_document"
filename="x_fc_mdc_po_document_filename"
invisible="x_fc_mdc_status in ('quotation', 'awaiting_ot_letter', 'documents_ready', 'submitted_to_mdc')"/>
<field name="x_fc_mdc_po_document_filename" invisible="1"/>
</group>
</group>
<!-- ================== HARDSHIP ================== -->
<group name="hardship_case_details" string="Hardship Funding Case"
invisible="not x_fc_show_hardship_fields">
<group>
<field name="x_fc_hardship_status" string="Status"
required="x_fc_sale_type == 'hardship'"/>
<field name="x_fc_hardship_funder_id"/>
<field name="x_fc_hardship_submitted_by"/>
<field name="x_fc_hardship_pre_assessment_source"/>
</group>
<group>
<field name="x_fc_hardship_interview_date"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf')"/>
<field name="x_fc_hardship_approval_date"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_approval_received_via"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_approval_amount"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_client_portion"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_approval_letter"
filename="x_fc_hardship_approval_letter_filename"
invisible="x_fc_hardship_status in ('quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', 'application_package_ready', 'submitted_to_hf', 'eligibility_interview')"/>
<field name="x_fc_hardship_approval_letter_filename" invisible="1"/>
</group>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
{"sessionId":"0a121fd1-ed48-4ab3-a959-e02485e0a699","pid":32713,"acquiredAt":1776444791006}

View File

@@ -6,13 +6,15 @@
<odoo>
<data>
<!-- Cron Job: Calculate Travel Times for Technician Tasks (every 15 min) -->
<!-- Cron Job: Calculate Travel Times for Technician Tasks (every 30 min)
Only recalculates today's tasks whose technician has recent GPS
movement, to keep paid Google API usage bounded. -->
<record id="ir_cron_technician_travel_times" model="ir.cron">
<field name="name">Fusion Tasks: Calculate Technician Travel Times</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_calculate_travel_times()</field>
<field name="interval_number">15</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>

View File

@@ -41,6 +41,20 @@ class ResConfigSettings(models.TransientModel):
config_parameter='fusion_claims.technician_start_address',
help='Default start location for technician travel calculations (e.g. warehouse/office address)',
)
fc_osrm_url = fields.Char(
string='OSRM Base URL',
config_parameter='fusion_tasks.osrm_url',
help='Self-hosted OSRM endpoint (e.g. http://192.168.1.114:5000). '
'When set, Distance Matrix calls go to OSRM instead of Google, '
'saving paid API cost. Leave empty to use Google.',
)
fc_nominatim_url = fields.Char(
string='Nominatim Base URL',
config_parameter='fusion_tasks.nominatim_url',
help='Self-hosted Nominatim geocoding endpoint (e.g. http://192.168.1.114:8080). '
'When set, address geocoding goes to Nominatim instead of Google. '
'Leave empty to use Google.',
)
fc_location_retention_days = fields.Char(
string='Location History Retention (Days)',
config_parameter='fusion_claims.location_retention_days',

View File

@@ -28,14 +28,29 @@ class ResPartner(models.Model):
def _geocode_start_address(self, address):
if not address or not address.strip():
return 0.0, 0.0
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', '')
ICP = self.env['ir.config_parameter'].sudo()
addr = address.strip()
nominatim_url = (ICP.get_param('fusion_tasks.nominatim_url', '') or '').strip()
if nominatim_url:
try:
resp = requests.get(
f'{nominatim_url.rstrip("/")}/search',
params={'q': addr, 'format': 'json', 'limit': 1, 'countrycodes': 'ca'},
timeout=5,
headers={'User-Agent': 'fusion_tasks/1.0'},
)
data = resp.json()
if isinstance(data, list) and data:
return float(data[0]['lat']), float(data[0]['lon'])
except Exception as e:
_logger.warning("Nominatim start-address geocoding failed for '%s': %s", addr, e)
api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
if not api_key:
return 0.0, 0.0
try:
resp = requests.get(
'https://maps.googleapis.com/maps/api/geocode/json',
params={'address': address.strip(), 'key': api_key, 'region': 'ca'},
params={'address': addr, 'key': api_key, 'region': 'ca'},
timeout=10,
)
data = resp.json()

View File

@@ -978,11 +978,19 @@ class FusionTechnicianTask(models.Model):
task.prev_task_summary_html = Markup(html)
def _quick_travel_time(self, from_lat, from_lng, to_lat, to_lng):
"""Quick inline travel time calculation using Google Distance Matrix API.
"""Quick inline travel time calculation. Prefers self-hosted OSRM
when fusion_tasks.osrm_url is set; falls back to Google Distance Matrix.
Returns travel time in minutes, or 0 if unavailable."""
ICP = self.env['ir.config_parameter'].sudo()
osrm_url = (ICP.get_param('fusion_tasks.osrm_url', '') or '').strip()
if osrm_url:
minutes, _dist = self._osrm_travel(
osrm_url, from_lat, from_lng, to_lat, to_lng)
if minutes:
return minutes
try:
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', '')
api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
if not api_key:
return 0
@@ -992,7 +1000,6 @@ class FusionTechnicianTask(models.Model):
'destinations': f'{to_lat},{to_lng}',
'mode': 'driving',
'avoid': 'tolls',
'departure_time': 'now',
'key': api_key,
}
resp = requests.get(url, params=params, timeout=5)
@@ -1000,9 +1007,7 @@ class FusionTechnicianTask(models.Model):
if data.get('status') == 'OK':
elements = data['rows'][0]['elements'][0]
if elements.get('status') == 'OK':
# Use duration_in_traffic if available, else duration
duration = elements.get(
'duration_in_traffic', elements.get('duration', {}))
duration = elements.get('duration', {})
seconds = duration.get('value', 0)
return max(1, int(seconds / 60))
except Exception:
@@ -1672,8 +1677,18 @@ class FusionTechnicianTask(models.Model):
.get_param('fusion_claims.technician_start_address', '') or '').strip()
def _geocode_address_string(self, address, api_key):
"""Geocode an address string and return (lat, lng) or (0.0, 0.0)."""
if not address or not api_key:
"""Geocode an address string. Prefers self-hosted Nominatim when
fusion_tasks.nominatim_url is set; falls back to Google Geocoding.
Returns (lat, lng) or (0.0, 0.0)."""
if not address:
return 0.0, 0.0
nominatim_url = (self.env['ir.config_parameter'].sudo()
.get_param('fusion_tasks.nominatim_url', '') or '').strip()
if nominatim_url:
lat, lng = self._nominatim_geocode(nominatim_url, address)
if lat and lng:
return lat, lng
if not api_key:
return 0.0, 0.0
try:
url = 'https://maps.googleapis.com/maps/api/geocode/json'
@@ -1687,6 +1702,46 @@ class FusionTechnicianTask(models.Model):
_logger.warning("Address geocoding failed for '%s': %s", address, e)
return 0.0, 0.0
def _get_technician_start_coords(self, tech_id, api_key):
"""Return cached (lat, lng) for a technician's start address.
Reads from res.partner.x_fc_start_address_lat/lng (populated on
partner write). Falls back to the company-level parameter with
its own cached lat/lng in ir.config_parameter. Geocodes only
when no cache exists, then persists so subsequent cron runs skip
the API entirely.
"""
ICP = self.env['ir.config_parameter'].sudo()
tech_user = self.env['res.users'].sudo().browse(tech_id)
if tech_user.exists() and tech_user.x_fc_start_address:
partner = tech_user.partner_id
if partner.x_fc_start_address_lat and partner.x_fc_start_address_lng:
return partner.x_fc_start_address_lat, partner.x_fc_start_address_lng
lat, lng = self._geocode_address_string(
tech_user.x_fc_start_address.strip(), api_key)
if lat and lng:
partner.sudo().write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
return lat, lng
hq_addr = (ICP.get_param('fusion_claims.technician_start_address', '') or '').strip()
if not hq_addr:
return 0.0, 0.0
cached_key = f'fusion_tasks.hq_coords:{hq_addr}'
cached = ICP.get_param(cached_key, '')
if cached and ',' in cached:
try:
lat_s, lng_s = cached.split(',', 1)
return float(lat_s), float(lng_s)
except ValueError:
pass
lat, lng = self._geocode_address_string(hq_addr, api_key)
if lat and lng:
ICP.set_param(cached_key, f'{lat},{lng}')
return lat, lng
def _recalculate_combos_travel(self, combos):
"""Recalculate travel for a set of (tech_id, date) combinations.
@@ -1728,11 +1783,9 @@ class FusionTechnicianTask(models.Model):
cl = clock_locations[tech_id]
start_coords_cache[cache_key] = (cl['lat'], cl['lng'])
else:
addr = self._get_technician_start_address(tech_id)
start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
start_coords_cache[cache_key] = self._get_technician_start_coords(tech_id, api_key)
else:
addr = self._get_technician_start_address(tech_id)
start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
start_coords_cache[cache_key] = self._get_technician_start_coords(tech_id, api_key)
all_day_tasks = self.sudo().search([
'|',
@@ -2530,8 +2583,22 @@ class FusionTechnicianTask(models.Model):
if hq_address:
if not hq_lat and not hq_lng:
ICP = self.env['ir.config_parameter'].sudo()
cached = ICP.get_param(f'fusion_tasks.hq_coords:{hq_address}', '')
if cached and ',' in cached:
try:
lat_s, lng_s = cached.split(',', 1)
hq_lat, hq_lng = float(lat_s), float(lng_s)
except ValueError:
hq_lat, hq_lng = 0.0, 0.0
if not hq_lat or not hq_lng:
hq_lat, hq_lng = self._geocode_address_string(
hq_address, api_key)
if hq_lat and hq_lng:
ICP.set_param(
f'fusion_tasks.hq_coords:{hq_address}',
f'{hq_lat},{hq_lng}',
)
if hq_lat and hq_lng:
result[uid] = {
'lat': hq_lat, 'lng': hq_lng,
@@ -2620,12 +2687,23 @@ class FusionTechnicianTask(models.Model):
return result
def _geocode_address(self):
"""Geocode the task address using Google Geocoding API."""
"""Geocode the task address. Prefers self-hosted Nominatim when
fusion_tasks.nominatim_url is set; falls back to Google Geocoding."""
self.ensure_one()
api_key = self._get_google_maps_api_key()
if not api_key or not self.address_display:
if not self.address_display:
return False
ICP = self.env['ir.config_parameter'].sudo()
nominatim_url = (ICP.get_param('fusion_tasks.nominatim_url', '') or '').strip()
if nominatim_url:
lat, lng = self._nominatim_geocode(nominatim_url, self.address_display)
if lat and lng:
self.write({'address_lat': lat, 'address_lng': lng})
return True
api_key = self._get_google_maps_api_key()
if not api_key:
return False
try:
url = 'https://maps.googleapis.com/maps/api/geocode/json'
params = {
@@ -2647,14 +2725,29 @@ class FusionTechnicianTask(models.Model):
return False
def _calculate_travel_time(self, origin_lat, origin_lng):
"""Calculate travel time from origin to this task using Distance Matrix API."""
"""Calculate travel time from origin to this task. Prefers self-hosted
OSRM when fusion_tasks.osrm_url is set; falls back to Google Distance
Matrix."""
self.ensure_one()
api_key = self._get_google_maps_api_key()
if not api_key:
return False
if not (origin_lat and origin_lng and self.address_lat and self.address_lng):
return False
ICP = self.env['ir.config_parameter'].sudo()
osrm_url = (ICP.get_param('fusion_tasks.osrm_url', '') or '').strip()
if osrm_url:
minutes, km = self._osrm_travel(
osrm_url, origin_lat, origin_lng,
self.address_lat, self.address_lng)
if minutes:
self.write({
'travel_time_minutes': minutes,
'travel_distance_km': km,
})
return True
api_key = self._get_google_maps_api_key()
if not api_key:
return False
try:
url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
params = {
@@ -2663,15 +2756,13 @@ class FusionTechnicianTask(models.Model):
'key': api_key,
'mode': 'driving',
'avoid': 'tolls',
'traffic_model': 'best_guess',
'departure_time': 'now',
}
resp = requests.get(url, params=params, timeout=10)
data = resp.json()
if data.get('status') == 'OK':
element = data['rows'][0]['elements'][0]
if element.get('status') == 'OK':
duration_seconds = element['duration_in_traffic']['value'] if 'duration_in_traffic' in element else element['duration']['value']
duration_seconds = element['duration']['value']
distance_meters = element['distance']['value']
self.write({
'travel_time_minutes': round(duration_seconds / 60),
@@ -2682,6 +2773,49 @@ class FusionTechnicianTask(models.Model):
_logger.warning(f"Travel time calculation failed for task {self.name}: {e}")
return False
@api.model
def _osrm_travel(self, osrm_url, from_lat, from_lng, to_lat, to_lng):
"""Query self-hosted OSRM /route for driving time + distance.
Returns (minutes, km) or (0, 0) on failure."""
try:
url = (f'{osrm_url.rstrip("/")}/route/v1/driving/'
f'{from_lng},{from_lat};{to_lng},{to_lat}?overview=false')
resp = requests.get(url, timeout=5)
data = resp.json()
if data.get('code') == 'Ok' and data.get('routes'):
route = data['routes'][0]
minutes = max(1, round(route['duration'] / 60))
km = round(route['distance'] / 1000, 1)
return minutes, km
except Exception as e:
_logger.warning("OSRM travel query failed: %s", e)
return 0, 0
@api.model
def _nominatim_geocode(self, nominatim_url, address):
"""Query self-hosted Nominatim /search for address → (lat, lng).
Returns (0.0, 0.0) on failure so callers can fall through to Google."""
if not address or not address.strip():
return 0.0, 0.0
try:
url = f'{nominatim_url.rstrip("/")}/search'
params = {
'q': address.strip(),
'format': 'json',
'limit': 1,
'countrycodes': 'ca',
}
resp = requests.get(
url, params=params, timeout=5,
headers={'User-Agent': 'fusion_tasks/1.0'},
)
data = resp.json()
if isinstance(data, list) and data:
return float(data[0]['lat']), float(data[0]['lon'])
except Exception as e:
_logger.warning("Nominatim geocoding failed for '%s': %s", address, e)
return 0.0, 0.0
def action_calculate_travel_times(self):
"""Calculate travel times for a day's schedule. Called from backend button or cron."""
self._do_calculate_travel_times()
@@ -2723,12 +2857,10 @@ class FusionTechnicianTask(models.Model):
prev_lat, prev_lng = cl['lat'], cl['lng']
origin_label = 'Clock-In Location'
else:
addr = self._get_technician_start_address(tech_id)
prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
prev_lat, prev_lng = self._get_technician_start_coords(tech_id, api_key)
origin_label = 'Start Location'
else:
addr = self._get_technician_start_address(tech_id)
prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
prev_lat, prev_lng = self._get_technician_start_coords(tech_id, api_key)
origin_label = 'Start Location'
# Skip already-completed tasks for today (chain starts from
@@ -2765,22 +2897,47 @@ class FusionTechnicianTask(models.Model):
@api.model
def _cron_calculate_travel_times(self):
"""Cron job: Calculate travel times for today and tomorrow.
"""Cron job: Refresh travel times for TODAY's active tasks only.
Runs every 15 minutes. For today's tasks, uses the tech's latest
GPS location so ETAs stay accurate as technicians move.
Includes completed tasks in the search so the chain can skip
them and use their completion location as origin.
Future-dated tasks are handled on create/write, so we don't hit
the Distance Matrix API for them every 15 minutes. Completed
and cancelled tasks are skipped. If a task already has a
travel_time set and its technician hasn't moved recently, we
skip the API call to avoid redundant billing.
"""
today = fields.Date.context_today(self)
tomorrow = today + timedelta(days=1)
tasks = self.search([
('scheduled_date', 'in', [today, tomorrow]),
('status', 'not in', ['cancelled']),
('scheduled_date', '=', today),
('status', 'not in', ['cancelled', 'completed']),
])
if tasks:
tasks._do_calculate_travel_times()
_logger.info(f"Calculated travel times for {len(tasks)} tasks")
if not tasks:
return
# Skip techs with no recent GPS movement if every task in their
# chain already has travel_time computed — no point spending API
# calls to recompute a value that won't change.
cutoff = fields.Datetime.subtract(fields.Datetime.now(), minutes=20)
Location = self.env['fusion.technician.location'].sudo()
moving_tech_ids = set(Location.search([
('logged_at', '>', cutoff),
('source', '!=', 'sync'),
]).mapped('user_id.id'))
def _keep(task):
if not task.travel_time_minutes:
return True
tid = task.technician_id.id
if tid and tid in moving_tech_ids:
return True
return False
stale = tasks.filtered(_keep)
if stale:
stale._do_calculate_travel_times()
_logger.info("fusion_tasks cron: recalculated travel for %d / %d tasks",
len(stale), len(tasks))
else:
_logger.info("fusion_tasks cron: all %d tasks fresh, skipped API", len(tasks))
@api.model
def _cron_check_late_arrivals(self):

View File

@@ -83,6 +83,32 @@
</div>
</div>
</div>
<!-- Self-hosted OSRM URL -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Self-hosted OSRM URL</span>
<div class="text-muted">
Optional. When set, travel-time calculations use your self-hosted OSRM server instead of Google Distance Matrix, saving paid API cost.
Leave empty to keep using Google.
</div>
<div class="mt-2">
<field name="fc_osrm_url" placeholder="http://192.168.1.114:5000"/>
</div>
</div>
</div>
<!-- Self-hosted Nominatim URL -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Self-hosted Nominatim URL</span>
<div class="text-muted">
Optional. When set, address geocoding uses your self-hosted Nominatim server instead of Google Geocoding.
Leave empty to keep using Google.
</div>
<div class="mt-2">
<field name="fc_nominatim_url" placeholder="http://192.168.1.114:8080"/>
</div>
</div>
</div>
<!-- Location History Retention -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">