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:
@@ -4,3 +4,4 @@
|
||||
|
||||
from . import portal_sales_rep_repair
|
||||
from . import portal_client_repair
|
||||
from . import portal_maintenance_booking
|
||||
|
||||
70
fusion_repairs/controllers/portal_maintenance_booking.py
Normal file
70
fusion_repairs/controllers/portal_maintenance_booking.py
Normal 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,
|
||||
})
|
||||
Reference in New Issue
Block a user