feat(fusion_repairs): Bundle 5 - M5 pre-paid service plans + burn-down

New models
- fusion.repair.service.plan.subscription
  Tracks pre-paid maintenance packages: partner, plan product, optional
  category restriction, visits_included / visits_used / visits_remaining,
  start_date / end_date, computed state (active/exhausted/expired/cancelled),
  burn_history One2many. PLAN-NNNNN sequence.
- fusion.repair.service.plan.burn
  One row per maintenance visit that consumed a plan visit - feeds the
  Burn History tab on the subscription form.

product.template extensions
- x_fc_is_service_plan boolean toggle
- x_fc_plan_visits_included (default 4)
- x_fc_plan_duration_months (default 12)
- x_fc_plan_category_id - if set, only burns for repairs in that category
  (e.g. an Annual Stairlift Maintenance plan does not burn for wheelchair
  repairs)

sale.order.action_confirm() override
- For each order line whose product has x_fc_is_service_plan=True,
  spawns one fusion.repair.service.plan.subscription per qty unit.
- Start date = today; end date = today + plan_duration_months
  (relativedelta - correct month boundaries).

Visit report wizard
- New _burn_service_plan_visit(repair) call from action_confirm() finds
  the matching active subscription and burns one visit + posts a chatter
  note "Visit burned for repair X. N of M remaining." on the subscription.
- Skips quote-only repairs.
- The wizard does NOT zero out the invoice - the burn is informational;
  the office reconciles plan credits in their accounting workflow.

Backend
- Service Plans menu under Fusion Repairs root.
- List view colour-coded by state.
- Form with statusbar + cancel button + Burn History notebook.
- Service Plan tab added to product.template form (manager only).
- ACL: User read; Dispatcher write/create; Manager full + unlink.

Verified end-to-end on local westin-v19:
  Created plan product 'Annual Stairlift Maintenance - 4 Visits'
  Sold it via sale.order -> PLAN-00001 auto-created
  (visits_included=4, end_date=2027-05-21)
  Submitted visit-report on a stairlift repair -> visits_used=1
  remaining=3 (correctly category-matched).

Bumped to 19.0.1.5.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-05-21 00:19:28 -04:00
parent bf4464ba37
commit f463600585
8 changed files with 396 additions and 1 deletions

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
'version': '19.0.1.4.1',
'version': '19.0.1.5.0',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
@@ -84,6 +84,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'views/repair_dashboard_views.xml',
'views/repair_inspection_views.xml',
'views/repair_order_views.xml',
'views/repair_service_plan_views.xml',
'views/sale_order_views.xml',
'views/technician_task_views.xml',
'views/res_partner_views.xml',

View File

@@ -24,6 +24,17 @@
<field name="company_id" eval="False"/>
</record>
<!-- Service plan subscription reference: PLAN-NNNNN. -->
<record id="seq_repair_service_plan_subscription" model="ir.sequence">
<field name="name">Service Plan Subscription</field>
<field name="code">fusion.repair.service.plan.subscription</field>
<field name="prefix">PLAN-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
<!-- Inspection certificate reference: CERT-YYYY-NNNN, yearly reset. -->
<record id="seq_repair_inspection_certificate" model="ir.sequence">
<field name="name">Inspection Certificate</field>

View File

@@ -13,6 +13,7 @@ from . import repair_self_check_rule
from . import repair_ai_service
from . import repair_on_call_service
from . import repair_inspection
from . import repair_service_plan
from . import product_template
from . import res_partner
from . import res_users

View File

@@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""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

View File

@@ -30,3 +30,8 @@ access_repair_inspection_user,Inspection Cert User Read,model_fusion_repair_insp
access_repair_inspection_dispatcher,Inspection Cert Dispatcher,model_fusion_repair_inspection_certificate,group_fusion_repairs_dispatcher,1,1,1,0
access_repair_inspection_manager,Inspection Cert Manager Full,model_fusion_repair_inspection_certificate,group_fusion_repairs_manager,1,1,1,1
access_repair_inspection_technician,Inspection Cert Field Tech Read-Only,model_fusion_repair_inspection_certificate,fusion_tasks.group_field_technician,1,0,0,0
access_service_plan_sub_user,Service Plan Sub User Read,model_fusion_repair_service_plan_subscription,group_fusion_repairs_user,1,0,0,0
access_service_plan_sub_dispatcher,Service Plan Sub Dispatcher,model_fusion_repair_service_plan_subscription,group_fusion_repairs_dispatcher,1,1,1,0
access_service_plan_sub_manager,Service Plan Sub Manager Full,model_fusion_repair_service_plan_subscription,group_fusion_repairs_manager,1,1,1,1
access_service_plan_burn_user,Service Plan Burn User Read,model_fusion_repair_service_plan_burn,group_fusion_repairs_user,1,0,0,0
access_service_plan_burn_manager,Service Plan Burn Manager Full,model_fusion_repair_service_plan_burn,group_fusion_repairs_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
30 access_repair_inspection_dispatcher Inspection Cert Dispatcher model_fusion_repair_inspection_certificate group_fusion_repairs_dispatcher 1 1 1 0
31 access_repair_inspection_manager Inspection Cert Manager Full model_fusion_repair_inspection_certificate group_fusion_repairs_manager 1 1 1 1
32 access_repair_inspection_technician Inspection Cert Field Tech Read-Only model_fusion_repair_inspection_certificate fusion_tasks.group_field_technician 1 0 0 0
33 access_service_plan_sub_user Service Plan Sub User Read model_fusion_repair_service_plan_subscription group_fusion_repairs_user 1 0 0 0
34 access_service_plan_sub_dispatcher Service Plan Sub Dispatcher model_fusion_repair_service_plan_subscription group_fusion_repairs_dispatcher 1 1 1 0
35 access_service_plan_sub_manager Service Plan Sub Manager Full model_fusion_repair_service_plan_subscription group_fusion_repairs_manager 1 1 1 1
36 access_service_plan_burn_user Service Plan Burn User Read model_fusion_repair_service_plan_burn group_fusion_repairs_user 1 0 0 0
37 access_service_plan_burn_manager Service Plan Burn Manager Full model_fusion_repair_service_plan_burn group_fusion_repairs_manager 1 1 1 1

