Files
Odoo-Modules/fusion_inventory/models/product_template.py
gsinghpal e9cf75ee48 changes
2026-03-14 12:04:20 -04:00

357 lines
15 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
CASE_CONVERSION_OPTIONS = [
('none', 'No Conversion'),
('upper', 'UPPERCASE'),
('sentence', 'Sentence case'),
('capitalized', 'Capitalized Case'),
('lower', 'lowercase'),
]
class ProductTemplate(models.Model):
_inherit = 'product.template'
# ── Inventory Sync (from fusion_inventory_sync) ──
sync_mapping_ids = fields.One2many(
'fusion.product.sync.mapping', 'local_product_id',
string='Remote Inventory Links')
remote_qty_available = fields.Float(
string='Remote On Hand',
compute='_compute_remote_stock',
help='Total on-hand quantity at remote locations')
remote_qty_forecast = fields.Float(
string='Remote Forecast',
compute='_compute_remote_stock',
help='Total forecasted quantity at remote locations')
has_remote_mapping = fields.Boolean(
string='Has Remote Link',
compute='_compute_remote_stock')
# ── Margin / Profit ──
x_fi_margin_pct = fields.Float(
string='Margin (%)',
compute='_compute_margin_pct',
inverse='_inverse_margin_pct',
store=True, readonly=False,
help='Profit margin as percentage of sale price. '
'Entering a margin auto-calculates the sale price; '
'entering a sale price auto-calculates the margin.')
x_fi_profit_amount = fields.Float(
string='Profit',
compute='_compute_profit',
help='Sale Price minus Cost (at the template level).')
x_fi_shipping_cost = fields.Float(
string='Shipping Cost',
default=0.0,
help='Per-unit shipping cost. Saved here and automatically '
'distributed to all variants on save.')
# ── Brand / Vendor ──
x_fi_brand_ids = fields.Many2many(
'product.brand', 'product_template_brand_rel',
'product_tmpl_id', 'brand_id',
string='Brand(s)')
x_fi_expected_cost = fields.Float(
string='Expected Cost',
compute='_compute_expected_cost',
help='Estimated purchase cost calculated from Sales Price '
'and the primary brand discount tiers.')
# ── Case Conversion ──
x_fi_case_conversion = fields.Selection(
CASE_CONVERSION_OPTIONS,
string='Name Case',
default='none',
help='Convert this product name to the selected case. '
'Global setting in Inventory Settings overrides this.')
# ── Purchase History (computed link to vendor bill lines) ──
x_fi_purchase_history_ids = fields.Many2many(
'account.move.line',
compute='_compute_purchase_history',
string='Purchase History')
x_fi_purchase_history_count = fields.Integer(
compute='_compute_purchase_history',
string='Bill Lines')
# ────────────────────── Computed Methods ──────────────────────
@api.depends('sync_mapping_ids', 'sync_mapping_ids.remote_qty_available',
'sync_mapping_ids.remote_qty_forecast')
def _compute_remote_stock(self):
for product in self:
mappings = product.sync_mapping_ids
product.remote_qty_available = sum(mappings.mapped('remote_qty_available'))
product.remote_qty_forecast = sum(mappings.mapped('remote_qty_forecast'))
product.has_remote_mapping = bool(mappings)
@api.depends('list_price', 'standard_price')
def _compute_profit(self):
for rec in self:
rec.x_fi_profit_amount = rec.list_price - rec.standard_price
@api.depends('list_price', 'categ_id',
'x_fi_brand_ids', 'x_fi_brand_ids.primary_discount_pct',
'x_fi_brand_ids.secondary_discount_pct',
'x_fi_brand_ids.pricing_rule_ids',
'x_fi_brand_ids.pricing_rule_ids.pricing_method',
'x_fi_brand_ids.pricing_rule_ids.primary_discount_pct',
'x_fi_brand_ids.pricing_rule_ids.secondary_discount_pct',
'x_fi_brand_ids.pricing_rule_ids.flat_discount_pct',
'x_fi_brand_ids.pricing_rule_ids.fixed_rebate_amount',
'x_fi_brand_ids.pricing_rule_ids.fixed_cost_price')
def _compute_expected_cost(self):
for rec in self:
brand = rec.x_fi_brand_ids[:1]
if brand and rec.list_price > 0:
rec.x_fi_expected_cost = brand.calculate_cost_from_msrp(
rec.list_price, product_tmpl=rec)
else:
rec.x_fi_expected_cost = 0.0
def _compute_purchase_history(self):
AML = self.env['account.move.line']
for rec in self:
variant_ids = rec.product_variant_ids.ids
if variant_ids:
lines = AML.search([
('product_id', 'in', variant_ids),
('move_id.move_type', '=', 'in_invoice'),
('move_id.state', '=', 'posted'),
('price_unit', '>', 0),
], order='date desc', limit=500)
rec.x_fi_purchase_history_ids = lines
rec.x_fi_purchase_history_count = len(lines)
else:
rec.x_fi_purchase_history_ids = AML
rec.x_fi_purchase_history_count = 0
# ────────────────────── Margin Compute / Inverse / Onchange ──────────────────────
@api.depends('list_price', 'standard_price')
def _compute_margin_pct(self):
for rec in self:
if rec.list_price > 0 and rec.standard_price > 0:
rec.x_fi_margin_pct = round(
((rec.list_price - rec.standard_price) / rec.list_price) * 100, 2)
else:
rec.x_fi_margin_pct = 0.0
def _inverse_margin_pct(self):
for rec in self:
if rec.standard_price > 0 and 0 < rec.x_fi_margin_pct < 100:
rec.list_price = round(
rec.standard_price / (1 - rec.x_fi_margin_pct / 100), 2)
@api.onchange('list_price', 'standard_price')
def _onchange_price_to_margin(self):
"""Real-time margin recalculation when user changes price or cost."""
for rec in self:
if rec.list_price > 0 and rec.standard_price > 0:
new_margin = round(
((rec.list_price - rec.standard_price) / rec.list_price) * 100, 2)
if abs(rec.x_fi_margin_pct - new_margin) > 0.01:
rec.x_fi_margin_pct = new_margin
elif rec.list_price <= 0:
rec.x_fi_margin_pct = 0.0
@api.onchange('x_fi_margin_pct')
def _onchange_margin_to_price(self):
"""Real-time sale price recalculation when user changes margin."""
for rec in self:
if rec.standard_price > 0 and 0 < rec.x_fi_margin_pct < 100:
new_price = round(
rec.standard_price / (1 - rec.x_fi_margin_pct / 100), 2)
if abs(rec.list_price - new_price) > 0.01:
rec.list_price = new_price
# ────────────────────── Variant Margin Propagation ──────────────────────
def _propagate_margin_to_variants(self):
"""Apply the template margin to variant price offsets (skip overrides)."""
for rec in self:
margin = rec.x_fi_margin_pct or 0.0
if margin <= 0 or margin >= 100:
continue
variants = rec.product_variant_ids.filtered(
lambda v: not v.x_fi_margin_override
and v.standard_price > 0)
for var in variants:
var._apply_margin_to_variant(margin)
def _propagate_shipping_to_variants(self, shipping_cost):
"""Copy the template's shipping cost to all variants."""
for rec in self:
for var in rec.product_variant_ids:
if abs((var.x_fi_shipping_cost or 0.0) - shipping_cost) > 0.001:
var.x_fi_shipping_cost = shipping_cost
def action_apply_margin_to_all_variants(self):
"""Button: apply template margin to every non-overridden variant."""
self.ensure_one()
margin = self.x_fi_margin_pct or 0.0
if margin <= 0 or margin >= 100:
return
variants = self.product_variant_ids.filtered(
lambda v: not v.x_fi_margin_override
and v.standard_price > 0)
for var in variants:
var._apply_margin_to_variant(margin)
skipped = len(self.product_variant_ids.filtered(
lambda v: v.x_fi_margin_override))
msg = f'{len(variants)} variant prices updated to {margin}% margin.'
if skipped:
msg += f' {skipped} overridden variant(s) skipped.'
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Margin Applied',
'message': msg,
'type': 'success',
'sticky': False,
},
}
# ────────────────────── Case Conversion ──────────────────────
@staticmethod
def _apply_case_conversion(name, mode):
if not name or not mode or mode == 'none':
return name
if mode == 'upper':
return name.upper()
if mode == 'lower':
return name.lower()
if mode == 'sentence':
return name[0].upper() + name[1:].lower() if len(name) > 1 else name.upper()
if mode == 'capitalized':
return name.title()
return name
def _get_effective_case_mode(self, vals=None):
global_mode = self.env['ir.config_parameter'].sudo().get_param(
'fusion_inventory.case_conversion', 'none')
if global_mode and global_mode != 'none':
return global_mode
if vals and vals.get('x_fi_case_conversion'):
return vals['x_fi_case_conversion']
if self and self.x_fi_case_conversion:
return self.x_fi_case_conversion
return 'none'
@api.onchange('x_fi_case_conversion')
def _onchange_case_conversion(self):
for rec in self:
mode = rec._get_effective_case_mode()
if mode != 'none' and rec.name:
rec.name = self._apply_case_conversion(rec.name, mode)
@api.model_create_multi
def create(self, vals_list):
global_mode = self.env['ir.config_parameter'].sudo().get_param(
'fusion_inventory.case_conversion', 'none')
for vals in vals_list:
name = vals.get('name', '')
if name:
mode = global_mode if (global_mode and global_mode != 'none') else vals.get('x_fi_case_conversion', 'none')
if mode and mode != 'none':
vals['name'] = self._apply_case_conversion(name, mode)
if global_mode and global_mode != 'none':
vals.setdefault('x_fi_case_conversion', global_mode)
return super().create(vals_list)
def write(self, vals):
res = super().write(vals)
if ('name' in vals or 'x_fi_case_conversion' in vals) and not self.env.context.get('_fi_converting_case'):
for rec in self:
mode = rec._get_effective_case_mode(vals)
if mode != 'none':
converted = self._apply_case_conversion(rec.name, mode)
if converted and converted != rec.name:
rec.with_context(_fi_converting_case=True).write({'name': converted})
if ('x_fi_margin_pct' in vals or 'list_price' in vals) and not self.env.context.get('_fi_propagating'):
self.with_context(_fi_propagating=True)._propagate_margin_to_variants()
if 'x_fi_shipping_cost' in vals:
self._propagate_shipping_to_variants(vals['x_fi_shipping_cost'])
return res
# ────────────────────── Purchase History / Cost Sync ──────────────────────
def action_view_purchase_history(self):
self.ensure_one()
variant_ids = self.product_variant_ids.ids
return {
'type': 'ir.actions.act_window',
'name': f'Purchase History: {self.name}',
'res_model': 'account.move.line',
'view_mode': 'list',
'domain': [
('product_id', 'in', variant_ids),
('move_id.move_type', '=', 'in_invoice'),
('move_id.state', '=', 'posted'),
('price_unit', '>', 0),
],
'context': {'create': False},
'limit': 50,
}
def action_refresh_cost_from_bills(self):
"""Pull the latest non-zero vendor bill price into product cost."""
self._sync_cost_from_latest_bill()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Cost Refreshed',
'message': 'Product cost updated from the latest vendor bill.',
'type': 'success',
'sticky': False,
},
}
def _sync_cost_from_latest_bill(self):
"""For each variant, find the most recent posted vendor bill line
with a non-zero price and set its standard_price."""
AML = self.env['account.move.line']
for rec in self:
for variant in rec.product_variant_ids:
latest = AML.search([
('product_id', '=', variant.id),
('move_id.move_type', '=', 'in_invoice'),
('move_id.state', '=', 'posted'),
('price_unit', '>', 0),
], order='date desc, id desc', limit=1)
if latest and abs(variant.standard_price - latest.price_unit) > 0.001:
old = variant.standard_price
variant.standard_price = latest.price_unit
_logger.info(
'Cost synced for %s: %.2f -> %.2f (bill %s)',
variant.display_name, old, latest.price_unit,
latest.move_id.name)
@api.model
def _cron_sync_all_costs_from_bills(self):
"""Batch job: update every product's cost from latest vendor bill."""
auto_update = self.env['ir.config_parameter'].sudo().get_param(
'fusion_inventory.auto_update_cost', 'True')
if auto_update != 'True':
return
products = self.search([])
products._sync_cost_from_latest_bill()
_logger.info('Batch cost sync complete for %d products.', len(products))