Two big workflow additions:
1. Visual drag-and-drop flowchart designer (Drawflow) + card-by-card runner
(with show-whole-tree toggle) so admins build per-(category, symptom)
decision trees with embedded photos/videos and CS walks callers through
them on the phone. Resolved-on-call closes the repair; escalated copies
the full transcript into internal_notes so the dispatched tech sees what
was already tried before they arrive at the client.
2. Vendor + draft-PO + factory-tracking on the part-order capture. Tech on
the phone with the factory picks the vendor from contacts, types the OEM
part #, cost, ETA date (calendar widget), factory ticket #, RA #, ticks
under_warranty, and the system auto-creates a draft purchase.order with
the right product (looked up or created from OEM) + activity for the
office on the ETA day + client email with ETA prominently shown and
cost intentionally omitted.
NEW MODELS
fusion.repair.symptom.class - lookup table (category + name + code).
Replaces the flat x_fc_issue_category Char on repair.order. Seeded with
7 stairlift symptoms + lighter coverage for hospital bed / porch lift /
lift chair. Equipment Class added to fusion.repair.product.category
(this carried over from the Bundle 10 plan).
fusion.repair.flowchart + .node + .edge - design-time graph.
- flowchart has name, category, symptom, version, published flag,
canvas_layout (Drawflow JSON), node_ids, edge_ids, computed start_node
- node has node_type (question / suggestion / info / outcome),
content_html, media_ids (M2M ir.attachment for photos + videos),
is_start, outcome_kind (resolved / escalate / order_part),
canvas_x/y for Drawflow round-trip
- edge has source, target, label, sequence - supports N-ary branching
(not just Yes/No)
- designer_load() and designer_save(payload) RPC API the OWL component
consumes; save is atomic-replace + bumps version + soft-validates
fusion.repair.flowchart.run + .step - runtime sessions.
- One run per repair, audited; runtime_start_or_resume() returns the
existing in-progress run or creates a fresh one for the matching chart
- runtime_choose(edge_id, cs_note) records a step + advances current_node
- runtime_complete(outcome) snapshots final node + calls _apply_outcome:
resolved -> auto-close via action_repair_start + action_repair_end,
set x_fc_resolved_on_call, post transcript to chatter
escalated -> prepend transcript to repair.internal_notes so the tech
sees it first when they open the form
order_part -> chatter note; tech opens visit-report wizard next
abandoned -> just save transcript
- Each step snapshots node_name + chosen_label at write time so the
transcript survives later chart edits without breaking.
REPAIR.ORDER EXTENSIONS
- x_fc_symptom_class_id (M2O) - new structured symptom field
- x_fc_resolved_on_call (Boolean, tracked) - true after a resolved outcome
- x_fc_flowchart_run_ids + x_fc_flowchart_run_count
- action_start_troubleshoot() - opens the runner client action, raises a
helpful UserError if no symptom set or no published chart exists
- action_view_flowchart_runs() smart button
- x_fc_issue_category renamed string to "(legacy)" - kept for back-compat
+ AI prompt context; new intakes set the M2O
DRAWFLOW DESIGNER (OWL)
static/src/lib/drawflow/drawflow.min.{js,css} - vendored Drawflow 0.0.59
(MIT). Loaded only in web.assets_backend, ~48KB total.
components/flowchart_designer/flowchart_designer.{js,xml,scss}:
- Client action "fusion_repair_flowchart_designer" with full drag-drop
canvas + zoom + pan
- 4 custom node templates color-banded by type (question blue,
suggestion green, info gray, outcome red/green/amber per outcome_kind)
- Right-panel editor for selected node: title, type, outcome kind,
content (HTML), media uploader (drag-drop or click), set-as-start
toggle, per-outgoing-edge label editor
- Save serializes Drawflow JSON to canvas_layout + atomic-replaces the
structured node/edge rows via the designer_save RPC
CARD RUNNER (OWL)
components/flowchart_runner/flowchart_runner.{js,xml,scss}:
- Client action "fusion_repair_flowchart_runner"
- DEFAULT MODE: card-by-card. One big card per node, embedded photos +
inline <video controls>, answer buttons sized for phone use, CS note
textarea (saved as cs_note on the step), running transcript at the
bottom
- TOGGLE: "Show Whole Tree" loads the same Drawflow lib in read-only
fixed mode, imports the canvas_layout JSON, highlights current node
yellow / visited green via .fr-current / .fr-visited classes
- Outcome buttons drive the right runtime_complete() call; success
notifications + auto-return to the parent repair form
- "Abandon & Escalate" header button at all times - transcript is saved
even on bail-out so the dispatched tech still benefits
PART ORDER + VENDOR PO
repair.part.order new fields:
vendor_partner_id (M2O res.partner, is_company domain), purchase_order_id
(auto-created draft PO), product_id (auto-resolved or created),
unit_cost (Monetary) + currency_id, internal_po_ref, factory_ticket_ref,
factory_ra_number, under_warranty.
action_create_draft_po() - resolves product.product by OEM (default_code)
or creates a new one in a "Spare Parts" product.category, creates a
purchase.order in draft state with one line (product + qty + price_unit
+ date_planned from expected_date or +7d), stamps Westin's internal PO
ref as partner_ref so the factory can find it on return. Office reviews
and confirms via the normal Odoo flow.
_schedule_eta_activity() - schedules a Repair: Assign Technician activity
on the parent repair.order due on expected_date, assigned to
repair.user_id, so the office is reminded to call the client and book
the return visit on the day parts arrive.
VISIT-REPORT WIZARD PARTLINE EXTENSIONS
Same new fields exposed inline on the partline list so the tech captures
everything on the phone with the factory in one form:
vendor_partner_id (vendors-only filter), unit_cost + currency,
expected_date (calendar widget) replacing expected_lead_days as the
preferred input, under_warranty, internal_po_ref, factory_ticket_ref,
factory_ra_number, create_draft_po (default True - auto-builds PO on
submit when vendor + cost are both set).
CLIENT EMAIL TIGHTENED
email_template_parts_ordered:
- Subject now includes ETA "Parts ordered for your stairlift - expected 2026-06-06"
- Hero ETA panel: large blue-bordered card with "Expected Arrival" label
and the date in 24px bold
- Cost INTENTIONALLY OMITTED - "Our office will call you to confirm a
return visit time. If you have any questions about pricing or
scheduling, please reach out to our office directly."
- "There is nothing for you to do right now." callout
UI
- repair.order form header: new "Start Troubleshooting" button (info
style, sitemap icon, visible when state in (draft, confirmed,
under_repair) AND symptom is set)
- repair.order form intake row: x_fc_symptom_class_id picker filtered to
the category, x_fc_resolved_on_call display when true
- repair.part.order form: header button "Create Draft Purchase Order"
+ new Vendor / Cost / Warranty group + System group with the PO link
- Intake wizard equipment line: symptom_class_id picker
- New menus:
Configuration > Symptom Classes
Configuration > Troubleshooting Flowcharts
Fusion Repairs > Troubleshooting Sessions (run history)
SECURITY
18 new ACL rows for the 6 new models, scoped Manager-full / User-read /
FieldTech-read. Flowchart runs and steps get write access for User so CS
can record steps; Manager owns flowchart + node + edge CRUD.
POST-MIGRATION (19.0.2.2.0)
Existing installs: walks all distinct (category, x_fc_issue_category) text
pairs on repair.order, creates a placeholder fusion.repair.symptom.class
per pair (or reuses an existing match by code/name), back-fills the new
x_fc_symptom_class_id M2O. Idempotent + safe to re-run.
DEPENDENCY
Added 'purchase' to depends (action_create_draft_po needs purchase.order).
VERIFIED END-TO-END on local westin-v19 (Margaret persona, 0 bugs):
STEP 0 seed: chart v1 8 nodes / 12 edges / published, 7 stairlift
symptoms, stairlift class=lift_elevating
STEP 1 CS creates RO-202605-60 with symptom Not Moving
STEP 2 Start Troubleshooting -> client action tag returned
STEP 3 walk run: Power on? Yes -> Seatbelt? Yes -> Swivel? Yes ->
outcome 'Still not moving - dispatch technician'
(outcome_kind=escalate)
STEP 4 runtime_complete('escalated') -> internal_notes prepended with
CS troubleshooting summary
STEP 5 visit-report parts_needed with vendor Handicare + cost $425 +
warranty + factory refs -> PART-00008 created + draft
PO 26690 auto-built with line "Handicare 1100 control
board" qty 1 @ $425, partner_ref WH-2026-1042
STEP 6 mark_ordered -> client email queued (NO cost mentioned, ETA
shown prominently) + office activity scheduled for
2026-06-06
STEP 7 fresh resume returns same run; resolved outcome auto-closes the
repair (state=done, x_fc_resolved_on_call=True)
Bumped to 19.0.2.2.0.
Co-authored-by: Cursor <cursoragent@cursor.com>
1294 lines
51 KiB
Python
1294 lines
51 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 (legacy)',
|
|
help='LEGACY free-text symptom classification. Superseded by '
|
|
'x_fc_symptom_class_id - kept for backwards compatibility with '
|
|
'historical records and the AI prompt context. New intakes '
|
|
'should set the M2O instead.',
|
|
)
|
|
# Bundle 11: proper structured symptom classification.
|
|
x_fc_symptom_class_id = fields.Many2one(
|
|
'fusion.repair.symptom.class',
|
|
string='Symptom',
|
|
index=True,
|
|
tracking=True,
|
|
domain="[('category_id', '=', x_fc_repair_category_id)]",
|
|
help='Drives flowchart lookup. Pick the (category, symptom) pair and '
|
|
'CS can start a troubleshooting flowchart if one is published.',
|
|
)
|
|
# Bundle 11: troubleshooting runs against this repair.
|
|
x_fc_flowchart_run_ids = fields.One2many(
|
|
'fusion.repair.flowchart.run', 'repair_order_id',
|
|
string='Troubleshooting Runs',
|
|
)
|
|
x_fc_flowchart_run_count = fields.Integer(
|
|
compute='_compute_flowchart_run_count',
|
|
)
|
|
x_fc_resolved_on_call = fields.Boolean(
|
|
string='Resolved on Call',
|
|
copy=False,
|
|
tracking=True,
|
|
help='True when the CS troubleshooting flowchart hit a "resolved" '
|
|
'outcome - no technician was ever dispatched.',
|
|
)
|
|
|
|
@api.depends('x_fc_flowchart_run_ids')
|
|
def _compute_flowchart_run_count(self):
|
|
for r in self:
|
|
r.x_fc_flowchart_run_count = len(r.x_fc_flowchart_run_ids)
|
|
|
|
def action_view_flowchart_runs(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Troubleshooting Runs'),
|
|
'res_model': 'fusion.repair.flowchart.run',
|
|
'view_mode': 'list,form',
|
|
'domain': [('repair_order_id', '=', self.id)],
|
|
'context': {'default_repair_order_id': self.id},
|
|
}
|
|
|
|
def action_start_troubleshoot(self):
|
|
"""Open the OWL flowchart runner for this repair (creates or resumes
|
|
an in-progress run). Raises a helpful UserError if the symptom is
|
|
not picked or no published chart exists for the (category, symptom)
|
|
pair."""
|
|
self.ensure_one()
|
|
if not self.x_fc_symptom_class_id:
|
|
raise UserError(_(
|
|
'Pick a Symptom on this repair first (Callout Pricing tab or '
|
|
'the Fusion Repairs section), then start troubleshooting.'
|
|
))
|
|
chart = self.env['fusion.repair.flowchart'].sudo().search([
|
|
('category_id', '=', self.x_fc_repair_category_id.id),
|
|
('symptom_class_id', '=', self.x_fc_symptom_class_id.id),
|
|
('published', '=', True),
|
|
('active', '=', True),
|
|
], limit=1)
|
|
if not chart:
|
|
raise UserError(_(
|
|
'No published troubleshooting flowchart for %(cat)s + %(sym)s. '
|
|
'Ask a manager to publish one in Configuration > '
|
|
'Troubleshooting Flowcharts.'
|
|
) % {
|
|
'cat': self.x_fc_repair_category_id.name or '?',
|
|
'sym': self.x_fc_symptom_class_id.name or '?',
|
|
})
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'fusion_repair_flowchart_runner',
|
|
'name': _('Troubleshooting - %s') % self.name,
|
|
'params': {'repair_id': self.id},
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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.'))
|