changes
This commit is contained in:
1
fusion_tasks/.claude/scheduled_tasks.lock
Normal file
1
fusion_tasks/.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"0a121fd1-ed48-4ab3-a959-e02485e0a699","pid":32713,"acquiredAt":1776444791006}
|
||||
@@ -6,13 +6,15 @@
|
||||
<odoo>
|
||||
<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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -41,6 +41,20 @@ class ResConfigSettings(models.TransientModel):
|
||||
config_parameter='fusion_claims.technician_start_address',
|
||||
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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user