changes
This commit is contained in:
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
1
fusion_tasks/.claude/scheduled_tasks.lock
Normal file
1
fusion_tasks/.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"0a121fd1-ed48-4ab3-a959-e02485e0a699","pid":32713,"acquiredAt":1776444791006}
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user