feat(fusion_repairs): Bundle 10 - align pricing to Westin's printed rate card

User shared their actual published service-rate card. Bundle 9's seeded
numbers were placeholders that no longer match. Realigned the rate card,
added the LIFT & ELEVATING SERVICE class, added the in-shop labour
rate path, added the delivery / pickup charge model, added rush as a
proper tier (distinct from after-hours), and added 30-min increment
rounding on top of the existing 1-hour minimum.

EQUIPMENT CLASS

  fusion.repair.product.category gets a new x_fc_equipment_class
  selection: 'standard' vs 'lift_elevating'. The published card splits
  pricing into two service classes - lift_elevating has higher rates
  ($160 callout vs $95, $110/h vs $85).

  Categories marked lift_elevating in seed:
    stairlift, porch_lift, lift_chair (new)

  New 'Lift Chair' category seeded (power recliner / lift chair).

CALLOUT RATE CARD

  fusion.repair.callout.rate gets:
    - equipment_class field (standard / lift_elevating)
    - in_shop_labor_rate field (separate $75 vs $85 on-site)
    - 'rush' tier value (was missing - rush was implicit via emergency
       surcharge from Bundle 8; now a proper tier matching the printed
       rate card row 'Rush Service Calls $120')

  Re-seeded with the PUBLISHED Westin rate card (exact values):

    STANDARD SERVICE
      regular         $95  callout / $85/h on-site  / $75/h in-shop
      rush            $120 callout / $85/h          / $75/h
      after_hours     $140 callout / $85/h          / $75/h
      weekend         $180 callout / $85/h          / $75/h   (extension)
      holiday         $220 callout / $85/h          / $75/h   (extension)

    LIFT & ELEVATING SERVICE
      regular         $160 callout / $110/h on-site / $110/h in-shop
      rush            $200 callout / $110/h         / $110/h  (extension)
      after_hours     $240 callout / $110/h         / $110/h  (extension)
      weekend         $300 callout / $110/h         / $110/h  (extension)
      holiday         $360 callout / $110/h         / $110/h  (extension)

    Travel: $0.70 per km, BOTH WAYS, past 25 km, per technician
    (matches the per-card '$0.70 per km x 2-way' footnote).

  get_for_tier(tier, equipment_class) now resolves with a fallback:
  tries (tier, lift_elevating) first, falls back to (tier, standard)
  if no lift-specific row exists - so an admin can leave standard rows
  as the catch-all and only customise lift for the exceptions.

DELIVERY / PICKUP RATE CARD

  New fusion.repair.delivery.charge model + seed of all 7 items from
  the printed card:
    Local Service Area (within Brampton) ........ $35
    Outside Local Area .......................... $60
    Rush Pickups / Delivery ..................... $60 + $0.70/km x 2-way
    Lift Chair Delivery and Set-Up .............. $120
    Hospital Bed Delivery and Set-Up ............ $120
    Stairlift Delivery and Set-Up ............... $300
    Stairlift Removal ........................... $300

  quote_rush(distance_km) helper for the office's delivery scheduling.
  New menu: Configuration > Delivery / Pickup Charges.

PRICING ENGINE UPDATES (repair.order._compute_callout_quote)

  - Class-aware rate lookup (uses category.equipment_class).
  - In-shop mode (x_fc_in_shop=True): skips callout fee + extra-tech +
    travel; charges in_shop_labor_rate * hours * techs only. Per the
    rate-card footnote 'In-Shop Labour Rate'.
  - 30-min increment rounding ON TOP of the 1-hour floor:
    billable_h = max(ceil(actual * 2) / 2, min_hours)
    -> 20-min work bills 1.0 h
    -> 75-min work bills 1.5 h
    -> 95-min work bills 2.0 h
  - Improved breakdown text shows the rate-card row name + class +
    pro-ration math so the client can see how the total was computed.

