from odoo import models, fields, api, _ import logging from odoo.exceptions import UserError from odoo.tools import float_compare, float_is_zero from datetime import timedelta class StockInventoryRevaluation(models.Model): _name = 'stock.inventory.revaluation' _description = 'Stock Inventory Revaluation' _inherit = ['mail.thread', 'mail.activity.mixin'] name = fields.Char(string='Reference', required=True, copy=False, readonly=True, default=lambda self: _('New')) date = fields.Datetime(string='Date', required=True, default=fields.Datetime.now) product_id = fields.Many2one('product.product', string='Product', required=True, domain=[('type', '=', 'product')]) account_journal_id = fields.Many2one('account.journal', string='Journal', required=True) account_id = fields.Many2one('account.account', string='Account', help="Counterpart account for the revaluation") current_value = fields.Float(string='Current Value', compute='_compute_current_value', store=True) quantity = fields.Float(string='Quantity', compute='_compute_current_value', store=True) extra_cost = fields.Float(string='Extra Cost', help="Amount to add to the stock value") state = fields.Selection([ ('draft', 'Draft'), ('done', 'Done'), ('cancel', 'Cancelled') ], string='Status', default='draft', tracking=True) company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company) @api.depends('product_id', 'date') def _compute_current_value(self): for record in self: if record.product_id and record.date: # Calculate quantity and value at the specific date layers = self.env['stock.valuation.layer'].search([ ('product_id', '=', record.product_id.id), ('create_date', '<=', record.date), ('company_id', '=', record.company_id.id) ]) record.quantity = sum(layers.mapped('quantity')) record.current_value = sum(layers.mapped('value')) elif record.product_id: record.quantity = record.product_id.quantity_svl record.current_value = record.product_id.value_svl else: record.quantity = 0.0 record.current_value = 0.0 @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('name', _('New')) == _('New'): vals['name'] = self.env['ir.sequence'].next_by_code('stock.inventory.revaluation') or _('New') return super().create(vals_list) def action_validate(self): self.ensure_one() if float_is_zero(self.extra_cost, precision_rounding=self.currency_id.rounding): raise UserError(_("The Extra Cost cannot be zero.")) # Create Accounting Entry move_vals = self._prepare_account_move_vals() move = self.env['account.move'].with_context(default_date=self.date.date()).create(move_vals) # Check and fix sequence date mismatch if move.name == '/' and not move.posted_before: move._set_next_sequence() if move.name and move.date: move_date = move.date expected_prefix = move_date.strftime('%Y/%m') # If the sequence doesn't contain the expected Year/Month (e.g. 2025/11) # We strictly enforce that 2025/11 is in the name if date is Nov 2025 if expected_prefix not in move.name: journal_id = move.journal_id.id date_start = move_date.replace(day=1) # Calculate end of month next_month = move_date.replace(day=28) + timedelta(days=4) date_end = next_month - timedelta(days=next_month.day) # correct period query last_move = self.env['account.move'].search([ ('journal_id', '=', journal_id), ('name', '!=', '/'), ('date', '>=', date_start), ('date', '<=', date_end), ('company_id', '=', move.company_id.id), ('name', 'like', f"%{expected_prefix}%") ], order='sequence_number desc', limit=1) new_seq = 1 prefix = "" if last_move and last_move.name: # Try to parse the sequence number from the end parts = last_move.name.split('/') if len(parts) >= 2 and parts[-1].isdigit(): new_seq = int(parts[-1]) + 1 prefix = "/".join(parts[:-1]) + "/" else: # Construct prefix from the current (wrong) name but replacing the date part # Assuming format PREFIX/YEAR/MONTH/SEQ parts = move.name.split('/') if len(parts) >= 3: # Attempt to reconstruct: STJ/2025/12/XXXX -> STJ/2025/11/ # We know move_date.year and move_date.month # Let's try to preserve the prefix (index 0) prefix_code = parts[0] prefix = f"{prefix_code}/{move_date.year}/{move_date.month:02d}/" if prefix: new_name = f"{prefix}{new_seq:04d}" move.write({'name': new_name}) move.action_post() # Create Stock Valuation Layer self._create_valuation_layer(move) # 1. Common Logic: Calculate Unit Adjustment & Update Standard Price # This applies to Standard Price, AVCO, and FIFO if self.quantity > 0: unit_adjust = self.extra_cost / self.quantity new_std_price = self.product_id.standard_price + unit_adjust # Update the price on the product # For Standard Price: This sets the new fixed cost. # For AVCO/FIFO: This updates the current running average. self.product_id.with_context(disable_auto_svl=True).sudo().write({'standard_price': new_std_price}) # 2. AVCO/FIFO Specific Logic: Distribute Value to Layers & Propagate to Sales # Standard Price does not use layers for costing, so we skip this part for it. if self.product_id.categ_id.property_cost_method in ['average', 'fifo'] and self.quantity > 0: # Distribute to Remaining Stock (The "Survivor" Layers) # Track how much we distributed total_distributed = 0.0 remaining_svls = self.env['stock.valuation.layer'].search([ ('product_id', '=', self.product_id.id), ('remaining_qty', '>', 0), ('company_id', '=', self.company_id.id), ('create_date', '<=', self.date), ]) remaining_svls = remaining_svls.filtered(lambda l: l.create_date <= self.date) if remaining_svls: for layer in remaining_svls: adjustment_amount = layer.remaining_qty * unit_adjust adjustment_amount = self.currency_id.round(adjustment_amount) if not float_is_zero(adjustment_amount, precision_rounding=self.currency_id.rounding): layer.sudo().write({ 'remaining_value': layer.remaining_value + adjustment_amount, # 'value': layer.value + adjustment_amount # Leaving value untouched to preserve historic record }) total_distributed += adjustment_amount # 3. Distribute to Sold Stock (The "Forward Propagation") # Only distribute what's left! This ensures we don't "correct" sales of NEW stock. remaining_value_to_expense = self.extra_cost - total_distributed remaining_value_to_expense = self.currency_id.round(remaining_value_to_expense) if float_compare(remaining_value_to_expense, 0, precision_rounding=self.currency_id.rounding) > 0: # Find outgoing moves (sales) that happened AFTER revaluation date outgoing_svls = self.env['stock.valuation.layer'].search([ ('product_id', '=', self.product_id.id), ('company_id', '=', self.company_id.id), ('quantity', '<', 0), # Outgoing ('create_date', '>', self.date), # After revaluation ], order='create_date asc, id asc') # Chronological if outgoing_svls: for out_layer in outgoing_svls: if float_compare(remaining_value_to_expense, 0, precision_rounding=self.currency_id.rounding) <= 0: break # Limit reached # How much correction does this move "deserve"? qty_sold = abs(out_layer.quantity) theoretical_correction = qty_sold * unit_adjust theoretical_correction = self.currency_id.round(theoretical_correction) # We can only give what we have left actual_correction = min(theoretical_correction, remaining_value_to_expense) # If positive adjustment if unit_adjust < 0: # If negative adjustment? # If unit_adjust is negative, everything is negative. # extra_cost is neg, total_dist is neg, remaining_to_exp is neg. # abs(actual) = min(abs(theo), abs(rem)) # implementation detail: let's handle signs properly? # Simplify: assume always positive for logic "Cap", but math works. # Wait, min() with negatives works differently. # -100 vs -50. min is -100. We want "Closest to zero". pass # Handle Sign-Agnostic Capping # We want to reduce the magnitude of remaining_to_expense towards zero. # If remaining is +100, we reduce by positive amounts. # If remaining is -100, we reduce by negative amounts. if remaining_value_to_expense > 0: actual_correction = min(theoretical_correction, remaining_value_to_expense) else: # theoretical is likely negative too because unit_adjust is negative actual_correction = max(theoretical_correction, remaining_value_to_expense) if float_is_zero(actual_correction, precision_rounding=self.currency_id.rounding): continue # Create Correction SVL stock_val_acc = self.product_id.categ_id.property_stock_valuation_account_id.id cogs_acc = self.product_id.categ_id.property_stock_account_output_categ_id.id if not stock_val_acc or not cogs_acc: continue # Accounting Entries # If Positive Adjustment (Value Added): # Dr COGS -> Increased Cost # Cr Stock Asset -> Decreased Asset (Since we put it all in Asset initially) # Wait, initially we Debited Asset +100. # Now we say "50 of that was sold". # So we Credit Asset -50, Debit COGS +50. # Correct. # If Negative Adjustment (Value Removed): # Initially Credited Asset -100. # "50 of that removal belongs to sales". # So we Debit Asset +50, Credit COGS -50 (Reduce Cost). # The math: actual_correction is -50. # Using same logic: Debit COGS with -50? (Credit 50). # Credit Asset with -50? (Debit 50). # Logic holds purely with signs. move_lines = [ (0, 0, { 'name': _('Revaluation Correction for %s') % out_layer.stock_move_id.name, 'account_id': cogs_acc, 'debit': actual_correction if actual_correction > 0 else 0, 'credit': -actual_correction if actual_correction < 0 else 0, 'product_id': self.product_id.id, }), (0, 0, { 'name': _('Revaluation Correction for %s') % out_layer.stock_move_id.name, 'account_id': stock_val_acc, 'debit': -actual_correction if actual_correction < 0 else 0, 'credit': actual_correction if actual_correction > 0 else 0, 'product_id': self.product_id.id, }), ] am_vals = { 'ref': f"{self.name} - Corr - {out_layer.stock_move_id.name}", 'date': out_layer.create_date.date(), 'journal_id': self.account_journal_id.id, 'line_ids': move_lines, 'move_type': 'entry', 'company_id': self.company_id.id, } am = self.env['account.move'].create(am_vals) am.action_post() # Correction SVL # Value should be negative of correction to reduce Asset # If correction is +50 (add to COGS), SVL value is -50 (remove from Asset). svl_value = -actual_correction new_svl = self.env['stock.valuation.layer'].create({ 'product_id': self.product_id.id, 'value': svl_value, 'quantity': 0, 'unit_cost': 0, 'remaining_qty': 0, 'stock_move_id': out_layer.stock_move_id.id, 'company_id': self.company_id.id, 'description': _('Revaluation Correction (from %s)') % self.name, 'account_move_id': am.id, }) self.env.cr.execute('UPDATE stock_valuation_layer SET create_date = %s WHERE id = %s', (out_layer.create_date, new_svl.id)) remaining_value_to_expense -= actual_correction self.state = 'done' def _prepare_account_move_vals(self): self.ensure_one() debit_account_id = self.product_id.categ_id.property_stock_valuation_account_id.id # Auto-detect counterpart account if not set credit_account_id = self.account_id.id if not credit_account_id: if self.extra_cost > 0: credit_account_id = self.product_id.categ_id.property_stock_account_input_categ_id.id else: credit_account_id = self.product_id.categ_id.property_stock_account_output_categ_id.id if not debit_account_id: raise UserError(_("Please define the Stock Valuation Account for product category: %s") % self.product_id.categ_id.name) if not credit_account_id: raise UserError(_("Please define the Stock Input/Output Account for product category: %s, or select an Account manually.") % self.product_id.categ_id.name) amount = self.extra_cost name = _('%s - Revaluation') % self.name # If amount is negative, swap accounts/logic or just let debits be negative? # Usually easier to swap or just have positive/negative balance. # Standard: Debit Stock, Credit Counterpart for increase. lines = [ (0, 0, { 'name': name, 'account_id': debit_account_id, 'debit': amount if amount > 0 else 0, 'credit': -amount if amount < 0 else 0, 'product_id': self.product_id.id, }), (0, 0, { 'name': name, 'account_id': credit_account_id, 'debit': -amount if amount < 0 else 0, 'credit': amount if amount > 0 else 0, }), ] return { 'ref': self.name, 'date': self.date.date(), # BACKDATE HERE 'journal_id': self.account_journal_id.id, 'line_ids': lines, 'move_type': 'entry', } def _create_valuation_layer(self, move): self.ensure_one() layer_vals = { 'product_id': self.product_id.id, 'value': self.extra_cost, 'unit_cost': 0, # Not adjusting unit cost directly, just total value 'quantity': 0, 'remaining_qty': 0, 'description': _('Revaluation: %s') % self.name, 'account_move_id': move.id, 'company_id': self.company_id.id, # We try to force the date if the model allows it, but stock.valuation.layer usually takes create_date. # However, for reporting, Odoo joins with account_move. } # Note: stock.valuation.layer 'create_date' is automatic. # But we can try to override it or rely on the account move date for reports. # Standard Odoo valuation reports often rely on the move date. layer = self.env['stock.valuation.layer'].create(layer_vals) # Force backdate the validation layer's create_date to match the revaluation date # This is critical for "Inventory Valuation at Date" reports. self.env.cr.execute('UPDATE stock_valuation_layer SET create_date = %s WHERE id = %s', (self.date, layer.id)) @property def currency_id(self): return self.company_id.currency_id