From f463600585ceea4bde711c618a680ad529add3cb Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 00:19:28 -0400 Subject: [PATCH] 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 --- fusion_repairs/__manifest__.py | 3 +- fusion_repairs/data/ir_sequence_data.xml | 11 + fusion_repairs/models/__init__.py | 1 + fusion_repairs/models/repair_service_plan.py | 247 ++++++++++++++++++ fusion_repairs/security/ir.model.access.csv | 5 + fusion_repairs/views/menus.xml | 6 + .../views/repair_service_plan_views.xml | 108 ++++++++ .../wizard/repair_visit_report_wizard.py | 16 ++ 8 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 fusion_repairs/models/repair_service_plan.py create mode 100644 fusion_repairs/views/repair_service_plan_views.xml diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index b5dae8aa..a810fa10 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -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', diff --git a/fusion_repairs/data/ir_sequence_data.xml b/fusion_repairs/data/ir_sequence_data.xml index 1d151cd6..f378e8fb 100644 --- a/fusion_repairs/data/ir_sequence_data.xml +++ b/fusion_repairs/data/ir_sequence_data.xml @@ -24,6 +24,17 @@ + + + Service Plan Subscription + fusion.repair.service.plan.subscription + PLAN- + 5 + 1 + 1 + + + Inspection Certificate diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py index 90e32bdc..05ebd5ab 100644 --- a/fusion_repairs/models/__init__.py +++ b/fusion_repairs/models/__init__.py @@ -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 diff --git a/fusion_repairs/models/repair_service_plan.py b/fusion_repairs/models/repair_service_plan.py new file mode 100644 index 00000000..ef10c4bb --- /dev/null +++ b/fusion_repairs/models/repair_service_plan.py @@ -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 diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv index 33381e5b..ad3c9531 100644 --- a/fusion_repairs/security/ir.model.access.csv +++ b/fusion_repairs/security/ir.model.access.csv @@ -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 diff --git a/fusion_repairs/views/menus.xml b/fusion_repairs/views/menus.xml index 47456ebb..e4f02d84 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -45,6 +45,12 @@ action="action_repair_inspection" sequence="35"/> + + + + + + + product.template.form.service.plan.fusion_repairs + product.template + + + + + + + + + + + + + + + + + + fusion.repair.service.plan.subscription.list + fusion.repair.service.plan.subscription + + + + + + + + + + + + + + + + + + + fusion.repair.service.plan.subscription.form + fusion.repair.service.plan.subscription + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + Service Plans + fusion.repair.service.plan.subscription + list,form + + +
diff --git a/fusion_repairs/wizard/repair_visit_report_wizard.py b/fusion_repairs/wizard/repair_visit_report_wizard.py index 0301f866..0ba3e2a7 100644 --- a/fusion_repairs/wizard/repair_visit_report_wizard.py +++ b/fusion_repairs/wizard/repair_visit_report_wizard.py @@ -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."""