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:
gsinghpal
2026-05-21 01:56:09 -04:00
parent ebbadb3002
commit f41426c5b9
17 changed files with 999 additions and 3 deletions

View File

@@ -16,6 +16,8 @@ from . import repair_inspection
from . import repair_service_plan
from . import repair_emergency_charge
from . import repair_part_order
from . import repair_callout_rate
from . import repair_labor_warranty
from . import product_template
from . import res_partner
from . import res_users

View File

@@ -32,3 +32,11 @@ class ProductTemplate(models.Model):
help='Optional override of the intake template normally chosen from the '
'repair category. Leave empty to use category default.',
)
# Bundle 9: store labor warranty granted at point of sale.
x_fc_labor_warranty_years = fields.Integer(
string='Store Labor Warranty (years)',
default=0,
help='Years of store labor warranty granted when this product is sold. '
'0 = no warranty. Setting this triggers a fusion.repair.labor.warranty '
'record per unit on sale-order confirm.',
)

View File

@@ -0,0 +1,140 @@
# -*- 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)

View File

@@ -0,0 +1,231 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Store labor warranty.
Distinct from the manufacturer warranty. This is what we extend at point
of sale: "5-year labor warranty - bring it to the store, we fix the labour
for free". Carve-outs (user negligence, gross negligence, misuse, etc.)
are tracked explicitly so the visit-report wizard can VOID the warranty
in real time when the tech encounters one.
Important boundary - WHAT THE WARRANTY COVERS:
- In-store labour: FREE
- Home callout (tech dispatched): callout fee STILL applies (it includes
inspection / report); the hourly labour beyond 30 min is free
- Parts: NEVER free unless covered by separate manufacturer warranty
- Travel: ALWAYS charged when over the distance threshold
"""
from dateutil.relativedelta import relativedelta
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
VOID_REASONS = [
('user_negligence', 'User Negligence'),
('gross_negligence', 'Gross Negligence'),
('misuse', 'Misuse'),
('over_recommended_use', 'Over-Recommended Use'),
('accidental_damage', 'Accidental Damage'),
('not_covered_part', 'Part Not Covered'),
('other', 'Other (see notes)'),
]
class FusionRepairLaborWarranty(models.Model):
_name = 'fusion.repair.labor.warranty'
_inherit = ['mail.thread']
_description = 'Store Labor Warranty'
_order = 'end_date desc, id desc'
name = fields.Char(
string='Reference',
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='Equipment',
required=True,
tracking=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial',
tracking=True,
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sold On',
ondelete='set null',
tracking=True,
)
warranty_years = fields.Integer(
string='Years',
default=5,
required=True,
tracking=True,
)
start_date = fields.Date(
string='Start',
default=fields.Date.context_today,
required=True,
tracking=True,
)
end_date = fields.Date(
string='Ends',
compute='_compute_end_date',
store=True,
tracking=True,
)
state = fields.Selection(
[
('active', 'Active'),
('expired', 'Expired'),
('void', 'Void'),
('consumed', 'Consumed'),
],
string='Status',
default='active',
tracking=True,
compute='_compute_state',
store=True,
)
# When voided
void_reason = fields.Selection(
VOID_REASONS,
string='Void Reason',
tracking=True,
)
void_notes = fields.Text(string='Void Notes')
voided_at = fields.Datetime(string='Voided At', copy=False)
voided_by_id = fields.Many2one('res.users', string='Voided By', copy=False)
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
_name_unique = models.Constraint(
'unique(name)',
'Labor-warranty references must be unique.',
)
# ------------------------------------------------------------------
# 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.labor.warranty'
) or 'LW/NEW'
return super().create(vals_list)
# ------------------------------------------------------------------
# COMPUTES
# ------------------------------------------------------------------
@api.depends('start_date', 'warranty_years')
def _compute_end_date(self):
for r in self:
if r.start_date and r.warranty_years:
r.end_date = r.start_date + relativedelta(years=r.warranty_years)
else:
r.end_date = False
@api.depends('void_reason', 'end_date')
def _compute_state(self):
today = fields.Date.context_today(self)
for r in self:
if r.state == 'consumed':
continue
if r.void_reason:
r.state = 'void'
elif r.end_date and r.end_date < today:
r.state = 'expired'
else:
r.state = 'active'
# ------------------------------------------------------------------
# LOOKUP
# ------------------------------------------------------------------
@api.model
def find_active_for(self, partner, product=None, lot=None):
"""Find the active labor warranty covering (partner, product/lot).
Specificity order:
1. exact lot match
2. product + partner match
3. partner-only match (last resort)
"""
if not partner:
return self.browse()
today = fields.Date.context_today(self)
base_domain = [
('partner_id', '=', partner.id),
('state', '=', 'active'),
('end_date', '>=', today),
]
if lot:
hit = self.sudo().search(
base_domain + [('lot_id', '=', lot.id)],
order='end_date desc', limit=1,
)
if hit:
return hit
if product:
hit = self.sudo().search(
base_domain + [('product_id', '=', product.id)],
order='end_date desc', limit=1,
)
if hit:
return hit
return self.browse()
# ------------------------------------------------------------------
# VOID
# ------------------------------------------------------------------
def action_void(self, reason='other', notes=''):
if not reason:
raise UserError(_('A void reason is required.'))
for r in self:
r.write({
'void_reason': reason,
'void_notes': notes,
'voided_at': fields.Datetime.now(),
'voided_by_id': self.env.uid,
})
r.message_post(body=Markup(_(
'Warranty <b>voided</b> by %(user)s. Reason: %(reason)s.'
)) % {
'user': self.env.user.name,
'reason': dict(VOID_REASONS).get(reason, reason),
})
def action_reinstate(self):
for r in self:
r.write({
'void_reason': False,
'void_notes': False,
'voided_at': False,
'voided_by_id': False,
})
r.message_post(body=_('Warranty reinstated.'))

View File

@@ -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):

View File

@@ -244,4 +244,27 @@ class SaleOrder(models.Model):
'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),
})