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>
1218 lines
48 KiB
Python
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.'))
|