feat(fusion_portal): ADP/express->visit wiring, visit entry tile, email consolidation (live on westin 19.0.2.10.0)

- express save captures visit_id; visit-linked submit defers SO creation
  (saves draft + signature) and returns to the visit for grouping.
- portal dashboard 'Start a Visit' tile for sales reps.
- fix duplicate-authorizer completion email; visit grouped SOs email once per SO.
- define visit._assessment_sale_type (ADP grouping key) - fixes AttributeError.

Verified on a westin-v19 clone (load + ADP-grouping + combination-guard smoke
test, mail neutralised) then deployed to westin prod 19.0.2.10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-02 08:50:25 -04:00
parent 7fcf38ca82
commit 9a8e1d7ab5
6 changed files with 99 additions and 17 deletions

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'Fusion Authorizer & Sales Portal', 'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.9.0', 'version': '19.0.2.10.0',
'category': 'Sales/Portal', 'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """ 'description': """

View File

@@ -458,6 +458,7 @@ class AssessmentPortal(CustomerPortal):
'current_page': 1, 'current_page': 1,
'total_pages': 2, 'total_pages': 2,
'assessment': None, 'assessment': None,
'visit_id': kw.get('visit_id', ''),
'google_maps_api_key': google_maps_api_key, 'google_maps_api_key': google_maps_api_key,
} }
@@ -516,6 +517,7 @@ class AssessmentPortal(CustomerPortal):
'partner': partner, 'partner': partner,
'user': user, 'user': user,
'assessment': assessment, 'assessment': assessment,
'visit_id': kw.get('visit_id') or (assessment.visit_id.id if assessment.visit_id else ''),
'authorizers': authorizers, 'authorizers': authorizers,
'authorizers_json': authorizers_json, 'authorizers_json': authorizers_json,
'clients': clients, 'clients': clients,
@@ -630,6 +632,30 @@ class AssessmentPortal(CustomerPortal):
except Exception as e: except Exception as e:
_logger.error(f"Error saving Page 11 signature: {e}") _logger.error(f"Error saving Page 11 signature: {e}")
# ===== Visit-linked: defer SO creation to visit completion =====
# Started from a visit workspace: do NOT complete into a standalone
# sale order. Leave it as a draft linked to the visit so
# visit.action_complete_visit() groups the visit's ADP devices
# (combination-checked) into ONE ADP order. The Page 11 signature is
# already saved above; pre-generate its PDF so it is ready.
if assessment.visit_id and action == 'submit':
if assessment.signature_page_11 and assessment.consent_declaration_accepted:
try:
pdf_bytes = assessment.generate_template_pdf('Page 11')
if pdf_bytes:
import base64 as b64
assessment.write({
'signed_page_11_pdf': b64.b64encode(pdf_bytes),
'signed_page_11_pdf_filename': f'ADP_Page11_{assessment.reference}.pdf',
})
except Exception as pdf_e:
_logger.warning(f"Visit-linked Page 11 PDF generation failed (non-blocking): {pdf_e}")
_logger.info(
f"Express assessment {assessment.reference} saved to visit "
f"{assessment.visit_id.name} (completion deferred to visit)"
)
return request.redirect(f'/my/visit/{assessment.visit_id.id}')
# Handle navigation # Handle navigation
if action == 'submit': if action == 'submit':
# If already completed, we just saved consent/signature above -- redirect with success # If already completed, we just saved consent/signature above -- redirect with success
@@ -803,6 +829,13 @@ class AssessmentPortal(CustomerPortal):
def _build_express_assessment_vals(self, kw): def _build_express_assessment_vals(self, kw):
"""Build values dict from express form POST data""" """Build values dict from express form POST data"""
vals = {} vals = {}
# Visit linkage (assessment started from a visit workspace)
if kw.get('visit_id'):
try:
vals['visit_id'] = int(kw.get('visit_id'))
except (ValueError, TypeError):
pass
# Equipment type # Equipment type
if kw.get('equipment_type'): if kw.get('equipment_type'):

View File

@@ -1486,20 +1486,18 @@ class FusionAssessment(models.Model):
}) })
def _send_completion_notifications(self): def _send_completion_notifications(self):
"""Send email notifications when assessment is completed""" """Notify the CLIENT that the assessment is complete.
The authorizer, sales rep and office are already emailed (with the full
assessment report) by ``_send_assessment_completed_email`` inside
``_create_draft_sale_order``. This method used to ALSO send the
authorizer a second, template-only email — that duplicate is removed;
here we only notify the client.
"""
self.ensure_one() self.ensure_one()
# Send to authorizer # Send to client (authorizer/rep/office already emailed by
if self.authorizer_id and self.authorizer_id.email: # _send_assessment_completed_email in _create_draft_sale_order)
try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_authorizer', raise_if_not_found=False)
if template:
template.send_mail(self.id, force_send=True)
_logger.info(f"Sent assessment completion email to authorizer {self.authorizer_id.email}")
except Exception as e:
_logger.error(f"Failed to send authorizer notification: {e}")
# Send to client
if self.client_email: if self.client_email:
try: try:
template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False) template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False)

