changes
This commit is contained in:
356
fusion_inventory/models/product_template.py
Normal file
356
fusion_inventory/models/product_template.py
Normal file
@@ -0,0 +1,356 @@
|
||||
# -*- 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))
|
||||
Reference in New Issue
Block a user