Files
Odoo-Modules/fusion_portal/models/visit.py
gsinghpal 9a8e1d7ab5 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>
2026-06-02 08:50:25 -04:00

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},
}