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>
179 lines
6.8 KiB
Python
179 lines
6.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2024-2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
|
|
"""Service callout rate card.
|
|
|
|
When we dispatch a tech to a client's home (as opposed to in-store work),
|
|
this rate card answers "what do we charge?" for any combination of:
|
|
|
|
* tier (regular hours / after-hours / weekend / statutory holiday)
|
|
* number of technicians dispatched
|
|
* actual labour hours billable (after the 30 min the callout fee covers)
|
|
* round-trip travel kilometres beyond the threshold
|
|
|
|
The Bundle 8 emergency surcharge sits ON TOP of this when CS flags a
|
|
repair as a rush (same-day squeeze, etc.). They are separate concepts:
|
|
- callout rate = the BASELINE house-call price
|
|
- emergency surcharge = added "drop everything" premium
|
|
"""
|
|
|
|
from odoo import _, api, fields, models
|
|
|
|
|
|
class FusionRepairCalloutRate(models.Model):
|
|
_name = 'fusion.repair.callout.rate'
|
|
_description = 'Service Callout Rate Card'
|
|
_order = 'effective_from desc, tier, id'
|
|
|
|
name = fields.Char(compute='_compute_name', store=True)
|
|
tier = fields.Selection(
|
|
[
|
|
('regular', 'Regular Business Hours'),
|
|
('rush', 'Rush Service'),
|
|
('after_hours', 'After Hours (weekday evening)'),
|
|
('weekend', 'Weekend'),
|
|
('holiday', 'Statutory Holiday'),
|
|
],
|
|
string='Tier',
|
|
required=True,
|
|
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)',
|
|
currency_field='currency_id',
|
|
required=True,
|
|
default=0.0,
|
|
help='Charge for dispatching one technician to the client. INCLUDES '
|
|
'the first 30 minutes for inspection / check / report. Repair '
|
|
'labour above the 30 min is charged hourly at hourly_labor_rate.',
|
|
)
|
|
second_tech_fee = fields.Monetary(
|
|
string='Second Technician Fee',
|
|
currency_field='currency_id',
|
|
default=0.0,
|
|
help='Added to the callout when a 2nd technician is dispatched alongside '
|
|
'the first. Lower than a second base callout because they share '
|
|
'travel.',
|
|
)
|
|
additional_tech_fee = fields.Monetary(
|
|
string='Each Additional Technician Fee',
|
|
currency_field='currency_id',
|
|
default=0.0,
|
|
help='Applied to the 3rd, 4th... technician on the same callout. '
|
|
'Defaults to second_tech_fee if left zero.',
|
|
)
|
|
|
|
# ---- Hourly labour (after the included 30 min) ----
|
|
hourly_labor_rate = fields.Monetary(
|
|
string='On-Site Hourly Labour Rate (per tech)',
|
|
currency_field='currency_id',
|
|
required=True,
|
|
default=0.0,
|
|
help='Per-technician hourly rate applied to billable labour above the '
|
|
'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. Hours beyond the floor '
|
|
'are pro-rated in 30-minute increments per the published card.',
|
|
)
|
|
|
|
# ---- Travel ----
|
|
travel_distance_threshold_km = fields.Float(
|
|
string='Free Travel Distance (km, one-way)',
|
|
default=25.0,
|
|
help='Travel under this distance is free. Beyond it, every additional '
|
|
'kilometre is charged at travel_per_km_fee, BOTH WAYS (so the bill '
|
|
'is per-km * (one_way_km - threshold) * 2).',
|
|
)
|
|
travel_per_km_fee = fields.Monetary(
|
|
string='Per-km Fee Over Threshold',
|
|
currency_field='currency_id',
|
|
default=0.0,
|
|
help='Per technician, per kilometre, both ways.',
|
|
)
|
|
|
|
# ---- Bookkeeping ----
|
|
effective_from = fields.Date(
|
|
string='Effective From',
|
|
default=fields.Date.context_today,
|
|
required=True,
|
|
help='Rate effective from this date. Newer rates supersede older ones '
|
|
'for the same (tier, company).',
|
|
)
|
|
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)
|
|
description = fields.Text(
|
|
help='Optional notes shown to CS / dispatchers - e.g. "applies after 5 PM weekdays".',
|
|
)
|
|
|
|
@api.depends('tier', 'base_callout_fee', 'effective_from')
|
|
def _compute_name(self):
|
|
for r in self:
|
|
tier_label = dict(self._fields['tier'].selection).get(r.tier) or '?'
|
|
r.name = (
|
|
f'{tier_label} - ${r.base_callout_fee:.0f} callout'
|
|
f' (from {r.effective_from})'
|
|
)
|
|
|
|
@api.model
|
|
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)
|
|
Domain = lambda cls: [
|
|
('tier', '=', tier),
|
|
('equipment_class', '=', cls),
|
|
('active', '=', True),
|
|
('effective_from', '<=', on_date),
|
|
('company_id', 'in', self.env.companies.ids),
|
|
]
|
|
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
|