This commit is contained in:
gsinghpal
2026-04-17 17:31:12 -04:00
parent e07002d550
commit b09538b4e2
26 changed files with 1996 additions and 173 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"0a121fd1-ed48-4ab3-a959-e02485e0a699","pid":32713,"acquiredAt":1776444791006}

View File

@@ -6,13 +6,15 @@
<odoo>
<data>
<!-- Cron Job: Calculate Travel Times for Technician Tasks (every 15 min) -->
<!-- Cron Job: Calculate Travel Times for Technician Tasks (every 30 min)
Only recalculates today's tasks whose technician has recent GPS
movement, to keep paid Google API usage bounded. -->
<record id="ir_cron_technician_travel_times" model="ir.cron">
<field name="name">Fusion Tasks: Calculate Technician Travel Times</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_calculate_travel_times()</field>
<field name="interval_number">15</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>

View File

@@ -41,6 +41,20 @@ class ResConfigSettings(models.TransientModel):
config_parameter='fusion_claims.technician_start_address',
help='Default start location for technician travel calculations (e.g. warehouse/office address)',
)
fc_osrm_url = fields.Char(
string='OSRM Base URL',
config_parameter='fusion_tasks.osrm_url',
help='Self-hosted OSRM endpoint (e.g. http://192.168.1.114:5000). '
'When set, Distance Matrix calls go to OSRM instead of Google, '
'saving paid API cost. Leave empty to use Google.',
)
fc_nominatim_url = fields.Char(
string='Nominatim Base URL',
config_parameter='fusion_tasks.nominatim_url',
help='Self-hosted Nominatim geocoding endpoint (e.g. http://192.168.1.114:8080). '
'When set, address geocoding goes to Nominatim instead of Google. '
'Leave empty to use Google.',
)
fc_location_retention_days = fields.Char(
string='Location History Retention (Days)',
config_parameter='fusion_claims.location_retention_days',

View File

@@ -28,14 +28,29 @@ class ResPartner(models.Model):
def _geocode_start_address(self, address):
if not address or not address.strip():
return 0.0, 0.0
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', '')
ICP = self.env['ir.config_parameter'].sudo()
addr = address.strip()
nominatim_url = (ICP.get_param('fusion_tasks.nominatim_url', '') or '').strip()
if nominatim_url:
try:
resp = requests.get(
f'{nominatim_url.rstrip("/")}/search',
params={'q': addr, 'format': 'json', 'limit': 1, 'countrycodes': 'ca'},
timeout=5,
headers={'User-Agent': 'fusion_tasks/1.0'},
)
data = resp.json()
if isinstance(data, list) and data:
return float(data[0]['lat']), float(data[0]['lon'])
except Exception as e:
_logger.warning("Nominatim start-address geocoding failed for '%s': %s", addr, e)
api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
if not api_key:
return 0.0, 0.0
try:
resp = requests.get(
'https://maps.googleapis.com/maps/api/geocode/json',
params={'address': address.strip(), 'key': api_key, 'region': 'ca'},
params={'address': addr, 'key': api_key, 'region': 'ca'},
timeout=10,
)
data = resp.json()

View File

@@ -978,11 +978,19 @@ class FusionTechnicianTask(models.Model):
task.prev_task_summary_html = Markup(html)
def _quick_travel_time(self, from_lat, from_lng, to_lat, to_lng):
"""Quick inline travel time calculation using Google Distance Matrix API.
"""Quick inline travel time calculation. Prefers self-hosted OSRM
when fusion_tasks.osrm_url is set; falls back to Google Distance Matrix.
Returns travel time in minutes, or 0 if unavailable."""
ICP = self.env['ir.config_parameter'].sudo()
osrm_url = (ICP.get_param('fusion_tasks.osrm_url', '') or '').strip()
if osrm_url:
minutes, _dist = self._osrm_travel(
osrm_url, from_lat, from_lng, to_lat, to_lng)
if minutes:
return minutes
try:
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.google_maps_api_key', '')
api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
if not api_key:
return 0
@@ -992,7 +1000,6 @@ class FusionTechnicianTask(models.Model):
'destinations': f'{to_lat},{to_lng}',
'mode': 'driving',
'avoid': 'tolls',
'departure_time': 'now',
'key': api_key,
}
resp = requests.get(url, params=params, timeout=5)
@@ -1000,9 +1007,7 @@ class FusionTechnicianTask(models.Model):
if data.get('status') == 'OK':
elements = data['rows'][0]['elements'][0]
if elements.get('status') == 'OK':
# Use duration_in_traffic if available, else duration
duration = elements.get(
'duration_in_traffic', elements.get('duration', {}))
duration = elements.get('duration', {})
seconds = duration.get('value', 0)
return max(1, int(seconds / 60))
except Exception:
@@ -1672,8 +1677,18 @@ class FusionTechnicianTask(models.Model):
.get_param('fusion_claims.technician_start_address', '') or '').strip()
def _geocode_address_string(self, address, api_key):
"""Geocode an address string and return (lat, lng) or (0.0, 0.0)."""
if not address or not api_key:
"""Geocode an address string. Prefers self-hosted Nominatim when
fusion_tasks.nominatim_url is set; falls back to Google Geocoding.
Returns (lat, lng) or (0.0, 0.0)."""
if not address:
return 0.0, 0.0
nominatim_url = (self.env['ir.config_parameter'].sudo()
.get_param('fusion_tasks.nominatim_url', '') or '').strip()
if nominatim_url:
lat, lng = self._nominatim_geocode(nominatim_url, address)
if lat and lng:
return lat, lng
if not api_key:
return 0.0, 0.0
try:
url = 'https://maps.googleapis.com/maps/api/geocode/json'
@@ -1687,6 +1702,46 @@ class FusionTechnicianTask(models.Model):
_logger.warning("Address geocoding failed for '%s': %s", address, e)
return 0.0, 0.0
def _get_technician_start_coords(self, tech_id, api_key):
"""Return cached (lat, lng) for a technician's start address.
Reads from res.partner.x_fc_start_address_lat/lng (populated on
partner write). Falls back to the company-level parameter with
its own cached lat/lng in ir.config_parameter. Geocodes only
when no cache exists, then persists so subsequent cron runs skip
the API entirely.
"""
ICP = self.env['ir.config_parameter'].sudo()
tech_user = self.env['res.users'].sudo().browse(tech_id)
if tech_user.exists() and tech_user.x_fc_start_address:
partner = tech_user.partner_id
if partner.x_fc_start_address_lat and partner.x_fc_start_address_lng:
return partner.x_fc_start_address_lat, partner.x_fc_start_address_lng
lat, lng = self._geocode_address_string(
tech_user.x_fc_start_address.strip(), api_key)
if lat and lng:
partner.sudo().write({
'x_fc_start_address_lat': lat,
'x_fc_start_address_lng': lng,
})
return lat, lng
hq_addr = (ICP.get_param('fusion_claims.technician_start_address', '') or '').strip()
if not hq_addr:
return 0.0, 0.0
cached_key = f'fusion_tasks.hq_coords:{hq_addr}'
cached = ICP.get_param(cached_key, '')
if cached and ',' in cached:
try:
lat_s, lng_s = cached.split(',', 1)
return float(lat_s), float(lng_s)
except ValueError:
pass
lat, lng = self._geocode_address_string(hq_addr, api_key)
if lat and lng:
ICP.set_param(cached_key, f'{lat},{lng}')
return lat, lng
def _recalculate_combos_travel(self, combos):
"""Recalculate travel for a set of (tech_id, date) combinations.
@@ -1728,11 +1783,9 @@ class FusionTechnicianTask(models.Model):
cl = clock_locations[tech_id]
start_coords_cache[cache_key] = (cl['lat'], cl['lng'])
else:
addr = self._get_technician_start_address(tech_id)
start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
start_coords_cache[cache_key] = self._get_technician_start_coords(tech_id, api_key)
else:
addr = self._get_technician_start_address(tech_id)
start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
start_coords_cache[cache_key] = self._get_technician_start_coords(tech_id, api_key)
all_day_tasks = self.sudo().search([
'|',
@@ -2530,8 +2583,22 @@ class FusionTechnicianTask(models.Model):
if hq_address:
if not hq_lat and not hq_lng:
hq_lat, hq_lng = self._geocode_address_string(
hq_address, api_key)
ICP = self.env['ir.config_parameter'].sudo()
cached = ICP.get_param(f'fusion_tasks.hq_coords:{hq_address}', '')
if cached and ',' in cached:
try:
lat_s, lng_s = cached.split(',', 1)
hq_lat, hq_lng = float(lat_s), float(lng_s)
except ValueError:
hq_lat, hq_lng = 0.0, 0.0
if not hq_lat or not hq_lng:
hq_lat, hq_lng = self._geocode_address_string(
hq_address, api_key)
if hq_lat and hq_lng:
ICP.set_param(
f'fusion_tasks.hq_coords:{hq_address}',
f'{hq_lat},{hq_lng}',
)
if hq_lat and hq_lng:
result[uid] = {
'lat': hq_lat, 'lng': hq_lng,
@@ -2620,12 +2687,23 @@ class FusionTechnicianTask(models.Model):
return result
def _geocode_address(self):
"""Geocode the task address using Google Geocoding API."""
"""Geocode the task address. Prefers self-hosted Nominatim when
fusion_tasks.nominatim_url is set; falls back to Google Geocoding."""
self.ensure_one()
api_key = self._get_google_maps_api_key()
if not api_key or not self.address_display:
if not self.address_display:
return False
ICP = self.env['ir.config_parameter'].sudo()
nominatim_url = (ICP.get_param('fusion_tasks.nominatim_url', '') or '').strip()
if nominatim_url:
lat, lng = self._nominatim_geocode(nominatim_url, self.address_display)
if lat and lng:
self.write({'address_lat': lat, 'address_lng': lng})
return True
api_key = self._get_google_maps_api_key()
if not api_key:
return False
try:
url = 'https://maps.googleapis.com/maps/api/geocode/json'
params = {
@@ -2647,14 +2725,29 @@ class FusionTechnicianTask(models.Model):
return False
def _calculate_travel_time(self, origin_lat, origin_lng):
"""Calculate travel time from origin to this task using Distance Matrix API."""
"""Calculate travel time from origin to this task. Prefers self-hosted
OSRM when fusion_tasks.osrm_url is set; falls back to Google Distance
Matrix."""
self.ensure_one()
api_key = self._get_google_maps_api_key()
if not api_key:
return False
if not (origin_lat and origin_lng and self.address_lat and self.address_lng):
return False
ICP = self.env['ir.config_parameter'].sudo()
osrm_url = (ICP.get_param('fusion_tasks.osrm_url', '') or '').strip()
if osrm_url:
minutes, km = self._osrm_travel(
osrm_url, origin_lat, origin_lng,
self.address_lat, self.address_lng)
if minutes:
self.write({
'travel_time_minutes': minutes,
'travel_distance_km': km,
})
return True
api_key = self._get_google_maps_api_key()
if not api_key:
return False
try:
url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
params = {
@@ -2663,15 +2756,13 @@ class FusionTechnicianTask(models.Model):
'key': api_key,
'mode': 'driving',
'avoid': 'tolls',
'traffic_model': 'best_guess',
'departure_time': 'now',
}
resp = requests.get(url, params=params, timeout=10)
data = resp.json()
if data.get('status') == 'OK':
element = data['rows'][0]['elements'][0]
if element.get('status') == 'OK':
duration_seconds = element['duration_in_traffic']['value'] if 'duration_in_traffic' in element else element['duration']['value']
duration_seconds = element['duration']['value']
distance_meters = element['distance']['value']
self.write({
'travel_time_minutes': round(duration_seconds / 60),
@@ -2682,6 +2773,49 @@ class FusionTechnicianTask(models.Model):
_logger.warning(f"Travel time calculation failed for task {self.name}: {e}")
return False
@api.model
def _osrm_travel(self, osrm_url, from_lat, from_lng, to_lat, to_lng):
"""Query self-hosted OSRM /route for driving time + distance.
Returns (minutes, km) or (0, 0) on failure."""
try:
url = (f'{osrm_url.rstrip("/")}/route/v1/driving/'
f'{from_lng},{from_lat};{to_lng},{to_lat}?overview=false')
resp = requests.get(url, timeout=5)
data = resp.json()
if data.get('code') == 'Ok' and data.get('routes'):
route = data['routes'][0]
minutes = max(1, round(route['duration'] / 60))
km = round(route['distance'] / 1000, 1)
return minutes, km
except Exception as e:
_logger.warning("OSRM travel query failed: %s", e)
return 0, 0
@api.model
def _nominatim_geocode(self, nominatim_url, address):
"""Query self-hosted Nominatim /search for address → (lat, lng).
Returns (0.0, 0.0) on failure so callers can fall through to Google."""
if not address or not address.strip():
return 0.0, 0.0
try:
url = f'{nominatim_url.rstrip("/")}/search'
params = {
'q': address.strip(),
'format': 'json',
'limit': 1,
'countrycodes': 'ca',
}
resp = requests.get(
url, params=params, timeout=5,
headers={'User-Agent': 'fusion_tasks/1.0'},
)
data = resp.json()
if isinstance(data, list) and data:
return float(data[0]['lat']), float(data[0]['lon'])
except Exception as e:
_logger.warning("Nominatim geocoding failed for '%s': %s", address, e)
return 0.0, 0.0
def action_calculate_travel_times(self):
"""Calculate travel times for a day's schedule. Called from backend button or cron."""
self._do_calculate_travel_times()
@@ -2723,12 +2857,10 @@ class FusionTechnicianTask(models.Model):
prev_lat, prev_lng = cl['lat'], cl['lng']
origin_label = 'Clock-In Location'
else:
addr = self._get_technician_start_address(tech_id)
prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
prev_lat, prev_lng = self._get_technician_start_coords(tech_id, api_key)
origin_label = 'Start Location'
else:
addr = self._get_technician_start_address(tech_id)
prev_lat, prev_lng = self._geocode_address_string(addr, api_key)
prev_lat, prev_lng = self._get_technician_start_coords(tech_id, api_key)
origin_label = 'Start Location'
# Skip already-completed tasks for today (chain starts from
@@ -2765,22 +2897,47 @@ class FusionTechnicianTask(models.Model):
@api.model
def _cron_calculate_travel_times(self):
"""Cron job: Calculate travel times for today and tomorrow.
"""Cron job: Refresh travel times for TODAY's active tasks only.
Runs every 15 minutes. For today's tasks, uses the tech's latest
GPS location so ETAs stay accurate as technicians move.
Includes completed tasks in the search so the chain can skip
them and use their completion location as origin.
Future-dated tasks are handled on create/write, so we don't hit
the Distance Matrix API for them every 15 minutes. Completed
and cancelled tasks are skipped. If a task already has a
travel_time set and its technician hasn't moved recently, we
skip the API call to avoid redundant billing.
"""
today = fields.Date.context_today(self)
tomorrow = today + timedelta(days=1)
tasks = self.search([
('scheduled_date', 'in', [today, tomorrow]),
('status', 'not in', ['cancelled']),
('scheduled_date', '=', today),
('status', 'not in', ['cancelled', 'completed']),
])
if tasks:
tasks._do_calculate_travel_times()
_logger.info(f"Calculated travel times for {len(tasks)} tasks")
if not tasks:
return
# Skip techs with no recent GPS movement if every task in their
# chain already has travel_time computed — no point spending API
# calls to recompute a value that won't change.
cutoff = fields.Datetime.subtract(fields.Datetime.now(), minutes=20)
Location = self.env['fusion.technician.location'].sudo()
moving_tech_ids = set(Location.search([
('logged_at', '>', cutoff),
('source', '!=', 'sync'),
]).mapped('user_id.id'))
def _keep(task):
if not task.travel_time_minutes:
return True
tid = task.technician_id.id
if tid and tid in moving_tech_ids:
return True
return False
stale = tasks.filtered(_keep)
if stale:
stale._do_calculate_travel_times()
_logger.info("fusion_tasks cron: recalculated travel for %d / %d tasks",
len(stale), len(tasks))
else:
_logger.info("fusion_tasks cron: all %d tasks fresh, skipped API", len(tasks))
@api.model
def _cron_check_late_arrivals(self):

View File

@@ -83,6 +83,32 @@
</div>
</div>
</div>
<!-- Self-hosted OSRM URL -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Self-hosted OSRM URL</span>
<div class="text-muted">
Optional. When set, travel-time calculations use your self-hosted OSRM server instead of Google Distance Matrix, saving paid API cost.
Leave empty to keep using Google.
</div>
<div class="mt-2">
<field name="fc_osrm_url" placeholder="http://192.168.1.114:5000"/>
</div>
</div>
</div>
<!-- Self-hosted Nominatim URL -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Self-hosted Nominatim URL</span>
<div class="text-muted">
Optional. When set, address geocoding uses your self-hosted Nominatim server instead of Google Geocoding.
Leave empty to keep using Google.
</div>
<div class="mt-2">
<field name="fc_nominatim_url" placeholder="http://192.168.1.114:8080"/>
</div>
</div>
</div>
<!-- Location History Retention -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">