NEW FIELDS

  repair.order:
    x_fc_in_shop  (Boolean) - flip to switch the quote engine to
                              in-shop mode.
    x_fc_callout_tier now includes 'rush' as a value (was missing).

  visit-report wizard:
    callout_in_shop related field - tech can flip the mode on-site if
    the work was actually done in-store after pickup.

MIGRATION SCRIPT

  migrations/19.0.2.1.0/post-migration.py runs once on existing
  installs:
    1. Updates stairlift / porch_lift / lift_chair categories
       equipment_class -> lift_elevating
    2. Wipes the 4 Bundle 9 rate-card xml_ids so the new noupdate=1
       seed creates them with the correct printed values.

  Fresh installs get the right values directly from the seed XML.
  Admin-created custom rate rows (no xml_id) are NEVER touched.

VERIFIED END-TO-END (0 bugs across 28 checks)

  Rate card matches printed values exactly:
    regular/standard      = $95/$85h/$75h          PASS
    rush/standard         = $120/$85h/$75h         PASS
    after_hours/standard  = $140/$85h/$75h         PASS
    regular/lift          = $160/$110h/$110h       PASS

  Six end-to-end quote scenarios:
    A. Standard 12km 20-min   -> $180  ($95 + 1h*$85)
    B. Lift     12km 20-min   -> $270  ($160 + 1h*$110)
    C. Rush     30km 1.2h     -> $254.50
       ($120 + ceil(2.4)/2=1.5h * $85 + 5km*2*$0.70 = $7)
    D. After-hours lift 2-tech 35km 2.6h -> $928.00
       ($240 + ceil(5.2)/2=3.0h * $110 * 2 + 10km*2*$0.70*2)
    E. In-shop  standard 2h   -> $150  (2h * $75 in-shop, no callout)
    F. In-shop  lift 1.5h     -> $165  (1.5h * $110 in-shop)

  Seven delivery rates loaded with correct amounts; rush 40km calc
  = $81 ($60 base + 15km*2*$0.70).

  Stairlift / Porch Lift / Lift Chair categories correctly marked
  lift_elevating; rest stay standard.

Bumped to 19.0.2.1.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-05-21 02:47:11 -04:00
parent ecca8e357f
commit 48dd7718e2
16 changed files with 548 additions and 70 deletions

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
'version': '19.0.2.0.0',
'version': '19.0.2.1.0',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
@@ -77,6 +77,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'data/self_check_data.xml',
'data/emergency_charge_data.xml',
'data/callout_rate_data.xml',
'data/delivery_charge_data.xml',
# Views
'views/repair_product_category_views.xml',
'views/intake_template_views.xml',
@@ -87,6 +88,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'views/repair_emergency_charge_views.xml',
'views/repair_inspection_views.xml',
'views/repair_callout_rate_views.xml',
'views/repair_delivery_charge_views.xml',
'views/repair_labor_warranty_views.xml',
'views/repair_order_views.xml',
'views/repair_part_order_views.xml',

View File

