357 lines
15 KiB
Python
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))
|