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 @@
-
-
+
+