363 lines
16 KiB
Python
363 lines
16 KiB
Python
# -*- 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
|