Compare commits

..

10 Commits

Author SHA1 Message Date
gsinghpal
f84c22c743 feat(fusion_claims): Book a Service entry point + version bump
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:31:46 -04:00
gsinghpal
46d19fd581 fix(fusion_claims): OWL wizard review fixes (statement handler, scss borders, tech guard)
- Move the call-type select handler into onCallType() — OWL cannot compile a
  multi-statement inline t-on body (was a render-breaking crash on mount).
- Replace color-mix() inside border shorthands with var(--sb-border) (Odoo-19
  SCSS drops color-mix in a border shorthand).
- Technician placeholder option value '' (not 'false') so the required-tech
  guard isn't bypassed.
- Remove dead setTiming(); null-coalesce the refdata onWillStart load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:29:55 -04:00
gsinghpal
56ca82c611 feat(fusion_claims): OWL service-booking wizard + dark/light SCSS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:16:29 -04:00
gsinghpal
d457b86eaa fix(fusion_claims): default booking description + isolate order-less task test
Review follow-up: the base fusion.technician.task.description is required=True and
non-in-store tasks require an address (_check_address_required). So:
- action_book_from_wizard now defaults description to 'Service booking' when the
  payload carries neither description nor issue (avoids a required-field failure).
- test_task_without_order_is_allowed now sets description + is_in_store=True so it
  exercises only the relaxed _check_order_link, not those unrelated base constraints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:09:14 -04:00
gsinghpal
92e8a18fcb feat(fusion_claims): action_book_from_wizard + jsonrpc booking routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:00:53 -04:00
gsinghpal
245e551c68 feat(fusion_claims): service pricing resolver + draft-SO builder from rate table
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:54:39 -04:00
gsinghpal
a022eaaabe feat(fusion_claims): allow order-less tasks + service-repair SO flag
Relaxes _check_order_link to a no-op (service bookings auto-create their SO;
in-shop/walk-in tasks may have none) and adds x_fc_is_service_repair on
sale.order. The 'Service Repair' crm.tag from the plan is intentionally
omitted: fusion_claims does not depend on crm and sale.order has no tag_ids;
the boolean flag is the repair-SO identity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:51:10 -04:00
gsinghpal
0e6bb7b676 fix(fusion_tasks): make datetime inverses use the same tz resolver as compute
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:47:48 -04:00
gsinghpal
d5d410f6d0 chore(fusion_claims): bump version for service-rate foundation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:42:50 -04:00
gsinghpal
41141a75e8 feat(fusion_claims): Service Rates menu, list (inline-edit) + form + ACL
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:42:42 -04:00
18 changed files with 1142 additions and 14 deletions

View File

@@ -7,6 +7,7 @@ import logging
from . import models
from . import wizard
from . import controllers
_logger = logging.getLogger(__name__)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.9.2.0',
'version': '19.0.9.4.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -103,6 +103,8 @@
'wizard/status_change_reason_wizard_views.xml',
'views/res_company_views.xml',
'views/res_config_settings_views.xml',
'views/service_rate_views.xml',
'views/service_booking_action.xml',
'views/sale_order_views.xml',
'views/account_move_views.xml',
'views/account_journal_views.xml',
@@ -183,12 +185,20 @@
# Dashboard OWL countdown widget
'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
# Service Booking wizard (client action): tokens MUST load before
# the component scss so the --sb-* vars resolve.
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
'fusion_claims/static/src/js/service_booking/service_booking.js',
'fusion_claims/static/src/xml/service_booking.xml',
],
'web.assets_web_dark': [
# Dark bundle recompiles the same SCSS with the dark
# $o-webclient-color-scheme default so tokens branch correctly.
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
'fusion_claims/static/src/scss/_service_booking_tokens.scss',
'fusion_claims/static/src/scss/service_booking.scss',
],
},
'images': ['static/description/icon.png'],

View File

@@ -0,0 +1 @@
from . import service_booking

View 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)}

View File

