feat(fusion_repairs): Phase 3 - maintenance contracts + client self-booking

Maintenance contracts
- New fusion.repair.maintenance.contract model: one per partner +
  product + lot. Fields: interval_months, last_service_date,
  next_due_date, state, booking_token (secrets.token_urlsafe),
  last_reminder_band (30 / 7 / 1), booking_repair_id
- roll_next_due_date() advances the cycle by interval_months and resets
  the band / booked-repair so the next cycle starts fresh
- sale.order._spawn_maintenance_contracts() creates contracts for
  delivered SOs whose product has x_fc_maintenance_interval_months > 0
  (called from Phase 3 hooks; ready for cron / on-state change wiring)

Reminder cron
- Daily ir.cron at 07:00 -> cron_send_due_reminders()
- Sends email at 30 / 7 / 1 day bands before next_due_date; tracks
  last_reminder_band so we never re-send the same band in one cycle
- Master toggle via ir.config_parameter fusion_repairs.enable_email_notifications

Public client booking portal
- /repairs/maintenance/book/<token>  GET landing page with a date input
- /repairs/maintenance/book/<token>/confirm  POST creates a repair.order
  via contract.create_repair_from_booking() (source='client_portal')
- Idempotent: existing booking shows "already booked" instead of
  spawning a duplicate
- Invalid / expired tokens render a friendly "link not valid" page

Mail template
- email_template_maintenance_due_reminder with 4px green accent bar,
  600px max-width, dark/light safe; renders the tokenized booking CTA
  button directly to /repairs/maintenance/book/<token>

Backend
- Maintenance Contracts list / form with statusbar + chatter
- Menu under Operations -> Maintenance Contracts
- Sequence MC/##### for contract reference
- Access rules: User read, Dispatcher write, Manager full

Verified end-to-end on local westin-v19:
- Contract MC/00003 created due in 7 days
- cron_send_due_reminders() fires the 7-day band; second invocation
  skips (idempotent)
- create_repair_from_booking() spawns BR-WA/RO/00014 with
  x_fc_intake_source='client_portal' and links it back to the contract
- HTTP GET /repairs/maintenance/book/<token> -> 200 with the date input
  and contract reference visible in the page

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-05-20 22:01:30 -04:00
parent 7727745b73
commit 73ee48e7c9
12 changed files with 544 additions and 0 deletions

View File

@@ -69,6 +69,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
# Data (must load before views that reference records)
'data/ir_sequence_data.xml',
'data/ir_config_parameter_data.xml',
'data/ir_cron_data.xml',
'data/mail_activity_type_data.xml',
'data/mail_template_data.xml',
'data/repair_product_category_data.xml',
@@ -78,6 +79,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
'views/intake_template_views.xml',
'views/service_catalog_views.xml',
'views/repair_warranty_views.xml',
'views/maintenance_contract_views.xml',
'views/repair_order_views.xml',
'views/res_partner_views.xml',
'views/res_users_views.xml',
@@ -85,6 +87,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
# Portal templates
'views/portal_sales_rep_templates.xml',
'views/portal_client_repair_templates.xml',
'views/portal_maintenance_templates.xml',
# Wizards
'wizard/repair_intake_wizard_views.xml',
'wizard/repair_visit_report_wizard_views.xml',

View File