@@ -1,58 +1,161 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Default service callout rate card - 3 tiers + Westin Healthcare defaults.
Office can edit these in Configuration -> Callout Rate Card. noupdate=1
so site admin tweaks survive module upgrade.
Westin Healthcare published rate card (from the official client-facing
service-rates flyer / QR card). noupdate=1 so site admin tweaks survive
module upgrade.
STANDARD SERVICE:
Service Calls (includes 30 min labour) ........ $95
Hourly Labour Rate (on-site, per tech) ........ $85
In-Shop Labour Rate (per tech) ................ $75
Rush Service Call ............................. $120 + $0.70/km x 2-way
After Hours Service Call ...................... $140 + $0.70/km x 2-way
LIFT & ELEVATING SERVICE (stairlifts, porch lifts, lift chairs):
Service Calls (includes 30 min labour) ........ $160
Hourly Labour Rate (on-site, per tech) ........ $110
In-Shop Labour Rate (per tech) ................ $110
(Rush / After-hours / Weekend tiers follow the same multipliers as
Standard, applied to the higher base rate.)
Travel: $0.70 per km past 25 km, BOTH WAYS, per technician.
Footnote 2: "If multiple technicians are required, rates will apply per technician."
-->
<odoo>
<data noupdate="1">
<!-- ============== STANDARD SERVICE TIER ROWS ================== -->
<record id="callout_rate_regular" model="fusion.repair.callout.rate">
<field name="tier">regular</field>
<field name="base_callout_fee">120.00</field>
<field name="second_tech_fee">60.00</field>
<field name="additional_tech_fee">60.00</field>
<field name="hourly_labor_rate">95.00</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">95.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.85</field>
<field name="description">Standard business hours (Mon-Fri 9 AM - 5 PM). Base fee includes the first 30 minutes for inspection / report.</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - regular business hours. Service Call ($95) includes the first 30 min of labour. Hourly Rate ($85/h on-site, $75/h in-shop) applies past 30 min, per tech, pro-rated in 30-min increments with a 1-hour minimum.</field>
</record>
<record id="callout_rate_rush" model="fusion.repair.callout.rate">
<field name="tier">rush</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">120.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - rush. $120 plus $0.70 per km (2-way, past 25 km).</field>
</record>
<record id="callout_rate_after_hours" model="fusion.repair.callout.rate">
<field name="tier">after_hours</field>
<field name="base_callout_fee">180.00</field>
<field name="second_tech_fee">90.00</field>
<field name="additional_tech_fee">90.00</field>
<field name="hourly_labor_rate">140.00</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">140.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">1.10</field>
<field name="description">Weekday evenings 5 PM - 9 PM. Higher base + higher labour + travel always billed past 25 km.</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - after-hours (weekday evenings). $140 plus $0.70 per km (2-way, past 25 km).</field>
</record>
<record id="callout_rate_weekend" model="fusion.repair.callout.rate">
<field name="tier">weekend</field>
<field name="base_callout_fee">240.00</field>
<field name="second_tech_fee">120.00</field>
<field name="additional_tech_fee">120.00</field>
<field name="hourly_labor_rate">170.00</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">180.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">1.35</field>
<field name="description">Saturday + Sunday. Premium tier.</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - weekend (extension of published card). $180 callout.</field>
</record>
<record id="callout_rate_holiday" model="fusion.repair.callout.rate">
<field name="tier">holiday</field>
<field name="base_callout_fee">300.00</field>
<field name="second_tech_fee">150.00</field>
<field name="additional_tech_fee">150.00</field>
<field name="hourly_labor_rate">200.00</field>
<field name="equipment_class">standard</field>
<field name="base_callout_fee">220.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">85.00</field>
<field name="in_shop_labor_rate">75.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">1.50</field>
<field name="description">Statutory holidays. Highest tier.</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">STANDARD - statutory holiday (extension of published card). $220 callout.</field>
</record>
<!-- ============== LIFT & ELEVATING SERVICE TIER ROWS ========== -->
<record id="callout_rate_regular_lift" model="fusion.repair.callout.rate">
<field name="tier">regular</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">160.00</field>
<field name="second_tech_fee">0.0</field>
<field name="additional_tech_fee">0.0</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING SERVICE - regular business hours. $160 callout includes 30 min. $110/h labour past 30 min, per tech.</field>
</record>
<record id="callout_rate_rush_lift" model="fusion.repair.callout.rate">
<field name="tier">rush</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">200.00</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING - rush. $200 callout plus $0.70/km (2-way, past 25 km).</field>
</record>
<record id="callout_rate_after_hours_lift" model="fusion.repair.callout.rate">
<field name="tier">after_hours</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">240.00</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING - after-hours. $240 callout plus $0.70/km (2-way, past 25 km).</field>
</record>
<record id="callout_rate_weekend_lift" model="fusion.repair.callout.rate">
<field name="tier">weekend</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">300.00</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING - weekend. $300 callout.</field>
</record>
<record id="callout_rate_holiday_lift" model="fusion.repair.callout.rate">
<field name="tier">holiday</field>
<field name="equipment_class">lift_elevating</field>
<field name="base_callout_fee">360.00</field>
<field name="hourly_labor_rate">110.00</field>
<field name="in_shop_labor_rate">110.00</field>
<field name="minimum_labor_hours">1.0</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="travel_per_km_fee">0.70</field>
<field name="description">LIFT &amp; ELEVATING - statutory holiday. $360 callout.</field>
</record>
</data>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Westin Healthcare DELIVERY / PICKUP CHARGES from the published rate card.
Local Service Area (within Brampton) .................. $35
Outside Local Area .................................... $60
Rush Pickups / Delivery ............................... $60 + $0.70/km x 2-way
Lift Chair Delivery and Set-Up ........................ $120
Hospital Bed Delivery and Set-Up ...................... $120
Stairlift Delivery and Set-Up ......................... $300
Stairlift Removal ..................................... $300
Footnote 3: "Westin Healthcare Delivery includes the drop-off of any
product or material to a client's home office, facility or
predetermined location by one of the staff members. This includes
the return of equipment post-repair."
-->
<odoo>
<data noupdate="1">
<record id="delivery_local" model="fusion.repair.delivery.charge">
<field name="charge_type">local</field>
<field name="amount">35.00</field>
<field name="sequence">10</field>
<field name="description">Within the Brampton service area.</field>
</record>
<record id="delivery_outside" model="fusion.repair.delivery.charge">
<field name="charge_type">outside</field>
<field name="amount">60.00</field>
<field name="sequence">20</field>
<field name="description">Outside the local service area (per the published card).</field>
</record>
<record id="delivery_rush" model="fusion.repair.delivery.charge">
<field name="charge_type">rush</field>
<field name="amount">60.00</field>
<field name="travel_per_km_fee">0.70</field>
<field name="travel_distance_threshold_km">25.0</field>
<field name="sequence">30</field>
<field name="description">Rush pickup or delivery. $60 plus $0.70 per km, both ways, past 25 km.</field>
</record>
<record id="delivery_lift_chair" model="fusion.repair.delivery.charge">
<field name="charge_type">lift_chair_install</field>
<field name="amount">120.00</field>
<field name="sequence">40</field>
<field name="description">Lift Chair delivery and on-site set-up.</field>
</record>
<record id="delivery_hospital_bed" model="fusion.repair.delivery.charge">
<field name="charge_type">hospital_bed_install</field>
<field name="amount">120.00</field>
<field name="sequence">50</field>
<field name="description">Hospital Bed delivery and on-site assembly / set-up.</field>
</record>
<record id="delivery_stairlift_install" model="fusion.repair.delivery.charge">
<field name="charge_type">stairlift_install</field>
<field name="amount">300.00</field>
<field name="sequence">60</field>
<field name="description">Stairlift delivery and full set-up at client home.</field>
</record>
<record id="delivery_stairlift_removal" model="fusion.repair.delivery.charge">
<field name="charge_type">stairlift_removal</field>
<field name="amount">300.00</field>
<field name="sequence">70</field>
<field name="description">Removal of an old stairlift from client home.</field>
</record>
</data>
</odoo>

