diff --git a/fusion_claims/__init__.py b/fusion_claims/__init__.py index 5795da1..33ce5b6 100644 --- a/fusion_claims/__init__.py +++ b/fusion_claims/__init__.py @@ -4,6 +4,7 @@ # Part of the Fusion Claim Assistant product family. from . import models +from . import controllers from . import wizard diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 90f14d1..d533070 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.7.0.0', + 'version': '19.0.7.2.0', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ @@ -84,6 +84,7 @@ 'calendar', 'ai', 'fusion_ringcentral', + 'fusion_tasks', ], 'external_dependencies': { 'python': ['pdf2image', 'PIL'], @@ -102,6 +103,7 @@ 'views/res_company_views.xml', 'views/res_config_settings_views.xml', 'views/sale_order_views.xml', + 'views/sale_portal_templates.xml', 'views/account_move_views.xml', 'views/account_journal_views.xml', 'wizard/adp_export_wizard_views.xml', @@ -128,6 +130,7 @@ 'wizard/odsp_pre_approved_wizard_views.xml', 'wizard/odsp_ready_delivery_wizard_views.xml', 'wizard/ltc_repair_create_so_wizard_views.xml', + 'wizard/send_page11_wizard_views.xml', 'views/res_partner_views.xml', 'views/pdf_template_inherit_views.xml', 'views/dashboard_views.xml', @@ -140,9 +143,8 @@ 'views/adp_claims_views.xml', 'views/submission_history_views.xml', 'views/fusion_loaner_views.xml', + 'views/page11_sign_request_views.xml', 'views/technician_task_views.xml', - 'views/task_sync_views.xml', - 'views/technician_location_views.xml', 'report/report_actions.xml', 'report/report_templates.xml', 'report/sale_report_portrait.xml', @@ -153,7 +155,8 @@ 'report/report_proof_of_delivery.xml', 'report/report_proof_of_delivery_standard.xml', 'report/report_proof_of_pickup.xml', - 'report/report_rental_agreement.xml', + + 'report/report_approved_items.xml', 'report/report_grab_bar_waiver.xml', 'report/report_accessibility_contract.xml', 'report/report_mod_quotation.xml', @@ -167,7 +170,6 @@ 'assets': { 'web.assets_backend': [ 'fusion_claims/static/src/scss/fusion_claims.scss', - 'fusion_claims/static/src/css/fusion_task_map_view.scss', 'fusion_claims/static/src/js/chatter_resize.js', 'fusion_claims/static/src/js/document_preview.js', 'fusion_claims/static/src/js/preview_button_widget.js', @@ -176,10 +178,9 @@ 'fusion_claims/static/src/js/tax_totals_patch.js', 'fusion_claims/static/src/js/google_address_autocomplete.js', 'fusion_claims/static/src/js/calendar_store_hours.js', - 'fusion_claims/static/src/js/fusion_task_map_view.js', 'fusion_claims/static/src/js/attachment_image_compress.js', + 'fusion_claims/static/src/js/debug_required_fields.js', 'fusion_claims/static/src/xml/document_preview.xml', - 'fusion_claims/static/src/xml/fusion_task_map_view.xml', ], }, 'images': ['static/description/icon.png'], diff --git a/fusion_claims/controllers/__init__.py b/fusion_claims/controllers/__init__.py new file mode 100644 index 0000000..811abc3 --- /dev/null +++ b/fusion_claims/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import portal diff --git a/fusion_claims/controllers/portal.py b/fusion_claims/controllers/portal.py new file mode 100644 index 0000000..df79030 --- /dev/null +++ b/fusion_claims/controllers/portal.py @@ -0,0 +1,54 @@ +# -*- 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.addons.sale.controllers.portal import CustomerPortal + + +class FusionCustomerPortal(CustomerPortal): + + def _determine_is_down_payment(self, order_sudo, amount_selection, payment_amount): + """Override to use client portion for ADP down-payment comparison.""" + if ( + hasattr(order_sudo, '_is_adp_sale') + and order_sudo._is_adp_sale() + and order_sudo.x_fc_client_type == 'REG' + ): + payable = order_sudo.x_fc_client_portion_total or 0 + if amount_selection == 'down_payment': + return True + elif amount_selection == 'full_amount': + return False + else: + return ( + order_sudo.prepayment_percent < 1.0 if payment_amount is None + else payment_amount < payable + ) + return super()._determine_is_down_payment(order_sudo, amount_selection, payment_amount) + + def _get_payment_values(self, order_sudo, is_down_payment=False, payment_amount=None, **kwargs): + """Override to cap payment amount at client portion for ADP orders.""" + values = super()._get_payment_values( + order_sudo, + is_down_payment=is_down_payment, + payment_amount=payment_amount, + **kwargs, + ) + + if not ( + hasattr(order_sudo, '_is_adp_sale') + and order_sudo._is_adp_sale() + and order_sudo.x_fc_client_type == 'REG' + ): + return values + + client_portion = order_sudo.x_fc_client_portion_total or 0 + if client_portion <= 0: + return values + + current_amount = values.get('amount', 0) + if current_amount > client_portion: + values['amount'] = order_sudo.currency_id.round(client_portion) + + return values diff --git a/fusion_claims/models/sale_order.py b/fusion_claims/models/sale_order.py index 045f85e..29877a4 100644 --- a/fusion_claims/models/sale_order.py +++ b/fusion_claims/models/sale_order.py @@ -22,7 +22,7 @@ class SaleOrder(models.Model): for order in self: name = order.name or '' if order.partner_id and order.partner_id.name: - name = f"{name} -- {order.partner_id.name}" + name = f"{name} - {order.partner_id.name}" order.display_name = name # ========================================================================== @@ -1318,17 +1318,12 @@ class SaleOrder(models.Model): 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_id = self._get_and_prepare_field_attachment(signed_field, 'Signed SA Form') + if att_id: + att_ids.append(att_id) att_names.append('Signed SA Form') # 2. Internal POD -- generate on-the-fly from the standard report @@ -1353,12 +1348,14 @@ class SaleOrder(models.Model): try: report = self.env.ref('account.account_invoices') pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id]) + first, last = self._get_client_name_parts() att = Attachment.create({ - 'name': f'Invoice_{invoice.name}.pdf', + 'name': f'{first}_{last}_Invoice_{invoice.name}.pdf', 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'sale.order', 'res_id': self.id, + 'mimetype': 'application/pdf', }) att_ids.append(att.id) att_names.append(f'Invoice ({invoice.name})') @@ -1388,16 +1385,10 @@ class SaleOrder(models.Model): 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') + att_id = self._get_and_prepare_field_attachment('x_fc_odsp_approval_document', 'ODSP Approval Document') + if att_id: + att_ids.append(att_id) + att_names.append('ODSP Approval Document') # 2. Internal POD try: @@ -1426,12 +1417,14 @@ class SaleOrder(models.Model): try: report = self.env.ref('account.account_invoices') pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id]) + first, last = self._get_client_name_parts() att = Attachment.create({ - 'name': f'Invoice_{invoice.name}.pdf', + 'name': f'{first}_{last}_Invoice_{invoice.name}.pdf', 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'sale.order', 'res_id': self.id, + 'mimetype': 'application/pdf', }) att_ids.append(att.id) att_names.append(f'Invoice ({invoice.name})') @@ -1680,44 +1673,6 @@ class SaleOrder(models.Model): ) _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 # ========================================================================== @@ -1729,11 +1684,11 @@ class SaleOrder(models.Model): ) x_fc_client_ref_1 = fields.Char( string='Client Reference 1', - help='Primary client reference (e.g., Health Card Number)', + help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO', ) x_fc_client_ref_2 = fields.Char( string='Client Reference 2', - help='Secondary client reference', + help='Last four digits of the client\'s health card number. Example: 1234', ) x_fc_adp_delivery_date = fields.Date( string='ADP Delivery Date', @@ -1907,6 +1862,10 @@ class SaleOrder(models.Model): string='Previous Status Before Hold', help='Status before the application was put on hold (for resuming)', ) + x_fc_previous_status_before_withdrawal = fields.Char( + string='Status Before Withdrawal', + help='Records the status before withdrawal for audit trail.', + ) x_fc_status_before_delivery = fields.Char( string='Status Before Delivery', @@ -2372,6 +2331,20 @@ class SaleOrder(models.Model): help='Date when Page 11 was signed', ) + page11_sign_request_ids = fields.One2many( + 'fusion.page11.sign.request', 'sale_order_id', + string='Page 11 Signing Requests', + ) + page11_sign_request_count = fields.Integer( + compute='_compute_page11_sign_request_count', + string='Signing Requests', + ) + page11_sign_status = fields.Selection([ + ('none', 'Not Requested'), + ('sent', 'Pending Signature'), + ('signed', 'Signed'), + ], compute='_compute_page11_sign_request_count', string='Page 11 Remote Status') + # ========================================================================== # PAGE 12 SIGNATURE TRACKING (Authorizer + Vendor Signature) # Page 12 must be signed by: Authorizer (OT) and Vendor (our company) @@ -2988,10 +2961,136 @@ class SaleOrder(models.Model): # ========================================================================== # PDF DOCUMENT PREVIEW ACTIONS (opens in new tab using browser/system PDF handler) # ========================================================================== + MIME_TO_EXT = { + 'application/pdf': '.pdf', + 'application/xml': '.xml', + 'text/xml': '.xml', + 'text/plain': '.txt', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', + 'application/msword': '.doc', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', + 'application/vnd.ms-excel': '.xls', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'application/zip': '.zip', + 'application/octet-stream': '', + } + + FIELD_NAME_TEMPLATE = { + 'x_fc_final_submitted_application': '{first}_{last}.pdf', + 'x_fc_xml_file': '{first}_{last}_data.xml', + 'x_fc_original_application': '{first}_{last}_Original_Application.pdf', + 'x_fc_signed_pages_11_12': '{first}_{last}_Signed_Pages.pdf', + 'x_fc_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf', + 'x_fc_approval_letter': '{first}_{last}_Approval_Letter.pdf', + 'x_fc_sa_signed_form': '{first}_{last}_SA_Form_Signed.pdf', + 'x_fc_sa_physical_signed_copy': '{first}_{last}_SA_Form_Signed.pdf', + 'x_fc_sa_approval_form': '{first}_{last}_SA_Approval.pdf', + 'x_fc_odsp_approval_document': '{first}_{last}_ODSP_Approval.pdf', + 'x_fc_odsp_authorizer_letter': '{first}_{last}_ODSP_Authorizer_Letter.pdf', + 'x_fc_ow_discretionary_form': '{first}_{last}_OW_Discretionary_Form.pdf', + 'x_fc_ow_authorizer_letter': '{first}_{last}_OW_Authorizer_Letter.pdf', + 'x_fc_mod_drawing': '{first}_{last}_Drawing.pdf', + 'x_fc_mod_initial_photos': '{first}_{last}_Initial_Photos.pdf', + 'x_fc_mod_pca_document': '{first}_{last}_PCA_Document.pdf', + 'x_fc_mod_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf', + 'x_fc_mod_completion_photos': '{first}_{last}_Completion_Photos.pdf', + } + + FIELD_FILENAME_MAP = { + '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', + 'x_fc_approval_letter': 'x_fc_approval_letter_filename', + 'x_fc_sa_signed_form': 'x_fc_sa_signed_form_filename', + 'x_fc_sa_physical_signed_copy': 'x_fc_sa_physical_signed_copy_filename', + 'x_fc_sa_approval_form': 'x_fc_sa_approval_form_filename', + 'x_fc_odsp_approval_document': 'x_fc_odsp_approval_document_filename', + 'x_fc_odsp_authorizer_letter': 'x_fc_odsp_authorizer_letter_filename', + 'x_fc_ow_discretionary_form': 'x_fc_ow_discretionary_form_filename', + 'x_fc_ow_authorizer_letter': 'x_fc_ow_authorizer_letter_filename', + 'x_fc_mod_drawing': 'x_fc_mod_drawing_filename', + 'x_fc_mod_initial_photos': 'x_fc_mod_initial_photos_filename', + 'x_fc_mod_pca_document': 'x_fc_mod_pca_filename', + 'x_fc_mod_proof_of_delivery': 'x_fc_mod_pod_filename', + 'x_fc_mod_completion_photos': 'x_fc_mod_completion_photos_filename', + } + + def _get_ext_from_mime(self, mimetype): + """Return a file extension (with dot) for a MIME type.""" + return self.MIME_TO_EXT.get(mimetype or '', '') + + def _get_client_name_parts(self): + """Return (first_name, last_name) cleaned for filenames.""" + full_name = (self.partner_id.name or 'Client').strip() + parts = full_name.split() + first = parts[0] if parts else 'Client' + last = parts[-1] if len(parts) > 1 else '' + clean = lambda s: s.replace(',', '').replace("'", '').replace('"', '') + return clean(first), clean(last) + + def _build_attachment_name(self, field_name, mimetype=None): + """Build the proper filename for a field attachment. + + Uses FIELD_NAME_TEMPLATE for known fields with Firstname_Lastname convention. + For the XML file, respects the actual mimetype (could be .xml, .docx, .txt). + """ + first, last = self._get_client_name_parts() + template = self.FIELD_NAME_TEMPLATE.get(field_name) + + if template: + name = template.format(first=first, last=last) + if field_name == 'x_fc_xml_file' and mimetype: + ext = self._get_ext_from_mime(mimetype) + if ext and ext != '.xml': + name = f'{first}_{last}_data{ext}' + return name + + ext = self._get_ext_from_mime(mimetype) if mimetype else '.pdf' + return f'{first}_{last}_Document{ext}' + + def _prepare_attachment_for_email(self, attachment, field_name=None, label=None): + """Rename an attachment to a clean, professional filename. + + Always renames to the standard convention (Firstname_Lastname pattern) + so recipients get consistently named files regardless of what was uploaded. + """ + if not attachment: + return None + + new_name = self._build_attachment_name(field_name, attachment.mimetype) + + if attachment.name == new_name: + return attachment.id + + try: + attachment.sudo().write({'name': new_name}) + except Exception: + _logger.warning("Could not rename attachment %s to %s", attachment.id, new_name) + + return attachment.id + + def _get_and_prepare_field_attachment(self, field_name, label=None): + """Find the ir.attachment for a binary field, rename it properly, return its id. + + Convenience wrapper combining _get_document_attachment + _prepare_attachment_for_email. + Returns None if the field has no data or attachment is not found. + """ + self.ensure_one() + if not getattr(self, field_name, None): + return None + attachment = self._get_document_attachment(field_name) + if not attachment: + return None + return self._prepare_attachment_for_email(attachment, field_name=field_name, label=label) + 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), @@ -3001,9 +3100,9 @@ class SaleOrder(models.Model): 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. + We find the one with res_field set, ensure it has a proper name, and return it. """ self.ensure_one() @@ -3011,43 +3110,20 @@ class SaleOrder(models.Model): 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}) + self._prepare_attachment_for_email(attachment, field_name=field_name, label=document_label) 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' - + filename = self._build_attachment_name(field_name) + attachment = self.env['ir.attachment'].sudo().create({ - 'name': filename or f'{document_label}.pdf', + 'name': filename, 'datas': data, 'res_model': 'sale.order', 'res_id': self.id, @@ -3062,11 +3138,49 @@ class SaleOrder(models.Model): self.ensure_one() return self._action_open_document('x_fc_original_application', 'Original ADP Application') + @api.depends('page11_sign_request_ids', 'page11_sign_request_ids.state') + def _compute_page11_sign_request_count(self): + for order in self: + requests = order.page11_sign_request_ids + order.page11_sign_request_count = len(requests) + signed = requests.filtered(lambda r: r.state == 'signed') + pending = requests.filtered(lambda r: r.state == 'sent') + if signed: + order.page11_sign_status = 'signed' + elif pending: + order.page11_sign_status = 'sent' + else: + order.page11_sign_status = 'none' + 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_request_page11_signature(self): + """Open the wizard to send Page 11 for remote signing.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Request Page 11 Signature', + 'res_model': 'fusion_claims.send.page11.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': {'default_sale_order_id': self.id}, + } + + def action_view_page11_requests(self): + """Open the list of Page 11 signing requests.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Page 11 Signing Requests', + 'res_model': 'fusion.page11.sign.request', + 'view_mode': 'list,form', + 'domain': [('sale_order_id', '=', self.id)], + 'context': {'default_sale_order_id': self.id}, + } + def action_open_final_application(self): """Open the Final Submitted Application PDF.""" self.ensure_one() @@ -3399,16 +3513,20 @@ class SaleOrder(models.Model): } def action_complete_assessment(self): - """Open wizard to mark assessment as completed with date.""" + """Open wizard to mark assessment as completed with date. + Allowed from 'quotation' (override) or 'assessment_scheduled' (normal flow).""" self.ensure_one() - if self.x_fc_adp_application_status != 'assessment_scheduled': - raise UserError("Can only complete assessment from 'Assessment Scheduled' status.") - + if self.x_fc_adp_application_status not in ('quotation', 'assessment_scheduled'): + raise UserError( + _("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.") + ) + return { 'name': 'Assessment Completed', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.assessment.completed.wizard', 'view_mode': 'form', + 'views': [(False, 'form')], 'target': 'new', 'context': { 'active_id': self.id, @@ -3624,6 +3742,41 @@ class SaleOrder(models.Model): return True + def action_resubmit_from_withdrawn(self): + """Return a withdrawn application to Ready for Submission for correction and resubmission.""" + self.ensure_one() + + if self.x_fc_adp_application_status != 'withdrawn': + raise UserError("This action is only available for withdrawn applications.") + + self.with_context(skip_status_validation=True).write({ + 'x_fc_adp_application_status': 'ready_submission', + }) + + user_name = self.env.user.name + resubmit_date = fields.Date.today().strftime('%B %d, %Y') + + message_body = f''' + + ''' + + 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. @@ -4458,6 +4611,12 @@ class SaleOrder(models.Model): if 'x_fc_device_placement' in self.env['account.move.line']._fields: line_vals['x_fc_device_placement'] = line.x_fc_device_placement + # Copy deduction fields so export verification can recalculate correctly + if 'x_fc_deduction_type' in self.env['account.move.line']._fields: + line_vals['x_fc_deduction_type'] = line.x_fc_deduction_type or 'none' + if 'x_fc_deduction_value' in self.env['account.move.line']._fields: + line_vals['x_fc_deduction_value'] = line.x_fc_deduction_value or 0 + # 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 @@ -4563,6 +4722,48 @@ class SaleOrder(models.Model): return invoice + + # ========================================================================== + # PORTAL PAYMENT AMOUNT (ADP Client Portion) + # ========================================================================== + def _get_prepayment_required_amount(self): + """Override to return client portion for ADP orders. + + For ADP REG clients, the customer should only prepay their 25% + portion, not the full order amount that includes ADP's 75%. + """ + self.ensure_one() + if self._is_adp_sale() and self.x_fc_client_type == 'REG': + client_portion = self.x_fc_client_portion_total or 0 + if client_portion > 0: + return self.currency_id.round(client_portion * self.prepayment_percent) + return super()._get_prepayment_required_amount() + + def _has_to_be_paid(self): + """Override to use client portion for ADP payment threshold check. + + Standard Odoo checks amount_total > 0. For ADP orders where + the client type is not REG (100% ADP funded), the customer + has nothing to pay and the quotation should auto-confirm. + """ + self.ensure_one() + if self._is_adp_sale(): + client_type = self.x_fc_client_type or '' + if client_type and client_type != 'REG': + return False + if client_type == 'REG': + client_portion = self.x_fc_client_portion_total or 0 + if client_portion <= 0: + return False + return ( + self.state in ['draft', 'sent'] + and not self.is_expired + and self.require_payment + and client_portion > 0 + and not self._is_confirmation_amount_reached() + ) + return super()._has_to_be_paid() + # ========================================================================== # OVERRIDE _get_invoiceable_lines TO INCLUDE ALL SECTIONS AND NOTES # ========================================================================== @@ -4627,82 +4828,67 @@ class SaleOrder(models.Model): # ========================================================================== # DOCUMENT CHATTER POSTING # ========================================================================== - def _post_document_to_chatter(self, field_name, document_label=None): - """Post a document attachment to the chatter with a link. + def _post_document_to_chatter(self, field_name, document_label=None, preserve_copy=False): + """Post a document to the chatter, reusing the existing field attachment. + + By default, references the existing ir.attachment (created by Odoo for + attachment=True fields) instead of creating a duplicate. 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) + preserve_copy: If True, creates a separate copy (used when the original + is about to be deleted/replaced and we need to keep a snapshot). """ 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' - + data = getattr(self, field_name, None) 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}" + field_obj = self._fields.get(field_name) + document_label = field_obj.string if field_obj else field_name + + if preserve_copy: + proper_name = self._build_attachment_name(field_name) + base, _, ext = proper_name.rpartition('.') + if base: + copy_name = f"{base}_archived.{ext}" + else: + copy_name = f"{proper_name}_archived" + + attachment = self.env['ir.attachment'].sudo().create({ + 'name': copy_name, + 'datas': data, + 'res_model': 'sale.order', + 'res_id': self.id, + }) 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) + attachment = self._get_document_attachment(field_name) + if not attachment: + return + self._prepare_attachment_for_email(attachment, field_name=field_name) + user_name = self.env.user.name now = fields.Datetime.now() - - body = Markup(""" -

