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>
430 lines
17 KiB
Python
430 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2024-2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
|
|
"""Backend intake wizard.
|
|
|
|
A simple Phase 1 transient model that captures one-or-many equipment items
|
|
per call, then delegates to fusion.repair.intake.service to create the
|
|
repair.order(s). The shared service guarantees identical behaviour to the
|
|
sales rep portal and the public client portal added in later phases.
|
|
|
|
Multi-equipment per call is supported via the equipment_ids One2many.
|
|
|
|
Includes Phase 1 polish:
|
|
- C1: duplicate-call detection (yellow banner if the partner has an open
|
|
repair from the last N days)
|
|
- C5: outstanding-balance warning (red banner if open invoice total > config)
|
|
- C6: quote-only mode (creates the repair but does NOT dispatch a tech)
|
|
"""
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RepairIntakeWizard(models.TransientModel):
|
|
_name = 'fusion.repair.intake.wizard'
|
|
_description = 'Repair Intake Wizard'
|
|
|
|
# ------------------------------------------------------------------
|
|
# CALLER / CLIENT
|
|
# ------------------------------------------------------------------
|
|
intake_user_id = fields.Many2one(
|
|
'res.users',
|
|
string='Taken By',
|
|
default=lambda self: self.env.user,
|
|
required=True,
|
|
)
|
|
partner_id = fields.Many2one(
|
|
'res.partner',
|
|
string='Client',
|
|
required=True,
|
|
help='Existing client. Use the create-and-edit dialog to add a new contact.',
|
|
)
|
|
partner_phone = fields.Char(
|
|
related='partner_id.phone',
|
|
string='Phone',
|
|
readonly=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# CONTEXTUAL BANNERS (C1 + C5)
|
|
# Computed reactively when the partner is selected. Shown in the form
|
|
# so CS knows immediately about duplicate calls or unpaid invoices.
|
|
# ------------------------------------------------------------------
|
|
duplicate_repair_ids = fields.Many2many(
|
|
'repair.order',
|
|
compute='_compute_partner_context',
|
|
string='Open Repairs (last N days)',
|
|
)
|
|
duplicate_count = fields.Integer(
|
|
compute='_compute_partner_context',
|
|
string='Duplicate Call Count',
|
|
)
|
|
duplicate_window_days = fields.Integer(
|
|
compute='_compute_partner_context',
|
|
string='Duplicate Window (days)',
|
|
)
|
|
currency_id = fields.Many2one(
|
|
'res.currency',
|
|
compute='_compute_partner_context',
|
|
string='Currency',
|
|
)
|
|
outstanding_balance = fields.Monetary(
|
|
compute='_compute_partner_context',
|
|
currency_field='currency_id',
|
|
string='Open Invoice Balance',
|
|
)
|
|
outstanding_invoice_count = fields.Integer(
|
|
compute='_compute_partner_context',
|
|
string='Open Invoices',
|
|
)
|
|
show_outstanding_warning = fields.Boolean(
|
|
compute='_compute_partner_context',
|
|
string='Show Outstanding Balance Warning',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# OPTIONS (C6 quote-only mode)
|
|
# ------------------------------------------------------------------
|
|
quote_only = fields.Boolean(
|
|
string='Quote Only - Do Not Dispatch',
|
|
help='Create the service request and quote the client, but do NOT '
|
|
'auto-create a technician dispatch task. Use this when the client '
|
|
'is gathering quotes or has not yet authorised the repair.',
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Bundle 8: rush / emergency options + live surcharge preview
|
|
# ------------------------------------------------------------------
|
|
rush_requested = fields.Boolean(
|
|
string='Rush / Emergency Service',
|
|
help='Tick when the client needs faster-than-normal turnaround. '
|
|
'Surcharge is calculated automatically from the rate card.',
|
|
)
|
|
rush_tier = fields.Selection(
|
|
[
|
|
('same_day', 'Same Day (during business hours)'),
|
|
('next_day', 'Next Day Priority'),
|
|
('after_hours', 'After Hours (5pm-9pm weekday)'),
|
|
('weekend', 'Weekend'),
|
|
('holiday', 'Statutory Holiday'),
|
|
],
|
|
string='Rush Tier',
|
|
)
|
|
rush_techs_required = fields.Integer(
|
|
string='Technicians Required',
|
|
default=1,
|
|
)
|
|
rush_surcharge_preview = fields.Monetary(
|
|
string='Quoted Surcharge',
|
|
compute='_compute_rush_surcharge_preview',
|
|
currency_field='currency_id',
|
|
)
|
|
rush_acknowledged = fields.Boolean(
|
|
string='Client Agreed to Price',
|
|
help='Tick this AFTER you have read the surcharge to the client over the '
|
|
'phone and they have said yes. The repair will record the '
|
|
'acknowledgement timestamp + your user id for audit.',
|
|
)
|
|
|
|
@api.depends('rush_tier', 'rush_techs_required', 'equipment_ids.repair_category_id')
|
|
def _compute_rush_surcharge_preview(self):
|
|
Rates = self.env['fusion.repair.emergency.charge'].sudo()
|
|
for w in self:
|
|
if not w.rush_tier or not w.equipment_ids:
|
|
w.rush_surcharge_preview = 0.0
|
|
continue
|
|
# Use the FIRST equipment's category for the preview - per-equipment
|
|
# surcharges land on each repair.order after create.
|
|
cat = w.equipment_ids[:1].repair_category_id
|
|
w.rush_surcharge_preview = Rates.calculate(
|
|
cat, w.rush_tier, w.rush_techs_required or 1,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# EQUIPMENT (one-or-many)
|
|
# ------------------------------------------------------------------
|
|
equipment_ids = fields.One2many(
|
|
'fusion.repair.intake.wizard.equipment',
|
|
'wizard_id',
|
|
string='Equipment Items',
|
|
required=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# COMPUTES
|
|
# ------------------------------------------------------------------
|
|
@api.depends('partner_id')
|
|
def _compute_partner_context(self):
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
try:
|
|
window_days = int(ICP.get_param(
|
|
'fusion_repairs.duplicate_call_window_days', '14'
|
|
))
|
|
except (ValueError, TypeError):
|
|
window_days = 14
|
|
try:
|
|
threshold = float(ICP.get_param(
|
|
'fusion_repairs.outstanding_balance_threshold', '100'
|
|
))
|
|
except (ValueError, TypeError):
|
|
threshold = 100.0
|
|
|
|
# Avoid sudo - CS users already have access to their own company's
|
|
# repairs/invoices via the standard groups + the Repairs Office rule.
|
|
Repair = self.env['repair.order']
|
|
Move = self.env['account.move']
|
|
company_ids = self.env.companies.ids
|
|
default_currency = self.env.company.currency_id
|
|
cutoff = fields.Datetime.now() - timedelta(days=window_days)
|
|
|
|
for w in self:
|
|
w.duplicate_window_days = window_days
|
|
if not w.partner_id:
|
|
w.duplicate_repair_ids = False
|
|
w.duplicate_count = 0
|
|
w.outstanding_balance = 0.0
|
|
w.outstanding_invoice_count = 0
|
|
w.show_outstanding_warning = False
|
|
w.currency_id = default_currency
|
|
continue
|
|
|
|
# Multi-company scoped duplicate detection. search_count for the
|
|
# real total + search(limit=5) for the display list - so the banner
|
|
# never lies about a partner with >5 open calls.
|
|
dup_domain = [
|
|
('partner_id', '=', w.partner_id.id),
|
|
('state', 'not in', ('done', 'cancel')),
|
|
('create_date', '>=', cutoff),
|
|
('company_id', 'in', company_ids),
|
|
]
|
|
w.duplicate_repair_ids = Repair.search(
|
|
dup_domain, order='create_date desc', limit=5,
|
|
)
|
|
w.duplicate_count = Repair.search_count(dup_domain)
|
|
|
|
# commercial_partner_id is the canonical "billed-to root" - covers
|
|
# child contacts AND walks up from a child if the caller IS a child.
|
|
commercial = w.partner_id.commercial_partner_id or w.partner_id
|
|
inv_domain = [
|
|
('commercial_partner_id', '=', commercial.id),
|
|
('move_type', '=', 'out_invoice'),
|
|
('state', '=', 'posted'),
|
|
('payment_state', 'in', ('not_paid', 'partial')),
|
|
('company_id', 'in', company_ids),
|
|
]
|
|
# _read_group pushes the SUM to Postgres - O(1) load vs O(N) records.
|
|
rows = Move._read_group(
|
|
inv_domain, aggregates=['amount_residual:sum', '__count'],
|
|
)
|
|
balance, invoice_count = rows[0] if rows else (0.0, 0)
|
|
w.currency_id = default_currency
|
|
w.outstanding_balance = balance or 0.0
|
|
w.outstanding_invoice_count = invoice_count or 0
|
|
w.show_outstanding_warning = (balance or 0.0) >= threshold
|
|
|
|
# ------------------------------------------------------------------
|
|
# SUBMIT
|
|
# ------------------------------------------------------------------
|
|
def action_submit(self):
|
|
self.ensure_one()
|
|
if not self.equipment_ids:
|
|
raise UserError(_('Please add at least one piece of equipment.'))
|
|
|
|
payload = {
|
|
'partner_id': self.partner_id.id,
|
|
'intake_user_id': self.intake_user_id.id,
|
|
'quote_only': self.quote_only,
|
|
'rush_requested': self.rush_requested,
|
|
'rush_tier': self.rush_tier if self.rush_requested else False,
|
|
'rush_techs_required': self.rush_techs_required if self.rush_requested else 1,
|
|
'rush_acknowledged': self.rush_acknowledged,
|
|
'equipment_items': [self._equipment_payload(eq) for eq in self.equipment_ids],
|
|
}
|
|
|
|
# sudo() so sub-operations (mail.activity, mail.mail, fusion.technician.task)
|
|
# never trip on permission checks - x_fc_intake_user_id preserves audit identity.
|
|
repairs = self.env['fusion.repair.intake.service'].sudo().create_repair_orders(
|
|
payload, source='backend_wizard',
|
|
)
|
|
|
|
# If CS ticked "rush" and "client agreed", stamp the ack on every spawned repair.
|
|
if self.rush_requested and self.rush_acknowledged:
|
|
for r in repairs:
|
|
r.x_fc_rush_acknowledged_at = fields.Datetime.now()
|
|
r.x_fc_rush_acknowledged_by_id = self.intake_user_id.id or self.env.uid
|
|
|
|
if len(repairs) == 1:
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': repairs.name,
|
|
'res_model': 'repair.order',
|
|
'view_mode': 'form',
|
|
'res_id': repairs.id,
|
|
}
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Service Calls Created (%(count)s)', count=len(repairs)),
|
|
'res_model': 'repair.order',
|
|
'view_mode': 'list,form',
|
|
'domain': [('id', 'in', repairs.ids)],
|
|
}
|
|
|
|
def action_open_existing_repair(self):
|
|
"""C1: jump to the most recent duplicate repair so CS can add a note
|
|
instead of creating a new repair."""
|
|
self.ensure_one()
|
|
if not self.duplicate_repair_ids:
|
|
return False
|
|
repair = self.duplicate_repair_ids[0]
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': repair.name,
|
|
'res_model': 'repair.order',
|
|
'view_mode': 'form',
|
|
'res_id': repair.id,
|
|
'target': 'current',
|
|
}
|
|
|
|
def action_view_outstanding_invoices(self):
|
|
"""C5: open the list of unpaid invoices for context."""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Open Invoices - %s', self.partner_id.name or ''),
|
|
'res_model': 'account.move',
|
|
'view_mode': 'list,form',
|
|
'domain': [
|
|
('partner_id', 'child_of', self.partner_id.id),
|
|
('move_type', '=', 'out_invoice'),
|
|
('state', '=', 'posted'),
|
|
('payment_state', 'in', ('not_paid', 'partial')),
|
|
],
|
|
'target': 'current',
|
|
}
|
|
|
|
def _equipment_payload(self, eq):
|
|
"""Render an equipment record as a dict the intake service expects."""
|
|
return {
|
|
'product_id': eq.product_id.id or False,
|
|
'lot_id': eq.lot_id.id or False,
|
|
'repair_category_id': eq.repair_category_id.id or False,
|
|
'intake_template_id': eq.intake_template_id.id or False,
|
|
'third_party': eq.third_party,
|
|
'urgency': eq.urgency,
|
|
'issue_summary': eq.issue_summary or '',
|
|
'issue_category': eq.issue_category or '',
|
|
'symptom_class_id': eq.symptom_class_id.id or False,
|
|
'internal_notes': eq.internal_notes or '',
|
|
'schedule_date': eq.scheduled_date or False,
|
|
'photo_attachment_ids': eq.photo_ids.ids if eq.photo_ids else [],
|
|
'answers': [], # Phase 1 wizard doesn't expose per-question answer rows yet
|
|
}
|
|
|
|
|
|
class RepairIntakeWizardEquipment(models.TransientModel):
|
|
"""A single piece of equipment captured in the wizard.
|
|
|
|
Multiple lines = multi-equipment intake (one repair.order per line).
|
|
"""
|
|
|
|
_name = 'fusion.repair.intake.wizard.equipment'
|
|
_description = 'Repair Intake Wizard - Equipment Line'
|
|
_order = 'sequence, id'
|
|
|
|
wizard_id = fields.Many2one(
|
|
'fusion.repair.intake.wizard',
|
|
string='Wizard',
|
|
required=True,
|
|
ondelete='cascade',
|
|
)
|
|
sequence = fields.Integer(default=10)
|
|
|
|
# Equipment identification
|
|
repair_category_id = fields.Many2one(
|
|
'fusion.repair.product.category',
|
|
string='Category',
|
|
required=True,
|
|
)
|
|
product_id = fields.Many2one(
|
|
'product.product',
|
|
string='Product',
|
|
help='Specific product if known. Leave blank for generic equipment.',
|
|
)
|
|
lot_id = fields.Many2one(
|
|
'stock.lot',
|
|
string='Serial Number',
|
|
domain="[('product_id', '=', product_id)]",
|
|
help='Lot or serial number if known.',
|
|
)
|
|
third_party = fields.Boolean(
|
|
string='Not Purchased From Us',
|
|
help='Tick if this equipment was bought elsewhere - we still service it but '
|
|
'warranty is not honoured and a service call-out fee applies.',
|
|
)
|
|
|
|
# Intake context
|
|
intake_template_id = fields.Many2one(
|
|
'fusion.repair.intake.template',
|
|
string='Question Template',
|
|
help='Defaults to the template configured on the category if left blank.',
|
|
)
|
|
|
|
# Triage
|
|
urgency = fields.Selection(
|
|
[('normal', 'Normal'), ('urgent', 'Urgent'), ('safety', 'Safety Issue')],
|
|
string='Urgency',
|
|
default='normal',
|
|
required=True,
|
|
)
|
|
scheduled_date = fields.Datetime(
|
|
string='Preferred Date',
|
|
default=fields.Datetime.now,
|
|
)
|
|
issue_summary = fields.Char(
|
|
string='Issue Summary',
|
|
help='One-line summary of what is wrong (e.g. "stairlift stops halfway up").',
|
|
)
|
|
issue_category = fields.Char(
|
|
string='Symptom Category (legacy text)',
|
|
help='Free-text symptom tag - kept for backwards compat. New intakes '
|
|
'should pick the structured Symptom M2O below.',
|
|
)
|
|
# Bundle 11: proper symptom classification - drives flowchart lookup.
|
|
symptom_class_id = fields.Many2one(
|
|
'fusion.repair.symptom.class',
|
|
string='Symptom',
|
|
domain="[('category_id', '=', repair_category_id)]",
|
|
help='Pick the symptom to enable the Start Troubleshooting button '
|
|
'on the resulting repair (if a published flowchart exists).',
|
|
)
|
|
internal_notes = fields.Text(string='Internal Notes')
|
|
|
|
photo_ids = fields.Many2many(
|
|
'ir.attachment',
|
|
'fusion_repair_intake_wizard_eq_photo_rel',
|
|
'eq_id',
|
|
'attachment_id',
|
|
string='Photos / Videos',
|
|
)
|
|
|
|
@api.onchange('repair_category_id')
|
|
def _onchange_repair_category_id(self):
|
|
"""Pre-fill the intake template from the category default."""
|
|
if self.repair_category_id and not self.intake_template_id:
|
|
self.intake_template_id = self.repair_category_id.intake_template_id
|
|
|
|
@api.onchange('product_id')
|
|
def _onchange_product_id(self):
|
|
"""Pre-fill the category from the product if defined."""
|
|
if self.product_id and not self.repair_category_id:
|
|
cat = self.product_id.product_tmpl_id.x_fc_repair_category_id
|
|
if cat:
|
|
self.repair_category_id = cat
|