feat(fusion_repairs): Bundle 10 - align pricing to Westin's printed rate card
User shared their actual published service-rate card. Bundle 9's seeded
numbers were placeholders that no longer match. Realigned the rate card,
added the LIFT & ELEVATING SERVICE class, added the in-shop labour
rate path, added the delivery / pickup charge model, added rush as a
proper tier (distinct from after-hours), and added 30-min increment
rounding on top of the existing 1-hour minimum.
EQUIPMENT CLASS
fusion.repair.product.category gets a new x_fc_equipment_class
selection: 'standard' vs 'lift_elevating'. The published card splits
pricing into two service classes - lift_elevating has higher rates
($160 callout vs $95, $110/h vs $85).
Categories marked lift_elevating in seed:
stairlift, porch_lift, lift_chair (new)
New 'Lift Chair' category seeded (power recliner / lift chair).
CALLOUT RATE CARD
fusion.repair.callout.rate gets:
- equipment_class field (standard / lift_elevating)
- in_shop_labor_rate field (separate $75 vs $85 on-site)
- 'rush' tier value (was missing - rush was implicit via emergency
surcharge from Bundle 8; now a proper tier matching the printed
rate card row 'Rush Service Calls $120')
Re-seeded with the PUBLISHED Westin rate card (exact values):
STANDARD SERVICE
regular $95 callout / $85/h on-site / $75/h in-shop
rush $120 callout / $85/h / $75/h
after_hours $140 callout / $85/h / $75/h
weekend $180 callout / $85/h / $75/h (extension)
holiday $220 callout / $85/h / $75/h (extension)
LIFT & ELEVATING SERVICE
regular $160 callout / $110/h on-site / $110/h in-shop
rush $200 callout / $110/h / $110/h (extension)
after_hours $240 callout / $110/h / $110/h (extension)
weekend $300 callout / $110/h / $110/h (extension)
holiday $360 callout / $110/h / $110/h (extension)
Travel: $0.70 per km, BOTH WAYS, past 25 km, per technician
(matches the per-card '$0.70 per km x 2-way' footnote).
get_for_tier(tier, equipment_class) now resolves with a fallback:
tries (tier, lift_elevating) first, falls back to (tier, standard)
if no lift-specific row exists - so an admin can leave standard rows
as the catch-all and only customise lift for the exceptions.
DELIVERY / PICKUP RATE CARD
New fusion.repair.delivery.charge model + seed of all 7 items from
the printed card:
Local Service Area (within Brampton) ........ $35
Outside Local Area .......................... $60
Rush Pickups / Delivery ..................... $60 + $0.70/km x 2-way
Lift Chair Delivery and Set-Up .............. $120
Hospital Bed Delivery and Set-Up ............ $120
Stairlift Delivery and Set-Up ............... $300
Stairlift Removal ........................... $300
quote_rush(distance_km) helper for the office's delivery scheduling.
New menu: Configuration > Delivery / Pickup Charges.
PRICING ENGINE UPDATES (repair.order._compute_callout_quote)
- Class-aware rate lookup (uses category.equipment_class).
- In-shop mode (x_fc_in_shop=True): skips callout fee + extra-tech +
travel; charges in_shop_labor_rate * hours * techs only. Per the
rate-card footnote 'In-Shop Labour Rate'.
- 30-min increment rounding ON TOP of the 1-hour floor:
billable_h = max(ceil(actual * 2) / 2, min_hours)
-> 20-min work bills 1.0 h
-> 75-min work bills 1.5 h
-> 95-min work bills 2.0 h
- Improved breakdown text shows the rate-card row name + class +
pro-ration math so the client can see how the total was computed.
NEW FIELDS
repair.order:
x_fc_in_shop (Boolean) - flip to switch the quote engine to
in-shop mode.
x_fc_callout_tier now includes 'rush' as a value (was missing).
visit-report wizard:
callout_in_shop related field - tech can flip the mode on-site if
the work was actually done in-store after pickup.
MIGRATION SCRIPT
migrations/19.0.2.1.0/post-migration.py runs once on existing
installs:
1. Updates stairlift / porch_lift / lift_chair categories
equipment_class -> lift_elevating
2. Wipes the 4 Bundle 9 rate-card xml_ids so the new noupdate=1
seed creates them with the correct printed values.
Fresh installs get the right values directly from the seed XML.
Admin-created custom rate rows (no xml_id) are NEVER touched.
VERIFIED END-TO-END (0 bugs across 28 checks)
Rate card matches printed values exactly:
regular/standard = $95/$85h/$75h PASS
rush/standard = $120/$85h/$75h PASS
after_hours/standard = $140/$85h/$75h PASS
regular/lift = $160/$110h/$110h PASS
Six end-to-end quote scenarios:
A. Standard 12km 20-min -> $180 ($95 + 1h*$85)
B. Lift 12km 20-min -> $270 ($160 + 1h*$110)
C. Rush 30km 1.2h -> $254.50
($120 + ceil(2.4)/2=1.5h * $85 + 5km*2*$0.70 = $7)
D. After-hours lift 2-tech 35km 2.6h -> $928.00
($240 + ceil(5.2)/2=3.0h * $110 * 2 + 10km*2*$0.70*2)
E. In-shop standard 2h -> $150 (2h * $75 in-shop, no callout)
F. In-shop lift 1.5h -> $165 (1.5h * $110 in-shop)
Seven delivery rates loaded with correct amounts; rush 40km calc
= $81 ($60 base + 15km*2*$0.70).
Stairlift / Porch Lift / Lift Chair categories correctly marked
lift_elevating; rest stay standard.
Bumped to 19.0.2.1.0.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,7 @@ from . import repair_emergency_charge
|
||||
from . import repair_part_order
|
||||
from . import repair_callout_rate
|
||||
from . import repair_labor_warranty
|
||||
from . import repair_delivery_charge
|
||||
from . import product_template
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
|
||||
@@ -30,6 +30,7 @@ class FusionRepairCalloutRate(models.Model):
|
||||
tier = fields.Selection(
|
||||
[
|
||||
('regular', 'Regular Business Hours'),
|
||||
('rush', 'Rush Service'),
|
||||
('after_hours', 'After Hours (weekday evening)'),
|
||||
('weekend', 'Weekend'),
|
||||
('holiday', 'Statutory Holiday'),
|
||||
@@ -39,6 +40,20 @@ class FusionRepairCalloutRate(models.Model):
|
||||
default='regular',
|
||||
)
|
||||
|
||||
# Bundle 10: Westin's rate card splits by equipment class. Lift &
|
||||
# Elevating Service ($160 callout / $110 labour) is distinct from
|
||||
# Standard Service ($95 callout / $85 labour). The lookup falls back
|
||||
# from (tier, equipment_class) to (tier, 'standard').
|
||||
equipment_class = fields.Selection(
|
||||
[
|
||||
('standard', 'Standard Service'),
|
||||
('lift_elevating', 'Lift & Elevating Service'),
|
||||
],
|
||||
string='Equipment Class',
|
||||
default='standard',
|
||||
required=True,
|
||||
)
|
||||
|
||||
# ---- Base callout (covers first 30 minutes of labour) ----
|
||||
base_callout_fee = fields.Monetary(
|
||||
string='Base Callout Fee (1 tech)',
|
||||
@@ -67,7 +82,7 @@ class FusionRepairCalloutRate(models.Model):
|
||||
|
||||
# ---- Hourly labour (after the included 30 min) ----
|
||||
hourly_labor_rate = fields.Monetary(
|
||||
string='Hourly Labour Rate (per tech)',
|
||||
string='On-Site Hourly Labour Rate (per tech)',
|
||||
currency_field='currency_id',
|
||||
required=True,
|
||||
default=0.0,
|
||||
@@ -75,11 +90,21 @@ class FusionRepairCalloutRate(models.Model):
|
||||
'30 min the callout covers. Minimum bill is minimum_labor_hours '
|
||||
'even if the tech finished faster.',
|
||||
)
|
||||
# Bundle 10: separate IN-SHOP hourly rate. When the client brings the unit
|
||||
# to the store (no callout, no travel) we charge a lower hourly rate.
|
||||
in_shop_labor_rate = fields.Monetary(
|
||||
string='In-Shop Hourly Labour Rate',
|
||||
currency_field='currency_id',
|
||||
default=0.0,
|
||||
help='Hourly rate when work is done IN THE STORE (no callout fee, no '
|
||||
'travel). Per Westin rate card: $75 standard / $110 lift.',
|
||||
)
|
||||
minimum_labor_hours = fields.Float(
|
||||
string='Minimum Billable Hours',
|
||||
default=1.0,
|
||||
help='Round-up floor for labour beyond the included 30 min. Set 1.0 '
|
||||
'so a 20-minute fix still bills 1.0 hours.',
|
||||
'so a 20-minute fix still bills 1.0 hours. Hours beyond the floor '
|
||||
'are pro-rated in 30-minute increments per the published card.',
|
||||
)
|
||||
|
||||
# ---- Travel ----
|
||||
@@ -128,13 +153,26 @@ class FusionRepairCalloutRate(models.Model):
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_for_tier(self, tier, on_date=None):
|
||||
"""Return the active rate row for `tier` effective on `on_date`
|
||||
(default today). Returns empty recordset if none configured."""
|
||||
def get_for_tier(self, tier, equipment_class='standard', on_date=None):
|
||||
"""Return the active rate row for `tier` + `equipment_class` effective
|
||||
on `on_date`. Tries (tier, class) first, falls back to (tier, standard)
|
||||
if no class-specific row is configured. Empty recordset if none at all.
|
||||
"""
|
||||
on_date = on_date or fields.Date.context_today(self)
|
||||
return self.sudo().search([
|
||||
Domain = lambda cls: [
|
||||
('tier', '=', tier),
|
||||
('equipment_class', '=', cls),
|
||||
('active', '=', True),
|
||||
('effective_from', '<=', on_date),
|
||||
('company_id', 'in', self.env.companies.ids),
|
||||
], order='effective_from desc', limit=1)
|
||||
]
|
||||
hit = self.sudo().search(
|
||||
Domain(equipment_class or 'standard'),
|
||||
order='effective_from desc', limit=1,
|
||||
)
|
||||
if not hit and equipment_class and equipment_class != 'standard':
|
||||
hit = self.sudo().search(
|
||||
Domain('standard'),
|
||||
order='effective_from desc', limit=1,
|
||||
)
|
||||
return hit
|
||||
|
||||
88
fusion_repairs/models/repair_delivery_charge.py
Normal file
88
fusion_repairs/models/repair_delivery_charge.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""Delivery / pickup rate card (separate from repair callouts).
|
||||
|
||||
Per Westin's published rate card the DELIVERY / PICKUP CHARGES section is
|
||||
a distinct service from repair callouts. These are charged when we move
|
||||
equipment (drop-off of a sold unit, post-repair return delivery, removal
|
||||
of old equipment, etc.).
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionRepairDeliveryCharge(models.Model):
|
||||
_name = 'fusion.repair.delivery.charge'
|
||||
_description = 'Delivery / Pickup Rate Card'
|
||||
_order = 'sequence, charge_type, id'
|
||||
|
||||
name = fields.Char(compute='_compute_name', store=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
charge_type = fields.Selection(
|
||||
[
|
||||
('local', 'Local Service Area'),
|
||||
('outside', 'Outside Local Area'),
|
||||
('rush', 'Rush Pickup / Delivery'),
|
||||
('lift_chair_install', 'Lift Chair Delivery and Set-Up'),
|
||||
('hospital_bed_install', 'Hospital Bed Delivery and Set-Up'),
|
||||
('stairlift_install', 'Stairlift Delivery and Set-Up'),
|
||||
('stairlift_removal', 'Stairlift Removal'),
|
||||
('other', 'Other'),
|
||||
],
|
||||
string='Charge Type',
|
||||
required=True,
|
||||
)
|
||||
amount = fields.Monetary(
|
||||
string='Amount',
|
||||
currency_field='currency_id',
|
||||
required=True,
|
||||
)
|
||||
travel_per_km_fee = fields.Monetary(
|
||||
string='Per-km Fee (Rush, 2-way)',
|
||||
currency_field='currency_id',
|
||||
default=0.0,
|
||||
help='Only applies to rush pickups/deliveries. Per the published card: '
|
||||
'$60 plus $0.70 per km x 2-way.',
|
||||
)
|
||||
travel_distance_threshold_km = fields.Float(
|
||||
string='Free Travel Distance (km, 2-way)',
|
||||
default=0.0,
|
||||
help='Only applies to rush. Above this km, every additional km is '
|
||||
'charged travel_per_km_fee BOTH WAYS.',
|
||||
)
|
||||
description = fields.Text(translate=True)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', default=lambda self: self.env.company,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
@api.depends('charge_type', 'amount')
|
||||
def _compute_name(self):
|
||||
for r in self:
|
||||
label = dict(self._fields['charge_type'].selection).get(r.charge_type) or '?'
|
||||
r.name = f'{label} - ${r.amount:.0f}'
|
||||
|
||||
@api.model
|
||||
def get_charge(self, charge_type):
|
||||
"""Return the active rate row for `charge_type`, empty recordset if none."""
|
||||
return self.sudo().search([
|
||||
('charge_type', '=', charge_type),
|
||||
('active', '=', True),
|
||||
('company_id', 'in', self.env.companies.ids),
|
||||
], limit=1)
|
||||
|
||||
@api.model
|
||||
def quote_rush(self, distance_km):
|
||||
"""Convenience: returns the total for a Rush Pickup / Delivery at
|
||||
`distance_km` one-way. Returns 0.0 if no rush row configured."""
|
||||
rush = self.get_charge('rush')
|
||||
if not rush:
|
||||
return 0.0
|
||||
over = max(distance_km - rush.travel_distance_threshold_km, 0.0)
|
||||
return rush.amount + (over * 2.0 * rush.travel_per_km_fee)
|
||||
@@ -247,6 +247,7 @@ class RepairOrder(models.Model):
|
||||
x_fc_callout_tier = fields.Selection(
|
||||
[
|
||||
('regular', 'Regular Business Hours'),
|
||||
('rush', 'Rush Service'),
|
||||
('after_hours', 'After Hours'),
|
||||
('weekend', 'Weekend'),
|
||||
('holiday', 'Statutory Holiday'),
|
||||
@@ -274,7 +275,17 @@ class RepairOrder(models.Model):
|
||||
tracking=True,
|
||||
help='Hours of repair work above the 30 min included in the callout fee. '
|
||||
'Billing applies the minimum_labor_hours floor from the rate card '
|
||||
'(default 1.0) - 20 minutes of work still bills 1 hour.',
|
||||
'(default 1.0) AND rounds up to the next 30-min increment - '
|
||||
'20 minutes bills 1.0 h, 75 minutes bills 1.5 h.',
|
||||
)
|
||||
# Bundle 10: in-shop work uses a different (lower) hourly rate AND
|
||||
# waives both the callout fee and the travel charge - client brought
|
||||
# the unit to the store.
|
||||
x_fc_in_shop = fields.Boolean(
|
||||
string='In-Shop Repair',
|
||||
tracking=True,
|
||||
help='Work done in the store (no callout, no travel). Uses '
|
||||
'in_shop_labor_rate from the rate card.',
|
||||
)
|
||||
# Labor warranty link + status (resolved at visit time)
|
||||
x_fc_labor_warranty_id = fields.Many2one(
|
||||
@@ -362,41 +373,61 @@ class RepairOrder(models.Model):
|
||||
|
||||
@api.depends('x_fc_callout_tier', 'x_fc_callout_distance_km',
|
||||
'x_fc_callout_techs', 'x_fc_callout_labor_hours',
|
||||
'x_fc_labor_warranty_status', 'x_fc_labor_waived')
|
||||
'x_fc_labor_warranty_status', 'x_fc_labor_waived',
|
||||
'x_fc_in_shop', 'x_fc_repair_category_id')
|
||||
def _compute_callout_quote(self):
|
||||
import math
|
||||
Rate = self.env['fusion.repair.callout.rate'].sudo()
|
||||
for r in self:
|
||||
tier = r.x_fc_callout_tier or 'regular'
|
||||
rate = Rate.get_for_tier(tier)
|
||||
cls = (r.x_fc_repair_category_id.equipment_class
|
||||
or 'standard') if r.x_fc_repair_category_id else 'standard'
|
||||
rate = Rate.get_for_tier(tier, equipment_class=cls)
|
||||
techs = max(r.x_fc_callout_techs or 1, 1)
|
||||
hours = max(r.x_fc_callout_labor_hours or 0.0, 0.0)
|
||||
distance = r.x_fc_callout_distance_km or 0.0
|
||||
in_shop = bool(r.x_fc_in_shop)
|
||||
|
||||
base = rate.base_callout_fee if rate else 0.0
|
||||
extra_techs = 0.0
|
||||
if rate and techs >= 2:
|
||||
extra_techs += rate.second_tech_fee
|
||||
if rate and techs >= 3:
|
||||
# 3rd onwards: use additional_tech_fee, falling back to second_tech_fee.
|
||||
per_extra = rate.additional_tech_fee or rate.second_tech_fee
|
||||
extra_techs += per_extra * (techs - 2)
|
||||
if in_shop:
|
||||
# In-shop: no callout, no extra-tech, no travel. Only labour
|
||||
# at the lower in_shop_labor_rate.
|
||||
base = 0.0
|
||||
extra_techs = 0.0
|
||||
travel = 0.0
|
||||
else:
|
||||
base = rate.base_callout_fee if rate else 0.0
|
||||
extra_techs = 0.0
|
||||
if rate and techs >= 2 and rate.second_tech_fee:
|
||||
extra_techs += rate.second_tech_fee
|
||||
if rate and techs >= 3:
|
||||
per_extra = rate.additional_tech_fee or rate.second_tech_fee or 0.0
|
||||
extra_techs += per_extra * (techs - 2)
|
||||
# Travel - both ways, per tech, for distance over threshold.
|
||||
travel = 0.0
|
||||
if rate:
|
||||
over = max(distance - rate.travel_distance_threshold_km, 0.0)
|
||||
travel = over * 2.0 * rate.travel_per_km_fee * techs
|
||||
|
||||
# Labour - charged per tech with min-1-hour floor on the
|
||||
# BILLABLE portion (above 30 min the callout includes).
|
||||
# Labour: per the published rate card -
|
||||
# * minimum_labor_hours floor (default 1.0)
|
||||
# * beyond the floor, pro-rated in 30-min increments
|
||||
# (i.e. round UP to the next 0.5 h)
|
||||
# * per tech (footnote: 'If multiple technicians are required,
|
||||
# rates will apply per technician')
|
||||
# * in-shop uses in_shop_labor_rate
|
||||
labor = 0.0
|
||||
if rate and hours > 0:
|
||||
min_hours = rate.minimum_labor_hours or 1.0
|
||||
billable_h = max(hours, min_hours)
|
||||
labor = billable_h * rate.hourly_labor_rate * techs
|
||||
|
||||
# Travel - both ways, per tech, for distance over threshold.
|
||||
travel = 0.0
|
||||
if rate:
|
||||
over = max(distance - rate.travel_distance_threshold_km, 0.0)
|
||||
travel = over * 2.0 * rate.travel_per_km_fee * techs
|
||||
# ceil(actual * 2) / 2 -> rounds up to next 0.5 increment
|
||||
rounded_up = math.ceil(hours * 2) / 2.0
|
||||
billable_h = max(rounded_up, min_hours)
|
||||
hourly = (rate.in_shop_labor_rate
|
||||
if in_shop else rate.hourly_labor_rate)
|
||||
labor = billable_h * hourly * techs
|
||||
|
||||
waived = 0.0
|
||||
if r.x_fc_labor_warranty_status in ('eligible', 'waived') or r.x_fc_labor_waived:
|
||||
if (r.x_fc_labor_warranty_status in ('eligible', 'waived')
|
||||
or r.x_fc_labor_waived):
|
||||
waived = labor
|
||||
|
||||
total = base + extra_techs + labor + travel - waived
|
||||
@@ -407,16 +438,37 @@ class RepairOrder(models.Model):
|
||||
r.x_fc_quote_travel = travel
|
||||
r.x_fc_quote_waived = waived
|
||||
r.x_fc_quote_total = total
|
||||
r.x_fc_quote_breakdown_text = (
|
||||
f'Base callout ({tier}): ${base:.2f}\n'
|
||||
f'Extra technicians ({techs - 1}): ${extra_techs:.2f}\n'
|
||||
f'Labor ({hours:.2f} h x {techs} tech, min '
|
||||
f'{rate.minimum_labor_hours if rate else 1.0} h): ${labor:.2f}\n'
|
||||
f'Travel ({distance:.1f} km, both ways, per tech): ${travel:.2f}\n'
|
||||
+ (f'Less waived: -${waived:.2f}\n' if waived else '')
|
||||
+ f'-------------------------------------------\n'
|
||||
f'TOTAL (excl. parts): ${total:.2f}'
|
||||
|
||||
# Human-readable breakdown for chatter / quote emails.
|
||||
class_label = 'Lift & Elevating' if cls == 'lift_elevating' else 'Standard'
|
||||
hourly_used = (
|
||||
(rate.in_shop_labor_rate if in_shop else rate.hourly_labor_rate)
|
||||
if rate else 0.0
|
||||
)
|
||||
mode_label = 'IN-SHOP' if in_shop else f'on-site ({tier})'
|
||||
lines = []
|
||||
if not in_shop:
|
||||
lines.append(f'Service Call ({class_label}, {tier}, incl. 30 min): ${base:.2f}')
|
||||
if extra_techs:
|
||||
lines.append(f'Additional technicians ({techs - 1}): ${extra_techs:.2f}')
|
||||
if labor:
|
||||
lines.append(
|
||||
f'Labour {mode_label}: '
|
||||
f'{hours:.2f} h actual -> billed '
|
||||
f'{max(math.ceil(hours * 2) / 2.0, rate.minimum_labor_hours if rate else 1.0):.2f} h '
|
||||
f'x {techs} tech x ${hourly_used:.2f}/h = ${labor:.2f}'
|
||||
)
|
||||
if travel:
|
||||
over_km = max(distance - (rate.travel_distance_threshold_km if rate else 25), 0)
|
||||
lines.append(
|
||||
f'Travel: {distance:.1f} km, {over_km:.1f} km over threshold, '
|
||||
f'both ways x {techs} tech x ${rate.travel_per_km_fee if rate else 0}/km = ${travel:.2f}'
|
||||
)
|
||||
if waived:
|
||||
lines.append(f'Less labour waived: -${waived:.2f}')
|
||||
lines.append('-' * 50)
|
||||
lines.append(f'TOTAL (excl. parts): ${total:.2f}')
|
||||
r.x_fc_quote_breakdown_text = '\n'.join(lines)
|
||||
|
||||
def action_check_labor_warranty(self):
|
||||
"""Look up the active store labor warranty for this repair's
|
||||
|
||||
@@ -33,6 +33,20 @@ class FusionRepairProductCategory(models.Model):
|
||||
'self-help and force escalation when safety symptoms appear.',
|
||||
)
|
||||
|
||||
# Bundle 10: aligns Westin's printed rate card - LIFT & ELEVATING SERVICE
|
||||
# has its own higher rates (stairlifts, porch lifts, lift chairs, hoyer lifts).
|
||||
equipment_class = fields.Selection(
|
||||
[
|
||||
('standard', 'Standard Service'),
|
||||
('lift_elevating', 'Lift & Elevating Service'),
|
||||
],
|
||||
string='Equipment Class',
|
||||
default='standard',
|
||||
required=True,
|
||||
help='Determines which callout rate row applies. Lift & Elevating uses '
|
||||
'higher per-card rates (e.g. $160 callout vs $95 standard).',
|
||||
)
|
||||
|
||||
intake_template_id = fields.Many2one(
|
||||
'fusion.repair.intake.template',
|
||||
string='Default Intake Template',
|
||||
|
||||
Reference in New Issue
Block a user