248 lines
9.8 KiB
Python
248 lines
9.8 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 AccountMoveLine(models.Model):
|
|
_inherit = 'account.move.line'
|
|
|
|
# ==========================================================================
|
|
# SERIAL NUMBER AND DEVICE PLACEMENT
|
|
# ==========================================================================
|
|
x_fc_serial_number = fields.Char(
|
|
string='Serial Number',
|
|
help='Serial number for this product',
|
|
)
|
|
x_fc_device_placement = fields.Selection(
|
|
selection=[
|
|
('L', 'Left'),
|
|
('R', 'Right'),
|
|
('NA', 'N/A'),
|
|
],
|
|
string='Device Placement',
|
|
default='NA',
|
|
help='Device placement position (Left/Right/N/A)',
|
|
)
|
|
|
|
# ==========================================================================
|
|
# DEDUCTION FIELDS
|
|
# ==========================================================================
|
|
x_fc_deduction_type = fields.Selection(
|
|
selection=[
|
|
('none', 'No Deduction'),
|
|
('pct', 'Percentage'),
|
|
('amt', 'Amount'),
|
|
],
|
|
string='Deduction Type',
|
|
default='none',
|
|
help='Type of ADP deduction applied to this line',
|
|
)
|
|
x_fc_deduction_value = fields.Float(
|
|
string='Deduction Value',
|
|
digits='Product Price',
|
|
help='Deduction value (percentage if PCT, dollar amount if AMT)',
|
|
)
|
|
|
|
# ==========================================================================
|
|
# ADP REFERENCE FIELDS
|
|
# ==========================================================================
|
|
x_fc_adp_max_price = fields.Float(
|
|
string='ADP Max Price',
|
|
digits='Product Price',
|
|
help='Maximum price ADP will cover for this device (from mobility manual)',
|
|
)
|
|
x_fc_sn_required = fields.Boolean(
|
|
string='S/N Required',
|
|
help='Is serial number required for this device?',
|
|
)
|
|
|
|
# ==========================================================================
|
|
# ADP DEVICE APPROVAL TRACKING
|
|
# ==========================================================================
|
|
x_fc_adp_approved = fields.Boolean(
|
|
string='ADP Approved',
|
|
default=False,
|
|
help='Was this device approved by ADP in the application approval?',
|
|
)
|
|
x_fc_adp_device_type = fields.Char(
|
|
string='ADP Device Type',
|
|
help='Device type from ADP mobility manual (for approval matching)',
|
|
)
|
|
|
|
# ==========================================================================
|
|
# ADP PORTIONS - Stored fields set during invoice creation
|
|
# ==========================================================================
|
|
x_fc_adp_portion = fields.Monetary(
|
|
string='ADP Portion',
|
|
currency_field='currency_id',
|
|
help='ADP portion for this line (calculated during invoice creation from device codes database)',
|
|
)
|
|
x_fc_client_portion = fields.Monetary(
|
|
string='Client Portion',
|
|
currency_field='currency_id',
|
|
help='Client portion for this line (calculated during invoice creation from device codes database)',
|
|
)
|
|
|
|
def _compute_adp_portions(self):
|
|
"""Compute ADP and client portions based on device codes database.
|
|
|
|
This is called during invoice type/client type changes to recalculate portions.
|
|
"""
|
|
self.action_recalculate_portions()
|
|
|
|
def action_recalculate_portions(self):
|
|
"""Manually recalculate ADP and client portions based on device codes database.
|
|
|
|
This can be called to recalculate portions if values are incorrect.
|
|
Uses the same logic as invoice creation.
|
|
"""
|
|
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
|
|
|
for line in self:
|
|
move = line.move_id
|
|
if not move or move.move_type not in ['out_invoice', 'out_refund']:
|
|
continue
|
|
|
|
if not line.product_id or line.quantity <= 0:
|
|
continue
|
|
|
|
# Get client type
|
|
client_type = move._get_client_type()
|
|
if client_type == 'REG':
|
|
base_adp_pct = 0.75
|
|
base_client_pct = 0.25
|
|
else:
|
|
base_adp_pct = 1.0
|
|
base_client_pct = 0.0
|
|
|
|
# Get ADP price from device codes database (priority)
|
|
device_code = line._get_adp_device_code()
|
|
adp_price = 0
|
|
|
|
if device_code:
|
|
adp_device = ADPDevice.search([
|
|
('device_code', '=', device_code),
|
|
('active', '=', True)
|
|
], limit=1)
|
|
if adp_device:
|
|
adp_price = adp_device.adp_price or 0
|
|
|
|
# Fallback to product fields
|
|
if not adp_price and line.product_id:
|
|
product_tmpl = line.product_id.product_tmpl_id
|
|
if hasattr(product_tmpl, 'x_fc_adp_price'):
|
|
adp_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0
|
|
# (Studio field fallback removed)
|
|
|
|
# Fallback to line max price or unit price
|
|
if not adp_price:
|
|
adp_price = line.x_fc_adp_max_price or line.price_unit
|
|
|
|
qty = line.quantity
|
|
adp_base_total = adp_price * qty
|
|
|
|
# Apply deductions
|
|
if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
|
|
effective_adp_pct = base_adp_pct * (line.x_fc_deduction_value / 100)
|
|
adp_portion = adp_base_total * effective_adp_pct
|
|
client_portion = adp_base_total - adp_portion
|
|
elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
|
|
base_adp_amount = adp_base_total * base_adp_pct
|
|
adp_portion = max(0, base_adp_amount - line.x_fc_deduction_value)
|
|
client_portion = adp_base_total - adp_portion
|
|
else:
|
|
adp_portion = adp_base_total * base_adp_pct
|
|
client_portion = adp_base_total * base_client_pct
|
|
|
|
line.write({
|
|
'x_fc_adp_portion': adp_portion,
|
|
'x_fc_client_portion': client_portion,
|
|
'x_fc_adp_max_price': adp_price,
|
|
})
|
|
|
|
# ==========================================================================
|
|
# GETTER METHODS
|
|
# ==========================================================================
|
|
def _get_adp_device_code(self):
|
|
"""Get ADP device code from product.
|
|
|
|
Checks multiple sources in order and validates against ADP device database:
|
|
1. x_fc_adp_device_code (module field) - verified in ADP database
|
|
2. x_adp_code (Studio/custom field) - verified in ADP database
|
|
3. default_code - verified in ADP database
|
|
4. Code in parentheses in product name (e.g., "Product Name (SE0001109)")
|
|
|
|
Returns empty string if no valid ADP code found.
|
|
"""
|
|
import re
|
|
|
|
self.ensure_one()
|
|
if not self.product_id:
|
|
return ''
|
|
|
|
product_tmpl = self.product_id.product_tmpl_id
|
|
ADPDevice = self.env['fusion.adp.device.code'].sudo()
|
|
|
|
# 1. Check x_fc_adp_device_code (module field)
|
|
code = ''
|
|
if hasattr(product_tmpl, 'x_fc_adp_device_code'):
|
|
code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or ''
|
|
|
|
# Verify code exists in ADP database
|
|
if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
|
|
return code
|
|
|
|
# 2. Check x_adp_code (Studio/custom field)
|
|
if hasattr(product_tmpl, 'x_adp_code'):
|
|
code = getattr(product_tmpl, 'x_adp_code', '') or ''
|
|
if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
|
|
return code
|
|
|
|
# 3. Check default_code - ONLY if it's a valid ADP code
|
|
code = self.product_id.default_code or ''
|
|
if code and ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
|
|
return code
|
|
|
|
# 4. Try to extract code from product name in parentheses
|
|
# E.g., "[MXA-1618] GEOMATRIX SILVERBACK MAX BACKREST - ACTIVE (SE0001109)"
|
|
product_name = self.product_id.name or ''
|
|
match = re.search(r'\(([A-Z]{2}\d{7})\)', product_name)
|
|
if match:
|
|
code = match.group(1)
|
|
if ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0:
|
|
return code
|
|
|
|
# 5. Last resort: check if there's a linked sale order line with ADP code
|
|
if self.sale_line_ids:
|
|
for sale_line in self.sale_line_ids:
|
|
if hasattr(sale_line, '_get_adp_device_code'):
|
|
sale_code = sale_line._get_adp_device_code()
|
|
if sale_code:
|
|
return sale_code
|
|
|
|
# No valid ADP code found - return empty to skip this line in export
|
|
return ''
|
|
|
|
def _get_serial_number(self):
|
|
"""Get serial number from mapped field or native field."""
|
|
self.ensure_one()
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
field_name = ICP.get_param('fusion_claims.field_aml_serial', 'x_fc_serial_number')
|
|
|
|
# Try mapped field first
|
|
if hasattr(self, field_name):
|
|
value = getattr(self, field_name, None)
|
|
if value:
|
|
return value
|
|
|
|
# Fallback to native field
|
|
return self.x_fc_serial_number or ''
|
|
|
|
def _get_device_placement(self):
|
|
"""Get device placement."""
|
|
self.ensure_one()
|
|
return self.x_fc_device_placement or 'NA'
|