feat: add fusion_tasks module for field service management

Standalone module extracted from fusion_claims providing technician
scheduling, route simulation with Google Maps, GPS tracking, and
cross-instance task sync between odoo-westin and odoo-mobility.

Includes fix for route simulation: added Directions API error logging
to diagnose silent failures from conflicting Google Maps API keys.

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-03-09 16:56:53 -04:00
parent b649246e81
commit 3b3c57205a
23 changed files with 7445 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
Fusion Technician Location
GPS location logging for field technicians.
"""
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class FusionTechnicianLocation(models.Model):
_name = 'fusion.technician.location'
_description = 'Technician Location Log'
_order = 'logged_at desc'
user_id = fields.Many2one(
'res.users',
string='Technician',
required=True,
index=True,
ondelete='cascade',
)
latitude = fields.Float(
string='Latitude',
digits=(10, 7),
required=True,
)
longitude = fields.Float(
string='Longitude',
digits=(10, 7),
required=True,
)
accuracy = fields.Float(
string='Accuracy (m)',
help='GPS accuracy in meters',
)
logged_at = fields.Datetime(
string='Logged At',
default=fields.Datetime.now,
required=True,
index=True,
)
source = fields.Selection([
('portal', 'Portal'),
('app', 'Mobile App'),
('sync', 'Synced'),
], string='Source', default='portal')
sync_instance = fields.Char(
'Sync Instance', index=True,
help='Source instance ID if synced (e.g. westin, mobility)',
)
@api.model
def log_location(self, latitude, longitude, accuracy=None):
"""Log the current user's location. Called from portal JS."""
return self.sudo().create({
'user_id': self.env.user.id,
'latitude': latitude,
'longitude': longitude,
'accuracy': accuracy or 0,
'source': 'portal',
})
@api.model
def get_latest_locations(self):
"""Get the most recent location for each technician (for map view).
Includes both local GPS pings and synced locations from remote
instances, so the map shows all shared technicians regardless of
which Odoo instance they are clocked into.
"""
self.env.cr.execute("""
SELECT DISTINCT ON (user_id)
user_id, latitude, longitude, accuracy, logged_at,
COALESCE(sync_instance, '') AS sync_instance
FROM fusion_technician_location
WHERE logged_at > NOW() - INTERVAL '24 hours'
ORDER BY user_id, logged_at DESC
""")
rows = self.env.cr.dictfetchall()
local_id = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.sync_instance_id', '')
result = []
for row in rows:
user = self.env['res.users'].sudo().browse(row['user_id'])
src = row.get('sync_instance') or local_id
result.append({
'user_id': row['user_id'],
'name': user.name,
'latitude': row['latitude'],
'longitude': row['longitude'],
'accuracy': row['accuracy'],
'logged_at': str(row['logged_at']),
'sync_instance': src,
})
return result
@api.model
def _cron_cleanup_old_locations(self):
"""Remove location logs based on configurable retention setting.
Setting (fusion_claims.location_retention_days):
- Empty / not set => keep 30 days (default)
- "0" => delete at end of day (keep today only)
- "1" .. "N" => keep for N days
"""
ICP = self.env['ir.config_parameter'].sudo()
raw = (ICP.get_param('fusion_claims.location_retention_days') or '').strip()
if raw == '':
retention_days = 30 # default: 1 month
else:
try:
retention_days = max(int(raw), 0)
except (ValueError, TypeError):
retention_days = 30
cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=retention_days)
old_records = self.search([('logged_at', '<', cutoff)])
count = len(old_records)
if count:
old_records.unlink()
_logger.info(
"Cleaned up %d technician location records (retention=%d days)",
count, retention_days,
)