feat: Remove custom unit cost field, historical cost calculation, and consumption propagation logic from backdated inventory lines.
This commit is contained in:
parent
2f328ff193
commit
a4facbbb18
@ -150,52 +150,7 @@ class StockInventoryBackdate(models.Model):
|
||||
|
||||
return qty_in - qty_out
|
||||
|
||||
def _get_historical_cost(self, product, date, company_id):
|
||||
"""
|
||||
Calculate historical unit cost at a specific date.
|
||||
- Standard Price: Returns current standard price.
|
||||
- AVCO/FIFO: Calculates historical weighted average.
|
||||
"""
|
||||
cost_method = product.categ_id.property_cost_method
|
||||
|
||||
if cost_method == 'standard':
|
||||
return product.standard_price
|
||||
|
||||
# Optimized Key: Use SQL for aggregation instead of loading all objects
|
||||
sql = """
|
||||
SELECT SUM(quantity), SUM(value)
|
||||
FROM stock_valuation_layer
|
||||
WHERE product_id = %s
|
||||
AND create_date <= %s
|
||||
AND company_id = %s
|
||||
"""
|
||||
self.env.cr.execute(sql, (product.id, date, company_id.id))
|
||||
result = self.env.cr.fetchone()
|
||||
|
||||
quantity = result[0] or 0.0
|
||||
value = result[1] or 0.0
|
||||
|
||||
if quantity != 0:
|
||||
return value / quantity
|
||||
|
||||
# Fallback: Try to find the last valid unit_cost from an incoming layer
|
||||
# This helps if current stock is 0 but we want the 'last known cost'
|
||||
last_in_sql = """
|
||||
SELECT value, quantity
|
||||
FROM stock_valuation_layer
|
||||
WHERE product_id = %s
|
||||
AND create_date <= %s
|
||||
AND company_id = %s
|
||||
AND quantity > 0
|
||||
ORDER BY create_date DESC, id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
self.env.cr.execute(last_in_sql, (product.id, date, company_id.id))
|
||||
last_in = self.env.cr.fetchone()
|
||||
if last_in and last_in[1] != 0:
|
||||
return last_in[0] / last_in[1]
|
||||
|
||||
return product.standard_price
|
||||
|
||||
def action_validate(self):
|
||||
"""Validate and create backdated stock moves"""
|
||||
@ -268,11 +223,7 @@ class StockInventoryBackdateLine(models.Model):
|
||||
default=0.0,
|
||||
help="Positive value adds stock, negative value removes stock."
|
||||
)
|
||||
unit_cost = fields.Float(
|
||||
string='Unit Cost',
|
||||
digits='Product Price',
|
||||
help="Custom unit cost for this backdated adjustment. If left 0, it may use standard price."
|
||||
)
|
||||
|
||||
product_uom_id = fields.Many2one(
|
||||
'uom.uom',
|
||||
string='Unit of Measure',
|
||||
@ -317,17 +268,7 @@ class StockInventoryBackdateLine(models.Model):
|
||||
if not self.counted_qty and not self.difference_qty:
|
||||
self.counted_qty = self.theoretical_qty
|
||||
self.difference_qty = 0.0
|
||||
|
||||
# Calculate historical unit cost
|
||||
if self.inventory_id.backdate_datetime:
|
||||
limit_date = self.inventory_id.backdate_datetime
|
||||
self.unit_cost = self.inventory_id._get_historical_cost(
|
||||
self.product_id,
|
||||
limit_date,
|
||||
self.inventory_id.company_id
|
||||
)
|
||||
else:
|
||||
self.unit_cost = self.product_id.standard_price
|
||||
|
||||
|
||||
def _create_stock_move(self):
|
||||
"""Create backdated stock move for this line"""
|
||||
@ -453,76 +394,11 @@ class StockInventoryBackdateLine(models.Model):
|
||||
_logger.info(f"Found stock valuation layer for move {move.id}: {svl}")
|
||||
|
||||
if svl:
|
||||
new_val_layer_vals = {}
|
||||
# Update Date
|
||||
new_val_layer_vals['create_date'] = backdate
|
||||
|
||||
# --- COST ADJUSTMENT LOGIC ---
|
||||
# If unit_cost is provided, we override the value on the layer
|
||||
# and potentially update the account move
|
||||
if self.unit_cost and self.unit_cost > 0:
|
||||
original_value = svl.value
|
||||
new_value = self.unit_cost * qty
|
||||
if self.difference_qty < 0:
|
||||
new_value = -abs(new_value) # Ensure negative if qty is negative
|
||||
|
||||
if abs(new_value - original_value) > 0.01:
|
||||
_logger.info(f"Overriding SVL Value from {original_value} to {new_value} (Unit Cost: {self.unit_cost})")
|
||||
|
||||
# Update SVL via SQL to avoid constraint issues or re-triggering logic
|
||||
# Also need to update remaining_value if applicable?
|
||||
# For incoming (positive diff), remaining_value should match value.
|
||||
# For outgoing, usually remaining_value is 0 on the layer itself (it consumes others).
|
||||
|
||||
update_sql = "UPDATE stock_valuation_layer SET create_date = %s, value = %s, unit_cost = %s"
|
||||
params = [backdate, new_value, self.unit_cost]
|
||||
|
||||
if self.difference_qty > 0:
|
||||
# Incoming: update remaining_value too
|
||||
update_sql += ", remaining_value = %s"
|
||||
params.append(new_value)
|
||||
|
||||
update_sql += " WHERE id = %s"
|
||||
params.append(svl.id)
|
||||
|
||||
self.env.cr.execute(update_sql, tuple(params))
|
||||
|
||||
# Check Account Move and update amount
|
||||
if move.account_move_ids:
|
||||
for am in move.account_move_ids:
|
||||
# Update lines
|
||||
# We need to find which line is Debit/Credit and scale them?
|
||||
# Or just assuming 2 lines, simpler to match the total?
|
||||
# Let's check amounts.
|
||||
|
||||
# Usually Inventory Adjustment creates:
|
||||
# Dr Stock, Cr Inventory Adjustment (for Gain)
|
||||
# Dr Inventory Adjustment, Cr Stock (for Loss)
|
||||
|
||||
# We just need to replace the absolute amount on all lines that matched the old absolute amount?
|
||||
# Risky if multiple lines.
|
||||
|
||||
# Better: Iterate lines. If line amount matches old abs(value), update to new abs(value).
|
||||
for line in am.line_ids:
|
||||
if abs(line.debit - abs(original_value)) < 0.01:
|
||||
self.env.cr.execute("UPDATE account_move_line SET debit = %s WHERE id = %s", (abs(new_value), line.id))
|
||||
if abs(line.credit - abs(original_value)) < 0.01:
|
||||
self.env.cr.execute("UPDATE account_move_line SET credit = %s WHERE id = %s", (abs(new_value), line.id))
|
||||
|
||||
_logger.info(f"Updated account_move {am.id} amounts to match new value {new_value}")
|
||||
|
||||
else:
|
||||
# just update date
|
||||
self.env.cr.execute(
|
||||
"UPDATE stock_valuation_layer SET create_date = %s WHERE id = %s",
|
||||
(backdate, svl.id)
|
||||
)
|
||||
else:
|
||||
# No custom cost, just update date
|
||||
self.env.cr.execute(
|
||||
"UPDATE stock_valuation_layer SET create_date = %s WHERE id = %s",
|
||||
(backdate, svl.id)
|
||||
)
|
||||
# just update date
|
||||
self.env.cr.execute(
|
||||
"UPDATE stock_valuation_layer SET create_date = %s WHERE id = %s",
|
||||
(backdate, svl.id)
|
||||
)
|
||||
|
||||
_logger.info(f"Updated stock_valuation_layer for move {move.id}")
|
||||
else:
|
||||
@ -550,169 +426,4 @@ class StockInventoryBackdateLine(models.Model):
|
||||
# Invalidate cache
|
||||
self.env.cache.invalidate()
|
||||
|
||||
# Ghost Value Fix: Propagate consumption to subsequent sales
|
||||
# Note: We pass the backdate because move.date might still be cached as 'now' until full reload
|
||||
self._propagate_consumption(move, backdate)
|
||||
|
||||
return move
|
||||
|
||||
def _propagate_consumption(self, move, backdate_datetime):
|
||||
"""
|
||||
Ghost Value Fix:
|
||||
Simulate 'FIFO' consumption for the newly backdated layer against subsequent outgoing moves.
|
||||
If we retroactively add stock to Jan 1, but we sold stock on Jan 2,
|
||||
that sale should have consumed this (cheaper/older) stock depending on FIFO/AVCO.
|
||||
|
||||
This logic creates 'Correction SVLs' to expense the value of the backdated layer
|
||||
that corresponds to quantities subsequently sold.
|
||||
"""
|
||||
if move.product_id.categ_id.property_cost_method not in ['average', 'fifo']:
|
||||
return
|
||||
|
||||
# 1. Identify the new Layer
|
||||
new_svl = self.env['stock.valuation.layer'].search([
|
||||
('stock_move_id', '=', move.id)
|
||||
], limit=1)
|
||||
|
||||
if not new_svl:
|
||||
return
|
||||
|
||||
# Check if we have quantity to propagate
|
||||
# We generally only propagate INCOMING adjustments (Positive Qty) that have remaining qty
|
||||
# But for robustness (and user request), we ensure float comparison
|
||||
if float_compare(new_svl.quantity, 0, precision_rounding=move.product_uom.rounding) <= 0:
|
||||
return
|
||||
|
||||
if float_compare(new_svl.remaining_qty, 0, precision_rounding=move.product_uom.rounding) <= 0:
|
||||
return
|
||||
|
||||
# 2. Find Subsequent Outgoing Moves (The "Missed" Sales)
|
||||
# We look for outgoing SVLs (qty < 0) created AFTER the backdate
|
||||
outgoing_svls = self.env['stock.valuation.layer'].search([
|
||||
('product_id', '=', move.product_id.id),
|
||||
('company_id', '=', move.company_id.id),
|
||||
('quantity', '<', 0), # Outgoing
|
||||
('create_date', '>', backdate_datetime),
|
||||
], order='create_date asc, id asc')
|
||||
|
||||
if not outgoing_svls:
|
||||
return
|
||||
|
||||
# 3. Consumption Loop
|
||||
qty_to_consume_total = 0.0
|
||||
|
||||
for out_layer in outgoing_svls:
|
||||
if new_svl.remaining_qty <= 0:
|
||||
break
|
||||
|
||||
# How much can this sale consume from our new layer?
|
||||
# It can consume whole sale qty, limited by what we have in the new layer.
|
||||
sale_qty = abs(out_layer.quantity)
|
||||
|
||||
# Logic: In strict FIFO, this sale `out_layer` MIGHT have already consumed
|
||||
# some OLDER layer if it wasn't empty.
|
||||
# But the whole point of "Ghost Value" is that we assume the user wanted this Backdated Stock
|
||||
# to be available "Back Then".
|
||||
# So effectively, we are injecting this layer into the past.
|
||||
# Ideally, we should re-run the WHOLE fifo queue. That's too risky/complex.
|
||||
# Approximate Logic: "Any subsequent sale is a candidate to consume this 'unexpected' old stock".
|
||||
|
||||
consume_qty = min(new_svl.remaining_qty, sale_qty)
|
||||
|
||||
if consume_qty <= 0:
|
||||
continue
|
||||
|
||||
# Calculate Value to Expense
|
||||
# We expense based on OUR layer's unit cost (since we are consuming OUR layer)
|
||||
unit_val = new_svl.value / new_svl.quantity if new_svl.quantity else 0
|
||||
expense_value = consume_qty * unit_val
|
||||
|
||||
# Rounding
|
||||
expense_value = move.company_id.currency_id.round(expense_value)
|
||||
|
||||
if float_is_zero(expense_value, precision_rounding=move.company_id.currency_id.rounding):
|
||||
continue
|
||||
|
||||
# --- ACTION: REDUCE OUR LAYER ---
|
||||
# We update SQL directly to avoid ORM side effects / recomputes
|
||||
# Reduce remaining_qty and remaining_value
|
||||
# Note: We need to fetch latest state of new_svl inside loop if we modify it?
|
||||
# Yes, new_svl.remaining_qty is simple float in memory, we update it manually here to track loop.
|
||||
|
||||
new_svl.remaining_qty -= consume_qty
|
||||
new_svl.remaining_value -= expense_value
|
||||
|
||||
# Commit this reduction to DB immediately so it sticks
|
||||
self.env.cr.execute(
|
||||
"UPDATE stock_valuation_layer SET remaining_qty = remaining_qty - %s, remaining_value = remaining_value - %s WHERE id = %s",
|
||||
(consume_qty, expense_value, new_svl.id)
|
||||
)
|
||||
|
||||
# --- ACTION: CREATE Expense Entry (Correction) ---
|
||||
# Credit Asset (we just reduced remaining_value, effectively saying "It's gone")
|
||||
# Debit COGS (Expense it)
|
||||
|
||||
stock_val_acc = move.product_id.categ_id.property_stock_valuation_account_id.id
|
||||
cogs_acc = move.product_id.categ_id.property_stock_account_output_categ_id.id
|
||||
|
||||
if not stock_val_acc or not cogs_acc:
|
||||
continue
|
||||
|
||||
move_lines = [
|
||||
(0, 0, {
|
||||
'name': _('Backdate Correction for %s') % out_layer.stock_move_id.name,
|
||||
'account_id': cogs_acc,
|
||||
'debit': expense_value,
|
||||
'credit': 0,
|
||||
'product_id': move.product_id.id,
|
||||
}),
|
||||
(0, 0, {
|
||||
'name': _('Backdate Correction for %s') % out_layer.stock_move_id.name,
|
||||
'account_id': stock_val_acc,
|
||||
'debit': 0,
|
||||
'credit': expense_value,
|
||||
'product_id': move.product_id.id,
|
||||
}),
|
||||
]
|
||||
|
||||
am_vals = {
|
||||
'ref': f"{move.name} - Consumed by {out_layer.stock_move_id.name}",
|
||||
'date': out_layer.create_date.date(), # Match the SALE date
|
||||
'journal_id': move.account_move_ids[0].journal_id.id if move.account_move_ids else False,
|
||||
# Use same journal as original move adjustment? Or Stock Journal?
|
||||
# Generally Stock Journal.
|
||||
'line_ids': move_lines,
|
||||
'move_type': 'entry',
|
||||
'company_id': move.company_id.id,
|
||||
}
|
||||
# Fallback journal if not found
|
||||
if not am_vals['journal_id']:
|
||||
am_vals['journal_id'] = move.product_id.categ_id.property_stock_journal.id
|
||||
|
||||
am = self.env['account.move'].create(am_vals)
|
||||
am.action_post()
|
||||
|
||||
# Create Correction SVL
|
||||
# Value is negative (Reducing Asset)
|
||||
self.env['stock.valuation.layer'].create({
|
||||
'product_id': move.product_id.id,
|
||||
'value': -expense_value,
|
||||
'quantity': 0,
|
||||
'unit_cost': 0,
|
||||
'remaining_qty': 0,
|
||||
'stock_move_id': out_layer.stock_move_id.id, # Link to sale move
|
||||
'company_id': move.company_id.id,
|
||||
'description': _('Backdate Correction (from %s)') % move.name,
|
||||
'account_move_id': am.id,
|
||||
})
|
||||
# Backdate this correction SVL to match sale date
|
||||
# We don't have the ID easily here as create returns record but separate from SQL.
|
||||
# But standard create sets create_date=Now.
|
||||
# We want it to look like it happened AT SALE TIME.
|
||||
# We can find it via account_move_id
|
||||
self.env.cr.execute(
|
||||
"UPDATE stock_valuation_layer SET create_date = %s WHERE account_move_id = %s",
|
||||
(out_layer.create_date, am.id)
|
||||
)
|
||||
|
||||
_logger.info(f"Propagated consumption: Consumed {consume_qty} from Backdate Layer for Sale {out_layer.stock_move_id.name}")
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
decoration-warning="theoretical_qty < 0"
|
||||
force_save="1"/>
|
||||
<field name="counted_qty"/>
|
||||
<field name="unit_cost" optional="show" required="1"/>
|
||||
|
||||
<field name="difference_qty" sum="Total Adjustment"/>
|
||||
<field name="product_uom_id" string="UoM" groups="uom.group_uom"/>
|
||||
<field name="has_negative_theoretical" column_invisible="1"/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user