import sys import os import time # Try to import odoo, if not found, assume we are in odoo-bin shell or need path try: import odoo from odoo import api, SUPERUSER_ID from odoo.tools import config except ImportError as e: # Check if due to missing dependency like passlib if "No module named" in str(e): print("!" * 80) print(f"ERROR: Missing dependency: {e}") print("Please run this script using the Python executable that runs Odoo.") print("Example: /path/to/venv/bin/python move_stock_remote.py") print("!" * 80) sys.exit(1) # Fallback: assume running from odoo root (where odoo/ folder is generic repo structure) current_dir = os.path.dirname(os.path.abspath(__file__)) # If we are in root and 'odoo' folder exists which contains the package strings # We might need to add 'odoo' (the repo root) to path? # No, typically 'odoo' package is in 'odoo/' subdir of repo. # So we need to add .../odoo19/odoo to sys.path to find odoo package. # Try adding current_dir/odoo to path if os.path.exists(os.path.join(current_dir, 'odoo')): sys.path.append(os.path.join(current_dir, 'odoo')) else: sys.path.append(current_dir) import odoo from odoo import api, SUPERUSER_ID from odoo.tools import config def move_stock(): print("Starting stock relocation script...") import argparse parser = argparse.ArgumentParser(description='Move stock remotely.') parser.add_argument('db_name', help='Database name') parser.add_argument('-c', '--config', help='Path to odoo.conf', default=None) # We use parse_known_args in case Odoo tries to parse something? # No, we just need our args. args = parser.parse_args() db_name = args.db_name # Load config if provided or default exists conf_path = args.config if conf_path: if os.path.exists(conf_path): config.parse_config(['-c', conf_path]) else: print(f"WARNING: Config file '{conf_path}' not found.") elif os.path.exists('odoo.conf'): config.parse_config(['-c', 'odoo.conf']) print(f"Connecting to database: {db_name}") try: registry = odoo.registry(db_name) except AttributeError: from odoo.modules.registry import Registry registry = Registry.new(db_name) with registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) # 1. Define Move Configurations # Format: [{'sources': ['Src1', 'Src2'], 'dest': 'Dest'}, ...] move_configs = [ { 'sources': ['WHBK/Pre-Production', 'WHBK/Post-Production'], 'dest': 'WHBK/Stock/Preparation' }, { 'sources': ['WHBK/Output'], 'dest': 'WHBK/Stock/FG' }, { 'sources': [9], # WHBK/Input (Archived) 'dest': 'WHBK/Stock/FG' } ] for move_config in move_configs: dest_name = move_config['dest'] source_idents = move_config['sources'] print(f"\n=== Processing Move to {dest_name} ===") dest_loc = env['stock.location'].search([('complete_name', '=', dest_name)], limit=1) if not dest_loc: print(f"ERROR: Destination location '{dest_name}' not found. Skipping.") continue print(f"Destination: {dest_loc.complete_name}") for source_ident in source_idents: print(f"\nProcessing Source: {source_ident}") source_loc = False if isinstance(source_ident, int): source_loc = env['stock.location'].browse(source_ident) if not source_loc.exists(): print(f" WARNING: Source location ID {source_ident} not found.") source_loc = False else: print(f" Mapped Source ID {source_ident} to: {source_loc.display_name}") else: source_loc = env['stock.location'].search([('complete_name', '=', source_ident)], limit=1) if not source_loc: print(f" WARNING: Source location '{source_ident}' not found. Skipping.") continue # 2. Find Quants # We filter for positive quantities OR reserved quantities > 0 # We search for quants in the source location. quants = env['stock.quant'].search([ ('location_id', '=', source_loc.id) ]) # 2a. Valid Quants (Positive Quantity) -> Move (which unreserves) valid_quants = quants.filtered(lambda q: q.quantity > 0) if valid_quants: print(f" Found {len(valid_quants)} valid quants to move. Total Qty: {sum(valid_quants.mapped('quantity'))}") try: # Separate Storable vs Consumable # Odoo moves for Consumables might not reduce the source quant. storable = valid_quants.filtered(lambda q: q.product_id.is_storable) consumable = valid_quants.filtered(lambda q: not q.product_id.is_storable) if storable: storable.move_quants(location_dest_id=dest_loc, message='Bulk Relocation via Script') print(f" SUCCESS: Moved {len(storable)} storable quants.") if consumable: print(f" Found {len(consumable)} CONSUMABLE quants. Moving and force-clearing source.") consumable.move_quants(location_dest_id=dest_loc, message='Bulk Relocation via Script (Consumable)') # Manually clear source for consumables because move_quants might not for q in consumable: if q.quantity > 0: print(f" Force clearing consumable quant {q.id} (Product: {q.product_id.display_name})") # Use internal method to adjust quantity try: # Decrease by current quantity to zero it out env['stock.quant']._update_available_quantity(q.product_id, q.location_id, -q.quantity, lot_id=q.lot_id, package_id=q.package_id, owner_id=q.owner_id) except Exception as e: print(f" ERROR clearing consumable: {e}") except Exception as e: print(f" ERROR moving quants: {e}") # 2b. Zero Quantity but Reserved -> Unreserve Only reserved_only_quants = quants.filtered(lambda q: q.quantity <= 0 and q.reserved_quantity > 0) if reserved_only_quants: print(f" Found {len(reserved_only_quants)} quants with 0 qty but RESERVED > 0. Total Reserved: {sum(reserved_only_quants.mapped('reserved_quantity'))}") print(" Attempting to unreserve...") count = 0 for q in reserved_only_quants: # Find reserved move lines for this quant domain = [ ('product_id', '=', q.product_id.id), ('location_id', '=', q.location_id.id), ('lot_id', '=', q.lot_id.id), ('package_id', '=', q.package_id.id), ('owner_id', '=', q.owner_id.id), # ('product_uom_id', '=', q.product_uom_id.id), # Removed to ignore UoM mismatches ('state', 'not in', ['done', 'cancel']), ] # Note: move lines might have different UoM, but usually match. # Safest is to rely on stock.quant's reserved_quantity computation logic reversed # but unlinking move lines is generally how we force unreserve. move_lines = env['stock.move.line'].search(domain) if move_lines: move_lines.unlink() # or write {'quantity': 0} count += 1 # Check if reservation is stuck (Ghost reservation) if q.reserved_quantity > 0: print(f" WARNING: Quant {q.id} still has reserved_qty={q.reserved_quantity} after checking SMLs. Force clearing.") q.write({'reserved_quantity': 0}) if not move_lines: count += 1 print(f" SUCCESS: Unreserved {count} quants.") if not valid_quants and not reserved_only_quants: print(" No stock found to move or unreserve.") print("\n---------------------------------------------------") print("Dry run complete. No changes have been committed yet.") user_input = input("Do you want to COMMIT these changes to the database? (yes/no): ") if user_input.lower() == 'yes': cr.commit() print("Changes COMMITTED.") else: cr.rollback() print("Operation CANCELLED (Rolled back).") print("Done.") if __name__ == '__main__': move_stock()