@@ -338,6 +338,11 @@ class SaleOrder(models.Model):
help='Type of sale for billing purposes. This field determines the workflow and billing rules.',
)
x_fc_is_service_repair = fields.Boolean(
string='Service Repair', copy=False,
help='Auto-created from the technician service booking wizard.',
)
x_fc_sale_type_locked = fields.Boolean(
string='Sale Type Locked',
compute='_compute_sale_type_locked',

View File

@@ -9,7 +9,7 @@ features to the base fusion.technician.task model.
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
from odoo.exceptions import UserError
from markupsafe import Markup
import logging
@@ -72,6 +72,15 @@ class FusionTechnicianTaskClaims(models.Model):
default=False,
)
# ------------------------------------------------------------------
# SERVICE BOOKING FIELDS
# ------------------------------------------------------------------
x_fc_service_call_type = fields.Char(
string='Service Call Type',
help='Rate code resolved by the booking wizard (e.g. callout_standard_rush).',
)
# ------------------------------------------------------------------
# ONCHANGES
# ------------------------------------------------------------------
@@ -104,15 +113,9 @@ class FusionTechnicianTaskClaims(models.Model):
@api.constrains('sale_order_id', 'purchase_order_id')
def _check_order_link(self):
for task in self:
if task.x_fc_sync_source:
continue
if task.task_type == 'ltc_visit':
continue
if not task.sale_order_id and not task.purchase_order_id:
raise ValidationError(_(
"A task must be linked to either a Sale Order (Case) or a Purchase Order."
))
# Relaxed 2026-06: service bookings auto-create their SO, and in-shop /
# walk-in tasks may legitimately have none. No order link is required anymore.
return
# ------------------------------------------------------------------
# HOOK OVERRIDES
@@ -395,6 +398,166 @@ class FusionTechnicianTaskClaims(models.Model):
order.name, e,
)
# ------------------------------------------------------------------
# SERVICE BOOKING HELPERS
# ------------------------------------------------------------------
@api.model
def _resolve_service_lines(self, category, timing, in_shop, distance_km):
"""Return a list of sale.order.line vals dicts for a service booking,
priced from fusion.service.rate. Empty when in-shop (labour-only, added later)."""
Rate = self.env['fusion.service.rate']
lines = []
callout = Rate.get_callout(category, timing, in_shop=in_shop)
if not callout:
return lines
lines.append({
'product_id': callout.product_id.id,
'name': callout.name,
'product_uom_qty': 1.0,
'price_unit': callout.price,
'name_is_km': False,
})
if callout.adds_per_km and distance_km:
per_km = Rate.get_rate('per_km')
if per_km:
lines.append({
'product_id': per_km.product_id.id,
'name': '%s%.1f km \xd7 2-way' % (per_km.name, distance_km),
'product_uom_qty': round(distance_km * 2.0, 1),
'price_unit': per_km.price,
'name_is_km': True,
})
return lines
@api.model
def _build_service_so(self, partner, category, timing, in_shop, distance_km):
"""Create a draft repair sale.order with the resolved call-out (+per-km) lines.
Repair-SO identity is the x_fc_is_service_repair boolean (no crm.tag: fusion_claims
has no crm dependency). x_fc_sale_type is intentionally left blank — a service repair
is not one of the ADP/ODSP funder workflows, and the draft is editable afterwards.
"""
line_vals = self._resolve_service_lines(category, timing, in_shop, distance_km)
order_lines = [(0, 0, {k: v for k, v in l.items() if k != 'name_is_km'}) for l in line_vals]
so_vals = {
'partner_id': partner.id,
'x_fc_is_service_repair': True,
'order_line': order_lines,
}
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 _('Service booking'),
}
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 File

@@ -63,4 +63,6 @@ access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,mod
access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0
access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
access_fusion_service_rate_user,fusion.service.rate.user,model_fusion_service_rate,base.group_user,1,0,0,0
access_fusion_service_rate_admin,fusion.service.rate.admin,model_fusion_service_rate,base.group_system,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
63 access_fusion_page11_sign_request_public fusion.page11.sign.request.public model_fusion_page11_sign_request base.group_public 1 0 0 0
64 access_fusion_send_page11_wizard_user fusion_claims.send.page11.wizard.user model_fusion_claims_send_page11_wizard sales_team.group_sale_salesman 1 1 1 1
65 access_fusion_send_page11_wizard_manager fusion_claims.send.page11.wizard.manager model_fusion_claims_send_page11_wizard sales_team.group_sale_manager 1 1 1 1
66 access_fusion_adp_import_wizard_user fusion_claims.adp.import.wizard.user model_fusion_claims_adp_import_wizard account.group_account_invoice 1 1 1 1
67 access_fusion_service_rate_user fusion.service.rate.user model_fusion_service_rate base.group_user 1 0 0 0
68 access_fusion_service_rate_admin fusion.service.rate.admin model_fusion_service_rate base.group_system 1 1 1 1

View File