View File

@@ -35,6 +35,7 @@
<field name="icon">fa-arrows-v</field>
<field name="description">Straight and curved indoor stairlifts. Annual safety inspection required in many jurisdictions.</field>
<field name="safety_critical" eval="True"/>
<field name="equipment_class">lift_elevating</field>
</record>
<record id="category_porch_lift" model="fusion.repair.product.category">
@@ -44,6 +45,17 @@
<field name="icon">fa-arrow-up</field>
<field name="description">Vertical platform lifts for porches, decks, and accessible building entrances.</field>
<field name="safety_critical" eval="True"/>
<field name="equipment_class">lift_elevating</field>
</record>
<!-- Bundle 10: Lift Chair is its own category (power recliner / lift chair). -->
<record id="category_lift_chair" model="fusion.repair.product.category">
<field name="name">Lift Chair</field>
<field name="code">lift_chair</field>
<field name="sequence">55</field>
<field name="icon">fa-chair</field>
<field name="description">Powered recliner / lift chairs (Pride, Golden, MedLift). Falls under Lift &amp; Elevating Service per rate card.</field>
<field name="equipment_class">lift_elevating</field>
</record>
<record id="category_walker" model="fusion.repair.product.category">

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""Post-migration for 19.0.2.1.0 - align rate-card + categories to Westin's
printed service-rate card.
Sites that installed any earlier Bundle 9 build have:
- Old callout.rate rows with $120/$95/0.85 values (B9 placeholder rates)
- Stairlift / porch_lift categories with equipment_class='standard'
Both have noupdate=1 in their seed XML so a normal -u upgrade won't fix
them. This script:
1. Wipes the four B9-only rate xml_ids and re-imports the seed
2. Updates lift / porch / lift_chair categories to equipment_class='lift_elevating'
After this runs once, future upgrades respect noupdate=1 normally (admin
tweaks are preserved).
"""
from odoo.tools.sql import column_exists
def migrate(cr, version):
if not version:
return # fresh install - seed loads correctly
cr.execute("""
UPDATE fusion_repair_product_category
SET equipment_class = 'lift_elevating'
WHERE code IN ('stairlift', 'porch_lift', 'lift_chair')
AND (equipment_class IS NULL OR equipment_class = 'standard');
""")
# Wipe the four B9 rate rows so the new noupdate=1 seed re-creates them
# with the printed values. Only deletes rows that were originally seeded
# by this module (xml_id present) - admin-created rate rows stay put.
cr.execute("""
DELETE FROM fusion_repair_callout_rate
WHERE id IN (
SELECT res_id FROM ir_model_data
WHERE module = 'fusion_repairs'
AND model = 'fusion.repair.callout.rate'
AND name IN ('callout_rate_regular', 'callout_rate_after_hours',
'callout_rate_weekend', 'callout_rate_holiday')
);
DELETE FROM ir_model_data
WHERE module = 'fusion_repairs'
AND model = 'fusion.repair.callout.rate'
AND name IN ('callout_rate_regular', 'callout_rate_after_hours',
'callout_rate_weekend', 'callout_rate_holiday');
""")

View File

@@ -18,6 +18,7 @@ from . import repair_emergency_charge
from . import repair_part_order
from . import repair_callout_rate
from . import repair_labor_warranty
from . import repair_delivery_charge
from . import product_template
from . import res_partner
from . import res_users

View File

@@ -30,6 +30,7 @@ class FusionRepairCalloutRate(models.Model):
tier = fields.Selection(
[
('regular', 'Regular Business Hours'),
('rush', 'Rush Service'),
('after_hours', 'After Hours (weekday evening)'),
('weekend', 'Weekend'),
('holiday', 'Statutory Holiday'),
@@ -39,6 +40,20 @@ class FusionRepairCalloutRate(models.Model):
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)',
@@ -67,7 +82,7 @@ class FusionRepairCalloutRate(models.Model):
# ---- Hourly labour (after the included 30 min) ----
hourly_labor_rate = fields.Monetary(
string='Hourly Labour Rate (per tech)',
string='On-Site Hourly Labour Rate (per tech)',
currency_field='currency_id',
required=True,
default=0.0,
@@ -75,11 +90,21 @@ class FusionRepairCalloutRate(models.Model):
'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.',
'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 ----
@@ -128,13 +153,26 @@ class FusionRepairCalloutRate(models.Model):
)
@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."""
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)
return self.sudo().search([
Domain = lambda cls: [
('tier', '=', tier),
('equipment_class', '=', cls),
('active', '=', True),
('effective_from', '<=', on_date),
('company_id', 'in', self.env.companies.ids),
], order='effective_from desc', limit=1)
]
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

