feat(fusion_claims): action_book_from_wizard + jsonrpc booking routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import logging
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from . import wizard
|
from . import wizard
|
||||||
|
from . import controllers
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
1
fusion_claims/controllers/__init__.py
Normal file
1
fusion_claims/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import service_booking
|
||||||
38
fusion_claims/controllers/service_booking.py
Normal file
38
fusion_claims/controllers/service_booking.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceBookingController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/fusion_claims/service_booking/refdata', type='jsonrpc', auth='user')
|
||||||
|
def refdata(self, **kw):
|
||||||
|
env = request.env
|
||||||
|
Users = env['res.users']
|
||||||
|
techs = Users.search([('x_fc_is_field_staff', '=', True)]) \
|
||||||
|
if 'x_fc_is_field_staff' in Users._fields else Users.search([])
|
||||||
|
Rate = env['fusion.service.rate']
|
||||||
|
rates = Rate.search([('rate_kind', '=', 'callout'), ('active', '=', True)])
|
||||||
|
per_km = Rate.get_rate('per_km')
|
||||||
|
|
||||||
|
def labour(code):
|
||||||
|
r = Rate.get_rate(code)
|
||||||
|
return r.price if r else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'technicians': [{'id': t.id, 'name': t.name} for t in techs],
|
||||||
|
'callout_rates': [{
|
||||||
|
'code': r.code, 'category': r.category, 'timing': r.timing,
|
||||||
|
'name': r.name, 'price': r.price, 'adds_per_km': r.adds_per_km,
|
||||||
|
} for r in rates],
|
||||||
|
'per_km': per_km.price if per_km else 0.70,
|
||||||
|
'labour': {'onsite': labour('labour_onsite'), 'inshop': labour('labour_inshop'),
|
||||||
|
'lift': labour('labour_lift')},
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/fusion_claims/service_booking/submit', type='jsonrpc', auth='user')
|
||||||
|
def submit(self, payload=None, **kw):
|
||||||
|
try:
|
||||||
|
return request.env['fusion.technician.task'].action_book_from_wizard(payload or {})
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': str(e)}
|
||||||
@@ -447,6 +447,117 @@ class FusionTechnicianTaskClaims(models.Model):
|
|||||||
}
|
}
|
||||||
return self.env['sale.order'].create(so_vals)
|
return self.env['sale.order'].create(so_vals)
|
||||||
|
|
||||||
|
def _service_travel_origin(self):
|
||||||
|
"""Return (lat, lng) of the technician's day-start location, to be used
|
||||||
|
as the ORIGIN for the per-km travel calculation. NEVER returns the job's
|
||||||
|
own address (that would give origin == destination == 0 km).
|
||||||
|
|
||||||
|
Fallback chain (all read-only — no geocoding API calls here):
|
||||||
|
1. The technician's personal start address cached coords
|
||||||
|
(res.users.partner_id.x_fc_start_address_lat/_lng — populated when
|
||||||
|
the start address is saved, see fusion_tasks/models/res_partner.py).
|
||||||
|
2. The company HQ start address cached coords, keyed off the
|
||||||
|
``fusion_claims.technician_start_address`` ICP and cached by
|
||||||
|
fusion_tasks under ``fusion_tasks.hq_coords:<address>``.
|
||||||
|
3. (0.0, 0.0) — the correct graceful fallback. _calculate_travel_time
|
||||||
|
guards on a falsy origin and simply returns False (→ no per-km line).
|
||||||
|
|
||||||
|
Geocoding is deliberately NOT performed here: a freshly typed new-client
|
||||||
|
job address usually has no geocoded destination anyway, so distance is
|
||||||
|
expected to be 0 in v1. We only avoid passing a WRONG origin.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
tech = self.technician_id
|
||||||
|
if tech:
|
||||||
|
partner = tech.partner_id
|
||||||
|
if partner and 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
|
||||||
|
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
hq_addr = (ICP.get_param('fusion_claims.technician_start_address', '') or '').strip()
|
||||||
|
if hq_addr:
|
||||||
|
cached = ICP.get_param('fusion_tasks.hq_coords:%s' % hq_addr, '')
|
||||||
|
if cached and ',' in cached:
|
||||||
|
try:
|
||||||
|
lat_s, lng_s = cached.split(',', 1)
|
||||||
|
return float(lat_s), float(lng_s)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def action_book_from_wizard(self, payload):
|
||||||
|
"""Single entry point for the OWL booking wizard:
|
||||||
|
resolve/create contact -> create task -> compute distance -> build SO -> link.
|
||||||
|
Returns {'task_id', 'order_id'}."""
|
||||||
|
Partner = self.env['res.partner']
|
||||||
|
cust = payload.get('customer') or {}
|
||||||
|
|
||||||
|
# 1. contact: new -> find-or-create (match email then phone); existing -> chosen partner
|
||||||
|
if payload.get('cust_mode') == 'new':
|
||||||
|
partner = False
|
||||||
|
email = (cust.get('email') or '').strip()
|
||||||
|
phone = (cust.get('phone') or '').strip()
|
||||||
|
if email:
|
||||||
|
partner = Partner.search([('email', '=ilike', email)], limit=1)
|
||||||
|
if not partner and phone:
|
||||||
|
partner = Partner.search([('phone', '=', phone)], limit=1)
|
||||||
|
if not partner:
|
||||||
|
partner = Partner.create({
|
||||||
|
'name': cust.get('name') or 'Walk-in',
|
||||||
|
'phone': phone or False, 'email': email or False,
|
||||||
|
'street': cust.get('street') or False, 'city': cust.get('city') or False,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
partner = Partner.browse(int(payload['partner_id'])) if payload.get('partner_id') else Partner
|
||||||
|
|
||||||
|
category = payload.get('category', 'standard')
|
||||||
|
timing = payload.get('timing', 'normal')
|
||||||
|
in_shop = bool(payload.get('in_shop'))
|
||||||
|
|
||||||
|
# technician_id is REQUIRED on a task
|
||||||
|
technician_id = payload.get('technician_id')
|
||||||
|
if not technician_id:
|
||||||
|
raise UserError(_("Please choose a technician for this service booking."))
|
||||||
|
technician_id = int(technician_id)
|
||||||
|
|
||||||
|
# 2. task
|
||||||
|
dur = float(payload.get('duration_hr') or 1.0)
|
||||||
|
t_start = float(payload.get('time_start') or 9.0)
|
||||||
|
task_vals = {
|
||||||
|
'task_type': 'repair',
|
||||||
|
'technician_id': technician_id,
|
||||||
|
'scheduled_date': payload.get('date'),
|
||||||
|
'time_start': t_start,
|
||||||
|
'time_end': t_start + dur,
|
||||||
|
'duration_hours': dur,
|
||||||
|
'is_in_store': in_shop,
|
||||||
|
'x_fc_service_call_type': '%s_%s' % (category, timing),
|
||||||
|
'description': payload.get('description') or payload.get('issue') or '',
|
||||||
|
}
|
||||||
|
if partner:
|
||||||
|
task_vals['partner_id'] = partner.id
|
||||||
|
task = self.create(task_vals)
|
||||||
|
|
||||||
|
# 3. per-km distance: only when the rate adds it AND we have a real origin + a
|
||||||
|
# geocoded job destination. Origin is the technician's start, never the job.
|
||||||
|
distance_km = 0.0
|
||||||
|
callout = self.env['fusion.service.rate'].get_callout(category, timing, in_shop=in_shop)
|
||||||
|
if callout and callout.adds_per_km and not in_shop and task.address_lat and task.address_lng:
|
||||||
|
origin_lat, origin_lng = task._service_travel_origin()
|
||||||
|
if origin_lat and origin_lng:
|
||||||
|
try:
|
||||||
|
task._calculate_travel_time(origin_lat, origin_lng) # sets travel_distance_km
|
||||||
|
distance_km = task.travel_distance_km or 0.0
|
||||||
|
except Exception:
|
||||||
|
distance_km = 0.0
|
||||||
|
|
||||||
|
# 4. draft repair SO + link back to the task
|
||||||
|
order = self._build_service_so(partner, category, timing, in_shop, distance_km) if partner else False
|
||||||
|
if order:
|
||||||
|
task.sale_order_id = order.id
|
||||||
|
return {'task_id': task.id, 'order_id': order.id if order else False}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# VIEW ACTIONS
|
# VIEW ACTIONS
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,3 +51,21 @@ class TestServiceBooking(TransactionCase):
|
|||||||
self.assertTrue(so.x_fc_is_service_repair)
|
self.assertTrue(so.x_fc_is_service_repair)
|
||||||
self.assertEqual(so.partner_id, partner)
|
self.assertEqual(so.partner_id, partner)
|
||||||
self.assertEqual(so.order_line[0].price_unit, 95.0)
|
self.assertEqual(so.order_line[0].price_unit, 95.0)
|
||||||
|
|
||||||
|
def test_action_book_creates_contact_task_and_so(self):
|
||||||
|
payload = {
|
||||||
|
'cust_mode': 'new',
|
||||||
|
'customer': {'name': 'Nina New', 'phone': '4165550199', 'email': 'nina@x.com',
|
||||||
|
'street': '88 Bloor St E', 'city': 'Toronto'},
|
||||||
|
'category': 'standard', 'timing': 'normal', 'in_shop': False,
|
||||||
|
'device': 'scooter', 'issue': "won't power on",
|
||||||
|
'date': '2026-06-03', 'time_start': 9.0, 'duration_hr': 1.0,
|
||||||
|
'technician_id': self.tech.id, 'description': 'check battery',
|
||||||
|
}
|
||||||
|
res = self.Task.action_book_from_wizard(payload)
|
||||||
|
self.assertTrue(res['task_id'] and res['order_id'])
|
||||||
|
task = self.Task.browse(res['task_id'])
|
||||||
|
self.assertEqual(task.sale_order_id.id, res['order_id'])
|
||||||
|
self.assertEqual(task.sale_order_id.order_line[0].price_unit, 95.0)
|
||||||
|
partner = self.env['res.partner'].search([('email', '=ilike', 'nina@x.com')], limit=1)
|
||||||
|
self.assertTrue(partner)
|
||||||
|
|||||||
Reference in New Issue
Block a user