@@ -4,3 +4,4 @@
from . import portal_sales_rep_repair
from . import portal_client_repair
from . import portal_maintenance_booking

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Client maintenance booking portal.
The maintenance reminder email contains a tokenized URL:
/repairs/maintenance/book/<token>
Clicking it lands the client on a single-page form where they can confirm
a preferred date. On submit, a repair.order is spawned via the same
intake service (source='client_portal') and the contract's next reminder
band is locked so we don't keep nagging them.
"""
import logging
from odoo import _, fields, http
from odoo.http import request
_logger = logging.getLogger(__name__)
class MaintenanceBookingPortal(http.Controller):
def _resolve_contract(self, token):
if not token:
return None
Contract = request.env['fusion.repair.maintenance.contract'].sudo()
contract = Contract.search([('booking_token', '=', token)], limit=1)
if not contract or contract.state != 'active':
return None
return contract
@http.route('/repairs/maintenance/book/<string:token>', type='http',
auth='public', website=True, sitemap=False)
def maintenance_book_get(self, token, **kw):
contract = self._resolve_contract(token)
if not contract:
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
already = bool(contract.booking_repair_id)
return request.render('fusion_repairs.portal_maintenance_book', {
'contract': contract,
'already_booked': already,
'default_date': fields.Date.context_today(request.env.user).isoformat(),
})
@http.route('/repairs/maintenance/book/<string:token>/confirm', type='http',
auth='public', methods=['POST'], csrf=True, website=True)
def maintenance_book_post(self, token, **post):
contract = self._resolve_contract(token)
if not contract:
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
if contract.booking_repair_id:
return request.redirect(f'/repairs/maintenance/book/{token}?ok=already')
preferred_date = (post.get('preferred_date') or '').strip()
scheduled = False
if preferred_date:
try:
scheduled = fields.Date.from_string(preferred_date)
except ValueError:
scheduled = False
repair = contract.create_repair_from_booking(scheduled_date=scheduled)
return request.render('fusion_repairs.portal_maintenance_thanks', {
'contract': contract,
'repair': repair,
})

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Daily maintenance reminders at 30/7/1 days before due date. -->
<record id="cron_maintenance_due_reminders" model="ir.cron">
<field name="name">Fusion Repairs: Send maintenance due reminders</field>
<field name="model_id" ref="model_fusion_repair_maintenance_contract"/>
<field name="state">code</field>
<field name="code">model.cron_send_due_reminders()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(hours=1)).strftime('%Y-%m-%d 07:00:00')"/>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -12,5 +12,16 @@
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
<!-- Maintenance contract reference -->
<record id="seq_repair_maintenance_contract" model="ir.sequence">
<field name="name">Repair Maintenance Contract</field>
<field name="code">fusion.repair.maintenance.contract</field>
<field name="prefix">MC/</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>
</data>
</odoo>

View File

@@ -55,6 +55,52 @@
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Maintenance Due Reminder -->
<!-- ============================================================== -->
<record id="email_template_maintenance_due_reminder" model="mail.template">
<field name="name">Repair: Maintenance Due Reminder</field>
<field name="model_id" ref="model_fusion_repair_maintenance_contract"/>
<field name="subject">{{ object.company_id.name }} - Time to schedule your equipment maintenance</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#38a169;"></div>
<div style="padding:32px 28px;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Your equipment is due for maintenance</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Hello <t t-out="object.partner_id.name or 'there'"/>, your
<strong><t t-out="object.product_id.display_name or 'equipment'"/></strong>
is due for its next scheduled maintenance visit on
<strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>.
</p>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-attf-href="/repairs/maintenance/book/{{ object.booking_token }}"
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
Book my maintenance visit
</a>
</div>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
Regular maintenance keeps your equipment safe and reliable. Use the
button above to confirm and we will reach out to schedule a time that works for you.
</p>
</div>
<p style="opacity:0.55;font-size:12px;margin:0;">
Contract reference <strong><t t-out="object.name"/></strong>.
If you no longer have this equipment, you can ignore this email.
</p>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Repair Intake Received - Office Notification -->
<!-- ============================================================== -->

View File

@@ -8,6 +8,7 @@ from . import intake_question
from . import intake_answer
from . import service_catalog
from . import repair_warranty
from . import maintenance_contract
from . import product_template
from . import res_partner
from . import res_users

View File

@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Maintenance contracts.
One contract per sold unit (partner + product + lot). When the underlying
sale order is delivered and the product has x_fc_maintenance_interval_months>0,
a contract is auto-created. A daily cron walks active contracts and sends
the client a reminder email at 30, 7, and 1 days before next_due_date with
a tokenized booking link.
"""
import secrets
from datetime import timedelta
from odoo import _, api, fields, models
CONTRACT_STATES = [
('draft', 'Draft'),
('active', 'Active'),
('paused', 'Paused'),
('cancelled', 'Cancelled'),
]
class FusionRepairMaintenanceContract(models.Model):
_name = 'fusion.repair.maintenance.contract'
_description = 'Repair Maintenance Contract'
_order = 'next_due_date, id'
name = fields.Char(string='Reference', required=True, default='New',
copy=False, readonly=True)
partner_id = fields.Many2one(
'res.partner',
string='Client',
required=True,
index=True,
ondelete='restrict',
)
product_id = fields.Many2one(
'product.product',
string='Equipment',
required=True,
index=True,
)
lot_id = fields.Many2one('stock.lot', string='Serial Number')
original_sale_order_id = fields.Many2one(
'sale.order',
string='Original Sale Order',
index=True,
)
interval_months = fields.Integer(string='Interval (months)', default=12, required=True)
last_service_date = fields.Date(string='Last Service')
next_due_date = fields.Date(string='Next Due', required=True, index=True)
state = fields.Selection(CONTRACT_STATES, default='active', required=True,
tracking=True, index=True)
booking_token = fields.Char(string='Booking Token', copy=False, index=True)
last_reminder_band = fields.Selection(
[('30', '30 days'), ('7', '7 days'), ('1', '1 day')],
string='Last Reminder Sent',
copy=False,
help='The most recent reminder band sent for the current cycle.',
)
booking_repair_id = fields.Many2one(
'repair.order',
string='Booked Repair',
copy=False,
help='The repair.order created when the client used the booking link for this cycle.',
)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company,
)
_inherit = ['mail.thread']
@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.maintenance.contract'
) or 'MC/NEW'
if not vals.get('booking_token'):
vals['booking_token'] = secrets.token_urlsafe(20)
return super().create(vals_list)
# ------------------------------------------------------------------
# ROLL FORWARD
# ------------------------------------------------------------------
def roll_next_due_date(self):
"""Advance next_due_date by interval_months and reset cycle state."""
for c in self:
base = c.last_service_date or fields.Date.context_today(c)
c.next_due_date = base + timedelta(days=(c.interval_months or 12) * 30)
c.last_reminder_band = False
c.booking_repair_id = False
# ------------------------------------------------------------------
# REMINDER CRON
# ------------------------------------------------------------------
@api.model
def cron_send_due_reminders(self):
"""Daily cron - send reminder emails at 30/7/1 days before next_due_date."""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_repairs.enable_email_notifications', 'True') != 'True':
return
today = fields.Date.context_today(self)
bands = [
('30', 30),
('7', 7),
('1', 1),
]
tpl = self.env.ref(
'fusion_repairs.email_template_maintenance_due_reminder',
raise_if_not_found=False,
)
if not tpl:
return
for band_label, days in bands:
target = today + timedelta(days=days)
domain = [
('state', '=', 'active'),
('next_due_date', '=', target),
('partner_id.email', '!=', False),
]
# Don't re-send a smaller band if we already sent a larger one
# for the same cycle - the band order is 30 -> 7 -> 1.
contracts = self.search(domain)
for c in contracts:
if c.last_reminder_band == band_label:
continue
try:
tpl.send_mail(c.id, force_send=False)
c.last_reminder_band = band_label
c.message_post(
body=_('Sent %(band)s-day maintenance reminder to %(email)s.',
band=band_label,
email=c.partner_id.email),
)
except Exception:
continue
# ------------------------------------------------------------------
# PORTAL BOOKING
# ------------------------------------------------------------------
def create_repair_from_booking(self, scheduled_date=None):
"""Spawn a repair.order from the booking link (or any manual booking)."""
self.ensure_one()
if self.booking_repair_id and self.booking_repair_id.state != 'cancel':
return self.booking_repair_id
Repair = self.env['repair.order'].sudo()
repair = Repair.create({
'partner_id': self.partner_id.id,
'product_id': self.product_id.id,
'lot_id': self.lot_id.id if self.lot_id else False,
'schedule_date': scheduled_date or fields.Datetime.now(),
'x_fc_intake_source': 'client_portal',
'x_fc_urgency': 'normal',
'x_fc_repair_category_id': self.product_id.product_tmpl_id.x_fc_repair_category_id.id
if self.product_id.product_tmpl_id.x_fc_repair_category_id else False,
'internal_notes':
f'<p>Maintenance visit booked from reminder for contract <b>{self.name}</b>.</p>',
})
self.booking_repair_id = repair
self.message_post(
body=_('Maintenance visit <b>%(ref)s</b> booked from reminder link.',
ref=repair.name),
)
return repair
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _spawn_maintenance_contracts(self):
"""Create maintenance contracts for any delivered SO line whose
product has x_fc_maintenance_interval_months > 0."""
Contract = self.env['fusion.repair.maintenance.contract'].sudo()
today = fields.Date.context_today(self)
for so in self:
if so.state not in ('sale', 'done'):
continue
for line in so.order_line:
product = line.product_id
if not product:
continue
interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0
if interval <= 0:
continue
existing = Contract.search([
('partner_id', '=', so.partner_id.id),
('product_id', '=', product.id),
('original_sale_order_id', '=', so.id),
], limit=1)
if existing:
continue
Contract.create({
'partner_id': so.partner_id.id,
'product_id': product.id,
'original_sale_order_id': so.id,
'interval_months': interval,
'next_due_date': today + timedelta(days=interval * 30),
'state': 'active',
})

