From 693f80e02b474ce34af77e526d939d563322ea69 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Tue, 13 Jan 2026 16:49:16 +0700 Subject: [PATCH] add support for manufactured product also --- __manifest__.py | 2 +- models/stock_readjust_valuation.py | 290 ++++++++++++++++++++++++++++- 2 files changed, 281 insertions(+), 11 deletions(-) diff --git a/__manifest__.py b/__manifest__.py index 873600f..903ed0a 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -9,7 +9,7 @@ redistributing the cost to COGS and Inventory. """, 'author': 'Suherdy Yacob', - 'depends': ['stock_account', 'account'], + 'depends': ['stock_account', 'account', 'mrp'], 'data': [ 'security/ir.model.access.csv', 'data/ir_sequence_data.xml', diff --git a/models/stock_readjust_valuation.py b/models/stock_readjust_valuation.py index 20ce5d4..f3050d8 100644 --- a/models/stock_readjust_valuation.py +++ b/models/stock_readjust_valuation.py @@ -103,9 +103,12 @@ class StockReadjustValuation(models.Model): # Re-run initial stock just in case dates changed self._compute_initial_stock() + # 1. Reset & Load Base Vendor Data + # We separate Vendor (Immutable) from Mfg (Mutable) + vendor_data = {} # {product_id: (qty, value)} + for line in self.line_ids: - # 2. Purchases (Start <= Date <= End) - # Find incoming layers + # Vendor Incoming Layers incoming_layers = self.env['stock.valuation.layer'].search([ ('product_id', '=', line.product_id.id), ('create_date', '>=', self.date_start), @@ -114,19 +117,131 @@ class StockReadjustValuation(models.Model): ('stock_move_id.location_id.usage', '=', 'supplier'), # From Vendor ('company_id', '=', self.company_id.id) ]) + v_qty = sum(incoming_layers.mapped('quantity')) + v_val = sum(incoming_layers.mapped('value')) + vendor_data[line.product_id.id] = (v_qty, v_val) - line.purchase_qty = sum(incoming_layers.mapped('quantity')) - line.purchase_value = sum(incoming_layers.mapped('value')) - - # 3. Calculate Weighted Average - total_qty = line.qty_counted + line.purchase_qty - total_value = line.target_initial_value + line.purchase_value + # Initialize with Vendor Data only first + line.purchase_qty = v_qty + line.purchase_value = v_val + # Initial Average calc + total_qty = line.qty_counted + v_qty + total_value = line.target_initial_value + v_val if not float_is_zero(total_qty, precision_rounding=line.product_id.uom_id.rounding): line.new_average_cost = total_value / total_qty else: - line.new_average_cost = 0.0 - + line.new_average_cost = line.product_id.standard_price + + # 2. Iterative Cost Rollup (for Manufacturing) + # We loop to allow lower-level component cost changes to propagate to higher-level FGs + # 3 iterations should be sufficient for most chains in a retrospective batch + for _ in range(3): + # Create a localized cost map for fast lookup + # {product_id: new_cost} + cost_map = {l.product_id.id: l.new_average_cost for l in self.line_ids} + + changes_made = False + + for line in self.line_ids: + # Find Incoming Mfg Layers (Production -> Stock) + mfg_layers = self.env['stock.valuation.layer'].search([ + ('product_id', '=', line.product_id.id), + ('create_date', '>=', self.date_start), + ('create_date', '<=', self.date_end), + ('quantity', '>', 0), + ('stock_move_id.location_id.usage', '=', 'production'), # From Production + ('company_id', '=', self.company_id.id) + ]) + + if not mfg_layers: + continue + + # Calculate New Value for these MOs + # We need to look at the MOs linked to these moves + moves = mfg_layers.mapped('stock_move_id') + production_ids = moves.mapped('production_id') + + total_mfg_qty = 0 + total_mfg_value = 0 + + for production in production_ids: + # Calculate Cost of this specific MO + # Cost = Components + Operations + + # 1. Components + comp_cost = 0 + for raw_move in production.move_raw_ids.filtered(lambda m: m.state == 'done'): + # Use NEW cost if available, else Standard Price + p_id = raw_move.product_id.id + price = cost_map.get(p_id, raw_move.product_id.standard_price) + comp_cost += raw_move.product_uom_qty * price + + # 2. Operations (Workcenters) - Assume standard logic didn't change (immutable for now) + # We can fetch the 'value' from the original valuation layer? + # No, we must recalculate or look at existing cost share. + # Simplified: We assume Operation Cost is preserved from original recording? + # Or simpler: We don't touch Op Cost, we only readjust Material Cost. + # But how to get "Original Op Cost" easily? + # We can diff the Original Layer Value vs Original Component Value? + # That's complicated. + # Alternative: We just re-sum everything? + # Let's check if we can get operation cost. + # Usually stored in analytic lines or just captured in the layer? + # For V17, stock.valuation.layer has the total value. + # If we simply recalculate material, we might lose overheads if we don't include them. + # SAFE BET: + # Get Original Unit Cost of MO = Layer Value / Layer Qty. + # Get Original Material Cost. + # Difference = Overhead. + # New Cost = New Material + Overhead. + + # Let's try to preserve Overhead. + + # Original Layer for this MO (Output) + orig_output_layers = self.env['stock.valuation.layer'].search([('stock_move_id.production_id', '=', production.id), ('quantity', '>', 0)]) + orig_val = sum(orig_output_layers.mapped('value')) + orig_qty = sum(orig_output_layers.mapped('quantity')) + + if orig_qty == 0: continue + + # Original Material Cost (from Moves) + # We sum the ABSOLUTE value of raw moves + orig_raw_val = sum([abs(m.value) for m in production.move_raw_ids if m.state == 'done']) + + overhead_val = orig_val - orig_raw_val + + # New Material Cost + # Calculated above as `comp_cost` + + new_mo_total_value = comp_cost + overhead_val + + # Add to totals + total_mfg_qty += orig_qty + total_mfg_value += new_mo_total_value + + # Update Line + v_qty, v_val = vendor_data.get(line.product_id.id, (0, 0)) + + line.purchase_qty = v_qty + total_mfg_qty + line.purchase_value = v_val + total_mfg_value + + # Recalculate Average + total_qty = line.qty_counted + line.purchase_qty + total_value = line.target_initial_value + line.purchase_value + + prev_avg = line.new_average_cost + if not float_is_zero(total_qty, precision_rounding=line.product_id.uom_id.rounding): + line.new_average_cost = total_value / total_qty + else: + line.new_average_cost = 0.0 + + if abs(line.new_average_cost - prev_avg) > 0.01: + changes_made = True + + if not changes_made: + break + self.state = 'calculated' def action_reset_to_draft(self): @@ -200,6 +315,161 @@ class StockReadjustValuation(models.Model): if not float_is_zero(diff, precision_digits=2): self._create_correction_from_move(move, diff) + # Global Cost Map (Product ID -> New Cost) + # This map contains the new_average_cost for all products in the batch. + cost_map = {l.product_id.id: l.new_average_cost for l in self.line_ids} + + for line in self.line_ids: + # 4. Readjust Manufacturing Output (Incoming MOs) + # We need to correct the value of the Finished Good entering stock + # based on the new cost of components. + + mfg_layers = self.env['stock.valuation.layer'].search([ + ('product_id', '=', line.product_id.id), + ('create_date', '>=', self.date_start), + ('create_date', '<=', self.date_end), + ('quantity', '>', 0), # Incoming + ('stock_move_id.location_id.usage', '=', 'production'), # From Production + ('company_id', '=', self.company_id.id) + ]) + + mfg_moves = mfg_layers.mapped('stock_move_id') + + for move in mfg_moves: + production = move.production_id + if not production: continue + + # Re-Calculate MO Cost (Components New Cost + Overhead) + comp_cost = 0 + for raw_move in production.move_raw_ids.filtered(lambda m: m.state == 'done'): + p_id = raw_move.product_id.id + # Use NEW cost from cost_map if available, else Standard Price + price = cost_map.get(p_id, raw_move.product_id.standard_price) + comp_cost += raw_move.product_uom_qty * price + + # Overhead Preservation + # Get total original output value for the entire MO + all_output_layers_for_mo = self.env['stock.valuation.layer'].search([('stock_move_id.production_id', '=', production.id)]) + all_output_val_for_mo = sum(all_output_layers_for_mo.mapped('value')) + + # Original Material Cost (from Raw Moves for the entire MO) + orig_raw_val_for_mo = sum([abs(m.value) for m in production.move_raw_ids if m.state == 'done']) + + overhead_total = all_output_val_for_mo - orig_raw_val_for_mo + + # New Total Cost for the entire MO + new_total_cost_for_mo = comp_cost + overhead_total + + # Target Value for THIS specific move (proportion of total MO cost) + target_value = 0.0 + if production.qty_produced > 0: + ratio = move.product_uom_qty / production.qty_produced + target_value = new_total_cost_for_mo * ratio + + # Current Value of THIS move (sum of all layers for this move) + current_val = sum(self.env['stock.valuation.layer'].search([('stock_move_id', '=', move.id)]).mapped('value')) + + diff = target_value - current_val + + if not float_is_zero(diff, precision_digits=2): + # Create Correction + # Original Move: Dr Stock, Cr WIP + # Find accounts from original move's account_move_ids + + # We need to find the account move linked to the original stock move + original_am = move.account_move_ids.filtered(lambda am: am.state == 'posted') + if not original_am: + # Fallback: try to find an SVL for this move that has an account move + original_svl_with_am = self.env['stock.valuation.layer'].search([ + ('stock_move_id', '=', move.id), + ('account_move_id', '!=', False) + ], limit=1) + if original_svl_with_am: + original_am = original_svl_with_am.account_move_id + + if not original_am: + _logger.warning("Could not find original account move for stock move %s to determine accounts for MO adjustment.", move.name) + continue # Skip if can't find accounts + + am_lines = original_am.line_ids + + # Find Stock Account (usually debit for incoming) + stock_acc_id = line.product_id.categ_id.property_stock_valuation_account_id.id + stock_line = am_lines.filtered(lambda l: l.account_id.id == stock_acc_id) + + # Find WIP Account (usually credit for incoming) + wip_acc_id = False + # Try to find the credit line that is not the stock account + wip_line = am_lines.filtered(lambda l: l.credit > 0 and l.account_id.id != stock_acc_id) + if wip_line: + wip_acc_id = wip_line[0].account_id.id + else: + # Fallback: try to find the stock production location's valuation_out_account_id + prod_location = self.env['stock.location'].search([('usage', '=', 'production')], limit=1) + if prod_location and prod_location.valuation_out_account_id: + wip_acc_id = prod_location.valuation_out_account_id.id + + if not (stock_acc_id and wip_acc_id): + _logger.warning("Could not determine stock or WIP accounts for MO adjustment for move %s.", move.name) + continue # Skip if accounts not found + + # Construct Account Move + # If diff > 0 (Value Up): Dr Stock, Cr WIP + debit_acc = stock_acc_id + credit_acc = wip_acc_id + amt = diff + + if diff < 0: + # If diff < 0 (Value Down): Dr WIP, Cr Stock + debit_acc = wip_acc_id + credit_acc = stock_acc_id + amt = abs(diff) + + description = _("Readjustment MO: %s") % production.name + + move_vals = { + 'journal_id': self.journal_id.id, + 'date': self.date_end.date(), + 'ref': f"{self.name} - {description}", + 'move_type': 'entry', + 'line_ids': [ + (0, 0, { + 'name': description, + 'account_id': debit_acc, + 'debit': amt, + 'credit': 0, + 'product_id': line.product_id.id, + }), + (0, 0, { + 'name': description, + 'account_id': credit_acc, + 'debit': 0, + 'credit': amt, + 'product_id': line.product_id.id, + }), + ] + } + + ac_move = self.env['account.move'].create(move_vals) + ac_move.action_post() + self.account_move_ids = [(4, ac_move.id)] + + # Create SVL + svl_vals = { + 'company_id': self.company_id.id, + 'product_id': line.product_id.id, + 'description': description, + 'stock_move_id': move.id, + 'quantity': 0, + 'value': diff, # Value is positive for increase, negative for decrease + 'account_move_id': ac_move.id, + } + new_svl = self.env['stock.valuation.layer'].create(svl_vals) + + # Force Date + self.env.cr.execute('UPDATE stock_valuation_layer SET create_date = %s WHERE id = %s', (move.date, new_svl.id)) + self.env.cr.execute('UPDATE account_move SET date = %s WHERE id = %s', (self.date_end.date(), ac_move.id)) + self.state = 'done' def _create_correction_from_move(self, move, amount):