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>
271 lines
10 KiB
Python
271 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2024-2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
|
|
r"""Pre-paid service plans (M5).
|
|
|
|
Architecture:
|
|
|
|
product.template
|
|
\--> x_fc_is_service_plan = True
|
|
x_fc_plan_visits_included (e.g. 4)
|
|
x_fc_plan_duration_months (e.g. 12)
|
|
|
|
sale.order.confirm()
|
|
\--> for each line whose product is a service plan,
|
|
create a fusion.repair.service.plan.subscription
|
|
(partner + product + visits_included + start_date + end_date)
|
|
|
|
fusion.repair.maintenance.contract.create_repair_from_booking()
|
|
visit_report_wizard.action_confirm()
|
|
\--> burns down one visit if the partner has an active matching plan
|
|
(for the same product or category)
|
|
|
|
fusion.repair.dashboard.get_dashboard_data()
|
|
\--> exposes active_plan_count + plans_low_count for the dashboard
|
|
"""
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import _, api, fields, models
|
|
|
|
|
|
class ProductTemplate(models.Model):
|
|
_inherit = 'product.template'
|
|
|
|
x_fc_is_service_plan = fields.Boolean(
|
|
string='Service Plan',
|
|
help='Sell this product as a pre-paid maintenance package. '
|
|
'Confirming a sale order with this product creates a '
|
|
'visit subscription for the customer.',
|
|
)
|
|
x_fc_plan_visits_included = fields.Integer(
|
|
string='Visits Included',
|
|
default=4,
|
|
help='Number of maintenance visits the customer is entitled to under this plan.',
|
|
)
|
|
x_fc_plan_duration_months = fields.Integer(
|
|
string='Plan Duration (months)',
|
|
default=12,
|
|
help='Plan ends this many months after the sale-order date even if visits remain.',
|
|
)
|
|
x_fc_plan_category_id = fields.Many2one(
|
|
'fusion.repair.product.category',
|
|
string='Plan Category',
|
|
help='If set, plan visits only burn down for repairs on equipment of this category. '
|
|
'Leave blank to apply to any equipment from this customer.',
|
|
)
|
|
|
|
|
|
class FusionRepairServicePlanSubscription(models.Model):
|
|
_name = 'fusion.repair.service.plan.subscription'
|
|
_inherit = ['mail.thread']
|
|
_description = 'Pre-paid Service Plan Subscription'
|
|
_order = 'end_date desc, id desc'
|
|
|
|
name = fields.Char(
|
|
string='Reference', required=True, default='New',
|
|
copy=False, readonly=True, tracking=True,
|
|
)
|
|
partner_id = fields.Many2one(
|
|
'res.partner', string='Client',
|
|
required=True, tracking=True, index=True,
|
|
)
|
|
product_id = fields.Many2one(
|
|
'product.product', string='Plan Product',
|
|
required=True, tracking=True,
|
|
domain="[('x_fc_is_service_plan', '=', True)]",
|
|
)
|
|
category_id = fields.Many2one(
|
|
'fusion.repair.product.category',
|
|
string='Covers Category',
|
|
help='Computed from the plan product. Only burns visits for repairs '
|
|
'whose category matches.',
|
|
)
|
|
sale_order_id = fields.Many2one(
|
|
'sale.order', string='Sold On',
|
|
ondelete='set null', tracking=True,
|
|
)
|
|
|
|
visits_included = fields.Integer(string='Visits Included', required=True, default=4)
|
|
visits_used = fields.Integer(string='Visits Used', default=0, tracking=True)
|
|
visits_remaining = fields.Integer(
|
|
string='Remaining',
|
|
compute='_compute_visits_remaining', store=True,
|
|
)
|
|
|
|
start_date = fields.Date(
|
|
string='Start', required=True, default=fields.Date.context_today, tracking=True,
|
|
)
|
|
end_date = fields.Date(string='Expires', required=True, tracking=True)
|
|
|
|
state = fields.Selection(
|
|
[
|
|
('active', 'Active'),
|
|
('exhausted', 'Visits Exhausted'),
|
|
('expired', 'Expired'),
|
|
('cancelled', 'Cancelled'),
|
|
],
|
|
string='Status',
|
|
compute='_compute_state', store=True, tracking=True,
|
|
)
|
|
|
|
company_id = fields.Many2one(
|
|
'res.company', default=lambda self: self.env.company,
|
|
)
|
|
|
|
burn_history_ids = fields.One2many(
|
|
'fusion.repair.service.plan.burn',
|
|
'subscription_id',
|
|
string='Burn History',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# CRUD
|
|
# ------------------------------------------------------------------
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
for vals in vals_list:
|
|
if vals.get('name', 'New') == 'New':
|
|
vals['name'] = self.env['ir.sequence'].next_by_code(
|
|
'fusion.repair.service.plan.subscription'
|
|
) or 'PLAN/NEW'
|
|
if vals.get('product_id') and not vals.get('end_date'):
|
|
product = self.env['product.product'].sudo().browse(vals['product_id'])
|
|
months = product.product_tmpl_id.x_fc_plan_duration_months or 12
|
|
start = vals.get('start_date') or fields.Date.context_today(self)
|
|
vals['end_date'] = fields.Date.from_string(str(start)) + relativedelta(months=months)
|
|
if vals.get('product_id') and 'category_id' not in vals:
|
|
product = self.env['product.product'].sudo().browse(vals['product_id'])
|
|
if product.product_tmpl_id.x_fc_plan_category_id:
|
|
vals['category_id'] = product.product_tmpl_id.x_fc_plan_category_id.id
|
|
if vals.get('product_id') and 'visits_included' not in vals:
|
|
product = self.env['product.product'].sudo().browse(vals['product_id'])
|
|
vals['visits_included'] = product.product_tmpl_id.x_fc_plan_visits_included or 4
|
|
return super().create(vals_list)
|
|
|
|
# ------------------------------------------------------------------
|
|
# COMPUTES
|
|
# ------------------------------------------------------------------
|
|
@api.depends('visits_included', 'visits_used')
|
|
def _compute_visits_remaining(self):
|
|
for s in self:
|
|
s.visits_remaining = (s.visits_included or 0) - (s.visits_used or 0)
|
|
|
|
@api.depends('visits_remaining', 'end_date')
|
|
def _compute_state(self):
|
|
today = fields.Date.context_today(self)
|
|
for s in self:
|
|
if s.state == 'cancelled':
|
|
continue
|
|
if s.end_date and s.end_date < today:
|
|
s.state = 'expired'
|
|
elif s.visits_remaining <= 0:
|
|
s.state = 'exhausted'
|
|
else:
|
|
s.state = 'active'
|
|
|
|
# ------------------------------------------------------------------
|
|
# BURN ENGINE
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def find_for_repair(self, repair):
|
|
"""Return the most-recently-started active subscription covering this
|
|
repair (partner match + category match if the plan specifies one)."""
|
|
if not repair.partner_id:
|
|
return self.browse()
|
|
domain = [
|
|
('partner_id', '=', repair.partner_id.id),
|
|
('state', '=', 'active'),
|
|
('visits_remaining', '>', 0),
|
|
]
|
|
subs = self.search(domain, order='start_date desc')
|
|
for s in subs:
|
|
if not s.category_id or s.category_id == repair.x_fc_repair_category_id:
|
|
return s
|
|
return self.browse()
|
|
|
|
def burn_visit(self, repair):
|
|
"""Deduct one visit from this subscription and log the burn."""
|
|
self.ensure_one()
|
|
if self.visits_remaining <= 0:
|
|
return False
|
|
self.visits_used += 1
|
|
self.env['fusion.repair.service.plan.burn'].sudo().create({
|
|
'subscription_id': self.id,
|
|
'repair_order_id': repair.id,
|
|
'burned_on': fields.Date.context_today(self),
|
|
})
|
|
self.message_post(body=_(
|
|
'Visit burned for repair %s. %s of %s remaining.'
|
|
) % (repair.name, self.visits_remaining, self.visits_included))
|
|
return True
|
|
|
|
def action_cancel(self):
|
|
for s in self:
|
|
s.state = 'cancelled'
|
|
s.message_post(body=_('Plan cancelled.'))
|
|
|
|
|
|
class FusionRepairServicePlanBurn(models.Model):
|
|
_name = 'fusion.repair.service.plan.burn'
|
|
_description = 'Service Plan Visit Burn'
|
|
_order = 'burned_on desc, id desc'
|
|
|
|
subscription_id = fields.Many2one(
|
|
'fusion.repair.service.plan.subscription',
|
|
string='Subscription', required=True, ondelete='cascade',
|
|
)
|
|
repair_order_id = fields.Many2one(
|
|
'repair.order', string='Repair', required=True, ondelete='cascade',
|
|
)
|
|
burned_on = fields.Date(string='Burned On', required=True,
|
|
default=fields.Date.context_today)
|
|
|
|
|
|
class SaleOrder(models.Model):
|
|
_inherit = 'sale.order'
|
|
|
|
def action_confirm(self):
|
|
res = super().action_confirm()
|
|
# Spawn subscriptions for each service-plan line.
|
|
for order in self:
|
|
for line in order.order_line:
|
|
tmpl = line.product_id.product_tmpl_id
|
|
if not tmpl.x_fc_is_service_plan:
|
|
continue
|
|
# One subscription per quantity unit (so a SO line with qty=2
|
|
# creates two distinct plans - rare but supported).
|
|
qty = int(line.product_uom_qty or 1)
|
|
for _i in range(max(qty, 1)):
|
|
self.env['fusion.repair.service.plan.subscription'].sudo().create({
|
|
'partner_id': order.partner_id.id,
|
|
'product_id': line.product_id.id,
|
|
'sale_order_id': order.id,
|
|
'start_date': fields.Date.context_today(self),
|
|
})
|
|
# Bundle 9: spawn store labor warranties for any product line with
|
|
# x_fc_labor_warranty_years > 0.
|
|
self._fc_spawn_labor_warranties()
|
|
return res
|
|
|
|
def _fc_spawn_labor_warranties(self):
|
|
Warranty = self.env['fusion.repair.labor.warranty'].sudo()
|
|
for order in self:
|
|
for line in order.order_line:
|
|
tmpl = line.product_id.product_tmpl_id
|
|
years = tmpl.x_fc_labor_warranty_years or 0
|
|
if years <= 0:
|
|
continue
|
|
# One warranty record per unit so each can be voided
|
|
# independently if a specific unit is misused.
|
|
qty = int(line.product_uom_qty or 1)
|
|
for _i in range(max(qty, 1)):
|
|
Warranty.create({
|
|
'partner_id': order.partner_id.id,
|
|
'product_id': line.product_id.id,
|
|
'sale_order_id': order.id,
|
|
'warranty_years': years,
|
|
'start_date': fields.Date.context_today(self),
|
|
})
|