Files
Odoo-Modules/fusion_repairs/models/repair_service_plan.py
gsinghpal 4f1b7c2df6 fix(fusion_repairs): persona-driven workflow audit - 6 real bugs
Full end-to-end walk acting as customer, CS rep, dispatcher, technician,
and manager surfaced 6 real bugs (1 critical state-machine, 4 missing UX
wires, 1 docstring). Server endpoints existed for everything but several
were not wired into the templates.

B1 (HIGH) - Visit-report wizard never closed the repair
  Tech submitted visit -> state stayed 'draft' -> x_fc_done_at never
  stamped -> NPS cron never fired -> the whole post-visit flow died
  silently. Customers never got their NPS email.

  Fix: action_confirm() now drives the Odoo native state machine
  draft -> action_validate (with _action_repair_confirm fallback) ->
  action_repair_start -> action_repair_end. Each step guarded by the
  current state and exception-logged. Leaves the repair open if:
    - requires_requote=True (variance flag - office must re-quote)
    - no_show=True (office reschedules)
    - x_fc_is_quote_only (still a quote)
    - found_another_issue spawned a stub
  Posts a clear chatter line on success or failure.
  Verified: e2e walk now shows state=done + x_fc_done_at stamped +
  NPS cron fires + flags x_fc_nps_email_sent=True.

B2 (HIGH) - /repair/new form never called /repair/self_check
  The AI self-check engine was the headline weekend feature but it was
  invisible to the client. The endpoint worked server-side, just had
  no frontend.

  Fix: new portal_client_repair.js (Interaction class, registered on
  registry.category('public.interactions')). 'Try 1-3 safe self-check
  steps first' button POSTs to /repair/self_check, renders steps via
  createElement + textContent (no innerHTML - all server output is
  treated as untrusted text). Shows the AI's safety disclaimer on
  every result. On escalate_immediately, shows a clear 'submit the
  form, we'll come to you' message instead of the steps.
  Verified: HTTP POST returns full JSON with instruction +
  expected_result + disclaimer; new button + result panel appear in
  rendered HTML.

B3 (HIGH) - No phone-lookup UI for returning clients
  Same problem - endpoint existed but no UI. Returning clients had to
  retype everything from scratch.

  Fix:
  - lookup_phone now returns a 'partners' array (id, name, email,
    street, city) - cap of 3 results, rate-limited, every match logged
    at INFO level for audit. Privacy compromise: a phone holder
    deserves to see their own pre-fill; rate limit caps harvesting.
  - JS lookup widget at the top of the form posts to /repair/lookup_phone
    and pre-fills the 5 contact fields + writes the partner_id to a
    hidden #fr_known_partner_id input.
  - controller /repair/submit now trusts known_partner_id if present
    (skips the phone re-match) so we don't create duplicate partners
    when the lookup widget already identified the right one.
  Verified: HTTP POST returns the 2 partner records we have for
  +19055551234 with full id/name/email/street/city.

B4 (MEDIUM) - /repair?sn=<serial> from QR sticker did nothing
  Spec: 'Client scans QR sticker - portal pre-fills the unit info.'
  Reality: the form had no serial field; ?sn= was ignored.

  Fix: new _resolve_serial_info(serial) on the controller resolves
  the lot via stock.lot.search([('name','=',sn)]) and returns
  {serial, lot_id, product_id, product_name, category_id}. Both
  /repair (landing) and /repair/new pass it as serial_info template
  context. Templates show 'Recognized X (Serial: Y)' + auto-select
  the matching category in the dropdown. Hidden #fr_serial_number
  carries it through to /repair/submit, which attaches the lot_id +
  uses the QR category as fallback if user didn't pick one.
  Verified: ?sn=stella23-20040164 produces 'Pre-filled from QR scan:'
  banner + hidden input populated.

