From a4facbbb181e6d1950d023cb1944615cae3d30b3 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Tue, 3 Mar 2026 10:08:02 +0700 Subject: [PATCH] feat: Remove custom unit cost field, historical cost calculation, and consumption propagation logic from backdated inventory lines. --- models/stock_inventory_backdate.py | 303 +---------------------- views/stock_inventory_backdate_views.xml | 2 +- 2 files changed, 8 insertions(+), 297 deletions(-) diff --git a/models/stock_inventory_backdate.py b/models/stock_inventory_backdate.py index d575a6d..ffc4868 100755 --- a/models/stock_inventory_backdate.py +++ b/models/stock_inventory_backdate.py @@ -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}") diff --git a/views/stock_inventory_backdate_views.xml b/views/stock_inventory_backdate_views.xml index 6559620..cf84f67 100755 --- a/views/stock_inventory_backdate_views.xml +++ b/views/stock_inventory_backdate_views.xml @@ -65,7 +65,7 @@ decoration-warning="theoretical_qty < 0" force_save="1"/> - +