# -*- 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), }) # Bundle 9: spawn store labor warranties for any product line with # x_fc_labor_warranty_years > 0. self._fc_spawn_labor_warranties() return res def _fc_spawn_labor_warranties(self): Warranty = self.env['fusion.repair.labor.warranty'].sudo() for order in self: for line in order.order_line: tmpl = line.product_id.product_tmpl_id years = tmpl.x_fc_labor_warranty_years or 0 if years <= 0: continue # One warranty record per unit so each can be voided # independently if a specific unit is misused. qty = int(line.product_uom_qty or 1) for _i in range(max(qty, 1)): Warranty.create({ 'partner_id': order.partner_id.id, 'product_id': line.product_id.id, 'sale_order_id': order.id, 'warranty_years': years, 'start_date': fields.Date.context_today(self), })