B5 (MEDIUM) - No upsell after submit
  Spec required an upsell - 'reduce future calls'. Page was a bare
  'Got it'.

  Fix: /repair/thanks now shows a 2-card layout:
    - 'Want to avoid this next time?' with 4 bullets (priority booking,
      free inspection cert, discounted parts, annual reminder) +
      'See our maintenance plans' CTA to /shop?category=maintenance
    - 'What happens next' 4-step bulleted explanation
  Verified: both cards render.

B6 (LOW) - SyntaxWarning '\-->' in repair_service_plan.py
  Made the module docstring a raw string (r''') so the ASCII flowchart
  arrows don't trigger Python's invalid-escape-sequence warning.

Bumped to 19.0.1.8.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 01:06:12 -04:00

248 lines
9.4 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
r"""Pre-paid service plans (M5).
Architecture:
product.template
\--> x_fc_is_service_plan = True
x_fc_plan_visits_included (e.g. 4)
x_fc_plan_duration_months (e.g. 12)
sale.order.confirm()
\--> for each line whose product is a service plan,
create a fusion.repair.service.plan.subscription
(partner + product + visits_included + start_date + end_date)
fusion.repair.maintenance.contract.create_repair_from_booking()
visit_report_wizard.action_confirm()
\--> burns down one visit if the partner has an active matching plan
(for the same product or category)
fusion.repair.dashboard.get_dashboard_data()
\--> exposes active_plan_count + plans_low_count for the dashboard
"""
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
x_fc_is_service_plan = fields.Boolean(
string='Service Plan',
help='Sell this product as a pre-paid maintenance package. '
'Confirming a sale order with this product creates a '
'visit subscription for the customer.',
)
x_fc_plan_visits_included = fields.Integer(
string='Visits Included',
default=4,
help='Number of maintenance visits the customer is entitled to under this plan.',
)
x_fc_plan_duration_months = fields.Integer(
string='Plan Duration (months)',
default=12,
help='Plan ends this many months after the sale-order date even if visits remain.',
)
x_fc_plan_category_id = fields.Many2one(
'fusion.repair.product.category',
string='Plan Category',
help='If set, plan visits only burn down for repairs on equipment of this category. '
'Leave blank to apply to any equipment from this customer.',
)
class FusionRepairServicePlanSubscription(models.Model):
_name = 'fusion.repair.service.plan.subscription'
_inherit = ['mail.thread']
_description = 'Pre-paid Service Plan Subscription'
_order = 'end_date desc, id desc'
name = fields.Char(
string='Reference', required=True, default='New',
copy=False, readonly=True, tracking=True,
)
partner_id = fields.Many2one(
'res.partner', string='Client',
required=True, tracking=True, index=True,
)
product_id = fields.Many2one(
'product.product', string='Plan Product',
required=True, tracking=True,
domain="[('x_fc_is_service_plan', '=', True)]",
)
category_id = fields.Many2one(
'fusion.repair.product.category',
string='Covers Category',
help='Computed from the plan product. Only burns visits for repairs '
'whose category matches.',
)
sale_order_id = fields.Many2one(
'sale.order', string='Sold On',
ondelete='set null', tracking=True,
)
visits_included = fields.Integer(string='Visits Included', required=True, default=4)
visits_used = fields.Integer(string='Visits Used', default=0, tracking=True)
visits_remaining = fields.Integer(
string='Remaining',
compute='_compute_visits_remaining', store=True,
)
start_date = fields.Date(
string='Start', required=True, default=fields.Date.context_today, tracking=True,
)
end_date = fields.Date(string='Expires', required=True, tracking=True)
state = fields.Selection(
[
('active', 'Active'),
('exhausted', 'Visits Exhausted'),
('expired', 'Expired'),
('cancelled', 'Cancelled'),
],
string='Status',
compute='_compute_state', store=True, tracking=True,
)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)
burn_history_ids = fields.One2many(
'fusion.repair.service.plan.burn',
'subscription_id',
string='Burn History',
)
# ------------------------------------------------------------------
# CRUD
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code(
'fusion.repair.service.plan.subscription'
) or 'PLAN/NEW'
if vals.get('product_id') and not vals.get('end_date'):
product = self.env['product.product'].sudo().browse(vals['product_id'])
months = product.product_tmpl_id.x_fc_plan_duration_months or 12
start = vals.get('start_date') or fields.Date.context_today(self)
vals['end_date'] = fields.Date.from_string(str(start)) + relativedelta(months=months)
if vals.get('product_id') and 'category_id' not in vals:
product = self.env['product.product'].sudo().browse(vals['product_id'])
if product.product_tmpl_id.x_fc_plan_category_id:
vals['category_id'] = product.product_tmpl_id.x_fc_plan_category_id.id
if vals.get('product_id') and 'visits_included' not in vals:
product = self.env['product.product'].sudo().browse(vals['product_id'])
vals['visits_included'] = product.product_tmpl_id.x_fc_plan_visits_included or 4
return super().create(vals_list)
# ------------------------------------------------------------------
# COMPUTES
# ------------------------------------------------------------------
@api.depends('visits_included', 'visits_used')
def _compute_visits_remaining(self):
for s in self:
s.visits_remaining = (s.visits_included or 0) - (s.visits_used or 0)
@api.depends('visits_remaining', 'end_date')
def _compute_state(self):
today = fields.Date.context_today(self)
for s in self:
if s.state == 'cancelled':
continue
if s.end_date and s.end_date < today:
s.state = 'expired'
elif s.visits_remaining <= 0:
s.state = 'exhausted'
else:
s.state = 'active'
# ------------------------------------------------------------------
# BURN ENGINE
# ------------------------------------------------------------------
@api.model
def find_for_repair(self, repair):
"""Return the most-recently-started active subscription covering this
repair (partner match + category match if the plan specifies one)."""
if not repair.partner_id:
return self.browse()
domain = [
('partner_id', '=', repair.partner_id.id),
('state', '=', 'active'),
('visits_remaining', '>', 0),
]
subs = self.search(domain, order='start_date desc')
for s in subs:
if not s.category_id or s.category_id == repair.x_fc_repair_category_id:
return s
return self.browse()
def burn_visit(self, repair):
"""Deduct one visit from this subscription and log the burn."""
self.ensure_one()
if self.visits_remaining <= 0:
return False
self.visits_used += 1
self.env['fusion.repair.service.plan.burn'].sudo().create({
'subscription_id': self.id,
'repair_order_id': repair.id,
'burned_on': fields.Date.context_today(self),
})
self.message_post(body=_(
'Visit burned for repair %s. %s of %s remaining.'
) % (repair.name, self.visits_remaining, self.visits_included))
return True
def action_cancel(self):
for s in self:
s.state = 'cancelled'
s.message_post(body=_('Plan cancelled.'))
class FusionRepairServicePlanBurn(models.Model):
_name = 'fusion.repair.service.plan.burn'
_description = 'Service Plan Visit Burn'
_order = 'burned_on desc, id desc'
subscription_id = fields.Many2one(
'fusion.repair.service.plan.subscription',
string='Subscription', required=True, ondelete='cascade',
)
repair_order_id = fields.Many2one(
'repair.order', string='Repair', required=True, ondelete='cascade',
)
burned_on = fields.Date(string='Burned On', required=True,
default=fields.Date.context_today)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
res = super().action_confirm()
# Spawn subscriptions for each service-plan line.
for order in self:
for line in order.order_line:
tmpl = line.product_id.product_tmpl_id
if not tmpl.x_fc_is_service_plan:
continue
# One subscription per quantity unit (so a SO line with qty=2
# creates two distinct plans - rare but supported).
qty = int(line.product_uom_qty or 1)
for _i in range(max(qty, 1)):
self.env['fusion.repair.service.plan.subscription'].sudo().create({
'partner_id': order.partner_id.id,
'product_id': line.product_id.id,
'sale_order_id': order.id,
'start_date': fields.Date.context_today(self),
})
return res