feat(fusion_portal): assessment-visit redesign - live on westin 19.0.2.9.0

Bundles multiple assessments per home visit; on completion groups them by
funding workflow (x_fc_sale_type) into one draft sale order per workflow
(March of Dimes / ADP / ODSP / WSIB / private / hardship / insurance).
Adds the mobility scooter ADP device type, the power-mobility home-access
rule, ADP multi-device combination guard, and the portal visit workspace.

Verified on a westin-v19 clone (clean registry load + funding-grouping
smoke test) then deployed to westin prod (fusion_portal 19.0.2.9.0).
Prod's pre-existing orphaned tax links were preserved (Odoo skips existing
FKs), pending a later audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-02 08:23:43 -04:00
parent 319de06ca6
commit 13fabb0e79
14 changed files with 722 additions and 9 deletions

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.8.0',
'version': '19.0.2.9.0',
'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """
@@ -64,12 +64,14 @@ This module provides external portal access for:
'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml',
'data/welcome_articles.xml',
'data/visit_data.xml',
# Views
'views/res_partner_views.xml',
'views/sale_order_views.xml',
'views/assessment_views.xml',
'views/loaner_checkout_views.xml',
'views/pdf_template_views.xml',
'views/visit_views.xml',
# Portal Templates
'views/portal_templates.xml',
'views/portal_assessment_express.xml',
@@ -79,6 +81,7 @@ This module provides external portal access for:
'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml',
'views/portal_page11_sign_templates.xml',
'views/portal_visit.xml',
],
'assets': {
'web.assets_backend': [

View File

@@ -815,7 +815,15 @@ class AssessmentPortal(CustomerPortal):
vals['wheelchair_type'] = kw.get('wheelchair_type')
if kw.get('powerchair_type'):
vals['powerchair_type'] = kw.get('powerchair_type')
if kw.get('scooter_type'):
vals['scooter_type'] = kw.get('scooter_type')
if kw.get('scooter_max_range'):
vals['scooter_max_range'] = float(kw.get('scooter_max_range') or 0)
if kw.get('x_fc_power_home_accessible'):
vals['x_fc_power_home_accessible'] = kw.get('x_fc_power_home_accessible')
if kw.get('x_fc_power_home_access_notes'):
vals['x_fc_power_home_access_notes'] = kw.get('x_fc_power_home_access_notes')
# Float measurements
float_fields = [
'rollator_handle_height', 'rollator_seat_height',

View File

@@ -2479,6 +2479,56 @@ class AuthorizerPortal(CustomerPortal):
template = template_map.get(assessment_type, 'fusion_portal.portal_accessibility_selector')
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)
def accessibility_assessment_save(self, **post):
"""Save an accessibility assessment and optionally create a Sale Order"""
@@ -2547,6 +2597,13 @@ class AuthorizerPortal(CustomerPortal):
if partner.is_authorizer:
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
assessment = Assessment.create(vals)
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
@@ -2572,8 +2629,12 @@ class AuthorizerPortal(CustomerPortal):
if video_data:
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)
if vals.get('visit_id'):
create_sale_order = False
if create_sale_order:
sale_order = assessment.action_complete()
return {
@@ -2586,12 +2647,13 @@ class AuthorizerPortal(CustomerPortal):
'redirect_url': f'/my/sales/case/{sale_order.id}',
}
else:
redirect_url = ('/my/visit/%s' % vals['visit_id']) if vals.get('visit_id') else '/my/accessibility/list'
return {
'success': True,
'assessment_id': assessment.id,
'assessment_ref': assessment.reference,
'message': f'Assessment {assessment.reference} saved as draft.',
'redirect_url': '/my/accessibility/list',
'message': f'Assessment {assessment.reference} saved.',
'redirect_url': redirect_url,
}
except Exception as e:

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Reference sequence for assessment visits (VISIT/2026/0001) -->
<record id="seq_fusion_assessment_visit" model="ir.sequence">
<field name="name">Assessment Visit</field>
<field name="code">fusion.assessment.visit</field>
<field name="prefix">VISIT/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -7,5 +7,6 @@ from . import adp_document
from . import assessment
from . import accessibility_assessment
from . import sale_order
from . import visit
from . import loaner_checkout
from . import pdf_template

View File

@@ -157,7 +157,14 @@ class FusionAccessibilityAssessment(models.Model):
readonly=True,
copy=False,
)
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this accessibility assessment belongs to.',
)
# Dates
assessment_date = fields.Date(
string='Assessment Date',

View File

@@ -45,6 +45,7 @@ class FusionAssessment(models.Model):
('rollator', 'Rollator'),
('wheelchair', 'Wheelchair'),
('powerchair', 'Powerchair'),
('scooter', 'Mobility Scooter'),
], string='Equipment Type', tracking=True, index=True)
# Rollator Types
@@ -69,6 +70,31 @@ class FusionAssessment(models.Model):
('type_2', 'Adult Power Base Type 2'),
('type_3', 'Adult Power Base Type 3'),
], string='Powerchair Type')
# ===== MOBILITY SCOOTER (ADP) — 2026-06 Phase 2 =====
scooter_type = fields.Selection([
('travel_3', '3-Wheel Travel/Portable'),
('travel_4', '4-Wheel Travel/Portable'),
('standard_3', '3-Wheel Standard'),
('standard_4', '4-Wheel Standard'),
('heavy_duty', 'Heavy-Duty / Bariatric'),
], string='Scooter Type')
scooter_max_range = fields.Float(
string='Maximum Range Needed (km)', digits=(10, 1),
help='Maximum distance the client needs the scooter to travel on a charge.',
)
# ===== POWER-MOBILITY HOME ACCESSIBILITY (ADP hard rule) =====
# Applies to scooter + power wheelchair: ADP funds power mobility only if the
# device can enter and be used at the residence independently, without lifting.
x_fc_power_home_accessible = fields.Selection([
('yes', 'Yes — usable inside and outside independently'),
('no', 'No — home needs accessibility work'),
], string='Home accessible for power-mobility device?',
help='ADP will not fund a scooter / power wheelchair if the home cannot '
'take it (device left outside or in the garage). If No, the home '
'needs an accessibility product (ramp / porch lift).')
x_fc_power_home_access_notes = fields.Text(string='Home Access Notes')
# ===== EXPRESS FORM: ROLLATOR MEASUREMENTS =====
rollator_handle_height = fields.Float(string='Handle Height (inches)', digits=(10, 2))
@@ -425,7 +451,15 @@ class FusionAssessment(models.Model):
readonly=True,
copy=False,
)
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
ondelete='set null',
index=True,
help='The home visit this ADP assessment belongs to (groups multiple '
'assessments / funding workflows from one visit).',
)
# ===== COMPUTED FIELDS =====
document_count = fields.Integer(
string='Document Count',

View File

@@ -53,6 +53,17 @@ class SaleOrder(models.Model):
'sale order — stair lift, VPL, ceiling lift, ramp, bathroom mod, '
'or tub cutout visits.',
)
# Link to the assessment visit (one visit -> one SO per funding workflow).
visit_id = fields.Many2one(
'fusion.assessment.visit',
string='Assessment Visit',
readonly=True,
index=True,
help='The home visit this sale order was generated from. A visit '
'produces one sale order per funding workflow (ADP / MOD / ODSP / '
'private / ...).',
)
# Authorizer helper field (consolidates multiple possible fields)
portal_authorizer_id = fields.Many2one(

View File

@@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
"""Assessment Visit — bundles the assessments done during one home visit.
A sales rep + occupational therapist visit a client for 30-45 min and may do
several assessments (an ADP wheelchair plus accessibility products like a stair
lift, ramp, or tub cutout). The Visit is the hub that holds the client/context
ONCE and, on completion, groups its assessments by FUNDING WORKFLOW
(x_fc_sale_type) and creates ONE draft sale order per workflow — never one
combined SO, and never a separate SO per item within the same funding.
See docs/superpowers/specs/2026-06-02-assessment-visit-funding-design.md.
"""
import logging
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Accessibility funding source -> sale.order x_fc_sale_type. Mirrors
# fusion.accessibility.assessment._create_draft_sale_order so a grouped Visit
# routes exactly the way a single accessibility assessment would.
ACCESSIBILITY_SALE_TYPE_MAP = {
'march_of_dimes': 'march_of_dimes',
'odsp': 'odsp',
'wsib': 'wsib',
'hardship': 'hardship',
'insurance': 'insurance',
'direct_private': 'direct_private',
'other': 'other',
}
class FusionAssessmentVisit(models.Model):
_name = 'fusion.assessment.visit'
_description = 'Assessment Visit'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'visit_date desc, id desc'
name = fields.Char(
string='Visit Reference', required=True, readonly=True,
copy=False, default=lambda self: _('New'),
)
state = fields.Selection(
selection=[
('measuring', 'Measuring'),
('client_pending', 'Client Details Pending'),
('done', 'Completed'),
('cancelled', 'Cancelled'),
],
default='measuring', tracking=True, copy=False,
)
# --- Shared client + context (entered once for the whole visit) ---------
partner_id = fields.Many2one('res.partner', string='Client', tracking=True)
client_name = fields.Char(string='Client Name', tracking=True)
client_phone = fields.Char(string='Phone')
client_email = fields.Char(string='Email')
client_address = fields.Char(string='Address')
visit_date = fields.Date(
string='Visit Date', default=fields.Date.context_today, tracking=True,
)
sales_rep_id = fields.Many2one(
'res.users', string='Sales Rep',
default=lambda self: self.env.user, tracking=True,
)
authorizer_id = fields.Many2one(
'res.partner', string='Occupational Therapist', tracking=True,
)
# --- March of Dimes funding context (informational; spec §4.1) ----------
# MOD covers up to $15,000 per person (lifetime), income-gated.
x_fc_income_under_mod_threshold = fields.Selection(
selection=[
('yes', 'Yes — under threshold (full $15k available)'),
('no', 'No — over threshold (may be denied / partial)'),
('unknown', 'Unknown'),
],
string='Income under MOD threshold?', default='unknown',
help='March of Dimes funds up to $15,000 per person (lifetime) when the '
"client's income is under that year's threshold; over it, MOD may "
'deny or partially approve. Reminder only — no automatic cap '
'enforcement in this version.',
)
# --- Assessments performed during this visit ----------------------------
adp_assessment_ids = fields.One2many(
'fusion.assessment', 'visit_id', string='ADP Assessments',
)
accessibility_assessment_ids = fields.One2many(
'fusion.accessibility.assessment', 'visit_id',
string='Accessibility Assessments',
)
# --- Sale orders produced — one per funding workflow --------------------
sale_order_ids = fields.One2many(
'sale.order', 'visit_id', string='Sale Orders',
)
assessment_count = fields.Integer(compute='_compute_counts')
sale_order_count = fields.Integer(compute='_compute_counts')
has_mod_items = fields.Boolean(compute='_compute_has_mod_items')
@api.depends('adp_assessment_ids', 'accessibility_assessment_ids', 'sale_order_ids')
def _compute_counts(self):
for visit in self:
visit.assessment_count = (
len(visit.adp_assessment_ids)
+ len(visit.accessibility_assessment_ids)
)
visit.sale_order_count = len(visit.sale_order_ids)
@api.depends('accessibility_assessment_ids.x_fc_funding_source')
def _compute_has_mod_items(self):
for visit in self:
visit.has_mod_items = any(
a.x_fc_funding_source == 'march_of_dimes'
for a in visit.accessibility_assessment_ids
)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals['name'] == _('New'):
seq = self.env['ir.sequence'].next_by_code('fusion.assessment.visit')
vals['name'] = seq or _('New')
return super().create(vals_list)
# ------------------------------------------------------------------
# Completion — group assessments by funding workflow → one SO each
# ------------------------------------------------------------------
def _ensure_partner(self):
"""Resolve the client partner for the visit, reusing an assessment's
partner/_ensure_partner when one is already set."""
self.ensure_one()
if self.partner_id:
return self.partner_id
# Borrow a child assessment's partner resolution if available.
for assessment in self.accessibility_assessment_ids:
if assessment.partner_id:
return assessment.partner_id
if hasattr(assessment, '_ensure_partner'):
return assessment._ensure_partner()
for assessment in self.adp_assessment_ids:
if assessment.partner_id:
return assessment.partner_id
if self.client_name:
return self.env['res.partner'].sudo().create({
'name': self.client_name,
'phone': self.client_phone or False,
'email': self.client_email or False,
})
raise UserError(_('Set a client (or client name) on the visit first.'))
def _create_grouped_sale_order(self, partner, sale_type, accessibility_assessments):
"""Create ONE draft sale order for a set of same-funding accessibility
assessments, link them all to it, and post each one's spec to chatter.
Mirrors fusion.accessibility.assessment._create_draft_sale_order but for
a group sharing one funding workflow."""
self.ensure_one()
SaleOrder = self.env['sale.order'].sudo()
so_vals = {
'partner_id': partner.id,
'user_id': self.sales_rep_id.id if self.sales_rep_id else self.env.user.id,
'state': 'draft',
'origin': _('Visit %s (%s)') % (self.name, sale_type),
'x_fc_sale_type': sale_type,
'visit_id': self.id,
}
if self.authorizer_id:
so_vals['x_fc_authorizer_id'] = self.authorizer_id.id
# MOD: pre-fill the accessibility specialist from the sales rep.
if sale_type == 'march_of_dimes' and self.sales_rep_id and self.sales_rep_id.partner_id:
so_vals['x_fc_mod_accessibility_specialist_id'] = self.sales_rep_id.partner_id.id
sale_order = SaleOrder.create(so_vals)
for assessment in accessibility_assessments:
assessment.sale_order_id = sale_order.id
assessment._add_assessment_tag(sale_order)
sale_order.message_post(
body=Markup(assessment._format_assessment_html_table()),
message_type='comment',
subtype_xmlid='mail.mt_note',
)
assessment.write({'state': 'completed'})
_logger.info(
"Visit %s created %s sale order %s grouping %d accessibility assessment(s)",
self.name, sale_type, sale_order.name, len(accessibility_assessments),
)
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):
"""Group the visit's accessibility assessments by funding workflow and
create one draft SO per workflow. ADP equipment assessments keep their
existing one-assessment-one-SO completion for now (ADP multi-device
grouping arrives in Phase 2)."""
self.ensure_one()
if self.state == 'done':
raise UserError(_('This visit is already completed.'))
if not (self.accessibility_assessment_ids or self.adp_assessment_ids):
raise UserError(_('Add at least one assessment before completing the visit.'))
partner = self._ensure_partner()
# Group accessibility assessments by their funding -> sale type.
by_sale_type = {}
for assessment in self.accessibility_assessment_ids:
if assessment.sale_order_id:
continue # already has an SO; don't duplicate
sale_type = ACCESSIBILITY_SALE_TYPE_MAP.get(
assessment.x_fc_funding_source, 'direct_private')
by_sale_type.setdefault(sale_type, []).append(assessment)
for sale_type, group in by_sale_type.items():
self._create_grouped_sale_order(partner, sale_type, group)
# ADP equipment assessments: one ADP order per funding type, with the
# 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:
if assessment.sale_order_id:
continue
adp_by_type.setdefault(self._assessment_sale_type(assessment), []).append(assessment)
labels = dict(self.env['fusion.assessment']._fields['equipment_type'].selection)
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})
return self._action_view_sale_orders()
def _action_view_sale_orders(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Visit Sale Orders'),
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('visit_id', '=', self.id)],
'context': {'create': False},
}

