changes
This commit is contained in:
@@ -168,5 +168,26 @@
|
|||||||
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
|
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
|
||||||
</record>
|
</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>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,12 +12,16 @@
|
|||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<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"/>
|
<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>
|
<style>
|
||||||
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||||
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
.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 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 td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
|
||||||
.fc-landscape .text-center { text-align: center; }
|
.fc-landscape .text-center { text-align: center; }
|
||||||
.fc-landscape .text-end { text-align: right; }
|
.fc-landscape .text-end { text-align: right; }
|
||||||
@@ -26,7 +30,7 @@
|
|||||||
.fc-landscape .client-bg { background-color: #fff3e0; }
|
.fc-landscape .client-bg { background-color: #fff3e0; }
|
||||||
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||||
.fc-landscape .note-row { font-style: italic; }
|
.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 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 .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
|
||||||
.fc-landscape .totals-table { border: 1px solid #000; }
|
.fc-landscape .totals-table { border: 1px solid #000; }
|
||||||
|
|||||||
@@ -11,7 +11,11 @@
|
|||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<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"/>
|
<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>
|
<style>
|
||||||
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
|
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||||
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
||||||
@@ -25,7 +29,7 @@
|
|||||||
.fc-report .client-bg { background-color: #fff3e0; }
|
.fc-report .client-bg { background-color: #fff3e0; }
|
||||||
.fc-report .section-row { background-color: #f8f8f8; font-weight: bold; }
|
.fc-report .section-row { background-color: #f8f8f8; font-weight: bold; }
|
||||||
.fc-report .note-row { font-style: italic; }
|
.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 { border: 1px solid #000; border-collapse: collapse; }
|
||||||
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
|
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,19 +12,22 @@
|
|||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||||
<t t-set="company" t-value="doc.company_id"/>
|
<t t-set="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>
|
<style>
|
||||||
.fc-contract { font-family: Arial, sans-serif; font-size: 8pt; line-height: 1.3; }
|
.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 h1 { color: <t t-out="primary"/>; 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 h2 { color: <t t-out="primary"/>; 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 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 p { margin: 2px 0; text-align: justify; }
|
||||||
.fc-contract .intro { margin-bottom: 8px; font-size: 8pt; }
|
.fc-contract .intro { margin-bottom: 8px; font-size: 8pt; }
|
||||||
.fc-contract ul { margin: 2px 0 2px 15px; padding: 0; }
|
.fc-contract ul { margin: 2px 0 2px 15px; padding: 0; }
|
||||||
.fc-contract li { margin-bottom: 1px; }
|
.fc-contract li { margin-bottom: 1px; }
|
||||||
.fc-contract table { width: 100%; border-collapse: collapse; }
|
.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 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 td { padding: 3px 5px; vertical-align: top; font-size: 8pt; }
|
||||||
.fc-contract .text-center { text-align: center; }
|
.fc-contract .text-center { text-align: center; }
|
||||||
.fc-contract .text-end { text-align: right; }
|
.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-row { display: table; width: 100%; margin-bottom: 20px; }
|
||||||
.fc-contract .sig-col { display: table-cell; width: 48%; vertical-align: top; }
|
.fc-contract .sig-col { display: table-cell; width: 48%; vertical-align: top; }
|
||||||
.fc-contract .sig-spacer { display: table-cell; width: 4%; }
|
.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-field { margin-bottom: 12px; }
|
||||||
.fc-contract .sig-line { border-bottom: 1px solid #000; min-height: 25px; }
|
.fc-contract .sig-line { border-bottom: 1px solid #000; min-height: 25px; }
|
||||||
.fc-contract .sig-label { font-size: 7pt; color: #666; margin-top: 2px; }
|
.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="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="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)"/>
|
<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>
|
<style>
|
||||||
.fc-ai { font-family: Arial, sans-serif; font-size: 10pt; }
|
.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 { 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 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 td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||||
.fc-ai .text-center { text-align: center; }
|
.fc-ai .text-center { text-align: center; }
|
||||||
.fc-ai .text-end { text-align: right; }
|
.fc-ai .text-end { text-align: right; }
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||||
<t t-set="company" t-value="doc.company_id"/>
|
<t t-set="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>
|
<style>
|
||||||
.fc-waiver { font-family: Arial, sans-serif; font-size: 10pt; line-height: 1.5; }
|
.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 h2 { color: #333; font-size: 11pt; margin: 15px 0 8px 0; }
|
||||||
.fc-waiver p { margin: 8px 0; text-align: justify; }
|
.fc-waiver p { margin: 8px 0; text-align: justify; }
|
||||||
.fc-waiver .intro { margin-bottom: 15px; font-style: italic; }
|
.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)"/>
|
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
||||||
<!-- Get sale order for MOD fields -->
|
<!-- Get sale order for MOD fields -->
|
||||||
<t t-set="so" t-value="doc.x_fc_source_sale_order_id"/>
|
<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>
|
<style>
|
||||||
.fc-mod-inv { font-family: Arial, sans-serif; font-size: 10pt; }
|
.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 { 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 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 td { padding: 6px 8px; vertical-align: top; }
|
||||||
.fc-mod-inv .text-center { text-align: center; }
|
.fc-mod-inv .text-center { text-align: center; }
|
||||||
.fc-mod-inv .text-end { text-align: right; }
|
.fc-mod-inv .text-end { text-align: right; }
|
||||||
.fc-mod-inv .text-start { text-align: left; }
|
.fc-mod-inv .text-start { text-align: left; }
|
||||||
.fc-mod-inv .section-row { background-color: #f0f0f0; font-weight: bold; }
|
.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 .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 h4 { color: <t t-out="primary"/>; 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 .req-box { border: 2px solid <t t-out="primary"/>; padding: 8px 12px; margin: 6px 0; background-color: #fafafa; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="fc-mod-inv">
|
<div class="fc-mod-inv">
|
||||||
|
|||||||
@@ -9,21 +9,25 @@
|
|||||||
<t t-foreach="docs" t-as="doc">
|
<t t-foreach="docs" t-as="doc">
|
||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<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>
|
<style>
|
||||||
.fc-mod { font-family: Arial, sans-serif; font-size: 10pt; }
|
.fc-mod { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||||
.fc-mod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
.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 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 td { padding: 6px 8px; vertical-align: top; }
|
||||||
.fc-mod .text-center { text-align: center; }
|
.fc-mod .text-center { text-align: center; }
|
||||||
.fc-mod .text-end { text-align: right; }
|
.fc-mod .text-end { text-align: right; }
|
||||||
.fc-mod .text-start { text-align: left; }
|
.fc-mod .text-start { text-align: left; }
|
||||||
.fc-mod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
.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 .info-header { background-color: #eaf2f8; color: #333; }
|
||||||
.fc-mod .mod-accent { color: #1a5276; font-weight: bold; }
|
.fc-mod .mod-accent { color: <t t-out="primary"/>; font-weight: bold; }
|
||||||
.fc-mod .highlight-box { border: 2px solid #1a5276; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
|
.fc-mod .highlight-box { border: 2px solid <t t-out="primary"/>; padding: 10px; margin: 10px 0; background-color: #eaf2f8; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="fc-mod">
|
<div class="fc-mod">
|
||||||
|
|||||||
@@ -12,12 +12,16 @@
|
|||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<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"/>
|
<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>
|
<style>
|
||||||
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
|
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||||
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
.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 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 td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||||
.fc-pod .text-center { text-align: center; }
|
.fc-pod .text-center { text-align: center; }
|
||||||
.fc-pod .text-end { text-align: right; }
|
.fc-pod .text-end { text-align: right; }
|
||||||
@@ -25,7 +29,7 @@
|
|||||||
.fc-pod .adp-bg { background-color: #e3f2fd; }
|
.fc-pod .adp-bg { background-color: #e3f2fd; }
|
||||||
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||||
.fc-pod .note-row { font-style: italic; }
|
.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 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 .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; }
|
.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-foreach="docs" t-as="doc">
|
||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<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>
|
<style>
|
||||||
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
|
.fc-pod { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||||
.fc-pod table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
.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 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 td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||||
.fc-pod .text-center { text-align: center; }
|
.fc-pod .text-center { text-align: center; }
|
||||||
.fc-pod .text-end { text-align: right; }
|
.fc-pod .text-end { text-align: right; }
|
||||||
.fc-pod .text-start { text-align: left; }
|
.fc-pod .text-start { text-align: left; }
|
||||||
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
.fc-pod .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||||
.fc-pod .note-row { font-style: italic; }
|
.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 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 .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; }
|
.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-foreach="docs" t-as="doc">
|
||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<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>
|
<style>
|
||||||
.fc-pop { font-family: Arial, sans-serif; font-size: 10pt; }
|
.fc-pop { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||||
.fc-pop table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
.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 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 td { padding: 5px 6px; vertical-align: top; font-size: 9pt; }
|
||||||
.fc-pop .text-center { text-align: center; }
|
.fc-pop .text-center { text-align: center; }
|
||||||
.fc-pop .text-end { text-align: right; }
|
.fc-pop .text-end { text-align: right; }
|
||||||
.fc-pop .text-start { text-align: left; }
|
.fc-pop .text-start { text-align: left; }
|
||||||
.fc-pop .section-row { background-color: #f0f0f0; font-weight: bold; }
|
.fc-pop .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||||
.fc-pop .note-row { font-style: italic; }
|
.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 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 .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; }
|
.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.
|
Part of the Fusion Claim Assistant product family.
|
||||||
-->
|
-->
|
||||||
<odoo>
|
<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 -->
|
<!-- Shared Report Header Template -->
|
||||||
<template id="report_header_fusion_claims">
|
<template id="report_header_fusion_claims">
|
||||||
<div class="fc-header">
|
<div class="fc-header">
|
||||||
@@ -117,20 +132,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Report Styles -->
|
<!-- Report Styles (callers should set `primary` before t-call). -->
|
||||||
<template id="report_styles_fusion_claims">
|
<template id="report_styles_fusion_claims">
|
||||||
|
<t t-set="primary" t-value="primary or ((company.primary_color if company else False) or '#0077b6')"/>
|
||||||
<style>
|
<style>
|
||||||
.fc-header { margin-bottom: 20px; }
|
.fc-header { margin-bottom: 20px; }
|
||||||
.fc-footer { margin-top: 20px; }
|
.fc-footer { margin-top: 20px; }
|
||||||
.fc-table { width: 100%; border-collapse: collapse; }
|
.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 td { padding: 6px; border-bottom: 1px solid #ddd; }
|
||||||
.fc-table .section-header { background-color: #f0f0f0; font-weight: bold; }
|
.fc-table .section-header { background-color: #f0f0f0; font-weight: bold; }
|
||||||
.fc-table .text-end { text-align: right; }
|
.fc-table .text-end { text-align: right; }
|
||||||
.fc-totals { margin-top: 20px; }
|
.fc-totals { margin-top: 20px; }
|
||||||
.fc-totals table { width: 300px; float: right; }
|
.fc-totals table { width: 300px; float: right; }
|
||||||
.fc-totals td { padding: 4px 8px; }
|
.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>
|
</style>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,16 @@
|
|||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<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"/>
|
<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>
|
<style>
|
||||||
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
.fc-landscape { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||||
.fc-landscape table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
|
.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 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 td { padding: 6px 8px; vertical-align: top; font-size: 10pt; }
|
||||||
.fc-landscape .text-center { text-align: center; }
|
.fc-landscape .text-center { text-align: center; }
|
||||||
.fc-landscape .text-end { text-align: right; }
|
.fc-landscape .text-end { text-align: right; }
|
||||||
@@ -26,7 +30,7 @@
|
|||||||
.fc-landscape .client-bg { background-color: #fff3e0; }
|
.fc-landscape .client-bg { background-color: #fff3e0; }
|
||||||
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
.fc-landscape .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||||
.fc-landscape .note-row { font-style: italic; }
|
.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 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 .info-table th { background-color: #f5f5f5; color: #333; font-size: 10pt; padding: 6px 12px; }
|
||||||
.fc-landscape .totals-table { border: 1px solid #000; }
|
.fc-landscape .totals-table { border: 1px solid #000; }
|
||||||
|
|||||||
@@ -11,12 +11,16 @@
|
|||||||
<t t-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
|
<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"/>
|
<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>
|
<style>
|
||||||
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
|
.fc-report { font-family: Arial, sans-serif; font-size: 10pt; }
|
||||||
.fc-report table { width: 100%; border-collapse: collapse; margin-bottom: 10px; }
|
.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 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 td { padding: 6px 8px; vertical-align: top; }
|
||||||
.fc-report .text-center { text-align: center; }
|
.fc-report .text-center { text-align: center; }
|
||||||
.fc-report .text-end { text-align: right; }
|
.fc-report .text-end { text-align: right; }
|
||||||
@@ -25,7 +29,7 @@
|
|||||||
.fc-report .client-bg { background-color: #fff3e0; }
|
.fc-report .client-bg { background-color: #fff3e0; }
|
||||||
.fc-report .section-row { background-color: #f0f0f0; font-weight: bold; }
|
.fc-report .section-row { background-color: #f0f0f0; font-weight: bold; }
|
||||||
.fc-report .note-row { font-style: italic; }
|
.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 { border: 1px solid #000; border-collapse: collapse; }
|
||||||
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
|
.fc-report .totals-table td { border: 1px solid #000; padding: 6px 8px; }
|
||||||
.fc-report .info-header { background-color: #f5f5f5; color: #333; }
|
.fc-report .info-header { background-color: #f5f5f5; color: #333; }
|
||||||
|
|||||||
@@ -1385,12 +1385,12 @@
|
|||||||
confirm="Reopen this cancelled application at the Quotation stage?"
|
confirm="Reopen this cancelled application at the Quotation stage?"
|
||||||
help="Return a cancelled application to Quotation"/>
|
help="Return a cancelled application to Quotation"/>
|
||||||
|
|
||||||
<button name="action_adp_reopen_expired" type="object"
|
<button name="action_adp_duplicate_for_reassessment" type="object"
|
||||||
string="Reopen" class="btn-info"
|
string="Create Reassessment Order" class="btn-primary"
|
||||||
icon="fa-refresh"
|
icon="fa-copy"
|
||||||
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'expired'"
|
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status not in ('expired', 'cancelled')"
|
||||||
confirm="Reopen this expired application at the Quotation stage?"
|
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="Return an expired application to Quotation"/>
|
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"
|
<button name="action_adp_resubmit_from_denied" type="object"
|
||||||
string="Resubmit" class="btn-primary"
|
string="Resubmit" class="btn-primary"
|
||||||
@@ -1604,11 +1604,11 @@
|
|||||||
icon="fa-refresh"
|
icon="fa-refresh"
|
||||||
invisible="x_fc_adp_application_status != 'cancelled'"
|
invisible="x_fc_adp_application_status != 'cancelled'"
|
||||||
confirm="Reopen this cancelled application at the Quotation stage?"/>
|
confirm="Reopen this cancelled application at the Quotation stage?"/>
|
||||||
<button name="action_adp_reopen_expired" type="object"
|
<button name="action_adp_duplicate_for_reassessment" type="object"
|
||||||
string="Reopen" class="btn-info btn-sm me-1"
|
string="Create Reassessment Order" class="btn-primary btn-sm me-1"
|
||||||
icon="fa-refresh"
|
icon="fa-copy"
|
||||||
invisible="x_fc_adp_application_status != 'expired'"
|
invisible="x_fc_adp_application_status not in ('expired', 'cancelled')"
|
||||||
confirm="Reopen this expired application at the Quotation stage?"/>
|
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"
|
<button name="action_adp_resubmit_from_denied" type="object"
|
||||||
string="Resubmit" class="btn-primary btn-sm me-1"
|
string="Resubmit" class="btn-primary btn-sm me-1"
|
||||||
icon="fa-repeat"
|
icon="fa-repeat"
|
||||||
@@ -2635,10 +2635,134 @@
|
|||||||
context="{'group_by': 'x_fc_adp_application_status'}"/>
|
context="{'group_by': 'x_fc_adp_application_status'}"/>
|
||||||
<filter string="Client Type" name="group_client_type"
|
<filter string="Client Type" name="group_client_type"
|
||||||
context="{'group_by': 'x_fc_client_type'}"/>
|
context="{'group_by': 'x_fc_client_type'}"/>
|
||||||
<filter string="Authorizer" name="group_authorizer"
|
<filter string="Authorizer" name="group_authorizer"
|
||||||
context="{'group_by': 'x_fc_authorizer_id'}"/>
|
context="{'group_by': 'x_fc_authorizer_id'}"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</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>
|
</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>
|
<odoo>
|
||||||
<data>
|
<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">
|
<record id="ir_cron_technician_travel_times" model="ir.cron">
|
||||||
<field name="name">Fusion Tasks: Calculate Technician Travel Times</field>
|
<field name="name">Fusion Tasks: Calculate Technician Travel Times</field>
|
||||||
<field name="model_id" ref="model_fusion_technician_task"/>
|
<field name="model_id" ref="model_fusion_technician_task"/>
|
||||||
<field name="state">code</field>
|
<field name="state">code</field>
|
||||||
<field name="code">model._cron_calculate_travel_times()</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="interval_type">minutes</field>
|
||||||
<field name="active">True</field>
|
<field name="active">True</field>
|
||||||
</record>
|
</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',
|
config_parameter='fusion_claims.technician_start_address',
|
||||||
help='Default start location for technician travel calculations (e.g. warehouse/office 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(
|
fc_location_retention_days = fields.Char(
|
||||||
string='Location History Retention (Days)',
|
string='Location History Retention (Days)',
|
||||||
config_parameter='fusion_claims.location_retention_days',
|
config_parameter='fusion_claims.location_retention_days',
|
||||||
|
|||||||
@@ -28,14 +28,29 @@ class ResPartner(models.Model):
|
|||||||
def _geocode_start_address(self, address):
|
def _geocode_start_address(self, address):
|
||||||
if not address or not address.strip():
|
if not address or not address.strip():
|
||||||
return 0.0, 0.0
|
return 0.0, 0.0
|
||||||
api_key = self.env['ir.config_parameter'].sudo().get_param(
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
'fusion_claims.google_maps_api_key', '')
|
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:
|
if not api_key:
|
||||||
return 0.0, 0.0
|
return 0.0, 0.0
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
'https://maps.googleapis.com/maps/api/geocode/json',
|
'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,
|
timeout=10,
|
||||||
)
|
)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|||||||
@@ -978,11 +978,19 @@ class FusionTechnicianTask(models.Model):
|
|||||||
task.prev_task_summary_html = Markup(html)
|
task.prev_task_summary_html = Markup(html)
|
||||||
|
|
||||||
def _quick_travel_time(self, from_lat, from_lng, to_lat, to_lng):
|
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."""
|
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:
|
try:
|
||||||
api_key = self.env['ir.config_parameter'].sudo().get_param(
|
api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
||||||
'fusion_claims.google_maps_api_key', '')
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -992,7 +1000,6 @@ class FusionTechnicianTask(models.Model):
|
|||||||
'destinations': f'{to_lat},{to_lng}',
|
'destinations': f'{to_lat},{to_lng}',
|
||||||
'mode': 'driving',
|
'mode': 'driving',
|
||||||
'avoid': 'tolls',
|
'avoid': 'tolls',
|
||||||
'departure_time': 'now',
|
|
||||||
'key': api_key,
|
'key': api_key,
|
||||||
}
|
}
|
||||||
resp = requests.get(url, params=params, timeout=5)
|
resp = requests.get(url, params=params, timeout=5)
|
||||||
@@ -1000,9 +1007,7 @@ class FusionTechnicianTask(models.Model):
|
|||||||
if data.get('status') == 'OK':
|
if data.get('status') == 'OK':
|
||||||
elements = data['rows'][0]['elements'][0]
|
elements = data['rows'][0]['elements'][0]
|
||||||
if elements.get('status') == 'OK':
|
if elements.get('status') == 'OK':
|
||||||
# Use duration_in_traffic if available, else duration
|
duration = elements.get('duration', {})
|
||||||
duration = elements.get(
|
|
||||||
'duration_in_traffic', elements.get('duration', {}))
|
|
||||||
seconds = duration.get('value', 0)
|
seconds = duration.get('value', 0)
|
||||||
return max(1, int(seconds / 60))
|
return max(1, int(seconds / 60))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1672,8 +1677,18 @@ class FusionTechnicianTask(models.Model):
|
|||||||
.get_param('fusion_claims.technician_start_address', '') or '').strip()
|
.get_param('fusion_claims.technician_start_address', '') or '').strip()
|
||||||
|
|
||||||
def _geocode_address_string(self, address, api_key):
|
def _geocode_address_string(self, address, api_key):
|
||||||
"""Geocode an address string and return (lat, lng) or (0.0, 0.0)."""
|
"""Geocode an address string. Prefers self-hosted Nominatim when
|
||||||
if not address or not api_key:
|
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
|
return 0.0, 0.0
|
||||||
try:
|
try:
|
||||||
url = 'https://maps.googleapis.com/maps/api/geocode/json'
|
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)
|
_logger.warning("Address geocoding failed for '%s': %s", address, e)
|
||||||
return 0.0, 0.0
|
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):
|
def _recalculate_combos_travel(self, combos):
|
||||||
"""Recalculate travel for a set of (tech_id, date) combinations.
|
"""Recalculate travel for a set of (tech_id, date) combinations.
|
||||||
|
|
||||||
@@ -1728,11 +1783,9 @@ class FusionTechnicianTask(models.Model):
|
|||||||
cl = clock_locations[tech_id]
|
cl = clock_locations[tech_id]
|
||||||
start_coords_cache[cache_key] = (cl['lat'], cl['lng'])
|
start_coords_cache[cache_key] = (cl['lat'], cl['lng'])
|
||||||
else:
|
else:
|
||||||
addr = self._get_technician_start_address(tech_id)
|
start_coords_cache[cache_key] = self._get_technician_start_coords(tech_id, api_key)
|
||||||
start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
|
|
||||||
else:
|
else:
|
||||||
addr = self._get_technician_start_address(tech_id)
|
start_coords_cache[cache_key] = self._get_technician_start_coords(tech_id, api_key)
|
||||||
start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
|
|
||||||
|
|
||||||
all_day_tasks = self.sudo().search([
|
all_day_tasks = self.sudo().search([
|
||||||
'|',
|
'|',
|
||||||
@@ -2530,8 +2583,22 @@ class FusionTechnicianTask(models.Model):
|
|||||||
|
|
||||||
if hq_address:
|
if hq_address:
|
||||||
if not hq_lat and not hq_lng:
|
if not hq_lat and not hq_lng:
|
||||||
hq_lat, hq_lng = self._geocode_address_string(
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
hq_address, api_key)
|
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:
|
if hq_lat and hq_lng:
|
||||||
result[uid] = {
|
result[uid] = {
|
||||||
'lat': hq_lat, 'lng': hq_lng,
|
'lat': hq_lat, 'lng': hq_lng,
|
||||||
@@ -2620,12 +2687,23 @@ class FusionTechnicianTask(models.Model):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def _geocode_address(self):
|
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()
|
self.ensure_one()
|
||||||
api_key = self._get_google_maps_api_key()
|
if not self.address_display:
|
||||||
if not api_key or not self.address_display:
|
|
||||||
return False
|
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:
|
try:
|
||||||
url = 'https://maps.googleapis.com/maps/api/geocode/json'
|
url = 'https://maps.googleapis.com/maps/api/geocode/json'
|
||||||
params = {
|
params = {
|
||||||
@@ -2647,14 +2725,29 @@ class FusionTechnicianTask(models.Model):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _calculate_travel_time(self, origin_lat, origin_lng):
|
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()
|
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):
|
if not (origin_lat and origin_lng and self.address_lat and self.address_lng):
|
||||||
return False
|
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:
|
try:
|
||||||
url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
|
url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
|
||||||
params = {
|
params = {
|
||||||
@@ -2663,15 +2756,13 @@ class FusionTechnicianTask(models.Model):
|
|||||||
'key': api_key,
|
'key': api_key,
|
||||||
'mode': 'driving',
|
'mode': 'driving',
|
||||||
'avoid': 'tolls',
|
'avoid': 'tolls',
|
||||||
'traffic_model': 'best_guess',
|
|
||||||
'departure_time': 'now',
|
|
||||||
}
|
}
|
||||||
resp = requests.get(url, params=params, timeout=10)
|
resp = requests.get(url, params=params, timeout=10)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if data.get('status') == 'OK':
|
if data.get('status') == 'OK':
|
||||||
element = data['rows'][0]['elements'][0]
|
element = data['rows'][0]['elements'][0]
|
||||||
if element.get('status') == 'OK':
|
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']
|
distance_meters = element['distance']['value']
|
||||||
self.write({
|
self.write({
|
||||||
'travel_time_minutes': round(duration_seconds / 60),
|
'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}")
|
_logger.warning(f"Travel time calculation failed for task {self.name}: {e}")
|
||||||
return False
|
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):
|
def action_calculate_travel_times(self):
|
||||||
"""Calculate travel times for a day's schedule. Called from backend button or cron."""
|
"""Calculate travel times for a day's schedule. Called from backend button or cron."""
|
||||||
self._do_calculate_travel_times()
|
self._do_calculate_travel_times()
|
||||||
@@ -2723,12 +2857,10 @@ class FusionTechnicianTask(models.Model):
|
|||||||
prev_lat, prev_lng = cl['lat'], cl['lng']
|
prev_lat, prev_lng = cl['lat'], cl['lng']
|
||||||
origin_label = 'Clock-In Location'
|
origin_label = 'Clock-In Location'
|
||||||
else:
|
else:
|
||||||
addr = self._get_technician_start_address(tech_id)
|
prev_lat, prev_lng = self._get_technician_start_coords(tech_id, api_key)
|
||||||
prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
|
|
||||||
origin_label = 'Start Location'
|
origin_label = 'Start Location'
|
||||||
else:
|
else:
|
||||||
addr = self._get_technician_start_address(tech_id)
|
prev_lat, prev_lng = self._get_technician_start_coords(tech_id, api_key)
|
||||||
prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
|
|
||||||
origin_label = 'Start Location'
|
origin_label = 'Start Location'
|
||||||
|
|
||||||
# Skip already-completed tasks for today (chain starts from
|
# Skip already-completed tasks for today (chain starts from
|
||||||
@@ -2765,22 +2897,47 @@ class FusionTechnicianTask(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _cron_calculate_travel_times(self):
|
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
|
Future-dated tasks are handled on create/write, so we don't hit
|
||||||
GPS location so ETAs stay accurate as technicians move.
|
the Distance Matrix API for them every 15 minutes. Completed
|
||||||
Includes completed tasks in the search so the chain can skip
|
and cancelled tasks are skipped. If a task already has a
|
||||||
them and use their completion location as origin.
|
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)
|
today = fields.Date.context_today(self)
|
||||||
tomorrow = today + timedelta(days=1)
|
|
||||||
tasks = self.search([
|
tasks = self.search([
|
||||||
('scheduled_date', 'in', [today, tomorrow]),
|
('scheduled_date', '=', today),
|
||||||
('status', 'not in', ['cancelled']),
|
('status', 'not in', ['cancelled', 'completed']),
|
||||||
])
|
])
|
||||||
if tasks:
|
if not tasks:
|
||||||
tasks._do_calculate_travel_times()
|
return
|
||||||
_logger.info(f"Calculated travel times for {len(tasks)} tasks")
|
|
||||||
|
# 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
|
@api.model
|
||||||
def _cron_check_late_arrivals(self):
|
def _cron_check_late_arrivals(self):
|
||||||
|
|||||||
@@ -83,6 +83,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 -->
|
<!-- Location History Retention -->
|
||||||
<div class="col-12 col-lg-6 o_setting_box">
|
<div class="col-12 col-lg-6 o_setting_box">
|
||||||
<div class="o_setting_right_pane">
|
<div class="o_setting_right_pane">
|
||||||
|
|||||||
Reference in New Issue
Block a user