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:
@@ -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
|
||||
|
||||
247
fusion_repairs/models/repair_service_plan.py
Normal file
247
fusion_repairs/models/repair_service_plan.py
Normal 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
|
||||
Reference in New Issue
Block a user