363 lines
19 KiB
Python
Executable File
363 lines
19 KiB
Python
Executable File
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
|