From 53158e76e4cb71efa152d468261f94ae259385dc Mon Sep 17 00:00:00 2001 From: Javier Date: Fri, 23 Jan 2026 11:45:35 -0600 Subject: [PATCH] V0.8.6 - Frefractor: Counting.py --- app.py | 641 +----------------- .../__pycache__/counting.cpython-313.pyc | Bin 0 -> 22338 bytes .../__pycache__/data_imports.cpython-313.pyc | Bin 7423 -> 7458 bytes blueprints/counting.py | 633 +++++++++++++++++ blueprints/data_imports.py | 12 +- templates/base.html | 2 + templates/count_location.html | 14 +- templates/my_counts.html | 8 +- templates/session_detail.html | 48 +- templates/staff_dashboard.html | 16 +- 10 files changed, 705 insertions(+), 669 deletions(-) create mode 100644 blueprints/__pycache__/counting.cpython-313.pyc create mode 100644 blueprints/counting.py diff --git a/app.py b/app.py index be47068..0e51199 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ """ -ScanLook V1.0 - Inventory Management System +ScanLook - Inventory Management System Flask Application Production-Ready Release """ @@ -17,6 +17,7 @@ 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 # Add this import from utils import login_required # Register Blueprints @@ -24,6 +25,7 @@ app.register_blueprint(data_imports_bp) app.register_blueprint(users_bp) app.register_blueprint(sessions_bp) app.register_blueprint(admin_locations_bp) +app.register_blueprint(counting_bp) # Add this registration # 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') @@ -33,6 +35,14 @@ app.config['DATABASE'] = os.path.join(os.path.dirname(__file__), 'database', 'sc app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=1) +# 1. Define the version +APP_VERSION = '0.8.6' + +# 2. Inject it into all templates automatically +@app.context_processor +def inject_version(): + return dict(version=APP_VERSION) + # ==================== ROUTES: AUTHENTICATION ==================== @app.route('/') @@ -126,635 +136,6 @@ def staff_mode(): return render_template('staff_dashboard.html', sessions=active_sessions, is_admin_mode=True) -# ==================== ROUTES: COUNTING (STAFF) ==================== - -@app.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('dashboard')) - - # Redirect to my_counts page (staff can manage multiple bins) - return redirect(url_for('my_counts', session_id=session_id)) - - -@app.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('dashboard')) - - # 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.counted_by = ? - AND lc.status = 'in_progress' - GROUP BY lc.location_count_id - ORDER BY lc.start_timestamp DESC - ''', [session_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.counted_by = ? - AND lc.status = 'completed' - GROUP BY lc.location_count_id - ORDER BY lc.end_timestamp DESC - ''', [session_id, session['user_id']]) - - return render_template('my_counts.html', - count_session=sess, - active_bins=active_bins, - completed_bins=completed_bins) - - -@app.route('/session//start-bin', methods=['POST']) -@login_required -def start_bin_count(session_id): - """Start counting a new bin""" - location_name = request.form.get('location_name', '').strip().upper() - - if not location_name: - flash('Bin number is required', 'danger') - return redirect(url_for('my_counts', session_id=session_id)) - - # 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 - 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('count_location', session_id=session_id, location_count_id=location_count_id)) - - -@app.route('/location//complete', methods=['POST']) -@login_required -def complete_location(location_count_id): - """Mark a location count as complete""" - # 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'}) - - -@app.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 = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True) - - 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('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('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('count_location.html', - session_id=session_id, - location=location, - scans=scans, - expected_lots=expected_lots, - session_type=sess['session_type'] if sess else '') - -@app.route('/count//location//scan', methods=['POST']) -@login_required -def scan_lot(session_id, location_count_id): - """Process a lot scan with duplicate detection""" - 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 * FROM BaselineInventory_Master - WHERE session_id = ? AND lot_number = ? AND 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 * FROM BaselineInventory_Master - WHERE session_id = ? AND lot_number = ? - ''', [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 * FROM BaselineInventory_Master - WHERE session_id = ? AND lot_number = ? - ''', [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 - }) - - -@app.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 = ?', [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 - }) - - -@app.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) - }) - - -@app.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 = ?', [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 = ? - ''', [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 = ? - ''', [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 = ? - ''', [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 = ? - ''', [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 - - -@app.route('/count//location//finish', methods=['POST']) -@login_required -def finish_location(session_id, location_count_id): - """Finish counting a location""" - # 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('count_session', session_id=session_id) - }) - # ==================== PWA SUPPORT ROUTES ==================== diff --git a/blueprints/__pycache__/counting.cpython-313.pyc b/blueprints/__pycache__/counting.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6fa32d054974ce1f458a0956c5da557c13e55ac3 GIT binary patch literal 22338 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 delta 203 zcmZ2v_1}{BGcPX}0}vQpEzFGG$h(@I=@#GS!|W?rcno$#U6k;+%p_*4MoyN%L#`u|;fytNg1Cs+Iqub>7GV&r2p%BK8OddcX#v*>8>jB?? BKMVi> diff --git a/blueprints/counting.py b/blueprints/counting.py new file mode 100644 index 0000000..58e4b1c --- /dev/null +++ b/blueprints/counting.py @@ -0,0 +1,633 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session +from db import query_db, execute_db, get_db +from utils import login_required + +counting_bp = Blueprint('counting', __name__) + +@counting_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('dashboard')) + + # Redirect to my_counts page (staff can manage multiple bins) + return redirect(url_for('counting.my_counts', session_id=session_id)) + + +@counting_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('dashboard')) + + # 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.counted_by = ? + AND lc.status = 'in_progress' + GROUP BY lc.location_count_id + ORDER BY lc.start_timestamp DESC + ''', [session_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.counted_by = ? + AND lc.status = 'completed' + GROUP BY lc.location_count_id + ORDER BY lc.end_timestamp DESC + ''', [session_id, session['user_id']]) + + return render_template('my_counts.html', + count_session=sess, + active_bins=active_bins, + completed_bins=completed_bins) + + +@counting_bp.route('/session//start-bin', methods=['POST']) +@login_required +def start_bin_count(session_id): + """Start counting a new bin""" + location_name = request.form.get('location_name', '').strip().upper() + + if not location_name: + flash('Bin number is required', 'danger') + return redirect(url_for('counting.my_counts', session_id=session_id)) + + # 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 + 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('counting.count_location', session_id=session_id, location_count_id=location_count_id)) + + +@counting_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'}) + + +@counting_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 = query_db('SELECT * FROM CountSessions WHERE session_id = ?', [session_id], one=True) + + 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('counting.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('counting.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('count_location.html', + session_id=session_id, + location=location, + scans=scans, + expected_lots=expected_lots, + session_type=sess['session_type'] if sess else '') + + +@counting_bp.route('/count//location//scan', methods=['POST']) +@login_required +def scan_lot(session_id, location_count_id): + """Process a lot scan with duplicate detection""" + 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 * FROM BaselineInventory_Master + WHERE session_id = ? AND lot_number = ? AND 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 * FROM BaselineInventory_Master + WHERE session_id = ? AND lot_number = ? + ''', [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 * FROM BaselineInventory_Master + WHERE session_id = ? AND lot_number = ? + ''', [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 + }) + + +@counting_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 = ?', [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 + }) + + +@counting_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) + }) + + +@counting_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 = ?', [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 = ? + ''', [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 = ? + ''', [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 = ? + ''', [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 = ? + ''', [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 + + +@counting_bp.route('/count//location//finish', methods=['POST']) +@login_required +def finish_location(session_id, location_count_id): + """Finish counting a location""" + # 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('counting.count_session', session_id=session_id) + }) \ No newline at end of file diff --git a/blueprints/data_imports.py b/blueprints/data_imports.py index 89cca65..fcb802f 100644 --- a/blueprints/data_imports.py +++ b/blueprints/data_imports.py @@ -17,12 +17,12 @@ def upload_current(session_id): if 'csv_file' not in request.files: flash('No file part', 'danger') - return redirect(url_for('session_detail', session_id=session_id)) + return redirect(url_for('sessions.session_detail', session_id=session_id)) file = request.files['csv_file'] if file.filename == '': flash('No selected file', 'danger') - return redirect(url_for('session_detail', session_id=session_id)) + return redirect(url_for('sessions.session_detail', session_id=session_id)) if file: conn = get_db() @@ -76,7 +76,7 @@ def upload_current(session_id): finally: conn.close() - return redirect(url_for('session_detail', session_id=session_id)) + return redirect(url_for('sessions.session_detail', session_id=session_id)) # --- ROUTE 2: Upload MASTER Baseline (Session Specific) --- @data_imports_bp.route('/session//upload_master', methods=['POST']) @@ -85,12 +85,12 @@ def upload_master(session_id): if 'csv_file' not in request.files: flash('No file uploaded', 'danger') - return redirect(url_for('session_detail', session_id=session_id)) + return redirect(url_for('sessions.session_detail', session_id=session_id)) file = request.files['csv_file'] if file.filename == '': flash('No file selected', 'danger') - return redirect(url_for('session_detail', session_id=session_id)) + return redirect(url_for('sessions.session_detail', session_id=session_id)) conn = get_db() cursor = conn.cursor() @@ -152,4 +152,4 @@ def upload_master(session_id): finally: conn.close() - return redirect(url_for('session_detail', session_id=session_id)) \ No newline at end of file + return redirect(url_for('sessions. session_detail', session_id=session_id)) \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 975de22..d0e80d5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -62,6 +62,8 @@
+
diff --git a/templates/count_location.html b/templates/count_location.html index dadc302..33c2a10 100644 --- a/templates/count_location.html +++ b/templates/count_location.html @@ -97,9 +97,9 @@ {% set row_class = 'weight_discrepancy' %} {% endif %} -
+
{{ scan.lot_number }}
{{ scan.item or 'N/A' }}
{{ scan.actual_weight }} lbs
@@ -160,7 +160,7 @@
- + ← Back to My Counts
- + Resume Counting
- -
@@ -105,7 +105,7 @@
-
+
diff --git a/templates/session_detail.html b/templates/session_detail.html index 9cf43b4..446cf82 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -8,8 +8,9 @@
← Back to Dashboard