View File

@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Delivery / pickup rate card (separate from repair callouts).
Per Westin's published rate card the DELIVERY / PICKUP CHARGES section is
a distinct service from repair callouts. These are charged when we move
equipment (drop-off of a sold unit, post-repair return delivery, removal
of old equipment, etc.).
"""
from odoo import api, fields, models
class FusionRepairDeliveryCharge(models.Model):
_name = 'fusion.repair.delivery.charge'
_description = 'Delivery / Pickup Rate Card'
_order = 'sequence, charge_type, id'
name = fields.Char(compute='_compute_name', store=True)
sequence = fields.Integer(default=10)
charge_type = fields.Selection(
[
('local', 'Local Service Area'),
('outside', 'Outside Local Area'),
('rush', 'Rush Pickup / Delivery'),
('lift_chair_install', 'Lift Chair Delivery and Set-Up'),
('hospital_bed_install', 'Hospital Bed Delivery and Set-Up'),
('stairlift_install', 'Stairlift Delivery and Set-Up'),
('stairlift_removal', 'Stairlift Removal'),
('other', 'Other'),
],
string='Charge Type',
required=True,
)
amount = fields.Monetary(
string='Amount',
currency_field='currency_id',
required=True,
)
travel_per_km_fee = fields.Monetary(
string='Per-km Fee (Rush, 2-way)',
currency_field='currency_id',
default=0.0,
help='Only applies to rush pickups/deliveries. Per the published card: '
'$60 plus $0.70 per km x 2-way.',
)
travel_distance_threshold_km = fields.Float(
string='Free Travel Distance (km, 2-way)',
default=0.0,
help='Only applies to rush. Above this km, every additional km is '
'charged travel_per_km_fee BOTH WAYS.',
)
description = fields.Text(translate=True)
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)
@api.depends('charge_type', 'amount')
def _compute_name(self):
for r in self:
label = dict(self._fields['charge_type'].selection).get(r.charge_type) or '?'
r.name = f'{label} - ${r.amount:.0f}'
@api.model
def get_charge(self, charge_type):
"""Return the active rate row for `charge_type`, empty recordset if none."""
return self.sudo().search([
('charge_type', '=', charge_type),
('active', '=', True),
('company_id', 'in', self.env.companies.ids),
], limit=1)
@api.model
def quote_rush(self, distance_km):
"""Convenience: returns the total for a Rush Pickup / Delivery at
`distance_km` one-way. Returns 0.0 if no rush row configured."""
rush = self.get_charge('rush')
if not rush:
return 0.0
over = max(distance_km - rush.travel_distance_threshold_km, 0.0)
return rush.amount + (over * 2.0 * rush.travel_per_km_fee)

View File

@@ -247,6 +247,7 @@ class RepairOrder(models.Model):
x_fc_callout_tier = fields.Selection(
[
('regular', 'Regular Business Hours'),
('rush', 'Rush Service'),
('after_hours', 'After Hours'),
('weekend', 'Weekend'),
('holiday', 'Statutory Holiday'),
@@ -274,7 +275,17 @@ class RepairOrder(models.Model):
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.',
'(default 1.0) AND rounds up to the next 30-min increment - '
'20 minutes bills 1.0 h, 75 minutes bills 1.5 h.',
)
# Bundle 10: in-shop work uses a different (lower) hourly rate AND
# waives both the callout fee and the travel charge - client brought
# the unit to the store.
x_fc_in_shop = fields.Boolean(
string='In-Shop Repair',
tracking=True,
help='Work done in the store (no callout, no travel). Uses '
'in_shop_labor_rate from the rate card.',
)
# Labor warranty link + status (resolved at visit time)
x_fc_labor_warranty_id = fields.Many2one(
@@ -362,41 +373,61 @@ class RepairOrder(models.Model):
@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')
'x_fc_labor_warranty_status', 'x_fc_labor_waived',
'x_fc_in_shop', 'x_fc_repair_category_id')
def _compute_callout_quote(self):
import math
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)
cls = (r.x_fc_repair_category_id.equipment_class
or 'standard') if r.x_fc_repair_category_id else 'standard'
rate = Rate.get_for_tier(tier, equipment_class=cls)
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
in_shop = bool(r.x_fc_in_shop)
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)
if in_shop:
# In-shop: no callout, no extra-tech, no travel. Only labour
# at the lower in_shop_labor_rate.
base = 0.0
extra_techs = 0.0
travel = 0.0
else:
base = rate.base_callout_fee if rate else 0.0
extra_techs = 0.0
if rate and techs >= 2 and rate.second_tech_fee:
extra_techs += rate.second_tech_fee
if rate and techs >= 3:
per_extra = rate.additional_tech_fee or rate.second_tech_fee or 0.0
extra_techs += per_extra * (techs - 2)
# 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
# Labour - charged per tech with min-1-hour floor on the
# BILLABLE portion (above 30 min the callout includes).
# Labour: per the published rate card -
# * minimum_labor_hours floor (default 1.0)
# * beyond the floor, pro-rated in 30-min increments
# (i.e. round UP to the next 0.5 h)
# * per tech (footnote: 'If multiple technicians are required,
# rates will apply per technician')
# * in-shop uses in_shop_labor_rate
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
# ceil(actual * 2) / 2 -> rounds up to next 0.5 increment
rounded_up = math.ceil(hours * 2) / 2.0
billable_h = max(rounded_up, min_hours)
hourly = (rate.in_shop_labor_rate
if in_shop else rate.hourly_labor_rate)
labor = billable_h * hourly * techs
waived = 0.0
if r.x_fc_labor_warranty_status in ('eligible', 'waived') or r.x_fc_labor_waived:
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
@@ -407,16 +438,37 @@ class RepairOrder(models.Model):
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}'
# Human-readable breakdown for chatter / quote emails.
class_label = 'Lift & Elevating' if cls == 'lift_elevating' else 'Standard'
hourly_used = (
(rate.in_shop_labor_rate if in_shop else rate.hourly_labor_rate)
if rate else 0.0
)
mode_label = 'IN-SHOP' if in_shop else f'on-site ({tier})'
lines = []
if not in_shop:
lines.append(f'Service Call ({class_label}, {tier}, incl. 30 min): ${base:.2f}')
if extra_techs:
lines.append(f'Additional technicians ({techs - 1}): ${extra_techs:.2f}')
if labor:
lines.append(
f'Labour {mode_label}: '
f'{hours:.2f} h actual -> billed '
f'{max(math.ceil(hours * 2) / 2.0, rate.minimum_labor_hours if rate else 1.0):.2f} h '
f'x {techs} tech x ${hourly_used:.2f}/h = ${labor:.2f}'
)
if travel:
over_km = max(distance - (rate.travel_distance_threshold_km if rate else 25), 0)
lines.append(
f'Travel: {distance:.1f} km, {over_km:.1f} km over threshold, '
f'both ways x {techs} tech x ${rate.travel_per_km_fee if rate else 0}/km = ${travel:.2f}'
)
if waived:
lines.append(f'Less labour waived: -${waived:.2f}')
lines.append('-' * 50)
lines.append(f'TOTAL (excl. parts): ${total:.2f}')
r.x_fc_quote_breakdown_text = '\n'.join(lines)
def action_check_labor_warranty(self):
"""Look up the active store labor warranty for this repair's

