Files
Odoo-Modules/fusion_repairs/models/repair_service_plan.py
gsinghpal f41426c5b9 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>
2026-05-21 01:56:09 -04:00

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),
})