feat(fusion_repairs): Bundle 9 - service callout pricing + store labor warranty
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>
This commit is contained in:
@@ -241,6 +241,230 @@ class RepairOrder(models.Model):
|
||||
string='# Part Orders',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bundle 9: SERVICE CALLOUT PRICING + LABOR WARRANTY
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_callout_tier = fields.Selection(
|
||||
[
|
||||
('regular', 'Regular Business Hours'),
|
||||
('after_hours', 'After Hours'),
|
||||
('weekend', 'Weekend'),
|
||||
('holiday', 'Statutory Holiday'),
|
||||
],
|
||||
string='Callout Tier',
|
||||
default='regular',
|
||||
tracking=True,
|
||||
help='Which rate-card tier applies. Set by CS at intake; can be changed '
|
||||
'by dispatcher if the schedule moves into after-hours / weekend.',
|
||||
)
|
||||
x_fc_callout_distance_km = fields.Float(
|
||||
string='One-Way Distance (km)',
|
||||
tracking=True,
|
||||
help='Distance from the shop to the client. Travel beyond the rate-card '
|
||||
"threshold is billed BOTH WAYS at the rate's per-km fee.",
|
||||
)
|
||||
x_fc_callout_techs = fields.Integer(
|
||||
string='Technicians on Callout',
|
||||
default=1,
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_callout_labor_hours = fields.Float(
|
||||
string='Billable Labor Hours',
|
||||
default=0.0,
|
||||
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.',
|
||||
)
|
||||
# Labor warranty link + status (resolved at visit time)
|
||||
x_fc_labor_warranty_id = fields.Many2one(
|
||||
'fusion.repair.labor.warranty',
|
||||
string='Store Labor Warranty',
|
||||
tracking=True,
|
||||
help='Auto-resolved when the visit-report wizard runs - links to the '
|
||||
'active store labor warranty for this client + product if any.',
|
||||
)
|
||||
x_fc_labor_warranty_status = fields.Selection(
|
||||
[
|
||||
('not_checked', 'Not Yet Checked'),
|
||||
('eligible', 'Covered - Labor Free'),
|
||||
('not_covered', 'No Warranty on File'),
|
||||
('expired', 'Warranty Expired'),
|
||||
('void_misuse', 'Void - Misuse / Negligence'),
|
||||
('waived', 'Manually Waived'),
|
||||
],
|
||||
string='Labor Warranty Status',
|
||||
default='not_checked',
|
||||
tracking=True,
|
||||
)
|
||||
# Manual labor-fee waiver (manager / sales rep only)
|
||||
x_fc_labor_waived = fields.Boolean(
|
||||
string='Labor Fee Waived',
|
||||
tracking=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
)
|
||||
x_fc_labor_waived_by_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Labor Waived By',
|
||||
tracking=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
)
|
||||
x_fc_labor_waived_at = fields.Datetime(
|
||||
string='Labor Waived At',
|
||||
tracking=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
)
|
||||
x_fc_labor_waived_reason = fields.Char(
|
||||
string='Labor Waiver Reason',
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# Computed quote breakdown (all non-stored - depend on the rate-card)
|
||||
x_fc_quote_callout_base = fields.Monetary(
|
||||
string='Base Callout Fee',
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_callout_quote',
|
||||
)
|
||||
x_fc_quote_extra_techs = fields.Monetary(
|
||||
string='Extra Tech Fees',
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_callout_quote',
|
||||
)
|
||||
x_fc_quote_labor = fields.Monetary(
|
||||
string='Labor Charge',
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_callout_quote',
|
||||
)
|
||||
x_fc_quote_travel = fields.Monetary(
|
||||
string='Travel Charge',
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_callout_quote',
|
||||
)
|
||||
x_fc_quote_waived = fields.Monetary(
|
||||
string='Less: Waived',
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_callout_quote',
|
||||
)
|
||||
x_fc_quote_total = fields.Monetary(
|
||||
string='Quote Total (excl. parts)',
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_callout_quote',
|
||||
store=True, # stored so we can show it on list views and search
|
||||
)
|
||||
x_fc_quote_breakdown_text = fields.Text(
|
||||
string='Quote Breakdown',
|
||||
compute='_compute_callout_quote',
|
||||
help='Human-readable line-by-line breakdown - used in the quote email.',
|
||||
)
|
||||
|
||||
@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')
|
||||
def _compute_callout_quote(self):
|
||||
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)
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# Labour - charged per tech with min-1-hour floor on the
|
||||
# BILLABLE portion (above 30 min the callout includes).
|
||||
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
|
||||
|
||||
waived = 0.0
|
||||
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
|
||||
|
||||
r.x_fc_quote_callout_base = base
|
||||
r.x_fc_quote_extra_techs = extra_techs
|
||||
r.x_fc_quote_labor = labor
|
||||
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}'
|
||||
)
|
||||
|
||||
def action_check_labor_warranty(self):
|
||||
"""Look up the active store labor warranty for this repair's
|
||||
partner + product. Updates x_fc_labor_warranty_id and
|
||||
x_fc_labor_warranty_status. Called from the visit-report wizard
|
||||
AND from the dashboard's "check warranty" button."""
|
||||
Warr = self.env['fusion.repair.labor.warranty'].sudo()
|
||||
for r in self:
|
||||
w = Warr.find_active_for(
|
||||
r.partner_id, r.product_id, r.lot_id or False,
|
||||
)
|
||||
r.x_fc_labor_warranty_id = w.id if w else False
|
||||
if not w:
|
||||
r.x_fc_labor_warranty_status = 'not_covered'
|
||||
elif w.state == 'expired':
|
||||
r.x_fc_labor_warranty_status = 'expired'
|
||||
elif w.state == 'void':
|
||||
r.x_fc_labor_warranty_status = 'void_misuse'
|
||||
else:
|
||||
r.x_fc_labor_warranty_status = 'eligible'
|
||||
|
||||
def action_waive_labor_fee(self):
|
||||
"""Manager / sales rep only. CS rep cannot waive."""
|
||||
Group = self.env.ref
|
||||
user = self.env.user
|
||||
can_waive = (
|
||||
user.has_group('fusion_repairs.group_fusion_repairs_manager')
|
||||
or user.has_group('fusion_repairs.group_fusion_repairs_sales_rep')
|
||||
)
|
||||
if not can_waive:
|
||||
raise UserError(_(
|
||||
'Only Repairs Managers and Sales Reps can waive the labor fee. '
|
||||
'CS staff must escalate to a manager.'
|
||||
))
|
||||
for r in self:
|
||||
r.write({
|
||||
'x_fc_labor_waived': True,
|
||||
'x_fc_labor_waived_by_id': user.id,
|
||||
'x_fc_labor_waived_at': fields.Datetime.now(),
|
||||
'x_fc_labor_warranty_status': 'waived',
|
||||
})
|
||||
r.message_post(body=Markup(_(
|
||||
'<b>Labor fee waived</b> by %(user)s. (Reason: %(reason)s)'
|
||||
)) % {
|
||||
'user': user.name,
|
||||
'reason': r.x_fc_labor_waived_reason or 'goodwill',
|
||||
})
|
||||
|
||||
@api.depends('x_fc_rush_tier', 'x_fc_rush_techs_required',
|
||||
'x_fc_repair_category_id')
|
||||
def _compute_rush_surcharge(self):
|
||||
|
||||
Reference in New Issue
Block a user