View File

@@ -33,6 +33,20 @@ class FusionRepairProductCategory(models.Model):
'self-help and force escalation when safety symptoms appear.',
)
# Bundle 10: aligns Westin's printed rate card - LIFT & ELEVATING SERVICE
# has its own higher rates (stairlifts, porch lifts, lift chairs, hoyer lifts).
equipment_class = fields.Selection(
[
('standard', 'Standard Service'),
('lift_elevating', 'Lift & Elevating Service'),
],
string='Equipment Class',
default='standard',
required=True,
help='Determines which callout rate row applies. Lift & Elevating uses '
'higher per-card rates (e.g. $160 callout vs $95 standard).',
)
intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Default Intake Template',

View File

@@ -45,6 +45,8 @@ access_visit_report_partline_user,Visit Report Part Line User Full,model_fusion_
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_delivery_charge_user,Delivery Charge User Read,model_fusion_repair_delivery_charge,group_fusion_repairs_user,1,0,0,0
access_delivery_charge_manager,Delivery Charge Manager Full,model_fusion_repair_delivery_charge,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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
45 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
46 access_callout_rate_user Callout Rate User Read model_fusion_repair_callout_rate group_fusion_repairs_user 1 0 0 0
47 access_callout_rate_manager Callout Rate Manager Full model_fusion_repair_callout_rate group_fusion_repairs_manager 1 1 1 1
48 access_delivery_charge_user Delivery Charge User Read model_fusion_repair_delivery_charge group_fusion_repairs_user 1 0 0 0
49 access_delivery_charge_manager Delivery Charge Manager Full model_fusion_repair_delivery_charge group_fusion_repairs_manager 1 1 1 1
50 access_labor_warranty_user Labor Warranty User Read model_fusion_repair_labor_warranty group_fusion_repairs_user 1 0 0 0
51 access_labor_warranty_sales_rep Labor Warranty Sales Rep Write model_fusion_repair_labor_warranty group_fusion_repairs_sales_rep 1 1 0 0
52 access_labor_warranty_manager Labor Warranty Manager Full model_fusion_repair_labor_warranty group_fusion_repairs_manager 1 1 1 1