View File

@@ -16,3 +16,6 @@ access_repair_warranty_user,Warranty User Read,model_fusion_repair_warranty_cove
access_repair_warranty_manager,Warranty Manager Full,model_fusion_repair_warranty_coverage,group_fusion_repairs_manager,1,1,1,1
access_repair_visit_report_wizard_user,Visit Report Wizard User,model_fusion_repair_visit_report_wizard,group_fusion_repairs_user,1,1,1,1
access_repair_visit_report_wizard_line_user,Visit Report Line User,model_fusion_repair_visit_report_wizard_line,group_fusion_repairs_user,1,1,1,1
access_repair_maintenance_user,Maintenance Contract User Read,model_fusion_repair_maintenance_contract,group_fusion_repairs_user,1,0,0,0
access_repair_maintenance_dispatcher,Maintenance Contract Dispatcher,model_fusion_repair_maintenance_contract,group_fusion_repairs_dispatcher,1,1,1,0
access_repair_maintenance_manager,Maintenance Contract Manager Full,model_fusion_repair_maintenance_contract,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
16 access_repair_warranty_manager Warranty Manager Full model_fusion_repair_warranty_coverage group_fusion_repairs_manager 1 1 1 1
17 access_repair_visit_report_wizard_user Visit Report Wizard User model_fusion_repair_visit_report_wizard group_fusion_repairs_user 1 1 1 1
18 access_repair_visit_report_wizard_line_user Visit Report Line User model_fusion_repair_visit_report_wizard_line group_fusion_repairs_user 1 1 1 1
19 access_repair_maintenance_user Maintenance Contract User Read model_fusion_repair_maintenance_contract group_fusion_repairs_user 1 0 0 0
20 access_repair_maintenance_dispatcher Maintenance Contract Dispatcher model_fusion_repair_maintenance_contract group_fusion_repairs_dispatcher 1 1 1 0
21 access_repair_maintenance_manager Maintenance Contract Manager Full model_fusion_repair_maintenance_contract group_fusion_repairs_manager 1 1 1 1

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_maintenance_contract_list" model="ir.ui.view">
<field name="name">fusion.repair.maintenance.contract.list</field>
<field name="model">fusion.repair.maintenance.contract</field>
<field name="arch" type="xml">
<list string="Maintenance Contracts" decoration-warning="next_due_date and next_due_date &lt;= context_today().strftime('%Y-%m-%d')">
<field name="name"/>
<field name="partner_id"/>
<field name="product_id"/>
<field name="interval_months"/>
<field name="last_service_date"/>
<field name="next_due_date"/>
<field name="last_reminder_band" optional="show"/>
<field name="state" widget="badge"
decoration-success="state == 'active'"
decoration-muted="state == 'cancelled'"
decoration-warning="state == 'paused'"/>
</list>
</field>
</record>
<record id="view_maintenance_contract_form" model="ir.ui.view">
<field name="name">fusion.repair.maintenance.contract.form</field>
<field name="model">fusion.repair.maintenance.contract</field>
<field name="arch" type="xml">
<form string="Maintenance Contract">
<header>
<field name="state" widget="statusbar"
statusbar_visible="draft,active,paused,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"/>
<field name="lot_id"/>
<field name="original_sale_order_id" readonly="1"/>
</group>
<group>
<field name="interval_months"/>
<field name="last_service_date"/>
<field name="next_due_date"/>
<field name="last_reminder_band" readonly="1"/>
<field name="booking_repair_id" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_maintenance_contract" model="ir.actions.act_window">
<field name="name">Maintenance Contracts</field>
<field name="res_model">fusion.repair.maintenance.contract</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -26,6 +26,12 @@
action="repair.action_repair_order_tree"
sequence="20"/>
<menuitem id="menu_fusion_repairs_maintenance_contracts"
name="Maintenance Contracts"
parent="menu_fusion_repairs_operations"
action="action_maintenance_contract"
sequence="30"/>
<!-- Configuration -->
<menuitem id="menu_fusion_repairs_configuration"
name="Configuration"

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================== -->
<!-- Booking landing page -->
<!-- ============================================================== -->
<template id="portal_maintenance_book" name="Maintenance - Book Visit">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-7">
<h1 class="mb-3">Book your maintenance visit</h1>
<p class="lead text-muted">
Hello <strong t-out="contract.partner_id.name"/>!
Your <strong t-out="contract.product_id.display_name"/> is due for service.
</p>
<t t-if="already_booked">
<div class="alert alert-info">
<strong>Already booked.</strong>
Your maintenance visit reference is
<strong t-out="contract.booking_repair_id.name"/>. We will be in touch shortly.
</div>
</t>
<t t-if="not already_booked">
<form t-attf-action="/repairs/maintenance/book/{{ contract.booking_token }}/confirm"
method="POST" class="card shadow-sm">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<div class="card-body p-4">
<div class="mb-3">
<label class="form-label">Preferred date</label>
<input type="date" name="preferred_date"
class="form-control form-control-lg"
t-att-value="default_date"/>
<small class="text-muted">
A team member will call to confirm the exact time.
</small>
</div>
<p class="small text-muted mb-0">
By submitting, you confirm you want this maintenance visit.
Contract reference <strong t-out="contract.name"/>.
</p>
</div>
<div class="card-footer text-end">
<button type="submit" class="btn btn-success btn-lg">
Yes, book my visit
</button>
</div>
</form>
</t>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ============================================================== -->
<!-- Thanks -->
<!-- ============================================================== -->
<template id="portal_maintenance_thanks" name="Maintenance - Booked">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center text-center">
<div class="col-12 col-lg-7">
<i class="fa fa-calendar-check fa-4x text-success mb-3"/>
<h1>Booking received</h1>
<p class="lead text-muted">
Your maintenance visit <strong t-out="repair.name"/> has been scheduled
and our office will reach out to confirm the exact time.
</p>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ============================================================== -->
<!-- Invalid token -->
<!-- ============================================================== -->
<template id="portal_maintenance_invalid_token" name="Maintenance - Invalid Link">
<t t-call="website.layout">
<div id="wrap" class="o_fusion_repairs_client">
<section class="container py-5">
<div class="row justify-content-center text-center">
<div class="col-12 col-lg-7">
<i class="fa fa-exclamation-triangle fa-3x text-warning mb-3"/>
<h1>Link not valid</h1>
<p class="lead text-muted">
This booking link is no longer valid or has been used. Please call our
office directly to schedule your maintenance visit.
</p>
<a href="/repair" class="btn btn-outline-secondary">Submit a service request</a>
</div>
</div>
</section>
</div>
</t>
</template>
</odoo>