diff --git a/AI Prompt.txt b/AI Prompt.txt index 9cee6bb..4a90af5 100644 --- a/AI Prompt.txt +++ b/AI Prompt.txt @@ -31,7 +31,7 @@ Long-term goal: evolve into a WMS, but right now focus on making this workflow r ## Scanlook (current product summary) Scanlook is a web app for warehouse counting workflows built with Flask + SQLite. -**Current Version:** 0.14.0 +**Current Version:** 0.15.0 **Tech Stack:** - Backend: Python/Flask, raw SQL (no ORM), openpyxl (Excel file generation) @@ -68,7 +68,7 @@ Scanlook is a web app for warehouse counting workflows built with Flask + SQLite **Long-term goal:** Modular WMS with future modules for Shipping, Receiving, Transfers, Production. -**Module System (v0.14.0):** +**Module System:** - Modules table defines available modules (module_key used for routing) - UserModules table tracks per-user access - Home page (/home) shows module cards based on user's access diff --git a/app.py b/app.py index 39573bb..2fbba0e 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ """ -ScanLook - Inventory Management System +ScanLook - Modular Inventory Management System Flask Application -Production-Ready Release +Production-Ready Release with Module System """ from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory from werkzeug.security import check_password_hash @@ -13,21 +13,11 @@ app = Flask(__name__) # Now import your custom modules from db import query_db, execute_db, get_db -from blueprints.data_imports import data_imports_bp from blueprints.users import users_bp -from blueprints.sessions import sessions_bp -from blueprints.admin_locations import admin_locations_bp -from blueprints.counting import counting_bp -from blueprints.cons_sheets import cons_sheets_bp from utils import login_required -# Register Blueprints -app.register_blueprint(data_imports_bp) +# Register Core Blueprints (non-modular) app.register_blueprint(users_bp) -app.register_blueprint(sessions_bp) -app.register_blueprint(admin_locations_bp) -app.register_blueprint(counting_bp) -app.register_blueprint(cons_sheets_bp) # V1.0: Use environment variable for production, fallback to demo key for development app.secret_key = os.environ.get('SCANLOOK_SECRET_KEY', 'scanlook-demo-key-replace-for-production') @@ -38,7 +28,7 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1) # 1. Define the version -APP_VERSION = '0.15.0' +APP_VERSION = '0.16.0' # Bumped version for modular architecture # 2. Inject it into all templates automatically @app.context_processor @@ -53,11 +43,17 @@ if not os.path.exists(db_path): init_database() create_default_users() print("Database initialized!") + print("šŸ“¦ Install modules from /admin/modules") # Run migrations to apply any pending database changes from migrations import run_migrations run_migrations() +# Load and register active modules +from module_manager import get_module_manager +module_manager = get_module_manager() +module_manager.load_active_modules(app) + # ==================== ROUTES: AUTHENTICATION ==================== @@ -119,49 +115,124 @@ def home(): return render_template('home.html', modules=modules) -# ==================== ROUTES: DASHBOARD ==================== +# ==================== ROUTES: ADMIN DASHBOARD ==================== @app.route('/admin') @login_required def admin_dashboard(): - """Main dashboard - different views for admin vs staff""" + """Admin dashboard - shows all available modules""" role = session.get('role') - if role in ['owner', 'admin']: - # Admin dashboard - show_archived = request.args.get('show_archived', '0') == '1' - - if show_archived: - # Show all sessions (active and archived) - sessions_list = query_db(''' - SELECT s.*, u.full_name as created_by_name, - COUNT(DISTINCT lc.location_count_id) as total_locations, - SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations, - SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations - FROM CountSessions s - LEFT JOIN Users u ON s.created_by = u.user_id - LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id - WHERE s.status IN ('active', 'archived') - GROUP BY s.session_id - ORDER BY s.status ASC, s.created_timestamp DESC - ''') - else: - # Show only active sessions - sessions_list = query_db(''' - SELECT s.*, u.full_name as created_by_name, - COUNT(DISTINCT lc.location_count_id) as total_locations, - SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations, - SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations - FROM CountSessions s - LEFT JOIN Users u ON s.created_by = u.user_id - LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id - WHERE s.status = 'active' - GROUP BY s.session_id - ORDER BY s.created_timestamp DESC - ''') - - return render_template('admin_dashboard.html', sessions=sessions_list, show_archived=show_archived) + if role not in ['owner', 'admin']: + flash('Access denied. Admin role required.', 'danger') + return redirect(url_for('home')) + + # Get modules this user has access to + user_id = session.get('user_id') + modules = query_db(''' + SELECT m.module_id, m.module_name, m.module_key, m.description, m.icon + FROM Modules m + JOIN UserModules um ON m.module_id = um.module_id + WHERE um.user_id = ? AND m.is_active = 1 + ORDER BY m.display_order + ''', [user_id]) + + return render_template('admin_dashboard.html', modules=modules) +# ==================== MODULE MANAGER UI ==================== + +@app.route('/admin/modules') +@login_required +def module_manager_ui(): + """Module manager interface for admins""" + if session.get('role') not in ['owner', 'admin']: + flash('Access denied. Admin role required.', 'danger') + return redirect(url_for('home')) + + modules = module_manager.scan_available_modules() + return render_template('module_manager.html', modules=modules) + + +@app.route('/admin/modules//install', methods=['POST']) +@login_required +def install_module(module_key): + """Install a module""" + if session.get('role') not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Access denied'}), 403 + + result = module_manager.install_module(module_key) + + # Hot-reload: Register the blueprint immediately if installation succeeded + if result['success']: + try: + from pathlib import Path + import importlib.util + import sys + + module = module_manager.get_module_by_key(module_key) + if module: + init_path = Path(module['path']) / '__init__.py' + + # Import the module + spec = importlib.util.spec_from_file_location( + f"modules.{module_key}", + init_path + ) + module_package = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module_package + spec.loader.exec_module(module_package) + + # Create and register blueprint + if hasattr(module_package, 'create_blueprint'): + blueprint = module_package.create_blueprint() + app.register_blueprint(blueprint) + print(f"šŸ”„ Hot-loaded: {module['name']} at {module.get('routes_prefix')}") + result['message'] += ' (Module loaded - no restart needed!)' + else: + print(f"āš ļø Module {module_key} missing create_blueprint()") + result['message'] += ' (Restart required - missing create_blueprint)' + else: + print(f"āš ļø Could not find module {module_key} after installation") + result['message'] += ' (Restart required - module not found)' + except Exception as e: + print(f"āŒ Hot-reload failed for {module_key}: {e}") + import traceback + traceback.print_exc() + result['message'] += f' (Restart required - hot-reload failed)' + + return jsonify(result) + +@app.route('/admin/modules//uninstall', methods=['POST']) +@login_required +def uninstall_module(module_key): + """Uninstall a module""" + if session.get('role') not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Access denied'}), 403 + + result = module_manager.uninstall_module(module_key, drop_tables=True) + return jsonify(result) + + +@app.route('/admin/modules//activate', methods=['POST']) +@login_required +def activate_module(module_key): + """Activate a module""" + if session.get('role') not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Access denied'}), 403 + + result = module_manager.activate_module(module_key) + return jsonify(result) + + +@app.route('/admin/modules//deactivate', methods=['POST']) +@login_required +def deactivate_module(module_key): + """Deactivate a module""" + if session.get('role') not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Access denied'}), 403 + + result = module_manager.deactivate_module(module_key) + return jsonify(result) # ==================== PWA SUPPORT ROUTES ==================== @@ -204,6 +275,19 @@ def whatami(): """ +@app.route('/debug/routes') +@login_required +def list_routes(): + """Debug: List all registered routes""" + if session.get('role') not in ['owner', 'admin']: + return "Access denied", 403 + + routes = [] + for rule in app.url_map.iter_rules(): + routes.append(f"{rule.endpoint}: {rule.rule}") + + return "
".join(sorted(routes)) + # ==================== RUN APPLICATION ==================== if __name__ == '__main__': diff --git a/database/init_db.py b/database/init_db.py index de7643f..21dbd74 100644 --- a/database/init_db.py +++ b/database/init_db.py @@ -1,7 +1,6 @@ """ -ScanLook Database Initialization -Creates all tables and indexes for the inventory management system -UPDATED: Reflects post-migration schema (CURRENT baseline is now global) +ScanLook Database Initialization - CORE ONLY +Creates only core system tables. Module tables are created when modules are installed. """ import sqlite3 @@ -13,10 +12,14 @@ DB_PATH = os.path.join(os.path.dirname(__file__), 'scanlook.db') def init_database(): - """Initialize the database with all tables and indexes""" + """Initialize the database with core system tables only""" conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() + # ============================================ + # CORE SYSTEM TABLES + # ============================================ + # Users Table cursor.execute(''' CREATE TABLE IF NOT EXISTS Users ( @@ -32,145 +35,7 @@ def init_database(): ) ''') - # CountSessions Table - # NOTE: current_baseline_version removed - CURRENT is now global - cursor.execute(''' - CREATE TABLE IF NOT EXISTS CountSessions ( - session_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_name TEXT NOT NULL, - session_type TEXT NOT NULL CHECK(session_type IN ('cycle_count', 'full_physical')), - created_by INTEGER NOT NULL, - created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - master_baseline_timestamp DATETIME, - current_baseline_timestamp DATETIME, - status TEXT DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')), - branch TEXT DEFAULT 'Main', - FOREIGN KEY (created_by) REFERENCES Users(user_id) - ) - ''') - - # BaselineInventory_Master Table (Session-specific, immutable) - cursor.execute(''' - CREATE TABLE IF NOT EXISTS BaselineInventory_Master ( - baseline_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - lot_number TEXT NOT NULL, - item TEXT NOT NULL, - description TEXT, - system_location TEXT NOT NULL, - system_bin TEXT NOT NULL, - system_quantity REAL NOT NULL, - uploaded_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (session_id) REFERENCES CountSessions(session_id) - ) - ''') - - # BaselineInventory_Current Table (GLOBAL - shared across all sessions) - # MIGRATION CHANGE: No session_id, no baseline_version, no is_deleted - # This table is replaced entirely on each upload - cursor.execute(''' - CREATE TABLE IF NOT EXISTS BaselineInventory_Current ( - current_id INTEGER PRIMARY KEY AUTOINCREMENT, - lot_number TEXT NOT NULL, - item TEXT NOT NULL, - description TEXT, - system_location TEXT, - system_bin TEXT NOT NULL, - system_quantity REAL NOT NULL, - upload_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(lot_number, system_bin) - ) - ''') - - # LocationCounts Table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS LocationCounts ( - location_count_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - location_name TEXT NOT NULL, - counted_by INTEGER NOT NULL, - start_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - end_timestamp DATETIME, - status TEXT DEFAULT 'not_started' CHECK(status IN ('not_started', 'in_progress', 'completed')), - expected_lots_master INTEGER DEFAULT 0, - lots_found INTEGER DEFAULT 0, - lots_missing INTEGER DEFAULT 0, - FOREIGN KEY (session_id) REFERENCES CountSessions(session_id), - FOREIGN KEY (counted_by) REFERENCES Users(user_id) - ) - ''') - - # ScanEntries Table - # MIGRATION CHANGE: Removed current_* columns - now fetched via JOIN - cursor.execute(''' - CREATE TABLE IF NOT EXISTS ScanEntries ( - entry_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - location_count_id INTEGER NOT NULL, - lot_number TEXT NOT NULL, - item TEXT, - description TEXT, - scanned_location TEXT NOT NULL, - actual_weight REAL NOT NULL, - scanned_by INTEGER NOT NULL, - scan_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - - -- MASTER baseline comparison (immutable, set at scan time) - master_status TEXT CHECK(master_status IN ('match', 'wrong_location', 'ghost_lot', 'missing')), - master_expected_location TEXT, - master_expected_weight REAL, - master_variance_lbs REAL, - master_variance_pct REAL, - - -- Duplicate detection - duplicate_status TEXT DEFAULT '00' CHECK(duplicate_status IN ('00', '01', '03', '04')), - duplicate_info TEXT, - - -- CURRENT baseline comparison removed - now via JOIN in queries - -- Removed: current_status, current_system_location, current_system_weight, - -- current_variance_lbs, current_variance_pct, current_baseline_version - - -- Metadata - comment TEXT, - is_deleted INTEGER DEFAULT 0, - deleted_by INTEGER, - deleted_timestamp DATETIME, - modified_timestamp DATETIME, - - FOREIGN KEY (session_id) REFERENCES CountSessions(session_id), - FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id), - FOREIGN KEY (scanned_by) REFERENCES Users(user_id), - FOREIGN KEY (deleted_by) REFERENCES Users(user_id) - ) - ''') - - # MissingLots Table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS MissingLots ( - missing_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - location_count_id INTEGER, - lot_number TEXT NOT NULL, - item TEXT, - master_expected_location TEXT NOT NULL, - master_expected_quantity REAL NOT NULL, - current_system_location TEXT, - current_system_quantity REAL, - marked_by INTEGER NOT NULL, - marked_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - found_later TEXT DEFAULT 'N' CHECK(found_later IN ('Y', 'N')), - found_location TEXT, - FOREIGN KEY (session_id) REFERENCES CountSessions(session_id), - FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id), - FOREIGN KEY (marked_by) REFERENCES Users(user_id) - ) - ''') - - # ============================================ - # MODULE SYSTEM TABLES - # ============================================ - - # Modules Table - Available feature modules + # Modules Table (legacy - for user permissions) cursor.execute(''' CREATE TABLE IF NOT EXISTS Modules ( module_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -183,7 +48,7 @@ def init_database(): ) ''') - # UserModules Table - Module access per user + # UserModules Table (module access per user) cursor.execute(''' CREATE TABLE IF NOT EXISTS UserModules ( user_module_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -198,104 +63,35 @@ def init_database(): ) ''') - # ============================================ - # CONSUMPTION SHEETS MODULE TABLES - # ============================================ - - # cons_processes - Master list of consumption sheet process types + # Module Registry Table (new module manager system) cursor.execute(''' - CREATE TABLE IF NOT EXISTS cons_processes ( + CREATE TABLE IF NOT EXISTS module_registry ( id INTEGER PRIMARY KEY AUTOINCREMENT, - process_key TEXT UNIQUE NOT NULL, - process_name TEXT NOT NULL, - template_file BLOB, - template_filename TEXT, - rows_per_page INTEGER DEFAULT 30, - detail_start_row INTEGER DEFAULT 10, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_by INTEGER NOT NULL, - is_active INTEGER DEFAULT 1, - FOREIGN KEY (created_by) REFERENCES Users(user_id) + module_key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL, + author TEXT, + description TEXT, + is_installed INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 0, + installed_at TEXT, + config_json TEXT ) ''') - # cons_process_fields - Custom field definitions for each process + # Schema Migrations Table (for core migrations only) cursor.execute(''' - CREATE TABLE IF NOT EXISTS cons_process_fields ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - process_id INTEGER NOT NULL, - table_type TEXT NOT NULL CHECK(table_type IN ('header', 'detail')), - field_name TEXT NOT NULL, - field_label TEXT NOT NULL, - field_type TEXT NOT NULL CHECK(field_type IN ('TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME')), - max_length INTEGER, - is_required INTEGER DEFAULT 0, - is_duplicate_key INTEGER DEFAULT 0, - is_active INTEGER DEFAULT 1, - sort_order INTEGER DEFAULT 0, - excel_cell TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (process_id) REFERENCES cons_processes(id) + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ''') - # cons_sessions - Staff scanning sessions - cursor.execute(''' - CREATE TABLE IF NOT EXISTS cons_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - process_id INTEGER NOT NULL, - created_by INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - status TEXT DEFAULT 'active' CHECK(status IN ('active', 'archived')), - FOREIGN KEY (process_id) REFERENCES cons_processes(id), - FOREIGN KEY (created_by) REFERENCES Users(user_id) - ) - ''') - - # Note: Header values still use flexible key-value storage - # cons_session_header_values - Flexible storage for header field values - cursor.execute(''' - CREATE TABLE IF NOT EXISTS cons_session_header_values ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - field_id INTEGER NOT NULL, - field_value TEXT, - FOREIGN KEY (session_id) REFERENCES cons_sessions(id), - FOREIGN KEY (field_id) REFERENCES cons_process_fields(id) - ) - ''') - - # Note: Detail tables are created dynamically per process as cons_proc_{process_key}_details - # They include system columns (id, session_id, scanned_by, scanned_at, duplicate_status, - # duplicate_info, comment, is_deleted) plus custom fields defined in cons_process_fields - - # Create Indexes - # MASTER baseline indexes - cursor.execute('CREATE INDEX IF NOT EXISTS idx_baseline_master_lot ON BaselineInventory_Master(session_id, lot_number)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_baseline_master_loc ON BaselineInventory_Master(session_id, system_location)') - - # ScanEntries indexes - cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_session ON ScanEntries(session_id)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_location ON ScanEntries(location_count_id)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_lot ON ScanEntries(lot_number)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_scanentries_deleted ON ScanEntries(is_deleted)') - - # LocationCounts indexes - cursor.execute('CREATE INDEX IF NOT EXISTS idx_location_counts ON LocationCounts(session_id, status)') - - # Note: No indexes on BaselineInventory_Current needed - UNIQUE constraint handles lookups - - # Consumption Sheets indexes - cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_process_fields_process ON cons_process_fields(process_id, table_type)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_process_fields_active ON cons_process_fields(process_id, is_active)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_process ON cons_sessions(process_id, status)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_cons_sessions_user ON cons_sessions(created_by, status)') - # Note: Detail table indexes are created dynamically when process tables are created - conn.commit() conn.close() - print(f"āœ… Database initialized at: {DB_PATH}") - print("šŸ“ Schema version: Post-migration (CURRENT baseline is global)") + print(f"āœ… Core database initialized at: {DB_PATH}") + print("šŸ“¦ Module tables will be created when modules are installed") def create_default_users(): @@ -327,52 +123,7 @@ def create_default_users(): conn.close() -def create_default_modules(): - """Create default modules and assign to admin users""" - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - - # Define default modules - default_modules = [ - ('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1), - ('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2), - ] - - # Insert modules (ignore if already exist) - for module in default_modules: - try: - cursor.execute(''' - INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order) - VALUES (?, ?, ?, ?, ?, ?) - ''', module) - except sqlite3.IntegrityError: - pass # Module already exists - - conn.commit() - - # Auto-assign all modules to owner and admin users - cursor.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")') - admin_users = cursor.fetchall() - - cursor.execute('SELECT module_id FROM Modules') - all_modules = cursor.fetchall() - - for user in admin_users: - for module in all_modules: - try: - cursor.execute(''' - INSERT INTO UserModules (user_id, module_id) - VALUES (?, ?) - ''', (user[0], module[0])) - except sqlite3.IntegrityError: - pass # Assignment already exists - - conn.commit() - conn.close() - print("āœ… Default modules created and assigned to admin users") - - if __name__ == '__main__': init_database() create_default_users() - create_default_modules() \ No newline at end of file + \ No newline at end of file diff --git a/database/scanlook.db.backup_before_modular b/database/scanlook.db.backup_before_modular new file mode 100644 index 0000000..10524fb Binary files /dev/null and b/database/scanlook.db.backup_before_modular differ diff --git a/migrations.py b/migrations.py index b5b02f3..179ffc4 100644 --- a/migrations.py +++ b/migrations.py @@ -1,12 +1,8 @@ """ -ScanLook Database Migration System +ScanLook Core Database Migration System -Simple migration system that tracks and applies database changes. -Each migration has a version number and an up() function. - -Usage: - from migrations import run_migrations - run_migrations() # Call on app startup +IMPORTANT: This file only contains CORE system migrations. +Module-specific migrations are in each module's migrations.py file. """ import sqlite3 @@ -75,19 +71,13 @@ def table_exists(table): # ============================================ -# MIGRATIONS +# CORE SYSTEM MIGRATIONS ONLY # ============================================ -# Add new migrations to this list. -# Each migration is a tuple: (version, name, up_function) -# -# RULES: -# - Never modify an existing migration -# - Always add new migrations at the end with the next version number -# - Check if changes are needed before applying (idempotent) +# Module-specific migrations are handled by each module's migrations.py # ============================================ def migration_001_add_modules_tables(): - """Add Modules and UserModules tables""" + """Add Modules and UserModules tables (if not created by init_db)""" conn = get_db() if not table_exists('Modules'): @@ -141,132 +131,42 @@ def migration_002_add_usermodules_granted_columns(): conn.close() -def migration_003_add_default_modules(): - """Add default modules if they don't exist""" +def migration_003_add_module_registry(): + """Add module_registry table for new module manager system""" conn = get_db() - # Check if modules exist - existing = conn.execute('SELECT COUNT(*) as cnt FROM Modules').fetchone() - - if existing['cnt'] == 0: + if not table_exists('module_registry'): conn.execute(''' - INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order) - VALUES ('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1) + CREATE TABLE module_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL, + author TEXT, + description TEXT, + is_installed INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 0, + installed_at TEXT, + config_json TEXT + ) ''') - conn.execute(''' - INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order) - VALUES ('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2) - ''') - print(" Added default modules") + print(" Created module_registry table") conn.commit() conn.close() -def migration_004_assign_modules_to_admins(): - """Auto-assign all modules to owner and admin users""" - conn = get_db() - - # Get admin users - admins = conn.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")').fetchall() - modules = conn.execute('SELECT module_id FROM Modules').fetchall() - - for user in admins: - for module in modules: - try: - conn.execute(''' - INSERT INTO UserModules (user_id, module_id) - VALUES (?, ?) - ''', [user['user_id'], module['module_id']]) - except sqlite3.IntegrityError: - pass # Already assigned - - conn.commit() - conn.close() - print(" Assigned modules to admin users") - - -def migration_005_add_cons_process_fields_duplicate_key(): - """Add is_duplicate_key column to cons_process_fields if missing""" - conn = get_db() - - if table_exists('cons_process_fields'): - if not column_exists('cons_process_fields', 'is_duplicate_key'): - conn.execute('ALTER TABLE cons_process_fields ADD COLUMN is_duplicate_key INTEGER DEFAULT 0') - print(" Added is_duplicate_key column to cons_process_fields") - - conn.commit() - conn.close() - -def migration_006_add_is_deleted_to_locationcounts(): - """Add is_deleted column to LocationCounts table""" - conn = get_db() - - if table_exists('LocationCounts'): - if not column_exists('LocationCounts', 'is_deleted'): - conn.execute('ALTER TABLE LocationCounts ADD COLUMN is_deleted INTEGER DEFAULT 0') - print(" Added is_deleted column to LocationCounts") - - conn.commit() - conn.close() - - -def migration_007_add_detail_end_row(): - """Add detail_end_row column to cons_processes table""" - conn = get_db() - - if table_exists('cons_processes'): - if not column_exists('cons_processes', 'detail_end_row'): - conn.execute('ALTER TABLE cons_processes ADD COLUMN detail_end_row INTEGER') - print(" Added detail_end_row column to cons_processes") - - conn.commit() - conn.close() - - -def migration_008_add_page_height(): - """Add page_height column to cons_processes table""" - conn = get_db() - - if table_exists('cons_processes'): - if not column_exists('cons_processes', 'page_height'): - conn.execute('ALTER TABLE cons_processes ADD COLUMN page_height INTEGER') - print(" Added page_height column to cons_processes") - - conn.commit() - conn.close() - -def migration_009_add_print_columns(): - """Add print_start_col and print_end_col to cons_processes""" - conn = get_db() - if table_exists('cons_processes'): - if not column_exists('cons_processes', 'print_start_col'): - conn.execute('ALTER TABLE cons_processes ADD COLUMN print_start_col TEXT DEFAULT "A"') - print(" Added print_start_col") - if not column_exists('cons_processes', 'print_end_col'): - conn.execute('ALTER TABLE cons_processes ADD COLUMN print_end_col TEXT') - print(" Added print_end_col") - conn.commit() - conn.close() - - -# List of all migrations in order +# List of CORE migrations only MIGRATIONS = [ (1, 'add_modules_tables', migration_001_add_modules_tables), (2, 'add_usermodules_granted_columns', migration_002_add_usermodules_granted_columns), - (3, 'add_default_modules', migration_003_add_default_modules), - (4, 'assign_modules_to_admins', migration_004_assign_modules_to_admins), - (5, 'add_cons_process_fields_duplicate_key', migration_005_add_cons_process_fields_duplicate_key), - (6, 'add_is_deleted_to_locationcounts', migration_006_add_is_deleted_to_locationcounts), - (7, 'add_detail_end_row', migration_007_add_detail_end_row), - (8, 'add_page_height', migration_008_add_page_height), - (9, 'add_print_columns', migration_009_add_print_columns), + (3, 'add_module_registry', migration_003_add_module_registry), ] def run_migrations(): - """Run all pending migrations""" - print("šŸ”„ Checking database migrations...") + """Run all pending core migrations""" + print("šŸ”„ Checking core database migrations...") # Make sure migrations table exists init_migrations_table() @@ -278,10 +178,10 @@ def run_migrations(): pending = [(v, n, f) for v, n, f in MIGRATIONS if v not in applied] if not pending: - print("āœ… Database is up to date") + print("āœ… Core database is up to date") return - print(f"šŸ“¦ Running {len(pending)} migration(s)...") + print(f"šŸ“¦ Running {len(pending)} core migration(s)...") for version, name, func in pending: print(f"\n Migration {version}: {name}") @@ -293,8 +193,8 @@ def run_migrations(): print(f" āŒ Migration {version} failed: {e}") raise - print("\nāœ… All migrations complete") + print("\nāœ… All core migrations complete") if __name__ == '__main__': - run_migrations() + run_migrations() \ No newline at end of file diff --git a/module_manager.py b/module_manager.py new file mode 100644 index 0000000..b1a9ec1 --- /dev/null +++ b/module_manager.py @@ -0,0 +1,359 @@ +""" +ScanLook Module Manager +Handles module discovery, installation, uninstallation, and activation +""" + +import os +import json +import sqlite3 +import importlib.util +from pathlib import Path +from typing import List, Dict, Optional + + +MODULES_DIR = Path(__file__).parent / 'modules' +DB_PATH = Path(__file__).parent / 'database' / 'scanlook.db' + + +def get_db(): + """Get database connection (standalone, no Flask context needed)""" + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + +def query_db(query, args=(), one=False): + """Query database and return results""" + conn = get_db() + cur = conn.execute(query, args) + rv = cur.fetchall() + conn.close() + return (rv[0] if rv else None) if one else rv + + +def execute_db(query, args=()): + """Execute database command and return lastrowid""" + conn = get_db() + cur = conn.execute(query, args) + conn.commit() + last_id = cur.lastrowid + conn.close() + return last_id + + +class ModuleManager: + """Manages ScanLook modules""" + + def __init__(self): + self.modules_dir = MODULES_DIR + self._ensure_modules_table() + + def _ensure_modules_table(self): + """Ensure the module_registry table exists in the database""" + conn = get_db() + conn.execute(''' + CREATE TABLE IF NOT EXISTS module_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + module_key TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + version TEXT NOT NULL, + author TEXT, + description TEXT, + is_installed INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 0, + installed_at TEXT, + config_json TEXT + ) + ''') + conn.commit() + conn.close() + + def scan_available_modules(self) -> List[Dict]: + """ + Scan the /modules directory for available modules. + Returns list of module info dicts from manifest.json files. + """ + available = [] + + if not self.modules_dir.exists(): + return available + + for item in self.modules_dir.iterdir(): + if not item.is_dir(): + continue + + manifest_path = item / 'manifest.json' + if not manifest_path.exists(): + continue + + try: + with open(manifest_path, 'r') as f: + manifest = json.load(f) + + # Validate required fields + required = ['module_key', 'name', 'version', 'author', 'description'] + if not all(field in manifest for field in required): + print(f"āš ļø Invalid manifest in {item.name}: missing required fields") + continue + + # Check installation status from database + db_module = query_db( + 'SELECT is_installed, is_active FROM module_registry WHERE module_key = ?', + [manifest['module_key']], + one=True + ) + + manifest['is_installed'] = db_module['is_installed'] if db_module else False + manifest['is_active'] = db_module['is_active'] if db_module else False + manifest['path'] = str(item) + + available.append(manifest) + + except json.JSONDecodeError as e: + print(f"āš ļø Invalid JSON in {manifest_path}: {e}") + continue + except Exception as e: + print(f"āš ļø Error reading manifest from {item.name}: {e}") + continue + + return sorted(available, key=lambda x: x['name']) + + def get_module_by_key(self, module_key: str) -> Optional[Dict]: + """Get module info by module_key""" + modules = self.scan_available_modules() + for module in modules: + if module['module_key'] == module_key: + return module + return None + + def install_module(self, module_key: str) -> Dict: + """ + Install a module: + 1. Load manifest + 2. Run migrations (create tables) + 3. Register in database + 4. Set is_installed=1, is_active=1 + + Returns: {'success': bool, 'message': str} + """ + try: + # Get module info + module = self.get_module_by_key(module_key) + if not module: + return {'success': False, 'message': f'Module {module_key} not found'} + + # Check if already installed + if module['is_installed']: + return {'success': False, 'message': f'Module {module_key} is already installed'} + + # Load module's migrations + migrations_path = Path(module['path']) / 'migrations.py' + if not migrations_path.exists(): + return {'success': False, 'message': 'Module is missing migrations.py'} + + # Import migrations module + spec = importlib.util.spec_from_file_location(f"{module_key}_migrations", migrations_path) + migrations_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migrations_module) + + # Run schema installation + print(f"\nšŸ“¦ Installing module: {module['name']}") + + conn = get_db() + + # Execute schema SQL + if hasattr(migrations_module, 'get_schema'): + schema_sql = migrations_module.get_schema() + conn.executescript(schema_sql) + print(f" āœ… Database schema created") + + # Run module-specific migrations + if hasattr(migrations_module, 'get_migrations'): + migrations = migrations_module.get_migrations() + for version, name, func in migrations: + print(f" Running migration {version}: {name}") + func(conn) + + conn.commit() + + # Register module in database + existing = query_db('SELECT id FROM module_registry WHERE module_key = ?', [module_key], one=True) + + if existing: + execute_db(''' + UPDATE module_registry + SET name = ?, version = ?, author = ?, description = ?, + is_installed = 1, is_active = 1, installed_at = CURRENT_TIMESTAMP + WHERE module_key = ? + ''', [module['name'], module['version'], module['author'], + module['description'], module_key]) + else: + execute_db(''' + INSERT INTO module_registry (module_key, name, version, author, description, + is_installed, is_active, installed_at) + VALUES (?, ?, ?, ?, ?, 1, 1, CURRENT_TIMESTAMP) + ''', [module_key, module['name'], module['version'], + module['author'], module['description']]) + + # Also register in old Modules table for compatibility + old_module = query_db('SELECT module_id FROM Modules WHERE module_key = ?', [module_key], one=True) + if not old_module: + execute_db(''' + INSERT INTO Modules (module_name, module_key, description, is_active) + VALUES (?, ?, ?, 1) + ''', [module['name'], module_key, module['description']]) + + conn.close() + + print(f"āœ… Module {module['name']} installed successfully") + return {'success': True, 'message': f'Module {module["name"]} installed successfully'} + + except Exception as e: + print(f"āŒ Installation failed: {e}") + import traceback + traceback.print_exc() + return {'success': False, 'message': f'Installation failed: {str(e)}'} + + def uninstall_module(self, module_key: str, drop_tables: bool = True) -> Dict: + """ + Uninstall a module: + 1. Set is_installed=0, is_active=0 in database + 2. Optionally drop all module tables + 3. Remove from old Modules table + + Returns: {'success': bool, 'message': str} + """ + try: + module = self.get_module_by_key(module_key) + if not module: + return {'success': False, 'message': f'Module {module_key} not found'} + + if not module['is_installed']: + return {'success': False, 'message': f'Module {module_key} is not installed'} + + print(f"\nšŸ—‘ļø Uninstalling module: {module['name']}") + + conn = get_db() + + # Drop tables if requested + if drop_tables: + print(f" Dropping database tables...") + # Load migrations to get table names + migrations_path = Path(module['path']) / 'migrations.py' + if migrations_path.exists(): + spec = importlib.util.spec_from_file_location(f"{module_key}_migrations", migrations_path) + migrations_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migrations_module) + + # Get schema and extract table names + if hasattr(migrations_module, 'get_schema'): + schema = migrations_module.get_schema() + # Simple regex to find CREATE TABLE statements + import re + tables = re.findall(r'CREATE TABLE IF NOT EXISTS (\w+)', schema) + + for table in tables: + try: + conn.execute(f'DROP TABLE IF EXISTS {table}') + print(f" Dropped table: {table}") + except Exception as e: + print(f" Warning: Could not drop {table}: {e}") + + # Update module_registry table + execute_db(''' + UPDATE module_registry + SET is_installed = 0, is_active = 0 + WHERE module_key = ? + ''', [module_key]) + + # Remove from old Modules table + execute_db('DELETE FROM Modules WHERE module_key = ?', [module_key]) + + # Remove user module assignments + old_module_id = query_db('SELECT module_id FROM Modules WHERE module_key = ?', [module_key], one=True) + if old_module_id: + execute_db('DELETE FROM UserModules WHERE module_id = ?', [old_module_id['module_id']]) + + conn.commit() + conn.close() + + print(f"āœ… Module {module['name']} uninstalled successfully") + return {'success': True, 'message': f'Module {module["name"]} uninstalled successfully'} + + except Exception as e: + print(f"āŒ Uninstallation failed: {e}") + return {'success': False, 'message': f'Uninstallation failed: {str(e)}'} + + def activate_module(self, module_key: str) -> Dict: + """Activate an installed module""" + module = self.get_module_by_key(module_key) + if not module: + return {'success': False, 'message': f'Module {module_key} not found'} + + if not module['is_installed']: + return {'success': False, 'message': 'Module must be installed first'} + + execute_db('UPDATE module_registry SET is_active = 1 WHERE module_key = ?', [module_key]) + execute_db('UPDATE Modules SET is_active = 1 WHERE module_key = ?', [module_key]) + + return {'success': True, 'message': f'Module {module["name"]} activated'} + + def deactivate_module(self, module_key: str) -> Dict: + """Deactivate a module (keeps it installed)""" + module = self.get_module_by_key(module_key) + if not module: + return {'success': False, 'message': f'Module {module_key} not found'} + + execute_db('UPDATE module_registry SET is_active = 0 WHERE module_key = ?', [module_key]) + execute_db('UPDATE Modules SET is_active = 0 WHERE module_key = ?', [module_key]) + + return {'success': True, 'message': f'Module {module["name"]} deactivated'} + + def load_active_modules(self, app): + """ + Load all active modules and register their blueprints with Flask app. + Called during app startup. + """ + modules = self.scan_available_modules() + active_modules = [m for m in modules if m['is_installed'] and m['is_active']] + + print(f"\nšŸ”Œ Loading {len(active_modules)} active module(s)...") + + for module in active_modules: + try: + # Import module's __init__.py + init_path = Path(module['path']) / '__init__.py' + if not init_path.exists(): + print(f" āš ļø {module['name']}: Missing __init__.py") + continue + + spec = importlib.util.spec_from_file_location( + f"modules.{module['module_key']}", + init_path + ) + module_package = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module_package) + + # Get blueprint from create_blueprint() + if hasattr(module_package, 'create_blueprint'): + blueprint = module_package.create_blueprint() + app.register_blueprint(blueprint) + print(f" āœ… {module['name']} loaded at {module.get('routes_prefix', '/unknown')}") + else: + print(f" āš ļø {module['name']}: Missing create_blueprint() function") + + except Exception as e: + print(f" āŒ Failed to load {module['name']}: {e}") + import traceback + traceback.print_exc() + + print("āœ… Module loading complete\n") + + +# Global instance +manager = ModuleManager() + + +def get_module_manager() -> ModuleManager: + """Get the global module manager instance""" + return manager \ No newline at end of file diff --git a/modules/conssheets/__init__.py b/modules/conssheets/__init__.py new file mode 100644 index 0000000..28ad6b5 --- /dev/null +++ b/modules/conssheets/__init__.py @@ -0,0 +1,20 @@ +""" +Consumption Sheets Module +Handles production lot tracking and consumption reporting +""" +from flask import Blueprint + +def create_blueprint(): + """Create and return the conssheets blueprint""" + bp = Blueprint( + 'conssheets', + __name__, + template_folder='templates', + url_prefix='/conssheets' + ) + + # Import and register routes + from .routes import register_routes + register_routes(bp) + + return bp \ No newline at end of file diff --git a/modules/conssheets/manifest.json b/modules/conssheets/manifest.json new file mode 100644 index 0000000..3588b4c --- /dev/null +++ b/modules/conssheets/manifest.json @@ -0,0 +1,11 @@ +{ + "module_key": "conssheets", + "name": "Consumption Sheets", + "version": "1.0.0", + "author": "STUFF", + "description": "Production lot tracking and consumption reporting with Excel export", + "requires_roles": ["owner", "admin", "staff"], + "routes_prefix": "/conssheets", + "has_migrations": true, + "dependencies": [] +} \ No newline at end of file diff --git a/modules/conssheets/migrations.py b/modules/conssheets/migrations.py new file mode 100644 index 0000000..1c8ee04 --- /dev/null +++ b/modules/conssheets/migrations.py @@ -0,0 +1,138 @@ +""" +Consumption Sheets Module - Database Migrations +Contains schema for all consumption tracking tables +""" + +def get_schema(): + """ + Returns the complete schema SQL for this module. + This is used when the module is installed. + """ + return """ + -- cons_processes - Master list of consumption sheet process types + CREATE TABLE IF NOT EXISTS cons_processes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + process_key TEXT UNIQUE NOT NULL, + process_name TEXT NOT NULL, + template_file BLOB, + template_filename TEXT, + rows_per_page INTEGER DEFAULT 30, + detail_start_row INTEGER DEFAULT 10, + detail_end_row INTEGER, + page_height INTEGER, + print_start_col TEXT DEFAULT 'A', + print_end_col TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by INTEGER NOT NULL, + is_active INTEGER DEFAULT 1, + FOREIGN KEY (created_by) REFERENCES Users(user_id) + ); + + -- cons_process_fields - Custom field definitions for each process + CREATE TABLE IF NOT EXISTS cons_process_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + process_id INTEGER NOT NULL, + table_type TEXT NOT NULL CHECK(table_type IN ('header', 'detail')), + field_name TEXT NOT NULL, + field_label TEXT NOT NULL, + field_type TEXT NOT NULL CHECK(field_type IN ('TEXT', 'INTEGER', 'REAL', 'DATE', 'DATETIME')), + max_length INTEGER, + is_required INTEGER DEFAULT 0, + is_duplicate_key INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + sort_order INTEGER DEFAULT 0, + excel_cell TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (process_id) REFERENCES cons_processes(id) + ); + + -- cons_sessions - Staff scanning sessions + CREATE TABLE IF NOT EXISTS cons_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + process_id INTEGER NOT NULL, + created_by INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + status TEXT DEFAULT 'active' CHECK(status IN ('active', 'archived')), + FOREIGN KEY (process_id) REFERENCES cons_processes(id), + FOREIGN KEY (created_by) REFERENCES Users(user_id) + ); + + -- cons_session_header_values - Flexible storage for header field values + CREATE TABLE IF NOT EXISTS cons_session_header_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + field_id INTEGER NOT NULL, + field_value TEXT, + FOREIGN KEY (session_id) REFERENCES cons_sessions(id), + FOREIGN KEY (field_id) REFERENCES cons_process_fields(id) + ); + + -- Indexes + CREATE INDEX IF NOT EXISTS idx_cons_process_fields_process ON cons_process_fields(process_id, table_type); + CREATE INDEX IF NOT EXISTS idx_cons_process_fields_active ON cons_process_fields(process_id, is_active); + CREATE INDEX IF NOT EXISTS idx_cons_sessions_process ON cons_sessions(process_id, status); + CREATE INDEX IF NOT EXISTS idx_cons_sessions_user ON cons_sessions(created_by, status); + """ + + +def get_migrations(): + """ + Returns list of migrations specific to this module. + Format: [(version, name, up_function), ...] + """ + + def migration_001_add_is_duplicate_key(conn): + """Add is_duplicate_key column to cons_process_fields""" + cursor = conn.cursor() + + # Check if column exists + cursor.execute('PRAGMA table_info(cons_process_fields)') + columns = [row[1] for row in cursor.fetchall()] + + if 'is_duplicate_key' not in columns: + cursor.execute('ALTER TABLE cons_process_fields ADD COLUMN is_duplicate_key INTEGER DEFAULT 0') + print(" Added is_duplicate_key column to cons_process_fields") + + def migration_002_add_detail_end_row(conn): + """Add detail_end_row column to cons_processes""" + cursor = conn.cursor() + + cursor.execute('PRAGMA table_info(cons_processes)') + columns = [row[1] for row in cursor.fetchall()] + + if 'detail_end_row' not in columns: + cursor.execute('ALTER TABLE cons_processes ADD COLUMN detail_end_row INTEGER') + print(" Added detail_end_row column to cons_processes") + + def migration_003_add_page_height(conn): + """Add page_height column to cons_processes""" + cursor = conn.cursor() + + cursor.execute('PRAGMA table_info(cons_processes)') + columns = [row[1] for row in cursor.fetchall()] + + if 'page_height' not in columns: + cursor.execute('ALTER TABLE cons_processes ADD COLUMN page_height INTEGER') + print(" Added page_height column to cons_processes") + + def migration_004_add_print_columns(conn): + """Add print_start_col and print_end_col to cons_processes""" + cursor = conn.cursor() + + cursor.execute('PRAGMA table_info(cons_processes)') + columns = [row[1] for row in cursor.fetchall()] + + if 'print_start_col' not in columns: + cursor.execute('ALTER TABLE cons_processes ADD COLUMN print_start_col TEXT DEFAULT "A"') + print(" Added print_start_col column to cons_processes") + + if 'print_end_col' not in columns: + cursor.execute('ALTER TABLE cons_processes ADD COLUMN print_end_col TEXT') + print(" Added print_end_col column to cons_processes") + + return [ + (1, 'add_is_duplicate_key', migration_001_add_is_duplicate_key), + (2, 'add_detail_end_row', migration_002_add_detail_end_row), + (3, 'add_page_height', migration_003_add_page_height), + (4, 'add_print_columns', migration_004_add_print_columns), + ] \ No newline at end of file diff --git a/modules/conssheets/routes.py b/modules/conssheets/routes.py new file mode 100644 index 0000000..f9db78b --- /dev/null +++ b/modules/conssheets/routes.py @@ -0,0 +1,1244 @@ +""" +Consumption Sheets Module - Routes +Converted from cons_sheets.py +""" +from flask import render_template, request, redirect, url_for, flash, jsonify, session, send_file +from db import query_db, execute_db +from utils import login_required, role_required +from datetime import datetime +import io + + +def register_routes(bp): + """Register all conssheets routes on the blueprint""" + + # ========================================================================= + # CONSUMPTION SHEETS ROUTES + # ========================================================================= + + @bp.route('/admin/consumption-sheets') + @role_required('owner', 'admin') + def admin_processes(): + """List all consumption sheet process types (Active or Archived)""" + show_archived = request.args.get('archived') == '1' + is_active_val = 0 if show_archived else 1 + + processes = query_db(''' + SELECT cp.*, + u.full_name as created_by_name, + (SELECT COUNT(*) FROM cons_process_fields WHERE process_id = cp.id) as field_count + FROM cons_processes cp + LEFT JOIN users u ON cp.created_by = u.user_id + WHERE cp.is_active = ? + ORDER BY cp.process_name ASC + ''', [is_active_val]) + + return render_template('cons_sheets/admin_processes.html', + processes=processes, + showing_archived=show_archived) + + + @bp.route('/admin/consumption-sheets/create', methods=['GET', 'POST']) + @role_required('owner', 'admin') + def create_process(): + """Create a new process type""" + if request.method == 'POST': + process_name = request.form.get('process_name', '').strip() + + if not process_name: + flash('Process name is required', 'danger') + return redirect(url_for('cons_sheets.create_process')) + + # Generate process_key from name (lowercase, underscores) + process_key = process_name.lower().replace(' ', '_').replace('-', '_') + # Remove any non-alphanumeric characters except underscore + process_key = ''.join(c for c in process_key if c.isalnum() or c == '_') + + # Check for duplicate key + existing = query_db('SELECT id FROM cons_processes WHERE process_key = ?', [process_key], one=True) + if existing: + flash(f'A process with key "{process_key}" already exists', 'danger') + return redirect(url_for('cons_sheets.create_process')) + + process_id = execute_db(''' + INSERT INTO cons_processes (process_key, process_name, created_by) + VALUES (?, ?, ?) + ''', [process_key, process_name, session['user_id']]) + + # Create dynamic detail table for this process + create_process_detail_table(process_key) + + flash(f'Process "{process_name}" created successfully!', 'success') + return redirect(url_for('cons_sheets.process_detail', process_id=process_id)) + + return render_template('cons_sheets/create_process.html') + + + + def get_db_path(): + """Get the database path""" + db_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'database', 'scanlook.db') + print(f"DEBUG: Database path is: {db_path}") + print(f"DEBUG: Path exists: {os.path.exists(db_path)}") + return db_path + + + def create_process_detail_table(process_key): + """Create the dynamic detail table for a process with system columns""" + table_name = f'cons_proc_{process_key}_details' + print(f"DEBUG: Creating table {table_name}") + + try: + db_path = get_db_path() + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute(f''' + CREATE TABLE IF NOT EXISTS {table_name} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + scanned_by INTEGER NOT NULL, + scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP, + duplicate_status TEXT DEFAULT 'normal' CHECK(duplicate_status IN ('normal', 'dup_same_session', 'dup_other_session')), + duplicate_info TEXT, + comment TEXT, + is_deleted INTEGER DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES cons_sessions(id), + FOREIGN KEY (scanned_by) REFERENCES Users(user_id) + ) + ''') + + # Create index on session_id + cursor.execute(f'CREATE INDEX IF NOT EXISTS idx_{process_key}_session ON {table_name}(session_id, is_deleted)') + + conn.commit() + conn.close() + print(f"DEBUG: Table {table_name} created successfully!") + except Exception as e: + print(f"ERROR creating table {table_name}: {e}") + + + def add_column_to_detail_table(process_key, field_name, field_type): + """Add a column to the process detail table""" + table_name = f'cons_proc_{process_key}_details' + + # Map field types to SQLite types + sqlite_type = 'TEXT' + if field_type == 'INTEGER': + sqlite_type = 'INTEGER' + elif field_type == 'REAL': + sqlite_type = 'REAL' + + conn = sqlite3.connect(get_db_path()) + cursor = conn.cursor() + + try: + cursor.execute(f'ALTER TABLE {table_name} ADD COLUMN {field_name} {sqlite_type}') + conn.commit() + except Exception as e: + # Column might already exist + print(f"Note: Could not add column {field_name}: {e}") + finally: + conn.close() + + + def rename_column_in_detail_table(process_key, old_name, new_name): + """Rename a column (for soft delete)""" + table_name = f'cons_proc_{process_key}_details' + + conn = sqlite3.connect(get_db_path()) + cursor = conn.cursor() + + try: + cursor.execute(f'ALTER TABLE {table_name} RENAME COLUMN {old_name} TO {new_name}') + conn.commit() + except Exception as e: + print(f"Note: Could not rename column {old_name}: {e}") + finally: + conn.close() + + + @bp.route('/admin/consumption-sheets//delete', methods=['POST']) + @role_required('owner', 'admin') + def delete_process(process_id): + """Soft-delete a process type (Archive it)""" + # Check if process exists + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + # Soft delete: Set is_active = 0 + # The existing admin_processes route already filters for is_active=1, + # so this will effectively hide it from the list. + execute_db('UPDATE cons_processes SET is_active = 0 WHERE id = ?', [process_id]) + + flash(f'Process "{process["process_name"]}" has been deleted.', 'success') + return redirect(url_for('cons_sheets.admin_processes')) + + + @bp.route('/admin/consumption-sheets//restore', methods=['POST']) + @role_required('owner', 'admin') + def restore_process(process_id): + """Restore a soft-deleted process type""" + execute_db('UPDATE cons_processes SET is_active = 1 WHERE id = ?', [process_id]) + flash('Process has been restored.', 'success') + return redirect(url_for('cons_sheets.admin_processes', archived=1)) + + + + @bp.route('/admin/consumption-sheets/') + @role_required('owner', 'admin') + def process_detail(process_id): + """Process detail page - Database and Excel configuration""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + # Get header fields + header_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'header' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + # Get detail fields + detail_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + return render_template('cons_sheets/process_detail.html', + process=process, + header_fields=header_fields, + detail_fields=detail_fields) + + + @bp.route('/admin/consumption-sheets//fields') + @role_required('owner', 'admin') + def process_fields(process_id): + """Configure database fields for a process""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + # Get header fields + header_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'header' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + # Get detail fields + detail_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + return render_template('cons_sheets/process_fields.html', + process=process, + header_fields=header_fields, + detail_fields=detail_fields) + + + @bp.route('/admin/consumption-sheets//template') + @role_required('owner', 'admin') + def process_template(process_id): + """Configure Excel template for a process""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + # Get all active fields for mapping display + header_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'header' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + detail_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + return render_template('cons_sheets/process_template.html', + process=process, + header_fields=header_fields, + detail_fields=detail_fields) + + + @bp.route('/admin/consumption-sheets//template/upload', methods=['POST']) + @role_required('owner', 'admin') + def upload_template(process_id): + """Upload Excel template file""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + if 'template_file' not in request.files: + flash('No file selected', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + file = request.files['template_file'] + + if file.filename == '': + flash('No file selected', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + if not file.filename.endswith('.xlsx'): + flash('Only .xlsx files are allowed', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + # Read file as binary + template_data = file.read() + filename = file.filename + + # Store in database + execute_db(''' + UPDATE cons_processes + SET template_file = ?, template_filename = ? + WHERE id = ? + ''', [template_data, filename, process_id]) + + flash(f'Template "{filename}" uploaded successfully!', 'success') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + + @bp.route('/admin/consumption-sheets//template/settings', methods=['POST']) + @role_required('owner', 'admin') + def update_template_settings(process_id): + """Update template page settings""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + rows_per_page = request.form.get('rows_per_page', 30) + detail_start_row = request.form.get('detail_start_row', 10) + page_height = request.form.get('page_height') + print_start_col = request.form.get('print_start_col', 'A').strip().upper() + print_end_col = request.form.get('print_end_col', '').strip().upper() + + try: + rows_per_page = int(rows_per_page) + detail_start_row = int(detail_start_row) + # We enforce page_height is required now + page_height = int(page_height) if page_height and page_height.strip() else None + + if not page_height: + flash('Page Height is required for the new strategy', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + except ValueError: + flash('Invalid number values', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + # Update query - We ignore detail_end_row (leave it as is or null) + execute_db(''' + UPDATE cons_processes + SET rows_per_page = ?, detail_start_row = ?, page_height = ?, + print_start_col = ?, print_end_col = ? + WHERE id = ? + ''', [rows_per_page, detail_start_row, page_height, print_start_col, print_end_col, process_id]) + + flash('Settings updated successfully!', 'success') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + @bp.route('/admin/consumption-sheets//template/download') + @role_required('owner', 'admin') + def download_template(process_id): + """Download the stored Excel template""" + from flask import Response + + process = query_db('SELECT template_file, template_filename FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process or not process['template_file']: + flash('No template found', 'danger') + return redirect(url_for('cons_sheets.process_template', process_id=process_id)) + + return Response( + process['template_file'], + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f'attachment; filename={process["template_filename"]}'} + ) + + + @bp.route('/admin/consumption-sheets//fields/add/', methods=['GET', 'POST']) + @role_required('owner', 'admin') + def add_field(process_id, table_type): + """Add a new field to a process""" + if table_type not in ['header', 'detail']: + flash('Invalid table type', 'danger') + return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) + + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + if request.method == 'POST': + field_label = request.form.get('field_label', '').strip() + field_type = request.form.get('field_type', 'TEXT') + max_length = request.form.get('max_length', '') + is_required = 1 if request.form.get('is_required') else 0 + excel_cell = request.form.get('excel_cell', '').strip().upper() + + if not field_label: + flash('Field label is required', 'danger') + return redirect(url_for('cons_sheets.add_field', process_id=process_id, table_type=table_type)) + + # Generate field_name from label (lowercase, underscores) + field_name = field_label.lower().replace(' ', '_').replace('-', '_') + field_name = ''.join(c for c in field_name if c.isalnum() or c == '_') + + # Check for duplicate field name in this process/table_type + existing = query_db(''' + SELECT id FROM cons_process_fields + WHERE process_id = ? AND table_type = ? AND field_name = ? AND is_active = 1 + ''', [process_id, table_type, field_name], one=True) + + if existing: + flash(f'A field with name "{field_name}" already exists', 'danger') + return redirect(url_for('cons_sheets.add_field', process_id=process_id, table_type=table_type)) + + # Get next sort_order + max_sort = query_db(''' + SELECT MAX(sort_order) as max_sort FROM cons_process_fields + WHERE process_id = ? AND table_type = ? + ''', [process_id, table_type], one=True) + sort_order = (max_sort['max_sort'] or 0) + 1 + + # For detail fields, check for duplicate key checkbox + is_duplicate_key = 1 if (table_type == 'detail' and request.form.get('is_duplicate_key')) else 0 + + # Insert the field + execute_db(''' + INSERT INTO cons_process_fields + (process_id, table_type, field_name, field_label, field_type, max_length, is_required, is_duplicate_key, sort_order, excel_cell) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', [process_id, table_type, field_name, field_label, field_type, + int(max_length) if max_length else None, is_required, is_duplicate_key, sort_order, excel_cell or None]) + + # For detail fields, also add the column to the dynamic table + if table_type == 'detail': + add_column_to_detail_table(process['process_key'], field_name, field_type) + + flash(f'Field "{field_label}" added successfully!', 'success') + return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) + + return render_template('cons_sheets/add_field.html', + process=process, + table_type=table_type) + + + @bp.route('/admin/consumption-sheets//fields//edit', methods=['GET', 'POST']) + @role_required('owner', 'admin') + def edit_field(process_id, field_id): + """Edit an existing field""" + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + field = query_db('SELECT * FROM cons_process_fields WHERE id = ? AND process_id = ?', [field_id, process_id], one=True) + + if not process or not field: + flash('Process or field not found', 'danger') + return redirect(url_for('cons_sheets.admin_processes')) + + if request.method == 'POST': + field_label = request.form.get('field_label', '').strip() + field_type = request.form.get('field_type', 'TEXT') + max_length = request.form.get('max_length', '') + is_required = 1 if request.form.get('is_required') else 0 + is_duplicate_key = 1 if (field['table_type'] == 'detail' and request.form.get('is_duplicate_key')) else 0 + excel_cell = request.form.get('excel_cell', '').strip().upper() + + if not field_label: + flash('Field label is required', 'danger') + return redirect(url_for('cons_sheets.edit_field', process_id=process_id, field_id=field_id)) + + execute_db(''' + UPDATE cons_process_fields + SET field_label = ?, field_type = ?, max_length = ?, is_required = ?, is_duplicate_key = ?, excel_cell = ? + WHERE id = ? + ''', [field_label, field_type, int(max_length) if max_length else None, is_required, is_duplicate_key, excel_cell or None, field_id]) + + flash(f'Field "{field_label}" updated successfully!', 'success') + return redirect(url_for('cons_sheets.process_fields', process_id=process_id)) + + return render_template('cons_sheets/edit_field.html', + process=process, + field=field) + + + @bp.route('/admin/consumption-sheets//fields//delete', methods=['POST']) + @role_required('owner', 'admin') + def delete_field(process_id, field_id): + """Soft-delete a field (rename column, set is_active = 0)""" + field = query_db('SELECT * FROM cons_process_fields WHERE id = ? AND process_id = ?', [field_id, process_id], one=True) + process = query_db('SELECT * FROM cons_processes WHERE id = ?', [process_id], one=True) + + if not field or not process: + return jsonify({'success': False, 'message': 'Field not found'}) + + # Soft delete: set is_active = 0 + execute_db('UPDATE cons_process_fields SET is_active = 0 WHERE id = ?', [field_id]) + + # For detail fields, rename the column to preserve data + if field['table_type'] == 'detail': + old_name = field['field_name'] + new_name = f"Del_{field_id}_{old_name}" + rename_column_in_detail_table(process['process_key'], old_name, new_name) + + return jsonify({'success': True, 'message': f'Field "{field["field_label"]}" deleted'}) + + + + + def get_detail_table_name(process_key): + """Get the dynamic detail table name for a process""" + return f'cons_proc_{process_key}_details' + + + def get_duplicate_key_field(process_id): + """Get the field marked as duplicate key for a process""" + return query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_duplicate_key = 1 AND is_active = 1 + LIMIT 1 + ''', [process_id], one=True) + + + @bp.route('/cons-sheets') + @login_required + def index(): + """Consumption Sheets module landing - show user's sessions""" + user_id = session.get('user_id') + + # Check if user has access to this module + has_access = query_db(''' + SELECT 1 FROM UserModules um + JOIN Modules m ON um.module_id = m.module_id + WHERE um.user_id = ? AND m.module_key = 'cons_sheets' AND m.is_active = 1 + ''', [user_id], one=True) + + if not has_access: + flash('You do not have access to this module', 'danger') + return redirect(url_for('home')) + + # Get user's active sessions with process info and scan counts + active_sessions = query_db(''' + SELECT cs.*, cp.process_name, cp.process_key + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.created_by = ? AND cs.status = 'active' + ORDER BY cs.created_at DESC + ''', [user_id]) + + # Get scan counts for each session from their dynamic tables + sessions_with_counts = [] + for sess in active_sessions: + table_name = get_detail_table_name(sess['process_key']) + try: + count_result = query_db(f''' + SELECT COUNT(*) as scan_count FROM {table_name} + WHERE session_id = ? AND is_deleted = 0 + ''', [sess['id']], one=True) + sess_dict = dict(sess) + sess_dict['scan_count'] = count_result['scan_count'] if count_result else 0 + except: + sess_dict = dict(sess) + sess_dict['scan_count'] = 0 + sessions_with_counts.append(sess_dict) + + # Get available process types for creating new sessions + processes = query_db(''' + SELECT * FROM cons_processes WHERE is_active = 1 ORDER BY process_name + ''') + + return render_template('cons_sheets/staff_index.html', + sessions=sessions_with_counts, + processes=processes) + + + @bp.route('/cons-sheets/new/', methods=['GET', 'POST']) + @login_required + def new_session(process_id): + """Create a new scanning session - enter header info""" + process = query_db('SELECT * FROM cons_processes WHERE id = ? AND is_active = 1', [process_id], one=True) + + if not process: + flash('Process not found', 'danger') + return redirect(url_for('cons_sheets.index')) + + # Get header fields for this process + header_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'header' AND is_active = 1 + ORDER BY sort_order, id + ''', [process_id]) + + if request.method == 'POST': + # Validate required fields + missing_required = [] + for field in header_fields: + if field['is_required']: + value = request.form.get(field['field_name'], '').strip() + if not value: + missing_required.append(field['field_label']) + + if missing_required: + flash(f'Required fields missing: {", ".join(missing_required)}', 'danger') + return render_template('cons_sheets/new_session.html', + process=process, + header_fields=header_fields, + form_data=request.form) + + # Create the session + session_id = execute_db(''' + INSERT INTO cons_sessions (process_id, created_by) + VALUES (?, ?) + ''', [process_id, session['user_id']]) + + # Save header field values + for field in header_fields: + value = request.form.get(field['field_name'], '').strip() + if value: + execute_db(''' + INSERT INTO cons_session_header_values (session_id, field_id, field_value) + VALUES (?, ?, ?) + ''', [session_id, field['id'], value]) + + flash('Session created! Start scanning lots.', 'success') + return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) + + return render_template('cons_sheets/new_session.html', + process=process, + header_fields=header_fields, + form_data={}) + + + @bp.route('/cons-sheets/session/') + @login_required + def scan_session(session_id): + """Main scanning interface for a session""" + # Get session with process info + sess = query_db(''' + SELECT cs.*, cp.process_name, cp.process_key, cp.id as process_id + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.id = ? + ''', [session_id], one=True) + + if not sess: + flash('Session not found', 'danger') + return redirect(url_for('cons_sheets.index')) + + if sess['status'] == 'archived': + flash('This session has been archived', 'warning') + return redirect(url_for('cons_sheets.index')) + + # Get header values for display + header_values = query_db(''' + SELECT cpf.field_label, cpf.field_name, cshv.field_value + FROM cons_session_header_values cshv + JOIN cons_process_fields cpf ON cshv.field_id = cpf.id + WHERE cshv.session_id = ? + ORDER BY cpf.sort_order, cpf.id + ''', [session_id]) + + # Get detail fields for this process (convert to dicts for JSON serialization) + detail_fields_rows = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order, id + ''', [sess['process_id']]) + detail_fields = [dict(row) for row in detail_fields_rows] if detail_fields_rows else [] + + # Get scanned details from the dynamic table + table_name = get_detail_table_name(sess['process_key']) + scans = query_db(f''' + SELECT t.*, u.full_name as scanned_by_name + FROM {table_name} t + JOIN Users u ON t.scanned_by = u.user_id + WHERE t.session_id = ? AND t.is_deleted = 0 + ORDER BY t.scanned_at DESC + ''', [session_id]) + + # Get the duplicate key field (convert to dict for JSON) + dup_key_field_row = get_duplicate_key_field(sess['process_id']) + dup_key_field = dict(dup_key_field_row) if dup_key_field_row else None + + return render_template('cons_sheets/scan_session.html', + session=sess, + header_values=header_values, + scans=scans, + detail_fields=detail_fields, + dup_key_field=dup_key_field) + + + @bp.route('/cons-sheets/session//scan', methods=['POST']) + @login_required + def scan_lot(session_id): + """Process a scan with duplicate detection using dynamic tables""" + sess = query_db(''' + SELECT cs.*, cp.process_key, cp.id as process_id + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.id = ? AND cs.status = 'active' + ''', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found or archived'}) + + data = request.get_json() + field_values = data.get('field_values', {}) # Dict of field_name: value + confirm_duplicate = data.get('confirm_duplicate', False) + check_only = data.get('check_only', False) + + # Get the duplicate key field + dup_key_field = get_duplicate_key_field(sess['process_id']) + + if not dup_key_field: + return jsonify({'success': False, 'message': 'No duplicate key field configured for this process'}) + + dup_key_value = field_values.get(dup_key_field['field_name'], '').strip() + + if not dup_key_value: + return jsonify({'success': False, 'message': f'{dup_key_field["field_label"]} is required'}) + + table_name = get_detail_table_name(sess['process_key']) + + # Check for duplicates in SAME session + same_session_dup = query_db(f''' + SELECT * FROM {table_name} + WHERE session_id = ? AND {dup_key_field['field_name']} = ? AND is_deleted = 0 + ''', [session_id, dup_key_value], one=True) + + # Check for duplicates in OTHER sessions (need to check all sessions of same process type) + other_session_dup = query_db(f''' + SELECT t.*, cs.id as other_session_id, cs.created_at as other_session_date, + u.full_name as other_user, + (SELECT field_value FROM cons_session_header_values + WHERE session_id = cs.id AND field_id = ( + SELECT id FROM cons_process_fields + WHERE process_id = cs.process_id AND field_name LIKE '%wo%' AND is_active = 1 LIMIT 1 + )) as other_wo + FROM {table_name} t + JOIN cons_sessions cs ON t.session_id = cs.id + JOIN Users u ON t.scanned_by = u.user_id + WHERE t.{dup_key_field['field_name']} = ? AND t.session_id != ? AND t.is_deleted = 0 + ORDER BY t.scanned_at DESC + LIMIT 1 + ''', [dup_key_value, session_id], one=True) + + duplicate_status = 'normal' + duplicate_info = None + needs_confirmation = False + + if same_session_dup: + duplicate_status = 'dup_same_session' + duplicate_info = 'Already scanned in this session' + needs_confirmation = True + elif other_session_dup: + duplicate_status = 'dup_other_session' + dup_date = other_session_dup['other_session_date'][:10] if other_session_dup['other_session_date'] else 'Unknown' + dup_user = other_session_dup['other_user'] or 'Unknown' + dup_wo = other_session_dup['other_wo'] or 'N/A' + duplicate_info = f"Previously scanned on {dup_date} by {dup_user} on WO {dup_wo}" + needs_confirmation = True + + # If just checking, return early + if check_only: + if needs_confirmation: + return jsonify({ + 'success': False, + 'needs_confirmation': True, + 'duplicate_status': duplicate_status, + 'duplicate_info': duplicate_info, + 'message': duplicate_info + }) + return jsonify({'success': True, 'needs_confirmation': False}) + + # If needs confirmation and not confirmed, ask user + if needs_confirmation and not confirm_duplicate: + return jsonify({ + 'success': False, + 'needs_confirmation': True, + 'duplicate_status': duplicate_status, + 'duplicate_info': duplicate_info, + 'message': duplicate_info + }) + + # Get all active detail fields for this process + detail_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order, id + ''', [sess['process_id']]) + + # Build dynamic INSERT statement + field_names = ['session_id', 'scanned_by', 'duplicate_status', 'duplicate_info'] + field_placeholders = ['?', '?', '?', '?'] + values = [session_id, session['user_id'], duplicate_status, duplicate_info] + + for field in detail_fields: + field_names.append(field['field_name']) + field_placeholders.append('?') + values.append(field_values.get(field['field_name'], '')) + + insert_sql = f''' + INSERT INTO {table_name} ({', '.join(field_names)}) + VALUES ({', '.join(field_placeholders)}) + ''' + + detail_id = execute_db(insert_sql, values) + + # If this is a same-session duplicate, update the original scan too + updated_entry_ids = [] + if duplicate_status == 'dup_same_session' and same_session_dup: + execute_db(f''' + UPDATE {table_name} + SET duplicate_status = 'dup_same_session', duplicate_info = 'Duplicate' + WHERE id = ? + ''', [same_session_dup['id']]) + updated_entry_ids.append(same_session_dup['id']) + + return jsonify({ + 'success': True, + 'detail_id': detail_id, + 'duplicate_status': duplicate_status, + 'updated_entry_ids': updated_entry_ids + }) + + + @bp.route('/cons-sheets/session//detail/') + @login_required + def get_detail(session_id, detail_id): + """Get detail info for editing""" + sess = query_db(''' + SELECT cs.*, cp.process_key + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.id = ? + ''', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + table_name = get_detail_table_name(sess['process_key']) + + detail = query_db(f''' + SELECT t.*, u.full_name as scanned_by_name + FROM {table_name} t + JOIN Users u ON t.scanned_by = u.user_id + WHERE t.id = ? AND t.session_id = ? + ''', [detail_id, session_id], one=True) + + if not detail: + return jsonify({'success': False, 'message': 'Detail not found'}) + + return jsonify({'success': True, 'detail': dict(detail)}) + + + @bp.route('/cons-sheets/session//detail//update', methods=['POST']) + @login_required + def update_detail(session_id, detail_id): + """Update a scanned detail""" + sess = query_db(''' + SELECT cs.*, cp.process_key, cp.id as process_id + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.id = ? + ''', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + table_name = get_detail_table_name(sess['process_key']) + + detail = query_db(f'SELECT * FROM {table_name} WHERE id = ? AND session_id = ?', [detail_id, session_id], one=True) + + if not detail: + return jsonify({'success': False, 'message': 'Detail not found'}) + + # Check permission + if detail['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + data = request.get_json() + field_values = data.get('field_values', {}) + comment = data.get('comment', '') + + # Get all active detail fields for this process + detail_fields = query_db(''' + SELECT * FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ''', [sess['process_id']]) + + # Build dynamic UPDATE statement + set_clauses = ['comment = ?'] + values = [comment] + + for field in detail_fields: + if field['field_name'] in field_values: + set_clauses.append(f"{field['field_name']} = ?") + values.append(field_values[field['field_name']]) + + values.append(detail_id) + + update_sql = f''' + UPDATE {table_name} + SET {', '.join(set_clauses)} + WHERE id = ? + ''' + + execute_db(update_sql, values) + + return jsonify({'success': True}) + + + @bp.route('/cons-sheets/session//detail//delete', methods=['POST']) + @login_required + def delete_detail(session_id, detail_id): + """Soft-delete a scanned detail""" + sess = query_db(''' + SELECT cs.*, cp.process_key + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.id = ? + ''', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + table_name = get_detail_table_name(sess['process_key']) + + detail = query_db(f'SELECT * FROM {table_name} WHERE id = ? AND session_id = ?', [detail_id, session_id], one=True) + + if not detail: + return jsonify({'success': False, 'message': 'Detail not found'}) + + # Check permission + if detail['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + execute_db(f'UPDATE {table_name} SET is_deleted = 1 WHERE id = ?', [detail_id]) + + return jsonify({'success': True}) + + + @bp.route('/cons-sheets/session//archive', methods=['POST']) + @login_required + def archive_session(session_id): + """Archive (soft-delete) a session""" + sess = query_db('SELECT * FROM cons_sessions WHERE id = ?', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + # Check permission + if sess['created_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + execute_db('UPDATE cons_sessions SET status = "archived" WHERE id = ?', [session_id]) + + return jsonify({'success': True}) + + + @bp.route('/cons-sheets/session//template') + @login_required + def download_import_template(session_id): + """Generate a blank Excel template for bulk import""" + from flask import Response # <--- ADDED THIS + from io import BytesIO + import openpyxl + + # Get Process ID + sess = query_db('SELECT process_id FROM cons_sessions WHERE id = ?', [session_id], one=True) + if not sess: return redirect(url_for('cons_sheets.index')) + + # Get Detail Fields + fields = query_db(''' + SELECT field_name, field_label + FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ORDER BY sort_order + ''', [sess['process_id']]) + + # Create Workbook + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Import Data" + + # Write Header Row (Field Names) + headers = [f['field_name'] for f in fields] + ws.append(headers) + + output = BytesIO() + wb.save(output) + output.seek(0) + + return Response( + output.getvalue(), + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': 'attachment; filename=import_template.xlsx'} + ) + + @bp.route('/cons-sheets/session//import', methods=['POST']) + @login_required + def import_session_data(session_id): + """Bulk import detail rows from Excel""" + # Import EVERYTHING locally to avoid NameErrors + import openpyxl + from datetime import datetime + from flask import request, flash, redirect, url_for, session + + # 1. Get Session Info + sess = query_db(''' + SELECT cs.*, cp.process_key + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.id = ? + ''', [session_id], one=True) + + if not sess: + flash('Session not found', 'danger') + return redirect(url_for('cons_sheets.index')) + + # 2. Check File + if 'file' not in request.files: + flash('No file uploaded', 'danger') + return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) + + file = request.files['file'] + if file.filename == '': + flash('No file selected', 'danger') + return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) + + try: + # 3. Read Excel + wb = openpyxl.load_workbook(file) + ws = wb.active + + # Get headers from first row + headers = [cell.value for cell in ws[1]] + + # Get valid field names for this process + valid_fields = query_db(''' + SELECT field_name + FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 + ''', [sess['process_id']]) + valid_field_names = [f['field_name'] for f in valid_fields] + + # Map Excel Columns to DB Fields + col_mapping = {} + for idx, header in enumerate(headers): + if header and header in valid_field_names: + col_mapping[idx] = header + + if not col_mapping: + flash('Error: No matching columns found in Excel. Please use the template.', 'danger') + return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) + + # 4. Process Rows + table_name = f"cons_proc_{sess['process_key']}_details" + rows_inserted = 0 + + # Get User ID safely from session + user_id = session.get('user_id') + + for row in ws.iter_rows(min_row=2, values_only=True): + if not any(row): continue + + data = {} + for col_idx, value in enumerate(row): + if col_idx in col_mapping: + data[col_mapping[col_idx]] = value + + if not data: continue + + # Add Metadata + data['session_id'] = session_id + data['scanned_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + data['scanned_by'] = user_id + + # REMOVED: data['is_valid'] = 1 (This column does not exist) + + data['is_deleted'] = 0 + + # Dynamic Insert SQL + columns = ', '.join(data.keys()) + placeholders = ', '.join(['?'] * len(data)) + values = list(data.values()) + + sql = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})" + execute_db(sql, values) + rows_inserted += 1 + + flash(f'Successfully imported {rows_inserted} records!', 'success') + + except Exception as e: + # This will catch any other errors and show them to you + flash(f'Import Error: {str(e)}', 'danger') + print(f"DEBUG IMPORT ERROR: {str(e)}") # Print to console for good measure + + return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) + + @bp.route('/cons-sheets/session//export') + @login_required + def export_session(session_id): + """Export session: Hide Rows Strategy + Manual Column Widths""" + from flask import Response + from io import BytesIO + import openpyxl + # Correct imports for newer openpyxl + from openpyxl.utils.cell import coordinate_from_string, get_column_letter + from openpyxl.worksheet.pagebreak import Break + from datetime import datetime + import math + + # --- FIX 1: Update SQL to fetch the new columns --- + sess = query_db(''' + SELECT cs.*, cp.process_name, cp.process_key, cp.id as process_id, + cp.template_file, cp.template_filename, + cp.rows_per_page, cp.detail_start_row, cp.page_height, + cp.print_start_col, cp.print_end_col + FROM cons_sessions cs + JOIN cons_processes cp ON cs.process_id = cp.id + WHERE cs.id = ? + ''', [session_id], one=True) + + if not sess or not sess['template_file']: + flash('Session or Template not found', 'danger') + return redirect(url_for('cons_sheets.index')) + + # Validation + page_height = sess['page_height'] + rows_per_page = sess['rows_per_page'] or 30 + detail_start_row = sess['detail_start_row'] or 10 + + if not page_height: + flash('Configuration Error: Page Height is not set.', 'danger') + return redirect(url_for('cons_sheets.scan_session', session_id=session_id)) + + # Get Data + header_fields = query_db(''' + SELECT cpf.field_name, cpf.excel_cell, cshv.field_value + FROM cons_process_fields cpf + LEFT JOIN cons_session_header_values cshv ON cpf.id = cshv.field_id AND cshv.session_id = ? + WHERE cpf.process_id = ? AND cpf.table_type = 'header' AND cpf.is_active = 1 AND cpf.excel_cell IS NOT NULL + ''', [session_id, sess['process_id']]) + + detail_fields = query_db(''' + SELECT field_name, excel_cell, field_type + FROM cons_process_fields + WHERE process_id = ? AND table_type = 'detail' AND is_active = 1 AND excel_cell IS NOT NULL + ORDER BY sort_order, id + ''', [sess['process_id']]) + + table_name = f'cons_proc_{sess["process_key"]}_details' + scans = query_db(f''' + SELECT * FROM {table_name} + WHERE session_id = ? AND is_deleted = 0 + ORDER BY scanned_at ASC + ''', [session_id]) + + # Setup Excel + wb = openpyxl.load_workbook(BytesIO(sess['template_file'])) + ws = wb.active + + # Clear existing breaks + ws.row_breaks.brk = [] + ws.col_breaks.brk = [] + + # Calculate Pages Needed + total_items = len(scans) + total_pages = math.ceil(total_items / rows_per_page) if total_items > 0 else 1 + + # --- MAIN LOOP --- + for page_idx in range(total_pages): + + # 1. Fill Header + for field in header_fields: + if field['excel_cell'] and field['field_value']: + try: + col_letter, row_str = coordinate_from_string(field['excel_cell']) + base_row = int(row_str) + target_row = base_row + (page_idx * page_height) + ws[f"{col_letter}{target_row}"] = field['field_value'] + except: pass + + # 2. Fill Details + start_idx = page_idx * rows_per_page + end_idx = start_idx + rows_per_page + page_scans = scans[start_idx:end_idx] + + for i, scan in enumerate(page_scans): + target_row = detail_start_row + (page_idx * page_height) + i + for field in detail_fields: + if field['excel_cell']: + try: + col_letter = field['excel_cell'].upper().strip() + cell_ref = f"{col_letter}{target_row}" + value = scan[field['field_name']] + if field['field_type'] == 'REAL' and value: value = float(value) + elif field['field_type'] == 'INTEGER' and value: value = int(value) + ws[cell_ref] = value + except: pass + + # 3. Force Page Break (BEFORE the new header) + if page_idx < total_pages - 1: + next_page_start_row = ((page_idx + 1) * page_height) # No +1 here! + ws.row_breaks.append(Break(id=next_page_start_row)) + + # --- STEP 3: CLEANUP (Hide Unused Rows) --- + last_used_row = (total_pages * page_height) + SAFE_MAX_ROW = 5000 + + for row_num in range(last_used_row + 1, SAFE_MAX_ROW): + ws.row_dimensions[row_num].hidden = True + + # --- FINAL POLISH (Manual Widths) --- + + # --- FIX 2: Use bracket notation (sess['col']) instead of .get() --- + # We use 'or' to provide defaults if the DB value is None + start_col = sess['print_start_col'] or 'A' + + if sess['print_end_col']: + end_col = sess['print_end_col'] + else: + # Fallback to auto-detection if user left it blank + end_col = get_column_letter(ws.max_column) + + # Set Print Area + ws.print_area = f"{start_col}1:{end_col}{last_used_row}" + + if ws.sheet_properties.pageSetUpPr: + ws.sheet_properties.pageSetUpPr.fitToPage = False + + # Save + output = BytesIO() + wb.save(output) + output.seek(0) + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + base_filename = f"{sess['process_key']}_{session_id}_{timestamp}" + + return Response( + output.getvalue(), + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + headers={'Content-Disposition': f'attachment; filename={base_filename}.xlsx'} + ) \ No newline at end of file diff --git a/templates/cons_sheets/add_field.html b/modules/conssheets/templates/conssheets/add_field.html similarity index 100% rename from templates/cons_sheets/add_field.html rename to modules/conssheets/templates/conssheets/add_field.html diff --git a/modules/conssheets/templates/conssheets/admin_processes.html b/modules/conssheets/templates/conssheets/admin_processes.html new file mode 100644 index 0000000..6ca23cc --- /dev/null +++ b/modules/conssheets/templates/conssheets/admin_processes.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block title %}Consumption Sheets - Admin - ScanLook{% endblock %} + +{% block content %} +
+
+
+ + Back to Admin + + +
+