@@ -0,0 +1,108 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
export class ServiceBookingWizard extends Component {
static template = "fusion_claims.ServiceBookingWizard";
static props = ["*"];
setup() {
this.action = useService("action");
this.notification = useService("notification");
this.state = useState({
custMode: "existing",
customer: { name: "", phone: "", email: "", street: "", unit: "", buzz: "", city: "" },
partnerId: false, soSearch: "",
device: "standard", category: "standard", timing: "normal", inShop: false, issue: "",
date: "", hour: 9, minute: 0, ampm: "AM", durationHr: 1.0, technicianId: false,
warranty: false, pod: false, emailConfirm: true, googleReview: true,
description: "", materials: "",
technicians: [], calloutRates: [], perKm: 0.70,
labour: { onsite: 85, inshop: 75, lift: 110 }, distanceKm: 13, saving: false,
});
onWillStart(async () => {
const r = await rpc("/fusion_claims/service_booking/refdata", {});
Object.assign(this.state, {
technicians: r.technicians || [],
calloutRates: r.callout_rates || [],
perKm: r.per_km ?? 0.70,
labour: r.labour || this.state.labour,
});
});
}
get callout() {
if (this.state.inShop) return null;
return this.state.calloutRates.find(
r => r.category === this.state.category && r.timing === this.state.timing) || null;
}
get labourRate() {
if (this.state.inShop) return this.state.labour.inshop;
return this.state.category === "lift" ? this.state.labour.lift : this.state.labour.onsite;
}
get estimate() {
const c = this.callout;
const callout = c ? c.price : 0;
const incl = (c && !c.adds_per_km) ? 0.5 : 0;
const billHr = Math.max(0, this.state.durationHr - incl);
const labour = billHr * this.labourRate;
const km = (c && c.adds_per_km) ? this.state.distanceKm * 2 * this.state.perKm : 0;
return { callout, labour, billHr, km, total: callout + labour + km, addsKm: !!(c && c.adds_per_km) };
}
get endLabel() {
let h = (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0);
let m = h * 60 + this.state.minute + this.state.durationHr * 60;
let eh = Math.floor(m / 60) % 24, em = m % 60, ap = eh >= 12 ? "PM" : "AM";
return `${eh % 12 || 12}:${String(em).padStart(2, "0")} ${ap}`;
}
fmt(n) { return (n || 0).toFixed(2); }
onDevice(ev) {
this.state.device = ev.target.value;
this.state.category = ev.target.value === "lift" ? "lift" : "standard";
}
onCallType(ev) {
const r = this.state.calloutRates.find(x => x.code === ev.target.value);
if (r) { this.state.category = r.category; this.state.timing = r.timing; }
}
setCust(m) { this.state.custMode = m; }
setAmpm(v) { this.state.ampm = v; }
toggleInShop() { this.state.inShop = !this.state.inShop; }
_timeStartFloat() { return (this.state.hour % 12) + (this.state.ampm === "PM" ? 12 : 0) + this.state.minute / 60; }
async submit() {
if (this.state.saving) return;
const s = this.state;
if (s.custMode === "new" && (!s.customer.name || !s.customer.phone)) {
this.notification.add("Client name and phone are required.", { type: "danger" });
return;
}
if (!s.technicianId) {
this.notification.add("Please choose a technician.", { type: "danger" });
return;
}
s.saving = true;
const payload = {
cust_mode: s.custMode, customer: s.customer, partner_id: s.partnerId, so_search: s.soSearch,
category: s.category, timing: s.timing, in_shop: s.inShop, device: s.device, issue: s.issue,
date: s.date, time_start: this._timeStartFloat(), duration_hr: s.durationHr,
technician_id: s.technicianId, warranty: s.warranty, pod: s.pod,
email_confirm: s.emailConfirm, google_review: s.googleReview,
description: s.description, materials: s.materials,
};
try {
const res = await rpc("/fusion_claims/service_booking/submit", { payload });
if (res.error) { this.notification.add(res.error, { type: "danger" }); s.saving = false; return; }
this.notification.add("Service booked — draft repair SO created.", { type: "success" });
this.action.doAction({
type: "ir.actions.act_window", res_model: "fusion.technician.task",
res_id: res.task_id, views: [[false, "form"]], target: "current",
});
} catch (e) {
this.notification.add("Booking failed: " + (e.message || e), { type: "danger" });
s.saving = false;
}
}
}
registry.category("actions").add("fusion_claims.service_booking", ServiceBookingWizard);

View File

@@ -0,0 +1,73 @@
// Fusion Claims — Service Booking wizard design tokens.
//
// Per the repo dark-mode rule (CLAUDE.md "Dark Mode — Branch on
// $o-webclient-color-scheme at SCSS Compile Time"): this file is compiled into
// BOTH web.assets_backend (bright) and web.assets_web_dark (dark). We branch at
// COMPILE TIME on $o-webclient-color-scheme and emit one --sb-* CSS custom
// property per token, scoped to .o_service_booking. Do NOT use .o_dark_mode /
// [data-bs-theme] / prefers-color-scheme — none fire reliably in Odoo 19.
//
// Values are copied verbatim from the mockup's :root (light) and
// [data-theme="dark"] (dark) blocks — technician-booking-wizard.html.
$o-webclient-color-scheme: bright !default;
// --- light values (mockup :root / [data-theme="light"]) ---
$_page: #eef0f3;
$_panel: #e6e9ed;
$_card: #ffffff;
$_border: #d8dadd;
$_text: #1f2430;
$_muted: #6b7280;
$_faint: #9ca3af;
$_field: #ffffff;
$_field-border: #cfd3d8;
$_field-focus: #3a8fb7;
$_chip: #f1f4f7;
$_accent: #2e7aad;
$_accent-soft: #e8f2f8;
$_ok: #16a34a;
$_star: #f5b301;
$_money: #0f7d4e;
$_money-soft: #e7f6ee;
@if $o-webclient-color-scheme == dark {
// --- dark values (mockup [data-theme="dark"]) ---
$_page: #14161b !global;
$_panel: #1b1e24 !global;
$_card: #22262d !global;
$_border: #343a42 !global;
$_text: #e7eaef !global;
$_muted: #9aa3af !global;
$_faint: #6b7480 !global;
$_field: #1a1d23 !global;
$_field-border: #3a4049 !global;
$_field-focus: #4aa3cf !global;
$_chip: #2a2f37 !global;
$_accent: #3a8fb7 !global;
$_accent-soft: #19303d !global;
$_ok: #22c55e !global;
$_star: #f5b301 !global;
$_money: #34d27f !global;
$_money-soft: #15281f !global;
}
.o_service_booking {
--sb-page: #{$_page};
--sb-panel: #{$_panel};
--sb-card: #{$_card};
--sb-border: #{$_border};
--sb-text: #{$_text};
--sb-muted: #{$_muted};
--sb-faint: #{$_faint};
--sb-field: #{$_field};
--sb-field-border: #{$_field-border};
--sb-field-focus: #{$_field-focus};
--sb-chip: #{$_chip};
--sb-accent: #{$_accent};
--sb-accent-soft: #{$_accent-soft};
--sb-ok: #{$_ok};
--sb-star: #{$_star};
--sb-money: #{$_money};
--sb-money-soft: #{$_money-soft};
}

