feat: Add Odoo utility scripts for remote stock movement and BOM operation unlinking.
This commit is contained in:
parent
6fa8d55e7e
commit
8bc083b84e
211
move_stock_remote.py
Normal file
211
move_stock_remote.py
Normal file
@ -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()
|
||||
103
unlink_bom_operations.py
Normal file
103
unlink_bom_operations.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user