+ {{ 'Archived Processes' if showing_archived else 'Consumption Sheets' }} +

+ +

Manage process types and templates

+ + {% if showing_archived %} + + Return to Active List + + {% else %} + + View Archived Processes + + {% endif %} +
+
+ + + + New Process + +
+ + {% if processes %} +
+ {% for process in processes %} +
+
+
+

{{ process.process_name }}

+ + {{ process.field_count or 0 }} fields + +
+ + {% if showing_archived %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+ +
+
+ Key: + {{ process.process_key }} +
+
+ Created: + {{ process.created_at[:16] if process.created_at else 'N/A' }} +
+
+ By: + {{ process.created_by_name or 'Unknown' }} +
+
+ Template: + {{ 'āœ… Uploaded' if process.template_file else 'āŒ None' }} +
+
+ + +
+ + + {% endfor %} +
+ {% else %} +
+
šŸ“
+

No Processes Defined

+

Create a process type to get started (e.g., "AD WIP")

+ + Create First Process + +
+ {% endif %} +
+{% endblock %} diff --git a/templates/cons_sheets/create_process.html b/modules/conssheets/templates/conssheets/create_process.html similarity index 100% rename from templates/cons_sheets/create_process.html rename to modules/conssheets/templates/conssheets/create_process.html diff --git a/templates/cons_sheets/edit_field.html b/modules/conssheets/templates/conssheets/edit_field.html similarity index 100% rename from templates/cons_sheets/edit_field.html rename to modules/conssheets/templates/conssheets/edit_field.html diff --git a/templates/cons_sheets/new_session.html b/modules/conssheets/templates/conssheets/new_session.html similarity index 100% rename from templates/cons_sheets/new_session.html rename to modules/conssheets/templates/conssheets/new_session.html diff --git a/templates/cons_sheets/process_detail.html b/modules/conssheets/templates/conssheets/process_detail.html similarity index 100% rename from templates/cons_sheets/process_detail.html rename to modules/conssheets/templates/conssheets/process_detail.html diff --git a/templates/cons_sheets/process_fields.html b/modules/conssheets/templates/conssheets/process_fields.html similarity index 100% rename from templates/cons_sheets/process_fields.html rename to modules/conssheets/templates/conssheets/process_fields.html diff --git a/templates/cons_sheets/process_template.html b/modules/conssheets/templates/conssheets/process_template.html similarity index 100% rename from templates/cons_sheets/process_template.html rename to modules/conssheets/templates/conssheets/process_template.html diff --git a/templates/cons_sheets/scan_session.html b/modules/conssheets/templates/conssheets/scan_session.html similarity index 100% rename from templates/cons_sheets/scan_session.html rename to modules/conssheets/templates/conssheets/scan_session.html diff --git a/templates/cons_sheets/staff_index.html b/modules/conssheets/templates/conssheets/staff_index.html similarity index 100% rename from templates/cons_sheets/staff_index.html rename to modules/conssheets/templates/conssheets/staff_index.html diff --git a/modules/invcount/__init__.py b/modules/invcount/__init__.py new file mode 100644 index 0000000..d1f6275 --- /dev/null +++ b/modules/invcount/__init__.py @@ -0,0 +1,20 @@ +""" +Inventory Counts Module +Handles cycle counting and physical inventory workflows +""" +from flask import Blueprint + +def create_blueprint(): + """Create and return the invcount blueprint""" + bp = Blueprint( + 'invcount', + __name__, + template_folder='templates', + url_prefix='/invcount' + ) + + # Import and register routes + from .routes import register_routes + register_routes(bp) + + return bp \ No newline at end of file diff --git a/modules/invcount/manifest.json b/modules/invcount/manifest.json new file mode 100644 index 0000000..b01225c --- /dev/null +++ b/modules/invcount/manifest.json @@ -0,0 +1,11 @@ +{ + "module_key": "invcount", + "name": "Inventory Counts", + "version": "1.0.0", + "author": "STUFF", + "description": "Cycle counting and physical inventory workflows with session-based tracking", + "requires_roles": ["owner", "admin", "staff"], + "routes_prefix": "/invcount", + "has_migrations": true, + "dependencies": [] +} \ No newline at end of file diff --git a/modules/invcount/migrations.py b/modules/invcount/migrations.py new file mode 100644 index 0000000..645f897 --- /dev/null +++ b/modules/invcount/migrations.py @@ -0,0 +1,158 @@ +""" +Inventory Counts Module - Database Migrations +Contains schema for all inventory counting tables +""" + +def get_schema(): + """ + Returns the complete schema SQL for this module. + This is used when the module is installed. + """ + return """ + -- CountSessions Table + CREATE TABLE IF NOT EXISTS CountSessions ( + session_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_name TEXT NOT NULL, + session_type TEXT NOT NULL CHECK(session_type IN ('cycle_count', 'full_physical')), + created_by INTEGER NOT NULL, + created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + master_baseline_timestamp DATETIME, + current_baseline_timestamp DATETIME, + status TEXT DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')), + branch TEXT DEFAULT 'Main', + FOREIGN KEY (created_by) REFERENCES Users(user_id) + ); + + -- BaselineInventory_Master Table (Session-specific, immutable) + CREATE TABLE IF NOT EXISTS BaselineInventory_Master ( + baseline_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + lot_number TEXT NOT NULL, + item TEXT NOT NULL, + description TEXT, + system_location TEXT NOT NULL, + system_bin TEXT NOT NULL, + system_quantity REAL NOT NULL, + uploaded_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES CountSessions(session_id) + ); + + -- BaselineInventory_Current Table (GLOBAL - shared across all sessions) + CREATE TABLE IF NOT EXISTS BaselineInventory_Current ( + current_id INTEGER PRIMARY KEY AUTOINCREMENT, + lot_number TEXT NOT NULL, + item TEXT NOT NULL, + description TEXT, + system_location TEXT, + system_bin TEXT NOT NULL, + system_quantity REAL NOT NULL, + upload_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(lot_number, system_bin) + ); + + -- LocationCounts Table + CREATE TABLE IF NOT EXISTS LocationCounts ( + location_count_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + location_name TEXT NOT NULL, + counted_by INTEGER NOT NULL, + start_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + end_timestamp DATETIME, + status TEXT DEFAULT 'not_started' CHECK(status IN ('not_started', 'in_progress', 'completed')), + expected_lots_master INTEGER DEFAULT 0, + lots_found INTEGER DEFAULT 0, + lots_missing INTEGER DEFAULT 0, + is_deleted INTEGER DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES CountSessions(session_id), + FOREIGN KEY (counted_by) REFERENCES Users(user_id) + ); + + -- ScanEntries Table + CREATE TABLE IF NOT EXISTS ScanEntries ( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + location_count_id INTEGER NOT NULL, + lot_number TEXT NOT NULL, + item TEXT, + description TEXT, + scanned_location TEXT NOT NULL, + actual_weight REAL NOT NULL, + scanned_by INTEGER NOT NULL, + scan_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + + -- MASTER baseline comparison + master_status TEXT CHECK(master_status IN ('match', 'wrong_location', 'ghost_lot', 'missing')), + master_expected_location TEXT, + master_expected_weight REAL, + master_variance_lbs REAL, + master_variance_pct REAL, + + -- Duplicate detection + duplicate_status TEXT DEFAULT '00' CHECK(duplicate_status IN ('00', '01', '03', '04')), + duplicate_info TEXT, + + -- Metadata + comment TEXT, + is_deleted INTEGER DEFAULT 0, + deleted_by INTEGER, + deleted_timestamp DATETIME, + modified_timestamp DATETIME, + + FOREIGN KEY (session_id) REFERENCES CountSessions(session_id), + FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id), + FOREIGN KEY (scanned_by) REFERENCES Users(user_id), + FOREIGN KEY (deleted_by) REFERENCES Users(user_id) + ); + + -- MissingLots Table + CREATE TABLE IF NOT EXISTS MissingLots ( + missing_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + location_count_id INTEGER, + lot_number TEXT NOT NULL, + item TEXT, + master_expected_location TEXT NOT NULL, + master_expected_quantity REAL NOT NULL, + current_system_location TEXT, + current_system_quantity REAL, + marked_by INTEGER NOT NULL, + marked_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + found_later TEXT DEFAULT 'N' CHECK(found_later IN ('Y', 'N')), + found_location TEXT, + FOREIGN KEY (session_id) REFERENCES CountSessions(session_id), + FOREIGN KEY (location_count_id) REFERENCES LocationCounts(location_count_id), + FOREIGN KEY (marked_by) REFERENCES Users(user_id) + ); + + -- Indexes + CREATE INDEX IF NOT EXISTS idx_baseline_master_lot ON BaselineInventory_Master(session_id, lot_number); + CREATE INDEX IF NOT EXISTS idx_baseline_master_loc ON BaselineInventory_Master(session_id, system_location); + CREATE INDEX IF NOT EXISTS idx_scanentries_session ON ScanEntries(session_id); + CREATE INDEX IF NOT EXISTS idx_scanentries_location ON ScanEntries(location_count_id); + CREATE INDEX IF NOT EXISTS idx_scanentries_lot ON ScanEntries(lot_number); + CREATE INDEX IF NOT EXISTS idx_scanentries_deleted ON ScanEntries(is_deleted); + CREATE INDEX IF NOT EXISTS idx_location_counts ON LocationCounts(session_id, status); + """ + + +def get_migrations(): + """ + Returns list of migrations specific to this module. + Format: [(version, name, up_function), ...] + """ + + def migration_001_add_is_deleted_to_locationcounts(conn): + """Add is_deleted column to LocationCounts table""" + cursor = conn.cursor() + + # Check if column exists + cursor.execute('PRAGMA table_info(LocationCounts)') + columns = [row[1] for row in cursor.fetchall()] + + if 'is_deleted' not in columns: + cursor.execute('ALTER TABLE LocationCounts ADD COLUMN is_deleted INTEGER DEFAULT 0') + print(" Added is_deleted column to LocationCounts") + + return [ + (1, 'add_is_deleted_to_locationcounts', migration_001_add_is_deleted_to_locationcounts), + ] \ No newline at end of file diff --git a/modules/invcount/routes.py b/modules/invcount/routes.py new file mode 100644 index 0000000..06f3c8b --- /dev/null +++ b/modules/invcount/routes.py @@ -0,0 +1,1391 @@ +""" +Inventory Counts Module - Routes +Consolidated from counting.py, sessions.py, and admin_locations.py +""" +import csv +import io + +from flask import render_template, request, redirect, url_for, flash, jsonify, session, current_app +from db import query_db, execute_db, get_db +from utils import login_required, role_required + + +def register_routes(bp): + """Register all invcount routes on the blueprint""" + + # ========================================================================= + # HELPER FUNCTIONS + # ========================================================================= + + def get_active_session(session_id): + """Get session if it exists and is not archived. Returns None if invalid.""" + sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True) + if not sess or sess['status'] == 'archived': + return None + return sess + + + # ========================================================================= + # COUNTING ROUTES (from counting.py) + # ========================================================================= + + @bp.route('/admin') + @login_required + def admin_dashboard(): + """Admin dashboard for Counts module""" + # Security check: Ensure user is admin/owner + if session.get('role') not in ['owner', 'admin']: + flash('Access denied. Admin role required.', 'danger') + return redirect(url_for('invcount.index')) + + show_archived = request.args.get('show_archived', '0') == '1' + + if show_archived: + sessions_list = query_db(''' + SELECT s.*, u.full_name as created_by_name, + COUNT(DISTINCT lc.location_count_id) as total_locations, + SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations, + SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations + FROM CountSessions s + LEFT JOIN Users u ON s.created_by = u.user_id + LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id + WHERE s.status IN ('active', 'archived') + GROUP BY s.session_id + ORDER BY s.status ASC, s.created_timestamp DESC + ''') + else: + sessions_list = query_db(''' + SELECT s.*, u.full_name as created_by_name, + COUNT(DISTINCT lc.location_count_id) as total_locations, + SUM(CASE WHEN lc.status = 'completed' THEN 1 ELSE 0 END) as completed_locations, + SUM(CASE WHEN lc.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress_locations + FROM CountSessions s + LEFT JOIN Users u ON s.created_by = u.user_id + LEFT JOIN LocationCounts lc ON s.session_id = lc.session_id + WHERE s.status = 'active' + GROUP BY s.session_id + ORDER BY s.created_timestamp DESC + ''') + + return render_template('invcount/admin_dashboard.html', sessions=sessions_list, show_archived=show_archived) + + @bp.route('/') + @login_required + def index(): + """Counts module landing - show active sessions""" + user_id = session.get('user_id') + + has_access = query_db(''' + SELECT 1 FROM UserModules um + JOIN Modules m ON um.module_id = m.module_id + WHERE um.user_id = ? AND m.module_key = 'invcount' AND m.is_active = 1 + ''', [user_id], one=True) + + if not has_access: + flash('You do not have access to this module', 'danger') + return redirect(url_for('home')) + + active_sessions = query_db(''' + SELECT session_id, session_name, session_type, created_timestamp + FROM CountSessions + WHERE status = 'active' + ORDER BY created_timestamp DESC + ''') + + return render_template('invcount/staff_dashboard.html', sessions=active_sessions) + + + @bp.route('/count/') + @login_required + def count_session(session_id): + """Select session and begin counting""" + sess = query_db('SELECT * FROM CountSessions WHERE session_id = ? AND status = "active"', + [session_id], one=True) + + if not sess: + flash('Session not found or not active', 'danger') + return redirect(url_for('invcount.index')) + + # Redirect to my_counts page (staff can manage multiple bins) + return redirect(url_for('invcount.my_counts', session_id=session_id)) + + + @bp.route('/session//my-counts') + @login_required + def my_counts(session_id): + """Staff view of their active and completed bins""" + sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True) + + if not sess: + flash('Session not found', 'danger') + return redirect(url_for('invcount.index')) + + if sess['status'] == 'archived': + flash('This session has been archived', 'warning') + return redirect(url_for('invcount.index')) + + # Get this user's active bins + active_bins = query_db(''' + SELECT lc.*, + COUNT(se.entry_id) as scan_count + FROM LocationCounts lc + LEFT JOIN ScanEntries se ON lc.location_count_id = se.location_count_id AND se.is_deleted = 0 + WHERE lc.session_id = ? + AND lc.status = 'in_progress' + AND lc.is_deleted = 0 + AND ( + lc.counted_by = ? + OR lc.location_count_id IN ( + SELECT location_count_id FROM ScanEntries + WHERE scanned_by = ? AND is_deleted = 0 + ) + ) + GROUP BY lc.location_count_id + ORDER BY lc.start_timestamp DESC + ''', [session_id, session['user_id'], session['user_id']]) + + # Get this user's completed bins + completed_bins = query_db(''' + SELECT lc.*, + COUNT(se.entry_id) as scan_count + FROM LocationCounts lc + LEFT JOIN ScanEntries se ON lc.location_count_id = se.location_count_id AND se.is_deleted = 0 + WHERE lc.session_id = ? + AND lc.status = 'completed' + AND ( + lc.counted_by = ? + OR lc.location_count_id IN ( + SELECT location_count_id FROM ScanEntries + WHERE scanned_by = ? AND is_deleted = 0 + ) + ) + GROUP BY lc.location_count_id + ORDER BY lc.start_timestamp DESC + ''', [session_id, session['user_id'], session['user_id']]) + + return render_template('/invcount/my_counts.html', + count_session=sess, + active_bins=active_bins, + completed_bins=completed_bins) + + + @bp.route('/session//start-bin', methods=['POST']) + @login_required + def start_bin_count(session_id): + """Start counting a new bin or resume an existing in-progress one""" + sess = get_active_session(session_id) + if not sess: + flash('Session not found or archived', 'warning') + return redirect(url_for('invcount.index')) + if not sess['master_baseline_timestamp']: + flash('Master File not uploaded. Please upload it before starting bins.', 'warning') + return redirect(url_for('invcount.my_counts', session_id=session_id)) + + location_name = request.form.get('location_name', '').strip().upper() + + if not location_name: + flash('Bin number is required', 'danger') + return redirect(url_for('invcount.my_counts', session_id=session_id)) + + # --- NEW LOGIC: Check for existing in-progress bin --- + existing_bin = query_db(''' + SELECT location_count_id + FROM LocationCounts + WHERE session_id = ? AND location_name = ? AND status = 'in_progress' + ''', [session_id, location_name], one=True) + + if existing_bin: + flash(f'Resuming bin: {location_name}', 'info') + return redirect(url_for('invcount.count_location', + session_id=session_id, + location_count_id=existing_bin['location_count_id'])) + # --- END NEW LOGIC --- + + # Count expected lots from MASTER baseline for this location + expected_lots = query_db(''' + SELECT COUNT(DISTINCT lot_number) as count + FROM BaselineInventory_Master + WHERE session_id = ? AND system_bin = ? + ''', [session_id, location_name], one=True) + + expected_count = expected_lots['count'] if expected_lots else 0 + + # Create new location count if none existed + conn = get_db() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO LocationCounts (session_id, location_name, counted_by, status, start_timestamp, expected_lots_master) + VALUES (?, ?, ?, 'in_progress', CURRENT_TIMESTAMP, ?) + ''', [session_id, location_name, session['user_id'], expected_count]) + + location_count_id = cursor.lastrowid + conn.commit() + conn.close() + + flash(f'Started counting bin: {location_name}', 'success') + return redirect(url_for('invcount.count_location', session_id=session_id, location_count_id=location_count_id)) + + @bp.route('/location//complete', methods=['POST']) + @login_required + def complete_location(location_count_id): + """Mark a location count as complete (Simple toggle)""" + # Verify ownership + loc = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?', [location_count_id], one=True) + + if not loc: + return jsonify({'success': False, 'message': 'Location not found'}) + + if loc['counted_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + # Mark as completed + execute_db(''' + UPDATE LocationCounts + SET status = 'completed', end_timestamp = CURRENT_TIMESTAMP + WHERE location_count_id = ? + ''', [location_count_id]) + + return jsonify({'success': True, 'message': 'Bin marked as complete'}) + + + @bp.route('/count//location/') + @login_required + def count_location(session_id, location_count_id): + """Count lots in a specific location""" + # Get session info to determine type (Cycle Count vs Physical) + sess = get_active_session(session_id) + if not sess: + flash('Session not found or archived', 'warning') + return redirect(url_for('invcount.index')) + if not sess['master_baseline_timestamp']: + flash('Master File not uploaded. Please upload it before starting bins.', 'warning') + return redirect(url_for('invcount.my_counts', session_id=session_id)) + + location = query_db(''' + SELECT * FROM LocationCounts + WHERE location_count_id = ? AND session_id = ? + ''', [location_count_id, session_id], one=True) + + if not location: + flash('Location not found', 'danger') + return redirect(url_for('invcount.count_session', session_id=session_id)) + + # Check if location is completed and user is staff (not admin/owner) + if location['status'] == 'completed' and session['role'] == 'staff': + flash(f'Location {location["location_name"]} has been finalized and cannot accept new scans', 'warning') + return redirect(url_for('invcount.my_counts', session_id=session_id)) + + # Get scans for this location (Scanned Lots) + scans = query_db(''' + SELECT * FROM ScanEntries + WHERE location_count_id = ? AND is_deleted = 0 + ORDER BY scan_timestamp DESC + ''', [location_count_id]) + + # NEW LOGIC: Get Expected Lots for Cycle Counts (Grouped & Summed) + expected_lots = [] + if sess and sess['session_type'] == 'cycle_count': + expected_lots = query_db(''' + SELECT + lot_number, + MAX(item) as item, -- Pick one item code if they differ (rare) + SUM(system_quantity) as total_weight + FROM BaselineInventory_Master + WHERE session_id = ? + AND system_bin = ? + AND lot_number NOT IN ( + SELECT lot_number + FROM ScanEntries + WHERE location_count_id = ? + AND is_deleted = 0 + ) + GROUP BY lot_number + ORDER BY lot_number + ''', [session_id, location['location_name'], location_count_id]) + + return render_template('invcount/count_location.html', + session_id=session_id, + location=location, + scans=scans, + expected_lots=expected_lots, + session_type=sess['session_type'] if sess else '') + + + @bp.route('/count//location//scan', methods=['POST']) + @login_required + def scan_lot(session_id, location_count_id): + """Process a lot scan with duplicate detection""" + sess = get_active_session(session_id) + if not sess: + return jsonify({'success': False, 'message': 'Session not found or archived'}) + + data = request.get_json() + lot_number = data.get('lot_number', '').strip() + weight = data.get('weight') + confirm_duplicate = data.get('confirm_duplicate', False) + check_only = data.get('check_only', False) # Just checking for duplicates, not saving + + if not lot_number: + return jsonify({'success': False, 'message': 'Lot number required'}) + + if not check_only and not weight: + return jsonify({'success': False, 'message': 'Weight required'}) + + if not check_only: + try: + weight = float(weight) + except ValueError: + return jsonify({'success': False, 'message': 'Invalid weight value'}) + + # Get location info + location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?', + [location_count_id], one=True) + + # Check for duplicates in this session + existing_scans = query_db(''' + SELECT se.*, lc.location_name, u.full_name + FROM ScanEntries se + JOIN LocationCounts lc ON se.location_count_id = lc.location_count_id + JOIN Users u ON se.scanned_by = u.user_id + WHERE se.session_id = ? AND se.lot_number = ? AND se.is_deleted = 0 + ''', [session_id, lot_number]) + + duplicate_status = '00' # Default: no duplicate + duplicate_info = None + needs_confirmation = False + + if existing_scans: + # Check for same location duplicates (by this user) + same_location = [s for s in existing_scans + if s['location_name'] == location['location_name'] + and s['scanned_by'] == session['user_id']] + + # Check for different location duplicates (by anyone) + diff_location = [s for s in existing_scans + if s['location_name'] != location['location_name']] + + if same_location and diff_location: + # Status 04: Duplicate in both same and different locations + duplicate_status = '04' + other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location)) + duplicate_info = f"Also found in {other_locs}. Duplicate Lot" + needs_confirmation = True + + elif same_location: + # Status 01: Duplicate in same location only + duplicate_status = '01' + duplicate_info = "Duplicate" + needs_confirmation = True + + elif diff_location: + # Status 03: Duplicate in different location only + duplicate_status = '03' + other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location)) + duplicate_info = f"Also found in {other_locs}" + + # If just checking, return early with baseline info + if check_only: + # Get baseline info to show what they're scanning + master_info = query_db(''' + SELECT item, description FROM BaselineInventory_Master + WHERE session_id = ? AND lot_number = ? + LIMIT 1 + ''', [session_id, lot_number], one=True) + + if needs_confirmation: + return jsonify({ + 'success': False, + 'needs_confirmation': True, + 'message': 'Lot already scanned, Are you sure?', + 'duplicate_status': duplicate_status, + 'item': master_info['item'] if master_info else None, + 'description': master_info['description'] if master_info else None + }) + else: + return jsonify({ + 'success': True, + 'needs_confirmation': False, + 'item': master_info['item'] if master_info else None, + 'description': master_info['description'] if master_info else None + }) + + # If needs confirmation and not yet confirmed, ask user + if needs_confirmation and not confirm_duplicate: + return jsonify({ + 'success': False, + 'needs_confirmation': True, + 'message': 'Lot already scanned, Are you sure?', + 'duplicate_status': duplicate_status + }) + + # Check against MASTER baseline + master = query_db(''' + SELECT + system_bin, + SUM(system_quantity) as system_quantity, + MAX(item) as item, + MAX(description) as description + FROM BaselineInventory_Master + WHERE session_id = ? AND lot_number = ? AND system_bin = ? + GROUP BY system_bin + ''', [session_id, lot_number, location['location_name']], one=True) + + # Determine master_status (only if not a duplicate issue) + if duplicate_status == '00': + if master: + # Lot exists in correct location + master_status = 'match' + if master['system_quantity'] is not None: + variance_lbs = weight - master['system_quantity'] + variance_pct = (variance_lbs / master['system_quantity'] * 100) if master['system_quantity'] > 0 else 0 + else: + variance_lbs = None + variance_pct = None + else: + # Check if lot exists in different location + master_other = query_db(''' + SELECT + system_bin, + SUM(system_quantity) as system_quantity, + MAX(item) as item, + MAX(description) as description + FROM BaselineInventory_Master + WHERE session_id = ? AND lot_number = ? + GROUP BY system_bin + ORDER BY system_bin + LIMIT 1 + ''', [session_id, lot_number], one=True) + + if master_other: + master_status = 'wrong_location' + master = master_other + variance_lbs = None + variance_pct = None + else: + # Ghost lot + master_status = 'ghost_lot' + variance_lbs = None + variance_pct = None + else: + # For duplicates, still check baseline for item info + if not master: + master = query_db(''' + SELECT + system_bin, + SUM(system_quantity) as system_quantity, + MAX(item) as item, + MAX(description) as description + FROM BaselineInventory_Master + WHERE session_id = ? AND lot_number = ? + GROUP BY system_bin + ORDER BY system_bin + LIMIT 1 + ''', [session_id, lot_number], one=True) + master_status = 'match' # Don't override with wrong_location for duplicates + variance_lbs = None + variance_pct = None + + # Insert scan + entry_id = execute_db(''' + INSERT INTO ScanEntries + (session_id, location_count_id, lot_number, item, description, + scanned_location, actual_weight, scanned_by, + master_status, master_expected_location, master_expected_weight, + master_variance_lbs, master_variance_pct, + duplicate_status, duplicate_info, comment) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', [ + session_id, location_count_id, lot_number, + master['item'] if master else None, + master['description'] if master else None, + location['location_name'], weight, session['user_id'], + master_status, + master['system_bin'] if master else None, + master['system_quantity'] if master else None, + variance_lbs, variance_pct, + duplicate_status, duplicate_info, duplicate_info + ]) + + # If this is a confirmed duplicate (01 or 04), update previous scans in same location + updated_entry_ids = [] + if duplicate_status in ['01', '04'] and confirm_duplicate: + same_location_ids = [s['entry_id'] for s in existing_scans + if s['location_name'] == location['location_name'] + and s['scanned_by'] == session['user_id']] + + for scan_id in same_location_ids: + execute_db(''' + UPDATE ScanEntries + SET duplicate_status = ?, + duplicate_info = ?, + comment = ?, + modified_timestamp = CURRENT_TIMESTAMP + WHERE entry_id = ? + ''', [duplicate_status, duplicate_info, duplicate_info, scan_id]) + updated_entry_ids.append(scan_id) + + # Update location count + execute_db(''' + UPDATE LocationCounts + SET lots_found = lots_found + 1 + WHERE location_count_id = ? + ''', [location_count_id]) + + return jsonify({ + 'success': True, + 'entry_id': entry_id, + 'master_status': master_status, + 'duplicate_status': duplicate_status, + 'duplicate_info': duplicate_info, + 'master_expected_location': master['system_bin'] if master else None, + 'master_expected_weight': master['system_quantity'] if master else None, + 'actual_weight': weight, + 'variance_lbs': variance_lbs, + 'item': master['item'] if master else 'Unknown Item', + 'description': master['description'] if master else 'Not in system', + 'updated_entry_ids': updated_entry_ids # IDs of scans that were updated to duplicate + }) + + + @bp.route('/scan//delete', methods=['POST']) + @login_required + def delete_scan(entry_id): + """Soft delete a scan and recalculate duplicate statuses""" + # Get the scan being deleted + scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ? AND is_deleted = 0', [entry_id], one=True) + + if not scan: + return jsonify({'success': False, 'message': 'Scan not found'}) + + # Only allow user to delete their own scans + if scan['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + # Soft delete the scan + execute_db(''' + UPDATE ScanEntries + SET is_deleted = 1, + deleted_by = ?, + deleted_timestamp = CURRENT_TIMESTAMP + WHERE entry_id = ? + ''', [session['user_id'], entry_id]) + + # Recalculate duplicate statuses for this lot number in this session + updated_entries = recalculate_duplicate_status(scan['session_id'], scan['lot_number'], scan['scanned_location']) + + # Update location count + execute_db(''' + UPDATE LocationCounts + SET lots_found = lots_found - 1 + WHERE location_count_id = ? + ''', [scan['location_count_id']]) + + return jsonify({ + 'success': True, + 'message': 'Scan deleted', + 'updated_entries': updated_entries # Return which scans were updated + }) + + + @bp.route('/scan//details', methods=['GET']) + @login_required + def get_scan_details(entry_id): + """Get detailed information about a scan""" + scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ? AND is_deleted = 0', [entry_id], one=True) + + if not scan: + return jsonify({'success': False, 'message': 'Scan not found'}) + + return jsonify({ + 'success': True, + 'scan': dict(scan) + }) + + + @bp.route('/scan//update', methods=['POST']) + @login_required + def update_scan(entry_id): + """Update scan item, weight and comment""" + data = request.get_json() + item = data.get('item', '').strip() + weight = data.get('weight') + comment = data.get('comment', '') + + # Get the scan + scan = query_db('SELECT * FROM ScanEntries WHERE entry_id = ? AND is_deleted = 0', [entry_id], one=True) + + if not scan: + return jsonify({'success': False, 'message': 'Scan not found'}) + + # Only allow user to update their own scans + if scan['scanned_by'] != session['user_id'] and session['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}) + + try: + weight = float(weight) + except ValueError: + return jsonify({'success': False, 'message': 'Invalid weight value'}) + + # Update the scan + execute_db(''' + UPDATE ScanEntries + SET item = ?, + actual_weight = ?, + comment = ?, + modified_timestamp = CURRENT_TIMESTAMP + WHERE entry_id = ? and is_deleted = 0 + ''', [item, weight, comment, entry_id]) + + return jsonify({'success': True, 'message': 'Scan updated'}) + + + def recalculate_duplicate_status(session_id, lot_number, current_location): + """Recalculate duplicate statuses for a lot after deletion""" + # Track which entries were updated + updated_entries = [] + + # Get all active scans for this lot in this session + scans = query_db(''' + SELECT se.*, lc.location_name, u.full_name, u.user_id as scan_user_id + FROM ScanEntries se + JOIN LocationCounts lc ON se.location_count_id = lc.location_count_id + JOIN Users u ON se.scanned_by = u.user_id + WHERE se.session_id = ? AND se.lot_number = ? AND se.is_deleted = 0 + ORDER BY se.scan_timestamp + ''', [session_id, lot_number]) + + if not scans: + return updated_entries + + # Reset all to status 00 + for scan in scans: + execute_db(''' + UPDATE ScanEntries + SET duplicate_status = '00', + duplicate_info = NULL, + comment = NULL, + modified_timestamp = CURRENT_TIMESTAMP + WHERE entry_id = ? and is_deleted = 0 + ''', [scan['entry_id']]) + updated_entries.append({ + 'entry_id': scan['entry_id'], + 'duplicate_status': '00', + 'duplicate_info': None + }) + + # Recalculate statuses + for i, scan in enumerate(scans): + # Get previous scans (before this one chronologically) + prev_scans = scans[:i] + + if not prev_scans: + continue # First scan, stays 00 + + same_location = [s for s in prev_scans if s['location_name'] == scan['location_name'] and s['scan_user_id'] == scan['scan_user_id']] + diff_location = [s for s in prev_scans if s['location_name'] != scan['location_name']] + + duplicate_status = '00' + duplicate_info = None + + if same_location and diff_location: + # Status 04 + duplicate_status = '04' + other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location)) + duplicate_info = f"Also found in {other_locs}. Duplicate Lot" + elif same_location: + # Status 01 + duplicate_status = '01' + duplicate_info = "Duplicate" + elif diff_location: + # Status 03 + duplicate_status = '03' + other_locs = ', '.join(set(f"{s['location_name']} by {s['full_name']}" for s in diff_location)) + duplicate_info = f"Also found in {other_locs}" + + # Update this scan if it changed from 00 + if duplicate_status != '00': + execute_db(''' + UPDATE ScanEntries + SET duplicate_status = ?, + duplicate_info = ?, + comment = ?, + modified_timestamp = CURRENT_TIMESTAMP + WHERE entry_id = ? and is_deleted = 0 + ''', [duplicate_status, duplicate_info, duplicate_info, scan['entry_id']]) + + # Update our tracking list + for entry in updated_entries: + if entry['entry_id'] == scan['entry_id']: + entry['duplicate_status'] = duplicate_status + entry['duplicate_info'] = duplicate_info + break + + # If status 01 or 04, also update previous scans in same location + if duplicate_status in ['01', '04'] and same_location: + for prev_scan in same_location: + execute_db(''' + UPDATE ScanEntries + SET duplicate_status = ?, + duplicate_info = ?, + comment = ?, + modified_timestamp = CURRENT_TIMESTAMP + WHERE entry_id = ? and is_deleted = 0 + ''', [duplicate_status, duplicate_info, duplicate_info, prev_scan['entry_id']]) + + # Update tracking for previous scans + for entry in updated_entries: + if entry['entry_id'] == prev_scan['entry_id']: + entry['duplicate_status'] = duplicate_status + entry['duplicate_info'] = duplicate_info + break + + return updated_entries + + + @bp.route('/count//location//finish', methods=['POST']) + @login_required + def finish_location(session_id, location_count_id): + """Finish counting a location""" + sess = get_active_session(session_id) + if not sess: + return jsonify({'success': False, 'message': 'Session not found or archived'}) + + # Get location info + location = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?', + [location_count_id], one=True) + + if not location: + return jsonify({'success': False, 'message': 'Location not found'}) + + # Mark location as completed + execute_db(''' + UPDATE LocationCounts + SET status = 'completed', end_timestamp = CURRENT_TIMESTAMP + WHERE location_count_id = ? + ''', [location_count_id]) + + # V1.0: Mark missing lots from MASTER baseline that weren't scanned + # Get all expected lots for this location from MASTER baseline + expected_lots = query_db(''' + SELECT lot_number, item, description, system_quantity + FROM BaselineInventory_Master + WHERE session_id = ? AND system_bin = ? + ''', [session_id, location['location_name']]) + + # Get all scanned lots for this location + scanned_lots = query_db(''' + SELECT DISTINCT lot_number + FROM ScanEntries + WHERE location_count_id = ? AND is_deleted = 0 + ''', [location_count_id]) + + scanned_lot_numbers = {s['lot_number'] for s in scanned_lots} + + # Insert missing lots + for expected in expected_lots: + if expected['lot_number'] not in scanned_lot_numbers: + execute_db(''' + INSERT INTO MissingLots (session_id, lot_number, master_expected_location, item, master_expected_quantity, marked_by) + VALUES (?, ?, ?, ?, ?, ?) + ''', [session_id, expected['lot_number'], location['location_name'], + expected['item'], expected['system_quantity'], session['user_id']]) + + flash('Location count completed!', 'success') + return jsonify({ + 'success': True, + 'redirect': url_for('invcount.count_session', session_id=session_id) + }) + + @bp.route('/session//finalize-all', methods=['POST']) + @login_required + def finalize_all_locations(session_id): + """Finalize all 'in_progress' locations in a session""" + if session.get('role') not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}), 403 + + # 1. Get all in_progress locations for this session + locations = query_db(''' + SELECT location_count_id, location_name + FROM LocationCounts + WHERE session_id = ? + AND status = 'in_progress' + AND is_deleted = 0 + ''', [session_id]) + + if not locations: + return jsonify({'success': True, 'message': 'No open bins to finalize.'}) + + # 2. Loop through and run the finalize logic for each + for loc in locations: + # We reuse the logic from your existing finish_location route + execute_db(''' + UPDATE LocationCounts + SET status = 'completed', end_timestamp = CURRENT_TIMESTAMP + WHERE location_count_id = ? + ''', [loc['location_count_id']]) + + # Identify missing lots from MASTER baseline + expected_lots = query_db(''' + SELECT lot_number, item, description, system_quantity + FROM BaselineInventory_Master + WHERE session_id = ? AND system_bin = ? + ''', [session_id, loc['location_name']]) + + scanned_lots = query_db(''' + SELECT DISTINCT lot_number + FROM ScanEntries + WHERE location_count_id = ? AND is_deleted = 0 + ''', [loc['location_count_id']]) + + scanned_lot_numbers = {s['lot_number'] for s in scanned_lots} + + for expected in expected_lots: + if expected['lot_number'] not in scanned_lot_numbers: + execute_db(''' + INSERT INTO MissingLots (session_id, lot_number, master_expected_location, item, master_expected_quantity, marked_by) + VALUES (?, ?, ?, ?, ?, ?) + ''', [session_id, expected['lot_number'], loc['location_name'], + expected['item'], expected['system_quantity'], session['user_id']]) + + return jsonify({'success': True, 'message': f'Successfully finalized {len(locations)} bins.'}) + + # ========================================================================= + # SESSION MANAGEMENT ROUTES (from sessions.py) + # ========================================================================= + + @bp.route('/session/create', methods=['GET', 'POST']) + @role_required('owner', 'admin') + def create_session(): + """Create new count session""" + if request.method == 'POST': + session_name = request.form.get('session_name', '').strip() + session_type = request.form.get('session_type') + + if not session_name: + flash('Session name is required', 'danger') + return redirect(url_for('invcount.create_session')) + + session_id = execute_db(''' + INSERT INTO CountSessions (session_name, session_type, created_by, branch) + VALUES (?, ?, ?, ?) + ''', [session_name, session_type, session['user_id'], 'Main']) + + flash(f'Session "{session_name}" created successfully!', 'success') + return redirect(url_for('invcount.session_detail', session_id=session_id)) + + return render_template('invcount/create_session.html') + + + @bp.route('/session/') + @role_required('owner', 'admin') + def session_detail(session_id): + """Session detail and monitoring page""" + sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True) + + if not sess: + flash('Session not found', 'danger') + return redirect(url_for('dashboard')) + + # Get statistics + stats = query_db(''' + SELECT + COUNT(DISTINCT se.entry_id) FILTER (WHERE se.is_deleted = 0) as total_scans, + COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'match' AND se.duplicate_status = '00' AND se.is_deleted = 0 AND ABS(se.actual_weight - se.master_expected_weight) < 0.01) as matched, + COUNT(DISTINCT se.lot_number) FILTER (WHERE se.duplicate_status IN ('01', '03', '04') AND se.is_deleted = 0) as duplicates, + COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'match' AND se.duplicate_status = '00' AND se.is_deleted = 0 AND ABS(se.actual_weight - se.master_expected_weight) >= 0.01) as weight_discrepancy, + COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'wrong_location' AND se.is_deleted = 0) as wrong_location, + COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'ghost_lot' AND se.is_deleted = 0) as ghost_lots, + COUNT(DISTINCT ml.missing_id) as missing_lots + FROM CountSessions cs + LEFT JOIN ScanEntries se ON cs.session_id = se.session_id + LEFT JOIN MissingLots ml ON cs.session_id = ml.session_id + WHERE cs.session_id = ? + ''', [session_id], one=True) + + # Get location progress + # We add a subquery to count the actual missing lots for each bin + locations = query_db(''' + SELECT + lc.*, + u.full_name as counter_name, + (SELECT COUNT(*) FROM MissingLots ml + WHERE ml.session_id = lc.session_id + AND ml.master_expected_location = lc.location_name) as lots_missing_calc + FROM LocationCounts lc + LEFT JOIN Users u ON lc.counted_by = u.user_id + WHERE lc.session_id = ? + AND lc.is_deleted = 0 + ORDER BY lc.status DESC, lc.location_name + ''', [session_id]) + + # Get active counters + active_counters = query_db(''' + SELECT + u.full_name, + u.user_id, + MAX(lc.start_timestamp) AS start_timestamp, -- Add the alias here! + lc.location_name + FROM LocationCounts lc + JOIN Users u ON lc.counted_by = u.user_id + WHERE lc.session_id = ? + AND lc.status = 'in_progress' + AND lc.is_deleted = 0 + GROUP BY u.user_id + ORDER BY start_timestamp DESC + ''', [session_id]) + + return render_template('invcount/session_detail.html', + count_session=sess, + stats=stats, + locations=locations, + active_counters=active_counters) + + + @bp.route('/session//status-details/') + @role_required('owner', 'admin') + def get_status_details(session_id, status): + """Get detailed breakdown for a specific status""" + + try: + if status == 'match': + # Matched lots (not duplicates) - JOIN with CURRENT for live data + items = query_db(''' + SELECT + se.*, + u.full_name as scanned_by_name, + bic.system_bin as current_system_location, + bic.system_quantity as current_system_weight + FROM ScanEntries se + JOIN Users u ON se.scanned_by = u.user_id + LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number + WHERE se.session_id = ? + AND se.master_status = 'match' + AND se.duplicate_status = '00' + AND se.master_variance_lbs = 0 + AND se.is_deleted = 0 + ORDER BY se.scan_timestamp DESC + ''', [session_id]) + + elif status == 'duplicates': + # Duplicate lots (grouped by lot number) - JOIN with CURRENT + items = query_db(''' + SELECT + se.lot_number, + se.item, + se.description, + GROUP_CONCAT(DISTINCT se.scanned_location) as scanned_location, + SUM(se.actual_weight) as actual_weight, + se.master_expected_location, + se.master_expected_weight, + GROUP_CONCAT(DISTINCT u.full_name) as scanned_by_name, + MIN(se.scan_timestamp) as scan_timestamp, + bic.system_bin as current_system_location, + bic.system_quantity as current_system_weight + FROM ScanEntries se + JOIN Users u ON se.scanned_by = u.user_id + LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number + WHERE se.session_id = ? + AND se.duplicate_status IN ('01', '03', '04') + AND se.is_deleted = 0 + GROUP BY se.lot_number + ORDER BY se.lot_number + ''', [session_id]) + + elif status == 'wrong_location': + # Wrong location lots - JOIN with CURRENT + items = query_db(''' + SELECT + se.*, + u.full_name as scanned_by_name, + bic.system_bin as current_system_location, + bic.system_quantity as current_system_weight + FROM ScanEntries se + JOIN Users u ON se.scanned_by = u.user_id + LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number + WHERE se.session_id = ? + AND se.master_status = 'wrong_location' + AND se.is_deleted = 0 + ORDER BY se.scan_timestamp DESC + ''', [session_id]) + + elif status == 'weight_discrepancy': + # Weight discrepancies (right location, wrong weight) - JOIN with CURRENT + items = query_db(''' + SELECT + se.*, + u.full_name as scanned_by_name, + bic.system_bin as current_system_location, + bic.system_quantity as current_system_weight + FROM ScanEntries se + JOIN Users u ON se.scanned_by = u.user_id + LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number + WHERE se.session_id = ? + AND se.master_status = 'match' + AND se.duplicate_status = '00' + AND ABS(se.actual_weight - se.master_expected_weight) >= 0.01 + AND se.is_deleted = 0 + ORDER BY ABS(se.actual_weight - se.master_expected_weight) DESC + ''', [session_id]) + + elif status == 'ghost_lot': + # Ghost lots (not in master baseline) - JOIN with CURRENT + items = query_db(''' + SELECT + se.*, + u.full_name as scanned_by_name, + bic.system_bin as current_system_location, + bic.system_quantity as current_system_weight + FROM ScanEntries se + JOIN Users u ON se.scanned_by = u.user_id + LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number + WHERE se.session_id = ? + AND se.master_status = 'ghost_lot' + AND se.is_deleted = 0 + ORDER BY se.scan_timestamp DESC + ''', [session_id]) + + elif status == 'missing': + # Missing lots (in master but not scanned) + items = query_db(''' + SELECT + ml.lot_number, + ml.item, + bim.description, + ml.master_expected_location as system_bin, + ml.master_expected_quantity as system_quantity + FROM MissingLots ml + LEFT JOIN BaselineInventory_Master bim ON + ml.lot_number = bim.lot_number AND + ml.item = bim.item AND + ml.master_expected_location = bim.system_bin AND + ml.session_id = bim.session_id + WHERE ml.session_id = ? + GROUP BY ml.lot_number, ml.item, ml.master_expected_location + ORDER BY ml.master_expected_location, ml.lot_number + ''', [session_id]) + else: + return jsonify({'success': False, 'message': 'Invalid status'}) + + return jsonify({ + 'success': True, + 'items': [dict(item) for item in items] if items else [] + }) + + except Exception as e: + print(f"Error in get_status_details: {str(e)}") + return jsonify({'success': False, 'message': f'Error: {str(e)}'}) + + @bp.route('/session//archive', methods=['POST']) + @role_required('owner', 'admin') + def archive_session(session_id): + """Archive a count session""" + sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + if sess['status'] == 'archived': + return jsonify({'success': False, 'message': 'Session is already archived'}) + + execute_db('UPDATE CountSessions SET status = ? WHERE session_id = ?', ['archived', session_id]) + + return jsonify({'success': True, 'message': 'Session archived successfully'}) + + + @bp.route('/session//activate', methods=['POST']) + @role_required('owner', 'admin') + def activate_session(session_id): + """Reactivate an archived session""" + sess = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True) + + if not sess: + return jsonify({'success': False, 'message': 'Session not found'}) + + if sess['status'] != 'archived': + return jsonify({'success': False, 'message': 'Session is not archived'}) + + execute_db('UPDATE CountSessions SET status = ? WHERE session_id = ?', ['active', session_id]) + + return jsonify({'success': True, 'message': 'Session activated successfully'}) + + @bp.route('/session//get_stats') + @role_required('owner', 'admin') + def get_session_stats(session_id): + stats = query_db(''' + SELECT + COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'match' AND se.duplicate_status = '00' AND se.master_variance_lbs = 0 AND se.is_deleted = 0 AND ABS(se.actual_weight - se.master_expected_weight) < 0.01) as matched, + COUNT(DISTINCT se.lot_number) FILTER (WHERE se.duplicate_status IN ('01', '03', '04') AND se.is_deleted = 0) as duplicates, + COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'match' AND se.duplicate_status = '00' AND se.is_deleted = 0 AND ABS(se.actual_weight - se.master_expected_weight) >= 0.01) as discrepancy, + COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'wrong_location' AND se.is_deleted = 0) as wrong_location, + COUNT(DISTINCT se.entry_id) FILTER (WHERE se.master_status = 'ghost_lot' AND se.is_deleted = 0) as ghost_lots, + COUNT(DISTINCT ml.missing_id) as missing + FROM CountSessions cs + LEFT JOIN ScanEntries se ON cs.session_id = se.session_id + LEFT JOIN MissingLots ml ON cs.session_id = ml.session_id + WHERE cs.session_id = ? + ''', [session_id], one=True) + + return jsonify(success=True, stats=dict(stats)) + @bp.route('/session//active-counters-fragment') + @role_required('owner', 'admin') + def active_counters_fragment(session_id): + """Get currently active counters based on recent scan activity""" + active_counters = query_db(''' + SELECT + u.full_name, + lc.location_name, + MAX(se.scan_timestamp) AS start_timestamp + FROM ScanEntries se + JOIN LocationCounts lc ON se.location_count_id = lc.location_count_id + JOIN Users u ON se.scanned_by = u.user_id + WHERE lc.session_id = ? + AND lc.status = 'in_progress' + AND lc.is_deleted = 0 + AND se.scan_timestamp >= datetime('now', '-15 minutes') + GROUP BY u.user_id, lc.location_name + ORDER BY MAX(se.scan_timestamp) DESC + ''', [session_id]) + + return render_template('invcount/partials/_active_counters.html', active_counters=active_counters) + + # ========================================================================= + # ADMIN LOCATION ROUTES (from admin_locations.py) + # ========================================================================= + + @bp.route('/location//reopen', methods=['POST']) + @login_required + def reopen_location(location_count_id): + """Reopen a completed location (admin/owner only)""" + # Check permissions + user = query_db('SELECT role FROM Users WHERE user_id = ?', [session['user_id']], one=True) + if not user or user['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}), 403 + + # Verify location exists + loc = query_db('SELECT * FROM LocationCounts WHERE location_count_id = ?', [location_count_id], one=True) + + if not loc: + return jsonify({'success': False, 'message': 'Location not found'}) + + # Reopen the location + execute_db(''' + UPDATE LocationCounts + SET status = 'in_progress', end_timestamp = NULL + WHERE location_count_id = ? + ''', [location_count_id]) + + return jsonify({'success': True, 'message': 'Bin reopened for counting'}) + + + @bp.route('/location//scans') + @login_required + def get_location_scans(location_count_id): + """Get all scans for a specific location (admin/owner only)""" + # Check permissions + user = query_db('SELECT role FROM Users WHERE user_id = ?', [session['user_id']], one=True) + if not user or user['role'] not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Permission denied'}), 403 + + try: + scans = query_db(''' + SELECT + se.*, + bic.system_bin as current_system_location, + bic.system_quantity as current_system_weight + FROM ScanEntries se + LEFT JOIN BaselineInventory_Current bic ON se.lot_number = bic.lot_number + WHERE se.location_count_id = ? + AND se.is_deleted = 0 + ORDER BY se.scan_timestamp DESC + ''', [location_count_id]) + + # Convert Row objects to dicts + scans_list = [dict(scan) for scan in scans] if scans else [] + + return jsonify({'success': True, 'scans': scans_list}) + + except Exception as e: + return jsonify({'success': False, 'message': str(e)}) + + @bp.route('/location//delete', methods=['POST']) + @login_required + def soft_delete_location(location_count_id): + """Admin-only: Soft delete a bin count and its associated data""" + if session.get('role') not in ['owner', 'admin']: + return jsonify({'success': False, 'message': 'Admin role required'}), 403 + + # 1. Verify location exists + loc = query_db('SELECT session_id, location_name FROM LocationCounts WHERE location_count_id = ?', + [location_count_id], one=True) + + if not loc: + return jsonify({'success': False, 'message': 'Location not found'}) + + # 2. Soft delete the bin count itself + execute_db(''' + UPDATE LocationCounts + SET is_deleted = 1 + WHERE location_count_id = ? + ''', [location_count_id]) + + # 3. Soft delete all scans in that bin + execute_db(''' + UPDATE ScanEntries + SET is_deleted = 1 + WHERE location_count_id = ? + ''', [location_count_id]) + + # 4. Remove any MissingLots records generated for this bin + execute_db(''' + DELETE FROM MissingLots + WHERE session_id = ? AND master_expected_location = ? + ''', [loc['session_id'], loc['location_name']]) + + return jsonify({'success': True, 'message': 'Bin count and associated data soft-deleted'}) + + +# --- DATA IMPORT ROUTES --- + + @bp.route('/upload_current/', methods=['POST']) + @login_required + def upload_current(session_id): + """Upload current inventory CSV (global baseline)""" + if 'csv_file' not in request.files: + flash('No file part', 'danger') + return redirect(url_for('invcount.session_detail', session_id=session_id)) + + file = request.files['csv_file'] + if file.filename == '': + flash('No selected file', 'danger') + return redirect(url_for('invcount.session_detail', session_id=session_id)) + + if file: + conn = get_db() + cursor = conn.cursor() + try: + stream = io.StringIO(file.stream.read().decode("UTF8"), newline=None) + csv_input = csv.DictReader(stream) + + # 1. Reset Table + cursor.execute('DROP TABLE IF EXISTS BaselineInventory_Current') + cursor.execute(''' + CREATE TABLE BaselineInventory_Current ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item TEXT, + lot_number TEXT, + system_bin TEXT, + system_quantity REAL, + uom TEXT + ) + ''') + + # 2. BULK INSERT with Correct Headers + rows_to_insert = [] + for row in csv_input: + # Clean up keys (remove hidden characters/spaces) + row = {k.strip(): v for k, v in row.items()} + + rows_to_insert.append(( + row.get('Item', ''), + row.get('Lot Number', ''), + row.get('Bin Number', ''), + row.get('On Hand', 0), + row.get('UOM', 'LBS') + )) + + cursor.executemany(''' + INSERT INTO BaselineInventory_Current + (item, lot_number, system_bin, system_quantity, uom) + VALUES (?, ?, ?, ?, ?) + ''', rows_to_insert) + + # 3. Update timestamp + cursor.execute('UPDATE CountSessions SET current_baseline_timestamp = CURRENT_TIMESTAMP') + + conn.commit() + flash(f'Successfully uploaded {len(rows_to_insert)} records.', 'success') + + except Exception as e: + conn.rollback() + flash(f'Error uploading CSV: {str(e)}', 'danger') + finally: + conn.close() + + return redirect(url_for('invcount.session_detail', session_id=session_id)) + + + @bp.route('/invcount/session//upload_master', methods=['POST']) + @login_required + def upload_master(session_id): + """Upload master baseline CSV (session-specific)""" + if 'csv_file' not in request.files: + flash('No file uploaded', 'danger') + return redirect(url_for('invcount.session_detail', session_id=session_id)) + + file = request.files['csv_file'] + if file.filename == '': + flash('No file selected', 'danger') + return redirect(url_for('invcount.session_detail', session_id=session_id)) + + conn = get_db() + cursor = conn.cursor() + cursor.execute('DELETE FROM BaselineInventory_Master WHERE session_id = ?', [session_id]) + try: + stream = io.StringIO(file.stream.read().decode("UTF8"), newline=None) + csv_reader = csv.DictReader(stream) + + lot_location_data = {} + + # Consolidate duplicates in memory + for row in csv_reader: + # Clean keys here too just in case + row = {k.strip(): v for k, v in row.items()} + + lot_num = row.get('Lot Number', '').strip() + bin_num = row.get('Bin Number', '').strip() + key = (lot_num, bin_num) + + qty = float(row.get('On Hand', 0)) + + if key in lot_location_data: + lot_location_data[key]['quantity'] += qty + else: + lot_location_data[key] = { + 'item': row.get('Item', '').strip(), + 'description': row.get('Description', '').strip(), + 'location': row.get('Location', '').strip(), + 'bin': bin_num, + 'quantity': qty + } + + # BULK INSERT + rows_to_insert = [] + for (lot_num, bin_num), data in lot_location_data.items(): + rows_to_insert.append(( + session_id, + lot_num, + data['item'], + data['description'], + data['location'], + data['bin'], + data['quantity'] + )) + + cursor.executemany(''' + INSERT INTO BaselineInventory_Master + (session_id, lot_number, item, description, system_location, system_bin, system_quantity) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', rows_to_insert) + + cursor.execute('UPDATE CountSessions SET master_baseline_timestamp = CURRENT_TIMESTAMP WHERE session_id = ?', [session_id]) + conn.commit() + flash(f'āœ… MASTER baseline uploaded: {len(rows_to_insert)} records', 'success') + + except Exception as e: + conn.rollback() + flash(f'Error uploading Master CSV: {str(e)}', 'danger') + finally: + conn.close() + + return redirect(url_for('invcount.session_detail', session_id=session_id)) \ No newline at end of file diff --git a/templates/counts/DELETE_count_session.html b/modules/invcount/templates/invcount/DELETE_count_session.html similarity index 100% rename from templates/counts/DELETE_count_session.html rename to modules/invcount/templates/invcount/DELETE_count_session.html diff --git a/templates/counts/admin_dashboard.html b/modules/invcount/templates/invcount/admin_dashboard.html similarity index 95% rename from templates/counts/admin_dashboard.html rename to modules/invcount/templates/invcount/admin_dashboard.html index 4fa1d21..fe6b0c7 100644 --- a/templates/counts/admin_dashboard.html +++ b/modules/invcount/templates/invcount/admin_dashboard.html @@ -19,7 +19,7 @@ Show Archived - + + New Session @@ -66,7 +66,7 @@ @@ -85,7 +85,7 @@ diff --git a/templates/counts/count_location.html b/modules/invcount/templates/invcount/count_location.html similarity index 98% rename from templates/counts/count_location.html rename to modules/invcount/templates/invcount/count_location.html index b4e6481..9d3ce42 100644 --- a/templates/counts/count_location.html +++ b/modules/invcount/templates/invcount/count_location.html @@ -160,7 +160,7 @@
- + ← Back to My Counts {# Finish button moved to Admin Dashboard #} @@ -216,7 +216,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) { }); function checkDuplicate() { - fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', { + fetch('{{ url_for("invcount.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ @@ -285,7 +285,7 @@ function submitScan(weight) { return; } - fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', { + fetch('{{ url_for("invcount.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ @@ -585,7 +585,7 @@ function deleteFromDetail(entryId) { function finishLocation() { if (!confirm('Are you finished counting this location?')) return; - fetch('{{ url_for("counting.finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', { + fetch('{{ url_for("invcount.finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', { method: 'POST', headers: {'Content-Type': 'application/json'} }) diff --git a/templates/counts/create_session.html b/modules/invcount/templates/invcount/create_session.html similarity index 100% rename from templates/counts/create_session.html rename to modules/invcount/templates/invcount/create_session.html diff --git a/templates/counts/my_counts.html b/modules/invcount/templates/invcount/my_counts.html similarity index 97% rename from templates/counts/my_counts.html rename to modules/invcount/templates/invcount/my_counts.html index 6671b57..8ada71c 100644 --- a/templates/counts/my_counts.html +++ b/modules/invcount/templates/invcount/my_counts.html @@ -6,7 +6,7 @@
@@ -106,7 +106,7 @@
-
+
diff --git a/templates/counts/partials/_active_counters.html b/modules/invcount/templates/invcount/partials/_active_counters.html similarity index 100% rename from templates/counts/partials/_active_counters.html rename to modules/invcount/templates/invcount/partials/_active_counters.html diff --git a/templates/counts/session_detail.html b/modules/invcount/templates/invcount/session_detail.html similarity index 96% rename from templates/counts/session_detail.html rename to modules/invcount/templates/invcount/session_detail.html index 617d703..9a26dc1 100644 --- a/templates/counts/session_detail.html +++ b/modules/invcount/templates/invcount/session_detail.html @@ -41,7 +41,7 @@
{% if not count_session.master_baseline_timestamp %} - +
@@ -59,7 +59,7 @@ {% endif %}
{% if count_session.master_baseline_timestamp %} -
+ + {% elif module.is_active %} + + + {% else %} + + + {% endif %} +
+ + + {% endfor %} + + + {% if not modules %} +
+ No modules found in the /modules directory. +
+ {% endif %} + + + +{% endblock %} \ No newline at end of file