feat: separate fusion field service and LTC into standalone modules, update core modules
- fusion_claims: separated field service logic, updated controllers/views - fusion_tasks: updated task views and map integration - fusion_authorizer_portal: added page 11 signing, schedule booking, migrations - fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator) - fusion_ltc_management: new standalone LTC management module
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Claim Assistant product family.
|
||||
|
||||
from . import email_builder_mixin
|
||||
from . import adp_posting_schedule
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
@@ -27,12 +26,5 @@ from . import client_chat
|
||||
from . import ai_agent_ext
|
||||
from . import dashboard
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
from . import technician_task
|
||||
from . import task_sync
|
||||
from . import technician_location
|
||||
from . import push_subscription
|
||||
from . import ltc_facility
|
||||
from . import ltc_repair
|
||||
from . import ltc_cleanup
|
||||
from . import ltc_form_submission
|
||||
from . import page11_sign_request
|
||||
@@ -105,9 +105,11 @@ class AccountMove(models.Model):
|
||||
try:
|
||||
report = self.env.ref('fusion_claims.action_report_mod_invoice')
|
||||
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
|
||||
client_name = (so.partner_id.name or 'Client').replace(' ', '_').replace(',', '')
|
||||
name_parts = (so.partner_id.name or 'Client').strip().split()
|
||||
first = name_parts[0] if name_parts else 'Client'
|
||||
last = name_parts[-1] if len(name_parts) > 1 else ''
|
||||
att = Attachment.create({
|
||||
'name': f'Invoice - {client_name} - {self.name}.pdf',
|
||||
'name': f'{first}_{last}_MOD_Invoice_{self.name}.pdf',
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(pdf_content),
|
||||
'res_model': 'account.move',
|
||||
|
||||
@@ -4,74 +4,16 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
PREV_FUNDED_FIELDS = {
|
||||
'prev_funded_forearm': 'Forearm Crutches',
|
||||
'prev_funded_wheeled': 'Wheeled Walker',
|
||||
'prev_funded_manual': 'Manual Wheelchair',
|
||||
'prev_funded_power': 'Power Wheelchair',
|
||||
'prev_funded_addon': 'Power Add-On Device',
|
||||
'prev_funded_scooter': 'Power Scooter',
|
||||
'prev_funded_seating': 'Positioning Devices',
|
||||
'prev_funded_tilt': 'Power Tilt System',
|
||||
'prev_funded_recline': 'Power Recline System',
|
||||
'prev_funded_legrests': 'Power Elevating Leg Rests',
|
||||
'prev_funded_frame': 'Paediatric Standing Frame',
|
||||
'prev_funded_stroller': 'Paediatric Specialty Stroller',
|
||||
}
|
||||
|
||||
STATUS_NEXT_STEPS = {
|
||||
'quotation': 'Schedule assessment with the client',
|
||||
'assessment_scheduled': 'Complete the assessment',
|
||||
'assessment_completed': 'Prepare and send ADP application to client',
|
||||
'waiting_for_application': 'Follow up with client to return signed application',
|
||||
'application_received': 'Review application and prepare for submission',
|
||||
'ready_submission': 'Submit application to ADP',
|
||||
'submitted': 'Wait for ADP acceptance (typically within 24 hours)',
|
||||
'accepted': 'Wait for ADP approval decision',
|
||||
'rejected': 'Review rejection reason and correct the application',
|
||||
'resubmitted': 'Wait for ADP acceptance of resubmission',
|
||||
'needs_correction': 'Review and correct the application per ADP feedback',
|
||||
'approved': 'Prepare order for delivery',
|
||||
'approved_deduction': 'Prepare order for delivery (note: approved with deduction)',
|
||||
'ready_delivery': 'Schedule and complete delivery to client',
|
||||
'ready_bill': 'Create and submit ADP invoice',
|
||||
'billed': 'Monitor for ADP payment',
|
||||
'case_closed': 'No further action required',
|
||||
'on_hold': 'Check hold reason and follow up when ready to resume',
|
||||
'denied': 'Review denial reason; consider appeal or alternative funding',
|
||||
'withdrawn': 'No further action unless client wants to reinstate',
|
||||
'cancelled': 'No further action required',
|
||||
'expired': 'Contact client about reapplication if still needed',
|
||||
}
|
||||
|
||||
BASE_DEVICE_LABELS = {
|
||||
'adultWalkertype1': 'Adult Walker Type 1',
|
||||
'adultWalkertype2': 'Adult Walker Type 2',
|
||||
'adultWalkertype3': 'Adult Walker Type 3',
|
||||
'adultlightwtStdwheelchair': 'Lightweight Standard Wheelchair',
|
||||
'adultlightwtPermwheelchair': 'Lightweight Permanent Wheelchair',
|
||||
'adultTiltwheelchair': 'Adult Tilt Wheelchair',
|
||||
'adultStdwheelchair': 'Adult Standard Wheelchair',
|
||||
'adultHighperfwheelchair': 'Adult High Performance Wheelchair',
|
||||
'adultType2': 'Adult Power Type 2',
|
||||
'adultType3': 'Adult Power Type 3',
|
||||
'powerScooter': 'Power Scooter',
|
||||
}
|
||||
|
||||
|
||||
class AIAgentFusionClaims(models.Model):
|
||||
"""Extend ai.agent with Fusion Claims tool methods."""
|
||||
_inherit = 'ai.agent'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool 1: Search Client Profiles
|
||||
# ------------------------------------------------------------------
|
||||
def _fc_tool_search_clients(self, search_term=None, city_filter=None, condition_filter=None):
|
||||
"""AI Tool: Search client profiles."""
|
||||
Profile = self.env['fusion.client.profile'].sudo()
|
||||
@@ -104,37 +46,20 @@ class AIAgentFusionClaims(models.Model):
|
||||
})
|
||||
return json.dumps({'count': len(results), 'profiles': results})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool 2: Get Client Details (enriched with funding history)
|
||||
# ------------------------------------------------------------------
|
||||
def _fc_tool_client_details(self, profile_id):
|
||||
"""AI Tool: Get detailed client information with funding history."""
|
||||
"""AI Tool: Get detailed client information."""
|
||||
Profile = self.env['fusion.client.profile'].sudo()
|
||||
profile = Profile.browse(int(profile_id))
|
||||
if not profile.exists():
|
||||
return json.dumps({'error': 'Profile not found'})
|
||||
|
||||
# Get orders
|
||||
orders = []
|
||||
if profile.partner_id:
|
||||
Invoice = self.env['account.move'].sudo()
|
||||
for o in self.env['sale.order'].sudo().search([
|
||||
('partner_id', '=', profile.partner_id.id),
|
||||
('x_fc_sale_type', '!=', False),
|
||||
], limit=20, order='date_order desc'):
|
||||
invoices = Invoice.search([
|
||||
('x_fc_source_sale_order_id', '=', o.id),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
])
|
||||
inv_summary = []
|
||||
for inv in invoices:
|
||||
inv_summary.append({
|
||||
'number': inv.name or '',
|
||||
'portion': inv.x_fc_adp_invoice_portion or 'full',
|
||||
'amount': float(inv.amount_total),
|
||||
'paid': inv.payment_state in ('paid', 'in_payment'),
|
||||
'date': str(inv.invoice_date) if inv.invoice_date else '',
|
||||
})
|
||||
|
||||
], limit=20):
|
||||
orders.append({
|
||||
'name': o.name,
|
||||
'sale_type': o.x_fc_sale_type,
|
||||
@@ -143,19 +68,11 @@ class AIAgentFusionClaims(models.Model):
|
||||
'client_total': float(o.x_fc_client_portion_total),
|
||||
'total': float(o.amount_total),
|
||||
'date': str(o.date_order.date()) if o.date_order else '',
|
||||
'billing_date': str(o.x_fc_billing_date) if o.x_fc_billing_date else '',
|
||||
'previous_funding_date': str(o.x_fc_previous_funding_date) if o.x_fc_previous_funding_date else '',
|
||||
'funding_warning': o.x_fc_funding_warning_message or '',
|
||||
'funding_warning_level': o.x_fc_funding_warning_level or '',
|
||||
'invoices': inv_summary,
|
||||
})
|
||||
|
||||
# Get applications
|
||||
apps = []
|
||||
for a in profile.application_data_ids[:10]:
|
||||
funded_devices = [
|
||||
label for field, label in PREV_FUNDED_FIELDS.items()
|
||||
if getattr(a, field, False)
|
||||
]
|
||||
apps.append({
|
||||
'date': str(a.application_date) if a.application_date else '',
|
||||
'device': a.base_device or '',
|
||||
@@ -163,17 +80,8 @@ class AIAgentFusionClaims(models.Model):
|
||||
'reason': a.reason_for_application or '',
|
||||
'condition': (a.medical_condition or '')[:100],
|
||||
'authorizer': f'{a.authorizer_first_name or ""} {a.authorizer_last_name or ""}'.strip(),
|
||||
'previously_funded': funded_devices if funded_devices else ['None'],
|
||||
})
|
||||
|
||||
ai_summary = ''
|
||||
ai_risk = ''
|
||||
try:
|
||||
ai_summary = profile.ai_summary or ''
|
||||
ai_risk = profile.ai_risk_flags or ''
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return json.dumps({
|
||||
'profile': {
|
||||
'id': profile.id,
|
||||
@@ -198,18 +106,12 @@ class AIAgentFusionClaims(models.Model):
|
||||
'total_adp': float(profile.total_adp_funded),
|
||||
'total_client': float(profile.total_client_portion),
|
||||
'total_amount': float(profile.total_amount),
|
||||
'applications_count': profile.application_count,
|
||||
'last_assessment': str(profile.last_assessment_date) if profile.last_assessment_date else '',
|
||||
'ai_summary': ai_summary,
|
||||
'ai_risk_flags': ai_risk,
|
||||
},
|
||||
'orders': orders,
|
||||
'applications': apps,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool 3: Get Aggregated Stats (migrated from read_group)
|
||||
# ------------------------------------------------------------------
|
||||
def _fc_tool_claims_stats(self):
|
||||
"""AI Tool: Get aggregated claims statistics."""
|
||||
SO = self.env['sale.order'].sudo()
|
||||
@@ -218,46 +120,40 @@ class AIAgentFusionClaims(models.Model):
|
||||
total_profiles = Profile.search_count([])
|
||||
total_orders = SO.search_count([('x_fc_sale_type', '!=', False)])
|
||||
|
||||
# By sale type
|
||||
type_data = SO.read_group(
|
||||
[('x_fc_sale_type', '!=', False)],
|
||||
['x_fc_sale_type', 'amount_total:sum'],
|
||||
['x_fc_sale_type'],
|
||||
)
|
||||
by_type = {}
|
||||
try:
|
||||
type_results = SO._read_group(
|
||||
[('x_fc_sale_type', '!=', False)],
|
||||
groupby=['x_fc_sale_type'],
|
||||
aggregates=['__count', 'amount_total:sum'],
|
||||
)
|
||||
for sale_type, count, total_amount in type_results:
|
||||
by_type[sale_type or 'unknown'] = {
|
||||
'count': count,
|
||||
'total': float(total_amount or 0),
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.warning('Stats by_type failed: %s', e)
|
||||
for r in type_data:
|
||||
by_type[r['x_fc_sale_type']] = {
|
||||
'count': r['x_fc_sale_type_count'],
|
||||
'total': float(r['amount_total'] or 0),
|
||||
}
|
||||
|
||||
# By status
|
||||
status_data = SO.read_group(
|
||||
[('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)],
|
||||
['x_fc_adp_application_status'],
|
||||
['x_fc_adp_application_status'],
|
||||
)
|
||||
by_status = {}
|
||||
try:
|
||||
status_results = SO._read_group(
|
||||
[('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)],
|
||||
groupby=['x_fc_adp_application_status'],
|
||||
aggregates=['__count'],
|
||||
)
|
||||
for status, count in status_results:
|
||||
by_status[status or 'unknown'] = count
|
||||
except Exception as e:
|
||||
_logger.warning('Stats by_status failed: %s', e)
|
||||
for r in status_data:
|
||||
by_status[r['x_fc_adp_application_status']] = r['x_fc_adp_application_status_count']
|
||||
|
||||
# By city (top 10)
|
||||
city_data = Profile.read_group(
|
||||
[('city', '!=', False)],
|
||||
['city'],
|
||||
['city'],
|
||||
limit=10,
|
||||
orderby='city_count desc',
|
||||
)
|
||||
by_city = {}
|
||||
try:
|
||||
city_results = Profile._read_group(
|
||||
[('city', '!=', False)],
|
||||
groupby=['city'],
|
||||
aggregates=['__count'],
|
||||
limit=10,
|
||||
order='__count desc',
|
||||
)
|
||||
for city, count in city_results:
|
||||
by_city[city or 'unknown'] = count
|
||||
except Exception as e:
|
||||
_logger.warning('Stats by_city failed: %s', e)
|
||||
for r in city_data:
|
||||
by_city[r['city']] = r['city_count']
|
||||
|
||||
return json.dumps({
|
||||
'total_profiles': total_profiles,
|
||||
@@ -266,405 +162,3 @@ class AIAgentFusionClaims(models.Model):
|
||||
'by_status': by_status,
|
||||
'top_cities': by_city,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool 4: Client Status Lookup (by name, not order number)
|
||||
# ------------------------------------------------------------------
|
||||
def _fc_tool_client_status(self, client_name):
|
||||
"""AI Tool: Look up a client's complete status by name."""
|
||||
if not client_name or not client_name.strip():
|
||||
return json.dumps({'error': 'Please provide a client name to search for'})
|
||||
|
||||
client_name = client_name.strip()
|
||||
Profile = self.env['fusion.client.profile'].sudo()
|
||||
SO = self.env['sale.order'].sudo()
|
||||
Invoice = self.env['account.move'].sudo()
|
||||
|
||||
profiles = Profile.search([
|
||||
'|',
|
||||
('first_name', 'ilike', client_name),
|
||||
('last_name', 'ilike', client_name),
|
||||
], limit=5)
|
||||
|
||||
if not profiles:
|
||||
partners = self.env['res.partner'].sudo().search([
|
||||
('name', 'ilike', client_name),
|
||||
], limit=5)
|
||||
if not partners:
|
||||
return json.dumps({'error': f'No client found matching "{client_name}"'})
|
||||
|
||||
results = []
|
||||
for partner in partners:
|
||||
orders = SO.search([
|
||||
('partner_id', '=', partner.id),
|
||||
('x_fc_sale_type', '!=', False),
|
||||
], order='date_order desc', limit=20)
|
||||
if not orders:
|
||||
continue
|
||||
results.append(self._build_client_status_result(
|
||||
partner_name=partner.name,
|
||||
partner_id=partner.id,
|
||||
profile=None,
|
||||
orders=orders,
|
||||
Invoice=Invoice,
|
||||
))
|
||||
if not results:
|
||||
return json.dumps({'error': f'No orders found for "{client_name}"'})
|
||||
return json.dumps({'clients': results})
|
||||
|
||||
results = []
|
||||
for profile in profiles:
|
||||
orders = SO.search([
|
||||
('partner_id', '=', profile.partner_id.id),
|
||||
('x_fc_sale_type', '!=', False),
|
||||
], order='date_order desc', limit=20) if profile.partner_id else SO
|
||||
results.append(self._build_client_status_result(
|
||||
partner_name=profile.display_name,
|
||||
partner_id=profile.partner_id.id if profile.partner_id else None,
|
||||
profile=profile,
|
||||
orders=orders if profile.partner_id else SO.browse(),
|
||||
Invoice=Invoice,
|
||||
))
|
||||
|
||||
return json.dumps({'clients': results})
|
||||
|
||||
def _build_client_status_result(self, partner_name, partner_id, profile, orders, Invoice):
|
||||
"""Build a complete status result for one client."""
|
||||
order_data = []
|
||||
for o in orders:
|
||||
invoices = Invoice.search([
|
||||
('x_fc_source_sale_order_id', '=', o.id),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
])
|
||||
adp_inv = invoices.filtered(lambda i: i.x_fc_adp_invoice_portion == 'adp')
|
||||
client_inv = invoices.filtered(lambda i: i.x_fc_adp_invoice_portion == 'client')
|
||||
|
||||
docs = {
|
||||
'original_application': bool(o.x_fc_original_application),
|
||||
'signed_pages': bool(o.x_fc_signed_pages_11_12),
|
||||
'xml_file': bool(o.x_fc_xml_file),
|
||||
'proof_of_delivery': bool(o.x_fc_proof_of_delivery),
|
||||
}
|
||||
|
||||
status = o.x_fc_adp_application_status or ''
|
||||
next_step = STATUS_NEXT_STEPS.get(status, '')
|
||||
|
||||
order_data.append({
|
||||
'order': o.name,
|
||||
'sale_type': o.x_fc_sale_type,
|
||||
'status': status,
|
||||
'date': str(o.date_order.date()) if o.date_order else '',
|
||||
'total': float(o.amount_total),
|
||||
'adp_total': float(o.x_fc_adp_portion_total),
|
||||
'client_total': float(o.x_fc_client_portion_total),
|
||||
'billing_date': str(o.x_fc_billing_date) if o.x_fc_billing_date else '',
|
||||
'funding_warning': o.x_fc_funding_warning_message or '',
|
||||
'adp_invoice': {
|
||||
'number': adp_inv[0].name if adp_inv else '',
|
||||
'amount': float(adp_inv[0].amount_total) if adp_inv else 0,
|
||||
'paid': adp_inv[0].payment_state in ('paid', 'in_payment') if adp_inv else False,
|
||||
} if adp_inv else None,
|
||||
'client_invoice': {
|
||||
'number': client_inv[0].name if client_inv else '',
|
||||
'amount': float(client_inv[0].amount_total) if client_inv else 0,
|
||||
'paid': client_inv[0].payment_state in ('paid', 'in_payment') if client_inv else False,
|
||||
} if client_inv else None,
|
||||
'documents': docs,
|
||||
'next_step': next_step,
|
||||
})
|
||||
|
||||
result = {
|
||||
'name': partner_name,
|
||||
'partner_id': partner_id,
|
||||
'orders': order_data,
|
||||
'order_count': len(order_data),
|
||||
}
|
||||
|
||||
if profile:
|
||||
result['profile_id'] = profile.id
|
||||
result['health_card'] = profile.health_card_number or ''
|
||||
result['city'] = profile.city or ''
|
||||
result['condition'] = (profile.medical_condition or '')[:100]
|
||||
result['total_adp_funded'] = float(profile.total_adp_funded)
|
||||
result['total_client_funded'] = float(profile.total_client_portion)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool 5: ADP Billing Period Summary
|
||||
# ------------------------------------------------------------------
|
||||
def _fc_tool_adp_billing_period(self, period=None):
|
||||
"""AI Tool: Get ADP billing summary for a posting period."""
|
||||
Mixin = self.env['fusion_claims.adp.posting.schedule.mixin']
|
||||
Invoice = self.env['account.move'].sudo()
|
||||
|
||||
today = date.today()
|
||||
frequency = Mixin._get_adp_posting_frequency()
|
||||
|
||||
if not period or period == 'current':
|
||||
posting_date = Mixin._get_current_posting_date(today)
|
||||
elif period == 'previous':
|
||||
current = Mixin._get_current_posting_date(today)
|
||||
posting_date = current - timedelta(days=frequency)
|
||||
elif period == 'next':
|
||||
posting_date = Mixin._get_next_posting_date(today)
|
||||
else:
|
||||
try:
|
||||
ref_date = date.fromisoformat(period)
|
||||
posting_date = Mixin._get_current_posting_date(ref_date)
|
||||
except (ValueError, TypeError):
|
||||
return json.dumps({'error': f'Invalid period: "{period}". Use "current", "previous", "next", or a date (YYYY-MM-DD).'})
|
||||
|
||||
period_start = posting_date
|
||||
period_end = posting_date + timedelta(days=frequency - 1)
|
||||
submission_deadline = Mixin._get_posting_week_wednesday(posting_date)
|
||||
expected_payment = Mixin._get_expected_payment_date(posting_date)
|
||||
|
||||
adp_invoices = Invoice.search([
|
||||
('x_fc_adp_invoice_portion', '=', 'adp'),
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('invoice_date', '>=', str(period_start)),
|
||||
('invoice_date', '<=', str(period_end)),
|
||||
])
|
||||
|
||||
total_invoiced = sum(adp_invoices.mapped('amount_total'))
|
||||
total_paid = sum(adp_invoices.filtered(
|
||||
lambda i: i.payment_state in ('paid', 'in_payment')
|
||||
).mapped('amount_total'))
|
||||
total_unpaid = total_invoiced - total_paid
|
||||
|
||||
source_orders = adp_invoices.mapped('x_fc_source_sale_order_id')
|
||||
|
||||
invoice_details = []
|
||||
for inv in adp_invoices[:25]:
|
||||
so = inv.x_fc_source_sale_order_id
|
||||
invoice_details.append({
|
||||
'invoice': inv.name or '',
|
||||
'order': so.name if so else '',
|
||||
'client': inv.partner_id.name or '',
|
||||
'amount': float(inv.amount_total),
|
||||
'paid': inv.payment_state in ('paid', 'in_payment'),
|
||||
'date': str(inv.invoice_date) if inv.invoice_date else '',
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
'period': {
|
||||
'posting_date': str(posting_date),
|
||||
'start': str(period_start),
|
||||
'end': str(period_end),
|
||||
'submission_deadline': f'{submission_deadline.strftime("%A, %B %d, %Y")} 6:00 PM',
|
||||
'expected_payment_date': str(expected_payment),
|
||||
},
|
||||
'summary': {
|
||||
'total_invoices': len(adp_invoices),
|
||||
'total_invoiced': float(total_invoiced),
|
||||
'total_paid': float(total_paid),
|
||||
'total_unpaid': float(total_unpaid),
|
||||
'orders_billed': len(source_orders),
|
||||
},
|
||||
'invoices': invoice_details,
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool 6: Demographics & Analytics
|
||||
# ------------------------------------------------------------------
|
||||
def _fc_tool_demographics(self, analysis_type=None, city_filter=None, sale_type_filter=None):
|
||||
"""AI Tool: Run demographic and analytical queries on client data."""
|
||||
cr = self.env.cr
|
||||
results = {}
|
||||
|
||||
if not analysis_type:
|
||||
analysis_type = 'full'
|
||||
|
||||
if analysis_type in ('full', 'age_groups'):
|
||||
cr.execute("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN age < 18 THEN 'Under 18'
|
||||
WHEN age BETWEEN 18 AND 30 THEN '18-30'
|
||||
WHEN age BETWEEN 31 AND 45 THEN '31-45'
|
||||
WHEN age BETWEEN 46 AND 60 THEN '46-60'
|
||||
WHEN age BETWEEN 61 AND 75 THEN '61-75'
|
||||
ELSE '75+'
|
||||
END AS age_group,
|
||||
COUNT(DISTINCT p.id) AS clients,
|
||||
COUNT(app.id) AS applications,
|
||||
ROUND(COUNT(app.id)::numeric / NULLIF(COUNT(DISTINCT p.id), 0), 2) AS avg_applications,
|
||||
COALESCE(ROUND(SUM(p.total_adp_funded) / NULLIF(COUNT(DISTINCT p.id), 0), 2), 0) AS avg_adp_funded,
|
||||
COALESCE(ROUND(SUM(p.total_amount) / NULLIF(COUNT(DISTINCT p.id), 0), 2), 0) AS avg_total
|
||||
FROM fusion_client_profile p
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age
|
||||
) a
|
||||
LEFT JOIN fusion_adp_application_data app ON app.profile_id = p.id
|
||||
WHERE p.date_of_birth IS NOT NULL
|
||||
GROUP BY age_group
|
||||
ORDER BY MIN(age)
|
||||
""")
|
||||
rows = cr.fetchall()
|
||||
results['age_groups'] = [
|
||||
{
|
||||
'age_group': r[0], 'clients': r[1], 'applications': r[2],
|
||||
'avg_applications': float(r[3]), 'avg_adp_funded': float(r[4]),
|
||||
'avg_total': float(r[5]),
|
||||
} for r in rows
|
||||
]
|
||||
|
||||
if analysis_type in ('full', 'devices_by_age'):
|
||||
cr.execute("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN age < 45 THEN 'Under 45'
|
||||
WHEN age BETWEEN 45 AND 60 THEN '45-60'
|
||||
WHEN age BETWEEN 61 AND 75 THEN '61-75'
|
||||
ELSE '75+'
|
||||
END AS age_group,
|
||||
app.base_device,
|
||||
COUNT(*) AS count
|
||||
FROM fusion_client_profile p
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age
|
||||
) a
|
||||
JOIN fusion_adp_application_data app ON app.profile_id = p.id
|
||||
WHERE p.date_of_birth IS NOT NULL
|
||||
AND app.base_device IS NOT NULL AND app.base_device != ''
|
||||
GROUP BY age_group, app.base_device
|
||||
ORDER BY age_group, count DESC
|
||||
""")
|
||||
rows = cr.fetchall()
|
||||
devices_by_age = {}
|
||||
for age_group, device, count in rows:
|
||||
if age_group not in devices_by_age:
|
||||
devices_by_age[age_group] = []
|
||||
label = BASE_DEVICE_LABELS.get(device, device)
|
||||
devices_by_age[age_group].append({'device': label, 'count': count})
|
||||
results['devices_by_age'] = devices_by_age
|
||||
|
||||
if analysis_type in ('full', 'city_demographics'):
|
||||
city_clause = ""
|
||||
params = []
|
||||
if city_filter:
|
||||
city_clause = "AND LOWER(p.city) = LOWER(%s)"
|
||||
params = [city_filter]
|
||||
|
||||
cr.execute(f"""
|
||||
SELECT
|
||||
p.city,
|
||||
COUNT(DISTINCT p.id) AS clients,
|
||||
COUNT(app.id) AS applications,
|
||||
ROUND(AVG(a.age), 1) AS avg_age,
|
||||
COALESCE(ROUND(SUM(p.total_adp_funded) / NULLIF(COUNT(DISTINCT p.id), 0), 2), 0) AS avg_adp_funded
|
||||
FROM fusion_client_profile p
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age
|
||||
) a
|
||||
LEFT JOIN fusion_adp_application_data app ON app.profile_id = p.id
|
||||
WHERE p.city IS NOT NULL AND p.city != ''
|
||||
AND p.date_of_birth IS NOT NULL
|
||||
{city_clause}
|
||||
GROUP BY p.city
|
||||
ORDER BY clients DESC
|
||||
LIMIT 15
|
||||
""", params)
|
||||
rows = cr.fetchall()
|
||||
results['city_demographics'] = [
|
||||
{
|
||||
'city': r[0], 'clients': r[1], 'applications': r[2],
|
||||
'avg_age': float(r[3]), 'avg_adp_funded': float(r[4]),
|
||||
} for r in rows
|
||||
]
|
||||
|
||||
if analysis_type in ('full', 'benefits'):
|
||||
cr.execute("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN p.benefit_type = 'odsp' THEN 'ODSP'
|
||||
WHEN p.benefit_type = 'owp' THEN 'Ontario Works'
|
||||
WHEN p.benefit_type = 'acsd' THEN 'ACSD'
|
||||
WHEN p.receives_social_assistance THEN 'Social Assistance (other)'
|
||||
ELSE 'Regular (no assistance)'
|
||||
END AS benefit_category,
|
||||
COUNT(DISTINCT p.id) AS clients,
|
||||
COUNT(app.id) AS applications,
|
||||
ROUND(AVG(a.age), 1) AS avg_age,
|
||||
COALESCE(ROUND(AVG(p.total_adp_funded), 2), 0) AS avg_adp_funded
|
||||
FROM fusion_client_profile p
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age
|
||||
) a
|
||||
LEFT JOIN fusion_adp_application_data app ON app.profile_id = p.id
|
||||
WHERE p.date_of_birth IS NOT NULL
|
||||
GROUP BY benefit_category
|
||||
ORDER BY clients DESC
|
||||
""")
|
||||
rows = cr.fetchall()
|
||||
results['benefits_breakdown'] = [
|
||||
{
|
||||
'category': r[0], 'clients': r[1], 'applications': r[2],
|
||||
'avg_age': float(r[3]), 'avg_adp_funded': float(r[4]),
|
||||
} for r in rows
|
||||
]
|
||||
|
||||
if analysis_type in ('full', 'top_devices'):
|
||||
cr.execute("""
|
||||
SELECT
|
||||
app.base_device,
|
||||
COUNT(*) AS count,
|
||||
COUNT(DISTINCT app.profile_id) AS unique_clients,
|
||||
ROUND(AVG(a.age), 1) AS avg_age
|
||||
FROM fusion_adp_application_data app
|
||||
JOIN fusion_client_profile p ON p.id = app.profile_id
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age
|
||||
) a
|
||||
WHERE app.base_device IS NOT NULL AND app.base_device != '' AND app.base_device != 'none'
|
||||
AND p.date_of_birth IS NOT NULL
|
||||
GROUP BY app.base_device
|
||||
ORDER BY count DESC
|
||||
LIMIT 15
|
||||
""")
|
||||
rows = cr.fetchall()
|
||||
results['top_devices'] = [
|
||||
{
|
||||
'device': BASE_DEVICE_LABELS.get(r[0], r[0]),
|
||||
'device_code': r[0],
|
||||
'applications': r[1],
|
||||
'unique_clients': r[2],
|
||||
'avg_client_age': float(r[3]),
|
||||
} for r in rows
|
||||
]
|
||||
|
||||
if analysis_type in ('full', 'funding_summary'):
|
||||
cr.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total_profiles,
|
||||
ROUND(AVG(a.age), 1) AS avg_age,
|
||||
ROUND(SUM(p.total_adp_funded), 2) AS total_adp_funded,
|
||||
ROUND(SUM(p.total_client_portion), 2) AS total_client_portion,
|
||||
ROUND(SUM(p.total_amount), 2) AS grand_total,
|
||||
ROUND(AVG(p.total_adp_funded), 2) AS avg_adp_per_client,
|
||||
ROUND(AVG(p.total_client_portion), 2) AS avg_client_per_client,
|
||||
ROUND(AVG(p.claim_count), 2) AS avg_claims_per_client,
|
||||
MIN(a.age) AS youngest,
|
||||
MAX(a.age) AS oldest
|
||||
FROM fusion_client_profile p
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT EXTRACT(YEAR FROM AGE(CURRENT_DATE, p.date_of_birth))::int AS age
|
||||
) a
|
||||
WHERE p.date_of_birth IS NOT NULL
|
||||
""")
|
||||
r = cr.fetchone()
|
||||
results['funding_summary'] = {
|
||||
'total_profiles': r[0],
|
||||
'avg_age': float(r[1]),
|
||||
'total_adp_funded': float(r[2]),
|
||||
'total_client_portion': float(r[3]),
|
||||
'grand_total': float(r[4]),
|
||||
'avg_adp_per_client': float(r[5]),
|
||||
'avg_client_per_client': float(r[6]),
|
||||
'avg_claims_per_client': float(r[7]),
|
||||
'youngest_client_age': r[8],
|
||||
'oldest_client_age': r[9],
|
||||
}
|
||||
|
||||
return json.dumps(results)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class FusionLTCCleanup(models.Model):
|
||||
_name = 'fusion.ltc.cleanup'
|
||||
_description = 'LTC Cleanup Schedule'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'scheduled_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.ltc.facility',
|
||||
string='LTC Facility',
|
||||
required=True,
|
||||
tracking=True,
|
||||
index=True,
|
||||
)
|
||||
scheduled_date = fields.Date(
|
||||
string='Scheduled Date',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
completed_date = fields.Date(
|
||||
string='Completed Date',
|
||||
tracking=True,
|
||||
)
|
||||
state = fields.Selection([
|
||||
('scheduled', 'Scheduled'),
|
||||
('in_progress', 'In Progress'),
|
||||
('completed', 'Completed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
('rescheduled', 'Rescheduled'),
|
||||
], string='Status', default='scheduled', required=True, tracking=True)
|
||||
|
||||
technician_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Technician',
|
||||
tracking=True,
|
||||
)
|
||||
task_id = fields.Many2one(
|
||||
'fusion.technician.task',
|
||||
string='Field Service Task',
|
||||
)
|
||||
notes = fields.Text(string='Notes')
|
||||
items_cleaned = fields.Integer(string='Items Cleaned')
|
||||
photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'ltc_cleanup_photo_rel',
|
||||
'cleanup_id',
|
||||
'attachment_id',
|
||||
string='Photos',
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('fusion.ltc.cleanup') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_start(self):
|
||||
self.write({'state': 'in_progress'})
|
||||
|
||||
def action_complete(self):
|
||||
self.write({
|
||||
'state': 'completed',
|
||||
'completed_date': fields.Date.context_today(self),
|
||||
})
|
||||
for record in self:
|
||||
record._schedule_next_cleanup()
|
||||
record.message_post(
|
||||
body=_("Cleanup completed. Items cleaned: %s", record.items_cleaned or 0),
|
||||
message_type='comment',
|
||||
)
|
||||
|
||||
def action_cancel(self):
|
||||
self.write({'state': 'cancelled'})
|
||||
|
||||
def action_reschedule(self):
|
||||
self.write({'state': 'rescheduled'})
|
||||
|
||||
def action_reset(self):
|
||||
self.write({'state': 'scheduled'})
|
||||
|
||||
def _schedule_next_cleanup(self):
|
||||
facility = self.facility_id
|
||||
interval = facility._get_cleanup_interval_days()
|
||||
next_date = (self.completed_date or fields.Date.context_today(self)) + timedelta(days=interval)
|
||||
facility.next_cleanup_date = next_date
|
||||
|
||||
next_cleanup = self.env['fusion.ltc.cleanup'].create({
|
||||
'facility_id': facility.id,
|
||||
'scheduled_date': next_date,
|
||||
'technician_id': self.technician_id.id if self.technician_id else False,
|
||||
})
|
||||
|
||||
self.activity_schedule(
|
||||
'mail.mail_activity_data_todo',
|
||||
date_deadline=next_date - timedelta(days=7),
|
||||
summary=_('Upcoming cleanup at %s', facility.name),
|
||||
note=_('Next cleanup is scheduled for %s at %s.', next_date, facility.name),
|
||||
)
|
||||
return next_cleanup
|
||||
|
||||
def action_create_task(self):
|
||||
self.ensure_one()
|
||||
if self.task_id:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.technician.task',
|
||||
'view_mode': 'form',
|
||||
'res_id': self.task_id.id,
|
||||
}
|
||||
task = self.env['fusion.technician.task'].create({
|
||||
'task_type': 'ltc_visit',
|
||||
'facility_id': self.facility_id.id,
|
||||
'scheduled_date': self.scheduled_date,
|
||||
'technician_id': self.technician_id.id if self.technician_id else False,
|
||||
'description': _('Cleanup visit at %s', self.facility_id.name),
|
||||
})
|
||||
self.task_id = task.id
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.technician.task',
|
||||
'view_mode': 'form',
|
||||
'res_id': task.id,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _cron_schedule_cleanups(self):
|
||||
today = fields.Date.context_today(self)
|
||||
week_ahead = today + timedelta(days=7)
|
||||
facilities = self.env['fusion.ltc.facility'].search([
|
||||
('active', '=', True),
|
||||
('cleanup_frequency', '!=', False),
|
||||
('next_cleanup_date', '<=', week_ahead),
|
||||
('next_cleanup_date', '>=', today),
|
||||
])
|
||||
for facility in facilities:
|
||||
existing = self.search([
|
||||
('facility_id', '=', facility.id),
|
||||
('scheduled_date', '=', facility.next_cleanup_date),
|
||||
('state', 'not in', ['cancelled', 'rescheduled']),
|
||||
], limit=1)
|
||||
if not existing:
|
||||
cleanup = self.create({
|
||||
'facility_id': facility.id,
|
||||
'scheduled_date': facility.next_cleanup_date,
|
||||
})
|
||||
cleanup.activity_schedule(
|
||||
'mail.mail_activity_data_todo',
|
||||
date_deadline=facility.next_cleanup_date - timedelta(days=3),
|
||||
summary=_('Cleanup scheduled at %s', facility.name),
|
||||
note=_('Cleanup is scheduled for %s.', facility.next_cleanup_date),
|
||||
)
|
||||
@@ -1,314 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class FusionLTCFacility(models.Model):
|
||||
_name = 'fusion.ltc.facility'
|
||||
_description = 'LTC Facility'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Facility Name',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
image_1920 = fields.Image(string='Image', max_width=1920, max_height=1920)
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Contact Record',
|
||||
help='The facility as a contact in the system',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# Address
|
||||
street = fields.Char(string='Street')
|
||||
street2 = fields.Char(string='Street2')
|
||||
city = fields.Char(string='City')
|
||||
state_id = fields.Many2one(
|
||||
'res.country.state',
|
||||
string='Province',
|
||||
domain="[('country_id', '=', country_id)]",
|
||||
)
|
||||
zip = fields.Char(string='Postal Code')
|
||||
country_id = fields.Many2one('res.country', string='Country')
|
||||
phone = fields.Char(string='Phone')
|
||||
email = fields.Char(string='Email')
|
||||
website = fields.Char(string='Website')
|
||||
|
||||
# Key contacts
|
||||
director_of_care_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Director of Care',
|
||||
tracking=True,
|
||||
)
|
||||
service_supervisor_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Service Supervisor',
|
||||
tracking=True,
|
||||
)
|
||||
physiotherapist_ids = fields.Many2many(
|
||||
'res.partner',
|
||||
'ltc_facility_physiotherapist_rel',
|
||||
'facility_id',
|
||||
'partner_id',
|
||||
string='Physiotherapists',
|
||||
help='Primary contacts for equipment recommendations and communication',
|
||||
)
|
||||
|
||||
# Structure
|
||||
number_of_floors = fields.Integer(string='Number of Floors')
|
||||
floor_ids = fields.One2many(
|
||||
'fusion.ltc.floor',
|
||||
'facility_id',
|
||||
string='Floors',
|
||||
)
|
||||
|
||||
# Contract
|
||||
contract_start_date = fields.Date(string='Contract Start Date', tracking=True)
|
||||
contract_end_date = fields.Date(string='Contract End Date', tracking=True)
|
||||
contract_file = fields.Binary(
|
||||
string='Contract Document',
|
||||
attachment=True,
|
||||
)
|
||||
contract_file_filename = fields.Char(string='Contract Filename')
|
||||
contract_notes = fields.Text(string='Contract Notes')
|
||||
|
||||
# Cleanup scheduling
|
||||
cleanup_frequency = fields.Selection([
|
||||
('quarterly', 'Quarterly (Every 3 Months)'),
|
||||
('semi_annual', 'Semi-Annual (Every 6 Months)'),
|
||||
('annual', 'Annual (Yearly)'),
|
||||
('custom', 'Custom Interval'),
|
||||
], string='Cleanup Frequency', default='quarterly')
|
||||
cleanup_interval_days = fields.Integer(
|
||||
string='Custom Interval (Days)',
|
||||
help='Number of days between cleanups when using custom interval',
|
||||
)
|
||||
next_cleanup_date = fields.Date(
|
||||
string='Next Cleanup Date',
|
||||
compute='_compute_next_cleanup_date',
|
||||
store=True,
|
||||
readonly=False,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# Related records
|
||||
repair_ids = fields.One2many('fusion.ltc.repair', 'facility_id', string='Repairs')
|
||||
cleanup_ids = fields.One2many('fusion.ltc.cleanup', 'facility_id', string='Cleanups')
|
||||
|
||||
# Computed counts
|
||||
repair_count = fields.Integer(compute='_compute_repair_count', string='Total Repairs')
|
||||
active_repair_count = fields.Integer(compute='_compute_repair_count', string='Active Repairs')
|
||||
cleanup_count = fields.Integer(compute='_compute_cleanup_count', string='Cleanups')
|
||||
|
||||
notes = fields.Html(string='Notes')
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('code', _('New')) == _('New'):
|
||||
vals['code'] = self.env['ir.sequence'].next_by_code('fusion.ltc.facility') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.depends('contract_start_date', 'cleanup_frequency', 'cleanup_interval_days')
|
||||
def _compute_next_cleanup_date(self):
|
||||
today = fields.Date.context_today(self)
|
||||
for facility in self:
|
||||
start = facility.contract_start_date
|
||||
freq = facility.cleanup_frequency
|
||||
if not start or not freq:
|
||||
if not facility.next_cleanup_date:
|
||||
facility.next_cleanup_date = False
|
||||
continue
|
||||
|
||||
interval = facility._get_cleanup_interval_days()
|
||||
delta = relativedelta(days=interval)
|
||||
|
||||
candidate = start + delta
|
||||
while candidate < today:
|
||||
candidate += delta
|
||||
|
||||
facility.next_cleanup_date = candidate
|
||||
|
||||
def action_preview_contract(self):
|
||||
self.ensure_one()
|
||||
if not self.contract_file:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('No Document'),
|
||||
'message': _('No contract document has been uploaded yet.'),
|
||||
'type': 'warning',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
attachment = self.env['ir.attachment'].search([
|
||||
('res_model', '=', self._name),
|
||||
('res_id', '=', self.id),
|
||||
('res_field', '=', 'contract_file'),
|
||||
], limit=1)
|
||||
|
||||
if not attachment:
|
||||
attachment = self.env['ir.attachment'].search([
|
||||
('res_model', '=', self._name),
|
||||
('res_id', '=', self.id),
|
||||
('name', '=', self.contract_file_filename or 'contract_file'),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if attachment:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_claims.preview_document',
|
||||
'params': {
|
||||
'attachment_id': attachment.id,
|
||||
'title': _('Contract - %s', self.name),
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Error'),
|
||||
'message': _('Could not load contract document.'),
|
||||
'type': 'danger',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
def _compute_repair_count(self):
|
||||
for facility in self:
|
||||
repairs = facility.repair_ids
|
||||
facility.repair_count = len(repairs)
|
||||
facility.active_repair_count = len(repairs.filtered(
|
||||
lambda r: r.stage_id and not r.stage_id.fold
|
||||
))
|
||||
|
||||
def _compute_cleanup_count(self):
|
||||
for facility in self:
|
||||
facility.cleanup_count = len(facility.cleanup_ids)
|
||||
|
||||
def action_view_repairs(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Repairs - %s', self.name),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.ltc.repair',
|
||||
'view_mode': 'kanban,list,form',
|
||||
'domain': [('facility_id', '=', self.id)],
|
||||
'context': {'default_facility_id': self.id},
|
||||
}
|
||||
|
||||
def action_view_cleanups(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Cleanups - %s', self.name),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.ltc.cleanup',
|
||||
'view_mode': 'list,kanban,form',
|
||||
'domain': [('facility_id', '=', self.id)],
|
||||
'context': {'default_facility_id': self.id},
|
||||
}
|
||||
|
||||
def _get_cleanup_interval_days(self):
|
||||
mapping = {
|
||||
'quarterly': 90,
|
||||
'semi_annual': 180,
|
||||
'annual': 365,
|
||||
}
|
||||
if self.cleanup_frequency == 'custom':
|
||||
return self.cleanup_interval_days or 90
|
||||
return mapping.get(self.cleanup_frequency, 90)
|
||||
|
||||
|
||||
class FusionLTCFloor(models.Model):
|
||||
_name = 'fusion.ltc.floor'
|
||||
_description = 'LTC Facility Floor'
|
||||
_order = 'sequence, name'
|
||||
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.ltc.facility',
|
||||
string='Facility',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
name = fields.Char(string='Floor Name', required=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
station_ids = fields.One2many(
|
||||
'fusion.ltc.station',
|
||||
'floor_id',
|
||||
string='Nursing Stations',
|
||||
)
|
||||
head_nurse_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Head Nurse',
|
||||
)
|
||||
physiotherapist_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Physiotherapist',
|
||||
help='Floor-level physiotherapist if different from facility level',
|
||||
)
|
||||
|
||||
|
||||
class FusionLTCStation(models.Model):
|
||||
_name = 'fusion.ltc.station'
|
||||
_description = 'LTC Nursing Station'
|
||||
_order = 'sequence, name'
|
||||
|
||||
floor_id = fields.Many2one(
|
||||
'fusion.ltc.floor',
|
||||
string='Floor',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
name = fields.Char(string='Station Name', required=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
head_nurse_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Head Nurse',
|
||||
)
|
||||
phone = fields.Char(string='Phone')
|
||||
|
||||
|
||||
class FusionLTCFamilyContact(models.Model):
|
||||
_name = 'fusion.ltc.family.contact'
|
||||
_description = 'LTC Resident Family Contact'
|
||||
_order = 'is_poa desc, name'
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Resident',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
name = fields.Char(string='Contact Name', required=True)
|
||||
relationship = fields.Selection([
|
||||
('spouse', 'Spouse'),
|
||||
('child', 'Child'),
|
||||
('sibling', 'Sibling'),
|
||||
('parent', 'Parent'),
|
||||
('guardian', 'Guardian'),
|
||||
('poa', 'Power of Attorney'),
|
||||
('other', 'Other'),
|
||||
], string='Relationship')
|
||||
phone = fields.Char(string='Phone')
|
||||
phone2 = fields.Char(string='Phone 2')
|
||||
email = fields.Char(string='Email')
|
||||
is_poa = fields.Boolean(string='Is POA', help='Is this person the Power of Attorney?')
|
||||
notes = fields.Char(string='Notes')
|
||||
@@ -1,68 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class FusionLTCFormSubmission(models.Model):
|
||||
_name = 'fusion.ltc.form.submission'
|
||||
_description = 'LTC Form Submission'
|
||||
_order = 'submitted_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
)
|
||||
form_type = fields.Selection([
|
||||
('repair', 'Repair Request'),
|
||||
], string='Form Type', default='repair', required=True, index=True)
|
||||
repair_id = fields.Many2one(
|
||||
'fusion.ltc.repair',
|
||||
string='Repair Request',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.ltc.facility',
|
||||
string='Facility',
|
||||
index=True,
|
||||
)
|
||||
client_name = fields.Char(string='Client Name')
|
||||
room_number = fields.Char(string='Room Number')
|
||||
product_serial = fields.Char(string='Product Serial #')
|
||||
is_emergency = fields.Boolean(string='Emergency')
|
||||
submitted_date = fields.Datetime(
|
||||
string='Submitted Date',
|
||||
default=fields.Datetime.now,
|
||||
readonly=True,
|
||||
)
|
||||
ip_address = fields.Char(string='IP Address', readonly=True)
|
||||
status = fields.Selection([
|
||||
('submitted', 'Submitted'),
|
||||
('processed', 'Processed'),
|
||||
('rejected', 'Rejected'),
|
||||
], string='Status', default='submitted', tracking=True)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.ltc.form.submission'
|
||||
) or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_view_repair(self):
|
||||
self.ensure_one()
|
||||
if not self.repair_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.ltc.repair',
|
||||
'view_mode': 'form',
|
||||
'res_id': self.repair_id.id,
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FusionLTCRepairStage(models.Model):
|
||||
_name = 'fusion.ltc.repair.stage'
|
||||
_description = 'LTC Repair Stage'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Stage Name', required=True, translate=True)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
fold = fields.Boolean(
|
||||
string='Folded in Kanban',
|
||||
help='Folded stages are hidden by default in the kanban view',
|
||||
)
|
||||
color = fields.Char(
|
||||
string='Stage Color',
|
||||
help='CSS color class for stage badge (e.g. info, success, warning, danger)',
|
||||
default='secondary',
|
||||
)
|
||||
description = fields.Text(string='Description')
|
||||
|
||||
|
||||
class FusionLTCRepair(models.Model):
|
||||
_name = 'fusion.ltc.repair'
|
||||
_description = 'LTC Repair Request'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'issue_reported_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.ltc.facility',
|
||||
string='LTC Facility',
|
||||
required=True,
|
||||
tracking=True,
|
||||
index=True,
|
||||
)
|
||||
client_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Client/Resident',
|
||||
tracking=True,
|
||||
help='Link to the resident contact record',
|
||||
)
|
||||
client_name = fields.Char(
|
||||
string='Client Name',
|
||||
help='Quick entry name when no contact record exists',
|
||||
)
|
||||
display_client_name = fields.Char(
|
||||
compute='_compute_display_client_name',
|
||||
string='Client',
|
||||
store=True,
|
||||
)
|
||||
room_number = fields.Char(string='Room Number')
|
||||
|
||||
stage_id = fields.Many2one(
|
||||
'fusion.ltc.repair.stage',
|
||||
string='Stage',
|
||||
tracking=True,
|
||||
group_expand='_read_group_stage_ids',
|
||||
default=lambda self: self._default_stage_id(),
|
||||
index=True,
|
||||
)
|
||||
kanban_state = fields.Selection([
|
||||
('normal', 'In Progress'),
|
||||
('done', 'Ready'),
|
||||
('blocked', 'Blocked'),
|
||||
], string='Kanban State', default='normal', tracking=True)
|
||||
color = fields.Integer(string='Color Index')
|
||||
|
||||
is_emergency = fields.Boolean(
|
||||
string='Emergency Repair',
|
||||
tracking=True,
|
||||
help='Emergency visits may be chargeable at an extra rate',
|
||||
)
|
||||
priority = fields.Selection([
|
||||
('0', 'Normal'),
|
||||
('1', 'High'),
|
||||
], string='Priority', default='0')
|
||||
|
||||
product_serial = fields.Char(string='Product Serial #')
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Product',
|
||||
help='Link to product record if applicable',
|
||||
)
|
||||
issue_description = fields.Text(
|
||||
string='Issue Description',
|
||||
required=True,
|
||||
)
|
||||
issue_reported_date = fields.Date(
|
||||
string='Issue Reported Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
tracking=True,
|
||||
)
|
||||
issue_fixed_date = fields.Date(
|
||||
string='Issue Fixed Date',
|
||||
tracking=True,
|
||||
)
|
||||
resolution_description = fields.Text(string='Resolution Description')
|
||||
|
||||
assigned_technician_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Assigned Technician',
|
||||
tracking=True,
|
||||
)
|
||||
task_id = fields.Many2one(
|
||||
'fusion.technician.task',
|
||||
string='Field Service Task',
|
||||
tracking=True,
|
||||
)
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order',
|
||||
string='Sale Order',
|
||||
tracking=True,
|
||||
help='Sale order created for this repair if applicable',
|
||||
)
|
||||
sale_order_name = fields.Char(
|
||||
related='sale_order_id.name',
|
||||
string='SO Reference',
|
||||
)
|
||||
|
||||
poa_name = fields.Char(string='Family/POA Name')
|
||||
poa_phone = fields.Char(string='Family/POA Phone')
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
related='company_id.currency_id',
|
||||
)
|
||||
repair_value = fields.Monetary(
|
||||
string='Repair Value',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
|
||||
photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'ltc_repair_photo_rel',
|
||||
'repair_id',
|
||||
'attachment_id',
|
||||
string='Photos (Legacy)',
|
||||
)
|
||||
before_photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'ltc_repair_before_photo_rel',
|
||||
'repair_id',
|
||||
'attachment_id',
|
||||
string='Before Photos',
|
||||
)
|
||||
after_photo_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'ltc_repair_after_photo_rel',
|
||||
'repair_id',
|
||||
'attachment_id',
|
||||
string='After Photos',
|
||||
)
|
||||
notes = fields.Text(string='Internal Notes')
|
||||
|
||||
source = fields.Selection([
|
||||
('portal_form', 'Portal Form'),
|
||||
('manual', 'Manual Entry'),
|
||||
('phone', 'Phone Call'),
|
||||
('migrated', 'Migrated'),
|
||||
], string='Source', default='manual', tracking=True)
|
||||
|
||||
stage_color = fields.Char(
|
||||
related='stage_id.color',
|
||||
string='Stage Color',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('fusion.ltc.repair') or _('New')
|
||||
records = super().create(vals_list)
|
||||
for record in records:
|
||||
record._post_creation_message()
|
||||
return records
|
||||
|
||||
def _post_creation_message(self):
|
||||
body = _(
|
||||
"Repair request submitted for <b>%(client)s</b> in Room <b>%(room)s</b>"
|
||||
" at <b>%(facility)s</b>.<br/>"
|
||||
"Issue: %(issue)s",
|
||||
client=self.display_client_name or 'N/A',
|
||||
room=self.room_number or 'N/A',
|
||||
facility=self.facility_id.name,
|
||||
issue=self.issue_description or '',
|
||||
)
|
||||
self.message_post(body=body, message_type='comment')
|
||||
|
||||
def _default_stage_id(self):
|
||||
return self.env['fusion.ltc.repair.stage'].search([], order='sequence', limit=1).id
|
||||
|
||||
@api.model
|
||||
def _read_group_stage_ids(self, stages, domain):
|
||||
return self.env['fusion.ltc.repair.stage'].search([], order='sequence')
|
||||
|
||||
@api.depends('client_id', 'client_name')
|
||||
def _compute_display_client_name(self):
|
||||
for repair in self:
|
||||
if repair.client_id:
|
||||
repair.display_client_name = repair.client_id.name
|
||||
else:
|
||||
repair.display_client_name = repair.client_name or ''
|
||||
|
||||
@api.onchange('client_id')
|
||||
def _onchange_client_id(self):
|
||||
if self.client_id:
|
||||
self.client_name = self.client_id.name
|
||||
if hasattr(self.client_id, 'x_fc_ltc_room_number') and self.client_id.x_fc_ltc_room_number:
|
||||
self.room_number = self.client_id.x_fc_ltc_room_number
|
||||
if hasattr(self.client_id, 'x_fc_ltc_facility_id') and self.client_id.x_fc_ltc_facility_id:
|
||||
self.facility_id = self.client_id.x_fc_ltc_facility_id
|
||||
|
||||
def action_view_sale_order(self):
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return
|
||||
return {
|
||||
'name': self.sale_order_id.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'form',
|
||||
'res_id': self.sale_order_id.id,
|
||||
}
|
||||
|
||||
def action_view_task(self):
|
||||
self.ensure_one()
|
||||
if not self.task_id:
|
||||
return
|
||||
return {
|
||||
'name': self.task_id.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.technician.task',
|
||||
'view_mode': 'form',
|
||||
'res_id': self.task_id.id,
|
||||
}
|
||||
|
||||
def action_create_sale_order(self):
|
||||
self.ensure_one()
|
||||
if self.sale_order_id:
|
||||
raise UserError(_('A sale order already exists for this repair.'))
|
||||
|
||||
if not self.client_id and self.client_name:
|
||||
return {
|
||||
'name': _('Link Contact'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.ltc.repair.create.so.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_repair_id': self.id,
|
||||
'default_client_name': self.client_name,
|
||||
},
|
||||
}
|
||||
|
||||
if not self.client_id:
|
||||
raise UserError(_('Please set a client before creating a sale order.'))
|
||||
|
||||
return self._create_linked_sale_order()
|
||||
|
||||
def _create_linked_sale_order(self):
|
||||
self.ensure_one()
|
||||
SaleOrder = self.env['sale.order']
|
||||
OrderLine = self.env['sale.order.line']
|
||||
|
||||
so_vals = {
|
||||
'partner_id': self.client_id.id,
|
||||
'x_fc_ltc_repair_id': self.id,
|
||||
}
|
||||
sale_order = SaleOrder.create(so_vals)
|
||||
|
||||
seq = 10
|
||||
OrderLine.create({
|
||||
'order_id': sale_order.id,
|
||||
'display_type': 'line_section',
|
||||
'name': 'PRODUCTS & REPAIRS',
|
||||
'sequence': seq,
|
||||
})
|
||||
seq += 10
|
||||
|
||||
repair_tmpl = self.env.ref(
|
||||
'fusion_claims.product_ltc_repair_service', raise_if_not_found=False
|
||||
)
|
||||
repair_product = (
|
||||
repair_tmpl.product_variant_id if repair_tmpl else False
|
||||
)
|
||||
line_vals = {
|
||||
'order_id': sale_order.id,
|
||||
'sequence': seq,
|
||||
'name': 'Repairs at LTC Home - %s' % (self.facility_id.name or ''),
|
||||
}
|
||||
if repair_product:
|
||||
line_vals['product_id'] = repair_product.id
|
||||
else:
|
||||
line_vals['display_type'] = 'line_note'
|
||||
OrderLine.create(line_vals)
|
||||
seq += 10
|
||||
|
||||
OrderLine.create({
|
||||
'order_id': sale_order.id,
|
||||
'display_type': 'line_section',
|
||||
'name': 'REPORTED ISSUES',
|
||||
'sequence': seq,
|
||||
})
|
||||
seq += 10
|
||||
|
||||
if self.issue_description:
|
||||
OrderLine.create({
|
||||
'order_id': sale_order.id,
|
||||
'display_type': 'line_note',
|
||||
'name': self.issue_description,
|
||||
'sequence': seq,
|
||||
})
|
||||
seq += 10
|
||||
|
||||
if self.issue_reported_date:
|
||||
OrderLine.create({
|
||||
'order_id': sale_order.id,
|
||||
'display_type': 'line_note',
|
||||
'name': 'Reported Date: %s' % self.issue_reported_date,
|
||||
'sequence': seq,
|
||||
})
|
||||
seq += 10
|
||||
|
||||
OrderLine.create({
|
||||
'order_id': sale_order.id,
|
||||
'display_type': 'line_section',
|
||||
'name': 'PROPOSED RESOLUTION',
|
||||
'sequence': seq,
|
||||
})
|
||||
seq += 10
|
||||
|
||||
if self.resolution_description:
|
||||
OrderLine.create({
|
||||
'order_id': sale_order.id,
|
||||
'display_type': 'line_note',
|
||||
'name': self.resolution_description,
|
||||
'sequence': seq,
|
||||
})
|
||||
seq += 10
|
||||
|
||||
if self.product_serial:
|
||||
OrderLine.create({
|
||||
'order_id': sale_order.id,
|
||||
'display_type': 'line_note',
|
||||
'name': 'Serial Number: %s' % self.product_serial,
|
||||
'sequence': seq,
|
||||
})
|
||||
|
||||
self.sale_order_id = sale_order.id
|
||||
|
||||
return {
|
||||
'name': sale_order.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'form',
|
||||
'res_id': sale_order.id,
|
||||
}
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -477,16 +411,6 @@ class ResConfigSettings(models.TransientModel):
|
||||
help='Default ODSP office contact for new ODSP cases',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# PORTAL FORMS
|
||||
# =========================================================================
|
||||
|
||||
fc_ltc_form_password = fields.Char(
|
||||
string='LTC Form Access Password',
|
||||
config_parameter='fusion_claims.ltc_form_password',
|
||||
help='Minimum 4 characters. Share with facility staff to access the repair form.',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# PORTAL BRANDING
|
||||
# =========================================================================
|
||||
@@ -609,15 +533,11 @@ class ResConfigSettings(models.TransientModel):
|
||||
# an existing non-empty value (e.g. API keys, user-customized settings).
|
||||
_protected_keys = [
|
||||
'fusion_claims.ai_api_key',
|
||||
'fusion_claims.google_maps_api_key',
|
||||
'fusion_claims.vendor_code',
|
||||
'fusion_claims.ai_model',
|
||||
'fusion_claims.adp_posting_base_date',
|
||||
'fusion_claims.application_reminder_days',
|
||||
'fusion_claims.application_reminder_2_days',
|
||||
'fusion_claims.store_open_hour',
|
||||
'fusion_claims.store_close_hour',
|
||||
'fusion_claims.technician_start_address',
|
||||
]
|
||||
# Snapshot existing values BEFORE super().set_values() runs
|
||||
_existing = {}
|
||||
@@ -656,13 +576,6 @@ class ResConfigSettings(models.TransientModel):
|
||||
# Office notification recipients are stored via related field on res.company
|
||||
# No need to store in ir.config_parameter
|
||||
|
||||
# Validate LTC form password length
|
||||
form_pw = self.fc_ltc_form_password or ''
|
||||
if form_pw and len(form_pw.strip()) < 4:
|
||||
raise ValidationError(
|
||||
'LTC Form Access Password must be at least 4 characters.'
|
||||
)
|
||||
|
||||
# Store designated vendor signer (Many2one - manual handling)
|
||||
if self.fc_designated_vendor_signer:
|
||||
ICP.set_param('fusion_claims.designated_vendor_signer',
|
||||
|
||||
@@ -8,13 +8,6 @@ from odoo import models, fields, api
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
x_fc_start_address = fields.Char(
|
||||
string='Start Location',
|
||||
help='Technician daily start location (home, warehouse, etc.). '
|
||||
'Used as origin for first travel time calculation. '
|
||||
'If empty, the company default HQ address is used.',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# CONTACT TYPE
|
||||
# ==========================================================================
|
||||
@@ -76,25 +69,6 @@ class ResPartner(models.Model):
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# LTC FIELDS
|
||||
# ==========================================================================
|
||||
x_fc_ltc_facility_id = fields.Many2one(
|
||||
'fusion.ltc.facility',
|
||||
string='LTC Home',
|
||||
tracking=True,
|
||||
help='Long-Term Care Home this resident belongs to',
|
||||
)
|
||||
x_fc_ltc_room_number = fields.Char(
|
||||
string='Room Number',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_ltc_family_contact_ids = fields.One2many(
|
||||
'fusion.ltc.family.contact',
|
||||
'partner_id',
|
||||
string='Family Contacts',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_contact_type')
|
||||
def _compute_is_odsp_office(self):
|
||||
for partner in self:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -15,7 +15,9 @@ _logger = logging.getLogger(__name__)
|
||||
class SaleOrder(models.Model):
|
||||
_name = 'sale.order'
|
||||
_inherit = ['sale.order', 'fusion_claims.adp.posting.schedule.mixin', 'fusion.email.builder.mixin']
|
||||
_rec_names_search = ['name', 'partner_id.name']
|
||||
@property
|
||||
def _rec_names_search(self):
|
||||
return ['name', 'partner_id.name']
|
||||
|
||||
@api.depends('name', 'partner_id.name')
|
||||
def _compute_display_name(self):
|
||||
@@ -35,22 +37,6 @@ class SaleOrder(models.Model):
|
||||
help='True only for ADP or ADP/ODSP sale types',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# LTC REPAIR LINK
|
||||
# ==========================================================================
|
||||
x_fc_ltc_repair_id = fields.Many2one(
|
||||
'fusion.ltc.repair',
|
||||
string='LTC Repair',
|
||||
tracking=True,
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
)
|
||||
x_fc_is_ltc_repair_sale = fields.Boolean(
|
||||
compute='_compute_is_ltc_repair_sale',
|
||||
store=True,
|
||||
string='Is LTC Repair Sale',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# INVOICE COUNT FIELDS (Separate ADP and Client invoices)
|
||||
# ==========================================================================
|
||||
@@ -418,11 +404,6 @@ class SaleOrder(models.Model):
|
||||
for order in self:
|
||||
order.x_fc_is_adp_sale = order._is_adp_sale()
|
||||
|
||||
@api.depends('x_fc_ltc_repair_id')
|
||||
def _compute_is_ltc_repair_sale(self):
|
||||
for order in self:
|
||||
order.x_fc_is_ltc_repair_sale = bool(order.x_fc_ltc_repair_id)
|
||||
|
||||
# ==========================================================================
|
||||
# SALE TYPE AND CLIENT TYPE FIELDS
|
||||
# ==========================================================================
|
||||
@@ -1836,8 +1817,7 @@ class SaleOrder(models.Model):
|
||||
domain, groupby=groupby, aggregates=aggregates,
|
||||
having=having, offset=offset, limit=limit, order=order,
|
||||
)
|
||||
groupby_list = list(groupby) if not isinstance(groupby, (list, tuple)) else groupby
|
||||
if groupby_list and groupby_list[0] == 'x_fc_adp_application_status':
|
||||
if groupby and groupby[0] == 'x_fc_adp_application_status':
|
||||
status_order = self._STATUS_ORDER
|
||||
result = sorted(result, key=lambda r: status_order.get(r[0], 999))
|
||||
return result
|
||||
@@ -4723,48 +4703,6 @@ class SaleOrder(models.Model):
|
||||
|
||||
return invoice
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# PORTAL PAYMENT AMOUNT (ADP Client Portion)
|
||||
# ==========================================================================
|
||||
def _get_prepayment_required_amount(self):
|
||||
"""Override to return client portion for ADP orders.
|
||||
|
||||
For ADP REG clients, the customer should only prepay their 25%
|
||||
portion, not the full order amount that includes ADP's 75%.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self._is_adp_sale() and self.x_fc_client_type == 'REG':
|
||||
client_portion = self.x_fc_client_portion_total or 0
|
||||
if client_portion > 0:
|
||||
return self.currency_id.round(client_portion * self.prepayment_percent)
|
||||
return super()._get_prepayment_required_amount()
|
||||
|
||||
def _has_to_be_paid(self):
|
||||
"""Override to use client portion for ADP payment threshold check.
|
||||
|
||||
Standard Odoo checks amount_total > 0. For ADP orders where
|
||||
the client type is not REG (100% ADP funded), the customer
|
||||
has nothing to pay and the quotation should auto-confirm.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self._is_adp_sale():
|
||||
client_type = self.x_fc_client_type or ''
|
||||
if client_type and client_type != 'REG':
|
||||
return False
|
||||
if client_type == 'REG':
|
||||
client_portion = self.x_fc_client_portion_total or 0
|
||||
if client_portion <= 0:
|
||||
return False
|
||||
return (
|
||||
self.state in ['draft', 'sent']
|
||||
and not self.is_expired
|
||||
and self.require_payment
|
||||
and client_portion > 0
|
||||
and not self._is_confirmation_amount_reached()
|
||||
)
|
||||
return super()._has_to_be_paid()
|
||||
|
||||
# ==========================================================================
|
||||
# OVERRIDE _get_invoiceable_lines TO INCLUDE ALL SECTIONS AND NOTES
|
||||
# ==========================================================================
|
||||
|
||||
@@ -29,10 +29,11 @@ class SaleOrderLine(models.Model):
|
||||
|
||||
@api.depends('product_id', 'product_id.default_code')
|
||||
def _compute_adp_device_type(self):
|
||||
"""Compute ADP device type from the product's device code."""
|
||||
"""Compute ADP device type and build type from the product's device code."""
|
||||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||||
for line in self:
|
||||
device_type = ''
|
||||
build_type = False
|
||||
if line.product_id:
|
||||
# Get the device code from product (default_code or custom field)
|
||||
device_code = line._get_adp_device_code()
|
||||
@@ -44,7 +45,9 @@ class SaleOrderLine(models.Model):
|
||||
], limit=1)
|
||||
if adp_device:
|
||||
device_type = adp_device.device_type or ''
|
||||
build_type = adp_device.build_type or False
|
||||
line.x_fc_adp_device_type = device_type
|
||||
line.x_fc_adp_build_type = build_type
|
||||
|
||||
# ==========================================================================
|
||||
# SERIAL NUMBER AND DEVICE PLACEMENT
|
||||
@@ -110,6 +113,16 @@ class SaleOrderLine(models.Model):
|
||||
store=True,
|
||||
help='Device type from ADP mobility manual (for approval matching)',
|
||||
)
|
||||
x_fc_adp_build_type = fields.Selection(
|
||||
selection=[
|
||||
('modular', 'Modular'),
|
||||
('custom_fabricated', 'Custom Fabricated'),
|
||||
],
|
||||
string='Build Type',
|
||||
compute='_compute_adp_device_type',
|
||||
store=True,
|
||||
help='Build type from ADP mobility manual (Modular or Custom Fabricated)',
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# COMPUTED ADP PORTIONS
|
||||
@@ -306,6 +319,49 @@ class SaleOrderLine(models.Model):
|
||||
# 5. Final fallback - return default_code even if not in ADP database
|
||||
return self.product_id.default_code or ''
|
||||
|
||||
def _get_adp_code_for_report(self):
|
||||
"""Return the ADP device code for display on reports.
|
||||
|
||||
Uses the product's x_fc_adp_device_code field (not default_code).
|
||||
Returns 'NON-FUNDED' for non-ADP products.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.product_id:
|
||||
return 'NON-FUNDED'
|
||||
if self.product_id.is_non_adp_funded():
|
||||
return 'NON-FUNDED'
|
||||
product_tmpl = self.product_id.product_tmpl_id
|
||||
code = ''
|
||||
if hasattr(product_tmpl, 'x_fc_adp_device_code'):
|
||||
code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or ''
|
||||
if not code and hasattr(product_tmpl, 'x_adp_code'):
|
||||
code = getattr(product_tmpl, 'x_adp_code', '') or ''
|
||||
if not code:
|
||||
return 'NON-FUNDED'
|
||||
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
||||
if ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
|
||||
return code
|
||||
return 'NON-FUNDED'
|
||||
|
||||
def _get_adp_device_type(self):
|
||||
"""Live lookup of device type from the ADP device code table.
|
||||
|
||||
Returns 'No Funding Available' for non-ADP products.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.product_id or self.product_id.is_non_adp_funded():
|
||||
return 'No Funding Available'
|
||||
code = self._get_adp_code_for_report()
|
||||
if code == 'NON-FUNDED':
|
||||
return 'No Funding Available'
|
||||
if self.x_fc_adp_device_type:
|
||||
return self.x_fc_adp_device_type
|
||||
adp_device = self.env['fusion.adp.device.code'].sudo().search([
|
||||
('device_code', '=', code),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
return adp_device.device_type if adp_device else 'No Funding Available'
|
||||
|
||||
def _get_serial_number(self):
|
||||
"""Get serial number from mapped field or native field."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""
|
||||
Cross-instance technician task sync.
|
||||
|
||||
Enables two Odoo instances (e.g. Westin and Mobility) that share the same
|
||||
field technicians to see each other's delivery tasks, preventing double-booking.
|
||||
|
||||
Remote tasks appear as read-only "shadow" records in the local calendar.
|
||||
The existing _find_next_available_slot() automatically sees shadow tasks,
|
||||
so collision detection works without changes to the scheduling algorithm.
|
||||
|
||||
Technicians are matched across instances using the x_fc_tech_sync_id field
|
||||
on res.users. Set the same value (e.g. "gordy") on both instances for the
|
||||
same person -- no mapping table needed.
|
||||
"""
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
import requests
|
||||
from datetime import timedelta
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
SYNC_TASK_FIELDS = [
|
||||
'x_fc_sync_uuid', 'name', 'technician_id', 'additional_technician_ids',
|
||||
'task_type', 'status',
|
||||
'scheduled_date', 'time_start', 'time_end', 'duration_hours',
|
||||
'address_street', 'address_street2', 'address_city', 'address_zip',
|
||||
'address_lat', 'address_lng', 'priority', 'partner_id',
|
||||
]
|
||||
|
||||
|
||||
class FusionTaskSyncConfig(models.Model):
|
||||
_name = 'fusion.task.sync.config'
|
||||
_description = 'Task Sync Remote Instance'
|
||||
|
||||
name = fields.Char('Instance Name', required=True,
|
||||
help='e.g. Westin Healthcare, Mobility Specialties')
|
||||
instance_id = fields.Char('Instance ID', required=True,
|
||||
help='Short identifier, e.g. westin or mobility')
|
||||
url = fields.Char('Odoo URL', required=True,
|
||||
help='e.g. http://192.168.1.40:8069')
|
||||
database = fields.Char('Database', required=True)
|
||||
username = fields.Char('API Username', required=True)
|
||||
api_key = fields.Char('API Key', required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
last_sync = fields.Datetime('Last Successful Sync', readonly=True)
|
||||
last_sync_error = fields.Text('Last Error', readonly=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON-RPC helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _jsonrpc(self, service, method, args):
|
||||
"""Execute a JSON-RPC call against the remote Odoo instance."""
|
||||
self.ensure_one()
|
||||
url = f"{self.url.rstrip('/')}/jsonrpc"
|
||||
payload = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'call',
|
||||
'id': 1,
|
||||
'params': {
|
||||
'service': service,
|
||||
'method': method,
|
||||
'args': args,
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=15)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get('error'):
|
||||
err = result['error'].get('data', {}).get('message', str(result['error']))
|
||||
raise UserError(f"Remote error: {err}")
|
||||
return result.get('result')
|
||||
except requests.exceptions.ConnectionError:
|
||||
_logger.warning("Task sync: cannot connect to %s", self.url)
|
||||
return None
|
||||
except requests.exceptions.Timeout:
|
||||
_logger.warning("Task sync: timeout connecting to %s", self.url)
|
||||
return None
|
||||
|
||||
def _authenticate(self):
|
||||
"""Authenticate with the remote instance and return the uid."""
|
||||
self.ensure_one()
|
||||
uid = self._jsonrpc('common', 'authenticate',
|
||||
[self.database, self.username, self.api_key, {}])
|
||||
if not uid:
|
||||
_logger.error("Task sync: authentication failed for %s", self.name)
|
||||
return uid
|
||||
|
||||
def _rpc(self, model, method, args, kwargs=None):
|
||||
"""Execute a method on the remote instance via execute_kw.
|
||||
execute_kw(db, uid, password, model, method, [args], {kwargs})
|
||||
"""
|
||||
self.ensure_one()
|
||||
uid = self._authenticate()
|
||||
if not uid:
|
||||
return None
|
||||
call_args = [self.database, uid, self.api_key, model, method, args]
|
||||
if kwargs:
|
||||
call_args.append(kwargs)
|
||||
return self._jsonrpc('object', 'execute_kw', call_args)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tech sync ID helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_local_tech_map(self):
|
||||
"""Build {local_user_id: x_fc_tech_sync_id} for all local field staff."""
|
||||
techs = self.env['res.users'].sudo().search([
|
||||
('x_fc_is_field_staff', '=', True),
|
||||
('x_fc_tech_sync_id', '!=', False),
|
||||
('active', '=', True),
|
||||
])
|
||||
return {u.id: u.x_fc_tech_sync_id for u in techs}
|
||||
|
||||
def _get_remote_tech_map(self):
|
||||
"""Build {x_fc_tech_sync_id: remote_user_id} from the remote instance."""
|
||||
self.ensure_one()
|
||||
remote_users = self._rpc('res.users', 'search_read', [
|
||||
[('x_fc_is_field_staff', '=', True),
|
||||
('x_fc_tech_sync_id', '!=', False),
|
||||
('active', '=', True)],
|
||||
], {'fields': ['id', 'x_fc_tech_sync_id']})
|
||||
if not remote_users:
|
||||
return {}
|
||||
return {
|
||||
ru['x_fc_tech_sync_id']: ru['id']
|
||||
for ru in remote_users
|
||||
if ru.get('x_fc_tech_sync_id')
|
||||
}
|
||||
|
||||
def _get_local_syncid_to_uid(self):
|
||||
"""Build {x_fc_tech_sync_id: local_user_id} for local field staff."""
|
||||
techs = self.env['res.users'].sudo().search([
|
||||
('x_fc_is_field_staff', '=', True),
|
||||
('x_fc_tech_sync_id', '!=', False),
|
||||
('active', '=', True),
|
||||
])
|
||||
return {u.x_fc_tech_sync_id: u.id for u in techs}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection test
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Test the connection to the remote instance."""
|
||||
self.ensure_one()
|
||||
uid = self._authenticate()
|
||||
if uid:
|
||||
remote_map = self._get_remote_tech_map()
|
||||
local_map = self._get_local_tech_map()
|
||||
matched = set(local_map.values()) & set(remote_map.keys())
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Connection Successful',
|
||||
'message': f'Connected to {self.name}. '
|
||||
f'{len(matched)} technician(s) matched by sync ID.',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
raise UserError(f"Cannot connect to {self.name}. Check URL, database, and API key.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUSH: send local task changes to remote instance
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_local_instance_id(self):
|
||||
"""Return this instance's own ID from config parameters."""
|
||||
return self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_claims.sync_instance_id', '')
|
||||
|
||||
@api.model
|
||||
def _push_tasks(self, tasks, operation='create'):
|
||||
"""Push local task changes to all active remote instances.
|
||||
Called from technician_task create/write overrides.
|
||||
Non-blocking: errors are logged, not raised.
|
||||
"""
|
||||
configs = self.sudo().search([('active', '=', True)])
|
||||
if not configs:
|
||||
return
|
||||
local_id = configs[0]._get_local_instance_id()
|
||||
if not local_id:
|
||||
return
|
||||
for config in configs:
|
||||
try:
|
||||
config._push_tasks_to_remote(tasks, operation, local_id)
|
||||
except Exception:
|
||||
_logger.exception("Task sync push to %s failed", config.name)
|
||||
|
||||
def _push_tasks_to_remote(self, tasks, operation, local_instance_id):
|
||||
"""Push task data to a single remote instance.
|
||||
|
||||
Maps additional_technician_ids via sync IDs so the remote instance
|
||||
also blocks those technicians' schedules.
|
||||
"""
|
||||
self.ensure_one()
|
||||
local_map = self._get_local_tech_map()
|
||||
remote_map = self._get_remote_tech_map()
|
||||
if not local_map or not remote_map:
|
||||
return
|
||||
|
||||
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
|
||||
|
||||
for task in tasks:
|
||||
sync_id = local_map.get(task.technician_id.id)
|
||||
if not sync_id:
|
||||
continue
|
||||
remote_tech_uid = remote_map.get(sync_id)
|
||||
if not remote_tech_uid:
|
||||
continue
|
||||
|
||||
# Map additional technicians to remote user IDs
|
||||
remote_additional_ids = []
|
||||
for tech in task.additional_technician_ids:
|
||||
add_sync_id = local_map.get(tech.id)
|
||||
if add_sync_id:
|
||||
remote_add_uid = remote_map.get(add_sync_id)
|
||||
if remote_add_uid:
|
||||
remote_additional_ids.append(remote_add_uid)
|
||||
|
||||
task_data = {
|
||||
'x_fc_sync_uuid': task.x_fc_sync_uuid,
|
||||
'x_fc_sync_source': local_instance_id,
|
||||
'x_fc_sync_remote_id': task.id,
|
||||
'name': f"[{local_instance_id.upper()}] {task.name}",
|
||||
'technician_id': remote_tech_uid,
|
||||
'additional_technician_ids': [(6, 0, remote_additional_ids)],
|
||||
'task_type': task.task_type,
|
||||
'status': task.status,
|
||||
'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False,
|
||||
'time_start': task.time_start,
|
||||
'time_end': task.time_end,
|
||||
'duration_hours': task.duration_hours,
|
||||
'address_street': task.address_street or '',
|
||||
'address_street2': task.address_street2 or '',
|
||||
'address_city': task.address_city or '',
|
||||
'address_zip': task.address_zip or '',
|
||||
'address_lat': float(task.address_lat or 0),
|
||||
'address_lng': float(task.address_lng or 0),
|
||||
'priority': task.priority or 'normal',
|
||||
'x_fc_sync_client_name': task.partner_id.name if task.partner_id else '',
|
||||
}
|
||||
|
||||
existing = self._rpc(
|
||||
'fusion.technician.task', 'search',
|
||||
[[('x_fc_sync_uuid', '=', task.x_fc_sync_uuid)]],
|
||||
{'limit': 1})
|
||||
|
||||
if operation in ('create', 'write'):
|
||||
if existing:
|
||||
self._rpc('fusion.technician.task', 'write',
|
||||
[existing, task_data], ctx)
|
||||
elif operation == 'create':
|
||||
task_data['sale_order_id'] = False
|
||||
self._rpc('fusion.technician.task', 'create',
|
||||
[[task_data]], ctx)
|
||||
|
||||
elif operation == 'unlink' and existing:
|
||||
self._rpc('fusion.technician.task', 'write',
|
||||
[existing, {'status': 'cancelled', 'active': False}], ctx)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PULL: cron-based full reconciliation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _cron_pull_remote_tasks(self):
|
||||
"""Cron job: pull tasks from all active remote instances."""
|
||||
configs = self.sudo().search([('active', '=', True)])
|
||||
for config in configs:
|
||||
try:
|
||||
config._pull_tasks_from_remote()
|
||||
config.sudo().write({
|
||||
'last_sync': fields.Datetime.now(),
|
||||
'last_sync_error': False,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception("Task sync pull from %s failed", config.name)
|
||||
config.sudo().write({'last_sync_error': str(e)})
|
||||
|
||||
def _pull_tasks_from_remote(self):
|
||||
"""Pull all active tasks for matched technicians from the remote instance."""
|
||||
self.ensure_one()
|
||||
local_syncid_to_uid = self._get_local_syncid_to_uid()
|
||||
if not local_syncid_to_uid:
|
||||
return
|
||||
|
||||
remote_map = self._get_remote_tech_map()
|
||||
if not remote_map:
|
||||
return
|
||||
|
||||
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
|
||||
if not matched_sync_ids:
|
||||
_logger.info("Task sync: no matched technicians between local and %s", self.name)
|
||||
return
|
||||
|
||||
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
|
||||
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
|
||||
|
||||
cutoff = fields.Date.today() - timedelta(days=7)
|
||||
remote_tasks = self._rpc(
|
||||
'fusion.technician.task', 'search_read',
|
||||
[[
|
||||
'|',
|
||||
('technician_id', 'in', remote_tech_ids),
|
||||
('additional_technician_ids', 'in', remote_tech_ids),
|
||||
('scheduled_date', '>=', str(cutoff)),
|
||||
('x_fc_sync_source', '=', False),
|
||||
]],
|
||||
{'fields': SYNC_TASK_FIELDS + ['id']})
|
||||
|
||||
if remote_tasks is None:
|
||||
return
|
||||
|
||||
Task = self.env['fusion.technician.task'].sudo().with_context(
|
||||
skip_task_sync=True, skip_travel_recalc=True)
|
||||
|
||||
remote_uuids = set()
|
||||
for rt in remote_tasks:
|
||||
sync_uuid = rt.get('x_fc_sync_uuid')
|
||||
if not sync_uuid:
|
||||
continue
|
||||
remote_uuids.add(sync_uuid)
|
||||
|
||||
remote_tech_raw = rt['technician_id']
|
||||
remote_uid = remote_tech_raw[0] if isinstance(remote_tech_raw, (list, tuple)) else remote_tech_raw
|
||||
tech_sync_id = remote_syncid_by_uid.get(remote_uid)
|
||||
local_uid = local_syncid_to_uid.get(tech_sync_id) if tech_sync_id else None
|
||||
if not local_uid:
|
||||
continue
|
||||
|
||||
partner_raw = rt.get('partner_id')
|
||||
client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
|
||||
|
||||
# Map additional technicians from remote to local
|
||||
local_additional_ids = []
|
||||
remote_add_raw = rt.get('additional_technician_ids', [])
|
||||
if remote_add_raw and isinstance(remote_add_raw, list):
|
||||
for add_uid in remote_add_raw:
|
||||
add_sync_id = remote_syncid_by_uid.get(add_uid)
|
||||
if add_sync_id:
|
||||
local_add_uid = local_syncid_to_uid.get(add_sync_id)
|
||||
if local_add_uid:
|
||||
local_additional_ids.append(local_add_uid)
|
||||
|
||||
vals = {
|
||||
'x_fc_sync_uuid': sync_uuid,
|
||||
'x_fc_sync_source': self.instance_id,
|
||||
'x_fc_sync_remote_id': rt['id'],
|
||||
'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}",
|
||||
'technician_id': local_uid,
|
||||
'additional_technician_ids': [(6, 0, local_additional_ids)],
|
||||
'task_type': rt.get('task_type', 'delivery'),
|
||||
'status': rt.get('status', 'scheduled'),
|
||||
'scheduled_date': rt.get('scheduled_date'),
|
||||
'time_start': rt.get('time_start', 9.0),
|
||||
'time_end': rt.get('time_end', 10.0),
|
||||
'duration_hours': rt.get('duration_hours', 1.0),
|
||||
'address_street': rt.get('address_street', ''),
|
||||
'address_street2': rt.get('address_street2', ''),
|
||||
'address_city': rt.get('address_city', ''),
|
||||
'address_zip': rt.get('address_zip', ''),
|
||||
'address_lat': rt.get('address_lat', 0),
|
||||
'address_lng': rt.get('address_lng', 0),
|
||||
'priority': rt.get('priority', 'normal'),
|
||||
'x_fc_sync_client_name': client_name,
|
||||
}
|
||||
|
||||
existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
vals['sale_order_id'] = False
|
||||
Task.create([vals])
|
||||
|
||||
stale_shadows = Task.search([
|
||||
('x_fc_sync_source', '=', self.instance_id),
|
||||
('x_fc_sync_uuid', 'not in', list(remote_uuids)),
|
||||
('scheduled_date', '>=', str(cutoff)),
|
||||
('active', '=', True),
|
||||
])
|
||||
if stale_shadows:
|
||||
stale_shadows.write({'active': False, 'status': 'cancelled'})
|
||||
_logger.info("Deactivated %d stale shadow tasks from %s",
|
||||
len(stale_shadows), self.instance_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CLEANUP
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _cron_cleanup_old_shadows(self):
|
||||
"""Remove shadow tasks older than 30 days (completed/cancelled)."""
|
||||
cutoff = fields.Date.today() - timedelta(days=30)
|
||||
old_shadows = self.env['fusion.technician.task'].sudo().search([
|
||||
('x_fc_sync_source', '!=', False),
|
||||
('scheduled_date', '<', str(cutoff)),
|
||||
('status', 'in', ['completed', 'cancelled']),
|
||||
])
|
||||
if old_shadows:
|
||||
count = len(old_shadows)
|
||||
old_shadows.unlink()
|
||||
_logger.info("Cleaned up %d old shadow tasks", count)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Manual trigger
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_sync_now(self):
|
||||
"""Manually trigger a full sync for this config."""
|
||||
self.ensure_one()
|
||||
self._pull_tasks_from_remote()
|
||||
self.sudo().write({
|
||||
'last_sync': fields.Datetime.now(),
|
||||
'last_sync_error': False,
|
||||
})
|
||||
shadow_count = self.env['fusion.technician.task'].sudo().search_count([
|
||||
('x_fc_sync_source', '=', self.instance_id),
|
||||
])
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Sync Complete',
|
||||
'message': f'Synced from {self.name}. {shadow_count} shadow task(s) now visible.',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""
|
||||
Fusion Technician Location
|
||||
GPS location logging for field technicians.
|
||||
"""
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionTechnicianLocation(models.Model):
|
||||
_name = 'fusion.technician.location'
|
||||
_description = 'Technician Location Log'
|
||||
_order = 'logged_at desc'
|
||||
|
||||
user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Technician',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
latitude = fields.Float(
|
||||
string='Latitude',
|
||||
digits=(10, 7),
|
||||
required=True,
|
||||
)
|
||||
longitude = fields.Float(
|
||||
string='Longitude',
|
||||
digits=(10, 7),
|
||||
required=True,
|
||||
)
|
||||
accuracy = fields.Float(
|
||||
string='Accuracy (m)',
|
||||
help='GPS accuracy in meters',
|
||||
)
|
||||
logged_at = fields.Datetime(
|
||||
string='Logged At',
|
||||
default=fields.Datetime.now,
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
source = fields.Selection([
|
||||
('portal', 'Portal'),
|
||||
('app', 'Mobile App'),
|
||||
], string='Source', default='portal')
|
||||
|
||||
@api.model
|
||||
def log_location(self, latitude, longitude, accuracy=None):
|
||||
"""Log the current user's location. Called from portal JS."""
|
||||
return self.sudo().create({
|
||||
'user_id': self.env.user.id,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'accuracy': accuracy or 0,
|
||||
'source': 'portal',
|
||||
})
|
||||
|
||||
@api.model
|
||||
def get_latest_locations(self):
|
||||
"""Get the most recent location for each technician (for map view)."""
|
||||
self.env.cr.execute("""
|
||||
SELECT DISTINCT ON (user_id)
|
||||
user_id, latitude, longitude, accuracy, logged_at
|
||||
FROM fusion_technician_location
|
||||
WHERE logged_at > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY user_id, logged_at DESC
|
||||
""")
|
||||
rows = self.env.cr.dictfetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
user = self.env['res.users'].sudo().browse(row['user_id'])
|
||||
result.append({
|
||||
'user_id': row['user_id'],
|
||||
'name': user.name,
|
||||
'latitude': row['latitude'],
|
||||
'longitude': row['longitude'],
|
||||
'accuracy': row['accuracy'],
|
||||
'logged_at': str(row['logged_at']),
|
||||
})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _cron_cleanup_old_locations(self):
|
||||
"""Remove location logs based on configurable retention setting.
|
||||
|
||||
Setting (fusion_claims.location_retention_days):
|
||||
- Empty / not set => keep 30 days (default)
|
||||
- "0" => delete at end of day (keep today only)
|
||||
- "1" .. "N" => keep for N days
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
raw = (ICP.get_param('fusion_claims.location_retention_days') or '').strip()
|
||||
|
||||
if raw == '':
|
||||
retention_days = 30 # default: 1 month
|
||||
else:
|
||||
try:
|
||||
retention_days = max(int(raw), 0)
|
||||
except (ValueError, TypeError):
|
||||
retention_days = 30
|
||||
|
||||
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=retention_days)
|
||||
old_records = self.search([('logged_at', '<', cutoff)])
|
||||
count = len(old_records)
|
||||
if count:
|
||||
old_records.unlink()
|
||||
_logger.info(
|
||||
"Cleaned up %d technician location records (retention=%d days)",
|
||||
count, retention_days,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user