- Vendors blocked from automatic email bill creation will appear here.
- Block vendors whose bills should be created through Purchase Orders instead.
-
-
- Emails from this vendor will not create vendor bills automatically.
- Bills for this vendor should be created through Purchase Orders.
-
-
-
-
-
-
-
-
-
-
-
-
- res.partner.list.fusion.accounts
- res.partner
- 99
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/fusion_authorizer_portal/.DS_Store b/fusion_authorizer_portal/.DS_Store
new file mode 100644
index 0000000..1a29dce
Binary files /dev/null and b/fusion_authorizer_portal/.DS_Store differ
diff --git a/fusion_authorizer_portal/controllers/pdf_editor.py b/fusion_authorizer_portal/controllers/pdf_editor.py
index b3f14ef..9b3f098 100644
--- a/fusion_authorizer_portal/controllers/pdf_editor.py
+++ b/fusion_authorizer_portal/controllers/pdf_editor.py
@@ -35,6 +35,7 @@ class FusionPdfEditorController(http.Controller):
fields = template.field_ids.read([
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
+ 'text_align',
])
return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
@@ -56,6 +57,7 @@ class FusionPdfEditorController(http.Controller):
return template.field_ids.read([
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
+ 'text_align',
])
# ================================================================
@@ -73,6 +75,7 @@ class FusionPdfEditorController(http.Controller):
allowed = {
'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
+ 'text_align',
}
safe_values = {k: v for k, v in values.items() if k in allowed}
if safe_values:
diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/README.md b/fusion_authorizer_portal/fusion_authorizer_portal/README.md
deleted file mode 100644
index 36a60a2..0000000
--- a/fusion_authorizer_portal/fusion_authorizer_portal/README.md
+++ /dev/null
@@ -1,721 +0,0 @@
-# Fusion Authorizer & Sales Portal
-
-**Version:** 19.0.1.0.0
-**License:** LGPL-3
-**Category:** Sales/Portal
-**Author:** Fusion Claims
-
-## Table of Contents
-
-1. [Overview](#overview)
-2. [Features](#features)
-3. [Installation](#installation)
-4. [Configuration](#configuration)
-5. [Models](#models)
-6. [Controllers & Routes](#controllers--routes)
-7. [Security](#security)
-8. [Frontend Assets](#frontend-assets)
-9. [Email Templates](#email-templates)
-10. [User Guide](#user-guide)
-11. [API Reference](#api-reference)
-12. [Troubleshooting](#troubleshooting)
-13. [Changelog](#changelog)
-
----
-
-## Overview
-
-The **Fusion Authorizer & Sales Portal** module extends Odoo's portal functionality to provide external access for two key user types:
-
-- **Authorizers (Occupational Therapists/OTs):** Healthcare professionals who authorize ADP (Assistive Devices Program) claims
-- **Sales Representatives:** Field sales staff who conduct client assessments and manage orders
-
-This module integrates with the `fusion_claims` module to provide a seamless workflow for ADP claims management, from initial client assessment through to order completion.
-
-### Target Platform
-- **Odoo Enterprise v19**
-- Requires: `base`, `sale`, `portal`, `website`, `mail`, `fusion_claims`
-
----
-
-## Features
-
-### Authorizer Portal
-- View all assigned ADP cases with full details (excluding internal costs)
-- Real-time search by client name, reference numbers, or claim number
-- Upload ADP application documents with revision tracking
-- Add comments/notes to cases
-- Download submitted ADP applications
-- Receive email notifications for new assignments and status changes
-
-### Sales Rep Portal
-- View sales cases linked to the logged-in user
-- Start and manage client assessments
-- Record detailed wheelchair specifications and measurements
-- Capture digital signatures for ADP pages 11 & 12
-- Track assessment progress through workflow states
-
-### Assessment System
-- Comprehensive client information collection
-- Wheelchair specifications (seat width, depth, height, cushion type, etc.)
-- Accessibility and mobility needs documentation
-- Touch-friendly digital signature capture
-- Automatic draft Sale Order creation upon completion
-- Document distribution to authorizers, sales reps, and internal records
-- Automated email notifications
-
----
-
-## Installation
-
-### Prerequisites
-1. Odoo Enterprise v19 installed and running
-2. The `fusion_claims` module installed and configured
-3. Portal module enabled
-4. Website module enabled
-5. Mail module configured with outgoing email server
-
-### Installation Steps
-
-1. **Copy the module** to your Odoo addons directory:
- ```bash
- cp -r fusion_authorizer_portal /path/to/odoo/custom-addons/
- ```
-
-2. **Update the apps list** in Odoo:
- - Go to Apps menu
- - Click "Update Apps List"
- - Search for "Fusion Authorizer"
-
-3. **Install the module**:
- - Click Install on "Fusion Authorizer & Sales Portal"
- - Wait for installation to complete
-
-4. **Restart Odoo** (recommended):
- ```bash
- docker restart odoo-app # For Docker installations
- # OR
- sudo systemctl restart odoo # For systemd installations
- ```
-
----
-
-## Configuration
-
-### Granting Portal Access to Users
-
-1. Navigate to **Contacts** in Odoo backend
-2. Open the contact record for the authorizer or sales rep
-3. Go to the **Portal Access** tab
-4. Check the appropriate role:
- - `Is Authorizer` - For Occupational Therapists
- - `Is Sales Rep (Portal)` - For Sales Representatives
-5. Click the **Grant Portal Access** button
-6. An invitation email will be sent to the contact's email address
-
-### Setting Up Authorizers on Sales Orders
-
-1. Open a Sales Order
-2. In the order details, set the **Authorizer** field (`x_fc_authorizer_id`)
-3. The authorizer will receive an email notification about the assignment
-4. The case will appear in their portal dashboard
-
----
-
-## Models
-
-### New Models
-
-#### `fusion.assessment`
-**Wheelchair Assessment Record**
-
-Captures comprehensive client assessment data including:
-
-| Field Group | Fields |
-|-------------|--------|
-| **Client Info** | `client_name`, `client_first_name`, `client_last_name`, `client_street`, `client_city`, `client_state`, `client_postal_code`, `client_country_id`, `client_phone`, `client_mobile`, `client_email`, `client_dob`, `client_health_card`, `client_reference_1`, `client_reference_2` |
-| **Participants** | `sales_rep_id` (res.users), `authorizer_id` (res.partner) |
-| **Assessment Details** | `assessment_date`, `assessment_location`, `assessment_location_notes` |
-| **Measurements** | `seat_width`, `seat_depth`, `seat_to_floor_height`, `back_height`, `armrest_height`, `footrest_length`, `overall_width`, `overall_length`, `overall_height`, `seat_angle`, `back_angle`, `client_weight`, `client_height` |
-| **Product Types** | `cushion_type`, `cushion_notes`, `backrest_type`, `backrest_notes`, `frame_type`, `frame_notes`, `wheel_type`, `wheel_notes` |
-| **Needs** | `mobility_notes`, `accessibility_notes`, `special_requirements`, `diagnosis` |
-| **Signatures** | `signature_page_11`, `signature_page_11_name`, `signature_page_11_date`, `signature_page_12`, `signature_page_12_name`, `signature_page_12_date` |
-| **Status** | `state` (draft, pending_signature, completed, cancelled) |
-| **References** | `reference` (auto-generated ASM-XXXXX), `sale_order_id`, `partner_id` |
-
-**Key Methods:**
-- `action_complete()` - Completes assessment, creates draft Sale Order, sends notifications
-- `_ensure_partner()` - Creates or links res.partner for the client
-- `_create_draft_sale_order()` - Generates Sale Order with specifications
-- `_generate_signed_documents()` - Creates document records for signatures
-- `_send_completion_notifications()` - Sends emails to authorizer and client
-
----
-
-#### `fusion.adp.document`
-**ADP Document Management with Revision Tracking**
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `sale_order_id` | Many2one | Link to Sale Order |
-| `assessment_id` | Many2one | Link to Assessment |
-| `document_type` | Selection | full_application, page_11, page_12, pages_11_12, final_submission, other |
-| `file` | Binary | Document file content |
-| `filename` | Char | Original filename |
-| `file_size` | Integer | File size in bytes |
-| `mimetype` | Char | MIME type |
-| `revision` | Integer | Revision number (auto-incremented) |
-| `revision_note` | Text | Notes about this revision |
-| `is_current` | Boolean | Whether this is the current version |
-| `uploaded_by` | Many2one | User who uploaded |
-| `upload_date` | Datetime | Upload timestamp |
-| `source` | Selection | portal, internal, assessment |
-
-**Key Methods:**
-- `action_download()` - Download the document
-- `get_documents_for_order()` - Get all documents for a sale order
-- `get_revision_history()` - Get all revisions of a document type
-
----
-
-#### `fusion.authorizer.comment`
-**Portal Comments System**
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `sale_order_id` | Many2one | Link to Sale Order |
-| `assessment_id` | Many2one | Link to Assessment |
-| `author_id` | Many2one | res.partner who authored |
-| `author_user_id` | Many2one | res.users who authored |
-| `comment` | Text | Comment content |
-| `comment_type` | Selection | general, question, update, approval |
-| `is_internal` | Boolean | Internal-only comment |
-
----
-
-### Extended Models
-
-#### `res.partner` (Extended)
-
-| New Field | Type | Description |
-|-----------|------|-------------|
-| `is_authorizer` | Boolean | Partner is an Authorizer/OT |
-| `is_sales_rep_portal` | Boolean | Partner is a Sales Rep with portal access |
-| `authorizer_portal_user_id` | Many2one | Linked portal user account |
-| `assigned_case_count` | Integer | Computed count of assigned cases |
-| `assessment_count` | Integer | Computed count of assessments |
-
-**New Methods:**
-- `action_grant_portal_access()` - Creates portal user and sends invitation
-- `action_view_assigned_cases()` - Opens list of assigned Sale Orders
-- `action_view_assessments()` - Opens list of assessments
-
----
-
-#### `sale.order` (Extended)
-
-| New Field | Type | Description |
-|-----------|------|-------------|
-| `portal_comment_ids` | One2many | Comments from portal users |
-| `portal_comment_count` | Integer | Computed comment count |
-| `portal_document_ids` | One2many | Documents uploaded via portal |
-| `portal_document_count` | Integer | Computed document count |
-| `assessment_id` | Many2one | Source assessment that created this order |
-| `portal_authorizer_id` | Many2one | Authorizer reference (computed from x_fc_authorizer_id) |
-
-**New Methods:**
-- `_send_authorizer_assignment_notification()` - Email on authorizer assignment
-- `_send_status_change_notification()` - Email on status change
-- `get_portal_display_data()` - Safe data for portal display (excludes costs)
-- `get_authorizer_portal_cases()` - Search cases for authorizer portal
-- `get_sales_rep_portal_cases()` - Search cases for sales rep portal
-
----
-
-## Controllers & Routes
-
-### Authorizer Portal Routes
-
-| Route | Method | Auth | Description |
-|-------|--------|------|-------------|
-| `/my/authorizer` | GET | user | Authorizer dashboard |
-| `/my/authorizer/cases` | GET | user | List of assigned cases |
-| `/my/authorizer/cases/search` | POST | user | AJAX search (jsonrpc) |
-| `/my/authorizer/case/` | GET | user | Case detail view |
-| `/my/authorizer/case//comment` | POST | user | Add comment to case |
-| `/my/authorizer/case//upload` | POST | user | Upload document |
-| `/my/authorizer/document//download` | GET | user | Download document |
-
-### Sales Rep Portal Routes
-
-| Route | Method | Auth | Description |
-|-------|--------|------|-------------|
-| `/my/sales` | GET | user | Sales rep dashboard |
-| `/my/sales/cases` | GET | user | List of sales cases |
-| `/my/sales/cases/search` | POST | user | AJAX search (jsonrpc) |
-| `/my/sales/case/` | GET | user | Case detail view |
-
-### Assessment Routes
-
-| Route | Method | Auth | Description |
-|-------|--------|------|-------------|
-| `/my/assessments` | GET | user | List of assessments |
-| `/my/assessment/new` | GET | user | New assessment form |
-| `/my/assessment/` | GET | user | View/edit assessment |
-| `/my/assessment/save` | POST | user | Save assessment data |
-| `/my/assessment//signatures` | GET | user | Signature capture page |
-| `/my/assessment//save_signature` | POST | user | Save signature (jsonrpc) |
-| `/my/assessment//complete` | POST | user | Complete assessment |
-
----
-
-## Security
-
-### Security Groups
-
-| Group | XML ID | Description |
-|-------|--------|-------------|
-| Authorizer Portal | `group_authorizer_portal` | Access to authorizer portal features |
-| Sales Rep Portal | `group_sales_rep_portal` | Access to sales rep portal features |
-
-### Record Rules
-
-| Model | Rule | Description |
-|-------|------|-------------|
-| `fusion.authorizer.comment` | Portal Read | Users can read non-internal comments on their cases |
-| `fusion.authorizer.comment` | Portal Create | Users can create comments on their cases |
-| `fusion.adp.document` | Portal Read | Users can read documents on their cases |
-| `fusion.adp.document` | Portal Create | Users can upload documents to their cases |
-| `fusion.assessment` | Portal Access | Users can access assessments they're linked to |
-| `sale.order` | Portal Authorizer | Authorizers can view their assigned orders |
-
-### Access Rights (ir.model.access.csv)
-
-| Model | Group | Read | Write | Create | Unlink |
-|-------|-------|------|-------|--------|--------|
-| `fusion.authorizer.comment` | base.group_user | 1 | 1 | 1 | 1 |
-| `fusion.authorizer.comment` | base.group_portal | 1 | 0 | 1 | 0 |
-| `fusion.adp.document` | base.group_user | 1 | 1 | 1 | 1 |
-| `fusion.adp.document` | base.group_portal | 1 | 0 | 1 | 0 |
-| `fusion.assessment` | base.group_user | 1 | 1 | 1 | 1 |
-| `fusion.assessment` | base.group_portal | 1 | 1 | 1 | 0 |
-
----
-
-## Frontend Assets
-
-### CSS (`static/src/css/portal_style.css`)
-
-Custom portal styling with a dark blue and green color scheme:
-
-- **Primary Color:** Dark blue (#1e3a5f)
-- **Secondary Color:** Medium blue (#2c5282)
-- **Accent Color:** Green (#38a169)
-- **Background:** Light gray (#f7fafc)
-
-Styled components:
-- Portal cards with shadow effects
-- Status badges with color coding
-- Custom buttons with hover effects
-- Responsive tables
-- Form inputs with focus states
-
-### JavaScript
-
-#### `portal_search.js`
-Real-time search functionality:
-- Debounced input handling (300ms delay)
-- AJAX calls to search endpoints
-- Dynamic table updates
-- Search result highlighting
-
-#### `assessment_form.js`
-Assessment form enhancements:
-- Unsaved changes warning
-- Auto-fill client name from first/last name
-- Number input validation
-- Form state tracking
-
-#### `signature_pad.js`
-Digital signature capture:
-- HTML5 Canvas-based drawing
-- Touch and mouse event support
-- Clear signature functionality
-- Export to base64 PNG
-- AJAX save to server
-
----
-
-## Email Templates
-
-### Case Assignment (`mail_template_case_assigned`)
-**Trigger:** Authorizer assigned to a Sale Order
-**Recipient:** Authorizer email
-**Content:** Case details, client information, link to portal
-
-### Status Change (`mail_template_status_changed`)
-**Trigger:** Sale Order state changes
-**Recipient:** Assigned authorizer
-**Content:** Previous and new status, case details
-
-### Assessment Complete - Authorizer (`mail_template_assessment_complete_authorizer`)
-**Trigger:** Assessment completed
-**Recipient:** Assigned authorizer
-**Content:** Assessment details, measurements, signed documents
-
-### Assessment Complete - Client (`mail_template_assessment_complete_client`)
-**Trigger:** Assessment completed
-**Recipient:** Client email
-**Content:** Confirmation, next steps, measurements summary
-
-### Document Uploaded (`mail_template_document_uploaded`)
-**Trigger:** Document uploaded via portal
-**Recipient:** Internal team
-**Content:** Document details, revision info, download link
-
----
-
-## User Guide
-
-### For Administrators
-
-#### Granting Portal Access
-
-1. Go to **Contacts** > Select the contact
-2. Navigate to the **Portal Access** tab
-3. Enable the appropriate role:
- - Check `Is Authorizer` for OTs/Therapists
- - Check `Is Sales Rep (Portal)` for Sales Reps
-4. Click **Grant Portal Access**
-5. The user receives an email with login instructions
-
-#### Assigning Cases to Authorizers
-
-1. Open a **Sale Order**
-2. Set the **Authorizer** field to the appropriate contact
-3. Save the order
-4. The authorizer receives a notification email
-5. The case appears in their portal dashboard
-
----
-
-### For Authorizers
-
-#### Accessing the Portal
-
-1. Visit `https://your-domain.com/my`
-2. Log in with your portal credentials
-3. Click **Authorizer Portal** in the menu
-
-#### Viewing Cases
-
-1. From the dashboard, view recent cases and statistics
-2. Click **View All Cases** or **My Cases** for the full list
-3. Use the search bar to find specific cases by:
- - Client name
- - Client reference 1 or 2
- - Claim number
-
-#### Adding Comments
-
-1. Open a case detail view
-2. Scroll to the Comments section
-3. Enter your comment
-4. Select comment type (General, Question, Update, Approval)
-5. Click **Add Comment**
-
-#### Uploading Documents
-
-1. Open a case detail view
-2. Go to the Documents section
-3. Click **Upload Document**
-4. Select document type (Full Application, Page 11, Page 12, etc.)
-5. Choose the file and add revision notes
-6. Click **Upload**
-
----
-
-### For Sales Representatives
-
-#### Starting a New Assessment
-
-1. Log in to the portal
-2. Click **New Assessment**
-3. Fill in client information:
- - Name, address, contact details
- - Client references
-4. Record wheelchair specifications:
- - Measurements (seat width, depth, height)
- - Product types (cushion, backrest, frame, wheels)
-5. Document accessibility and mobility needs
-6. Click **Save & Continue**
-
-#### Capturing Signatures
-
-1. After saving assessment data, click **Proceed to Signatures**
-2. **Page 11 (Authorizer):**
- - Have the OT sign on the canvas
- - Enter their printed name
- - Click **Save Signature**
-3. **Page 12 (Client):**
- - Have the client sign on the canvas
- - Enter their printed name
- - Click **Save Signature**
-
-#### Completing the Assessment
-
-1. Once both signatures are captured, click **Complete Assessment**
-2. The system will:
- - Create a new customer record (if needed)
- - Generate a draft Sale Order
- - Attach signed documents
- - Send notification emails
-3. The assessment moves to "Completed" status
-
----
-
-## API Reference
-
-### Assessment Model Methods
-
-```python
-# Complete an assessment and create Sale Order
-assessment.action_complete()
-
-# Get formatted specifications for order notes
-specs = assessment._format_specifications_for_order()
-
-# Ensure partner exists or create new
-partner = assessment._ensure_partner()
-```
-
-### Sale Order Portal Methods
-
-```python
-# Get safe data for portal display (no costs)
-data = order.get_portal_display_data()
-
-# Search cases for authorizer
-cases = SaleOrder.get_authorizer_portal_cases(
- partner_id=123,
- search_query='Smith',
- limit=50,
- offset=0
-)
-
-# Search cases for sales rep
-cases = SaleOrder.get_sales_rep_portal_cases(
- user_id=456,
- search_query='wheelchair',
- limit=50,
- offset=0
-)
-```
-
-### Partner Methods
-
-```python
-# Grant portal access programmatically
-partner.action_grant_portal_access()
-
-# Check if partner is an authorizer
-if partner.is_authorizer:
- cases = partner.assigned_case_count
-```
-
-### Document Methods
-
-```python
-# Get all documents for an order
-docs = ADPDocument.get_documents_for_order(sale_order_id)
-
-# Get revision history
-history = document.get_revision_history()
-```
-
----
-
-## Troubleshooting
-
-### Common Errors
-
-#### Error: `Invalid field 'in_portal' in 'portal.wizard.user'`
-
-**Cause:** Odoo 19 changed the portal wizard API, removing the `in_portal` field.
-
-**Solution:** The `action_grant_portal_access` method has been updated to:
-1. First attempt using the standard portal wizard
-2. If that fails, fall back to direct user creation with portal group assignment
-
-```python
-# The fallback code creates the user directly:
-portal_group = self.env.ref('base.group_portal')
-portal_user = self.env['res.users'].sudo().create({
- 'name': self.name,
- 'login': self.email,
- 'email': self.email,
- 'partner_id': self.id,
- 'groups_id': [(6, 0, [portal_group.id])],
-})
-```
-
----
-
-#### Error: `Invalid view type: 'tree'`
-
-**Cause:** Odoo 19 renamed `` views to ``.
-
-**Solution:** Replace all `` tags with `` in XML view definitions:
-```xml
-
-...
-
-
-...
-```
-
----
-
-#### Error: `Invalid field 'category_id' in 'res.groups'`
-
-**Cause:** Odoo 19 no longer supports `category_id` in `res.groups` XML definitions.
-
-**Solution:** Remove the `` element from security group definitions:
-```xml
-
-
-```
-
----
-
-#### Error: `DeprecationWarning: @route(type='json') is deprecated`
-
-**Cause:** Odoo 19 uses `type='jsonrpc'` instead of `type='json'`.
-
-**Solution:** Update route decorators:
-```python
-# Old
-@http.route('/my/endpoint', type='json', auth='user')
-
-# New
-@http.route('/my/endpoint', type='jsonrpc', auth='user')
-```
-
----
-
-### Portal Access Issues
-
-#### User can't see cases in portal
-
-1. Verify the partner has `is_authorizer` or `is_sales_rep_portal` checked
-2. Verify the `authorizer_portal_user_id` is set
-3. For authorizers, verify the Sale Order has `x_fc_authorizer_id` set to their partner ID
-4. For sales reps, verify the Sale Order has `user_id` set to their user ID
-
-#### Email notifications not sending
-
-1. Check that the outgoing mail server is configured in Odoo
-2. Verify the email templates exist and are active
-3. Check the mail queue (Settings > Technical > Email > Emails)
-4. Review the Odoo logs for mail errors
-
----
-
-### Debug Logging
-
-Enable debug logging for this module:
-
-```python
-import logging
-_logger = logging.getLogger('fusion_authorizer_portal')
-_logger.setLevel(logging.DEBUG)
-```
-
-Or in Odoo configuration:
-```ini
-[options]
-log_handler = fusion_authorizer_portal:DEBUG
-```
-
----
-
-## Changelog
-
-### Version 19.0.1.0.0 (Initial Release)
-
-**New Features:**
-- Authorizer Portal with case management
-- Sales Rep Portal with assessment forms
-- Wheelchair Assessment model with 50+ fields
-- Digital signature capture (Pages 11 & 12)
-- Document management with revision tracking
-- Real-time search functionality
-- Email notifications for key events
-- Portal access management from partner form
-
-**Technical:**
-- Compatible with Odoo Enterprise v19
-- Integrates with fusion_claims module
-- Mobile-responsive portal design
-- Touch-friendly signature pad
-- AJAX-powered search
-
-**Bug Fixes:**
-- Fixed `in_portal` field error in Odoo 19 portal wizard
-- Fixed `tree` to `list` view type for Odoo 19
-- Fixed `category_id` error in security groups
-- Fixed `type='json'` deprecation warning
-
----
-
-## File Structure
-
-```
-fusion_authorizer_portal/
-├── __init__.py
-├── __manifest__.py
-├── README.md
-├── controllers/
-│ ├── __init__.py
-│ ├── portal_main.py # Authorizer & Sales Rep portal routes
-│ └── portal_assessment.py # Assessment routes
-├── data/
-│ ├── mail_template_data.xml # Email templates & sequences
-│ └── portal_menu_data.xml # Portal menu items
-├── models/
-│ ├── __init__.py
-│ ├── adp_document.py # Document management model
-│ ├── assessment.py # Assessment model
-│ ├── authorizer_comment.py # Comments model
-│ ├── res_partner.py # Partner extensions
-│ └── sale_order.py # Sale Order extensions
-├── security/
-│ ├── ir.model.access.csv # Access rights
-│ └── portal_security.xml # Groups & record rules
-├── static/
-│ └── src/
-│ ├── css/
-│ │ └── portal_style.css # Portal styling
-│ └── js/
-│ ├── assessment_form.js # Form enhancements
-│ ├── portal_search.js # Real-time search
-│ └── signature_pad.js # Signature capture
-└── views/
- ├── assessment_views.xml # Assessment backend views
- ├── portal_templates.xml # Portal QWeb templates
- ├── res_partner_views.xml # Partner form extensions
- └── sale_order_views.xml # Sale Order extensions
-```
-
----
-
-## Support
-
-For support or feature requests, contact:
-
-- **Email:** support@fusionclaims.com
-- **Website:** https://fusionclaims.com
-
----
-
-*Last Updated: January 2026*
diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/__init__.py b/fusion_authorizer_portal/fusion_authorizer_portal/__init__.py
deleted file mode 100644
index c3d410e..0000000
--- a/fusion_authorizer_portal/fusion_authorizer_portal/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from . import models
-from . import controllers
diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/__manifest__.py b/fusion_authorizer_portal/fusion_authorizer_portal/__manifest__.py
deleted file mode 100644
index 283339b..0000000
--- a/fusion_authorizer_portal/fusion_authorizer_portal/__manifest__.py
+++ /dev/null
@@ -1,100 +0,0 @@
-# -*- coding: utf-8 -*-
-{
- 'name': 'Fusion Authorizer & Sales Portal',
- 'version': '19.0.2.0.9',
- 'category': 'Sales/Portal',
- 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
- 'description': """
-Fusion Authorizer & Sales Rep Portal
-=====================================
-
-This module provides external portal access for:
-
-**Authorizers (Occupational Therapists)**
-- View assigned ADP cases
-- Upload documents (ADP applications, signed pages)
-- Add comments to cases
-- Complete assessments with clients
-- Capture digital signatures for ADP pages 11 & 12
-
-**Sales Representatives**
-- View their sales cases
-- Start new client assessments
-- Record wheelchair specifications and measurements
-- Capture client signatures
-- Track assessment progress
-
-**Assessment System**
-- Client information collection
-- Wheelchair specifications (seat width, depth, height, etc.)
-- Accessibility and mobility needs documentation
-- Digital signature capture for ADP pages 11 & 12
-- Automatic draft Sale Order creation
-- Document distribution to all parties
-- Automated email notifications
-
-**Features**
-- Real-time client search
-- Document version tracking
-- Mobile-friendly signature capture
-- Email notifications for status changes
-- Secure portal access with role-based permissions
- """,
- 'author': 'Fusion Claims',
- 'website': 'https://fusionclaims.com',
- 'license': 'LGPL-3',
- 'depends': [
- 'base',
- 'sale',
- 'portal',
- 'website',
- 'mail',
- 'calendar',
- 'knowledge',
- 'fusion_claims',
- ],
- 'data': [
- # Security
- 'security/portal_security.xml',
- 'security/ir.model.access.csv',
- # Data
- 'data/mail_template_data.xml',
- 'data/portal_menu_data.xml',
- 'data/ir_actions_server_data.xml',
- 'data/welcome_articles.xml',
- # Views
- 'views/res_partner_views.xml',
- 'views/sale_order_views.xml',
- 'views/assessment_views.xml',
- 'views/pdf_template_views.xml',
- # Portal Templates
- 'views/portal_templates.xml',
- 'views/portal_assessment_express.xml',
- 'views/portal_pdf_editor.xml',
- 'views/portal_accessibility_templates.xml',
- 'views/portal_accessibility_forms.xml',
- 'views/portal_technician_templates.xml',
- 'views/portal_book_assessment.xml',
- ],
- 'assets': {
- 'web.assets_backend': [
- 'fusion_authorizer_portal/static/src/xml/chatter_message_authorizer.xml',
- 'fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js',
- ],
- 'web.assets_frontend': [
- 'fusion_authorizer_portal/static/src/css/portal_style.css',
- 'fusion_authorizer_portal/static/src/css/technician_portal.css',
- 'fusion_authorizer_portal/static/src/js/portal_search.js',
- 'fusion_authorizer_portal/static/src/js/assessment_form.js',
- 'fusion_authorizer_portal/static/src/js/signature_pad.js',
- 'fusion_authorizer_portal/static/src/js/loaner_portal.js',
- 'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
- 'fusion_authorizer_portal/static/src/js/technician_push.js',
- 'fusion_authorizer_portal/static/src/js/technician_location.js',
- ],
- },
- 'images': ['static/description/icon.png'],
- 'installable': True,
- 'application': False,
- 'auto_install': False,
-}
diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/__init__.py b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/__init__.py
deleted file mode 100644
index 81b165c..0000000
--- a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from . import portal_main
-from . import portal_assessment
-from . import pdf_editor
\ No newline at end of file
diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/pdf_editor.py b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/pdf_editor.py
deleted file mode 100644
index 9b3f098..0000000
--- a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/pdf_editor.py
+++ /dev/null
@@ -1,218 +0,0 @@
-# -*- coding: utf-8 -*-
-# Fusion PDF Field Editor Controller
-# Provides routes for the visual drag-and-drop field position editor
-
-import base64
-import json
-import logging
-
-from odoo import http
-from odoo.http import request
-
-_logger = logging.getLogger(__name__)
-
-
-class FusionPdfEditorController(http.Controller):
- """Controller for the PDF field position visual editor."""
-
- # ================================================================
- # Editor Page
- # ================================================================
-
- @http.route('/fusion/pdf-editor/', type='http', auth='user', website=True)
- def pdf_field_editor(self, template_id, **kw):
- """Render the visual field editor for a PDF template."""
- template = request.env['fusion.pdf.template'].browse(template_id)
- if not template.exists():
- return request.redirect('/web')
-
- # Get preview image for page 1
- preview_url = ''
- preview = template.preview_ids.filtered(lambda p: p.page == 1)
- if preview and preview[0].image:
- preview_url = f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image'
-
- fields = template.field_ids.read([
- 'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
- 'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
- 'text_align',
- ])
-
- return request.render('fusion_authorizer_portal.portal_pdf_field_editor', {
- 'template': template,
- 'fields': fields,
- 'preview_url': preview_url,
- })
-
- # ================================================================
- # JSONRPC: Get fields for template
- # ================================================================
-
- @http.route('/fusion/pdf-editor/fields', type='json', auth='user')
- def get_fields(self, template_id, **kw):
- """Return all fields for a template."""
- template = request.env['fusion.pdf.template'].browse(template_id)
- if not template.exists():
- return []
- return template.field_ids.read([
- 'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
- 'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
- 'text_align',
- ])
-
- # ================================================================
- # JSONRPC: Update field position/properties
- # ================================================================
-
- @http.route('/fusion/pdf-editor/update-field', type='json', auth='user')
- def update_field(self, field_id, values, **kw):
- """Update a field's position or properties."""
- field = request.env['fusion.pdf.template.field'].browse(field_id)
- if not field.exists():
- return {'error': 'Field not found'}
-
- # Filter to allowed fields only
- allowed = {
- 'name', 'label', 'page', 'pos_x', 'pos_y', 'width', 'height',
- 'field_type', 'font_size', 'font_name', 'field_key', 'default_value', 'is_active',
- 'text_align',
- }
- safe_values = {k: v for k, v in values.items() if k in allowed}
- if safe_values:
- field.write(safe_values)
- return {'success': True}
-
- # ================================================================
- # JSONRPC: Create new field
- # ================================================================
-
- @http.route('/fusion/pdf-editor/create-field', type='json', auth='user')
- def create_field(self, **kw):
- """Create a new field on a template."""
- template_id = kw.get('template_id')
- if not template_id:
- return {'error': 'Missing template_id'}
-
- vals = {
- 'template_id': int(template_id),
- 'name': kw.get('name', 'new_field'),
- 'label': kw.get('label', 'New Field'),
- 'field_type': kw.get('field_type', 'text'),
- 'field_key': kw.get('field_key', kw.get('name', '')),
- 'page': int(kw.get('page', 1)),
- 'pos_x': float(kw.get('pos_x', 0.3)),
- 'pos_y': float(kw.get('pos_y', 0.3)),
- 'width': float(kw.get('width', 0.150)),
- 'height': float(kw.get('height', 0.015)),
- 'font_size': float(kw.get('font_size', 10)),
- }
-
- field = request.env['fusion.pdf.template.field'].create(vals)
- return {'id': field.id, 'success': True}
-
- # ================================================================
- # JSONRPC: Delete field
- # ================================================================
-
- @http.route('/fusion/pdf-editor/delete-field', type='json', auth='user')
- def delete_field(self, field_id, **kw):
- """Delete a field from a template."""
- field = request.env['fusion.pdf.template.field'].browse(field_id)
- if field.exists():
- field.unlink()
- return {'success': True}
-
- # ================================================================
- # JSONRPC: Get page preview image URL
- # ================================================================
-
- @http.route('/fusion/pdf-editor/page-image', type='json', auth='user')
- def get_page_image(self, template_id, page, **kw):
- """Return the preview image URL for a specific page."""
- template = request.env['fusion.pdf.template'].browse(template_id)
- if not template.exists():
- return {'image_url': ''}
-
- preview = template.preview_ids.filtered(lambda p: p.page == page)
- if preview and preview[0].image:
- return {'image_url': f'/web/image/fusion.pdf.template.preview/{preview[0].id}/image'}
- return {'image_url': ''}
-
- # ================================================================
- # Upload page preview image (from editor)
- # ================================================================
-
- @http.route('/fusion/pdf-editor/upload-preview', type='http', auth='user',
- methods=['POST'], csrf=True, website=True)
- def upload_preview_image(self, **kw):
- """Upload a preview image for a template page directly from the editor."""
- template_id = int(kw.get('template_id', 0))
- page = int(kw.get('page', 1))
- template = request.env['fusion.pdf.template'].browse(template_id)
- if not template.exists():
- return json.dumps({'error': 'Template not found'})
-
- image_file = request.httprequest.files.get('preview_image')
- if not image_file:
- return json.dumps({'error': 'No image uploaded'})
-
- image_data = base64.b64encode(image_file.read())
-
- # Find or create preview for this page
- preview = template.preview_ids.filtered(lambda p: p.page == page)
- if preview:
- preview[0].write({'image': image_data, 'image_filename': image_file.filename})
- else:
- request.env['fusion.pdf.template.preview'].create({
- 'template_id': template_id,
- 'page': page,
- 'image': image_data,
- 'image_filename': image_file.filename,
- })
-
- _logger.info("Uploaded preview image for template %s page %d", template.name, page)
- return request.redirect(f'/fusion/pdf-editor/{template_id}')
-
- # ================================================================
- # Preview: Generate sample filled PDF
- # ================================================================
-
- @http.route('/fusion/pdf-editor/preview/', type='http', auth='user')
- def preview_pdf(self, template_id, **kw):
- """Generate a preview filled PDF with sample data."""
- template = request.env['fusion.pdf.template'].browse(template_id)
- if not template.exists() or not template.pdf_file:
- return request.redirect('/web')
-
- # Build sample data for preview
- sample_context = {
- 'client_last_name': 'Smith',
- 'client_first_name': 'John',
- 'client_middle_name': 'A',
- 'client_health_card': '1234-567-890',
- 'client_health_card_version': 'AB',
- 'client_street': '123 Main Street',
- 'client_unit': 'Unit 4B',
- 'client_city': 'Toronto',
- 'client_state': 'Ontario',
- 'client_postal_code': 'M5V 2T6',
- 'client_phone': '(416) 555-0123',
- 'client_email': 'john.smith@example.com',
- 'client_weight': '185',
- 'consent_applicant': True,
- 'consent_agent': False,
- 'consent_date': '2026-02-08',
- 'agent_last_name': '',
- 'agent_first_name': '',
- }
-
- try:
- pdf_bytes = template.generate_filled_pdf(sample_context)
- headers = [
- ('Content-Type', 'application/pdf'),
- ('Content-Disposition', f'inline; filename="preview_{template.name}.pdf"'),
- ]
- return request.make_response(pdf_bytes, headers=headers)
- except Exception as e:
- _logger.error("PDF preview generation failed: %s", e)
- return request.redirect(f'/fusion/pdf-editor/{template_id}?error=preview_failed')
diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_assessment.py b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_assessment.py
deleted file mode 100644
index b69c431..0000000
--- a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_assessment.py
+++ /dev/null
@@ -1,1443 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from odoo import http, fields, _
-from odoo.http import request
-from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
-from odoo.exceptions import AccessError, MissingError, ValidationError
-import json
-import base64
-import logging
-from datetime import datetime
-from markupsafe import Markup
-
-_logger = logging.getLogger(__name__)
-
-
-class AssessmentPortal(CustomerPortal):
- """Portal controller for Assessments"""
-
- @http.route(['/my/assessments', '/my/assessments/page/'], type='http', auth='user', website=True)
- def portal_assessments(self, page=1, search='', state='', sortby='date', **kw):
- """List of assessments"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- Assessment = request.env['fusion.assessment'].sudo()
-
- # Build domain based on role
- domain = []
- if partner.is_authorizer and partner.is_sales_rep_portal:
- domain = ['|', ('authorizer_id', '=', partner.id), ('sales_rep_id', '=', user.id)]
- elif partner.is_authorizer:
- domain = [('authorizer_id', '=', partner.id)]
- elif partner.is_sales_rep_portal:
- domain = [('sales_rep_id', '=', user.id)]
-
- # Add state filter
- if state:
- domain.append(('state', '=', state))
-
- # Add search filter
- if search:
- domain = domain + [
- '|', '|',
- ('client_name', 'ilike', search),
- ('reference', 'ilike', search),
- ('client_email', 'ilike', search),
- ]
-
- # Sorting
- sortings = {
- 'date': {'label': _('Date'), 'order': 'assessment_date desc'},
- 'name': {'label': _('Client'), 'order': 'client_name'},
- 'reference': {'label': _('Reference'), 'order': 'reference'},
- 'state': {'label': _('Status'), 'order': 'state'},
- }
- order = sortings.get(sortby, sortings['date'])['order']
-
- # Pager
- assessment_count = Assessment.search_count(domain)
- pager = portal_pager(
- url='/my/assessments',
- url_args={'search': search, 'state': state, 'sortby': sortby},
- total=assessment_count,
- page=page,
- step=20,
- )
-
- # Get assessments
- assessments = Assessment.search(domain, order=order, limit=20, offset=pager['offset'])
-
- # State options for filter
- state_options = [
- ('', _('All')),
- ('draft', _('In Progress')),
- ('pending_signature', _('Pending Signatures')),
- ('completed', _('Completed')),
- ('cancelled', _('Cancelled')),
- ]
-
- values = {
- 'assessments': assessments,
- 'pager': pager,
- 'search': search,
- 'state': state,
- 'state_options': state_options,
- 'sortby': sortby,
- 'sortings': sortings,
- 'page_name': 'assessments',
- }
-
- return request.render('fusion_authorizer_portal.portal_assessments', values)
-
- @http.route('/my/assessment/new', type='http', auth='user', website=True)
- def portal_assessment_new(self, **kw):
- """Start a new assessment"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- # Get list of authorizers for dropdown (if sales rep starting assessment)
- authorizers = request.env['res.partner'].sudo().search([
- ('is_authorizer', '=', True),
- ])
-
- values = {
- 'partner': partner,
- 'user': user,
- 'authorizers': authorizers,
- 'countries': request.env['res.country'].sudo().search([]),
- 'default_country': request.env.ref('base.ca', raise_if_not_found=False),
- 'page_name': 'assessment_new',
- }
-
- return request.render('fusion_authorizer_portal.portal_assessment_form', values)
-
- @http.route('/my/assessment/', type='http', auth='user', website=True)
- def portal_assessment_view(self, assessment_id, **kw):
- """View/edit an assessment"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- try:
- assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
- if not assessment.exists():
- raise MissingError(_('Assessment not found.'))
-
- # Check access
- has_access = (
- (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
- (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
- )
- if not has_access:
- raise AccessError(_('You do not have access to this assessment.'))
-
- except (AccessError, MissingError):
- return request.redirect('/my/assessments')
-
- # Get list of authorizers for dropdown
- authorizers = request.env['res.partner'].sudo().search([
- ('is_authorizer', '=', True),
- ])
-
- # Get assessment photos
- photos = request.env['ir.attachment'].sudo().search([
- ('res_model', '=', 'fusion.assessment'),
- ('res_id', '=', assessment.id),
- ('mimetype', 'like', 'image/%'),
- ])
-
- values = {
- 'assessment': assessment,
- 'partner': partner,
- 'user': user,
- 'authorizers': authorizers,
- 'countries': request.env['res.country'].sudo().search([]),
- 'page_name': 'assessment_edit',
- 'is_readonly': assessment.state in ['completed', 'cancelled'],
- 'photos': photos,
- }
-
- return request.render('fusion_authorizer_portal.portal_assessment_form', values)
-
- @http.route('/my/assessment/save', type='http', auth='user', website=True, methods=['POST'], csrf=True)
- def portal_assessment_save(self, assessment_id=None, **kw):
- """Save assessment data (create or update)"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- Assessment = request.env['fusion.assessment'].sudo()
-
- # Prepare values
- vals = {
- 'client_name': kw.get('client_name', ''),
- 'client_first_name': kw.get('client_first_name', ''),
- 'client_last_name': kw.get('client_last_name', ''),
- 'client_street': kw.get('client_street', ''),
- 'client_unit': kw.get('client_unit', ''),
- 'client_city': kw.get('client_city', ''),
- 'client_state': kw.get('client_state', 'Ontario'),
- 'client_postal_code': kw.get('client_postal_code', ''),
- 'client_phone': kw.get('client_phone', ''),
- 'client_mobile': kw.get('client_mobile', ''),
- 'client_email': kw.get('client_email', ''),
- 'client_reference_1': kw.get('client_reference_1', ''),
- 'client_reference_2': kw.get('client_reference_2', ''),
- 'assessment_location': kw.get('assessment_location', 'home'),
- 'assessment_location_notes': kw.get('assessment_location_notes', ''),
- }
-
- # Wheelchair specifications
- float_fields = [
- 'seat_width', 'seat_depth', 'seat_to_floor_height', 'back_height',
- 'armrest_height', 'footrest_length', 'overall_width', 'overall_length',
- 'overall_height', 'seat_angle', 'back_angle', 'client_weight', 'client_height'
- ]
- for field in float_fields:
- if kw.get(field):
- try:
- vals[field] = float(kw.get(field))
- except (ValueError, TypeError):
- pass
-
- # Selection fields
- selection_fields = ['cushion_type', 'backrest_type', 'frame_type', 'wheel_type']
- for field in selection_fields:
- if kw.get(field):
- vals[field] = kw.get(field)
-
- # Text fields
- text_fields = ['cushion_notes', 'backrest_notes', 'frame_notes', 'wheel_notes',
- 'mobility_notes', 'accessibility_notes', 'special_requirements', 'diagnosis']
- for field in text_fields:
- if kw.get(field):
- vals[field] = kw.get(field)
-
- # Authorizer
- if kw.get('authorizer_id'):
- try:
- vals['authorizer_id'] = int(kw.get('authorizer_id'))
- except (ValueError, TypeError):
- pass
-
- # Country
- if kw.get('client_country_id'):
- try:
- vals['client_country_id'] = int(kw.get('client_country_id'))
- except (ValueError, TypeError):
- pass
-
- try:
- if assessment_id and assessment_id != 'None':
- # Update existing
- assessment = Assessment.browse(int(assessment_id))
- if not assessment.exists():
- raise MissingError(_('Assessment not found.'))
-
- # Check access
- has_access = (
- (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
- (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
- )
- if not has_access:
- raise AccessError(_('You do not have access to this assessment.'))
-
- if assessment.state not in ['draft', 'pending_signature']:
- raise ValidationError(_('Cannot modify a completed or cancelled assessment.'))
-
- assessment.write(vals)
- _logger.info(f"Updated assessment {assessment.reference}")
-
- else:
- # Create new
- vals['sales_rep_id'] = user.id
- if partner.is_authorizer:
- vals['authorizer_id'] = partner.id
-
- assessment = Assessment.create(vals)
- _logger.info(f"Created new assessment {assessment.reference}")
-
- # Redirect based on action
- action = kw.get('action', 'save')
- if action == 'save_signatures':
- return request.redirect(f'/my/assessment/{assessment.id}/signatures')
- elif action == 'save_exit':
- return request.redirect('/my/assessments')
- else:
- return request.redirect(f'/my/assessment/{assessment.id}')
-
- except Exception as e:
- _logger.error(f"Error saving assessment: {e}")
- return request.redirect('/my/assessments')
-
- @http.route('/my/assessment//signatures', type='http', auth='user', website=True)
- def portal_assessment_signatures(self, assessment_id, **kw):
- """Signature capture page"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- try:
- assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
- if not assessment.exists():
- raise MissingError(_('Assessment not found.'))
-
- # Check access
- has_access = (
- (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
- (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
- )
- if not has_access:
- raise AccessError(_('You do not have access to this assessment.'))
-
- except (AccessError, MissingError):
- return request.redirect('/my/assessments')
-
- values = {
- 'assessment': assessment,
- 'partner': partner,
- 'page_name': 'assessment_signatures',
- }
-
- return request.render('fusion_authorizer_portal.portal_assessment_signatures', values)
-
- @http.route('/my/assessment//save_signature', type='jsonrpc', auth='user')
- def portal_save_signature(self, assessment_id, signature_type='', signature_data='', signer_name='', **kw):
- """Save a signature (AJAX)"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return {'success': False, 'error': 'Access denied'}
-
- try:
- assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
- if not assessment.exists():
- return {'success': False, 'error': 'Assessment not found'}
-
- # Check access
- has_access = (
- (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
- (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
- )
- if not has_access:
- return {'success': False, 'error': 'Access denied'}
-
- if not signature_data:
- return {'success': False, 'error': 'No signature data provided'}
-
- # Remove data URL prefix if present
- if signature_data.startswith('data:image'):
- signature_data = signature_data.split(',')[1]
-
- vals = {}
- if signature_type == 'page_11':
- vals = {
- 'signature_page_11': signature_data,
- 'signature_page_11_name': signer_name,
- 'signature_page_11_date': datetime.now(),
- }
- elif signature_type == 'page_12':
- vals = {
- 'signature_page_12': signature_data,
- 'signature_page_12_name': signer_name,
- 'signature_page_12_date': datetime.now(),
- }
- else:
- return {'success': False, 'error': 'Invalid signature type'}
-
- assessment.write(vals)
-
- # Update state if needed
- if assessment.state == 'draft':
- assessment.state = 'pending_signature'
-
- return {
- 'success': True,
- 'signatures_complete': assessment.signatures_complete,
- }
-
- except Exception as e:
- _logger.error(f"Error saving signature: {e}")
- return {'success': False, 'error': str(e)}
-
- @http.route('/my/assessment//complete', type='http', auth='user', website=True, methods=['POST'], csrf=True)
- def portal_assessment_complete(self, assessment_id, **kw):
- """Complete the assessment"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- try:
- assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
- if not assessment.exists():
- raise MissingError(_('Assessment not found.'))
-
- # Check access
- has_access = (
- (partner.is_authorizer and assessment.authorizer_id.id == partner.id) or
- (partner.is_sales_rep_portal and assessment.sales_rep_id.id == user.id)
- )
- if not has_access:
- raise AccessError(_('You do not have access to this assessment.'))
-
- # Complete the assessment
- result = assessment.action_complete()
-
- # Redirect to the created sale order or assessments list
- if assessment.sale_order_id:
- return request.redirect('/my/assessments?message=completed')
- else:
- return request.redirect('/my/assessments')
-
- except ValidationError as e:
- _logger.warning(f"Validation error completing assessment: {e}")
- return request.redirect(f'/my/assessment/{assessment_id}/signatures?error=signatures_required')
- except Exception as e:
- _logger.error(f"Error completing assessment: {e}")
- return request.redirect(f'/my/assessment/{assessment_id}?error=1')
-
- # ==========================================================================
- # EXPRESS ASSESSMENT FORM ROUTES
- # ==========================================================================
-
- @http.route('/my/assessment/express', type='http', auth='user', website=True)
- def portal_assessment_express_new(self, **kw):
- """Start a new express assessment (Page 1 - Equipment Selection)"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- # Get list of authorizers for dropdown
- authorizers = request.env['res.partner'].sudo().search([
- ('is_authorizer', '=', True),
- ], order='name')
-
- # JSON-safe authorizer list for searchable dropdown (Markup so t-out won't escape)
- authorizers_json = Markup(json.dumps([
- {'id': a.id, 'name': a.name, 'email': a.email or ''}
- for a in authorizers
- ]))
-
- # Get existing clients for dropdown
- clients = request.env['res.partner'].sudo().search([
- ('customer_rank', '>', 0),
- ], order='name', limit=500)
-
- # Get Google Maps API key
- ICP = request.env['ir.config_parameter'].sudo()
- google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
-
- values = {
- 'partner': partner,
- 'user': user,
- 'authorizers': authorizers,
- 'authorizers_json': authorizers_json,
- 'clients': clients,
- 'countries': request.env['res.country'].sudo().search([]),
- 'provinces': self._get_canadian_provinces(),
- 'default_country': request.env.ref('base.ca', raise_if_not_found=False),
- 'page_name': 'assessment_express',
- 'current_page': 1,
- 'total_pages': 2,
- 'assessment': None,
- 'google_maps_api_key': google_maps_api_key,
- }
-
- return request.render('fusion_authorizer_portal.portal_assessment_express', values)
-
- @http.route('/my/assessment/express/', type='http', auth='user', website=True)
- def portal_assessment_express_edit(self, assessment_id, page=1, **kw):
- """Continue/edit an express assessment"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- try:
- assessment = request.env['fusion.assessment'].sudo().browse(assessment_id)
- if not assessment.exists():
- raise MissingError(_('Assessment not found.'))
-
- # Check access - must be the sales rep who created it
- if assessment.sales_rep_id.id != user.id:
- raise AccessError(_('You do not have access to this assessment.'))
-
- if assessment.state in ['cancelled']:
- return request.redirect('/my/assessments')
-
- except (AccessError, MissingError):
- return request.redirect('/my/assessments')
-
- # Get list of authorizers for dropdown
- authorizers = request.env['res.partner'].sudo().search([
- ('is_authorizer', '=', True),
- ], order='name')
-
- # JSON-safe authorizer list for searchable dropdown (Markup so t-out won't escape)
- authorizers_json = Markup(json.dumps([
- {'id': a.id, 'name': a.name, 'email': a.email or ''}
- for a in authorizers
- ]))
-
- # Get existing clients for dropdown
- clients = request.env['res.partner'].sudo().search([
- ('customer_rank', '>', 0),
- ], order='name', limit=500)
-
- try:
- current_page = int(page)
- except (ValueError, TypeError):
- current_page = 1
-
- # Get Google Maps API key
- ICP = request.env['ir.config_parameter'].sudo()
- google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
-
- values = {
- 'partner': partner,
- 'user': user,
- 'assessment': assessment,
- 'authorizers': authorizers,
- 'authorizers_json': authorizers_json,
- 'clients': clients,
- 'countries': request.env['res.country'].sudo().search([]),
- 'provinces': self._get_canadian_provinces(),
- 'default_country': request.env.ref('base.ca', raise_if_not_found=False),
- 'page_name': 'assessment_express',
- 'current_page': current_page,
- 'total_pages': 2,
- 'google_maps_api_key': google_maps_api_key,
- }
-
- return request.render('fusion_authorizer_portal.portal_assessment_express', values)
-
- @http.route('/my/assessment/express/save', type='http', auth='user', website=True, methods=['POST'], csrf=True)
- def portal_assessment_express_save(self, **kw):
- """Save express assessment data (create or update)"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- Assessment = request.env['fusion.assessment'].sudo()
- assessment_id = kw.get('assessment_id')
- current_page = int(kw.get('current_page', 1))
- action = kw.get('action', 'next') # next, back, save, submit
-
- # Build values from form
- vals = self._build_express_assessment_vals(kw)
-
- try:
- if assessment_id and assessment_id != 'None' and assessment_id != '':
- # Update existing
- assessment = Assessment.browse(int(assessment_id))
- if not assessment.exists():
- raise MissingError(_('Assessment not found.'))
-
- if assessment.sales_rep_id.id != user.id:
- raise AccessError(_('You do not have access to this assessment.'))
-
- if assessment.state == 'completed':
- # Allow updating ONLY consent/signature fields on completed assessments
- consent_fields = {
- 'consent_signed_by', 'consent_declaration_accepted', 'consent_date',
- 'agent_relationship', 'agent_first_name', 'agent_last_name',
- 'agent_middle_initial', 'agent_unit', 'agent_street_number',
- 'agent_street_name', 'agent_city', 'agent_province',
- 'agent_postal_code', 'agent_home_phone', 'agent_business_phone',
- 'agent_phone_ext',
- }
- consent_vals = {k: v for k, v in vals.items() if k in consent_fields}
- if consent_vals:
- assessment.write(consent_vals)
- _logger.info(f"Updated consent fields on completed assessment {assessment.reference}")
- elif assessment.state == 'cancelled':
- raise ValidationError(_('Cannot modify a cancelled assessment.'))
- else:
- # Draft - allow full update
- assessment.write(vals)
- _logger.info(f"Updated express assessment {assessment.reference}")
- else:
- # Create new
- vals['sales_rep_id'] = user.id
- vals['state'] = 'draft'
- assessment = Assessment.create(vals)
- _logger.info(f"Created new express assessment {assessment.reference}")
-
- # Handle photo uploads
- uploaded_photos = request.httprequest.files.getlist('assessment_photos')
- if uploaded_photos:
- for photo_file in uploaded_photos:
- if photo_file and photo_file.filename:
- try:
- file_content = photo_file.read()
- file_base64 = base64.b64encode(file_content)
-
- # Create attachment linked to assessment
- attachment = request.env['ir.attachment'].sudo().create({
- 'name': photo_file.filename,
- 'type': 'binary',
- 'datas': file_base64,
- 'res_model': 'fusion.assessment',
- 'res_id': assessment.id,
- 'mimetype': photo_file.content_type or 'image/jpeg',
- })
- _logger.info(f"Uploaded assessment photo: {photo_file.filename}")
- except Exception as e:
- _logger.error(f"Error uploading photo {photo_file.filename}: {e}")
-
- # ===== Handle Page 11 signature capture =====
- signature_data = kw.get('signature_page_11_data', '')
- if signature_data and signature_data.startswith('data:image/'):
- try:
- # Strip data URL prefix: "data:image/png;base64,..."
- sig_base64 = signature_data.split(',', 1)[1]
- sig_vals = {
- 'signature_page_11': sig_base64,
- 'signature_page_11_date': fields.Datetime.now(),
- }
- # Set signer name
- if kw.get('consent_signed_by') == 'agent' and kw.get('agent_first_name'):
- sig_vals['signature_page_11_name'] = (
- f"{kw.get('agent_first_name', '')} {kw.get('agent_last_name', '')}"
- ).strip()
- else:
- sig_vals['signature_page_11_name'] = (
- f"{kw.get('client_first_name', '')} {kw.get('client_last_name', '')}"
- ).strip()
- assessment.write(sig_vals)
- _logger.info(f"Saved Page 11 signature for assessment {assessment.reference}")
- except Exception as e:
- _logger.error(f"Error saving Page 11 signature: {e}")
-
- # Handle navigation
- if action == 'submit':
- # If already completed, we just saved consent/signature above -- redirect with success
- if assessment.state == 'completed':
- # Generate filled PDF if signature was added
- 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',
- })
- # Update sale order too
- # Issue 8 fix: bypass document lock since this is a portal
- # re-signing on an already-completed assessment (SO may have
- # progressed past 'submitted' where the lock kicks in)
- if assessment.sale_order_id:
- assessment.sale_order_id.with_context(
- skip_document_lock_validation=True
- ).write({
- 'x_fc_signed_pages_11_12': b64.b64encode(pdf_bytes),
- 'x_fc_signed_pages_filename': f'ADP_Page11_{assessment.reference}.pdf',
- })
- _logger.info(f"Generated Page 11 PDF for completed assessment {assessment.reference}")
- except Exception as pdf_e:
- _logger.warning(f"PDF generation failed (non-blocking): {pdf_e}")
-
- # Post consent & signature info to sale order chatter
- if assessment.sale_order_id and assessment.signature_page_11:
- try:
- from markupsafe import Markup
- signer = assessment.signature_page_11_name or 'Unknown'
- signed_by = 'Applicant' if assessment.consent_signed_by == 'applicant' else 'Agent'
- consent_date = str(assessment.consent_date) if assessment.consent_date else 'N/A'
-
- # Create signature as attachment
- sig_att = request.env['ir.attachment'].sudo().create({
- 'name': f'Page11_Signature_{assessment.reference}.png',
- 'type': 'binary',
- 'datas': assessment.signature_page_11,
- 'res_model': 'sale.order',
- 'res_id': assessment.sale_order_id.id,
- 'mimetype': 'image/png',
- })
-
- body = Markup(
- '
'
- '
Page 11 Consent & Signature
'
- '
'
- f'
Signed by: {signer} ({signed_by})
'
- f'
Consent date: {consent_date}
'
- f'
Declaration accepted: Yes
'
- '
'
- '
'
- )
- assessment.sale_order_id.message_post(
- body=body,
- message_type='comment',
- subtype_xmlid='mail.mt_note',
- attachment_ids=[sig_att.id],
- )
- _logger.info(f"Posted Page 11 consent info to SO {assessment.sale_order_id.name}")
- except Exception as chat_e:
- _logger.warning(f"Failed to post consent to chatter: {chat_e}")
-
- so_id = assessment.sale_order_id.id if assessment.sale_order_id else ''
- return request.redirect(f'/my/assessments?message=completed&so={so_id}')
-
- # Complete the express assessment
- try:
- sale_order = assessment.action_complete_express()
-
- # Post assessment photos to sale order chatter
- photo_attachments = request.env['ir.attachment'].sudo().search([
- ('res_model', '=', 'fusion.assessment'),
- ('res_id', '=', assessment.id),
- ('mimetype', 'like', 'image/%'),
- ])
- if photo_attachments:
- # Copy attachments to sale order
- attachment_ids = []
- for att in photo_attachments:
- new_att = att.copy({
- 'res_model': 'sale.order',
- 'res_id': sale_order.id,
- })
- attachment_ids.append(new_att.id)
-
- # Post message to chatter with photos
- sale_order.message_post(
- body=f"
Assessment Photos Photos from assessment {assessment.reference} by {request.env.user.name}
",
- message_type='comment',
- subtype_xmlid='mail.mt_comment',
- attachment_ids=attachment_ids,
- )
- _logger.info(f"Posted {len(attachment_ids)} assessment photos to sale order {sale_order.name}")
-
- # Process loaner checkout if loaner data was submitted
- loaner_product_id = kw.get('loaner_product_id')
- loaner_checkout_flag = kw.get('loaner_checkout', '0')
- if loaner_product_id and loaner_checkout_flag == '1':
- try:
- loaner_vals = {
- 'product_id': int(loaner_product_id),
- 'sale_order_id': sale_order.id,
- 'partner_id': sale_order.partner_id.id,
- 'loaner_period_days': int(kw.get('loaner_period_days', 7)),
- 'checkout_condition': kw.get('loaner_condition', 'good'),
- 'checkout_notes': kw.get('loaner_notes', ''),
- 'sales_rep_id': request.env.user.id,
- }
- if sale_order.x_fc_authorizer_id:
- loaner_vals['authorizer_id'] = sale_order.x_fc_authorizer_id.id
- if sale_order.partner_shipping_id:
- loaner_vals['delivery_address'] = sale_order.partner_shipping_id.contact_address
- loaner_lot = kw.get('loaner_lot_id')
- if loaner_lot:
- loaner_vals['lot_id'] = int(loaner_lot)
- checkout = request.env['fusion.loaner.checkout'].sudo().create(loaner_vals)
- checkout.action_checkout()
- _logger.info(f"Created loaner checkout {checkout.name} for SO {sale_order.name}")
- except Exception as le:
- _logger.error(f"Error creating loaner checkout: {le}")
-
- # ===== Generate filled Page 11 PDF if signature exists =====
- 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',
- })
- # Also store on sale order
- # Issue 8 fix: bypass document lock for portal writes
- sale_order.with_context(
- skip_document_lock_validation=True
- ).write({
- 'x_fc_signed_pages_11_12': b64.b64encode(pdf_bytes),
- 'x_fc_signed_pages_filename': f'ADP_Page11_{assessment.reference}.pdf',
- })
- _logger.info(f"Generated Page 11 PDF for assessment {assessment.reference}")
- except Exception as pdf_e:
- _logger.warning(f"PDF generation failed (non-blocking): {pdf_e}")
-
- return request.redirect(f'/my/assessments?message=completed&so={sale_order.id}')
- except Exception as e:
- _logger.error(f"Error completing express assessment: {e}")
- return request.redirect(f'/my/assessment/express/{assessment.id}?error={str(e)}')
- elif action == 'start_over':
- # Cancel and start fresh
- if assessment_id and assessment_id != 'None':
- assessment.unlink()
- return request.redirect('/my/assessment/express')
- else:
- # Just save
- return request.redirect(f'/my/assessment/express/{assessment.id}?page={current_page}')
-
- except Exception as e:
- _logger.error(f"Error saving express assessment: {e}")
- if assessment_id and assessment_id != 'None':
- return request.redirect(f'/my/assessment/express/{assessment_id}?page={current_page}&error=1')
- return request.redirect('/my/assessment/express?error=1')
-
- def _build_express_assessment_vals(self, kw):
- """Build values dict from express form POST data"""
- vals = {}
-
- # Equipment type
- if kw.get('equipment_type'):
- vals['equipment_type'] = kw.get('equipment_type')
-
- # Equipment sub-types
- if kw.get('rollator_type'):
- vals['rollator_type'] = kw.get('rollator_type')
- if kw.get('wheelchair_type'):
- vals['wheelchair_type'] = kw.get('wheelchair_type')
- if kw.get('powerchair_type'):
- vals['powerchair_type'] = kw.get('powerchair_type')
-
- # Float measurements
- float_fields = [
- 'rollator_handle_height', 'rollator_seat_height',
- 'seat_width', 'seat_depth', 'seat_to_floor_height', 'back_height',
- 'legrest_length', 'cane_height', 'client_weight',
- ]
- for field in float_fields:
- if kw.get(field):
- try:
- vals[field] = float(kw.get(field))
- except (ValueError, TypeError):
- pass
-
- # Checkbox options - collect as comma-separated strings
- # Rollator addons
- rollator_addons = kw.getlist('rollator_addons') if hasattr(kw, 'getlist') else []
- if not rollator_addons and 'rollator_addons' in kw:
- rollator_addons = [kw.get('rollator_addons')] if kw.get('rollator_addons') else []
- if rollator_addons:
- vals['rollator_addons'] = ', '.join(rollator_addons)
-
- # Wheelchair options
- frame_options = kw.getlist('frame_options') if hasattr(kw, 'getlist') else []
- if not frame_options and 'frame_options' in kw:
- frame_options = [kw.get('frame_options')] if kw.get('frame_options') else []
- if frame_options:
- vals['frame_options'] = ', '.join(frame_options)
-
- wheel_options = kw.getlist('wheel_options') if hasattr(kw, 'getlist') else []
- if not wheel_options and 'wheel_options' in kw:
- wheel_options = [kw.get('wheel_options')] if kw.get('wheel_options') else []
- if wheel_options:
- vals['wheel_options'] = ', '.join(wheel_options)
-
- legrest_options = kw.getlist('legrest_options') if hasattr(kw, 'getlist') else []
- if not legrest_options and 'legrest_options' in kw:
- legrest_options = [kw.get('legrest_options')] if kw.get('legrest_options') else []
- if legrest_options:
- vals['legrest_options'] = ', '.join(legrest_options)
-
- additional_adp_options = kw.getlist('additional_adp_options') if hasattr(kw, 'getlist') else []
- if not additional_adp_options and 'additional_adp_options' in kw:
- additional_adp_options = [kw.get('additional_adp_options')] if kw.get('additional_adp_options') else []
- if additional_adp_options:
- vals['additional_adp_options'] = ', '.join(additional_adp_options)
-
- # Powerchair options
- powerchair_options = kw.getlist('powerchair_options') if hasattr(kw, 'getlist') else []
- if not powerchair_options and 'powerchair_options' in kw:
- powerchair_options = [kw.get('powerchair_options')] if kw.get('powerchair_options') else []
- if powerchair_options:
- vals['powerchair_options'] = ', '.join(powerchair_options)
-
- specialty_controls = kw.getlist('specialty_controls') if hasattr(kw, 'getlist') else []
- if not specialty_controls and 'specialty_controls' in kw:
- specialty_controls = [kw.get('specialty_controls')] if kw.get('specialty_controls') else []
- if specialty_controls:
- vals['specialty_controls'] = ', '.join(specialty_controls)
-
- # Seatbelt type
- if kw.get('seatbelt_type'):
- vals['seatbelt_type'] = kw.get('seatbelt_type')
-
- # Additional customization
- if kw.get('additional_customization'):
- vals['additional_customization'] = kw.get('additional_customization')
-
- # Cushion and backrest
- if kw.get('cushion_info'):
- vals['cushion_info'] = kw.get('cushion_info')
- if kw.get('backrest_info'):
- vals['backrest_info'] = kw.get('backrest_info')
-
- # Client type
- if kw.get('client_type'):
- vals['client_type'] = kw.get('client_type')
-
- # Client info (Page 2)
- if kw.get('client_first_name'):
- vals['client_first_name'] = kw.get('client_first_name')
- if kw.get('client_middle_name'):
- vals['client_middle_name'] = kw.get('client_middle_name')
- if kw.get('client_last_name'):
- vals['client_last_name'] = kw.get('client_last_name')
-
- # Build full client name
- name_parts = []
- if kw.get('client_first_name'):
- name_parts.append(kw.get('client_first_name'))
- if kw.get('client_middle_name'):
- name_parts.append(kw.get('client_middle_name'))
- if kw.get('client_last_name'):
- name_parts.append(kw.get('client_last_name'))
- if name_parts:
- vals['client_name'] = ' '.join(name_parts)
-
- # Health card
- if kw.get('client_health_card'):
- vals['client_health_card'] = kw.get('client_health_card')
- if kw.get('client_health_card_version'):
- vals['client_health_card_version'] = kw.get('client_health_card_version')
-
- # Address
- if kw.get('client_street'):
- vals['client_street'] = kw.get('client_street')
- if kw.get('client_unit'):
- vals['client_unit'] = kw.get('client_unit')
- if kw.get('client_city'):
- vals['client_city'] = kw.get('client_city')
- if kw.get('client_state'):
- vals['client_state'] = kw.get('client_state')
- if kw.get('client_postal_code'):
- vals['client_postal_code'] = kw.get('client_postal_code')
- if kw.get('client_country_id'):
- try:
- vals['client_country_id'] = int(kw.get('client_country_id'))
- except (ValueError, TypeError):
- pass
-
- # Contact
- if kw.get('client_phone'):
- vals['client_phone'] = kw.get('client_phone')
- if kw.get('client_email'):
- vals['client_email'] = kw.get('client_email')
-
- # Dates
- date_fields = ['assessment_start_date', 'assessment_end_date', 'claim_authorization_date', 'previous_funding_date']
- for field in date_fields:
- if kw.get(field):
- try:
- vals[field] = kw.get(field)
- except (ValueError, TypeError):
- pass
-
- # Reason for application
- if kw.get('reason_for_application'):
- vals['reason_for_application'] = kw.get('reason_for_application')
-
- # Authorizer
- if kw.get('authorizer_id'):
- try:
- vals['authorizer_id'] = int(kw.get('authorizer_id'))
- except (ValueError, TypeError):
- pass
-
- # Existing partner selection
- if kw.get('partner_id'):
- try:
- partner_id = int(kw.get('partner_id'))
- if partner_id > 0:
- vals['partner_id'] = partner_id
- vals['create_new_partner'] = False
- else:
- vals['create_new_partner'] = True
- except (ValueError, TypeError):
- vals['create_new_partner'] = True
-
- # ===== PAGE 11: Consent & Declaration fields =====
- if kw.get('consent_signed_by'):
- vals['consent_signed_by'] = kw.get('consent_signed_by')
-
- if kw.get('consent_declaration_accepted'):
- vals['consent_declaration_accepted'] = True
-
- if kw.get('consent_date'):
- try:
- vals['consent_date'] = kw.get('consent_date')
- except (ValueError, TypeError):
- pass
-
- # Agent fields (only relevant when consent_signed_by == 'agent')
- agent_fields = [
- 'agent_relationship', 'agent_first_name', 'agent_last_name',
- 'agent_middle_initial', 'agent_unit', 'agent_street_number',
- 'agent_street_name', 'agent_city', 'agent_province',
- 'agent_postal_code', 'agent_home_phone', 'agent_business_phone',
- 'agent_phone_ext',
- ]
- for field in agent_fields:
- if kw.get(field):
- vals[field] = kw.get(field)
-
- return vals
-
- def _get_canadian_provinces(self):
- """Return list of Canadian provinces for dropdown"""
- return [
- ('Ontario', 'Ontario'),
- ('Quebec', 'Quebec'),
- ('British Columbia', 'British Columbia'),
- ('Alberta', 'Alberta'),
- ('Manitoba', 'Manitoba'),
- ('Saskatchewan', 'Saskatchewan'),
- ('Nova Scotia', 'Nova Scotia'),
- ('New Brunswick', 'New Brunswick'),
- ('Newfoundland and Labrador', 'Newfoundland and Labrador'),
- ('Prince Edward Island', 'Prince Edward Island'),
- ('Northwest Territories', 'Northwest Territories'),
- ('Yukon', 'Yukon'),
- ('Nunavut', 'Nunavut'),
- ]
-
- # =========================================================================
- # LOANER PORTAL ROUTES
- # =========================================================================
-
- @http.route('/my/loaner/categories', type='jsonrpc', auth='user', website=True)
- def portal_loaner_categories(self, **kw):
- """Return loaner product categories."""
- parent = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False)
- if not parent:
- return []
- categories = request.env['product.category'].sudo().search([
- ('parent_id', '=', parent.id),
- ], order='name')
- return [{'id': c.id, 'name': c.name} for c in categories]
-
- @http.route('/my/loaner/products', type='jsonrpc', auth='user', website=True)
- def portal_loaner_products(self, **kw):
- """Return available loaner products and their serial numbers."""
- domain = [('x_fc_can_be_loaned', '=', True)]
- category_id = kw.get('category_id')
- if category_id:
- domain.append(('categ_id', '=', int(category_id)))
-
- products = request.env['product.product'].sudo().search(domain)
- loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
-
- result = []
- for p in products:
- lots = []
- if loaner_location:
- quants = request.env['stock.quant'].sudo().search([
- ('product_id', '=', p.id),
- ('location_id', '=', loaner_location.id),
- ('quantity', '>', 0),
- ])
- for q in quants:
- if q.lot_id:
- lots.append({'id': q.lot_id.id, 'name': q.lot_id.name})
- result.append({
- 'id': p.id,
- 'name': p.name,
- 'category_id': p.categ_id.id,
- 'period_days': p.product_tmpl_id.x_fc_loaner_period_days or 7,
- 'lots': lots,
- })
- return result
-
- @http.route('/my/loaner/locations', type='jsonrpc', auth='user', website=True)
- def portal_loaner_locations(self, **kw):
- """Return internal stock locations for return."""
- locations = request.env['stock.location'].sudo().search([
- ('usage', '=', 'internal'),
- ('company_id', '=', request.env.company.id),
- ])
- return [{'id': loc.id, 'name': loc.complete_name} for loc in locations]
-
- @http.route('/my/loaner/checkout', type='jsonrpc', auth='user', website=True)
- def portal_loaner_checkout(self, **kw):
- """Checkout a loaner from the portal."""
- partner = request.env.user.partner_id
- if not partner.is_sales_rep_portal and not partner.is_authorizer:
- return {'error': 'Unauthorized'}
-
- product_id = int(kw.get('product_id', 0))
- lot_id = int(kw.get('lot_id', 0)) if kw.get('lot_id') else False
- sale_order_id = int(kw.get('sale_order_id', 0)) if kw.get('sale_order_id') else False
- client_id = int(kw.get('client_id', 0)) if kw.get('client_id') else False
- loaner_period = int(kw.get('loaner_period_days', 7))
- condition = kw.get('checkout_condition', 'good')
- notes = kw.get('checkout_notes', '')
-
- if not product_id:
- return {'error': 'Product is required'}
-
- vals = {
- 'product_id': product_id,
- 'loaner_period_days': loaner_period,
- 'checkout_condition': condition,
- 'checkout_notes': notes,
- 'sales_rep_id': request.env.user.id,
- }
- if lot_id:
- vals['lot_id'] = lot_id
- if sale_order_id:
- so = request.env['sale.order'].sudo().browse(sale_order_id)
- if so.exists():
- vals['sale_order_id'] = so.id
- vals['partner_id'] = so.partner_id.id
- vals['authorizer_id'] = so.x_fc_authorizer_id.id if so.x_fc_authorizer_id else False
- vals['delivery_address'] = so.partner_shipping_id.contact_address if so.partner_shipping_id else ''
- if client_id and not vals.get('partner_id'):
- vals['partner_id'] = client_id
-
- if not vals.get('partner_id'):
- return {'error': 'Client is required'}
-
- try:
- checkout = request.env['fusion.loaner.checkout'].sudo().create(vals)
- checkout.action_checkout()
- return {
- 'success': True,
- 'checkout_id': checkout.id,
- 'name': checkout.name,
- 'message': f'Loaner {checkout.name} checked out successfully',
- }
- except Exception as e:
- _logger.error(f"Loaner checkout error: {e}")
- return {'error': str(e)}
-
- @http.route('/my/loaner/create-product', type='jsonrpc', auth='user', website=True)
- def portal_loaner_create_product(self, **kw):
- """Quick-create a loaner product with serial number from the portal."""
- partner = request.env.user.partner_id
- if not partner.is_sales_rep_portal and not partner.is_authorizer:
- return {'error': 'Unauthorized'}
-
- product_name = kw.get('product_name', '').strip()
- serial_number = kw.get('serial_number', '').strip()
-
- if not product_name:
- return {'error': 'Product name is required'}
- if not serial_number:
- return {'error': 'Serial number is required'}
-
- try:
- # Use provided category or default to Loaner Equipment
- category_id = kw.get('category_id')
- if category_id:
- category = request.env['product.category'].sudo().browse(int(category_id))
- if not category.exists():
- category = None
- else:
- category = None
-
- if not category:
- category = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False)
- if not category:
- category = request.env['product.category'].sudo().search([
- ('name', '=', 'Loaner Equipment'),
- ], limit=1)
- if not category:
- category = request.env['product.category'].sudo().create({
- 'name': 'Loaner Equipment',
- })
-
- # Create product template
- product_tmpl = request.env['product.template'].sudo().create({
- 'name': product_name,
- 'type': 'consu',
- 'tracking': 'serial',
- 'categ_id': category.id,
- 'x_fc_can_be_loaned': True,
- 'x_fc_loaner_period_days': 7,
- 'sale_ok': False,
- 'purchase_ok': False,
- })
- product = product_tmpl.product_variant_id
-
- # Create serial number (lot)
- lot = request.env['stock.lot'].sudo().create({
- 'name': serial_number,
- 'product_id': product.id,
- 'company_id': request.env.company.id,
- })
-
- # Add stock in loaner location
- loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
- if loaner_location:
- request.env['stock.quant'].sudo().create({
- 'product_id': product.id,
- 'location_id': loaner_location.id,
- 'lot_id': lot.id,
- 'quantity': 1,
- })
-
- return {
- 'success': True,
- 'product_id': product.id,
- 'product_name': product.name,
- 'lot_id': lot.id,
- 'lot_name': lot.name,
- }
- except Exception as e:
- _logger.error(f"Loaner product creation error: {e}")
- return {'error': str(e)}
-
- @http.route('/my/loaner/return', type='jsonrpc', auth='user', website=True)
- def portal_loaner_return(self, **kw):
- """Return/pickup a loaner from the portal."""
- partner = request.env.user.partner_id
- if not partner.is_sales_rep_portal and not partner.is_authorizer:
- return {'error': 'Unauthorized'}
-
- checkout_id = int(kw.get('checkout_id', 0))
- return_condition = kw.get('return_condition', 'good')
- return_notes = kw.get('return_notes', '')
- return_location_id = int(kw.get('return_location_id', 0)) if kw.get('return_location_id') else None
-
- if not checkout_id:
- return {'error': 'Checkout ID is required'}
-
- try:
- checkout = request.env['fusion.loaner.checkout'].sudo().browse(checkout_id)
- if not checkout.exists():
- return {'error': 'Checkout not found'}
- if checkout.state not in ('checked_out', 'overdue', 'rental_pending'):
- return {'error': 'This loaner is not currently checked out'}
-
- checkout.action_process_return(
- return_condition=return_condition,
- return_notes=return_notes,
- return_location_id=return_location_id,
- )
- return {
- 'success': True,
- 'message': f'Loaner {checkout.name} returned successfully',
- }
- except Exception as e:
- _logger.error(f"Loaner return error: {e}")
- return {'error': str(e)}
-
- # ==========================================================================
- # PUBLIC ASSESSMENT BOOKING
- # ==========================================================================
-
- @http.route('/book-assessment', type='http', auth='public', website=True, sitemap=True)
- def portal_book_assessment(self, **kw):
- """Public page for booking an accessibility assessment."""
- # Get available sales reps for assignment
- SalesGroup = request.env.ref('sales_team.group_sale_salesman', raise_if_not_found=False)
- sales_reps = []
- if SalesGroup:
- sales_reps = request.env['res.users'].sudo().search([
- ('groups_id', 'in', [SalesGroup.id]),
- ('active', '=', True),
- ])
-
- assessment_types = [
- ('stairlift_straight', 'Straight Stair Lift'),
- ('stairlift_curved', 'Curved Stair Lift'),
- ('vpl', 'Vertical Platform Lift'),
- ('ceiling_lift', 'Ceiling Lift'),
- ('ramp', 'Custom Ramp'),
- ('bathroom', 'Bathroom Modification'),
- ('tub_cutout', 'Tub Cutout'),
- ]
-
- values = {
- 'assessment_types': assessment_types,
- 'sales_reps': sales_reps,
- 'success': kw.get('success'),
- 'error': kw.get('error'),
- }
- return request.render('fusion_authorizer_portal.portal_book_assessment', values)
-
- @http.route('/book-assessment/submit', type='http', auth='public', website=True, methods=['POST'], csrf=True)
- def portal_book_assessment_submit(self, **kw):
- """Process assessment booking form submission."""
- try:
- # Validate required fields
- if not kw.get('client_name') or not kw.get('client_phone'):
- return request.redirect('/book-assessment?error=Please+provide+client+name+and+phone+number')
-
- if not kw.get('assessment_type'):
- return request.redirect('/book-assessment?error=Please+select+an+assessment+type')
-
- Assessment = request.env['fusion.accessibility.assessment'].sudo()
-
- # Determine booking source
- booking_source = 'portal'
- if kw.get('booking_source'):
- booking_source = kw['booking_source']
-
- # Parse date
- assessment_date = False
- if kw.get('assessment_date'):
- try:
- assessment_date = fields.Date.from_string(kw['assessment_date'])
- except Exception:
- assessment_date = False
-
- # Determine sales rep
- sales_rep_id = False
- if kw.get('sales_rep_id'):
- try:
- sales_rep_id = int(kw['sales_rep_id'])
- except (ValueError, TypeError):
- pass
-
- # Build address string
- address_parts = []
- if kw.get('client_street'):
- address_parts.append(kw['client_street'])
- if kw.get('client_city'):
- address_parts.append(kw['client_city'])
- if kw.get('client_province'):
- address_parts.append(kw['client_province'])
- if kw.get('client_postal'):
- address_parts.append(kw['client_postal'])
-
- vals = {
- 'assessment_type': kw['assessment_type'],
- 'client_name': kw['client_name'],
- 'client_phone': kw.get('client_phone', ''),
- 'client_email': kw.get('client_email', ''),
- 'client_address': ', '.join(address_parts) if address_parts else '',
- 'client_address_street': kw.get('client_street', ''),
- 'client_address_city': kw.get('client_city', ''),
- 'client_address_province': kw.get('client_province', ''),
- 'client_address_postal': kw.get('client_postal', ''),
- 'assessment_date': assessment_date,
- 'booking_source': booking_source,
- 'modification_requested': kw.get('modification_requested', ''),
- }
-
- if sales_rep_id:
- vals['sales_rep_id'] = sales_rep_id
-
- # Link authorizer if provided
- if kw.get('authorizer_name') and kw.get('authorizer_email'):
- Partner = request.env['res.partner'].sudo()
- authorizer = Partner.search([('email', '=', kw['authorizer_email'])], limit=1)
- if not authorizer:
- authorizer = Partner.create({
- 'name': kw['authorizer_name'],
- 'email': kw['authorizer_email'],
- 'phone': kw.get('authorizer_phone', ''),
- 'is_authorizer': True,
- })
- vals['authorizer_id'] = authorizer.id
-
- assessment = Assessment.create(vals)
-
- # Create calendar event for the sales rep
- if assessment_date and sales_rep_id:
- try:
- from datetime import datetime as dt, timedelta
- # Default: 10 AM, 1.5 hour duration
- start = dt.combine(assessment_date, dt.min.time().replace(hour=10))
- stop = start + timedelta(hours=1, minutes=30)
- event = request.env['calendar.event'].sudo().create({
- 'name': f'Assessment: {kw["client_name"]} ({kw.get("client_city", "")})',
- 'start': fields.Datetime.to_string(start),
- 'stop': fields.Datetime.to_string(stop),
- 'user_id': sales_rep_id,
- 'location': vals.get('client_address', ''),
- 'description': (
- f'Accessibility Assessment Booking\n'
- f'Client: {kw["client_name"]}\n'
- f'Phone: {kw.get("client_phone", "")}\n'
- f'Type: {kw["assessment_type"]}\n'
- f'Request: {kw.get("modification_requested", "")}'
- ),
- 'partner_ids': [(4, request.env['res.users'].sudo().browse(sales_rep_id).partner_id.id)],
- })
- assessment.write({'calendar_event_id': event.id})
- except Exception as e:
- _logger.error(f"Failed to create calendar event: {e}")
-
- # Send authorizer notification email
- if assessment.authorizer_id and assessment.authorizer_id.email:
- try:
- company = request.env.company
- body_html = assessment._email_build(
- title='Assessment Scheduled',
- summary=f'An accessibility assessment has been booked for '
- f'{kw["client_name"]}.',
- email_type='info',
- sections=[('Booking Details', [
- ('Client', kw['client_name']),
- ('Phone', kw.get('client_phone', '')),
- ('Address', vals.get('client_address', '')),
- ('Assessment Type', dict(Assessment._fields['assessment_type'].selection).get(kw['assessment_type'], '')),
- ('Date', str(assessment_date) if assessment_date else 'TBD'),
- ('Requested', kw.get('modification_requested', '')),
- ])],
- note='This booking was made through the online portal.',
- sender_name=company.name,
- )
- # Replace footer
- body_html = body_html.replace(
- 'This is an automated notification from the ADP Claims Management System.',
- 'This is an automated notification from the Accessibility Case Management System.',
- )
- request.env['mail.mail'].sudo().create({
- 'subject': f'Assessment Booked - {kw["client_name"]}',
- 'body_html': body_html,
- 'email_to': assessment.authorizer_id.email,
- 'model': 'fusion.accessibility.assessment',
- 'res_id': assessment.id,
- }).send()
- except Exception as e:
- _logger.error(f"Failed to send authorizer notification: {e}")
-
- # Send Twilio SMS to client
- if kw.get('client_phone'):
- try:
- ICP = request.env['ir.config_parameter'].sudo()
- if ICP.get_param('fusion_claims.twilio_enabled', 'False').lower() in ('true', '1', 'yes'):
- import requests as req
- account_sid = ICP.get_param('fusion_claims.twilio_account_sid', '')
- auth_token = ICP.get_param('fusion_claims.twilio_auth_token', '')
- from_number = ICP.get_param('fusion_claims.twilio_phone_number', '')
- company_phone = request.env.company.phone or ''
- date_str = str(assessment_date) if assessment_date else 'a date to be confirmed'
- sms_body = (
- f"Hi {kw['client_name']}, your accessibility assessment with "
- f"Westin Healthcare has been booked for {date_str}. "
- f"For questions, call {company_phone}."
- )
- if all([account_sid, auth_token, from_number]):
- url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json'
- req.post(url, data={
- 'To': kw['client_phone'],
- 'From': from_number,
- 'Body': sms_body,
- }, auth=(account_sid, auth_token), timeout=10)
- assessment.write({'sms_confirmation_sent': True})
- except Exception as e:
- _logger.error(f"Failed to send SMS: {e}")
-
- return request.redirect('/book-assessment?success=1')
-
- except Exception as e:
- _logger.error(f"Assessment booking error: {e}")
- return request.redirect(f'/book-assessment?error={str(e)}')
diff --git a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_main.py b/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_main.py
deleted file mode 100644
index e05288a..0000000
--- a/fusion_authorizer_portal/fusion_authorizer_portal/controllers/portal_main.py
+++ /dev/null
@@ -1,2468 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from odoo import http, _, fields
-from odoo.http import request
-from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
-from odoo.exceptions import AccessError, MissingError
-import base64
-import logging
-import pytz
-
-_logger = logging.getLogger(__name__)
-
-
-class AuthorizerPortal(CustomerPortal):
- """Portal controller for Authorizers (OTs/Therapists)"""
-
- @http.route(['/my', '/my/home'], type='http', auth='user', website=True)
- def home(self, **kw):
- """Override home to add ADP posting info for Fusion users"""
- partner = request.env.user.partner_id
-
- # Get the standard portal home response
- response = super().home(**kw)
-
- # Add ADP posting info and other data for Fusion users
- if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
- posting_info = self._get_adp_posting_info()
- response.qcontext.update(posting_info)
-
- # Add signature count (documents to sign) - only if Sign module is installed
- sign_count = 0
- sign_module_available = 'sign.request.item' in request.env
- if sign_module_available:
- sign_count = request.env['sign.request.item'].sudo().search_count([
- ('partner_id', '=', partner.id),
- ('state', '=', 'sent'),
- ])
- response.qcontext['sign_count'] = sign_count
- response.qcontext['sign_module_available'] = sign_module_available
-
- return response
-
- def _prepare_home_portal_values(self, counters):
- """Add authorizer/sales rep counts to portal home"""
- values = super()._prepare_home_portal_values(counters)
- partner = request.env.user.partner_id
-
- if 'authorizer_case_count' in counters:
- if partner.is_authorizer:
- values['authorizer_case_count'] = request.env['sale.order'].sudo().search_count([
- ('x_fc_authorizer_id', '=', partner.id)
- ])
- else:
- values['authorizer_case_count'] = 0
-
- if 'sales_rep_case_count' in counters:
- if partner.is_sales_rep_portal:
- values['sales_rep_case_count'] = request.env['sale.order'].sudo().search_count([
- ('user_id', '=', request.env.user.id)
- ])
- else:
- values['sales_rep_case_count'] = 0
-
- if 'assessment_count' in counters:
- count = 0
- if partner.is_authorizer:
- count += request.env['fusion.assessment'].sudo().search_count([
- ('authorizer_id', '=', partner.id)
- ])
- if partner.is_sales_rep_portal:
- count += request.env['fusion.assessment'].sudo().search_count([
- ('sales_rep_id', '=', request.env.user.id)
- ])
- values['assessment_count'] = count
-
- if 'technician_delivery_count' in counters:
- if partner.is_technician_portal:
- values['technician_delivery_count'] = request.env['sale.order'].sudo().search_count([
- ('x_fc_delivery_technician_ids', 'in', [request.env.user.id])
- ])
- else:
- values['technician_delivery_count'] = 0
-
- # Add ADP posting schedule info for portal users
- if partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal:
- values.update(self._get_adp_posting_info())
-
- return values
-
- def _get_adp_posting_info(self):
- """Get ADP posting schedule information for the portal home."""
- from datetime import date, timedelta
-
- ICP = request.env['ir.config_parameter'].sudo()
-
- # Get base date and frequency from settings
- base_date_str = ICP.get_param('fusion_claims.adp_posting_base_date', '2026-01-23')
- frequency = int(ICP.get_param('fusion_claims.adp_posting_frequency_days', '14'))
-
- try:
- base_date = date.fromisoformat(base_date_str)
- except (ValueError, TypeError):
- base_date = date(2026, 1, 23)
-
- # Get user's timezone for accurate date display
- user_tz = request.env.user.tz or 'UTC'
- try:
- tz = pytz.timezone(user_tz)
- except pytz.exceptions.UnknownTimeZoneError:
- tz = pytz.UTC
-
- # Get today's date in user's timezone
- from datetime import datetime
- now_utc = datetime.now(pytz.UTC)
- now_local = now_utc.astimezone(tz)
- today = now_local.date()
-
- # Calculate next posting date
- if today < base_date:
- next_posting = base_date
- else:
- days_since_base = (today - base_date).days
- cycles_passed = days_since_base // frequency
- next_posting = base_date + timedelta(days=(cycles_passed + 1) * frequency)
-
- # If today is a posting day, return the next one
- if days_since_base % frequency == 0:
- next_posting = base_date + timedelta(days=(cycles_passed + 1) * frequency)
-
- # Calculate key dates for the posting cycle
- # Wednesday is submission deadline (posting day - 2 if posting is Friday)
- days_until_wednesday = (next_posting.weekday() - 2) % 7
- if days_until_wednesday == 0 and next_posting.weekday() != 2:
- days_until_wednesday = 7
- submission_deadline = next_posting - timedelta(days=days_until_wednesday)
-
- # Get next 3 posting dates for the calendar
- posting_dates = []
- current_posting = next_posting
- for i in range(6):
- posting_dates.append({
- 'date': current_posting.isoformat(),
- 'display': current_posting.strftime('%B %d, %Y'),
- 'day': current_posting.day,
- 'month': current_posting.strftime('%B'),
- 'year': current_posting.year,
- 'weekday': current_posting.strftime('%A'),
- 'is_next': i == 0,
- })
- current_posting = current_posting + timedelta(days=frequency)
-
- # Days until next posting
- days_until_posting = (next_posting - today).days
-
- return {
- 'next_posting_date': next_posting,
- 'next_posting_display': next_posting.strftime('%B %d, %Y'),
- 'next_posting_weekday': next_posting.strftime('%A'),
- 'submission_deadline': submission_deadline,
- 'submission_deadline_display': submission_deadline.strftime('%B %d, %Y'),
- 'days_until_posting': days_until_posting,
- 'posting_dates': posting_dates,
- 'current_month': today.strftime('%B %Y'),
- 'today': today,
- }
-
- # ==================== AUTHORIZER PORTAL ====================
-
- @http.route(['/my/authorizer', '/my/authorizer/dashboard'], type='http', auth='user', website=True)
- def authorizer_dashboard(self, **kw):
- """Authorizer dashboard - simplified mobile-first view"""
- partner = request.env.user.partner_id
-
- if not partner.is_authorizer:
- return request.redirect('/my')
-
- SaleOrder = request.env['sale.order'].sudo()
- Assessment = request.env['fusion.assessment'].sudo()
-
- # Base domain for this authorizer
- base_domain = [('x_fc_authorizer_id', '=', partner.id)]
-
- # Total cases
- total_cases = SaleOrder.search_count(base_domain)
-
- # Assessment counts (express + accessibility)
- express_count = Assessment.search_count([('authorizer_id', '=', partner.id)])
- accessibility_count = 0
- if 'fusion.accessibility.assessment' in request.env:
- accessibility_count = request.env['fusion.accessibility.assessment'].sudo().search_count([
- ('authorizer_id', '=', partner.id)
- ])
- assessment_count = express_count + accessibility_count
-
- # Cases needing authorizer attention (waiting for application)
- needs_attention = SaleOrder.search(
- base_domain + [('x_fc_adp_application_status', 'in', [
- 'waiting_for_application', 'assessment_completed',
- ])],
- order='write_date desc',
- limit=10,
- )
-
- # Human-readable status labels
- status_labels = {}
- if needs_attention:
- status_labels = dict(needs_attention[0]._fields['x_fc_adp_application_status'].selection)
-
- # Sale type labels
- sale_type_labels = {}
- if total_cases:
- sample = SaleOrder.search(base_domain, limit=1)
- if sample and 'x_fc_sale_type' in sample._fields:
- sale_type_labels = dict(sample._fields['x_fc_sale_type'].selection)
-
- # Recent cases (last 5 updated)
- recent_cases = SaleOrder.search(
- base_domain,
- order='write_date desc',
- limit=5,
- )
-
- # Get status labels from recent cases if not already loaded
- if not status_labels and recent_cases:
- status_labels = dict(recent_cases[0]._fields['x_fc_adp_application_status'].selection)
-
- # Pending assessments
- pending_assessments = Assessment.search([
- ('authorizer_id', '=', partner.id),
- ('state', 'in', ['draft', 'pending_signature'])
- ], limit=5, order='assessment_date desc')
-
- company = request.env.company
-
- values = {
- 'partner': partner,
- 'company': company,
- 'total_cases': total_cases,
- 'assessment_count': assessment_count,
- 'needs_attention': needs_attention,
- 'recent_cases': recent_cases,
- 'pending_assessments': pending_assessments,
- 'status_labels': status_labels,
- 'sale_type_labels': sale_type_labels,
- 'page_name': 'authorizer_dashboard',
- }
-
- return request.render('fusion_authorizer_portal.portal_authorizer_dashboard', values)
-
- @http.route(['/my/authorizer/cases', '/my/authorizer/cases/page/'], type='http', auth='user', website=True)
- def authorizer_cases(self, page=1, search='', sortby='date', sale_type='', **kw):
- """List of cases assigned to the authorizer"""
- partner = request.env.user.partner_id
-
- if not partner.is_authorizer:
- return request.redirect('/my')
-
- SaleOrder = request.env['sale.order'].sudo()
-
- # Sale type groupings for filtering
- sale_type_groups = {
- 'adp': ['adp', 'adp_odsp'],
- 'odsp': ['odsp'],
- 'march_of_dimes': ['march_of_dimes'],
- 'others': ['wsib', 'direct_private', 'insurance', 'muscular_dystrophy', 'other', 'rental'],
- }
-
- # Build domain
- from odoo.osv import expression
- domain = [('x_fc_authorizer_id', '=', partner.id)]
-
- # Add sale type filter
- if sale_type and sale_type in sale_type_groups:
- domain.append(('x_fc_sale_type', 'in', sale_type_groups[sale_type]))
-
- # Add search filter
- if search:
- search_domain = [
- '|', '|', '|', '|',
- ('partner_id.name', 'ilike', search),
- ('name', 'ilike', search),
- ('x_fc_claim_number', 'ilike', search),
- ('x_fc_client_ref_1', 'ilike', search),
- ('x_fc_client_ref_2', 'ilike', search),
- ]
- domain = expression.AND([domain, search_domain])
-
- # Sorting
- sortings = {
- 'date': {'label': _('Date'), 'order': 'date_order desc'},
- 'name': {'label': _('Reference'), 'order': 'name'},
- 'client': {'label': _('Client'), 'order': 'partner_id'},
- 'state': {'label': _('Status'), 'order': 'state'},
- }
- order = sortings.get(sortby, sortings['date'])['order']
-
- # Pager
- case_count = SaleOrder.search_count(domain)
- pager = portal_pager(
- url='/my/authorizer/cases',
- url_args={'search': search, 'sortby': sortby, 'sale_type': sale_type},
- total=case_count,
- page=page,
- step=20,
- )
-
- # Get cases
- cases = SaleOrder.search(domain, order=order, limit=20, offset=pager['offset'])
-
- values = {
- 'cases': cases,
- 'pager': pager,
- 'search': search,
- 'sortby': sortby,
- 'sortings': sortings,
- 'sale_type': sale_type,
- 'sale_type_label': {
- 'adp': 'ADP Cases',
- 'odsp': 'ODSP Cases',
- 'march_of_dimes': 'March of Dimes',
- 'others': 'Other Cases',
- }.get(sale_type, 'All Cases'),
- 'page_name': 'authorizer_cases',
- }
-
- return request.render('fusion_authorizer_portal.portal_authorizer_cases', values)
-
- @http.route('/my/authorizer/cases/search', type='jsonrpc', auth='user')
- def authorizer_cases_search(self, query='', **kw):
- """AJAX search endpoint for real-time search"""
- partner = request.env.user.partner_id
-
- if not partner.is_authorizer:
- return {'error': 'Access denied', 'results': []}
-
- if len(query) < 2:
- return {'results': []}
-
- SaleOrder = request.env['sale.order'].sudo()
- orders = SaleOrder.get_authorizer_portal_cases(partner.id, search_query=query, limit=50)
-
- results = []
- for order in orders:
- results.append({
- 'id': order.id,
- 'name': order.name,
- 'partner_name': order.partner_id.name if order.partner_id else '',
- 'date_order': order.date_order.strftime('%Y-%m-%d') if order.date_order else '',
- 'state': order.state,
- 'state_display': dict(order._fields['state'].selection).get(order.state, order.state),
- 'claim_number': getattr(order, 'x_fc_claim_number', '') or '',
- 'client_ref_1': order.x_fc_client_ref_1 or '',
- 'client_ref_2': order.x_fc_client_ref_2 or '',
- 'url': f'/my/authorizer/case/{order.id}',
- })
-
- return {'results': results}
-
- @http.route('/my/authorizer/case/', type='http', auth='user', website=True)
- def authorizer_case_detail(self, order_id, **kw):
- """View a specific case"""
- partner = request.env.user.partner_id
-
- if not partner.is_authorizer:
- return request.redirect('/my')
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists() or order.x_fc_authorizer_id.id != partner.id:
- raise AccessError(_('You do not have access to this case.'))
- except (AccessError, MissingError):
- return request.redirect('/my/authorizer/cases')
-
- # Get documents
- documents = request.env['fusion.adp.document'].sudo().search([
- ('sale_order_id', '=', order_id),
- ('is_current', '=', True),
- ])
-
- # Get messages from chatter - only those relevant to this user
- # (authored by them, sent to them, or mentioning them)
- all_messages = request.env['mail.message'].sudo().search([
- ('model', '=', 'sale.order'),
- ('res_id', '=', order_id),
- ('message_type', 'in', ['comment', 'notification']),
- ('body', '!=', ''),
- ('body', '!=', '
'),
- ], order='date desc', limit=100)
-
- # Filter to only show messages relevant to this partner:
- # 1. Messages authored by this partner
- # 2. Messages where this partner is in notified_partner_ids
- # 3. Messages where this partner is mentioned (partner_ids)
- def is_relevant_message(msg):
- if not msg.body or len(msg.body.strip()) == 0 or '
' in msg.body:
- return False
- # Authored by current partner
- if msg.author_id.id == partner.id:
- return True
- # Partner is in notified partners
- if partner.id in msg.notified_partner_ids.ids:
- return True
- # Partner is mentioned in partner_ids
- if partner.id in msg.partner_ids.ids:
- return True
- return False
-
- filtered_messages = all_messages.filtered(is_relevant_message)
-
- values = {
- 'order': order,
- 'documents': documents,
- 'messages': filtered_messages,
- 'page_name': 'authorizer_case_detail',
- }
-
- return request.render('fusion_authorizer_portal.portal_authorizer_case_detail', values)
-
- @http.route('/my/authorizer/case//comment', type='http', auth='user', website=True, methods=['POST'])
- def authorizer_add_comment(self, order_id, comment='', **kw):
- """Add a comment to a case - posts to sale order chatter and emails salesperson"""
- partner = request.env.user.partner_id
-
- if not partner.is_authorizer:
- return request.redirect('/my')
-
- if not comment.strip():
- return request.redirect(f'/my/authorizer/case/{order_id}')
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists() or order.x_fc_authorizer_id.id != partner.id:
- raise AccessError(_('You do not have access to this case.'))
-
- # Post message to sale order chatter (internal note, not to all followers)
- message = order.message_post(
- body=comment.strip(),
- message_type='comment',
- subtype_xmlid='mail.mt_note', # Internal note - doesn't notify followers
- author_id=partner.id,
- )
-
- # Send email notification to the salesperson
- if order.user_id and order.user_id.partner_id:
- from markupsafe import Markup
- salesperson_partner = order.user_id.partner_id
- order.message_notify(
- partner_ids=[salesperson_partner.id],
- body=Markup(f"
New message from Authorizer {partner.name}:
{comment.strip()}
"),
- subject=f"[{order.name}] New message from Authorizer",
- author_id=partner.id,
- )
-
- # Also save to fusion.authorizer.comment for portal display
- if 'fusion.authorizer.comment' in request.env:
- request.env['fusion.authorizer.comment'].sudo().create({
- 'sale_order_id': order_id,
- 'author_id': partner.id,
- 'comment': comment.strip(),
- 'comment_type': 'general',
- })
-
- except Exception as e:
- _logger.error(f"Error adding comment: {e}")
-
- return request.redirect(f'/my/authorizer/case/{order_id}')
-
- @http.route('/my/authorizer/case//upload', type='http', auth='user', website=True, methods=['POST'], csrf=True)
- def authorizer_upload_document(self, order_id, document_type='full_application', document_file=None, revision_note='', **kw):
- """Upload a document for a case"""
- partner = request.env.user.partner_id
-
- if not partner.is_authorizer:
- return request.redirect('/my')
-
- if not document_file or not document_file.filename:
- return request.redirect(f'/my/authorizer/case/{order_id}')
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists() or order.x_fc_authorizer_id.id != partner.id:
- raise AccessError(_('You do not have access to this case.'))
-
- # Don't allow authorizers to upload 'submitted_final'
- if document_type == 'submitted_final':
- document_type = 'full_application'
-
- file_content = document_file.read()
- file_base64 = base64.b64encode(file_content)
-
- request.env['fusion.adp.document'].sudo().create({
- 'sale_order_id': order_id,
- 'document_type': document_type,
- 'file': file_base64,
- 'filename': document_file.filename,
- 'revision_note': revision_note,
- 'source': 'authorizer',
- })
-
- except Exception as e:
- _logger.error(f"Error uploading document: {e}")
-
- return request.redirect(f'/my/authorizer/case/{order_id}')
-
- @http.route('/my/authorizer/document//download', type='http', auth='user')
- def authorizer_download_document(self, doc_id, **kw):
- """Download a document"""
- partner = request.env.user.partner_id
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- try:
- document = request.env['fusion.adp.document'].sudo().browse(doc_id)
- if not document.exists():
- raise MissingError(_('Document not found.'))
-
- # Verify access
- if document.sale_order_id:
- order = document.sale_order_id
- has_access = (
- (partner.is_authorizer and order.x_fc_authorizer_id.id == partner.id) or
- (partner.is_sales_rep_portal and order.user_id.id == request.env.user.id)
- )
- if not has_access:
- raise AccessError(_('You do not have access to this document.'))
-
- file_content = base64.b64decode(document.file)
-
- # Check if viewing inline or downloading
- view_inline = kw.get('view', '0') == '1'
- disposition = 'inline' if view_inline else 'attachment'
-
- return request.make_response(
- file_content,
- headers=[
- ('Content-Type', document.mimetype or 'application/octet-stream'),
- ('Content-Disposition', f'{disposition}; filename="{document.filename}"'),
- ('Content-Length', len(file_content)),
- ]
- )
-
- except Exception as e:
- _logger.error(f"Error downloading document: {e}")
- return request.redirect('/my')
-
- @http.route(['/my/authorizer/case//attachment/',
- '/my/sales/case//attachment/'], type='http', auth='user')
- def authorizer_download_attachment(self, order_id, attachment_type, **kw):
- """Download an attachment from sale order (original application, xml, proof of delivery)"""
- partner = request.env.user.partner_id
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists():
- raise MissingError(_('Order not found.'))
-
- # Verify access
- has_access = (
- (partner.is_authorizer and order.x_fc_authorizer_id.id == partner.id) or
- (partner.is_sales_rep_portal and order.user_id.id == request.env.user.id)
- )
- if not has_access:
- raise AccessError(_('You do not have access to this order.'))
-
- # Get the attachment based on type
- attachment_map = {
- 'original_application': ('x_fc_original_application', 'x_fc_original_application_filename', 'application/pdf', 'original_application.pdf'),
- 'final_application': ('x_fc_final_submitted_application', 'x_fc_final_application_filename', 'application/pdf', 'final_application.pdf'),
- 'xml_file': ('x_fc_xml_file', 'x_fc_xml_filename', 'application/xml', 'application.xml'),
- 'proof_of_delivery': ('x_fc_proof_of_delivery', 'x_fc_proof_of_delivery_filename', 'application/pdf', 'proof_of_delivery.pdf'),
- }
-
- if attachment_type not in attachment_map:
- raise MissingError(_('Invalid attachment type.'))
-
- field_name, filename_field, default_mimetype, default_filename = attachment_map[attachment_type]
-
- if not hasattr(order, field_name) or not getattr(order, field_name):
- raise MissingError(_('Attachment not found.'))
-
- file_content = base64.b64decode(getattr(order, field_name))
- filename = getattr(order, filename_field, None) or default_filename
-
- # Check if viewing inline or downloading
- view_inline = kw.get('view', '0') == '1'
- disposition = 'inline' if view_inline else 'attachment'
-
- return request.make_response(
- file_content,
- headers=[
- ('Content-Type', default_mimetype),
- ('Content-Disposition', f'{disposition}; filename="{filename}"'),
- ('Content-Length', len(file_content)),
- ]
- )
-
- except Exception as e:
- _logger.error(f"Error downloading attachment: {e}")
- return request.redirect('/my')
-
- @http.route(['/my/authorizer/case//photo/',
- '/my/sales/case//photo/'], type='http', auth='user')
- def authorizer_view_photo(self, order_id, photo_id, **kw):
- """View an approval photo"""
- partner = request.env.user.partner_id
-
- if not partner.is_authorizer and not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists():
- raise MissingError(_('Order not found.'))
-
- # Verify access
- has_access = (
- (partner.is_authorizer and order.x_fc_authorizer_id.id == partner.id) or
- (partner.is_sales_rep_portal and order.user_id.id == request.env.user.id)
- )
- if not has_access:
- raise AccessError(_('You do not have access to this order.'))
-
- # Find the photo attachment
- attachment = request.env['ir.attachment'].sudo().browse(photo_id)
- if not attachment.exists() or attachment.id not in order.x_fc_approval_photo_ids.ids:
- raise MissingError(_('Photo not found.'))
-
- file_content = base64.b64decode(attachment.datas)
-
- return request.make_response(
- file_content,
- headers=[
- ('Content-Type', attachment.mimetype or 'image/png'),
- ('Content-Disposition', f'inline; filename="{attachment.name}"'),
- ('Content-Length', len(file_content)),
- ]
- )
-
- except Exception as e:
- _logger.error(f"Error viewing photo: {e}")
- return request.redirect('/my')
-
- # ==================== SALES REP PORTAL ====================
-
- @http.route(['/my/sales', '/my/sales/dashboard'], type='http', auth='user', website=True)
- def sales_rep_dashboard(self, search='', sale_type='', status='', **kw):
- """Sales rep dashboard with search and filters"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- SaleOrder = request.env['sale.order'].sudo()
- Assessment = request.env['fusion.assessment'].sudo()
-
- # Get case counts by status (unfiltered for stats)
- base_domain = [('user_id', '=', user.id)]
-
- draft_count = SaleOrder.search_count(base_domain + [('state', '=', 'draft')])
- sent_count = SaleOrder.search_count(base_domain + [('state', '=', 'sent')])
- sale_count = SaleOrder.search_count(base_domain + [('state', '=', 'sale')])
- total_count = SaleOrder.search_count(base_domain)
-
- # Build filtered domain for recent cases
- filtered_domain = base_domain.copy()
-
- # Apply search filter
- if search:
- search = search.strip()
- filtered_domain += [
- '|', '|', '|',
- ('name', 'ilike', search),
- ('partner_id.name', 'ilike', search),
- ('x_fc_claim_number', 'ilike', search),
- ('partner_id.email', 'ilike', search),
- ]
-
- # Apply sale type filter
- if sale_type:
- filtered_domain += [('x_fc_sale_type', '=', sale_type)]
-
- # Apply status filter
- if status:
- filtered_domain += [('state', '=', status)]
-
- # Recent cases (filtered)
- recent_cases = SaleOrder.search(filtered_domain, limit=20, order='date_order desc')
-
- # Assessments
- assessment_domain = [('sales_rep_id', '=', user.id)]
- pending_assessments = Assessment.search(
- assessment_domain + [('state', 'in', ['draft', 'pending_signature'])],
- limit=5, order='assessment_date desc'
- )
- completed_assessments_count = Assessment.search_count(
- assessment_domain + [('state', '=', 'completed')]
- )
-
- values = {
- 'partner': partner,
- 'draft_count': draft_count,
- 'sent_count': sent_count,
- 'sale_count': sale_count,
- 'total_count': total_count,
- 'recent_cases': recent_cases,
- 'pending_assessments': pending_assessments,
- 'completed_assessments_count': completed_assessments_count,
- 'page_name': 'sales_dashboard',
- # Search and filter values
- 'search': search,
- 'sale_type_filter': sale_type,
- 'status_filter': status,
- }
-
- return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
-
- @http.route(['/my/sales/cases', '/my/sales/cases/page/'], type='http', auth='user', website=True)
- def sales_rep_cases(self, page=1, search='', sortby='date', **kw):
- """List of cases for the sales rep"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- SaleOrder = request.env['sale.order'].sudo()
-
- # Build domain
- from odoo.osv import expression
- domain = [('user_id', '=', user.id)]
-
- # Add search filter
- if search:
- search_domain = [
- '|', '|', '|', '|',
- ('partner_id.name', 'ilike', search),
- ('name', 'ilike', search),
- ('x_fc_claim_number', 'ilike', search),
- ('x_fc_client_ref_1', 'ilike', search),
- ('x_fc_client_ref_2', 'ilike', search),
- ]
- domain = expression.AND([domain, search_domain])
-
- # Sorting
- sortings = {
- 'date': {'label': _('Date'), 'order': 'date_order desc'},
- 'name': {'label': _('Reference'), 'order': 'name'},
- 'client': {'label': _('Client'), 'order': 'partner_id'},
- 'state': {'label': _('Status'), 'order': 'state'},
- }
- order = sortings.get(sortby, sortings['date'])['order']
-
- # Pager
- case_count = SaleOrder.search_count(domain)
- pager = portal_pager(
- url='/my/sales/cases',
- url_args={'search': search, 'sortby': sortby},
- total=case_count,
- page=page,
- step=20,
- )
-
- # Get cases
- cases = SaleOrder.search(domain, order=order, limit=20, offset=pager['offset'])
-
- values = {
- 'cases': cases,
- 'pager': pager,
- 'search': search,
- 'sortby': sortby,
- 'sortings': sortings,
- 'page_name': 'sales_cases',
- }
-
- return request.render('fusion_authorizer_portal.portal_sales_cases', values)
-
- @http.route('/my/sales/cases/search', type='jsonrpc', auth='user')
- def sales_rep_cases_search(self, query='', **kw):
- """AJAX search endpoint for sales rep real-time search"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_sales_rep_portal:
- return {'error': 'Access denied', 'results': []}
-
- if len(query) < 2:
- return {'results': []}
-
- SaleOrder = request.env['sale.order'].sudo()
- orders = SaleOrder.get_sales_rep_portal_cases(user.id, search_query=query, limit=50)
-
- results = []
- for order in orders:
- results.append({
- 'id': order.id,
- 'name': order.name,
- 'partner_name': order.partner_id.name if order.partner_id else '',
- 'date_order': order.date_order.strftime('%Y-%m-%d') if order.date_order else '',
- 'state': order.state,
- 'state_display': dict(order._fields['state'].selection).get(order.state, order.state),
- 'claim_number': getattr(order, 'x_fc_claim_number', '') or '',
- 'client_ref_1': order.x_fc_client_ref_1 or '',
- 'client_ref_2': order.x_fc_client_ref_2 or '',
- 'url': f'/my/sales/case/{order.id}',
- })
-
- return {'results': results}
-
- @http.route('/my/sales/case/', type='http', auth='user', website=True)
- def sales_rep_case_detail(self, order_id, **kw):
- """View a specific case for sales rep"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists() or order.user_id.id != user.id:
- raise AccessError(_('You do not have access to this case.'))
- except (AccessError, MissingError):
- return request.redirect('/my/sales/cases')
-
- # Get documents
- documents = request.env['fusion.adp.document'].sudo().search([
- ('sale_order_id', '=', order_id),
- ('is_current', '=', True),
- ])
-
- # Get messages from chatter - only those relevant to this user
- all_messages = request.env['mail.message'].sudo().search([
- ('model', '=', 'sale.order'),
- ('res_id', '=', order_id),
- ('message_type', 'in', ['comment', 'notification']),
- ('body', '!=', ''),
- ('body', '!=', '
'),
- ], order='date desc', limit=100)
-
- # Filter to only show messages relevant to this partner:
- # 1. Messages authored by this partner
- # 2. Messages where this partner is in notified_partner_ids
- # 3. Messages where this partner is mentioned (partner_ids)
- def is_relevant_message(msg):
- if not msg.body or len(msg.body.strip()) == 0 or '
' in msg.body:
- return False
- # Authored by current partner
- if msg.author_id.id == partner.id:
- return True
- # Partner is in notified partners
- if partner.id in msg.notified_partner_ids.ids:
- return True
- # Partner is mentioned in partner_ids
- if partner.id in msg.partner_ids.ids:
- return True
- return False
-
- filtered_messages = all_messages.filtered(is_relevant_message)
-
- values = {
- 'order': order,
- 'documents': documents,
- 'messages': filtered_messages,
- 'page_name': 'sales_case_detail',
- }
-
- return request.render('fusion_authorizer_portal.portal_sales_case_detail', values)
-
- @http.route('/my/sales/case//comment', type='http', auth='user', website=True, methods=['POST'])
- def sales_rep_add_comment(self, order_id, comment='', **kw):
- """Add a comment to a case (sales rep) - posts to sale order chatter and emails authorizer"""
- partner = request.env.user.partner_id
- user = request.env.user
-
- if not partner.is_sales_rep_portal:
- return request.redirect('/my')
-
- if not comment.strip():
- return request.redirect(f'/my/sales/case/{order_id}')
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists() or order.user_id.id != user.id:
- raise AccessError(_('You do not have access to this case.'))
-
- # Post message to sale order chatter (internal note, not to all followers)
- message = order.message_post(
- body=comment.strip(),
- message_type='comment',
- subtype_xmlid='mail.mt_note', # Internal note - doesn't notify followers
- author_id=partner.id,
- )
-
- # Send email notification to the authorizer
- if order.x_fc_authorizer_id:
- from markupsafe import Markup
- order.message_notify(
- partner_ids=[order.x_fc_authorizer_id.id],
- body=Markup(f"
New message from Sales Rep {partner.name}:
{comment.strip()}
"),
- subject=f"[{order.name}] New message from Sales Rep",
- author_id=partner.id,
- )
-
- # Also save to fusion.authorizer.comment for portal display
- if 'fusion.authorizer.comment' in request.env:
- request.env['fusion.authorizer.comment'].sudo().create({
- 'sale_order_id': order_id,
- 'author_id': partner.id,
- 'comment': comment.strip(),
- 'comment_type': 'general',
- })
-
- except Exception as e:
- _logger.error(f"Error adding comment: {e}")
-
- return request.redirect(f'/my/sales/case/{order_id}')
-
- # ==================== CLIENT FUNDING CLAIMS PORTAL ====================
-
- def _prepare_home_portal_values(self, counters):
- """Add client funding claims count to portal home"""
- values = super()._prepare_home_portal_values(counters)
- partner = request.env.user.partner_id
-
- if 'funding_claims_count' in counters:
- # Count sale orders where partner is the customer
- values['funding_claims_count'] = request.env['sale.order'].sudo().search_count([
- ('partner_id', '=', partner.id),
- ('x_fc_sale_type', 'in', ['adp', 'adp_odsp', 'odsp', 'march_of_dimes']),
- ])
-
- return values
-
- @http.route(['/my/funding-claims', '/my/funding-claims/page/'], type='http', auth='user', website=True)
- def client_funding_claims(self, page=1, sortby='date', **kw):
- """List of funding claims for the client"""
- partner = request.env.user.partner_id
- SaleOrder = request.env['sale.order'].sudo()
-
- # Build domain - orders where partner is the customer
- domain = [
- ('partner_id', '=', partner.id),
- ('x_fc_sale_type', 'in', ['adp', 'adp_odsp', 'odsp', 'march_of_dimes']),
- ]
-
- # Sorting
- sortings = {
- 'date': {'label': _('Date'), 'order': 'date_order desc'},
- 'name': {'label': _('Reference'), 'order': 'name'},
- 'status': {'label': _('Status'), 'order': 'x_fc_adp_application_status'},
- }
- order = sortings.get(sortby, sortings['date'])['order']
-
- # Pager
- claim_count = SaleOrder.search_count(domain)
- pager = portal_pager(
- url='/my/funding-claims',
- url_args={'sortby': sortby},
- total=claim_count,
- page=page,
- step=20,
- )
-
- # Get claims
- claims = SaleOrder.search(domain, order=order, limit=20, offset=pager['offset'])
-
- values = {
- 'claims': claims,
- 'pager': pager,
- 'sortby': sortby,
- 'sortings': sortings,
- 'page_name': 'funding_claims',
- }
-
- return request.render('fusion_authorizer_portal.portal_client_claims', values)
-
- @http.route('/my/funding-claims/', type='http', auth='user', website=True)
- def client_funding_claim_detail(self, order_id, **kw):
- """View a specific funding claim"""
- partner = request.env.user.partner_id
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists() or order.partner_id.id != partner.id:
- raise AccessError(_('You do not have access to this claim.'))
- except (AccessError, MissingError):
- return request.redirect('/my/funding-claims')
-
- # Check if case is closed - documents only visible after case closed
- is_case_closed = order.x_fc_adp_application_status == 'case_closed'
-
- # Get documents (only if case is closed)
- documents = []
- if is_case_closed:
- documents = request.env['fusion.adp.document'].sudo().search([
- ('sale_order_id', '=', order_id),
- ('is_current', '=', True),
- ('document_type', 'in', ['submitted_final', 'pages_11_12']),
- ])
-
- # Get invoices
- invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
-
- values = {
- 'order': order,
- 'is_case_closed': is_case_closed,
- 'documents': documents,
- 'invoices': invoices,
- 'page_name': 'funding_claim_detail',
- }
-
- return request.render('fusion_authorizer_portal.portal_client_claim_detail', values)
-
- @http.route('/my/funding-claims//document//download', type='http', auth='user')
- def client_download_document(self, order_id, doc_id, **kw):
- """Download a document from a funding claim"""
- partner = request.env.user.partner_id
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists() or order.partner_id.id != partner.id:
- raise AccessError(_('You do not have access to this claim.'))
-
- # Check if case is closed
- if order.x_fc_adp_application_status != 'case_closed':
- raise AccessError(_('Documents are only available after the case is closed.'))
-
- document = request.env['fusion.adp.document'].sudo().browse(doc_id)
- if not document.exists() or document.sale_order_id.id != order_id:
- raise MissingError(_('Document not found.'))
-
- file_content = base64.b64decode(document.file)
-
- return request.make_response(
- file_content,
- headers=[
- ('Content-Type', document.mimetype or 'application/octet-stream'),
- ('Content-Disposition', f'attachment; filename="{document.filename}"'),
- ('Content-Length', len(file_content)),
- ]
- )
-
- except Exception as e:
- _logger.error(f"Error downloading document: {e}")
- return request.redirect('/my/funding-claims')
-
- @http.route('/my/funding-claims//proof-of-delivery', type='http', auth='user')
- def client_download_proof_of_delivery(self, order_id, **kw):
- """Download proof of delivery from a funding claim"""
- partner = request.env.user.partner_id
-
- try:
- order = request.env['sale.order'].sudo().browse(order_id)
- if not order.exists() or order.partner_id.id != partner.id:
- raise AccessError(_('You do not have access to this claim.'))
-
- # Check if case is closed
- if order.x_fc_adp_application_status != 'case_closed':
- raise AccessError(_('Documents are only available after the case is closed.'))
-
- if not order.x_fc_proof_of_delivery:
- raise MissingError(_('Proof of delivery not found.'))
-
- file_content = base64.b64decode(order.x_fc_proof_of_delivery)
- filename = order.x_fc_proof_of_delivery_filename or 'proof_of_delivery.pdf'
-
- return request.make_response(
- file_content,
- headers=[
- ('Content-Type', 'application/pdf'),
- ('Content-Disposition', f'attachment; filename="{filename}"'),
- ('Content-Length', len(file_content)),
- ]
- )
-
- except Exception as e:
- _logger.error(f"Error downloading proof of delivery: {e}")
- return request.redirect('/my/funding-claims')
-
- # ==================== TECHNICIAN PORTAL ====================
-
- def _check_technician_access(self):
- """Check if current user is a technician portal user."""
- partner = request.env.user.partner_id
- if not partner.is_technician_portal:
- return False
- return True
-
- @http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
- def technician_dashboard(self, **kw):
- """Technician dashboard - today's schedule with timeline."""
- if not self._check_technician_access():
- return request.redirect('/my')
-
- partner = request.env.user.partner_id
- user = request.env.user
- Task = request.env['fusion.technician.task'].sudo()
- SaleOrder = request.env['sale.order'].sudo()
- today = fields.Date.context_today(request.env['fusion.technician.task'])
-
- # Today's tasks
- today_tasks = Task.search([
- ('technician_id', '=', user.id),
- ('scheduled_date', '=', today),
- ('status', '!=', 'cancelled'),
- ], order='sequence, time_start, id')
-
- # Current in-progress task
- current_task = today_tasks.filtered(lambda t: t.status == 'in_progress')[:1]
-
- # Next upcoming task (first scheduled/en_route today)
- next_task = today_tasks.filtered(lambda t: t.status in ('scheduled', 'en_route'))[:1]
-
- # Stats
- completed_today = len(today_tasks.filtered(lambda t: t.status == 'completed'))
- remaining_today = len(today_tasks.filtered(lambda t: t.status in ('scheduled', 'en_route', 'in_progress')))
- total_today = len(today_tasks)
-
- # Total travel time for the day
- total_travel = sum(today_tasks.mapped('travel_time_minutes'))
-
- # Legacy: deliveries assigned (for backward compat with existing delivery views)
- delivery_domain = [('x_fc_delivery_technician_ids', 'in', [user.id])]
- pending_pod_count = SaleOrder.search_count(delivery_domain + [
- ('x_fc_pod_signature', '=', False),
- ('x_fc_adp_application_status', '=', 'ready_delivery'),
- ])
-
- # Tomorrow's task count
- from datetime import timedelta
- tomorrow = today + timedelta(days=1)
- tomorrow_count = Task.search_count([
- ('technician_id', '=', user.id),
- ('scheduled_date', '=', tomorrow),
- ('status', '!=', 'cancelled'),
- ])
-
- # Technician's personal start address
- start_address = user.sudo().x_fc_start_address or ''
-
- # Google Maps API key for Places autocomplete
- ICP = request.env['ir.config_parameter'].sudo()
- google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
-
- values = {
- 'today_tasks': today_tasks,
- 'current_task': current_task,
- 'next_task': next_task,
- 'completed_today': completed_today,
- 'remaining_today': remaining_today,
- 'total_today': total_today,
- 'total_travel': total_travel,
- 'pending_pod_count': pending_pod_count,
- 'tomorrow_count': tomorrow_count,
- 'today_date': today,
- 'start_address': start_address,
- 'google_maps_api_key': google_maps_api_key,
- 'page_name': 'technician_dashboard',
- }
- return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
-
- @http.route(['/my/technician/tasks', '/my/technician/tasks/page/'], type='http', auth='user', website=True)
- def technician_tasks(self, page=1, search='', filter_status='all', filter_date='', **kw):
- """List of all tasks for the technician."""
- if not self._check_technician_access():
- return request.redirect('/my')
-
- user = request.env.user
- Task = request.env['fusion.technician.task'].sudo()
-
- domain = [('technician_id', '=', user.id)]
-
- if filter_status == 'scheduled':
- domain.append(('status', '=', 'scheduled'))
- elif filter_status == 'in_progress':
- domain.append(('status', 'in', ('en_route', 'in_progress')))
- elif filter_status == 'completed':
- domain.append(('status', '=', 'completed'))
- elif filter_status == 'active':
- domain.append(('status', 'not in', ('cancelled', 'completed')))
- # Default: show all
-
- if filter_date:
- domain.append(('scheduled_date', '=', filter_date))
-
- if search:
- domain += ['|', '|', '|',
- ('name', 'ilike', search),
- ('partner_id.name', 'ilike', search),
- ('address_city', 'ilike', search),
- ('sale_order_id.name', 'ilike', search),
- ]
-
- task_count = Task.search_count(domain)
- pager = portal_pager(
- url='/my/technician/tasks',
- url_args={'search': search, 'filter_status': filter_status, 'filter_date': filter_date},
- total=task_count,
- page=page,
- step=20,
- )
-
- tasks = Task.search(domain, limit=20, offset=pager['offset'],
- order='scheduled_date desc, sequence, time_start')
-
- values = {
- 'tasks': tasks,
- 'pager': pager,
- 'search': search,
- 'filter_status': filter_status,
- 'filter_date': filter_date,
- 'page_name': 'technician_tasks',
- }
- return request.render('fusion_authorizer_portal.portal_technician_tasks', values)
-
- @http.route('/my/technician/task/', type='http', auth='user', website=True)
- def technician_task_detail(self, task_id, **kw):
- """View a specific technician task."""
- if not self._check_technician_access():
- return request.redirect('/my')
-
- user = request.env.user
- Task = request.env['fusion.technician.task'].sudo()
-
- try:
- task = Task.browse(task_id)
- if not task.exists() or task.technician_id.id != user.id:
- raise AccessError(_('You do not have access to this task.'))
- except (AccessError, MissingError):
- return request.redirect('/my/technician/tasks')
-
- # Check for earlier uncompleted tasks (sequential enforcement)
- earlier_incomplete = Task.search([
- ('technician_id', '=', user.id),
- ('scheduled_date', '=', task.scheduled_date),
- ('time_start', '<', task.time_start),
- ('status', 'not in', ['completed', 'cancelled']),
- ('id', '!=', task.id),
- ], order='time_start', limit=1)
-
- # Get order lines if linked to a sale order
- order_lines = []
- if task.sale_order_id:
- order_lines = task.sale_order_id.order_line.filtered(lambda l: not l.display_type)
-
- # Get VAPID public key for push notifications
- vapid_public = request.env['ir.config_parameter'].sudo().get_param(
- 'fusion_claims.vapid_public_key', ''
- )
-
- values = {
- 'task': task,
- 'order_lines': order_lines,
- 'vapid_public_key': vapid_public,
- 'page_name': 'technician_task_detail',
- 'earlier_incomplete': earlier_incomplete,
- }
- return request.render('fusion_authorizer_portal.portal_technician_task_detail', values)
-
- @http.route('/my/technician/task//add-notes', type='json', auth='user', website=True)
- def technician_task_add_notes(self, task_id, notes, photos=None, **kw):
- """Add notes (and optional photos) to a completed task.
-
- :param notes: text content of the note
- :param photos: list of dicts with 'data' (base64 data-url) and 'name'
- """
- if not self._check_technician_access():
- return {'success': False, 'error': 'Access denied'}
- user = request.env.user
- Task = request.env['fusion.technician.task'].sudo()
- Attachment = request.env['ir.attachment'].sudo()
- try:
- task = Task.browse(task_id)
- if not task.exists() or task.technician_id.id != user.id:
- return {'success': False, 'error': 'Task not found'}
-
- from markupsafe import Markup, escape
- import re
-
- # ----------------------------------------------------------
- # Process photos -> create ir.attachment records
- # ----------------------------------------------------------
- attachment_ids = []
- if photos:
- for i, photo in enumerate(photos):
- photo_data = photo.get('data', '')
- photo_name = photo.get('name', f'photo_{i+1}.jpg')
- if not photo_data:
- continue
- # Strip data-url prefix (e.g. "data:image/jpeg;base64,...")
- if ',' in photo_data:
- photo_data = photo_data.split(',', 1)[1]
- try:
- att = Attachment.create({
- 'name': photo_name,
- 'type': 'binary',
- 'datas': photo_data,
- 'res_model': 'fusion.technician.task',
- 'res_id': task.id,
- 'mimetype': 'image/jpeg',
- })
- attachment_ids.append(att.id)
- except Exception as e:
- _logger.warning("Failed to attach photo %s: %s", photo_name, e)
-
- # ----------------------------------------------------------
- # Sanitize and format the notes text
- # ----------------------------------------------------------
- safe_notes = str(escape(notes or ''))
- formatted_notes = re.sub(r'\n', ' ', safe_notes)
-
- timestamp = fields.Datetime.now().strftime("%b %d, %Y %I:%M %p")
- safe_user = str(escape(user.name))
- safe_task = str(escape(task.name))
-
- has_text = bool((notes or '').strip())
- photo_count = len(attachment_ids)
-
- # Build a small photo summary for inline display
- photo_html = ''
- if photo_count:
- photo_html = '
%d photo(s) attached
' % photo_count
-
- # --- 1. Append to the completion_notes field on the task ---
- note_parts = []
- if has_text:
- note_parts.append(
- '
%s
' % formatted_notes
- )
- if photo_html:
- note_parts.append(photo_html)
-
- if note_parts:
- new_note = Markup(
- '
'
- '%s - %s'
- '%s'
- '
'
- ) % (Markup(timestamp), Markup(safe_user), Markup(''.join(note_parts)))
-
- existing = task.completion_notes or ''
- task.completion_notes = Markup(existing) + new_note
-
- # --- 2. Post to the TASK chatter ---
- chatter_parts = []
- if has_text:
- chatter_parts.append(
- '
- Welcome to the Authorizer Portal.
- This portal is designed to streamline the ADP (Assistive Devices Program) process
- and keep you connected with your assigned cases.
-
-
-
What You Can Do
-
-
-
1
-
- View Assigned Cases
- Access all ADP cases assigned to you with real-time status updates.
-
-
-
-
2
-
- Complete Assessments
- Fill out assessments online with measurements, photos, and specifications.
-
-
-
-
3
-
- Track Application Status
- Monitor the progress of ADP applications from submission to approval.
-
-
-
-
4
-
- Upload Documents
- Upload ADP applications, signed pages 11 and 12, and supporting documentation.
-
-
-
-
-
Getting Started
-
-
Navigate to My Cases from the portal menu to see your assigned ADP cases.
-
Click on any case to view details, upload documents, or add comments.
-
To start a new assessment, go to Assessments and click New Assessment.
-
Complete the assessment form with all required measurements and photos.
-
-
-
Important Reminders
-
-
-
Assessment Validity: Assessments are valid for 3 months from the completion date.
-
Application Submission: Please submit the ADP application promptly after the assessment is completed.
-
Page 11: Must be signed by the applicant (or authorized agent: spouse, parent, legal guardian, public trustee, or power of attorney).
-
Page 12: Must be signed by the authorizer and the vendor.
-
-
-
-
Need Help?
-
- If you have any questions or need assistance, please contact our office:
-
-
-
- Email:
- Phone:
-
-
-
-
-
-
-
-
-
-
-
-
Welcome to the Sales Portal
-
- - Sales Dashboard
-
-
-
-
- Dear ,
-
-
- Welcome to the Sales Portal.
- This is your hub for managing sales orders, completing assessments, and tracking ADP cases.
-
-
-
What You Can Do
-
-
-
1
-
- Sales Dashboard
- View all your sales orders, filter by status, sale type, and search by client name or order number.
-
Assessment Date: {self.assessment_start_date or "N/A"}'
- f' to {self.assessment_end_date or "N/A"}
'
- f'
Status set to: {target_status.replace("_", " ").title()}
'
- '
'
- '
'
- )
- sale_order.message_post(
- body=workflow_msg,
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- # Post assessment details to chatter as HTML table
- assessment_html = self._format_assessment_html_table()
- sale_order.message_post(
- body=Markup(assessment_html),
- message_type='comment',
- subtype_xmlid='mail.mt_note',
- )
-
- # =====================================================================
- # Issue 4 fix: Post consent & agent details to chatter
- # =====================================================================
- if self.consent_signed_by:
- consent_label = 'Applicant' if self.consent_signed_by == 'applicant' else 'Agent'
- consent_parts = [
- f'
Signed by: {consent_label}
',
- ]
- if self.consent_date:
- consent_parts.append(f'
Consent date: {self.consent_date}
')
- if self.consent_declaration_accepted:
- consent_parts.append('
Declaration accepted: Yes
')
-
- # Add agent details if signed by agent
- if self.consent_signed_by == 'agent':
- agent_name = f"{self.agent_first_name or ''} {self.agent_last_name or ''}".strip()
- if agent_name:
- consent_parts.append(f'
Agent name: {agent_name}
')
- if self.agent_relationship:
- rel_labels = dict(self._fields['agent_relationship'].selection)
- consent_parts.append(
- f'
-
- Manage your assigned cases and documents from your Authorizer Portal.
-
-
- View your assigned deliveries and collect proof of delivery signatures.
-
-
- Track your cases and assessments from your Sales Portal.
-
-
- View your funding claims and track your applications.
-
-
-
- Rollator
- Wheelchair
- Powerchair
-
-
-
- Type 1 - Two Wheel Walker
- Type 2 - Rollator
- Type 3 - Heavy Duty Rollator
-
-
-
-
- Type 1 - Standard
- Type 2 - Lightweight
- Type 3 - Ultra Lightweight
- Type 4 - Tilt
- Type 5 - Dynamic Tilt
-
-
-
-
- Power Base Type 1
- Power Base Type 2
- Power Base Type 3
-
-
-
-
-
-
-
-
-
- Handle Height
- "
-
-
-
-
- Seat Height
- "
-
-
-
-
-
-
- Seat Width
- "
-
-
-
-
- Seat Depth
- "
-
-
-
-
- Legrest Length
- "
-
-
-
-
- Cane Height
- "
-
-
-
-
- Back Height
- "
-
-
-
-
- Client Weight
- lbs
-
-
-
-
-
-
-
-
-
- Cushion
-
-
-
-
-
- Backrest
-
-
-
-
-
-
-
-
-
-
- Selected Options & Addons
-
-
-
-
-
-
-
-
-
-
-
-
-
- Seat Belt
-
- Standard Belt
- Padded Belt (Modular)
- 4 Point Belt (Modular)
- Chest Harness (Modular)
- Additional Pads (Custom)
-
-
-
-
-
-
-
-
- Additional Notes
-
-
-
-
-
-
-
-
-
-
-
-
-
- Assessment Details
-
-
-
- Authorizer / OT
-
-
-
- Assessment Start
-
-
-
- Assessment End
-
-
-
- Claim Authorization Date
-
-
-
- Location
-
- Home Visit
- Clinic
- Hospital
- Long-Term Care
- Other
-
This invoice represents your client portion for the ADP-funded equipment. The remaining amount will be billed directly to the Assistive Devices Program.
-
-
-
-
-
Please review the attached invoice and process payment at your earliest convenience. Contact us if you have any questions.
-
-
-
-
-
--
-
-
-
-
- {{ object.partner_id.lang }}
-
-
-
-
-
-
diff --git a/fusion_claims/fusion_claims/data/pdf_template_data.xml b/fusion_claims/fusion_claims/data/pdf_template_data.xml
deleted file mode 100644
index 48f8e2b..0000000
--- a/fusion_claims/fusion_claims/data/pdf_template_data.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
diff --git a/fusion_claims/fusion_claims/data/product_labor_data.xml b/fusion_claims/fusion_claims/data/product_labor_data.xml
deleted file mode 100644
index 1af8108..0000000
--- a/fusion_claims/fusion_claims/data/product_labor_data.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
- LABOR - CHARGED HOURLY - NON REFUNDABLE
- LABOR
- service
- 75.00
-
-
-
-
-
-
-
diff --git a/fusion_claims/fusion_claims/data/stock_location_data.xml b/fusion_claims/fusion_claims/data/stock_location_data.xml
deleted file mode 100644
index c30d61d..0000000
--- a/fusion_claims/fusion_claims/data/stock_location_data.xml
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
-
-
- Loaner Stock
- internal
-
-
-
-
-
- Loaner Checkout Sequence
- fusion.loaner.checkout
- LOAN/
- 5
-
-
-
-
- Loaner Equipment
-
-
- Rollators
-
-
-
- Wheelchairs
-
-
-
- Powerchairs
-
-
-
-
diff --git a/fusion_claims/fusion_claims/models/__init__.py b/fusion_claims/fusion_claims/models/__init__.py
deleted file mode 100644
index 33231ce..0000000
--- a/fusion_claims/fusion_claims/models/__init__.py
+++ /dev/null
@@ -1,34 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-from . import email_builder_mixin
-from . import adp_posting_schedule
-from . import res_company
-from . import res_config_settings
-from . import fusion_central_config
-from . import fusion_adp_device_code
-from . import product_template
-from . import product_product
-from . import sale_order
-from . import sale_order_line
-from . import account_move
-from . import account_move_line
-from . import account_payment
-from . import account_payment_method_line
-from . import submission_history
-from . import fusion_loaner_checkout
-from . import fusion_loaner_history
-from . import client_profile
-from . import adp_application_data
-from . import xml_parser
-from . import client_chat
-from . import ai_agent_ext
-from . import dashboard
-from . import res_partner
-from . import res_users
-from . import technician_task
-from . import task_sync
-from . import technician_location
-from . import push_subscription
\ No newline at end of file
diff --git a/fusion_claims/fusion_claims/models/account_move.py b/fusion_claims/fusion_claims/models/account_move.py
deleted file mode 100644
index 3ed5517..0000000
--- a/fusion_claims/fusion_claims/models/account_move.py
+++ /dev/null
@@ -1,1217 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-import logging
-from odoo import models, fields, api
-
-_logger = logging.getLogger(__name__)
-
-
-class AccountMove(models.Model):
- _name = 'account.move'
- _inherit = ['account.move', 'fusion_claims.adp.posting.schedule.mixin']
-
- # ==========================================================================
- # FIELD FLAGS
- # ==========================================================================
- x_fc_is_adp_invoice = fields.Boolean(
- compute='_compute_is_adp_invoice_flag',
- string='Is ADP Invoice',
- )
-
- def _compute_is_adp_invoice_flag(self):
- """Compute if this is an ADP invoice."""
- for move in self:
- move.x_fc_is_adp_invoice = move._is_adp_invoice()
-
- def _compute_payment_state(self):
- """Extend to auto-advance linked ODSP orders when invoice is paid."""
- old_states = {move.id: move.payment_state for move in self}
- super()._compute_payment_state()
- for move in self:
- if move.payment_state in ('paid', 'in_payment') and old_states.get(move.id) != move.payment_state:
- move._auto_advance_odsp_on_payment()
-
- x_fc_is_mod_invoice = fields.Boolean(
- compute='_compute_is_mod_invoice',
- string='Is MOD Invoice',
- )
-
- @api.depends('x_fc_invoice_type')
- def _compute_is_mod_invoice(self):
- for move in self:
- move.x_fc_is_mod_invoice = move.x_fc_invoice_type == 'march_of_dimes'
-
- def _auto_advance_odsp_on_payment(self):
- """When invoice is paid, auto-advance linked ODSP order to payment_received."""
- self.ensure_one()
- so = self.x_fc_source_sale_order_id
- if not so:
- so = self.invoice_line_ids.mapped('sale_line_ids.order_id')[:1]
- if not so or not so.x_fc_is_odsp_sale:
- return
- current_status = so._get_odsp_status()
- eligible = {'pod_submitted', 'submitted_to_ow', 'payment_received'}
- if current_status in eligible:
- if current_status != 'payment_received':
- so._odsp_advance_status(
- 'payment_received',
- f"Payment received on invoice {self.name}. Status auto-advanced.",
- )
- _logger.info(f"Auto-advanced ODSP order {so.name} to payment_received")
- if so.x_fc_odsp_division == 'ontario_works':
- self._ow_schedule_delivery_activity(so)
-
- def _ow_schedule_delivery_activity(self, so):
- """Schedule an activity on the OW sale order to arrange delivery."""
- from datetime import timedelta
- try:
- activity_type = self.env.ref('mail.mail_activity_data_todo')
- so.activity_schedule(
- 'mail.mail_activity_data_todo',
- date_deadline=fields.Date.today() + timedelta(days=3),
- summary="Schedule delivery for Ontario Works case",
- note="Payment received on invoice %s. Please schedule delivery for this Ontario Works order." % self.name,
- user_id=so.user_id.id or self.env.uid,
- )
- except Exception as e:
- _logger.warning(f"Could not schedule delivery activity for {so.name}: {e}")
-
- def action_mod_send_invoice(self):
- """Send MOD invoice to the case worker via email."""
- self.ensure_one()
- so = self.x_fc_source_sale_order_id
- if not so:
- from odoo.exceptions import UserError
- raise UserError("No linked sale order found.")
-
- # Get case worker email from the sale order's case worker contact
- case_worker = so.x_fc_case_worker
- if not case_worker or not case_worker.email:
- from odoo.exceptions import UserError
- raise UserError(
- "No case worker with email found on the sale order.\n\n"
- "Please add the case worker contact in the sale order first."
- )
-
- # Generate MOD Invoice PDF
- import base64
- from markupsafe import Markup
- attachment_ids = []
- attachment_names = []
- Attachment = self.env['ir.attachment'].sudo()
- try:
- report = self.env.ref('fusion_claims.action_report_mod_invoice')
- pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
- client_name = (so.partner_id.name or 'Client').replace(' ', '_').replace(',', '')
- att = Attachment.create({
- 'name': f'Invoice - {client_name} - {self.name}.pdf',
- 'type': 'binary',
- 'datas': base64.b64encode(pdf_content),
- 'res_model': 'account.move',
- 'res_id': self.id,
- 'mimetype': 'application/pdf',
- })
- attachment_ids.append(att.id)
- attachment_names.append(att.name)
- except Exception as e:
- import logging
- logging.getLogger(__name__).error(f"Failed to generate MOD invoice PDF: {e}")
-
- client_name = so.partner_id.name or 'Client'
- sender_name = (so.user_id or self.env.user).name
- ref = so.x_fc_case_reference or ''
-
- body_html = so._mod_email_build(
- title='Invoice Submitted',
- summary=f'Please find attached the invoice for the accessibility modification project '
- f'for {client_name}.',
- email_type='info',
- sections=[('Invoice Details', [
- ('Invoice', self.name),
- ('Client', client_name),
- ('HVMP Reference', ref or 'N/A'),
- ('Amount', f'${self.amount_total:,.2f}'),
- ])],
- note='Please process the payment as per the Payment Commitment Agreement terms.',
- attachments_note=', '.join(attachment_names) if attachment_names else None,
- sender_name=sender_name,
- )
-
- subject = f'Invoice - {ref} - {client_name}' if ref else f'Invoice - {client_name} - {self.name}'
-
- cc_list = []
- if so.user_id and so.user_id.email:
- cc_list.append(so.user_id.email)
-
- self.env['mail.mail'].sudo().create({
- 'subject': subject,
- 'body_html': body_html,
- 'email_to': case_worker.email,
- 'email_cc': ', '.join(cc_list) if cc_list else '',
- 'model': 'account.move',
- 'res_id': self.id,
- 'attachment_ids': [(6, 0, attachment_ids)] if attachment_ids else False,
- }).send()
-
- # Log to chatter on both invoice and sale order
- so._email_chatter_log('MOD Invoice sent', case_worker.email,
- ', '.join(cc_list) if cc_list else None,
- [f'Invoice: {self.name}'] + ([f'Attachments: {", ".join(attachment_names)}'] if attachment_names else []))
- self.message_post(
- body=Markup(
- f'
'
- f'Invoice sent to case worker: {case_worker.name} ({case_worker.email})'
- f'
'
- ),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'Invoice Sent',
- 'message': f'Invoice sent to {case_worker.name} ({case_worker.email})',
- 'type': 'success',
- 'sticky': False,
- },
- }
-
- # ==========================================================================
- # INVOICE TYPE AND CLIENT TYPE FIELDS
- # ==========================================================================
- x_fc_invoice_type = fields.Selection(
- selection=[
- ('adp', 'ADP'),
- ('adp_client', 'ADP Client Portion'),
- ('adp_odsp', 'ADP/ODSP'),
- ('odsp', 'ODSP'),
- ('wsib', 'WSIB'),
- ('direct_private', 'Direct/Private'),
- ('insurance', 'Insurance'),
- ('march_of_dimes', 'March of Dimes'),
- ('muscular_dystrophy', 'Muscular Dystrophy'),
- ('other', 'Others'),
- ('rental', 'Rentals'),
- ('hardship', 'Hardship Funding'),
- ('regular', 'Regular'),
- ],
- string='Invoice Type',
- tracking=True,
- help='Type of invoice for billing purposes',
- )
- x_fc_client_type = fields.Selection(
- selection=[
- ('REG', 'REG'),
- ('ODS', 'ODS'),
- ('OWP', 'OWP'),
- ('ACS', 'ACS'),
- ('LTC', 'LTC'),
- ('SEN', 'SEN'),
- ('CCA', 'CCA'),
- ],
- string='Client Type',
- tracking=True,
- help='Client type for ADP portion calculations. REG = 75%/25%, others = 100%/0%',
- )
-
- # Authorizer Required field - only for certain invoice types
- x_fc_authorizer_required = fields.Selection(
- selection=[
- ('yes', 'Yes'),
- ('no', 'No'),
- ],
- string='Authorizer Required?',
- help='For ODSP, Direct/Private, Insurance, Others, and Rentals - specify if an authorizer is needed.',
- )
-
- # Computed field to determine if authorizer should be shown
- x_fc_show_authorizer = fields.Boolean(
- compute='_compute_show_authorizer',
- string='Show Authorizer',
- )
-
- @api.depends('x_fc_invoice_type', 'x_fc_authorizer_required')
- def _compute_show_authorizer(self):
- """Compute whether to show the authorizer field based on invoice type and authorizer_required."""
- # Invoice types that require the "Authorizer Required?" question
- optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
- # Invoice types where authorizer is always shown
- always_auth_types = ('adp', 'adp_client', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
-
- for move in self:
- invoice_type = move.x_fc_invoice_type
- if invoice_type in always_auth_types:
- move.x_fc_show_authorizer = True
- elif invoice_type in optional_auth_types:
- move.x_fc_show_authorizer = move.x_fc_authorizer_required == 'yes'
- else:
- move.x_fc_show_authorizer = False
-
- # Computed field to determine if "Authorizer Required?" question should be shown
- x_fc_show_authorizer_question = fields.Boolean(
- compute='_compute_show_authorizer_question',
- string='Show Authorizer Question',
- )
-
- @api.depends('x_fc_invoice_type')
- def _compute_show_authorizer_question(self):
- """Compute whether to show the 'Authorizer Required?' field."""
- optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
- for move in self:
- move.x_fc_show_authorizer_question = move.x_fc_invoice_type in optional_auth_types
-
- # ==========================================================================
- # ADP CLAIM FIELDS (Copied from Sale Order)
- # ==========================================================================
- x_fc_claim_number = fields.Char(
- string='Claim Number',
- tracking=True,
- copy=False,
- help='ADP Claim Number',
- )
- x_fc_client_ref_1 = fields.Char(
- string='Client Reference 1',
- help='Primary client reference (e.g., Health Card Number)',
- )
- x_fc_client_ref_2 = fields.Char(
- string='Client Reference 2',
- help='Secondary client reference',
- )
- x_fc_adp_delivery_date = fields.Date(
- string='ADP Delivery Date',
- help='Date the product was delivered to the client (for ADP billing)',
- )
- x_fc_service_start_date = fields.Date(
- string='Service Start Date',
- help='Service period start date (optional)',
- )
- x_fc_service_end_date = fields.Date(
- string='Service End Date',
- help='Service period end date (optional)',
- )
- x_fc_authorizer_id = fields.Many2one(
- 'res.partner',
- string='Authorizer',
- help='Authorizer contact for this invoice',
- domain="[('is_company', '=', False)]",
- )
-
- x_fc_primary_serial = fields.Char(
- string='Primary Serial Number',
- help='Primary serial number for the invoice (header level). '
- 'Line-level serials are tracked on individual invoice lines.',
- copy=False,
- )
-
- # ==========================================================================
- # SPLIT INVOICE TRACKING
- # ==========================================================================
- x_fc_source_sale_order_id = fields.Many2one(
- 'sale.order',
- string='Source Sale Order',
- help='The sale order this split invoice was created from',
- index=True,
- )
- x_fc_adp_invoice_portion = fields.Selection(
- selection=[
- ('client', 'Client Portion (25%)'),
- ('adp', 'ADP Portion (75%)'),
- ('full', 'Full Invoice'),
- ],
- string='Invoice Portion',
- default='full',
- help='Tracks whether this is a split invoice for client or ADP portion',
- )
-
- is_manually_modified = fields.Boolean(
- string='Manually Modified',
- default=False,
- copy=False,
- help='Set to True if invoice fields have been manually edited (not synced from SO)',
- )
-
- # ==========================================================================
- # COMPUTED TOTALS FOR ADP PORTIONS
- # ==========================================================================
- x_fc_adp_portion_total = fields.Monetary(
- string='Total ADP Portion',
- compute='_compute_adp_totals',
- currency_field='currency_id',
- help='Total ADP portion from the linked sale order',
- )
- x_fc_client_portion_total = fields.Monetary(
- string='Total Client Portion',
- compute='_compute_adp_totals',
- currency_field='currency_id',
- help='Total client portion from the linked sale order',
- )
-
- # Sibling invoice totals (shows the OTHER invoice's total)
- x_fc_sibling_adp_total = fields.Monetary(
- string='ADP Invoice Total',
- compute='_compute_sibling_totals',
- currency_field='currency_id',
- help='Total from the sibling ADP portion invoice',
- )
- x_fc_sibling_client_total = fields.Monetary(
- string='Client Invoice Total',
- compute='_compute_sibling_totals',
- currency_field='currency_id',
- help='Total from the sibling Client portion invoice',
- )
-
- # ==========================================================================
- # COMPUTED FIELD FOR PRODUCT-ONLY LINES (for ADP Summary)
- # ==========================================================================
- x_fc_product_lines = fields.One2many(
- 'account.move.line',
- compute='_compute_product_lines',
- string='Product Lines Only',
- help='Only product lines (excludes sections, notes, and empty lines)',
- )
-
- # ==========================================================================
- # DEDUCTION TRACKING
- # ==========================================================================
- x_fc_has_deductions = fields.Boolean(
- string='Has Deductions',
- compute='_compute_has_deductions',
- help='True if any line has a deduction applied',
- )
- x_fc_total_deduction_amount = fields.Monetary(
- string='Total Deduction Amount',
- compute='_compute_has_deductions',
- currency_field='currency_id',
- help='Total amount of deductions applied to ADP portion',
- )
-
- # ==========================================================================
- # DEVICE VERIFICATION STATUS (for linked Sale Order)
- # ==========================================================================
- x_fc_needs_device_verification = fields.Boolean(
- string='Needs Device Verification',
- compute='_compute_needs_device_verification',
- help='True if this is a client invoice and the linked SO needs device verification',
- )
-
- @api.depends('x_fc_adp_invoice_portion', 'invoice_line_ids.sale_line_ids')
- def _compute_needs_device_verification(self):
- """Check if this client invoice needs device verification.
-
- Shows True if:
- - This is a client portion invoice
- - The linked sale order has ADP devices
- - Device verification is NOT complete
- """
- for move in self:
- needs_verification = False
-
- # Only applies to client invoices
- if move.x_fc_adp_invoice_portion == 'client':
- # Find linked sale order
- sale_order = None
- for line in move.invoice_line_ids:
- if line.sale_line_ids:
- sale_order = line.sale_line_ids[0].order_id
- break
-
- if sale_order:
- # Check if SO has ADP devices and verification is not complete
- if (sale_order.x_fc_total_device_count > 0 and
- not sale_order.x_fc_device_verification_complete):
- needs_verification = True
-
- move.x_fc_needs_device_verification = needs_verification
-
- # ==========================================================================
- # ADP EXPORT TRACKING
- # ==========================================================================
- adp_exported = fields.Boolean(
- string='ADP Exported',
- default=False,
- copy=False,
- help='Has this invoice been exported to ADP format',
- )
- adp_export_date = fields.Datetime(
- string='ADP Export Date',
- copy=False,
- )
- adp_export_count = fields.Integer(
- string='Export Count',
- default=0,
- copy=False,
- help='Number of times this invoice has been exported',
- )
-
- # ==========================================================================
- # ADP BILLING STATUS (Post-Export Lifecycle)
- # ==========================================================================
- x_fc_adp_billing_status = fields.Selection(
- selection=[
- ('not_applicable', 'Not Applicable'),
- ('waiting', 'Waiting'),
- ('submitted', 'Submitted'),
- ('resubmitted', 'Resubmitted'),
- ('need_correction', 'Need Correction'),
- ('payment_issued', 'Payment Issued'),
- ('cancelled', 'Cancelled'),
- ],
- string='ADP Billing Status',
- default='not_applicable',
- tracking=True,
- copy=False,
- help='Tracks the ADP billing lifecycle after invoice export',
- )
-
- # (Legacy studio fields removed - all data migrated to x_fc_* fields)
-
- # ==========================================================================
- # COMPUTED METHODS
- # ==========================================================================
- @api.depends('invoice_line_ids', 'invoice_line_ids.x_fc_adp_portion', 'invoice_line_ids.x_fc_client_portion')
- def _compute_adp_totals(self):
- """Compute ADP and Client portion totals from invoice lines.
-
- These totals are calculated from the stored portion values on each
- invoice line, which were set during invoice creation using the
- device codes database pricing and client type calculation.
- """
- for move in self:
- # Sum portions from invoice lines (values set during invoice creation)
- adp_total = sum(move.invoice_line_ids.mapped('x_fc_adp_portion') or [0])
- client_total = sum(move.invoice_line_ids.mapped('x_fc_client_portion') or [0])
-
- move.x_fc_adp_portion_total = adp_total
- move.x_fc_client_portion_total = client_total
-
- @api.depends('invoice_line_ids', 'invoice_line_ids.product_id', 'invoice_line_ids.quantity', 'invoice_line_ids.display_type')
- def _compute_product_lines(self):
- """Compute filtered list of only actual product lines (no sections, notes, or empty lines)."""
- for move in self:
- # Invoice lines have display_type='product' for actual products (unlike sale.order.line which uses False)
- # Filter to only include product lines with quantity > 0
- move.x_fc_product_lines = move.invoice_line_ids.filtered(
- lambda l: l.display_type == 'product' and l.product_id and l.quantity > 0
- )
-
- @api.depends('invoice_line_ids.x_fc_deduction_type', 'invoice_line_ids.x_fc_deduction_value',
- 'invoice_line_ids.x_fc_adp_portion', 'invoice_line_ids.product_id')
- def _compute_has_deductions(self):
- """Compute if invoice has any deductions and total deduction amount."""
- for move in self:
- product_lines = move.invoice_line_ids.filtered(
- lambda l: l.display_type == 'product' and l.product_id and l.quantity > 0
- )
-
- # Check if any line has a deduction
- has_deductions = any(
- line.x_fc_deduction_type and line.x_fc_deduction_type != 'none'
- for line in product_lines
- )
-
- # Calculate total deduction impact
- total_deduction = 0.0
- if has_deductions:
- for line in product_lines:
- if line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
- total_deduction += line.x_fc_deduction_value
- elif line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
- # For percentage, calculate the reduction from normal
- client_type = move._get_client_type()
- base_pct = 0.75 if client_type == 'REG' else 1.0
- adp_price = line.x_fc_adp_max_price or line.price_unit
- normal_adp = adp_price * line.quantity * base_pct
- actual_adp = line.x_fc_adp_portion
- total_deduction += max(0, normal_adp - actual_adp)
-
- move.x_fc_has_deductions = has_deductions
- move.x_fc_total_deduction_amount = total_deduction
-
- def _compute_sibling_totals(self):
- """Compute the OTHER portion totals from the linked sale order.
-
- For Client Invoice: show the ADP portion from the sale order
- For ADP Invoice: show the Client portion from the sale order
-
- This way the report always shows both portions even if the other
- invoice hasn't been created yet.
- """
- SaleOrder = self.env['sale.order'].sudo()
-
- for move in self:
- adp_total = 0.0
- client_total = 0.0
- sale_order = False
-
- # Method 1: Find the SO via invoice_ids reverse relation
- sale_order = SaleOrder.search([('invoice_ids', 'in', move.id)], limit=1)
-
- # Method 2: Find via invoice_line_ids.sale_line_ids
- if not sale_order:
- sale_line_ids = move.invoice_line_ids.mapped('sale_line_ids')
- if sale_line_ids:
- sale_order = sale_line_ids[0].order_id
-
- # Method 3: Parse invoice_origin (e.g., "S29958 (Client 25%)")
- if not sale_order and move.invoice_origin:
- origin = move.invoice_origin.split(' ')[0] if move.invoice_origin else ''
- if origin:
- sale_order = SaleOrder.search([('name', '=', origin)], limit=1)
-
- if sale_order:
- # Get the portions from the sale order
- adp_total = sale_order.x_fc_adp_portion_total or 0.0
- client_total = sale_order.x_fc_client_portion_total or 0.0
-
- move.x_fc_sibling_adp_total = adp_total
- move.x_fc_sibling_client_total = client_total
-
- @api.onchange('x_fc_invoice_type', 'x_fc_client_type')
- def _onchange_invoice_type_client_type(self):
- """Trigger recalculation when invoice type or client type changes."""
- for line in self.invoice_line_ids:
- line._compute_adp_portions()
-
- # ==========================================================================
- # GETTER METHODS
- # ==========================================================================
- def _get_invoice_type(self):
- """Get invoice type from mapped field or built-in field."""
- self.ensure_one()
- ICP = self.env['ir.config_parameter'].sudo()
- field_name = ICP.get_param('fusion_claims.field_invoice_type', 'x_fc_invoice_type')
- value = getattr(self, field_name, None) if hasattr(self, field_name) else None
- if not value and field_name != 'x_fc_invoice_type':
- value = self.x_fc_invoice_type
- return value or ''
-
- def _get_client_type(self):
- """Get client type from mapped field or built-in field."""
- self.ensure_one()
- ICP = self.env['ir.config_parameter'].sudo()
- field_name = ICP.get_param('fusion_claims.field_inv_client_type', 'x_fc_client_type')
- value = getattr(self, field_name, None) if hasattr(self, field_name) else None
- if not value and field_name != 'x_fc_client_type':
- value = self.x_fc_client_type
- return value or ''
-
- def _get_authorizer(self):
- """Get authorizer from mapped field or built-in field. Returns name as string."""
- self.ensure_one()
- ICP = self.env['ir.config_parameter'].sudo()
- field_name = ICP.get_param('fusion_claims.field_inv_authorizer', 'x_fc_authorizer_id')
- value = getattr(self, field_name, None) if hasattr(self, field_name) else None
- if not value and field_name != 'x_fc_authorizer_id':
- value = self.x_fc_authorizer_id
- # Return name if it's a record, otherwise return string value
- if hasattr(value, 'name'):
- return value.name or ''
- return str(value) if value else ''
-
- def _get_claim_number(self):
- """Get claim number."""
- self.ensure_one()
- return self.x_fc_claim_number or ''
-
- def _get_client_ref_1(self):
- """Get client reference 1."""
- self.ensure_one()
- return self.x_fc_client_ref_1 or ''
-
- def _get_client_ref_2(self):
- """Get client reference 2."""
- self.ensure_one()
- return self.x_fc_client_ref_2 or ''
-
- def _get_adp_delivery_date(self):
- """Get ADP delivery date."""
- self.ensure_one()
- return self.x_fc_adp_delivery_date
-
- def _is_adp_invoice(self):
- """Check if this is an ADP invoice type."""
- self.ensure_one()
- invoice_type = self._get_invoice_type()
- if not invoice_type:
- return False
- invoice_type_lower = str(invoice_type).lower()
- return 'adp' in invoice_type_lower
-
- def _get_serial_numbers(self):
- """Get all serial numbers from invoice lines."""
- self.ensure_one()
- serial_lines = []
- for line in self.invoice_line_ids:
- serial = line._get_serial_number()
- if serial:
- serial_lines.append({
- 'product': line.product_id.name if line.product_id else line.name,
- 'serial': serial,
- 'adp_code': line._get_adp_device_code(),
- })
- return serial_lines
-
- # ==========================================================================
- # ACTION METHODS
- # ==========================================================================
- def action_export_adp_claim(self):
- """Open the ADP export wizard for this invoice."""
- self.ensure_one()
- return {
- 'name': 'Export ADP Claim',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.export.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'default_invoice_ids': [(6, 0, self.ids)],
- 'active_ids': self.ids,
- 'active_model': 'account.move',
- },
- }
-
- def action_recalculate_adp_portions(self):
- """Manually recalculate ADP and Client portions for all lines."""
- for move in self:
- for line in move.invoice_line_ids:
- line._compute_adp_portions()
- move._compute_adp_totals()
-
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'ADP Portions Recalculated',
- 'message': 'All line portions have been recalculated.',
- 'type': 'success',
- 'sticky': False,
- }
- }
-
- def action_sync_to_sale_order(self):
- """Sync ADP fields from this Invoice TO the linked Sale Order and all other invoices.
-
- This is a 2-way sync: Invoice becomes the source of truth, updates SO,
- then SO syncs to all linked invoices.
- """
- synced_orders = []
- for move in self:
- _logger.info(f"=== Invoice {move.name} Sync to SO started ===")
-
- # Find linked sale orders via sale_line_ids -> order_id
- sale_orders = self.env['sale.order']
- for line in move.invoice_line_ids:
- if line.sale_line_ids:
- sale_orders |= line.sale_line_ids.mapped('order_id')
-
- # Also try direct search
- if not sale_orders:
- sale_orders = self.env['sale.order'].search([
- ('invoice_ids', 'in', move.id)
- ])
-
- if not sale_orders:
- _logger.warning(f"No linked sale orders found for invoice {move.name}")
- continue
-
- _logger.info(f"Found {len(sale_orders)} linked sale orders: {sale_orders.mapped('name')}")
-
- # Update all linked sale orders with invoice values
- for order in sale_orders:
- so_vals = {}
-
- # Get values from invoice FC fields
- so_vals['x_fc_claim_number'] = move.x_fc_claim_number or False
- so_vals['x_fc_client_ref_1'] = move.x_fc_client_ref_1 or False
- so_vals['x_fc_client_ref_2'] = move.x_fc_client_ref_2 or False
- so_vals['x_fc_adp_delivery_date'] = move.x_fc_adp_delivery_date or False
-
- authorizer_id = move.x_fc_authorizer_id.id if move.x_fc_authorizer_id else False
- so_vals['x_fc_authorizer_id'] = authorizer_id or False
-
- # Client Type
- if move.x_fc_client_type:
- so_vals['x_fc_client_type'] = move.x_fc_client_type
-
- # Service Dates
- so_vals['x_fc_service_start_date'] = move.x_fc_service_start_date or False
- so_vals['x_fc_service_end_date'] = move.x_fc_service_end_date or False
-
- # Primary Serial Number
- if move.x_fc_primary_serial:
- so_vals['x_fc_primary_serial'] = move.x_fc_primary_serial
-
- _logger.debug(f" SO vals to write: {so_vals}")
-
- try:
- # Update the Sale Order (skip_sync to avoid infinite loop)
- order.sudo().with_context(skip_sync=True).write(so_vals)
- synced_orders.append(order.name)
- _logger.info(f"SUCCESS: Synced fields from invoice {move.name} to SO {order.name}")
-
- # Sync line-level fields (Serial Numbers) from Invoice Lines to SO Lines
- self._sync_line_fields_to_sale_order(move, order)
-
- # Now sync from SO to ALL linked invoices (including this one and others)
- order.with_context(skip_sync=False)._sync_fields_to_invoices()
-
- except Exception as e:
- _logger.error(f"FAILED to sync from invoice {move.name} to SO {order.name}: {e}")
-
- if synced_orders:
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'Sync Complete',
- 'message': f'Synced ADP fields to Sale Order(s): {", ".join(synced_orders)} and all linked invoices.',
- 'type': 'success',
- 'sticky': False,
- }
- }
- else:
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'No Sync Performed',
- 'message': 'No linked Sale Orders found for this invoice.',
- 'type': 'warning',
- 'sticky': False,
- }
- }
-
- def _sync_line_fields_to_sale_order(self, invoice, sale_order):
- """Sync Serial Numbers from Invoice lines to corresponding SO lines and sibling invoice lines.
-
- Each invoice line syncs its serial to:
- 1. The linked SO line's x_fc_serial_number
- 2. All other invoice lines linked to that same SO line
- Also syncs first serial to SO header's x_fc_primary_serial
- """
- primary_serial = None # To collect primary serial for SO header
-
- for inv_line in invoice.invoice_line_ids:
- if inv_line.display_type in ('line_section', 'line_note'):
- continue
-
- # Get Serial Number from invoice line
- serial_number = None
- if 'x_fc_serial_number' in inv_line._fields:
- serial_number = inv_line.x_fc_serial_number
-
- # Track first non-empty serial as primary for SO header
- if serial_number and not primary_serial:
- primary_serial = serial_number
-
- # Find linked SO lines
- so_lines = inv_line.sale_line_ids
- if not so_lines:
- continue
-
- for so_line in so_lines:
- # Update SO line's serial
- if serial_number is not None:
- so_line_vals = {}
- if 'x_fc_serial_number' in so_line._fields:
- so_line_vals['x_fc_serial_number'] = serial_number or False
- if so_line_vals:
- try:
- so_line.sudo().with_context(skip_sync=True).write(so_line_vals)
- _logger.debug(f" Synced serial '{serial_number}' to SO line {so_line.id}")
- except Exception as e:
- _logger.error(f" Failed to sync serial to SO line {so_line.id}: {e}")
-
- # Find sibling invoice lines (other invoices linked to same SO line)
- sibling_inv_lines = self.env['account.move.line'].sudo().search([
- ('sale_line_ids', 'in', so_line.id),
- ('id', '!=', inv_line.id),
- ('move_id.state', '!=', 'cancel'),
- ])
-
- for sibling_line in sibling_inv_lines:
- sibling_vals = {}
- # Sync serial number to sibling invoice line
- if 'x_fc_serial_number' in sibling_line._fields:
- sibling_vals['x_fc_serial_number'] = serial_number or False
- if sibling_vals:
- try:
- sibling_line.sudo().with_context(skip_sync=True).write(sibling_vals)
- _logger.debug(f" Synced serial '{serial_number}' to sibling inv line {sibling_line.id} (inv {sibling_line.move_id.name})")
- except Exception as e:
- _logger.error(f" Failed to sync serial to sibling inv line {sibling_line.id}: {e}")
-
- # Sync primary serial to SO header (FC field only - Studio fields are read-only)
- if primary_serial:
- so_header_vals = {'x_fc_primary_serial': primary_serial}
- try:
- sale_order.sudo().with_context(skip_sync=True).write(so_header_vals)
- _logger.debug(f" Synced primary serial to SO header {sale_order.name}: {so_header_vals}")
- except Exception as e:
- _logger.error(f" Failed to sync primary serial to SO {sale_order.name}: {e}")
-
- # ==========================================================================
- # OVERRIDE WRITE
- # ==========================================================================
- def write(self, vals):
- """Override write to trigger recalculation and handle billing status changes."""
- # Track billing status changes for reminder scheduling
- new_billing_status = vals.get('x_fc_adp_billing_status')
- new_payment_state = vals.get('payment_state')
-
- result = super().write(vals)
-
- # Check if we need to recalculate
- ICP = self.env['ir.config_parameter'].sudo()
- invoice_type_field = ICP.get_param('fusion_claims.field_invoice_type', 'x_fc_invoice_type')
- client_type_field = ICP.get_param('fusion_claims.field_inv_client_type', 'x_fc_client_type')
-
- trigger_fields = {'x_fc_invoice_type', 'x_fc_client_type', invoice_type_field, client_type_field}
- if trigger_fields & set(vals.keys()):
- for move in self:
- for line in move.invoice_line_ids:
- line._compute_adp_portions()
-
- # Auto-update ADP billing status when payment is registered
- # payment_state values: 'not_paid', 'in_payment', 'paid', 'partial', 'reversed', 'invoicing_legacy'
- if new_payment_state in ('paid', 'in_payment'):
- for move in self:
- # Only for ADP invoices that are in 'submitted' or 'resubmitted' status
- if (move.x_fc_adp_invoice_portion == 'adp' and
- move.x_fc_adp_billing_status in ('submitted', 'resubmitted', 'waiting')):
- move.with_context(skip_payment_status_update=True).write({
- 'x_fc_adp_billing_status': 'payment_issued'
- })
- _logger.info(f"Auto-updated ADP billing status to 'payment_issued' for {move.name}")
-
-
- # Handle billing status changes for reminders
- if new_billing_status:
- for move in self:
- if move.x_fc_adp_invoice_portion != 'adp':
- # Only track billing status for ADP invoices
- continue
-
- if new_billing_status == 'waiting':
- # Schedule billing deadline reminder
- move._schedule_billing_reminder()
-
- elif new_billing_status == 'need_correction':
- # Schedule correction reminders for all configured users
- move._schedule_correction_reminders()
-
- elif new_billing_status == 'submitted':
- # Complete billing deadline activity
- move._complete_adp_activities('fusion_claims.mail_activity_type_adp_billing')
-
- elif new_billing_status == 'resubmitted':
- # Complete correction activities
- move._complete_adp_activities('fusion_claims.mail_activity_type_adp_correction')
-
- elif new_billing_status == 'payment_issued':
- # Complete all remaining activities
- move._complete_adp_activities('fusion_claims.mail_activity_type_adp_billing')
- move._complete_adp_activities('fusion_claims.mail_activity_type_adp_correction')
-
- return result
-
- # ==========================================================================
- # DEVICE APPROVAL WIZARD FROM INVOICE
- # ==========================================================================
- def action_open_device_approval_wizard(self):
- """Open the Device Approval Wizard for the linked Sale Order.
-
- This allows users to complete device verification from the Client Invoice
- when the invoice was created before ADP approval.
- """
- self.ensure_one()
-
- # Find linked sale order
- sale_order = None
- for line in self.invoice_line_ids:
- if line.sale_line_ids:
- sale_order = line.sale_line_ids[0].order_id
- break
-
- if not sale_order:
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'No Linked Sale Order',
- 'message': 'Cannot find linked Sale Order for device verification.',
- 'type': 'warning',
- }
- }
-
- # Check if verification is already complete
- if sale_order.x_fc_device_verification_complete:
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'Already Verified',
- 'message': f'Device verification is already complete for {sale_order.name}.',
- 'type': 'info',
- }
- }
-
- # Open the device approval wizard for the linked sale order
- return {
- 'name': 'Verify Device Approval',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.device.approval.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': sale_order.id,
- 'active_model': 'sale.order',
- },
- }
-
- # ==========================================================================
- # EMAIL SEND OVERRIDE (Use ADP templates for ADP invoices)
- # ==========================================================================
- def action_invoice_sent(self):
- """Override to use ADP email template for ADP invoices.
-
- When sending an invoice for an ADP sale, automatically selects the
- ADP landscape template instead of the default template.
- """
- self.ensure_one()
-
- # Check if this is an ADP invoice
- if self._is_adp_invoice():
- # Get the ADP invoice template
- template_xmlid = 'fusion_claims.email_template_adp_invoice'
-
- try:
- template = self.env.ref(template_xmlid, raise_if_not_found=False)
- if template:
- # Open the mail compose wizard with the ADP template pre-selected
- ctx = {
- 'default_model': 'account.move',
- 'default_res_ids': self.ids,
- 'default_template_id': template.id,
- 'default_email_layout_xmlid': 'mail.mail_notification_layout_with_responsible_signature',
- 'default_composition_mode': 'comment',
- 'mark_invoice_as_sent': True,
- 'force_email': True,
- 'model_description': self.type_name,
- }
-
- return {
- 'type': 'ir.actions.act_window',
- 'res_model': 'mail.compose.message',
- 'view_mode': 'form',
- 'views': [(False, 'form')],
- 'target': 'new',
- 'context': ctx,
- }
- except Exception as e:
- _logger.warning(f"Could not load ADP email template: {e}")
-
- # Fall back to standard behavior for non-ADP invoices
- return super().action_invoice_sent()
-
- # ==========================================================================
- # ADP ACTIVITY REMINDER METHODS
- # ==========================================================================
- def _schedule_or_renew_adp_activity(self, activity_type_xmlid, user_id, date_deadline, summary, note=False):
- """Schedule or renew an ADP-related activity.
-
- If an activity of the same type for the same user already exists,
- update its deadline instead of creating a duplicate.
- """
- self.ensure_one()
-
- try:
- activity_type = self.env.ref(activity_type_xmlid)
- except ValueError:
- _logger.warning(f"Activity type not found: {activity_type_xmlid}")
- return
-
- # Search for existing activity of this type for this user
- existing = self.activity_ids.filtered(
- lambda a: a.activity_type_id.id == activity_type.id
- and a.user_id.id == user_id
- )
-
- if existing:
- # Update existing activity
- existing[0].write({
- 'date_deadline': date_deadline,
- 'summary': summary,
- 'note': note or existing[0].note,
- })
- _logger.info(f"Renewed ADP activity for invoice {self.name}: {summary} -> {date_deadline}")
- else:
- # Create new activity
- self.activity_schedule(
- activity_type_xmlid,
- date_deadline=date_deadline,
- summary=summary,
- note=note,
- user_id=user_id
- )
- _logger.info(f"Scheduled new ADP activity for invoice {self.name}: {summary} -> {date_deadline}")
-
- def _complete_adp_activities(self, activity_type_xmlid):
- """Complete all activities of a specific type for this record."""
- self.ensure_one()
-
- try:
- activity_type = self.env.ref(activity_type_xmlid)
- except ValueError:
- return
-
- activities = self.activity_ids.filtered(
- lambda a: a.activity_type_id.id == activity_type.id
- )
-
- for activity in activities:
- activity.action_feedback(feedback='Completed automatically')
- _logger.info(f"Completed ADP activity for invoice {self.name}: {activity.summary}")
-
- def _schedule_billing_reminder(self):
- """Schedule a billing deadline reminder for the configured billing person.
-
- Reminds on Monday to complete billing by Wednesday 6 PM of the posting week.
- """
- self.ensure_one()
-
- if not self._is_adp_invoice():
- return
-
- # Get the configured billing reminder user
- billing_user = self._get_adp_billing_reminder_user()
- if not billing_user:
- _logger.warning(f"No billing reminder user configured, cannot schedule reminder for {self.name}")
- return
-
- # Calculate the next posting date and Monday of that week
- next_posting = self._get_next_posting_date()
- reminder_date = self._get_posting_week_monday(next_posting)
- deadline_wednesday = self._get_posting_week_wednesday(next_posting)
-
- # Don't schedule if reminder date is in the past
- from datetime import date
- if reminder_date < date.today():
- next_posting = self._get_next_posting_date(next_posting)
- reminder_date = self._get_posting_week_monday(next_posting)
- deadline_wednesday = self._get_posting_week_wednesday(next_posting)
-
- summary = f"Complete ADP billing for {self.name} by Wednesday 6 PM"
- note = f"Submit invoice {self.name} to ADP by {deadline_wednesday.strftime('%A, %B %d, %Y')} 6 PM for the {next_posting.strftime('%B %d')} posting."
-
- self._schedule_or_renew_adp_activity(
- 'fusion_claims.mail_activity_type_adp_billing',
- billing_user.id,
- reminder_date,
- summary,
- note
- )
-
- def _schedule_correction_reminders(self):
- """Schedule correction reminders for all configured correction alert users.
-
- Creates an activity for each user when an invoice needs correction.
- """
- self.ensure_one()
-
- if not self._is_adp_invoice():
- return
-
- # Get all configured correction reminder users
- correction_users = self._get_adp_correction_reminder_users()
- if not correction_users:
- _logger.warning(f"No correction reminder users configured, cannot schedule reminder for {self.name}")
- return
-
- # Calculate the next submission deadline
- next_posting = self._get_next_posting_date()
- deadline_wednesday = self._get_posting_week_wednesday(next_posting)
-
- from datetime import date
- if deadline_wednesday < date.today():
- next_posting = self._get_next_posting_date(next_posting)
- deadline_wednesday = self._get_posting_week_wednesday(next_posting)
-
- summary = f"Invoice {self.name} needs correction - resubmit to ADP"
- note = f"This invoice was rejected by ADP and needs correction. Please fix and resubmit by {deadline_wednesday.strftime('%A, %B %d, %Y')} 6 PM."
-
- for user in correction_users:
- self._schedule_or_renew_adp_activity(
- 'fusion_claims.mail_activity_type_adp_correction',
- user.id,
- deadline_wednesday,
- summary,
- note
- )
-
- def _cron_renew_billing_reminders(self):
- """Cron job to renew overdue billing reminders.
-
- For invoices with 'waiting' status that have overdue billing activities,
- reschedule them to the next posting week's Monday.
- """
- from datetime import date
- today = date.today()
-
- try:
- activity_type = self.env.ref('fusion_claims.mail_activity_type_adp_billing')
- except ValueError:
- _logger.warning("ADP Billing activity type not found")
- return
-
- # Find ADP invoices in 'waiting' status with overdue billing activities
- waiting_invoices = self.search([
- ('x_fc_adp_invoice_portion', '=', 'adp'),
- ('x_fc_adp_billing_status', '=', 'waiting'),
- ])
-
- for invoice in waiting_invoices:
- overdue_activities = invoice.activity_ids.filtered(
- lambda a: a.activity_type_id.id == activity_type.id
- and a.date_deadline < today
- )
-
- if overdue_activities:
- invoice._schedule_billing_reminder()
- _logger.info(f"Renewed overdue billing reminder for invoice {invoice.name}")
-
- def _cron_renew_correction_reminders(self):
- """Cron job to renew overdue correction reminders.
-
- For invoices with 'need_correction' status that have overdue activities,
- reschedule them to the next posting week's Wednesday.
- """
- from datetime import date
- today = date.today()
-
- try:
- activity_type = self.env.ref('fusion_claims.mail_activity_type_adp_correction')
- except ValueError:
- _logger.warning("ADP Correction activity type not found")
- return
-
- # Find ADP invoices needing correction with overdue activities
- correction_invoices = self.search([
- ('x_fc_adp_invoice_portion', '=', 'adp'),
- ('x_fc_adp_billing_status', '=', 'need_correction'),
- ])
-
- for invoice in correction_invoices:
- overdue_activities = invoice.activity_ids.filtered(
- lambda a: a.activity_type_id.id == activity_type.id
- and a.date_deadline < today
- )
-
- if overdue_activities:
- invoice._schedule_correction_reminders()
- _logger.info(f"Renewed overdue correction reminder for invoice {invoice.name}")
diff --git a/fusion_claims/fusion_claims/models/account_move_line.py b/fusion_claims/fusion_claims/models/account_move_line.py
deleted file mode 100644
index b77bf81..0000000
--- a/fusion_claims/fusion_claims/models/account_move_line.py
+++ /dev/null
@@ -1,247 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-from odoo import models, fields, api
-
-
-class AccountMoveLine(models.Model):
- _inherit = 'account.move.line'
-
- # ==========================================================================
- # SERIAL NUMBER AND DEVICE PLACEMENT
- # ==========================================================================
- x_fc_serial_number = fields.Char(
- string='Serial Number',
- help='Serial number for this product',
- )
- x_fc_device_placement = fields.Selection(
- selection=[
- ('L', 'Left'),
- ('R', 'Right'),
- ('NA', 'N/A'),
- ],
- string='Device Placement',
- default='NA',
- help='Device placement position (Left/Right/N/A)',
- )
-
- # ==========================================================================
- # DEDUCTION FIELDS
- # ==========================================================================
- x_fc_deduction_type = fields.Selection(
- selection=[
- ('none', 'No Deduction'),
- ('pct', 'Percentage'),
- ('amt', 'Amount'),
- ],
- string='Deduction Type',
- default='none',
- help='Type of ADP deduction applied to this line',
- )
- x_fc_deduction_value = fields.Float(
- string='Deduction Value',
- digits='Product Price',
- help='Deduction value (percentage if PCT, dollar amount if AMT)',
- )
-
- # ==========================================================================
- # ADP REFERENCE FIELDS
- # ==========================================================================
- x_fc_adp_max_price = fields.Float(
- string='ADP Max Price',
- digits='Product Price',
- help='Maximum price ADP will cover for this device (from mobility manual)',
- )
- x_fc_sn_required = fields.Boolean(
- string='S/N Required',
- help='Is serial number required for this device?',
- )
-
- # ==========================================================================
- # ADP DEVICE APPROVAL TRACKING
- # ==========================================================================
- x_fc_adp_approved = fields.Boolean(
- string='ADP Approved',
- default=False,
- help='Was this device approved by ADP in the application approval?',
- )
- x_fc_adp_device_type = fields.Char(
- string='ADP Device Type',
- help='Device type from ADP mobility manual (for approval matching)',
- )
-
- # ==========================================================================
- # ADP PORTIONS - Stored fields set during invoice creation
- # ==========================================================================
- x_fc_adp_portion = fields.Monetary(
- string='ADP Portion',
- currency_field='currency_id',
- help='ADP portion for this line (calculated during invoice creation from device codes database)',
- )
- x_fc_client_portion = fields.Monetary(
- string='Client Portion',
- currency_field='currency_id',
- help='Client portion for this line (calculated during invoice creation from device codes database)',
- )
-
- def _compute_adp_portions(self):
- """Compute ADP and client portions based on device codes database.
-
- This is called during invoice type/client type changes to recalculate portions.
- """
- self.action_recalculate_portions()
-
- def action_recalculate_portions(self):
- """Manually recalculate ADP and client portions based on device codes database.
-
- This can be called to recalculate portions if values are incorrect.
- Uses the same logic as invoice creation.
- """
- ADPDevice = self.env['fusion.adp.device.code'].sudo()
-
- for line in self:
- move = line.move_id
- if not move or move.move_type not in ['out_invoice', 'out_refund']:
- continue
-
- if not line.product_id or line.quantity <= 0:
- continue
-
- # Get client type
- client_type = move._get_client_type()
- if client_type == 'REG':
- base_adp_pct = 0.75
- base_client_pct = 0.25
- else:
- base_adp_pct = 1.0
- base_client_pct = 0.0
-
- # Get ADP price from device codes database (priority)
- device_code = line._get_adp_device_code()
- adp_price = 0
-
- if device_code:
- adp_device = ADPDevice.search([
- ('device_code', '=', device_code),
- ('active', '=', True)
- ], limit=1)
- if adp_device:
- adp_price = adp_device.adp_price or 0
-
- # Fallback to product fields
- if not adp_price and line.product_id:
- product_tmpl = line.product_id.product_tmpl_id
- if hasattr(product_tmpl, 'x_fc_adp_price'):
- adp_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0
- # (Studio field fallback removed)
-
- # Fallback to line max price or unit price
- if not adp_price:
- adp_price = line.x_fc_adp_max_price or line.price_unit
-
- qty = line.quantity
- adp_base_total = adp_price * qty
-
- # Apply deductions
- if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
- effective_adp_pct = base_adp_pct * (line.x_fc_deduction_value / 100)
- adp_portion = adp_base_total * effective_adp_pct
- client_portion = adp_base_total - adp_portion
- elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
- base_adp_amount = adp_base_total * base_adp_pct
- adp_portion = max(0, base_adp_amount - line.x_fc_deduction_value)
- client_portion = adp_base_total - adp_portion
- else:
- adp_portion = adp_base_total * base_adp_pct
- client_portion = adp_base_total * base_client_pct
-
- line.write({
- 'x_fc_adp_portion': adp_portion,
- 'x_fc_client_portion': client_portion,
- 'x_fc_adp_max_price': adp_price,
- })
-
- # ==========================================================================
- # GETTER METHODS
- # ==========================================================================
- def _get_adp_device_code(self):
- """Get ADP device code from product.
-
- Checks multiple sources in order and validates against ADP device database:
- 1. x_fc_adp_device_code (module field) - verified in ADP database
- 2. x_adp_code (Studio/custom field) - verified in ADP database
- 3. default_code - verified in ADP database
- 4. Code in parentheses in product name (e.g., "Product Name (SE0001109)")
-
- Returns empty string if no valid ADP code found.
- """
- import re
-
- self.ensure_one()
- if not self.product_id:
- return ''
-
- product_tmpl = self.product_id.product_tmpl_id
- ADPDevice = self.env['fusion.adp.device.code'].sudo()
-
- # 1. Check x_fc_adp_device_code (module field)
- code = ''
- if hasattr(product_tmpl, 'x_fc_adp_device_code'):
- code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or ''
-
- # Verify code exists in ADP database
- if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
- return code
-
- # 2. Check x_adp_code (Studio/custom field)
- if hasattr(product_tmpl, 'x_adp_code'):
- code = getattr(product_tmpl, 'x_adp_code', '') or ''
- if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
- return code
-
- # 3. Check default_code - ONLY if it's a valid ADP code
- code = self.product_id.default_code or ''
- if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
- return code
-
- # 4. Try to extract code from product name in parentheses
- # E.g., "[MXA-1618] GEOMATRIX SILVERBACK MAX BACKREST - ACTIVE (SE0001109)"
- product_name = self.product_id.name or ''
- match = re.search(r'\(([A-Z]{2}\d{7})\)', product_name)
- if match:
- code = match.group(1)
- if ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
- return code
-
- # 5. Last resort: check if there's a linked sale order line with ADP code
- if self.sale_line_ids:
- for sale_line in self.sale_line_ids:
- if hasattr(sale_line, '_get_adp_device_code'):
- sale_code = sale_line._get_adp_device_code()
- if sale_code:
- return sale_code
-
- # No valid ADP code found - return empty to skip this line in export
- return ''
-
- def _get_serial_number(self):
- """Get serial number from mapped field or native field."""
- self.ensure_one()
- ICP = self.env['ir.config_parameter'].sudo()
- field_name = ICP.get_param('fusion_claims.field_aml_serial', 'x_fc_serial_number')
-
- # Try mapped field first
- if hasattr(self, field_name):
- value = getattr(self, field_name, None)
- if value:
- return value
-
- # Fallback to native field
- return self.x_fc_serial_number or ''
-
- def _get_device_placement(self):
- """Get device placement."""
- self.ensure_one()
- return self.x_fc_device_placement or 'NA'
diff --git a/fusion_claims/fusion_claims/models/account_payment.py b/fusion_claims/fusion_claims/models/account_payment.py
deleted file mode 100644
index 6c7f2dc..0000000
--- a/fusion_claims/fusion_claims/models/account_payment.py
+++ /dev/null
@@ -1,21 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-from odoo import models, fields, api
-
-
-class AccountPayment(models.Model):
- _inherit = 'account.payment'
-
- x_fc_card_last_four = fields.Char(
- string='Card Last 4 Digits',
- size=4,
- help='Last 4 digits of the card used for payment (for card payments only)',
- )
-
- x_fc_payment_note = fields.Char(
- string='Payment Note',
- help='Additional note for this payment (e.g., transaction reference)',
- )
diff --git a/fusion_claims/fusion_claims/models/account_payment_method_line.py b/fusion_claims/fusion_claims/models/account_payment_method_line.py
deleted file mode 100644
index bbeaf60..0000000
--- a/fusion_claims/fusion_claims/models/account_payment_method_line.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-from odoo import models, fields
-
-
-class AccountPaymentMethodLine(models.Model):
- _inherit = 'account.payment.method.line'
-
- x_fc_requires_card_digits = fields.Boolean(
- string='Requires Card Digits',
- default=False,
- help='If checked, the user must enter the last 4 digits of the card when using this payment method.',
- )
diff --git a/fusion_claims/fusion_claims/models/adp_application_data.py b/fusion_claims/fusion_claims/models/adp_application_data.py
deleted file mode 100644
index 03ce1e0..0000000
--- a/fusion_claims/fusion_claims/models/adp_application_data.py
+++ /dev/null
@@ -1,670 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-
-import base64
-import json
-import logging
-import xml.etree.ElementTree as ET
-
-from odoo import api, fields, models
-
-_logger = logging.getLogger(__name__)
-
-
-class FusionAdpApplicationData(models.Model):
- _name = 'fusion.adp.application.data'
- _description = 'ADP Application Data (Parsed XML)'
- _order = 'application_date desc, id desc'
- _rec_name = 'display_name'
-
- # ------------------------------------------------------------------
- # LINKAGE
- # ------------------------------------------------------------------
- profile_id = fields.Many2one(
- 'fusion.client.profile', string='Client Profile',
- ondelete='cascade', index=True,
- )
- sale_order_id = fields.Many2one(
- 'sale.order', string='Sale Order',
- ondelete='set null', index=True,
- )
- display_name = fields.Char(
- string='Name', compute='_compute_display_name', store=True,
- )
-
- # ------------------------------------------------------------------
- # COMPLETE XML DATA (for round-trip export fidelity)
- # ------------------------------------------------------------------
- xml_data_json = fields.Text(
- string='Complete XML Data (JSON)',
- help='Complete 1:1 JSON representation of all ~300 XML fields for export',
- )
- raw_xml = fields.Text(string='Raw XML Data')
-
- # ------------------------------------------------------------------
- # APPLICATION METADATA
- # ------------------------------------------------------------------
- device_category = fields.Selection([
- ('AA', 'Ambulation Aids (Section 2a)'),
- ('MD', 'Mobility Devices (Section 2b/2c)'),
- ('PS', 'Positioning/Seating (Section 2d)'),
- ('MX', 'Mixed/Multiple Sections'),
- ], string='Device Category')
- version_number = fields.Char(string='Form Version')
- application_date = fields.Date(string='Application Date')
-
- # ------------------------------------------------------------------
- # SECTION 1 - APPLICANT BIOGRAPHICAL INFORMATION
- # ------------------------------------------------------------------
- applicant_last_name = fields.Char(string='Last Name')
- applicant_first_name = fields.Char(string='First Name')
- applicant_middle_initial = fields.Char(string='Middle Initial')
- health_card_number = fields.Char(string='Health Card Number', index=True)
- health_card_version = fields.Char(string='Health Card Version')
- date_of_birth = fields.Date(string='Date of Birth')
- ltch_name = fields.Char(string='Long-Term Care Home')
-
- # Address (individual fields, not combined)
- unit_number = fields.Char(string='Unit Number')
- street_number = fields.Char(string='Street Number')
- street_name = fields.Char(string='Street Name')
- rural_route = fields.Char(string='Lot/Concession/Rural Route')
- city = fields.Char(string='City', index=True)
- province = fields.Char(string='Province')
- postal_code = fields.Char(string='Postal Code')
-
- # Contact
- home_phone = fields.Char(string='Home Phone')
- business_phone = fields.Char(string='Business Phone')
- phone_extension = fields.Char(string='Phone Extension')
-
- # ------------------------------------------------------------------
- # SECTION 1 - CONFIRMATION OF BENEFITS
- # ------------------------------------------------------------------
- receives_social_assistance = fields.Boolean(string='Receives Social Assistance')
- benefit_type = fields.Char(string='Benefit Program')
- benefit_owp = fields.Boolean(string='Ontario Works Program (OWP)')
- benefit_odsp = fields.Boolean(string='Ontario Disability Support Program (ODSP)')
- benefit_acsd = fields.Boolean(string='Assistance to Children with Severe Disabilities (ACSD)')
- wsib_eligible = fields.Boolean(string='WSIB Eligible')
- vac_eligible = fields.Boolean(string='Veterans Affairs Canada (VAC) Eligible')
-
- # ------------------------------------------------------------------
- # SECTION 2 - DEVICES AND ELIGIBILITY
- # ------------------------------------------------------------------
- medical_condition = fields.Text(string='Medical Condition / Diagnosis')
- mobility_status = fields.Text(string='Functional Mobility Status')
-
- # Previously funded equipment
- prev_funded_none = fields.Boolean(string='None Previously Funded')
- prev_funded_forearm = fields.Boolean(string='Forearm Crutches (Previously)')
- prev_funded_wheeled = fields.Boolean(string='Wheeled Walker (Previously)')
- prev_funded_manual = fields.Boolean(string='Manual Wheelchair (Previously)')
- prev_funded_power = fields.Boolean(string='Power Wheelchair (Previously)')
- prev_funded_addon = fields.Boolean(string='Power Add-On Device (Previously)')
- prev_funded_scooter = fields.Boolean(string='Power Scooter (Previously)')
- prev_funded_seating = fields.Boolean(string='Positioning Devices (Previously)')
- prev_funded_tilt = fields.Boolean(string='Power Tilt System (Previously)')
- prev_funded_recline = fields.Boolean(string='Power Recline System (Previously)')
- prev_funded_legrests = fields.Boolean(string='Power Elevating Leg Rests (Previously)')
- prev_funded_frame = fields.Boolean(string='Paediatric Standing Frame (Previously)')
- prev_funded_stroller = fields.Boolean(string='Paediatric Specialty Stroller (Previously)')
-
- # Devices currently required
- device_forearm_crutches = fields.Boolean(string='Forearm Crutches')
- device_wheeled_walker = fields.Boolean(string='Wheeled Walker')
- device_manual_wheelchair = fields.Boolean(string='Manual Wheelchair')
- device_ambulation_manual = fields.Boolean(string='Ambulation Aid + Manual Wheelchair')
- device_dependent_wheelchair = fields.Boolean(string='Manual Wheelchair (Dependent)')
- device_dynamic_tilt = fields.Boolean(string='Manual Dynamic Tilt Wheelchair')
- device_manual_dynamic = fields.Boolean(string='Manual Dynamic Tilt (Dependent)')
- device_manual_power_addon = fields.Boolean(string='Manual Wheelchair with Power Add-On')
- device_power_base = fields.Boolean(string='Power Base Only')
- device_power_scooter = fields.Boolean(string='Power Scooter Only')
- device_ambulation_power = fields.Boolean(string='Ambulation Aid + Power Base/Scooter')
- device_positioning = fields.Boolean(string='Positioning Devices (Seating)')
- device_high_tech = fields.Boolean(string='High Technology Power Base')
- device_standing_frame = fields.Boolean(string='Paediatric Standing Frame')
- device_adp_funded_mods = fields.Boolean(string='Modifications to ADP Funded Device(s)')
- device_non_adp_funded_mods = fields.Boolean(string='Modifications to Non ADP Funded Device(s)')
-
- # ------------------------------------------------------------------
- # SECTION 2A - AMBULATION AIDS (Walkers)
- # ------------------------------------------------------------------
- s2a_base_device = fields.Char(string='Walker Type')
- s2a_paediatric_frame = fields.Char(string='Paediatric Frame')
- s2a_forearm_crutches = fields.Char(string='Forearm Crutches Type')
- s2a_none = fields.Char(string='None Selected')
- s2a_reason = fields.Char(string='Reason for Application')
- s2a_replacement_status = fields.Char(string='Replacement - Mobility Status Change')
- s2a_replacement_size = fields.Char(string='Replacement - Body Size Change')
- s2a_replacement_adp = fields.Char(string='Replacement - Equipment Worn Out')
- s2a_replacement_special = fields.Char(string='Replacement - Special Circumstances')
- s2a_confirm1 = fields.Char(string='Confirmation 1')
- s2a_confirm2 = fields.Char(string='Confirmation 2')
- s2a_confirm3 = fields.Char(string='Confirmation 3')
- s2a_confirm4 = fields.Char(string='Confirmation 4')
- s2a_confirm5 = fields.Char(string='Confirmation 5')
- s2a_confirm6 = fields.Char(string='Confirmation 6')
- # Prescription
- s2a_seat_height = fields.Char(string='Seat Height')
- s2a_seat_height_unit = fields.Char(string='Seat Height Unit')
- s2a_handle_height = fields.Char(string='Push Handle Height')
- s2a_handle_height_unit = fields.Char(string='Handle Height Unit')
- s2a_hand_grips = fields.Char(string='Hand Grips')
- s2a_forearm_attachments = fields.Char(string='Forearm Attachments')
- s2a_width_handles = fields.Char(string='Width Between Push Handles')
- s2a_width_handles_unit = fields.Char(string='Width Handles Unit')
- s2a_client_weight = fields.Char(string='Client Weight')
- s2a_client_weight_unit = fields.Char(string='Client Weight Unit')
- s2a_brakes = fields.Char(string='Brakes')
- s2a_brake_type = fields.Char(string='Brake Type')
- s2a_num_wheels = fields.Char(string='Number of Wheels')
- s2a_wheel_size = fields.Char(string='Wheel Size')
- s2a_back_support = fields.Char(string='Back Support')
- # ADP options
- s2a_adp_walker = fields.Char(string='ADP Adolescent Walker')
- s2a_adp_frame = fields.Char(string='ADP Adolescent Frame')
- s2a_adp_standing = fields.Char(string='ADP Adolescent Standing')
- # Custom modifications
- s2a_custom = fields.Char(string='Custom Modifications Required')
- s2a_cost_labour = fields.Char(string='Cost of Labour')
-
- # ------------------------------------------------------------------
- # SECTION 2B - MANUAL WHEELCHAIRS
- # ------------------------------------------------------------------
- s2b_base_device = fields.Char(string='Manual Wheelchair Type')
- s2b_power_addon = fields.Char(string='Power Add-On Device')
- s2b_reason = fields.Char(string='Reason for Application')
- s2b_replacement_status = fields.Char(string='Replacement - Mobility Status')
- s2b_replacement_size = fields.Char(string='Replacement - Body Size')
- s2b_replacement_adp = fields.Char(string='Replacement - Equipment Worn')
- s2b_replacement_special = fields.Char(string='Replacement - Special')
- s2b_confirm1 = fields.Char(string='Confirmation 1')
- s2b_confirm2 = fields.Char(string='Confirmation 2')
- s2b_confirm3 = fields.Char(string='Confirmation 3')
- s2b_confirm4 = fields.Char(string='Confirmation 4')
- s2b_confirm5 = fields.Char(string='Confirmation 5')
- s2b_confirm6 = fields.Char(string='Confirmation 6')
- s2b_confirm7 = fields.Char(string='Confirmation 7')
- s2b_confirm8 = fields.Char(string='Confirmation 8')
- s2b_confirm9 = fields.Char(string='Confirmation 9')
- s2b_confirm10 = fields.Char(string='Confirmation 10')
- s2b_confirm11 = fields.Char(string='Confirmation 11')
- s2b_confirm12 = fields.Char(string='Confirmation 12')
- s2b_confirm13 = fields.Char(string='Confirmation 13')
- # Prescription
- s2b_seat_width = fields.Char(string='Seat Width')
- s2b_seat_width_unit = fields.Char(string='Seat Width Unit')
- s2b_seat_depth = fields.Char(string='Seat Depth')
- s2b_seat_depth_unit = fields.Char(string='Seat Depth Unit')
- s2b_floor_height = fields.Char(string='Finished Seat to Floor Height')
- s2b_floor_height_unit = fields.Char(string='Floor Height Unit')
- s2b_cane_height = fields.Char(string='Back Cane Height')
- s2b_cane_height_unit = fields.Char(string='Cane Height Unit')
- s2b_back_height = fields.Char(string='Finished Back Height')
- s2b_back_height_unit = fields.Char(string='Back Height Unit')
- s2b_rest_length = fields.Char(string='Finished Leg Rest Length')
- s2b_rest_length_unit = fields.Char(string='Rest Length Unit')
- s2b_client_weight = fields.Char(string='Client Weight')
- s2b_client_weight_unit = fields.Char(string='Client Weight Unit')
- # Add-on options
- s2b_adjustable_tension = fields.Boolean(string='Adjustable Tension Back Upholstery')
- s2b_heavy_duty = fields.Boolean(string='Heavy Duty Cross Braces & Upholstery')
- s2b_recliner = fields.Boolean(string='Recliner Option')
- s2b_footplates = fields.Boolean(string='Angle Adjustable Footplates')
- s2b_legrests = fields.Boolean(string='Elevating Legrests')
- s2b_spoke = fields.Boolean(string='Spoke Protectors')
- s2b_projected = fields.Boolean(string='Projected Handrims')
- s2b_standard_manual = fields.Boolean(string='Standard Manual with Dynamic Tilt')
- s2b_grade_aids = fields.Boolean(string='Grade Aids')
- s2b_caster_pin = fields.Boolean(string='Caster Pin Locks')
- s2b_amputee_axle = fields.Boolean(string='Amputee Axle Plates')
- s2b_quick_release = fields.Boolean(string='Quick Release Axles')
- s2b_stroller = fields.Boolean(string='Stroller Handles/Paediatric')
- s2b_oxygen = fields.Boolean(string='Oxygen Tank Holder')
- s2b_ventilator = fields.Boolean(string='Ventilator Tray')
- s2b_titanium = fields.Boolean(string='Titanium Frame')
- s2b_clothing_guards = fields.Boolean(string='Clothing Guards')
- s2b_one_arm = fields.Boolean(string='One Arm/Lever Drive')
- s2b_uni_lateral = fields.Boolean(string='Uni-Lateral Wheel Lock')
- s2b_plastic = fields.Boolean(string='Plastic Coated Handrims')
- s2b_rationale = fields.Text(string='Clinical Rationale')
- s2b_custom = fields.Char(string='Custom Modifications Required')
- s2b_cost_labour = fields.Char(string='Cost of Labour')
-
- # ------------------------------------------------------------------
- # SECTION 2C - POWER BASES AND POWER SCOOTERS
- # ------------------------------------------------------------------
- s2c_base_device = fields.Char(string='Power Base/Scooter Type')
- s2c_reason = fields.Char(string='Reason for Application')
- s2c_replacement_status = fields.Char(string='Replacement - Mobility Status')
- s2c_replacement_size = fields.Char(string='Replacement - Body Size')
- s2c_replacement_adp = fields.Char(string='Replacement - Equipment Worn')
- s2c_replacement_special = fields.Char(string='Replacement - Special')
- s2c_confirm1 = fields.Char(string='Power Base Confirmation 1')
- s2c_confirm2 = fields.Char(string='Power Base Confirmation 2')
- s2c_confirm3 = fields.Char(string='Scooter Confirmation 1')
- s2c_confirm4 = fields.Char(string='Scooter Confirmation 2')
- s2c_confirm5 = fields.Char(string='Scooter Confirmation 3')
- # Prescription
- s2c_seat_width = fields.Char(string='Seat Width')
- s2c_seat_width_unit = fields.Char(string='Seat Width Unit')
- s2c_back_height = fields.Char(string='Finished Back Height')
- s2c_back_height_unit = fields.Char(string='Back Height Unit')
- s2c_floor_height = fields.Char(string='Finished Seat to Floor Height')
- s2c_floor_height_unit = fields.Char(string='Floor Height Unit')
- s2c_rest_length = fields.Char(string='Leg Rest Length')
- s2c_rest_length_unit = fields.Char(string='Rest Length Unit')
- s2c_seat_depth = fields.Char(string='Seat Depth')
- s2c_seat_depth_unit = fields.Char(string='Seat Depth Unit')
- s2c_client_weight = fields.Char(string='Client Weight')
- s2c_client_weight_unit = fields.Char(string='Client Weight Unit')
- # Add-on options
- s2c_adjustable_tension = fields.Boolean(string='Adjustable Tension Back Upholstery')
- s2c_midline = fields.Boolean(string='Midline Control')
- s2c_manual_recline = fields.Boolean(string='Manual Recline Option')
- s2c_footplates = fields.Boolean(string='Angle Adjustable Footplates')
- s2c_legrests = fields.Boolean(string='Manual Elevating Legrests')
- s2c_swingaway = fields.Boolean(string='Swingaway Mounting Bracket')
- s2c_one_piece = fields.Boolean(string='One Piece 90/90 Front Riggings')
- s2c_seat_package_1 = fields.Boolean(string='Seat Package 1 for Power Bases')
- s2c_seat_package_2 = fields.Boolean(string='Seat Package 2 for Power Bases')
- s2c_oxygen = fields.Boolean(string='Oxygen Tank Holder')
- s2c_ventilator = fields.Boolean(string='Ventilator Tray')
- # Specialty controls
- s2c_sp_controls_1 = fields.Boolean(string='Specialty Controls 1 - Non Standard Joystick')
- s2c_sp_controls_2 = fields.Boolean(string='Specialty Controls 2 - Chin/Rim Control')
- s2c_sp_controls_3 = fields.Boolean(string='Specialty Controls 3 - Simple Touch')
- s2c_sp_controls_4 = fields.Boolean(string='Specialty Controls 4 - Proximity Control')
- s2c_sp_controls_5 = fields.Boolean(string='Specialty Controls 5 - Breath Control')
- s2c_sp_controls_6 = fields.Boolean(string='Specialty Controls 6 - Scanners')
- s2c_auto_correction = fields.Boolean(string='Auto Correction System')
- s2c_rationale = fields.Text(string='Clinical Rationale')
- # Power positioning
- s2c_power_tilt = fields.Boolean(string='Power Tilt Only')
- s2c_power_recline = fields.Boolean(string='Power Recline Only')
- s2c_tilt_and_recline = fields.Boolean(string='Power Tilt and Recline')
- s2c_power_elevating = fields.Boolean(string='Power Elevating Footrests')
- s2c_control_box = fields.Boolean(string='Multi-Function Control Box')
- s2c_custom = fields.Char(string='Custom Modifications Required')
- s2c_cost_labour = fields.Char(string='Cost of Labour')
-
- # ------------------------------------------------------------------
- # SECTION 2D - POSITIONING DEVICES (SEATING) FOR MOBILITY
- # ------------------------------------------------------------------
- # Seat Cushion
- s2d_seat_modular = fields.Boolean(string='Seat Cushion - Modular')
- s2d_seat_custom = fields.Boolean(string='Seat Cushion - Custom Fabricated')
- s2d_seat_cover_modular = fields.Boolean(string='Seat Cover - Modular')
- s2d_seat_cover_custom = fields.Boolean(string='Seat Cover - Custom Fabricated')
- s2d_seat_option_modular = fields.Boolean(string='Seat Options - Modular')
- s2d_seat_option_custom = fields.Boolean(string='Seat Options - Custom Fabricated')
- s2d_seat_hardware_modular = fields.Boolean(string='Seat Hardware - Modular')
- s2d_seat_hardware_custom = fields.Boolean(string='Seat Hardware - Custom Fabricated')
- s2d_adductor_modular = fields.Boolean(string='Pommel/Adductors - Modular')
- s2d_adductor_custom = fields.Boolean(string='Pommel/Adductors - Custom Fabricated')
- s2d_pommel_custom = fields.Boolean(string='Pommel Hardware - Custom Fabricated')
- # Back Support
- s2d_back_modular = fields.Boolean(string='Back Support - Modular')
- s2d_back_custom = fields.Boolean(string='Back Support - Custom Fabricated')
- s2d_back_option_modular = fields.Boolean(string='Back Options - Modular')
- s2d_back_option_custom = fields.Boolean(string='Back Options - Custom Fabricated')
- s2d_back_cover_custom = fields.Boolean(string='Back Cover - Custom Fabricated')
- s2d_back_hardware_modular = fields.Boolean(string='Back Hardware - Modular')
- s2d_back_hardware_custom = fields.Boolean(string='Back Hardware - Custom Fabricated')
- # Complete Assembly
- s2d_complete_modular = fields.Boolean(string='Complete Assembly - Modular')
- s2d_complete_custom = fields.Boolean(string='Complete Assembly - Custom Fabricated')
- # Headrest/Neckrest
- s2d_headrest_modular = fields.Boolean(string='Headrest/Neckrest - Modular')
- s2d_headrest_custom = fields.Boolean(string='Headrest/Neckrest - Custom Fabricated')
- s2d_head_option_custom = fields.Boolean(string='Headrest Options - Custom Fabricated')
- s2d_head_hardware_modular = fields.Boolean(string='Headrest Hardware - Modular')
- s2d_head_hardware_custom = fields.Boolean(string='Headrest Hardware - Custom Fabricated')
- # Positioning Belts
- s2d_belt_modular = fields.Boolean(string='Positioning Belt - Modular')
- s2d_belt_custom = fields.Boolean(string='Positioning Belt - Custom Fabricated')
- s2d_belt_option_custom = fields.Boolean(string='Belt Options - Custom Fabricated')
- # Arm Supports
- s2d_arm_modular = fields.Boolean(string='Arm Support - Modular')
- s2d_arm_custom = fields.Boolean(string='Arm Support - Custom Fabricated')
- s2d_arm_option_modular = fields.Boolean(string='Arm Options - Modular')
- s2d_arm_option_custom = fields.Boolean(string='Arm Options - Custom Fabricated')
- s2d_arm_hardware_modular = fields.Boolean(string='Arm Hardware - Modular')
- s2d_arm_hardware_custom = fields.Boolean(string='Arm Hardware - Custom Fabricated')
- # Tray
- s2d_tray_modular = fields.Boolean(string='Tray - Modular')
- s2d_tray_custom = fields.Boolean(string='Tray - Custom Fabricated')
- s2d_tray_option_modular = fields.Boolean(string='Tray Options - Modular')
- s2d_tray_option_custom = fields.Boolean(string='Tray Options - Custom Fabricated')
- # Lateral Supports
- s2d_lateral_modular = fields.Boolean(string='Lateral Support - Modular')
- s2d_lateral_custom = fields.Boolean(string='Lateral Support - Custom Fabricated')
- s2d_lateral_option_custom = fields.Boolean(string='Lateral Options - Custom Fabricated')
- s2d_lateral_hardware_custom = fields.Boolean(string='Lateral Hardware - Custom Fabricated')
- # Foot/Leg Supports
- s2d_foot_modular = fields.Boolean(string='Foot/Leg Support - Modular')
- s2d_foot_custom = fields.Boolean(string='Foot/Leg Support - Custom Fabricated')
- s2d_foot_option_modular = fields.Boolean(string='Foot Options - Modular')
- s2d_foot_option_custom = fields.Boolean(string='Foot Options - Custom Fabricated')
- s2d_foot_hardware_modular = fields.Boolean(string='Foot Hardware - Modular')
- s2d_foot_hardware_custom = fields.Boolean(string='Foot Hardware - Custom Fabricated')
- # Seating reason and confirmations
- s2d_reason = fields.Char(string='Reason for Application')
- s2d_replacement_status = fields.Char(string='Replacement - Mobility Status')
- s2d_replacement_size = fields.Char(string='Replacement - Body Size')
- s2d_replacement_adp = fields.Char(string='Replacement - Equipment Worn')
- s2d_replacement_special = fields.Char(string='Replacement - Special')
- s2d_confirm1 = fields.Char(string='Seating Confirmation 1')
- s2d_confirm2 = fields.Char(string='Seating Confirmation 2')
- s2d_custom = fields.Char(string='Custom Modifications Required')
- s2d_cost_labour = fields.Char(string='Cost of Labour')
-
- # ------------------------------------------------------------------
- # SECTION 3 - APPLICANT CONSENT AND SIGNATURE
- # ------------------------------------------------------------------
- consent_date = fields.Date(string='Consent Date')
- consent_signed_by = fields.Selection([
- ('applicant', 'Applicant'),
- ('agent', 'Agent'),
- ], string='Signed By')
- # Agent/Contact info (if signed by agent)
- agent_relationship = fields.Char(string='Agent Relationship')
- agent_last_name = fields.Char(string='Agent Last Name')
- agent_first_name = fields.Char(string='Agent First Name')
- agent_middle_initial = fields.Char(string='Agent Middle Initial')
- agent_unit = fields.Char(string='Agent Unit')
- agent_street_no = fields.Char(string='Agent Street Number')
- agent_street_name = fields.Char(string='Agent Street Name')
- agent_rural_route = fields.Char(string='Agent Rural Route')
- agent_city = fields.Char(string='Agent City')
- agent_province = fields.Char(string='Agent Province')
- agent_postal_code = fields.Char(string='Agent Postal Code')
- agent_home_phone = fields.Char(string='Agent Home Phone')
- agent_bus_phone = fields.Char(string='Agent Business Phone')
- agent_phone_ext = fields.Char(string='Agent Phone Ext')
-
- # ------------------------------------------------------------------
- # SECTION 4 - AUTHORIZER
- # ------------------------------------------------------------------
- authorizer_last_name = fields.Char(string='Authorizer Last Name')
- authorizer_first_name = fields.Char(string='Authorizer First Name')
- authorizer_phone = fields.Char(string='Authorizer Phone')
- authorizer_phone_ext = fields.Char(string='Authorizer Phone Ext')
- authorizer_adp_number = fields.Char(string='Authorizer ADP Registration Number')
- assessment_date = fields.Date(string='Assessment Date')
-
- # ------------------------------------------------------------------
- # SECTION 4 - VENDOR 1
- # ------------------------------------------------------------------
- vendor_business_name = fields.Char(string='Vendor Business Name')
- vendor_adp_number = fields.Char(string='Vendor ADP Registration Number')
- vendor_representative = fields.Char(string='Vendor Representative (Last, First)')
- vendor_position = fields.Char(string='Vendor Position Title')
- vendor_location = fields.Char(string='Vendor Location')
- vendor_phone = fields.Char(string='Vendor Phone')
- vendor_phone_ext = fields.Char(string='Vendor Phone Ext')
- vendor_sign_date = fields.Date(string='Vendor Sign Date')
-
- # ------------------------------------------------------------------
- # SECTION 4 - VENDOR 2
- # ------------------------------------------------------------------
- vendor2_business_name = fields.Char(string='Vendor 2 Business Name')
- vendor2_adp_number = fields.Char(string='Vendor 2 ADP Registration')
- vendor2_representative = fields.Char(string='Vendor 2 Representative')
- vendor2_position = fields.Char(string='Vendor 2 Position')
- vendor2_location = fields.Char(string='Vendor 2 Location')
- vendor2_phone = fields.Char(string='Vendor 2 Phone')
- vendor2_phone_ext = fields.Char(string='Vendor 2 Phone Ext')
- vendor2_sign_date = fields.Date(string='Vendor 2 Sign Date')
-
- # ------------------------------------------------------------------
- # SECTION 4 - EQUIPMENT SPEC & PROOF OF DELIVERY
- # ------------------------------------------------------------------
- equip_vendor_invoice_no = fields.Char(string='Vendor Invoice Number')
- equip_vendor_adp_reg = fields.Char(string='Vendor ADP Reg (Page 12)')
- equip_cell1 = fields.Char(string='ADP Device Code')
- equip_cell2 = fields.Char(string='Description of Item')
- equip_cell3 = fields.Char(string='Base Device')
- equip_cell4 = fields.Char(string='ADP Portion')
- equip_cell5 = fields.Char(string='Client Portion')
- pod_received_by = fields.Char(string='Proof of Delivery - Received By')
- pod_date = fields.Date(string='Proof of Delivery Date')
-
- # ------------------------------------------------------------------
- # SECTION 4 - NOTE TO ADP (sections submitted checklist)
- # ------------------------------------------------------------------
- note_section1 = fields.Boolean(string='Section 1 Submitted')
- note_section2a = fields.Boolean(string='Section 2a Submitted')
- note_section2b = fields.Boolean(string='Section 2b Submitted')
- note_section2c = fields.Boolean(string='Section 2c Submitted')
- note_section2d = fields.Boolean(string='Section 2d Submitted')
- note_section3and4 = fields.Boolean(string='Section 3 & 4 Submitted')
- note_vendor_replacement = fields.Char(string='Vendor Quote - Replacement')
- note_vendor_custom = fields.Char(string='Vendor Quote - Custom Modifications')
- note_funding_chart = fields.Char(string='Justification for Funding Chart')
- note_letter = fields.Char(string='Letter of Rationale')
-
- # Computed summary
- sections_submitted = fields.Char(
- string='Sections Submitted',
- compute='_compute_sections_submitted', store=True,
- )
-
- # Legacy compat fields
- base_device = fields.Char(
- string='Base Device Selected',
- compute='_compute_base_device', store=True,
- )
- reason_for_application = fields.Char(
- string='Reason for Application',
- compute='_compute_reason', store=True,
- )
-
- # ------------------------------------------------------------------
- # COMPUTED
- # ------------------------------------------------------------------
- @api.depends('applicant_last_name', 'applicant_first_name', 'application_date')
- def _compute_display_name(self):
- for rec in self:
- name_parts = [rec.applicant_last_name or '', rec.applicant_first_name or '']
- name = ', '.join(p for p in name_parts if p) or 'Unknown'
- date_str = rec.application_date.strftime('%Y-%m-%d') if rec.application_date else 'No Date'
- rec.display_name = f'{name} ({date_str})'
-
- @api.depends('note_section1', 'note_section2a', 'note_section2b',
- 'note_section2c', 'note_section2d', 'note_section3and4')
- def _compute_sections_submitted(self):
- for rec in self:
- parts = []
- if rec.note_section1:
- parts.append('1')
- if rec.note_section2a:
- parts.append('2a')
- if rec.note_section2b:
- parts.append('2b')
- if rec.note_section2c:
- parts.append('2c')
- if rec.note_section2d:
- parts.append('2d')
- if rec.note_section3and4:
- parts.append('3+4')
- rec.sections_submitted = ', '.join(parts) if parts else ''
-
- @api.depends('s2a_base_device', 's2b_base_device', 's2c_base_device')
- def _compute_base_device(self):
- for rec in self:
- rec.base_device = rec.s2a_base_device or rec.s2b_base_device or rec.s2c_base_device or ''
-
- @api.depends('s2a_reason', 's2b_reason', 's2c_reason', 's2d_reason')
- def _compute_reason(self):
- for rec in self:
- rec.reason_for_application = rec.s2a_reason or rec.s2b_reason or rec.s2c_reason or rec.s2d_reason or ''
-
- # ------------------------------------------------------------------
- # XML EXPORT
- # ------------------------------------------------------------------
- def action_export_xml(self):
- """Reconstruct ADP XML from stored JSON data."""
- self.ensure_one()
- if not self.xml_data_json:
- # Fall back to raw_xml if available
- if self.raw_xml:
- xml_content = self.raw_xml.encode('utf-8')
- attachment = self.env['ir.attachment'].create({
- 'name': f'{self.applicant_last_name}_{self.applicant_first_name}_data.xml',
- 'type': 'binary',
- 'datas': base64.b64encode(xml_content),
- 'mimetype': 'application/xml',
- })
- return {
- 'type': 'ir.actions.act_url',
- 'url': f'/web/content/{attachment.id}?download=true',
- 'target': 'new',
- }
- return False
-
- try:
- data = json.loads(self.xml_data_json)
- xml_str = self._json_to_xml(data)
- attachment = self.env['ir.attachment'].create({
- 'name': f'{self.applicant_last_name or "export"}_{self.applicant_first_name or "data"}_data.xml',
- 'type': 'binary',
- 'datas': base64.b64encode(xml_str.encode('utf-8')),
- 'mimetype': 'application/xml',
- })
- return {
- 'type': 'ir.actions.act_url',
- 'url': f'/web/content/{attachment.id}?download=true',
- 'target': 'new',
- }
- except Exception as e:
- _logger.exception('XML export error: %s', e)
- return False
-
- def _json_to_xml(self, data):
- """Reconstruct the ADP XML from flat JSON dictionary."""
- # Build the XML tree following the exact ADP structure
- root = ET.Element('form1')
- form = ET.SubElement(root, 'Form')
-
- # Simple top-level fields
- self._set_el(form, 'deviceCategory', data.get('deviceCategory', ''))
- self._set_el(form, 'VersionNumber', data.get('VersionNumber', ''))
-
- # Section 1
- s1 = ET.SubElement(form, 'section1')
- s1_fields = [
- 'applicantLastname', 'applicantFirstname', 'applicantMiddleinitial',
- 'healthNo', 'versionNo', 'DateOfBirth', 'nameLTCH',
- 'unitNo', 'streetNo', 'streetName', 'rrRoute',
- 'city', 'province', 'postalCode',
- 'homePhone', 'busPhone', 'phoneExtension',
- ]
- for f in s1_fields:
- self._set_el(s1, f, data.get(f'section1.{f}', ''))
-
- # Confirmation of benefit
- cob = ET.SubElement(s1, 'confirmationOfBenefit')
- for f in ['q1Yn', 'q1Ifyes', 'q2Yn', 'q3Yn']:
- self._set_el(cob, f, data.get(f'section1.confirmationOfBenefit.{f}', ''))
-
- # Section 2
- s2 = ET.SubElement(form, 'section2')
-
- # Devices and Eligibility
- de = ET.SubElement(s2, 'devicesandEligibility')
- de_fields = [
- 'condition', 'status', 'none', 'forearm', 'wheeled', 'manual',
- 'power', 'addOn', 'scooter', 'seating', 'tiltSystem', 'reclineSystem',
- 'legRests', 'frame', 'stroller', 'deviceForearm', 'deviceWheeled',
- 'deviceManual', 'deviceAmbulation', 'deviceDependent', 'deviceDynamic',
- 'manualDyanmic', 'manualWheelchair', 'powerBase', 'powerScooter',
- 'ambulation', 'positioning', 'highTech', 'standingFrame',
- 'adpFunded', 'nonADPFunded',
- ]
- for f in de_fields:
- self._set_el(de, f, data.get(f'section2.devicesandEligibility.{f}', ''))
-
- # Sections 2a, 2b, 2c, 2d
- for section_key in ['section2a', 'section2b', 'section2c', 'section2d']:
- section_data = {k.split(f'section2.{section_key}.')[1]: v
- for k, v in data.items()
- if k.startswith(f'section2.{section_key}.')}
- sec = ET.SubElement(s2, section_key)
- # Preserve field order from the data keys
- ordered_keys = [k.split(f'section2.{section_key}.')[1]
- for k in sorted(data.keys())
- if k.startswith(f'section2.{section_key}.')]
- for f in ordered_keys:
- self._set_el(sec, f, section_data.get(f, ''))
-
- # Section 3
- s3 = ET.SubElement(form, 'section3')
- sig = ET.SubElement(s3, 'sig')
- for f in ['signature', 'person', 'Date']:
- self._set_el(sig, f, data.get(f'section3.sig.{f}', ''))
- contact = ET.SubElement(s3, 'contact')
- contact_fields = [
- 'relationship', 'applicantLastname', 'applicantFirstname',
- 'applicantMiddleinitial', 'unitNo', 'streetNo', 'streetName',
- 'rrRoute', 'city', 'province', 'postalCode',
- 'homePhone', 'busPhone', 'phoneExtension',
- ]
- for f in contact_fields:
- self._set_el(contact, f, data.get(f'section3.contact.{f}', ''))
-
- # Section 4
- s4 = ET.SubElement(form, 'section4')
- # Authorizer
- auth = ET.SubElement(s4, 'authorizer')
- for f in ['authorizerLastname', 'authorizerFirstname', 'busPhone',
- 'phoneExtension', 'adpNo', 'signature', 'Date']:
- self._set_el(auth, f, data.get(f'section4.authorizer.{f}', ''))
- # Vendor
- vendor = ET.SubElement(s4, 'vendor')
- for f in ['vendorBusName', 'adpVendorRegNo', 'vendorLastfirstname',
- 'positionTitle', 'vendorLocation', 'busPhone',
- 'phoneExtension', 'signature', 'Date']:
- self._set_el(vendor, f, data.get(f'section4.vendor.{f}', ''))
- # Vendor 2
- v2 = ET.SubElement(s4, 'vendor2')
- for f in ['vendorBusName', 'adpVendorRegNo', 'vendorLastfirstname',
- 'positionTitle', 'vendorLocation', 'busPhone',
- 'phoneExtension', 'signature', 'Date']:
- self._set_el(v2, f, data.get(f'section4.vendor2.{f}', ''))
- # Equipment Spec
- eq = ET.SubElement(s4, 'equipmentSpec')
- self._set_el(eq, 'vendorInvoiceNo', data.get('section4.equipmentSpec.vendorInvoiceNo', ''))
- self._set_el(eq, 'vendorADPRegNo', data.get('section4.equipmentSpec.vendorADPRegNo', ''))
- t2 = ET.SubElement(eq, 'Table2')
- r1 = ET.SubElement(t2, 'Row1')
- for c in ['Cell1', 'Cell2', 'Cell3', 'Cell4', 'Cell5']:
- self._set_el(r1, c, data.get(f'section4.equipmentSpec.Table2.Row1.{c}', ''))
- # Proof of delivery
- pod = ET.SubElement(s4, 'proofOfDelivery')
- for f in ['signature', 'receivedBy', 'Date']:
- self._set_el(pod, f, data.get(f'section4.proofOfDelivery.{f}', ''))
- # Note to ADP
- note = ET.SubElement(s4, 'noteToADP')
- for f in ['section1', 'section2a', 'section2b', 'section2c', 'section2d',
- 'section3and4', 'vendorReplacement', 'vendorCustom',
- 'fundingChart', 'letter']:
- self._set_el(note, f, data.get(f'section4.noteToADP.{f}', ''))
-
- # Convert to string
- tree = ET.ElementTree(root)
- ET.indent(tree, space='')
- import io
- buf = io.BytesIO()
- tree.write(buf, encoding='unicode', xml_declaration=True)
- return buf.getvalue()
-
- @staticmethod
- def _set_el(parent, tag, value):
- """Create a child element, self-closing if empty."""
- el = ET.SubElement(parent, tag)
- if value:
- el.text = str(value)
diff --git a/fusion_claims/fusion_claims/models/adp_posting_schedule.py b/fusion_claims/fusion_claims/models/adp_posting_schedule.py
deleted file mode 100644
index 29cae5c..0000000
--- a/fusion_claims/fusion_claims/models/adp_posting_schedule.py
+++ /dev/null
@@ -1,262 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-import logging
-from datetime import date, timedelta
-from odoo import models, api
-
-_logger = logging.getLogger(__name__)
-
-
-class ADPPostingScheduleMixin(models.AbstractModel):
- """Mixin providing ADP posting schedule calculation methods.
-
- This mixin can be inherited by any model that needs to calculate
- ADP posting dates and deadlines.
-
- Posting Schedule Logic:
- - Posting days occur every N days (default 14) from a base date
- - Submission deadline: Wednesday 6 PM before posting day
- - Delivery reminder: Tuesday of posting week
- - Billing reminder: Monday of posting week
- - Payment processed: Posting day + 7 days
- - Payment received: Posting day + 10 days
- """
- _name = 'fusion_claims.adp.posting.schedule.mixin'
- _description = 'ADP Posting Schedule Mixin'
-
- @api.model
- def _get_adp_posting_base_date(self):
- """Get the configured base posting date from settings."""
- ICP = self.env['ir.config_parameter'].sudo()
- base_date_str = ICP.get_param('fusion_claims.adp_posting_base_date', '2026-01-23')
- try:
- return date.fromisoformat(base_date_str)
- except (ValueError, TypeError):
- return date(2026, 1, 23)
-
- @api.model
- def _get_adp_posting_frequency(self):
- """Get the configured posting frequency in days from settings."""
- ICP = self.env['ir.config_parameter'].sudo()
- frequency = ICP.get_param('fusion_claims.adp_posting_frequency_days', '14')
- try:
- return int(frequency)
- except (ValueError, TypeError):
- return 14
-
- @api.model
- def _get_next_posting_date(self, from_date=None):
- """Calculate the next ADP posting date from a given date.
-
- Args:
- from_date: The date to calculate from (default: today)
-
- Returns:
- date: The next posting date
- """
- if from_date is None:
- from_date = date.today()
- elif hasattr(from_date, 'date'):
- from_date = from_date.date()
-
- base_date = self._get_adp_posting_base_date()
- frequency = self._get_adp_posting_frequency()
-
- if frequency <= 0:
- frequency = 14
-
- # Calculate days since base date
- days_diff = (from_date - base_date).days
-
- if days_diff < 0:
- # from_date is before base_date, so base_date is the next posting date
- return base_date
-
- # Calculate how many complete cycles have passed
- cycles_passed = days_diff // frequency
-
- # The next posting date is (cycles_passed + 1) * frequency days from base
- next_posting = base_date + timedelta(days=(cycles_passed + 1) * frequency)
-
- # If from_date equals a posting date, return the next one
- if days_diff % frequency == 0:
- return next_posting
-
- return next_posting
-
- @api.model
- def _get_current_posting_date(self, from_date=None):
- """Get the posting date for the current cycle (may be in the past).
-
- Args:
- from_date: The date to calculate from (default: today)
-
- Returns:
- date: The current cycle's posting date
- """
- if from_date is None:
- from_date = date.today()
- elif hasattr(from_date, 'date'):
- from_date = from_date.date()
-
- base_date = self._get_adp_posting_base_date()
- frequency = self._get_adp_posting_frequency()
-
- if frequency <= 0:
- frequency = 14
-
- days_diff = (from_date - base_date).days
-
- if days_diff < 0:
- return base_date
-
- cycles_passed = days_diff // frequency
- return base_date + timedelta(days=cycles_passed * frequency)
-
- @api.model
- def _get_posting_week_wednesday(self, posting_date):
- """Get the Wednesday before the posting date (submission deadline day).
-
- The submission deadline is Wednesday 6 PM of the posting week.
- Posting day is typically Friday, so Wednesday is 2 days before.
-
- Args:
- posting_date: The posting date
-
- Returns:
- date: The Wednesday before posting date
- """
- if hasattr(posting_date, 'date'):
- posting_date = posting_date.date()
-
- # Find the Wednesday of the same week
- # weekday(): Monday=0, Tuesday=1, Wednesday=2, Thursday=3, Friday=4
- days_since_wednesday = (posting_date.weekday() - 2) % 7
- if days_since_wednesday == 0 and posting_date.weekday() != 2:
- days_since_wednesday = 7
-
- return posting_date - timedelta(days=days_since_wednesday)
-
- @api.model
- def _get_posting_week_tuesday(self, posting_date):
- """Get the Tuesday of the posting week (delivery reminder date).
-
- Args:
- posting_date: The posting date
-
- Returns:
- date: The Tuesday of posting week
- """
- if hasattr(posting_date, 'date'):
- posting_date = posting_date.date()
-
- # Find the Tuesday of the same week
- days_since_tuesday = (posting_date.weekday() - 1) % 7
- if days_since_tuesday == 0 and posting_date.weekday() != 1:
- days_since_tuesday = 7
-
- return posting_date - timedelta(days=days_since_tuesday)
-
- @api.model
- def _get_posting_week_monday(self, posting_date):
- """Get the Monday of the posting week (billing reminder date).
-
- Args:
- posting_date: The posting date
-
- Returns:
- date: The Monday of posting week
- """
- if hasattr(posting_date, 'date'):
- posting_date = posting_date.date()
-
- # Find the Monday of the same week
- days_since_monday = posting_date.weekday() # Monday=0
- return posting_date - timedelta(days=days_since_monday)
-
- @api.model
- def _get_expected_payment_date(self, posting_date):
- """Get the expected payment received date (posting + 10 days).
-
- Args:
- posting_date: The posting date
-
- Returns:
- date: The expected payment received date
- """
- if hasattr(posting_date, 'date'):
- posting_date = posting_date.date()
-
- return posting_date + timedelta(days=10)
-
- @api.model
- def _get_payment_processed_date(self, posting_date):
- """Get the payment processed date (posting + 7 days).
-
- Args:
- posting_date: The posting date
-
- Returns:
- date: The payment processed date
- """
- if hasattr(posting_date, 'date'):
- posting_date = posting_date.date()
-
- return posting_date + timedelta(days=7)
-
- @api.model
- def _get_adp_billing_reminder_user(self):
- """Get the configured billing reminder user from settings."""
- ICP = self.env['ir.config_parameter'].sudo()
- user_id_str = ICP.get_param('fusion_claims.adp_billing_reminder_user_id', '')
- if user_id_str:
- try:
- user_id = int(user_id_str)
- return self.env['res.users'].browse(user_id).exists()
- except (ValueError, TypeError):
- pass
- return self.env['res.users']
-
- @api.model
- def _get_adp_correction_reminder_users(self):
- """Get the configured correction reminder users from settings."""
- ICP = self.env['ir.config_parameter'].sudo()
- user_ids_str = ICP.get_param('fusion_claims.adp_correction_reminder_user_ids', '')
- if user_ids_str:
- try:
- user_ids = [int(x.strip()) for x in user_ids_str.split(',') if x.strip()]
- return self.env['res.users'].browse(user_ids).exists()
- except (ValueError, TypeError):
- pass
- return self.env['res.users']
-
- @api.model
- def _is_past_submission_deadline(self, posting_date=None, check_time=True):
- """Check if we're past the submission deadline for a posting cycle.
-
- Args:
- posting_date: The posting date to check (default: next posting date)
- check_time: If True, checks if past 6 PM on Wednesday
-
- Returns:
- bool: True if past deadline
- """
- from datetime import datetime
-
- if posting_date is None:
- posting_date = self._get_next_posting_date()
-
- wednesday = self._get_posting_week_wednesday(posting_date)
- today = date.today()
-
- if today > wednesday:
- return True
- elif today == wednesday and check_time:
- # Check if past 6 PM (18:00)
- now = datetime.now()
- return now.hour >= 18
-
- return False
diff --git a/fusion_claims/fusion_claims/models/ai_agent_ext.py b/fusion_claims/fusion_claims/models/ai_agent_ext.py
deleted file mode 100644
index 520c092..0000000
--- a/fusion_claims/fusion_claims/models/ai_agent_ext.py
+++ /dev/null
@@ -1,164 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-
-import json
-import logging
-
-from odoo import api, models
-
-_logger = logging.getLogger(__name__)
-
-
-class AIAgentFusionClaims(models.Model):
- """Extend ai.agent with Fusion Claims tool methods."""
- _inherit = 'ai.agent'
-
- def _fc_tool_search_clients(self, search_term=None, city_filter=None, condition_filter=None):
- """AI Tool: Search client profiles."""
- Profile = self.env['fusion.client.profile'].sudo()
- domain = []
- if search_term:
- domain = ['|', '|', '|',
- ('first_name', 'ilike', search_term),
- ('last_name', 'ilike', search_term),
- ('health_card_number', 'ilike', search_term),
- ('city', 'ilike', search_term),
- ]
- if city_filter:
- domain.append(('city', 'ilike', city_filter))
- if condition_filter:
- domain.append(('medical_condition', 'ilike', condition_filter))
-
- profiles = Profile.search(domain, limit=20)
- results = []
- for p in profiles:
- results.append({
- 'id': p.id,
- 'name': p.display_name,
- 'health_card': p.health_card_number or '',
- 'dob': str(p.date_of_birth) if p.date_of_birth else '',
- 'city': p.city or '',
- 'condition': (p.medical_condition or '')[:100],
- 'claims': p.claim_count,
- 'total_adp': float(p.total_adp_funded),
- 'total_client': float(p.total_client_portion),
- })
- return json.dumps({'count': len(results), 'profiles': results})
-
- def _fc_tool_client_details(self, profile_id):
- """AI Tool: Get detailed client information."""
- Profile = self.env['fusion.client.profile'].sudo()
- profile = Profile.browse(int(profile_id))
- if not profile.exists():
- return json.dumps({'error': 'Profile not found'})
-
- # Get orders
- orders = []
- if profile.partner_id:
- for o in self.env['sale.order'].sudo().search([
- ('partner_id', '=', profile.partner_id.id),
- ('x_fc_sale_type', '!=', False),
- ], limit=20):
- orders.append({
- 'name': o.name,
- 'sale_type': o.x_fc_sale_type,
- 'status': o.x_fc_adp_application_status or '',
- 'adp_total': float(o.x_fc_adp_portion_total),
- 'client_total': float(o.x_fc_client_portion_total),
- 'total': float(o.amount_total),
- 'date': str(o.date_order.date()) if o.date_order else '',
- })
-
- # Get applications
- apps = []
- for a in profile.application_data_ids[:10]:
- apps.append({
- 'date': str(a.application_date) if a.application_date else '',
- 'device': a.base_device or '',
- 'category': a.device_category or '',
- 'reason': a.reason_for_application or '',
- 'condition': (a.medical_condition or '')[:100],
- 'authorizer': f'{a.authorizer_first_name or ""} {a.authorizer_last_name or ""}'.strip(),
- })
-
- return json.dumps({
- 'profile': {
- 'id': profile.id,
- 'name': profile.display_name,
- 'first_name': profile.first_name,
- 'last_name': profile.last_name,
- 'health_card': profile.health_card_number or '',
- 'dob': str(profile.date_of_birth) if profile.date_of_birth else '',
- 'city': profile.city or '',
- 'province': profile.province or '',
- 'postal_code': profile.postal_code or '',
- 'phone': profile.home_phone or '',
- 'condition': profile.medical_condition or '',
- 'mobility': profile.mobility_status or '',
- 'benefits': {
- 'social_assistance': profile.receives_social_assistance,
- 'type': profile.benefit_type or '',
- 'wsib': profile.wsib_eligible,
- 'vac': profile.vac_eligible,
- },
- 'claims_count': profile.claim_count,
- 'total_adp': float(profile.total_adp_funded),
- 'total_client': float(profile.total_client_portion),
- 'total_amount': float(profile.total_amount),
- 'last_assessment': str(profile.last_assessment_date) if profile.last_assessment_date else '',
- },
- 'orders': orders,
- 'applications': apps,
- })
-
- def _fc_tool_claims_stats(self):
- """AI Tool: Get aggregated claims statistics."""
- SO = self.env['sale.order'].sudo()
- Profile = self.env['fusion.client.profile'].sudo()
-
- total_profiles = Profile.search_count([])
- total_orders = SO.search_count([('x_fc_sale_type', '!=', False)])
-
- # By sale type
- type_data = SO.read_group(
- [('x_fc_sale_type', '!=', False)],
- ['x_fc_sale_type', 'amount_total:sum'],
- ['x_fc_sale_type'],
- )
- by_type = {}
- for r in type_data:
- by_type[r['x_fc_sale_type']] = {
- 'count': r['x_fc_sale_type_count'],
- 'total': float(r['amount_total'] or 0),
- }
-
- # By status
- status_data = SO.read_group(
- [('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)],
- ['x_fc_adp_application_status'],
- ['x_fc_adp_application_status'],
- )
- by_status = {}
- for r in status_data:
- by_status[r['x_fc_adp_application_status']] = r['x_fc_adp_application_status_count']
-
- # By city (top 10)
- city_data = Profile.read_group(
- [('city', '!=', False)],
- ['city'],
- ['city'],
- limit=10,
- orderby='city_count desc',
- )
- by_city = {}
- for r in city_data:
- by_city[r['city']] = r['city_count']
-
- return json.dumps({
- 'total_profiles': total_profiles,
- 'total_orders': total_orders,
- 'by_sale_type': by_type,
- 'by_status': by_status,
- 'top_cities': by_city,
- })
diff --git a/fusion_claims/fusion_claims/models/client_chat.py b/fusion_claims/fusion_claims/models/client_chat.py
deleted file mode 100644
index 6d508df..0000000
--- a/fusion_claims/fusion_claims/models/client_chat.py
+++ /dev/null
@@ -1,350 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-
-import json
-import logging
-
-from odoo import api, fields, models
-from odoo.exceptions import UserError
-
-_logger = logging.getLogger(__name__)
-
-
-class FusionClientChatSession(models.Model):
- _name = 'fusion.client.chat.session'
- _description = 'Client Intelligence Chat Session'
- _order = 'create_date desc'
-
- name = fields.Char(string='Session Title', required=True,
- default=lambda self: f'Chat - {fields.Date.today()}')
- profile_id = fields.Many2one(
- 'fusion.client.profile', string='Client Profile',
- ondelete='set null',
- help='If set, chat is scoped to this specific client',
- )
- user_id = fields.Many2one(
- 'res.users', string='User', default=lambda self: self.env.user,
- required=True,
- )
- message_ids = fields.One2many(
- 'fusion.client.chat.message', 'session_id', string='Messages',
- )
- state = fields.Selection([
- ('active', 'Active'),
- ('archived', 'Archived'),
- ], default='active', string='State')
-
- # Input field for the form view
- user_input = fields.Text(string='Your Question')
-
- def action_send_message(self):
- """Process user message and generate AI response."""
- self.ensure_one()
- if not self.user_input or not self.user_input.strip():
- return
-
- question = self.user_input.strip()
-
- # Create user message
- self.env['fusion.client.chat.message'].create({
- 'session_id': self.id,
- 'role': 'user',
- 'content': question,
- })
-
- # Generate AI response
- try:
- response = self._generate_ai_response(question)
- except Exception as e:
- _logger.exception('AI chat error: %s', e)
- response = f'Sorry, I encountered an error processing your question. Error: {str(e)}'
-
- # Create assistant message
- msg = self.env['fusion.client.chat.message'].create({
- 'session_id': self.id,
- 'role': 'assistant',
- 'content': response,
- })
-
- # Clear input
- self.user_input = False
- return {
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion.client.chat.session',
- 'view_mode': 'form',
- 'res_id': self.id,
- 'target': 'current',
- }
-
- def _generate_ai_response(self, question):
- """Generate an AI-powered response to the user question.
-
- Uses OpenAI API to analyze the question, query relevant data,
- and formulate a response.
- """
- ICP = self.env['ir.config_parameter'].sudo()
- api_key = ICP.get_param('fusion_claims.ai_api_key', '')
- if not api_key:
- return self._generate_local_response(question)
-
- ai_model = ICP.get_param('fusion_claims.ai_model', 'gpt-4o-mini')
-
- # Build context about available data
- context_data = self._build_data_context(question)
-
- # Build system prompt
- system_prompt = self._build_system_prompt()
-
- # Build messages
- messages = [{'role': 'system', 'content': system_prompt}]
-
- # Add conversation history (last 10 messages)
- history = self.message_ids.sorted('create_date')[-10:]
- for msg in history:
- messages.append({'role': msg.role, 'content': msg.content})
-
- # Add current question with data context
- user_msg = question
- if context_data:
- user_msg += f'\n\n--- Retrieved Data ---\n{context_data}'
- messages.append({'role': 'user', 'content': user_msg})
-
- # Call OpenAI API
- try:
- import requests
- response = requests.post(
- 'https://api.openai.com/v1/chat/completions',
- headers={
- 'Authorization': f'Bearer {api_key}',
- 'Content-Type': 'application/json',
- },
- json={
- 'model': ai_model,
- 'messages': messages,
- 'max_tokens': 2000,
- 'temperature': 0.3,
- },
- timeout=30,
- )
- response.raise_for_status()
- result = response.json()
- return result['choices'][0]['message']['content']
- except ImportError:
- return self._generate_local_response(question)
- except Exception as e:
- _logger.warning('OpenAI API error: %s', e)
- return self._generate_local_response(question)
-
- def _generate_local_response(self, question):
- """Generate a response without AI, using direct database queries.
-
- This is the fallback when no API key is configured.
- """
- question_lower = question.lower()
- Profile = self.env['fusion.client.profile']
- SaleOrder = self.env['sale.order']
-
- # If scoped to a specific profile
- if self.profile_id:
- profile = self.profile_id
- orders = SaleOrder.search([
- ('partner_id', '=', profile.partner_id.id),
- ('x_fc_sale_type', '!=', False),
- ]) if profile.partner_id else SaleOrder
-
- lines = []
- lines.append(f'**Client: {profile.display_name}**')
- lines.append(f'- Health Card: {profile.health_card_number or "N/A"}')
- lines.append(f'- Date of Birth: {profile.date_of_birth or "N/A"}')
- lines.append(f'- City: {profile.city or "N/A"}')
- lines.append(f'- Medical Condition: {profile.medical_condition or "N/A"}')
- lines.append(f'- Mobility Status: {profile.mobility_status or "N/A"}')
- lines.append(f'- Total Claims: {len(orders)}')
- lines.append(f'- Total ADP Funded: ${profile.total_adp_funded:,.2f}')
- lines.append(f'- Total Client Portion: ${profile.total_client_portion:,.2f}')
-
- if orders:
- lines.append('\n**Claims History:**')
- for order in orders[:10]:
- status = dict(order._fields['x_fc_adp_application_status'].selection).get(
- order.x_fc_adp_application_status, order.x_fc_adp_application_status or 'N/A'
- )
- lines.append(
- f'- {order.name}: {order.x_fc_sale_type or "N/A"} | '
- f'Status: {status} | '
- f'ADP: ${order.x_fc_adp_portion_total:,.2f} | '
- f'Client: ${order.x_fc_client_portion_total:,.2f}'
- )
-
- apps = profile.application_data_ids[:5]
- if apps:
- lines.append('\n**Application History:**')
- for app in apps:
- lines.append(
- f'- {app.application_date or "No date"}: '
- f'{app.device_category or "N/A"} | '
- f'Device: {app.base_device or "N/A"} | '
- f'Reason: {app.reason_for_application or "N/A"}'
- )
-
- return '\n'.join(lines)
-
- # Global queries
- total_profiles = Profile.search_count([])
- total_orders = SaleOrder.search_count([('x_fc_sale_type', '!=', False)])
-
- if 'how many' in question_lower or 'count' in question_lower:
- if 'client' in question_lower or 'profile' in question_lower:
- return f'There are currently **{total_profiles}** client profiles in the system.'
- if 'claim' in question_lower or 'order' in question_lower or 'case' in question_lower:
- return f'There are currently **{total_orders}** claims/orders in the system.'
-
- # Search for specific client
- if 'find' in question_lower or 'search' in question_lower or 'show' in question_lower:
- # Try to extract name from question
- words = question.split()
- profiles = Profile.search([], limit=20)
- for word in words:
- if len(word) > 2 and word[0].isupper():
- found = Profile.search([
- '|',
- ('first_name', 'ilike', word),
- ('last_name', 'ilike', word),
- ], limit=5)
- if found:
- profiles = found
- break
-
- if profiles:
- lines = [f'Found **{len(profiles)}** matching profile(s):']
- for p in profiles[:10]:
- lines.append(
- f'- **{p.display_name}** | HC: {p.health_card_number or "N/A"} | '
- f'City: {p.city or "N/A"} | Claims: {p.claim_count}'
- )
- return '\n'.join(lines)
-
- return (
- f'I have access to **{total_profiles}** client profiles and **{total_orders}** claims. '
- f'You can ask me questions like:\n'
- f'- "How many clients are from Brampton?"\n'
- f'- "Find client Raymond Wellesley"\n'
- f'- "Show all clients with CVA diagnosis"\n\n'
- f'For more intelligent responses, configure an OpenAI API key in '
- f'Fusion Claims > Configuration > Settings.'
- )
-
- def _build_system_prompt(self):
- """Build the system prompt for the AI."""
- profile_context = ''
- if self.profile_id:
- p = self.profile_id
- profile_context = f"""
-You are currently looking at a specific client profile:
-- Name: {p.display_name}
-- Health Card: {p.health_card_number or 'N/A'}
-- DOB: {p.date_of_birth or 'N/A'}
-- City: {p.city or 'N/A'}
-- Medical Condition: {p.medical_condition or 'N/A'}
-- Mobility Status: {p.mobility_status or 'N/A'}
-- Total Claims: {p.claim_count}
-- Total ADP Funded: ${p.total_adp_funded:,.2f}
-- Total Client Portion: ${p.total_client_portion:,.2f}
-"""
-
- return f"""You are a helpful AI assistant for Fusion Claims, a healthcare equipment claims management system.
-You help users find information about clients, their ADP (Assistive Devices Program) claims, funding history,
-medical conditions, and devices.
-
-Available data includes:
-- Client profiles with personal info, health card numbers, addresses, medical conditions
-- ADP application data parsed from XML submissions
-- Sale orders with funding type, status, ADP/client portions
-- Device information (wheelchairs, walkers, power bases, seating)
-
-Funding types: ADP, ODSP, WSIB, March of Dimes, Muscular Dystrophy, Insurance, Hardship Funding, Rentals, Direct/Private
-Client types: REG (75%/25%), ODS, OWP, ACS, LTC, SEN, CCA (100%/0%)
-{profile_context}
-Answer concisely and include specific data when available. Format monetary values with $ and commas."""
-
- def _build_data_context(self, question):
- """Query relevant data based on the question to provide context to AI."""
- question_lower = question.lower()
- context_parts = []
-
- Profile = self.env['fusion.client.profile']
- SaleOrder = self.env['sale.order']
-
- if self.profile_id:
- # Scoped to specific client - load their data
- p = self.profile_id
- orders = SaleOrder.search([
- ('partner_id', '=', p.partner_id.id),
- ('x_fc_sale_type', '!=', False),
- ], limit=20) if p.partner_id else SaleOrder
-
- if orders:
- order_data = []
- for o in orders:
- order_data.append({
- 'name': o.name,
- 'sale_type': o.x_fc_sale_type,
- 'status': o.x_fc_adp_application_status,
- 'adp_total': o.x_fc_adp_portion_total,
- 'client_total': o.x_fc_client_portion_total,
- 'amount_total': o.amount_total,
- 'date': str(o.date_order) if o.date_order else '',
- })
- context_parts.append(f'Orders: {json.dumps(order_data)}')
-
- apps = p.application_data_ids[:10]
- if apps:
- app_data = []
- for a in apps:
- app_data.append({
- 'date': str(a.application_date) if a.application_date else '',
- 'device_category': a.device_category,
- 'base_device': a.base_device or '',
- 'condition': a.medical_condition or '',
- 'reason': a.reason_for_application or '',
- 'authorizer': f'{a.authorizer_first_name} {a.authorizer_last_name}'.strip(),
- })
- context_parts.append(f'Applications: {json.dumps(app_data)}')
- else:
- # Global query - provide summary stats
- total_profiles = Profile.search_count([])
- total_orders = SaleOrder.search_count([('x_fc_sale_type', '!=', False)])
-
- # City distribution
- if 'city' in question_lower or 'cities' in question_lower or 'where' in question_lower:
- city_data = SaleOrder.read_group(
- [('x_fc_sale_type', '!=', False), ('partner_id.city', '!=', False)],
- ['partner_id'],
- ['partner_id'],
- limit=20,
- )
- context_parts.append(f'Total profiles: {total_profiles}, Total orders: {total_orders}')
-
- context_parts.append(f'Summary: {total_profiles} profiles, {total_orders} orders')
-
- return '\n'.join(context_parts) if context_parts else ''
-
-
-class FusionClientChatMessage(models.Model):
- _name = 'fusion.client.chat.message'
- _description = 'Chat Message'
- _order = 'create_date asc'
-
- session_id = fields.Many2one(
- 'fusion.client.chat.session', string='Session',
- required=True, ondelete='cascade',
- )
- role = fields.Selection([
- ('user', 'User'),
- ('assistant', 'Assistant'),
- ], string='Role', required=True)
- content = fields.Text(string='Content', required=True)
- timestamp = fields.Datetime(
- string='Timestamp', default=fields.Datetime.now,
- )
diff --git a/fusion_claims/fusion_claims/models/client_profile.py b/fusion_claims/fusion_claims/models/client_profile.py
deleted file mode 100644
index 55bfd54..0000000
--- a/fusion_claims/fusion_claims/models/client_profile.py
+++ /dev/null
@@ -1,298 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-
-from odoo import api, fields, models
-
-
-class FusionClientProfile(models.Model):
- _name = 'fusion.client.profile'
- _description = 'Client Profile'
- _order = 'last_name, first_name'
- _rec_name = 'display_name'
- _inherit = ['mail.thread', 'mail.activity.mixin']
-
- # ------------------------------------------------------------------
- # PERSONAL INFORMATION (from ADP XML Section 1)
- # ------------------------------------------------------------------
- partner_id = fields.Many2one(
- 'res.partner', string='Odoo Contact',
- help='Linked contact record in Odoo',
- )
- first_name = fields.Char(string='First Name', tracking=True)
- last_name = fields.Char(string='Last Name', tracking=True)
- middle_initial = fields.Char(string='Middle Initial')
- display_name = fields.Char(
- string='Name', compute='_compute_display_name', store=True,
- )
- health_card_number = fields.Char(
- string='Health Card Number', index=True, tracking=True,
- help='Ontario Health Card Number (10 digits)',
- )
- health_card_version = fields.Char(string='Health Card Version')
- date_of_birth = fields.Date(string='Date of Birth', tracking=True)
- ltch_name = fields.Char(
- string='Long-Term Care Home',
- help='Name of LTCH if applicable',
- )
-
- # ------------------------------------------------------------------
- # ADDRESS
- # ------------------------------------------------------------------
- unit_number = fields.Char(string='Unit Number')
- street_number = fields.Char(string='Street Number')
- street_name = fields.Char(string='Street Name')
- rural_route = fields.Char(string='Lot/Concession/Rural Route')
- city = fields.Char(string='City', index=True, tracking=True)
- province = fields.Char(string='Province', default='ON')
- postal_code = fields.Char(string='Postal Code')
-
- # ------------------------------------------------------------------
- # CONTACT
- # ------------------------------------------------------------------
- home_phone = fields.Char(string='Home Phone')
- business_phone = fields.Char(string='Business Phone')
- phone_extension = fields.Char(string='Phone Extension')
-
- # ------------------------------------------------------------------
- # BENEFITS ELIGIBILITY (from XML confirmationOfBenefit)
- # ------------------------------------------------------------------
- receives_social_assistance = fields.Boolean(
- string='Receives Social Assistance', tracking=True,
- )
- benefit_type = fields.Selection([
- ('owp', 'Ontario Works Program (OWP)'),
- ('odsp', 'Ontario Disability Support Program (ODSP)'),
- ('acsd', 'Assistance to Children with Severe Disabilities (ACSD)'),
- ], string='Benefit Type', tracking=True)
- wsib_eligible = fields.Boolean(string='WSIB Eligible', tracking=True)
- vac_eligible = fields.Boolean(
- string='Veterans Affairs Canada Eligible', tracking=True,
- )
-
- # ------------------------------------------------------------------
- # CURRENT MEDICAL STATUS (updated from latest XML)
- # ------------------------------------------------------------------
- medical_condition = fields.Text(
- string='Medical Condition/Diagnosis', tracking=True,
- help='Current presenting medical condition from latest ADP application',
- )
- mobility_status = fields.Text(
- string='Functional Mobility Status', tracking=True,
- help='Current functional mobility status from latest ADP application',
- )
- last_assessment_date = fields.Date(
- string='Last Assessment Date', tracking=True,
- )
-
- # ------------------------------------------------------------------
- # RELATIONSHIPS
- # ------------------------------------------------------------------
- application_data_ids = fields.One2many(
- 'fusion.adp.application.data', 'profile_id',
- string='ADP Applications',
- )
- # Chat is handled via Odoo's native AI agent (discuss.channel with ai_chat type)
-
- # ------------------------------------------------------------------
- # COMPUTED FIELDS
- # ------------------------------------------------------------------
- claim_count = fields.Integer(
- string='Claims', compute='_compute_claim_stats', store=True,
- )
- total_adp_funded = fields.Monetary(
- string='Total ADP Funded', compute='_compute_claim_stats', store=True,
- currency_field='currency_id',
- )
- total_client_portion = fields.Monetary(
- string='Total Client Portion', compute='_compute_claim_stats', store=True,
- currency_field='currency_id',
- )
- total_amount = fields.Monetary(
- string='Total Amount', compute='_compute_claim_stats', store=True,
- currency_field='currency_id',
- )
- currency_id = fields.Many2one(
- 'res.currency', string='Currency',
- default=lambda self: self.env.company.currency_id,
- )
- application_count = fields.Integer(
- string='Applications', compute='_compute_application_count',
- )
-
- # ------------------------------------------------------------------
- # AI ANALYSIS (auto-computed from application data)
- # ------------------------------------------------------------------
- ai_summary = fields.Text(
- string='Summary',
- compute='_compute_ai_analysis',
- )
- ai_risk_flags = fields.Text(
- string='Risk Flags',
- compute='_compute_ai_analysis',
- )
- ai_last_analyzed = fields.Datetime(string='Last AI Analysis')
-
- # ------------------------------------------------------------------
- # COMPUTED METHODS
- # ------------------------------------------------------------------
- @api.depends('first_name', 'last_name')
- def _compute_display_name(self):
- for profile in self:
- parts = [profile.last_name or '', profile.first_name or '']
- profile.display_name = ', '.join(p for p in parts if p) or 'New Profile'
-
- @api.depends('partner_id', 'partner_id.sale_order_ids',
- 'partner_id.sale_order_ids.x_fc_adp_portion_total',
- 'partner_id.sale_order_ids.x_fc_client_portion_total',
- 'partner_id.sale_order_ids.amount_total')
- def _compute_claim_stats(self):
- for profile in self:
- if profile.partner_id:
- orders = self.env['sale.order'].search([
- ('partner_id', '=', profile.partner_id.id),
- ('x_fc_sale_type', '!=', False),
- ])
- profile.claim_count = len(orders)
- profile.total_adp_funded = sum(orders.mapped('x_fc_adp_portion_total'))
- profile.total_client_portion = sum(orders.mapped('x_fc_client_portion_total'))
- profile.total_amount = sum(orders.mapped('amount_total'))
- else:
- profile.claim_count = 0
- profile.total_adp_funded = 0
- profile.total_client_portion = 0
- profile.total_amount = 0
-
- def _compute_application_count(self):
- for profile in self:
- profile.application_count = len(profile.application_data_ids)
-
- @api.depends('application_data_ids', 'application_data_ids.application_date',
- 'application_data_ids.base_device', 'application_data_ids.reason_for_application')
- def _compute_ai_analysis(self):
- for profile in self:
- apps = profile.application_data_ids.sorted('application_date', reverse=True)
-
- # --- SUMMARY ---
- summary_lines = []
-
- # Number of applications
- app_count = len(apps)
- summary_lines.append(f"Total Applications: {app_count}")
-
- # Last funding history
- if apps:
- latest = apps[0]
- date_str = latest.application_date.strftime('%B %d, %Y') if latest.application_date else 'Unknown date'
- device = latest.base_device or 'Not specified'
- category = dict(latest._fields['device_category'].selection).get(
- latest.device_category, latest.device_category or 'N/A'
- ) if latest.device_category else 'N/A'
- summary_lines.append(f"Last Application: {date_str}")
- summary_lines.append(f"Last Device: {device} ({category})")
-
- # Reason for last application
- reason = latest.reason_for_application or 'Not specified'
- summary_lines.append(f"Reason: {reason}")
-
- # Authorizer
- auth_name = f"{latest.authorizer_first_name or ''} {latest.authorizer_last_name or ''}".strip()
- if auth_name:
- summary_lines.append(f"Authorizer: {auth_name}")
-
- # All devices received (unique)
- devices = set()
- for a in apps:
- if a.base_device:
- devices.add(a.base_device)
- if len(devices) > 1:
- summary_lines.append(f"All Devices: {', '.join(sorted(devices))}")
- else:
- summary_lines.append("No applications on file.")
-
- profile.ai_summary = '\n'.join(summary_lines)
-
- # --- RISK FLAGS ---
- risk_lines = []
-
- if app_count >= 2:
- # Calculate frequency
- dated_apps = [a for a in apps if a.application_date]
- if len(dated_apps) >= 2:
- dates = sorted([a.application_date for a in dated_apps])
- total_span = (dates[-1] - dates[0]).days
- if total_span > 0:
- avg_days = total_span / (len(dates) - 1)
- if avg_days < 365:
- risk_lines.append(
- f"High Frequency: {app_count} applications over "
- f"{total_span} days (avg {avg_days:.0f} days apart)"
- )
- elif avg_days < 730:
- risk_lines.append(
- f"Moderate Frequency: {app_count} applications over "
- f"{total_span // 365} year(s) (avg {avg_days:.0f} days apart)"
- )
- else:
- risk_lines.append(
- f"Normal Frequency: {app_count} applications over "
- f"{total_span // 365} year(s) (avg {avg_days:.0f} days apart)"
- )
-
- # Check for multiple replacements
- replacements = [a for a in apps if a.reason_for_application and 'replacement' in a.reason_for_application.lower()]
- if len(replacements) >= 2:
- risk_lines.append(f"Multiple Replacements: {len(replacements)} replacement applications")
-
- if not risk_lines:
- risk_lines.append("No flags identified.")
-
- profile.ai_risk_flags = '\n'.join(risk_lines)
-
- # ------------------------------------------------------------------
- # ACTIONS
- # ------------------------------------------------------------------
- def action_view_claims(self):
- """Open sale orders for this client."""
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_window',
- 'name': f'Claims - {self.display_name}',
- 'res_model': 'sale.order',
- 'view_mode': 'list,form',
- 'domain': [('partner_id', '=', self.partner_id.id), ('x_fc_sale_type', '!=', False)],
- }
-
- def action_view_applications(self):
- """Open parsed ADP application data for this client."""
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_window',
- 'name': f'Applications - {self.display_name}',
- 'res_model': 'fusion.adp.application.data',
- 'view_mode': 'list,form',
- 'domain': [('profile_id', '=', self.id)],
- }
-
- def action_open_ai_chat(self):
- """Open AI chat about this client using Odoo's native AI agent."""
- self.ensure_one()
- agent = self.env.ref('fusion_claims.ai_agent_fusion_claims', raise_if_not_found=False)
- if agent:
- # Create channel with client context so the AI knows which client
- channel = agent._create_ai_chat_channel()
- # Post an initial context message about this client
- initial_prompt = (
- f"I want to ask about client {self.display_name} "
- f"(Profile ID: {self.id}, Health Card: {self.health_card_number or 'N/A'}). "
- f"Please look up their details."
- )
- return {
- 'type': 'ir.actions.client',
- 'tag': 'agent_chat_action',
- 'params': {
- 'channelId': channel.id,
- 'user_prompt': initial_prompt,
- },
- }
- return {'type': 'ir.actions.act_window_close'}
diff --git a/fusion_claims/fusion_claims/models/dashboard.py b/fusion_claims/fusion_claims/models/dashboard.py
deleted file mode 100644
index fe02079..0000000
--- a/fusion_claims/fusion_claims/models/dashboard.py
+++ /dev/null
@@ -1,162 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-
-from odoo import api, fields, models
-
-CASE_TYPE_SELECTION = [
- ('adp', 'ADP'),
- ('odsp', 'ODSP'),
- ('march_of_dimes', 'March of Dimes'),
- ('hardship', 'Hardship Funding'),
- ('acsd', 'ACSD'),
- ('muscular_dystrophy', 'Muscular Dystrophy'),
- ('insurance', 'Insurance'),
- ('wsib', 'WSIB'),
-]
-
-TYPE_DOMAINS = {
- 'adp': [('x_fc_sale_type', 'in', ['adp', 'adp_odsp'])],
- 'odsp': [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp'])],
- 'march_of_dimes': [('x_fc_sale_type', '=', 'march_of_dimes')],
- 'hardship': [('x_fc_sale_type', '=', 'hardship')],
- 'acsd': [('x_fc_client_type', '=', 'ACS')],
- 'muscular_dystrophy': [('x_fc_sale_type', '=', 'muscular_dystrophy')],
- 'insurance': [('x_fc_sale_type', '=', 'insurance')],
- 'wsib': [('x_fc_sale_type', '=', 'wsib')],
-}
-
-TYPE_LABELS = dict(CASE_TYPE_SELECTION)
-
-
-class FusionClaimsDashboard(models.TransientModel):
- _name = 'fusion.claims.dashboard'
- _description = 'Fusion Claims Dashboard'
- _rec_name = 'name'
-
- name = fields.Char(default='Dashboard', readonly=True)
-
- # Case counts by funding type
- adp_count = fields.Integer(compute='_compute_stats')
- odsp_count = fields.Integer(compute='_compute_stats')
- march_of_dimes_count = fields.Integer(compute='_compute_stats')
- hardship_count = fields.Integer(compute='_compute_stats')
- acsd_count = fields.Integer(compute='_compute_stats')
- muscular_dystrophy_count = fields.Integer(compute='_compute_stats')
- insurance_count = fields.Integer(compute='_compute_stats')
- wsib_count = fields.Integer(compute='_compute_stats')
- total_profiles = fields.Integer(compute='_compute_stats')
-
- # Panel selectors (4 panels)
- panel1_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 1', default='adp')
- panel2_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 2', default='odsp')
- panel3_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 3', default='march_of_dimes')
- panel4_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 4', default='hardship')
-
- # Panel HTML
- panel1_html = fields.Html(compute='_compute_panels', sanitize=False)
- panel2_html = fields.Html(compute='_compute_panels', sanitize=False)
- panel3_html = fields.Html(compute='_compute_panels', sanitize=False)
- panel4_html = fields.Html(compute='_compute_panels', sanitize=False)
- panel1_title = fields.Char(compute='_compute_panels')
- panel2_title = fields.Char(compute='_compute_panels')
- panel3_title = fields.Char(compute='_compute_panels')
- panel4_title = fields.Char(compute='_compute_panels')
-
- def _compute_stats(self):
- SO = self.env['sale.order'].sudo()
- Profile = self.env['fusion.client.profile'].sudo()
- for rec in self:
- rec.adp_count = SO.search_count(TYPE_DOMAINS['adp'])
- rec.odsp_count = SO.search_count(TYPE_DOMAINS['odsp'])
- rec.march_of_dimes_count = SO.search_count(TYPE_DOMAINS['march_of_dimes'])
- rec.hardship_count = SO.search_count(TYPE_DOMAINS['hardship'])
- rec.acsd_count = SO.search_count(TYPE_DOMAINS['acsd'])
- rec.muscular_dystrophy_count = SO.search_count(TYPE_DOMAINS['muscular_dystrophy'])
- rec.insurance_count = SO.search_count(TYPE_DOMAINS['insurance'])
- rec.wsib_count = SO.search_count(TYPE_DOMAINS['wsib'])
- rec.total_profiles = Profile.search_count([])
-
- @api.depends('panel1_type', 'panel2_type', 'panel3_type', 'panel4_type')
- def _compute_panels(self):
- SO = self.env['sale.order'].sudo()
- for rec in self:
- for i in range(1, 5):
- ptype = getattr(rec, f'panel{i}_type') or 'adp'
- domain = TYPE_DOMAINS.get(ptype, [])
- orders = SO.search(domain, order='create_date desc', limit=50)
- count = SO.search_count(domain)
- title = f'Window {i} - {TYPE_LABELS.get(ptype, ptype)} ({count} cases)'
- html = rec._build_top_list(orders)
- setattr(rec, f'panel{i}_title', title)
- setattr(rec, f'panel{i}_html', html)
-
- def _build_top_list(self, orders):
- if not orders:
- return '
No cases found
'
- rows = []
- for o in orders:
- status = o.x_fc_adp_application_status or ''
- status_label = dict(o._fields['x_fc_adp_application_status'].selection).get(status, status)
- rows.append(
- f'
"
- message_parts.append(msg)
-
- if not_found:
- message_parts.append(f"Not found in database: {', '.join(not_found)}")
-
- if no_code:
- message_parts.append(f"No ADP code: {', '.join(no_code)}")
-
- if not message_parts:
- message_parts.append("No products to process.")
-
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'ADP Price Sync',
- 'message': ' '.join(message_parts),
- 'type': 'success' if updated else 'warning',
- 'sticky': True,
- }
- }
diff --git a/fusion_claims/fusion_claims/models/product_template.py b/fusion_claims/fusion_claims/models/product_template.py
deleted file mode 100644
index 770eb5e..0000000
--- a/fusion_claims/fusion_claims/models/product_template.py
+++ /dev/null
@@ -1,109 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-from odoo import api, fields, models
-
-
-class ProductTemplate(models.Model):
- _inherit = 'product.template'
-
- # ==========================================================================
- # ADP PRODUCT FIELDS
- # These are the module's own fields - independent of Odoo Studio
- # ==========================================================================
-
- x_fc_adp_device_code = fields.Char(
- string='ADP Device Code',
- help='Device code used for ADP claims export',
- copy=True,
- tracking=True,
- )
-
- x_fc_adp_price = fields.Float(
- string='ADP Price',
- digits='Product Price',
- help='ADP retail price for this product. Used in ADP reports and claims.',
- copy=True,
- tracking=True,
- )
-
- x_fc_is_adp_product = fields.Boolean(
- string='Is ADP Product',
- compute='_compute_is_adp_product',
- store=True,
- help='Indicates if this product has ADP pricing set up',
- )
-
- # ==========================================================================
- # LOANER PRODUCT FIELDS
- # ==========================================================================
-
- x_fc_can_be_loaned = fields.Boolean(
- string='Can be Loaned',
- default=False,
- help='If checked, this product can be loaned out to clients',
- )
- x_fc_loaner_period_days = fields.Integer(
- string='Loaner Period (Days)',
- default=7,
- help='Default number of free loaner days before rental conversion',
- )
- x_fc_rental_price_weekly = fields.Float(
- string='Weekly Rental Price',
- digits='Product Price',
- help='Rental price per week if loaner converts to rental',
- )
- x_fc_rental_price_monthly = fields.Float(
- string='Monthly Rental Price',
- digits='Product Price',
- help='Rental price per month if loaner converts to rental',
- )
-
- # ==========================================================================
- # COMPUTED FIELDS
- # ==========================================================================
-
- @api.depends('x_fc_adp_device_code', 'x_fc_adp_price')
- def _compute_is_adp_product(self):
- """Determine if this is an ADP product based on having device code or price."""
- for product in self:
- product.x_fc_is_adp_product = bool(
- product.x_fc_adp_device_code or product.x_fc_adp_price
- )
-
- # ==========================================================================
- # HELPER METHODS
- # ==========================================================================
-
- def get_adp_price(self):
- """
- Get ADP price with fallback to Studio field.
-
- Checks in order:
- 1. x_fc_adp_price (module field)
- 2. list_price (default product price)
- """
- self.ensure_one()
-
- if self.x_fc_adp_price:
- return self.x_fc_adp_price
-
- return self.list_price or 0.0
-
- def get_adp_device_code(self):
- """
- Get ADP device code.
-
- Checks in order:
- 1. x_fc_adp_device_code (module field)
- 2. default_code (internal reference)
- """
- self.ensure_one()
-
- if self.x_fc_adp_device_code:
- return self.x_fc_adp_device_code
-
- return self.default_code or ''
-
diff --git a/fusion_claims/fusion_claims/models/push_subscription.py b/fusion_claims/fusion_claims/models/push_subscription.py
deleted file mode 100644
index 19f9033..0000000
--- a/fusion_claims/fusion_claims/models/push_subscription.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-
-"""
-Web Push Subscription model for storing browser push notification subscriptions.
-"""
-
-from odoo import models, fields, api
-import logging
-
-_logger = logging.getLogger(__name__)
-
-
-class FusionPushSubscription(models.Model):
- _name = 'fusion.push.subscription'
- _description = 'Web Push Subscription'
- _order = 'create_date desc'
-
- user_id = fields.Many2one(
- 'res.users',
- string='User',
- required=True,
- ondelete='cascade',
- index=True,
- )
- endpoint = fields.Text(
- string='Endpoint URL',
- required=True,
- )
- p256dh_key = fields.Text(
- string='P256DH Key',
- required=True,
- )
- auth_key = fields.Text(
- string='Auth Key',
- required=True,
- )
- browser_info = fields.Char(
- string='Browser Info',
- help='User agent or browser identification',
- )
- active = fields.Boolean(
- default=True,
- )
-
- _constraints = [
- models.Constraint(
- 'unique(endpoint)',
- 'This push subscription endpoint already exists.',
- ),
- ]
-
- @api.model
- def register_subscription(self, user_id, endpoint, p256dh_key, auth_key, browser_info=None):
- """Register or update a push subscription."""
- existing = self.sudo().search([('endpoint', '=', endpoint)], limit=1)
- if existing:
- existing.write({
- 'user_id': user_id,
- 'p256dh_key': p256dh_key,
- 'auth_key': auth_key,
- 'browser_info': browser_info or existing.browser_info,
- 'active': True,
- })
- return existing
- return self.sudo().create({
- 'user_id': user_id,
- 'endpoint': endpoint,
- 'p256dh_key': p256dh_key,
- 'auth_key': auth_key,
- 'browser_info': browser_info,
- })
diff --git a/fusion_claims/fusion_claims/models/res_company.py b/fusion_claims/fusion_claims/models/res_company.py
deleted file mode 100644
index 039be29..0000000
--- a/fusion_claims/fusion_claims/models/res_company.py
+++ /dev/null
@@ -1,69 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-from odoo import models, fields
-
-
-class ResCompany(models.Model):
- _inherit = 'res.company'
-
- # Store/Location Information
- x_fc_store_address_1 = fields.Char(
- string='Store Address Line 1',
- help='First store/location address for reports (e.g., "Main Store - 123 Street, City, Province, Postal")',
- )
- x_fc_store_address_2 = fields.Char(
- string='Store Address Line 2',
- help='Second store/location address for reports (optional)',
- )
- x_fc_company_tagline = fields.Char(
- string='Company Tagline',
- help='Company tagline/slogan for reports (e.g., "Enhancing Accessibility, Improving Lives.")',
- )
-
- # Payment Information
- x_fc_etransfer_email = fields.Char(
- string='E-Transfer Email',
- help='Email address for Interac e-Transfers',
- )
- x_fc_cheque_payable_to = fields.Char(
- string='Cheque Payable To',
- help='Name for cheque payments (defaults to company name if empty)',
- )
- x_fc_payment_terms_html = fields.Html(
- string='Payment Terms',
- help='Payment terms and conditions displayed on reports (supports HTML formatting)',
- sanitize=True,
- sanitize_overridable=True,
- )
-
- # Refund Policy
- x_fc_include_refund_page = fields.Boolean(
- string='Include Refund Policy Page',
- default=True,
- help='Include a separate refund policy page at the end of reports',
- )
- x_fc_refund_policy_html = fields.Html(
- string='Refund Policy',
- help='Full refund policy displayed on a separate page (supports HTML formatting)',
- sanitize=True,
- sanitize_overridable=True,
- )
-
- # Office Notification Recipients
- x_fc_office_notification_ids = fields.Many2many(
- 'res.partner',
- 'fc_company_office_notification_partners_rel',
- 'company_id',
- 'partner_id',
- string='Office Notification Recipients',
- help='Contacts who will receive a copy (CC) of all automated ADP notifications',
- )
-
- def _get_cheque_payable_name(self):
- """Get the name for cheque payments, defaulting to company name."""
- self.ensure_one()
- return self.x_fc_cheque_payable_to or self.name
-
diff --git a/fusion_claims/fusion_claims/models/res_config_settings.py b/fusion_claims/fusion_claims/models/res_config_settings.py
deleted file mode 100644
index a8a09ab..0000000
--- a/fusion_claims/fusion_claims/models/res_config_settings.py
+++ /dev/null
@@ -1,602 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-import logging
-
-from odoo import models, fields, api
-
-_logger = logging.getLogger(__name__)
-
-
-class ResConfigSettings(models.TransientModel):
- _inherit = 'res.config.settings'
-
- # =========================================================================
- # COMPANY SETTINGS (Related to res.company)
- # =========================================================================
-
- fc_store_address_1 = fields.Char(
- related='company_id.x_fc_store_address_1',
- readonly=False,
- string='Store Address Line 1',
- )
- fc_store_address_2 = fields.Char(
- related='company_id.x_fc_store_address_2',
- readonly=False,
- string='Store Address Line 2',
- )
- fc_company_tagline = fields.Char(
- related='company_id.x_fc_company_tagline',
- readonly=False,
- string='Company Tagline',
- )
- fc_etransfer_email = fields.Char(
- related='company_id.x_fc_etransfer_email',
- readonly=False,
- string='E-Transfer Email',
- )
- fc_cheque_payable_to = fields.Char(
- related='company_id.x_fc_cheque_payable_to',
- readonly=False,
- string='Cheque Payable To',
- )
- fc_payment_terms_html = fields.Html(
- related='company_id.x_fc_payment_terms_html',
- readonly=False,
- string='Payment Terms',
- )
- fc_include_refund_page = fields.Boolean(
- related='company_id.x_fc_include_refund_page',
- readonly=False,
- string='Include Refund Policy Page',
- )
- fc_refund_policy_html = fields.Html(
- related='company_id.x_fc_refund_policy_html',
- readonly=False,
- string='Refund Policy',
- )
-
- # =========================================================================
- # ADP BILLING SETTINGS
- # =========================================================================
-
- fc_vendor_code = fields.Char(
- string='ADP Vendor Code',
- config_parameter='fusion_claims.vendor_code',
- help='Your ADP vendor/location code for claim submissions',
- )
-
- # =========================================================================
- # FIELD MAPPINGS
- # =========================================================================
-
- fc_field_sale_type = fields.Char(
- string='Sale Type Field',
- config_parameter='fusion_claims.field_sale_type',
- help='Field name for sale type on sale.order',
- )
- fc_field_so_client_type = fields.Char(
- string='SO Client Type Field',
- config_parameter='fusion_claims.field_so_client_type',
- help='Field name for client type on sale.order',
- )
- fc_field_so_authorizer = fields.Char(
- string='SO Authorizer Field',
- config_parameter='fusion_claims.field_so_authorizer',
- help='Field name for authorizer on sale.order',
- )
- fc_field_invoice_type = fields.Char(
- string='Invoice Type Field',
- config_parameter='fusion_claims.field_invoice_type',
- help='Field name for invoice type on account.move',
- )
- fc_field_inv_client_type = fields.Char(
- string='Invoice Client Type Field',
- config_parameter='fusion_claims.field_inv_client_type',
- help='Field name for client type on account.move',
- )
- fc_field_inv_authorizer = fields.Char(
- string='Invoice Authorizer Field',
- config_parameter='fusion_claims.field_inv_authorizer',
- help='Field name for authorizer on account.move',
- )
- fc_field_product_code = fields.Char(
- string='Product ADP Code Field',
- config_parameter='fusion_claims.field_product_code',
- help='Field name for ADP device code on product.template',
- )
- fc_field_sol_serial = fields.Char(
- string='SO Line Serial Field',
- config_parameter='fusion_claims.field_sol_serial',
- help='Field name for serial number on sale.order.line',
- )
- fc_field_aml_serial = fields.Char(
- string='Invoice Line Serial Field',
- config_parameter='fusion_claims.field_aml_serial',
- help='Field name for serial number on account.move.line',
- )
-
- # =========================================================================
- # ADDITIONAL SALE ORDER FIELD MAPPINGS
- # =========================================================================
-
- fc_field_so_claim_number = fields.Char(
- string='SO Claim Number Field',
- config_parameter='fusion_claims.field_so_claim_number',
- help='Field name for claim number on sale.order',
- )
- fc_field_so_client_ref_1 = fields.Char(
- string='SO Client Ref 1 Field',
- config_parameter='fusion_claims.field_so_client_ref_1',
- help='Field name for client reference 1 on sale.order',
- )
- fc_field_so_client_ref_2 = fields.Char(
- string='SO Client Ref 2 Field',
- config_parameter='fusion_claims.field_so_client_ref_2',
- help='Field name for client reference 2 on sale.order',
- )
- fc_field_so_delivery_date = fields.Char(
- string='SO Delivery Date Field',
- config_parameter='fusion_claims.field_so_delivery_date',
- help='Field name for ADP delivery date on sale.order',
- )
- fc_field_so_adp_status = fields.Char(
- string='SO ADP Status Field',
- config_parameter='fusion_claims.field_so_adp_status',
- help='Field name for ADP status on sale.order',
- )
- fc_field_so_service_start = fields.Char(
- string='SO Service Start Date Field',
- config_parameter='fusion_claims.field_so_service_start',
- help='Field name for service start date on sale.order',
- )
- fc_field_so_service_end = fields.Char(
- string='SO Service End Date Field',
- config_parameter='fusion_claims.field_so_service_end',
- help='Field name for service end date on sale.order',
- )
-
- # =========================================================================
- # ADDITIONAL INVOICE FIELD MAPPINGS
- # =========================================================================
-
- fc_field_inv_claim_number = fields.Char(
- string='Invoice Claim Number Field',
- config_parameter='fusion_claims.field_inv_claim_number',
- help='Field name for claim number on account.move',
- )
- fc_field_inv_client_ref_1 = fields.Char(
- string='Invoice Client Ref 1 Field',
- config_parameter='fusion_claims.field_inv_client_ref_1',
- help='Field name for client reference 1 on account.move',
- )
- fc_field_inv_client_ref_2 = fields.Char(
- string='Invoice Client Ref 2 Field',
- config_parameter='fusion_claims.field_inv_client_ref_2',
- help='Field name for client reference 2 on account.move',
- )
- fc_field_inv_delivery_date = fields.Char(
- string='Invoice Delivery Date Field',
- config_parameter='fusion_claims.field_inv_delivery_date',
- help='Field name for ADP delivery date on account.move',
- )
- fc_field_inv_service_start = fields.Char(
- string='Invoice Service Start Date Field',
- config_parameter='fusion_claims.field_inv_service_start',
- help='Field name for service start date on account.move',
- )
- fc_field_inv_service_end = fields.Char(
- string='Invoice Service End Date Field',
- config_parameter='fusion_claims.field_inv_service_end',
- help='Field name for service end date on account.move',
- )
-
- # =========================================================================
- # SALE ORDER LINE FIELD MAPPINGS
- # =========================================================================
-
- fc_field_sol_placement = fields.Char(
- string='SO Line Placement Field',
- config_parameter='fusion_claims.field_sol_placement',
- help='Field name for device placement on sale.order.line',
- )
-
- # =========================================================================
- # INVOICE LINE FIELD MAPPINGS
- # =========================================================================
-
- fc_field_aml_placement = fields.Char(
- string='Invoice Line Placement Field',
- config_parameter='fusion_claims.field_aml_placement',
- help='Field name for device placement on account.move.line',
- )
-
- # =========================================================================
- # PRODUCT FIELD MAPPINGS
- # =========================================================================
-
- fc_field_product_adp_price = fields.Char(
- string='Product ADP Price Field',
- config_parameter='fusion_claims.field_product_adp_price',
- help='Field name for ADP price on product.template',
- )
-
- # =========================================================================
- # HEADER-LEVEL SERIAL NUMBER MAPPINGS
- # =========================================================================
-
- fc_field_so_primary_serial = fields.Char(
- string='SO Primary Serial Field',
- config_parameter='fusion_claims.field_so_primary_serial',
- help='Field name for primary serial number on sale.order (header level)',
- )
- fc_field_inv_primary_serial = fields.Char(
- string='Invoice Primary Serial Field',
- config_parameter='fusion_claims.field_inv_primary_serial',
- help='Field name for primary serial number on account.move (header level)',
- )
-
- # =========================================================================
- # ADP POSTING SCHEDULE SETTINGS
- # =========================================================================
-
- fc_adp_posting_base_date = fields.Char(
- string='ADP Posting Base Date',
- config_parameter='fusion_claims.adp_posting_base_date',
- help='Reference date for calculating bi-weekly posting schedule (a known posting day). Format: YYYY-MM-DD',
- )
- fc_adp_posting_frequency_days = fields.Integer(
- string='Posting Frequency (Days)',
- config_parameter='fusion_claims.adp_posting_frequency_days',
- help='Number of days between ADP posting cycles (typically 14 days)',
- )
- fc_adp_billing_reminder_user_id = fields.Many2one(
- 'res.users',
- string='Billing Deadline Reminder Person',
- # NOTE: stored manually via get_values/set_values (not config_parameter)
- # because Many2one + config_parameter causes double-write conflicts
- help='Person to remind on Monday to complete ADP billing by Wednesday 6 PM',
- )
- fc_adp_correction_reminder_user_ids = fields.Many2many(
- 'res.users',
- 'fc_config_correction_reminder_users_rel',
- 'config_id',
- 'user_id',
- string='Correction Alert Recipients',
- help='People to notify when an ADP invoice needs correction/resubmission',
- )
-
- # =========================================================================
- # EMAIL NOTIFICATION SETTINGS
- # =========================================================================
-
- fc_enable_email_notifications = fields.Boolean(
- string='Enable Automated Email Notifications',
- config_parameter='fusion_claims.enable_email_notifications',
- help='Enable/disable automated email notifications for ADP workflow events',
- )
- fc_office_notification_ids = fields.Many2many(
- related='company_id.x_fc_office_notification_ids',
- readonly=False,
- string='Office Notification Recipients',
- )
- fc_application_reminder_days = fields.Integer(
- string='First Reminder Days',
- config_parameter='fusion_claims.application_reminder_days',
- help='Number of days after assessment completion to send first application reminder to therapist',
- )
- fc_application_reminder_2_days = fields.Integer(
- string='Second Reminder Days (After First)',
- config_parameter='fusion_claims.application_reminder_2_days',
- help='Number of days after first reminder to send second application reminder to therapist',
- )
-
- # =========================================================================
- # WORKFLOW LOCK SETTINGS
- # =========================================================================
-
- fc_allow_sale_type_override = fields.Boolean(
- string='Allow Sale Type Override',
- config_parameter='fusion_claims.allow_sale_type_override',
- help='If enabled, allows changing Sale Type even after application is submitted (for cases where additional benefits are discovered)',
- )
-
- fc_allow_document_lock_override = fields.Boolean(
- string='Allow Document Lock Override',
- config_parameter='fusion_claims.allow_document_lock_override',
- help='When enabled, users in the "Document Lock Override" group can edit locked documents on old cases. '
- 'Disable this once all legacy cases have been processed to enforce strict workflow.',
- )
-
- fc_designated_vendor_signer = fields.Many2one(
- 'res.users',
- string='Designated Vendor Signer',
- help='The user who signs Page 12 on behalf of the company',
- )
-
- # =========================================================================
- # GOOGLE MAPS API SETTINGS
- # =========================================================================
-
- fc_google_maps_api_key = fields.Char(
- string='Google Maps API Key',
- config_parameter='fusion_claims.google_maps_api_key',
- help='API key for Google Maps Places autocomplete in address fields',
- )
-
- # ------------------------------------------------------------------
- # AI CLIENT INTELLIGENCE
- # ------------------------------------------------------------------
- fc_ai_api_key = fields.Char(
- string='AI API Key',
- config_parameter='fusion_claims.ai_api_key',
- help='OpenAI API key for Client Intelligence chat',
- )
- fc_ai_model = fields.Selection([
- ('gpt-4o-mini', 'GPT-4o Mini (Fast, Lower Cost)'),
- ('gpt-4o', 'GPT-4o (Best Quality)'),
- ('gpt-4.1-mini', 'GPT-4.1 Mini'),
- ('gpt-4.1', 'GPT-4.1'),
- ], string='AI Model',
- config_parameter='fusion_claims.ai_model',
- )
- fc_auto_parse_xml = fields.Boolean(
- string='Auto-Parse XML Files',
- config_parameter='fusion_claims.auto_parse_xml',
- help='Automatically parse ADP XML files when uploaded and create/update client profiles',
- )
-
- # ------------------------------------------------------------------
- # TECHNICIAN MANAGEMENT
- # ------------------------------------------------------------------
- fc_store_open_hour = fields.Float(
- string='Store Open Time',
- config_parameter='fusion_claims.store_open_hour',
- help='Store opening time for technician scheduling (e.g. 9.0 = 9:00 AM)',
- )
- fc_store_close_hour = fields.Float(
- string='Store Close Time',
- config_parameter='fusion_claims.store_close_hour',
- help='Store closing time for technician scheduling (e.g. 18.0 = 6:00 PM)',
- )
- fc_google_distance_matrix_enabled = fields.Boolean(
- string='Enable Distance Matrix',
- config_parameter='fusion_claims.google_distance_matrix_enabled',
- help='Enable Google Distance Matrix API for travel time calculations between technician tasks',
- )
- fc_technician_start_address = fields.Char(
- string='Technician Start Address',
- config_parameter='fusion_claims.technician_start_address',
- help='Default start location for technician travel calculations (e.g. warehouse/office address)',
- )
- fc_location_retention_days = fields.Char(
- string='Location History Retention (Days)',
- config_parameter='fusion_claims.location_retention_days',
- help='How many days to keep technician location history. '
- 'Leave empty = 30 days (1 month). '
- '0 = delete at end of each day. '
- '1+ = keep for that many days.',
- )
-
- # ------------------------------------------------------------------
- # WEB PUSH NOTIFICATIONS
- # ------------------------------------------------------------------
- fc_push_enabled = fields.Boolean(
- string='Enable Push Notifications',
- config_parameter='fusion_claims.push_enabled',
- help='Enable web push notifications for technician tasks',
- )
- fc_vapid_public_key = fields.Char(
- string='VAPID Public Key',
- config_parameter='fusion_claims.vapid_public_key',
- help='Public key for Web Push VAPID authentication (auto-generated)',
- )
- fc_vapid_private_key = fields.Char(
- string='VAPID Private Key',
- config_parameter='fusion_claims.vapid_private_key',
- help='Private key for Web Push VAPID authentication (auto-generated)',
- )
- fc_push_advance_minutes = fields.Integer(
- string='Notification Advance (min)',
- config_parameter='fusion_claims.push_advance_minutes',
- help='Send push notifications this many minutes before a scheduled task',
- )
-
- # ------------------------------------------------------------------
- # TWILIO SMS SETTINGS
- # ------------------------------------------------------------------
- fc_twilio_enabled = fields.Boolean(
- string='Enable Twilio SMS',
- config_parameter='fusion_claims.twilio_enabled',
- help='Enable SMS notifications via Twilio for assessment bookings and key status updates',
- )
- fc_twilio_account_sid = fields.Char(
- string='Twilio Account SID',
- config_parameter='fusion_claims.twilio_account_sid',
- groups='fusion_claims.group_fusion_claims_manager',
- )
- fc_twilio_auth_token = fields.Char(
- string='Twilio Auth Token',
- config_parameter='fusion_claims.twilio_auth_token',
- groups='fusion_claims.group_fusion_claims_manager',
- )
- fc_twilio_phone_number = fields.Char(
- string='Twilio Phone Number',
- config_parameter='fusion_claims.twilio_phone_number',
- help='Your Twilio phone number for sending SMS (e.g. +1234567890)',
- )
-
- # ------------------------------------------------------------------
- # MARCH OF DIMES SETTINGS
- # ------------------------------------------------------------------
- fc_mod_default_email = fields.Char(
- string='MOD Default Email',
- config_parameter='fusion_claims.mod_default_email',
- help='Default email for sending quotations and documents to March of Dimes (e.g. hvmp@marchofdimes.ca)',
- )
- fc_mod_vendor_code = fields.Char(
- string='March of Dimes Vendor Code',
- config_parameter='fusion_claims.mod_vendor_code',
- help='Your vendor code assigned by March of Dimes (e.g. TRD0001234)',
- )
-
- # ------------------------------------------------------------------
- # MOD FOLLOW-UP SETTINGS
- # ------------------------------------------------------------------
- fc_mod_followup_interval_days = fields.Integer(
- string='Follow-up Interval (Days)',
- config_parameter='fusion_claims.mod_followup_interval_days',
- help='Number of days between follow-up reminders for MOD cases awaiting funding (default: 14)',
- )
- fc_mod_followup_escalation_days = fields.Integer(
- string='Escalation Delay (Days)',
- config_parameter='fusion_claims.mod_followup_escalation_days',
- help='Days after a follow-up activity is due before auto-sending email to client (default: 3)',
- )
-
- # ------------------------------------------------------------------
- # ODSP CONFIGURATION
- # ------------------------------------------------------------------
- fc_sa_mobility_email = fields.Char(
- string='SA Mobility Email',
- config_parameter='fusion_claims.sa_mobility_email',
- help='Email address for SA Mobility submissions (default: samobility@ontario.ca)',
- )
- fc_sa_mobility_phone = fields.Char(
- string='SA Mobility Phone',
- config_parameter='fusion_claims.sa_mobility_phone',
- help='SA Mobility phone number (default: 1-888-222-5099)',
- )
- fc_odsp_default_office_id = fields.Many2one(
- 'res.partner',
- string='Default ODSP Office',
- domain="[('x_fc_contact_type', '=', 'odsp_office')]",
- help='Default ODSP office contact for new ODSP cases',
- )
-
- @api.model
- def get_values(self):
- res = super().get_values()
- ICP = self.env['ir.config_parameter'].sudo()
-
- # Get billing reminder user
- billing_user_id = ICP.get_param('fusion_claims.adp_billing_reminder_user_id', False)
- if billing_user_id:
- try:
- res['fc_adp_billing_reminder_user_id'] = int(billing_user_id)
- except (ValueError, TypeError):
- res['fc_adp_billing_reminder_user_id'] = False
-
- # Get correction reminder users (stored as comma-separated IDs)
- correction_user_ids = ICP.get_param('fusion_claims.adp_correction_reminder_user_ids', '')
- if correction_user_ids:
- try:
- user_ids = [int(x.strip()) for x in correction_user_ids.split(',') if x.strip()]
- res['fc_adp_correction_reminder_user_ids'] = [(6, 0, user_ids)]
- except (ValueError, TypeError):
- res['fc_adp_correction_reminder_user_ids'] = [(6, 0, [])]
-
- # Get designated vendor signer
- vendor_signer_id = ICP.get_param('fusion_claims.designated_vendor_signer', False)
- if vendor_signer_id:
- try:
- res['fc_designated_vendor_signer'] = int(vendor_signer_id)
- except (ValueError, TypeError):
- res['fc_designated_vendor_signer'] = False
-
- # Get default ODSP office
- odsp_office_id = ICP.get_param('fusion_claims.odsp_default_office_id', False)
- if odsp_office_id:
- try:
- res['fc_odsp_default_office_id'] = int(odsp_office_id)
- except (ValueError, TypeError):
- res['fc_odsp_default_office_id'] = False
-
- return res
-
- def set_values(self):
- ICP = self.env['ir.config_parameter'].sudo()
-
- # --- Protect sensitive config_parameter fields from accidental blanking ---
- # These are keys where a blank/default value should NOT overwrite
- # an existing non-empty value (e.g. API keys, user-customized settings).
- _protected_keys = [
- 'fusion_claims.ai_api_key',
- 'fusion_claims.google_maps_api_key',
- 'fusion_claims.vendor_code',
- 'fusion_claims.ai_model',
- 'fusion_claims.adp_posting_base_date',
- 'fusion_claims.application_reminder_days',
- 'fusion_claims.application_reminder_2_days',
- 'fusion_claims.store_open_hour',
- 'fusion_claims.store_close_hour',
- 'fusion_claims.technician_start_address',
- ]
- # Snapshot existing values BEFORE super().set_values() runs
- _existing = {}
- for key in _protected_keys:
- val = ICP.get_param(key, '')
- if val:
- _existing[key] = val
-
- super().set_values()
-
- # Restore any protected values that were blanked by the save
- for key, old_val in _existing.items():
- new_val = ICP.get_param(key, '')
- if not new_val and old_val:
- ICP.set_param(key, old_val)
- _logger.warning(
- "Settings protection: restored %s (was blanked during save)", key
- )
-
- # Store billing reminder user (Many2one - manual handling)
- if self.fc_adp_billing_reminder_user_id:
- ICP.set_param('fusion_claims.adp_billing_reminder_user_id',
- str(self.fc_adp_billing_reminder_user_id.id))
- # Only clear if explicitly set to empty AND there was no existing value
- elif not ICP.get_param('fusion_claims.adp_billing_reminder_user_id', ''):
- ICP.set_param('fusion_claims.adp_billing_reminder_user_id', '')
-
- # Store correction reminder users as comma-separated IDs
- if self.fc_adp_correction_reminder_user_ids:
- user_ids = ','.join(str(u.id) for u in self.fc_adp_correction_reminder_user_ids)
- ICP.set_param('fusion_claims.adp_correction_reminder_user_ids', user_ids)
- # Only clear if explicitly empty AND no existing value
- elif not ICP.get_param('fusion_claims.adp_correction_reminder_user_ids', ''):
- ICP.set_param('fusion_claims.adp_correction_reminder_user_ids', '')
-
- # Office notification recipients are stored via related field on res.company
- # No need to store in ir.config_parameter
-
- # Store designated vendor signer (Many2one - manual handling)
- if self.fc_designated_vendor_signer:
- ICP.set_param('fusion_claims.designated_vendor_signer',
- str(self.fc_designated_vendor_signer.id))
- elif not ICP.get_param('fusion_claims.designated_vendor_signer', ''):
- ICP.set_param('fusion_claims.designated_vendor_signer', '')
-
- # Store default ODSP office (Many2one - manual handling)
- if self.fc_odsp_default_office_id:
- ICP.set_param('fusion_claims.odsp_default_office_id',
- str(self.fc_odsp_default_office_id.id))
- elif not ICP.get_param('fusion_claims.odsp_default_office_id', ''):
- ICP.set_param('fusion_claims.odsp_default_office_id', '')
-
- # =========================================================================
- # ACTION METHODS
- # =========================================================================
-
- def action_open_field_mapping_wizard(self):
- """Open the field mapping configuration wizard."""
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'Field Mapping Configuration',
- 'res_model': 'fusion_claims.field_mapping_config',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {},
- }
-
diff --git a/fusion_claims/fusion_claims/models/res_partner.py b/fusion_claims/fusion_claims/models/res_partner.py
deleted file mode 100644
index 8ddc9f0..0000000
--- a/fusion_claims/fusion_claims/models/res_partner.py
+++ /dev/null
@@ -1,82 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-
-from odoo import models, fields, api
-
-
-class ResPartner(models.Model):
- _inherit = 'res.partner'
-
- x_fc_start_address = fields.Char(
- string='Start Location',
- help='Technician daily start location (home, warehouse, etc.). '
- 'Used as origin for first travel time calculation. '
- 'If empty, the company default HQ address is used.',
- )
-
- # ==========================================================================
- # CONTACT TYPE
- # ==========================================================================
- x_fc_contact_type = fields.Selection(
- selection=[
- ('adp_customer', 'ADP Customer'),
- ('adp_odsp_customer', 'ADP-ODSP Customer'),
- ('odsp_customer', 'ODSP Customer'),
- ('mod_customer', 'MOD Customer'),
- ('private_customer', 'Private Customer'),
- ('wsib_customer', 'WSIB Customer'),
- ('acsd_customer', 'ACSD Customer'),
- ('private_insurance', 'Private Insurance'),
- ('adp_agent', 'ADP Agent'),
- ('odsp_agent', 'ODSP Agent'),
- ('muscular_dystrophy', 'Muscular Dystrophy'),
- ('occupational_therapist', 'Occupational Therapist'),
- ('physiotherapist', 'Physiotherapist'),
- ('vendor', 'Vendor'),
- ('funding_agency', 'Funding Agency'),
- ('government_agency', 'Government Agency'),
- ('company_contact', 'Company Contact'),
- ('long_term_care_home', 'Long Term Care Home'),
- ('retirement_home', 'Retirement Home'),
- ('odsp_office', 'ODSP Office'),
- ('other', 'Other'),
- ],
- string='Contact Type',
- tracking=True,
- index=True,
- )
-
- # ==========================================================================
- # ODSP FIELDS
- # ==========================================================================
- x_fc_odsp_member_id = fields.Char(
- string='ODSP Member ID',
- size=9,
- tracking=True,
- help='9-digit Ontario Disability Support Program Member ID',
- )
- x_fc_case_worker_id = fields.Many2one(
- 'res.partner',
- string='ODSP Case Worker',
- tracking=True,
- help='ODSP Case Worker assigned to this client',
- )
- x_fc_date_of_birth = fields.Date(
- string='Date of Birth',
- tracking=True,
- )
- x_fc_healthcard_number = fields.Char(
- string='Healthcard Number',
- tracking=True,
- )
- x_fc_is_odsp_office = fields.Boolean(
- compute='_compute_is_odsp_office',
- string='Is ODSP Office',
- store=True,
- )
-
- @api.depends('x_fc_contact_type')
- def _compute_is_odsp_office(self):
- for partner in self:
- partner.x_fc_is_odsp_office = partner.x_fc_contact_type == 'odsp_office'
diff --git a/fusion_claims/fusion_claims/models/res_users.py b/fusion_claims/fusion_claims/models/res_users.py
deleted file mode 100644
index f17757b..0000000
--- a/fusion_claims/fusion_claims/models/res_users.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-
-from odoo import models, fields, api
-
-
-class ResUsers(models.Model):
- _inherit = 'res.users'
-
- x_fc_is_field_staff = fields.Boolean(
- string='Field Staff',
- default=False,
- help='Check this to show the user in the Technician/Field Staff dropdown when scheduling tasks.',
- )
- x_fc_start_address = fields.Char(
- related='partner_id.x_fc_start_address',
- readonly=False,
- string='Start Location',
- )
- x_fc_tech_sync_id = fields.Char(
- string='Tech Sync ID',
- help='Shared identifier for this technician across Odoo instances. '
- 'Must be the same value on all instances for the same person.',
- copy=False,
- )
\ No newline at end of file
diff --git a/fusion_claims/fusion_claims/models/sale_order.py b/fusion_claims/fusion_claims/models/sale_order.py
deleted file mode 100644
index 3a47fba..0000000
--- a/fusion_claims/fusion_claims/models/sale_order.py
+++ /dev/null
@@ -1,7817 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-import logging
-import re
-from markupsafe import Markup
-from odoo import models, fields, api
-from odoo.exceptions import ValidationError, UserError
-
-_logger = logging.getLogger(__name__)
-
-
-class SaleOrder(models.Model):
- _name = 'sale.order'
- _inherit = ['sale.order', 'fusion_claims.adp.posting.schedule.mixin', 'fusion.email.builder.mixin']
- _rec_names_search = ['name', 'partner_id.name']
-
- @api.depends('name', 'partner_id.name')
- def _compute_display_name(self):
- for order in self:
- name = order.name or ''
- if order.partner_id and order.partner_id.name:
- name = f"{name} -- {order.partner_id.name}"
- order.display_name = name
-
- # ==========================================================================
- # FIELD FLAGS
- # ==========================================================================
- x_fc_is_adp_sale = fields.Boolean(
- compute='_compute_is_adp_sale',
- store=True,
- string='Is ADP Sale',
- help='True only for ADP or ADP/ODSP sale types',
- )
-
- # ==========================================================================
- # INVOICE COUNT FIELDS (Separate ADP and Client invoices)
- # ==========================================================================
- x_fc_adp_invoice_count = fields.Integer(
- compute='_compute_invoice_counts',
- string='ADP Invoices',
- )
- x_fc_client_invoice_count = fields.Integer(
- compute='_compute_invoice_counts',
- string='Client Invoices',
- )
-
- @api.depends('x_fc_adp_invoice_id', 'x_fc_client_invoice_id')
- def _compute_invoice_counts(self):
- """Compute separate counts for ADP and Client invoices.
-
- Uses x_fc_source_sale_order_id for direct linking instead of invoice_ids
- which only works when invoice lines have sale_line_ids.
- Also includes manually mapped invoices from x_fc_adp_invoice_id and x_fc_client_invoice_id.
- """
- AccountMove = self.env['account.move'].sudo()
- for order in self:
- # Search for invoices directly linked to this order via x_fc_source_sale_order_id
- adp_invoices = AccountMove.search([
- ('x_fc_source_sale_order_id', '=', order.id),
- ('x_fc_adp_invoice_portion', '=', 'adp'),
- ('state', '!=', 'cancel'),
- ])
- client_invoices = AccountMove.search([
- ('x_fc_source_sale_order_id', '=', order.id),
- ('x_fc_adp_invoice_portion', '=', 'client'),
- ('state', '!=', 'cancel'),
- ])
-
- # Also include manually mapped invoices from Invoice Mapping section
- if order.x_fc_adp_invoice_id and order.x_fc_adp_invoice_id.state != 'cancel':
- adp_invoices |= order.x_fc_adp_invoice_id
- if order.x_fc_client_invoice_id and order.x_fc_client_invoice_id.state != 'cancel':
- client_invoices |= order.x_fc_client_invoice_id
-
- order.x_fc_adp_invoice_count = len(adp_invoices)
- order.x_fc_client_invoice_count = len(client_invoices)
-
- # ==========================================================================
- # MOD INVOICE COUNT FIELDS (Separate MOD and Client invoices)
- # ==========================================================================
- x_fc_mod_invoice_count = fields.Integer(
- compute='_compute_mod_invoice_counts',
- string='MOD Invoices',
- )
- x_fc_mod_client_invoice_count = fields.Integer(
- compute='_compute_mod_invoice_counts',
- string='Client Invoices (MOD)',
- )
-
- @api.depends('order_line')
- def _compute_mod_invoice_counts(self):
- """Compute separate counts for MOD and Client invoices on MOD cases."""
- AccountMove = self.env['account.move'].sudo()
- for order in self:
- if not order.x_fc_is_mod_sale:
- order.x_fc_mod_invoice_count = 0
- order.x_fc_mod_client_invoice_count = 0
- continue
- # MOD portion invoices (full or adp = MOD's share)
- mod_invoices = AccountMove.search([
- ('x_fc_source_sale_order_id', '=', order.id),
- ('x_fc_adp_invoice_portion', 'in', ('full', 'adp')),
- ('state', '!=', 'cancel'),
- ])
- # Client portion invoices
- client_invoices = AccountMove.search([
- ('x_fc_source_sale_order_id', '=', order.id),
- ('x_fc_adp_invoice_portion', '=', 'client'),
- ('state', '!=', 'cancel'),
- ])
- order.x_fc_mod_invoice_count = len(mod_invoices)
- order.x_fc_mod_client_invoice_count = len(client_invoices)
-
- def action_view_mod_invoices(self):
- """Open MOD portion invoices for this order."""
- self.ensure_one()
- invoices = self.env['account.move'].sudo().search([
- ('x_fc_source_sale_order_id', '=', self.id),
- ('x_fc_adp_invoice_portion', 'in', ('full', 'adp')),
- ('state', '!=', 'cancel'),
- ])
- action = {
- 'name': 'MOD Invoices',
- 'type': 'ir.actions.act_window',
- 'res_model': 'account.move',
- 'view_mode': 'list,form',
- 'domain': [('id', 'in', invoices.ids)],
- 'context': {'default_move_type': 'out_invoice'},
- }
- if len(invoices) == 1:
- action['view_mode'] = 'form'
- action['res_id'] = invoices.id
- return action
-
- def action_view_mod_client_invoices(self):
- """Open Client portion invoices for MOD cases."""
- self.ensure_one()
- invoices = self.env['account.move'].sudo().search([
- ('x_fc_source_sale_order_id', '=', self.id),
- ('x_fc_adp_invoice_portion', '=', 'client'),
- ('state', '!=', 'cancel'),
- ])
- action = {
- 'name': 'Client Invoices',
- 'type': 'ir.actions.act_window',
- 'res_model': 'account.move',
- 'view_mode': 'list,form',
- 'domain': [('id', 'in', invoices.ids)],
- 'context': {'default_move_type': 'out_invoice'},
- }
- if len(invoices) == 1:
- action['view_mode'] = 'form'
- action['res_id'] = invoices.id
- return action
-
- # ==========================================================================
- # VENDOR BILL LINKING (for audit trail)
- # ==========================================================================
- x_fc_vendor_bill_ids = fields.Many2many(
- 'account.move',
- 'sale_order_vendor_bill_rel',
- 'sale_order_id',
- 'move_id',
- string='Vendor Bills',
- domain=[('move_type', '=', 'in_invoice')],
- help='Vendor bills/invoices linked to this sales order for audit trail purposes.',
- )
- x_fc_vendor_bill_count = fields.Integer(
- compute='_compute_vendor_bill_count',
- string='Vendor Bills',
- )
-
- @api.depends('x_fc_vendor_bill_ids')
- def _compute_vendor_bill_count(self):
- """Compute count of linked vendor bills."""
- for order in self:
- order.x_fc_vendor_bill_count = len(order.x_fc_vendor_bill_ids)
-
- def action_view_vendor_bills(self):
- """Open view of linked vendor bills."""
- self.ensure_one()
- action = {
- 'name': 'Vendor Bills',
- 'type': 'ir.actions.act_window',
- 'res_model': 'account.move',
- 'view_mode': 'list,form',
- 'domain': [('id', 'in', self.x_fc_vendor_bill_ids.ids)],
- 'context': {'default_move_type': 'in_invoice'},
- }
- if len(self.x_fc_vendor_bill_ids) == 1:
- action['view_mode'] = 'form'
- action['res_id'] = self.x_fc_vendor_bill_ids.id
- return action
-
- # ==========================================================================
- # SUBMISSION HISTORY (for tracking all submissions/resubmissions to ADP)
- # ==========================================================================
- x_fc_submission_history_ids = fields.One2many(
- 'fusion.submission.history',
- 'sale_order_id',
- string='Submission History',
- help='History of all submissions and resubmissions to ADP',
- )
- x_fc_submission_count = fields.Integer(
- string='Submissions',
- compute='_compute_submission_count',
- )
-
- @api.depends('x_fc_submission_history_ids')
- def _compute_submission_count(self):
- """Compute the number of submissions for this order."""
- for order in self:
- order.x_fc_submission_count = len(order.x_fc_submission_history_ids)
-
- def action_view_submission_history(self):
- """Open the submission history for this order."""
- self.ensure_one()
- return {
- 'name': 'Submission History',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion.submission.history',
- 'view_mode': 'list,form',
- 'domain': [('sale_order_id', '=', self.id)],
- 'context': {'default_sale_order_id': self.id},
- }
-
- # ==========================================================================
- # TECHNICIAN TASKS
- # ==========================================================================
- x_fc_technician_task_ids = fields.One2many(
- 'fusion.technician.task',
- 'sale_order_id',
- string='Technician Tasks',
- )
- x_fc_technician_task_count = fields.Integer(
- string='Tasks',
- compute='_compute_technician_task_count',
- )
-
- @api.depends('x_fc_technician_task_ids')
- def _compute_technician_task_count(self):
- for order in self:
- order.x_fc_technician_task_count = len(order.x_fc_technician_task_ids)
-
- def action_view_technician_tasks(self):
- """Open the technician tasks linked to this order."""
- self.ensure_one()
- action = {
- 'name': 'Technician Tasks',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion.technician.task',
- 'view_mode': 'list,form',
- 'domain': [('sale_order_id', '=', self.id)],
- 'context': {'default_sale_order_id': self.id},
- }
- if len(self.x_fc_technician_task_ids) == 1:
- action['view_mode'] = 'form'
- action['res_id'] = self.x_fc_technician_task_ids.id
- return action
-
- # LOANER EQUIPMENT TRACKING
- # ==========================================================================
- x_fc_loaner_checkout_ids = fields.One2many(
- 'fusion.loaner.checkout',
- 'sale_order_id',
- string='Loaner Checkouts',
- help='Loaner equipment checked out for this order',
- )
- x_fc_loaner_count = fields.Integer(
- string='Loaners',
- compute='_compute_loaner_count',
- )
- x_fc_active_loaner_count = fields.Integer(
- string='Active Loaners',
- compute='_compute_loaner_count',
- )
- x_fc_has_overdue_loaner = fields.Boolean(
- string='Has Overdue Loaner',
- compute='_compute_loaner_count',
- help='True if any active loaner is past its expected return date',
- )
-
- @api.depends('x_fc_loaner_checkout_ids', 'x_fc_loaner_checkout_ids.state',
- 'x_fc_loaner_checkout_ids.expected_return_date')
- def _compute_loaner_count(self):
- """Compute loaner counts and overdue status for this order."""
- today = fields.Date.today()
- for order in self:
- active = order.x_fc_loaner_checkout_ids.filtered(
- lambda l: l.state in ('checked_out', 'overdue', 'rental_pending')
- )
- order.x_fc_loaner_count = len(order.x_fc_loaner_checkout_ids)
- order.x_fc_active_loaner_count = len(active)
- order.x_fc_has_overdue_loaner = any(
- l.state == 'overdue' or (l.expected_return_date and l.expected_return_date < today)
- for l in active
- )
-
- def action_view_loaners(self):
- """Open the loaner checkouts for this order."""
- self.ensure_one()
- action = {
- 'name': 'Loaner Checkouts',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion.loaner.checkout',
- 'view_mode': 'tree,form',
- 'domain': [('sale_order_id', '=', self.id)],
- 'context': {'default_sale_order_id': self.id},
- }
- if len(self.x_fc_loaner_checkout_ids) == 1:
- action['view_mode'] = 'form'
- action['res_id'] = self.x_fc_loaner_checkout_ids.id
- return action
-
- def action_checkout_loaner(self):
- """Open the loaner checkout wizard."""
- self.ensure_one()
- return {
- 'name': 'Checkout Loaner',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion.loaner.checkout.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'default_sale_order_id': self.id,
- 'default_partner_id': self.partner_id.id,
- 'default_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False,
- },
- }
-
- def action_checkin_loaner(self):
- """Open the return wizard for the active loaner on this order."""
- self.ensure_one()
- active_loaners = self.x_fc_loaner_checkout_ids.filtered(
- lambda l: l.state in ('checked_out', 'overdue', 'rental_pending')
- )
- if not active_loaners:
- raise UserError("No active loaners to check in for this order.")
- if len(active_loaners) == 1:
- return active_loaners.action_return()
- # Multiple active loaners - show the list so user can pick which one to return
- return {
- 'name': 'Return Loaner',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion.loaner.checkout',
- 'view_mode': 'tree,form',
- 'domain': [('id', 'in', active_loaners.ids)],
- 'target': 'current',
- }
-
- def action_ready_for_delivery(self):
- """Open the task scheduling form to schedule a delivery task.
-
- Instead of a separate wizard, this opens the full technician task
- form pre-filled with delivery defaults. When the task is saved,
- the sale order is automatically marked as Ready for Delivery.
- """
- self.ensure_one()
-
- # Validate the order can be marked ready for delivery
- if not self._is_adp_sale():
- raise UserError("Ready for Delivery is only available for ADP sales.")
-
- valid_statuses = ('approved', 'approved_deduction')
- if self.x_fc_early_delivery:
- valid_statuses = ('submitted', 'accepted', 'approved', 'approved_deduction')
-
- if self.x_fc_adp_application_status not in valid_statuses:
- if self.x_fc_early_delivery:
- raise UserError(
- "For early delivery, the application must be at least Submitted.\n"
- f"Current status: {dict(self._fields['x_fc_adp_application_status'].selection).get(self.x_fc_adp_application_status)}"
- )
- else:
- raise UserError(
- "The application must be Approved before marking Ready for Delivery.\n"
- "To deliver before approval, check 'Early Delivery' first.\n"
- f"Current status: {dict(self._fields['x_fc_adp_application_status'].selection).get(self.x_fc_adp_application_status)}"
- )
-
- return {
- 'name': 'Schedule Delivery Task',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion.technician.task',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'default_task_type': 'delivery',
- 'default_sale_order_id': self.id,
- 'default_partner_id': self.partner_id.id,
- 'default_pod_required': True,
- 'mark_ready_for_delivery': True,
- },
- }
-
- @api.depends('x_fc_sale_type')
- def _compute_is_adp_sale(self):
- """Compute if this is an ADP sale - only ADP or ADP/ODSP sale types."""
- for order in self:
- order.x_fc_is_adp_sale = order._is_adp_sale()
-
- # ==========================================================================
- # SALE TYPE AND CLIENT TYPE FIELDS
- # ==========================================================================
- x_fc_sale_type = fields.Selection(
- selection=[
- ('adp', 'ADP'),
- ('adp_odsp', 'ADP/ODSP'),
- ('odsp', 'ODSP'),
- ('wsib', 'WSIB'),
- ('direct_private', 'Direct/Private'),
- ('insurance', 'Insurance'),
- ('march_of_dimes', 'March of Dimes'),
- ('muscular_dystrophy', 'Muscular Dystrophy'),
- ('other', 'Others'),
- ('rental', 'Rentals'),
- ('hardship', 'Hardship Funding'),
- ],
- string='Sale Type',
- index=True,
- copy=True,
- tracking=True,
- help='Type of sale for billing purposes. This field determines the workflow and billing rules.',
- )
-
- x_fc_sale_type_locked = fields.Boolean(
- string='Sale Type Locked',
- compute='_compute_sale_type_locked',
- help='Sale type is locked after application is submitted to ADP',
- )
-
- @api.depends('x_fc_adp_application_status')
- def _compute_sale_type_locked(self):
- """Sale type is locked once application is submitted to ADP."""
- locked_statuses = [
- 'submitted', 'accepted', 'rejected', 'resubmitted',
- 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
- ]
- for order in self:
- order.x_fc_sale_type_locked = order.x_fc_adp_application_status in locked_statuses
-
- x_fc_client_type = fields.Selection(
- selection=[
- ('REG', 'REG'),
- ('ODS', 'ODS'),
- ('OWP', 'OWP'),
- ('ACS', 'ACS'),
- ('LTC', 'LTC'),
- ('SEN', 'SEN'),
- ('CCA', 'CCA'),
- ],
- string='Client Type',
- tracking=True,
- help='Client type for ADP portion calculations. REG = 75%/25%, others = 100%/0%',
- )
-
- # Authorizer Required field - only for certain sale types
- # For: odsp, direct_private, insurance, other, rental
- x_fc_authorizer_required = fields.Selection(
- selection=[
- ('yes', 'Yes'),
- ('no', 'No'),
- ],
- string='Authorizer Required?',
- help='For ODSP, Direct/Private, Insurance, Others, and Rentals - specify if an authorizer is needed.',
- )
-
- # Computed field to determine if authorizer should be shown
- x_fc_show_authorizer = fields.Boolean(
- compute='_compute_show_authorizer',
- string='Show Authorizer',
- )
-
- @api.depends('x_fc_sale_type', 'x_fc_authorizer_required')
- def _compute_show_authorizer(self):
- """Compute whether to show the authorizer field based on sale type and authorizer_required."""
- # Sale types that require the "Authorizer Required?" question
- optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
- # Sale types where authorizer is always shown/required
- always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
-
- for order in self:
- sale_type = order.x_fc_sale_type
- if sale_type in always_auth_types:
- # Always show authorizer for ADP-related types
- order.x_fc_show_authorizer = True
- elif sale_type in optional_auth_types:
- # Show authorizer only if user selected "Yes"
- order.x_fc_show_authorizer = order.x_fc_authorizer_required == 'yes'
- else:
- # No sale type selected - don't show
- order.x_fc_show_authorizer = False
-
- # Computed field to determine if "Authorizer Required?" question should be shown
- x_fc_show_authorizer_question = fields.Boolean(
- compute='_compute_show_authorizer_question',
- string='Show Authorizer Question',
- )
-
- @api.depends('x_fc_sale_type')
- def _compute_show_authorizer_question(self):
- """Compute whether to show the 'Authorizer Required?' field."""
- optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
- for order in self:
- order.x_fc_show_authorizer_question = order.x_fc_sale_type in optional_auth_types
-
- # ==========================================================================
- # MARCH OF DIMES FIELDS
- # ==========================================================================
- x_fc_mod_status = fields.Selection(
- selection=[
- ('need_to_schedule', 'Schedule Assessment'),
- ('assessment_scheduled', 'Assessment Booked'),
- ('assessment_completed', 'Assessment Done'),
- ('processing_drawings', 'Processing Drawing'),
- ('quote_submitted', 'Quote Sent'),
- ('awaiting_funding', 'Awaiting Funding'),
- ('funding_approved', 'Approved'),
- ('funding_denied', 'Denied'),
- ('contract_received', 'PCA Received'),
- ('in_production', 'In Production'),
- ('project_complete', 'Complete'),
- ('pod_submitted', 'POD Sent'),
- ('case_closed', 'Closed'),
- ('on_hold', 'On Hold'),
- ('cancelled', 'Cancelled'),
- ],
- string='MOD Status',
- default='need_to_schedule',
- tracking=True,
- group_expand='_expand_mod_statuses',
- help='March of Dimes case workflow status',
- )
-
- @api.model
- def _expand_mod_statuses(self, states, domain):
- """Return the main MOD workflow statuses for kanban columns.
- Always shows core statuses; special statuses (funding_denied, on_hold,
- cancelled) only appear when records exist in them."""
- main = [
- 'need_to_schedule', 'assessment_scheduled', 'assessment_completed',
- 'processing_drawings', 'quote_submitted', 'awaiting_funding',
- 'funding_approved', 'contract_received', 'in_production',
- 'project_complete', 'pod_submitted', 'case_closed',
- ]
- result = list(main)
- for s in (states or []):
- if s and s not in result:
- result.append(s)
- return result
-
- # --- Case contacts (per-order MOD contacts) ---
- x_fc_case_handler = fields.Many2one(
- 'res.partner',
- string='MOD Case Handler',
- tracking=True,
- help='March of Dimes case handler / counsellor (e.g. Barrier Free Design Counsellor)',
- )
-
- x_fc_case_worker = fields.Many2one(
- 'res.partner',
- string='Case Worker',
- tracking=True,
- help='Case worker assigned to this order',
- )
-
- x_fc_mod_contact_name = fields.Char(
- string='MOD Contact Person',
- tracking=True,
- help='Legacy field - kept for backwards compatibility',
- )
- x_fc_mod_contact_email = fields.Char(
- string='Case Worker Email',
- tracking=True,
- help='Case worker email - assigned after funding approval. '
- 'Completion photos and POD are sent to this email.',
- )
- x_fc_mod_contact_phone = fields.Char(
- string='Case Worker Phone',
- tracking=True,
- )
-
- # --- Case identifiers ---
- x_fc_case_reference = fields.Char(
- string='HVMP Reference Number',
- tracking=True,
- help='March of Dimes HVMP Reference Number (e.g. HVW38845)',
- )
- x_fc_mod_vendor_code = fields.Char(
- string='MOD Vendor Code',
- tracking=True,
- help='Vendor code assigned by March of Dimes (e.g. TRD0001662)',
- )
-
- # --- Key dates ---
- x_fc_case_submitted = fields.Date(
- string='Quote Submitted Date',
- tracking=True,
- help='Legacy field - kept for backwards compatibility',
- )
- x_fc_case_approved = fields.Date(
- string='Funding Approved Date',
- tracking=True,
- help='Date funding was approved by March of Dimes',
- )
- x_fc_estimated_completion_date = fields.Date(
- string='Estimated Completion Date',
- tracking=True,
- help='Estimated project completion date. Auto-calculated from weeks if funding is approved.',
- )
- x_fc_mod_estimated_weeks = fields.Integer(
- string='Est. Completion (Weeks)',
- tracking=True,
- help='Estimated completion time in weeks from funding approval date.',
- )
-
- @api.onchange('x_fc_mod_estimated_weeks')
- def _onchange_mod_estimated_weeks(self):
- """When weeks change, compute the completion date from approval date."""
- if self.x_fc_mod_estimated_weeks and self.x_fc_mod_estimated_weeks > 0:
- from datetime import timedelta
- base = self.x_fc_case_approved or fields.Date.today()
- self.x_fc_estimated_completion_date = base + timedelta(weeks=self.x_fc_mod_estimated_weeks)
-
- @api.onchange('x_fc_estimated_completion_date')
- def _onchange_mod_estimated_completion_date(self):
- """When date changes, compute weeks from approval date."""
- if self.x_fc_estimated_completion_date:
- base = self.x_fc_case_approved or fields.Date.today()
- delta = self.x_fc_estimated_completion_date - base
- weeks = max(1, delta.days // 7)
- self.x_fc_mod_estimated_weeks = weeks
-
- # --- MOD Documents ---
- x_fc_mod_drawing = fields.Binary(
- string='Drawing',
- attachment=True,
- help='Technical drawing for the accessibility modification',
- )
- x_fc_mod_drawing_filename = fields.Char(string='Drawing Filename')
-
- x_fc_mod_initial_photos = fields.Binary(
- string='Initial Photos',
- attachment=True,
- help='Photos taken during the initial assessment',
- )
- x_fc_mod_initial_photos_filename = fields.Char(string='Initial Photos Filename')
-
- x_fc_mod_pca_document = fields.Binary(
- string='PCA Document',
- attachment=True,
- help='Payment Commitment Agreement from March of Dimes',
- )
- x_fc_mod_pca_filename = fields.Char(string='PCA Filename')
-
- x_fc_mod_proof_of_delivery = fields.Binary(
- string='Proof of Delivery',
- attachment=True,
- help='Signed proof of delivery and installation document',
- )
- x_fc_mod_pod_filename = fields.Char(string='POD Filename')
-
- x_fc_mod_initial_payment_amount = fields.Monetary(
- string='Initial Payment Amount',
- currency_field='currency_id',
- help='Amount received as initial payment from March of Dimes',
- )
- x_fc_mod_initial_payment_date = fields.Date(
- string='Initial Payment Date',
- help='Date the initial payment was received',
- )
- x_fc_mod_final_payment_amount = fields.Monetary(
- string='Final Payment Amount',
- currency_field='currency_id',
- help='Final payment amount received from March of Dimes',
- )
- x_fc_mod_final_payment_date = fields.Date(
- string='Final Payment Date',
- help='Date the final payment was received',
- )
-
- x_fc_mod_completion_photos = fields.Binary(
- string='Completion Photos',
- attachment=True,
- help='Photos of the completed installation',
- )
- x_fc_mod_completion_photos_filename = fields.Char(string='Completion Photos Filename')
-
- # Trail computed fields for MOD documents
- x_fc_mod_trail_has_drawing = fields.Boolean(compute='_compute_mod_trail', string='Has Drawing')
- x_fc_mod_trail_has_initial_photos = fields.Boolean(compute='_compute_mod_trail', string='Has Initial Photos')
- x_fc_mod_trail_has_pca = fields.Boolean(compute='_compute_mod_trail', string='Has PCA')
- x_fc_mod_trail_has_pod = fields.Boolean(compute='_compute_mod_trail', string='Has POD')
- x_fc_mod_trail_has_completion_photos = fields.Boolean(compute='_compute_mod_trail', string='Has Completion Photos')
-
- @api.depends('x_fc_mod_drawing', 'x_fc_mod_initial_photos', 'x_fc_mod_pca_document',
- 'x_fc_mod_proof_of_delivery', 'x_fc_mod_completion_photos')
- def _compute_mod_trail(self):
- for order in self:
- order.x_fc_mod_trail_has_drawing = bool(order.x_fc_mod_drawing)
- order.x_fc_mod_trail_has_initial_photos = bool(order.x_fc_mod_initial_photos)
- order.x_fc_mod_trail_has_pca = bool(order.x_fc_mod_pca_document)
- order.x_fc_mod_trail_has_pod = bool(order.x_fc_mod_proof_of_delivery)
- order.x_fc_mod_trail_has_completion_photos = bool(order.x_fc_mod_completion_photos)
-
- # --- PCA terms ---
- x_fc_mod_project_completion_date = fields.Date(
- string='PCA Completion Deadline',
- tracking=True,
- help='Project Completion Date as stated in the PCA',
- )
- x_fc_mod_payment_commitment = fields.Monetary(
- string='Payment Commitment',
- tracking=True,
- currency_field='currency_id',
- help='Legacy field - kept for backwards compatibility',
- )
-
- # --- MOD Funding ---
- x_fc_mod_approved_amount = fields.Monetary(
- string='MOD Approved Amount',
- currency_field='currency_id',
- tracking=True,
- help='Amount approved by March of Dimes',
- )
- x_fc_mod_approval_type = fields.Selection(
- selection=[('full', 'Full Approval'), ('partial', 'Partial Approval')],
- string='Approval Type',
- tracking=True,
- )
-
- # --- Product type and production stage ---
- x_fc_mod_product_type = fields.Selection(
- selection=[
- ('stairlift', 'Stairlift'),
- ('vpl', 'Vertical Platform Lift / Porch Lift'),
- ('ceiling_lift', 'Ceiling Lift'),
- ('ramp', 'Custom Ramp'),
- ('bathroom', 'Bathroom Modification'),
- ('other', 'Other'),
- ],
- string='Product Type',
- tracking=True,
- help='Type of accessibility product/modification for this project',
- )
-
- x_fc_mod_production_status = fields.Selection(
- selection=[
- # --- Stairlift stages ---
- ('sl_photo_survey_booked', 'Photo Survey Booked'),
- ('sl_photo_survey_done', 'Photo Survey Completed'),
- ('sl_sent_to_engineering', 'Sent to Engineering'),
- ('sl_engineering_received', 'Engineering Drawing Received'),
- ('sl_drawing_signing', 'Drawing Signing & Acceptance'),
- ('sl_in_production', 'Stairlift in Production'),
- ('sl_payment_processing', 'Payment Processing for Manufacturer'),
- ('sl_shipping', 'Stairlift Shipping'),
- ('sl_received', 'Stairlift Received'),
- ('sl_install_scheduled', 'Installation Scheduled'),
- ('sl_install_complete', 'Installation Complete'),
- # --- VPL / Porch Lift stages ---
- ('vpl_survey_complete', 'Final Survey & Marking Complete'),
- ('vpl_lift_ordered', 'Lift Ordered'),
- ('vpl_concrete_poured', 'Concrete Base Poured'),
- ('vpl_concrete_curing', 'Concrete Curing'),
- ('vpl_install_complete', 'Lift & Safety Gate Installed'),
- # --- Ceiling Lift stages ---
- ('cl_marking_done', 'Lift Marking Completed'),
- ('cl_anchors_installed', 'Anchors Installed'),
- ('cl_curing', 'Epoxy Curing (24 hrs)'),
- ('cl_track_installed', 'Track & Lift Installed'),
- ('cl_safety_tested', 'Safety & Deflection Tests Passed'),
- ('cl_ready_for_use', 'Ready for Use'),
- # --- Ramp stages ---
- ('rp_permit_check', 'Checking Municipality Permit'),
- ('rp_permit_obtained', 'Permit Obtained'),
- ('rp_ordered', 'Ramp Ordered'),
- ('rp_received', 'Ramp Received in Warehouse'),
- ('rp_install_scheduled', 'Installation Scheduled'),
- ('rp_install_complete', 'Installation Complete'),
- # --- Bathroom Modification stages ---
- ('br_demolition', 'Demolition of Existing Bathroom'),
- ('br_design_changes', 'Final Design Changes Discussed'),
- ('br_construction', 'Construction in Progress'),
- ('br_construction_done', 'Construction Finished'),
- ('br_safety_check', 'Safety Check Complete'),
- ('br_ready_for_use', 'Ready for Use'),
- # --- Common ---
- ('completed', 'Stage Completed'),
- ('on_hold', 'On Hold'),
- ],
- string='Production Stage',
- tracking=True,
- help='Detailed production/installation stage for the product',
- )
-
- # --- Follow-up tracking ---
- x_fc_mod_last_followup_date = fields.Date(
- string='Last Follow-up Date',
- help='Date of the last follow-up call or email',
- )
- x_fc_mod_next_followup_date = fields.Date(
- string='Next Follow-up Date',
- help='Scheduled date for the next follow-up',
- )
- x_fc_mod_followup_count = fields.Integer(
- string='Follow-up Count',
- default=0,
- help='Number of follow-up attempts made',
- )
- x_fc_mod_followup_escalated = fields.Boolean(
- string='Follow-up Escalated',
- default=False,
- help='True if an automatic follow-up email was sent because activity was not completed',
- )
-
- # --- MOD Audit Trail dates ---
- x_fc_mod_assessment_scheduled_date = fields.Date(string='Assessment Scheduled', tracking=True)
- x_fc_mod_assessment_completed_date = fields.Date(string='Assessment Completed', tracking=True)
- x_fc_mod_drawing_submitted_date = fields.Date(string='Drawing Submitted', tracking=True)
- x_fc_mod_application_submitted_date = fields.Date(
- string='Application Submitted to MOD',
- tracking=True,
- help='Date the application/proposal was submitted to March of Dimes for funding review',
- )
- x_fc_mod_pca_received_date = fields.Date(string='PCA Received', tracking=True)
- x_fc_mod_production_started_date = fields.Date(string='Production Started', tracking=True)
- x_fc_mod_project_completed_date = fields.Date(string='Project Completed', tracking=True)
- x_fc_mod_pod_submitted_date = fields.Date(string='POD Submitted', tracking=True)
- x_fc_mod_case_closed_date = fields.Date(string='Case Closed', tracking=True)
-
- # Trail computed booleans
- x_fc_mod_trail_assessment_done = fields.Boolean(compute='_compute_mod_audit_trail')
- x_fc_mod_trail_drawing_done = fields.Boolean(compute='_compute_mod_audit_trail')
- x_fc_mod_trail_app_submitted = fields.Boolean(compute='_compute_mod_audit_trail')
- x_fc_mod_trail_funding_approved = fields.Boolean(compute='_compute_mod_audit_trail')
- x_fc_mod_trail_pca_received = fields.Boolean(compute='_compute_mod_audit_trail')
- x_fc_mod_trail_production_started = fields.Boolean(compute='_compute_mod_audit_trail')
- x_fc_mod_trail_project_completed = fields.Boolean(compute='_compute_mod_audit_trail')
- x_fc_mod_trail_pod_sent = fields.Boolean(compute='_compute_mod_audit_trail')
- x_fc_mod_trail_case_closed = fields.Boolean(compute='_compute_mod_audit_trail')
-
- @api.depends('x_fc_mod_assessment_completed_date', 'x_fc_mod_drawing_submitted_date',
- 'x_fc_mod_application_submitted_date', 'x_fc_case_approved',
- 'x_fc_mod_pca_received_date', 'x_fc_mod_production_started_date',
- 'x_fc_mod_project_completed_date', 'x_fc_mod_pod_submitted_date',
- 'x_fc_mod_case_closed_date')
- def _compute_mod_audit_trail(self):
- for order in self:
- order.x_fc_mod_trail_assessment_done = bool(order.x_fc_mod_assessment_completed_date)
- order.x_fc_mod_trail_drawing_done = bool(order.x_fc_mod_drawing_submitted_date)
- order.x_fc_mod_trail_app_submitted = bool(order.x_fc_mod_application_submitted_date)
- order.x_fc_mod_trail_funding_approved = bool(order.x_fc_case_approved)
- order.x_fc_mod_trail_pca_received = bool(order.x_fc_mod_pca_received_date)
- order.x_fc_mod_trail_production_started = bool(order.x_fc_mod_production_started_date)
- order.x_fc_mod_trail_project_completed = bool(order.x_fc_mod_project_completed_date)
- order.x_fc_mod_trail_pod_sent = bool(order.x_fc_mod_pod_submitted_date)
- order.x_fc_mod_trail_case_closed = bool(order.x_fc_mod_case_closed_date)
-
- # --- Computed helpers ---
- x_fc_show_mod_fields = fields.Boolean(
- compute='_compute_show_mod_fields',
- string='Show MOD Fields',
- )
- x_fc_is_mod_sale = fields.Boolean(
- compute='_compute_is_mod_sale',
- string='Is MOD Sale',
- )
-
- @api.depends('x_fc_sale_type')
- def _compute_show_mod_fields(self):
- """Compute whether to show March of Dimes case fields."""
- for order in self:
- order.x_fc_show_mod_fields = order.x_fc_sale_type == 'march_of_dimes'
-
- @api.depends('x_fc_sale_type')
- def _compute_is_mod_sale(self):
- """Compute if this is a March of Dimes sale."""
- for order in self:
- order.x_fc_is_mod_sale = order.x_fc_sale_type == 'march_of_dimes'
-
- def _is_mod_sale(self):
- """Helper: check if this order is a March of Dimes sale."""
- self.ensure_one()
- return self.x_fc_sale_type == 'march_of_dimes'
-
- # ==========================================================================
- # ODSP (Ontario Disability Support Program) FIELDS
- # ==========================================================================
- x_fc_odsp_division = fields.Selection(
- selection=[
- ('standard', 'ODSP Standard'),
- ('sa_mobility', 'SA Mobility'),
- ('ontario_works', 'Ontario Works'),
- ],
- string='ODSP Division',
- tracking=True,
- help='ODSP sub-division handling this case',
- )
- x_fc_is_odsp_sale = fields.Boolean(
- compute='_compute_is_odsp_sale',
- store=True,
- string='Is ODSP Sale',
- help='True when sale type is ODSP or ADP-ODSP',
- )
- x_fc_odsp_member_id = fields.Char(
- related='partner_id.x_fc_odsp_member_id',
- string='ODSP Member ID',
- readonly=False,
- store=True,
- help='ODSP Member ID from contact (editable per order)',
- )
- x_fc_odsp_office_id = fields.Many2one(
- 'res.partner',
- string='ODSP Office',
- tracking=True,
- domain="[('x_fc_contact_type', '=', 'odsp_office')]",
- help='ODSP office handling this case',
- )
- x_fc_odsp_case_worker_name = fields.Char(
- string='ODSP Case Worker',
- tracking=True,
- help='Case worker name for this order',
- )
-
- # --- SA Mobility status ---
- x_fc_sa_status = fields.Selection(
- selection=[
- ('quotation', 'Quotation'),
- ('form_ready', 'SA Form Ready'),
- ('submitted_to_sa', 'Submitted to SA Mobility'),
- ('pre_approved', 'Pre-Approved'),
- ('ready_delivery', 'Ready for Delivery'),
- ('delivered', 'Delivered'),
- ('pod_submitted', 'POD Submitted'),
- ('payment_received', 'Payment Received'),
- ('case_closed', 'Case Closed'),
- ('on_hold', 'On Hold'),
- ('cancelled', 'Cancelled'),
- ('denied', 'Denied'),
- ],
- string='SA Mobility Status',
- default='quotation',
- tracking=True,
- group_expand='_expand_sa_statuses',
- )
-
- @api.model
- def _expand_sa_statuses(self, states, domain):
- main = [
- 'quotation', 'form_ready', 'submitted_to_sa',
- 'pre_approved', 'ready_delivery', 'delivered',
- 'pod_submitted', 'payment_received', 'case_closed',
- ]
- result = list(main)
- for s in (states or []):
- if s and s not in result:
- result.append(s)
- return result
-
- # --- Standard ODSP status ---
- x_fc_odsp_std_status = fields.Selection(
- selection=[
- ('quotation', 'Quotation'),
- ('submitted_to_odsp', 'Submitted to ODSP'),
- ('pre_approved', 'Pre-Approved'),
- ('ready_delivery', 'Ready for Delivery'),
- ('delivered', 'Delivered'),
- ('pod_submitted', 'POD Submitted'),
- ('payment_received', 'Payment Received'),
- ('case_closed', 'Case Closed'),
- ('on_hold', 'On Hold'),
- ('cancelled', 'Cancelled'),
- ('denied', 'Denied'),
- ],
- string='ODSP Status',
- default='quotation',
- tracking=True,
- group_expand='_expand_odsp_std_statuses',
- )
-
- @api.model
- def _expand_odsp_std_statuses(self, states, domain):
- main = [
- 'quotation', 'submitted_to_odsp',
- 'pre_approved', 'ready_delivery', 'delivered',
- 'pod_submitted', 'payment_received', 'case_closed',
- ]
- result = list(main)
- for s in (states or []):
- if s and s not in result:
- result.append(s)
- return result
-
- # --- Ontario Works status ---
- x_fc_ow_status = fields.Selection(
- selection=[
- ('quotation', 'Quotation'),
- ('documents_ready', 'Documents Ready'),
- ('submitted_to_ow', 'Submitted to Ontario Works'),
- ('payment_received', 'Payment Received'),
- ('ready_delivery', 'Ready for Delivery'),
- ('delivered', 'Delivered'),
- ('case_closed', 'Case Closed'),
- ('on_hold', 'On Hold'),
- ('cancelled', 'Cancelled'),
- ('denied', 'Denied'),
- ],
- string='Ontario Works Status',
- default='quotation',
- tracking=True,
- group_expand='_expand_ow_statuses',
- )
-
- @api.model
- def _expand_ow_statuses(self, states, domain):
- main = [
- 'quotation', 'documents_ready', 'submitted_to_ow',
- 'payment_received', 'ready_delivery', 'delivered',
- 'case_closed',
- ]
- result = list(main)
- for s in (states or []):
- if s and s not in result:
- result.append(s)
- return result
-
- # --- Division-to-status field mapping ---
- _ODSP_STATUS_FIELD_MAP = {
- 'sa_mobility': 'x_fc_sa_status',
- 'standard': 'x_fc_odsp_std_status',
- 'ontario_works': 'x_fc_ow_status',
- }
-
- def _get_odsp_status_field(self):
- """Return the status field name for this order's division."""
- self.ensure_one()
- return self._ODSP_STATUS_FIELD_MAP.get(
- self.x_fc_odsp_division, 'x_fc_odsp_std_status')
-
- def _get_odsp_status(self):
- """Return the current division-specific status value."""
- self.ensure_one()
- return getattr(self, self._get_odsp_status_field(), '') or ''
-
- # --- ODSP computed helpers ---
- x_fc_show_odsp_fields = fields.Boolean(
- compute='_compute_show_odsp_fields',
- string='Show ODSP Fields',
- )
-
- @api.depends('x_fc_sale_type')
- def _compute_is_odsp_sale(self):
- """Compute if this is an ODSP sale."""
- for order in self:
- order.x_fc_is_odsp_sale = order.x_fc_sale_type in ('odsp', 'adp_odsp')
-
- @api.depends('x_fc_sale_type')
- def _compute_show_odsp_fields(self):
- """Compute whether to show ODSP case fields."""
- for order in self:
- order.x_fc_show_odsp_fields = order.x_fc_sale_type in ('odsp', 'adp_odsp')
-
- def _is_odsp_sale(self):
- """Helper: check if this order is an ODSP sale."""
- self.ensure_one()
- return self.x_fc_sale_type in ('odsp', 'adp_odsp')
-
- @api.onchange('partner_id')
- def _onchange_partner_odsp_case_worker(self):
- """Auto-populate ODSP case worker from partner when partner changes."""
- if self.partner_id and self.partner_id.x_fc_case_worker_id:
- self.x_fc_odsp_case_worker_name = self.partner_id.x_fc_case_worker_id.name
-
- # --- SA Mobility form data (persisted for wizard reuse) ---
- x_fc_sa_relationship = fields.Selection([
- ('self', 'Self'), ('spouse', 'Spouse'), ('dependent', 'Dependent'),
- ], string='SA Relationship', default='self')
- x_fc_sa_device_type = fields.Selection([
- ('manual_wheelchair', 'Manual Wheelchair'),
- ('high_tech_wheelchair', 'High Technology Wheelchair'),
- ('mobility_scooter', 'Mobility Scooter'),
- ('walker', 'Walker'),
- ('lifting_device', 'Lifting Device'),
- ('other', 'Other'),
- ], string='SA Device Type')
- x_fc_sa_device_other = fields.Char(string='SA Device Other Description')
- x_fc_sa_serial_number = fields.Char(string='SA Serial Number')
- x_fc_sa_year = fields.Char(string='SA Year')
- x_fc_sa_make = fields.Char(string='SA Make')
- x_fc_sa_model = fields.Char(string='SA Model')
- x_fc_sa_warranty = fields.Boolean(string='SA Warranty in Effect')
- x_fc_sa_warranty_desc = fields.Char(string='SA Warranty Description')
- x_fc_sa_after_hours = fields.Boolean(string='SA After-hours Work')
- x_fc_sa_request_type = fields.Selection([
- ('batteries', 'Batteries'), ('repair', 'Repair / Maintenance'),
- ], string='SA Request Type', default='repair')
- x_fc_sa_notes = fields.Text(string='SA Notes / Comments')
-
- # --- SA Mobility signature fields ---
- x_fc_sa_client_name = fields.Char(
- string='SA Client Name (Printed)',
- help='Client printed name on SA Mobility form Page 2',
- )
- x_fc_sa_client_signature = fields.Binary(
- string='SA Client Signature',
- help='Client signature image on SA Mobility form Page 2',
- )
- x_fc_sa_client_signed_date = fields.Date(
- string='SA Signed Date',
- )
- x_fc_sa_signed_form = fields.Binary(
- string='SA Signed Form',
- help='Final signed SA Mobility PDF',
- )
- x_fc_sa_signed_form_filename = fields.Char(
- string='SA Signed Form Filename',
- )
- x_fc_sa_physical_signed_copy = fields.Binary(
- string='Physical Signed Copy',
- attachment=True,
- help='Upload a scanned/photographed copy of the physically signed SA Mobility form. '
- 'Use this when the client signs a paper copy instead of the digital e-signature.',
- )
- x_fc_sa_physical_signed_copy_filename = fields.Char(
- string='Physical Copy Filename',
- )
- # --- SA Mobility approval form fields ---
- x_fc_sa_approval_form = fields.Binary(
- string='SA Approval Form',
- help='ODSP approval PDF uploaded during pre-approval',
- )
- x_fc_sa_approval_form_filename = fields.Char(
- string='SA Approval Form Filename',
- )
- x_fc_sa_signature_page = fields.Integer(
- string='Signature Page',
- default=2,
- help='Page number in approval form where signature should be placed (1-indexed)',
- )
-
- # --- Ontario Works document fields ---
- x_fc_ow_discretionary_form = fields.Binary(
- string='Discretionary Benefits Form',
- attachment=True,
- help='Auto-populated when the Discretionary Benefits form is generated via wizard.',
- )
- x_fc_ow_discretionary_form_filename = fields.Char(
- string='Discretionary Form Filename',
- )
- x_fc_ow_authorizer_letter = fields.Binary(
- string='Authorizer Letter',
- attachment=True,
- help='Optional authorizer letter for this Ontario Works case.',
- )
- x_fc_ow_authorizer_letter_filename = fields.Char(
- string='Authorizer Letter Filename',
- )
-
- # --- Standard ODSP document fields ---
- x_fc_odsp_approval_document = fields.Binary(
- string='ODSP Approval Document',
- attachment=True,
- help='Upload the approval document received from ODSP.',
- )
- x_fc_odsp_approval_document_filename = fields.Char(
- string='Approval Document Filename',
- )
- x_fc_odsp_authorizer_letter = fields.Binary(
- string='Authorizer Letter',
- attachment=True,
- help='Optional authorizer letter for this ODSP case.',
- )
- x_fc_odsp_authorizer_letter_filename = fields.Char(
- string='Authorizer Letter Filename',
- )
-
- def action_open_sa_mobility_wizard(self):
- """Open the SA Mobility form filling wizard."""
- self.ensure_one()
- return {
- 'name': 'SA Mobility Form',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.sa.mobility.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {'active_id': self.id},
- }
-
- def action_open_discretionary_wizard(self):
- """Open the Discretionary Benefits form filling wizard."""
- self.ensure_one()
- return {
- 'name': 'Discretionary Benefits Form',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.discretionary.benefit.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {'active_id': self.id},
- }
-
- def action_open_submit_to_odsp_wizard(self):
- """Open the Submit to ODSP wizard (quotation + authorizer letter)."""
- self.ensure_one()
- return {
- 'name': 'Submit to ODSP',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.submit.to.odsp.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {'active_id': self.id},
- }
-
- # --- ODSP workflow step actions ---
-
- def _odsp_advance_status(self, new_status, log_message):
- """Advance the division-specific ODSP status and log to chatter."""
- self.ensure_one()
- field = self._get_odsp_status_field()
- setattr(self, field, new_status)
- self.message_post(body=log_message, message_type='comment')
-
- def action_odsp_submitted(self):
- self.ensure_one()
- self._odsp_advance_status('submitted_to_odsp', "Application submitted to ODSP.")
-
- def action_odsp_submitted_ow(self):
- self.ensure_one()
- self._odsp_advance_status('submitted_to_ow', "Application submitted to Ontario Works.")
-
- def action_odsp_pre_approved(self):
- self.ensure_one()
- if self.x_fc_odsp_division in ('sa_mobility', 'standard'):
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'Upload ODSP Approval Document',
- 'res_model': 'fusion_claims.odsp.pre.approved.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {'active_id': self.id},
- }
- self._odsp_advance_status('pre_approved', "ODSP pre-approval received.")
-
- def action_odsp_ready_delivery(self):
- self.ensure_one()
- if self.x_fc_odsp_division == 'sa_mobility' and self.x_fc_sa_approval_form:
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'Ready for Delivery - Signature Setup',
- 'res_model': 'fusion_claims.odsp.ready.delivery.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {'active_id': self.id},
- }
- if self.x_fc_odsp_division in ('ontario_works', 'standard'):
- return {
- 'name': 'Schedule Delivery Task',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion.technician.task',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'default_task_type': 'delivery',
- 'default_sale_order_id': self.id,
- 'default_partner_id': self.partner_id.id,
- 'default_pod_required': True,
- 'mark_odsp_ready_for_delivery': True,
- },
- }
- self._odsp_advance_status('ready_delivery', "Order is ready for delivery.")
-
- def action_odsp_delivered(self):
- self.ensure_one()
- if self.x_fc_odsp_division == 'sa_mobility':
- has_signed_form = self.x_fc_sa_signed_form or self.x_fc_sa_physical_signed_copy
- if has_signed_form:
- self._odsp_advance_status('delivered',
- "Delivery completed. SA form is signed.")
- else:
- self._odsp_advance_status('delivered', "Delivery completed.")
- else:
- self._odsp_advance_status('delivered', "Delivery completed.")
-
- def action_odsp_pod_submitted(self):
- self.ensure_one()
- if self.x_fc_odsp_division == 'sa_mobility':
- self._sa_mobility_submit_documents()
- elif self.x_fc_odsp_division == 'standard':
- self._odsp_std_submit_documents()
- self._odsp_advance_status('pod_submitted', "Proof of Delivery submitted to ODSP.")
-
- def _sa_mobility_submit_documents(self):
- """Collect signed SA form, internal POD, and invoice, then email to SA Mobility."""
- self.ensure_one()
- import base64
- Attachment = self.env['ir.attachment'].sudo()
- att_ids = []
- att_names = []
-
- # 1. Signed SA Form -- reuse existing attachment created by attachment=True
- signed_field = 'x_fc_sa_signed_form' if self.x_fc_sa_signed_form else (
- 'x_fc_sa_physical_signed_copy' if self.x_fc_sa_physical_signed_copy else None)
- if signed_field:
- att = Attachment.search([
- ('res_model', '=', 'sale.order'),
- ('res_id', '=', self.id),
- ('res_field', '=', signed_field),
- ], order='id desc', limit=1)
- if att:
- att_ids.append(att.id)
- att_names.append('Signed SA Form')
-
- # 2. Internal POD -- generate on-the-fly from the standard report
- try:
- pod_pdf, pod_fname = self._get_sa_pod_pdf()
- att = Attachment.create({
- 'name': pod_fname,
- 'type': 'binary',
- 'datas': base64.b64encode(pod_pdf),
- 'res_model': 'sale.order',
- 'res_id': self.id,
- })
- att_ids.append(att.id)
- att_names.append('Proof of Delivery')
- except Exception as e:
- _logger.warning("Could not generate POD PDF for %s: %s", self.name, e)
-
- # 3. Invoice PDF -- generate from the latest posted invoice
- invoices = self.invoice_ids.filtered(lambda inv: inv.state == 'posted')
- if invoices:
- invoice = invoices[0]
- try:
- report = self.env.ref('account.account_invoices')
- pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
- att = Attachment.create({
- 'name': f'Invoice_{invoice.name}.pdf',
- 'type': 'binary',
- 'datas': base64.b64encode(pdf_content),
- 'res_model': 'sale.order',
- 'res_id': self.id,
- })
- att_ids.append(att.id)
- att_names.append(f'Invoice ({invoice.name})')
- except Exception as e:
- _logger.warning("Could not generate invoice PDF for %s: %s", self.name, e)
-
- self._send_sa_mobility_completion_email(attachment_ids=att_ids)
-
- if att_names:
- self.message_post(
- body=Markup(
- '
'
- 'Documents submitted to SA Mobility'
- '
'
- + ''.join(f'
{n}
' for n in att_names)
- + '
'
- ),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- def _odsp_std_submit_documents(self):
- """Standard ODSP: collect approval doc, POD, and invoice, then email to ODSP office."""
- self.ensure_one()
- import base64
- Attachment = self.env['ir.attachment'].sudo()
- att_ids = []
- att_names = []
-
- # 1. Approval document
- if self.x_fc_odsp_approval_document:
- att = Attachment.search([
- ('res_model', '=', 'sale.order'),
- ('res_id', '=', self.id),
- ('res_field', '=', 'x_fc_odsp_approval_document'),
- ], order='id desc', limit=1)
- if att:
- att_ids.append(att.id)
- att_names.append('ODSP Approval Document')
-
- # 2. Internal POD
- try:
- pod_pdf, pod_fname = self._get_sa_pod_pdf()
- att = Attachment.create({
- 'name': pod_fname,
- 'type': 'binary',
- 'datas': base64.b64encode(pod_pdf),
- 'res_model': 'sale.order',
- 'res_id': self.id,
- })
- att_ids.append(att.id)
- att_names.append('Proof of Delivery')
- except Exception as e:
- _logger.warning("Could not generate POD PDF for %s: %s", self.name, e)
-
- # 3. Invoice PDF
- invoices = self.invoice_ids.filtered(lambda inv: inv.state == 'posted')
- if not invoices:
- if self.state != 'sale':
- self.action_confirm()
- invoices = self._create_invoices()
- invoices.write({'x_fc_source_sale_order_id': self.id})
- if invoices:
- invoice = invoices[0]
- try:
- report = self.env.ref('account.account_invoices')
- pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
- att = Attachment.create({
- 'name': f'Invoice_{invoice.name}.pdf',
- 'type': 'binary',
- 'datas': base64.b64encode(pdf_content),
- 'res_model': 'sale.order',
- 'res_id': self.id,
- })
- att_ids.append(att.id)
- att_names.append(f'Invoice ({invoice.name})')
- except Exception as e:
- _logger.warning("Could not generate invoice PDF for %s: %s", self.name, e)
-
- self._send_odsp_submission_email(attachment_ids=att_ids)
-
- if att_names:
- self.message_post(
- body=Markup(
- '
'
- 'Documents submitted to ODSP'
- '
'
- + ''.join(f'
{n}
' for n in att_names)
- + '
'
- ),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- def action_odsp_payment_received(self):
- self.ensure_one()
- if self.x_fc_odsp_division == 'ontario_works':
- return self._ow_payment_create_invoice()
- self._odsp_advance_status('payment_received', "Payment received from ODSP.")
-
- def _ow_payment_create_invoice(self):
- """Ontario Works: create invoice from SO, advance status, open invoice."""
- self.ensure_one()
- if self.state != 'sale':
- self.action_confirm()
-
- invoice = self._create_invoices()
- invoice.write({'x_fc_source_sale_order_id': self.id})
-
- self._odsp_advance_status('payment_received',
- "Ontario Works payment confirmed. Invoice %s created." % invoice.name)
-
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'Invoice',
- 'res_model': 'account.move',
- 'view_mode': 'form',
- 'res_id': invoice.id,
- 'target': 'current',
- }
-
- def action_odsp_close_case(self):
- self.ensure_one()
- self._odsp_advance_status('case_closed', "ODSP case closed.")
-
- def action_odsp_on_hold(self):
- self.ensure_one()
- self._odsp_advance_status('on_hold', "ODSP case placed on hold.")
-
- def action_odsp_resume(self):
- """Resume from on_hold -- go back to the previous logical status."""
- self.ensure_one()
- self._odsp_advance_status('quotation', "ODSP case resumed.")
-
- def action_odsp_denied(self):
- self.ensure_one()
- self._odsp_advance_status('denied', "ODSP application denied.")
-
- def action_sign_sa_mobility_form(self):
- """Overlay client signature onto Page 2 of the approved SA Mobility form.
-
- Uses the PDFTemplateFiller overlay approach:
- - Reads the last attached SA Mobility form
- - Overlays client printed name, signature image, and date on Page 2
- - Stores result in x_fc_sa_signed_form
- """
- self.ensure_one()
- if not self.x_fc_sa_client_signature:
- from odoo.exceptions import UserError
- raise UserError("Client signature is required to sign the SA Mobility form.")
-
- # Find the most recent SA Mobility form attachment
- attachment = self.env['ir.attachment'].search([
- ('res_model', '=', 'sale.order'),
- ('res_id', '=', self.id),
- ('name', 'like', 'SA_Mobility_Form_'),
- ], order='create_date desc', limit=1)
-
- if not attachment:
- from odoo.exceptions import UserError
- raise UserError("No SA Mobility form found. Please fill the form first.")
-
- import base64
- from io import BytesIO
- try:
- from reportlab.pdfgen import canvas as rl_canvas
- from reportlab.lib.utils import ImageReader
- from odoo.tools.pdf import PdfFileReader, PdfFileWriter
- except ImportError:
- from odoo.exceptions import UserError
- raise UserError("Required PDF libraries not available.")
-
- # Read the existing filled form
- pdf_bytes = base64.b64decode(attachment.datas)
- original = PdfFileReader(BytesIO(pdf_bytes))
- output = PdfFileWriter()
- num_pages = original.getNumPages()
-
- for page_idx in range(num_pages):
- page = original.getPage(page_idx)
-
- if page_idx == 1: # Page 2 (0-based index)
- page_w = float(page.mediaBox.getWidth())
- page_h = float(page.mediaBox.getHeight())
-
- overlay_buf = BytesIO()
- c = rl_canvas.Canvas(overlay_buf, pagesize=(page_w, page_h))
-
- # Text103 area - client printed name (upper confirmation line)
- if self.x_fc_sa_client_name:
- c.setFont('Helvetica', 11)
- c.drawString(180, page_h - 180, self.x_fc_sa_client_name)
-
- # Text104 area - printed name on lower line
- if self.x_fc_sa_client_name:
- c.setFont('Helvetica', 11)
- c.drawString(72, page_h - 560, self.x_fc_sa_client_name)
-
- # Text105 area - date
- if self.x_fc_sa_client_signed_date:
- from odoo import fields as odoo_fields
- date_str = odoo_fields.Date.to_string(self.x_fc_sa_client_signed_date)
- c.setFont('Helvetica', 11)
- c.drawString(350, page_h - 560, date_str)
-
- # Signature image overlay on the signature line
- if self.x_fc_sa_client_signature:
- sig_data = base64.b64decode(self.x_fc_sa_client_signature)
- sig_image = ImageReader(BytesIO(sig_data))
- c.drawImage(sig_image, 72, page_h - 540, width=200, height=50,
- preserveAspectRatio=True, mask='auto')
-
- c.save()
- overlay_buf.seek(0)
- overlay_pdf = PdfFileReader(overlay_buf)
- page.mergePage(overlay_pdf.getPage(0))
-
- output.addPage(page)
-
- result_buf = BytesIO()
- output.write(result_buf)
- signed_pdf = result_buf.getvalue()
-
- filename = f'SA_Mobility_Signed_{self.name}.pdf'
- self.write({
- 'x_fc_sa_signed_form': base64.b64encode(signed_pdf),
- 'x_fc_sa_signed_form_filename': filename,
- })
-
- self.message_post(
- body="SA Mobility form signed by client: %s" % self.x_fc_sa_client_name,
- message_type='comment',
- )
-
- return {'type': 'ir.actions.act_window_close'}
-
- def _apply_pod_signature_to_approval_form(self):
- """Auto-overlay POD signature onto the ODSP approval form.
-
- Uses the ODSP PDF Template (fusion.pdf.template, category=odsp) for
- field positions, and the per-case signature page number.
- """
- self.ensure_one()
- if not all([
- self.x_fc_odsp_division == 'sa_mobility',
- self.x_fc_sa_approval_form,
- self.x_fc_sa_signature_page,
- self.x_fc_pod_signature,
- ]):
- return
-
- import base64
- from odoo.addons.fusion_authorizer_portal.utils.pdf_filler import PDFTemplateFiller
-
- tpl = self.env['fusion.pdf.template'].search([
- ('category', '=', 'odsp'), ('state', '=', 'active'),
- ], limit=1)
- if not tpl:
- _logger.warning("No active ODSP PDF template found for signing %s", self.name)
- return
-
- sig_page = self.x_fc_sa_signature_page or 2
-
- fields_by_page = {}
- for field in tpl.field_ids.filtered(lambda f: f.is_active):
- page = sig_page
- if page not in fields_by_page:
- fields_by_page[page] = []
- fields_by_page[page].append({
- 'field_name': field.name,
- 'field_key': field.field_key or field.name,
- 'pos_x': field.pos_x,
- 'pos_y': field.pos_y,
- 'width': field.width,
- 'height': field.height,
- 'field_type': field.field_type,
- 'font_size': field.font_size,
- 'font_name': field.font_name or 'Helvetica',
- 'text_align': field.text_align or 'left',
- })
-
- client_name = self.x_fc_pod_client_name or self.x_fc_sa_client_name or self.partner_id.name or ''
- sign_date = self.x_fc_pod_signature_date or self.x_fc_sa_client_signed_date
- context_data = {
- 'sa_client_name': client_name,
- 'sa_sign_date': sign_date.strftime('%b %d, %Y') if sign_date else '',
- }
- signatures = {
- 'sa_signature': base64.b64decode(self.x_fc_pod_signature),
- }
-
- pdf_bytes = base64.b64decode(self.x_fc_sa_approval_form)
- try:
- signed_pdf = PDFTemplateFiller.fill_template(
- pdf_bytes, fields_by_page, context_data, signatures,
- )
- except Exception as e:
- _logger.error("Failed to apply signature to approval form for %s: %s", self.name, e)
- return
-
- filename = f'SA_Approval_Signed_{self.name}.pdf'
- self.with_context(skip_pod_signature_hook=True).write({
- 'x_fc_sa_signed_form': base64.b64encode(signed_pdf),
- 'x_fc_sa_signed_form_filename': filename,
- })
-
- att = self.env['ir.attachment'].create({
- 'name': filename,
- 'type': 'binary',
- 'datas': base64.b64encode(signed_pdf),
- 'res_model': 'sale.order',
- 'res_id': self.id,
- 'mimetype': 'application/pdf',
- })
- self.message_post(
- body="POD signature applied to ODSP approval form (page %s)." % sig_page,
- message_type='comment',
- attachment_ids=[att.id],
- )
- _logger.info("POD signature applied to approval form for %s", self.name)
-
- # ==========================================================================
- # DELIVERY STATUS FIELDS
- # ==========================================================================
- x_fc_delivery_status = fields.Selection(
- selection=[
- ('waiting', 'Waiting'),
- ('waiting_approval', 'Waiting for Approval'),
- ('ready', 'Ready for Delivery'),
- ('scheduled', 'Delivery Scheduled'),
- ('shipped_warehouse', 'Shipped to Warehouse'),
- ('received_warehouse', 'Received in Warehouse'),
- ('delivered', 'Delivered'),
- ('hold', 'Hold'),
- ('cancelled', 'Cancelled'),
- ],
- string='Delivery Status',
- tracking=True,
- help='Current delivery status of the order',
- )
-
- x_fc_delivery_datetime = fields.Datetime(
- string='Delivery Date & Time',
- tracking=True,
- help='Scheduled or actual delivery date and time',
- )
-
- # Computed field to show/hide delivery datetime
- x_fc_show_delivery_datetime = fields.Boolean(
- compute='_compute_show_delivery_datetime',
- string='Show Delivery DateTime',
- )
-
- @api.depends('x_fc_delivery_status')
- def _compute_show_delivery_datetime(self):
- """Compute whether to show delivery datetime field."""
- for order in self:
- order.x_fc_show_delivery_datetime = order.x_fc_delivery_status in ('scheduled', 'delivered')
-
- # ==========================================================================
- # ADP CLAIM FIELDS
- # ==========================================================================
- x_fc_claim_number = fields.Char(
- string='Claim Number',
- tracking=True,
- copy=False,
- help='ADP Claim Number assigned after submission',
- )
- x_fc_client_ref_1 = fields.Char(
- string='Client Reference 1',
- help='Primary client reference (e.g., Health Card Number)',
- )
- x_fc_client_ref_2 = fields.Char(
- string='Client Reference 2',
- help='Secondary client reference',
- )
- x_fc_adp_delivery_date = fields.Date(
- string='ADP Delivery Date',
- help='Date the product was delivered to the client (for ADP billing)',
- )
- x_fc_service_start_date = fields.Date(
- string='Service Start Date',
- help='Service period start date (optional, for rentals/services)',
- )
- x_fc_service_end_date = fields.Date(
- string='Service End Date',
- help='Service period end date (optional, for rentals/services)',
- )
- x_fc_authorizer_id = fields.Many2one(
- 'res.partner',
- string='Authorizer',
- help='Authorizer contact for this order',
- domain="[('is_company', '=', False)]",
- )
-
- x_fc_primary_serial = fields.Char(
- string='Primary Serial Number',
- help='Primary serial number for the order (header level). '
- 'Line-level serials are tracked on individual order lines.',
- copy=False,
- )
-
- # ==========================================================================
- # ADP WORKFLOW STATUS (Legacy - keeping for backward compatibility)
- # ==========================================================================
- x_fc_adp_status = fields.Selection(
- selection=[
- ('quote', 'Quote'),
- ('submitted', 'Submitted to ADP'),
- ('approved', 'ADP Approved'),
- ('client_paid', 'Client Paid (25%)'),
- ('delivered', 'Delivered'),
- ('billed', 'Billed to ADP (75%)'),
- ('closed', 'Closed'),
- ],
- string='ADP Status (Legacy)',
- default='quote',
- tracking=True,
- help='Legacy status field - use x_fc_adp_application_status instead',
- )
-
- # ==========================================================================
- # ADP APPLICATION STATUS (New comprehensive status field)
- # ==========================================================================
- x_fc_adp_application_status = fields.Selection(
- selection=[
- ('quotation', 'Quotation Stage'),
- ('assessment_scheduled', 'Assessment Scheduled'),
- ('assessment_completed', 'Assessment Completed'),
- ('waiting_for_application', 'Waiting for Application'),
- ('application_received', 'Application Received'),
- ('ready_submission', 'Ready for Submission'),
- ('submitted', 'Application Submitted'),
- ('accepted', 'Accepted by ADP'), # New: ADP accepted submission (within 24 hours)
- ('rejected', 'Rejected by ADP'), # New: ADP rejected submission (errors, need correction)
- ('resubmitted', 'Application Resubmitted'),
- ('needs_correction', 'Application Needs Correction'),
- ('approved', 'Application Approved'),
- ('approved_deduction', 'Approved with Deduction'),
- ('ready_delivery', 'Ready for Delivery'), # After approved OR when early delivery
- ('denied', 'Application Denied'),
- ('withdrawn', 'Application Withdrawn'),
- ('ready_bill', 'Ready to Bill'),
- ('billed', 'Billed to ADP'),
- ('case_closed', 'Case Closed'),
- ('on_hold', 'On Hold'),
- ('cancelled', 'Cancelled'),
- ('expired', 'Application Expired'),
- ],
- string='ADP Application Status',
- default='quotation',
- tracking=True,
- copy=False,
- group_expand='_expand_adp_application_statuses',
- help='Comprehensive ADP application workflow status',
- )
-
- @api.model
- def _expand_adp_application_statuses(self, states, domain):
- """Return the main workflow statuses for kanban columns.
- Always shows core statuses; special statuses (on_hold, denied, etc.)
- only appear when records exist in them."""
- main = [
- 'quotation', 'assessment_scheduled', 'waiting_for_application',
- 'application_received', 'ready_submission', 'submitted',
- 'needs_correction', 'approved', 'ready_delivery',
- 'ready_bill', 'billed', 'case_closed',
- ]
- # Also include any special status that currently has records
- result = list(main)
- for s in (states or []):
- if s and s not in result:
- result.append(s)
- return result
-
- x_fc_status_sequence = fields.Integer(
- string='Status Sequence',
- compute='_compute_status_sequence',
- store=True,
- index=True,
- help='Numeric workflow order for sorting when grouping by status',
- )
-
- _STATUS_ORDER = {
- 'quotation': 10,
- 'assessment_scheduled': 20,
- 'assessment_completed': 30,
- 'waiting_for_application': 40,
- 'application_received': 50,
- 'ready_submission': 60,
- 'submitted': 70,
- 'accepted': 80,
- 'rejected': 85,
- 'resubmitted': 75,
- 'needs_correction': 65,
- 'approved': 90,
- 'approved_deduction': 91,
- 'ready_delivery': 95,
- 'ready_bill': 100,
- 'billed': 110,
- 'case_closed': 120,
- 'on_hold': 130,
- 'denied': 140,
- 'withdrawn': 150,
- 'cancelled': 160,
- 'expired': 170,
- }
-
- @api.depends('x_fc_adp_application_status')
- def _compute_status_sequence(self):
- for order in self:
- order.x_fc_status_sequence = self._STATUS_ORDER.get(
- order.x_fc_adp_application_status, 999
- )
-
- @api.model
- def _read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None):
- """Override to sort groups by workflow order when grouping by ADP status."""
- result = super()._read_group(
- domain, groupby=groupby, aggregates=aggregates,
- having=having, offset=offset, limit=limit, order=order,
- )
- if groupby and groupby[0] == 'x_fc_adp_application_status':
- status_order = self._STATUS_ORDER
- result = sorted(result, key=lambda r: status_order.get(r[0], 999))
- return result
-
- # ==========================================================================
- # SERVICE FLAG (for service start/end date visibility)
- # ==========================================================================
- x_fc_has_service = fields.Boolean(
- string='Is Service?',
- default=False,
- help='Check if this order includes a service component (shows service date fields)',
- )
-
- # ==========================================================================
- # ON HOLD TRACKING
- # ==========================================================================
- x_fc_on_hold_date = fields.Date(
- string='On Hold Since',
- tracking=True,
- help='Date when the application was put on hold',
- )
- x_fc_previous_status_before_hold = fields.Char(
- string='Previous Status Before Hold',
- help='Status before the application was put on hold (for resuming)',
- )
-
- x_fc_status_before_delivery = fields.Char(
- string='Status Before Delivery',
- help='Status before the order was marked Ready for Delivery (for reverting if task cancelled)',
- )
-
- # ==========================================================================
- # DELIVERY TECHNICIAN TRACKING
- # ==========================================================================
- x_fc_early_delivery = fields.Boolean(
- string='Early Delivery',
- default=False,
- tracking=True,
- help='Check if delivery will occur before ADP approval (client pays their portion upfront)',
- )
- x_fc_delivery_technician_ids = fields.Many2many(
- 'res.users',
- 'sale_order_delivery_technician_rel',
- 'sale_order_id',
- 'user_id',
- string='Delivery Technicians',
- tracking=True,
- help='Field technicians assigned to deliver this order',
- )
- x_fc_ready_for_delivery_date = fields.Datetime(
- string='Ready for Delivery Date',
- tracking=True,
- help='Date/time when the order was marked ready for delivery',
- )
- x_fc_scheduled_delivery_datetime = fields.Datetime(
- string='Scheduled Delivery',
- tracking=True,
- help='Scheduled date and time for delivery',
- )
-
- # ==========================================================================
- # REJECTION REASON TRACKING (Initial rejection by ADP - within 24 hours)
- # ==========================================================================
- x_fc_rejection_reason = fields.Selection(
- selection=[
- ('name_correction', 'Name Correction Needed'),
- ('healthcard_correction', 'Health Card Correction Needed'),
- ('duplicate_claim', 'Duplicate Claim Exists'),
- ('xml_format_error', 'XML Format/Validation Error'),
- ('missing_info', 'Missing Required Information'),
- ('other', 'Other'),
- ],
- string='Rejection Reason',
- tracking=True,
- help='Reason for initial rejection by ADP (within 24 hours of submission)',
- )
- x_fc_rejection_reason_other = fields.Text(
- string='Rejection Details',
- tracking=True,
- help='Additional details when rejection reason is "Other"',
- )
- x_fc_rejection_date = fields.Date(
- string='Rejection Date',
- tracking=True,
- help='Date when ADP rejected the submission',
- )
- x_fc_rejection_count = fields.Integer(
- string='Rejection Count',
- default=0,
- help='Number of times this application has been rejected by ADP',
- )
-
- # ==========================================================================
- # DENIAL REASON TRACKING (Funding denied after review - 2-3 weeks)
- # ==========================================================================
- x_fc_denial_reason = fields.Selection(
- selection=[
- ('eligibility', 'Client Eligibility Issues'),
- ('recent_funding', 'Previous Funding Within 5 Years'),
- ('medical_justification', 'Insufficient Medical Justification'),
- ('equipment_not_covered', 'Equipment Not Covered by ADP'),
- ('documentation_incomplete', 'Documentation Incomplete'),
- ('other', 'Other'),
- ],
- string='Denial Reason',
- tracking=True,
- help='Reason for denial of funding by ADP (after 2-3 week review)',
- )
- x_fc_denial_reason_other = fields.Text(
- string='Denial Details',
- tracking=True,
- help='Additional details when denial reason is "Other"',
- )
- x_fc_denial_date = fields.Date(
- string='Denial Date',
- tracking=True,
- help='Date when ADP denied the funding',
- )
-
- # ==========================================================================
- # EMAIL NOTIFICATION TRACKING
- # ==========================================================================
- x_fc_application_reminder_sent = fields.Boolean(
- string='Application Reminder Sent',
- default=False,
- copy=False,
- help='Whether the first application reminder email has been sent',
- )
- x_fc_application_reminder_2_sent = fields.Boolean(
- string='Application Reminder 2 Sent',
- default=False,
- copy=False,
- help='Whether the second application reminder email has been sent',
- )
- x_fc_acceptance_reminder_sent = fields.Boolean(
- string='Acceptance Reminder Sent',
- default=False,
- copy=False,
- help='Whether the acceptance reminder email has been sent for submitted orders',
- )
-
- # ==========================================================================
- # VALIDITY & EXPIRY TRACKING
- # ==========================================================================
- x_fc_assessment_validity_days = fields.Integer(
- string='Assessment Validity (Days)',
- compute='_compute_validity_expiry',
- help='Days remaining before assessment expires (valid for 3 months)',
- )
- x_fc_assessment_expired = fields.Boolean(
- string='Assessment Expired',
- compute='_compute_validity_expiry',
- help='True if assessment is more than 3 months old',
- )
- x_fc_approval_expiry_days = fields.Integer(
- string='Approval Expiry (Days)',
- compute='_compute_validity_expiry',
- help='Days remaining before approval expires (valid for 6 months)',
- )
- x_fc_approval_expired = fields.Boolean(
- string='Approval Expired',
- compute='_compute_validity_expiry',
- help='True if approval is more than 6 months old',
- )
- x_fc_billing_warning = fields.Boolean(
- string='Billing Warning',
- compute='_compute_validity_expiry',
- help='True if more than 1 year since approval (verbal ADP permission needed)',
- )
- x_fc_show_expiry_card = fields.Boolean(
- string='Show Expiry Card',
- compute='_compute_validity_expiry',
- help='True if expiry card should be shown',
- )
-
- @api.depends('x_fc_assessment_end_date', 'x_fc_claim_approval_date', 'x_fc_adp_application_status', 'x_fc_claim_number')
- def _compute_validity_expiry(self):
- """Compute validity and expiry information for assessments and approvals."""
- from datetime import date as date_class
- today = date_class.today()
-
- # Statuses that show expiry card
- expiry_card_statuses = ['approved', 'approved_deduction', 'on_hold']
-
- for order in self:
- # Assessment validity (3 months = 90 days)
- if order.x_fc_assessment_end_date:
- days_since_assessment = (today - order.x_fc_assessment_end_date).days
- order.x_fc_assessment_validity_days = max(0, 90 - days_since_assessment)
- order.x_fc_assessment_expired = days_since_assessment > 90
- else:
- order.x_fc_assessment_validity_days = 0
- order.x_fc_assessment_expired = False
-
- # Approval expiry (6 months = 180 days)
- if order.x_fc_claim_approval_date:
- days_since_approval = (today - order.x_fc_claim_approval_date).days
- order.x_fc_approval_expiry_days = max(0, 180 - days_since_approval)
- order.x_fc_approval_expired = days_since_approval > 180
- # Billing warning (1 year = 365 days)
- order.x_fc_billing_warning = days_since_approval > 365
- else:
- order.x_fc_approval_expiry_days = 0
- order.x_fc_approval_expired = False
- order.x_fc_billing_warning = False
-
- # Show expiry card for approved/approved_deduction/on_hold (with claim number)
- status = order.x_fc_adp_application_status
- if status in expiry_card_statuses and order.x_fc_claim_approval_date:
- # For on_hold, only show if has claim number
- if status == 'on_hold':
- order.x_fc_show_expiry_card = bool(order.x_fc_claim_number)
- else:
- order.x_fc_show_expiry_card = True
- else:
- order.x_fc_show_expiry_card = False
-
- # ==========================================================================
- # WORKFLOW STAGE FLAGS (computed for view visibility)
- # ==========================================================================
- x_fc_stage_after_assessment_initiated = fields.Boolean(
- compute='_compute_workflow_stages',
- string='After Assessment Initiated Stage',
- )
- x_fc_stage_after_assessment_completed = fields.Boolean(
- compute='_compute_workflow_stages',
- string='After Assessment Completed Stage',
- )
- x_fc_stage_after_application_received = fields.Boolean(
- compute='_compute_workflow_stages',
- string='After Application Received Stage',
- )
- x_fc_stage_after_ready_submission = fields.Boolean(
- compute='_compute_workflow_stages',
- string='After Ready Submission Stage',
- )
- x_fc_stage_after_submitted = fields.Boolean(
- compute='_compute_workflow_stages',
- string='After Submitted Stage',
- )
- x_fc_stage_after_accepted = fields.Boolean(
- compute='_compute_workflow_stages',
- string='After Accepted Stage',
- )
- x_fc_stage_after_approved = fields.Boolean(
- compute='_compute_workflow_stages',
- string='After Approved Stage',
- )
- x_fc_stage_after_ready_bill = fields.Boolean(
- compute='_compute_workflow_stages',
- string='After Ready Bill Stage',
- )
- x_fc_stage_after_billed = fields.Boolean(
- compute='_compute_workflow_stages',
- string='After Billed Stage',
- )
- x_fc_requires_previous_funding = fields.Boolean(
- compute='_compute_workflow_stages',
- string='Requires Previous Funding Date',
- )
-
- @api.depends('x_fc_adp_application_status', 'x_fc_reason_for_application')
- def _compute_workflow_stages(self):
- """Compute workflow stage flags for conditional visibility in views.
-
- Terminal statuses (cancelled, denied, withdrawn, expired) should NOT make
- later-stage fields required - only 'on_hold' preserves field requirements
- since the case can resume.
- """
- # Terminal statuses - these end the workflow, no further fields required
- terminal_statuses = ['cancelled', 'denied', 'withdrawn', 'expired']
-
- # On-hold preserves visibility but we handle it specially
- # so fields remain visible but not required
-
- # Define status groups - each list includes the starting status and all subsequent
- after_assessment_initiated_statuses = [
- 'assessment_scheduled', 'assessment_completed', 'waiting_for_application',
- 'application_received',
- 'ready_submission', 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
- 'approved', 'approved_deduction',
- 'ready_bill', 'billed', 'case_closed', 'on_hold',
- ]
-
- after_assessment_completed_statuses = [
- 'assessment_completed', 'waiting_for_application', 'application_received',
- 'ready_submission',
- 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
- 'approved', 'approved_deduction',
- 'ready_bill', 'billed', 'case_closed', 'on_hold',
- ]
-
- after_application_received_statuses = [
- 'application_received', 'ready_submission', 'submitted', 'accepted', 'rejected',
- 'resubmitted', 'needs_correction', 'approved', 'approved_deduction',
- 'ready_bill', 'billed', 'case_closed', 'on_hold',
- ]
-
- after_ready_submission_statuses = [
- 'ready_submission', 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
- 'approved', 'approved_deduction',
- 'ready_bill', 'billed', 'case_closed', 'on_hold',
- ]
-
- after_submitted_statuses = [
- 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
- 'approved', 'approved_deduction',
- 'ready_bill', 'billed', 'case_closed', 'on_hold',
- ]
-
- # New: After accepted by ADP (waiting for approval decision)
- after_accepted_statuses = [
- 'accepted', 'approved', 'approved_deduction',
- 'ready_bill', 'billed', 'case_closed', 'on_hold',
- ]
-
- after_approved_statuses = [
- 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
- 'on_hold',
- ]
-
- after_ready_bill_statuses = [
- 'ready_bill', 'billed', 'case_closed',
- # NOT on_hold here - if on_hold before ready_bill, these shouldn't be required
- ]
-
- after_billed_statuses = [
- 'billed', 'case_closed',
- ]
-
- # Reasons that DON'T require previous funding date
- no_prev_funding_reasons = ['first_access', 'mod_non_adp']
-
- for order in self:
- status = order.x_fc_adp_application_status or ''
- reason = order.x_fc_reason_for_application or ''
-
- order.x_fc_stage_after_assessment_initiated = status in after_assessment_initiated_statuses
- order.x_fc_stage_after_assessment_completed = status in after_assessment_completed_statuses
- order.x_fc_stage_after_application_received = status in after_application_received_statuses
- order.x_fc_stage_after_ready_submission = status in after_ready_submission_statuses
- order.x_fc_stage_after_submitted = status in after_submitted_statuses
- order.x_fc_stage_after_accepted = status in after_accepted_statuses
- order.x_fc_stage_after_approved = status in after_approved_statuses
- order.x_fc_stage_after_ready_bill = status in after_ready_bill_statuses
- order.x_fc_stage_after_billed = status in after_billed_statuses
- # Previous funding required if reason is set AND not in exempt list
- order.x_fc_requires_previous_funding = bool(reason) and reason not in no_prev_funding_reasons
-
- # ==========================================================================
- # REASON FOR APPLICATION
- # ==========================================================================
- x_fc_reason_for_application = fields.Selection(
- selection=[
- ('first_access', 'First Time Access - NO previous ADP'),
- ('additions', 'Additions'),
- ('mod_non_adp', 'Modification/Upgrade - Original NOT through ADP'),
- ('mod_adp', 'Modification/Upgrade - Original through ADP'),
- ('replace_status', 'Replacement - Change in Status'),
- ('replace_size', 'Replacement - Change in Body Size'),
- ('replace_worn', 'Replacement - Worn out (past useful life)'),
- ('replace_lost', 'Replacement - Lost'),
- ('replace_stolen', 'Replacement - Stolen'),
- ('replace_damaged', 'Replacement - Damaged beyond repair'),
- ('replace_no_longer_meets', 'Replacement - No longer meets needs'),
- ('growth', 'Growth/Change in condition'),
- ],
- string='Reason for Application',
- tracking=True,
- help='Reason for the ADP application - affects invoice creation rules',
- )
-
- x_fc_previous_funding_date = fields.Date(
- string='Previous Funding Date',
- tracking=True,
- help='Date of previous ADP funding for replacement applications',
- )
-
- x_fc_years_since_funding = fields.Float(
- string='Years Since Funding',
- compute='_compute_years_since_funding',
- store=True,
- help='Number of years since previous funding',
- )
-
- x_fc_under_5_years = fields.Boolean(
- string='Under 5 Years',
- compute='_compute_years_since_funding',
- store=True,
- help='True if less than 5 years since previous funding (may have deductions)',
- )
-
- # ==========================================================================
- # ADP DATE CLASSIFICATIONS (6 dates)
- # ==========================================================================
- x_fc_assessment_start_date = fields.Date(
- string='Assessment Start Date',
- tracking=True,
- help='Date when the assessment started',
- )
-
- x_fc_assessment_end_date = fields.Date(
- string='Assessment End Date',
- tracking=True,
- help='Date when the assessment was completed',
- )
-
- x_fc_claim_authorization_date = fields.Date(
- string='Claim Authorization Date',
- tracking=True,
- help='Date when the claim was authorized by the OT/Authorizer',
- )
-
- x_fc_claim_submission_date = fields.Date(
- string='Claim Submission Date',
- tracking=True,
- help='Date when the claim was submitted to ADP',
- )
-
- x_fc_claim_acceptance_date = fields.Date(
- string='ADP Acceptance Date',
- tracking=True,
- help='Date when ADP accepted the submission (within 24 hours of submission)',
- )
-
- x_fc_claim_approval_date = fields.Date(
- string='Claim Approval Date',
- tracking=True,
- help='Date when ADP approved the claim',
- )
-
- x_fc_billing_date = fields.Date(
- string='Billing Date',
- tracking=True,
- help='Date when the ADP invoice was created/billed',
- )
-
- # ==========================================================================
- # ADP DOCUMENT ATTACHMENTS
- # ==========================================================================
- x_fc_original_application = fields.Binary(
- string='Original ADP Application',
- attachment=True,
- help='The original ADP application document received from the authorizer',
- )
- x_fc_original_application_filename = fields.Char(
- string='Original Application Filename',
- )
-
- x_fc_signed_pages_11_12 = fields.Binary(
- string='Page 11 & 12 (Signed)',
- attachment=True,
- help='Signed pages 11 and 12 of the ADP application',
- )
- x_fc_signed_pages_filename = fields.Char(
- string='Signed Pages Filename',
- )
-
- # ==========================================================================
- # PAGE 11 SIGNATURE TRACKING (Client/Agent Signature)
- # Page 11 must be signed by: Client, Spouse, Parent, Legal Guardian, POA, or Public Trustee
- # ==========================================================================
- x_fc_page11_signer_type = fields.Selection(
- selection=[
- ('client', 'Client (Self)'),
- ('spouse', 'Spouse'),
- ('parent', 'Parent'),
- ('legal_guardian', 'Legal Guardian'),
- ('poa', 'Power of Attorney'),
- ('public_trustee', 'Public Trustee'),
- ],
- string='Page 11 Signed By',
- tracking=True,
- help='Who signed Page 11 of the ADP application (client consent page)',
- )
- x_fc_page11_signer_name = fields.Char(
- string='Page 11 Signer Name',
- tracking=True,
- help='Name of the person who signed Page 11',
- )
- x_fc_page11_signer_relationship = fields.Char(
- string='Relationship to Client',
- help='Relationship of the signer to the client (if not client self)',
- )
- x_fc_page11_signed_date = fields.Date(
- string='Page 11 Signed Date',
- tracking=True,
- help='Date when Page 11 was signed',
- )
-
- # ==========================================================================
- # PAGE 12 SIGNATURE TRACKING (Authorizer + Vendor Signature)
- # Page 12 must be signed by: Authorizer (OT) and Vendor (our company)
- # ==========================================================================
- x_fc_page12_authorizer_signed = fields.Boolean(
- string='Authorizer Signed Page 12',
- default=False,
- tracking=True,
- help='Whether the authorizer/OT has signed Page 12',
- )
- x_fc_page12_authorizer_signed_date = fields.Date(
- string='Authorizer Signed Date',
- tracking=True,
- help='Date when the authorizer signed Page 12',
- )
- x_fc_page12_vendor_signed = fields.Boolean(
- string='Vendor Signed Page 12',
- default=False,
- tracking=True,
- help='Whether the vendor (our company) has signed Page 12',
- )
- x_fc_page12_vendor_signer_id = fields.Many2one(
- 'res.users',
- string='Vendor Signer',
- tracking=True,
- help='The user who signed Page 12 on behalf of the company',
- )
- x_fc_page12_vendor_signed_date = fields.Date(
- string='Vendor Signed Date',
- tracking=True,
- help='Date when the vendor signed Page 12',
- )
-
- x_fc_final_submitted_application = fields.Binary(
- string='Final Submitted Application',
- attachment=True,
- help='The final ADP application as submitted to ADP',
- )
- x_fc_final_application_filename = fields.Char(
- string='Final Application Filename',
- )
-
- x_fc_xml_file = fields.Binary(
- string='XML File',
- attachment=True,
- help='The XML data file submitted to ADP',
- )
- x_fc_xml_filename = fields.Char(
- string='XML Filename',
- )
-
- x_fc_approval_letter = fields.Binary(
- string='ADP Approval Letter',
- attachment=True,
- help='ADP approval letter document',
- )
- x_fc_approval_letter_filename = fields.Char(
- string='Approval Letter Filename',
- )
- x_fc_approval_photo_ids = fields.Many2many(
- 'ir.attachment',
- 'sale_order_approval_photo_rel',
- 'sale_order_id',
- 'attachment_id',
- string='Approval Screenshots',
- help='Upload approval screenshots/photos from ADP portal',
- )
- x_fc_approval_photo_count = fields.Integer(
- string='Approval Photos',
- compute='_compute_approval_photo_count',
- )
-
- @api.depends('x_fc_approval_photo_ids')
- def _compute_approval_photo_count(self):
- """Count approval photos."""
- for order in self:
- order.x_fc_approval_photo_count = len(order.x_fc_approval_photo_ids)
-
- x_fc_proof_of_delivery = fields.Binary(
- string='Proof of Delivery',
- attachment=True,
- help='Proof of delivery document - required before creating ADP invoice',
- )
- x_fc_proof_of_delivery_filename = fields.Char(
- string='Proof of Delivery Filename',
- )
-
- # POD Digital Signature Fields (captured via portal)
- x_fc_pod_signature = fields.Binary(
- string='POD Client Signature',
- attachment=True,
- help='Digital signature captured from client via portal',
- )
- x_fc_pod_client_name = fields.Char(
- string='POD Client Name',
- help='Name of the person who signed the Proof of Delivery',
- )
- x_fc_pod_signature_date = fields.Date(
- string='POD Signature Date',
- help='Date specified on the Proof of Delivery (optional)',
- )
- x_fc_pod_signed_by_user_id = fields.Many2one(
- 'res.users',
- string='POD Collected By',
- help='The sales rep or technician who collected the POD signature',
- )
- x_fc_pod_signed_datetime = fields.Datetime(
- string='POD Collection Timestamp',
- help='When the POD signature was collected',
- )
-
- # ==========================================================================
- # VERIFICATION TRACKING
- # ==========================================================================
- x_fc_submission_verified = fields.Boolean(
- string='Submission Verified',
- default=False,
- copy=False,
- help='True when user has verified device types for submission via the wizard',
- )
-
- x_fc_submitted_device_types = fields.Text(
- string='Submitted Device Types (JSON)',
- copy=False,
- help='JSON storing which device types were selected for submission',
- )
-
- # ==========================================================================
- # COMPUTED TOTALS FOR ADP PORTIONS
- # ==========================================================================
- x_fc_adp_portion_total = fields.Monetary(
- string='Total ADP Portion',
- compute='_compute_adp_totals',
- store=True,
- currency_field='currency_id',
- help='Total ADP portion for all lines',
- )
- x_fc_client_portion_total = fields.Monetary(
- string='Total Client Portion',
- compute='_compute_adp_totals',
- store=True,
- currency_field='currency_id',
- help='Total client portion for all lines',
- )
-
- # ==========================================================================
- # COMPUTED FIELDS FOR SPLIT INVOICE TRACKING
- # ==========================================================================
- x_fc_has_client_invoice = fields.Boolean(
- string='Has Client Invoice',
- compute='_compute_adp_invoice_status',
- help='Whether a client portion (25%) invoice has been created',
- )
- x_fc_has_adp_invoice = fields.Boolean(
- string='Has ADP Invoice',
- compute='_compute_adp_invoice_status',
- help='Whether an ADP portion (75%/100%) invoice has been created',
- )
-
- # ==========================================================================
- # COMPUTED FIELD FOR PRODUCT-ONLY LINES (for ADP Summary)
- # ==========================================================================
- x_fc_product_lines = fields.One2many(
- 'sale.order.line',
- compute='_compute_product_lines',
- string='Product Lines Only',
- help='Only product lines (excludes sections, notes, and empty lines)',
- )
-
- # ==========================================================================
- # DEVICE APPROVAL TRACKING
- # ==========================================================================
- x_fc_has_unapproved_devices = fields.Boolean(
- string='Has Unapproved Devices',
- compute='_compute_device_approval_status',
- help='True if there are devices that have not been marked as approved by ADP',
- )
- x_fc_device_verification_complete = fields.Boolean(
- string='Verification Complete',
- default=False,
- copy=False,
- help='True if the user has completed device verification via the wizard. '
- 'Set when user clicks Confirm in the Device Approval wizard.',
- )
- x_fc_device_approval_done = fields.Boolean(
- string='All Devices Approved',
- compute='_compute_device_approval_status',
- help='True if ALL ADP devices have been approved. For display purposes only.',
- )
- x_fc_approved_device_count = fields.Integer(
- string='Approved Device Count',
- compute='_compute_device_approval_status',
- )
- x_fc_total_device_count = fields.Integer(
- string='Total Device Count',
- compute='_compute_device_approval_status',
- )
-
- # ==========================================================================
- # CASE LOCK
- # ==========================================================================
- x_fc_case_locked = fields.Boolean(
- string='Case Locked',
- default=False,
- copy=False,
- tracking=True,
- help='When enabled, all ADP-related fields become read-only and cannot be modified.',
- )
-
- # ==========================================================================
- # INVOICE MAPPING (for linking legacy invoices)
- # ==========================================================================
- x_fc_adp_invoice_id = fields.Many2one(
- 'account.move',
- string='ADP Invoice',
- domain="[('move_type', 'in', ['out_invoice', 'out_refund'])]",
- copy=False,
- help='Link to the ADP invoice for this order',
- )
- x_fc_client_invoice_id = fields.Many2one(
- 'account.move',
- string='Client Invoice',
- domain="[('move_type', 'in', ['out_invoice', 'out_refund'])]",
- copy=False,
- help='Link to the client portion invoice for this order',
- )
-
- # ==========================================================================
- # (Legacy studio fields removed - all data migrated to x_fc_* fields)
- # ==========================================================================
-
- # ==========================================================================
- # ORDER TRAIL CHECKLIST (computed for display)
- # ==========================================================================
- x_fc_trail_has_assessment_dates = fields.Boolean(
- string='Assessment Dates Set',
- compute='_compute_order_trail',
- )
- x_fc_trail_has_authorization = fields.Boolean(
- string='Authorization Date Set',
- compute='_compute_order_trail',
- )
- x_fc_trail_has_original_app = fields.Boolean(
- string='Original Application Uploaded',
- compute='_compute_order_trail',
- )
- x_fc_trail_has_signed_pages = fields.Boolean(
- string='Signed Pages 11 & 12 Uploaded',
- compute='_compute_order_trail',
- )
- x_fc_trail_has_final_app = fields.Boolean(
- string='Final Application Uploaded',
- compute='_compute_order_trail',
- )
- x_fc_trail_has_xml = fields.Boolean(
- string='XML File Uploaded',
- compute='_compute_order_trail',
- )
- x_fc_trail_has_approval_letter = fields.Boolean(
- string='Approval Letter Uploaded',
- compute='_compute_order_trail',
- )
- x_fc_trail_has_pod = fields.Boolean(
- string='Proof of Delivery Uploaded',
- compute='_compute_order_trail',
- )
- x_fc_trail_has_vendor_bills = fields.Boolean(
- string='Vendor Bills Linked',
- compute='_compute_order_trail',
- )
- x_fc_trail_invoices_posted = fields.Boolean(
- string='Invoices Posted',
- compute='_compute_order_trail',
- )
-
- @api.depends(
- 'x_fc_assessment_start_date', 'x_fc_assessment_end_date',
- 'x_fc_claim_authorization_date', 'x_fc_original_application',
- 'x_fc_signed_pages_11_12', 'x_fc_final_submitted_application',
- 'x_fc_xml_file', 'x_fc_approval_letter', 'x_fc_proof_of_delivery',
- 'x_fc_vendor_bill_ids', 'invoice_ids', 'invoice_ids.state'
- )
- def _compute_order_trail(self):
- for order in self:
- order.x_fc_trail_has_assessment_dates = bool(
- order.x_fc_assessment_start_date and order.x_fc_assessment_end_date
- )
- order.x_fc_trail_has_authorization = bool(order.x_fc_claim_authorization_date)
- order.x_fc_trail_has_original_app = bool(order.x_fc_original_application)
- order.x_fc_trail_has_signed_pages = bool(order.x_fc_signed_pages_11_12)
- order.x_fc_trail_has_final_app = bool(order.x_fc_final_submitted_application)
- order.x_fc_trail_has_xml = bool(order.x_fc_xml_file)
- order.x_fc_trail_has_approval_letter = bool(order.x_fc_approval_letter)
- order.x_fc_trail_has_pod = bool(order.x_fc_proof_of_delivery)
- order.x_fc_trail_has_vendor_bills = bool(order.x_fc_vendor_bill_ids)
- # Check if there are posted invoices
- order.x_fc_trail_invoices_posted = any(
- inv.state == 'posted' for inv in order.invoice_ids
- )
-
- # ==========================================================================
- # DEDUCTION TRACKING
- # ==========================================================================
- x_fc_has_deductions = fields.Boolean(
- string='Has Deductions',
- compute='_compute_has_deductions',
- help='True if any line has a deduction applied',
- )
- x_fc_total_deduction_amount = fields.Monetary(
- string='Total Deduction Amount',
- compute='_compute_has_deductions',
- currency_field='currency_id',
- help='Total amount of deductions applied to ADP portion',
- )
-
- # ==========================================================================
- # COMPUTED METHODS
- # ==========================================================================
- @api.depends('order_line.x_fc_adp_portion', 'order_line.x_fc_client_portion')
- def _compute_adp_totals(self):
- for order in self:
- order.x_fc_adp_portion_total = sum(order.order_line.mapped('x_fc_adp_portion'))
- order.x_fc_client_portion_total = sum(order.order_line.mapped('x_fc_client_portion'))
-
- def _compute_adp_invoice_status(self):
- """Check if client/ADP split invoices have already been created."""
- for order in self:
- client_invoice_exists = False
- adp_invoice_exists = False
-
- # Check linked invoices for the portion type
- invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
- for invoice in invoices:
- if hasattr(invoice, 'x_fc_adp_invoice_portion'):
- if invoice.x_fc_adp_invoice_portion == 'client':
- client_invoice_exists = True
- elif invoice.x_fc_adp_invoice_portion == 'adp':
- adp_invoice_exists = True
-
- order.x_fc_has_client_invoice = client_invoice_exists
- order.x_fc_has_adp_invoice = adp_invoice_exists
-
- @api.depends('order_line', 'order_line.product_id', 'order_line.product_uom_qty', 'order_line.display_type')
- def _compute_product_lines(self):
- """Compute filtered list of only actual product lines (no sections, notes, or empty lines)."""
- for order in self:
- order.x_fc_product_lines = order.order_line.filtered(
- lambda l: not l.display_type and l.product_id and l.product_uom_qty > 0
- )
-
- @api.depends('order_line.x_fc_adp_approved', 'order_line.product_id', 'order_line.display_type')
- def _compute_device_approval_status(self):
- """Compute device approval status for ADP orders.
-
- Only counts lines with valid ADP device codes in the database.
- Non-ADP items are ignored for verification purposes.
- """
- ADPDevice = self.env['fusion.adp.device.code'].sudo()
-
- for order in self:
- # Get lines with device codes (actual ADP billable products)
- product_lines = order.order_line.filtered(
- lambda l: not l.display_type
- and l.product_id
- and l.product_uom_qty > 0
- )
-
- # Filter to only lines with valid ADP device codes in the database
- device_lines = self.env['sale.order.line']
- for line in product_lines:
- device_code = line._get_adp_device_code()
- if device_code:
- # Check if this code exists in ADP database
- if ADPDevice.search_count([('device_code', '=', device_code), ('active', '=', True)]) > 0:
- device_lines |= line
-
- total_count = len(device_lines)
- approved_count = len(device_lines.filtered(lambda l: l.x_fc_adp_approved))
-
- order.x_fc_total_device_count = total_count
- order.x_fc_approved_device_count = approved_count
- order.x_fc_has_unapproved_devices = approved_count < total_count and total_count > 0
-
- # Verification is "done" only if ALL ADP devices have been approved
- # If there are no ADP devices, verification is automatically done
- order.x_fc_device_approval_done = (approved_count == total_count) or total_count == 0
-
- @api.depends('order_line.x_fc_deduction_type', 'order_line.x_fc_deduction_value',
- 'order_line.x_fc_adp_portion', 'order_line.product_id')
- def _compute_has_deductions(self):
- """Compute if order has any deductions and total deduction amount."""
- for order in self:
- product_lines = order.order_line.filtered(
- lambda l: not l.display_type
- and l.product_id
- and l.product_uom_qty > 0
- )
-
- # Check if any line has a deduction
- has_deductions = any(
- line.x_fc_deduction_type and line.x_fc_deduction_type != 'none'
- for line in product_lines
- )
-
- # Calculate total deduction impact (difference from full ADP coverage)
- total_deduction = 0.0
- if has_deductions:
- for line in product_lines:
- if line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
- total_deduction += line.x_fc_deduction_value
- elif line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
- # For percentage, calculate the reduction from normal
- # If normally 75% ADP, and now 50%, the deduction is 25% of total
- client_type = order._get_client_type()
- base_pct = 0.75 if client_type == 'REG' else 1.0
- adp_price = line.x_fc_adp_max_price or line.price_unit
- normal_adp = adp_price * line.product_uom_qty * base_pct
- actual_adp = line.x_fc_adp_portion
- total_deduction += max(0, normal_adp - actual_adp)
-
- order.x_fc_has_deductions = has_deductions
- order.x_fc_total_deduction_amount = total_deduction
-
- @api.depends('x_fc_previous_funding_date')
- def _compute_years_since_funding(self):
- """Compute years since previous funding and 5-year flag."""
- from datetime import date
- today = date.today()
- for order in self:
- if order.x_fc_previous_funding_date:
- delta = today - order.x_fc_previous_funding_date
- years = delta.days / 365.25
- order.x_fc_years_since_funding = round(years, 2)
- order.x_fc_under_5_years = years < 5
- else:
- order.x_fc_years_since_funding = 0
- order.x_fc_under_5_years = False
-
- # ==========================================================================
- # PREVIOUS FUNDING WARNING MESSAGE
- # ==========================================================================
- x_fc_funding_warning_message = fields.Char(
- string='Funding Warning',
- compute='_compute_funding_warning_message',
- help='Warning message for recent previous funding',
- )
-
- x_fc_funding_warning_level = fields.Selection(
- selection=[
- ('none', 'None'),
- ('warning', 'Warning'),
- ('danger', 'Danger'),
- ],
- string='Warning Level',
- compute='_compute_funding_warning_message',
- help='Level of warning for previous funding',
- )
-
- @api.depends('x_fc_previous_funding_date')
- def _compute_funding_warning_message(self):
- """Compute warning message for previous funding with time elapsed."""
- from datetime import date
- today = date.today()
- for order in self:
- if order.x_fc_previous_funding_date:
- delta = today - order.x_fc_previous_funding_date
- total_months = delta.days / 30.44 # Average days per month
- years = int(total_months // 12)
- months = int(total_months % 12)
-
- if years == 0:
- time_str = f"{months} month{'s' if months != 1 else ''}"
- elif months == 0:
- time_str = f"{years} year{'s' if years != 1 else ''}"
- else:
- time_str = f"{years} year{'s' if years != 1 else ''} and {months} month{'s' if months != 1 else ''}"
-
- order.x_fc_funding_warning_message = f"Previous funding was {time_str} ago ({order.x_fc_previous_funding_date.strftime('%B %d, %Y')})"
-
- # Set warning level - red if within 1 year
- if total_months < 12:
- order.x_fc_funding_warning_level = 'danger'
- elif total_months < 60: # Less than 5 years
- order.x_fc_funding_warning_level = 'warning'
- else:
- order.x_fc_funding_warning_level = 'none'
- else:
- order.x_fc_funding_warning_message = False
- order.x_fc_funding_warning_level = 'none'
-
- # ==========================================================================
- # FIELD VALIDATION CONSTRAINTS
- # ==========================================================================
- @api.constrains('x_fc_claim_number')
- def _check_claim_number(self):
- """Validate claim number: 10 digits only, numbers only."""
- for order in self:
- if order.x_fc_claim_number:
- # Remove any whitespace
- claim = order.x_fc_claim_number.strip()
- if not re.match(r'^\d{10}$', claim):
- raise ValidationError(
- "Claim Number must be exactly 10 digits (numbers only).\n"
- f"Current value: '{order.x_fc_claim_number}'"
- )
-
- @api.constrains('x_fc_client_ref_1')
- def _check_client_ref_1(self):
- """Validate client reference 1: up to 4 letters, comma allowed."""
- for order in self:
- if order.x_fc_client_ref_1:
- # Allow letters and comma only, max 4 characters (excluding comma)
- ref = order.x_fc_client_ref_1.strip().upper()
- # Remove commas for letter count
- letters_only = ref.replace(',', '')
- if len(letters_only) > 4:
- raise ValidationError(
- "Client Reference 1 can only have up to 4 letters.\n"
- f"Current value: '{order.x_fc_client_ref_1}'"
- )
- if not re.match(r'^[A-Za-z,]+$', ref):
- raise ValidationError(
- "Client Reference 1 can only contain letters and comma.\n"
- f"Current value: '{order.x_fc_client_ref_1}'"
- )
-
- @api.constrains('x_fc_client_ref_2')
- def _check_client_ref_2(self):
- """Validate client reference 2: exactly 4 digits, numbers only."""
- for order in self:
- if order.x_fc_client_ref_2:
- ref = order.x_fc_client_ref_2.strip()
- if not re.match(r'^\d{4}$', ref):
- raise ValidationError(
- "Client Reference 2 must be exactly 4 digits (numbers only).\n"
- f"Current value: '{order.x_fc_client_ref_2}'"
- )
-
- @api.constrains('x_fc_original_application_filename')
- def _check_original_application_pdf(self):
- """Validate that Original ADP Application is a PDF file."""
- for order in self:
- if order.x_fc_original_application_filename:
- if not order.x_fc_original_application_filename.lower().endswith('.pdf'):
- raise ValidationError(
- "Original ADP Application must be a PDF file.\n"
- f"Uploaded file: '{order.x_fc_original_application_filename}'"
- )
-
- @api.constrains('x_fc_signed_pages_filename')
- def _check_signed_pages_pdf(self):
- """Validate that Page 11 & 12 is a PDF file."""
- for order in self:
- if order.x_fc_signed_pages_filename:
- if not order.x_fc_signed_pages_filename.lower().endswith('.pdf'):
- raise ValidationError(
- "Page 11 & 12 (Signed) must be a PDF file.\n"
- f"Uploaded file: '{order.x_fc_signed_pages_filename}'"
- )
-
- @api.constrains('x_fc_final_application_filename')
- def _check_final_application_pdf(self):
- """Validate that Final Submitted Application is a PDF file."""
- for order in self:
- if order.x_fc_final_application_filename:
- if not order.x_fc_final_application_filename.lower().endswith('.pdf'):
- raise ValidationError(
- "Final Submitted Application must be a PDF file.\n"
- f"Uploaded file: '{order.x_fc_final_application_filename}'"
- )
-
- @api.constrains('x_fc_xml_filename')
- def _check_xml_file(self):
- """Validate that XML File is an XML file."""
- for order in self:
- if order.x_fc_xml_filename:
- if not order.x_fc_xml_filename.lower().endswith('.xml'):
- raise ValidationError(
- "XML File must be an XML file (.xml).\n"
- f"Uploaded file: '{order.x_fc_xml_filename}'"
- )
-
- @api.constrains('x_fc_proof_of_delivery_filename')
- def _check_proof_of_delivery_pdf(self):
- """Validate that Proof of Delivery is a PDF file."""
- for order in self:
- if order.x_fc_proof_of_delivery_filename:
- if not order.x_fc_proof_of_delivery_filename.lower().endswith('.pdf'):
- raise ValidationError(
- "Proof of Delivery must be a PDF file.\n"
- f"Uploaded file: '{order.x_fc_proof_of_delivery_filename}'"
- )
-
- @api.constrains('x_fc_adp_delivery_date', 'x_fc_claim_approval_date')
- def _check_delivery_date_after_approval(self):
- """Validate that delivery date is not before approval date.
-
- Per business rule: The delivery date on POD cannot be before the approval date.
- If client takes delivery before approval (early delivery case), the POD
- should show the approval date, not the actual delivery date.
- """
- for order in self:
- if order.x_fc_adp_delivery_date and order.x_fc_claim_approval_date:
- if order.x_fc_adp_delivery_date < order.x_fc_claim_approval_date:
- raise ValidationError(
- "Delivery Date cannot be before Approval Date.\n\n"
- f"Delivery Date: {order.x_fc_adp_delivery_date.strftime('%B %d, %Y')}\n"
- f"Approval Date: {order.x_fc_claim_approval_date.strftime('%B %d, %Y')}\n\n"
- "For early delivery cases (delivery before approval), the Proof of Delivery "
- "document should show the approval date, not the actual delivery date.\n\n"
- "Please correct the delivery date and re-upload the Proof of Delivery."
- )
-
- # ==========================================================================
- # PDF DOCUMENT PREVIEW ACTIONS (opens in new tab using browser/system PDF handler)
- # ==========================================================================
- def _get_document_attachment(self, field_name):
- """Get the ir.attachment record for a binary field stored as attachment."""
- self.ensure_one()
- # Find the attachment by field name - get most recent one
- attachment = self.env['ir.attachment'].sudo().search([
- ('res_model', '=', 'sale.order'),
- ('res_id', '=', self.id),
- ('res_field', '=', field_name),
- ], order='create_date desc', limit=1)
- return attachment
-
- def _get_or_create_attachment(self, field_name, document_label):
- """Get the current attachment for a binary field (attachment=True).
-
- For attachment=True fields, Odoo creates attachments automatically.
- We find the one with res_field set and return it.
- """
- self.ensure_one()
-
- data = getattr(self, field_name)
- if not data:
- return None
-
- # For attachment=True fields, Odoo creates/updates an attachment with res_field set
- attachment = self.env['ir.attachment'].sudo().search([
- ('res_model', '=', 'sale.order'),
- ('res_id', '=', self.id),
- ('res_field', '=', field_name),
- ], order='id desc', limit=1)
-
- if attachment:
- # If attachment name is the field name (Odoo default), use the actual filename
- if attachment.name == field_name:
- filename_mapping = {
- 'x_fc_original_application': 'x_fc_original_application_filename',
- 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
- 'x_fc_final_submitted_application': 'x_fc_final_application_filename',
- 'x_fc_xml_file': 'x_fc_xml_filename',
- 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
- }
- filename_field = filename_mapping.get(field_name)
- if filename_field:
- filename = getattr(self, filename_field, None)
- if filename and filename != field_name:
- attachment.sudo().write({'name': filename})
- return attachment
-
- # Fallback: create attachment manually (shouldn't happen for attachment=True fields)
- filename_mapping = {
- 'x_fc_original_application': 'x_fc_original_application_filename',
- 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
- 'x_fc_final_submitted_application': 'x_fc_final_application_filename',
- 'x_fc_xml_file': 'x_fc_xml_filename',
- 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
- }
- filename_field = filename_mapping.get(field_name)
- filename = getattr(self, filename_field) if filename_field else f'{document_label}.pdf'
-
- attachment = self.env['ir.attachment'].sudo().create({
- 'name': filename or f'{document_label}.pdf',
- 'datas': data,
- 'res_model': 'sale.order',
- 'res_id': self.id,
- 'res_field': field_name,
- 'type': 'binary',
- })
-
- return attachment
-
- def action_open_original_application(self):
- """Open the Original ADP Application PDF."""
- self.ensure_one()
- return self._action_open_document('x_fc_original_application', 'Original ADP Application')
-
- def action_open_signed_pages(self):
- """Open the Page 11 & 12 PDF."""
- self.ensure_one()
- return self._action_open_document('x_fc_signed_pages_11_12', 'Page 11 & 12 (Signed)')
-
- def action_open_final_application(self):
- """Open the Final Submitted Application PDF."""
- self.ensure_one()
- return self._action_open_document('x_fc_final_submitted_application', 'Final Submitted Application')
-
- def action_open_xml_file(self):
- """Open the XML File in viewer."""
- self.ensure_one()
- return self._action_open_document('x_fc_xml_file', 'XML File', is_xml=True)
-
- def action_open_proof_of_delivery(self):
- """Open the Proof of Delivery PDF."""
- self.ensure_one()
- return self._action_open_document('x_fc_proof_of_delivery', 'Proof of Delivery')
-
- def action_open_approval_letter(self):
- """Open the ADP Approval Letter PDF."""
- self.ensure_one()
- return self._action_open_document('x_fc_approval_letter', 'ADP Approval Letter')
-
- def action_open_sa_approval_form(self):
- """Open the SA Mobility ODSP Approval Form PDF."""
- self.ensure_one()
- return self._action_open_document('x_fc_sa_approval_form', 'ODSP Approval Form')
-
- def action_open_sa_signed_form(self):
- """Open the signed SA Mobility form PDF."""
- self.ensure_one()
- return self._action_open_document('x_fc_sa_signed_form', 'SA Signed Form')
-
- def action_open_sa_physical_copy(self):
- """Open the physically signed SA Mobility copy."""
- self.ensure_one()
- return self._action_open_document('x_fc_sa_physical_signed_copy', 'Physical Signed Copy')
-
- def action_open_sa_internal_pod(self):
- """Generate and open the internal POD report on-the-fly."""
- self.ensure_one()
- report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard')
- return report.report_action(self)
-
- def action_open_ow_discretionary_form(self):
- """Open the Ontario Works Discretionary Benefits form PDF."""
- self.ensure_one()
- return self._action_open_document('x_fc_ow_discretionary_form', 'Discretionary Benefits Form')
-
- def action_open_ow_authorizer_letter(self):
- """Open the Ontario Works Authorizer Letter."""
- self.ensure_one()
- return self._action_open_document('x_fc_ow_authorizer_letter', 'Authorizer Letter')
-
- def action_open_ow_internal_pod(self):
- """Generate and open the internal POD report on-the-fly (Ontario Works)."""
- self.ensure_one()
- report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard')
- return report.report_action(self)
-
- def action_open_odsp_std_approval_document(self):
- """Open the Standard ODSP Approval Document."""
- self.ensure_one()
- return self._action_open_document('x_fc_odsp_approval_document', 'ODSP Approval Document')
-
- def action_open_odsp_std_authorizer_letter(self):
- """Open the Standard ODSP Authorizer Letter."""
- self.ensure_one()
- return self._action_open_document('x_fc_odsp_authorizer_letter', 'Authorizer Letter')
-
- def action_open_odsp_std_internal_pod(self):
- """Generate and open the internal POD report on-the-fly (Standard ODSP)."""
- self.ensure_one()
- report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard')
- return report.report_action(self)
-
- def _get_sa_pod_pdf(self):
- """Generate the standard POD report PDF and return (pdf_bytes, filename)."""
- self.ensure_one()
- report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard')
- pdf_content, _ct = report._render_qweb_pdf(report.id, [self.id])
- return pdf_content, f'POD_{self.name}.pdf'
-
- def action_view_approval_photos(self):
- """Open approval photos using Odoo's native attachment viewer."""
- self.ensure_one()
- attachments = self.x_fc_approval_photo_ids
- if not attachments:
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'No Photos',
- 'message': 'No approval screenshots have been uploaded yet.',
- 'type': 'warning',
- 'sticky': False,
- }
- }
-
- # Use Odoo's native attachment viewer (same as chatter)
- return {
- 'type': 'ir.actions.act_url',
- 'url': f'/web/image/{attachments[0].id}',
- 'target': 'new',
- }
-
- def _action_open_document(self, field_name, document_label, download=False, is_xml=False):
- """Open a document in a preview dialog (PDF or XML viewer)."""
- self.ensure_one()
-
- # Check if the field has data
- if not getattr(self, field_name):
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'No Document',
- 'message': f'No {document_label} has been uploaded yet.',
- 'type': 'warning',
- 'sticky': False,
- }
- }
-
- # Get or create attachment
- attachment = self._get_or_create_attachment(field_name, document_label)
-
- if attachment:
- if download:
- # Open in new tab for download
- return {
- 'type': 'ir.actions.act_url',
- 'url': f'/web/content/{attachment.id}?download=true',
- 'target': 'new',
- }
- elif is_xml:
- # For XML files, open in XML viewer dialog
- return {
- 'type': 'ir.actions.client',
- 'tag': 'fusion_claims.preview_xml',
- 'params': {
- 'attachment_id': attachment.id,
- 'title': f'{document_label} - {self.name}',
- }
- }
- else:
- # For PDF files, open in PDF preview dialog
- return {
- 'type': 'ir.actions.client',
- 'tag': 'fusion_claims.preview_document',
- 'params': {
- 'attachment_id': attachment.id,
- 'title': f'{document_label} - {self.name}',
- }
- }
- else:
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'Error',
- 'message': f'Failed to load {document_label}.',
- 'type': 'danger',
- 'sticky': False,
- }
- }
-
- @api.onchange('x_fc_sale_type', 'x_fc_client_type')
- def _onchange_sale_type_client_type(self):
- """Trigger recalculation when sale type or client type changes."""
- for line in self.order_line:
- line._compute_adp_portions()
-
- # ==========================================================================
- # GETTER METHODS
- # ==========================================================================
- def _get_sale_type(self):
- """Get sale type from x_fc_sale_type."""
- self.ensure_one()
- return self.x_fc_sale_type or ''
-
- def _get_client_type(self):
- """Get client type from x_fc_client_type."""
- self.ensure_one()
- return self.x_fc_client_type or ''
-
- def _get_authorizer(self):
- """Get authorizer from mapped field or built-in field. Returns name as string."""
- self.ensure_one()
- ICP = self.env['ir.config_parameter'].sudo()
- field_name = ICP.get_param('fusion_claims.field_so_authorizer', 'x_fc_authorizer_id')
- value = getattr(self, field_name, None) if hasattr(self, field_name) else None
- if not value and field_name != 'x_fc_authorizer_id':
- value = self.x_fc_authorizer_id
- # Return name if it's a record, otherwise return string value
- if hasattr(value, 'name'):
- return value.name or ''
- return str(value) if value else ''
-
- def _get_claim_number(self):
- """Get claim number."""
- self.ensure_one()
- return self.x_fc_claim_number or ''
-
- def _get_client_ref_1(self):
- """Get client reference 1."""
- self.ensure_one()
- return self.x_fc_client_ref_1 or ''
-
- def _get_client_ref_2(self):
- """Get client reference 2."""
- self.ensure_one()
- return self.x_fc_client_ref_2 or ''
-
- def _get_adp_delivery_date(self):
- """Get ADP delivery date."""
- self.ensure_one()
- return self.x_fc_adp_delivery_date
-
- def _is_adp_sale(self):
- """Check if this is an ADP sale type.
-
- Returns True only for ADP-related sale types.
- """
- self.ensure_one()
- sale_type = self.x_fc_sale_type or ''
-
- if not sale_type:
- return False
-
- sale_type_lower = str(sale_type).lower().strip()
- adp_keywords = ('adp',)
- return any(keyword in sale_type_lower for keyword in adp_keywords)
-
- def _get_serial_numbers(self):
- """Get all serial numbers from order lines."""
- self.ensure_one()
- serial_lines = []
- for line in self.order_line:
- serial = line._get_serial_number()
- if serial:
- serial_lines.append({
- 'product': line.product_id.name,
- 'serial': serial,
- 'adp_code': line._get_adp_device_code(),
- })
- return serial_lines
-
- # ==========================================================================
- # ACTION METHODS
- # ==========================================================================
- def action_recalculate_adp_portions(self):
- """Manually recalculate ADP and Client portions for all lines."""
- for order in self:
- for line in order.order_line:
- line._compute_adp_portions()
- order._compute_adp_totals()
-
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'ADP Portions Recalculated',
- 'message': 'All line portions have been recalculated.',
- 'type': 'success',
- 'sticky': False,
- }
- }
-
- def action_submit_to_adp(self):
- """Mark order as submitted to ADP."""
- for order in self:
- if order._is_adp_sale():
- order.x_fc_adp_status = 'submitted'
- return True
-
- def action_mark_adp_approved(self):
- """Mark order as approved by ADP."""
- for order in self:
- if order._is_adp_sale():
- order.x_fc_adp_status = 'approved'
- return True
-
- def action_open_device_approval_wizard(self):
- """Open the device approval wizard to verify which devices were approved by ADP."""
- self.ensure_one()
- return {
- 'name': 'Verify Device Approval',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.device.approval.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- },
- }
-
- def action_open_submission_verification_wizard(self):
- """Open the submission verification wizard to confirm which device types are being submitted."""
- self.ensure_one()
- return {
- 'name': 'Verify Submission Device Types',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.submission.verification.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- },
- }
-
- # ==========================================================================
- # EARLY WORKFLOW STAGE ACTIONS (No wizard required - simple status updates)
- # ==========================================================================
-
- def action_schedule_assessment(self):
- """Open wizard to schedule assessment with date/time and calendar event."""
- self.ensure_one()
- if self.x_fc_adp_application_status != 'quotation':
- raise UserError("Can only schedule assessment from 'Quotation' status.")
-
- return {
- 'name': 'Schedule Assessment',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.schedule.assessment.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- },
- }
-
- def action_complete_assessment(self):
- """Open wizard to mark assessment as completed with date."""
- self.ensure_one()
- if self.x_fc_adp_application_status != 'assessment_scheduled':
- raise UserError("Can only complete assessment from 'Assessment Scheduled' status.")
-
- return {
- 'name': 'Assessment Completed',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.assessment.completed.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- },
- }
-
- def action_application_received(self):
- """Open wizard to upload ADP application and pages 11 & 12."""
- self.ensure_one()
- if self.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'):
- raise UserError("Can only mark application received from 'Waiting for Application' status.")
-
- return {
- 'name': 'Application Received',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.application.received.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- },
- }
-
- def action_ready_for_submission(self):
- """Open wizard to collect required fields and mark as ready for submission."""
- self.ensure_one()
- if self.x_fc_adp_application_status != 'application_received':
- raise UserError("Can only mark ready for submission from 'Application Received' status.")
-
- return {
- 'name': 'Ready for Submission',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.ready.for.submission.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- },
- }
-
- # ==========================================================================
- # SUBMISSION WORKFLOW ACTIONS
- # ==========================================================================
-
- def action_submit_application(self):
- """Open submission verification wizard and submit the application.
-
- This forces verification of device types before changing status to 'submitted'.
- """
- self.ensure_one()
-
- # Validate we're in a status that can be submitted
- if self.x_fc_adp_application_status not in ('ready_submission', 'needs_correction'):
- raise UserError(
- "Application can only be submitted from 'Ready for Submission' or 'Needs Correction' status."
- )
-
- return {
- 'name': 'Submit Application - Verify Device Types',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.submission.verification.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- 'submit_application': True, # Flag to set status after verification
- },
- }
-
- def action_close_case(self):
- """Open case close verification wizard to verify audit trail before closing.
-
- This forces verification of:
- - Signed Pages 11 & 12
- - Final Application
- - Proof of Delivery
- - Vendor Bills
- """
- self.ensure_one()
-
- # Validate we're in a status that can be closed
- if self.x_fc_adp_application_status != 'billed':
- raise UserError(
- "Case can only be closed from 'Billed' status."
- )
-
- return {
- 'name': 'Close Case - Audit Trail Verification',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.case.close.verification.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- },
- }
-
- def action_mark_accepted(self):
- """Mark the application as accepted by ADP.
-
- This is called when ADP accepts the submission (within 24 hours).
- This is a simple status change - no wizard needed.
- Submission history is updated in the write() method.
- """
- self.ensure_one()
-
- # Validate we're in a status that can be accepted
- if self.x_fc_adp_application_status not in ('submitted', 'resubmitted'):
- raise UserError(
- "Application can only be marked as accepted from 'Submitted' or 'Resubmitted' status."
- )
-
- # Update status - this will trigger the write() method which updates submission history
- self.with_context(skip_status_validation=True).write({
- 'x_fc_adp_application_status': 'accepted',
- })
-
- # Post to chatter
- self.message_post(
- body=Markup(
- '
'
- '
Submission Accepted by ADP
'
- f'
The application has been accepted by ADP on {fields.Date.today().strftime("%B %d, %Y")}. '
- 'Awaiting approval decision (2-3 weeks).
'
- '
'
- ),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- return True
-
- def action_mark_approved(self):
- """Open device approval wizard and mark as approved.
-
- This is called when ADP approval letter is received.
- The wizard allows verifying which devices were approved.
- """
- self.ensure_one()
-
- # Validate we're in a status that can be approved
- if self.x_fc_adp_application_status not in ('submitted', 'resubmitted', 'accepted'):
- raise UserError(
- "Application can only be marked as approved from 'Submitted', 'Resubmitted', or 'Accepted' status."
- )
-
- return {
- 'name': 'Mark as Approved - Verify Device Approval',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.device.approval.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- 'mark_as_approved': True, # Flag to set status after verification
- },
- }
-
- def action_resume_from_hold(self):
- """Resume the application from on-hold status.
-
- Returns the application to its previous status before being put on hold.
- """
- self.ensure_one()
-
- if self.x_fc_adp_application_status != 'on_hold':
- raise UserError("This action is only available for applications that are On Hold.")
-
- # Get the previous status
- previous_status = self.x_fc_previous_status_before_hold
-
- # If no previous status recorded, default to 'approved'
- if not previous_status:
- previous_status = 'approved'
-
- # Get status labels for message
- status_labels = dict(self._fields['x_fc_adp_application_status'].selection)
- prev_label = status_labels.get(previous_status, previous_status)
-
- # Update the status
- self.with_context(skip_status_validation=True).write({
- 'x_fc_adp_application_status': previous_status,
- 'x_fc_on_hold_date': False,
- 'x_fc_previous_status_before_hold': False,
- })
-
- # Post to chatter
- user_name = self.env.user.name
- resume_date = fields.Date.today().strftime('%B %d, %Y')
-
- message_body = f'''
-
-
Application Resumed
-
-
Resumed By: {user_name}
-
Date: {resume_date}
-
Restored To: {prev_label}
-
-
- '''
-
- self.message_post(
- body=Markup(message_body),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- return True
-
- def action_set_ready_to_bill(self):
- """Open the Ready to Bill wizard to collect POD and delivery date.
-
- The wizard will:
- - Collect Proof of Delivery document
- - Set the delivery date
- - Validate device verification is complete
- - Mark the order as Ready to Bill
- """
- self.ensure_one()
-
- # Validate we're in a status that can move to ready_bill
- if self.x_fc_adp_application_status not in ('approved', 'approved_deduction'):
- raise UserError(
- "Order can only be marked as 'Ready to Bill' from 'Approved' status."
- )
-
- # Check device verification first (this can't be done in wizard)
- if not self.x_fc_device_verification_complete:
- raise UserError(
- "Device approval verification must be completed before marking as Ready to Bill.\n\n"
- "Please verify which devices were approved by ADP using the 'Mark as Approved' button first."
- )
-
- # Open the wizard to collect POD and delivery date
- return {
- 'name': 'Ready to Bill',
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion_claims.ready.to.bill.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- },
- }
-
- def action_set_ready_to_bill_direct(self):
- """Direct method to mark as ready to bill (used when POD already uploaded).
-
- This is kept for backward compatibility and for cases where POD is already uploaded.
- """
- self.ensure_one()
-
- # Validate we're in a status that can move to ready_bill
- if self.x_fc_adp_application_status not in ('approved', 'approved_deduction'):
- raise UserError(
- "Order can only be marked as 'Ready to Bill' from 'Approved' status."
- )
-
- # Check POD
- if not self.x_fc_proof_of_delivery:
- # Redirect to wizard
- return self.action_set_ready_to_bill()
-
- # Check delivery date
- if not self.x_fc_adp_delivery_date:
- # Redirect to wizard
- return self.action_set_ready_to_bill()
-
- # Check device verification
- if not self.x_fc_device_verification_complete:
- raise UserError(
- "Device approval verification must be completed before marking as Ready to Bill.\n\n"
- "Please verify which devices were approved by ADP using the 'Mark as Approved' button first."
- )
-
- # All validations passed - set status
- from markupsafe import Markup
- from datetime import date
-
- self.with_context(skip_status_validation=True).write({
- 'x_fc_adp_application_status': 'ready_bill',
- })
-
- # Post to chatter with nice card style using Bootstrap classes for dark/light mode
- self.message_post(
- body=Markup(
- '
'
- '
Ready to Bill
'
- f'
Date: {date.today().strftime("%B %d, %Y")}
'
- '
Prerequisites Verified:
'
- '
'
- '
Proof of Delivery uploaded
'
- '
ADP Delivery Date set
'
- '
Device verification complete
'
- '
'
- '
'
- ),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- return True
-
- def action_mark_as_billed(self):
- """Mark order as billed after validating invoices are posted.
-
- Validates:
- - ADP invoice exists and is posted
- - For REG clients: Client invoice exists and is posted
- """
- self.ensure_one()
-
- # Validate we're in ready_bill status
- if self.x_fc_adp_application_status != 'ready_bill':
- raise UserError(
- "Order can only be marked as 'Billed' from 'Ready to Bill' status."
- )
-
- # Check ADP invoice
- AccountMove = self.env['account.move'].sudo()
- adp_invoices = AccountMove.search([
- ('x_fc_source_sale_order_id', '=', self.id),
- ('x_fc_adp_invoice_portion', '=', 'adp'),
- ('state', '=', 'posted'),
- ])
-
- if not adp_invoices:
- raise UserError(
- "ADP invoice must be created and posted before marking as Billed.\n\n"
- "Please create and post the ADP invoice first."
- )
-
- # For REG clients, check client invoice
- if self.x_fc_client_type == 'REG':
- client_invoices = AccountMove.search([
- ('x_fc_source_sale_order_id', '=', self.id),
- ('x_fc_adp_invoice_portion', '=', 'client'),
- ('state', '=', 'posted'),
- ])
-
- if not client_invoices:
- raise UserError(
- "Client invoice must be created and posted before marking as Billed.\n\n"
- "For REG clients, both the ADP invoice (75%) and Client invoice (25%) must be posted."
- )
-
- # All validations passed - set status and billing date
- from markupsafe import Markup
- from datetime import date
-
- self.with_context(skip_status_validation=True).write({
- 'x_fc_adp_application_status': 'billed',
- 'x_fc_billing_date': date.today(),
- })
-
- # Update ADP invoice billing status to 'submitted'
- for adp_inv in adp_invoices:
- if adp_inv.x_fc_adp_billing_status in ('waiting', 'not_applicable'):
- adp_inv.write({'x_fc_adp_billing_status': 'submitted'})
- _logger.info(f"Updated ADP invoice {adp_inv.name} billing status to 'submitted'")
-
- # Build invoice list
- invoice_list = ', '.join(adp_invoices.mapped('name'))
- if self.x_fc_client_type == 'REG':
- invoice_list += ', ' + ', '.join(client_invoices.mapped('name'))
-
- # Calculate total billed
- total_billed = sum(adp_invoices.mapped('amount_total'))
- if self.x_fc_client_type == 'REG':
- total_billed += sum(client_invoices.mapped('amount_total'))
-
- # Post to chatter with nice card style using Bootstrap classes for dark/light mode
- self.message_post(
- body=Markup(
- '
'
- ),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- return True
-
- def _check_unapproved_devices(self):
- """Check if there are any unapproved ADP devices in the order.
-
- Only checks lines with valid ADP device codes in the database.
- Non-ADP items are ignored.
- """
- self.ensure_one()
- ADPDevice = self.env['fusion.adp.device.code'].sudo()
-
- unapproved = self.env['sale.order.line']
- for line in self.order_line:
- if line.display_type:
- continue
- if not line.product_id or line.product_uom_qty <= 0:
- continue
- if line.x_fc_adp_approved:
- continue
-
- # Check if this has a valid ADP device code
- device_code = line._get_adp_device_code()
- if device_code and ADPDevice.search_count([('device_code', '=', device_code), ('active', '=', True)]) > 0:
- unapproved |= line
-
- return unapproved
-
- def _get_approved_devices_summary(self):
- """Get a summary of approved vs unapproved devices."""
- self.ensure_one()
- lines_with_codes = self.order_line.filtered(
- lambda l: not l.display_type
- and l.product_id
- and l.product_uom_qty > 0
- and l._get_adp_device_code()
- )
- approved = lines_with_codes.filtered(lambda l: l.x_fc_adp_approved)
- unapproved = lines_with_codes - approved
- return {
- 'total': len(lines_with_codes),
- 'approved': len(approved),
- 'unapproved': len(unapproved),
- 'unapproved_lines': unapproved,
- }
-
- def action_mark_client_paid(self):
- """Mark order as client paid (25%)."""
- for order in self:
- if order._is_adp_sale():
- order.x_fc_adp_status = 'client_paid'
- return True
-
- def action_mark_delivered(self):
- """Mark order as delivered."""
- for order in self:
- if order._is_adp_sale():
- order.x_fc_adp_status = 'delivered'
- return True
-
- def action_mark_billed(self):
- """Mark order as billed to ADP (75%)."""
- for order in self:
- if order._is_adp_sale():
- order.x_fc_adp_status = 'billed'
- return True
-
- def action_mark_closed(self):
- """Mark order as closed."""
- for order in self:
- if order._is_adp_sale():
- order.x_fc_adp_status = 'closed'
- return True
-
- # ==========================================================================
- # VIEW INVOICES BY TYPE (Smart button actions)
- # ==========================================================================
- def action_view_adp_invoices(self):
- """Open list of ADP portion invoices for this order."""
- self.ensure_one()
- adp_invoices = self.env['account.move'].sudo().search([
- ('x_fc_source_sale_order_id', '=', self.id),
- ('x_fc_adp_invoice_portion', '=', 'adp'),
- ('state', '!=', 'cancel'),
- ])
- # Include manually mapped ADP invoice
- if self.x_fc_adp_invoice_id and self.x_fc_adp_invoice_id.state != 'cancel':
- adp_invoices |= self.x_fc_adp_invoice_id
-
- action = {
- 'name': 'ADP Invoices',
- 'type': 'ir.actions.act_window',
- 'res_model': 'account.move',
- 'view_mode': 'list,form',
- 'domain': [('id', 'in', adp_invoices.ids)],
- 'context': {'default_move_type': 'out_invoice'},
- }
- if len(adp_invoices) == 1:
- action['view_mode'] = 'form'
- action['res_id'] = adp_invoices.id
- return action
-
- def action_view_client_invoices(self):
- """Open list of Client portion invoices for this order."""
- self.ensure_one()
- client_invoices = self.env['account.move'].sudo().search([
- ('x_fc_source_sale_order_id', '=', self.id),
- ('x_fc_adp_invoice_portion', '=', 'client'),
- ('state', '!=', 'cancel'),
- ])
- # Include manually mapped Client invoice
- if self.x_fc_client_invoice_id and self.x_fc_client_invoice_id.state != 'cancel':
- client_invoices |= self.x_fc_client_invoice_id
-
- action = {
- 'name': 'Client Invoices',
- 'type': 'ir.actions.act_window',
- 'res_model': 'account.move',
- 'view_mode': 'list,form',
- 'domain': [('id', 'in', client_invoices.ids)],
- 'context': {'default_move_type': 'out_invoice'},
- }
- if len(client_invoices) == 1:
- action['view_mode'] = 'form'
- action['res_id'] = client_invoices.id
- return action
-
- # ==========================================================================
- # SPLIT INVOICE CREATION (Client 25% and ADP 75%)
- # ==========================================================================
- def action_create_client_invoice(self):
- """Create invoice for client portion (25% for REG clients, 0% for others).
-
- NOTE: Client invoice can be created WITHOUT device verification.
- This allows clients to pay their portion and receive products before ADP approval.
- Device verification is only required for ADP invoice creation.
-
- BLOCKING for Modification reasons:
- - Modifications to NON-ADP Equipment and Modifications to ADP Equipment
- require ADP approval before client invoice can be created.
-
- WARNING for Replacement reasons:
- - If previous funding was less than 5 years ago, show warning about possible deductions.
- """
- self.ensure_one()
- if not self._is_adp_sale():
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'Not an ADP Sale',
- 'message': 'Client invoices are only for ADP sales.',
- 'type': 'warning',
- }
- }
-
- client_type = self._get_client_type()
- if client_type != 'REG':
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'No Client Portion',
- 'message': f'{client_type} clients have 100% ADP funding. No client invoice needed.',
- 'type': 'info',
- }
- }
-
- # =================================================================
- # BLOCKING: Modification reasons require ADP approval first
- # =================================================================
- reason = self.x_fc_reason_for_application
- status = self.x_fc_adp_application_status
-
- if reason in ('mod_non_adp', 'mod_adp'):
- if status not in ('approved', 'approved_deduction'):
- reason_label = dict(self._fields['x_fc_reason_for_application'].selection or []).get(
- reason, reason
- )
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'ADP Approval Required',
- 'message': f'Cannot create client invoice for "{reason_label}".\n\n'
- f'ADP application must be approved before creating invoices for modification requests.\n\n'
- f'Current status: {status or "Not set"}',
- 'type': 'danger',
- 'sticky': True,
- }
- }
-
- # =================================================================
- # WARNING: Replacement reasons with <5 years funding
- # =================================================================
- if reason in ('replace_status', 'replace_size', 'replace_worn') and self.x_fc_under_5_years:
- years = self.x_fc_years_since_funding
- # Show warning but allow proceeding
- self.message_post(
- body=Markup(
- '
'
- ' Replacement Warning '
- f'Previous funding was only {years:.1f} years ago (less than 5 years). '
- 'ADP may apply deductions to this replacement claim. Please verify the approval letter.'
- '
'
- ),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- # NO VERIFICATION CHECK - Client invoice can be created before ADP approval
- # User will need to complete verification before creating ADP invoice
-
- # Create client invoice (25% portion)
- invoice = self._create_adp_split_invoice(invoice_type='client')
-
- if invoice:
- result = {
- 'name': 'Client Invoice (25%)',
- 'type': 'ir.actions.act_window',
- 'res_model': 'account.move',
- 'res_id': invoice.id,
- 'view_mode': 'form',
- 'target': 'current',
- }
- return result
- return True
-
- def action_create_adp_invoice(self):
- """Create invoice for ADP portion (75% for REG clients, 100% for others).
-
- NOTE: Device verification MUST be completed before creating ADP invoice.
- Verification can be done from the Sales Order or from the Client Invoice (if created first).
-
- Proof of Delivery is REQUIRED before ADP invoice creation.
- """
- self.ensure_one()
- if not self._is_adp_sale():
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'Not an ADP Sale',
- 'message': 'ADP invoices are only for ADP sales.',
- 'type': 'warning',
- }
- }
-
- # =================================================================
- # REQUIREMENT: Proof of Delivery must be uploaded
- # =================================================================
- if not self.x_fc_proof_of_delivery:
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'Proof of Delivery Required',
- 'message': 'Please upload the Proof of Delivery document in the ADP Documents tab before creating the ADP invoice.',
- 'type': 'danger',
- 'sticky': True,
- }
- }
-
- # =================================================================
- # REQUIREMENT: Device verification MUST be complete for ADP invoice
- # =================================================================
- if not self.x_fc_device_verification_complete:
- # Check if there's a client invoice - provide appropriate message
- client_invoice = self.env['account.move'].sudo().search([
- ('x_fc_source_sale_order_id', '=', self.id),
- ('x_fc_adp_invoice_portion', '=', 'client'),
- ('state', '!=', 'cancel'),
- ], limit=1)
-
- device_count = self.x_fc_total_device_count
- approved_count = self.x_fc_approved_device_count
-
- if device_count > 0:
- device_info = f'{approved_count}/{device_count} devices verified.'
- else:
- device_info = 'No ADP devices detected on this order.'
-
- if client_invoice:
- message = (
- f'Cannot create ADP invoice: Device verification is not complete.\n\n'
- f'{device_info}\n\n'
- f'Please complete verification from either:\n'
- f'• This Sales Order: Click "Verify Device Approval"\n'
- f'• Client Invoice ({client_invoice.name}): Click "Verify Device Approval"'
- )
- else:
- message = (
- f'Cannot create ADP invoice: Device verification is not complete.\n\n'
- f'{device_info}\n\n'
- f'Click "Verify Device Approval" to review which devices were approved by ADP.'
- )
-
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'Device Verification Required',
- 'message': message,
- 'type': 'danger',
- 'sticky': True,
- }
- }
-
- # =================================================================
- # REQUIREMENT: At least one device must be approved for ADP invoice
- # =================================================================
- approved_count = self.x_fc_approved_device_count
- total_count = self.x_fc_total_device_count
-
- if approved_count == 0:
- if total_count > 0:
- message = (
- f'Cannot create ADP invoice: No devices are approved.\n\n'
- f'All {total_count} device(s) on this order were marked as NOT approved by ADP.\n\n'
- f'If this is incorrect, click "Verify Device Approval" to update the approval status.'
- )
- else:
- message = (
- 'Cannot create ADP invoice: No ADP-funded devices found.\n\n'
- 'This order has no products with ADP device codes. '
- 'ADP invoices can only be created for orders with approved ADP devices.'
- )
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'No Approved Devices',
- 'message': message,
- 'type': 'danger',
- 'sticky': True,
- }
- }
-
- # Check for unapproved devices - show info message but allow creation
- # Unapproved items will be excluded from the ADP invoice
- unapproved = self._check_unapproved_devices()
- unapproved_message = None
- if unapproved:
- device_names = ', '.join(unapproved.mapped('product_id.name')[:3])
- if len(unapproved) > 3:
- device_names += f' and {len(unapproved) - 3} more'
- unapproved_message = f"Note: {len(unapproved)} unapproved item(s) will be excluded: {device_names}"
-
- # Create ADP invoice
- invoice = self._create_adp_split_invoice(invoice_type='adp')
-
- if invoice:
- return {
- 'name': 'ADP Invoice',
- 'type': 'ir.actions.act_window',
- 'res_model': 'account.move',
- 'res_id': invoice.id,
- 'view_mode': 'form',
- 'target': 'current',
- }
- return True
-
- def _create_adp_split_invoice(self, invoice_type='client'):
- """
- Create a split invoice for ADP sales.
-
- Args:
- invoice_type: 'client' for client portion invoice, 'adp' for ADP portion invoice
-
- Returns:
- account.move record
- """
- self.ensure_one()
-
- # Re-read fresh data from the database to get current values
- self.invalidate_recordset()
-
- # Get client type to determine percentages
- client_type = self._get_client_type()
- if client_type == 'REG':
- client_pct = 0.25
- adp_pct = 0.75
- else:
- # ODS, OWP, ACS, etc. - 100% ADP, 0% client
- client_pct = 0.0
- adp_pct = 1.0
-
- if invoice_type == 'client' and client_pct == 0:
- return False # No client invoice for non-REG clients
-
- # Determine invoice label
- if invoice_type == 'client':
- invoice_name_suffix = f' (Client {int(client_pct*100)}%)'
- else:
- invoice_name_suffix = f' (ADP {int(adp_pct*100)}%)'
-
- # Prepare base invoice values
- invoice_vals = self._prepare_invoice()
- invoice_vals['invoice_origin'] = f"{self.name}{invoice_name_suffix}"
-
- # Add marker for invoice type and link to source sale order
- invoice_vals['x_fc_adp_invoice_portion'] = invoice_type
- invoice_vals['x_fc_source_sale_order_id'] = self.id
-
- # Copy Studio fields if they exist on the invoice model
- # Use helper function to safely set values
- AccountMove = self.env['account.move']
-
- def safe_set_field(field_name, value, target_dict):
- """Safely set a field value, checking field type and valid options."""
- if field_name not in AccountMove._fields:
- return
- field = AccountMove._fields[field_name]
- try:
- if field.type == 'boolean':
- # Convert to boolean
- target_dict[field_name] = bool(value)
- elif field.type == 'selection':
- # Check if value is valid
- selection = field.selection
- if callable(selection):
- selection = selection(AccountMove)
- valid_values = [s[0] for s in selection] if selection else []
- if value in valid_values:
- target_dict[field_name] = value
- elif str(value).lower() in [v.lower() for v in valid_values if isinstance(v, str)]:
- # Find the matching case
- for v in valid_values:
- if isinstance(v, str) and v.lower() == str(value).lower():
- target_dict[field_name] = v
- break
- elif field.type == 'many2one':
- # Handle Many2one - pass record id or False
- if hasattr(value, 'id'):
- target_dict[field_name] = value.id
- elif value:
- target_dict[field_name] = value
- else:
- # Char, Text, etc. - just set directly
- target_dict[field_name] = value
- except Exception:
- pass # Skip if any error
-
- # Invoice type will be set based on invoice_type parameter ('adp' or 'client')
- # and the sale type - handled below when setting x_fc_invoice_type
-
- # Copy primary serial to invoice
- primary_serial = self.x_fc_primary_serial
- if primary_serial:
- invoice_vals['x_fc_primary_serial'] = primary_serial
-
- # Set invoice type based on invoice_type parameter and sale type
- if invoice_type == 'client':
- # Client portion invoice - set to 'adp_client'
- fc_invoice_type = 'adp_client'
- else:
- # ADP/Funder portion invoice - use the sale type directly
- # This preserves the sale type context (ADP, ADP/ODSP, ODSP, WSIB, etc.)
- sale_type = self.x_fc_sale_type or 'adp'
- # For ADP-related sale types, use the sale type as invoice type
- fc_invoice_type = sale_type
-
- # For ADP invoices: Change customer to ADP contact, keep original client as delivery address
- original_partner = self.partner_id
- original_delivery = self.partner_shipping_id or self.partner_id
-
- # Find the ADP contact (search by name)
- adp_partner = self.env['res.partner'].sudo().search([
- '|', '|', '|',
- ('name', 'ilike', 'ADP (Assistive Device Program)'),
- ('name', 'ilike', 'Assistive Device Program'),
- ('name', '=', 'ADP'),
- ('name', 'ilike', 'ADP -'),
- ], limit=1)
-
- if adp_partner:
- # Set ADP as the invoice customer
- invoice_vals['partner_id'] = adp_partner.id
- # Keep original client as delivery address
- invoice_vals['partner_shipping_id'] = original_partner.id
-
- invoice_vals.update({
- 'x_fc_invoice_type': fc_invoice_type,
- 'x_fc_client_type': self._get_client_type(),
- 'x_fc_claim_number': self.x_fc_claim_number,
- 'x_fc_client_ref_1': self.x_fc_client_ref_1,
- 'x_fc_client_ref_2': self.x_fc_client_ref_2,
- 'x_fc_adp_delivery_date': self.x_fc_adp_delivery_date,
- 'x_fc_service_start_date': self.x_fc_service_start_date,
- 'x_fc_service_end_date': self.x_fc_service_end_date,
- 'x_fc_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False,
- # Set ADP billing status to 'waiting' by default for ADP invoices
- 'x_fc_adp_billing_status': 'waiting' if invoice_type == 'adp' else 'not_applicable',
- })
-
- # Create invoice
- invoice = self.env['account.move'].sudo().create(invoice_vals)
-
- # Create invoice lines - include sections, notes, AND products
- #
- # PORTION CALCULATION:
- # - Get ADP price from device codes database (priority) or product field
- # - Calculate portions based on client type: REG = 75%/25%, Others = 100%/0%
- # - Client Invoice: price_unit = client portion per unit
- # - ADP Invoice: price_unit = ADP portion per unit
- # - Both portions stored on line for reference
- #
- ADPDevice = self.env['fusion.adp.device.code'].sudo()
- invoice_lines = []
- price_mismatches = [] # Track products with price mismatches
-
- for line in self.order_line:
- # For section and note lines, create minimal line
- if line.display_type in ('line_section', 'line_note'):
- invoice_lines.append({
- 'move_id': invoice.id,
- 'display_type': line.display_type,
- 'name': line.name,
- 'sequence': line.sequence,
- })
- continue
-
- # Skip lines without products or zero quantity
- if not line.product_id or line.product_uom_qty <= 0:
- continue
-
- # =================================================================
- # CHECK 1: Is this a NON-ADP funded product?
- # =================================================================
- is_non_adp_funded = line.product_id.is_non_adp_funded()
-
- # =================================================================
- # CHECK 2: Get ADP device info from database
- # =================================================================
- device_code = line._get_adp_device_code()
- adp_device = None
- is_adp_device = False
- db_adp_price = 0
-
- if device_code and not is_non_adp_funded:
- adp_device = ADPDevice.search([
- ('device_code', '=', device_code),
- ('active', '=', True)
- ], limit=1)
- is_adp_device = bool(adp_device)
- if adp_device:
- db_adp_price = adp_device.adp_price or 0
-
- # Determine if item is approved
- is_approved = line.x_fc_adp_approved
-
- # =================================================================
- # GET ADP PRICE - Priority: DB > Product Field > Line Price
- # =================================================================
- product_tmpl = line.product_id.product_tmpl_id
- product_adp_price = 0
-
- # Try product fields
- if hasattr(product_tmpl, 'x_fc_adp_price'):
- product_adp_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0
- # Determine final ADP price to use
- if db_adp_price > 0:
- adp_max_price = db_adp_price
- # Check for price mismatch
- if product_adp_price > 0 and abs(db_adp_price - product_adp_price) > 0.01:
- price_mismatches.append({
- 'product': line.product_id,
- 'device_code': device_code,
- 'db_price': db_adp_price,
- 'product_price': product_adp_price,
- })
- elif product_adp_price > 0:
- adp_max_price = product_adp_price
- else:
- # Fallback to selling price
- adp_max_price = line.price_unit
-
- # =================================================================
- # CALCULATE PORTIONS based on client type
- # =================================================================
- qty = line.product_uom_qty
- total_adp_base = adp_max_price * qty
-
- if is_non_adp_funded or not is_adp_device:
- # NON-ADP item: Client pays 100%
- adp_portion = 0
- client_portion = line.price_subtotal
- is_full_client = True
- elif is_adp_device and not is_approved:
- # ADP device NOT approved: Client pays 100%
- adp_portion = 0
- client_portion = line.price_subtotal
- is_full_client = True
- else:
- # ADP device APPROVED: Calculate based on client type
- is_full_client = False
- if client_type == 'REG':
- adp_portion = total_adp_base * 0.75
- client_portion = total_adp_base * 0.25
- else:
- # ODS, OWP, ACS, etc. = 100% ADP
- adp_portion = total_adp_base
- client_portion = 0
-
- # Apply deductions if any
- if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
- # PCT deduction: ADP pays X% of their normal portion
- effective_pct = line.x_fc_deduction_value / 100
- if client_type == 'REG':
- adp_portion = total_adp_base * 0.75 * effective_pct
- else:
- adp_portion = total_adp_base * effective_pct
- client_portion = total_adp_base - adp_portion
- elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
- # AMT deduction: Fixed amount deducted from ADP
- adp_portion = max(0, adp_portion - line.x_fc_deduction_value)
- client_portion = total_adp_base - adp_portion
-
- # =================================================================
- # DETERMINE INVOICE LINE AMOUNT
- # =================================================================
- if invoice_type == 'client':
- if is_full_client:
- portion_amount = client_portion
- line_name = line.name if not (is_adp_device and not is_approved) else f"{line.name} [NOT APPROVED - 100% Client]"
- else:
- portion_amount = client_portion
- line_name = line.name
- else: # ADP invoice
- if is_non_adp_funded or not is_adp_device or (is_adp_device and not is_approved):
- # Skip from ADP invoice
- continue
- portion_amount = adp_portion
- line_name = line.name
-
- # Calculate adjusted price per unit
- adjusted_price = portion_amount / qty if qty else 0
-
- # Build invoice line vals
- line_vals = {
- 'move_id': invoice.id,
- 'product_id': line.product_id.id,
- 'name': line_name,
- 'quantity': qty,
- 'product_uom_id': line.product_uom_id.id,
- 'price_unit': adjusted_price,
- 'discount': line.discount,
- 'tax_ids': [(6, 0, line.tax_ids.ids)],
- 'sale_line_ids': [(6, 0, [line.id])],
- 'sequence': line.sequence,
- }
-
- # Copy serial number and other fields
- if 'x_fc_serial_number' in self.env['account.move.line']._fields:
- line_vals['x_fc_serial_number'] = line.x_fc_serial_number
- if 'x_fc_device_placement' in self.env['account.move.line']._fields:
- line_vals['x_fc_device_placement'] = line.x_fc_device_placement
-
- # Store BOTH portions on invoice line (for display)
- if 'x_fc_adp_portion' in self.env['account.move.line']._fields:
- line_vals['x_fc_adp_portion'] = adp_portion
- if 'x_fc_client_portion' in self.env['account.move.line']._fields:
- line_vals['x_fc_client_portion'] = client_portion
- if 'x_fc_adp_max_price' in self.env['account.move.line']._fields:
- line_vals['x_fc_adp_max_price'] = adp_max_price
- if 'x_fc_adp_approved' in self.env['account.move.line']._fields:
- line_vals['x_fc_adp_approved'] = is_approved
- if 'x_fc_adp_device_type' in self.env['account.move.line']._fields and adp_device:
- line_vals['x_fc_adp_device_type'] = adp_device.device_type or ''
-
- invoice_lines.append(line_vals)
-
- if invoice_lines:
- self.env['account.move.line'].sudo().create(invoice_lines)
-
- # =================================================================
- # POST PRICE MISMATCH WARNINGS
- # =================================================================
- if price_mismatches:
- mismatch_msg = '
⚠️ ADP Price Mismatches Detected
'
- for pm in price_mismatches:
- mismatch_msg += (
- f'
{pm["product"].name} ({pm["device_code"]}): '
- f'Product price ${pm["product_price"]:.2f} vs Database ${pm["db_price"]:.2f}
'
- )
- mismatch_msg += '
Database prices were used. Consider updating product prices.
'
- self.message_post(body=mismatch_msg, message_type='notification', subtype_xmlid='mail.mt_note')
-
- # Auto-update product prices from database
- for pm in price_mismatches:
- product_tmpl = pm['product'].product_tmpl_id
- if hasattr(product_tmpl, 'x_fc_adp_price'):
- product_tmpl.sudo().write({'x_fc_adp_price': pm['db_price']})
-
- # =================================================================
- # POST INVOICE CREATION MESSAGE TO CHATTER
- # =================================================================
- if invoice and invoice_lines:
- # Calculate totals from the created invoice
- invoice.invalidate_recordset()
- invoice_total = invoice.amount_total
-
- # Calculate portion totals from the lines we just created
- total_adp_portion = sum(
- line_vals.get('x_fc_adp_portion', 0)
- for line_vals in invoice_lines
- if isinstance(line_vals, dict) and 'x_fc_adp_portion' in line_vals
- )
- total_client_portion = sum(
- line_vals.get('x_fc_client_portion', 0)
- for line_vals in invoice_lines
- if isinstance(line_vals, dict) and 'x_fc_client_portion' in line_vals
- )
-
- user_name = self.env.user.name
- create_date = fields.Date.today().strftime('%B %d, %Y')
- client_name = self.partner_id.name or 'N/A'
-
- if invoice_type == 'client':
- # Client Invoice - Blue theme using Bootstrap
- pct_label = f'{int(client_pct*100)}%'
-
- invoice_msg = Markup(f'''
''')
-
- self.message_post(
- body=invoice_msg,
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- return invoice
-
- # ==========================================================================
- # OVERRIDE _get_invoiceable_lines TO INCLUDE ALL SECTIONS AND NOTES
- # ==========================================================================
- def _get_invoiceable_lines(self, final=False):
- """Override to ensure ALL sections and notes are included in invoices.
-
- Standard Odoo behavior only includes sections/notes if they have invoiceable
- product lines AFTER them. This causes warranty, refund policy, and other
- important sections at the end of the order to be dropped from invoices.
-
- This override includes all sections and notes regardless of position.
- """
- # Get the standard invoiceable lines first
- invoiceable_lines = super()._get_invoiceable_lines(final)
-
- # Collect all section and note lines from the order
- all_display_lines = self.order_line.filtered(
- lambda l: l.display_type in ('line_section', 'line_subsection', 'line_note')
- )
-
- # Add any sections/notes that weren't included by the standard method
- missing_display_lines = all_display_lines - invoiceable_lines
-
- if missing_display_lines:
- # Combine and sort by sequence to maintain order
- combined = invoiceable_lines | missing_display_lines
- return combined.sorted(key=lambda l: l.sequence)
-
- return invoiceable_lines
-
- # ==========================================================================
- # INVOICE PREPARATION (Copy ADP fields to Invoice)
- # ==========================================================================
- def _prepare_invoice(self):
- """Override to copy ADP fields to the invoice."""
- vals = super()._prepare_invoice()
- if self._is_adp_sale():
- # Normalize sale type to match x_fc_invoice_type selection (lowercase)
- sale_type_raw = self.x_fc_sale_type or ''
- sale_type_normalized = str(sale_type_raw).lower() if sale_type_raw else 'adp'
- valid_types = ('adp', 'adp_odsp', 'odsp', 'wsib', 'direct_private', 'insurance',
- 'march_of_dimes', 'muscular_dystrophy', 'other', 'rental')
- if sale_type_normalized not in valid_types:
- if 'adp' in sale_type_normalized:
- sale_type_normalized = 'adp'
- else:
- sale_type_normalized = 'other'
-
- vals.update({
- 'x_fc_invoice_type': sale_type_normalized,
- 'x_fc_client_type': self.x_fc_client_type,
- 'x_fc_claim_number': self.x_fc_claim_number,
- 'x_fc_client_ref_1': self.x_fc_client_ref_1,
- 'x_fc_client_ref_2': self.x_fc_client_ref_2,
- 'x_fc_adp_delivery_date': self.x_fc_adp_delivery_date,
- 'x_fc_service_start_date': self.x_fc_service_start_date,
- 'x_fc_service_end_date': self.x_fc_service_end_date,
- 'x_fc_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False,
- })
- return vals
-
- # ==========================================================================
- # DOCUMENT CHATTER POSTING
- # ==========================================================================
- def _post_document_to_chatter(self, field_name, document_label=None):
- """Post a document attachment to the chatter with a link.
-
- Args:
- field_name: The binary field name (e.g., 'x_fc_final_submitted_application')
- document_label: Optional label for the document (defaults to field string)
- """
- self.ensure_one()
-
- # Map field names to filename fields
- filename_mapping = {
- 'x_fc_original_application': 'x_fc_original_application_filename',
- 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
- 'x_fc_final_submitted_application': 'x_fc_final_application_filename',
- 'x_fc_xml_file': 'x_fc_xml_filename',
- 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
- }
-
- data_field = field_name
- filename_field = filename_mapping.get(field_name, field_name + '_filename')
-
- data = getattr(self, data_field, None)
- original_filename = getattr(self, filename_field, None) or 'document'
-
- if not data:
- return
-
- # Get document label from field definition if not provided
- if not document_label:
- field_obj = self._fields.get(data_field)
- document_label = field_obj.string if field_obj else data_field
-
- # Check for existing attachments with same name for revision numbering
- existing_count = self.env['ir.attachment'].sudo().search_count([
- ('res_model', '=', 'sale.order'),
- ('res_id', '=', self.id),
- ('name', '=like', original_filename.rsplit('.', 1)[0] + '%'),
- ])
-
- # Add revision number if this is a replacement
- if existing_count > 0 and '(replaced)' in (document_label or ''):
- # This is an old document being replaced - add revision number
- base_name, ext = original_filename.rsplit('.', 1) if '.' in original_filename else (original_filename, '')
- filename = f"R{existing_count}_{base_name}.{ext}" if ext else f"R{existing_count}_{base_name}"
- else:
- filename = original_filename
-
- # Create attachment with the original/revised filename
- attachment = self.env['ir.attachment'].sudo().create({
- 'name': filename,
- 'datas': data,
- 'res_model': 'sale.order',
- 'res_id': self.id,
- })
-
- # Post message with attachment (shows as native Odoo attachment with preview)
- user_name = self.env.user.name
- now = fields.Datetime.now()
-
- body = Markup("""
-
{label} uploaded by {user}
-
{timestamp}
- """).format(
- label=document_label,
- user=user_name,
- timestamp=now.strftime('%Y-%m-%d %H:%M:%S')
- )
-
- # Use attachment_ids to show as native attachment with preview capability
- self.message_post(
- body=body,
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- attachment_ids=[attachment.id],
- )
-
- return attachment
-
- # ==========================================================================
- # AUTOMATIC EMAIL SENDING
- # ==========================================================================
-
- def _is_email_notifications_enabled(self):
- """Check if email notifications are enabled in settings."""
- ICP = self.env['ir.config_parameter'].sudo()
- return ICP.get_param('fusion_claims.enable_email_notifications', 'True').lower() in ('true', '1', 'yes')
-
- def _get_office_cc_emails(self):
- """Get office notification emails from company settings."""
- company = self.company_id or self.env.company
- partners = company.sudo().x_fc_office_notification_ids
- return [p.email for p in partners if p.email]
-
- def _get_email_recipients(self, include_client=False, include_authorizer=True, include_sales_rep=True):
- """Get standard email recipients for ADP notifications.
-
- Returns dict with:
- - 'to': List of primary recipient emails
- - 'cc': List of CC recipient emails
- - 'office_cc': List of office CC emails from settings
- """
- self.ensure_one()
- to_emails = []
- cc_emails = []
-
- # Get authorizer
- authorizer = self.x_fc_authorizer_id
-
- # Get sales rep
- sales_rep = self.user_id
-
- # Get client
- client = self.partner_id
-
- # Build recipient lists
- if include_client and client and client.email:
- to_emails.append(client.email)
-
- if include_authorizer and authorizer and authorizer.email:
- if to_emails:
- cc_emails.append(authorizer.email)
- else:
- to_emails.append(authorizer.email)
-
- if include_sales_rep and sales_rep and sales_rep.email:
- cc_emails.append(sales_rep.email)
-
- # Get office CC emails
- office_cc = self._get_office_cc_emails()
-
- return {
- 'to': to_emails,
- 'cc': cc_emails,
- 'office_cc': office_cc,
- 'authorizer': authorizer,
- 'sales_rep': sales_rep,
- 'client': client,
- }
-
- def _check_authorizer_portal_access(self):
- """Check if authorizer has logged into portal.
-
- Returns True if authorizer has a portal user with a password set.
- """
- self.ensure_one()
- authorizer = self.x_fc_authorizer_id
-
- if not authorizer:
- return False
-
- # Find portal user for this partner
- portal_user = self.env['res.users'].sudo().search([
- ('partner_id', '=', authorizer.id),
- ('share', '=', True), # Portal users have share=True
- ], limit=1)
-
- if not portal_user:
- return False
-
- # Check if user has logged in (has password and has login date)
- return bool(portal_user.login_date)
-
- def _build_case_detail_rows(self, include_amounts=False):
- """Build standard case detail rows for email templates."""
- self.ensure_one()
-
- def fmt(d):
- return d.strftime('%B %d, %Y') if d else None
-
- rows = [
- ('Case', self.name),
- ('Client', self.partner_id.name or 'N/A'),
- ('Claim Number', self.x_fc_claim_number or None),
- ('Client Ref 1', self.x_fc_client_ref_1 or None),
- ('Client Ref 2', self.x_fc_client_ref_2 or None),
- ('Assessment Date', fmt(self.x_fc_assessment_end_date)),
- ('Submission Date', fmt(self.x_fc_claim_submission_date)),
- ('Approval Date', fmt(self.x_fc_claim_approval_date)),
- ('Delivery Date', fmt(self.x_fc_adp_delivery_date)),
- ]
- if include_amounts:
- rows.extend([
- ('ADP Portion', f'${self.x_fc_adp_portion_total or 0:,.2f}'),
- ('Client Portion', f'${self.x_fc_client_portion_total or 0:,.2f}'),
- ('Total', f'${self.amount_total:,.2f}'),
- ])
- # Filter out None values
- return [(l, v) for l, v in rows if v is not None]
-
- def _email_chatter_log(self, label, email_to, email_cc=None, extra_lines=None):
- """Post a concise chatter note confirming an email was sent."""
- lines = [f'
To: {email_to}
']
- if email_cc:
- lines.append(f'
CC: {email_cc}
')
- if extra_lines:
- for line in extra_lines:
- lines.append(f'
{line}
')
- body = Markup(
- '
'
- f'{label}'
- f'
{"".join(lines)}
'
- '
'
- )
- self.message_post(body=body, message_type='notification', subtype_xmlid='mail.mt_note')
-
- def _send_submission_email(self):
- """Send email when application is submitted with PDF and XML attachments."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
-
- recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
- if not to_emails and not cc_emails:
- return False
-
- # Reuse existing field attachments (created by Odoo for attachment=True fields)
- # instead of creating duplicates
- attachments = []
- attachment_names = []
- Attachment = self.env['ir.attachment'].sudo()
- if self.x_fc_final_submitted_application:
- att = Attachment.search([
- ('res_model', '=', 'sale.order'),
- ('res_id', '=', self.id),
- ('res_field', '=', 'x_fc_final_submitted_application'),
- ], order='id desc', limit=1)
- if att:
- attachments.append(att.id)
- attachment_names.append('Final ADP Application (PDF)')
- if self.x_fc_xml_file:
- att = Attachment.search([
- ('res_model', '=', 'sale.order'),
- ('res_id', '=', self.id),
- ('res_field', '=', 'x_fc_xml_file'),
- ], order='id desc', limit=1)
- if att:
- attachments.append(att.id)
- attachment_names.append('XML Data File')
-
- client_name = recipients.get('client', self.partner_id).name or 'Client'
- sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
- submission_date = self.x_fc_claim_submission_date.strftime('%B %d, %Y') if self.x_fc_claim_submission_date else 'Today'
-
- body_html = self._email_build(
- title='Application Submitted',
- summary=f'The ADP application for {client_name} has been submitted on {submission_date}.',
- email_type='info',
- sections=[('Case Details', self._build_case_detail_rows())],
- note='What happens next: The Assistive Devices Program will review the application. '
- 'This typically takes 2-4 weeks. We will notify you as soon as we receive a decision.',
- attachments_note=', '.join(attachment_names) if attachment_names else None,
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
-
- email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
- email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
- try:
- mail = self.env['mail.mail'].sudo().create({
- 'subject': f'Application Submitted - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': email_to, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- 'attachment_ids': [(6, 0, attachments)] if attachments else False,
- })
- mail.send()
- self._email_chatter_log('Application Submitted email sent', email_to, email_cc,
- [f'Attachments: {", ".join(attachment_names)}'] if attachment_names else None)
- _logger.info(f"Submission email sent for {self.name}")
- return True
- except Exception as e:
- _logger.error(f"Failed to send submission email for {self.name}: {e}")
- return False
-
- @api.model
- def _cron_send_application_reminders(self):
- """Cron job: Find assessments completed X days ago without application and send reminders."""
- from datetime import timedelta
-
- if not self._is_email_notifications_enabled():
- _logger.info("Email notifications disabled, skipping application reminders")
- return
-
- # Get reminder days from settings (default 4)
- ICP = self.env['ir.config_parameter'].sudo()
- reminder_days = int(ICP.get_param('fusion_claims.application_reminder_days', '4'))
-
- # Calculate target date (X days ago)
- target_date = fields.Date.today() - timedelta(days=reminder_days)
-
- # Find orders where:
- # - Assessment completed on target date (x_fc_assessment_end_date = target_date)
- # - Status is still 'waiting_for_application' (no application received yet)
- # - Not already reminded (we'll track this with x_fc_application_reminder_sent)
- orders = self.search([
- ('x_fc_assessment_end_date', '=', target_date),
- ('x_fc_adp_application_status', 'in', ['waiting_for_application', 'assessment_completed']),
- ('x_fc_application_reminder_sent', '=', False),
- ])
-
- _logger.info(f"Application reminder cron: Found {len(orders)} orders to remind (assessed on {target_date})")
-
- for order in orders:
- try:
- order._send_application_reminder_email()
- except Exception as e:
- _logger.error(f"Failed to send application reminder for {order.name}: {e}")
-
- def _send_application_reminder_email(self):
- """Send reminder to therapist to submit ADP application."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
-
- authorizer = self.x_fc_authorizer_id
- if not authorizer or not authorizer.email:
- return False
-
- client_name = self.partner_id.name or 'the client'
- assessment_date = self.x_fc_assessment_end_date.strftime('%B %d, %Y') if self.x_fc_assessment_end_date else 'recently'
- sales_rep_name = self.user_id.name if self.user_id else 'The Sales Team'
-
- body_html = self._email_build(
- title='Application Reminder',
- summary=f'The assessment for {client_name} was completed on {assessment_date}. '
- f'We have not yet received the ADP application documents.',
- email_type='attention',
- sections=[('Case Details', self._build_case_detail_rows())],
- note='Action needed: Please submit the completed ADP application '
- '(including pages 11-12 signed by the client) so we can proceed with the claim submission.',
- note_color='#d69e2e',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
-
- recipients = self._get_email_recipients(include_client=False, include_authorizer=True, include_sales_rep=True)
- all_cc = recipients.get('cc', []) + recipients.get('office_cc', [])
- email_cc = ', '.join(all_cc) if all_cc else ''
-
- try:
- mail = self.env['mail.mail'].sudo().create({
- 'subject': f'Application Reminder - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': authorizer.email, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- })
- mail.send()
- self.with_context(skip_all_validations=True).write({'x_fc_application_reminder_sent': True})
- self._email_chatter_log('Application Reminder sent', authorizer.email, email_cc)
- return True
- except Exception as e:
- _logger.error(f"Failed to send application reminder for {self.name}: {e}")
- return False
-
- @api.model
- def _cron_send_application_reminders_2(self):
- """Cron job: Send second reminder X days after first reminder was sent."""
- from datetime import timedelta
-
- if not self._is_email_notifications_enabled():
- _logger.info("Email notifications disabled, skipping second application reminders")
- return
-
- # Get reminder days from settings
- ICP = self.env['ir.config_parameter'].sudo()
- first_reminder_days = int(ICP.get_param('fusion_claims.application_reminder_days', '4'))
- second_reminder_days = int(ICP.get_param('fusion_claims.application_reminder_2_days', '4'))
-
- # Calculate target date: assessment_end_date + first_reminder_days + second_reminder_days
- total_days = first_reminder_days + second_reminder_days
- target_date = fields.Date.today() - timedelta(days=total_days)
-
- # Find orders where:
- # - Assessment completed on target date
- # - First reminder was sent
- # - Second reminder not yet sent
- # - Status still waiting for application
- orders = self.search([
- ('x_fc_assessment_end_date', '=', target_date),
- ('x_fc_adp_application_status', 'in', ['waiting_for_application', 'assessment_completed']),
- ('x_fc_application_reminder_sent', '=', True),
- ('x_fc_application_reminder_2_sent', '=', False),
- ])
-
- _logger.info(f"Second application reminder cron: Found {len(orders)} orders to remind (assessed on {target_date})")
-
- for order in orders:
- try:
- order._send_application_reminder_2_email()
- except Exception as e:
- _logger.error(f"Failed to send second application reminder for {order.name}: {e}")
-
- def _send_application_reminder_2_email(self):
- """Send second/follow-up reminder to therapist to submit ADP application."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
-
- authorizer = self.x_fc_authorizer_id
- if not authorizer or not authorizer.email:
- return False
-
- client_name = self.partner_id.name or 'the client'
- assessment_date = self.x_fc_assessment_end_date.strftime('%B %d, %Y') if self.x_fc_assessment_end_date else 'recently'
- days_since = (fields.Date.today() - self.x_fc_assessment_end_date).days if self.x_fc_assessment_end_date else 'several'
- sales_rep_name = self.user_id.name if self.user_id else 'The Sales Team'
-
- body_html = self._email_build(
- title='Follow-up: Application Needed',
- summary=f'It has been {days_since} days since the assessment for '
- f'{client_name} was completed on {assessment_date}. '
- f'We have not yet received the ADP application.',
- email_type='attention',
- sections=[('Case Details', self._build_case_detail_rows())],
- note='Assessment validity: ADP assessments are valid for 90 days. '
- 'To avoid delays or the need for a new assessment, please submit the application '
- 'as soon as possible.',
- note_color='#d69e2e',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
-
- all_cc = []
- if self.user_id and self.user_id.email:
- all_cc.append(self.user_id.email)
- all_cc.extend(self._get_office_cc_emails())
- email_cc = ', '.join(all_cc) if all_cc else ''
-
- try:
- mail = self.env['mail.mail'].sudo().create({
- 'subject': f'Follow-up: Application Needed - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': authorizer.email, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- })
- mail.send()
- self.with_context(skip_all_validations=True).write({'x_fc_application_reminder_2_sent': True})
- self._email_chatter_log('Follow-up Reminder sent', authorizer.email, email_cc,
- [f'Days since assessment: {days_since}'])
- return True
- except Exception as e:
- _logger.error(f"Failed to send second reminder for {self.name}: {e}")
- return False
-
- def _send_billed_summary_email(self):
- """Send summary email when order is billed to ADP."""
- self.ensure_one()
- authorizer = self.x_fc_authorizer_id
- sales_rep = self.user_id
- email_list = []
- if authorizer and authorizer.email:
- email_list.append(authorizer.email)
- if sales_rep and sales_rep.email:
- email_list.append(sales_rep.email)
- if not email_list:
- return False
-
- client_name = self.partner_id.name or 'Client'
- body_html = self._email_build(
- title='Billing Complete',
- summary=f'The ADP claim for {client_name} has been successfully billed.',
- email_type='success',
- sections=[
- ('Case Details', self._build_case_detail_rows(include_amounts=True)),
- ],
- note='This case has been billed. Thank you for your collaboration.',
- note_color='#38a169',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=(sales_rep.name if sales_rep else None),
- )
- email_to = ', '.join(email_list)
- try:
- mail = self.env['mail.mail'].sudo().create({
- 'subject': f'Billing Complete - {client_name} - {self.name}',
- 'body_html': body_html, 'email_to': email_to,
- 'model': 'sale.order', 'res_id': self.id,
- })
- mail.send()
- self._email_chatter_log('Billing Complete email sent', email_to)
- return True
- except Exception as e:
- _logger.error(f"Failed to send billed email for {self.name}: {e}")
- return False
-
- def _send_approval_email(self):
- """Send notification when ADP application is approved."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
- recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
- if not to_emails:
- return False
- client_name = (recipients.get('client') or self.partner_id).name or 'Client'
- sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
- is_deduction = self.x_fc_adp_application_status == 'approved_deduction'
- status_label = 'Approved with Deduction' if is_deduction else 'Approved'
- note_text = (
- 'Next steps: Our team will be in touch shortly to schedule '
- 'the delivery of your equipment.'
- )
- if is_deduction:
- note_text = (
- 'Note: This application was approved with a deduction. '
- 'The final amounts may differ from the original request. Our team will '
- 'contact you with the details and next steps for delivery.'
- )
-
- body_html = self._email_build(
- title='Application Approved',
- summary=f'The ADP application for {client_name} has been '
- f'{status_label.lower()}.',
- email_type='success',
- sections=[('Case Details', self._build_case_detail_rows(include_amounts=True))],
- note=note_text,
- note_color='#38a169',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
- email_to = ', '.join(to_emails)
- email_cc = ', '.join(cc_emails) if cc_emails else ''
- try:
- self.env['mail.mail'].sudo().create({
- 'subject': f'Application {status_label} - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': email_to, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- }).send()
- self._email_chatter_log(f'Application {status_label} email sent', email_to, email_cc)
- return True
- except Exception as e:
- _logger.error(f"Failed to send approval email for {self.name}: {e}")
- return False
-
- def _send_denial_email(self):
- """Send notification when ADP application is denied (funding denied)."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
- recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
- if not to_emails:
- return False
- client_name = (recipients.get('client') or self.partner_id).name or 'Client'
- sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
-
- body_html = self._email_build(
- title='Application Update',
- summary=f'The ADP application for {client_name} was not approved at this time.',
- email_type='urgent',
- sections=[('Case Details', self._build_case_detail_rows())],
- note='Your options: You may request a detailed explanation, '
- 'submit an appeal if you believe the decision was made in error, or explore '
- 'alternative funding options. Our team is here to help.',
- note_color='#c53030',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
- email_to = ', '.join(to_emails)
- email_cc = ', '.join(cc_emails) if cc_emails else ''
- try:
- self.env['mail.mail'].sudo().create({
- 'subject': f'Application Update - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': email_to, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- }).send()
- self._email_chatter_log('Application Denied email sent', email_to, email_cc)
- return True
- except Exception as e:
- _logger.error(f"Failed to send denial email for {self.name}: {e}")
- return False
-
- def _send_rejection_email(self):
- """Send notification when ADP rejects the submission (data errors, not funding denial)."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
- recipients = self._get_email_recipients(include_client=False, include_authorizer=True, include_sales_rep=True)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
- if not to_emails:
- return False
-
- client_name = self.partner_id.name or 'the client'
- sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
- rejection_reason_labels = {
- 'name_correction': 'Name Correction Needed',
- 'healthcard_correction': 'Health Card Correction Needed',
- 'duplicate_claim': 'Duplicate Claim Exists',
- 'xml_format_error': 'XML Format/Validation Error',
- 'missing_info': 'Missing Required Information',
- 'other': 'Other',
- }
- rejection_reason = self.x_fc_rejection_reason or 'other'
- rejection_label = rejection_reason_labels.get(rejection_reason, rejection_reason)
- rejection_details = self.x_fc_rejection_reason_other or ''
- note_text = f'Reason: {rejection_label}'
- # PLACEHOLDER_REJECTION_START -- marker removed
- if rejection_details:
- note_text += f' {rejection_details}'
-
- body_html = self._email_build(
- title='Action Required: Submission Returned',
- summary=f'The ADP submission for {client_name} has been returned and needs correction.',
- email_type='urgent',
- sections=[('Case Details', self._build_case_detail_rows())],
- note=note_text,
- note_color='#c53030',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
- email_to = ', '.join(to_emails)
- email_cc = ', '.join(cc_emails) if cc_emails else ''
- try:
- self.env['mail.mail'].sudo().create({
- 'subject': f'Action Required: Submission Returned - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': email_to, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- }).send()
- self._email_chatter_log('Submission Returned email sent', email_to, email_cc,
- [f'Reason: {rejection_label}'])
- return True
- except Exception as e:
- _logger.error(f"Failed to send rejection email for {self.name}: {e}")
- return False
-
- def _send_correction_needed_email(self, reason=None):
- """Send notification when ADP application needs correction."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
- recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
- if not to_emails:
- return False
- client_name = self.partner_id.name or 'the client'
- sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
-
- note_text = 'Action needed: Please review the application, make the necessary corrections, and resubmit.'
- if reason:
- note_text = f'Reason for correction: {reason}
{note_text}'
-
- body_html = self._email_build(
- title='Correction Needed',
- summary=f'The ADP application for {client_name} requires corrections before resubmission.',
- email_type='attention',
- sections=[('Case Details', self._build_case_detail_rows())],
- note=note_text,
- note_color='#d69e2e',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
- email_to = ', '.join(to_emails)
- email_cc = ', '.join(cc_emails) if cc_emails else ''
- try:
- self.env['mail.mail'].sudo().create({
- 'subject': f'Correction Needed - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': email_to, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- }).send()
- self._email_chatter_log('Correction Needed email sent', email_to, email_cc)
- return True
- except Exception as e:
- _logger.error(f"Failed to send correction email for {self.name}: {e}")
- return False
-
- def _send_case_closed_email(self):
- """Send summary email when case is closed."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
- recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
- if not to_emails and not cc_emails:
- return False
- client_name = (recipients.get('client') or self.partner_id).name or 'Client'
- sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
-
- body_html = self._email_build(
- title='Case Closed',
- summary=f'The ADP case for {client_name} has been completed and closed.',
- email_type='success',
- sections=[('Case Summary', self._build_case_detail_rows(include_amounts=True))],
- note='This case is now closed. All equipment has been delivered and billing is complete. '
- 'Thank you for your collaboration throughout the process.',
- note_color='#38a169',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
- email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
- email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
- try:
- self.env['mail.mail'].sudo().create({
- 'subject': f'Case Closed - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': email_to, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- }).send()
- self._email_chatter_log('Case Closed email sent', email_to, email_cc)
- return True
- except Exception as e:
- _logger.error(f"Failed to send case closed email for {self.name}: {e}")
- return False
-
- def _send_withdrawal_email(self, reason=None):
- """Send notification when application is withdrawn."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
- recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
- if not to_emails and not cc_emails:
- return False
- client_name = (recipients.get('client') or self.partner_id).name or 'Client'
- sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
-
- note_text = 'This application has been withdrawn from the Assistive Devices Program.'
- if reason:
- note_text += f' Reason: {reason}'
-
- body_html = self._email_build(
- title='Application Withdrawn',
- summary=f'The ADP application for {client_name} has been withdrawn.',
- email_type='attention',
- sections=[('Case Details', self._build_case_detail_rows())],
- note=note_text,
- note_color='#d69e2e',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
- email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
- email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
- try:
- self.env['mail.mail'].sudo().create({
- 'subject': f'Application Withdrawn - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': email_to, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- }).send()
- self._email_chatter_log('Application Withdrawn email sent', email_to, email_cc)
- return True
- except Exception as e:
- _logger.error(f"Failed to send withdrawal email for {self.name}: {e}")
- return False
-
- def _send_ready_for_delivery_email(self, technicians=None, scheduled_datetime=None, notes=None):
- """Send notification when application is marked Ready for Delivery."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
- recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
-
- # Add technician emails to CC
- if technicians:
- for tech in technicians:
- if hasattr(tech, 'email') and tech.email:
- cc_emails.append(tech.email)
-
- if not to_emails and not cc_emails:
- return False
-
- client_name = (recipients.get('client') or self.partner_id).name or 'Client'
- sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
-
- # Build extra rows for delivery details
- detail_rows = self._build_case_detail_rows()
- if self.partner_shipping_id:
- addr = self.partner_shipping_id.contact_address or ''
- detail_rows.append(('Delivery Address', addr.replace('\n', ', ')))
- if technicians:
- tech_names = ', '.join(t.name for t in technicians if hasattr(t, 'name'))
- if tech_names:
- detail_rows.append(('Technician(s)', tech_names))
- if scheduled_datetime:
- detail_rows.append(('Scheduled', str(scheduled_datetime)))
-
- note_text = 'Next steps: Our delivery team will contact you to confirm the delivery schedule.'
- if self.x_fc_early_delivery:
- note_text = ('Note: This is an early delivery (before final ADP approval). '
- 'Our team will contact you to schedule.')
- if notes:
- note_text += f' Notes: {notes}'
-
- body_html = self._email_build(
- title='Ready for Delivery',
- summary=f'The equipment for {client_name} is ready for delivery.',
- email_type='success',
- sections=[('Delivery Details', detail_rows)],
- note=note_text,
- note_color='#38a169',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
- email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
- email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
- try:
- self.env['mail.mail'].sudo().create({
- 'subject': f'Ready for Delivery - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': email_to, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- }).send()
- _logger.info(f"Ready for delivery email sent for {self.name}")
- return True
- except Exception as e:
- _logger.error(f"Failed to send delivery email for {self.name}: {e}")
- return False
-
- def _send_on_hold_email(self, reason=None):
- """Send notification when application is put on hold."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
- recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
- if not to_emails and not cc_emails:
- return False
- client_name = (recipients.get('client') or self.partner_id).name or 'Client'
- sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
-
- note_text = 'This application has been placed on hold. We will resume processing as soon as possible.'
- if reason:
- note_text += f' Reason: {reason}'
-
- body_html = self._email_build(
- title='Application On Hold',
- summary=f'The ADP application for {client_name} has been placed on hold.',
- email_type='attention',
- sections=[('Case Details', self._build_case_detail_rows())],
- note=note_text,
- note_color='#d69e2e',
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sales_rep_name,
- )
- email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
- email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
- try:
- self.env['mail.mail'].sudo().create({
- 'subject': f'Application On Hold - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': email_to, 'email_cc': email_cc,
- 'model': 'sale.order', 'res_id': self.id,
- }).send()
- self._email_chatter_log('Application On Hold email sent', email_to, email_cc)
- return True
- except Exception as e:
- _logger.error(f"Failed to send on-hold email for {self.name}: {e}")
- return False
-
- # ==========================================================================
- # OVERRIDE WRITE
- # ==========================================================================
- def write(self, vals):
- """Override write to handle ADP status changes, date auto-population, and document tracking."""
- from datetime import date as date_class
-
- # =================================================================
- # VALIDATION BYPASS (for internal operations like crons, email tracking)
- # =================================================================
- if self.env.context.get('skip_all_validations'):
- return super().write(vals)
-
- # =================================================================
- # CASE LOCK CHECK
- # =================================================================
- # If unlocking (setting x_fc_case_locked to False), allow it
- # Otherwise, if any order is locked, block changes to ADP fields
- if 'x_fc_case_locked' not in vals or vals.get('x_fc_case_locked') is True:
- # Fields that are always allowed to be modified even when locked
- always_allowed = {
- 'x_fc_case_locked', # Allow toggling the lock itself
- 'message_main_attachment_id',
- 'message_follower_ids',
- 'activity_ids',
- }
-
- # Check if any locked orders would have ADP fields modified
- adf_fields_being_changed = [k for k in vals.keys() if k.startswith('x_fc_') and k not in always_allowed]
-
- if adf_fields_being_changed:
- for order in self:
- if order.x_fc_case_locked:
- raise UserError(
- f"Cannot modify order {order.name}.\n\n"
- "This case is locked. Please unlock it first by toggling off the "
- "'Case Locked' switch in the ADP Order Trail tab."
- )
-
- # =================================================================
- # SALE TYPE LOCK CHECK
- # =================================================================
- # Sale type is locked after application is submitted
- # Can be overridden by: context flag, sale type override setting,
- # OR the document lock override (setting + group)
- if 'x_fc_sale_type' in vals and not self.env.context.get('skip_sale_type_lock'):
- locked_statuses = [
- 'submitted', 'accepted', 'rejected', 'resubmitted',
- 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
- ]
-
- ICP = self.env['ir.config_parameter'].sudo()
- allow_sale_type = ICP.get_param('fusion_claims.allow_sale_type_override', 'False').lower() in ('true', '1', 'yes')
- allow_doc_lock = ICP.get_param('fusion_claims.allow_document_lock_override', 'False').lower() in ('true', '1', 'yes')
- has_override_group = self.env.user.has_group('fusion_claims.group_document_lock_override')
-
- if not allow_sale_type and not (allow_doc_lock and has_override_group):
- for order in self:
- if order.x_fc_adp_application_status in locked_statuses:
- raise UserError(
- f"Cannot modify Sale Type on order {order.name}.\n\n"
- f"Sale Type is locked after the application has been submitted to ADP.\n"
- f"Current status: {order.x_fc_adp_application_status}\n\n"
- f"To modify, enable 'Allow Document Lock Override' in Settings\n"
- f"and ensure your user is in the 'Document Lock Override' group."
- )
-
- # =================================================================
- # DOCUMENT LOCKING BASED ON STATUS PROGRESSION
- # =================================================================
- # Documents become locked at specific stages to prevent modification
- # Lock rules:
- # - Original Application & Signed Pages 11/12 → Lock when submitted or later
- # - Final Application & XML File → Lock when approved or later
- # - Approval Letter & Screenshots → Lock when billed (but tracked on change)
- # - Proof of Delivery → Lock when billed or later
-
- # Define document lock rules: field -> list of statuses where field is locked
- statuses_after_submitted = [
- 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
- 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
- ]
- statuses_after_approved = [
- 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
- ]
- statuses_after_billed = ['billed', 'case_closed']
-
- document_lock_rules = {
- # Application documents - lock after submitted
- 'x_fc_original_application': statuses_after_submitted,
- 'x_fc_original_application_filename': statuses_after_submitted,
- 'x_fc_signed_pages_11_12': statuses_after_submitted,
- 'x_fc_signed_pages_filename': statuses_after_submitted,
- # Submission documents - lock after approved
- 'x_fc_final_submitted_application': statuses_after_approved,
- 'x_fc_final_application_filename': statuses_after_approved,
- 'x_fc_xml_file': statuses_after_approved,
- 'x_fc_xml_filename': statuses_after_approved,
- # Approval documents - lock after billed
- 'x_fc_approval_letter': statuses_after_billed,
- 'x_fc_approval_letter_filename': statuses_after_billed,
- # POD - lock after billed
- 'x_fc_proof_of_delivery': statuses_after_billed,
- 'x_fc_proof_of_delivery_filename': statuses_after_billed,
- }
-
- # Check if any locked documents are being modified
- # Skip check if:
- # - context has skip_document_lock_validation (for programmatic override)
- # - BOTH: the "Allow Document Lock Override" setting is ON
- # AND the user is in the "Document Lock Override" group
- can_override = False
- if not self.env.context.get('skip_document_lock_validation'):
- ICP_lock = self.env['ir.config_parameter'].sudo()
- override_enabled = ICP_lock.get_param(
- 'fusion_claims.allow_document_lock_override', 'False'
- ).lower() in ('true', '1', 'yes')
- if override_enabled:
- can_override = self.env.user.has_group('fusion_claims.group_document_lock_override')
- else:
- can_override = True
-
- if not can_override:
- for order in self:
- current_status = order.x_fc_adp_application_status or ''
-
- for field_name, locked_statuses in document_lock_rules.items():
- if field_name in vals:
- if current_status in locked_statuses:
- old_value = getattr(order, field_name, None)
- new_value = vals.get(field_name)
-
- if old_value == new_value:
- continue
-
- if current_status in statuses_after_billed:
- lock_stage = "billed"
- elif current_status in statuses_after_approved:
- lock_stage = "approved"
- else:
- lock_stage = "submitted"
-
- field_label_map = {
- 'x_fc_original_application': 'Original ADP Application',
- 'x_fc_signed_pages_11_12': 'Signed Pages 11 & 12',
- 'x_fc_final_submitted_application': 'Final Submitted Application',
- 'x_fc_xml_file': 'XML File',
- 'x_fc_approval_letter': 'Approval Letter',
- 'x_fc_proof_of_delivery': 'Proof of Delivery',
- }
- field_label = field_label_map.get(field_name, field_name)
-
- raise UserError(
- f"Cannot modify '{field_label}' on order {order.name}.\n\n"
- f"This document is locked because the application status is '{current_status}'.\n"
- f"Documents are locked once the application reaches the '{lock_stage}' stage.\n\n"
- f"To modify this document:\n"
- f"1. The 'Allow Document Lock Override' setting must be enabled (Fusion Claims > Settings)\n"
- f"2. Your user must be in the 'Document Lock Override' group"
- )
-
- # =================================================================
- # DOCUMENT AUDIT TRAIL - Track all document changes
- # =================================================================
- document_fields = [
- 'x_fc_original_application',
- 'x_fc_signed_pages_11_12',
- 'x_fc_final_submitted_application',
- 'x_fc_xml_file',
- 'x_fc_proof_of_delivery',
- 'x_fc_approval_letter',
- ]
- doc_changes = {f: vals.get(f) for f in document_fields if f in vals and vals.get(f)}
-
- # Preserve old documents in chatter BEFORE they get replaced or deleted
- # This ensures document history is maintained for audit purposes
- document_labels = {
- 'x_fc_original_application': 'Original ADP Application',
- 'x_fc_signed_pages_11_12': 'Page 11 & 12 (Signed)',
- 'x_fc_final_submitted_application': 'Final Application',
- 'x_fc_xml_file': 'XML File',
- 'x_fc_proof_of_delivery': 'Proof of Delivery',
- 'x_fc_approval_letter': 'Approval Letter',
- }
-
- user_name = self.env.user.name
- change_timestamp = fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S')
-
- # Fields already handled by the needs_correction flow below (avoid duplicate posts)
- correction_handled = set()
- if vals.get('x_fc_adp_application_status') == 'needs_correction':
- correction_handled = {'x_fc_final_submitted_application', 'x_fc_xml_file'}
-
- for order in self:
- for field_name in document_fields:
- if field_name in vals and field_name not in correction_handled:
- old_data = getattr(order, field_name, None)
- new_data = vals.get(field_name)
- label = document_labels.get(field_name, field_name)
-
- if old_data and new_data:
- # REPLACEMENT: Old document being replaced with new one
- # Preserve old document in chatter as attachment
- order._post_document_to_chatter(
- field_name,
- f"{label} (replaced)"
- )
-
- elif old_data and not new_data:
- # DELETION: Document is being deleted
- # Preserve the deleted document in chatter
- order._post_document_to_chatter(
- field_name,
- f"{label} (DELETED)"
- )
-
- # Post deletion notice
- deletion_msg = Markup(
- '
'
- '
'
- ' Document Deleted
'
- '
'
- '
Document:
'
- f'
{label}
'
- '
Deleted By:
'
- f'
{user_name}
'
- '
Time:
'
- f'
{change_timestamp}
'
- '
'
- '
'
- 'The deleted document has been preserved in the message above.
'
- '
'
- )
- order.message_post(
- body=deletion_msg,
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- # Track status changes for auto-actions
- new_app_status = vals.get('x_fc_adp_application_status')
- new_mod_status = vals.get('x_fc_mod_status')
-
- # Handle document correction flow - clear document fields and submission date when needs_correction
- if new_app_status == 'needs_correction':
- for order in self:
- # Post existing final application to chatter before clearing
- if order.x_fc_final_submitted_application:
- order._post_document_to_chatter('x_fc_final_submitted_application',
- 'Final Application (before correction)')
- if order.x_fc_xml_file:
- order._post_document_to_chatter('x_fc_xml_file',
- 'XML File (before correction)')
-
- # Clear the document fields AND submission date
- # Use _correction_cleared to prevent the audit trail from posting duplicates
- vals['x_fc_final_submitted_application'] = False
- vals['x_fc_final_application_filename'] = False
- vals['x_fc_xml_file'] = False
- vals['x_fc_xml_filename'] = False
- vals['x_fc_claim_submission_date'] = False
-
- # Post correction notice
- for order in self:
- order.message_post(
- body=Markup(
- '
'
- ' Correction Needed '
- 'Document fields and submission date have been cleared. Please upload the corrected application and resubmit.'
- '
'
- ),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- # Auto-populate date fields based on status changes
- today = date_class.today()
- if new_app_status == 'assessment_scheduled' and 'x_fc_assessment_start_date' not in vals:
- vals['x_fc_assessment_start_date'] = today
- elif new_app_status == 'assessment_completed' and 'x_fc_assessment_end_date' not in vals:
- vals['x_fc_assessment_end_date'] = today
- # Auto-transition to 'waiting_for_application'
- vals['x_fc_adp_application_status'] = 'waiting_for_application'
- new_app_status = 'waiting_for_application'
- elif new_app_status in ('submitted', 'resubmitted') and 'x_fc_claim_submission_date' not in vals:
- vals['x_fc_claim_submission_date'] = today
- elif new_app_status == 'accepted' and 'x_fc_claim_acceptance_date' not in vals:
- vals['x_fc_claim_acceptance_date'] = today
- elif new_app_status in ('approved', 'approved_deduction') and 'x_fc_claim_approval_date' not in vals:
- vals['x_fc_claim_approval_date'] = today
- elif new_app_status == 'billed' and 'x_fc_billing_date' not in vals:
- vals['x_fc_billing_date'] = today
-
- # =================================================================
- # REQUIRED FIELD VALIDATION based on status
- # =================================================================
- # Note: UserError is imported at top of file
-
- # Helper to get field value (check vals first, then existing record)
- def get_val(order, field):
- if field in vals:
- return vals[field]
- return getattr(order, field, None)
-
- # Authorizer validation based on sale type and authorizer_required field
- # Always required for: adp, adp_odsp, wsib, march_of_dimes, muscular_dystrophy
- # Optional for: odsp, direct_private, insurance, other, rental (depends on x_fc_authorizer_required)
- #
- # IMPORTANT: Only validate when changing relevant fields, not on every write.
- # This prevents blocking unrelated saves when authorizer is missing.
- authorizer_related_fields = {
- 'x_fc_sale_type', 'x_fc_authorizer_id', 'x_fc_authorizer_required',
- 'x_fc_adp_application_status', # Also validate when changing ADP status
- }
- should_validate_authorizer = bool(authorizer_related_fields & set(vals.keys()))
-
- if should_validate_authorizer:
- always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
- optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other', 'rental')
-
- for order in self:
- sale_type = get_val(order, 'x_fc_sale_type')
- auth_id = get_val(order, 'x_fc_authorizer_id')
- auth_required = get_val(order, 'x_fc_authorizer_required')
-
- if sale_type in always_auth_types:
- # Always required for these types
- if not auth_id:
- raise UserError("Authorizer is required for this sale type.")
- elif sale_type in optional_auth_types and auth_required == 'yes':
- # Required only if user selected "Yes"
- if not auth_id:
- raise UserError("Authorizer is required. You selected 'Yes' for Authorizer Required.")
-
- # Helper to check if previous funding date is required based on reason
- def requires_previous_funding(reason_val):
- """Return True if the reason requires previous funding date."""
- exempt_reasons = ['first_access', 'mod_non_adp']
- return reason_val and reason_val not in exempt_reasons
-
- # =================================================================
- # STATUS TRANSITION VALIDATIONS
- # =================================================================
- # All status changes to "controlled" statuses must go through dedicated
- # buttons/wizards. Direct dropdown/statusbar changes are blocked.
- # Use context flag 'skip_status_validation' to bypass when calling from wizards.
-
- if not self.env.context.get('skip_status_validation') and new_app_status:
- # Statuses that can ONLY be set via buttons/wizards
- # This ensures proper workflow tracking and validation
- controlled_statuses = {
- # Early workflow stages
- 'assessment_scheduled': 'Schedule Assessment',
- 'assessment_completed': 'Complete Assessment',
- 'waiting_for_application': 'Complete Assessment',
- 'application_received': 'Application Received',
- 'ready_submission': 'Ready for Submission',
- # Submission and approval stages
- 'submitted': 'Submit Application',
- 'accepted': 'Mark as Accepted', # New: ADP accepted submission
- 'rejected': 'Mark as Rejected', # New: ADP rejected submission
- 'resubmitted': 'Submit Application',
- 'approved': 'Mark as Approved',
- 'approved_deduction': 'Mark as Approved',
- # Delivery stage
- 'ready_delivery': 'Ready for Delivery',
- # Billing stages
- 'ready_bill': 'Ready to Bill',
- 'billed': 'Mark as Billed',
- 'case_closed': 'Close Case',
- # Special statuses (require reason wizard)
- 'on_hold': 'Put On Hold',
- 'withdrawn': 'Withdraw',
- 'denied': 'Denied',
- 'cancelled': 'Cancel',
- 'needs_correction': 'Needs Correction',
- }
-
- if new_app_status in controlled_statuses:
- button_name = controlled_statuses[new_app_status]
- raise UserError(
- f"To change status to this value, please use the '{button_name}' button.\n\n"
- f"Direct status changes are not allowed for workflow integrity."
- )
-
- # =================================================================
- # RESUMING FROM ON_HOLD: Check assessment validity (3 months)
- # =================================================================
- for order in self:
- if order.x_fc_adp_application_status == 'on_hold' and new_app_status and new_app_status != 'on_hold':
- # Check if assessment is expired (more than 3 months old)
- if order.x_fc_assessment_expired:
- days_expired = (today - order.x_fc_assessment_end_date).days - 90 if order.x_fc_assessment_end_date else 0
- order.message_post(
- body=Markup(
- '
'
- ' Assessment Expired '
- f'The assessment was completed on {order.x_fc_assessment_end_date} and is now {days_expired} days past the 3-month validity period. '
- 'A new assessment must be completed by the Occupational Therapist before proceeding.'
- '
'
- ),
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
- raise UserError(
- f"Cannot resume from 'On Hold' - Assessment has expired!\n\n"
- f"The assessment was completed on {order.x_fc_assessment_end_date} and is now "
- f"{days_expired} days past the 3-month validity period.\n\n"
- f"A new assessment must be completed before proceeding."
- )
-
- # assessment_scheduled: No special requirements (Assessment Start Date auto-populated)
-
- if new_app_status == 'assessment_completed':
- for order in self:
- missing = []
- if not get_val(order, 'x_fc_assessment_start_date'):
- missing.append('Assessment Start Date')
- if not get_val(order, 'x_fc_assessment_end_date'):
- missing.append('Assessment End Date')
- if missing:
- raise UserError(
- f"Cannot change status to 'Assessment Completed'.\n\n"
- f"Required fields missing:\n• " + "\n• ".join(missing)
- )
-
- elif new_app_status == 'application_received':
- for order in self:
- missing = []
- # Only Assessment Start Date required at this stage
- if not get_val(order, 'x_fc_assessment_start_date'):
- missing.append('Assessment Start Date')
- if missing:
- raise UserError(
- f"Cannot change status to 'Application Received'.\n\n"
- f"Required fields missing:\n• " + "\n• ".join(missing)
- )
-
- elif new_app_status == 'ready_submission':
- for order in self:
- missing = []
- # Assessment dates
- if not get_val(order, 'x_fc_assessment_start_date'):
- missing.append('Assessment Start Date')
- if not get_val(order, 'x_fc_assessment_end_date'):
- missing.append('Assessment End Date')
- # Reason for application
- if not get_val(order, 'x_fc_reason_for_application'):
- missing.append('Reason for Application')
- # Client references and authorization date
- if not get_val(order, 'x_fc_client_ref_1'):
- missing.append('Client Reference 1')
- if not get_val(order, 'x_fc_client_ref_2'):
- missing.append('Client Reference 2')
- if not get_val(order, 'x_fc_claim_authorization_date'):
- missing.append('Claim Authorization Date')
- # Previous funding date if required by reason
- reason_val = get_val(order, 'x_fc_reason_for_application')
- if requires_previous_funding(reason_val):
- if not get_val(order, 'x_fc_previous_funding_date'):
- missing.append('Previous Funding Date')
- # Documents
- if not order.x_fc_original_application and not vals.get('x_fc_original_application'):
- missing.append('Original ADP Application')
- if not order.x_fc_signed_pages_11_12 and not vals.get('x_fc_signed_pages_11_12'):
- missing.append('Page 11 & 12 (Signed)')
- if missing:
- raise UserError(
- f"Cannot change status to 'Ready for Submission'.\n\n"
- f"Required fields/documents missing:\n• " + "\n• ".join(missing)
- )
-
- elif new_app_status in ('submitted', 'resubmitted'):
- for order in self:
- missing = []
- # Documents
- if not order.x_fc_final_submitted_application and not vals.get('x_fc_final_submitted_application'):
- missing.append('Final Submitted Application')
- if not order.x_fc_xml_file and not vals.get('x_fc_xml_file'):
- missing.append('XML File')
- # Fields
- if not get_val(order, 'x_fc_claim_submission_date'):
- missing.append('Claim Submission Date')
- if missing:
- raise UserError(
- f"Cannot change status to 'Application Submitted'.\n\n"
- f"Required fields/documents missing:\n• " + "\n• ".join(missing)
- )
-
- elif new_app_status in ('approved', 'approved_deduction'):
- for order in self:
- missing = []
- if not get_val(order, 'x_fc_claim_number'):
- missing.append('Claim Number')
- if not get_val(order, 'x_fc_claim_approval_date'):
- missing.append('Claim Approval Date')
- if missing:
- raise UserError(
- f"Cannot change status to 'Application Approved'.\n\n"
- f"Required fields missing:\n• " + "\n• ".join(missing)
- )
-
- elif new_app_status == 'ready_bill':
- for order in self:
- missing = []
- if not get_val(order, 'x_fc_adp_delivery_date'):
- missing.append('ADP Delivery Date')
- if not order.x_fc_proof_of_delivery and not vals.get('x_fc_proof_of_delivery'):
- missing.append('Proof of Delivery')
- if missing:
- raise UserError(
- f"Cannot change status to 'Ready to Bill'.\n\n"
- f"Required fields/documents missing:\n• " + "\n• ".join(missing)
- )
-
- elif new_app_status == 'billed':
- for order in self:
- missing = []
- if not get_val(order, 'x_fc_billing_date'):
- missing.append('Billing Date')
- if missing:
- raise UserError(
- f"Cannot change status to 'Billed to ADP'.\n\n"
- f"Required fields missing:\n• " + "\n• ".join(missing)
- )
-
- elif new_app_status == 'case_closed':
- for order in self:
- missing = []
- if not get_val(order, 'x_fc_billing_date'):
- missing.append('Billing Date')
- if missing:
- raise UserError(
- f"Cannot change status to 'Case Closed'.\n\n"
- f"Required fields missing:\n• " + "\n• ".join(missing)
- )
-
- # ==================================================================
- # MARCH OF DIMES STATUS TRANSITION VALIDATIONS
- # ==================================================================
- if new_mod_status:
- for order in self:
- if not order._is_mod_sale() and order.x_fc_sale_type != 'march_of_dimes':
- continue
-
- if new_mod_status == 'contract_received':
- missing = []
- if not get_val(order, 'x_fc_case_reference') and not vals.get('x_fc_case_reference'):
- missing.append('HVMP Reference Number')
- if missing:
- raise UserError(
- "Cannot change status to 'PCA Received'.\n\n"
- "Required:\n" + "\n".join(f"- {m}" for m in missing)
- )
-
- elif new_mod_status == 'pod_submitted':
- if not order.x_fc_mod_proof_of_delivery and not vals.get('x_fc_mod_proof_of_delivery'):
- raise UserError(
- "Cannot change status to 'POD Sent'.\n\n"
- "Please upload the Proof of Delivery document first."
- )
-
- result = super().write(vals)
-
- # Skip additional processing if we're in a sync operation (prevent infinite loops)
- if self.env.context.get('skip_sync'):
- return result
-
- # Post document uploads to chatter
- if doc_changes:
- for order in self:
- for field_name, data in doc_changes.items():
- if data:
- order._post_document_to_chatter(field_name)
-
- # Auto-overlay POD signature onto SA Mobility approval form
- if 'x_fc_pod_signature' in vals and vals['x_fc_pod_signature'] and not self.env.context.get('skip_pod_signature_hook'):
- for order in self:
- try:
- order._apply_pod_signature_to_approval_form()
- except Exception as e:
- _logger.error("Failed to overlay POD signature for %s: %s", order.name, e)
- # Auto-advance SA Mobility from ready_delivery to delivered when POD is signed
- if (order.x_fc_odsp_division == 'sa_mobility'
- and order.x_fc_sa_status == 'ready_delivery'):
- order._odsp_advance_status(
- 'delivered',
- "Delivery completed. POD signature collected and SA form auto-signed.",
- )
-
- # Handle status-based actions (emails and reminders)
- # skip_status_emails: suppress all status-triggered emails
- # (used when reverting status e.g. cancelled delivery task)
- if self.env.context.get('skip_status_emails'):
- new_app_status = None # Disable all email triggers below
-
- if new_app_status in ('submitted', 'resubmitted'):
- for order in self:
- order._send_submission_email()
- # Create submission history record
- submission_type = 'resubmission' if new_app_status == 'resubmitted' else 'initial'
- self.env['fusion.submission.history'].create_from_submission(order, submission_type=submission_type)
- elif new_app_status in ('approved', 'approved_deduction'):
- for order in self:
- order._send_approval_email()
- order._schedule_delivery_reminder()
- elif new_app_status == 'accepted':
- # 'Accepted' is internal tracking - no external email notification
- # But we record it in submission history
- for order in self:
- # Update the most recent pending submission to 'accepted'
- pending_submission = self.env['fusion.submission.history'].search([
- ('sale_order_id', '=', order.id),
- ('result', '=', 'pending'),
- ], order='submission_date desc', limit=1)
- if pending_submission:
- pending_submission.update_result('accepted')
- elif new_app_status == 'rejected':
- # 'Rejected' - ADP rejected the submission, needs correction
- for order in self:
- order._send_rejection_email()
- # Update the most recent pending submission to 'rejected'
- pending_submission = self.env['fusion.submission.history'].search([
- ('sale_order_id', '=', order.id),
- ('result', '=', 'pending'),
- ], order='submission_date desc', limit=1)
- if pending_submission:
- pending_submission.update_result(
- 'rejected',
- rejection_reason=order.x_fc_rejection_reason,
- rejection_details=order.x_fc_rejection_reason_other,
- )
- elif new_app_status == 'denied':
- for order in self:
- order._send_denial_email()
- elif new_app_status == 'needs_correction':
- # Email sent from the wizard with the reason text, not here.
- # If called programmatically without the wizard, send without reason.
- if not self.env.context.get('skip_correction_email'):
- for order in self:
- order._send_correction_needed_email()
- elif new_app_status == 'case_closed':
- for order in self:
- order._send_case_closed_email()
-
- # ==================================================================
- # MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS
- # ==================================================================
- if new_mod_status and not self.env.context.get('skip_status_emails'):
- for order in self:
- if not order._is_mod_sale():
- continue
- try:
- if new_mod_status == 'assessment_scheduled':
- order._send_mod_assessment_scheduled_email()
- order._send_mod_sms('assessment_scheduled')
- elif new_mod_status == 'assessment_completed':
- order._send_mod_assessment_completed_email()
- elif new_mod_status == 'quote_submitted':
- order._send_mod_quote_submitted_email()
- if not order.x_fc_case_submitted:
- order.with_context(skip_all_validations=True).write({
- 'x_fc_case_submitted': fields.Date.today()})
- elif new_mod_status == 'funding_approved':
- order._send_mod_funding_approved_email()
- order._send_mod_sms('funding_approved')
- if not order.x_fc_case_approved:
- order.with_context(skip_all_validations=True).write({
- 'x_fc_case_approved': fields.Date.today()})
- elif new_mod_status == 'funding_denied':
- order._send_mod_funding_denied_email()
- elif new_mod_status == 'contract_received':
- order._send_mod_contract_received_email()
- elif new_mod_status == 'in_production':
- order._send_mod_sms('initial_payment_received')
- elif new_mod_status == 'project_complete':
- order._send_mod_project_complete_email()
- order._send_mod_sms('project_complete')
- elif new_mod_status == 'pod_submitted':
- order._send_mod_pod_submitted_email()
- elif new_mod_status == 'case_closed':
- order._send_mod_case_closed_email()
- except Exception as e:
- _logger.error(f"MOD status email/sms failed for {order.name} ({new_mod_status}): {e}")
-
- # Check if we need to recalculate
- ICP = self.env['ir.config_parameter'].sudo()
- sale_type_field = ICP.get_param('fusion_claims.field_sale_type', 'x_fc_sale_type')
- client_type_field = ICP.get_param('fusion_claims.field_so_client_type', 'x_fc_client_type')
-
- trigger_fields = {
- 'x_fc_sale_type', 'x_fc_client_type',
- sale_type_field, client_type_field,
- }
-
- if trigger_fields & set(vals.keys()):
- for order in self:
- # Trigger recomputation of x_fc_is_adp_sale
- order._compute_is_adp_sale()
- # Trigger recalculation of ADP portions
- for line in order.order_line:
- line._compute_adp_portions()
-
- # Sync FC fields to invoices when relevant fields change
- sync_fields = {
- 'x_fc_claim_number', 'x_fc_client_ref_1', 'x_fc_client_ref_2',
- 'x_fc_adp_delivery_date', 'x_fc_authorizer_id', 'x_fc_client_type', 'x_fc_primary_serial',
- 'x_fc_service_start_date', 'x_fc_service_end_date',
- }
- if sync_fields & set(vals.keys()):
- for order in self:
- order._sync_fields_to_invoices()
-
- return result
-
- # ==========================================================================
- # FIELD SYNCHRONIZATION (SO -> Invoice)
- # ==========================================================================
- def _get_field_mappings(self):
- """Get field mappings from system parameters.
-
- Returns dict with SO and Invoice field mappings configured in Settings.
- """
- ICP = self.env['ir.config_parameter'].sudo()
- return {
- # Sale Order field mappings
- 'so_claim_number': ICP.get_param('fusion_claims.field_so_claim_number', 'x_fc_claim_number'),
- 'so_client_ref_1': ICP.get_param('fusion_claims.field_so_client_ref_1', 'x_fc_client_ref_1'),
- 'so_client_ref_2': ICP.get_param('fusion_claims.field_so_client_ref_2', 'x_fc_client_ref_2'),
- 'so_delivery_date': ICP.get_param('fusion_claims.field_so_delivery_date', 'x_fc_adp_delivery_date'),
- 'so_authorizer': ICP.get_param('fusion_claims.field_so_authorizer', 'x_fc_authorizer_id'),
- 'so_client_type': ICP.get_param('fusion_claims.field_so_client_type', 'x_fc_client_type'),
- 'so_service_start': ICP.get_param('fusion_claims.field_so_service_start', 'x_fc_service_start_date'),
- 'so_service_end': ICP.get_param('fusion_claims.field_so_service_end', 'x_fc_service_end_date'),
- 'sol_serial': ICP.get_param('fusion_claims.field_sol_serial', 'x_fc_serial_number'),
- # Invoice field mappings
- 'inv_claim_number': ICP.get_param('fusion_claims.field_inv_claim_number', 'x_fc_claim_number'),
- 'inv_client_ref_1': ICP.get_param('fusion_claims.field_inv_client_ref_1', 'x_fc_client_ref_1'),
- 'inv_client_ref_2': ICP.get_param('fusion_claims.field_inv_client_ref_2', 'x_fc_client_ref_2'),
- 'inv_delivery_date': ICP.get_param('fusion_claims.field_inv_delivery_date', 'x_fc_adp_delivery_date'),
- 'inv_authorizer': ICP.get_param('fusion_claims.field_inv_authorizer', 'x_fc_authorizer_id'),
- 'inv_client_type': ICP.get_param('fusion_claims.field_inv_client_type', 'x_fc_client_type'),
- 'inv_service_start': ICP.get_param('fusion_claims.field_inv_service_start', 'x_fc_service_start_date'),
- 'inv_service_end': ICP.get_param('fusion_claims.field_inv_service_end', 'x_fc_service_end_date'),
- 'aml_serial': ICP.get_param('fusion_claims.field_aml_serial', 'x_fc_serial_number'),
- }
-
- def _get_field_value(self, record, field_name):
- """Safely get a field value from a record."""
- if not field_name or field_name not in record._fields:
- return None
- value = getattr(record, field_name, None)
- # Handle Many2one fields - return id for writing
- if hasattr(value, 'id'):
- return value.id if value else False
- return value
-
- def _sync_fields_to_invoices(self):
- """Sync ADP fields from Sale Order to linked Invoices.
-
- Uses dynamic field mappings from Settings.
- """
- mappings = self._get_field_mappings()
-
- for order in self:
- invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
- if not invoices:
- _logger.debug(f"No invoices found for order {order.name}")
- continue
-
- for invoice in invoices:
- vals = {}
-
- # Get source values from SO
- claim_number = order.x_fc_claim_number
- client_ref_1 = order.x_fc_client_ref_1
- client_ref_2 = order.x_fc_client_ref_2
- delivery_date = order.x_fc_adp_delivery_date
-
- authorizer_id = False
- if order.x_fc_authorizer_id:
- authorizer_id = order.x_fc_authorizer_id.id
-
- client_type = order.x_fc_client_type
- service_start = order.x_fc_service_start_date
- service_end = order.x_fc_service_end_date
-
- # Write to Invoice FC fields only (no Studio field writes)
- if claim_number:
- if 'x_fc_claim_number' in invoice._fields:
- vals['x_fc_claim_number'] = claim_number
-
- if client_ref_1:
- if 'x_fc_client_ref_1' in invoice._fields:
- vals['x_fc_client_ref_1'] = client_ref_1
-
- if client_ref_2:
- if 'x_fc_client_ref_2' in invoice._fields:
- vals['x_fc_client_ref_2'] = client_ref_2
-
- if delivery_date:
- if 'x_fc_adp_delivery_date' in invoice._fields:
- vals['x_fc_adp_delivery_date'] = delivery_date
-
- if authorizer_id:
- if 'x_fc_authorizer_id' in invoice._fields:
- vals['x_fc_authorizer_id'] = authorizer_id
-
- if client_type:
- if 'x_fc_client_type' in invoice._fields:
- vals['x_fc_client_type'] = client_type
-
- if service_start:
- if 'x_fc_service_start_date' in invoice._fields:
- vals['x_fc_service_start_date'] = service_start
-
- if service_end:
- if 'x_fc_service_end_date' in invoice._fields:
- vals['x_fc_service_end_date'] = service_end
-
- # Serial Number - sync from SO header to invoice header
- primary_serial = order.x_fc_primary_serial
- if primary_serial:
- vals['x_fc_primary_serial'] = primary_serial
-
- if vals:
- try:
- invoice.sudo().with_context(skip_sync=True).write(vals)
- _logger.debug(f"Synced fields to invoice {invoice.name}: {list(vals.keys())}")
- except Exception as e:
- _logger.warning(f"Failed to sync to invoice {invoice.name}: {e}")
- else:
- _logger.debug(f"No fields to sync to invoice {invoice.name}")
-
- # Sync serial numbers from SO lines to corresponding invoice lines
- order._sync_serial_numbers_to_invoices()
-
- def _sync_serial_numbers_to_invoices(self):
- """Sync serial numbers from SO lines to linked invoice lines.
-
- Uses dynamic field mappings from Settings.
- """
- if self.env.context.get('skip_sync'):
- _logger.info("_sync_serial_numbers_to_invoices: skipped (skip_sync context)")
- return
-
- mappings = self._get_field_mappings()
- sol_serial_field = mappings.get('sol_serial', 'x_fc_serial_number')
- aml_serial_field = mappings.get('aml_serial', 'x_fc_serial_number')
-
- _logger.debug(f"_sync_serial_numbers_to_invoices: Starting. sol_field={sol_serial_field}, aml_field={aml_serial_field}")
-
- for order in self:
- _logger.debug(f" Processing SO {order.name}")
- for so_line in order.order_line:
- if so_line.display_type in ('line_section', 'line_note'):
- continue
-
- # Get serial from THIS SO line ONLY - no fallback to header
- # Each line syncs its OWN serial to corresponding invoice lines
- serial_value = None
- if sol_serial_field in so_line._fields:
- serial_value = getattr(so_line, sol_serial_field, None)
-
- # Skip if this line has no serial - don't use header fallback
- if not serial_value:
- continue
-
- _logger.debug(f" SO line {so_line.id}: serial={serial_value}")
-
- # Find linked invoice lines
- invoice_lines = self.env['account.move.line'].sudo().search([
- ('sale_line_ids', 'in', so_line.id),
- ('move_id.state', '!=', 'cancel')
- ])
-
- for inv_line in invoice_lines:
- vals = {}
- # Write to x_fc_serial_number on invoice line
- if 'x_fc_serial_number' in inv_line._fields:
- vals['x_fc_serial_number'] = serial_value
-
- if vals:
- try:
- inv_line.sudo().with_context(skip_sync=True).write(vals)
- _logger.debug(f" Synced serial '{serial_value}' to invoice line {inv_line.id} (inv {inv_line.move_id.name})")
- except Exception as e:
- _logger.error(f" Failed to sync serial to invoice line {inv_line.id}: {e}")
-
- def action_sync_adp_fields(self):
- """Manual action to sync all ADP fields to invoices."""
- synced_invoices = 0
- for order in self:
- # First sync Studio fields to FC fields on the SO itself
- order._sync_studio_to_fc_fields()
-
- # Then sync to invoices
- invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
- if invoices:
- order._sync_fields_to_invoices()
- synced_invoices += len(invoices)
-
- # Force refresh of the view
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'Fields Synchronized',
- 'message': f'Synced ADP fields from {len(self)} sale order(s) to {synced_invoices} invoice(s). Please refresh the page to see updated values.',
- 'type': 'success',
- 'sticky': False,
- }
- }
-
- @api.model
- def _cron_sync_adp_fields(self):
- """Cron job to sync ADP fields from Sale Orders to Invoices.
-
- Processes all ADP sales created/modified in the last 7 days.
- Uses dynamic field mappings from Settings.
- """
- from datetime import timedelta
- cutoff_date = fields.Datetime.now() - timedelta(days=7)
-
- # Get field mappings
- mappings = self._get_field_mappings()
- sale_type_field = self.env['ir.config_parameter'].sudo().get_param(
- 'fusion_claims.field_sale_type', 'x_fc_sale_type'
- )
-
- # Build domain - check FC sale type fields
- domain = [('write_date', '>=', cutoff_date)]
- or_conditions = []
-
- # Check FC sale type field
- if sale_type_field in self._fields:
- or_conditions.append((sale_type_field, 'in', ['adp', 'adp_odsp', 'ADP', 'ADP/ODSP']))
-
- # Check claim number fields
- claim_field = mappings.get('so_claim_number', 'x_fc_claim_number')
- if claim_field in self._fields:
- or_conditions.append((claim_field, '!=', False))
-
- # Combine with OR - each '|' must be a separate element in the domain list
- if or_conditions:
- # Add (n-1) OR operators for n conditions
- for _ in range(len(or_conditions) - 1):
- domain.append('|')
- # Add all conditions
- for cond in or_conditions:
- domain.append(cond)
-
- try:
- orders = self.search(domain)
- except Exception as e:
- _logger.error(f"Error searching for ADP orders: {e}")
- # Fallback to simpler search
- orders = self.search([
- ('write_date', '>=', cutoff_date),
- ('invoice_ids', '!=', False),
- ])
-
- synced_count = 0
- error_count = 0
-
- for order in orders:
- try:
- # Only sync if it's an ADP sale
- if order._is_adp_sale() or order.x_fc_claim_number:
- order._sync_studio_to_fc_fields()
- order._sync_fields_to_invoices()
- synced_count += 1
- except Exception as e:
- error_count += 1
- _logger.warning(f"Failed to sync order {order.name}: {e}")
-
- _logger.info(f"Fusion Claims sync complete: {synced_count} orders synced, {error_count} errors")
- return synced_count
-
- # ==========================================================================
- # EMAIL SEND OVERRIDE (Use ADP templates for ADP sales)
- # ==========================================================================
- def action_quotation_send(self):
- """Override to use ADP email template for ADP sales.
-
- When sending a quotation for an ADP sale, automatically selects the
- ADP landscape template instead of the default template.
- """
- self.ensure_one()
-
- # Check if this is an ADP sale
- if self._is_adp_sale():
- # Get the ADP template
- template_xmlid = 'fusion_claims.email_template_adp_quotation'
- if self.state in ('sale', 'done'):
- # Use sales order confirmation template for confirmed orders
- template_xmlid = 'fusion_claims.email_template_adp_sales_order'
-
- try:
- template = self.env.ref(template_xmlid, raise_if_not_found=False)
- if template:
- # Open the mail compose wizard with the ADP template pre-selected
- ctx = {
- 'default_model': 'sale.order',
- 'default_res_ids': self.ids,
- 'default_template_id': template.id,
- 'default_email_layout_xmlid': 'mail.mail_notification_layout',
- 'default_composition_mode': 'comment',
- 'mark_so_as_sent': True,
- 'force_email': True,
- 'model_description': self.with_context(lang=self.partner_id.lang).type_name,
- }
-
- return {
- 'type': 'ir.actions.act_window',
- 'res_model': 'mail.compose.message',
- 'view_mode': 'form',
- 'views': [(False, 'form')],
- 'target': 'new',
- 'context': ctx,
- }
- except Exception as e:
- _logger.warning(f"Could not load ADP email template: {e}")
-
- # Fall back to standard behavior for non-ADP sales
- return super().action_quotation_send()
-
- # ==========================================================================
- # ADP ACTIVITY REMINDER METHODS
- # ==========================================================================
- def _schedule_or_renew_adp_activity(self, activity_type_xmlid, user_id, date_deadline, summary, note=False):
- """Schedule or renew an ADP-related activity.
-
- If an activity of the same type for the same user already exists,
- update its deadline instead of creating a duplicate.
-
- Args:
- activity_type_xmlid: XML ID of the activity type
- user_id: ID of the user to assign the activity to
- date_deadline: Deadline date for the activity
- summary: Activity summary text
- note: Optional note text
- """
- self.ensure_one()
-
- try:
- activity_type = self.env.ref(activity_type_xmlid)
- except ValueError:
- _logger.warning(f"Activity type not found: {activity_type_xmlid}")
- return
-
- # Search for existing activity of this type for this user
- existing = self.activity_ids.filtered(
- lambda a: a.activity_type_id.id == activity_type.id
- and a.user_id.id == user_id
- )
-
- if existing:
- # Update existing activity
- existing[0].write({
- 'date_deadline': date_deadline,
- 'summary': summary,
- 'note': note or existing[0].note,
- })
- _logger.info(f"Renewed ADP activity for {self.name}: {summary} -> {date_deadline}")
- else:
- # Create new activity
- self.activity_schedule(
- activity_type_xmlid,
- date_deadline=date_deadline,
- summary=summary,
- note=note,
- user_id=user_id
- )
- _logger.info(f"Scheduled new ADP activity for {self.name}: {summary} -> {date_deadline}")
-
- def _complete_adp_activities(self, activity_type_xmlid):
- """Complete all activities of a specific type for this record.
-
- Args:
- activity_type_xmlid: XML ID of the activity type to complete
- """
- self.ensure_one()
-
- try:
- activity_type = self.env.ref(activity_type_xmlid)
- except ValueError:
- return
-
- activities = self.activity_ids.filtered(
- lambda a: a.activity_type_id.id == activity_type.id
- )
-
- for activity in activities:
- activity.action_feedback(feedback='Completed automatically')
- _logger.info(f"Completed ADP activity for {self.name}: {activity.summary}")
-
- def _schedule_delivery_reminder(self):
- """Schedule a delivery reminder for the salesperson.
-
- Triggered when ADP application status changes to 'approved' or 'approved_deduction'.
- Reminds the salesperson to deliver the order by Tuesday of the next posting week.
- """
- self.ensure_one()
-
- if not self._is_adp_sale():
- return
-
- # Get the salesperson
- salesperson = self.user_id
- if not salesperson:
- _logger.warning(f"No salesperson assigned to {self.name}, cannot schedule delivery reminder")
- return
-
- # Calculate the next posting date and the Tuesday of that week
- next_posting = self._get_next_posting_date()
- reminder_date = self._get_posting_week_tuesday(next_posting)
-
- # Don't schedule if reminder date is in the past
- from datetime import date
- if reminder_date < date.today():
- # Schedule for the next posting cycle
- next_posting = self._get_next_posting_date(next_posting)
- reminder_date = self._get_posting_week_tuesday(next_posting)
-
- summary = f"Deliver ADP order {self.name} for {next_posting.strftime('%b %d')} billing"
- note = f"Complete delivery by Tuesday to meet the Wednesday 6 PM submission deadline for the {next_posting.strftime('%B %d, %Y')} ADP posting."
-
- self._schedule_or_renew_adp_activity(
- 'fusion_claims.mail_activity_type_adp_delivery',
- salesperson.id,
- reminder_date,
- summary,
- note
- )
-
- def _cron_renew_delivery_reminders(self):
- """Cron job to renew overdue delivery reminders.
-
- For sale orders with approved status that have overdue delivery activities,
- reschedule them to the next posting week's Tuesday.
- """
- from datetime import date
- today = date.today()
-
- # Find approved orders with overdue delivery activities
- try:
- activity_type = self.env.ref('fusion_claims.mail_activity_type_adp_delivery')
- except ValueError:
- _logger.warning("ADP Delivery activity type not found")
- return
-
- # Find orders that are approved but not yet billed (delivery still pending)
- approved_orders = self.search([
- ('x_fc_is_adp_sale', '=', True),
- ('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
- ])
-
- for order in approved_orders:
- # Check if there's an overdue delivery activity
- overdue_activities = order.activity_ids.filtered(
- lambda a: a.activity_type_id.id == activity_type.id
- and a.date_deadline < today
- )
-
- if overdue_activities:
- # Reschedule to next posting week
- order._schedule_delivery_reminder()
- _logger.info(f"Renewed overdue delivery reminder for {order.name}")
-
- def _cron_auto_close_billed_cases(self):
- """Cron job to automatically close cases 1 month after being billed.
-
- Finds all sale orders with 'billed' status where the billing date
- was more than 30 days ago, and automatically changes them to 'case_closed'.
- """
- from datetime import date, timedelta
- today = date.today()
- cutoff_date = today - timedelta(days=30)
-
- # Find orders that are billed and have billing date > 30 days ago
- orders_to_close = self.search([
- ('x_fc_is_adp_sale', '=', True),
- ('x_fc_adp_application_status', '=', 'billed'),
- ('x_fc_billing_date', '<=', cutoff_date),
- ])
-
- for order in orders_to_close:
- try:
- # Use context to skip status validation for automated process
- order.with_context(skip_status_validation=True).write({
- 'x_fc_adp_application_status': 'case_closed',
- })
-
- # Post to chatter
- days_since_billed = (today - order.x_fc_billing_date).days
- order.message_post(
- body=f'
Case Automatically Closed
'
- f'
This case has been automatically closed after {days_since_billed} days since billing.
'
- f'
Billing Date: {order.x_fc_billing_date}
',
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- _logger.info(f"Auto-closed case {order.name} after {days_since_billed} days since billing")
- except Exception as e:
- _logger.error(f"Failed to auto-close case {order.name}: {e}")
-
- @api.model
- def _cron_auto_close_odsp_paid_cases(self):
- """Auto-close ODSP/SA/OW cases 7 days after their final workflow step.
-
- SA Mobility & Standard ODSP: close 7 days after payment_received.
- Ontario Works: close 7 days after delivered (payment comes before delivery).
- """
- from datetime import timedelta
- cutoff = fields.Datetime.now() - timedelta(days=7)
- orders = self.search([
- ('x_fc_is_odsp_sale', '=', True),
- ('write_date', '<=', cutoff),
- '|', '|', '|',
- ('x_fc_sa_status', '=', 'payment_received'),
- ('x_fc_odsp_std_status', '=', 'payment_received'),
- ('x_fc_ow_status', '=', 'payment_received'),
- ('x_fc_ow_status', '=', 'delivered'),
- ])
- closeable = {'payment_received', 'delivered'}
- for order in orders:
- status = order._get_odsp_status()
- if status not in closeable:
- continue
- if order.x_fc_odsp_division == 'ontario_works' and status != 'delivered':
- continue
- if order.x_fc_odsp_division != 'ontario_works' and status != 'payment_received':
- continue
- try:
- order._odsp_advance_status(
- 'case_closed',
- "Case automatically closed 7 days after %s." % status.replace('_', ' '),
- )
- _logger.info(f"Auto-closed ODSP case {order.name}")
- except Exception as e:
- _logger.error(f"Failed to auto-close ODSP case {order.name}: {e}")
-
- @api.model
- def _cron_send_acceptance_reminders(self):
- """Cron job: Send reminders for orders still in 'submitted' status next business day.
-
- Per business rule: If 'Accepted by ADP' not marked within 1 business day after submission:
- - First email to Office Notification Recipients
- - Second email to Office + Sales Rep
- """
- from datetime import timedelta
-
- if not self._is_email_notifications_enabled():
- _logger.info("Email notifications disabled, skipping acceptance reminders")
- return
-
- today = fields.Date.today()
-
- # Find orders where:
- # - Status is still 'submitted' (not accepted, rejected, or later)
- # - Submission date was at least 1 business day ago
- #
- # For simplicity, we check if submission was 2+ days ago (covers weekends)
- cutoff_date = today - timedelta(days=2)
-
- orders = self.search([
- ('x_fc_is_adp_sale', '=', True),
- ('x_fc_adp_application_status', '=', 'submitted'),
- ('x_fc_claim_submission_date', '<=', cutoff_date),
- ('x_fc_acceptance_reminder_sent', '=', False),
- ])
-
- if not orders:
- _logger.info("Acceptance reminder cron: No orders require reminders")
- return
-
- _logger.info(f"Acceptance reminder cron: Found {len(orders)} orders to remind")
-
- # Get office notification emails from company
- company = self.env.company
- office_partners = company.sudo().x_fc_office_notification_ids
- office_emails = [p.email for p in office_partners if p.email]
-
- if not office_emails:
- _logger.warning("Acceptance reminder cron: No office notification recipients configured")
- return
-
- for order in orders:
- try:
- days_since_submission = (today - order.x_fc_claim_submission_date).days
-
- client_name = order.partner_id.name or 'Client'
- claim_number = order.x_fc_claim_number or 'N/A'
- order_name = order.name
- submission_date = order.x_fc_claim_submission_date.strftime('%B %d, %Y')
- sales_rep = order.user_id
-
- # Determine recipients
- if days_since_submission > 3:
- to_emails = office_emails.copy()
- if sales_rep and sales_rep.email:
- to_emails.append(sales_rep.email)
- reminder_type = "SECOND"
- else:
- to_emails = office_emails.copy()
- reminder_type = "FIRST"
-
- # Build email using the mixin builder
- level = 'Follow-up' if reminder_type == 'SECOND' else 'Pending'
- subject = f'{level} Review: Acceptance Status - {order_name}'
- body_html = order._email_build(
- title='Acceptance Status Pending',
- summary=f'The application for {client_name} was submitted on '
- f'{submission_date} but has not been marked as accepted or rejected '
- f'({days_since_submission} days pending).',
- email_type='attention',
- sections=[('Details', [
- ('Case', order_name),
- ('Client', client_name),
- ('Claim Number', claim_number),
- ('Submitted', submission_date),
- ('Days Pending', f'{days_since_submission} days'),
- ])],
- note='Action needed: Please update the acceptance status in the system.',
- note_color='#d69e2e',
- button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form',
- button_text='Open Case',
- )
-
- self.env['mail.mail'].sudo().create({
- 'subject': subject,
- 'body_html': body_html,
- 'email_to': ', '.join(to_emails),
- 'model': 'sale.order', 'res_id': order.id,
- }).send()
-
- # Mark as sent so it won't resend on next cron run / restart
- order.with_context(skip_all_validations=True).write({
- 'x_fc_acceptance_reminder_sent': True,
- })
-
- _logger.info(f"Sent {reminder_type.lower()} acceptance reminder for {order.name}")
-
- except Exception as e:
- _logger.error(f"Failed to send acceptance reminder for {order.name}: {e}")
-
- # ======================================================================
- # MARCH OF DIMES - WORKFLOW ACTION METHODS
- # ======================================================================
-
- def action_mod_schedule_assessment(self):
- self.ensure_one()
- self.write({
- 'x_fc_mod_status': 'assessment_scheduled',
- 'x_fc_mod_assessment_scheduled_date': fields.Date.today(),
- })
-
- def action_mod_complete_assessment(self):
- self.ensure_one()
- self.write({
- 'x_fc_mod_status': 'assessment_completed',
- 'x_fc_mod_assessment_completed_date': fields.Date.today(),
- })
-
- def action_mod_processing_drawing(self):
- """Open wizard to attach drawing + photos and send quotation to MOD.
- If drawing/photos already exist, they are pre-loaded in the wizard.
- On confirm: saves to order, sets status to quote_submitted, sends email."""
- self.ensure_one()
- # First set to processing_drawings
- self.with_context(skip_status_emails=True).write({'x_fc_mod_status': 'processing_drawings'})
- # Open the wizard in drawing mode
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'Attach Drawing and Send Quotation',
- 'res_model': 'fusion_claims.send.to.mod.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- 'mod_wizard_mode': 'drawing',
- },
- }
-
- def action_mod_awaiting_funding(self):
- """Open wizard to record Application Submission Date before moving to awaiting funding."""
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'Application Submitted to March of Dimes',
- 'res_model': 'fusion_claims.mod.awaiting.funding.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {'active_id': self.id},
- }
-
- def action_mod_funding_approved(self):
- """Open wizard to record case worker and HVMP reference on approval."""
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'Funding Approved',
- 'res_model': 'fusion_claims.mod.funding.approved.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {'active_id': self.id},
- }
-
- def action_mod_funding_denied(self):
- self.ensure_one()
- self.write({'x_fc_mod_status': 'funding_denied'})
-
- def action_mod_contract_received(self):
- """Open wizard to upload PCA document and record receipt."""
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'PCA Received',
- 'res_model': 'fusion_claims.mod.pca.received.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {'active_id': self.id},
- }
-
- def action_mod_in_production(self):
- self.ensure_one()
- self.write({
- 'x_fc_mod_status': 'in_production',
- 'x_fc_mod_production_started_date': fields.Date.today(),
- })
-
- def action_mod_project_complete(self):
- self.ensure_one()
- self.write({
- 'x_fc_mod_status': 'project_complete',
- 'x_fc_mod_project_completed_date': fields.Date.today(),
- })
-
- def action_mod_pod_submitted(self):
- """Open wizard to attach completion photos + POD and send to case worker."""
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'Submit Completion Photos and POD',
- 'res_model': 'fusion_claims.send.to.mod.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- 'mod_wizard_mode': 'completion',
- },
- }
-
- def action_mod_close_case(self):
- self.ensure_one()
- self.write({
- 'x_fc_mod_status': 'case_closed',
- 'x_fc_mod_case_closed_date': fields.Date.today(),
- })
-
- def action_mod_on_hold(self):
- self.ensure_one()
- self.write({'x_fc_mod_status': 'on_hold'})
-
- def action_mod_resume(self):
- """Resume from on_hold - go back to in_production."""
- self.ensure_one()
- self.write({'x_fc_mod_status': 'in_production'})
-
- def action_cancel(self):
- """Override: also set MOD status to cancelled when order is cancelled."""
- res = super().action_cancel()
- for order in self:
- if order._is_mod_sale() and order.x_fc_mod_status not in ('cancelled', False):
- order.with_context(skip_all_validations=True, skip_status_emails=True).write({
- 'x_fc_mod_status': 'cancelled',
- })
- return res
-
- def _get_mod_partner(self):
- """Find or create the March of Dimes partner for invoicing."""
- ICP = self.env['ir.config_parameter'].sudo()
- mod_email = ICP.get_param('fusion_claims.mod_default_email', 'hvmp@marchofdimes.ca')
- partner = self.env['res.partner'].sudo().search([('email', '=', mod_email)], limit=1)
- if not partner:
- partner = self.env['res.partner'].sudo().create({
- 'name': 'March of Dimes Canada (HVMP)',
- 'email': mod_email,
- 'is_company': True,
- })
- return partner
-
- def _create_mod_invoice(self, partner_id, invoice_lines, portion_type='full', label=''):
- """Create a MOD invoice with given lines. Reusable for full/split."""
- self.ensure_one()
- from odoo.fields import Command
- ICP = self.env['ir.config_parameter'].sudo()
- vendor_code = ICP.get_param('fusion_claims.mod_vendor_code', '')
- authorizer = self.x_fc_authorizer_id
- case_worker = self.x_fc_case_worker
-
- invoice = self.env['account.move'].sudo().create({
- 'move_type': 'out_invoice',
- 'partner_id': partner_id,
- 'partner_shipping_id': self.partner_id.id,
- 'x_fc_source_sale_order_id': self.id,
- 'x_fc_invoice_type': 'march_of_dimes',
- 'x_fc_adp_invoice_portion': portion_type,
- 'x_fc_authorizer_id': authorizer.id if authorizer else False,
- 'x_fc_claim_number': self.x_fc_case_reference or '',
- 'ref': self.x_fc_case_reference or self.name,
- 'invoice_origin': f'{self.name}{label}',
- 'invoice_line_ids': invoice_lines,
- 'narration': Markup(
- f'HVMP Reference: {self.x_fc_case_reference or "N/A"} '
- f'Client: {self.partner_id.name} '
- f'Case Worker: {case_worker.name if case_worker else "N/A"} '
- f'Sale Order: {self.name} '
- f'Vendor Code: {vendor_code}'
- ),
- })
- return invoice
-
- def action_send_to_mod(self):
- """Open the Send to March of Dimes wizard."""
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_window',
- 'name': 'Send to March of Dimes',
- 'res_model': 'fusion_claims.send.to.mod.wizard',
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'active_id': self.id,
- 'active_model': 'sale.order',
- },
- }
-
- # --- MOD Document Preview Actions ---
-
- def action_open_mod_drawing(self):
- self.ensure_one()
- return self._action_open_document('x_fc_mod_drawing', 'Drawing')
-
- def action_open_mod_initial_photos(self):
- self.ensure_one()
- return self._action_open_image('x_fc_mod_initial_photos', 'Initial Photos')
-
- def action_open_mod_pca(self):
- self.ensure_one()
- return self._action_open_document('x_fc_mod_pca_document', 'PCA Document')
-
- def action_open_mod_pod(self):
- self.ensure_one()
- return self._action_open_document('x_fc_mod_proof_of_delivery', 'Proof of Delivery')
-
- def action_open_mod_completion_photos(self):
- self.ensure_one()
- return self._action_open_image('x_fc_mod_completion_photos', 'Completion Photos')
-
- def _action_open_image(self, field_name, label):
- """Open an image attachment in a new browser tab."""
- self.ensure_one()
- if not getattr(self, field_name):
- return {
- 'type': 'ir.actions.client',
- 'tag': 'display_notification',
- 'params': {
- 'title': 'No File',
- 'message': f'No {label} uploaded yet.',
- 'type': 'warning', 'sticky': False,
- },
- }
- attachment = self._get_or_create_attachment(field_name, label)
- if attachment:
- return {
- 'type': 'ir.actions.act_url',
- 'url': f'/web/content/{attachment.id}',
- 'target': 'new',
- }
- return {'type': 'ir.actions.act_window_close'}
-
- # ======================================================================
- # MARCH OF DIMES - EMAIL METHODS
- # ======================================================================
-
- def _build_mod_case_detail_rows(self, include_amounts=False):
- """Build case detail rows for MOD email templates."""
- self.ensure_one()
-
- def fmt(d):
- return d.strftime('%B %d, %Y') if d else None
-
- status_label = dict(self._fields['x_fc_mod_status'].selection).get(
- self.x_fc_mod_status, self.x_fc_mod_status or '')
- rows = [
- ('Case', self.name),
- ('Client', self.partner_id.name or 'N/A'),
- ('HVMP Reference', self.x_fc_case_reference or None),
- ('Status', status_label or None),
- ('Funding Approved', fmt(self.x_fc_case_approved)),
- ('Est. Completion', fmt(self.x_fc_estimated_completion_date)),
- ]
- if include_amounts:
- approved = self.x_fc_mod_approved_amount or 0
- rows.extend([
- ('Order Total', f'${self.amount_total:,.2f}'),
- ])
- if approved:
- rows.append(('MOD Approved', f'${approved:,.2f}'))
- if approved < self.amount_total:
- rows.append(('Client Portion', f'${self.amount_total - approved:,.2f}'))
- return [(l, v) for l, v in rows if v is not None]
-
- def _mod_email_build(self, **kwargs):
- """Wrapper around _email_build that overrides the footer for MOD emails."""
- # Build the email normally
- html = self._email_build(**kwargs)
- # Replace the footer text
- html = html.replace(
- 'This is an automated notification from the ADP Claims Management System.',
- 'This is an automated notification from the Accessibility Case Management System.',
- )
- return html
-
- def _get_mod_email_recipients(self, include_client=True, include_authorizer=True,
- include_mod_contact=False, include_sales_rep=True):
- """Get email recipients for MOD notifications."""
- self.ensure_one()
- to_emails = []
- cc_emails = []
-
- client = self.partner_id
- authorizer = self.x_fc_authorizer_id
- sales_rep = self.user_id
-
- if include_client and client and client.email:
- to_emails.append(client.email)
-
- if include_authorizer and authorizer and authorizer.email:
- if to_emails:
- cc_emails.append(authorizer.email)
- else:
- to_emails.append(authorizer.email)
-
- if include_mod_contact and self.x_fc_mod_contact_email:
- cc_emails.append(self.x_fc_mod_contact_email)
-
- if include_sales_rep and sales_rep and sales_rep.email:
- cc_emails.append(sales_rep.email)
-
- office_cc = self._get_office_cc_emails()
-
- return {
- 'to': to_emails,
- 'cc': cc_emails,
- 'office_cc': office_cc,
- 'authorizer': authorizer,
- 'sales_rep': sales_rep,
- 'client': client,
- }
-
- def _send_mod_email(self, subject_prefix, title, summary, email_type='info',
- include_client=True, include_authorizer=True,
- include_mod_contact=False, include_sales_rep=True,
- sections=None, note=None, note_color=None,
- attachments=None, attachment_names=None):
- """Generic MOD email sender to avoid repeating boilerplate."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
-
- recipients = self._get_mod_email_recipients(
- include_client=include_client, include_authorizer=include_authorizer,
- include_mod_contact=include_mod_contact, include_sales_rep=include_sales_rep)
- to_emails = recipients.get('to', [])
- cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
- if not to_emails and not cc_emails:
- return False
-
- client_name = (recipients.get('client') or self.partner_id).name or 'Client'
- sender_name = (recipients.get('sales_rep') or self.env.user).name
-
- body_html = self._mod_email_build(
- title=title,
- summary=summary,
- email_type=email_type,
- sections=sections or [('Case Details', self._build_mod_case_detail_rows())],
- note=note,
- note_color=note_color,
- button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
- sender_name=sender_name,
- attachments_note=', '.join(attachment_names) if attachment_names else None,
- )
-
- email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
- email_cc_str = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
-
- subject = f'{subject_prefix} - {client_name} - {self.name}'
- try:
- mail_vals = {
- 'subject': subject,
- 'body_html': body_html,
- 'email_to': email_to,
- 'email_cc': email_cc_str,
- 'model': 'sale.order',
- 'res_id': self.id,
- }
- if attachments:
- mail_vals['attachment_ids'] = [(6, 0, attachments)]
- self.env['mail.mail'].sudo().create(mail_vals).send()
- self._email_chatter_log(f'{title} email sent', email_to, email_cc_str,
- [f'Attachments: {", ".join(attachment_names)}'] if attachment_names else None)
- _logger.info(f"MOD email '{title}' sent for {self.name}")
- return True
- except Exception as e:
- _logger.error(f"Failed to send MOD email '{title}' for {self.name}: {e}")
- return False
-
- # --- Individual MOD status email methods ---
-
- def _send_mod_assessment_scheduled_email(self):
- """Email: Assessment has been scheduled. To: Client, CC: Authorizer."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- assess_date = self.x_fc_assessment_start_date.strftime('%B %d, %Y') if hasattr(self, 'x_fc_assessment_start_date') and self.x_fc_assessment_start_date else 'a date to be confirmed'
- return self._send_mod_email(
- subject_prefix='Assessment Scheduled',
- title='Assessment Scheduled',
- summary=f'An accessibility assessment for {client_name} has been scheduled for {assess_date}.',
- email_type='info',
- include_client=True, include_authorizer=True,
- note='What to expect: Our assessor will visit your home to evaluate '
- 'the accessibility modifications needed. Please ensure someone is available at the '
- 'scheduled time. If you need to reschedule, please contact us as soon as possible.',
- )
-
- def _send_mod_assessment_completed_email(self):
- """Email: Assessment completed. To: Client."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- return self._send_mod_email(
- subject_prefix='Assessment Completed',
- title='Assessment Completed',
- summary=f'The accessibility assessment for {client_name} has been completed.',
- email_type='success',
- include_client=True, include_authorizer=False,
- note='Next steps: Our team is now preparing the drawings and quotation '
- 'based on the assessment. We will send you the proposal once it is ready for review.',
- note_color='#38a169',
- )
-
- def _send_mod_quote_submitted_email(self):
- """Email: Quote/drawings submitted. To: Client, CC: Authorizer, MOD contact."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- return self._send_mod_email(
- subject_prefix='Quotation & Drawings Submitted',
- title='Quotation & Drawings Submitted',
- summary=f'The quotation and drawings for {client_name} have been submitted for review.',
- email_type='info',
- include_client=True, include_authorizer=True, include_mod_contact=True,
- note='Next steps: The proposal will be reviewed by March of Dimes. '
- 'The funding review process typically takes several weeks. We will follow up '
- 'regularly and keep you updated on the status.',
- )
-
- def _send_mod_funding_approved_email(self):
- """Email: Funding approved by MOD. To: Client, CC: Authorizer, Sales Rep."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- commitment = f'${self.x_fc_mod_approved_amount:,.2f}' if self.x_fc_mod_approved_amount else 'TBD'
- return self._send_mod_email(
- subject_prefix='Funding Approved',
- title='Great News - Funding Approved',
- summary=f'The March of Dimes funding for {client_name} has been approved.',
- email_type='success',
- include_client=True, include_authorizer=True,
- sections=[('Case Details', self._build_mod_case_detail_rows(include_amounts=True))],
- note=f'Approved Amount: {commitment}
'
- 'Next steps: We will receive the Payment Commitment Agreement (PCA) '
- 'from March of Dimes shortly. Once received, we will proceed with the project. '
- 'Our team will be in touch to discuss timelines.',
- note_color='#38a169',
- )
-
- def _send_mod_funding_denied_email(self):
- """Email: Funding denied. To: Client, CC: Authorizer, Sales Rep."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- return self._send_mod_email(
- subject_prefix='Funding Update',
- title='Funding Update',
- summary=f'Unfortunately, the March of Dimes funding request for {client_name} '
- f'was not approved at this time.',
- email_type='urgent',
- include_client=True, include_authorizer=True,
- note='Your options: You may contact March of Dimes directly for more '
- 'information about the decision. Alternative funding options or private payment '
- 'arrangements may be available. Our team is here to help explore your options.',
- note_color='#c53030',
- )
-
- def _send_mod_contract_received_email(self):
- """Email: PCA/Contract received. To: Client, CC: Sales Rep."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- completion_date = self.x_fc_estimated_completion_date.strftime('%B %d, %Y') if self.x_fc_estimated_completion_date else 'TBD'
- return self._send_mod_email(
- subject_prefix='Contract Received - Project Starting',
- title='Contract Received',
- summary=f'The Payment Commitment Agreement for {client_name} has been received from March of Dimes.',
- email_type='success',
- include_client=True, include_authorizer=False,
- note=f'Project Completion Deadline: {completion_date}
'
- 'Next steps: We will now begin processing your project. '
- 'Our team will be in contact to schedule the next steps and keep you updated on progress.',
- note_color='#38a169',
- )
-
- def _send_mod_invoice_submitted_email(self):
- """Email: Invoice submitted to MOD. To: MOD contact."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- if not self.x_fc_mod_contact_email:
- _logger.warning(f"No MOD contact email for {self.name}, skipping invoice email")
- return False
- return self._send_mod_email(
- subject_prefix='Invoice Submitted',
- title='Invoice Submitted',
- summary=f'Please find attached the invoice for the accessibility modification project for {client_name}.',
- email_type='info',
- include_client=False, include_authorizer=False, include_mod_contact=True,
- sections=[('Invoice Details', self._build_mod_case_detail_rows(include_amounts=True))],
- note='Please process the initial payment (90%) as per the Payment Commitment Agreement terms.',
- )
-
- def _send_mod_initial_payment_email(self):
- """Email: 90% payment received, project progressing. To: Client."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- amount = f'${self.x_fc_mod_initial_payment_amount:,.2f}' if self.x_fc_mod_initial_payment_amount else 'the initial payment'
- return self._send_mod_email(
- subject_prefix='Project Update - Payment Received',
- title='Project Update',
- summary=f'We have received {amount} for the accessibility project for {client_name}. '
- f'Your project is now in active production.',
- email_type='success',
- include_client=True, include_authorizer=False,
- note='What is happening: Your project is being processed and we are '
- 'working towards completion. We will keep you updated on key milestones.',
- note_color='#38a169',
- )
-
- def _send_mod_project_complete_email(self):
- """Email: Project/installation complete. To: Client, CC: Authorizer."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- return self._send_mod_email(
- subject_prefix='Project Complete',
- title='Project Installation Complete',
- summary=f'The accessibility modification project for {client_name} has been completed.',
- email_type='success',
- include_client=True, include_authorizer=True,
- note='Next steps: We will be submitting the photos and proof of delivery '
- 'to March of Dimes for final payment processing. If you have any questions or '
- 'concerns about the installation, please contact us.',
- note_color='#38a169',
- )
-
- def _send_mod_pod_submitted_email(self):
- """Email: Photos/POD submitted to MOD. To: MOD contact, CC: Authorizer."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- return self._send_mod_email(
- subject_prefix='Proof of Delivery Submitted',
- title='Proof of Delivery Submitted',
- summary=f'Photos and proof of delivery for {client_name} have been submitted.',
- email_type='info',
- include_client=False, include_authorizer=True, include_mod_contact=True,
- note='Please process the final payment (10%) as per the Payment Commitment Agreement terms.',
- )
-
- def _send_mod_final_payment_email(self):
- """Email: Final payment received. To: Client."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- return self._send_mod_email(
- subject_prefix='Final Payment Received',
- title='Final Payment Received',
- summary=f'The final payment for the accessibility project for {client_name} has been received.',
- email_type='success',
- include_client=True, include_authorizer=False,
- note='Thank you! All payments have been received and your project is '
- 'now fully complete. If you need any support or have warranty questions, '
- 'please do not hesitate to contact us.',
- note_color='#38a169',
- )
-
- def _send_mod_case_closed_email(self):
- """Email: Case closed. To: Client, CC: Authorizer."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- return self._send_mod_email(
- subject_prefix='Case Closed',
- title='Case Closed',
- summary=f'The accessibility modification case for {client_name} has been closed.',
- email_type='info',
- include_client=True, include_authorizer=True,
- note='Important: Your equipment comes with a one-year warranty on '
- 'materials, equipment, and workmanship from the date of installation. '
- 'If you experience any issues, please contact us immediately.',
- )
-
- def _send_mod_followup_email(self):
- """Auto-email to client when follow-up activity is not completed on time."""
- self.ensure_one()
- if not self._is_email_notifications_enabled():
- return False
-
- client = self.partner_id
- if not client or not client.email:
- return False
-
- client_name = client.name or 'Client'
- sender_name = (self.user_id or self.env.user).name
- followup_count = self.x_fc_mod_followup_count or 0
-
- body_html = self._mod_email_build(
- title='Project Status Check-In',
- summary=f'We wanted to check in on the accessibility modification project for '
- f'{client_name}.',
- email_type='info',
- sections=[('Case Details', self._build_mod_case_detail_rows())],
- note='We are here to help: If you have received any updates from March of Dimes '
- 'regarding your funding application, please let us know so we can proceed accordingly. '
- 'If you have any questions about your project, feel free to reach out to us anytime.',
- button_url=False,
- sender_name=sender_name,
- )
-
- try:
- self.env['mail.mail'].sudo().create({
- 'subject': f'Project Update Check-In - {client_name} - {self.name}',
- 'body_html': body_html,
- 'email_to': client.email,
- 'model': 'sale.order', 'res_id': self.id,
- }).send()
- self.with_context(skip_all_validations=True).write({
- 'x_fc_mod_last_followup_date': fields.Date.today(),
- 'x_fc_mod_followup_count': followup_count + 1,
- 'x_fc_mod_followup_escalated': True,
- })
- self._email_chatter_log('MOD Follow-up auto-email sent (activity overdue)', client.email)
- return True
- except Exception as e:
- _logger.error(f"Failed to send MOD follow-up email for {self.name}: {e}")
- return False
-
- # ======================================================================
- # MARCH OF DIMES - TWILIO SMS
- # ======================================================================
-
- def _send_mod_sms(self, trigger):
- """Send Twilio SMS for key MOD status changes."""
- self.ensure_one()
- ICP = self.env['ir.config_parameter'].sudo()
- if ICP.get_param('fusion_claims.twilio_enabled', 'False').lower() not in ('true', '1', 'yes'):
- return False
-
- client = self.partner_id
- phone = client.mobile or client.phone if client else None
- if not phone:
- _logger.info(f"No phone number for {self.name}, skipping SMS")
- return False
-
- client_name = client.name or 'Client'
- company_phone = self.company_id.phone or ''
-
- messages = {
- 'assessment_scheduled': (
- f"Hi {client_name}, your accessibility assessment with Westin Healthcare "
- f"has been scheduled. We will confirm the exact date and time shortly. "
- f"For questions, call {company_phone}."
- ),
- 'funding_approved': (
- f"Hi {client_name}, great news! Your March of Dimes funding has been approved. "
- f"Our team will be in touch with next steps. Questions? Call {company_phone}."
- ),
- 'initial_payment_received': (
- f"Hi {client_name}, we have received the initial payment for your project. "
- f"Work is in progress. We will keep you updated. Call {company_phone} for info."
- ),
- 'project_complete': (
- f"Hi {client_name}, your accessibility modification project is now complete! "
- f"If you have any questions or concerns, call us at {company_phone}."
- ),
- }
-
- message = messages.get(trigger)
- if not message:
- return False
-
- return self._twilio_send_sms(phone, message)
-
- def _twilio_send_sms(self, to_number, message):
- """Send SMS via Twilio REST API."""
- import requests as req
- self.ensure_one()
- ICP = self.env['ir.config_parameter'].sudo()
- account_sid = ICP.get_param('fusion_claims.twilio_account_sid', '')
- auth_token = ICP.get_param('fusion_claims.twilio_auth_token', '')
- from_number = ICP.get_param('fusion_claims.twilio_phone_number', '')
-
- if not all([account_sid, auth_token, from_number]):
- _logger.warning("Twilio not configured, skipping SMS")
- return False
-
- url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json'
- try:
- resp = req.post(url, data={
- 'To': to_number,
- 'From': from_number,
- 'Body': message,
- }, auth=(account_sid, auth_token), timeout=10)
- if resp.status_code in (200, 201):
- self._email_chatter_log(f'SMS sent to {to_number}', to_number)
- _logger.info(f"Twilio SMS sent to {to_number} for {self.name}")
- return True
- else:
- _logger.error(f"Twilio SMS failed ({resp.status_code}): {resp.text}")
- return False
- except Exception as e:
- _logger.error(f"Twilio SMS error for {self.name}: {e}")
- return False
-
- # ======================================================================
- # MARCH OF DIMES - FOLLOW-UP CRON
- # ======================================================================
-
- @api.model
- def _cron_mod_schedule_followups(self):
- """Cron: Schedule bi-weekly follow-up activities for MOD cases awaiting funding."""
- from datetime import timedelta
-
- ICP = self.env['ir.config_parameter'].sudo()
- interval_days = int(ICP.get_param('fusion_claims.mod_followup_interval_days', '14'))
-
- # Statuses that need follow-up (waiting for funding decision)
- followup_statuses = ['quote_submitted', 'awaiting_funding']
-
- orders = self.search([
- ('x_fc_sale_type', '=', 'march_of_dimes'),
- ('x_fc_mod_status', 'in', followup_statuses),
- ])
-
- today = fields.Date.today()
-
- for order in orders:
- try:
- next_date = order.x_fc_mod_next_followup_date
- # If no next followup date set, or it's in the past, schedule one
- if not next_date or next_date <= today:
- # Calculate from last followup or quote submission date
- base_date = order.x_fc_mod_last_followup_date or order.x_fc_case_submitted or today
- new_followup = base_date + timedelta(days=interval_days)
- if new_followup <= today:
- new_followup = today + timedelta(days=1) # Schedule for tomorrow at minimum
-
- # Create scheduled activity
- activity_type = self.env.ref(
- 'fusion_claims.mail_activity_type_mod_followup', raise_if_not_found=False)
- if activity_type:
- # Check if there's already an open activity of this type
- existing = self.env['mail.activity'].search([
- ('res_model', '=', 'sale.order'),
- ('res_id', '=', order.id),
- ('activity_type_id', '=', activity_type.id),
- ], limit=1)
- if not existing:
- order.activity_schedule(
- 'fusion_claims.mail_activity_type_mod_followup',
- date_deadline=new_followup,
- user_id=(order.user_id or self.env.user).id,
- summary=f'MOD Follow-up: Call {order.partner_id.name or "client"} for funding update',
- )
-
- order.with_context(skip_all_validations=True).write({
- 'x_fc_mod_next_followup_date': new_followup,
- })
- _logger.info(f"Scheduled MOD follow-up for {order.name} on {new_followup}")
- except Exception as e:
- _logger.error(f"Failed to schedule MOD follow-up for {order.name}: {e}")
-
- @api.model
- def _cron_mod_escalate_followups(self):
- """Cron: Send auto-email if follow-up activity is overdue (not completed within 3 days)."""
- from datetime import timedelta
-
- ICP = self.env['ir.config_parameter'].sudo()
- escalation_days = int(ICP.get_param('fusion_claims.mod_followup_escalation_days', '3'))
-
- activity_type = self.env.ref(
- 'fusion_claims.mail_activity_type_mod_followup', raise_if_not_found=False)
- if not activity_type:
- return
-
- # Find overdue follow-up activities
- cutoff_date = fields.Date.today() - timedelta(days=escalation_days)
- overdue_activities = self.env['mail.activity'].search([
- ('res_model', '=', 'sale.order'),
- ('activity_type_id', '=', activity_type.id),
- ('date_deadline', '<=', cutoff_date),
- ])
-
- for activity in overdue_activities:
- try:
- order = self.browse(activity.res_id)
- if not order.exists() or not order._is_mod_sale():
- continue
- if order.x_fc_mod_status not in ('quote_submitted', 'awaiting_funding'):
- # Status moved past follow-up phase, clean up the activity
- activity.unlink()
- continue
- # Only escalate once per activity
- if not order.x_fc_mod_followup_escalated:
- order._send_mod_followup_email()
- # Clean up the overdue activity and let the scheduler create a new one
- activity.unlink()
- except Exception as e:
- _logger.error(f"Failed to escalate MOD follow-up for activity {activity.id}: {e}")
-
- # ======================================================================
- # ODSP EMAIL AUTOMATION
- # ======================================================================
-
- def _odsp_email_build(self, **kwargs):
- """Wrapper around _email_build that overrides the footer for ODSP emails."""
- html = self._email_build(**kwargs)
- html = html.replace(
- 'This is an automated notification from the ADP Claims Management System.',
- 'This is an automated notification from the ODSP Case Management System.',
- )
- return html
-
- def _get_sa_mobility_email(self):
- """Get the configured SA Mobility email address."""
- return self.env['ir.config_parameter'].sudo().get_param(
- 'fusion_claims.sa_mobility_email', 'samobility@ontario.ca')
-
- def _send_sa_mobility_email(self, request_type='repair', device_description='',
- attachment_ids=None, email_body_notes=None):
- """Send SA Mobility submission email.
-
- Args:
- request_type: 'batteries' or 'repair'
- device_description: human-readable device label
- attachment_ids: list of ir.attachment IDs to attach
- email_body_notes: optional urgency/priority notes for the email body
- """
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- member_id = self.x_fc_odsp_member_id or self.partner_id.x_fc_odsp_member_id or ''
- client_address = self.partner_id.contact_address or ''
-
- sa_email = self._get_sa_mobility_email()
- subject = f'{client_name} - ODSP - {member_id}' if member_id else client_name
-
- summary_parts = []
- if email_body_notes:
- summary_parts.append(
- f'{email_body_notes}'
- )
-
- if request_type == 'batteries':
- summary_parts.append(
- f'Client is getting {device_description or "Electric Wheelchair / Mobility Scooter"} from ADP. '
- f'ADP is covering the equipment. We have submitted request for approval to ADP '
- f'and client is seeking approval from ODSP for Batteries.'
- )
- else:
- summary_parts.append(
- f'Client has {device_description or "mobility equipment"} '
- f'and is looking for replacement parts and repairs. '
- f'Please find the attached SA Mobility Form and Quotation.'
- )
- summary = ' '.join(summary_parts)
-
- sections = [('Client Details', [
- ('Client Name', client_name),
- ('ODSP Member ID', member_id),
- ('Address', client_address),
- ('Order #', self.name),
- ])]
-
- body_html = self._odsp_email_build(
- title='SA Mobility Request',
- summary=summary,
- email_type='info',
- sections=sections,
- sender_name=(self.user_id or self.env.user).name,
- )
-
- cc_emails = []
- if self.user_id and self.user_id.email:
- cc_emails.append(self.user_id.email)
-
- mail_vals = {
- 'subject': subject,
- 'body_html': body_html,
- 'email_to': sa_email,
- 'email_cc': ', '.join(cc_emails) if cc_emails else '',
- 'model': 'sale.order',
- 'res_id': self.id,
- }
- if attachment_ids:
- mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
-
- try:
- self.env['mail.mail'].sudo().create(mail_vals).send()
- self._email_chatter_log(
- 'SA Mobility request sent', sa_email,
- ', '.join(cc_emails) if cc_emails else None)
- _logger.info(f"SA Mobility email sent for {self.name} to {sa_email}")
- except Exception as e:
- _logger.error(f"Failed to send SA Mobility email for {self.name}: {e}")
- from odoo.exceptions import UserError
- raise UserError(f"Failed to send email: {e}")
-
- def _send_sa_mobility_completion_email(self, attachment_ids=None):
- """Send SA Mobility completion email with signed form, POD, and invoice."""
- self.ensure_one()
- client_name = self.partner_id.name or 'Client'
- member_id = self.x_fc_odsp_member_id or ''
- sa_email = self._get_sa_mobility_email()
-
- subject = f'{client_name} - {member_id} - Completed'
- summary = (
- f'Delivery/repair for {client_name} has been completed. '
- f'Please find the attached signed SA Mobility approval form, '
- f'Proof of Delivery, and Invoice.'
- )
-
- body_html = self._odsp_email_build(
- title='SA Mobility - Completed',
- summary=summary,
- email_type='success',
- sections=[('Case Details', [
- ('Client Name', client_name),
- ('ODSP Member ID', member_id),
- ('Order #', self.name),
- ])],
- sender_name=(self.user_id or self.env.user).name,
- )
-
- cc_emails = []
- if self.user_id and self.user_id.email:
- cc_emails.append(self.user_id.email)
-
- mail_vals = {
- 'subject': subject,
- 'body_html': body_html,
- 'email_to': sa_email,
- 'email_cc': ', '.join(cc_emails) if cc_emails else '',
- 'model': 'sale.order',
- 'res_id': self.id,
- }
- if attachment_ids:
- mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
-
- try:
- self.env['mail.mail'].sudo().create(mail_vals).send()
- self._email_chatter_log(
- 'SA Mobility completion sent', sa_email,
- ', '.join(cc_emails) if cc_emails else None)
- _logger.info(f"SA Mobility completion email sent for {self.name}")
- except Exception as e:
- _logger.error(f"Failed to send SA Mobility completion email for {self.name}: {e}")
-
- def _send_odsp_submission_email(self, attachment_ids=None, email_body_notes=None):
- """Send ODSP submission email to the selected ODSP office."""
- self.ensure_one()
- if not self.x_fc_odsp_office_id or not self.x_fc_odsp_office_id.email:
- from odoo.exceptions import UserError
- raise UserError("ODSP Office email is required. Please select an ODSP Office with an email.")
-
- client_name = self.partner_id.name or 'Client'
- member_id = self.x_fc_odsp_member_id or ''
- office_email = self.x_fc_odsp_office_id.email
-
- subject = f'ODSP Application - {client_name} - {member_id}'
-
- summary_parts = []
- if email_body_notes:
- summary_parts.append(
- f'{email_body_notes}'
- )
- summary_parts.append(
- f'Please find enclosed the ODSP application documents for '
- f'{client_name} (Member ID: {member_id}).'
- )
- summary = ' '.join(summary_parts)
-
- body_html = self._odsp_email_build(
- title='ODSP Application Submission',
- summary=summary,
- email_type='info',
- sections=[('Application Details', [
- ('Client Name', client_name),
- ('ODSP Member ID', member_id),
- ('Order #', self.name),
- ])],
- sender_name=(self.user_id or self.env.user).name,
- )
-
- cc_emails = []
- if self.user_id and self.user_id.email:
- cc_emails.append(self.user_id.email)
-
- mail_vals = {
- 'subject': subject,
- 'body_html': body_html,
- 'email_to': office_email,
- 'email_cc': ', '.join(cc_emails) if cc_emails else '',
- 'model': 'sale.order',
- 'res_id': self.id,
- }
- if attachment_ids:
- mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
-
- try:
- self.env['mail.mail'].sudo().create(mail_vals).send()
- self._email_chatter_log('ODSP submission sent', office_email,
- ', '.join(cc_emails) if cc_emails else None)
- if self._get_odsp_status() in ('quotation', 'documents_ready'):
- self._odsp_advance_status('submitted_to_odsp',
- "Status auto-advanced after ODSP submission email.")
- _logger.info(f"ODSP submission email sent for {self.name} to {office_email}")
- except Exception as e:
- _logger.error(f"Failed to send ODSP submission email for {self.name}: {e}")
diff --git a/fusion_claims/fusion_claims/models/sale_order_line.py b/fusion_claims/fusion_claims/models/sale_order_line.py
deleted file mode 100644
index c7118ef..0000000
--- a/fusion_claims/fusion_claims/models/sale_order_line.py
+++ /dev/null
@@ -1,362 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2025 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-from odoo import models, fields, api
-
-
-class SaleOrderLine(models.Model):
- _inherit = 'sale.order.line'
-
- # ==========================================================================
- # PARENT FIELD FOR VISIBILITY (used by Studio conditions)
- # ==========================================================================
- x_fc_is_adp_sale = fields.Boolean(
- compute='_compute_is_adp_sale_line',
- string='Is ADP Sale',
- store=False,
- help='True if parent order is an ADP sale - used for column visibility',
- )
-
- def _compute_is_adp_sale_line(self):
- """Check if parent order is an ADP sale."""
- for line in self:
- is_adp = False
- if line.order_id and line.order_id.x_fc_sale_type:
- is_adp = 'adp' in str(line.order_id.x_fc_sale_type).lower()
- line.x_fc_is_adp_sale = is_adp
-
- @api.depends('product_id', 'product_id.default_code')
- def _compute_adp_device_type(self):
- """Compute ADP device type from the product's device code."""
- ADPDevice = self.env['fusion.adp.device.code'].sudo()
- for line in self:
- device_type = ''
- if line.product_id:
- # Get the device code from product (default_code or custom field)
- device_code = line._get_adp_device_code()
- if device_code:
- # Look up device type in ADP database
- adp_device = ADPDevice.search([
- ('device_code', '=', device_code),
- ('active', '=', True)
- ], limit=1)
- if adp_device:
- device_type = adp_device.device_type or ''
- line.x_fc_adp_device_type = device_type
-
- # ==========================================================================
- # SERIAL NUMBER AND DEVICE PLACEMENT
- # ==========================================================================
- x_fc_serial_number = fields.Char(
- string='Serial Number',
- help='Serial number for this product',
- )
- x_fc_device_placement = fields.Selection(
- selection=[
- ('L', 'Left'),
- ('R', 'Right'),
- ('NA', 'N/A'),
- ],
- string='Device Placement',
- default='NA',
- help='Device placement position (Left/Right/N/A)',
- )
-
- # ==========================================================================
- # DEDUCTION FIELDS
- # ==========================================================================
- x_fc_deduction_type = fields.Selection(
- selection=[
- ('none', 'No Deduction'),
- ('pct', 'Percentage'),
- ('amt', 'Amount'),
- ],
- string='Deduction Type',
- default='none',
- help='Type of ADP deduction applied to this line',
- )
- x_fc_deduction_value = fields.Float(
- string='Deduction Value',
- digits='Product Price',
- help='Deduction value (percentage if PCT, dollar amount if AMT)',
- )
-
- # ==========================================================================
- # ADP REFERENCE FIELDS
- # ==========================================================================
- x_fc_adp_max_price = fields.Float(
- string='ADP Max Price',
- digits='Product Price',
- help='Maximum price ADP will cover for this device (from mobility manual)',
- )
- x_fc_sn_required = fields.Boolean(
- string='S/N Required',
- help='Is serial number required for this device?',
- )
-
- # ==========================================================================
- # ADP DEVICE APPROVAL TRACKING
- # ==========================================================================
- x_fc_adp_approved = fields.Boolean(
- string='ADP Approved',
- default=False,
- help='Was this device approved by ADP in the application approval?',
- )
- x_fc_adp_device_type = fields.Char(
- string='ADP Device Type',
- compute='_compute_adp_device_type',
- store=True,
- help='Device type from ADP mobility manual (for approval matching)',
- )
-
- # ==========================================================================
- # COMPUTED ADP PORTIONS
- # ==========================================================================
- x_fc_adp_portion = fields.Monetary(
- string='ADP Portion',
- compute='_compute_adp_portions',
- store=True,
- currency_field='currency_id',
- help='ADP portion for this line',
- )
- x_fc_client_portion = fields.Monetary(
- string='Client Portion',
- compute='_compute_adp_portions',
- store=True,
- currency_field='currency_id',
- help='Client portion for this line',
- )
-
- # ==========================================================================
- # COMPUTE ADP PORTIONS WITH DEDUCTIONS AND APPROVAL STATUS
- # ==========================================================================
- @api.depends('price_subtotal', 'product_uom_qty', 'price_unit', 'product_id',
- 'order_id.x_fc_sale_type', 'order_id.x_fc_client_type',
- 'order_id.x_fc_device_verification_complete',
- 'x_fc_deduction_type', 'x_fc_deduction_value', 'x_fc_adp_max_price',
- 'x_fc_adp_approved')
- def _compute_adp_portions(self):
- """Compute ADP and client portions based on product's ADP price, client type, and approval status.
-
- IMPORTANT:
- 1. If a product has NON-ADP code (NON-ADP, NON-FUNDED, etc.): Client pays 100%
- 2. If a product is NOT in the ADP device database: Client pays 100%
- 3. If a device is NOT approved by ADP: Client pays 100%
- 4. Only products with valid ADP codes that are approved get the 75%/25% (or 100%/0%) split
- """
- ADPDevice = self.env['fusion.adp.device.code'].sudo()
-
- for line in self:
- # Get sale type and client type from parent order
- order = line.order_id
- if not order:
- line.x_fc_adp_portion = 0
- line.x_fc_client_portion = 0
- continue
-
- # Check if this is an ADP sale
- if not order._is_adp_sale():
- line.x_fc_adp_portion = 0
- line.x_fc_client_portion = 0
- continue
-
- # Skip non-product lines
- if not line.product_id or line.product_uom_qty <= 0:
- line.x_fc_adp_portion = 0
- line.x_fc_client_portion = 0
- continue
-
- # =================================================================
- # CHECK 1: Is this a NON-ADP funded product?
- # Products with NON-ADP, NON-FUNDED, UNFUNDED codes = 100% client
- # =================================================================
- if line.product_id.is_non_adp_funded():
- line.x_fc_adp_portion = 0
- line.x_fc_client_portion = line.price_subtotal
- continue
-
- # =================================================================
- # CHECK 2: Does this product have a valid ADP device code?
- # Products without valid ADP codes in the database = 100% client
- # =================================================================
- device_code = line._get_adp_device_code()
- is_adp_device = False
- if device_code:
- # Check if this code exists in the ADP mobility manual database
- is_adp_device = ADPDevice.search_count([
- ('device_code', '=', device_code),
- ('active', '=', True)
- ]) > 0
-
- # If product has NO valid ADP code in database: client pays 100%
- if not is_adp_device:
- line.x_fc_adp_portion = 0
- line.x_fc_client_portion = line.price_subtotal
- continue
-
- # =================================================================
- # CHECK 3: If this is an ADP device but NOT approved: 100% client
- # =================================================================
- if order.x_fc_device_verification_complete and not line.x_fc_adp_approved:
- line.x_fc_adp_portion = 0
- line.x_fc_client_portion = line.price_subtotal
- continue
-
- # =================================================================
- # STANDARD CALCULATION: Product is a valid, approved ADP device
- # =================================================================
-
- # Get client type and determine base percentages
- client_type = order._get_client_type()
- if client_type == 'REG':
- # REG: 75% ADP, 25% Client
- base_adp_pct = 0.75
- base_client_pct = 0.25
- else:
- # ODS, OWP, ACS, LTC, SEN, CCA: 100% ADP, 0% Client
- base_adp_pct = 1.0
- base_client_pct = 0.0
-
- # Get the ADP price from the product
- adp_price = 0
- if line.product_id and line.product_id.product_tmpl_id:
- product_tmpl = line.product_id.product_tmpl_id
- if hasattr(product_tmpl, 'x_fc_adp_price'):
- adp_price = product_tmpl.x_fc_adp_price or 0
- if not adp_price and line.x_fc_adp_max_price:
- adp_price = line.x_fc_adp_max_price
- if not adp_price:
- adp_price = line.price_unit
-
- qty = line.product_uom_qty
- adp_base_total = adp_price * qty
-
- # Apply deductions
- if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
- # PCT: ADP only covers deduction_value% of their portion
- effective_adp_pct = base_adp_pct * (line.x_fc_deduction_value / 100)
- effective_client_pct = 1 - effective_adp_pct
- adp_portion = adp_base_total * effective_adp_pct
- client_portion = adp_base_total * effective_client_pct
- elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
- # AMT: Subtract fixed amount from ADP portion
- base_adp_amount = adp_base_total * base_adp_pct
- adp_portion = max(0, base_adp_amount - line.x_fc_deduction_value)
- client_portion = adp_base_total - adp_portion
- else:
- # No deduction - standard calculation based on ADP price
- adp_portion = adp_base_total * base_adp_pct
- client_portion = adp_base_total * base_client_pct
-
- line.x_fc_adp_portion = adp_portion
- line.x_fc_client_portion = client_portion
-
- # ==========================================================================
- # GETTER METHODS
- # ==========================================================================
- def _get_adp_device_code(self):
- """Get ADP device code from product.
-
- Checks multiple sources in order:
- 1. x_fc_adp_device_code (module field)
- 2. x_adp_code (Studio/custom field)
- 3. default_code
- 4. Code in parentheses in product name (e.g., "Product Name (SE0001109)")
- """
- import re
-
- self.ensure_one()
- if not self.product_id:
- return ''
-
- product_tmpl = self.product_id.product_tmpl_id
- ADPDevice = self.env['fusion.adp.device.code'].sudo()
-
- # 1. Check x_fc_adp_device_code (module field)
- code = ''
- if hasattr(product_tmpl, 'x_fc_adp_device_code'):
- code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or ''
-
- # Verify code exists in ADP database
- if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
- return code
-
- # 2. Check x_adp_code (Studio/custom field)
- if hasattr(product_tmpl, 'x_adp_code'):
- code = getattr(product_tmpl, 'x_adp_code', '') or ''
- if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
- return code
-
- # 3. Check default_code
- code = self.product_id.default_code or ''
- if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
- return code
-
- # 4. Try to extract code from product name in parentheses
- # E.g., "[MXA-1618] GEOMATRIX SILVERBACK MAX BACKREST - ACTIVE (SE0001109)"
- product_name = self.product_id.name or ''
- # Find all codes in parentheses
- matches = re.findall(r'\(([A-Z0-9]+)\)', product_name)
- for potential_code in matches:
- if ADPDevice.search_count([('device_code', '=', potential_code), ('active', '=', True)]) > 0:
- return potential_code
-
- # 5. Final fallback - return default_code even if not in ADP database
- return self.product_id.default_code or ''
-
- def _get_serial_number(self):
- """Get serial number from mapped field or native field."""
- self.ensure_one()
- ICP = self.env['ir.config_parameter'].sudo()
- field_name = ICP.get_param('fusion_claims.field_sol_serial', 'x_fc_serial_number')
-
- # Try mapped field first
- if hasattr(self, field_name):
- value = getattr(self, field_name, None)
- if value:
- return value
-
- # Fallback to native field
- return self.x_fc_serial_number or ''
-
- def _get_device_placement(self):
- """Get device placement."""
- self.ensure_one()
- return self.x_fc_device_placement or 'NA'
-
- # ==========================================================================
- # INVOICE LINE PREPARATION
- # ==========================================================================
- def _prepare_invoice_line(self, **optional_values):
- """Override to copy ADP line fields to the invoice line."""
- vals = super()._prepare_invoice_line(**optional_values)
- vals.update({
- 'x_fc_serial_number': self.x_fc_serial_number,
- 'x_fc_device_placement': self.x_fc_device_placement,
- 'x_fc_deduction_type': self.x_fc_deduction_type,
- 'x_fc_deduction_value': self.x_fc_deduction_value,
- 'x_fc_adp_max_price': self.x_fc_adp_max_price,
- 'x_fc_sn_required': self.x_fc_sn_required,
- 'x_fc_adp_approved': self.x_fc_adp_approved,
- 'x_fc_adp_device_type': self.x_fc_adp_device_type,
- })
- return vals
-
- # ==========================================================================
- # ONCHANGE FOR ADP MAX PRICE LOOKUP
- # ==========================================================================
- @api.onchange('product_id')
- def _onchange_product_adp_info(self):
- """Lookup ADP info from device codes when product changes."""
- if self.product_id:
- # Try to find device code in the reference table
- device_code = self._get_adp_device_code()
- if device_code:
- adp_device = self.env['fusion.adp.device.code'].sudo().search([
- ('device_code', '=', device_code)
- ], limit=1)
- if adp_device:
- self.x_fc_adp_max_price = adp_device.adp_price
- self.x_fc_sn_required = adp_device.sn_required
diff --git a/fusion_claims/fusion_claims/models/submission_history.py b/fusion_claims/fusion_claims/models/submission_history.py
deleted file mode 100644
index 17f7218..0000000
--- a/fusion_claims/fusion_claims/models/submission_history.py
+++ /dev/null
@@ -1,237 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2024-2026 Nexa Systems Inc.
-# License OPL-1 (Odoo Proprietary License v1.0)
-# Part of the Fusion Claim Assistant product family.
-
-from odoo import api, fields, models
-from markupsafe import Markup
-import logging
-
-_logger = logging.getLogger(__name__)
-
-
-class FusionSubmissionHistory(models.Model):
- """Track submission history for ADP applications.
-
- Each record represents one submission or resubmission to ADP,
- including the documents submitted, the result, and any rejection reasons.
- """
- _name = 'fusion.submission.history'
- _description = 'ADP Submission History'
- _order = 'submission_date desc, id desc'
- _rec_name = 'display_name'
-
- # ==========================================================================
- # RELATIONSHIPS
- # ==========================================================================
- sale_order_id = fields.Many2one(
- 'sale.order',
- string='Sale Order',
- required=True,
- ondelete='cascade',
- index=True,
- )
-
- # ==========================================================================
- # SUBMISSION DETAILS
- # ==========================================================================
- display_name = fields.Char(
- string='Display Name',
- compute='_compute_display_name',
- store=True,
- )
-
- submission_number = fields.Integer(
- string='Submission #',
- default=1,
- help='Sequence number for this submission (1 = first submission, 2+ = resubmissions)',
- )
-
- submission_type = fields.Selection(
- selection=[
- ('initial', 'Initial Submission'),
- ('resubmission', 'Resubmission'),
- ('correction', 'Correction'),
- ],
- string='Type',
- default='initial',
- )
-
- submission_date = fields.Date(
- string='Submission Date',
- default=fields.Date.today,
- required=True,
- )
-
- submitted_by_id = fields.Many2one(
- 'res.users',
- string='Submitted By',
- default=lambda self: self.env.user,
- )
-
- # ==========================================================================
- # DOCUMENTS SUBMITTED (copies at time of submission)
- # ==========================================================================
- final_application = fields.Binary(
- string='Final Application (PDF)',
- attachment=True,
- help='Copy of the final application PDF at time of submission',
- )
- final_application_filename = fields.Char(
- string='Final Application Filename',
- )
-
- xml_file = fields.Binary(
- string='XML File',
- attachment=True,
- help='Copy of the XML file at time of submission',
- )
- xml_filename = fields.Char(
- string='XML Filename',
- )
-
- # ==========================================================================
- # RESULT TRACKING
- # ==========================================================================
- result = fields.Selection(
- selection=[
- ('pending', 'Pending'),
- ('accepted', 'Accepted'),
- ('rejected', 'Rejected'),
- ('approved', 'Approved'),
- ('denied', 'Denied'),
- ],
- string='Result',
- default='pending',
- )
-
- result_date = fields.Date(
- string='Result Date',
- help='Date when the result was received from ADP',
- )
-
- # ==========================================================================
- # REJECTION DETAILS (if rejected)
- # ==========================================================================
- rejection_reason = fields.Selection(
- selection=[
- ('name_correction', 'Name Correction Needed'),
- ('healthcard_correction', 'Health Card Correction Needed'),
- ('duplicate_claim', 'Duplicate Claim Exists'),
- ('xml_format_error', 'XML Format/Validation Error'),
- ('missing_info', 'Missing Required Information'),
- ('other', 'Other'),
- ],
- string='Rejection Reason',
- )
-
- rejection_details = fields.Text(
- string='Rejection Details',
- help='Additional details about the rejection',
- )
-
- # ==========================================================================
- # CORRECTION NOTES (for resubmissions)
- # ==========================================================================
- correction_notes = fields.Text(
- string='Correction Notes',
- help='Notes about what was corrected for this resubmission',
- )
-
- # ==========================================================================
- # COMPUTED FIELDS
- # ==========================================================================
- @api.depends('sale_order_id', 'submission_number', 'submission_type')
- def _compute_display_name(self):
- for record in self:
- order_name = record.sale_order_id.name or 'New'
- type_label = dict(record._fields['submission_type'].selection).get(
- record.submission_type, record.submission_type
- )
- record.display_name = f"{order_name} - Submission #{record.submission_number} ({type_label})"
-
- # ==========================================================================
- # HELPER METHODS
- # ==========================================================================
- @api.model
- def create_from_submission(self, sale_order, submission_type='initial', correction_notes=None):
- """Create a submission history record from a sale order submission.
-
- Args:
- sale_order: The sale.order record being submitted
- submission_type: 'initial', 'resubmission', or 'correction'
- correction_notes: Optional notes about corrections made
-
- Returns:
- The created fusion.submission.history record
- """
- # Get next submission number
- existing_count = self.search_count([('sale_order_id', '=', sale_order.id)])
- submission_number = existing_count + 1
-
- # If submission_number > 1, it's a resubmission
- if submission_number > 1 and submission_type == 'initial':
- submission_type = 'resubmission'
-
- vals = {
- 'sale_order_id': sale_order.id,
- 'submission_number': submission_number,
- 'submission_type': submission_type,
- 'submission_date': fields.Date.today(),
- 'submitted_by_id': self.env.user.id,
- 'correction_notes': correction_notes,
- }
-
- # Copy current documents
- if sale_order.x_fc_final_submitted_application:
- vals['final_application'] = sale_order.x_fc_final_submitted_application
- vals['final_application_filename'] = sale_order.x_fc_final_application_filename
-
- if sale_order.x_fc_xml_file:
- vals['xml_file'] = sale_order.x_fc_xml_file
- vals['xml_filename'] = sale_order.x_fc_xml_filename
-
- record = self.create(vals)
-
- # Post to chatter
- sale_order.message_post(
- body=Markup(
- '
')
-
- task.schedule_info_html = Markup(''.join(html_parts))
-
- @api.depends('technician_id', 'scheduled_date', 'time_start',
- 'address_lat', 'address_lng', 'address_street')
- def _compute_prev_task_summary(self):
- """Show previous task info + travel time warning with color coding."""
- for task in self:
- if not task.technician_id or not task.scheduled_date:
- task.prev_task_summary_html = ''
- continue
-
- exclude_id = task.id if task.id else 0
- # Find the task that ends just before this one starts
- prev_tasks = self.sudo().search([
- ('technician_id', '=', task.technician_id.id),
- ('scheduled_date', '=', task.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', exclude_id),
- ('time_end', '<=', task.time_start or 99.0),
- ], order='time_end desc', limit=1)
-
- if not prev_tasks:
- # Check if this is the first task of the day -- show start location info
- task.prev_task_summary_html = Markup(
- '
'
- ' First task of the day -- '
- 'travel calculated from start location.
'
- )
- continue
-
- prev = prev_tasks[0]
- prev_start = self._float_to_time_str(prev.time_start)
- prev_end = self._float_to_time_str(prev.time_end)
- type_label = dict(self._fields['task_type'].selection).get(
- prev.task_type, prev.task_type or '')
- client_name = prev.partner_id.name or ''
- prev_addr = prev.address_display or 'No address'
-
- # Calculate gap between prev task end and this task start
- s_open, _s_close = self._get_store_hours()
- gap_hours = (task.time_start or s_open) - (prev.time_end or s_open)
- gap_minutes = int(gap_hours * 60)
-
- # Try to get travel time if both have coordinates
- travel_minutes = 0
- travel_text = ''
- if (prev.address_lat and prev.address_lng and
- task.address_lat and task.address_lng):
- travel_minutes = self._quick_travel_time(
- prev.address_lat, prev.address_lng,
- task.address_lat, task.address_lng,
- )
- if travel_minutes > 0:
- travel_text = f'{travel_minutes} min drive'
- else:
- travel_text = 'Could not calculate travel time'
- elif prev.address_street and task.address_street:
- travel_text = 'Save to calculate travel time'
- else:
- travel_text = 'Address missing -- cannot calculate travel'
-
- # Determine color coding
- if travel_minutes > 0 and gap_minutes >= travel_minutes:
- bg_class = 'alert-success' # Green -- enough time
- icon = 'fa-check-circle'
- status_text = (
- f'{gap_minutes} min gap -- enough travel time '
- f'(~{travel_minutes} min drive)'
- )
- elif travel_minutes > 0 and gap_minutes > 0:
- bg_class = 'alert-warning' # Yellow -- tight
- icon = 'fa-exclamation-triangle'
- status_text = (
- f'{gap_minutes} min gap -- tight! '
- f'Travel is ~{travel_minutes} min drive'
- )
- elif travel_minutes > 0 and gap_minutes <= 0:
- bg_class = 'alert-danger' # Red -- impossible
- icon = 'fa-times-circle'
- status_text = (
- f'No gap! Previous task ends at {prev_end}. '
- f'Travel is ~{travel_minutes} min drive'
- )
- else:
- bg_class = 'alert-info' # Blue -- no travel data yet
- icon = 'fa-info-circle'
- status_text = travel_text
-
- html = (
- f'
'
- f' '
- f'Previous: {prev.name} '
- f'({type_label}) {prev_start} - {prev_end}'
- f'{" -- " + client_name if client_name else ""}'
- f' '
- f' {prev_addr}'
- f' '
- f' {status_text}'
- f'
'
- )
- task.prev_task_summary_html = Markup(html)
-
- def _quick_travel_time(self, from_lat, from_lng, to_lat, to_lng):
- """Quick inline travel time calculation using Google Distance Matrix API.
- Returns travel time in minutes, or 0 if unavailable."""
- try:
- api_key = self.env['ir.config_parameter'].sudo().get_param(
- 'fusion_claims.google_maps_api_key', '')
- if not api_key:
- return 0
-
- url = 'https://maps.googleapis.com/maps/api/distancematrix/json'
- params = {
- 'origins': f'{from_lat},{from_lng}',
- 'destinations': f'{to_lat},{to_lng}',
- 'mode': 'driving',
- 'avoid': 'tolls',
- 'departure_time': 'now',
- 'key': api_key,
- }
- resp = requests.get(url, params=params, timeout=5)
- data = resp.json()
- if data.get('status') == 'OK':
- elements = data['rows'][0]['elements'][0]
- if elements.get('status') == 'OK':
- # Use duration_in_traffic if available, else duration
- duration = elements.get(
- 'duration_in_traffic', elements.get('duration', {}))
- seconds = duration.get('value', 0)
- return max(1, int(seconds / 60))
- except Exception:
- _logger.warning('Failed to calculate travel time', exc_info=True)
- return 0
-
- @api.depends('status')
- def _compute_color(self):
- color_map = {
- 'scheduled': 0, # grey
- 'en_route': 4, # blue
- 'in_progress': 2, # orange
- 'completed': 10, # green
- 'cancelled': 1, # red
- 'rescheduled': 3, # yellow
- }
- for task in self:
- task.color = color_map.get(task.status, 0)
-
- @api.depends('address_street', 'address_street2', 'address_city',
- 'address_state_id', 'address_zip')
- def _compute_address_display(self):
- for task in self:
- street = task.address_street or ''
- # If the street field already contains a full address (has a comma),
- # use it directly -- Google Places stores the formatted address here.
- if ',' in street and (
- (task.address_city and task.address_city in street) or
- (task.address_zip and task.address_zip in street)
- ):
- # Street already has full address; just append unit if separate
- if task.address_street2 and task.address_street2 not in street:
- task.address_display = f"{street}, {task.address_street2}"
- else:
- task.address_display = street
- else:
- # Build from components (manual entry or legacy data)
- parts = [
- street,
- task.address_street2,
- task.address_city,
- task.address_state_id.name if task.address_state_id else '',
- task.address_zip,
- ]
- task.address_display = ', '.join([p for p in parts if p])
-
- # ------------------------------------------------------------------
- # ONCHANGE - Auto-fill address from client
- # ------------------------------------------------------------------
-
- @api.onchange('partner_id')
- def _onchange_partner_id(self):
- """Auto-fill address fields from the selected client's address."""
- if self.partner_id:
- addr = self.partner_id
- self.address_partner_id = addr.id
- self.address_street = addr.street or ''
- self.address_street2 = addr.street2 or ''
- self.address_city = addr.city or ''
- self.address_state_id = addr.state_id.id if addr.state_id else False
- self.address_zip = addr.zip or ''
- self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0
- self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0
-
- @api.onchange('sale_order_id')
- def _onchange_sale_order_id(self):
- """Auto-fill client and address from the sale order's shipping address."""
- if self.sale_order_id:
- order = self.sale_order_id
- if not self.partner_id:
- self.partner_id = order.partner_id
- # Use shipping address if different
- addr = order.partner_shipping_id or order.partner_id
- self.address_partner_id = addr.id
- self.address_street = addr.street or ''
- self.address_street2 = addr.street2 or ''
- self.address_city = addr.city or ''
- self.address_state_id = addr.state_id.id if addr.state_id else False
- self.address_zip = addr.zip or ''
- self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0
- self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0
-
- # ------------------------------------------------------------------
- # CONSTRAINTS + VALIDATION
- # ------------------------------------------------------------------
-
- @api.constrains('technician_id', 'scheduled_date', 'time_start', 'time_end')
- def _check_no_overlap(self):
- """Prevent overlapping bookings for the same technician on the same date."""
- for task in self:
- if task.status == 'cancelled':
- continue
- if task.x_fc_sync_source:
- continue
- # Validate time range
- if task.time_start >= task.time_end:
- raise ValidationError(_("Start time must be before end time."))
- # Validate store hours
- s_open, s_close = self._get_store_hours()
- if task.time_start < s_open or task.time_end > s_close:
- open_str = self._float_to_time_str(s_open)
- close_str = self._float_to_time_str(s_close)
- raise ValidationError(_(
- "Tasks must be scheduled within store hours (%s - %s)."
- ) % (open_str, close_str))
- # Validate not in the past (only for new/scheduled local tasks)
- if task.status == 'scheduled' and task.scheduled_date and not task.x_fc_sync_source:
- today = fields.Date.context_today(self)
- if task.scheduled_date < today:
- raise ValidationError(_("Cannot schedule tasks in the past."))
- if task.scheduled_date == today:
- now = fields.Datetime.now()
- current_hour = now.hour + now.minute / 60.0
- if task.time_start < current_hour:
- pass # Allow editing existing tasks that started earlier today
- # Check overlap with other tasks
- overlapping = self.sudo().search([
- ('technician_id', '=', task.technician_id.id),
- ('scheduled_date', '=', task.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', task.id),
- ('time_start', '<', task.time_end),
- ('time_end', '>', task.time_start),
- ], limit=1)
- if overlapping:
- start_str = self._float_to_time_str(overlapping.time_start)
- end_str = self._float_to_time_str(overlapping.time_end)
- raise ValidationError(_(
- "Time slot overlaps with %(task)s (%(start)s - %(end)s). "
- "Please choose a different time.",
- task=overlapping.name,
- start=start_str,
- end=end_str,
- ))
-
- # Check travel time gap to the NEXT task on the same day
- next_task = self.sudo().search([
- ('technician_id', '=', task.technician_id.id),
- ('scheduled_date', '=', task.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', task.id),
- ('time_start', '>=', task.time_end),
- ], order='time_start', limit=1)
- if next_task and task.address_lat and task.address_lng and \
- next_task.address_lat and next_task.address_lng:
- travel_min = self._quick_travel_time(
- task.address_lat, task.address_lng,
- next_task.address_lat, next_task.address_lng,
- )
- if travel_min > 0:
- gap_min = int((next_task.time_start - task.time_end) * 60)
- if gap_min < travel_min:
- raise ValidationError(_(
- "Not enough travel time to the next task!\n\n"
- "This task ends at %(end)s, and %(next)s starts "
- "at %(next_start)s (%(gap)d min gap).\n"
- "Travel time is ~%(travel)d minutes.\n\n"
- "Please allow at least %(travel)d minutes between tasks.",
- end=self._float_to_time_str(task.time_end),
- next=next_task.name,
- next_start=self._float_to_time_str(next_task.time_start),
- gap=gap_min,
- travel=travel_min,
- ))
-
- # Check travel time gap FROM the PREVIOUS task on the same day
- prev_task = self.sudo().search([
- ('technician_id', '=', task.technician_id.id),
- ('scheduled_date', '=', task.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', task.id),
- ('time_end', '<=', task.time_start),
- ], order='time_end desc', limit=1)
- if prev_task and task.address_lat and task.address_lng and \
- prev_task.address_lat and prev_task.address_lng:
- travel_min = self._quick_travel_time(
- prev_task.address_lat, prev_task.address_lng,
- task.address_lat, task.address_lng,
- )
- if travel_min > 0:
- gap_min = int((task.time_start - prev_task.time_end) * 60)
- if gap_min < travel_min:
- raise ValidationError(_(
- "Not enough travel time from the previous task!\n\n"
- "%(prev)s ends at %(prev_end)s, and this task starts "
- "at %(start)s (%(gap)d min gap).\n"
- "Travel time is ~%(travel)d minutes.\n\n"
- "Please allow at least %(travel)d minutes between tasks.",
- prev=prev_task.name,
- prev_end=self._float_to_time_str(prev_task.time_end),
- start=self._float_to_time_str(task.time_start),
- gap=gap_min,
- travel=travel_min,
- ))
-
- @api.onchange('technician_id', 'scheduled_date')
- def _onchange_technician_date_autoset(self):
- """Auto-set start/end time to the first available slot when tech+date change."""
- if not self.technician_id or not self.scheduled_date:
- return
- exclude_id = self._origin.id if self._origin else False
- duration = self.duration_hours or 1.0
- s_open, _s_close = self._get_store_hours()
- preferred = self.time_start or s_open
- start, end = self._find_next_available_slot(
- self.technician_id.id,
- self.scheduled_date,
- preferred_start=preferred,
- duration=duration,
- exclude_task_id=exclude_id,
- dest_lat=self.address_lat or 0,
- dest_lng=self.address_lng or 0,
- )
- if start is not False:
- self.time_start = start
- self.time_end = end
- self.duration_hours = end - start
- else:
- return {'warning': {
- 'title': _('Fully Booked'),
- 'message': _(
- '%s is fully booked on %s. No available slots.'
- ) % (self.technician_id.name,
- self.scheduled_date.strftime('%B %d, %Y')),
- }}
-
- def _snap_if_overlap(self):
- """Check if current time_start/time_end overlaps with another task.
- If so, auto-snap to the next available slot and return a warning dict."""
- if not self.technician_id or not self.scheduled_date or not self.time_start:
- return None
- exclude_id = self._origin.id if self._origin else 0
- duration = max(self.duration_hours or 1.0, 0.25)
-
- overlapping = self.sudo().search([
- ('technician_id', '=', self.technician_id.id),
- ('scheduled_date', '=', self.scheduled_date),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', exclude_id),
- ('time_start', '<', self.time_end),
- ('time_end', '>', self.time_start),
- ], limit=1)
- if overlapping:
- conflict_name = overlapping.name
- conflict_start = self._float_to_time_str(overlapping.time_start)
- conflict_end = self._float_to_time_str(overlapping.time_end)
- start, end = self._find_next_available_slot(
- self.technician_id.id,
- self.scheduled_date,
- preferred_start=self.time_start,
- duration=duration,
- exclude_task_id=exclude_id,
- dest_lat=self.address_lat or 0,
- dest_lng=self.address_lng or 0,
- )
- if start is not False:
- new_start_str = self._float_to_time_str(start)
- new_end_str = self._float_to_time_str(end)
- self.time_start = start
- self.time_end = end
- self.duration_hours = end - start
- return {'warning': {
- 'title': _('Moved to Available Slot'),
- 'message': _(
- 'The selected time conflicts with %s (%s - %s).\n'
- 'Automatically moved to: %s - %s.'
- ) % (conflict_name, conflict_start, conflict_end,
- new_start_str, new_end_str),
- }}
- else:
- return {'warning': {
- 'title': _('No Available Slots'),
- 'message': _(
- 'The selected time conflicts with %s (%s - %s) '
- 'and no other slots are available on this day.'
- ) % (conflict_name, conflict_start, conflict_end),
- }}
- return None
-
- # ------------------------------------------------------------------
- # DEFAULT_GET - Calendar pre-fill
- # ------------------------------------------------------------------
-
- def _snap_to_quarter(self, hour_float):
- """Round a float hour to the nearest 15-minute slot and clamp to store hours."""
- s_open, s_close = self._get_store_hours()
- snapped = round(hour_float * 4) / 4
- return max(s_open, min(s_close, snapped))
-
- @api.model
- def default_get(self, fields_list):
- """Handle calendar time range selection: pre-fill date + times from context."""
- res = super().default_get(fields_list)
- ctx = self.env.context
-
- # Set duration default based on task type from context
- task_type = ctx.get('default_task_type', res.get('task_type', 'delivery'))
- if 'duration_hours' not in res or not res.get('duration_hours'):
- res['duration_hours'] = self.TASK_TYPE_DURATIONS.get(task_type, 1.0)
-
- # When user clicks a time range on the calendar, Odoo passes
- # default_datetime_start/end in UTC
- dt_start_utc = None
- dt_end_utc = None
- if ctx.get('default_datetime_start'):
- try:
- dt_start_utc = fields.Datetime.from_string(ctx['default_datetime_start'])
- except (ValueError, TypeError):
- pass
- if ctx.get('default_datetime_end'):
- try:
- dt_end_utc = fields.Datetime.from_string(ctx['default_datetime_end'])
- except (ValueError, TypeError):
- pass
-
- if dt_start_utc or dt_end_utc:
- import pytz
- user_tz = pytz.timezone(self.env.user.tz or 'UTC')
-
- if dt_start_utc:
- dt_start_local = pytz.utc.localize(dt_start_utc).astimezone(user_tz)
- res['scheduled_date'] = dt_start_local.date()
- start_float = self._snap_to_quarter(
- dt_start_local.hour + dt_start_local.minute / 60.0)
- res['time_start'] = start_float
-
- if dt_end_utc:
- dt_end_local = pytz.utc.localize(dt_end_utc).astimezone(user_tz)
- end_float = self._snap_to_quarter(
- dt_end_local.hour + dt_end_local.minute / 60.0)
- if 'time_start' in res and end_float <= res['time_start']:
- end_float = res['time_start'] + 1.0
- res['time_end'] = end_float
- # Compute duration from the calendar drag
- if 'time_start' in res:
- res['duration_hours'] = end_float - res['time_start']
-
- # Always compute end from start + duration if not already set
- if 'time_end' not in res and 'time_start' in res and 'duration_hours' in res:
- _open, close = self._get_store_hours()
- res['time_end'] = min(
- res['time_start'] + res['duration_hours'], close)
-
- return res
-
- # ------------------------------------------------------------------
- # CRUD OVERRIDES
- # ------------------------------------------------------------------
-
- @api.model_create_multi
- def create(self, vals_list):
- for vals in vals_list:
- if vals.get('name', _('New')) == _('New'):
- vals['name'] = self.env['ir.sequence'].next_by_code('fusion.technician.task') or _('New')
- if not vals.get('x_fc_sync_uuid') and not vals.get('x_fc_sync_source'):
- vals['x_fc_sync_uuid'] = str(uuid.uuid4())
- # Auto-populate address from sale order if not provided
- if vals.get('sale_order_id') and not vals.get('address_street'):
- order = self.env['sale.order'].browse(vals['sale_order_id'])
- addr = order.partner_shipping_id or order.partner_id
- if addr:
- self._fill_address_vals(vals, addr)
- if not vals.get('partner_id'):
- vals['partner_id'] = order.partner_id.id
- # Auto-populate address from partner if sale order not set
- elif vals.get('partner_id') and not vals.get('address_street'):
- partner = self.env['res.partner'].browse(vals['partner_id'])
- if partner.street:
- self._fill_address_vals(vals, partner)
- records = super().create(vals_list)
- # Post creation notice to linked sale order chatter
- for rec in records:
- rec._post_task_created_to_sale_order()
- # If created from "Ready for Delivery" flow, mark the sale order
- if self.env.context.get('mark_ready_for_delivery'):
- records._mark_sale_order_ready_for_delivery()
- if self.env.context.get('mark_odsp_ready_for_delivery'):
- for rec in records:
- order = rec.sale_order_id
- if order and order.x_fc_is_odsp_sale and order._get_odsp_status() != 'ready_delivery':
- order._odsp_advance_status('ready_delivery',
- "Order is ready for delivery. Delivery task scheduled.")
- # Auto-calculate travel times for the full day chain
- if not self.env.context.get('skip_travel_recalc'):
- records._recalculate_day_travel_chains()
- # Send "Appointment Scheduled" email
- for rec in records:
- rec._send_task_scheduled_email()
- # Push new local tasks to remote instances
- local_records = records.filtered(lambda r: not r.x_fc_sync_source)
- if local_records and not self.env.context.get('skip_task_sync'):
- self.env['fusion.task.sync.config']._push_tasks(local_records, 'create')
- return records
-
- def write(self, vals):
- if self.env.context.get('skip_travel_recalc'):
- return super().write(vals)
-
- # Safety: ensure time_end is consistent when start/duration change
- # but time_end wasn't sent (readonly field in view may not save)
- if ('time_start' in vals or 'duration_hours' in vals) and 'time_end' not in vals:
- _open, close = self._get_store_hours()
- start = vals.get('time_start', self[:1].time_start if len(self) == 1 else 9.0)
- dur = vals.get('duration_hours', self[:1].duration_hours if len(self) == 1 else 1.0) or 1.0
- vals['time_end'] = min(start + dur, close)
-
- # Detect reschedule mode: capture old values BEFORE write
- reschedule_mode = self.env.context.get('reschedule_mode')
- old_schedule = {}
- schedule_fields = {'scheduled_date', 'time_start', 'time_end',
- 'duration_hours', 'technician_id'}
- schedule_changed = schedule_fields & set(vals.keys())
- if reschedule_mode and schedule_changed:
- for task in self:
- old_schedule[task.id] = {
- 'date': task.scheduled_date,
- 'time_start': task.time_start,
- 'time_end': task.time_end,
- }
-
- # Capture old tech+date combos BEFORE write for travel recalc
- travel_fields = {'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng',
- 'scheduled_date', 'sequence', 'time_start', 'technician_id'}
- needs_travel_recalc = travel_fields & set(vals.keys())
- old_combos = set()
- if needs_travel_recalc:
- old_combos = {(t.technician_id.id, t.scheduled_date) for t in self}
- res = super().write(vals)
- if needs_travel_recalc:
- new_combos = {(t.technician_id.id, t.scheduled_date) for t in self}
- all_combos = old_combos | new_combos
- self._recalculate_combos_travel(all_combos)
-
- # After write: send reschedule email if schedule actually changed
- if reschedule_mode and old_schedule:
- for task in self:
- old = old_schedule.get(task.id, {})
- if old and (
- old['date'] != task.scheduled_date
- or abs(old['time_start'] - task.time_start) > 0.01
- or abs(old['time_end'] - task.time_end) > 0.01
- ):
- task._post_status_message('rescheduled')
- task._send_task_rescheduled_email(
- old_date=old['date'],
- old_start=old['time_start'],
- old_end=old['time_end'],
- )
- # Push updates to remote instances for local tasks
- sync_fields = {'technician_id', 'scheduled_date', 'time_start', 'time_end',
- 'duration_hours', 'status', 'task_type', 'address_street',
- 'address_city', 'address_zip', 'address_lat', 'address_lng',
- 'partner_id'}
- if sync_fields & set(vals.keys()) and not self.env.context.get('skip_task_sync'):
- local_records = self.filtered(lambda r: not r.x_fc_sync_source)
- if local_records:
- self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
- return res
-
- @api.model
- def _fill_address_vals(self, vals, partner):
- """Helper to fill address vals dict from a partner record."""
- vals.update({
- 'address_partner_id': partner.id,
- 'address_street': partner.street or '',
- 'address_street2': partner.street2 or '',
- 'address_city': partner.city or '',
- 'address_state_id': partner.state_id.id if partner.state_id else False,
- 'address_zip': partner.zip or '',
- 'address_lat': partner.x_fc_latitude if hasattr(partner, 'x_fc_latitude') else 0,
- 'address_lng': partner.x_fc_longitude if hasattr(partner, 'x_fc_longitude') else 0,
- })
-
- def _post_task_created_to_sale_order(self):
- """Post a brief task creation notice to the linked sale order's chatter."""
- self.ensure_one()
- if not self.sale_order_id:
- return
- task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
- date_str = self.scheduled_date.strftime('%B %d, %Y') if self.scheduled_date else 'TBD'
- time_str = self._float_to_time_str(self.time_start)
- task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
- body = Markup(
- f'
'
- f' Technician Task Scheduled '
- f'{self.name} ({task_type_label}) - {date_str} at {time_str} '
- f'Technician: {self.technician_id.name} '
- f'View Task'
- f'
'
- )
- self.sale_order_id.message_post(
- body=body, message_type='notification', subtype_xmlid='mail.mt_note',
- )
-
- def _mark_sale_order_ready_for_delivery(self):
- """Mark linked sale orders as Ready for Delivery.
-
- Called when a delivery task is created from the "Ready for Delivery"
- button on the sale order. This replaces the old wizard workflow.
- """
- for task in self:
- order = task.sale_order_id
- if not order:
- continue
- # Only update if not already marked
- if order.x_fc_adp_application_status == 'ready_delivery':
- continue
-
- user_name = self.env.user.name
- tech_name = task.technician_id.name or ''
-
- # Save current status so we can revert if task is cancelled
- previous_status = order.x_fc_adp_application_status
-
- # Update the sale order status and delivery fields
- order.with_context(skip_status_validation=True).write({
- 'x_fc_adp_application_status': 'ready_delivery',
- 'x_fc_status_before_delivery': previous_status,
- 'x_fc_delivery_technician_ids': [(4, task.technician_id.id)],
- 'x_fc_ready_for_delivery_date': fields.Datetime.now(),
- 'x_fc_scheduled_delivery_datetime': task.datetime_start,
- })
-
- # Post chatter message
- early_badge = ''
- if order.x_fc_early_delivery:
- early_badge = ' Early Delivery'
-
- scheduled_str = ''
- if task.scheduled_date:
- time_str = task._float_to_time_str(task.time_start) if task.time_start else ''
- date_str = task.scheduled_date.strftime('%B %d, %Y')
- scheduled_str = f'
Scheduled: {date_str} at {time_str}
'
-
- notes_str = ''
- if task.description:
- notes_str = f'
Delivery Notes: {task.description}
'
-
- chatter_body = Markup(
- f'
'
- f'
Ready for Delivery{early_badge}
'
- f'
'
- f'
Marked By: {user_name}
'
- f'
Technician: {tech_name}
'
- f'{scheduled_str}'
- f'
Delivery Address: {task.address_display or "N/A"}
'
- f'
'
- f'{notes_str}'
- f'
'
- )
- order.message_post(
- body=chatter_body,
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- # Send email notifications
- try:
- order._send_ready_for_delivery_email(
- technicians=task.technician_id,
- scheduled_datetime=task.datetime_start,
- notes=task.description,
- )
- except Exception as e:
- _logger.warning("Ready for delivery email failed for %s: %s", order.name, e)
-
- def _recalculate_day_travel_chains(self):
- """Recalculate travel for all tech+date combos affected by these tasks."""
- combos = {(t.technician_id.id, t.scheduled_date) for t in self if t.technician_id and t.scheduled_date}
- self._recalculate_combos_travel(combos)
-
- def _get_technician_start_address(self, tech_id):
- """Get the start address for a technician.
-
- Priority:
- 1. Technician's personal x_fc_start_address (if set)
- 2. Company default HQ address (fusion_claims.technician_start_address)
- Returns the address string or ''.
- """
- tech_user = self.env['res.users'].sudo().browse(tech_id)
- if tech_user.exists() and tech_user.x_fc_start_address:
- return tech_user.x_fc_start_address.strip()
- # Fallback to company default
- return (self.env['ir.config_parameter'].sudo()
- .get_param('fusion_claims.technician_start_address', '') or '').strip()
-
- def _geocode_address_string(self, address, api_key):
- """Geocode an address string and return (lat, lng) or (0.0, 0.0)."""
- if not address or not api_key:
- return 0.0, 0.0
- try:
- url = 'https://maps.googleapis.com/maps/api/geocode/json'
- params = {'address': address, 'key': api_key, 'region': 'ca'}
- resp = requests.get(url, params=params, timeout=10)
- data = resp.json()
- if data.get('status') == 'OK' and data.get('results'):
- loc = data['results'][0]['geometry']['location']
- return loc['lat'], loc['lng']
- except Exception as e:
- _logger.warning("Address geocoding failed for '%s': %s", address, e)
- return 0.0, 0.0
-
- def _recalculate_combos_travel(self, combos):
- """Recalculate travel for a set of (tech_id, date) combinations."""
- ICP = self.env['ir.config_parameter'].sudo()
- enabled = ICP.get_param('fusion_claims.google_distance_matrix_enabled', False)
- if not enabled:
- return
- api_key = self._get_google_maps_api_key()
-
- # Cache geocoded start addresses per technician to avoid repeated API calls
- start_coords_cache = {}
-
- for tech_id, date in combos:
- if not tech_id or not date:
- continue
- all_day_tasks = self.sudo().search([
- ('technician_id', '=', tech_id),
- ('scheduled_date', '=', date),
- ('status', 'not in', ['cancelled']),
- ], order='time_start, sequence, id')
- if not all_day_tasks:
- continue
-
- # Get this technician's start location (personal or company default)
- if tech_id not in start_coords_cache:
- addr = self._get_technician_start_address(tech_id)
- start_coords_cache[tech_id] = self._geocode_address_string(addr, api_key)
-
- prev_lat, prev_lng = start_coords_cache[tech_id]
- for i, task in enumerate(all_day_tasks):
- if not (task.address_lat and task.address_lng):
- task._geocode_address()
- travel_vals = {}
- if prev_lat and prev_lng and task.address_lat and task.address_lng:
- task.with_context(skip_travel_recalc=True)._calculate_travel_time(prev_lat, prev_lng)
- travel_vals['previous_task_id'] = all_day_tasks[i - 1].id if i > 0 else False
- travel_vals['travel_origin'] = 'Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}'
- if travel_vals:
- task.with_context(skip_travel_recalc=True).write(travel_vals)
- prev_lat = task.address_lat or prev_lat
- prev_lng = task.address_lng or prev_lng
-
- # ------------------------------------------------------------------
- # STATUS ACTIONS
- # ------------------------------------------------------------------
-
- def _check_previous_tasks_completed(self):
- """Check that all earlier tasks for the same technician+date are completed."""
- self.ensure_one()
- earlier_incomplete = self.sudo().search([
- ('technician_id', '=', self.technician_id.id),
- ('scheduled_date', '=', self.scheduled_date),
- ('time_start', '<', self.time_start),
- ('status', 'not in', ['completed', 'cancelled']),
- ('id', '!=', self.id),
- ], limit=1)
- if earlier_incomplete:
- raise UserError(_(
- "Please complete previous task %s first before starting this one."
- ) % earlier_incomplete.name)
-
- def action_start_en_route(self):
- """Mark task as En Route."""
- for task in self:
- if task.status != 'scheduled':
- raise UserError(_("Only scheduled tasks can be marked as En Route."))
- task._check_previous_tasks_completed()
- task.status = 'en_route'
- task._post_status_message('en_route')
-
- def action_start_task(self):
- """Mark task as In Progress."""
- for task in self:
- if task.status not in ('scheduled', 'en_route'):
- raise UserError(_("Task must be scheduled or en route to start."))
- task._check_previous_tasks_completed()
- task.status = 'in_progress'
- task._post_status_message('in_progress')
-
- def action_view_sale_order(self):
- """Open the linked sale order / case."""
- self.ensure_one()
- if not self.sale_order_id:
- return
- return {
- 'name': self.sale_order_id.name,
- 'type': 'ir.actions.act_window',
- 'res_model': 'sale.order',
- 'view_mode': 'form',
- 'res_id': self.sale_order_id.id,
- }
-
- def action_complete_task(self):
- """Mark task as Completed."""
- for task in self:
- if task.status not in ('in_progress', 'en_route', 'scheduled'):
- raise UserError(_("Task must be in progress to complete."))
- task.with_context(skip_travel_recalc=True).write({
- 'status': 'completed',
- 'completion_datetime': fields.Datetime.now(),
- })
- task._post_status_message('completed')
- # Post completion notes to sale order chatter if linked
- if task.sale_order_id and task.completion_notes:
- task._post_completion_to_sale_order()
- # Notify the person who scheduled the task
- task._notify_scheduler_on_completion()
- # Auto-advance ODSP status for delivery tasks
- if (task.task_type == 'delivery'
- and task.sale_order_id
- and task.sale_order_id.x_fc_is_odsp_sale
- and task.sale_order_id._get_odsp_status() == 'ready_delivery'):
- task.sale_order_id._odsp_advance_status(
- 'delivered',
- "Delivery task completed by technician. Order marked as delivered.",
- )
-
- def action_cancel_task(self):
- """Cancel the task. Sends cancellation email and reverts sale order if delivery."""
- for task in self:
- if task.status == 'completed':
- raise UserError(_("Cannot cancel a completed task."))
- task.status = 'cancelled'
- task._post_status_message('cancelled')
- # If this was a delivery task linked to a sale order that is
- # currently in "Ready for Delivery" -- revert the order back.
- # _revert_sale_order_on_cancel also sends the cancellation email
- # for delivery tasks.
- if task.task_type == 'delivery':
- task._revert_sale_order_on_cancel()
- else:
- # Non-delivery tasks: still send a cancellation email
- task._send_task_cancelled_email()
-
- def _revert_sale_order_on_cancel(self):
- """When a delivery task is cancelled, check if the linked sale order
- should revert to its previous status. Only reverts if:
- - Task is a delivery type
- - Sale order is currently 'ready_delivery'
- - No other active (non-cancelled) delivery tasks exist for this order
- """
- self.ensure_one()
- if self.task_type != 'delivery' or not self.sale_order_id:
- return
- order = self.sale_order_id
- if order.x_fc_adp_application_status != 'ready_delivery':
- return
-
- # Check if any other non-cancelled delivery tasks exist for this order
- other_delivery_tasks = self.sudo().search([
- ('sale_order_id', '=', order.id),
- ('task_type', '=', 'delivery'),
- ('status', 'not in', ['cancelled']),
- ('id', '!=', self.id),
- ], limit=1)
- if other_delivery_tasks:
- return # Other active delivery tasks still exist, don't revert
-
- # Revert to the status saved before Ready for Delivery
- prev_status = order.x_fc_status_before_delivery or 'approved'
- status_labels = dict(order._fields['x_fc_adp_application_status'].selection)
- prev_label = status_labels.get(prev_status, prev_status)
-
- # skip_status_emails prevents the "Approved" email from re-firing
- order.with_context(
- skip_status_validation=True,
- skip_status_emails=True,
- ).write({
- 'x_fc_adp_application_status': prev_status,
- 'x_fc_status_before_delivery': False,
- })
-
- # Post chatter message about the revert
- body = Markup(
- f'
'
- f'
Delivery Task Cancelled
'
- f'
Delivery task {self.name} was cancelled by '
- f'{self.env.user.name}.
'
- f'
Order status reverted to {prev_label}.
'
- f'
'
- )
- order.message_post(
- body=body,
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- # Send a "Delivery Cancelled" email instead
- self._send_task_cancelled_email()
-
- def action_reschedule(self):
- """Open the reschedule form for this task.
- Saves old schedule info, then opens the same task form for editing.
- On save, the write() method detects the reschedule and sends emails."""
- self.ensure_one()
- return {
- 'type': 'ir.actions.act_window',
- 'res_model': 'fusion.technician.task',
- 'res_id': self.id,
- 'view_mode': 'form',
- 'target': 'new',
- 'context': {
- 'reschedule_mode': True,
- 'old_date': str(self.scheduled_date) if self.scheduled_date else '',
- 'old_time_start': self.time_start,
- 'old_time_end': self.time_end,
- },
- }
-
- def action_reset_to_scheduled(self):
- """Reset task back to scheduled."""
- for task in self:
- task.status = 'scheduled'
-
- # ------------------------------------------------------------------
- # CHATTER / NOTIFICATIONS
- # ------------------------------------------------------------------
-
- def _post_status_message(self, new_status):
- """Post a status change message to the task chatter."""
- self.ensure_one()
- status_labels = dict(self._fields['status'].selection)
- label = status_labels.get(new_status, new_status)
- icons = {
- 'en_route': 'fa-road',
- 'in_progress': 'fa-wrench',
- 'completed': 'fa-check-circle',
- 'cancelled': 'fa-times-circle',
- 'rescheduled': 'fa-calendar',
- }
- icon = icons.get(new_status, 'fa-info-circle')
- body = Markup(
- f'
Task status changed to '
- f'{label} by {self.env.user.name}
'
- )
- self.message_post(body=body, message_type='notification', subtype_xmlid='mail.mt_note')
-
- def _post_completion_to_sale_order(self):
- """Post the completion notes to the linked sale order's chatter."""
- self.ensure_one()
- if not self.sale_order_id or not self.completion_notes:
- return
- task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
- body = Markup(
- f'
'
- f'
Technician Task Completed
'
- f'
'
- f'
Task: {self.name} ({task_type_label})
'
- f'
Technician: {self.technician_id.name}
'
- f'
Completed: {self.completion_datetime.strftime("%B %d, %Y at %I:%M %p") if self.completion_datetime else "N/A"}
'
- f'
'
- f''
- f'{self.completion_notes}'
- f'
'
- )
- self.sale_order_id.message_post(
- body=body,
- message_type='notification',
- subtype_xmlid='mail.mt_note',
- )
-
- def _notify_scheduler_on_completion(self):
- """Send an Odoo notification to whoever created/scheduled the task."""
- self.ensure_one()
- # Notify the task creator (scheduler) if they're not the technician
- if self.create_uid and self.create_uid != self.technician_id:
- task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
- task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
- client_name = self.partner_id.name or 'N/A'
- case_ref = self.sale_order_id.name if self.sale_order_id else ''
- # Build address string
- addr_parts = [p for p in [
- self.address_street,
- self.address_street2,
- self.address_city,
- self.address_state_id.name if self.address_state_id else '',
- self.address_zip,
- ] if p]
- address_str = ', '.join(addr_parts) or 'No address'
- # Build subject
- subject = f'Task Completed: {client_name}'
- if case_ref:
- subject += f' ({case_ref})'
- body = Markup(
- f'
This Accessibility Contract ("Contract") is between ("Company") and the Customer for the purchase, delivery, and installation of accessibility equipment including Stairlifts, Platform Lifts, Ceiling Lifts, Ramps, and Door Openers.
-
-
-
-
-
-
1. Scope of Contract
-
This Contract governs the sale, delivery, installation, and ownership of the equipment specified. By signing, the Customer agrees to all terms herein.
-
-
-
-
2. Delivery
-
The Company will make reasonable efforts to deliver as agreed. Delivery dates are estimates, not guarantees. The Company is not liable for delays. The Customer cannot cancel due to delays unless agreed in writing.
-
-
-
-
3. Deposits
-
A deposit of 70% of total value (or as specified) is required at order. Upon receipt, equipment preparation begins. If cancelled, the Company may recover all costs incurred including materials, labor, and administrative expenses.
-
-
-
-
4. Power Supply
-
The Customer must ensure a suitable power source is available at the installation site. Electrical outlet installation is not included unless stated in the order.
-
-
-
-
5. Concrete Work (Porch Lifts)
-
For Porch Lifts requiring a concrete base, the Company provides specifications. Unless agreed otherwise, the Customer arranges and pays for concrete work. The Company is not liable for issues from third-party concrete work.
-
-
-
-
6. Ownership and Title
-
Title remains with the Company until full payment. The Company may reclaim equipment upon non-payment or breach of this Contract.
-
-
-
-
7. Warranty
-
Equipment is covered by limited warranty as specified. Warranty excludes: accidental damages, normal wear and tear, damages from misuse or lack of maintenance. Claims must be in writing.
-
-
-
-
8. Returns and Refunds
-
Once submitted for production, orders are final and non-refundable. No returns permitted. Post-submission modifications incur additional charges at Company's discretion.
-
-
-
-
9. Liability
-
The Company is not liable for injuries or damages from: misuse, unauthorized modifications, failure to follow instructions, lack of maintenance, or use beyond intended purpose. Customer indemnifies Company from related claims.
-
-
-
-
10. Indemnification
-
Customer indemnifies Company from claims arising from installation site conditions including structural issues, electrical faults, or inadequate site preparation.
-
-
-
-
11. Buying Back Goods
-
The Company does not buy back goods unless explicitly agreed in writing.
-
-
-
-
12. Force Majeure
-
Company is not liable for delays from events beyond control: natural disasters, strikes, supply chain disruptions, or governmental actions.
-
-
-
-
13. Entire Agreement
-
This Contract constitutes the entire understanding and supersedes all prior agreements, written or oral.
-
-
-
-
14. Governing Law
-
This Contract is governed by laws of the jurisdiction where the Company operates.
-
-
-
-
15. Acceptance
-
By signing, Customer acknowledges reading, understanding, and agreeing to all terms and conditions.
-
-
-
-
-
-
-
CUSTOMER DECLARATION
-
- I HAVE READ, UNDERSTOOD AND AGREE TO ALL CONDITIONS SET FORTH IN THIS ACCESSIBILITY CONTRACT AND AGREE TO PAY THE AMOUNT OWED TO AS SPECIFIED.
-
GRAB BAR INSTALLATION WAIVER AND LIABILITY RELEASE AGREEMENT
-
-
-
-
This Waiver and Release Agreement ("Agreement") is entered into by and between the undersigned customer ("Customer") and ("Installer") as of the date of signature below. By signing this Agreement, the Customer acknowledges and agrees to the following terms and conditions:
-
-
-
-
-
-
Customer Name
-
-
-
-
Address
-
-
-
-
-
-
Order Reference
-
-
-
-
-
-
-
1. Scope of Work
-
The Installer agrees to install grab bars as per the Customer's request. The Customer understands and acknowledges that the installation process may involve drilling into walls, tiles, or other surfaces, and the Installer will take reasonable care to perform the work professionally.
-
-
-
-
-
2. Liability Waiver
-
To the fullest extent permitted by law, the Customer agrees to release, waive, and hold harmless the Installer, its employees, agents, and subcontractors from any and all liability, claims, demands, damages, or causes of action arising from or related to the installation of grab bars, including but not limited to:
-
-
-
a. Water Leaks
- The Installer is not responsible for any water leaks that may occur in the wall or plumbing as a result of the installation process.
-
-
-
-
b. Rust or Corrosion
- The Installer is not liable for any rust, corrosion, or degradation of the grab bars over time due to environmental factors or improper maintenance.
-
-
-
-
c. Falls or Misuse
- The Installer is not responsible for any injuries, falls, or accidents resulting from the misuse or improper use of the grab bars after installation.
-
-
-
-
d. Change of Mind
- The Customer acknowledges that once the grab bars are installed, no refunds or returns will be provided under any circumstance.
-
-
-
-
e. Tile Damage
- The Installer is not responsible for any cracks, chips, or other damage to tiles or grout during the installation process.
-
-
-
-
f. Plumbing Damage
- The Installer is not liable for any damage to plumbing or hidden pipes within the wall during the installation process.
-
-
-
-
g. Structural Damage
- The Installer is not responsible for any structural damage to the house, including but not limited to damage to walls, floors, or support structures.
-
-
-
-
h. Floor Damage
- The Installer is not liable for any damage to flooring that may occur during the installation process.
-
-
-
-
-
-
3. Order Cancellation Policy
-
The Customer may cancel the order at any time before installation begins without incurring additional charges, except for any applicable delivery fees.
-
If the Installer has already arrived at the installation location and the Customer decides to cancel the order, the Customer agrees to pay a delivery & installation fee.
-
-
-
-
-
4. No Warranties
-
The Installer provides no warranties, express or implied, regarding the quality, durability, or fitness of the grab bars or the installation process. The grab bars are installed "as is" and at the Customer's own risk.
-
-
-
-
-
5. Indemnification
-
The Customer agrees to indemnify and hold harmless the Installer, its employees, agents, and subcontractors from any claims, damages, or expenses (including attorney's fees) arising from or related to the installation of grab bars.
-
-
-
-
-
6. Acknowledgement
-
By signing this Agreement, the Customer acknowledges that they have read, understood, and agreed to all terms and conditions outlined above. The Customer further acknowledges that they have had the opportunity to ask questions regarding the installation process and the terms of this Agreement.
-
-
-
-
-
7. Governing Law
-
This Agreement shall be governed by and construed in accordance with the laws of the Province of Ontario.
-
-
-
-
-
By signing this form, I acknowledge that I have read the waiver agreement and understand the terms and conditions set forth herein.
- By signing below, you are acknowledging that you had the opportunity to inspect the product and found it to be free from any reasonably discoverable defects. You are also agreeing that you had the opportunity to review the entire transaction and the refund policy on the back side of the page, and it has satisfactorily addressed any questions or concerns you had, and nothing has been promised to you that has not been put into writing. This Delivery Confirmation Form is hereby incorporated into this transaction.
-
-
- I HAVE RECEIVED ALL OF THE PRODUCTS AND SERVICES PROMISED TO ME IN CONNECTION WITH THIS TRANSACTION AT THE TIME OF DELIVERY.
-
-
-
-
-
-
-
-
-
-
PRINT NAME
-
-
-
-
-
-
DATE
-
-
-
-
-
-
-
-
-
SIGNATURE
-
-
-
-
-
- Collected by:
-
-
-
-
-
-
-
-
-
PRINT NAME
-
-
-
-
DATE
-
-
-
-
-
SIGNATURE
-
-
-
-
-
-
-
-
-
-
- RETURN AND REFUND POLICY
-
-
-
-
- does not accept any returns or refunds for custom-built orders or any modified products.
-
-
- The following policy applies to all customers:
-
-
-
Change Order and Cancellation Policy
-
- sells custom Mobility products according to customer specifications. Quote, and order acknowledgments must be reviewed for accuracy.
-
-
- Customized, specially ordered, or fabricated mobility and accessibility devices are not returnable or eligible for a refund. We also do not accept returns or exchanges for items that have come in contact with the skin. This includes, but is not limited to, lift chairs, seating, and positioning products, including wheelchair cushions and back supports, TENS units, patient lifts, blood pressure units, breast pumps, bathroom safety items, pillows, bedding, and mattresses, bracing, and continence care products.
-
-
-
Restocking Fees
-
- Product or parts returns for warranty items (i.e. product defects) are subject to 's evaluation but not subject to a restocking fee.
-
-
- 30% Restocking Fee: Some products require a 30% restocking fee for returns. These include: Patient lifts, Hospital Beds, Transport Wheelchairs, Standard Rollators & Walkers
-
-
-
Non-Returnable Items
-
- There are certain products that are custom-made by the manufacturer and are non-returnable. These include:
-
-
-
Built to order items / Custom wheelchairs, Rigid ultra lightweight wheelchairs
-
Type/Category 2, 3, 4 & 5 Wheelchair
-
Tilt & Reclining wheelchairs
-
Sports wheelchairs
-
Custom Power Wheelchairs
-
All Spare Parts
-
Adaptive Strollers
-
Geri chairs
-
Custom-built lift chairs/Recliners, including those with upgraded fabrics, heat and massage, Power Pillow, footrest extension or left-hand control options are non-returnable.
- By signing below, you are acknowledging that you had the opportunity to inspect the product and found it to be free from any reasonably discoverable defects. You are also agreeing that you had the opportunity to review the entire transaction and the refund policy on the back side of the page, and it has satisfactorily addressed any questions or concerns you had, and nothing has been promised to you that has not been put into writing. This Delivery Confirmation Form is hereby incorporated into this transaction.
-
-
- I HAVE RECEIVED ALL OF THE PRODUCTS AND SERVICES PROMISED TO ME IN CONNECTION WITH THIS TRANSACTION AT THE TIME OF DELIVERY.
-
-
-
-
-
-
-
-
-
-
PRINT NAME
-
-
-
-
-
-
DATE
-
-
-
-
-
-
-
-
-
SIGNATURE
-
-
-
-
-
- Collected by:
-
-
-
-
-
-
-
-
-
PRINT NAME
-
-
-
-
DATE
-
-
-
-
-
SIGNATURE
-
-
-
-
-
-
-
-
-
-
- RETURN AND REFUND POLICY
-
-
-
-
- does not accept any returns or refunds for custom-built orders or any modified products.
-
-
- The following policy applies to all customers:
-
-
-
Change Order and Cancellation Policy
-
- sells custom Mobility products according to customer specifications. Quote, and order acknowledgments must be reviewed for accuracy.
-
-
- Customized, specially ordered, or fabricated mobility and accessibility devices are not returnable or eligible for a refund. We also do not accept returns or exchanges for items that have come in contact with the skin. This includes, but is not limited to, lift chairs, seating, and positioning products, including wheelchair cushions and back supports, TENS units, patient lifts, blood pressure units, breast pumps, bathroom safety items, pillows, bedding, and mattresses, bracing, and continence care products.
-
-
-
Restocking Fees
-
- Product or parts returns for warranty items (i.e. product defects) are subject to 's evaluation but not subject to a restocking fee.
-
-
- 30% Restocking Fee: Some products require a 30% restocking fee for returns. These include: Patient lifts, Hospital Beds, Transport Wheelchairs, Standard Rollators & Walkers
-
-
-
Non-Returnable Items
-
- There are certain products that are custom-made by the manufacturer and are non-returnable. These include:
-
-
-
Built to order items / Custom wheelchairs, Rigid ultra lightweight wheelchairs
-
Type/Category 2, 3, 4 & 5 Wheelchair
-
Tilt & Reclining wheelchairs
-
Sports wheelchairs
-
Custom Power Wheelchairs
-
All Spare Parts
-
Adaptive Strollers
-
Geri chairs
-
Custom-built lift chairs/Recliners, including those with upgraded fabrics, heat and massage, Power Pillow, footrest extension or left-hand control options are non-returnable.
- By signing below, you are confirming that you have returned the above listed equipment to . You acknowledge that all items listed have been picked up by our authorized representative, and you no longer have possession of these products. You are also confirming that the equipment was returned in the same condition as when it was delivered, normal wear and tear excepted, and that you are aware of any applicable charges related to the rental period or equipment condition.
-
-
- I CONFIRM THAT ALL OF THE PRODUCTS LISTED ABOVE HAVE BEEN RETURNED AND PICKED UP BY AN AUTHORIZED REPRESENTATIVE OF .
-
-
-
-
-
-
-
-
PRINT NAME
-
-
-
-
DATE
-
-
-
-
-
SIGNATURE
-
-
-
-
-
-
-
-
-
- RENTAL RETURN TERMS AND CONDITIONS
-
-
-
-
- - Rental Equipment Return Policy
-
-
- The following terms apply to all rental equipment returns:
-
-
-
Equipment Condition
-
- All rental equipment must be returned in the same condition as when delivered, allowing for normal wear and tear. The customer is responsible for any damage beyond normal use. Equipment will be inspected upon return, and any damage or missing parts will be assessed and charged accordingly.
-
-
-
Rental Period
-
- Rental charges are calculated based on the agreed rental period. Early returns may be subject to minimum rental fees as specified in the rental agreement. Late returns may incur additional daily rental charges.
-
-
-
Pickup and Scheduling
-
- Equipment pickup must be scheduled in advance. The customer is responsible for ensuring the equipment is accessible and ready for pickup at the scheduled time. Missed pickup appointments may result in continued rental charges.
-
-
-
Cleaning Requirements
-
- Equipment should be returned clean and sanitized. Additional cleaning fees may apply if equipment is returned in an unsanitary condition.
-
-
-
Damage Assessment
-
- Upon pickup, our representative will conduct an inspection of the equipment. Any damage will be documented and the customer will be notified of repair or replacement costs. The following are examples of damage that may result in charges:
-
-
-
Broken or bent frames
-
Torn or stained upholstery
-
Missing parts or accessories
-
Non-functional electrical components
-
Excessive wear on wheels or tires
-
Water damage or rust
-
Modifications made without authorization
-
-
-
Security Deposit
-
- If a security deposit was collected at the time of rental, it will be refunded within 14 business days after the equipment has been inspected and cleared, less any applicable charges for damage, cleaning, or extended rental periods.
-
-
-
Contact Information
-
- For questions regarding your rental return or any charges, please contact customer service.
-
rents to the Renter medical equipment (hospital beds, patient lifts, trapeze, over-bed tables, mobility scooters, electric wheelchairs, manual wheelchairs, stairlifts, ceiling lifts and lift chairs) subject to the terms and conditions set forth in this Rental Agreement.
-
-
-
-
-
-
-
1. Ownership and Condition of Equipment
-
The medical equipment is the property of and is provided in good condition. The Renter shall return the equipment in the same condition as when received, subject to normal wear and tear. reserves the right to inspect the equipment upon its return and may repossess it without prior notice if it is being used in violation of this agreement.
-
-
-
-
2. Cancellation Policy
-
The Renter may cancel the order before delivery and will be charged twenty-five percent (25%) of the total rental cost. If the order is canceled during the rental period after delivery, no refund will be provided.
-
-
-
-
3. Security Deposit
-
The security deposit will be returned after an inspection of the equipment. If the equipment has any damage, the cost of repairs will be deducted from the security deposit. If the security deposit is insufficient to cover the damages, the credit card on file will be charged for the remaining amount. Security deposit refunds may take 4 to 15 business days to process. is not responsible for delays caused by the Renter's financial institution.
-
-
-
-
4. Liability for Loss or Damage
-
shall not be liable for any loss of or damage to property left, lost, damaged, stolen, stored, or transported by the Renter or any other person using the medical equipment. The Renter assumes all risks associated with such loss or damage and waives any claims against . The Renter agrees to defend, indemnify, and hold harmless against all claims arising from such loss or damage.
-
-
-
-
5. Risk and Liability
-
The Renter assumes all risk and liability for any loss, damage, injury, or death resulting from the use or operation of the medical equipment. is not responsible for any acts or omissions of the Renter or the Renter's agents, servants, or employees.
-
-
-
-
6. Renter Responsibilities
-
The Renter is responsible for the full cost of replacement for any damage, loss, theft, or destruction of the medical equipment. may charge the Renter's credit card for repair or replacement costs as deemed necessary. The equipment must not be used by individuals under the age of 18, under the influence of intoxicants or narcotics, or in an unsafe manner.
-
-
-
-
7. Indemnification
-
The Renter shall indemnify, defend, and hold harmless , its agents, officers, and employees, from any claims, demands, actions, or causes of action arising from the use or operation of the medical equipment, except where caused by 's gross negligence or willful misconduct.
-
-
-
-
8. Accident Notification
-
The Renter must immediately notify of any accidents, damages, or incidents involving the medical equipment.
-
-
-
-
9. Costs and Expenses
-
The Renter agrees to cover all costs, expenses, and attorney's fees incurred by in collecting overdue payments, recovering possession of the equipment, or enforcing claims for damage or loss.
-
-
-
-
10. Independent Status
-
The Renter or any driver of the equipment shall not be considered an agent or employee of .
-
-
-
-
11. Binding Obligations
-
Any individual signing this agreement on behalf of a corporation or other entity shall be personally liable for all obligations under this agreement. This agreement is binding upon the heirs, executors, administrators, and assigns of the Renter.
-
-
-
-
12. Refusal of Service
-
reserves the right to refuse rental to any individual or entity at its sole discretion.
-
-
-
-
13. Governing Law
-
This Agreement shall be governed by and construed in accordance with the laws of the jurisdiction in which operates.
-
-
-
-
14. Entire Agreement
-
This Agreement constitutes the entire understanding between the parties concerning the rental of medical equipment and supersedes all prior agreements, representations, or understandings, whether written or oral.
-
-
-
-
-
-
-
-
-
-
-
RENTAL DETAILS
-
-
-
-
-
-
-
-
RENTER INFORMATION
-
-
-
Name
-
-
-
-
Address
-
-
-
-
-
-
Phone
-
-
-
-
Order Ref
-
-
-
-
-
-
-
-
RENTAL PERIOD
-
-
-
Start Date
-
-
-
-
- Not specified
-
-
-
-
Return Date
-
-
-
-
- Not specified
-
-
-
-
Duration
-
-
- Days
-
- , Hrs
-
-
- Less than 1 day
- Not specified
-
I authorize to charge the credit card indicated in this authorization form according to the terms outlined above. I certify that I am an authorized user of this credit card and will not dispute the payment. By signing this form, I acknowledge that I have read the rental agreement and understand the terms and conditions. I understand that if the rented item is not returned on the agreed return date, additional charges will be incurred. *Payments for monthly rental items will be charged on the re-rental date until the item is returned.
-
-
-
-
-
-
-
-
-
-
FULL NAME (PRINT)
-
-
-
-
SIGNATURE
-
-
-
-
DATE
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/fusion_claims/fusion_claims/report/report_saleorder_adp.xml b/fusion_claims/fusion_claims/report/report_saleorder_adp.xml
deleted file mode 100644
index 0519ecb..0000000
--- a/fusion_claims/fusion_claims/report/report_saleorder_adp.xml
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/fusion_claims/fusion_claims/report/report_templates.xml b/fusion_claims/fusion_claims/report/report_templates.xml
deleted file mode 100644
index c35e830..0000000
--- a/fusion_claims/fusion_claims/report/report_templates.xml
+++ /dev/null
@@ -1,203 +0,0 @@
-
-
-
-
-
-