Compare commits

..

2 Commits

Author SHA1 Message Date
gsinghpal
21cfd55419 feat(fusion_portal): Phase 3 - assessment visit workspace (accessibility path)
Adds the portal workspace: /my/visit/new starts a visit; /my/visit/<id> shows the
add-as-you-go workspace (add buttons -> existing forms carrying ?visit_id, a
deferred client+funding form, and a Complete button). Accessibility forms launched
from a visit save as a DRAFT linked to it (JS carries visit_id into the form; the
controller captures it and skips the per-assessment SO) - the VISIT completion then
creates the grouped per-funding sale orders.

NOT YET: express/ADP form visit-linking, email consolidation, polished tablet UI.
Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:37:08 -04:00
gsinghpal
89467432a7 feat(fusion_portal): Phase 2b - ADP multi-device grouping + combination guard
The visit groups its ADP assessments by funding type onto ONE ADP order (first
device creates the SO via the existing express completion; the rest attach),
enforcing the combination rule: at most one seated-mobility device (manual WC /
power WC / scooter) + optionally one walker, no duplicates. Also fixes a Phase 1b
bug - it called action_complete() (needs signatures, returns an action dict) for
ADP; now uses action_complete_express() which returns the SO.

Untested locally (Enterprise dep) - clone verification pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 02:31:38 -04:00
5 changed files with 237 additions and 8 deletions

View File