View File

@@ -7,6 +7,8 @@ access_fusion_assessment_user,fusion.assessment.user,model_fusion_assessment,bas
access_fusion_assessment_portal,fusion.assessment.portal,model_fusion_assessment,base.group_portal,1,1,1,0
access_fusion_accessibility_assessment_user,fusion.accessibility.assessment.user,model_fusion_accessibility_assessment,base.group_user,1,1,1,1
access_fusion_accessibility_assessment_portal,fusion.accessibility.assessment.portal,model_fusion_accessibility_assessment,base.group_portal,1,1,1,0
access_fusion_assessment_visit_user,fusion.assessment.visit.user,model_fusion_assessment_visit,base.group_user,1,1,1,1
access_fusion_assessment_visit_portal,fusion.assessment.visit.portal,model_fusion_assessment_visit,base.group_portal,1,1,1,0
access_fusion_pdf_template_user,fusion.pdf.template.user,model_fusion_pdf_template,base.group_user,1,1,1,1
access_fusion_pdf_template_preview_user,fusion.pdf.template.preview.user,model_fusion_pdf_template_preview,base.group_user,1,1,1,1
access_fusion_pdf_template_field_user,fusion.pdf.template.field.user,model_fusion_pdf_template_field,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_fusion_assessment_portal fusion.assessment.portal model_fusion_assessment base.group_portal 1 1 1 0
8 access_fusion_accessibility_assessment_user fusion.accessibility.assessment.user model_fusion_accessibility_assessment base.group_user 1 1 1 1
9 access_fusion_accessibility_assessment_portal fusion.accessibility.assessment.portal model_fusion_accessibility_assessment base.group_portal 1 1 1 0
10 access_fusion_assessment_visit_user fusion.assessment.visit.user model_fusion_assessment_visit base.group_user 1 1 1 1
11 access_fusion_assessment_visit_portal fusion.assessment.visit.portal model_fusion_assessment_visit base.group_portal 1 1 1 0
12 access_fusion_pdf_template_user fusion.pdf.template.user model_fusion_pdf_template base.group_user 1 1 1 1
13 access_fusion_pdf_template_preview_user fusion.pdf.template.preview.user model_fusion_pdf_template_preview base.group_user 1 1 1 1
14 access_fusion_pdf_template_field_user fusion.pdf.template.field.user model_fusion_pdf_template_field base.group_user 1 1 1 1