View File

@@ -69,6 +69,12 @@
action="action_repair_callout_rate"
sequence="65"/>
<menuitem id="menu_fusion_repairs_delivery_charge"
name="Delivery / Pickup Charges"
parent="menu_fusion_repairs_configuration"
action="action_repair_delivery_charge"
sequence="67"/>
<menuitem id="menu_fusion_repairs_labor_warranty"
name="Labor Warranties"
parent="menu_fusion_repairs_root"

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_repair_delivery_charge_list" model="ir.ui.view">
<field name="name">fusion.repair.delivery.charge.list</field>
<field name="model">fusion.repair.delivery.charge</field>
<field name="arch" type="xml">
<list string="Delivery / Pickup Charges" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="charge_type"/>
<field name="amount" widget="monetary"/>
<field name="travel_per_km_fee" widget="monetary"/>
<field name="travel_distance_threshold_km"/>
<field name="description" optional="show"/>
<field name="currency_id" invisible="1"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="action_repair_delivery_charge" model="ir.actions.act_window">
<field name="name">Delivery / Pickup Charges</field>
<field name="res_model">fusion.repair.delivery.charge</field>
<field name="view_mode">list</field>
</record>
</odoo>

View File

@@ -163,8 +163,11 @@
<group>
<group string="Inputs">
<field name="x_fc_callout_tier"/>
<field name="x_fc_callout_techs"/>
<field name="x_fc_callout_distance_km"/>
<field name="x_fc_in_shop"/>
<field name="x_fc_callout_techs"
invisible="x_fc_in_shop"/>
<field name="x_fc_callout_distance_km"
invisible="x_fc_in_shop"/>
<field name="x_fc_callout_labor_hours"/>
</group>
<group string="Warranty / Waiver">

View File

@@ -150,6 +150,11 @@ class RepairVisitReportWizard(models.TransientModel):
string='Callout Tier',
readonly=False,
)
callout_in_shop = fields.Boolean(
related='repair_id.x_fc_in_shop',
string='In-Shop Repair',
readonly=False,
)
callout_labor_hours_used = fields.Float(
string='Repair Hours (after 30 min inspection)',
default=1.0,

View File

@@ -79,8 +79,11 @@
<group invisible="outcome != 'completed'">
<group>
<field name="callout_tier"/>
<field name="callout_techs"/>
<field name="callout_distance_km"/>
<field name="callout_in_shop"/>
<field name="callout_techs"
invisible="callout_in_shop"/>
<field name="callout_distance_km"
invisible="callout_in_shop"/>
<field name="callout_labor_hours_used"/>
</group>
<group>