changes
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Claim Assistant product family.
|
||||
|
||||
from . import email_builder_mixin
|
||||
from . import adp_posting_schedule
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
@@ -27,12 +26,9 @@ from . import client_chat
|
||||
from . import ai_agent_ext
|
||||
from . import dashboard
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
from . import technician_task
|
||||
from . import task_sync
|
||||
from . import technician_location
|
||||
from . import push_subscription
|
||||
from . import ltc_facility
|
||||
from . import ltc_repair
|
||||
from . import ltc_cleanup
|
||||
from . import ltc_form_submission
|
||||
from . import ltc_form_submission
|
||||
from . import page11_sign_request
|
||||
BIN
fusion_claims/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_claims/models/__pycache__/account_move.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/account_move.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/account_payment.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/account_payment.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/ai_agent_ext.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/ai_agent_ext.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_claims/models/__pycache__/client_chat.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/client_chat.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_claims/models/__pycache__/client_profile.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/client_profile.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_claims/models/__pycache__/dashboard.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/dashboard.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/ltc_cleanup.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/ltc_cleanup.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_claims/models/__pycache__/ltc_facility.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/ltc_facility.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/ltc_repair.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/ltc_repair.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/product_product.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/product_product.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/res_company.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/res_company.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/res_partner.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/res_partner.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_claims/models/__pycache__/sale_order.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/sale_order.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_claims/models/__pycache__/sale_order_line.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/sale_order_line.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/task_sync.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/task_sync.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/technician_task.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/technician_task.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_claims/models/__pycache__/xml_parser.cpython-312.pyc
Normal file
BIN
fusion_claims/models/__pycache__/xml_parser.cpython-312.pyc
Normal file
Binary file not shown.
@@ -1,242 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Fusion Claims - Professional Email Builder Mixin
|
||||
# Provides consistent, dark/light mode safe email templates across all modules.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class FusionEmailBuilderMixin(models.AbstractModel):
|
||||
_name = 'fusion.email.builder.mixin'
|
||||
_description = 'Fusion Email Builder Mixin'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Color constants
|
||||
# ------------------------------------------------------------------
|
||||
_EMAIL_COLORS = {
|
||||
'info': '#2B6CB0',
|
||||
'success': '#38a169',
|
||||
'attention': '#d69e2e',
|
||||
'urgent': '#c53030',
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _email_build(
|
||||
self,
|
||||
title,
|
||||
summary,
|
||||
sections=None,
|
||||
note=None,
|
||||
note_color=None,
|
||||
email_type='info',
|
||||
attachments_note=None,
|
||||
button_url=None,
|
||||
button_text='View Case Details',
|
||||
sender_name=None,
|
||||
extra_html='',
|
||||
):
|
||||
"""Build a complete professional email HTML string.
|
||||
|
||||
Args:
|
||||
title: Email heading (e.g. "Application Approved")
|
||||
summary: One-sentence summary HTML (may contain <strong> tags)
|
||||
sections: list of (heading, rows) where rows is list of (label, value)
|
||||
e.g. [('Case Details', [('Client', 'John'), ('Case', 'S30073')])]
|
||||
note: Optional note/next-steps text (plain or HTML)
|
||||
note_color: Override left-border color for note (default uses email_type)
|
||||
email_type: 'info' | 'success' | 'attention' | 'urgent'
|
||||
attachments_note: Optional string listing attached files
|
||||
button_url: Optional CTA button URL
|
||||
button_text: CTA button label
|
||||
sender_name: Name for sign-off (defaults to current user)
|
||||
extra_html: Any additional HTML to insert before sign-off
|
||||
"""
|
||||
accent = self._EMAIL_COLORS.get(email_type, self._EMAIL_COLORS['info'])
|
||||
company = self._get_company_info()
|
||||
|
||||
parts = []
|
||||
# -- Wrapper open + accent bar
|
||||
parts.append(
|
||||
f'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
|
||||
f'max-width:600px;margin:0 auto;color:#2d3748;">'
|
||||
f'<div style="height:4px;background-color:{accent};"></div>'
|
||||
f'<div style="background:#ffffff;padding:32px 28px;border:1px solid #e2e8f0;border-top:none;">'
|
||||
)
|
||||
|
||||
# -- Company name
|
||||
parts.append(
|
||||
f'<p style="color:{accent};font-size:13px;font-weight:600;letter-spacing:0.5px;'
|
||||
f'text-transform:uppercase;margin:0 0 24px 0;">{company["name"]}</p>'
|
||||
)
|
||||
|
||||
# -- Title
|
||||
parts.append(
|
||||
f'<h2 style="color:#1a202c;font-size:22px;font-weight:700;'
|
||||
f'margin:0 0 6px 0;line-height:1.3;">{title}</h2>'
|
||||
)
|
||||
|
||||
# -- Summary
|
||||
parts.append(
|
||||
f'<p style="color:#718096;font-size:15px;line-height:1.5;'
|
||||
f'margin:0 0 24px 0;">{summary}</p>'
|
||||
)
|
||||
|
||||
# -- Sections (details tables)
|
||||
if sections:
|
||||
for heading, rows in sections:
|
||||
parts.append(self._email_section(heading, rows))
|
||||
|
||||
# -- Note / Next Steps
|
||||
if note:
|
||||
nc = note_color or accent
|
||||
parts.append(self._email_note(note, nc))
|
||||
|
||||
# -- Extra HTML
|
||||
if extra_html:
|
||||
parts.append(extra_html)
|
||||
|
||||
# -- Attachment note
|
||||
if attachments_note:
|
||||
parts.append(self._email_attachment_note(attachments_note))
|
||||
|
||||
# -- CTA Button
|
||||
if button_url:
|
||||
parts.append(self._email_button(button_url, button_text, accent))
|
||||
|
||||
# -- Sign-off
|
||||
signer = sender_name or (self.env.user.name if self.env.user else '')
|
||||
parts.append(
|
||||
f'<p style="color:#2d3748;font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
|
||||
f'Best regards,<br/>'
|
||||
f'<strong>{signer}</strong><br/>'
|
||||
f'<span style="color:#718096;">{company["name"]}</span></p>'
|
||||
)
|
||||
|
||||
# -- Close content card
|
||||
parts.append('</div>')
|
||||
|
||||
# -- Footer
|
||||
footer_parts = [company['name']]
|
||||
if company['phone']:
|
||||
footer_parts.append(company['phone'])
|
||||
if company['email']:
|
||||
footer_parts.append(company['email'])
|
||||
footer_text = ' · '.join(footer_parts)
|
||||
|
||||
parts.append(
|
||||
f'<div style="padding:16px 28px;text-align:center;">'
|
||||
f'<p style="color:#a0aec0;font-size:11px;line-height:1.5;margin:0;">'
|
||||
f'{footer_text}<br/>'
|
||||
f'This is an automated notification from the ADP Claims Management System.</p>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
# -- Close wrapper
|
||||
parts.append('</div>')
|
||||
|
||||
return ''.join(parts)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Building blocks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _email_section(self, heading, rows):
|
||||
"""Build a labeled details table section.
|
||||
|
||||
Args:
|
||||
heading: Section title (e.g. "Case Details")
|
||||
rows: list of (label, value) tuples. Value can be plain text or HTML.
|
||||
"""
|
||||
if not rows:
|
||||
return ''
|
||||
|
||||
html = (
|
||||
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
|
||||
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
|
||||
f'color:#718096;text-transform:uppercase;letter-spacing:0.5px;'
|
||||
f'border-bottom:2px solid #e2e8f0;">{heading}</td></tr>'
|
||||
)
|
||||
|
||||
for label, value in rows:
|
||||
if value is None or value == '' or value is False:
|
||||
continue
|
||||
html += (
|
||||
f'<tr>'
|
||||
f'<td style="padding:10px 14px;color:#718096;font-size:14px;'
|
||||
f'border-bottom:1px solid #f0f0f0;width:35%;">{label}</td>'
|
||||
f'<td style="padding:10px 14px;color:#2d3748;font-size:14px;'
|
||||
f'border-bottom:1px solid #f0f0f0;">{value}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
|
||||
html += '</table>'
|
||||
return html
|
||||
|
||||
def _email_note(self, text, color='#2B6CB0'):
|
||||
"""Build a left-border accent note block."""
|
||||
return (
|
||||
f'<div style="border-left:3px solid {color};padding:12px 16px;'
|
||||
f'margin:0 0 24px 0;background:#f7fafc;">'
|
||||
f'<p style="margin:0;font-size:14px;line-height:1.5;color:#2d3748;">{text}</p>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
def _email_button(self, url, text='View Case Details', color='#2B6CB0'):
|
||||
"""Build a centered CTA button."""
|
||||
return (
|
||||
f'<p style="text-align:center;margin:28px 0;">'
|
||||
f'<a href="{url}" style="display:inline-block;background:{color};color:#ffffff;'
|
||||
f'padding:12px 28px;text-decoration:none;border-radius:6px;'
|
||||
f'font-size:14px;font-weight:600;">{text}</a></p>'
|
||||
)
|
||||
|
||||
def _email_attachment_note(self, description):
|
||||
"""Build a dashed-border attachment callout.
|
||||
|
||||
Args:
|
||||
description: e.g. "ADP Application (PDF), XML Data File"
|
||||
"""
|
||||
return (
|
||||
f'<div style="padding:10px 14px;border:1px dashed #e2e8f0;border-radius:6px;'
|
||||
f'margin:0 0 24px 0;">'
|
||||
f'<p style="margin:0;font-size:13px;color:#718096;">'
|
||||
f'<strong style="color:#2d3748;">Attached:</strong> {description}</p>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
def _email_status_badge(self, label, color='#2B6CB0'):
|
||||
"""Return an inline status badge/pill HTML snippet."""
|
||||
# Pick a light background tint for the badge
|
||||
bg_map = {
|
||||
'#38a169': '#f0fff4',
|
||||
'#2B6CB0': '#ebf4ff',
|
||||
'#d69e2e': '#fefcbf',
|
||||
'#c53030': '#fff5f5',
|
||||
}
|
||||
bg = bg_map.get(color, '#ebf4ff')
|
||||
return (
|
||||
f'<span style="display:inline-block;background:{bg};color:{color};'
|
||||
f'padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600;">'
|
||||
f'{label}</span>'
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_company_info(self):
|
||||
"""Return company name, phone, email for email templates."""
|
||||
company = getattr(self, 'company_id', None) or self.env.company
|
||||
return {
|
||||
'name': company.name or 'Our Company',
|
||||
'phone': company.phone or '',
|
||||
'email': company.email or '',
|
||||
}
|
||||
|
||||
def _email_is_enabled(self):
|
||||
"""Check if email notifications are enabled in settings."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
val = ICP.get_param('fusion_claims.enable_email_notifications', 'True')
|
||||
return val.lower() in ('true', '1', 'yes')
|
||||
@@ -57,6 +57,12 @@ class FusionADPDeviceCode(models.Model):
|
||||
index=True,
|
||||
help='Device manufacturer',
|
||||
)
|
||||
build_type = fields.Selection(
|
||||
[('modular', 'Modular'), ('custom_fabricated', 'Custom Fabricated')],
|
||||
string='Build Type',
|
||||
index=True,
|
||||
help='Build type for positioning/seating devices: Modular or Custom Fabricated',
|
||||
)
|
||||
device_description = fields.Char(
|
||||
string='Device Description',
|
||||
help='Detailed device description from mobility manual',
|
||||
@@ -242,6 +248,16 @@ class FusionADPDeviceCode(models.Model):
|
||||
device_type = self._clean_text(item.get('Device Type', '') or item.get('device_type', ''))
|
||||
manufacturer = self._clean_text(item.get('Manufacturer', '') or item.get('manufacturer', ''))
|
||||
device_description = self._clean_text(item.get('Device Description', '') or item.get('device_description', ''))
|
||||
|
||||
# Parse build type (Modular / Custom Fabricated)
|
||||
build_type_raw = self._clean_text(item.get('Build Type', '') or item.get('build_type', ''))
|
||||
build_type = False
|
||||
if build_type_raw:
|
||||
bt_lower = build_type_raw.lower().strip()
|
||||
if bt_lower in ('modular', 'mod'):
|
||||
build_type = 'modular'
|
||||
elif bt_lower in ('custom fabricated', 'custom_fabricated', 'custom'):
|
||||
build_type = 'custom_fabricated'
|
||||
|
||||
# Parse quantity
|
||||
qty_val = item.get('Quantity', 1) or item.get('Qty', 1) or item.get('quantity', 1)
|
||||
@@ -277,6 +293,8 @@ class FusionADPDeviceCode(models.Model):
|
||||
'last_updated': fields.Datetime.now(),
|
||||
'active': True,
|
||||
}
|
||||
if build_type:
|
||||
vals['build_type'] = build_type
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
|
||||
389
fusion_claims/models/page11_sign_request.py
Normal file
389
fusion_claims/models/page11_sign_request.py
Normal file
@@ -0,0 +1,389 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
SIGNER_TYPE_SELECTION = [
|
||||
('client', 'Client (Self)'),
|
||||
('spouse', 'Spouse'),
|
||||
('parent', 'Parent'),
|
||||
('legal_guardian', 'Legal Guardian'),
|
||||
('poa', 'Power of Attorney'),
|
||||
('public_trustee', 'Public Trustee'),
|
||||
]
|
||||
|
||||
SIGNER_TYPE_TO_RELATIONSHIP = {
|
||||
'spouse': 'Spouse',
|
||||
'parent': 'Parent',
|
||||
'legal_guardian': 'Legal Guardian',
|
||||
'poa': 'Power of Attorney',
|
||||
'public_trustee': 'Public Trustee',
|
||||
}
|
||||
|
||||
|
||||
class Page11SignRequest(models.Model):
|
||||
_name = 'fusion.page11.sign.request'
|
||||
_description = 'ADP Page 11 Remote Signing Request'
|
||||
_inherit = ['fusion.email.builder.mixin']
|
||||
_order = 'create_date desc'
|
||||
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order', string='Sale Order',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
access_token = fields.Char(
|
||||
string='Access Token', required=True, copy=False,
|
||||
default=lambda self: str(uuid.uuid4()), index=True,
|
||||
)
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('sent', 'Sent'),
|
||||
('signed', 'Signed'),
|
||||
('expired', 'Expired'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='Status', default='draft', required=True, tracking=True)
|
||||
|
||||
signer_email = fields.Char(string='Recipient Email', required=True)
|
||||
signer_type = fields.Selection(
|
||||
SIGNER_TYPE_SELECTION, string='Signer Type',
|
||||
default='client', required=True,
|
||||
)
|
||||
signer_name = fields.Char(string='Signer Name')
|
||||
signer_relationship = fields.Char(string='Relationship to Client')
|
||||
|
||||
signature_data = fields.Binary(string='Signature', attachment=True)
|
||||
signed_pdf = fields.Binary(string='Signed PDF', attachment=True)
|
||||
signed_pdf_filename = fields.Char(string='Signed PDF Filename')
|
||||
signed_date = fields.Datetime(string='Signed Date')
|
||||
sent_date = fields.Datetime(string='Sent Date')
|
||||
expiry_date = fields.Datetime(string='Expiry Date')
|
||||
|
||||
consent_declaration_accepted = fields.Boolean(string='Declaration Accepted')
|
||||
consent_signed_by = fields.Selection([
|
||||
('applicant', 'Applicant'),
|
||||
('agent', 'Agent'),
|
||||
], string='Signed By')
|
||||
|
||||
client_first_name = fields.Char(string='Client First Name')
|
||||
client_last_name = fields.Char(string='Client Last Name')
|
||||
client_health_card = fields.Char(string='Health Card Number')
|
||||
client_health_card_version = fields.Char(string='Health Card Version')
|
||||
|
||||
agent_first_name = fields.Char(string='Agent First Name')
|
||||
agent_last_name = fields.Char(string='Agent Last Name')
|
||||
agent_middle_initial = fields.Char(string='Agent Middle Initial')
|
||||
agent_phone = fields.Char(string='Agent Phone')
|
||||
agent_unit = fields.Char(string='Agent Unit Number')
|
||||
agent_street_number = fields.Char(string='Agent Street Number')
|
||||
agent_street = fields.Char(string='Agent Street Name')
|
||||
agent_city = fields.Char(string='Agent City')
|
||||
agent_province = fields.Char(string='Agent Province', default='Ontario')
|
||||
agent_postal_code = fields.Char(string='Agent Postal Code')
|
||||
|
||||
custom_message = fields.Text(string='Custom Message')
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
related='sale_order_id.company_id', store=True,
|
||||
)
|
||||
|
||||
def name_get(self):
|
||||
return [
|
||||
(r.id, f"Page 11 - {r.sale_order_id.name} ({r.state})")
|
||||
for r in self
|
||||
]
|
||||
|
||||
def _send_signing_email(self):
|
||||
"""Build and send the signing request email."""
|
||||
self.ensure_one()
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
sign_url = f'{base_url}/page11/sign/{self.access_token}'
|
||||
order = self.sale_order_id
|
||||
|
||||
client_name = order.partner_id.name or 'N/A'
|
||||
sections = [
|
||||
('Case Details', [
|
||||
('Client', client_name),
|
||||
('Case Reference', order.name),
|
||||
]),
|
||||
]
|
||||
|
||||
if order.x_fc_authorizer_id:
|
||||
sections[0][1].append(('Authorizer', order.x_fc_authorizer_id.name))
|
||||
|
||||
if order.x_fc_assessment_start_date:
|
||||
sections[0][1].append((
|
||||
'Assessment Date',
|
||||
order.x_fc_assessment_start_date.strftime('%B %d, %Y'),
|
||||
))
|
||||
|
||||
note_parts = []
|
||||
if self.custom_message:
|
||||
note_parts.append(self.custom_message)
|
||||
days_left = 7
|
||||
if self.expiry_date:
|
||||
delta = self.expiry_date - fields.Datetime.now()
|
||||
days_left = max(1, delta.days)
|
||||
note_parts.append(
|
||||
f'This link will expire in {days_left} days. '
|
||||
'Please complete the signing at your earliest convenience.'
|
||||
)
|
||||
note_text = '<br/><br/>'.join(note_parts)
|
||||
|
||||
body_html = self._email_build(
|
||||
title='Page 11 Signature Required',
|
||||
summary=(
|
||||
f'{order.company_id.name} requires your signature on the '
|
||||
f'ADP Consent and Declaration form for <strong>{client_name}</strong>.'
|
||||
),
|
||||
sections=sections,
|
||||
note=note_text,
|
||||
email_type='info',
|
||||
button_url=sign_url,
|
||||
button_text='Sign Now',
|
||||
sender_name=self.env.user.name,
|
||||
)
|
||||
|
||||
mail_values = {
|
||||
'subject': f'{order.company_id.name} - Page 11 Signature Required ({order.name})',
|
||||
'body_html': body_html,
|
||||
'email_to': self.signer_email,
|
||||
'email_from': (
|
||||
self.env.user.email_formatted
|
||||
or order.company_id.email_formatted
|
||||
),
|
||||
'auto_delete': True,
|
||||
}
|
||||
mail = self.env['mail.mail'].sudo().create(mail_values)
|
||||
mail.send()
|
||||
|
||||
self.write({
|
||||
'state': 'sent',
|
||||
'sent_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
signer_display = self.signer_name or self.signer_email
|
||||
order.message_post(
|
||||
body=Markup(
|
||||
'Page 11 signing request sent to <strong>%s</strong> (%s).'
|
||||
) % (signer_display, self.signer_email),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
def _generate_signed_pdf(self):
|
||||
"""Generate the signed Page 11 PDF using the PDF template engine."""
|
||||
self.ensure_one()
|
||||
order = self.sale_order_id
|
||||
|
||||
assessment = self.env['fusion.assessment'].search([
|
||||
('sale_order_id', '=', order.id),
|
||||
], limit=1, order='create_date desc')
|
||||
|
||||
if assessment:
|
||||
ctx = assessment._get_pdf_context()
|
||||
else:
|
||||
ctx = self._build_pdf_context_from_order()
|
||||
|
||||
if self.client_first_name:
|
||||
ctx['client_first_name'] = self.client_first_name
|
||||
if self.client_last_name:
|
||||
ctx['client_last_name'] = self.client_last_name
|
||||
if self.client_health_card:
|
||||
ctx['client_health_card'] = self.client_health_card
|
||||
if self.client_health_card_version:
|
||||
ctx['client_health_card_version'] = self.client_health_card_version
|
||||
|
||||
ctx.update({
|
||||
'consent_signed_by': self.consent_signed_by or '',
|
||||
'consent_applicant': self.consent_signed_by == 'applicant',
|
||||
'consent_agent': self.consent_signed_by == 'agent',
|
||||
'consent_declaration_accepted': self.consent_declaration_accepted,
|
||||
'consent_date': str(fields.Date.today()),
|
||||
})
|
||||
|
||||
if self.consent_signed_by == 'agent':
|
||||
ctx.update({
|
||||
'agent_first_name': self.agent_first_name or '',
|
||||
'agent_last_name': self.agent_last_name or '',
|
||||
'agent_middle_initial': self.agent_middle_initial or '',
|
||||
'agent_unit': self.agent_unit or '',
|
||||
'agent_street_number': self.agent_street_number or '',
|
||||
'agent_street_name': self.agent_street or '',
|
||||
'agent_city': self.agent_city or '',
|
||||
'agent_province': self.agent_province or '',
|
||||
'agent_postal_code': self.agent_postal_code or '',
|
||||
'agent_home_phone': self.agent_phone or '',
|
||||
'agent_relationship': self.signer_relationship or '',
|
||||
'agent_rel_spouse': self.signer_type == 'spouse',
|
||||
'agent_rel_parent': self.signer_type == 'parent',
|
||||
'agent_rel_poa': self.signer_type == 'poa',
|
||||
'agent_rel_guardian': self.signer_type in ('legal_guardian', 'public_trustee'),
|
||||
})
|
||||
|
||||
signatures = {}
|
||||
if self.signature_data:
|
||||
signatures['signature_page_11'] = base64.b64decode(self.signature_data)
|
||||
|
||||
template = self.env['fusion.pdf.template'].search([
|
||||
('state', '=', 'active'),
|
||||
('name', 'ilike', 'adp_page_11'),
|
||||
], limit=1)
|
||||
|
||||
if not template:
|
||||
template = self.env['fusion.pdf.template'].search([
|
||||
('state', '=', 'active'),
|
||||
('name', 'ilike', 'page 11'),
|
||||
], limit=1)
|
||||
|
||||
if not template:
|
||||
_logger.warning("No active PDF template found for Page 11")
|
||||
return None
|
||||
|
||||
try:
|
||||
pdf_bytes = template.generate_filled_pdf(ctx, signatures)
|
||||
if pdf_bytes:
|
||||
first, last = order._get_client_name_parts()
|
||||
filename = f'{first}_{last}_Page11_Signed.pdf'
|
||||
self.write({
|
||||
'signed_pdf': base64.b64encode(pdf_bytes),
|
||||
'signed_pdf_filename': filename,
|
||||
})
|
||||
return pdf_bytes
|
||||
except Exception as e:
|
||||
_logger.error("Failed to generate Page 11 PDF: %s", e)
|
||||
return None
|
||||
|
||||
def _build_pdf_context_from_order(self):
|
||||
"""Build a PDF context dict from the sale order when no assessment exists."""
|
||||
order = self.sale_order_id
|
||||
partner = order.partner_id
|
||||
first, last = order._get_client_name_parts()
|
||||
return {
|
||||
'client_first_name': first,
|
||||
'client_last_name': last,
|
||||
'client_name': partner.name or '',
|
||||
'client_street': partner.street or '',
|
||||
'client_city': partner.city or '',
|
||||
'client_state': partner.state_id.name if partner.state_id else 'Ontario',
|
||||
'client_postal_code': partner.zip or '',
|
||||
'client_phone': partner.phone or partner.mobile or '',
|
||||
'client_email': partner.email or '',
|
||||
'client_type': order.x_fc_client_type or '',
|
||||
'client_type_reg': order.x_fc_client_type == 'REG',
|
||||
'client_type_ods': order.x_fc_client_type == 'ODS',
|
||||
'client_type_acs': order.x_fc_client_type == 'ACS',
|
||||
'client_type_owp': order.x_fc_client_type == 'OWP',
|
||||
'reference': order.name or '',
|
||||
'authorizer_name': order.x_fc_authorizer_id.name if order.x_fc_authorizer_id else '',
|
||||
'authorizer_phone': order.x_fc_authorizer_id.phone if order.x_fc_authorizer_id else '',
|
||||
'authorizer_email': order.x_fc_authorizer_id.email if order.x_fc_authorizer_id else '',
|
||||
'claim_authorization_date': str(order.x_fc_claim_authorization_date) if order.x_fc_claim_authorization_date else '',
|
||||
'assessment_start_date': str(order.x_fc_assessment_start_date) if order.x_fc_assessment_start_date else '',
|
||||
'assessment_end_date': str(order.x_fc_assessment_end_date) if order.x_fc_assessment_end_date else '',
|
||||
}
|
||||
|
||||
def _update_sale_order(self):
|
||||
"""Copy signing data from this request to the sale order."""
|
||||
self.ensure_one()
|
||||
order = self.sale_order_id
|
||||
vals = {
|
||||
'x_fc_page11_signer_type': self.signer_type,
|
||||
'x_fc_page11_signer_name': self.signer_name,
|
||||
'x_fc_page11_signed_date': fields.Date.today(),
|
||||
}
|
||||
if self.signer_type != 'client':
|
||||
vals['x_fc_page11_signer_relationship'] = (
|
||||
self.signer_relationship
|
||||
or SIGNER_TYPE_TO_RELATIONSHIP.get(self.signer_type, '')
|
||||
)
|
||||
if self.signed_pdf:
|
||||
vals['x_fc_signed_pages_11_12'] = self.signed_pdf
|
||||
vals['x_fc_signed_pages_filename'] = self.signed_pdf_filename
|
||||
|
||||
order.with_context(
|
||||
skip_page11_check=True,
|
||||
skip_document_chatter=True,
|
||||
).write(vals)
|
||||
|
||||
signer_display = self.signer_name or 'N/A'
|
||||
if self.signed_pdf:
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': self.signed_pdf_filename or 'Page11_Signed.pdf',
|
||||
'datas': self.signed_pdf,
|
||||
'res_model': 'sale.order',
|
||||
'res_id': order.id,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
order.message_post(
|
||||
body=Markup(
|
||||
'Page 11 has been signed by <strong>%s</strong> (%s).'
|
||||
) % (signer_display, self.signer_email),
|
||||
attachment_ids=[att.id],
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
else:
|
||||
order.message_post(
|
||||
body=Markup(
|
||||
'Page 11 has been signed by <strong>%s</strong> (%s). '
|
||||
'PDF generation was not available.'
|
||||
) % (signer_display, self.signer_email),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
def action_cancel(self):
|
||||
"""Cancel a pending signing request."""
|
||||
for rec in self:
|
||||
if rec.state in ('draft', 'sent'):
|
||||
rec.state = 'cancelled'
|
||||
|
||||
def action_resend(self):
|
||||
"""Resend the signing email."""
|
||||
for rec in self:
|
||||
if rec.state in ('sent', 'expired'):
|
||||
rec.expiry_date = fields.Datetime.now() + timedelta(days=7)
|
||||
rec.access_token = str(uuid.uuid4())
|
||||
rec._send_signing_email()
|
||||
|
||||
def action_request_new_signature(self):
|
||||
"""Create a new signing request (e.g. to re-sign after corrections)."""
|
||||
self.ensure_one()
|
||||
if self.state == 'signed':
|
||||
self.state = 'cancelled'
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Request Page 11 Signature',
|
||||
'res_model': 'fusion_claims.send.page11.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_sale_order_id': self.sale_order_id.id,
|
||||
'default_signer_email': self.signer_email,
|
||||
'default_signer_name': self.signer_name,
|
||||
'default_signer_type': self.signer_type,
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _cron_expire_requests(self):
|
||||
"""Mark expired unsigned requests."""
|
||||
expired = self.search([
|
||||
('state', '=', 'sent'),
|
||||
('expiry_date', '<', fields.Datetime.now()),
|
||||
])
|
||||
if expired:
|
||||
expired.write({'state': 'expired'})
|
||||
_logger.info("Expired %d Page 11 signing requests", len(expired))
|
||||
@@ -1,73 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""
|
||||
Web Push Subscription model for storing browser push notification subscriptions.
|
||||
"""
|
||||
|
||||
from odoo import models, fields, api
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionPushSubscription(models.Model):
|
||||
_name = 'fusion.push.subscription'
|
||||
_description = 'Web Push Subscription'
|
||||
_order = 'create_date desc'
|
||||
|
||||
user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='User',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
endpoint = fields.Text(
|
||||
string='Endpoint URL',
|
||||
required=True,
|
||||
)
|
||||
p256dh_key = fields.Text(
|
||||
string='P256DH Key',
|
||||
required=True,
|
||||
)
|
||||
auth_key = fields.Text(
|
||||
string='Auth Key',
|
||||
required=True,
|
||||
)
|
||||
browser_info = fields.Char(
|
||||
string='Browser Info',
|
||||
help='User agent or browser identification',
|
||||
)
|
||||
active = fields.Boolean(
|
||||
default=True,
|
||||
)
|
||||
|
||||
_constraints = [
|
||||
models.Constraint(
|
||||
'unique(endpoint)',
|
||||
'This push subscription endpoint already exists.',
|
||||
),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def register_subscription(self, user_id, endpoint, p256dh_key, auth_key, browser_info=None):
|
||||
"""Register or update a push subscription."""
|
||||
existing = self.sudo().search([('endpoint', '=', endpoint)], limit=1)
|
||||
if existing:
|
||||
existing.write({
|
||||
'user_id': user_id,
|
||||
'p256dh_key': p256dh_key,
|
||||
'auth_key': auth_key,
|
||||
'browser_info': browser_info or existing.browser_info,
|
||||
'active': True,
|
||||
})
|
||||
return existing
|
||||
return self.sudo().create({
|
||||
'user_id': user_id,
|
||||
'endpoint': endpoint,
|
||||
'p256dh_key': p256dh_key,
|
||||
'auth_key': auth_key,
|
||||
'browser_info': browser_info,
|
||||
})
|
||||
@@ -317,16 +317,6 @@ class ResConfigSettings(models.TransientModel):
|
||||
help='The user who signs Page 12 on behalf of the company',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# GOOGLE MAPS API SETTINGS
|
||||
# =========================================================================
|
||||
|
||||
fc_google_maps_api_key = fields.Char(
|
||||
string='Google Maps API Key',
|
||||
config_parameter='fusion_claims.google_maps_api_key',
|
||||
help='API key for Google Maps Places autocomplete in address fields',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AI CLIENT INTELLIGENCE
|
||||
# ------------------------------------------------------------------
|
||||
@@ -349,62 +339,6 @@ class ResConfigSettings(models.TransientModel):
|
||||
help='Automatically parse ADP XML files when uploaded and create/update client profiles',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TECHNICIAN MANAGEMENT
|
||||
# ------------------------------------------------------------------
|
||||
fc_store_open_hour = fields.Float(
|
||||
string='Store Open Time',
|
||||
config_parameter='fusion_claims.store_open_hour',
|
||||
help='Store opening time for technician scheduling (e.g. 9.0 = 9:00 AM)',
|
||||
)
|
||||
fc_store_close_hour = fields.Float(
|
||||
string='Store Close Time',
|
||||
config_parameter='fusion_claims.store_close_hour',
|
||||
help='Store closing time for technician scheduling (e.g. 18.0 = 6:00 PM)',
|
||||
)
|
||||
fc_google_distance_matrix_enabled = fields.Boolean(
|
||||
string='Enable Distance Matrix',
|
||||
config_parameter='fusion_claims.google_distance_matrix_enabled',
|
||||
help='Enable Google Distance Matrix API for travel time calculations between technician tasks',
|
||||
)
|
||||
fc_technician_start_address = fields.Char(
|
||||
string='Technician Start Address',
|
||||
config_parameter='fusion_claims.technician_start_address',
|
||||
help='Default start location for technician travel calculations (e.g. warehouse/office address)',
|
||||
)
|
||||
fc_location_retention_days = fields.Char(
|
||||
string='Location History Retention (Days)',
|
||||
config_parameter='fusion_claims.location_retention_days',
|
||||
help='How many days to keep technician location history. '
|
||||
'Leave empty = 30 days (1 month). '
|
||||
'0 = delete at end of each day. '
|
||||
'1+ = keep for that many days.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WEB PUSH NOTIFICATIONS
|
||||
# ------------------------------------------------------------------
|
||||
fc_push_enabled = fields.Boolean(
|
||||
string='Enable Push Notifications',
|
||||
config_parameter='fusion_claims.push_enabled',
|
||||
help='Enable web push notifications for technician tasks',
|
||||
)
|
||||
fc_vapid_public_key = fields.Char(
|
||||
string='VAPID Public Key',
|
||||
config_parameter='fusion_claims.vapid_public_key',
|
||||
help='Public key for Web Push VAPID authentication (auto-generated)',
|
||||
)
|
||||
fc_vapid_private_key = fields.Char(
|
||||
string='VAPID Private Key',
|
||||
config_parameter='fusion_claims.vapid_private_key',
|
||||
help='Private key for Web Push VAPID authentication (auto-generated)',
|
||||
)
|
||||
fc_push_advance_minutes = fields.Integer(
|
||||
string='Notification Advance (min)',
|
||||
config_parameter='fusion_claims.push_advance_minutes',
|
||||
help='Send push notifications this many minutes before a scheduled task',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TWILIO SMS SETTINGS
|
||||
# ------------------------------------------------------------------
|
||||
@@ -609,15 +543,11 @@ class ResConfigSettings(models.TransientModel):
|
||||
# an existing non-empty value (e.g. API keys, user-customized settings).
|
||||
_protected_keys = [
|
||||
'fusion_claims.ai_api_key',
|
||||
'fusion_claims.google_maps_api_key',
|
||||
'fusion_claims.vendor_code',
|
||||
'fusion_claims.ai_model',
|
||||
'fusion_claims.adp_posting_base_date',
|
||||
'fusion_claims.application_reminder_days',
|
||||
'fusion_claims.application_reminder_2_days',
|
||||
'fusion_claims.store_open_hour',
|
||||
'fusion_claims.store_close_hour',
|
||||
'fusion_claims.technician_start_address',
|
||||
]
|
||||
# Snapshot existing values BEFORE super().set_values() runs
|
||||
_existing = {}
|
||||
|
||||
@@ -2,82 +2,12 @@
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
x_fc_start_address = fields.Char(
|
||||
string='Start Location',
|
||||
help='Technician daily start location (home, warehouse, etc.). '
|
||||
'Used as origin for first travel time calculation. '
|
||||
'If empty, the company default HQ address is used.',
|
||||
)
|
||||
x_fc_start_address_lat = fields.Float(
|
||||
string='Start Latitude', digits=(10, 7),
|
||||
)
|
||||
x_fc_start_address_lng = fields.Float(
|
||||
string='Start Longitude', digits=(10, 7),
|
||||
)
|
||||
|
||||
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', '')
|
||||
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'},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get('status') == 'OK' and data.get('results'):
|
||||
loc = data['results'][0]['geometry']['location']
|
||||
return loc['lat'], loc['lng']
|
||||
except Exception as e:
|
||||
_logger.warning("Start address geocoding failed for '%s': %s", address, e)
|
||||
return 0.0, 0.0
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for rec, vals in zip(records, vals_list):
|
||||
addr = vals.get('x_fc_start_address')
|
||||
if addr:
|
||||
lat, lng = rec._geocode_start_address(addr)
|
||||
if lat and lng:
|
||||
rec.write({
|
||||
'x_fc_start_address_lat': lat,
|
||||
'x_fc_start_address_lng': lng,
|
||||
})
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'x_fc_start_address' in vals:
|
||||
addr = vals['x_fc_start_address']
|
||||
if addr and addr.strip():
|
||||
lat, lng = self._geocode_start_address(addr)
|
||||
if lat and lng:
|
||||
super().write({
|
||||
'x_fc_start_address_lat': lat,
|
||||
'x_fc_start_address_lng': lng,
|
||||
})
|
||||
else:
|
||||
super().write({
|
||||
'x_fc_start_address_lat': 0.0,
|
||||
'x_fc_start_address_lng': 0.0,
|
||||
})
|
||||
return res
|
||||
|
||||
# ==========================================================================
|
||||
# CONTACT TYPE
|
||||
# ==========================================================================
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
x_fc_is_field_staff = fields.Boolean(
|
||||
string='Field Staff',
|
||||
default=False,
|
||||
help='Check this to show the user in the Technician/Field Staff dropdown when scheduling tasks.',
|
||||
)
|
||||
x_fc_start_address = fields.Char(
|
||||
related='partner_id.x_fc_start_address',
|
||||
readonly=False,
|
||||
string='Start Location',
|
||||
)
|
||||
x_fc_tech_sync_id = fields.Char(
|
||||
string='Tech Sync ID',
|
||||
help='Shared identifier for this technician across Odoo instances. '
|
||||
'Must be the same value on all instances for the same person.',
|
||||
copy=False,
|
||||
)
|
||||
@@ -1862,6 +1862,10 @@ class SaleOrder(models.Model):
|
||||
string='Previous Status Before Hold',
|
||||
help='Status before the application was put on hold (for resuming)',
|
||||
)
|
||||
x_fc_previous_status_before_withdrawal = fields.Char(
|
||||
string='Status Before Withdrawal',
|
||||
help='Records the status before withdrawal for audit trail.',
|
||||
)
|
||||
|
||||
x_fc_status_before_delivery = fields.Char(
|
||||
string='Status Before Delivery',
|
||||
@@ -2327,6 +2331,20 @@ class SaleOrder(models.Model):
|
||||
help='Date when Page 11 was signed',
|
||||
)
|
||||
|
||||
page11_sign_request_ids = fields.One2many(
|
||||
'fusion.page11.sign.request', 'sale_order_id',
|
||||
string='Page 11 Signing Requests',
|
||||
)
|
||||
page11_sign_request_count = fields.Integer(
|
||||
compute='_compute_page11_sign_request_count',
|
||||
string='Signing Requests',
|
||||
)
|
||||
page11_sign_status = fields.Selection([
|
||||
('none', 'Not Requested'),
|
||||
('sent', 'Pending Signature'),
|
||||
('signed', 'Signed'),
|
||||
], compute='_compute_page11_sign_request_count', string='Page 11 Remote Status')
|
||||
|
||||
# ==========================================================================
|
||||
# PAGE 12 SIGNATURE TRACKING (Authorizer + Vendor Signature)
|
||||
# Page 12 must be signed by: Authorizer (OT) and Vendor (our company)
|
||||
@@ -3120,11 +3138,49 @@ class SaleOrder(models.Model):
|
||||
self.ensure_one()
|
||||
return self._action_open_document('x_fc_original_application', 'Original ADP Application')
|
||||
|
||||
@api.depends('page11_sign_request_ids', 'page11_sign_request_ids.state')
|
||||
def _compute_page11_sign_request_count(self):
|
||||
for order in self:
|
||||
requests = order.page11_sign_request_ids
|
||||
order.page11_sign_request_count = len(requests)
|
||||
signed = requests.filtered(lambda r: r.state == 'signed')
|
||||
pending = requests.filtered(lambda r: r.state == 'sent')
|
||||
if signed:
|
||||
order.page11_sign_status = 'signed'
|
||||
elif pending:
|
||||
order.page11_sign_status = 'sent'
|
||||
else:
|
||||
order.page11_sign_status = 'none'
|
||||
|
||||
def action_open_signed_pages(self):
|
||||
"""Open the Page 11 & 12 PDF."""
|
||||
self.ensure_one()
|
||||
return self._action_open_document('x_fc_signed_pages_11_12', 'Page 11 & 12 (Signed)')
|
||||
|
||||
|
||||
def action_request_page11_signature(self):
|
||||
"""Open the wizard to send Page 11 for remote signing."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Request Page 11 Signature',
|
||||
'res_model': 'fusion_claims.send.page11.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_sale_order_id': self.id},
|
||||
}
|
||||
|
||||
def action_view_page11_requests(self):
|
||||
"""Open the list of Page 11 signing requests."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Page 11 Signing Requests',
|
||||
'res_model': 'fusion.page11.sign.request',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
'context': {'default_sale_order_id': self.id},
|
||||
}
|
||||
|
||||
def action_open_final_application(self):
|
||||
"""Open the Final Submitted Application PDF."""
|
||||
self.ensure_one()
|
||||
@@ -3686,6 +3742,41 @@ class SaleOrder(models.Model):
|
||||
|
||||
return True
|
||||
|
||||
def action_resubmit_from_withdrawn(self):
|
||||
"""Return a withdrawn application to Ready for Submission for correction and resubmission."""
|
||||
self.ensure_one()
|
||||
|
||||
if self.x_fc_adp_application_status != 'withdrawn':
|
||||
raise UserError("This action is only available for withdrawn applications.")
|
||||
|
||||
self.with_context(skip_status_validation=True).write({
|
||||
'x_fc_adp_application_status': 'ready_submission',
|
||||
})
|
||||
|
||||
user_name = self.env.user.name
|
||||
resubmit_date = fields.Date.today().strftime('%B %d, %Y')
|
||||
|
||||
message_body = f'''
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h5 class="alert-heading"><i class="fa fa-repeat"></i> Application Returned for Resubmission</h5>
|
||||
<ul>
|
||||
<li><strong>Returned By:</strong> {user_name}</li>
|
||||
<li><strong>Date:</strong> {resubmit_date}</li>
|
||||
<li><strong>Status Returned To:</strong> Ready for Submission</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<p class="mb-0"><i class="fa fa-info-circle"></i> Make corrections and click <strong>Submit Application</strong> to resubmit.</p>
|
||||
</div>
|
||||
'''
|
||||
|
||||
self.message_post(
|
||||
body=Markup(message_body),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def action_set_ready_to_bill(self):
|
||||
"""Open the Ready to Bill wizard to collect POD and delivery date.
|
||||
|
||||
@@ -4520,6 +4611,12 @@ class SaleOrder(models.Model):
|
||||
if 'x_fc_device_placement' in self.env['account.move.line']._fields:
|
||||
line_vals['x_fc_device_placement'] = line.x_fc_device_placement
|
||||
|
||||
# Copy deduction fields so export verification can recalculate correctly
|
||||
if 'x_fc_deduction_type' in self.env['account.move.line']._fields:
|
||||
line_vals['x_fc_deduction_type'] = line.x_fc_deduction_type or 'none'
|
||||
if 'x_fc_deduction_value' in self.env['account.move.line']._fields:
|
||||
line_vals['x_fc_deduction_value'] = line.x_fc_deduction_value or 0
|
||||
|
||||
# Store BOTH portions on invoice line (for display)
|
||||
if 'x_fc_adp_portion' in self.env['account.move.line']._fields:
|
||||
line_vals['x_fc_adp_portion'] = adp_portion
|
||||
@@ -5170,13 +5267,13 @@ class SaleOrder(models.Model):
|
||||
f'border-bottom:2px solid #4a5568;{font}"'
|
||||
)
|
||||
cell_style = (
|
||||
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
|
||||
'border-bottom:1px solid #e2e8f0;"'
|
||||
'style="padding:7px 10px;font-size:12px;'
|
||||
'border-bottom:1px solid rgba(128,128,128,0.15);"'
|
||||
)
|
||||
alt_row = 'style="background:#f7fafc;"'
|
||||
alt_row = 'style="background:rgba(128,128,128,0.06);"'
|
||||
amt_style = (
|
||||
'style="padding:7px 10px;font-size:12px;color:#2d3748;'
|
||||
'border-bottom:1px solid #e2e8f0;text-align:right;"'
|
||||
'style="padding:7px 10px;font-size:12px;'
|
||||
'border-bottom:1px solid rgba(128,128,128,0.15);text-align:right;"'
|
||||
)
|
||||
hdr_r = hdr_style.replace('text-align:left', 'text-align:right')
|
||||
|
||||
@@ -5187,9 +5284,9 @@ class SaleOrder(models.Model):
|
||||
|
||||
html = (
|
||||
'<div style="margin:20px 0;">'
|
||||
f'<h3 style="color:#1a202c;font-size:15px;font-weight:700;'
|
||||
f'<h3 style="font-size:15px;font-weight:700;'
|
||||
f'margin:0 0 10px 0;{font}">Approved Items</h3>'
|
||||
'<table style="width:100%;border-collapse:collapse;border:1px solid #e2e8f0;">'
|
||||
'<table style="width:100%;border-collapse:collapse;border:1px solid rgba(128,128,128,0.25);">'
|
||||
'<thead><tr>'
|
||||
f'<th {hdr_style}>S/N</th>'
|
||||
f'<th {hdr_style}>ADP Code</th>'
|
||||
@@ -5241,13 +5338,13 @@ class SaleOrder(models.Model):
|
||||
colspan = 5
|
||||
total_style = (
|
||||
'style="padding:8px 10px;font-size:12px;font-weight:700;'
|
||||
'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"'
|
||||
'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"'
|
||||
)
|
||||
total_label_style = (
|
||||
f'style="padding:8px 10px;font-size:12px;font-weight:700;'
|
||||
f'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"'
|
||||
'style="padding:8px 10px;font-size:12px;font-weight:700;'
|
||||
'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"'
|
||||
)
|
||||
html += f'<tr style="background:#edf2f7;">'
|
||||
html += '<tr style="background:rgba(128,128,128,0.08);">'
|
||||
html += f'<td colspan="{colspan}" {total_label_style}>Total</td>'
|
||||
html += f'<td {total_style}>${total_adp:,.2f}</td>'
|
||||
html += f'<td {total_style}>${total_client:,.2f}</td>'
|
||||
@@ -5529,8 +5626,13 @@ class SaleOrder(models.Model):
|
||||
_logger.error(f"Failed to send case closed email for {self.name}: {e}")
|
||||
return False
|
||||
|
||||
def _send_withdrawal_email(self, reason=None):
|
||||
"""Send notification when application is withdrawn."""
|
||||
def _send_withdrawal_email(self, reason=None, intent=None):
|
||||
"""Send notification when application is withdrawn.
|
||||
|
||||
Args:
|
||||
reason: Free-text reason for withdrawal.
|
||||
intent: 'cancel' or 'resubmit' — determines email wording.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self._is_email_notifications_enabled():
|
||||
return False
|
||||
@@ -5542,17 +5644,34 @@ class SaleOrder(models.Model):
|
||||
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
|
||||
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
|
||||
|
||||
note_text = 'This application has been withdrawn from the Assistive Devices Program.'
|
||||
if intent == 'cancel':
|
||||
note_text = ('This application has been permanently withdrawn and cancelled. '
|
||||
'The sale order and all related invoices have been cancelled.')
|
||||
title = 'Application Withdrawn & Cancelled'
|
||||
subject_suffix = 'Withdrawn & Cancelled'
|
||||
note_color = '#dc3545'
|
||||
elif intent == 'resubmit':
|
||||
note_text = ('This application has been withdrawn for correction and will be resubmitted. '
|
||||
'The application has been returned to Ready for Submission status.')
|
||||
title = 'Application Withdrawn for Correction'
|
||||
subject_suffix = 'Withdrawn for Correction'
|
||||
note_color = '#d69e2e'
|
||||
else:
|
||||
note_text = 'This application has been withdrawn from the Assistive Devices Program.'
|
||||
title = 'Application Withdrawn'
|
||||
subject_suffix = 'Withdrawn'
|
||||
note_color = '#d69e2e'
|
||||
|
||||
if reason:
|
||||
note_text += f'<br/><strong>Reason:</strong> {reason}'
|
||||
|
||||
body_html = self._email_build(
|
||||
title='Application Withdrawn',
|
||||
title=title,
|
||||
summary=f'The ADP application for <strong>{client_name}</strong> has been withdrawn.',
|
||||
email_type='attention',
|
||||
sections=[('Case Details', self._build_case_detail_rows())],
|
||||
note=note_text,
|
||||
note_color='#d69e2e',
|
||||
note_color=note_color,
|
||||
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
|
||||
sender_name=sales_rep_name,
|
||||
)
|
||||
@@ -5560,12 +5679,12 @@ class SaleOrder(models.Model):
|
||||
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
|
||||
try:
|
||||
self.env['mail.mail'].sudo().create({
|
||||
'subject': f'Application Withdrawn - {client_name} - {self.name}',
|
||||
'subject': f'Application {subject_suffix} - {client_name} - {self.name}',
|
||||
'body_html': body_html,
|
||||
'email_to': email_to, 'email_cc': email_cc,
|
||||
'model': 'sale.order', 'res_id': self.id,
|
||||
}).send()
|
||||
self._email_chatter_log('Application Withdrawn email sent', email_to, email_cc)
|
||||
self._email_chatter_log(f'{title} email sent', email_to, email_cc)
|
||||
return True
|
||||
except Exception as e:
|
||||
_logger.error(f"Failed to send withdrawal email for {self.name}: {e}")
|
||||
@@ -5862,7 +5981,10 @@ class SaleOrder(models.Model):
|
||||
'x_fc_proof_of_delivery',
|
||||
'x_fc_approval_letter',
|
||||
]
|
||||
doc_changes = {f: vals.get(f) for f in document_fields if f in vals and vals.get(f)}
|
||||
if self.env.context.get('skip_document_chatter'):
|
||||
doc_changes = {}
|
||||
else:
|
||||
doc_changes = {f: vals.get(f) for f in document_fields if f in vals and vals.get(f)}
|
||||
|
||||
# Preserve old documents in chatter BEFORE they get replaced or deleted
|
||||
# This ensures document history is maintained for audit purposes
|
||||
@@ -5885,7 +6007,7 @@ class SaleOrder(models.Model):
|
||||
|
||||
for order in self:
|
||||
for field_name in document_fields:
|
||||
if field_name in vals and field_name not in correction_handled:
|
||||
if field_name in vals and field_name not in correction_handled and not self.env.context.get('skip_document_chatter'):
|
||||
old_data = getattr(order, field_name, None)
|
||||
new_data = vals.get(field_name)
|
||||
label = document_labels.get(field_name, field_name)
|
||||
@@ -6584,96 +6706,6 @@ class SaleOrder(models.Model):
|
||||
except Exception as e:
|
||||
_logger.error(f" Failed to sync serial to invoice line {inv_line.id}: {e}")
|
||||
|
||||
def action_sync_adp_fields(self):
|
||||
"""Manual action to sync all ADP fields to invoices."""
|
||||
synced_invoices = 0
|
||||
for order in self:
|
||||
# First sync Studio fields to FC fields on the SO itself
|
||||
order._sync_studio_to_fc_fields()
|
||||
|
||||
# Then sync to invoices
|
||||
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
|
||||
if invoices:
|
||||
order._sync_fields_to_invoices()
|
||||
synced_invoices += len(invoices)
|
||||
|
||||
# Force refresh of the view
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Fields Synchronized',
|
||||
'message': f'Synced ADP fields from {len(self)} sale order(s) to {synced_invoices} invoice(s). Please refresh the page to see updated values.',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _cron_sync_adp_fields(self):
|
||||
"""Cron job to sync ADP fields from Sale Orders to Invoices.
|
||||
|
||||
Processes all ADP sales created/modified in the last 7 days.
|
||||
Uses dynamic field mappings from Settings.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
cutoff_date = fields.Datetime.now() - timedelta(days=7)
|
||||
|
||||
# Get field mappings
|
||||
mappings = self._get_field_mappings()
|
||||
sale_type_field = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_claims.field_sale_type', 'x_fc_sale_type'
|
||||
)
|
||||
|
||||
# Build domain - check FC sale type fields
|
||||
domain = [('write_date', '>=', cutoff_date)]
|
||||
or_conditions = []
|
||||
|
||||
# Check FC sale type field
|
||||
if sale_type_field in self._fields:
|
||||
or_conditions.append((sale_type_field, 'in', ['adp', 'adp_odsp', 'ADP', 'ADP/ODSP']))
|
||||
|
||||
# Check claim number fields
|
||||
claim_field = mappings.get('so_claim_number', 'x_fc_claim_number')
|
||||
if claim_field in self._fields:
|
||||
or_conditions.append((claim_field, '!=', False))
|
||||
|
||||
# Combine with OR - each '|' must be a separate element in the domain list
|
||||
if or_conditions:
|
||||
# Add (n-1) OR operators for n conditions
|
||||
for _ in range(len(or_conditions) - 1):
|
||||
domain.append('|')
|
||||
# Add all conditions
|
||||
for cond in or_conditions:
|
||||
domain.append(cond)
|
||||
|
||||
try:
|
||||
orders = self.search(domain)
|
||||
except Exception as e:
|
||||
_logger.error(f"Error searching for ADP orders: {e}")
|
||||
# Fallback to simpler search
|
||||
orders = self.search([
|
||||
('write_date', '>=', cutoff_date),
|
||||
('invoice_ids', '!=', False),
|
||||
])
|
||||
|
||||
synced_count = 0
|
||||
error_count = 0
|
||||
|
||||
for order in orders:
|
||||
try:
|
||||
# Only sync if it's an ADP sale
|
||||
if order._is_adp_sale() or order.x_fc_claim_number:
|
||||
order._sync_studio_to_fc_fields()
|
||||
order._sync_fields_to_invoices()
|
||||
synced_count += 1
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
_logger.warning(f"Failed to sync order {order.name}: {e}")
|
||||
|
||||
_logger.info(f"Fusion Claims sync complete: {synced_count} orders synced, {error_count} errors")
|
||||
return synced_count
|
||||
|
||||
# ==========================================================================
|
||||
# EMAIL SEND OVERRIDE (Use ADP templates for ADP sales)
|
||||
# ==========================================================================
|
||||
|
||||
@@ -1,660 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""
|
||||
Cross-instance technician task sync.
|
||||
|
||||
Enables two Odoo instances (e.g. Westin and Mobility) that share the same
|
||||
field technicians to see each other's delivery tasks, preventing double-booking.
|
||||
|
||||
Remote tasks appear as read-only "shadow" records in the local calendar.
|
||||
The existing _find_next_available_slot() automatically sees shadow tasks,
|
||||
so collision detection works without changes to the scheduling algorithm.
|
||||
|
||||
Technicians are matched across instances using the x_fc_tech_sync_id field
|
||||
on res.users. Set the same value (e.g. "gordy") on both instances for the
|
||||
same person -- no mapping table needed.
|
||||
"""
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
import requests
|
||||
from datetime import timedelta
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
SYNC_TASK_FIELDS = [
|
||||
'x_fc_sync_uuid', 'name', 'technician_id', 'additional_technician_ids',
|
||||
'task_type', 'status',
|
||||
'scheduled_date', 'time_start', 'time_end', 'duration_hours',
|
||||
'address_street', 'address_street2', 'address_city', 'address_zip',
|
||||
'address_state_id', 'address_buzz_code',
|
||||
'address_lat', 'address_lng', 'priority', 'partner_id', 'partner_phone',
|
||||
'pod_required', 'description',
|
||||
]
|
||||
|
||||
TERMINAL_STATUSES = ('completed', 'cancelled')
|
||||
|
||||
|
||||
class FusionTaskSyncConfig(models.Model):
|
||||
_name = 'fusion.task.sync.config'
|
||||
_description = 'Task Sync Remote Instance'
|
||||
|
||||
name = fields.Char('Instance Name', required=True,
|
||||
help='e.g. Westin Healthcare, Mobility Specialties')
|
||||
instance_id = fields.Char('Instance ID', required=True,
|
||||
help='Short identifier, e.g. westin or mobility')
|
||||
url = fields.Char('Odoo URL', required=True,
|
||||
help='e.g. http://192.168.1.40:8069')
|
||||
database = fields.Char('Database', required=True)
|
||||
username = fields.Char('API Username', required=True)
|
||||
api_key = fields.Char('API Key', required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
last_sync = fields.Datetime('Last Successful Sync', readonly=True)
|
||||
last_sync_error = fields.Text('Last Error', readonly=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON-RPC helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _jsonrpc(self, service, method, args):
|
||||
"""Execute a JSON-RPC call against the remote Odoo instance."""
|
||||
self.ensure_one()
|
||||
url = f"{self.url.rstrip('/')}/jsonrpc"
|
||||
payload = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'call',
|
||||
'id': 1,
|
||||
'params': {
|
||||
'service': service,
|
||||
'method': method,
|
||||
'args': args,
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=15)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get('error'):
|
||||
err = result['error'].get('data', {}).get('message', str(result['error']))
|
||||
raise UserError(f"Remote error: {err}")
|
||||
return result.get('result')
|
||||
except requests.exceptions.ConnectionError:
|
||||
_logger.warning("Task sync: cannot connect to %s", self.url)
|
||||
return None
|
||||
except requests.exceptions.Timeout:
|
||||
_logger.warning("Task sync: timeout connecting to %s", self.url)
|
||||
return None
|
||||
|
||||
def _authenticate(self):
|
||||
"""Authenticate with the remote instance and return the uid."""
|
||||
self.ensure_one()
|
||||
uid = self._jsonrpc('common', 'authenticate',
|
||||
[self.database, self.username, self.api_key, {}])
|
||||
if not uid:
|
||||
_logger.error("Task sync: authentication failed for %s", self.name)
|
||||
return uid
|
||||
|
||||
def _rpc(self, model, method, args, kwargs=None):
|
||||
"""Execute a method on the remote instance via execute_kw.
|
||||
execute_kw(db, uid, password, model, method, [args], {kwargs})
|
||||
"""
|
||||
self.ensure_one()
|
||||
uid = self._authenticate()
|
||||
if not uid:
|
||||
return None
|
||||
call_args = [self.database, uid, self.api_key, model, method, args]
|
||||
if kwargs:
|
||||
call_args.append(kwargs)
|
||||
return self._jsonrpc('object', 'execute_kw', call_args)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tech sync ID helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_local_tech_map(self):
|
||||
"""Build {local_user_id: x_fc_tech_sync_id} for all local field staff."""
|
||||
techs = self.env['res.users'].sudo().search([
|
||||
('x_fc_is_field_staff', '=', True),
|
||||
('x_fc_tech_sync_id', '!=', False),
|
||||
('active', '=', True),
|
||||
])
|
||||
return {u.id: u.x_fc_tech_sync_id for u in techs}
|
||||
|
||||
def _get_remote_tech_map(self):
|
||||
"""Build {x_fc_tech_sync_id: remote_user_id} from the remote instance."""
|
||||
self.ensure_one()
|
||||
remote_users = self._rpc('res.users', 'search_read', [
|
||||
[('x_fc_is_field_staff', '=', True),
|
||||
('x_fc_tech_sync_id', '!=', False),
|
||||
('active', '=', True)],
|
||||
], {'fields': ['id', 'x_fc_tech_sync_id']})
|
||||
if not remote_users:
|
||||
return {}
|
||||
return {
|
||||
ru['x_fc_tech_sync_id']: ru['id']
|
||||
for ru in remote_users
|
||||
if ru.get('x_fc_tech_sync_id')
|
||||
}
|
||||
|
||||
def _get_local_syncid_to_uid(self):
|
||||
"""Build {x_fc_tech_sync_id: local_user_id} for local field staff."""
|
||||
techs = self.env['res.users'].sudo().search([
|
||||
('x_fc_is_field_staff', '=', True),
|
||||
('x_fc_tech_sync_id', '!=', False),
|
||||
('active', '=', True),
|
||||
])
|
||||
return {u.x_fc_tech_sync_id: u.id for u in techs}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection test
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Test the connection to the remote instance."""
|
||||
self.ensure_one()
|
||||
uid = self._authenticate()
|
||||
if uid:
|
||||
remote_map = self._get_remote_tech_map()
|
||||
local_map = self._get_local_tech_map()
|
||||
matched = set(local_map.values()) & set(remote_map.keys())
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Connection Successful',
|
||||
'message': f'Connected to {self.name}. '
|
||||
f'{len(matched)} technician(s) matched by sync ID.',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
raise UserError(f"Cannot connect to {self.name}. Check URL, database, and API key.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUSH: send local task changes to remote instance
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_local_instance_id(self):
|
||||
"""Return this instance's own ID from config parameters."""
|
||||
return self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_claims.sync_instance_id', '')
|
||||
|
||||
@api.model
|
||||
def _push_tasks(self, tasks, operation='create'):
|
||||
"""Push local task changes to all active remote instances.
|
||||
Called from technician_task create/write overrides.
|
||||
Non-blocking: errors are logged, not raised.
|
||||
"""
|
||||
configs = self.sudo().search([('active', '=', True)])
|
||||
if not configs:
|
||||
return
|
||||
local_id = configs[0]._get_local_instance_id()
|
||||
if not local_id:
|
||||
return
|
||||
for config in configs:
|
||||
try:
|
||||
config._push_tasks_to_remote(tasks, operation, local_id)
|
||||
except Exception:
|
||||
_logger.exception("Task sync push to %s failed", config.name)
|
||||
|
||||
def _push_tasks_to_remote(self, tasks, operation, local_instance_id):
|
||||
"""Push task data to a single remote instance.
|
||||
|
||||
Maps additional_technician_ids via sync IDs so the remote instance
|
||||
also blocks those technicians' schedules.
|
||||
"""
|
||||
self.ensure_one()
|
||||
local_map = self._get_local_tech_map()
|
||||
remote_map = self._get_remote_tech_map()
|
||||
if not local_map or not remote_map:
|
||||
return
|
||||
|
||||
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
|
||||
|
||||
for task in tasks:
|
||||
sync_id = local_map.get(task.technician_id.id)
|
||||
if not sync_id:
|
||||
continue
|
||||
remote_tech_uid = remote_map.get(sync_id)
|
||||
if not remote_tech_uid:
|
||||
continue
|
||||
|
||||
# Map additional technicians to remote user IDs
|
||||
remote_additional_ids = []
|
||||
for tech in task.additional_technician_ids:
|
||||
add_sync_id = local_map.get(tech.id)
|
||||
if add_sync_id:
|
||||
remote_add_uid = remote_map.get(add_sync_id)
|
||||
if remote_add_uid:
|
||||
remote_additional_ids.append(remote_add_uid)
|
||||
|
||||
task_data = {
|
||||
'x_fc_sync_uuid': task.x_fc_sync_uuid,
|
||||
'x_fc_sync_source': local_instance_id,
|
||||
'x_fc_sync_remote_id': task.id,
|
||||
'name': f"[{local_instance_id.upper()}] {task.name}",
|
||||
'technician_id': remote_tech_uid,
|
||||
'additional_technician_ids': [(6, 0, remote_additional_ids)],
|
||||
'task_type': task.task_type,
|
||||
'status': task.status,
|
||||
'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False,
|
||||
'time_start': task.time_start,
|
||||
'time_end': task.time_end,
|
||||
'duration_hours': task.duration_hours,
|
||||
'address_street': task.address_street or '',
|
||||
'address_street2': task.address_street2 or '',
|
||||
'address_city': task.address_city or '',
|
||||
'address_zip': task.address_zip or '',
|
||||
'address_lat': float(task.address_lat or 0),
|
||||
'address_lng': float(task.address_lng or 0),
|
||||
'priority': task.priority or 'normal',
|
||||
'x_fc_sync_client_name': task.partner_id.name if task.partner_id else '',
|
||||
}
|
||||
|
||||
existing = self._rpc(
|
||||
'fusion.technician.task', 'search',
|
||||
[[('x_fc_sync_uuid', '=', task.x_fc_sync_uuid)]],
|
||||
{'limit': 1})
|
||||
|
||||
if operation in ('create', 'write'):
|
||||
if existing:
|
||||
self._rpc('fusion.technician.task', 'write',
|
||||
[existing, task_data], ctx)
|
||||
elif operation == 'create':
|
||||
task_data['sale_order_id'] = False
|
||||
self._rpc('fusion.technician.task', 'create',
|
||||
[[task_data]], ctx)
|
||||
|
||||
elif operation == 'unlink' and existing:
|
||||
self._rpc('fusion.technician.task', 'write',
|
||||
[existing, {'status': 'cancelled', 'active': False}], ctx)
|
||||
|
||||
@api.model
|
||||
def _push_shadow_status(self, shadow_tasks):
|
||||
"""Push local status changes on shadow tasks back to their source instance.
|
||||
|
||||
When a tech completes (or cancels) a shadow task locally, update the
|
||||
original task on the remote instance so both sides stay in sync.
|
||||
"""
|
||||
configs = self.sudo().search([('active', '=', True)])
|
||||
config_by_instance = {c.instance_id: c for c in configs}
|
||||
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
|
||||
|
||||
for task in shadow_tasks:
|
||||
config = config_by_instance.get(task.x_fc_sync_source)
|
||||
if not config or not task.x_fc_sync_remote_id:
|
||||
continue
|
||||
try:
|
||||
update_vals = {'status': task.status}
|
||||
if task.status == 'completed' and task.completion_datetime:
|
||||
update_vals['completion_datetime'] = str(task.completion_datetime)
|
||||
config._rpc(
|
||||
'fusion.technician.task', 'write',
|
||||
[[task.x_fc_sync_remote_id], update_vals], ctx)
|
||||
_logger.info(
|
||||
"Pushed status '%s' for shadow task %s back to %s (remote id %d)",
|
||||
task.status, task.name, config.name, task.x_fc_sync_remote_id)
|
||||
if task.status == 'completed':
|
||||
try:
|
||||
config._rpc(
|
||||
'fusion.technician.task',
|
||||
'_notify_scheduler_on_completion',
|
||||
[[task.x_fc_sync_remote_id]])
|
||||
except Exception:
|
||||
_logger.warning(
|
||||
"Could not trigger completion notification on remote for %s",
|
||||
task.name)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Failed to push status for shadow task %s to %s",
|
||||
task.name, config.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PULL: cron-based full reconciliation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _cron_pull_remote_tasks(self):
|
||||
"""Cron job: pull tasks and technician locations from all active remote instances."""
|
||||
configs = self.sudo().search([('active', '=', True)])
|
||||
for config in configs:
|
||||
try:
|
||||
config._pull_tasks_from_remote()
|
||||
config._pull_technician_locations()
|
||||
config.sudo().write({
|
||||
'last_sync': fields.Datetime.now(),
|
||||
'last_sync_error': False,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("Task sync pull from %s failed", config.name)
|
||||
config.sudo().write({'last_sync_error': str(e)})
|
||||
|
||||
def _pull_tasks_from_remote(self):
|
||||
"""Pull all active tasks for matched technicians from the remote instance.
|
||||
|
||||
After syncing, recalculates travel chains for all affected tech+date
|
||||
combos so route planning accounts for both local and shadow tasks.
|
||||
"""
|
||||
self.ensure_one()
|
||||
local_syncid_to_uid = self._get_local_syncid_to_uid()
|
||||
if not local_syncid_to_uid:
|
||||
return
|
||||
|
||||
remote_map = self._get_remote_tech_map()
|
||||
if not remote_map:
|
||||
return
|
||||
|
||||
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
|
||||
if not matched_sync_ids:
|
||||
_logger.info("Task sync: no matched technicians between local and %s", self.name)
|
||||
return
|
||||
|
||||
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
|
||||
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
|
||||
|
||||
cutoff = fields.Date.today() - timedelta(days=7)
|
||||
remote_tasks = self._rpc(
|
||||
'fusion.technician.task', 'search_read',
|
||||
[[
|
||||
'|',
|
||||
('technician_id', 'in', remote_tech_ids),
|
||||
('additional_technician_ids', 'in', remote_tech_ids),
|
||||
('scheduled_date', '>=', str(cutoff)),
|
||||
('x_fc_sync_source', '=', False),
|
||||
]],
|
||||
{'fields': SYNC_TASK_FIELDS + ['id']})
|
||||
|
||||
if remote_tasks is None:
|
||||
return
|
||||
|
||||
Task = self.env['fusion.technician.task'].sudo().with_context(
|
||||
skip_task_sync=True, skip_travel_recalc=True)
|
||||
|
||||
remote_uuids = set()
|
||||
affected_combos = set()
|
||||
|
||||
for rt in remote_tasks:
|
||||
sync_uuid = rt.get('x_fc_sync_uuid')
|
||||
if not sync_uuid:
|
||||
continue
|
||||
remote_uuids.add(sync_uuid)
|
||||
|
||||
remote_tech_raw = rt['technician_id']
|
||||
remote_uid = remote_tech_raw[0] if isinstance(remote_tech_raw, (list, tuple)) else remote_tech_raw
|
||||
tech_sync_id = remote_syncid_by_uid.get(remote_uid)
|
||||
local_uid = local_syncid_to_uid.get(tech_sync_id) if tech_sync_id else None
|
||||
if not local_uid:
|
||||
continue
|
||||
|
||||
partner_raw = rt.get('partner_id')
|
||||
client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
|
||||
client_phone = rt.get('partner_phone', '') or ''
|
||||
|
||||
state_raw = rt.get('address_state_id')
|
||||
state_name = ''
|
||||
if isinstance(state_raw, (list, tuple)) and len(state_raw) > 1:
|
||||
state_name = state_raw[1]
|
||||
|
||||
# Map additional technicians from remote to local
|
||||
local_additional_ids = []
|
||||
remote_add_raw = rt.get('additional_technician_ids', [])
|
||||
if remote_add_raw and isinstance(remote_add_raw, list):
|
||||
for add_uid in remote_add_raw:
|
||||
add_sync_id = remote_syncid_by_uid.get(add_uid)
|
||||
if add_sync_id:
|
||||
local_add_uid = local_syncid_to_uid.get(add_sync_id)
|
||||
if local_add_uid:
|
||||
local_additional_ids.append(local_add_uid)
|
||||
|
||||
sched_date = rt.get('scheduled_date')
|
||||
|
||||
vals = {
|
||||
'x_fc_sync_uuid': sync_uuid,
|
||||
'x_fc_sync_source': self.instance_id,
|
||||
'x_fc_sync_remote_id': rt['id'],
|
||||
'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}",
|
||||
'technician_id': local_uid,
|
||||
'additional_technician_ids': [(6, 0, local_additional_ids)],
|
||||
'task_type': rt.get('task_type', 'delivery'),
|
||||
'status': rt.get('status', 'scheduled'),
|
||||
'scheduled_date': sched_date,
|
||||
'time_start': rt.get('time_start', 9.0),
|
||||
'time_end': rt.get('time_end', 10.0),
|
||||
'duration_hours': rt.get('duration_hours', 1.0),
|
||||
'address_street': rt.get('address_street', ''),
|
||||
'address_street2': rt.get('address_street2', ''),
|
||||
'address_city': rt.get('address_city', ''),
|
||||
'address_zip': rt.get('address_zip', ''),
|
||||
'address_buzz_code': rt.get('address_buzz_code', ''),
|
||||
'address_lat': rt.get('address_lat', 0),
|
||||
'address_lng': rt.get('address_lng', 0),
|
||||
'priority': rt.get('priority', 'normal'),
|
||||
'pod_required': rt.get('pod_required', False),
|
||||
'description': rt.get('description', ''),
|
||||
'x_fc_sync_client_name': client_name,
|
||||
'x_fc_sync_client_phone': client_phone,
|
||||
}
|
||||
|
||||
if state_name:
|
||||
state_rec = self.env['res.country.state'].sudo().search(
|
||||
[('name', '=', state_name)], limit=1)
|
||||
if state_rec:
|
||||
vals['address_state_id'] = state_rec.id
|
||||
|
||||
existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1)
|
||||
if existing:
|
||||
if existing.status in TERMINAL_STATUSES:
|
||||
vals.pop('status', None)
|
||||
existing.write(vals)
|
||||
else:
|
||||
vals['sale_order_id'] = False
|
||||
Task.create([vals])
|
||||
|
||||
if sched_date:
|
||||
affected_combos.add((local_uid, sched_date))
|
||||
for add_uid in local_additional_ids:
|
||||
affected_combos.add((add_uid, sched_date))
|
||||
|
||||
stale_shadows = Task.search([
|
||||
('x_fc_sync_source', '=', self.instance_id),
|
||||
('x_fc_sync_uuid', 'not in', list(remote_uuids)),
|
||||
('scheduled_date', '>=', str(cutoff)),
|
||||
('active', '=', True),
|
||||
])
|
||||
if stale_shadows:
|
||||
for st in stale_shadows:
|
||||
if st.scheduled_date and st.technician_id:
|
||||
affected_combos.add((st.technician_id.id, st.scheduled_date))
|
||||
for tech in st.additional_technician_ids:
|
||||
if st.scheduled_date:
|
||||
affected_combos.add((tech.id, st.scheduled_date))
|
||||
stale_shadows.write({'active': False, 'status': 'cancelled'})
|
||||
_logger.info("Deactivated %d stale shadow tasks from %s",
|
||||
len(stale_shadows), self.instance_id)
|
||||
|
||||
if affected_combos:
|
||||
today = fields.Date.today()
|
||||
today_str = str(today)
|
||||
future_combos = set()
|
||||
for tid, d in affected_combos:
|
||||
if not d:
|
||||
continue
|
||||
d_str = str(d) if not isinstance(d, str) else d
|
||||
if d_str >= today_str:
|
||||
future_combos.add((tid, d_str))
|
||||
if future_combos:
|
||||
TaskModel = self.env['fusion.technician.task'].sudo()
|
||||
try:
|
||||
ungeocode = TaskModel.search([
|
||||
('x_fc_sync_source', '=', self.instance_id),
|
||||
('active', '=', True),
|
||||
('scheduled_date', '>=', today_str),
|
||||
('status', 'not in', ['cancelled']),
|
||||
'|',
|
||||
('address_lat', '=', 0), ('address_lat', '=', False),
|
||||
])
|
||||
geocoded = 0
|
||||
for shadow in ungeocode:
|
||||
if shadow.address_display:
|
||||
if shadow.with_context(skip_travel_recalc=True)._geocode_address():
|
||||
geocoded += 1
|
||||
if geocoded:
|
||||
_logger.info("Geocoded %d shadow tasks from %s",
|
||||
geocoded, self.name)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Shadow task geocoding after sync from %s failed", self.name)
|
||||
|
||||
try:
|
||||
TaskModel._recalculate_combos_travel(future_combos)
|
||||
_logger.info(
|
||||
"Recalculated travel for %d tech+date combos after sync from %s",
|
||||
len(future_combos), self.name)
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Travel recalculation after sync from %s failed", self.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PULL: technician locations from remote instance
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _pull_technician_locations(self):
|
||||
"""Pull latest GPS locations for matched technicians from the remote instance.
|
||||
|
||||
Creates local location records with source='sync' so the map view
|
||||
shows technician positions from both instances. Only keeps the single
|
||||
most recent synced location per technician (replaces older synced
|
||||
records to avoid clutter).
|
||||
"""
|
||||
self.ensure_one()
|
||||
local_syncid_to_uid = self._get_local_syncid_to_uid()
|
||||
if not local_syncid_to_uid:
|
||||
return
|
||||
|
||||
remote_map = self._get_remote_tech_map()
|
||||
if not remote_map:
|
||||
return
|
||||
|
||||
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
|
||||
if not matched_sync_ids:
|
||||
return
|
||||
|
||||
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
|
||||
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
|
||||
|
||||
remote_locations = self._rpc(
|
||||
'fusion.technician.location', 'search_read',
|
||||
[[
|
||||
('user_id', 'in', remote_tech_ids),
|
||||
('logged_at', '>', str(fields.Datetime.subtract(
|
||||
fields.Datetime.now(), hours=24))),
|
||||
('source', '!=', 'sync'),
|
||||
]],
|
||||
{
|
||||
'fields': ['user_id', 'latitude', 'longitude',
|
||||
'accuracy', 'logged_at'],
|
||||
'order': 'logged_at desc',
|
||||
})
|
||||
|
||||
if not remote_locations:
|
||||
return
|
||||
|
||||
Location = self.env['fusion.technician.location'].sudo()
|
||||
|
||||
seen_techs = set()
|
||||
synced_count = 0
|
||||
for rloc in remote_locations:
|
||||
remote_uid_raw = rloc['user_id']
|
||||
remote_uid = (remote_uid_raw[0]
|
||||
if isinstance(remote_uid_raw, (list, tuple))
|
||||
else remote_uid_raw)
|
||||
if remote_uid in seen_techs:
|
||||
continue
|
||||
seen_techs.add(remote_uid)
|
||||
|
||||
sync_id = remote_syncid_by_uid.get(remote_uid)
|
||||
local_uid = local_syncid_to_uid.get(sync_id) if sync_id else None
|
||||
if not local_uid:
|
||||
continue
|
||||
|
||||
lat = rloc.get('latitude', 0)
|
||||
lng = rloc.get('longitude', 0)
|
||||
if not lat or not lng:
|
||||
continue
|
||||
|
||||
old_synced = Location.search([
|
||||
('user_id', '=', local_uid),
|
||||
('source', '=', 'sync'),
|
||||
('sync_instance', '=', self.instance_id),
|
||||
])
|
||||
if old_synced:
|
||||
old_synced.unlink()
|
||||
|
||||
Location.create({
|
||||
'user_id': local_uid,
|
||||
'latitude': lat,
|
||||
'longitude': lng,
|
||||
'accuracy': rloc.get('accuracy', 0),
|
||||
'logged_at': rloc.get('logged_at', fields.Datetime.now()),
|
||||
'source': 'sync',
|
||||
'sync_instance': self.instance_id,
|
||||
})
|
||||
synced_count += 1
|
||||
|
||||
if synced_count:
|
||||
_logger.info("Synced %d technician location(s) from %s",
|
||||
synced_count, self.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CLEANUP
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _cron_cleanup_old_shadows(self):
|
||||
"""Remove shadow tasks older than 30 days (completed/cancelled)."""
|
||||
cutoff = fields.Date.today() - timedelta(days=30)
|
||||
old_shadows = self.env['fusion.technician.task'].sudo().search([
|
||||
('x_fc_sync_source', '!=', False),
|
||||
('scheduled_date', '<', str(cutoff)),
|
||||
('status', 'in', ['completed', 'cancelled']),
|
||||
])
|
||||
if old_shadows:
|
||||
count = len(old_shadows)
|
||||
old_shadows.unlink()
|
||||
_logger.info("Cleaned up %d old shadow tasks", count)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Manual trigger
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_sync_now(self):
|
||||
"""Manually trigger a full sync for this config."""
|
||||
self.ensure_one()
|
||||
self._pull_tasks_from_remote()
|
||||
self._pull_technician_locations()
|
||||
self.sudo().write({
|
||||
'last_sync': fields.Datetime.now(),
|
||||
'last_sync_error': False,
|
||||
})
|
||||
shadow_count = self.env['fusion.technician.task'].sudo().search_count([
|
||||
('x_fc_sync_source', '=', self.instance_id),
|
||||
])
|
||||
loc_count = self.env['fusion.technician.location'].sudo().search_count([
|
||||
('source', '=', 'sync'),
|
||||
('sync_instance', '=', self.instance_id),
|
||||
])
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Sync Complete',
|
||||
'message': (f'Synced from {self.name}. '
|
||||
f'{shadow_count} shadow task(s), '
|
||||
f'{loc_count} technician location(s) visible.'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""
|
||||
Fusion Technician Location
|
||||
GPS location logging for field technicians.
|
||||
"""
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionTechnicianLocation(models.Model):
|
||||
_name = 'fusion.technician.location'
|
||||
_description = 'Technician Location Log'
|
||||
_order = 'logged_at desc'
|
||||
|
||||
user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Technician',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
latitude = fields.Float(
|
||||
string='Latitude',
|
||||
digits=(10, 7),
|
||||
required=True,
|
||||
)
|
||||
longitude = fields.Float(
|
||||
string='Longitude',
|
||||
digits=(10, 7),
|
||||
required=True,
|
||||
)
|
||||
accuracy = fields.Float(
|
||||
string='Accuracy (m)',
|
||||
help='GPS accuracy in meters',
|
||||
)
|
||||
logged_at = fields.Datetime(
|
||||
string='Logged At',
|
||||
default=fields.Datetime.now,
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
source = fields.Selection([
|
||||
('portal', 'Portal'),
|
||||
('app', 'Mobile App'),
|
||||
('sync', 'Synced'),
|
||||
], string='Source', default='portal')
|
||||
sync_instance = fields.Char(
|
||||
'Sync Instance', index=True,
|
||||
help='Source instance ID if synced (e.g. westin, mobility)',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def log_location(self, latitude, longitude, accuracy=None):
|
||||
"""Log the current user's location. Called from portal JS."""
|
||||
return self.sudo().create({
|
||||
'user_id': self.env.user.id,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'accuracy': accuracy or 0,
|
||||
'source': 'portal',
|
||||
})
|
||||
|
||||
@api.model
|
||||
def get_latest_locations(self):
|
||||
"""Get the most recent location for each technician (for map view).
|
||||
|
||||
Includes both local GPS pings and synced locations from remote
|
||||
instances, so the map shows all shared technicians regardless of
|
||||
which Odoo instance they are clocked into.
|
||||
"""
|
||||
self.env.cr.execute("""
|
||||
SELECT DISTINCT ON (user_id)
|
||||
user_id, latitude, longitude, accuracy, logged_at,
|
||||
COALESCE(sync_instance, '') AS sync_instance
|
||||
FROM fusion_technician_location
|
||||
WHERE logged_at > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY user_id, logged_at DESC
|
||||
""")
|
||||
rows = self.env.cr.dictfetchall()
|
||||
local_id = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_claims.sync_instance_id', '')
|
||||
result = []
|
||||
for row in rows:
|
||||
user = self.env['res.users'].sudo().browse(row['user_id'])
|
||||
src = row.get('sync_instance') or local_id
|
||||
result.append({
|
||||
'user_id': row['user_id'],
|
||||
'name': user.name,
|
||||
'latitude': row['latitude'],
|
||||
'longitude': row['longitude'],
|
||||
'accuracy': row['accuracy'],
|
||||
'logged_at': str(row['logged_at']),
|
||||
'sync_instance': src,
|
||||
})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _cron_cleanup_old_locations(self):
|
||||
"""Remove location logs based on configurable retention setting.
|
||||
|
||||
Setting (fusion_claims.location_retention_days):
|
||||
- Empty / not set => keep 30 days (default)
|
||||
- "0" => delete at end of day (keep today only)
|
||||
- "1" .. "N" => keep for N days
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
raw = (ICP.get_param('fusion_claims.location_retention_days') or '').strip()
|
||||
|
||||
if raw == '':
|
||||
retention_days = 30 # default: 1 month
|
||||
else:
|
||||
try:
|
||||
retention_days = max(int(raw), 0)
|
||||
except (ValueError, TypeError):
|
||||
retention_days = 30
|
||||
|
||||
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=retention_days)
|
||||
old_records = self.search([('logged_at', '<', cutoff)])
|
||||
count = len(old_records)
|
||||
if count:
|
||||
old_records.unlink()
|
||||
_logger.info(
|
||||
"Cleaned up %d technician location records (retention=%d days)",
|
||||
count, retention_days,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user