Initial commit
This commit is contained in:
362
fusion_claims/models/sale_order_line.py
Normal file
362
fusion_claims/models/sale_order_line.py
Normal file
@@ -0,0 +1,362 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user