View File

@@ -0,0 +1,283 @@
// Fusion Claims — Service Booking wizard component styles.
//
// Ported from the mockup (technician-booking-wizard.html) scoped under
// .o_service_booking. The mockup's CSS custom properties (--page, --card, …)
// are renamed mechanically to the --sb-* tokens emitted by
// _service_booking_tokens.scss (which MUST load first in the bundle). The
// manual .theme-btn dark toggle is dropped — Odoo serves the dark bundle.
//
// Surfaces use the explicit-hex tokens (three-layer contrast: page -> card ->
// field), never var(--bs-*). color-mix() is used only in standalone
// background / box-shadow properties — never inside a border shorthand (the
// Odoo 19 SCSS compiler silently drops color-mix in border shorthands).
.o_service_booking {
background: var(--sb-page);
color: var(--sb-text);
font-family: 'Inter', 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
font-size: 14px;
min-height: 100%;
overflow: auto;
* { box-sizing: border-box; }
.wrap { max-width: 1000px; margin: 24px auto; padding: 0 18px; }
.dialog {
background: var(--sb-panel);
border: 1px solid var(--sb-border);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(16, 24, 40, .16);
overflow: hidden;
}
.topbar {
background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%);
padding: 17px 24px;
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
h1 { font-size: 19px; font-weight: 700; margin: 0; }
.sub { font-size: 12.5px; opacity: .9; margin-top: 2px; }
}
.stepper {
display: flex;
gap: 6px;
padding: 11px 24px;
background: var(--sb-panel);
border-bottom: 1px solid var(--sb-border);
flex-wrap: wrap;
}
.step {
font-size: 11.5px;
font-weight: 600;
color: var(--sb-faint);
padding: 5px 13px;
border-radius: 20px;
background: var(--sb-chip);
}
.step.active { color: #fff; background: linear-gradient(135deg, #3a8fb7, #2e7aad); }
.step.draft { margin-left: auto; color: var(--sb-money); background: var(--sb-money-soft); }
.body { padding: 20px 24px 6px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 780px) { .grid { grid-template-columns: 1fr; } }
.card {
background: var(--sb-card);
border: 1px solid var(--sb-border);
border-radius: 13px;
padding: 16px 17px;
box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06);
}
.card.span2 { grid-column: 1 / -1; }
.card h3 {
margin: 0 0 13px;
font-size: 11.5px;
font-weight: 700;
letter-spacing: .7px;
text-transform: uppercase;
color: var(--sb-muted);
display: flex;
align-items: center;
gap: 7px;
}
.card h3 .dot { width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, #5ba848, #2e7aad); }
.card h3 .tag {
margin-left: auto;
font-size: 10px;
font-weight: 700;
color: var(--sb-money);
background: var(--sb-money-soft);
padding: 2px 8px;
border-radius: 10px;
letter-spacing: .3px;
}
label.fl { display: block; font-size: 12px; font-weight: 600; color: var(--sb-muted); margin: 0 0 5px; }
.row { margin-bottom: 12px; }
.row:last-child { margin-bottom: 0; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; }
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 9px; }
input.f, select.f, textarea.f {
width: 100%;
background: var(--sb-field);
color: var(--sb-text);
border: 1px solid var(--sb-field-border);
border-radius: 9px;
padding: 9px 11px;
font-size: 13.5px;
font-family: inherit;
outline: none;
transition: border .15s, box-shadow .15s;
}
input.f:focus, select.f:focus, textarea.f:focus {
border-color: var(--sb-field-focus);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--sb-field-focus) 22%, transparent);
}
textarea.f { resize: vertical; min-height: 56px; }
.hint { font-size: 11px; color: var(--sb-faint); margin-top: 5px; }
.with-icon { position: relative; }
.with-icon .pin { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #5ba848; font-size: 16px; }
.seg {
display: inline-flex;
background: var(--sb-chip);
border: 1px solid var(--sb-border);
border-radius: 9px;
padding: 3px;
gap: 3px;
}
.seg button {
border: none;
background: transparent;
color: var(--sb-muted);
font-weight: 600;
font-size: 12.5px;
padding: 6px 14px;
border-radius: 7px;
cursor: pointer;
font-family: inherit;
}
.seg button.on { background: var(--sb-card); color: var(--sb-accent); box-shadow: 0 1px 3px rgba(16, 24, 40, .08), 0 1px 2px rgba(16, 24, 40, .06); }
.seg.full { display: flex; }
.seg.full button { flex: 1; }
.timepick { display: inline-flex; align-items: stretch; gap: 7px; }
.timepick select.f { width: auto; padding-right: 24px; }
.ampm { display: inline-flex; background: var(--sb-chip); border: 1px solid var(--sb-border); border-radius: 9px; padding: 3px; }
.ampm button {
border: none;
background: transparent;
color: var(--sb-muted);
font-weight: 700;
font-size: 12px;
padding: 6px 12px;
border-radius: 7px;
cursor: pointer;
font-family: inherit;
}
.ampm button.on { background: var(--sb-accent); color: #fff; }
.endtime { font-size: 13px; color: var(--sb-muted); margin-top: 7px; }
.endtime b { color: var(--sb-text); }
.avail {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 600;
color: var(--sb-ok);
background: color-mix(in srgb, var(--sb-ok) 14%, transparent);
padding: 3px 9px;
border-radius: 20px;
margin-top: 6px;
}
.opt {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 0;
border-bottom: 1px solid var(--sb-border);
}
.opt:last-child { border-bottom: none; }
.opt .lab { font-size: 13.5px; font-weight: 500; }
.opt .lab small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11.5px; }
.sw {
width: 42px;
height: 24px;
border-radius: 20px;
background: var(--sb-field-border);
position: relative;
cursor: pointer;
transition: background .15s;
flex-shrink: 0;
}
.sw::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
top: 3px;
left: 3px;
transition: left .15s;
box-shadow: 0 1px 2px rgba(0, 0, 0, .3);
}
.sw.on { background: var(--sb-ok); }
.sw.on::after { left: 21px; }
// fee readout inside Service & Pricing
.feeline {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--sb-money-soft);
border: 1px solid var(--sb-border);
border-radius: 10px;
padding: 11px 14px;
margin-top: 4px;
}
.feeline .lbl { font-size: 12.5px; font-weight: 600; color: var(--sb-text); }
.feeline .lbl small { display: block; color: var(--sb-faint); font-weight: 400; font-size: 11px; }
.feeline .amt { font-size: 20px; font-weight: 800; color: var(--sb-money); }
// ESTIMATE strip
.estimate {
grid-column: 1 / -1;
background: var(--sb-money-soft);
border: 1px solid var(--sb-border);
border-left: 5px solid var(--sb-money);
border-radius: 13px;
padding: 15px 18px;
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.estimate .breakdown { display: flex; gap: 18px; flex-wrap: wrap; flex: 1; }
.estimate .bk .k { font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-faint); }
.estimate .bk .v { font-size: 15px; font-weight: 700; margin-top: 1px; }
.estimate .total { text-align: right; }
.estimate .total .k { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--sb-money); font-weight: 700; }
.estimate .total .v { font-size: 27px; font-weight: 800; color: var(--sb-money); line-height: 1; }
.estimate .total .note { font-size: 11px; color: var(--sb-faint); margin-top: 3px; }
.foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 11px;
padding: 16px 24px;
background: var(--sb-panel);
border-top: 1px solid var(--sb-border);
}
.foot .spacer { margin-right: auto; font-size: 12px; color: var(--sb-faint); }
.btn {
border: none;
border-radius: 10px;
padding: 11px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.btn.ghost { background: transparent; color: var(--sb-muted); border: 1px solid var(--sb-border); }
.btn.primary {
color: #fff;
background: linear-gradient(135deg, #5ba848, #2e7aad);
box-shadow: 0 3px 10px color-mix(in srgb, #2e7aad 40%, transparent);
}
.btn[disabled] { opacity: .6; cursor: not-allowed; }
.hide { display: none !important; }
}

View File

@@ -0,0 +1,208 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_claims.ServiceBookingWizard" owl="1">
<div class="o_service_booking">
<div class="wrap">
<div class="dialog">
<div class="topbar">
<div>
<h1>Book a Service</h1>
<div class="sub">Repair · delivery · pickup — captures the job and creates the priced repair order</div>
</div>
</div>
<div class="stepper">
<span class="step active">Scheduled</span>
<span class="step">En Route</span>
<span class="step">In Progress</span>
<span class="step">Completed</span>
<span class="step draft">● Draft repair SO will be created</span>
</div>
<div class="body">
<div class="grid">
<!-- CUSTOMER -->
<div class="card">
<h3><span class="dot"></span>Customer</h3>
<div class="row">
<div class="seg full">
<button t-att-class="{ on: state.custMode === 'existing' }"
t-on-click="() => this.setCust('existing')">Existing customer</button>
<button t-att-class="{ on: state.custMode === 'new' }"
t-on-click="() => this.setCust('new')">New client</button>
</div>
</div>
<div t-if="state.custMode === 'existing'">
<div class="row">
<label class="fl">Search by phone, name or SO</label>
<input class="f" t-model="state.soSearch" placeholder="e.g. (416) 555-0142 …"/>
<div class="hint">Inbound call? Type the phone number — we match the contact &amp; their history.</div>
</div>
</div>
<div t-if="state.custMode === 'new'">
<div class="row two">
<div><label class="fl">Client name *</label><input class="f" t-model="state.customer.name" placeholder="Full name"/></div>
<div><label class="fl">Phone *</label><input class="f" t-model="state.customer.phone" placeholder="(416) 555-…"/></div>
</div>
<div class="row"><label class="fl">Email</label><input class="f" type="email" t-model="state.customer.email" placeholder="client@email.com"/></div>
<div class="row"><label class="fl">Address</label>
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Start typing an address…"/><span class="pin">📍</span></div>
</div>
<div class="row three">
<div><label class="fl">Unit</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
<div><label class="fl">Buzz</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
<div><label class="fl">City</label><input class="f" t-model="state.customer.city" placeholder="City"/></div>
</div>
<div class="hint">Contact is created &amp; linked on save — all from this page.</div>
</div>
</div>
<!-- SERVICE & PRICING -->
<div class="card">
<h3><span class="dot"></span>Service &amp; Pricing<span class="tag">$ REVENUE</span></h3>
<div class="row two">
<div>
<label class="fl">Device being serviced</label>
<select class="f" t-on-change="onDevice">
<option value="standard">Mobility Scooter</option>
<option value="standard">Powerchair</option>
<option value="standard">Wheelchair</option>
<option value="lift">Stairlift</option>
<option value="lift">Patient / Ceiling Lift</option>
<option value="standard">Lift Chair</option>
<option value="standard">Hospital Bed</option>
<option value="standard">Other</option>
</select>
</div>
<div>
<label class="fl">Issue / symptom</label>
<input class="f" t-model="state.issue" placeholder="e.g. won't power on"/>
</div>
</div>
<div class="row" t-if="!state.inShop">
<label class="fl">Service call type</label>
<select class="f"
t-on-change="onCallType">
<t t-foreach="state.calloutRates" t-as="r" t-key="r.code">
<option t-att-value="r.code"
t-att-selected="state.category === r.category and state.timing === r.timing">
<t t-esc="r.name"/> — $<t t-esc="fmt(r.price)"/><t t-if="r.adds_per_km"> + $<t t-esc="fmt(state.perKm)"/>/km ×2-way</t>
</option>
</t>
</select>
<div class="hint">Auto-suggested from the device — change if needed.</div>
</div>
<div class="feeline" t-if="!state.inShop and callout">
<div class="lbl">Call-out fee<small><t t-esc="callout.name"/><t t-if="callout.adds_per_km"> · + travel</t><t t-else=""> · includes 30 min labour</t></small></div>
<div class="amt">$<t t-esc="fmt(callout.price)"/></div>
</div>
<div class="hint" t-if="state.inShop">In-shop job — no call-out fee; labour billed at $<t t-esc="fmt(state.labour.inshop)"/>/hr.</div>
</div>
<!-- SCHEDULE -->
<div class="card">
<h3><span class="dot"></span>Schedule</h3>
<div class="row two">
<div><label class="fl">Date</label><input class="f" type="date" t-model="state.date"/></div>
<div><label class="fl">Duration</label>
<select class="f" t-model.number="state.durationHr">
<option value="0.5">30 min</option>
<option value="1">1 hour</option>
<option value="1.5">1.5 hours</option>
<option value="2">2 hours</option>
<option value="3">3 hours</option>
</select>
</div>
</div>
<div class="row">
<label class="fl">Start time</label>
<div class="timepick">
<select class="f" t-model.number="state.hour">
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<select class="f" t-model.number="state.minute">
<option value="0">:00</option>
<option value="15">:15</option>
<option value="30">:30</option>
<option value="45">:45</option>
</select>
<div class="ampm">
<button t-att-class="{ on: state.ampm === 'AM' }" t-on-click="() => this.setAmpm('AM')">AM</button>
<button t-att-class="{ on: state.ampm === 'PM' }" t-on-click="() => this.setAmpm('PM')">PM</button>
</div>
</div>
<div class="endtime">Ends at <b><t t-esc="endLabel"/></b> · your local time</div>
</div>
<div class="row">
<label class="fl">Technician</label>
<select class="f" t-model.number="state.technicianId">
<option value="">— Choose —</option>
<t t-foreach="state.technicians" t-as="t" t-key="t.id">
<option t-att-value="t.id"><t t-esc="t.name"/></option>
</t>
</select>
</div>
</div>
<!-- LOCATION -->
<div class="card">
<h3><span class="dot"></span>Location</h3>
<div class="opt" style="border:none; padding-top:0;">
<div class="lab">In-shop job<small>At the store — no call-out, labour @ $<t t-esc="fmt(state.labour.inshop)"/>/hr</small></div>
<div class="sw" t-att-class="{ on: state.inShop }" t-on-click="toggleInShop"></div>
</div>
<div t-if="!state.inShop">
<div class="row"><label class="fl">Job address</label>
<div class="with-icon"><input class="f" t-model="state.customer.street" placeholder="Auto-fills from customer…"/><span class="pin">📍</span></div>
</div>
<div class="row two">
<div><label class="fl">Unit / Suite</label><input class="f" t-model="state.customer.unit" placeholder="#"/></div>
<div><label class="fl">Buzz code</label><input class="f" t-model="state.customer.buzz" placeholder="—"/></div>
</div>
</div>
</div>
<!-- JOB DETAILS -->
<div class="card span2">
<h3><span class="dot"></span>Job details</h3>
<div class="two">
<div class="row"><label class="fl">Work description</label><textarea class="f" t-model="state.description" placeholder="Symptom, what to check, history…"></textarea></div>
<div class="row"><label class="fl">Parts / materials to bring</label><textarea class="f" t-model="state.materials" placeholder="Batteries, controller, casters…"></textarea></div>
</div>
<div class="opt"><div class="lab">Under manufacturer warranty<small>Parts not billed when covered</small></div><div class="sw" t-att-class="{ on: state.warranty }" t-on-click="() => state.warranty = !state.warranty"></div></div>
<div class="opt"><div class="lab">POD required<small>Capture proof of delivery on completion</small></div><div class="sw" t-att-class="{ on: state.pod }" t-on-click="() => state.pod = !state.pod"></div></div>
<div class="opt"><div class="lab">Send client confirmation (email/SMS)<small>Booked · en-route · completed</small></div><div class="sw" t-att-class="{ on: state.emailConfirm }" t-on-click="() => state.emailConfirm = !state.emailConfirm"></div></div>
<div class="opt"><div class="lab">Request Google review after completion</div><div class="sw" t-att-class="{ on: state.googleReview }" t-on-click="() => state.googleReview = !state.googleReview"></div></div>
</div>
<!-- ESTIMATE -->
<div class="estimate">
<div class="breakdown">
<div class="bk"><div class="k">Call-out</div><div class="v"><t t-if="state.inShop"></t><t t-else="">$<t t-esc="fmt(estimate.callout)"/></t></div></div>
<div class="bk"><div class="k">Est. labour</div><div class="v">$<t t-esc="fmt(estimate.labour)"/> · <t t-esc="estimate.billHr"/>h @ $<t t-esc="fmt(labourRate)"/></div></div>
<div class="bk" t-if="estimate.addsKm"><div class="k">Travel ($<t t-esc="fmt(state.perKm)"/>/km ×2)</div><div class="v">$<t t-esc="fmt(estimate.km)"/></div></div>
</div>
<div class="total"><div class="k">Estimated total</div><div class="v">$<t t-esc="fmt(estimate.total)"/></div>
<div class="note">+ parts as used · pre-tax · a draft SO is created</div></div>
</div>
</div>
</div>
<div class="foot">
<span class="spacer">Local time · America/Toronto · <t t-esc="state.distanceKm"/> km away</span>
<button class="btn ghost" t-on-click="() => this.action.doAction({ type: 'ir.actions.act_window_close' })">Cancel</button>
<button class="btn primary" t-on-click="submit" t-att-disabled="state.saving">Book &amp; Create SO</button>
</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -4,3 +4,4 @@ from . import test_signed_pages_gate
from . import test_application_received_wizard
from . import test_dashboard
from . import test_service_rate
from . import test_service_booking

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestServiceBooking(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Task = cls.env['fusion.technician.task']
# technician_id is required on a task (domain x_fc_is_field_staff=True).
cls.tech = cls.env['res.users'].create({
'name': 'Service Booking Tech',
'login': 'svcbook_tech',
'x_fc_is_field_staff': True,
})
def test_task_without_order_is_allowed(self):
# No SO/PO must NOT raise after the relax. description is required and a
# non-in-store task needs an address, so set both here to isolate the test
# to the order-link relaxation (not those unrelated base constraints).
t = self.Task.create({
'task_type': 'repair',
'technician_id': self.tech.id,
'scheduled_date': date(2026, 6, 3),
'description': 'Test repair',
'is_in_store': True,
})
self.assertTrue(t.id)
def test_sale_order_has_service_repair_flag(self):
so = self.env['sale.order'].new({})
self.assertIn('x_fc_is_service_repair', so._fields)
def test_resolve_service_lines_standard_rush(self):
Task = self.Task
lines = Task._resolve_service_lines('standard', 'rush', in_shop=False, distance_km=10.0)
# call-out $120 + per-km line qty 20 @ $0.70
callout = [l for l in lines if l['price_unit'] == 120.0]
per_km = [l for l in lines if l['name_is_km']]
self.assertTrue(callout)
self.assertEqual(per_km[0]['product_uom_qty'], 20.0)
self.assertEqual(per_km[0]['price_unit'], 0.70)
def test_resolve_service_lines_in_shop_empty_callout(self):
lines = self.Task._resolve_service_lines('standard', 'normal', in_shop=True, distance_km=5.0)
self.assertEqual(lines, [])
def test_build_service_so(self):
partner = self.env['res.partner'].create({'name': 'Walk-in Wanda'})
so = self.Task._build_service_so(partner, 'standard', 'normal', False, 0.0)
self.assertEqual(so.state, 'draft')
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)

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<record id="action_service_booking_wizard" model="ir.actions.client">
<field name="name">Book a Service</field>
<field name="tag">fusion_claims.service_booking</field>
</record>
<menuitem id="menu_service_booking"
name="Book a Service"
parent="fusion_tasks.menu_field_service_root"
action="action_service_booking_wizard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2025 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo>
<!-- ===================================================================== -->
<!-- SERVICE RATE: List View (inline-edit enabled) -->
<!-- ===================================================================== -->
<record id="view_fusion_service_rate_list" model="ir.ui.view">
<field name="name">fusion.service.rate.list</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<list string="Service Rates" editable="top"
default_order="sequence, rate_kind, category, timing">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="rate_kind" string="Kind"/>
<field name="category"/>
<field name="timing"/>
<field name="unit"/>
<field name="price" string="Rate"/>
<field name="currency_id" column_invisible="True"/>
<field name="adds_per_km" string="+ km"/>
<field name="included_labour_min" string="Incl. Labour (min)"/>
<field name="in_shop" string="In-Shop"/>
<field name="product_id" string="Invoice Product"/>
<field name="active" column_invisible="True"/>
</list>
</field>
</record>
<!-- ===================================================================== -->
<!-- SERVICE RATE: Form View -->
<!-- ===================================================================== -->
<record id="view_fusion_service_rate_form" model="ir.ui.view">
<field name="name">fusion.service.rate.form</field>
<field name="model">fusion.service.rate</field>
<field name="arch" type="xml">
<form string="Service Rate">
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="Rate name…"/></h1>
</div>
<group>
<group string="Identification">
<field name="code"/>
<field name="rate_kind" string="Kind"/>
<field name="category"/>
<field name="timing"/>
<field name="in_shop"/>
<field name="active"/>
<field name="sequence"/>
</group>
<group string="Pricing">
<field name="price" string="Rate"/>
<field name="currency_id"/>
<field name="unit"/>
<field name="adds_per_km"/>
<field name="included_labour_min"/>
</group>
</group>
<group string="Invoice Product">
<field name="product_id" string="Product" colspan="2"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===================================================================== -->
<!-- SERVICE RATE: Action -->
<!-- ===================================================================== -->
<record id="action_fusion_service_rate" model="ir.actions.act_window">
<field name="name">Service Rates</field>
<field name="res_model">fusion.service.rate</field>
<field name="view_mode">list,form</field>
<field name="context">{'active_test': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No service rates found.
</p>
<p>
Add rates used for booking service calls, labour, travel, and delivery.
</p>
</field>
</record>
<!-- ===================================================================== -->
<!-- SERVICE RATE: Menu item under Technician Configuration -->
<!-- ===================================================================== -->
<menuitem id="menu_fusion_service_rate"
name="Service Rates"
parent="fusion_tasks.menu_technician_config"
action="action_fusion_service_rate"
sequence="50"
groups="base.group_system"/>
</odoo>

View File

@@ -781,7 +781,7 @@ class FusionTechnicianTask(models.Model):
def _inverse_datetime_start(self):
"""When datetime_start is changed (e.g. from calendar drag), update date + time."""
import pytz
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
user_tz = self._get_local_tz()
for task in self:
if task.datetime_start:
local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz)
@@ -791,7 +791,7 @@ class FusionTechnicianTask(models.Model):
def _inverse_datetime_end(self):
"""When datetime_end is changed (e.g. from calendar resize), update time_end."""
import pytz
user_tz = pytz.timezone(self.env.user.tz or 'UTC')
user_tz = self._get_local_tz()
for task in self:
if task.datetime_end:
local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz)

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_task_tz

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
from datetime import date
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestTaskTz(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# _compute_datetimes resolves company resource-calendar tz FIRST, then user tz.
# Set BOTH to Toronto so the UTC assertion and the round-trip are deterministic.
cls.env.user.tz = 'America/Toronto'
cal = cls.env.company.resource_calendar_id
if cal:
cal.tz = 'America/Toronto'
# technician_id is required (domain x_fc_is_field_staff=True) -> make a field tech.
cls.tech = cls.env['res.users'].create({
'name': 'TZ Test Tech',
'login': 'tz_test_tech_svcbook',
'x_fc_is_field_staff': True,
})
cls.task = cls.env['fusion.technician.task'].create({
'technician_id': cls.tech.id,
'scheduled_date': date(2026, 6, 3),
'time_start': 9.0,
'time_end': 10.0,
})
def test_local_to_utc_compute(self):
# 9:00 local Toronto (EDT, -4) -> 13:00 UTC stored
self.assertEqual(self.task.datetime_start.hour, 13)
def test_inverse_round_trips_with_same_tz(self):
# writing datetime_start back recovers the same local time_start
self.task.datetime_start = self.task.datetime_start # force inverse
self.task.flush_recordset(['datetime_start'])
self.assertAlmostEqual(self.task.time_start, 9.0, places=2)