@@ -81,6 +81,7 @@ This module provides external portal access for:
'views/portal_technician_templates.xml', 'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml', 'views/portal_book_assessment.xml',
'views/portal_page11_sign_templates.xml', 'views/portal_page11_sign_templates.xml',
'views/portal_visit.xml',
], ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [

View File

@@ -2479,6 +2479,56 @@ class AuthorizerPortal(CustomerPortal):
template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector') template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector')
return request.render(template, values) return request.render(template, values)
# ==========================================================================
# ASSESSMENT VISIT WORKSPACE (Phase 1b/3)
# ==========================================================================
@http.route('/my/visit/new', type='http', auth='user', website=True)
def visit_new(self, **kw):
"""Start a new assessment visit and open its workspace."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return request.redirect('/my')
visit = request.env['fusion.assessment.visit'].sudo().create({
'sales_rep_id': request.env.user.id,
})
return request.redirect('/my/visit/%s' % visit.id)
@http.route('/my/visit/<int:visit_id>', type='http', auth='user', website=True)
def visit_workspace(self, visit_id, **kw):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
return request.render('fusion_portal.portal_visit_workspace', {
'visit': visit,
'page_name': 'visit',
'error': kw.get('error'),
})
@http.route('/my/visit/<int:visit_id>/save', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_save_client(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if visit.exists():
visit.write({
'client_name': (post.get('client_name') or '').strip(),
'client_phone': (post.get('client_phone') or '').strip(),
'client_email': (post.get('client_email') or '').strip(),
'client_address': (post.get('client_address') or '').strip(),
'x_fc_income_under_mod_threshold': post.get('x_fc_income_under_mod_threshold') or 'unknown',
})
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/visit/<int:visit_id>/complete', type='http', auth='user', methods=['POST'], website=True, csrf=True)
def visit_complete(self, visit_id, **post):
visit = request.env['fusion.assessment.visit'].sudo().browse(visit_id)
if not visit.exists():
return request.redirect('/my')
try:
visit.action_complete_visit()
except Exception as e:
_logger.warning("Visit %s completion failed: %s", visit_id, e)
return request.redirect('/my/visit/%s?error=%s' % (visit_id, str(e)))
return request.redirect('/my/visit/%s' % visit_id)
@http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True) @http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True)
def accessibility_assessment_save(self, **post): def accessibility_assessment_save(self, **post):
"""Save an accessibility assessment and optionally create a Sale Order""" """Save an accessibility assessment and optionally create a Sale Order"""
@@ -2547,6 +2597,13 @@ class AuthorizerPortal(CustomerPortal):
if partner.is_authorizer: if partner.is_authorizer:
vals['authorizer_id'] = partner.id vals['authorizer_id'] = partner.id
# Link to a visit if this form was launched from the workspace.
if post.get('visit_id'):
try:
vals['visit_id'] = int(post.get('visit_id'))
except (TypeError, ValueError):
pass
# Create the assessment # Create the assessment
assessment = Assessment.create(vals) assessment = Assessment.create(vals)
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}") _logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
@@ -2572,8 +2629,12 @@ class AuthorizerPortal(CustomerPortal):
if video_data: if video_data:
self._attach_accessibility_video(assessment, video_data, video_filename) self._attach_accessibility_video(assessment, video_data, video_filename)
# Complete the assessment and create Sale Order if requested # Complete the assessment and create Sale Order if requested.
# When launched from a visit, always save as a draft linked to the
# visit — the VISIT completion creates the grouped sale order(s).
create_sale_order = post.get('create_sale_order', True) create_sale_order = post.get('create_sale_order', True)
if vals.get('visit_id'):
create_sale_order = False
if create_sale_order: if create_sale_order:
sale_order = assessment.action_complete() sale_order = assessment.action_complete()
return { return {
@@ -2586,12 +2647,13 @@ class AuthorizerPortal(CustomerPortal):
'redirect_url': f'/my/sales/case/{sale_order.id}', 'redirect_url': f'/my/sales/case/{sale_order.id}',
} }
else: else:
redirect_url = ('/my/visit/%s' % vals['visit_id']) if vals.get('visit_id') else '/my/accessibility/list'
return { return {
'success': True, 'success': True,
'assessment_id': assessment.id, 'assessment_id': assessment.id,
'assessment_ref': assessment.reference, 'assessment_ref': assessment.reference,
'message': f'Assessment {assessment.reference} saved as draft.', 'message': f'Assessment {assessment.reference} saved.',
'redirect_url': '/my/accessibility/list', 'redirect_url': redirect_url,
} }
except Exception as e: except Exception as e:

View File

@@ -190,6 +190,22 @@ class FusionAssessmentVisit(models.Model):
) )
return sale_order return sale_order
def _validate_adp_combination(self, adp_assessments):
"""Enforce ADP device-combination rules: at most one seated-mobility
device (manual wheelchair / power wheelchair / scooter), optionally one
walker/rollator, no duplicates."""
seated_types = {'wheelchair', 'powerchair', 'scooter'}
seated = [a for a in adp_assessments if a.equipment_type in seated_types]
walkers = [a for a in adp_assessments if a.equipment_type == 'rollator']
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
if len(seated) > 1:
raise UserError(_(
'An ADP order can include only one seated-mobility device '
'(manual wheelchair, power wheelchair, or scooter). This visit has: %s.'
) % ', '.join(labels.get(a.equipment_type, a.equipment_type) for a in seated))
if len(walkers) > 1:
raise UserError(_('An ADP order can include only one walker / rollator.'))
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
@@ -215,14 +231,39 @@ class FusionAssessmentVisit(models.Model):
for sale_type, group in by_sale_type.items(): for sale_type, group in by_sale_type.items():
self._create_grouped_sale_order(partner, sale_type, group) self._create_grouped_sale_order(partner, sale_type, group)
# ADP equipment assessments: complete individually (one SO each) until # ADP equipment assessments: one ADP order per funding type, with the
# Phase 2 multi-device lets several share one ADP order. # device-combination guard, reusing the existing (prod-tested) express
# completion. The first device creates the SO; the rest attach to it.
adp_by_type = {}
for assessment in self.adp_assessment_ids: for assessment in self.adp_assessment_ids:
if assessment.sale_order_id: if assessment.sale_order_id:
continue continue
so = assessment.action_complete() adp_by_type.setdefault(self._assessment_sale_type(assessment), []).append(assessment)
if so: labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
so.visit_id = self.id for sale_type, group in adp_by_type.items():
self._validate_adp_combination(group)
# Make sure each device carries the visit's client + OT so the
# existing completion logic has what it needs.
for assessment in group:
vals = {}
if not assessment.client_name:
vals['client_name'] = self.client_name or partner.name
if not assessment.authorizer_id and self.authorizer_id:
vals['authorizer_id'] = self.authorizer_id.id
if not assessment.partner_id:
vals['partner_id'] = partner.id
if vals:
assessment.write(vals)
primary = group[0]
sale_order = primary.action_complete_express()
sale_order.write({'visit_id': self.id, 'x_fc_sale_type': sale_type})
for extra in group[1:]:
extra.write({'state': 'completed', 'sale_order_id': sale_order.id})
sale_order.message_post(
body=Markup('<p><strong>Additional ADP device on this order:</strong> %s</p>')
% labels.get(extra.equipment_type, extra.equipment_type or 'device'),
subtype_xmlid='mail.mt_note',
)
self.write({'state': 'done', 'partner_id': partner.id}) self.write({'state': 'done', 'partner_id': partner.id})
return self._action_view_sale_orders() return self._action_view_sale_orders()

View File

@@ -388,6 +388,7 @@
<small class="text-muted">Determines which sale order / funding workflow this case enters.</small> <small class="text-muted">Determines which sale order / funding workflow this case enters.</small>
</div> </div>
</div> </div>
<input type="hidden" name="visit_id" id="acc_visit_id"/>
</div> </div>
</div> </div>
</template> </template>
@@ -647,6 +648,15 @@
// Fallback if Google Maps not loaded // Fallback if Google Maps not loaded
window.initAddressAutocomplete = window.initAddressAutocomplete || function() {}; window.initAddressAutocomplete = window.initAddressAutocomplete || function() {};
// Carry visit_id from the workspace launch (?visit_id=) into the form
(function() {
var _vid = new URLSearchParams(window.location.search).get('visit_id');
if (_vid) {
var f = document.getElementById('acc_visit_id');
if (f) { f.value = _vid; }
}
})();
// Form submission // Form submission
function saveAssessment(createSaleOrder) { function saveAssessment(createSaleOrder) {
var form = document.getElementById('accessibility_form'); var form = document.getElementById('accessibility_form');

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="portal_visit_workspace" name="Assessment Visit Workspace">
<t t-call="portal.portal_layout">
<t t-set="no_breadcrumbs" t-value="True"/>
<div class="container py-4">
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/my">Dashboard</a></li>
<li class="breadcrumb-item active">Assessment Visit</li>
</ol>
</nav>
<t t-if="error">
<div class="alert alert-danger"><i class="fa fa-exclamation-circle"/> <t t-esc="error"/></div>
</t>
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0"><i class="fa fa-clipboard text-primary"/> Visit <t t-esc="visit.name"/></h3>
<span class="badge bg-secondary"><span t-field="visit.state"/></span>
</div>
<t t-if="visit.state == 'done'">
<div class="alert alert-success">
<strong>Visit completed.</strong> Sale orders created:
<ul class="mb-0">
<t t-foreach="visit.sale_order_ids" t-as="so">
<li><a t-attf-href="/my/sales/case/{{so.id}}"><t t-esc="so.name"/></a><span t-field="so.x_fc_sale_type"/></li>
</t>
</ul>
</div>
</t>
<!-- Assessments added this visit -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white"><strong>Assessments this visit</strong> (<t t-esc="visit.assessment_count"/>)</div>
<div class="card-body">
<p t-if="not visit.assessment_count" class="text-muted mb-0">
Nothing added yet — use the buttons below to add what you're assessing.
</p>
<ul class="list-group" t-if="visit.assessment_count">
<t t-foreach="visit.adp_assessment_ids" t-as="a">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="fa fa-wheelchair text-primary"/> ADP — <span t-field="a.equipment_type"/></span>
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
</li>
</t>
<t t-foreach="visit.accessibility_assessment_ids" t-as="a">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><span t-field="a.assessment_type"/><span t-field="a.x_fc_funding_source"/></span>
<span class="badge bg-light text-dark"><span t-field="a.state"/></span>
</li>
</t>
</ul>
</div>
</div>
<t t-if="visit.state != 'done'">
<!-- Add assessment -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white"><strong>+ Add assessment</strong></div>
<div class="card-body d-flex flex-wrap gap-2">
<a class="btn btn-outline-primary" t-attf-href="/my/assessment/express?visit_id={{visit.id}}">Wheelchair / ADP</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/straight?visit_id={{visit.id}}">Straight Stair Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/stairlift/curved?visit_id={{visit.id}}">Curved Stair Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/vpl?visit_id={{visit.id}}">Platform / Porch Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ceiling-lift?visit_id={{visit.id}}">Ceiling Lift</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/ramp?visit_id={{visit.id}}">Custom Ramp</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/bathroom?visit_id={{visit.id}}">Bathroom Mod</a>
<a class="btn btn-outline-primary" t-attf-href="/my/accessibility/tub-cutout?visit_id={{visit.id}}">Tub Cutout</a>
</div>
</div>
<!-- Client details (deferred) -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white"><strong>Client details</strong>
<span class="text-muted small">— fill in after the therapist leaves</span></div>
<div class="card-body">
<form t-attf-action="/my/visit/{{visit.id}}/save" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="row">
<div class="col-md-6 mb-3"><label class="form-label">Client Name</label>
<input type="text" name="client_name" class="form-control" t-att-value="visit.client_name"/></div>
<div class="col-md-6 mb-3"><label class="form-label">Phone</label>
<input type="text" name="client_phone" class="form-control" t-att-value="visit.client_phone"/></div>
<div class="col-md-6 mb-3"><label class="form-label">Email</label>
<input type="email" name="client_email" class="form-control" t-att-value="visit.client_email"/></div>
<div class="col-md-6 mb-3"><label class="form-label">Address</label>
<input type="text" name="client_address" class="form-control" t-att-value="visit.client_address"/></div>
</div>
<div class="mb-3" t-if="visit.has_mod_items">
<label class="form-label">Income under March of Dimes threshold?
<span class="text-muted small">(MOD covers up to $15k/person, lifetime)</span></label>
<select name="x_fc_income_under_mod_threshold" class="form-select">
<option value="unknown" t-att-selected="visit.x_fc_income_under_mod_threshold == 'unknown'">Unknown</option>
<option value="yes" t-att-selected="visit.x_fc_income_under_mod_threshold == 'yes'">Yes — under threshold (full $15k)</option>
<option value="no" t-att-selected="visit.x_fc_income_under_mod_threshold == 'no'">No — over threshold (may be denied/partial)</option>
</select>
</div>
<button type="submit" class="btn btn-secondary">Save client details</button>
</form>
</div>
</div>
<!-- Complete -->
<form t-attf-action="/my/visit/{{visit.id}}/complete" method="post" t-if="visit.assessment_count">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<button type="submit" class="btn btn-primary btn-lg">Complete visit &amp; create sale orders &#8594;</button>
<p class="text-muted small mt-2">Creates one sale order per funding workflow (ADP / March of Dimes / private / ...).</p>
</form>
</t>
</div>
</t>
</template>
</odoo>