Files
Odoo-Modules/fusion_repairs/models/repair_order.py
gsinghpal eb186cac3c feat(fusion_repairs): Bundle 11 - CS guided troubleshooting flowcharts + vendor PO
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>
2026-05-27 12:50:06 -04:00

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.'))