From 37efc5b858862ee2b8535656edc7d6bd48af8eb4 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 01:33:24 -0400 Subject: [PATCH 01/10] feat(fusion_portal): funding-source selector on accessibility forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reps can now mark an accessibility assessment's funding source on the web form (Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) so the generated draft sale order routes to the correct funding pipeline instead of always defaulting to private pay. Adds Hardship to the x_fc_funding_source selection + sale_type_map; the new form ` (options mirror the model selection; `direct_private` pre-selected so existing private behaviour is unchanged) right after the phone/email row, before the card closes. +- [ ] **Step 2:** Validate XML well-formedness (`[xml]` parse). +- [ ] **Step 3:** Commit. + +### Task 3: Capture funding_source in the save controller + +**Files:** Modify `fusion_portal/controllers/portal_main.py` (`accessibility_assessment_save` vals, ~:2498-2511) + +- [ ] **Step 1:** Add `'x_fc_funding_source': post.get('funding_source') or 'direct_private',` to the `vals` dict. +- [ ] **Step 2:** `python -m pyflakes fusion_portal/controllers/portal_main.py` → no new undefined-name errors. +- [ ] **Step 3:** Commit. + +### Task 4: Verify + ship + +- [ ] **Step 1:** Grep confirms `funding_source` flows form → controller → `x_fc_funding_source` → `sale_type_map`. +- [ ] **Step 2:** Deploy to westin (backup → scp the 3 files → `-u fusion_portal` → cache-bust → restart) and confirm: open `/my/accessibility/stairlift/straight`, pick "March of Dimes", complete → the new SO shows `x_fc_sale_type = march_of_dimes` and appears in the MOD pipeline. diff --git a/fusion_portal/controllers/portal_main.py b/fusion_portal/controllers/portal_main.py index c85ce960..b5f65b51 100644 --- a/fusion_portal/controllers/portal_main.py +++ b/fusion_portal/controllers/portal_main.py @@ -2507,6 +2507,7 @@ class AuthorizerPortal(CustomerPortal): 'client_address_postal': post.get('client_address_postal', '').strip(), 'client_phone': post.get('client_phone', '').strip(), 'client_email': post.get('client_email', '').strip(), + 'x_fc_funding_source': post.get('funding_source') or 'direct_private', 'notes': post.get('notes', '').strip(), } diff --git a/fusion_portal/models/accessibility_assessment.py b/fusion_portal/models/accessibility_assessment.py index 4d18df1d..10a5534d 100644 --- a/fusion_portal/models/accessibility_assessment.py +++ b/fusion_portal/models/accessibility_assessment.py @@ -73,6 +73,7 @@ class FusionAccessibilityAssessment(models.Model): ('march_of_dimes', 'March of Dimes'), ('odsp', 'ODSP'), ('wsib', 'WSIB'), + ('hardship', 'Hardship Funding'), ('insurance', 'Private Insurance'), ('direct_private', 'Private Pay (Direct)'), ('other', 'Other'), @@ -772,6 +773,7 @@ class FusionAccessibilityAssessment(models.Model): 'march_of_dimes': 'march_of_dimes', 'odsp': 'odsp', 'wsib': 'wsib', + 'hardship': 'hardship', 'insurance': 'insurance', 'direct_private': 'direct_private', 'other': 'other', diff --git a/fusion_portal/views/portal_accessibility_templates.xml b/fusion_portal/views/portal_accessibility_templates.xml index 18484565..fcd1d35f 100644 --- a/fusion_portal/views/portal_accessibility_templates.xml +++ b/fusion_portal/views/portal_accessibility_templates.xml @@ -373,6 +373,21 @@ +
+
+ + + Determines which sale order / funding workflow this case enters. +
+
From e36aaab306a29ddc16624b58a144afb35bc3f985 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 01:38:42 -0400 Subject: [PATCH 02/10] fix(fusion_portal): validate funding_source in accessibility save (parity with booking) Coerce an unexpected/tampered funding_source to direct_private instead of passing it raw into create() (which would raise on the Selection field). Mirrors the /book-assessment controller; the whitelist is derived from the model selection so it auto-covers hardship and any future values. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_portal/controllers/portal_main.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/fusion_portal/controllers/portal_main.py b/fusion_portal/controllers/portal_main.py index b5f65b51..0563e099 100644 --- a/fusion_portal/controllers/portal_main.py +++ b/fusion_portal/controllers/portal_main.py @@ -2493,7 +2493,14 @@ class AuthorizerPortal(CustomerPortal): assessment_type = post.get('assessment_type') if not assessment_type: return {'success': False, 'error': 'Assessment type is required'} - + + # Funding source drives the downstream sale-order workflow; coerce + # anything unexpected to private pay (mirrors /book-assessment). + _funding_keys = dict(Assessment._fields['x_fc_funding_source'].selection) + funding_source = post.get('funding_source') or 'direct_private' + if funding_source not in _funding_keys: + funding_source = 'direct_private' + # Build assessment values vals = { 'assessment_type': assessment_type, @@ -2507,7 +2514,7 @@ class AuthorizerPortal(CustomerPortal): 'client_address_postal': post.get('client_address_postal', '').strip(), 'client_phone': post.get('client_phone', '').strip(), 'client_email': post.get('client_email', '').strip(), - 'x_fc_funding_source': post.get('funding_source') or 'direct_private', + 'x_fc_funding_source': funding_source, 'notes': post.get('notes', '').strip(), } From b17bd615bf333b49854247d8af3dd19c44b8ca70 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 02:00:53 -0400 Subject: [PATCH 03/10] 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) --- fusion_portal/__manifest__.py | 2 + fusion_portal/data/visit_data.xml | 13 + fusion_portal/models/__init__.py | 1 + .../models/accessibility_assessment.py | 9 +- fusion_portal/models/assessment.py | 10 +- fusion_portal/models/sale_order.py | 11 + fusion_portal/models/visit.py | 239 ++++++++++++++++++ fusion_portal/security/ir.model.access.csv | 2 + fusion_portal/views/visit_views.xml | 108 ++++++++ 9 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 fusion_portal/data/visit_data.xml create mode 100644 fusion_portal/models/visit.py create mode 100644 fusion_portal/views/visit_views.xml 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 + +
+
+
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + fusion.assessment.visit.list + fusion.assessment.visit + + + + + + + + + + + + + + + + + Assessment Visits + fusion.assessment.visit + list,form + + + +
From e0ddd9ef40fc555d419294bb63722020187e9fc8 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 02:22:47 -0400 Subject: [PATCH 04/10] feat(fusion_portal): Phase 2a - mobility scooter ADP type + power-mobility home-access rule Adds 'scooter' as a 4th ADP equipment type with a lean Express-form section (scooter type + max range) and the power-mobility home-accessibility hard rule (scooter + powerchair): "is the home usable inside and outside, no lifting?" - if No, prompts adding an accessibility item (ramp / porch lift). Captures x_fc_power_home_accessible + notes; the section toggles via the existing equipment-select JS; controller parses the new fields. NOT YET: ADP multi-device + combination rules (the bigger restructure). Untested locally (Enterprise dep) - to be verified on a westin-v19 clone. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/portal_assessment.py | 10 ++- fusion_portal/models/assessment.py | 26 ++++++++ .../views/portal_assessment_express.xml | 63 ++++++++++++++++++- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/fusion_portal/controllers/portal_assessment.py b/fusion_portal/controllers/portal_assessment.py index 0c242f23..7a69d431 100644 --- a/fusion_portal/controllers/portal_assessment.py +++ b/fusion_portal/controllers/portal_assessment.py @@ -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', diff --git a/fusion_portal/models/assessment.py b/fusion_portal/models/assessment.py index 46441514..de288bee 100644 --- a/fusion_portal/models/assessment.py +++ b/fusion_portal/models/assessment.py @@ -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)) diff --git a/fusion_portal/views/portal_assessment_express.xml b/fusion_portal/views/portal_assessment_express.xml index d2a625d7..b265e5be 100644 --- a/fusion_portal/views/portal_assessment_express.xml +++ b/fusion_portal/views/portal_assessment_express.xml @@ -98,6 +98,7 @@ + @@ -688,8 +689,62 @@ + + +
+ + +
+ ADP funds power mobility only if the device can enter and be used at the residence independently, without lifting (not left outside / in the garage). If No, add an accessibility assessment (ramp / porch lift) for the home. +
+ +
- + + + + @@ -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); } } From 89467432a72cb937b675806e08fd2a7dddc4a496 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 02:31:38 -0400 Subject: [PATCH 05/10] feat(fusion_portal): Phase 2b - ADP multi-device grouping + combination guard The visit groups its ADP assessments by funding type onto ONE ADP order (first device creates the SO via the existing express completion; the rest attach), enforcing the combination rule: at most one seated-mobility device (manual WC / power WC / scooter) + optionally one walker, no duplicates. Also fixes a Phase 1b bug - it called action_complete() (needs signatures, returns an action dict) for ADP; now uses action_complete_express() which returns the SO. Untested locally (Enterprise dep) - clone verification pending. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_portal/models/visit.py | 51 +++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/fusion_portal/models/visit.py b/fusion_portal/models/visit.py index ccd934f5..010b78ee 100644 --- a/fusion_portal/models/visit.py +++ b/fusion_portal/models/visit.py @@ -190,6 +190,22 @@ class FusionAssessmentVisit(models.Model): ) 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 @@ -215,14 +231,39 @@ class FusionAssessmentVisit(models.Model): 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. + # 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 - so = assessment.action_complete() - if so: - so.visit_id = self.id + 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('

