From f41426c5b9fa11a8d3910039a25b0d1c13335355 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 01:56:09 -0400 Subject: [PATCH] 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 --- fusion_repairs/__manifest__.py | 5 +- fusion_repairs/data/callout_rate_data.xml | 59 +++++ fusion_repairs/data/ir_sequence_data.xml | 11 + fusion_repairs/models/__init__.py | 2 + fusion_repairs/models/product_template.py | 8 + fusion_repairs/models/repair_callout_rate.py | 140 +++++++++++ .../models/repair_labor_warranty.py | 231 ++++++++++++++++++ fusion_repairs/models/repair_order.py | 224 +++++++++++++++++ fusion_repairs/models/repair_service_plan.py | 23 ++ fusion_repairs/security/ir.model.access.csv | 6 + fusion_repairs/security/security.xml | 14 +- fusion_repairs/views/menus.xml | 12 + .../views/repair_callout_rate_views.xml | 30 +++ .../views/repair_labor_warranty_views.xml | 77 ++++++ fusion_repairs/views/repair_order_views.xml | 55 +++++ .../wizard/repair_visit_report_wizard.py | 74 ++++++ .../repair_visit_report_wizard_views.xml | 31 +++ 17 files changed, 999 insertions(+), 3 deletions(-) create mode 100644 fusion_repairs/data/callout_rate_data.xml create mode 100644 fusion_repairs/models/repair_callout_rate.py create mode 100644 fusion_repairs/models/repair_labor_warranty.py create mode 100644 fusion_repairs/views/repair_callout_rate_views.xml create mode 100644 fusion_repairs/views/repair_labor_warranty_views.xml 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. + + + + fusion.repair.callout.rate.list + fusion.repair.callout.rate + + + + + + + + + + + + + + + + + + + Callout Rate Card + fusion.repair.callout.rate + list + + + diff --git a/fusion_repairs/views/repair_labor_warranty_views.xml b/fusion_repairs/views/repair_labor_warranty_views.xml new file mode 100644 index 00000000..2507c422 --- /dev/null +++ b/fusion_repairs/views/repair_labor_warranty_views.xml @@ -0,0 +1,77 @@ + + + + + fusion.repair.labor.warranty.list + fusion.repair.labor.warranty + + + + + + + + + + + + + + + + + + fusion.repair.labor.warranty.form + fusion.repair.labor.warranty + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Labor Warranties + fusion.repair.labor.warranty + list,form + + +
diff --git a/fusion_repairs/views/repair_order_views.xml b/fusion_repairs/views/repair_order_views.xml index a4caa604..8ea9218b 100644 --- a/fusion_repairs/views/repair_order_views.xml +++ b/fusion_repairs/views/repair_order_views.xml @@ -46,6 +46,21 @@ icon="fa-check" invisible="not x_fc_rush_requested or x_fc_rush_acknowledged_at" groups="fusion_repairs.group_fusion_repairs_user"/> + +