Full home-service pricing engine plus the store labor warranty model. The
call price now itemises base callout + extra techs + hourly labour (with
the 30-min-included + 1-hour-minimum rule) + travel both ways past
threshold, with three independent waive paths: in-warranty / manager
override / sales-rep override. CS cannot waive (RBAC).
NEW MODELS
fusion.repair.callout.rate (rate card)
Per (tier, company) row. Tiers: regular / after_hours / weekend / holiday.
Fields:
- base_callout_fee (INCLUDES first 30 min for inspection / report)
- second_tech_fee + additional_tech_fee (3rd, 4th tech)
- hourly_labor_rate + minimum_labor_hours (default 1.0 floor)
- travel_distance_threshold_km + travel_per_km_fee
- effective_from (newer rows supersede older)
Seeded with 4 default rows (regular $120/$95/0.85, after-hours
$180/$140/1.10, weekend $240/$170/1.35, holiday $300/$200/1.50).
fusion.repair.labor.warranty (store labor warranty)
Per (partner, product/lot, sale_order) record with warranty_years +
start_date + computed end_date. State machine: active / expired / void
/ consumed. Void reasons spec'd by the user: user_negligence /
gross_negligence / misuse / over_recommended_use / accidental_damage
/ not_covered_part / other.
find_active_for(partner, product, lot) - lot-first then product+partner
then partner-only fallback so warranty resolution survives partner-
contact / product-variant differences.
action_void(reason, notes) - manager-only; audit stamps voided_by_id
+ voided_at + reason; posts chatter.
PRODUCT EXTENSION
product.template.x_fc_labor_warranty_years (Integer, default 0).
SALE-ORDER EXTENSION
sale.order.action_confirm now also runs _fc_spawn_labor_warranties()
which creates one fusion.repair.labor.warranty per unit of any product
with x_fc_labor_warranty_years > 0. Lives alongside the existing
service-plan spawn so a 5y-LW stairlift sold with a maintenance plan
spawns both records in one go.
PRICING ENGINE ON REPAIR.ORDER
9 new fields:
x_fc_callout_tier (regular/after_hours/weekend/holiday)
x_fc_callout_distance_km (one-way; system bills both ways)
x_fc_callout_techs (1, 2, 3+)
x_fc_callout_labor_hours (hours above the 30 min the callout covers)
x_fc_labor_warranty_id (auto-resolved on visit)
x_fc_labor_warranty_status (not_checked / eligible / not_covered /
expired / void_misuse / waived)
x_fc_labor_waived + _by_id + _at + _reason
6 computed quote fields:
x_fc_quote_callout_base (base_callout_fee)
x_fc_quote_extra_techs (second + additional fees)
x_fc_quote_labor (max(hours, min_hours) * rate * techs)
x_fc_quote_travel (max(distance - threshold, 0) * 2 * per_km * techs)
x_fc_quote_waived (= labor if warranty eligible OR labor waived)
x_fc_quote_total (sum minus waived; stored, indexable)
+ a human-readable x_fc_quote_breakdown_text used in the email template.
3 new actions:
action_check_labor_warranty (anyone) - resolves the warranty and
stamps x_fc_labor_warranty_status. Called automatically by the
visit-report wizard.
action_waive_labor_fee (SECURITY GATED) - raises UserError unless
caller is in group_fusion_repairs_manager OR
group_fusion_repairs_sales_rep. CS users get the explicit message
'Only Repairs Managers and Sales Reps can waive the labor fee.'
action_acknowledge_rush - Bundle 8 carryover.
SECURITY
New group_fusion_repairs_sales_rep
Independent group so a sales rep can waive labor on their accounts
without becoming a Repairs Dispatcher / Manager. Manager IMPLIES
sales_rep so managers automatically inherit the right.
ACLs: callout.rate user-read / manager-full; labor.warranty user-read /
sales_rep-write / manager-full / technician-read+write.
VISIT-REPORT WIZARD EXTENSIONS
Pricing block (visible when outcome=completed):
callout_tier / techs / distance_km / labor_hours_used (default 1.0
minimum). Live quote_total_preview + breakdown shown to the tech so
they can confirm the price with the client right at the door.
Warranty block:
labor_warranty_id_preview + labor_warranty_status_preview (badge
coloured by status). 'warranty_void_reason' selection lets the tech
void the warranty in real time when they find misuse / negligence /
accidental damage - on submit the matching warranty record is voided
permanently (action_void) AND the repair's labor charge re-computes
without the waive.
On confirm the wizard:
1. Persists callout_labor_hours_used to the repair
2. Calls repair.action_check_labor_warranty()
3. If warranty_void_reason set + warranty resolved -> voids it,
posts chatter, repair labor_warranty_status -> void_misuse
NAVIGATION
Repair form 4 new header buttons:
Check Labor Warranty (anyone)
Waive Labor Fee (sales_rep + manager only, server-side gated)
(plus the Bundle 8 Squeeze + Ack Rush from before)
New 'Callout Pricing' notebook tab on repair form with:
inputs, warranty/waiver, and the 6-line quote breakdown.
New menus:
Fusion Repairs > Labor Warranties
Configuration > Callout Rate Card
Configuration > Emergency Surcharges (Bundle 8 carryover)
VERIFICATION END-TO-END (7 scenarios, 0 bugs)
A. Sale of a product with 5y LW -> LW-00002 spawned, expires 2031-05-21.
B. In-warranty regular 12km 20-min repair:
base 120 + labor 95 - waived 95 = $120 (callout only)
C. After-hours 2-tech 40km 1.5h, NO warranty:
180 + 90 + (1.5*140*2) + (15*2*1.10*2) = $756.00 exact
D. In-warranty visit -> tech ticks misuse void_reason:
Warranty record -> state=void / reason=misuse.
Repair labor_warranty_status -> void_misuse.
Quote re-computes WITHOUT waive: labor 1.5 * 95 = $142.50 charged.
E. Manager waives labor on a no-warranty repair:
Pre-waive $310 -> post-waive $120 (labor $190 -> waived).
Audit: waived_by_id stamped to gsingh@.
F. CS rep tries to waive: correctly denied with the spec'd error
'Only Repairs Managers and Sales Reps can waive the labor fee.'
G. Weekend 1-tech 30km 30-min:
240 + (1.0*170) + (5*2*1.35) = $423.50 exact (min-1h floor
correctly applied to the 0.5h actual work).
Bumped to 19.0.2.0.0 (minor version bump - new public-facing model).
Co-authored-by: Cursor <cursoragent@cursor.com>
1166 lines
45 KiB
Python
1166 lines
45 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'),
|
|
('after_hours', 'After Hours'),
|
|
('weekend', 'Weekend'),
|
|
('holiday', 'Statutory Holiday'),
|
|
],
|
|
string='Callout Tier',
|
|
default='regular',
|
|
tracking=True,
|
|
help='Which rate-card tier applies. Set by CS at intake; can be changed '
|
|
'by dispatcher if the schedule moves into after-hours / weekend.',
|
|
)
|
|
x_fc_callout_distance_km = fields.Float(
|
|
string='One-Way Distance (km)',
|
|
tracking=True,
|
|
help='Distance from the shop to the client. Travel beyond the rate-card '
|
|
"threshold is billed BOTH WAYS at the rate's per-km fee.",
|
|
)
|
|
x_fc_callout_techs = fields.Integer(
|
|
string='Technicians on Callout',
|
|
default=1,
|
|
tracking=True,
|
|
)
|
|
x_fc_callout_labor_hours = fields.Float(
|
|
string='Billable Labor Hours',
|
|
default=0.0,
|
|
tracking=True,
|
|
help='Hours of repair work above the 30 min included in the callout fee. '
|
|
'Billing applies the minimum_labor_hours floor from the rate card '
|
|
'(default 1.0) - 20 minutes of work still bills 1 hour.',
|
|
)
|
|
# Labor warranty link + status (resolved at visit time)
|
|
x_fc_labor_warranty_id = fields.Many2one(
|
|
'fusion.repair.labor.warranty',
|
|
string='Store Labor Warranty',
|
|
tracking=True,
|
|
help='Auto-resolved when the visit-report wizard runs - links to the '
|
|
'active store labor warranty for this client + product if any.',
|
|
)
|
|
x_fc_labor_warranty_status = fields.Selection(
|
|
[
|
|
('not_checked', 'Not Yet Checked'),
|
|
('eligible', 'Covered - Labor Free'),
|
|
('not_covered', 'No Warranty on File'),
|
|
('expired', 'Warranty Expired'),
|
|
('void_misuse', 'Void - Misuse / Negligence'),
|
|
('waived', 'Manually Waived'),
|
|
],
|
|
string='Labor Warranty Status',
|
|
default='not_checked',
|
|
tracking=True,
|
|
)
|
|
# Manual labor-fee waiver (manager / sales rep only)
|
|
x_fc_labor_waived = fields.Boolean(
|
|
string='Labor Fee Waived',
|
|
tracking=True,
|
|
copy=False,
|
|
readonly=True,
|
|
)
|
|
x_fc_labor_waived_by_id = fields.Many2one(
|
|
'res.users',
|
|
string='Labor Waived By',
|
|
tracking=True,
|
|
copy=False,
|
|
readonly=True,
|
|
)
|
|
x_fc_labor_waived_at = fields.Datetime(
|
|
string='Labor Waived At',
|
|
tracking=True,
|
|
copy=False,
|
|
readonly=True,
|
|
)
|
|
x_fc_labor_waived_reason = fields.Char(
|
|
string='Labor Waiver Reason',
|
|
copy=False,
|
|
)
|
|
|
|
# Computed quote breakdown (all non-stored - depend on the rate-card)
|
|
x_fc_quote_callout_base = fields.Monetary(
|
|
string='Base Callout Fee',
|
|
currency_field='company_currency_id',
|
|
compute='_compute_callout_quote',
|
|
)
|
|
x_fc_quote_extra_techs = fields.Monetary(
|
|
string='Extra Tech Fees',
|
|
currency_field='company_currency_id',
|
|
compute='_compute_callout_quote',
|
|
)
|
|
x_fc_quote_labor = fields.Monetary(
|
|
string='Labor Charge',
|
|
currency_field='company_currency_id',
|
|
compute='_compute_callout_quote',
|
|
)
|
|
x_fc_quote_travel = fields.Monetary(
|
|
string='Travel Charge',
|
|
currency_field='company_currency_id',
|
|
compute='_compute_callout_quote',
|
|
)
|
|
x_fc_quote_waived = fields.Monetary(
|
|
string='Less: Waived',
|
|
currency_field='company_currency_id',
|
|
compute='_compute_callout_quote',
|
|
)
|
|
x_fc_quote_total = fields.Monetary(
|
|
string='Quote Total (excl. parts)',
|
|
currency_field='company_currency_id',
|
|
compute='_compute_callout_quote',
|
|
store=True, # stored so we can show it on list views and search
|
|
)
|
|
x_fc_quote_breakdown_text = fields.Text(
|
|
string='Quote Breakdown',
|
|
compute='_compute_callout_quote',
|
|
help='Human-readable line-by-line breakdown - used in the quote email.',
|
|
)
|
|
|
|
@api.depends('x_fc_callout_tier', 'x_fc_callout_distance_km',
|
|
'x_fc_callout_techs', 'x_fc_callout_labor_hours',
|
|
'x_fc_labor_warranty_status', 'x_fc_labor_waived')
|
|
def _compute_callout_quote(self):
|
|
Rate = self.env['fusion.repair.callout.rate'].sudo()
|
|
for r in self:
|
|
tier = r.x_fc_callout_tier or 'regular'
|
|
rate = Rate.get_for_tier(tier)
|
|
techs = max(r.x_fc_callout_techs or 1, 1)
|
|
hours = max(r.x_fc_callout_labor_hours or 0.0, 0.0)
|
|
distance = r.x_fc_callout_distance_km or 0.0
|
|
|
|
base = rate.base_callout_fee if rate else 0.0
|
|
extra_techs = 0.0
|
|
if rate and techs >= 2:
|
|
extra_techs += rate.second_tech_fee
|
|
if rate and techs >= 3:
|
|
# 3rd onwards: use additional_tech_fee, falling back to second_tech_fee.
|
|
per_extra = rate.additional_tech_fee or rate.second_tech_fee
|
|
extra_techs += per_extra * (techs - 2)
|
|
|
|
# Labour - charged per tech with min-1-hour floor on the
|
|
# BILLABLE portion (above 30 min the callout includes).
|
|
labor = 0.0
|
|
if rate and hours > 0:
|
|
min_hours = rate.minimum_labor_hours or 1.0
|
|
billable_h = max(hours, min_hours)
|
|
labor = billable_h * rate.hourly_labor_rate * techs
|
|
|
|
# Travel - both ways, per tech, for distance over threshold.
|
|
travel = 0.0
|
|
if rate:
|
|
over = max(distance - rate.travel_distance_threshold_km, 0.0)
|
|
travel = over * 2.0 * rate.travel_per_km_fee * techs
|
|
|
|
waived = 0.0
|
|
if r.x_fc_labor_warranty_status in ('eligible', 'waived') or r.x_fc_labor_waived:
|
|
waived = labor
|
|
|
|
total = base + extra_techs + labor + travel - waived
|
|
|
|
r.x_fc_quote_callout_base = base
|
|
r.x_fc_quote_extra_techs = extra_techs
|
|
r.x_fc_quote_labor = labor
|
|
r.x_fc_quote_travel = travel
|
|
r.x_fc_quote_waived = waived
|
|
r.x_fc_quote_total = total
|
|
r.x_fc_quote_breakdown_text = (
|
|
f'Base callout ({tier}): ${base:.2f}\n'
|
|
f'Extra technicians ({techs - 1}): ${extra_techs:.2f}\n'
|
|
f'Labor ({hours:.2f} h x {techs} tech, min '
|
|
f'{rate.minimum_labor_hours if rate else 1.0} h): ${labor:.2f}\n'
|
|
f'Travel ({distance:.1f} km, both ways, per tech): ${travel:.2f}\n'
|
|
+ (f'Less waived: -${waived:.2f}\n' if waived else '')
|
|
+ f'-------------------------------------------\n'
|
|
f'TOTAL (excl. parts): ${total:.2f}'
|
|
)
|
|
|
|
def action_check_labor_warranty(self):
|
|
"""Look up the active store labor warranty for this repair's
|
|
partner + product. Updates x_fc_labor_warranty_id and
|
|
x_fc_labor_warranty_status. Called from the visit-report wizard
|
|
AND from the dashboard's "check warranty" button."""
|
|
Warr = self.env['fusion.repair.labor.warranty'].sudo()
|
|
for r in self:
|
|
w = Warr.find_active_for(
|
|
r.partner_id, r.product_id, r.lot_id or False,
|
|
)
|
|
r.x_fc_labor_warranty_id = w.id if w else False
|
|
if not w:
|
|
r.x_fc_labor_warranty_status = 'not_covered'
|
|
elif w.state == 'expired':
|
|
r.x_fc_labor_warranty_status = 'expired'
|
|
elif w.state == 'void':
|
|
r.x_fc_labor_warranty_status = 'void_misuse'
|
|
else:
|
|
r.x_fc_labor_warranty_status = 'eligible'
|
|
|
|
def action_waive_labor_fee(self):
|
|
"""Manager / sales rep only. CS rep cannot waive."""
|
|
Group = self.env.ref
|
|
user = self.env.user
|
|
can_waive = (
|
|
user.has_group('fusion_repairs.group_fusion_repairs_manager')
|
|
or user.has_group('fusion_repairs.group_fusion_repairs_sales_rep')
|
|
)
|
|
if not can_waive:
|
|
raise UserError(_(
|
|
'Only Repairs Managers and Sales Reps can waive the labor fee. '
|
|
'CS staff must escalate to a manager.'
|
|
))
|
|
for r in self:
|
|
r.write({
|
|
'x_fc_labor_waived': True,
|
|
'x_fc_labor_waived_by_id': user.id,
|
|
'x_fc_labor_waived_at': fields.Datetime.now(),
|
|
'x_fc_labor_warranty_status': 'waived',
|
|
})
|
|
r.message_post(body=Markup(_(
|
|
'<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.'))
|