View File

@@ -184,6 +184,16 @@ class FusionAssessmentVisit(models.Model):
subtype_xmlid='mail.mt_note', subtype_xmlid='mail.mt_note',
) )
assessment.write({'state': 'completed'}) assessment.write({'state': 'completed'})
# One completion notification per SO (not per assessment) — mirrors the
# standalone accessibility completion's office email.
if accessibility_assessments:
try:
accessibility_assessments[0]._send_completion_email(sale_order)
except Exception as e:
_logger.warning(
"Visit %s: completion email failed for %s: %s",
self.name, sale_order.name, e,
)
_logger.info( _logger.info(
"Visit %s created %s sale order %s grouping %d accessibility assessment(s)", "Visit %s created %s sale order %s grouping %d accessibility assessment(s)",
self.name, sale_type, sale_order.name, len(accessibility_assessments), self.name, sale_type, sale_order.name, len(accessibility_assessments),
@@ -206,6 +216,15 @@ class FusionAssessmentVisit(models.Model):
if len(walkers) > 1: if len(walkers) > 1:
raise UserError(_('An ADP order can include only one walker / rollator.')) raise UserError(_('An ADP order can include only one walker / rollator.'))
def _assessment_sale_type(self, adp_assessment):
"""Funding workflow key for an ADP equipment assessment, mirroring
fusion.assessment._create_draft_sale_order: ADP+ODSP when the client
type is an ODSP stream, plain ADP otherwise. ADP devices that share a
key are grouped onto one sale order."""
if adp_assessment.client_type in ('ods', 'acs', 'owp'):
return 'adp_odsp'
return 'adp'
def action_complete_visit(self): def action_complete_visit(self):
"""Group the visit's accessibility assessments by funding workflow and """Group the visit's accessibility assessments by funding workflow and
create one draft SO per workflow. ADP equipment assessments keep their create one draft SO per workflow. ADP equipment assessments keep their

View File

@@ -85,7 +85,19 @@
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="assessment_id" t-att-value="assessment.id if assessment else ''"/> <input type="hidden" name="assessment_id" t-att-value="assessment.id if assessment else ''"/>
<input type="hidden" name="current_page" value="1"/> <input type="hidden" name="current_page" value="1"/>
<input type="hidden" name="visit_id" id="express_visit_id" t-att-value="visit_id or ''"/>
<!-- Part of an assessment visit: completing returns to the visit, which groups
this device with the rest into one funding-routed ADP order. -->
<div t-if="visit_id" class="alert alert-info d-flex align-items-start gap-2 mb-3">
<i class="fa fa-clipboard mt-1"/>
<div>
<strong>Part of an assessment visit.</strong>
Completing this device returns you to the visit — it is grouped with the
visit's other ADP devices into a single sale order when you complete the visit.
</div>
</div>
<!-- Equipment Selection Section --> <!-- Equipment Selection Section -->
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
@@ -1246,9 +1258,10 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body text-center"> <div class="card-body text-center">
<button type="submit" name="action" value="submit" class="btn btn-success btn-lg px-5"> <button type="submit" name="action" value="submit" class="btn btn-success btn-lg px-5">
<i class="fa fa-check me-2"/>Submit Assessment <t t-if="visit_id"><i class="fa fa-clipboard me-2"/>Save to Visit</t>
<t t-else=""><i class="fa fa-check me-2"/>Submit Assessment</t>
</button> </button>
<a href="/my/assessments" class="btn btn-outline-secondary btn-lg px-4 ms-3"> <a t-att-href="('/my/visit/%s' % visit_id) if visit_id else '/my/assessments'" class="btn btn-outline-secondary btn-lg px-4 ms-3">
Cancel Cancel
</a> </a>
</div> </div>

View File

@@ -50,6 +50,25 @@
<!-- Main Action Tiles - Professional Grid Layout --> <!-- Main Action Tiles - Professional Grid Layout -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<!-- 0. Start a Visit (Sales Rep) - bundle multiple assessments from one home visit -->
<t t-if="request.env.user.partner_id.is_sales_rep_portal">
<div class="col-md-6">
<a href="/my/visit/new" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px; background: linear-gradient(135deg, #5ba848 0%, #3a8fb7 100%);">
<div class="card-body d-flex align-items-center p-4 text-white">
<div class="me-3">
<div class="icon-circle">
<i class="fa fa-clipboard"/>
</div>
</div>
<div>
<h5 class="mb-1">Start a Visit</h5>
<small>Bundle several assessments from one home visit into funding-routed orders</small>
</div>
</div>
</a>
</div>
</t>
<!-- 1. New Express Assessment (Sales Rep) - Featured tile --> <!-- 1. New Express Assessment (Sales Rep) - Featured tile -->
<t t-if="request.env.user.partner_id.is_sales_rep_portal"> <t t-if="request.env.user.partner_id.is_sales_rep_portal">
<div class="col-md-6"> <div class="col-md-6">