From 92e8a18fcb83f90390436b5c553cd19151820b81 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 01:00:53 -0400 Subject: [PATCH] feat(fusion_claims): action_book_from_wizard + jsonrpc booking routes Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_claims/__init__.py | 1 + fusion_claims/controllers/__init__.py | 1 + fusion_claims/controllers/service_booking.py | 38 +++++++ fusion_claims/models/technician_task.py | 111 +++++++++++++++++++ fusion_claims/tests/test_service_booking.py | 18 +++ 5 files changed, 169 insertions(+) create mode 100644 fusion_claims/controllers/__init__.py create mode 100644 fusion_claims/controllers/service_booking.py diff --git a/fusion_claims/__init__.py b/fusion_claims/__init__.py index 37f2e2d5..27d4f5ef 100644 --- a/fusion_claims/__init__.py +++ b/fusion_claims/__init__.py @@ -7,6 +7,7 @@ import logging from . import models from . import wizard +from . import controllers _logger = logging.getLogger(__name__) diff --git a/fusion_claims/controllers/__init__.py b/fusion_claims/controllers/__init__.py new file mode 100644 index 00000000..59492bb6 --- /dev/null +++ b/fusion_claims/controllers/__init__.py @@ -0,0 +1 @@ +from . import service_booking diff --git a/fusion_claims/controllers/service_booking.py b/fusion_claims/controllers/service_booking.py new file mode 100644 index 00000000..729df351 --- /dev/null +++ b/fusion_claims/controllers/service_booking.py @@ -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)} diff --git a/fusion_claims/models/technician_task.py b/fusion_claims/models/technician_task.py index 7371b6bc..fae97735 100644 --- a/fusion_claims/models/technician_task.py +++ b/fusion_claims/models/technician_task.py @@ -447,6 +447,117 @@ class FusionTechnicianTaskClaims(models.Model): } 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:
``. + 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 # ------------------------------------------------------------------ diff --git a/fusion_claims/tests/test_service_booking.py b/fusion_claims/tests/test_service_booking.py index ffc13921..1dc6cba2 100644 --- a/fusion_claims/tests/test_service_booking.py +++ b/fusion_claims/tests/test_service_booking.py @@ -51,3 +51,21 @@ class TestServiceBooking(TransactionCase): self.assertTrue(so.x_fc_is_service_repair) self.assertEqual(so.partner_id, partner) 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)