View File

@@ -388,6 +388,7 @@
<small class="text-muted">Determines which sale order / funding workflow this case enters.</small>
</div>
</div>
<input type="hidden" name="visit_id" id="acc_visit_id"/>
</div>
</div>
</template>
@@ -647,6 +648,15 @@
// Fallback if Google Maps not loaded
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
function saveAssessment(createSaleOrder) {
var form = document.getElementById('accessibility_form');

View File

@@ -98,6 +98,7 @@
<option value="rollator" t-att-selected="assessment.equipment_type == 'rollator' if assessment else False">Rollator</option>
<option value="wheelchair" t-att-selected="assessment.equipment_type == 'wheelchair' if assessment else False">Wheelchair</option>
<option value="powerchair" t-att-selected="assessment.equipment_type == 'powerchair' if assessment else False">Powerchair</option>
<option value="scooter" t-att-selected="assessment.equipment_type == 'scooter' if assessment else False">Mobility Scooter</option>
</select>
</div>
@@ -688,8 +689,62 @@
<label class="form-label fw-bold">Additional Information/Customization</label>
<textarea name="additional_customization" class="form-control powerchair-field" rows="4" placeholder="Enter any additional requirements or customization notes..."><t t-esc="assessment.additional_customization if assessment else ''"/></textarea>
</div>
<!-- Power-mobility home-accessibility — ADP hard rule -->
<div class="mb-4 p-3 border rounded bg-light">
<label class="form-label fw-bold">Home accessible for the device — inside &amp; outside?</label>
<select name="x_fc_power_home_accessible" class="form-control">
<option value="">-- Select --</option>
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
</select>
<div class="alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
</div>
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
</div>
</div>
<!-- ===== MOBILITY SCOOTER ===== -->
<div id="scooter_form" class="equipment-form" style="display: none;">
<h2 class="text-center fw-bold text-uppercase mb-4">Mobility Scooter Assessment</h2>
<div class="mb-4">
<label class="form-label fw-bold">Scooter Type</label>
<select name="scooter_type" class="form-select">
<option value="">-- Select Type --</option>
<option value="travel_3" t-att-selected="assessment.scooter_type == 'travel_3' if assessment else False">3-Wheel Travel/Portable</option>
<option value="travel_4" t-att-selected="assessment.scooter_type == 'travel_4' if assessment else False">4-Wheel Travel/Portable</option>
<option value="standard_3" t-att-selected="assessment.scooter_type == 'standard_3' if assessment else False">3-Wheel Standard</option>
<option value="standard_4" t-att-selected="assessment.scooter_type == 'standard_4' if assessment else False">4-Wheel Standard</option>
<option value="heavy_duty" t-att-selected="assessment.scooter_type == 'heavy_duty' if assessment else False">Heavy-Duty / Bariatric</option>
</select>
</div>
<div class="row mb-3">
<div class="col-12 col-md-6 mb-3">
<label class="form-label fw-bold">Maximum Range Needed (km)</label>
<div class="input-group">
<input type="number" step="1" name="scooter_max_range" class="form-control"
t-att-value="assessment.scooter_max_range if assessment else ''"/>
<span class="input-group-text">km</span>
</div>
</div>
</div>
<!-- Power-mobility home-accessibility — ADP hard rule -->
<div class="mb-4 p-3 border rounded bg-light">
<label class="form-label fw-bold">Home accessible for the device — inside &amp; outside?</label>
<select name="x_fc_power_home_accessible" class="form-control">
<option value="">-- Select --</option>
<option value="yes" t-att-selected="assessment.x_fc_power_home_accessible == 'yes' if assessment else False">Yes — usable inside and outside independently</option>
<option value="no" t-att-selected="assessment.x_fc_power_home_accessible == 'no' if assessment else False">No — home needs accessibility work</option>
</select>
<div class="alert alert-warning mt-2 mb-0">
<i class="fa fa-exclamation-triangle"/> ADP funds power mobility only if the device can enter and be used at the residence <strong>independently, without lifting</strong> (not left outside / in the garage). If <strong>No</strong>, add an accessibility assessment (ramp / porch lift) for the home.
</div>
<textarea name="x_fc_power_home_access_notes" class="form-control mt-2" rows="2" placeholder="Access notes (entry steps, garage, thresholds, turning space...)"><t t-esc="assessment.x_fc_power_home_access_notes if assessment else ''"/></textarea>
</div>
</div>
</div>
</div>
@@ -1278,6 +1333,7 @@
var rollatorForm = document.getElementById('rollator_form');
var wheelchairForm = document.getElementById('wheelchair_form');
var powerchairForm = document.getElementById('powerchair_form');
var scooterForm = document.getElementById('scooter_form');
var wheelchairTypeSelect = document.querySelector('select[name="wheelchair_type"]');
var reasonSelect = document.getElementById('reason_for_application');
var previousFundingContainer = document.getElementById('previous_funding_date_container');
@@ -1339,13 +1395,16 @@
disableFormInputs(rollatorForm);
disableFormInputs(wheelchairForm);
disableFormInputs(powerchairForm);
disableFormInputs(scooterForm);
if (value === 'rollator') {
enableFormInputs(rollatorForm);
} else if (value === 'wheelchair') {
enableFormInputs(wheelchairForm);
} else if (value === 'powerchair') {
enableFormInputs(powerchairForm);
} else if (value === 'scooter') {
enableFormInputs(scooterForm);
}
}

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>

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form -->
<record id="view_fusion_assessment_visit_form" model="ir.ui.view">
<field name="name">fusion.assessment.visit.form</field>
<field name="model">fusion.assessment.visit</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_complete_visit" type="object"
string="Complete Visit &amp; Create Sale Orders"
class="btn-primary" invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="measuring,client_pending,done"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group string="Client">
<field name="partner_id"/>
<field name="client_name"/>
<field name="client_phone"/>
<field name="client_email"/>
</group>
<group string="Visit">
<field name="visit_date"/>
<field name="sales_rep_id"/>
<field name="authorizer_id"/>
<field name="has_mod_items" invisible="1"/>
<field name="x_fc_income_under_mod_threshold"
invisible="not has_mod_items"/>
</group>
</group>
<div class="alert alert-info" role="alert" invisible="not has_mod_items">
<strong>March of Dimes:</strong> covers up to $15,000 per person
(lifetime), income-gated. Confirm the client's income status above.
</div>
<notebook>
<page string="Accessibility Assessments">
<field name="accessibility_assessment_ids" readonly="1">
<list>
<field name="reference"/>
<field name="assessment_type"/>
<field name="x_fc_funding_source"/>
<field name="state"/>
<field name="sale_order_id"/>
</list>
</field>
</page>
<page string="ADP Assessments">
<field name="adp_assessment_ids" readonly="1">
<list>
<field name="equipment_type"/>
<field name="state"/>
<field name="sale_order_id"/>
</list>
</field>
</page>
<page string="Sale Orders">
<field name="sale_order_ids" readonly="1">
<list>
<field name="name"/>
<field name="x_fc_sale_type"/>
<field name="state"/>
<field name="amount_total"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- List -->
<record id="view_fusion_assessment_visit_list" model="ir.ui.view">
<field name="name">fusion.assessment.visit.list</field>
<field name="model">fusion.assessment.visit</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="visit_date"/>
<field name="partner_id"/>
<field name="client_name"/>
<field name="sales_rep_id"/>
<field name="assessment_count"/>
<field name="sale_order_count"/>
<field name="state"/>
</list>
</field>
</record>
<!-- Action + menu -->
<record id="action_fusion_assessment_visit" model="ir.actions.act_window">
<field name="name">Assessment Visits</field>
<field name="res_model">fusion.assessment.visit</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_assessment_visit"
name="Assessment Visits"
parent="sale.sale_menu_root"
action="action_fusion_assessment_visit"
sequence="50"/>
</odoo>