feat: Remove custom unit cost field, historical cost calculation, and consumption propagation logic from backdated inventory lines.

This commit is contained in:
Suherdy Yacob 2026-03-03 10:08:02 +07:00
parent 2f328ff193
commit a4facbbb18
2 changed files with 8 additions and 297 deletions

View File

@ -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}")

View File

@ -65,7 +65,7 @@
decoration-warning="theoretical_qty &lt; 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"/>