From 8bc083b84edc061081f80d190a4016df42d6654c Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Tue, 10 Feb 2026 15:20:42 +0700 Subject: [PATCH] feat: Add Odoo utility scripts for remote stock movement and BOM operation unlinking. --- move_stock_remote.py | 211 +++++++++++++++++++++++++++++++++++++++ unlink_bom_operations.py | 103 +++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 move_stock_remote.py create mode 100644 unlink_bom_operations.py diff --git a/move_stock_remote.py b/move_stock_remote.py new file mode 100644 index 0000000..1e2e3a4 --- /dev/null +++ b/move_stock_remote.py @@ -0,0 +1,211 @@ +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() diff --git a/unlink_bom_operations.py b/unlink_bom_operations.py new file mode 100644 index 0000000..d1cc6d3 --- /dev/null +++ b/unlink_bom_operations.py @@ -0,0 +1,103 @@ +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 unlink_bom_operations.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 unlink_operations(): + print("Starting BOM Operation Unlink Script...") + + import argparse + parser = argparse.ArgumentParser(description='Unlink operations from BOMs remotely.') + parser.add_argument('db_name', help='Database name') + parser.add_argument('-c', '--config', help='Path to odoo.conf', default=None) + + 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, {}) + + print("\nSearching for BOMs...") + boms = env['mrp.bom'].search([]) + print(f"Found {len(boms)} total BOMs.") + + count = 0 + modified_count = 0 + for bom in boms: + count += 1 + if count % 100 == 0: + print(f"Processed {count}/{len(boms)}...") + + if bom.operation_ids: + op_count = len(bom.operation_ids) + print(f" [MODIFY] BOM: {bom.display_name} (ID: {bom.id}) - Unlinking {op_count} operations.") + # (5, 0, 0) command removes all records from the relation (deletes them if owned) + bom.write({'operation_ids': [(5, 0, 0)]}) + modified_count += 1 + + print(f"\nSummary: Modified {modified_count} BOMs out of {len(boms)}.") + + print("\n---------------------------------------------------") + print("Dry run complete. No changes have been committed yet.") + print("WARNING: This will permanently remove operations from the modified BOMs.") + 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__': + unlink_operations()