# -*- 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, _ from odoo.exceptions import UserError import logging _logger = logging.getLogger(__name__) class DeviceApprovalWizard(models.TransientModel): """Wizard to confirm which device types were approved by ADP and set deductions. This is Stage 2 of the two-stage verification system: - Stage 1 (Submission): What device types were submitted (stored in x_fc_submitted_device_types) - Stage 2 (Approval): What device types were approved by ADP (this wizard) """ _name = 'fusion_claims.device.approval.wizard' _description = 'ADP Device Approval Wizard' # ========================================================================== # MAIN FIELDS # ========================================================================== sale_order_id = fields.Many2one( 'sale.order', string='Sale Order', required=True, readonly=True, ) line_ids = fields.One2many( 'fusion_claims.device.approval.wizard.line', 'wizard_id', string='Device Lines', ) all_approved = fields.Boolean( string='All Approved', default=True, help='Check this to approve all devices at once', ) has_unapproved = fields.Boolean( string='Has Unapproved Devices', compute='_compute_has_unapproved', ) has_deductions = fields.Boolean( string='Has Deductions', compute='_compute_has_deductions', help='True if any device has a deduction applied', ) has_invoices = fields.Boolean( string='Has Invoices', compute='_compute_has_invoices', help='True if invoices already exist for this order', ) # Stage 1 comparison fields submitted_device_types = fields.Text( string='Submitted Device Types', compute='_compute_submitted_device_types', help='Device types that were verified during submission (Stage 1)', ) has_submission_data = fields.Boolean( string='Has Submission Data', compute='_compute_submitted_device_types', ) # Claim Number - required for Mark as Approved claim_number = fields.Char( string='Claim Number', help='ADP Claim Number from the approval letter', ) approval_date = fields.Date( string='Approval Date', default=fields.Date.context_today, help='Date ADP approved the application. Defaults to today but can be changed ' 'if ADP approved before the letter was received.', ) # Approval Documents - for Mark as Approved mode is_mark_approved_mode = fields.Boolean( string='Mark Approved Mode', compute='_compute_is_mark_approved_mode', ) approval_letter = fields.Binary( string='ADP Approval Letter', help='Upload the ADP approval letter PDF', ) approval_letter_filename = fields.Char( string='Approval Letter Filename', ) # For multiple approval photos, we'll use a Many2many to ir.attachment approval_photo_ids = fields.Many2many( 'ir.attachment', 'device_approval_wizard_attachment_rel', 'wizard_id', 'attachment_id', string='Approval Screenshots', help='Upload screenshots from the ADP approval document', ) @api.depends_context('mark_as_approved') def _compute_is_mark_approved_mode(self): for wizard in self: wizard.is_mark_approved_mode = self.env.context.get('mark_as_approved', False) # ========================================================================== # COMPUTED FIELDS # ========================================================================== @api.depends('line_ids.approved') def _compute_has_unapproved(self): for wizard in self: wizard.has_unapproved = any(not line.approved for line in wizard.line_ids) @api.depends('line_ids.deduction_type') def _compute_has_deductions(self): for wizard in self: wizard.has_deductions = any( line.deduction_type and line.deduction_type != 'none' for line in wizard.line_ids ) @api.depends('sale_order_id', 'sale_order_id.invoice_ids') def _compute_has_invoices(self): for wizard in self: if wizard.sale_order_id: wizard.has_invoices = bool(wizard.sale_order_id.invoice_ids.filtered( lambda inv: inv.state != 'cancel' )) else: wizard.has_invoices = False @api.depends('sale_order_id', 'sale_order_id.x_fc_submitted_device_types') def _compute_submitted_device_types(self): """Compute the submitted device types from Stage 1 for comparison display.""" import json for wizard in self: if wizard.sale_order_id and wizard.sale_order_id.x_fc_submitted_device_types: try: data = json.loads(wizard.sale_order_id.x_fc_submitted_device_types) # Format as readable list submitted = [dt for dt, selected in data.items() if selected] wizard.submitted_device_types = '\n'.join([f'• {dt}' for dt in sorted(submitted)]) wizard.has_submission_data = bool(submitted) except (json.JSONDecodeError, TypeError): wizard.submitted_device_types = '' wizard.has_submission_data = False else: wizard.submitted_device_types = '' wizard.has_submission_data = False @api.onchange('all_approved') def _onchange_all_approved(self): """Toggle all lines when 'All Approved' is changed. Only triggers when toggling ON - sets all devices to approved. When toggling OFF, users manually uncheck individual items. """ if self.all_approved: # Iterate and set approved flag - avoid replacing the entire line for line in self.line_ids: if not line.approved: line.approved = True # ========================================================================== # DEFAULT GET - Populate with order lines # ========================================================================== @api.model def default_get(self, fields_list): res = super().default_get(fields_list) active_id = self._context.get('active_id') if not active_id: return res order = self.env['sale.order'].browse(active_id) res['sale_order_id'] = order.id # Build line data from order lines that have ADP device codes ONLY # Non-ADP items are excluded from the verification list ADPDevice = self.env['fusion.adp.device.code'].sudo() lines_data = [] for so_line in order.order_line: # Skip non-product lines if so_line.display_type in ('line_section', 'line_note'): continue if not so_line.product_id or so_line.product_uom_qty <= 0: continue # Get device code and look up device type device_code = so_line._get_adp_device_code() # SKIP items without a valid ADP device code in the database # These are non-ADP items and don't need verification if not device_code: continue adp_device = ADPDevice.search([('device_code', '=', device_code), ('active', '=', True)], limit=1) # Skip if device code not found in ADP database (non-ADP item) if not adp_device: continue device_type = adp_device.device_type or '' device_description = adp_device.device_description or '' # Default to NOT approved - user must actively check each item # Unless it was already approved in a previous verification is_approved = so_line.x_fc_adp_approved if so_line.x_fc_adp_approved else False lines_data.append((0, 0, { 'sale_line_id': so_line.id, 'product_name': so_line.product_id.display_name, 'device_code': device_code, 'device_type': device_type or so_line.x_fc_adp_device_type or '', 'device_description': device_description, 'serial_number': so_line.x_fc_serial_number or '', 'quantity': so_line.product_uom_qty, 'unit_price': so_line.price_unit, 'adp_portion': so_line.x_fc_adp_portion, 'client_portion': so_line.x_fc_client_portion, 'approved': is_approved, 'deduction_type': so_line.x_fc_deduction_type or 'none', 'deduction_value': so_line.x_fc_deduction_value or 0, })) res['line_ids'] = lines_data # All Approved checkbox - only true if ALL lines are already approved if lines_data: all_approved = all(line[2].get('approved', False) for line in lines_data) res['all_approved'] = all_approved else: res['all_approved'] = True # No ADP items = nothing to verify return res # ========================================================================== # ACTION METHODS # ========================================================================== def action_confirm_approval(self): """Confirm the approval status and deductions, update order lines and invoices.""" self.ensure_one() approved_count = 0 unapproved_count = 0 deduction_count = 0 updated_lines = self.env['sale.order.line'] for wiz_line in self.line_ids: if wiz_line.sale_line_id: # NOTE: Do NOT write x_fc_adp_device_type here - it's a computed field # that should be computed from the product's device code vals = { 'x_fc_adp_approved': wiz_line.approved, 'x_fc_deduction_type': wiz_line.deduction_type or 'none', 'x_fc_deduction_value': wiz_line.deduction_value or 0, } wiz_line.sale_line_id.write(vals) updated_lines |= wiz_line.sale_line_id if wiz_line.approved: approved_count += 1 else: unapproved_count += 1 if wiz_line.deduction_type and wiz_line.deduction_type != 'none': deduction_count += 1 # MARK VERIFICATION AS COMPLETE on the sale order # This allows invoice creation even if some items are unapproved if self.sale_order_id: self.sale_order_id.write({'x_fc_device_verification_complete': True}) # FORCE RECALCULATION of ADP/Client portions on all order lines # This ensures unapproved items get 100% assigned to client portion for line in self.sale_order_id.order_line: line._compute_adp_portions() # Recalculate order totals self.sale_order_id._compute_adp_totals() # If we're in "mark_as_approved" mode, also update the status and save approval documents if self.env.context.get('mark_as_approved') and self.sale_order_id: # Determine status based on whether there are deductions new_status = 'approved_deduction' if deduction_count > 0 else 'approved' update_vals = { 'x_fc_adp_application_status': new_status, 'x_fc_claim_approval_date': self.approval_date or fields.Date.context_today(self), } # Save claim number if provided if self.claim_number: update_vals['x_fc_claim_number'] = self.claim_number # Save approval letter if uploaded if self.approval_letter: update_vals['x_fc_approval_letter'] = self.approval_letter update_vals['x_fc_approval_letter_filename'] = self.approval_letter_filename self.sale_order_id.with_context(skip_status_validation=True).write(update_vals) # Collect attachment IDs for chatter post chatter_attachment_ids = [] # IMPORTANT: When files are uploaded via many2many_binary to a transient model, # they are linked to the wizard and may be garbage collected when the wizard closes. # We need to COPY the attachment data to create persistent attachments linked to the sale order. photos_attached = 0 if self.approval_photo_ids: photo_ids_to_link = [] IrAttachment = self.env['ir.attachment'].sudo() for attachment in self.approval_photo_ids: # Create a NEW attachment linked to the sale order (copy the data) # This ensures the attachment persists after the wizard is deleted new_attachment = IrAttachment.create({ 'name': attachment.name or f'approval_screenshot_{photos_attached + 1}', 'datas': attachment.datas, 'mimetype': attachment.mimetype, 'res_model': 'sale.order', 'res_id': self.sale_order_id.id, 'type': 'binary', }) chatter_attachment_ids.append(new_attachment.id) photo_ids_to_link.append(new_attachment.id) photos_attached += 1 # Link photos to the Many2many field for easy access in ADP Documents tab if photo_ids_to_link: existing_photo_ids = self.sale_order_id.x_fc_approval_photo_ids.ids self.sale_order_id.write({ 'x_fc_approval_photo_ids': [(6, 0, existing_photo_ids + photo_ids_to_link)] }) # Create attachment for approval letter if uploaded if self.approval_letter: letter_attachment = self.env['ir.attachment'].create({ 'name': self.approval_letter_filename or 'ADP_Approval_Letter.pdf', 'datas': self.approval_letter, 'res_model': 'sale.order', 'res_id': self.sale_order_id.id, }) chatter_attachment_ids.append(letter_attachment.id) # Post approval to chatter with all documents in ONE message from markupsafe import Markup approval_date_str = (self.approval_date or fields.Date.context_today(self)).strftime('%B %d, %Y') device_details = f'{approved_count} approved' if unapproved_count > 0: device_details += f', {unapproved_count} not approved' if deduction_count > 0: device_details += f', {deduction_count} with deductions' # Build documents list - show individual file names for screenshots docs_items = '' if self.approval_letter: docs_items += f'
  • Approval Letter: {self.approval_letter_filename}
  • ' if photos_attached > 0: docs_items += f'
  • {photos_attached} approval screenshot(s) attached below
  • ' docs_html = '' if docs_items: docs_html = f'

    Documents:

    ' # Post to chatter with all attachments in one message if chatter_attachment_ids: self.sale_order_id.message_post( body=Markup( '' ), message_type='notification', subtype_xmlid='mail.mt_note', attachment_ids=chatter_attachment_ids, ) else: # No attachments, just post the status update self.sale_order_id.message_post( body=Markup( '' ), message_type='notification', subtype_xmlid='mail.mt_note', ) # Sync deductions and approval status to existing invoices invoices_updated = self._sync_approval_to_invoices(updated_lines) # Build notification message parts = [] if unapproved_count > 0: parts.append(_("%d approved, %d NOT approved (will bill to client)") % (approved_count, unapproved_count)) msg_type = 'warning' else: parts.append(_("All %d devices approved") % approved_count) msg_type = 'success' if deduction_count > 0: parts.append(_("%d deduction(s) applied") % deduction_count) if invoices_updated > 0: parts.append(_("%d invoice(s) updated") % invoices_updated) # Add status update note if applicable if self.env.context.get('mark_as_approved'): parts.append(_("Status updated to Approved")) message = ". ".join(parts) + "." # Close the wizard and show notification return { 'type': 'ir.actions.act_window_close', 'infos': { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Application Approved') if self.env.context.get('mark_as_approved') else _('Device Verification Complete'), 'message': message, 'type': msg_type, 'sticky': False, } } } def _sync_approval_to_invoices(self, sale_lines): """Sync approval status and deductions to existing invoices. When approval status or deductions change on SO lines: - Client Invoice: unapproved items get 100% price, approved items get normal client portion - ADP Invoice: unapproved items get removed (price = 0), approved items get normal ADP portion Returns number of invoices updated. """ if not sale_lines: return 0 invoices_updated = set() order = self.sale_order_id ADPDevice = self.env['fusion.adp.device.code'].sudo() # Get all non-cancelled invoices for this order invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel') if not invoices: return 0 for so_line in sale_lines: # ================================================================= # CHECK 1: Is this a NON-ADP funded product? # ================================================================= is_non_adp_funded = so_line.product_id.is_non_adp_funded() if so_line.product_id else False # ================================================================= # CHECK 2: Does this product have a valid ADP device code? # ================================================================= device_code = so_line._get_adp_device_code() is_adp_device = False if device_code and not is_non_adp_funded: is_adp_device = ADPDevice.search_count([ ('device_code', '=', device_code), ('active', '=', True) ]) > 0 is_approved = so_line.x_fc_adp_approved # Find linked invoice lines invoice_lines = self.env['account.move.line'].sudo().search([ ('sale_line_ids', 'in', so_line.id), ('move_id', 'in', invoices.ids), ]) for inv_line in invoice_lines: invoice = inv_line.move_id # Update approval and deduction fields on invoice line inv_line_vals = { 'x_fc_deduction_type': so_line.x_fc_deduction_type, 'x_fc_deduction_value': so_line.x_fc_deduction_value, 'x_fc_adp_approved': is_approved, } # Check invoice portion type portion_type = getattr(invoice, 'x_fc_adp_invoice_portion', '') or '' # ================================================================= # INVOICE LINE LOGIC: # - Non-ADP items (NON-ADP code OR not in ADP database): # -> Client Invoice: 100% price # -> ADP Invoice: $0 (should not be there) # - Unapproved ADP items: # -> Client Invoice: 100% price # -> ADP Invoice: $0 (excluded) # - Approved ADP items: # -> Client Invoice: client portion % # -> ADP Invoice: ADP portion % # ================================================================= if portion_type == 'client': if is_non_adp_funded or not is_adp_device: # NON-ADP item: Client pays 100% new_portion = so_line.price_subtotal inv_line_vals['name'] = so_line.name elif is_adp_device and not is_approved: # UNAPPROVED ADP device: Client pays 100% new_portion = so_line.price_subtotal inv_line_vals['name'] = f"{so_line.name} [NOT APPROVED - 100% Client]" else: # Normal client portion (approved ADP item) new_portion = so_line.x_fc_client_portion inv_line_vals['name'] = so_line.name elif portion_type == 'adp': if is_non_adp_funded or not is_adp_device: # NON-ADP item: Remove from ADP invoice (set to 0) new_portion = 0 inv_line_vals['name'] = f"{so_line.name} [NON-ADP - Excluded]" elif is_adp_device and not is_approved: # UNAPPROVED ADP device: Remove from ADP invoice (set to 0) new_portion = 0 inv_line_vals['name'] = f"{so_line.name} [NOT APPROVED - Excluded]" else: # Normal ADP portion new_portion = so_line.x_fc_adp_portion inv_line_vals['name'] = so_line.name else: # Unknown type - just update fields, skip price recalc inv_line.write(inv_line_vals) invoices_updated.add(invoice.id) continue # Calculate new unit price if so_line.product_uom_qty > 0: new_unit_price = new_portion / so_line.product_uom_qty else: new_unit_price = 0 inv_line_vals['price_unit'] = new_unit_price # Need to handle draft vs posted invoices differently if invoice.state == 'draft': inv_line.write(inv_line_vals) else: # For posted invoices, reset to draft first try: invoice.button_draft() inv_line.write(inv_line_vals) invoice.action_post() _logger.info(f"Reset and updated invoice {invoice.name} for approval/deduction change") except Exception as e: _logger.warning(f"Could not update posted invoice {invoice.name}: {e}") invoices_updated.add(invoice.id) return len(invoices_updated) def action_approve_all(self): """Approve all devices.""" self.ensure_one() # Write to lines to ensure proper update self.line_ids.write({'approved': True}) self.write({'all_approved': True}) # Return action to refresh the wizard view return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'res_id': self.id, 'view_mode': 'form', 'target': 'new', 'context': self.env.context, } class DeviceApprovalWizardLine(models.TransientModel): """Lines for the device approval wizard.""" _name = 'fusion_claims.device.approval.wizard.line' _description = 'Device Approval Wizard Line' wizard_id = fields.Many2one( 'fusion_claims.device.approval.wizard', string='Wizard', required=True, ondelete='cascade', ) sale_line_id = fields.Many2one( 'sale.order.line', string='Sale Line', required=True, ) product_name = fields.Char( string='Product', readonly=True, ) device_code = fields.Char( string='Device Code', readonly=True, ) device_type = fields.Char( string='Device Type', readonly=True, ) device_description = fields.Char( string='Description', readonly=True, ) serial_number = fields.Char( string='Serial Number', help='Serial number from the sale order line', ) quantity = fields.Float( string='Qty', readonly=True, ) unit_price = fields.Float( string='Unit Price', readonly=True, digits='Product Price', ) adp_portion = fields.Float( string='ADP Portion', readonly=True, digits='Product Price', ) client_portion = fields.Float( string='Client Portion', readonly=True, digits='Product Price', ) approved = fields.Boolean( string='Approved', default=True, help='Check if this device type was approved by ADP', ) # ========================================================================== # DEDUCTION FIELDS # ========================================================================== deduction_type = fields.Selection( selection=[ ('none', 'No Deduction'), ('pct', 'Percentage (%)'), ('amt', 'Amount ($)'), ], string='Deduction Type', default='none', help='Type of ADP deduction. PCT = ADP covers X% of normal. AMT = Fixed $ deducted.', ) deduction_value = fields.Float( string='Deduction', digits='Product Price', help='For PCT: enter percentage (e.g., 75 means ADP covers 75%). For AMT: enter dollar amount.', ) # ========================================================================== # COMPUTED FIELDS FOR PREVIEW # ========================================================================== estimated_adp = fields.Float( string='Est. ADP', compute='_compute_estimated_portions', digits='Product Price', help='Estimated ADP portion after deduction', ) estimated_client = fields.Float( string='Est. Client', compute='_compute_estimated_portions', digits='Product Price', help='Estimated client portion after deduction', ) @api.depends('deduction_type', 'deduction_value', 'adp_portion', 'client_portion', 'unit_price', 'quantity', 'approved') def _compute_estimated_portions(self): """Compute estimated portions based on current deduction settings.""" for line in self: if not line.approved: # If not approved, show 0 for ADP portion line.estimated_adp = 0 line.estimated_client = line.unit_price * line.quantity continue # Get base values from the sale line so_line = line.sale_line_id if not so_line or not so_line.order_id: line.estimated_adp = line.adp_portion line.estimated_client = line.client_portion continue # Get client type for base percentages client_type = so_line.order_id._get_client_type() if client_type == 'REG': base_adp_pct = 0.75 else: base_adp_pct = 1.0 # Get ADP price adp_price = so_line.x_fc_adp_max_price or line.unit_price total = adp_price * line.quantity # Apply deduction if line.deduction_type == 'pct' and line.deduction_value: # PCT: ADP only covers deduction_value% of their portion effective_pct = base_adp_pct * (line.deduction_value / 100) line.estimated_adp = total * effective_pct line.estimated_client = total - line.estimated_adp elif line.deduction_type == 'amt' and line.deduction_value: # AMT: Subtract fixed amount from ADP portion base_adp = total * base_adp_pct line.estimated_adp = max(0, base_adp - line.deduction_value) line.estimated_client = total - line.estimated_adp else: # No deduction line.estimated_adp = total * base_adp_pct line.estimated_client = total * (1 - base_adp_pct)