# -*- 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))