Additional ADP device on this order: %s

') + % 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() From 21cfd554194a60ef007679654b5b43ffdd8250d5 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 02:37:08 -0400 Subject: [PATCH 06/10] feat(fusion_portal): Phase 3 - assessment visit workspace (accessibility path) Adds the portal workspace: /my/visit/new starts a visit; /my/visit/ shows the add-as-you-go workspace (add buttons -> existing forms carrying ?visit_id, a deferred client+funding form, and a Complete button). Accessibility forms launched from a visit save as a DRAFT linked to it (JS carries visit_id into the form; the controller captures it and skips the per-assessment SO) - the VISIT completion then creates the grouped per-funding sale orders. NOT YET: express/ADP form visit-linking, email consolidation, polished tablet UI. Untested locally (Enterprise dep) - clone verification pending. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_portal/__manifest__.py | 1 + fusion_portal/controllers/portal_main.py | 68 ++++++++++- .../views/portal_accessibility_templates.xml | 10 ++ fusion_portal/views/portal_visit.xml | 115 ++++++++++++++++++ 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 fusion_portal/views/portal_visit.xml diff --git a/fusion_portal/__manifest__.py b/fusion_portal/__manifest__.py index 7397db46..e9de3a35 100644 --- a/fusion_portal/__manifest__.py +++ b/fusion_portal/__manifest__.py @@ -81,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': [ diff --git a/fusion_portal/controllers/portal_main.py b/fusion_portal/controllers/portal_main.py index 0563e099..42c498e4 100644 --- a/fusion_portal/controllers/portal_main.py +++ b/fusion_portal/controllers/portal_main.py @@ -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/', 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//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//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: diff --git a/fusion_portal/views/portal_accessibility_templates.xml b/fusion_portal/views/portal_accessibility_templates.xml index fcd1d35f..34176ab0 100644 --- a/fusion_portal/views/portal_accessibility_templates.xml +++ b/fusion_portal/views/portal_accessibility_templates.xml @@ -388,6 +388,7 @@ Determines which sale order / funding workflow this case enters. + @@ -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'); diff --git a/fusion_portal/views/portal_visit.xml b/fusion_portal/views/portal_visit.xml new file mode 100644 index 00000000..cd615077 --- /dev/null +++ b/fusion_portal/views/portal_visit.xml @@ -0,0 +1,115 @@ + + + + From 20de9a6b695bd7df41157db6513e336c212aff67 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 08:16:56 -0400 Subject: [PATCH 07/10] chore(fusion_portal): bump to 19.0.2.9.0 for assessment-visit redesign Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_portal/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fusion_portal/__manifest__.py b/fusion_portal/__manifest__.py index e9de3a35..7e7ae2f4 100644 --- a/fusion_portal/__manifest__.py +++ b/fusion_portal/__manifest__.py @@ -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': """ From ed91135a3fb85ab363ed2178e7723d0b5e2fcc14 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 08:39:37 -0400 Subject: [PATCH 08/10] feat(fusion_portal): wire ADP/express into visit + portal entry tile + email consolidation - express save captures visit_id; when visit-linked, action=submit saves the ADP assessment as a draft (signature + Page 11 PDF still captured) and returns to the visit instead of completing into a standalone SO, so the visit groups the ADP devices into one funding-routed order. Non-visit express flow unchanged. - portal dashboard: featured 'Start a Visit' tile (sales reps) -> /my/visit/new. - fix duplicate-authorizer email: _send_completion_notifications no longer re-emails the authorizer (already emailed with the full report by _send_assessment_completed_email); it now only notifies the client. - visit grouped accessibility SOs now send one office completion email per SO. Bump 19.0.2.10.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_portal/__manifest__.py | 2 +- .../controllers/portal_assessment.py | 33 +++++++++++++++++++ fusion_portal/models/assessment.py | 24 +++++++------- fusion_portal/models/visit.py | 10 ++++++ .../views/portal_assessment_express.xml | 19 +++++++++-- fusion_portal/views/portal_templates.xml | 19 +++++++++++ 6 files changed, 90 insertions(+), 17 deletions(-) diff --git a/fusion_portal/__manifest__.py b/fusion_portal/__manifest__.py index 7e7ae2f4..d87fcb75 100644 --- a/fusion_portal/__manifest__.py +++ b/fusion_portal/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'Fusion Authorizer & Sales Portal', - 'version': '19.0.2.9.0', + 'version': '19.0.2.10.0', 'category': 'Sales/Portal', 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', 'description': """ diff --git a/fusion_portal/controllers/portal_assessment.py b/fusion_portal/controllers/portal_assessment.py index 7a69d431..47e018b4 100644 --- a/fusion_portal/controllers/portal_assessment.py +++ b/fusion_portal/controllers/portal_assessment.py @@ -458,6 +458,7 @@ class AssessmentPortal(CustomerPortal): 'current_page': 1, 'total_pages': 2, 'assessment': None, + 'visit_id': kw.get('visit_id', ''), 'google_maps_api_key': google_maps_api_key, } @@ -516,6 +517,7 @@ class AssessmentPortal(CustomerPortal): 'partner': partner, 'user': user, 'assessment': assessment, + 'visit_id': kw.get('visit_id') or (assessment.visit_id.id if assessment.visit_id else ''), 'authorizers': authorizers, 'authorizers_json': authorizers_json, 'clients': clients, @@ -630,6 +632,30 @@ class AssessmentPortal(CustomerPortal): except Exception as e: _logger.error(f"Error saving Page 11 signature: {e}") + # ===== Visit-linked: defer SO creation to visit completion ===== + # Started from a visit workspace: do NOT complete into a standalone + # sale order. Leave it as a draft linked to the visit so + # visit.action_complete_visit() groups the visit's ADP devices + # (combination-checked) into ONE ADP order. The Page 11 signature is + # already saved above; pre-generate its PDF so it is ready. + if assessment.visit_id and action == 'submit': + if assessment.signature_page_11 and assessment.consent_declaration_accepted: + try: + pdf_bytes = assessment.generate_template_pdf('Page 11') + if pdf_bytes: + import base64 as b64 + assessment.write({ + 'signed_page_11_pdf': b64.b64encode(pdf_bytes), + 'signed_page_11_pdf_filename': f'ADP_Page11_{assessment.reference}.pdf', + }) + except Exception as pdf_e: + _logger.warning(f"Visit-linked Page 11 PDF generation failed (non-blocking): {pdf_e}") + _logger.info( + f"Express assessment {assessment.reference} saved to visit " + f"{assessment.visit_id.name} (completion deferred to visit)" + ) + return request.redirect(f'/my/visit/{assessment.visit_id.id}') + # Handle navigation if action == 'submit': # If already completed, we just saved consent/signature above -- redirect with success @@ -803,6 +829,13 @@ class AssessmentPortal(CustomerPortal): def _build_express_assessment_vals(self, kw): """Build values dict from express form POST data""" vals = {} + + # Visit linkage (assessment started from a visit workspace) + if kw.get('visit_id'): + try: + vals['visit_id'] = int(kw.get('visit_id')) + except (ValueError, TypeError): + pass # Equipment type if kw.get('equipment_type'): diff --git a/fusion_portal/models/assessment.py b/fusion_portal/models/assessment.py index de288bee..8a171269 100644 --- a/fusion_portal/models/assessment.py +++ b/fusion_portal/models/assessment.py @@ -1486,20 +1486,18 @@ class FusionAssessment(models.Model): }) def _send_completion_notifications(self): - """Send email notifications when assessment is completed""" + """Notify the CLIENT that the assessment is complete. + + The authorizer, sales rep and office are already emailed (with the full + assessment report) by ``_send_assessment_completed_email`` inside + ``_create_draft_sale_order``. This method used to ALSO send the + authorizer a second, template-only email — that duplicate is removed; + here we only notify the client. + """ self.ensure_one() - - # Send to authorizer - if self.authorizer_id and self.authorizer_id.email: - try: - template = self.env.ref('fusion_portal.mail_template_assessment_complete_authorizer', raise_if_not_found=False) - if template: - template.send_mail(self.id, force_send=True) - _logger.info(f"Sent assessment completion email to authorizer {self.authorizer_id.email}") - except Exception as e: - _logger.error(f"Failed to send authorizer notification: {e}") - - # Send to client + + # Send to client (authorizer/rep/office already emailed by + # _send_assessment_completed_email in _create_draft_sale_order) if self.client_email: try: template = self.env.ref('fusion_portal.mail_template_assessment_complete_client', raise_if_not_found=False) diff --git a/fusion_portal/models/visit.py b/fusion_portal/models/visit.py index 010b78ee..eaebdec1 100644 --- a/fusion_portal/models/visit.py +++ b/fusion_portal/models/visit.py @@ -184,6 +184,16 @@ class FusionAssessmentVisit(models.Model): 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), diff --git a/fusion_portal/views/portal_assessment_express.xml b/fusion_portal/views/portal_assessment_express.xml index b265e5be..c7d43f35 100644 --- a/fusion_portal/views/portal_assessment_express.xml +++ b/fusion_portal/views/portal_assessment_express.xml @@ -85,7 +85,19 @@ - + + + +
+ +
+ Part of an assessment visit. + Completing this device returns you to the visit — it is grouped with the + visit's other ADP devices into a single sale order when you complete the visit. +
+
+
@@ -1246,9 +1258,10 @@
- + Cancel
diff --git a/fusion_portal/views/portal_templates.xml b/fusion_portal/views/portal_templates.xml index f6b9e544..7d92664f 100644 --- a/fusion_portal/views/portal_templates.xml +++ b/fusion_portal/views/portal_templates.xml @@ -50,6 +50,25 @@
+ + + + +
From 837e7b09b74f65dc370e681697c5b04d95c62894 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 08:48:16 -0400 Subject: [PATCH 09/10] fix(fusion_portal): define visit._assessment_sale_type (ADP grouping key) action_complete_visit referenced self._assessment_sale_type() to group ADP devices by funding, but the method was never defined - any visit containing an ADP device would have raised AttributeError. Mirrors fusion.assessment._create_draft_sale_order: adp_odsp for ODSP client streams, adp otherwise. Caught by the clone ADP-grouping smoke test. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_portal/models/visit.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/fusion_portal/models/visit.py b/fusion_portal/models/visit.py index eaebdec1..f2b2b69e 100644 --- a/fusion_portal/models/visit.py +++ b/fusion_portal/models/visit.py @@ -216,6 +216,15 @@ class FusionAssessmentVisit(models.Model): 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 From 2285b7b814851ac299e948d0388b6e2d10322341 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 2 Jun 2026 08:58:11 -0400 Subject: [PATCH 10/10] fix(fusion_portal): readable gradient for the Start-a-Visit tile The tile used an inline background gradient with no !important, so the theme's .card background rule overrode it - the tile rendered near-white with invisible white text. Replace with a dedicated .portal-visit-card class (mirrors .portal-new-assessment-card: gradient !important, transparent card-body, white text, styled icon-circle) in a distinct blue->indigo gradient so the two featured tiles read as different. Bump 19.0.2.10.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_portal/__manifest__.py | 2 +- fusion_portal/static/src/css/portal_style.css | 36 +++++++++++++++++++ fusion_portal/views/portal_templates.xml | 4 +-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/fusion_portal/__manifest__.py b/fusion_portal/__manifest__.py index d87fcb75..198f0e57 100644 --- a/fusion_portal/__manifest__.py +++ b/fusion_portal/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'Fusion Authorizer & Sales Portal', - 'version': '19.0.2.10.0', + 'version': '19.0.2.10.1', 'category': 'Sales/Portal', 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', 'description': """ diff --git a/fusion_portal/static/src/css/portal_style.css b/fusion_portal/static/src/css/portal_style.css index 01c40e35..b3f14fd1 100644 --- a/fusion_portal/static/src/css/portal_style.css +++ b/fusion_portal/static/src/css/portal_style.css @@ -448,6 +448,42 @@ font-size: 1.25rem; } +/* Start a Visit Card on Portal Home (distinct blue->indigo so it differs from + the green New Assessment tile). Mirrors .portal-new-assessment-card. */ +.portal-visit-card { + background: linear-gradient(135deg, #2e7aad 0%, #4338ca 100%) !important; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.portal-visit-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px rgba(67, 56, 202, 0.3) !important; +} + +.portal-visit-card .card-body { + background: transparent !important; +} + +.portal-visit-card h5, +.portal-visit-card small { + color: #fff !important; +} + +.portal-visit-card .icon-circle { + width: 50px; + height: 50px; + background: rgba(255,255,255,0.25) !important; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.portal-visit-card .icon-circle i { + color: #fff !important; + font-size: 1.25rem; +} + /* Authorizer Portal Card on Portal Home */ .portal-authorizer-card { background: var(--fc-portal-gradient, linear-gradient(135deg, #5ba848 0%, #3a8fb7 60%, #2e7aad 100%)) !important; diff --git a/fusion_portal/views/portal_templates.xml b/fusion_portal/views/portal_templates.xml index 7d92664f..ba1fee7b 100644 --- a/fusion_portal/views/portal_templates.xml +++ b/fusion_portal/views/portal_templates.xml @@ -53,8 +53,8 @@