diff --git a/models/stock_readjust_valuation.py b/models/stock_readjust_valuation.py index 6be7ffa..efe3bbf 100644 --- a/models/stock_readjust_valuation.py +++ b/models/stock_readjust_valuation.py @@ -1,6 +1,10 @@ from odoo import models, fields, api, _ from odoo.exceptions import UserError from odoo.tools import float_is_zero +from collections import defaultdict +import logging + +_logger = logging.getLogger(__name__) class StockReadjustValuation(models.Model): _name = 'stock.readjust.valuation' @@ -17,6 +21,7 @@ class StockReadjustValuation(models.Model): state = fields.Selection([ ('draft', 'Draft'), ('calculated', 'Calculated'), + ('processing', 'Processing'), ('done', 'Done'), ('cancel', 'Cancelled') ], string='Status', default='draft', tracking=True) @@ -249,6 +254,542 @@ class StockReadjustValuation(models.Model): self.state = 'draft' def action_apply(self): + self.ensure_one() + if self.state not in ('calculated', 'processing'): + if self.state == 'done': + return + if self.state != 'calculated': + raise UserError(_("Please calculate first.")) + + # 0. Set to processing if not already + if self.state != 'processing': + self.state = 'processing' + self.env.cr.commit() + + # Optimization: Pre-fetch data to avoid loops + product_ids = self.line_ids.mapped('product_id').ids + if not product_ids: + return + + all_transactions = [] + cost_map = {l.product_id.id: l.new_average_cost for l in self.line_ids} + + # --- Batch Step 1: Initial Values --- + processed_initial_products = set() + existing_initial_ams = self.account_move_ids.filtered(lambda m: "Readjustment: Initial Value" in (m.ref or "")) + for am in existing_initial_ams: + for line in am.line_ids: + if line.product_id: + processed_initial_products.add(line.product_id.id) + + for line in self.line_ids: + if line.product_id.id in processed_initial_products: + continue + + initial_diff = line.target_initial_value - line.initial_value + if not float_is_zero(initial_diff, precision_digits=2): + self._create_correction_layer(line.product_id, initial_diff, self.date_start, _("Readjustment: Initial Value")) + + line.product_id.sudo().write({'standard_price': line.new_average_cost}) + + # Commit after Step 1 + self.env.cr.commit() + + # --- Batch Step 2: Identify Moves --- + base_domain = [ + ('product_id', 'in', product_ids), + ('create_date', '>=', self.date_start), + ('create_date', '<=', self.date_end), + ('company_id', '=', self.company_id.id) + ] + + # Outgoing: Quantity < 0 + outgoing_moves = self.env['stock.valuation.layer'].search(base_domain + [('quantity', '<', 0)]).mapped('stock_move_id') + + # MFG Incoming: Quantity > 0 and Location Usage = Production + mfg_moves = self.env['stock.valuation.layer'].search(base_domain + [ + ('quantity', '>', 0), + ('stock_move_id.location_id.usage', '=', 'production') + ]).mapped('stock_move_id') + + all_target_moves = outgoing_moves | mfg_moves + + # --- IDEMPOTENCY CHECK --- + processed_move_ids = set() + if self.account_move_ids: + related_svls = self.env['stock.valuation.layer'].search([ + ('account_move_id', 'in', self.account_move_ids.ids) + ]) + processed_move_ids = set(related_svls.mapped('stock_move_id').ids) + + moves_to_process = all_target_moves.filtered(lambda m: m.id not in processed_move_ids) + + if not moves_to_process: + self.state = 'done' + return + + # --- Batch Step 3: Fetch Data for Moves --- + all_layers_for_moves = self.env['stock.valuation.layer'].search([ + ('stock_move_id', 'in', moves_to_process.ids) + ]) + layers_by_move = defaultdict(lambda: self.env['stock.valuation.layer']) + for l in all_layers_for_moves: + layers_by_move[l.stock_move_id.id] |= l + + move_to_am_map = {} + for move in moves_to_process: + am = move.account_move_ids.filtered(lambda m: m.state == 'posted') + if not am: + svls = layers_by_move[move.id] + am = svls.mapped('account_move_id').filtered(lambda m: m.state == 'posted') + if am: + move_to_am_map[move.id] = am[0] + + # --- Batch Step 4: Process Outgoing (COGS) --- + outgoing_to_process = moves_to_process.filtered(lambda m: m in outgoing_moves) + for move in outgoing_to_process: + product_id = move.product_id.id + new_cost = cost_map.get(product_id) + if new_cost is None: continue + + move_layers = layers_by_move[move.id] + qty_sold = abs(sum(move_layers.mapped('quantity'))) + curr_val_abs = abs(sum(move_layers.mapped('value'))) + + target_value = qty_sold * new_cost + diff = target_value - curr_val_abs + + if abs(diff) > 0.01: + original_am = move_to_am_map.get(move.id) + txs = self._prepare_correction_vals(move, -diff, original_am) + all_transactions.extend(txs) + + # --- Batch Step 5: Process Manufacturing (Incoming) --- + mfg_to_process = moves_to_process.filtered(lambda m: m in mfg_moves) + for move in mfg_to_process: + production = move.production_id + if not production: continue + + # Re-Calculate MO Cost + comp_cost = 0 + for raw_move in production.move_raw_ids: + if raw_move.state != 'done': continue + 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 + + all_out_layers = self.env['stock.valuation.layer'].search([('stock_move_id.production_id', '=', production.id)]) + all_output_val = sum(all_out_layers.mapped('value')) + orig_raw_val = sum([abs(m.value) for m in production.move_raw_ids if m.state == 'done']) + + overhead_total = all_output_val - orig_raw_val + new_total_cost = comp_cost + overhead_total + + target_val = 0.0 + if production.qty_produced > 0: + ratio = move.product_uom_qty / production.qty_produced + target_val = new_total_cost * ratio + + current_val = sum(layers_by_move[move.id].mapped('value')) + diff = target_val - current_val + + if abs(diff) > 0.01: + original_am = move_to_am_map.get(move.id) + txs = self._prepare_correction_vals(move, diff, original_am, is_mfg=True) + all_transactions.extend(txs) + + # --- Batch Step 6: Chunked Create & Post --- + if not all_transactions: + self.state = 'done' + return + + chunk_size = 50 + created_moves = self.env['account.move'] + + # We need to process 'all_transactions' in chunks + for i in range(0, len(all_transactions), chunk_size): + chunk__txs = all_transactions[i:i+chunk_size] + + # 1. Create Moves + move_vals_list = [t['move_vals'] for t in chunk__txs] + chunk_moves = self.env['account.move'].create(move_vals_list) + + # 2. Post Moves + chunk_moves.action_post() + + created_moves += chunk_moves + + # 3. Create SVLs + svl_vals_list = [] + for t, am in zip(chunk__txs, chunk_moves): + if t.get('svl_vals'): + vals = t['svl_vals'] + vals['account_move_id'] = am.id + svl_vals_list.append(vals) + + if svl_vals_list: + chunk_svls = self.env['stock.valuation.layer'].create(svl_vals_list) + + # 4b. Force Dates SVL + tx_with_svl = [t for t in chunk__txs if t.get('svl_vals')] + date_map = defaultdict(list) + for svl, t in zip(chunk_svls, tx_with_svl): + date_map[t['date_svl']].append(svl.id) + + for d, ids in date_map.items(): + self._cr.execute("UPDATE stock_valuation_layer SET create_date = %s WHERE id IN %s", (d, tuple(ids))) + + # 4a. Force Dates Moves (Per Chunk) + if chunk_moves: + self._cr.execute("UPDATE account_move SET date = %s WHERE id IN %s", (self.date_end.date(), tuple(chunk_moves.ids))) + + # Link Created Moves to this record (Important for Resume Logic) + self.write({'account_move_ids': [(4, m.id) for m in chunk_moves]}) + + # COMMIT PER CHUNK + self.env.cr.commit() + _logger.info("Stock Readjustment: Processed chunk %s/%s", i + len(chunk__txs), len(all_transactions)) + + self.state = 'done' + + def _prepare_correction_vals(self, move, amount, original_am=False, is_mfg=False): + """ + Returns list of transaction dicts: + [{ + 'move_vals': dict, + 'svl_vals': dict (optional), + 'date_move': date, + 'date_svl': datetime + }] + """ + results = [] + product = move.product_id + + # 1. Accounts + debit_acc = False + credit_acc = False + + if original_am: + accounts = product.product_tmpl_id.get_product_accounts(fiscal_pos=False) + valuation_acc_id = accounts['stock_valuation'].id + + if is_mfg: + # For MFG: Dr Stock (Asset), Cr WIP (Other) + stock_line = original_am.line_ids.filtered(lambda l: l.account_id.id == valuation_acc_id) + wip_line = original_am.line_ids.filtered(lambda l: l.account_id.id != valuation_acc_id) + if stock_line and wip_line: + stock_id = stock_line[0].account_id.id + wip_id = wip_line[0].account_id.id + if amount > 0: # Increase Value + debit_acc, credit_acc = stock_id, wip_id + else: + debit_acc, credit_acc = wip_id, stock_id + else: + # For COGS: Dr Cost, Cr Stock + # If amount (SVL Val Adjust) < 0 (Cost increased): Dr Cost, Cr Stock + # If amount > 0 (Cost decreased): Dr Stock, Cr Cost + + # amount passed here is "-diff" for COGS. + # diff = Target - Current. + # If Target (correct) > Current: diff > 0. + # amount = -diff (< 0). + # SVL Value = -amount = diff (> 0). Asset Increases. + # COGS Decreases? + # Wait. + # If we sold at 100 (Cost=100). Should be 110. + # SVL -100. Should be -110. + # We need key adjustment: -10. + # SVL Value Adjustment: -10. + + # In action_apply logic: + # target = 110. current = 100. + # diff = 10. + # amount passed = -10. + # SVL Value = -(-10) = +10. (This increases inventory value??) + + # Let's re-read action_apply logic carefully. + # target_value = 110. curr_val_abs = 100. + # diff = +10. + # amount passed = -10. + + # SVL.value = -amount = -(-10) = +10. + # If we add +10 to inventory, we INCREASE stock value. + # But expected stock value for outgoing is NEGATIVE (credit). + # -100 + 10 = -90. This REDUCES the cost of goods sold. + # But we wanted cost to be 110 (Higher). + # So we need to add -10 to the layer. + + # In _prepare: + # svl['value'] = -amount. + # We want svl['value'] to be -10. + # So -amount = -10 => amount = 10. + + # Back to action_apply: + # I passed `self._prepare_correction_vals(move, -diff, ...)` + # If diff=10, amount=-10. + # svl['value'] = 10. WRONG. + + # FIX in _prepare or action_apply: + # If I want to make stock value MORE NEGATIVE (Increase Cost), + # I need a negative adjustment. + # New Value (-110) = Old Value (-100) + Adj (-10). + # So passed 'amount' should be such that -amount = -10. + # So amount = 10. + # So I should pass `diff` (which is 10), not `-diff`. + + # Wait, I passed `-diff` in my `action_apply` code. + # Line: `txs = self._prepare_correction_vals(move, -diff, original_am)` + # I should have passed `diff`. + + # I CAN FIX THIS IN `_prepare_correction_vals` by flipping sign for COGS! + # Or strict adherence to args. + # Let's fix semantics here. + # 'amount' arg usually implies "Amount to Adjust". + # If I pass `diff`, then `svl['value']` will be `-diff` = `-10`. Correct. + + # So `action_apply` passed `-diff`. So `svl['value']` becomes `+diff` (+10). Wrong. + # I need to invert logic in `_prepare` correction for COGS if I assume input is `-diff`? + # Or just treat `amount` as "Value Adjustment". + # Let's define: amount = The value to ADD to the SVL. + # `svl_vals['value'] = amount`. + # And `move_amount = abs(amount)`. + # Direction determined by sign. + + # Existing OLD `_create_correction_from_move` had: `svl_vals['value'] = -amount`. + # And `action_apply` calculated `diff` and called it with `diff`. + # Logic: diff=10. call(10). svl=-10. Correct. + + # In my NEW `action_apply`, I passed `-diff`. + # call(-10). + # If I keep `svl['value'] = -amount`: + # svl = -(-10) = 10. Wrong. + + # Strategy: I will change `_prepare` to strictly use `amount` as the SVL Value Adjustment. + # `svl_vals['value'] = amount`. + # And I need to verify what I passed in `action_apply`. + # I passed `-diff` (which is -10). + # So `svl = -10`. Correct. + + # So `svl_vals['value'] = amount` is the way. + pass + + # Let's Implement Correct Logic (ignoring legacy -amount swap overhead if possible) + # But I need to respect what I wrote in `action_apply` which is already saved. + # I passed `-diff` (e.g. -10). + # So I want SVL Value = -10. + # So I set `value: amount`. + + # Accounts Direction: + # If amount < 0 (Stock Value Down / More Negative): + # COGS logic: Value -100 -> -110. (Cost Up). + # Dr COGS, Cr Stock. + # Debit = Expense. Credit = Stock. + # move_amount = 10. + + # If amount > 0 (Stock Value Up / Less Negative): + # Value -100 -> -90. (Cost Down). + # Dr Stock, Cr COGS. + + if not (debit_acc and credit_acc): + accounts = product.product_tmpl_id.get_product_accounts(fiscal_pos=False) + expense = accounts['expense'].id + stock = accounts['stock_output'].id or expense + val = accounts['stock_valuation'].id + + if is_mfg: + debit_acc = val + credit_acc = stock + else: + debit_acc = stock + credit_acc = val + + # Direction Swap based on Amount + # amount is the SVL Value Change. + # If amount < 0: Stock Value Decreases (Credit Stock). Dr Other. + # If amount > 0: Stock Value Increases (Debit Stock). Cr Other. + + # Default assignment (Cost/WIP vs Stock): + # debit_acc = Cost/WIP + # credit_acc = Stock (Valuation) + + move_amount = amount + if move_amount < 0: + # Stock Credit. Dr Cost, Cr Stock. + # Already defaults. + move_amount = abs(move_amount) + else: + # Stock Debit. Dr Stock, Cr Cost. + debit_acc, credit_acc = credit_acc, debit_acc + + # POS Logic for LABEL and Reference + ref_text = f"{self.name} - Adj for {move.name}" + is_pos = False + pos_order_obj = False + pos_session_obj = False + + # Reuse Logic + if move.origin: + pos_orders = self.env['pos.order'].search(['|', ('name', '=', move.origin), ('pos_reference', '=', move.origin)], limit=1) + if pos_orders: + is_pos = True + pos_order_obj = pos_orders[0] + pos_session_obj = pos_order_obj.session_id + if not is_pos: + sessions = self.env['pos.session'].search([('name', '=', move.origin)], limit=1) + if sessions: + is_pos = True + pos_session_obj = sessions[0] + + if not is_pos and move.picking_id: + if 'pos_order_id' in move.picking_id._fields and move.picking_id.pos_order_id: + is_pos = True + pos_order_obj = move.picking_id.pos_order_id + pos_session_obj = pos_order_obj.session_id + if not is_pos and 'pos_session_id' in move.picking_id._fields and move.picking_id.pos_session_id: + is_pos = True + pos_session_obj = move.picking_id.pos_session_id + + if not is_pos and move.picking_id and move.picking_id.origin: + sessions = self.env['pos.session'].search([('name', '=', move.picking_id.origin)], limit=1) + if sessions: + is_pos = True + pos_session_obj = sessions[0] + + if not is_pos and move.picking_id: + pos_orders = self.env['pos.order'].search([('picking_ids', 'in', move.picking_id.id)], limit=1) + if pos_orders: + is_pos = True + # Weak link + pos_session_obj = pos_orders[0].session_id + + if not is_pos: + candidates = [move.origin, move.reference, move.picking_id.name] + for c in candidates: + if c and ('POS' in c or 'Sesi' in c): + is_pos = True + break + + if is_pos: + details = [] + if pos_order_obj: + details.append(pos_order_obj.name) + if pos_session_obj: + details.append(pos_session_obj.name) + + if details: + ref_text = f"{self.name} - POS Adj for {move.name} ({' - '.join(details)})" + else: + ref_text = f"{self.name} - POS Adj for {move.name}" + + # Product Name in Label + label_text = f"{_('Readjustment')} - {product.display_name}" + + move_vals = { + 'journal_id': self.journal_id.id, + 'date': self.date_end.date(), + 'ref': ref_text, + 'move_type': 'entry', + 'line_ids': [ + (0, 0, { + 'name': label_text, + 'account_id': debit_acc, + 'debit': move_amount, + 'credit': 0, + 'product_id': product.id, + }), + (0, 0, { + 'name': label_text, + 'account_id': credit_acc, + 'debit': 0, + 'credit': move_amount, + 'product_id': product.id, + }), + ] + } + + svl_vals = { + 'company_id': self.company_id.id, + 'product_id': product.id, + 'description': label_text, + 'stock_move_id': move.id, + 'quantity': 0, + 'value': amount, # Direct usage of amount + 'account_move_id': False, + } + + results.append({ + 'move_vals': move_vals, + 'svl_vals': svl_vals, + 'date_move': self.date_end.date(), + 'date_svl': move.date + }) + + # Secondary POS Logic + if is_pos: + final_expense_acc = product.product_tmpl_id.get_product_accounts(fiscal_pos=False)['expense'].id + # debit_acc above was the Interim/Cost account if amount < 0. + # If amount < 0 (Cost Up), we debited Cost. + # We want to transfer that Cost to Final Expense. + # Dr Final, Cr Interim. + + # If amount > 0 (Cost Down), we Credited Cost. + # We want to reduce Final Expense. + # Dr Interim, Cr Final. + + # The 'debit_acc' calculated above might be Stock if amount > 0. + # We need the Interim Account specifically. + # Interim is the "Cost/Expense" account in the pair, regardless of direction. + # In my logic block: + # If amount < 0: debit_acc = StockOut/Expense(Interim), credit_acc = Stock. + # If amount > 0: debit_acc = Stock, credit_acc = Interim. + + interim_acc = debit_acc if amount < 0 else credit_acc + + if interim_acc != final_expense_acc: + # Create Secondary Move + + # Direction: + # If amount < 0 (Cost Up): Transfer Cost Increase to Final. Dr Final, Cr Interim. + sec_debit = final_expense_acc + sec_credit = interim_acc + + if amount > 0: + # Cost Down. Transfer Cost Decrease. Cr Final, Dr Interim. + sec_debit = interim_acc + sec_credit = final_expense_acc + + move_vals_2 = { + 'journal_id': self.journal_id.id, + 'date': self.date_end.date(), + 'ref': ref_text, + 'move_type': 'entry', + 'line_ids': [ + (0, 0, { + 'name': label_text, + 'account_id': sec_debit, + 'debit': move_amount, + 'credit': 0, + 'product_id': product.id, + }), + (0, 0, { + 'name': label_text, + 'account_id': sec_credit, + 'debit': 0, + 'credit': move_amount, + 'product_id': product.id, + }), + ] + } + results.append({ + 'move_vals': move_vals_2, + 'svl_vals': False, # No SVL + 'date_move': self.date_end.date(), + 'date_svl': False + }) + + return results + + def action_apply_IGNORED(self): self.ensure_one() if self.state != 'calculated': raise UserError(_("Please calculate first.")) diff --git a/views/stock_readjust_valuation_views.xml b/views/stock_readjust_valuation_views.xml index a0ceec3..11307ad 100644 --- a/views/stock_readjust_valuation_views.xml +++ b/views/stock_readjust_valuation_views.xml @@ -11,8 +11,8 @@