changes
This commit is contained in:
@@ -350,6 +350,13 @@ class FusionTechnicianTask(models.Model):
|
||||
compute='_compute_address_display',
|
||||
)
|
||||
|
||||
# In-store flag -- uses company address instead of client address
|
||||
is_in_store = fields.Boolean(
|
||||
string='In Store',
|
||||
default=False,
|
||||
help='Task takes place at the store/office. Uses company address automatically.',
|
||||
)
|
||||
|
||||
# Geocoding
|
||||
address_lat = fields.Float(string='Latitude', digits=(10, 7))
|
||||
address_lng = fields.Float(string='Longitude', digits=(10, 7))
|
||||
@@ -382,6 +389,30 @@ class FusionTechnicianTask(models.Model):
|
||||
string='Completed At',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# GPS location captured at task actions
|
||||
started_latitude = fields.Float(
|
||||
string='Started Latitude', digits=(10, 7), readonly=True,
|
||||
)
|
||||
started_longitude = fields.Float(
|
||||
string='Started Longitude', digits=(10, 7), readonly=True,
|
||||
)
|
||||
completed_latitude = fields.Float(
|
||||
string='Completed Latitude', digits=(10, 7), readonly=True,
|
||||
)
|
||||
completed_longitude = fields.Float(
|
||||
string='Completed Longitude', digits=(10, 7), readonly=True,
|
||||
)
|
||||
action_latitude = fields.Float(
|
||||
string='Last Action Latitude', digits=(10, 7), readonly=True,
|
||||
)
|
||||
action_longitude = fields.Float(
|
||||
string='Last Action Longitude', digits=(10, 7), readonly=True,
|
||||
)
|
||||
action_location_accuracy = fields.Float(
|
||||
string='Location Accuracy (m)', readonly=True,
|
||||
)
|
||||
|
||||
voice_note_audio = fields.Binary(
|
||||
string='Voice Recording',
|
||||
attachment=True,
|
||||
@@ -961,9 +992,21 @@ class FusionTechnicianTask(models.Model):
|
||||
# ONCHANGE - Auto-fill address from client
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.onchange('is_in_store')
|
||||
def _onchange_is_in_store(self):
|
||||
"""Auto-fill company address when task is marked as in-store."""
|
||||
if self.is_in_store:
|
||||
company_partner = self.env.company.partner_id
|
||||
if company_partner and company_partner.street:
|
||||
self._fill_address_from_partner(company_partner)
|
||||
else:
|
||||
self.address_street = self.env.company.name or 'In Store'
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
"""Auto-fill address fields from the selected client's address."""
|
||||
if self.is_in_store:
|
||||
return
|
||||
if self.partner_id:
|
||||
addr = self.partner_id
|
||||
self.address_partner_id = addr.id
|
||||
@@ -1046,6 +1089,19 @@ class FusionTechnicianTask(models.Model):
|
||||
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
|
||||
))
|
||||
|
||||
@api.constrains('address_street', 'address_lat', 'address_lng', 'is_in_store')
|
||||
def _check_address_required(self):
|
||||
"""Non-in-store tasks must have a geocoded address."""
|
||||
for task in self:
|
||||
if task.x_fc_sync_source:
|
||||
continue
|
||||
if task.is_in_store:
|
||||
continue
|
||||
if not task.address_street:
|
||||
raise ValidationError(_(
|
||||
"A valid address is required. If this task is at the store, "
|
||||
"please check the 'In Store' option."
|
||||
))
|
||||
|
||||
@api.constrains('technician_id', 'additional_technician_ids',
|
||||
'scheduled_date', 'time_start', 'time_end')
|
||||
@@ -1334,6 +1390,14 @@ class FusionTechnicianTask(models.Model):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('fusion.technician.task') or _('New')
|
||||
if not vals.get('x_fc_sync_uuid') and not vals.get('x_fc_sync_source'):
|
||||
vals['x_fc_sync_uuid'] = str(uuid.uuid4())
|
||||
# In-store tasks: auto-fill company address
|
||||
if vals.get('is_in_store') and not vals.get('address_street'):
|
||||
company_partner = self.env.company.partner_id
|
||||
if company_partner and company_partner.street:
|
||||
self._fill_address_vals(vals, company_partner)
|
||||
else:
|
||||
vals['address_street'] = self.env.company.name or 'In Store'
|
||||
|
||||
# Auto-populate address from sale order if not provided
|
||||
if vals.get('sale_order_id') and not vals.get('address_street'):
|
||||
order = self.env['sale.order'].browse(vals['sale_order_id'])
|
||||
@@ -1676,6 +1740,22 @@ class FusionTechnicianTask(models.Model):
|
||||
"Please complete previous task %s first before starting this one."
|
||||
) % earlier_incomplete.name)
|
||||
|
||||
def _write_action_location(self, extra_vals=None):
|
||||
"""Write GPS coordinates from context onto the task record."""
|
||||
ctx = self.env.context
|
||||
lat = ctx.get('action_latitude', 0)
|
||||
lng = ctx.get('action_longitude', 0)
|
||||
acc = ctx.get('action_accuracy', 0)
|
||||
vals = {
|
||||
'action_latitude': lat,
|
||||
'action_longitude': lng,
|
||||
'action_location_accuracy': acc,
|
||||
}
|
||||
if extra_vals:
|
||||
vals.update(extra_vals)
|
||||
if lat and lng:
|
||||
self.with_context(skip_travel_recalc=True).write(vals)
|
||||
|
||||
def action_start_en_route(self):
|
||||
"""Mark task as En Route."""
|
||||
for task in self:
|
||||
@@ -1683,6 +1763,7 @@ class FusionTechnicianTask(models.Model):
|
||||
raise UserError(_("Only scheduled tasks can be marked as En Route."))
|
||||
task._check_previous_tasks_completed()
|
||||
task.status = 'en_route'
|
||||
task._write_action_location()
|
||||
task._post_status_message('en_route')
|
||||
|
||||
def action_start_task(self):
|
||||
@@ -1692,6 +1773,11 @@ class FusionTechnicianTask(models.Model):
|
||||
raise UserError(_("Task must be scheduled or en route to start."))
|
||||
task._check_previous_tasks_completed()
|
||||
task.status = 'in_progress'
|
||||
ctx = self.env.context
|
||||
task._write_action_location({
|
||||
'started_latitude': ctx.get('action_latitude', 0),
|
||||
'started_longitude': ctx.get('action_longitude', 0),
|
||||
})
|
||||
task._post_status_message('in_progress')
|
||||
|
||||
def action_view_sale_order(self):
|
||||
@@ -1742,9 +1828,15 @@ class FusionTechnicianTask(models.Model):
|
||||
"technician portal first."
|
||||
))
|
||||
|
||||
ctx = self.env.context
|
||||
task.with_context(skip_travel_recalc=True).write({
|
||||
'status': 'completed',
|
||||
'completion_datetime': fields.Datetime.now(),
|
||||
'completed_latitude': ctx.get('action_latitude', 0),
|
||||
'completed_longitude': ctx.get('action_longitude', 0),
|
||||
'action_latitude': ctx.get('action_latitude', 0),
|
||||
'action_longitude': ctx.get('action_longitude', 0),
|
||||
'action_location_accuracy': ctx.get('action_accuracy', 0),
|
||||
})
|
||||
task._post_status_message('completed')
|
||||
if task.completion_notes and (task.sale_order_id or task.purchase_order_id):
|
||||
@@ -1811,6 +1903,7 @@ class FusionTechnicianTask(models.Model):
|
||||
if task.status == 'completed':
|
||||
raise UserError(_("Cannot cancel a completed task."))
|
||||
task.status = 'cancelled'
|
||||
task._write_action_location()
|
||||
task._post_status_message('cancelled')
|
||||
# If this was a delivery task linked to a sale order that is
|
||||
# currently in "Ready for Delivery" -- revert the order back.
|
||||
@@ -2302,20 +2395,144 @@ class FusionTechnicianTask(models.Model):
|
||||
base_domain,
|
||||
['name', 'partner_id', 'technician_id', 'task_type',
|
||||
'address_lat', 'address_lng', 'address_display',
|
||||
'time_start', 'time_start_display', 'time_end_display',
|
||||
'time_start', 'time_end', 'time_start_display', 'time_end_display',
|
||||
'status', 'scheduled_date', 'travel_time_minutes',
|
||||
'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'],
|
||||
order='scheduled_date asc NULLS LAST, time_start asc',
|
||||
limit=500,
|
||||
)
|
||||
locations = self.env['fusion.technician.location'].get_latest_locations()
|
||||
tech_starts = self._get_tech_start_locations(tasks, api_key)
|
||||
return {
|
||||
'api_key': api_key,
|
||||
'tasks': tasks,
|
||||
'locations': locations,
|
||||
'local_instance_id': local_instance,
|
||||
'tech_start_locations': tech_starts,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_tech_start_locations(self, tasks, api_key):
|
||||
"""Build a dict of technician start locations for route origins.
|
||||
|
||||
Priority per technician:
|
||||
1. Today's fusion_clock check-in location (if module installed)
|
||||
2. Personal start address (x_fc_start_address with cached lat/lng)
|
||||
3. Company default HQ address
|
||||
"""
|
||||
tech_ids = {
|
||||
t['technician_id'][0]
|
||||
for t in tasks
|
||||
if t.get('technician_id')
|
||||
}
|
||||
if not tech_ids:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
today = fields.Date.today()
|
||||
|
||||
clock_locations = self._get_clock_in_locations(tech_ids, today)
|
||||
|
||||
hq_address = (
|
||||
self.env['ir.config_parameter'].sudo()
|
||||
.get_param('fusion_claims.technician_start_address', '') or ''
|
||||
).strip()
|
||||
hq_lat, hq_lng = 0.0, 0.0
|
||||
|
||||
for uid in tech_ids:
|
||||
if uid in clock_locations:
|
||||
result[uid] = clock_locations[uid]
|
||||
continue
|
||||
|
||||
user = self.env['res.users'].sudo().browse(uid)
|
||||
if not user.exists():
|
||||
continue
|
||||
partner = user.partner_id
|
||||
|
||||
if partner.x_fc_start_address and partner.x_fc_start_address.strip():
|
||||
lat = partner.x_fc_start_address_lat
|
||||
lng = partner.x_fc_start_address_lng
|
||||
if not lat or not lng:
|
||||
lat, lng = self._geocode_address_string(
|
||||
partner.x_fc_start_address, api_key)
|
||||
if lat and lng:
|
||||
partner.sudo().write({
|
||||
'x_fc_start_address_lat': lat,
|
||||
'x_fc_start_address_lng': lng,
|
||||
})
|
||||
if lat and lng:
|
||||
result[uid] = {
|
||||
'lat': lat, 'lng': lng,
|
||||
'address': partner.x_fc_start_address.strip(),
|
||||
'source': 'start_address',
|
||||
}
|
||||
continue
|
||||
|
||||
if hq_address:
|
||||
if not hq_lat and not hq_lng:
|
||||
hq_lat, hq_lng = self._geocode_address_string(
|
||||
hq_address, api_key)
|
||||
if hq_lat and hq_lng:
|
||||
result[uid] = {
|
||||
'lat': hq_lat, 'lng': hq_lng,
|
||||
'address': hq_address,
|
||||
'source': 'company_hq',
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _get_clock_in_locations(self, tech_ids, today):
|
||||
"""Get today's clock-in lat/lng from fusion_clock if installed."""
|
||||
result = {}
|
||||
try:
|
||||
module = self.env['ir.module.module'].sudo().search([
|
||||
('name', '=', 'fusion_clock'),
|
||||
('state', '=', 'installed'),
|
||||
], limit=1)
|
||||
if not module:
|
||||
return result
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
try:
|
||||
Attendance = self.env['hr.attendance'].sudo()
|
||||
Employee = self.env['hr.employee'].sudo()
|
||||
except KeyError:
|
||||
return result
|
||||
|
||||
employees = Employee.search([
|
||||
('user_id', 'in', list(tech_ids)),
|
||||
])
|
||||
emp_to_user = {e.id: e.user_id.id for e in employees}
|
||||
|
||||
if not employees:
|
||||
return result
|
||||
|
||||
today_start = dt_datetime.combine(today, dt_datetime.min.time())
|
||||
today_end = today_start + timedelta(days=1)
|
||||
|
||||
attendances = Attendance.search([
|
||||
('employee_id', 'in', employees.ids),
|
||||
('check_in', '>=', today_start),
|
||||
('check_in', '<', today_end),
|
||||
], order='check_in asc')
|
||||
|
||||
for att in attendances:
|
||||
uid = emp_to_user.get(att.employee_id.id)
|
||||
if not uid or uid in result:
|
||||
continue
|
||||
loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
|
||||
if loc and loc.latitude and loc.longitude:
|
||||
result[uid] = {
|
||||
'lat': loc.latitude,
|
||||
'lng': loc.longitude,
|
||||
'address': loc.address or loc.name or '',
|
||||
'source': 'clock_in',
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def _geocode_address(self):
|
||||
"""Geocode the task address using Google Geocoding API."""
|
||||
self.ensure_one()
|
||||
@@ -2573,12 +2790,14 @@ class FusionTechnicianTask(models.Model):
|
||||
return f'{display_hour}:{minutes:02d} {period}'
|
||||
|
||||
def get_google_maps_url(self):
|
||||
"""Get Google Maps navigation URL. Uses lat/lng coordinates to
|
||||
navigate to the exact location (text addresses cause Google to
|
||||
resolve to nearby business names instead)."""
|
||||
"""Get Google Maps navigation URL using the text address so the
|
||||
destination shows a proper street name instead of raw coordinates.
|
||||
Returns a google.com/maps URL that Android auto-opens in the app;
|
||||
iOS handling is done client-side via JS to launch comgooglemaps://."""
|
||||
self.ensure_one()
|
||||
if self.address_display:
|
||||
addr = urllib.parse.quote(self.address_display)
|
||||
return f'https://www.google.com/maps/dir/?api=1&destination={addr}&travelmode=driving'
|
||||
if self.address_lat and self.address_lng:
|
||||
return f'https://www.google.com/maps/dir/?api=1&destination={self.address_lat},{self.address_lng}&travelmode=driving'
|
||||
elif self.address_display:
|
||||
return f'https://www.google.com/maps/dir/?api=1&destination={urllib.parse.quote(self.address_display)}&travelmode=driving'
|
||||
return ''
|
||||
|
||||
Reference in New Issue
Block a user