{label} uploaded by {user}

-

{timestamp}

- """).format( + + body = Markup( + '

{label} uploaded by {user}

' + '

{timestamp}

' + ).format( label=document_label, user=user_name, - timestamp=now.strftime('%Y-%m-%d %H:%M:%S') + 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 # ========================================================================== @@ -4844,28 +5030,15 @@ class SaleOrder(models.Model): 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) + att_id = self._get_and_prepare_field_attachment('x_fc_final_submitted_application', 'ADP Application') + if att_id: + 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) + att_id = self._get_and_prepare_field_attachment('x_fc_xml_file', 'ADP XML Data') + if att_id: + attachments.append(att_id) attachment_names.append('XML Data File') client_name = recipients.get('client', self.partner_id).name or 'Client' @@ -5113,8 +5286,144 @@ class SaleOrder(models.Model): _logger.error(f"Failed to send billed email for {self.name}: {e}") return False + def _build_approved_items_html(self, for_pdf=False): + """Build an HTML table of approved order line items. + + Columns: S/N, ADP Code, Device Type, Product Name, Qty, + ADP Portion, Client Portion, Deduction. + """ + self.ensure_one() + lines = self.order_line.filtered( + lambda l: l.product_id and l.display_type not in ('line_section', 'line_note') + ) + if not lines: + return '' + + font = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;" + if for_pdf: + font = "font-family:Arial,Helvetica,sans-serif;" + + hdr_style = ( + f'style="background:#2d3748;color:#fff;padding:8px 10px;' + f'font-size:11px;font-weight:600;text-align:left;' + f'border-bottom:2px solid #4a5568;{font}"' + ) + cell_style = ( + 'style="padding:7px 10px;font-size:12px;' + 'border-bottom:1px solid rgba(128,128,128,0.15);"' + ) + alt_row = 'style="background:rgba(128,128,128,0.06);"' + amt_style = ( + 'style="padding:7px 10px;font-size:12px;' + 'border-bottom:1px solid rgba(128,128,128,0.15);text-align:right;"' + ) + hdr_r = hdr_style.replace('text-align:left', 'text-align:right') + + has_deduction = any( + l.x_fc_deduction_type and l.x_fc_deduction_type != 'none' + for l in lines + ) + + html = ( + '
' + f'

Approved Items

' + '' + '' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + ) + if has_deduction: + html += f'' + html += '' + + total_adp = 0.0 + total_client = 0.0 + + for idx, line in enumerate(lines, 1): + row_attr = alt_row if idx % 2 == 0 else '' + adp_code = line._get_adp_code_for_report() + device_type = line._get_adp_device_type() + product_name = line.product_id.name or '-' + if len(product_name) > 40 and not for_pdf: + product_name = product_name[:37] + '...' + qty = int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty + adp_portion = line.x_fc_adp_portion or 0.0 + client_portion = line.x_fc_client_portion or 0.0 + total_adp += adp_portion + total_client += client_portion + + deduction_str = '-' + if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value: + deduction_str = f'{line.x_fc_deduction_value:.0f}%' + elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value: + deduction_str = f'${line.x_fc_deduction_value:,.2f}' + + html += f'' + html += f'' + html += f'' + html += f'' + html += f'' + html += f'' + html += f'' + html += f'' + if has_deduction: + html += f'' + html += '' + + # Totals row + colspan = 5 + total_style = ( + 'style="padding:8px 10px;font-size:12px;font-weight:700;' + 'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"' + ) + total_label_style = ( + 'style="padding:8px 10px;font-size:12px;font-weight:700;' + 'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"' + ) + html += '' + html += f'' + html += f'' + html += f'' + if has_deduction: + html += f'' + html += '' + + html += '
S/NADP CodeDevice TypeProductQtyADP PortionClient PortionDeduction
{idx}{adp_code}{device_type}{product_name}{qty}${adp_portion:,.2f}${client_portion:,.2f}{deduction_str}
Total${total_adp:,.2f}${total_client:,.2f}
' + return html + + def _generate_approved_items_pdf(self): + """Generate the Approved Items PDF using the QWeb report and return an ir.attachment id.""" + self.ensure_one() + import base64 + + first, last = self._get_client_name_parts() + + try: + report = self.env.ref('fusion_claims.action_report_approved_items') + pdf_content, _ = report._render_qweb_pdf(report.id, [self.id]) + except Exception as e: + _logger.error("Failed to generate approved items PDF for %s: %s", self.name, e) + return None + + filename = f'{first}_{last}_Approved_Items.pdf' + att = self.env['ir.attachment'].sudo().create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content) if isinstance(pdf_content, bytes) else pdf_content, + 'res_model': 'sale.order', + 'res_id': self.id, + 'mimetype': 'application/pdf', + }) + return att.id + def _send_approval_email(self): - """Send notification when ADP application is approved.""" + """Send notification when ADP application is approved, with approved items report.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False @@ -5138,27 +5447,46 @@ class SaleOrder(models.Model): 'contact you with the details and next steps for delivery.' ) + items_html = self._build_approved_items_html() + 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))], + extra_html=items_html, note=note_text, note_color='#38a169', + attachments_note='Approved Items Report (PDF)' if items_html else None, button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) + + attachment_ids = [] + try: + att_id = self._generate_approved_items_pdf() + if att_id: + attachment_ids.append(att_id) + except Exception as e: + _logger.warning("Could not generate approved items PDF for %s: %s", self.name, e) + email_to = ', '.join(to_emails) email_cc = ', '.join(cc_emails) if cc_emails else '' try: - self.env['mail.mail'].sudo().create({ + mail_vals = { '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) + } + if attachment_ids: + mail_vals['attachment_ids'] = [(6, 0, attachment_ids)] + self.env['mail.mail'].sudo().create(mail_vals).send() + self._email_chatter_log( + f'Application {status_label} email sent', email_to, email_cc, + ['Attached: Approved Items Report'] if attachment_ids else None, + ) return True except Exception as e: _logger.error(f"Failed to send approval email for {self.name}: {e}") @@ -5340,8 +5668,13 @@ class SaleOrder(models.Model): _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.""" + def _send_withdrawal_email(self, reason=None, intent=None): + """Send notification when application is withdrawn. + + Args: + reason: Free-text reason for withdrawal. + intent: 'cancel' or 'resubmit' — determines email wording. + """ self.ensure_one() if not self._is_email_notifications_enabled(): return False @@ -5353,17 +5686,34 @@ class SaleOrder(models.Model): 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 intent == 'cancel': + note_text = ('This application has been permanently withdrawn and cancelled. ' + 'The sale order and all related invoices have been cancelled.') + title = 'Application Withdrawn & Cancelled' + subject_suffix = 'Withdrawn & Cancelled' + note_color = '#dc3545' + elif intent == 'resubmit': + note_text = ('This application has been withdrawn for correction and will be resubmitted. ' + 'The application has been returned to Ready for Submission status.') + title = 'Application Withdrawn for Correction' + subject_suffix = 'Withdrawn for Correction' + note_color = '#d69e2e' + else: + note_text = 'This application has been withdrawn from the Assistive Devices Program.' + title = 'Application Withdrawn' + subject_suffix = 'Withdrawn' + note_color = '#d69e2e' + if reason: note_text += f'
Reason: {reason}' body_html = self._email_build( - title='Application Withdrawn', + title=title, 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', + note_color=note_color, button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) @@ -5371,12 +5721,12 @@ class SaleOrder(models.Model): 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}', + 'subject': f'Application {subject_suffix} - {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) + self._email_chatter_log(f'{title} email sent', email_to, email_cc) return True except Exception as e: _logger.error(f"Failed to send withdrawal email for {self.name}: {e}") @@ -5492,6 +5842,18 @@ class SaleOrder(models.Model): # ========================================================================== # OVERRIDE WRITE # ========================================================================== + def web_save(self, vals, specification): + """TEMP DEBUG: Intercept web_save to diagnose 'Missing required fields' on old orders.""" + _logger.warning( + "DEBUG web_save() on %s: vals keys = %s", + [r.name for r in self], list(vals.keys()) + ) + try: + return super().web_save(vals, specification) + except Exception as e: + _logger.error("DEBUG web_save() FAILED on %s: %s", [r.name for r in self], e) + raise + def write(self, vals): """Override write to handle ADP status changes, date auto-population, and document tracking.""" from datetime import date as date_class @@ -5661,7 +6023,10 @@ class SaleOrder(models.Model): '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)} + if self.env.context.get('skip_document_chatter'): + doc_changes = {} + else: + 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 @@ -5684,25 +6049,23 @@ class SaleOrder(models.Model): for order in self: for field_name in document_fields: - if field_name in vals and field_name not in correction_handled: + if field_name in vals and field_name not in correction_handled and not self.env.context.get('skip_document_chatter'): 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)" + field_name, + f"{label} (replaced)", + preserve_copy=True, ) 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)" + field_name, + f"{label} (DELETED)", + preserve_copy=True, ) # Post deletion notice @@ -5737,11 +6100,17 @@ class SaleOrder(models.Model): 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)') + order._post_document_to_chatter( + 'x_fc_final_submitted_application', + 'Final Application (before correction)', + preserve_copy=True, + ) if order.x_fc_xml_file: - order._post_document_to_chatter('x_fc_xml_file', - 'XML File (before correction)') + order._post_document_to_chatter( + 'x_fc_xml_file', + 'XML File (before correction)', + preserve_copy=True, + ) # Clear the document fields AND submission date # Use _correction_cleared to prevent the audit trail from posting duplicates @@ -6131,8 +6500,9 @@ class SaleOrder(models.Model): for order in self: order._send_correction_needed_email() elif new_app_status == 'case_closed': - for order in self: - order._send_case_closed_email() + if not self.env.context.get('skip_status_emails'): + for order in self: + order._send_case_closed_email() # ================================================================== # MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS @@ -6378,96 +6748,6 @@ class SaleOrder(models.Model): 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) # ========================================================================== @@ -6679,7 +6959,7 @@ class SaleOrder(models.Model): for order in orders_to_close: try: # Use context to skip status validation for automated process - order.with_context(skip_status_validation=True).write({ + order.with_context(skip_status_validation=True, skip_status_emails=True).write({ 'x_fc_adp_application_status': 'case_closed', }) @@ -6725,7 +7005,7 @@ class SaleOrder(models.Model): if order.x_fc_odsp_division != 'ontario_works' and status != 'payment_received': continue try: - order._odsp_advance_status( + order.with_context(skip_status_emails=True)._odsp_advance_status( 'case_closed', "Case automatically closed 7 days after %s." % status.replace('_', ' '), ) diff --git a/fusion_claims/views/sale_portal_templates.xml b/fusion_claims/views/sale_portal_templates.xml new file mode 100644 index 0000000..8111b90 --- /dev/null +++ b/fusion_claims/views/sale_portal_templates.xml @@ -0,0 +1,64 @@ + + + + + + + + +