Full home-service pricing engine plus the store labor warranty model. The
call price now itemises base callout + extra techs + hourly labour (with
the 30-min-included + 1-hour-minimum rule) + travel both ways past
threshold, with three independent waive paths: in-warranty / manager
override / sales-rep override. CS cannot waive (RBAC).
NEW MODELS
fusion.repair.callout.rate (rate card)
Per (tier, company) row. Tiers: regular / after_hours / weekend / holiday.
Fields:
- base_callout_fee (INCLUDES first 30 min for inspection / report)
- second_tech_fee + additional_tech_fee (3rd, 4th tech)
- hourly_labor_rate + minimum_labor_hours (default 1.0 floor)
- travel_distance_threshold_km + travel_per_km_fee
- effective_from (newer rows supersede older)
Seeded with 4 default rows (regular $120/$95/0.85, after-hours
$180/$140/1.10, weekend $240/$170/1.35, holiday $300/$200/1.50).
fusion.repair.labor.warranty (store labor warranty)
Per (partner, product/lot, sale_order) record with warranty_years +
start_date + computed end_date. State machine: active / expired / void
/ consumed. Void reasons spec'd by the user: user_negligence /
gross_negligence / misuse / over_recommended_use / accidental_damage
/ not_covered_part / other.
find_active_for(partner, product, lot) - lot-first then product+partner
then partner-only fallback so warranty resolution survives partner-
contact / product-variant differences.
action_void(reason, notes) - manager-only; audit stamps voided_by_id
+ voided_at + reason; posts chatter.
PRODUCT EXTENSION
product.template.x_fc_labor_warranty_years (Integer, default 0).
SALE-ORDER EXTENSION
sale.order.action_confirm now also runs _fc_spawn_labor_warranties()
which creates one fusion.repair.labor.warranty per unit of any product
with x_fc_labor_warranty_years > 0. Lives alongside the existing
service-plan spawn so a 5y-LW stairlift sold with a maintenance plan
spawns both records in one go.
PRICING ENGINE ON REPAIR.ORDER
9 new fields:
x_fc_callout_tier (regular/after_hours/weekend/holiday)
x_fc_callout_distance_km (one-way; system bills both ways)
x_fc_callout_techs (1, 2, 3+)
x_fc_callout_labor_hours (hours above the 30 min the callout covers)
x_fc_labor_warranty_id (auto-resolved on visit)
x_fc_labor_warranty_status (not_checked / eligible / not_covered /
expired / void_misuse / waived)
x_fc_labor_waived + _by_id + _at + _reason
6 computed quote fields:
x_fc_quote_callout_base (base_callout_fee)
x_fc_quote_extra_techs (second + additional fees)
x_fc_quote_labor (max(hours, min_hours) * rate * techs)
x_fc_quote_travel (max(distance - threshold, 0) * 2 * per_km * techs)
x_fc_quote_waived (= labor if warranty eligible OR labor waived)
x_fc_quote_total (sum minus waived; stored, indexable)
+ a human-readable x_fc_quote_breakdown_text used in the email template.
3 new actions:
action_check_labor_warranty (anyone) - resolves the warranty and
stamps x_fc_labor_warranty_status. Called automatically by the
visit-report wizard.
action_waive_labor_fee (SECURITY GATED) - raises UserError unless
caller is in group_fusion_repairs_manager OR
group_fusion_repairs_sales_rep. CS users get the explicit message
'Only Repairs Managers and Sales Reps can waive the labor fee.'
action_acknowledge_rush - Bundle 8 carryover.
SECURITY
New group_fusion_repairs_sales_rep
Independent group so a sales rep can waive labor on their accounts
without becoming a Repairs Dispatcher / Manager. Manager IMPLIES
sales_rep so managers automatically inherit the right.
ACLs: callout.rate user-read / manager-full; labor.warranty user-read /
sales_rep-write / manager-full / technician-read+write.
VISIT-REPORT WIZARD EXTENSIONS
Pricing block (visible when outcome=completed):
callout_tier / techs / distance_km / labor_hours_used (default 1.0
minimum). Live quote_total_preview + breakdown shown to the tech so
they can confirm the price with the client right at the door.
Warranty block:
labor_warranty_id_preview + labor_warranty_status_preview (badge
coloured by status). 'warranty_void_reason' selection lets the tech
void the warranty in real time when they find misuse / negligence /
accidental damage - on submit the matching warranty record is voided
permanently (action_void) AND the repair's labor charge re-computes
without the waive.
On confirm the wizard:
1. Persists callout_labor_hours_used to the repair
2. Calls repair.action_check_labor_warranty()
3. If warranty_void_reason set + warranty resolved -> voids it,
posts chatter, repair labor_warranty_status -> void_misuse
NAVIGATION
Repair form 4 new header buttons:
Check Labor Warranty (anyone)
Waive Labor Fee (sales_rep + manager only, server-side gated)
(plus the Bundle 8 Squeeze + Ack Rush from before)
New 'Callout Pricing' notebook tab on repair form with:
inputs, warranty/waiver, and the 6-line quote breakdown.
New menus:
Fusion Repairs > Labor Warranties
Configuration > Callout Rate Card
Configuration > Emergency Surcharges (Bundle 8 carryover)
VERIFICATION END-TO-END (7 scenarios, 0 bugs)
A. Sale of a product with 5y LW -> LW-00002 spawned, expires 2031-05-21.
B. In-warranty regular 12km 20-min repair:
base 120 + labor 95 - waived 95 = $120 (callout only)
C. After-hours 2-tech 40km 1.5h, NO warranty:
180 + 90 + (1.5*140*2) + (15*2*1.10*2) = $756.00 exact
D. In-warranty visit -> tech ticks misuse void_reason:
Warranty record -> state=void / reason=misuse.
Repair labor_warranty_status -> void_misuse.
Quote re-computes WITHOUT waive: labor 1.5 * 95 = $142.50 charged.
E. Manager waives labor on a no-warranty repair:
Pre-waive $310 -> post-waive $120 (labor $190 -> waived).
Audit: waived_by_id stamped to gsingh@.
F. CS rep tries to waive: correctly denied with the spec'd error
'Only Repairs Managers and Sales Reps can waive the labor fee.'
G. Weekend 1-tech 30km 30-min:
240 + (1.0*170) + (5*2*1.35) = $423.50 exact (min-1h floor
correctly applied to the 0.5h actual work).
Bumped to 19.0.2.0.0 (minor version bump - new public-facing model).
Co-authored-by: Cursor <cursoragent@cursor.com>
141 lines
5.2 KiB
Python
141 lines
5.2 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'),
|
|
('after_hours', 'After Hours (weekday evening)'),
|
|
('weekend', 'Weekend'),
|
|
('holiday', 'Statutory Holiday'),
|
|
],
|
|
string='Tier',
|
|
required=True,
|
|
default='regular',
|
|
)
|
|
|
|
# ---- 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='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.',
|
|
)
|
|
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.',
|
|
)
|
|
|
|
# ---- 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, on_date=None):
|
|
"""Return the active rate row for `tier` effective on `on_date`
|
|
(default today). Returns empty recordset if none configured."""
|
|
on_date = on_date or fields.Date.context_today(self)
|
|
return self.sudo().search([
|
|
('tier', '=', tier),
|
|
('active', '=', True),
|
|
('effective_from', '<=', on_date),
|
|
('company_id', 'in', self.env.companies.ids),
|
|
], order='effective_from desc', limit=1)
|