- 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>
300 lines
14 KiB
Python
300 lines
14 KiB
Python
# -*- 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'})
|
|
# 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(
|
|
"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 _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):
|
|
"""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},
|
|
}
|