This commit is contained in:
gsinghpal
2026-03-14 12:04:20 -04:00
parent fc3c966484
commit e9cf75ee48
75 changed files with 6991 additions and 873 deletions

View File

@@ -1,15 +1,54 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
import logging
from . import models
from . import wizard
_logger = logging.getLogger(__name__)
def _load_adp_device_codes(env):
"""
Post-init hook to load ADP Mobility Manual device codes.
Called on module install AND upgrade.
"""Post-init hook: load device codes then link products to them.
Called on module install AND upgrade. Each step is idempotent.
"""
env['fusion.adp.device.code']._load_packaged_device_codes()
_link_products_to_device_codes(env)
def _link_products_to_device_codes(env):
"""Populate x_fc_adp_device_code_id and x_fc_is_adp_product for
existing products that already have a device code string set.
Uses raw SQL for speed since this may touch hundreds of rows.
"""
cr = env.cr
cr.execute("""
UPDATE product_template pt
SET x_fc_adp_device_code_id = adc.id,
x_fc_adp_price = adc.adp_price,
x_fc_is_adp_product = TRUE
FROM fusion_adp_device_code adc
WHERE pt.x_fc_adp_device_code IS NOT NULL
AND pt.x_fc_adp_device_code != ''
AND adc.device_code = pt.x_fc_adp_device_code
AND adc.active = TRUE
AND pt.x_fc_adp_device_code_id IS NULL
""")
linked = cr.rowcount
_logger.info("ADP migration: linked %d products to device code records", linked)
cr.execute("""
UPDATE product_template
SET x_fc_is_adp_product = TRUE
WHERE x_fc_adp_device_code IS NOT NULL
AND x_fc_adp_device_code != ''
AND (x_fc_is_adp_product IS NULL OR x_fc_is_adp_product = FALSE)
""")
toggled = cr.rowcount
_logger.info("ADP migration: toggled x_fc_is_adp_product on %d products", toggled)

View File

@@ -136,6 +136,7 @@
'wizard/xml_import_wizard_views.xml',
'views/adp_claims_views.xml',
'views/submission_history_views.xml',
'views/product_template_adp_views.xml',
'views/fusion_loaner_views.xml',
'views/page11_sign_request_views.xml',
'views/technician_task_views.xml',
@@ -160,7 +161,6 @@
'assets': {
'web.assets_backend': [
'fusion_claims/static/src/scss/fusion_claims.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',
'fusion_claims/static/src/js/status_selection_filter.js',

View File

@@ -84,6 +84,15 @@ class FusionADPDeviceCode(models.Model):
default=fields.Datetime.now,
)
# ==========================================================================
# REVERSE LINK TO PRODUCTS
# ==========================================================================
product_template_ids = fields.One2many(
'product.template',
'x_fc_adp_device_code_id',
string='Linked Products',
)
# ==========================================================================
# SQL CONSTRAINTS
# ==========================================================================
@@ -92,6 +101,28 @@ class FusionADPDeviceCode(models.Model):
'Device code must be unique!'),
]
# ==========================================================================
# WRITE OVERRIDE - push changes to linked products
# ==========================================================================
def write(self, vals):
res = super().write(vals)
sync_fields = {'adp_price', 'device_code'}
if sync_fields & set(vals):
products = self.env['product.template'].sudo().search([
('x_fc_adp_device_code_id', 'in', self.ids)
])
if products:
for product in products:
update = {}
device = product.x_fc_adp_device_code_id
if 'adp_price' in vals:
update['x_fc_adp_price'] = device.adp_price
if 'device_code' in vals:
update['x_fc_adp_device_code'] = device.device_code
if update:
product.sudo().write(update)
return res
# ==========================================================================
# COMPUTED FIELDS
# ==========================================================================

View File

@@ -10,70 +10,68 @@ class ProductProduct(models.Model):
_inherit = 'product.product'
def get_adp_device_code(self):
"""
Get ADP device code from the field mapped in fusion settings.
The field name is configured in Settings → Sales → Fusion Central →
Field Mappings → Product ADP Code Field.
Checks the mapped field on the product variant first, then on template.
Returns the value from the mapped field, or empty string if not found.
"""Get ADP device code, preferring the linked device code record.
Checks in order:
1. Linked Many2one device code record on template
2. x_fc_adp_device_code char field on template
3. Mapped field from fusion settings (legacy)
4. default_code
"""
self.ensure_one()
# Get the mapped field name from fusion settings
tmpl = self.product_tmpl_id
if tmpl and tmpl.x_fc_adp_device_code_id:
return tmpl.x_fc_adp_device_code_id.device_code or ''
if tmpl and tmpl.x_fc_adp_device_code:
return tmpl.x_fc_adp_device_code
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_product_code', 'x_fc_adp_device_code')
if not field_name:
return ''
# Check if the mapped field exists on the product variant (product.product)
if field_name in self._fields:
value = getattr(self, field_name, '') or ''
if value:
return value
# Check if the mapped field exists on the product template
if self.product_tmpl_id and field_name in self.product_tmpl_id._fields:
value = getattr(self.product_tmpl_id, field_name, '') or ''
if value:
return value
return ''
if field_name and field_name != 'x_fc_adp_device_code':
if field_name in self._fields:
value = getattr(self, field_name, '') or ''
if value:
return value
if tmpl and field_name in tmpl._fields:
value = getattr(tmpl, field_name, '') or ''
if value:
return value
return self.default_code or ''
def get_adp_price(self):
"""
Get ADP price from the field mapped in fusion settings.
The field name is configured in Settings → Sales → Fusion Central →
Field Mappings → Product ADP Price Field.
Checks the mapped field on the product variant first, then on template.
Returns the value from the mapped field, or 0.0 if not found.
"""Get ADP price, preferring the linked device code record.
Checks in order:
1. Linked Many2one device code record price on template
2. x_fc_adp_price field on template
3. Mapped field from fusion settings (legacy)
4. list_price
"""
self.ensure_one()
# Get the mapped field name from fusion settings
tmpl = self.product_tmpl_id
if tmpl and tmpl.x_fc_adp_device_code_id and tmpl.x_fc_adp_device_code_id.adp_price:
return tmpl.x_fc_adp_device_code_id.adp_price
if tmpl and tmpl.x_fc_adp_price:
return tmpl.x_fc_adp_price
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_product_adp_price', 'x_fc_adp_price')
if not field_name:
return 0.0
# Check if the mapped field exists on the product variant (product.product)
if field_name in self._fields:
value = getattr(self, field_name, 0.0) or 0.0
if value:
return value
# Check if the mapped field exists on the product template
if self.product_tmpl_id and field_name in self.product_tmpl_id._fields:
value = getattr(self.product_tmpl_id, field_name, 0.0) or 0.0
if value:
return value
return 0.0
if field_name and field_name != 'x_fc_adp_price':
if field_name in self._fields:
value = getattr(self, field_name, 0.0) or 0.0
if value:
return value
if tmpl and field_name in tmpl._fields:
value = getattr(tmpl, field_name, 0.0) or 0.0
if value:
return value
return tmpl.list_price if tmpl else 0.0
def is_non_adp_funded(self):
"""
@@ -114,65 +112,71 @@ class ProductProduct(models.Model):
return False
def action_sync_adp_price_from_database(self):
"""
Update product's ADP price from the device codes database.
Looks up the product's ADP device code in the fusion.adp.device.code table
and updates the product's x_fc_adp_price field with the database value.
Returns a notification with the result.
"""Sync product ADP data from the device codes database.
Looks up the product's device code in fusion.adp.device.code and
populates the Many2one link, price, and device code string.
"""
ADPDevice = self.env['fusion.adp.device.code'].sudo()
updated = []
not_found = []
no_code = []
for product in self:
device_code = product.get_adp_device_code()
product_tmpl = product.product_tmpl_id
if product_tmpl.x_fc_adp_device_code_id:
adp_device = product_tmpl.x_fc_adp_device_code_id
device_code = adp_device.device_code
else:
device_code = product.get_adp_device_code()
if not device_code:
no_code.append(product.name)
continue
adp_device = ADPDevice.search([
('device_code', '=', device_code),
('active', '=', True)
], limit=1)
if adp_device and adp_device.adp_price:
# Update product template
product_tmpl = product.product_tmpl_id
old_price = 0
if hasattr(product_tmpl, 'x_fc_adp_price'):
old_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0
product_tmpl.sudo().write({'x_fc_adp_price': adp_device.adp_price})
updated.append({
'name': product.name,
'code': device_code,
'old_price': old_price,
'new_price': adp_device.adp_price,
})
if adp_device:
old_price = product_tmpl.x_fc_adp_price or 0
write_vals = {
'x_fc_adp_device_code_id': adp_device.id,
'x_fc_adp_device_code': adp_device.device_code,
'x_fc_adp_price': adp_device.adp_price,
'x_fc_is_adp_product': True,
}
product_tmpl.sudo().write(write_vals)
updated.append({
'name': product.name,
'code': device_code,
'old_price': old_price,
'new_price': adp_device.adp_price,
})
else:
not_found.append(f"{product.name} ({device_code})")
# Build result message
message_parts = []
if updated:
msg = f"<strong>Updated {len(updated)} product(s):</strong><ul>"
msg = f"<strong>Synced {len(updated)} product(s):</strong><ul>"
for u in updated:
msg += f"<li>{u['name']}: ${u['old_price']:.2f} ${u['new_price']:.2f}</li>"
msg += f"<li>{u['name']}: ${u['old_price']:.2f} -> ${u['new_price']:.2f}</li>"
msg += "</ul>"
message_parts.append(msg)
if not_found:
message_parts.append(f"<strong>Not found in database:</strong> {', '.join(not_found)}")
message_parts.append(
f"<strong>Not found in database:</strong> {', '.join(not_found)}"
)
if no_code:
message_parts.append(f"<strong>No ADP code:</strong> {', '.join(no_code)}")
message_parts.append(
f"<strong>No ADP code:</strong> {', '.join(no_code)}"
)
if not message_parts:
message_parts.append("No products to process.")
return {
'type': 'ir.actions.client',
'tag': 'display_notification',

View File

@@ -3,7 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
from odoo import api, fields, models
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class ProductTemplate(models.Model):
@@ -11,12 +12,26 @@ class ProductTemplate(models.Model):
# ==========================================================================
# ADP PRODUCT FIELDS
# These are the module's own fields - independent of Odoo Studio
# ==========================================================================
x_fc_adp_device_code = fields.Char(
x_fc_is_adp_product = fields.Boolean(
string='ADP Product',
default=False,
tracking=True,
help='Toggle to mark this as an ADP product. Shows ADP fields when enabled.',
)
x_fc_adp_device_code_id = fields.Many2one(
'fusion.adp.device.code',
string='ADP Device Code',
help='Device code used for ADP claims export',
ondelete='set null',
tracking=True,
help='Link to the ADP Mobility Manual device code record',
)
x_fc_adp_device_code = fields.Char(
string='Device Code',
help='Device code string used for ADP claims export',
copy=True,
tracking=True,
)
@@ -24,16 +39,30 @@ class ProductTemplate(models.Model):
x_fc_adp_price = fields.Float(
string='ADP Price',
digits='Product Price',
help='ADP retail price for this product. Used in ADP reports and claims.',
help='ADP retail price from the device codes database.',
copy=True,
tracking=True,
)
x_fc_is_adp_product = fields.Boolean(
string='Is ADP Product',
compute='_compute_is_adp_product',
x_fc_adp_device_type = fields.Char(
related='x_fc_adp_device_code_id.device_type',
string='Device Type',
store=True,
help='Indicates if this product has ADP pricing set up',
readonly=True,
)
x_fc_adp_build_type = fields.Selection(
related='x_fc_adp_device_code_id.build_type',
string='Build Type',
store=True,
readonly=True,
)
x_fc_adp_max_quantity = fields.Integer(
related='x_fc_adp_device_code_id.max_quantity',
string='Max Quantity',
store=True,
readonly=True,
)
# ==========================================================================
@@ -117,49 +146,65 @@ class ProductTemplate(models.Model):
x_fc_package_info = fields.Text(string='Package Information')
# ==========================================================================
# COMPUTED FIELDS
# ONCHANGE / CONSTRAINTS
# ==========================================================================
@api.depends('x_fc_adp_device_code', 'x_fc_adp_price')
def _compute_is_adp_product(self):
"""Determine if this is an ADP product based on having device code or price."""
@api.onchange('x_fc_adp_device_code_id')
def _onchange_adp_device_code_id(self):
"""Populate device code string and price from the selected device record."""
if self.x_fc_adp_device_code_id:
self.x_fc_adp_device_code = self.x_fc_adp_device_code_id.device_code
self.x_fc_adp_price = self.x_fc_adp_device_code_id.adp_price
else:
self.x_fc_adp_device_code = False
self.x_fc_adp_price = 0.0
@api.constrains('x_fc_is_adp_product', 'x_fc_adp_device_code_id')
def _check_adp_product_device_code(self):
for product in self:
product.x_fc_is_adp_product = bool(
product.x_fc_adp_device_code or product.x_fc_adp_price
)
if product.x_fc_is_adp_product and not product.x_fc_adp_device_code_id:
raise ValidationError(
_("'%s' is marked as an ADP Product but has no ADP Device Code selected.") % product.name
)
# ==========================================================================
# HELPER METHODS
# ==========================================================================
def get_adp_price(self):
"""
Get ADP price with fallback to Studio field.
"""Get ADP price, preferring the linked device code record.
Checks in order:
1. x_fc_adp_price (module field)
2. list_price (default product price)
1. Linked device code record price
2. x_fc_adp_price (stored field)
3. list_price (default product price)
"""
self.ensure_one()
if self.x_fc_adp_device_code_id and self.x_fc_adp_device_code_id.adp_price:
return self.x_fc_adp_device_code_id.adp_price
if self.x_fc_adp_price:
return self.x_fc_adp_price
return self.list_price or 0.0
def get_adp_device_code(self):
"""
Get ADP device code.
"""Get ADP device code, preferring the linked device code record.
Checks in order:
1. x_fc_adp_device_code (module field)
2. default_code (internal reference)
1. Linked device code record
2. x_fc_adp_device_code (stored char)
3. default_code (internal reference)
"""
self.ensure_one()
if self.x_fc_adp_device_code_id:
return self.x_fc_adp_device_code_id.device_code or ''
if self.x_fc_adp_device_code:
return self.x_fc_adp_device_code
return self.default_code or ''
# ==========================================================================

View File

@@ -4626,11 +4626,13 @@ class SaleOrder(models.Model):
f'Product price ${pm["product_price"]:.2f} vs Database ${pm["db_price"]:.2f}</li>'
)
mismatch_msg += '</ul><p>Database prices were used. Consider updating product prices.</p>'
self.message_post(body=mismatch_msg, message_type='notification', subtype_xmlid='mail.mt_note')
self.message_post(body=Markup(mismatch_msg), message_type='notification', subtype_xmlid='mail.mt_note')
# Auto-update product prices from database
# Auto-update product prices from database (skip if managed via Many2one link)
for pm in price_mismatches:
product_tmpl = pm['product'].product_tmpl_id
if product_tmpl.x_fc_adp_device_code_id:
continue
if hasattr(product_tmpl, 'x_fc_adp_price'):
product_tmpl.sudo().write({'x_fc_adp_price': pm['db_price']})
@@ -6905,9 +6907,11 @@ class SaleOrder(models.Model):
# Post to chatter
days_since_billed = (today - order.x_fc_billing_date).days
order.message_post(
body=f'<p><strong><i class="fa fa-check-circle text-success"/> Case Automatically Closed</strong></p>'
f'<p>This case has been automatically closed after {days_since_billed} days since billing.</p>'
f'<p>Billing Date: {order.x_fc_billing_date}</p>',
body=Markup(
'<p><strong><i class="fa fa-check-circle text-success"/> Case Automatically Closed</strong></p>'
'<p>This case has been automatically closed after %s days since billing.</p>'
'<p>Billing Date: %s</p>'
) % (days_since_billed, order.x_fc_billing_date),
message_type='notification',
subtype_xmlid='mail.mt_note',
)

View File

@@ -1,40 +0,0 @@
/** @odoo-module **/
// Fusion Claims - Chatter Topbar Tooltips
// Copyright 2024-2026 Nexa Systems Inc.
// License OPL-1
//
// Adds title (tooltip) attributes to chatter topbar buttons that have
// their text hidden via CSS (icon-only mode).
const TOOLTIPS = {
'.o-mail-Chatter-sendMessage': 'Send Message',
'.o-mail-Chatter-logNote': 'Log Note',
'button[data-hotkey="shift+w"]': 'WhatsApp',
'.o-mail-Chatter-activity': 'Schedule Activity',
'.fusion-notes-mic-btn': 'Record Voice Note',
'.o-mail-Chatter-messageAuthorizer': 'Message Authorizer',
};
function applyTooltips() {
for (const [selector, title] of Object.entries(TOOLTIPS)) {
for (const btn of document.querySelectorAll(selector)) {
if (!btn.getAttribute('title')) {
btn.setAttribute('title', title);
}
}
}
}
// Run on DOM changes (OWL re-renders)
const observer = new MutationObserver(() => applyTooltips());
// Start observing once DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { childList: true, subtree: true });
applyTooltips();
});
} else {
observer.observe(document.body, { childList: true, subtree: true });
applyTooltips();
}

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
ADP Product toggle and linked device code fields on the product form.
-->
<odoo>
<!-- ADP Product checkbox in the top options row -->
<record id="view_product_template_adp_toggle" model="ir.ui.view">
<field name="name">product.template.form.fusion.claims.adp.toggle</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view"/>
<field name="priority">165</field>
<field name="arch" type="xml">
<xpath expr="//div[@name='options']" position="inside">
<span class="d-inline-flex">
<field name="x_fc_is_adp_product"/>
<label for="x_fc_is_adp_product" string="ADP Product"/>
</span>
</xpath>
</field>
</record>
<!-- ADP Information section (visible only when ADP Product is toggled) -->
<record id="view_product_template_adp_section" model="ir.ui.view">
<field name="name">product.template.form.fusion.claims.adp.section</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view"/>
<field name="priority">170</field>
<field name="arch" type="xml">
<xpath expr="//group[@name='group_standard_price']" position="inside">
<separator string="ADP Information"
invisible="not x_fc_is_adp_product"/>
<field name="x_fc_adp_device_code_id"
invisible="not x_fc_is_adp_product"
required="x_fc_is_adp_product"/>
<field name="x_fc_adp_device_code"
invisible="not x_fc_is_adp_product"
readonly="1"/>
<field name="x_fc_adp_price"
invisible="not x_fc_is_adp_product"
readonly="1"/>
<field name="x_fc_adp_device_type"
invisible="not x_fc_is_adp_product"
readonly="1"/>
<field name="x_fc_adp_build_type"
invisible="not x_fc_is_adp_product"
readonly="1"/>
<field name="x_fc_adp_max_quantity"
invisible="not x_fc_is_adp_product"
readonly="1"/>
</xpath>
</field>
</record>
<!-- ADP filters and grouping in product search view -->
<record id="view_product_template_search_adp" model="ir.ui.view">
<field name="name">product.template.search.fusion.claims.adp</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_search_view"/>
<field name="priority">170</field>
<field name="arch" type="xml">
<xpath expr="//filter[@name='filter_to_sell']" position="after">
<filter string="ADP Product" name="filter_adp_product"
domain="[('x_fc_is_adp_product', '=', True)]"/>
</xpath>
<xpath expr="//filter[@name='group_by_categ_id']" position="after">
<filter string="ADP Device Type" name="group_by_adp_device_type"
context="{'group_by': 'x_fc_adp_device_type'}"/>
</xpath>
</field>
</record>
</odoo>