{{ count_session.session_name }}

+ - {{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }} + {{ 'Full Physical' if count_session.session_type == 'full_physical' else 'Cycle Count' }}
@@ -30,6 +31,7 @@ {% endif %}
{% if not count_session.master_baseline_timestamp %} + @@ -41,8 +43,8 @@
CURRENT Baseline (Optional)
{% if count_session.current_baseline_timestamp %} - Last Updated: -
{{ count_session.current_baseline_timestamp[:16] if count_session.current_baseline_timestamp else 'Never' }}
+ ✓ Uploaded + {{ count_session.current_baseline_timestamp[:16] }} {% else %} Not Uploaded {% endif %} @@ -65,27 +67,27 @@

Real-Time Statistics

-
+
{{ stats.matched or 0 }}
✓ Matched
-
+
{{ stats.duplicates or 0 }}
🔵 Duplicates
-
+
{{ stats.weight_discrepancy or 0 }}
⚖️ Weight Discrepancy
-
+
{{ stats.wrong_location or 0 }}
⚠ Wrong Location
-
+
{{ stats.ghost_lots or 0 }}
🟣 Ghost Lots
-
+
{{ stats.missing_lots or 0 }}
🔴 Missing
@@ -129,7 +131,12 @@ {% for loc in locations %} - + + {{ loc.location_name }} @@ -246,7 +253,10 @@
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/staff_dashboard.html b/templates/staff_dashboard.html index 655c1fe..c401697 100644 --- a/templates/staff_dashboard.html +++ b/templates/staff_dashboard.html @@ -7,11 +7,11 @@ {% if session.role in ['owner', 'admin'] %}
- + + Admin Console +
{% endif %} @@ -22,11 +22,11 @@ {% if sessions %}