Files
Odoo-Modules/fusion_repairs/models/repair_order.py
gsinghpal 48dd7718e2 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>
2026-05-21 02:47:11 -04:00

1218 lines
48 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from datetime import date, datetime, timedelta
from dateutil.relativedelta import relativedelta
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
INTAKE_SOURCES = [
('backend_wizard', 'Backend Wizard (CS)'),
('sales_rep_portal', 'Sales Rep Portal'),
('client_portal', 'Client Self-Service'),
('manual', 'Manual / Other'),
]
URGENCY_LEVELS = [
('normal', 'Normal'),
('urgent', 'Urgent'),
('safety', 'Safety Issue'),
]
class RepairOrder(models.Model):
"""Extend Odoo Repairs with intake context, dispatch link, warranty
determination, and pricing variance tracking for Fusion Repairs."""
_inherit = 'repair.order'
# ------------------------------------------------------------------
# CREATE - replace the picking-type default sequence with our
# date-based RO-YYYYMM-NN reference. We set vals['name'] BEFORE
# super() so Odoo's native create() (which only assigns the picking
# type sequence when name is empty or 'New') skips its own numbering.
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
Sequence = self.env['ir.sequence'].sudo()
for vals in vals_list:
if not vals.get('name') or vals.get('name') == 'New':
next_name = Sequence.next_by_code('fusion.repair.order.monthly')
if next_name:
vals['name'] = next_name
return super().create(vals_list)
# ------------------------------------------------------------------
# INTAKE METADATA
# ------------------------------------------------------------------
x_fc_intake_source = fields.Selection(
INTAKE_SOURCES,
string='Intake Source',
default='manual',
tracking=True,
help='Which intake surface created this repair (backend CS wizard, '
'sales rep portal, public client portal, or manual entry).',
)
x_fc_intake_user_id = fields.Many2one(
'res.users',
string='Intake By',
tracking=True,
index=True,
help='User who took the call / submitted the intake. For client portal, '
'this is the OdooBot or admin user.',
)
x_fc_intake_session_id = fields.Char(
string='Intake Session',
index=True,
copy=False,
help='Reference shared by multiple repair orders created during the same call.',
)
x_fc_intake_template_id = fields.Many2one(
'fusion.repair.intake.template',
string='Intake Template',
help='Question template used during intake.',
)
x_fc_intake_answer_ids = fields.One2many(
'fusion.repair.intake.answer',
'repair_id',
string='Intake Answers',
)
# Catalogue match (Phase 2)
x_fc_service_catalog_id = fields.Many2one(
'fusion.repair.service.catalog',
string='Service Catalogue Match',
index=True,
help='Auto-matched catalogue entry that pre-fills estimated cost and duration.',
)
# C6: quote-only flag (set when intake submitted in quote-only mode).
x_fc_is_quote_only = fields.Boolean(
string='Quote Only',
tracking=True,
index=True,
help='True when the intake was submitted in "Quote Only" mode - the '
'office has not yet authorised dispatching a technician.',
)
# ------------------------------------------------------------------
# ON-CALL PAGING (CL15)
# Set when a safety repair is paged to the on-call manager. Allows
# ack and the 15-minute escalation cron to roll forward to the next
# priority if not acknowledged.
# ------------------------------------------------------------------
x_fc_on_call_token = fields.Char(
string='On-Call Ack Token',
copy=False,
index=True,
)
x_fc_on_call_paged_user_id = fields.Many2one(
'res.users',
string='On-Call Paged User',
copy=False,
index=True,
)
x_fc_on_call_paged_at = fields.Datetime(
string='On-Call Paged At',
copy=False,
)
x_fc_on_call_acknowledged_user_ids = fields.Many2many(
'res.users',
'fusion_repair_on_call_ack_rel',
'repair_id', 'user_id',
string='On-Call Acknowledgements',
copy=False,
)
x_fc_on_call_acknowledged_at = fields.Datetime(
string='Acknowledged At',
copy=False,
)
_on_call_token_unique = models.Constraint(
'unique(x_fc_on_call_token)',
'On-call acknowledgement tokens must be unique.',
)
# ------------------------------------------------------------------
# X4 + M3 - NPS sent flag + loaner offered flag + done-at stamp
# (X2 day-before flag now lives on fusion.technician.task per H1)
# ------------------------------------------------------------------
x_fc_nps_email_sent = fields.Boolean(
string='NPS Email Sent',
copy=False,
)
x_fc_done_at = fields.Datetime(
string='Closed At',
copy=False,
readonly=True,
help='Stamped when the repair first transitions to state=done. '
'Drives the post-visit NPS cron (24h after close) without '
'getting pushed forward by every subsequent chatter message.',
)
x_fc_loaner_offered = fields.Boolean(
string='Loaner Offered',
copy=False,
help='True once a loaner-offer activity has been posted for this '
'long-running repair (M3). Avoids re-posting daily.',
)
# ------------------------------------------------------------------
# Bundle 8: RUSH / EMERGENCY SERVICE
# ------------------------------------------------------------------
x_fc_rush_requested = fields.Boolean(
string='Rush / Emergency Service',
tracking=True,
copy=False,
)
x_fc_rush_tier = fields.Selection(
[
('same_day', 'Same Day'),
('next_day', 'Next Day Priority'),
('after_hours', 'After Hours'),
('weekend', 'Weekend'),
('holiday', 'Statutory Holiday'),
],
string='Rush Tier',
tracking=True,
copy=False,
)
x_fc_rush_techs_required = fields.Integer(
string='Technicians Required',
default=1,
copy=False,
help='Some calls need 2+ techs (e.g. heavy lifting, controller programming '
'plus mechanical). Surcharge scales accordingly.',
)
x_fc_rush_surcharge = fields.Monetary(
string='Rush Surcharge',
currency_field='company_currency_id',
compute='_compute_rush_surcharge',
store=True,
tracking=True,
)
x_fc_rush_acknowledged_at = fields.Datetime(
string='Rush Surcharge Acknowledged',
copy=False,
readonly=True,
help='Stamped when CS records that the client agreed to the rush price.',
)
x_fc_rush_acknowledged_by_id = fields.Many2one(
'res.users',
string='Acknowledged By',
copy=False,
readonly=True,
help='The CS rep who got the verbal OK from the client.',
)
# ------------------------------------------------------------------
# Bundle 8: PARTS AWAITING (when tech can't fix on the first visit)
# ------------------------------------------------------------------
x_fc_parts_awaiting = fields.Boolean(
string='Awaiting Parts',
tracking=True,
copy=False,
index=True,
help='Tech could not complete the repair without ordering parts. '
'Repair stays open; clears automatically when the last linked '
'fusion.repair.part.order moves to "received".',
)
x_fc_parts_eta_date = fields.Date(
string='Parts ETA',
copy=False,
tracking=True,
)
x_fc_part_order_ids = fields.One2many(
'fusion.repair.part.order',
'repair_order_id',
string='Part Orders',
copy=False,
)
x_fc_part_order_count = fields.Integer(
compute='_compute_part_order_count',
string='# Part Orders',
)
# ------------------------------------------------------------------
# Bundle 9: SERVICE CALLOUT PRICING + LABOR WARRANTY
# ------------------------------------------------------------------
x_fc_callout_tier = fields.Selection(
[
('regular', 'Regular Business Hours'),
('rush', 'Rush Service'),
('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) 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(
'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',
'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'
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)
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: 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
# 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):
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
# 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
partner + product. Updates x_fc_labor_warranty_id and
x_fc_labor_warranty_status. Called from the visit-report wizard
AND from the dashboard's "check warranty" button."""
Warr = self.env['fusion.repair.labor.warranty'].sudo()
for r in self:
w = Warr.find_active_for(
r.partner_id, r.product_id, r.lot_id or False,
)
r.x_fc_labor_warranty_id = w.id if w else False
if not w:
r.x_fc_labor_warranty_status = 'not_covered'
elif w.state == 'expired':
r.x_fc_labor_warranty_status = 'expired'
elif w.state == 'void':
r.x_fc_labor_warranty_status = 'void_misuse'
else:
r.x_fc_labor_warranty_status = 'eligible'
def action_waive_labor_fee(self):
"""Manager / sales rep only. CS rep cannot waive."""
Group = self.env.ref
user = self.env.user
can_waive = (
user.has_group('fusion_repairs.group_fusion_repairs_manager')
or user.has_group('fusion_repairs.group_fusion_repairs_sales_rep')
)
if not can_waive:
raise UserError(_(
'Only Repairs Managers and Sales Reps can waive the labor fee. '
'CS staff must escalate to a manager.'
))
for r in self:
r.write({
'x_fc_labor_waived': True,
'x_fc_labor_waived_by_id': user.id,
'x_fc_labor_waived_at': fields.Datetime.now(),
'x_fc_labor_warranty_status': 'waived',
})
r.message_post(body=Markup(_(
'<b>Labor fee waived</b> by %(user)s. (Reason: %(reason)s)'
)) % {
'user': user.name,
'reason': r.x_fc_labor_waived_reason or 'goodwill',
})
@api.depends('x_fc_rush_tier', 'x_fc_rush_techs_required',
'x_fc_repair_category_id')
def _compute_rush_surcharge(self):
Rates = self.env['fusion.repair.emergency.charge'].sudo()
for r in self:
if not r.x_fc_rush_tier or not r.x_fc_repair_category_id:
r.x_fc_rush_surcharge = 0.0
continue
r.x_fc_rush_surcharge = Rates.calculate(
r.x_fc_repair_category_id,
r.x_fc_rush_tier,
r.x_fc_rush_techs_required or 1,
)
@api.depends('x_fc_part_order_ids')
def _compute_part_order_count(self):
for r in self:
r.x_fc_part_order_count = len(r.x_fc_part_order_ids)
def action_view_part_orders(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Part Orders'),
'res_model': 'fusion.repair.part.order',
'view_mode': 'list,form',
'domain': [('repair_order_id', '=', self.id)],
'context': {'default_repair_order_id': self.id},
}
def action_acknowledge_rush(self):
"""CS clicks this AFTER getting verbal OK from the client on the rush price."""
for r in self:
r.x_fc_rush_acknowledged_at = fields.Datetime.now()
r.x_fc_rush_acknowledged_by_id = self.env.user
r.message_post(body=Markup(_(
'Rush surcharge of <b>%(amt).2f</b> acknowledged by client '
'(verbal OK to %(rep)s).'
)) % {
'amt': r.x_fc_rush_surcharge,
'rep': self.env.user.name,
})
def action_squeeze_into_today(self):
"""Squeeze this repair into a field tech's existing route today.
Picks the lightest-loaded skilled tech, finds the first free 1-hour
slot in their day, creates / updates the dispatch task to that slot,
and pushes a live bus.bus notification + email so the tech knows
mid-shift.
"""
from datetime import date as _date
today = _date.today()
Task = self.env['fusion.technician.task'].sudo()
for r in self:
tech_id = self._fc_find_lightest_today_tech()
if not tech_id:
raise UserError(_(
'No field-staff users available - mark someone as Field '
'Staff under Settings > Users and try again.'
))
slot_start, slot_end = self._fc_find_free_slot_today(tech_id)
if slot_start is None:
raise UserError(_(
"%s has no free hour left today. Either bump an existing "
"task or schedule for tomorrow instead."
) % self.env['res.users'].sudo().browse(tech_id).name)
existing = r.x_fc_technician_task_ids.filtered(
lambda t: t.status not in ('completed', 'cancelled')
)
vals = {
'technician_id': tech_id,
'scheduled_date': today,
'time_start': slot_start,
'time_end': slot_end,
}
if existing:
task = existing[0]
task.write(vals)
else:
self.env['fusion.repair.intake.service'].sudo() \
.with_context(
force_tech_id=tech_id,
force_schedule={
'scheduled_date': today,
'time_start': slot_start,
'time_end': slot_end,
},
) \
._create_dispatch_task(r)
task = r.x_fc_technician_task_ids[:1]
self._notify_tech_of_rush(task)
r.message_post(body=Markup(_(
'Squeezed into <b>%(name)s</b>\'s route today at '
'<b>%(start).0f:00 - %(end).0f:00</b>; tech notified.'
)) % {
'name': task.technician_id.name or '?',
'start': slot_start,
'end': slot_end,
})
def _fc_find_free_slot_today(self, tech_id):
"""Return (start_float, end_float) for the first free 1-hour window
in this tech's day between 9 AM and 6 PM, or (None, None)."""
from datetime import date as _date
today = _date.today()
Task = self.env['fusion.technician.task'].sudo()
existing = Task.search([
('technician_id', '=', tech_id),
('scheduled_date', '=', today),
('status', 'not in', ('completed', 'cancelled')),
])
# Build a set of busy hours (rounded down to integer hours).
busy = set()
for t in existing:
s = int(t.time_start or 0)
e = int(t.time_end or s + 1)
for h in range(s, max(s + 1, e)):
busy.add(h)
# Scan 9 AM - 5 PM (last slot is 17:00-18:00 inclusive).
for hour in range(9, 18):
if hour not in busy:
return float(hour), float(hour + 1)
return None, None
def _fc_find_lightest_today_tech(self):
"""Return the field-staff user with the fewest scheduled tasks today.
Honors skills filter if this repair has a category.
"""
Users = self.env['res.users'].sudo()
Task = self.env['fusion.technician.task'].sudo()
from datetime import date as _date
today = _date.today()
domain = [
('x_fc_is_field_staff', '=', True),
('active', '=', True),
]
if self.x_fc_repair_category_id:
domain.append(
('x_fc_repair_skills', 'in', [self.x_fc_repair_category_id.id])
)
candidates = Users.search(domain)
if not candidates:
# Fallback: any active field staff (skills filter relaxed).
candidates = Users.search([
('x_fc_is_field_staff', '=', True),
('active', '=', True),
])
if not candidates:
return False
# Pick the one with the fewest scheduled tasks today.
ranked = []
for u in candidates:
count = Task.search_count([
('technician_id', '=', u.id),
('scheduled_date', '=', today),
('status', 'not in', ('completed', 'cancelled')),
])
ranked.append((count, u.id))
ranked.sort()
return ranked[0][1]
def _notify_tech_of_rush(self, task):
"""Send a real-time bus push + email so the tech sees it mid-shift."""
for r in self:
tech = task.technician_id
if not tech:
continue
# 1) bus.bus live push (shows as a sticky in-app notification).
try:
# bus.bus.sendone goes to a specific user channel; the
# web client displays it via the simple_notification service.
self.env['bus.bus']._sendone(
tech.partner_id,
'simple_notification',
{
'type': 'warning',
'title': _('RUSH service added to your route'),
'message': (_('Stairlift / urgent stop at %(client)s. '
'Repair %(name)s. See your tasks.') % {
'client': r.partner_id.name or '',
'name': r.name,
}),
'sticky': True,
},
)
except Exception:
_logger.warning('bus.bus push failed for tech %s', tech.login)
# 2) email (matters if the tech is offline at the moment of squeeze).
tpl = self.env.ref(
'fusion_repairs.email_template_rush_tech_alert',
raise_if_not_found=False,
)
if tpl:
try:
tpl.with_context(tech_email=tech.email or tech.partner_id.email or '') \
.send_mail(r.id, force_send=True, email_values={
'email_to': tech.email or tech.partner_id.email or '',
})
except Exception:
_logger.warning('Rush-alert email failed for repair %s', r.name)
# 3) chatter on the task itself so the tech sees it inline.
task.message_post(body=Markup(_(
'<b>RUSH ADDED to your day:</b> %(client)s - %(name)s. '
'Office squeezed it in.'
)) % {
'client': r.partner_id.name or '?',
'name': r.name,
})
# ------------------------------------------------------------------
# M9 - Margin per repair (revenue - labour cost - parts cost)
# All non-stored computes; surfaced in the M7 analytics dashboard.
# ------------------------------------------------------------------
x_fc_revenue = fields.Monetary(
string='Revenue',
currency_field='company_currency_id',
compute='_compute_margin',
help='Sum of posted invoice totals for the repair sale order.',
)
x_fc_labour_cost = fields.Monetary(
string='Labour Cost',
currency_field='company_currency_id',
compute='_compute_margin',
help='Sum of (hours x technician cost rate) over all completed visits.',
)
x_fc_parts_cost = fields.Monetary(
string='Parts Cost',
currency_field='company_currency_id',
compute='_compute_margin',
help='Sum of standard_price for parts consumed via repair operations.',
)
x_fc_margin = fields.Monetary(
string='Margin',
currency_field='company_currency_id',
compute='_compute_margin',
help='Revenue - labour cost - parts cost.',
)
x_fc_margin_pct = fields.Float(
string='Margin %',
compute='_compute_margin',
)
def _compute_margin(self):
for r in self:
revenue = 0.0
if r.sale_order_id and hasattr(r.sale_order_id, 'invoice_ids'):
for inv in r.sale_order_id.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.move_type == 'out_invoice'
):
revenue += inv.amount_untaxed or 0.0
labour = 0.0
for task in r.x_fc_technician_task_ids:
if task.status != 'completed':
continue
rate = task.technician_id.x_fc_tech_cost_rate or 0.0
labour += (task.duration_hours or 0.0) * rate
parts = 0.0
for move in r.move_ids.filtered(lambda m: m.repair_line_type == 'add'):
parts += (move.product_id.standard_price or 0.0) * (move.product_uom_qty or 0.0)
r.x_fc_revenue = revenue
r.x_fc_labour_cost = labour
r.x_fc_parts_cost = parts
margin = revenue - labour - parts
r.x_fc_margin = margin
r.x_fc_margin_pct = (margin / revenue * 100) if revenue else 0.0
def write(self, vals):
# H2: stamp x_fc_done_at the first time state transitions to 'done'
# so the NPS cron has a stable timestamp (write_date moves on every
# chatter / invoice / attachment write).
if vals.get('state') == 'done':
for r in self:
if r.state != 'done' and not r.x_fc_done_at:
vals = dict(vals)
vals['x_fc_done_at'] = fields.Datetime.now()
break
return super().write(vals)
# ------------------------------------------------------------------
# X2 / X4 / M3 crons
# ------------------------------------------------------------------
@api.model
def cron_send_day_before_reminders(self):
"""X2: email the client the day before each scheduled tech visit.
Per-TASK flag (not per-repair) so multi-visit repairs get a
separate reminder for each individual visit.
"""
if not self._notifications_enabled():
return
tomorrow = date.today() + timedelta(days=1)
Task = self.env['fusion.technician.task'].sudo()
tasks = Task.search([
('scheduled_date', '=', tomorrow),
('x_fc_day_before_reminder_sent', '=', False),
('x_fc_repair_order_id', '!=', False),
('x_fc_repair_order_id.state', 'in', ('confirmed', 'under_repair')),
])
tpl = self.env.ref(
'fusion_repairs.email_template_visit_day_before',
raise_if_not_found=False,
)
if not tpl:
_logger.warning('X2 day-before cron: email template missing')
return
for task in tasks:
repair = task.x_fc_repair_order_id
if not repair.partner_id or not repair.partner_id.email:
task.x_fc_day_before_reminder_sent = True # don't keep retrying
continue
try:
# Pass the specific task via context so the template renders
# the right scheduled date / technician (H3).
tpl.with_context(reminder_task_id=task.id) \
.send_mail(repair.id, force_send=False)
except Exception:
_logger.exception('X2 day-before reminder failed for task %s', task.name)
# Still set the flag - the task's "tomorrow" is gone after midnight
# so retrying tomorrow would email about the wrong date.
task.x_fc_day_before_reminder_sent = True
@api.model
def cron_send_post_visit_nps(self):
"""X4: send NPS / Google review email 24h after state=done.
Uses x_fc_done_at (H2) so chatter writes don't push the timestamp
forward.
"""
if not self._notifications_enabled():
return
cutoff = datetime.now() - timedelta(hours=24)
repairs = self.search([
('state', '=', 'done'),
('x_fc_nps_email_sent', '=', False),
('x_fc_done_at', '!=', False),
('x_fc_done_at', '<=', cutoff),
])
tpl = self.env.ref(
'fusion_repairs.email_template_post_visit_nps',
raise_if_not_found=False,
)
if not tpl:
_logger.warning('X4 NPS cron: email template missing')
return
for r in repairs:
if not r.partner_id or not r.partner_id.email:
r.x_fc_nps_email_sent = True # don't keep retrying
continue
try:
tpl.send_mail(r.id, force_send=False)
except Exception:
_logger.exception('X4 NPS email failed for repair %s', r.name)
r.x_fc_nps_email_sent = True
@api.model
def cron_offer_loaner_for_long_repairs(self):
"""M3: post an Offer-Loaner activity when a confirmed/in-repair
order has been waiting longer than threshold days.
Soft-depends on fusion_loaners_management - silently no-ops when
the loaner model isn't installed. Uses schedule_date (or create_date
as fallback) so quote-only / draft repairs aren't bothered.
"""
if 'fusion.loaner.checkout' not in self.env:
return
ICP = self.env['ir.config_parameter'].sudo()
try:
threshold = int(ICP.get_param(
'fusion_repairs.loaner_offer_threshold_days', '3'
))
except (ValueError, TypeError):
threshold = 3
cutoff = datetime.now() - timedelta(days=threshold)
activity_type = self.env.ref(
'fusion_repairs.mail_activity_type_loaner_offer',
raise_if_not_found=False,
)
if not activity_type:
_logger.warning('M3 loaner cron: activity type missing, skipping')
return
repairs = self.search([
('state', 'in', ('confirmed', 'under_repair')),
('x_fc_is_quote_only', '=', False),
('x_fc_loaner_offered', '=', False),
'|',
'&', ('schedule_date', '!=', False), ('schedule_date', '<=', cutoff),
'&', ('schedule_date', '=', False), ('create_date', '<=', cutoff),
], limit=200, order='create_date desc')
for r in repairs:
try:
r.activity_schedule(
activity_type_id=activity_type.id,
summary='Offer a loaner unit',
note=(
'This repair has been waiting more than %s days. '
'Consider offering the client a loaner unit while we '
'complete the repair.'
) % threshold,
user_id=r.user_id.id or self.env.uid,
)
r.x_fc_loaner_offered = True
except Exception:
_logger.exception(
'M3 loaner cron: activity_schedule failed for repair %s',
r.name,
)
@api.model
def _notifications_enabled(self):
# Delegate to the shared intake-service single source of truth (M2).
Service = self.env.get('fusion.repair.intake.service')
if Service:
return Service._notifications_enabled()
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_repairs.enable_email_notifications', 'True'
) == 'True'
def action_offer_loaner(self):
"""Open the fusion_loaners_management checkout wizard pre-filled
with this repair's partner. Soft-link - raises if the module is
not installed."""
self.ensure_one()
if 'fusion.loaner.checkout' not in self.env:
raise UserError(_(
'Loaner management is not installed. Install '
'fusion_loaners_management to enable this feature.'
))
return {
'type': 'ir.actions.act_window',
'name': _('Offer Loaner'),
'res_model': 'fusion.loaner.checkout',
'view_mode': 'form',
'target': 'new',
'context': {
'default_partner_id': self.partner_id.id,
'default_sale_order_id': self.sale_order_id.id or False,
},
}
# Maintenance contract back-link (Phase 3)
x_fc_maintenance_contract_id = fields.Many2one(
'fusion.repair.maintenance.contract',
string='Maintenance Contract',
index=True,
help='Set when this repair was spawned from a maintenance reminder booking. '
'Completing the related technician task rolls the contract to its next cycle.',
)
x_fc_intake_answer_count = fields.Integer(
compute='_compute_intake_answer_count',
)
# ------------------------------------------------------------------
# EQUIPMENT / WARRANTY
# ------------------------------------------------------------------
x_fc_repair_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Equipment Category',
tracking=True,
index=True,
help='Medical equipment category - drives intake template and tech skills filter.',
)
x_fc_third_party_equipment = fields.Boolean(
string='Third-Party Equipment',
tracking=True,
help='True if the equipment was not sold by us. Forces under_warranty=False '
'and typically triggers a service call-out fee.',
)
x_fc_original_sale_order_id = fields.Many2one(
'sale.order',
string='Original Purchase SO',
tracking=True,
index=True,
help='Sale order through which the customer originally purchased this unit. '
'Auto-matched on intake by partner + lot/serial.',
)
x_fc_warranty_override_reason = fields.Char(
string='Warranty Override Reason',
help='Required when CS overrides the auto-detected warranty status.',
)
# ------------------------------------------------------------------
# TRIAGE / URGENCY
# ------------------------------------------------------------------
x_fc_urgency = fields.Selection(
URGENCY_LEVELS,
string='Urgency',
default='normal',
tracking=True,
index=True,
)
x_fc_issue_category = fields.Char(
string='Issue Category',
help='Symptom classification (e.g. "battery", "motor", "remote"). Used by '
'service catalogue matcher and AI prompt context.',
)
# ------------------------------------------------------------------
# PHOTOS
# ------------------------------------------------------------------
x_fc_photo_ids = fields.Many2many(
'ir.attachment',
'fusion_repair_order_photo_rel',
'repair_id',
'attachment_id',
string='Intake Photos / Videos',
help='Photos and videos uploaded during intake.',
)
x_fc_photo_count = fields.Integer(
compute='_compute_photo_count',
)
# ------------------------------------------------------------------
# PRICING (estimate vs actual - Phase 2 reconciliation)
# ------------------------------------------------------------------
x_fc_estimated_duration = fields.Float(
string='Estimated Duration (h)',
help='Estimated visit duration from service catalogue, used to size technician slot.',
)
x_fc_estimated_cost = fields.Monetary(
string='Estimated Cost',
currency_field='company_currency_id',
help='Estimated total from catalogue match at intake (pre-visit).',
)
x_fc_actual_cost = fields.Monetary(
string='Actual Cost',
currency_field='company_currency_id',
help='Actual total recorded from the visit report (post-visit).',
)
x_fc_cost_variance_pct = fields.Float(
string='Cost Variance %',
compute='_compute_cost_variance',
store=True,
help='(actual - estimated) / estimated * 100',
)
x_fc_requires_requote = fields.Boolean(
string='Requires Re-Quote',
help='Set when actual cost exceeds estimate beyond the configured threshold; '
'blocks automatic invoicing until manager approves or client re-confirms.',
)
company_currency_id = fields.Many2one(
'res.currency',
related='company_id.currency_id',
readonly=True,
)
# ------------------------------------------------------------------
# FIELD SERVICE LINK
# ------------------------------------------------------------------
x_fc_technician_task_ids = fields.One2many(
'fusion.technician.task',
'x_fc_repair_order_id',
string='Technician Tasks',
)
x_fc_technician_task_count = fields.Integer(
compute='_compute_technician_task_count',
)
# ------------------------------------------------------------------
# AI SUMMARY (Phase 2)
# ------------------------------------------------------------------
x_fc_ai_summary = fields.Text(
string='AI Pre-Visit Brief',
help='AI-generated short brief for the technician based on intake answers. '
'Optional - never blocks intake submit.',
)
# ------------------------------------------------------------------
# COMPUTES
# ------------------------------------------------------------------
@api.depends('x_fc_intake_answer_ids')
def _compute_intake_answer_count(self):
for repair in self:
repair.x_fc_intake_answer_count = len(repair.x_fc_intake_answer_ids)
@api.depends('x_fc_photo_ids')
def _compute_photo_count(self):
for repair in self:
repair.x_fc_photo_count = len(repair.x_fc_photo_ids)
@api.depends('x_fc_technician_task_ids')
def _compute_technician_task_count(self):
for repair in self:
repair.x_fc_technician_task_count = len(repair.x_fc_technician_task_ids)
@api.depends('x_fc_estimated_cost', 'x_fc_actual_cost')
def _compute_cost_variance(self):
for repair in self:
if repair.x_fc_estimated_cost:
repair.x_fc_cost_variance_pct = (
(repair.x_fc_actual_cost - repair.x_fc_estimated_cost)
/ repair.x_fc_estimated_cost * 100
)
else:
repair.x_fc_cost_variance_pct = 0.0
# ------------------------------------------------------------------
# WARRANTY DETERMINATION
# ------------------------------------------------------------------
def _fc_compute_warranty_status(self):
"""Auto-detect warranty: not third-party AND within warranty window."""
self.ensure_one()
if self.x_fc_third_party_equipment:
return False
if not self.x_fc_original_sale_order_id:
return False
original = self.x_fc_original_sale_order_id
delivery_date = original.commitment_date or original.date_order
if not delivery_date:
return False
warranty_months = (
self.product_id.product_tmpl_id.x_fc_warranty_months
if self.product_id else 0
)
if not warranty_months:
return False
# relativedelta handles month boundaries correctly (28/29/30/31).
cutoff = fields.Datetime.from_string(str(delivery_date)) + relativedelta(months=warranty_months)
return fields.Datetime.now() <= cutoff
# ------------------------------------------------------------------
# SMART BUTTONS
# ------------------------------------------------------------------
def action_view_intake_answers(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Intake Answers'),
'res_model': 'fusion.repair.intake.answer',
'view_mode': 'list,form',
'domain': [('repair_id', '=', self.id)],
'context': {'default_repair_id': self.id},
}
def action_view_technician_tasks(self):
self.ensure_one()
if len(self.x_fc_technician_task_ids) == 1:
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_technician_task_ids.name,
'res_model': 'fusion.technician.task',
'view_mode': 'form',
'res_id': self.x_fc_technician_task_ids.id,
}
return {
'type': 'ir.actions.act_window',
'name': _('Technician Tasks'),
'res_model': 'fusion.technician.task',
'view_mode': 'list,form',
'domain': [('x_fc_repair_order_id', '=', self.id)],
'context': {'default_x_fc_repair_order_id': self.id},
}
def action_view_original_sale_order(self):
self.ensure_one()
if not self.x_fc_original_sale_order_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_original_sale_order_id.name,
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': self.x_fc_original_sale_order_id.id,
}
# ------------------------------------------------------------------
# WIZARDS / PAYMENT
# ------------------------------------------------------------------
def action_open_visit_report(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Visit Report'),
'res_model': 'fusion.repair.visit.report.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_repair_id': self.id,
'default_labour_hours': self.x_fc_estimated_duration or 1.0,
},
}
def action_collect_payment(self):
"""Open the Poynt payment wizard for the linked posted invoice."""
self.ensure_one()
# Resolve the linked invoice via the standard repair -> SO -> invoice chain.
if not self.sale_order_id:
raise UserError(_('Confirm a sale order from this repair first.'))
invoice = self.sale_order_id.invoice_ids.filtered(
lambda m: m.state == 'posted' and m.payment_state in ('not_paid', 'partial')
)[:1]
if not invoice:
raise UserError(_('No posted, unpaid invoice was found for this repair.'))
if hasattr(invoice, 'action_open_poynt_payment_wizard'):
return invoice.action_open_poynt_payment_wizard()
raise UserError(_('Poynt payment is not available - install or configure fusion_poynt.'))