# -*- 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'), ('rush', 'Rush Service'), ('after_hours', 'After Hours (weekday evening)'), ('weekend', 'Weekend'), ('holiday', 'Statutory Holiday'), ], string='Tier', required=True, default='regular', ) # Bundle 10: Westin's rate card splits by equipment class. Lift & # Elevating Service ($160 callout / $110 labour) is distinct from # Standard Service ($95 callout / $85 labour). The lookup falls back # from (tier, equipment_class) to (tier, 'standard'). equipment_class = fields.Selection( [ ('standard', 'Standard Service'), ('lift_elevating', 'Lift & Elevating Service'), ], string='Equipment Class', default='standard', required=True, ) # ---- 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='On-Site 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.', ) # Bundle 10: separate IN-SHOP hourly rate. When the client brings the unit # to the store (no callout, no travel) we charge a lower hourly rate. in_shop_labor_rate = fields.Monetary( string='In-Shop Hourly Labour Rate', currency_field='currency_id', default=0.0, help='Hourly rate when work is done IN THE STORE (no callout fee, no ' 'travel). Per Westin rate card: $75 standard / $110 lift.', ) 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. Hours beyond the floor ' 'are pro-rated in 30-minute increments per the published card.', ) # ---- 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, equipment_class='standard', on_date=None): """Return the active rate row for `tier` + `equipment_class` effective on `on_date`. Tries (tier, class) first, falls back to (tier, standard) if no class-specific row is configured. Empty recordset if none at all. """ on_date = on_date or fields.Date.context_today(self) Domain = lambda cls: [ ('tier', '=', tier), ('equipment_class', '=', cls), ('active', '=', True), ('effective_from', '<=', on_date), ('company_id', 'in', self.env.companies.ids), ] hit = self.sudo().search( Domain(equipment_class or 'standard'), order='effective_from desc', limit=1, ) if not hit and equipment_class and equipment_class != 'standard': hit = self.sudo().search( Domain('standard'), order='effective_from desc', limit=1, ) return hit