diff --git a/fusion_portal/__manifest__.py b/fusion_portal/__manifest__.py index a210e6de..7397db46 100644 --- a/fusion_portal/__manifest__.py +++ b/fusion_portal/__manifest__.py @@ -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', diff --git a/fusion_portal/data/visit_data.xml b/fusion_portal/data/visit_data.xml new file mode 100644 index 00000000..d5682dca --- /dev/null +++ b/fusion_portal/data/visit_data.xml @@ -0,0 +1,13 @@ + + + + + + Assessment Visit + fusion.assessment.visit + VISIT/%(year)s/ + 4 + + + + diff --git a/fusion_portal/models/__init__.py b/fusion_portal/models/__init__.py index 4109891b..c3ce7f47 100644 --- a/fusion_portal/models/__init__.py +++ b/fusion_portal/models/__init__.py @@ -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 \ No newline at end of file diff --git a/fusion_portal/models/accessibility_assessment.py b/fusion_portal/models/accessibility_assessment.py index 10a5534d..a02f6e7c 100644 --- a/fusion_portal/models/accessibility_assessment.py +++ b/fusion_portal/models/accessibility_assessment.py @@ -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', diff --git a/fusion_portal/models/assessment.py b/fusion_portal/models/assessment.py index b3de9c1a..46441514 100644 --- a/fusion_portal/models/assessment.py +++ b/fusion_portal/models/assessment.py @@ -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', diff --git a/fusion_portal/models/sale_order.py b/fusion_portal/models/sale_order.py index 0d617ab2..404faf81 100644 --- a/fusion_portal/models/sale_order.py +++ b/fusion_portal/models/sale_order.py @@ -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( diff --git a/fusion_portal/models/visit.py b/fusion_portal/models/visit.py new file mode 100644 index 00000000..ccd934f5 --- /dev/null +++ b/fusion_portal/models/visit.py @@ -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}, + } diff --git a/fusion_portal/security/ir.model.access.csv b/fusion_portal/security/ir.model.access.csv index 10c6658c..125a2575 100644 --- a/fusion_portal/security/ir.model.access.csv +++ b/fusion_portal/security/ir.model.access.csv @@ -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 \ No newline at end of file diff --git a/fusion_portal/views/visit_views.xml b/fusion_portal/views/visit_views.xml new file mode 100644 index 00000000..92779ff0 --- /dev/null +++ b/fusion_portal/views/visit_views.xml @@ -0,0 +1,108 @@ + + + + + fusion.assessment.visit.form + fusion.assessment.visit + + + + + + + + + + + + + + + + + + + + + + + + + + + March of Dimes: covers up to $15,000 per person + (lifetime), income-gated. Confirm the client's income status above. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + fusion.assessment.visit.list + fusion.assessment.visit + + + + + + + + + + + + + + + + + Assessment Visits + fusion.assessment.visit + list,form + + + +