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:
@@ -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
|
||||
|
||||
209
fusion_repairs/models/maintenance_contract.py
Normal file
209
fusion_repairs/models/maintenance_contract.py
Normal 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',
|
||||
})
|
||||
Reference in New Issue
Block a user