feat(fusion_portal): Phase 1b - Assessment Visit model + accessibility funding grouping
Adds fusion.assessment.visit: the hub that bundles a home visit's assessments and, on completion, groups its ACCESSIBILITY assessments by funding workflow (x_fc_sale_type) and creates ONE draft sale order per workflow, reusing the existing MOD/ODSP/etc. pipelines + the chatter-note pattern. ADP assessments keep one-SO-each for now (ADP multi-device grouping is Phase 2). - New model + sequence (VISIT/YYYY/NNNN) + ACL + backend list/form/menu. - visit_id added to fusion.assessment, fusion.accessibility.assessment, sale.order. - action_complete_visit() group-and-routes; MOD $15k cap + income-threshold flag surfaced (informational, no auto-enforcement). NOT YET: completion-email consolidation; ADP multi-device (Phase 2); portal add-as-you-go workspace (Phase 3). Untested locally (Enterprise knowledge dep) - to be verified on a westin-v19 clone before prod. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
13
fusion_portal/data/visit_data.xml
Normal file
13
fusion_portal/data/visit_data.xml
Normal 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>
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -425,7 +425,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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
239
fusion_portal/models/visit.py
Normal file
239
fusion_portal/models/visit.py
Normal file
@@ -0,0 +1,239 @@
|
||||
# -*- 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 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: complete individually (one SO each) until
|
||||
# Phase 2 multi-device lets several share one ADP order.
|
||||
for assessment in self.adp_assessment_ids:
|
||||
if assessment.sale_order_id:
|
||||
continue
|
||||
so = assessment.action_complete()
|
||||
if so:
|
||||
so.visit_id = self.id
|
||||
|
||||
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},
|
||||
}
|
||||
@@ -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
|
||||
|
108
fusion_portal/views/visit_views.xml
Normal file
108
fusion_portal/views/visit_views.xml
Normal 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 & 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>
|
||||
Reference in New Issue
Block a user