diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py
index 731d1b74..3923b042 100644
--- a/fusion_repairs/__manifest__.py
+++ b/fusion_repairs/__manifest__.py
@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
- 'version': '19.0.1.9.1',
+ 'version': '19.0.2.0.0',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
@@ -76,6 +76,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'data/intake_template_data.xml',
'data/self_check_data.xml',
'data/emergency_charge_data.xml',
+ 'data/callout_rate_data.xml',
# Views
'views/repair_product_category_views.xml',
'views/intake_template_views.xml',
@@ -85,6 +86,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'views/repair_dashboard_views.xml',
'views/repair_emergency_charge_views.xml',
'views/repair_inspection_views.xml',
+ 'views/repair_callout_rate_views.xml',
+ 'views/repair_labor_warranty_views.xml',
'views/repair_order_views.xml',
'views/repair_part_order_views.xml',
'views/repair_service_plan_views.xml',
diff --git a/fusion_repairs/data/callout_rate_data.xml b/fusion_repairs/data/callout_rate_data.xml
new file mode 100644
index 00000000..4f00f554
--- /dev/null
+++ b/fusion_repairs/data/callout_rate_data.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+ regular
+ 120.00
+ 60.00
+ 60.00
+ 95.00
+ 1.0
+ 25.0
+ 0.85
+ Standard business hours (Mon-Fri 9 AM - 5 PM). Base fee includes the first 30 minutes for inspection / report.
+
+
+
+ after_hours
+ 180.00
+ 90.00
+ 90.00
+ 140.00
+ 1.0
+ 25.0
+ 1.10
+ Weekday evenings 5 PM - 9 PM. Higher base + higher labour + travel always billed past 25 km.
+
+
+
+ weekend
+ 240.00
+ 120.00
+ 120.00
+ 170.00
+ 1.0
+ 25.0
+ 1.35
+ Saturday + Sunday. Premium tier.
+
+
+
+ holiday
+ 300.00
+ 150.00
+ 150.00
+ 200.00
+ 1.0
+ 25.0
+ 1.50
+ Statutory holidays. Highest tier.
+
+
+
+
diff --git a/fusion_repairs/data/ir_sequence_data.xml b/fusion_repairs/data/ir_sequence_data.xml
index f58b5f26..4903a041 100644
--- a/fusion_repairs/data/ir_sequence_data.xml
+++ b/fusion_repairs/data/ir_sequence_data.xml
@@ -24,6 +24,17 @@
+
+
+ Labor Warranty
+ fusion.repair.labor.warranty
+ LW-
+ 5
+ 1
+ 1
+
+
+
Repair Part Order
diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py
index 2710f941..f9676fb7 100644
--- a/fusion_repairs/models/__init__.py
+++ b/fusion_repairs/models/__init__.py
@@ -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
diff --git a/fusion_repairs/models/product_template.py b/fusion_repairs/models/product_template.py
index ea6224b0..778710d9 100644
--- a/fusion_repairs/models/product_template.py
+++ b/fusion_repairs/models/product_template.py
@@ -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.',
+ )
diff --git a/fusion_repairs/models/repair_callout_rate.py b/fusion_repairs/models/repair_callout_rate.py
new file mode 100644
index 00000000..e78bef08
--- /dev/null
+++ b/fusion_repairs/models/repair_callout_rate.py
@@ -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)
diff --git a/fusion_repairs/models/repair_labor_warranty.py b/fusion_repairs/models/repair_labor_warranty.py
new file mode 100644
index 00000000..d8b2cc99
--- /dev/null
+++ b/fusion_repairs/models/repair_labor_warranty.py
@@ -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 voided 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.'))
diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py
index 911d348c..98ccb579 100644
--- a/fusion_repairs/models/repair_order.py
+++ b/fusion_repairs/models/repair_order.py
@@ -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(_(
+ 'Labor fee waived 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):
diff --git a/fusion_repairs/models/repair_service_plan.py b/fusion_repairs/models/repair_service_plan.py
index 4c0346d2..41cc41e0 100644
--- a/fusion_repairs/models/repair_service_plan.py
+++ b/fusion_repairs/models/repair_service_plan.py
@@ -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),
+ })
diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv
index 45dfc323..e942afe0 100644
--- a/fusion_repairs/security/ir.model.access.csv
+++ b/fusion_repairs/security/ir.model.access.csv
@@ -43,3 +43,9 @@ access_part_order_manager,Part Order Manager Full,model_fusion_repair_part_order
access_part_order_technician,Part Order Field Tech Create,model_fusion_repair_part_order,fusion_tasks.group_field_technician,1,1,1,0
access_visit_report_partline_user,Visit Report Part Line User Full,model_fusion_repair_visit_report_wizard_partline,group_fusion_repairs_user,1,1,1,1
access_visit_report_partline_tech,Visit Report Part Line Field Tech Full,model_fusion_repair_visit_report_wizard_partline,fusion_tasks.group_field_technician,1,1,1,1
+access_callout_rate_user,Callout Rate User Read,model_fusion_repair_callout_rate,group_fusion_repairs_user,1,0,0,0
+access_callout_rate_manager,Callout Rate Manager Full,model_fusion_repair_callout_rate,group_fusion_repairs_manager,1,1,1,1
+access_labor_warranty_user,Labor Warranty User Read,model_fusion_repair_labor_warranty,group_fusion_repairs_user,1,0,0,0
+access_labor_warranty_sales_rep,Labor Warranty Sales Rep Write,model_fusion_repair_labor_warranty,group_fusion_repairs_sales_rep,1,1,0,0
+access_labor_warranty_manager,Labor Warranty Manager Full,model_fusion_repair_labor_warranty,group_fusion_repairs_manager,1,1,1,1
+access_labor_warranty_technician,Labor Warranty Field Tech Read,model_fusion_repair_labor_warranty,fusion_tasks.group_field_technician,1,1,0,0
diff --git a/fusion_repairs/security/security.xml b/fusion_repairs/security/security.xml
index dcbe4bc1..0511d0fb 100644
--- a/fusion_repairs/security/security.xml
+++ b/fusion_repairs/security/security.xml
@@ -34,11 +34,21 @@
Assigns technicians to repairs, reschedules visits, manages parts pre-pull picklists.
+
+
+ Repairs: Sales Rep
+
+
+ Sales reps who can waive labor fees on their accounts (CS cannot waive).
+
+
Repairs: Manager
-
- Configures intake templates, pricing, maintenance contracts, on-call rotation, variance overrides.
+
+ Configures intake templates, pricing, maintenance contracts, on-call rotation, variance overrides. Implies all lower groups including sales rep.