View File

@@ -45,6 +45,12 @@
action="action_repair_inspection"
sequence="35"/>
<menuitem id="menu_fusion_repairs_service_plans"
name="Service Plans"
parent="menu_fusion_repairs_root"
action="action_service_plan_subscription"
sequence="37"/>
<!-- Configuration -->
<menuitem id="menu_fusion_repairs_configuration"
name="Configuration"

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Service plan toggle on the product template -->
<record id="view_product_template_form_service_plan" model="ir.ui.view">
<field name="name">product.template.form.service.plan.fusion_repairs</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Service Plan" name="fusion_repairs_plan"
groups="fusion_repairs.group_fusion_repairs_manager">
<group>
<field name="x_fc_is_service_plan"/>
<field name="x_fc_plan_visits_included"
invisible="not x_fc_is_service_plan"/>
<field name="x_fc_plan_duration_months"
invisible="not x_fc_is_service_plan"/>
<field name="x_fc_plan_category_id"
invisible="not x_fc_is_service_plan"
options="{'no_create': True}"/>
</group>
</page>
</xpath>
</field>
</record>
<!-- Subscription list -->
<record id="view_service_plan_subscription_list" model="ir.ui.view">
<field name="name">fusion.repair.service.plan.subscription.list</field>
<field name="model">fusion.repair.service.plan.subscription</field>
<field name="arch" type="xml">
<list string="Service Plan Subscriptions"
decoration-success="state == 'active'"
decoration-warning="state == 'exhausted'"
decoration-muted="state == 'expired' or state == 'cancelled'">
<field name="name"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="category_id" optional="show"/>
<field name="visits_used"/>
<field name="visits_included"/>
<field name="visits_remaining"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- Subscription form -->
<record id="view_service_plan_subscription_form" model="ir.ui.view">
<field name="name">fusion.repair.service.plan.subscription.form</field>
<field name="model">fusion.repair.service.plan.subscription</field>
<field name="arch" type="xml">
<form string="Service Plan Subscription">
<header>
<button name="action_cancel" type="object" string="Cancel Plan"
invisible="state == 'cancelled'"
confirm="Cancel this service plan? Remaining visits will be forfeited."/>
<field name="state" widget="statusbar"
statusbar_visible="active,exhausted,expired,cancelled"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="partner_id" options="{'no_create': True}"/>
<field name="product_id" options="{'no_create': True}"/>
<field name="category_id" readonly="1"/>
<field name="sale_order_id" readonly="1"/>
</group>
<group>
<field name="start_date"/>
<field name="end_date"/>
<field name="visits_included"/>
<field name="visits_used" readonly="1"/>
<field name="visits_remaining" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Burn History" name="burn_history">
<field name="burn_history_ids" readonly="1">
<list>
<field name="burned_on"/>
<field name="repair_order_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_service_plan_subscription" model="ir.actions.act_window">
<field name="name">Service Plans</field>
<field name="res_model">fusion.repair.service.plan.subscription</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -187,6 +187,14 @@ class RepairVisitReportWizard(models.TransientModel):
if self.issue_inspection_cert:
self._create_inspection_certificate(repair)
# M5: burn a pre-paid service plan visit if the client has one and
# the repair is a maintenance visit. The wizard intentionally does NOT
# zero out the client's invoice line - the office still posts the
# invoice; the burn is informational + the office reconciles credits
# in their accounting flow.
if not repair.x_fc_is_quote_only:
self._burn_service_plan_visit(repair)
# If a stub was spawned, open it directly so the tech can fill in details.
# Otherwise, if a certificate was issued, jump to it so the tech can print.
if stub:
@@ -213,6 +221,14 @@ class RepairVisitReportWizard(models.TransientModel):
'res_id': repair.id,
}
def _burn_service_plan_visit(self, repair):
"""M5: deduct one visit from the most-recently-active service plan
covering this repair. Quietly no-ops if the client has no plan."""
Plan = self.env['fusion.repair.service.plan.subscription'].sudo()
sub = Plan.find_for_repair(repair)
if sub:
sub.burn_visit(repair)
def _create_inspection_certificate(self, repair):
"""M1: create the inspection certificate. Requires a safety-critical
equipment category - otherwise just logs to chatter and skips."""