V0.8.6 - Frefractor: Counting.py

This commit is contained in:
Javier
2026-01-23 11:45:35 -06:00
parent 6789f0899a
commit 53158e76e4
10 changed files with 705 additions and 669 deletions

641
app.py
View File

@@ -1,5 +1,5 @@
""" """
ScanLook V1.0 - Inventory Management System ScanLook - Inventory Management System
Flask Application Flask Application
Production-Ready Release Production-Ready Release
""" """
@@ -17,6 +17,7 @@ from blueprints.data_imports import data_imports_bp
from blueprints.users import users_bp from blueprints.users import users_bp
from blueprints.sessions import sessions_bp from blueprints.sessions import sessions_bp
from blueprints.admin_locations import admin_locations_bp from blueprints.admin_locations import admin_locations_bp
from blueprints.counting import counting_bp # Add this import
from utils import login_required from utils import login_required
# Register Blueprints # Register Blueprints
@@ -24,6 +25,7 @@ app.register_blueprint(data_imports_bp)
app.register_blueprint(users_bp) app.register_blueprint(users_bp)
app.register_blueprint(sessions_bp) app.register_blueprint(sessions_bp)
app.register_blueprint(admin_locations_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 # 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') 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) 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 ==================== # ==================== ROUTES: AUTHENTICATION ====================
@app.route('/') @app.route('/')
@@ -126,635 +136,6 @@ def staff_mode():
return render_template('staff_dashboard.html', sessions=active_sessions, is_admin_mode=True) return render_template('staff_dashboard.html', sessions=active_sessions, is_admin_mode=True)
# ==================== ROUTES: COUNTING (STAFF) ====================
@app.route('/count/<int:session_id>')
@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/<int:session_id>/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/<int:session_id>/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/<int:location_count_id>/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/<int:session_id>/location/<int:location_count_id>')
@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/<int:session_id>/location/<int:location_count_id>/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/<int:entry_id>/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/<int:entry_id>/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/<int:entry_id>/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/<int:session_id>/location/<int:location_count_id>/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 ==================== # ==================== PWA SUPPORT ROUTES ====================

Binary file not shown.

633
blueprints/counting.py Normal file
View File

@@ -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/<int:session_id>')
@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/<int:session_id>/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/<int:session_id>/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/<int:location_count_id>/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/<int:session_id>/location/<int:location_count_id>')
@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/<int:session_id>/location/<int:location_count_id>/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/<int:entry_id>/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/<int:entry_id>/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/<int:entry_id>/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/<int:session_id>/location/<int:location_count_id>/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)
})

View File

@@ -17,12 +17,12 @@ def upload_current(session_id):
if 'csv_file' not in request.files: if 'csv_file' not in request.files:
flash('No file part', 'danger') 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'] file = request.files['csv_file']
if file.filename == '': if file.filename == '':
flash('No selected file', 'danger') 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: if file:
conn = get_db() conn = get_db()
@@ -76,7 +76,7 @@ def upload_current(session_id):
finally: finally:
conn.close() 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) --- # --- ROUTE 2: Upload MASTER Baseline (Session Specific) ---
@data_imports_bp.route('/session/<int:session_id>/upload_master', methods=['POST']) @data_imports_bp.route('/session/<int:session_id>/upload_master', methods=['POST'])
@@ -85,12 +85,12 @@ def upload_master(session_id):
if 'csv_file' not in request.files: if 'csv_file' not in request.files:
flash('No file uploaded', 'danger') 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'] file = request.files['csv_file']
if file.filename == '': if file.filename == '':
flash('No file selected', 'danger') 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() conn = get_db()
cursor = conn.cursor() cursor = conn.cursor()
@@ -152,4 +152,4 @@ def upload_master(session_id):
finally: finally:
conn.close() conn.close()
return redirect(url_for('session_detail', session_id=session_id)) return redirect(url_for('sessions. session_detail', session_id=session_id))

View File

@@ -62,6 +62,8 @@
<footer class="footer"> <footer class="footer">
<div class="footer-content"> <div class="footer-content">
<p>&copy; 2026 Javier Torres. All Rights Reserved.</p> <p>&copy; 2026 Javier Torres. All Rights Reserved.</p>
<p class="text-muted"><small>v{{ version }}</small></p>
</div> </div>
</footer> </footer>
</html> </html>

View File

@@ -99,7 +99,7 @@
<div class="scan-row scan-row-{{ row_class }}" <div class="scan-row scan-row-{{ row_class }}"
data-entry-id="{{ scan.entry_id }}" data-entry-id="{{ scan.entry_id }}"
onclick="openScanDetail({{ scan.entry_id }})"> onclick="openScanDetail('{{ scan.entry_id }}')">
<div class="scan-row-lot">{{ scan.lot_number }}</div> <div class="scan-row-lot">{{ scan.lot_number }}</div>
<div class="scan-row-item">{{ scan.item or 'N/A' }}</div> <div class="scan-row-item">{{ scan.item or 'N/A' }}</div>
<div class="scan-row-weight">{{ scan.actual_weight }} lbs</div> <div class="scan-row-weight">{{ scan.actual_weight }} lbs</div>
@@ -160,7 +160,7 @@
<div class="finish-section"> <div class="finish-section">
<div class="action-buttons-row"> <div class="action-buttons-row">
<a href="{{ url_for('my_counts', session_id=session_id) }}" class="btn btn-secondary btn-block btn-lg"> <a href="{{ url_for('counting.my_counts', session_id=session_id) }}" class="btn btn-secondary btn-block btn-lg">
← Back to My Counts ← Back to My Counts
</a> </a>
<button id="finishBtn" class="btn btn-success btn-block btn-lg" onclick="finishLocation()"> <button id="finishBtn" class="btn btn-success btn-block btn-lg" onclick="finishLocation()">
@@ -191,7 +191,7 @@ document.getElementById('lotScanForm').addEventListener('submit', function(e) {
}); });
function checkDuplicate() { function checkDuplicate() {
fetch('{{ url_for("scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', { fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
@@ -260,7 +260,7 @@ function submitScan(weight) {
return; return;
} }
fetch('{{ url_for("scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', { fetch('{{ url_for("counting.scan_lot", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
@@ -560,7 +560,7 @@ function deleteFromDetail(entryId) {
function finishLocation() { function finishLocation() {
if (!confirm('Are you finished counting this location?')) return; if (!confirm('Are you finished counting this location?')) return;
fetch('{{ url_for("finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', { fetch('{{ url_for("counting.finish_location", session_id=session_id, location_count_id=location.location_count_id) }}', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'} headers: {'Content-Type': 'application/json'}
}) })

View File

@@ -37,14 +37,14 @@
</div> </div>
</div> </div>
<div class="bin-actions"> <div class="bin-actions">
<a href="{{ url_for('count_location', session_id=count_session.session_id, location_count_id=bin.location_count_id) }}" class="btn btn-primary btn-block"> <a href="{{ url_for('counting.count_location', session_id=count_session.session_id, location_count_id=bin.location_count_id) }}" class="btn btn-primary btn-block">
Resume Counting Resume Counting
</a> </a>
<div class="bin-actions-row"> <div class="bin-actions-row">
<button class="btn btn-secondary" onclick="markComplete({{ bin.location_count_id }})"> <button class="btn btn-secondary" onclick="markComplete('{{ bin.location_count_id }}')">
✓ Mark Complete ✓ Mark Complete
</button> </button>
<button class="btn btn-danger" onclick="deleteBinCount({{ bin.location_count_id }}, '{{ bin.location_name }}')"> <button class="btn btn-danger" onclick="deleteBinCount('{{ bin.location_count_id }}', '{{ bin.location_name }}')">
🗑️ Delete 🗑️ Delete
</button> </button>
</div> </div>
@@ -105,7 +105,7 @@
<button type="button" class="btn-close-modal" onclick="closeStartBinModal()"></button> <button type="button" class="btn-close-modal" onclick="closeStartBinModal()"></button>
</div> </div>
<form id="startBinForm" action="{{ url_for('start_bin_count', session_id=count_session.session_id) }}" method="POST"> <form id="startBinForm" action="{{ url_for('counting.start_bin_count', session_id=count_session.session_id) }}" method="POST">
<div class="form-group"> <div class="form-group">
<label class="form-label">Bin Number *</label> <label class="form-label">Bin Number *</label>
<input type="text" name="location_name" class="form-input scan-input" required autofocus placeholder="Scan or type bin number"> <input type="text" name="location_name" class="form-input scan-input" required autofocus placeholder="Scan or type bin number">

View File

@@ -8,8 +8,9 @@
<div> <div>
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a> <a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a>
<h1 class="page-title">{{ count_session.session_name }}</h1> <h1 class="page-title">{{ count_session.session_name }}</h1>
<!-- Fixed variable name from session.session_type to count_session.session_type -->
<span class="session-type-badge session-type-{{ count_session.session_type }}"> <span class="session-type-badge session-type-{{ count_session.session_type }}">
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }} {{ 'Full Physical' if count_session.session_type == 'full_physical' else 'Cycle Count' }}
</span> </span>
</div> </div>
</div> </div>
@@ -30,6 +31,7 @@
{% endif %} {% endif %}
</div> </div>
{% if not count_session.master_baseline_timestamp %} {% if not count_session.master_baseline_timestamp %}
<!-- Note: Using data_imports blueprint URL -->
<form method="POST" action="{{ url_for('data_imports.upload_master', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form"> <form method="POST" action="{{ url_for('data_imports.upload_master', session_id=count_session.session_id) }}" enctype="multipart/form-data" class="upload-form">
<input type="file" name="csv_file" accept=".csv" required class="file-input"> <input type="file" name="csv_file" accept=".csv" required class="file-input">
<button type="submit" class="btn btn-primary btn-sm">Upload MASTER</button> <button type="submit" class="btn btn-primary btn-sm">Upload MASTER</button>
@@ -41,8 +43,8 @@
<div class="baseline-label">CURRENT Baseline (Optional)</div> <div class="baseline-label">CURRENT Baseline (Optional)</div>
<div class="baseline-status"> <div class="baseline-status">
{% if count_session.current_baseline_timestamp %} {% if count_session.current_baseline_timestamp %}
<span class="status-badge status-success">Last Updated: <span class="status-badge status-success">✓ Uploaded</span>
<div>{{ count_session.current_baseline_timestamp[:16] if count_session.current_baseline_timestamp else 'Never' }}</div> <small class="baseline-time">{{ count_session.current_baseline_timestamp[:16] }}</small>
{% else %} {% else %}
<span class="status-badge status-neutral">Not Uploaded</span> <span class="status-badge status-neutral">Not Uploaded</span>
{% endif %} {% endif %}
@@ -65,27 +67,27 @@
<h2 class="section-title">Real-Time Statistics</h2> <h2 class="section-title">Real-Time Statistics</h2>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card stat-match" onclick="showStatusDetails('match', {{ count_session.session_id }})"> <div class="stat-card stat-match" onclick="showStatusDetails('match')">
<div class="stat-number">{{ stats.matched or 0 }}</div> <div class="stat-number">{{ stats.matched or 0 }}</div>
<div class="stat-label">✓ Matched</div> <div class="stat-label">✓ Matched</div>
</div> </div>
<div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates', {{ count_session.session_id }})"> <div class="stat-card stat-duplicate" onclick="showStatusDetails('duplicates')">
<div class="stat-number">{{ stats.duplicates or 0 }}</div> <div class="stat-number">{{ stats.duplicates or 0 }}</div>
<div class="stat-label">🔵 Duplicates</div> <div class="stat-label">🔵 Duplicates</div>
</div> </div>
<div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy', {{ count_session.session_id }})"> <div class="stat-card stat-weight-disc" onclick="showStatusDetails('weight_discrepancy')">
<div class="stat-number">{{ stats.weight_discrepancy or 0 }}</div> <div class="stat-number">{{ stats.weight_discrepancy or 0 }}</div>
<div class="stat-label">⚖️ Weight Discrepancy</div> <div class="stat-label">⚖️ Weight Discrepancy</div>
</div> </div>
<div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location', {{ count_session.session_id }})"> <div class="stat-card stat-wrong" onclick="showStatusDetails('wrong_location')">
<div class="stat-number">{{ stats.wrong_location or 0 }}</div> <div class="stat-number">{{ stats.wrong_location or 0 }}</div>
<div class="stat-label">⚠ Wrong Location</div> <div class="stat-label">⚠ Wrong Location</div>
</div> </div>
<div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot', {{ count_session.session_id }})"> <div class="stat-card stat-ghost" onclick="showStatusDetails('ghost_lot')">
<div class="stat-number">{{ stats.ghost_lots or 0 }}</div> <div class="stat-number">{{ stats.ghost_lots or 0 }}</div>
<div class="stat-label">🟣 Ghost Lots</div> <div class="stat-label">🟣 Ghost Lots</div>
</div> </div>
<div class="stat-card stat-missing" onclick="showStatusDetails('missing', {{ count_session.session_id }})"> <div class="stat-card stat-missing" onclick="showStatusDetails('missing')">
<div class="stat-number">{{ stats.missing_lots or 0 }}</div> <div class="stat-number">{{ stats.missing_lots or 0 }}</div>
<div class="stat-label">🔴 Missing</div> <div class="stat-label">🔴 Missing</div>
</div> </div>
@@ -129,7 +131,12 @@
</thead> </thead>
<tbody> <tbody>
{% for loc in locations %} {% for loc in locations %}
<tr class="location-row-clickable" onclick="showLocationDetails({{ loc.location_count_id }}, '{{ loc.location_name }}', '{{ loc.status }}')"> <!-- Refactored to use data attributes instead of direct Jinja injection in onclick -->
<tr class="location-row-clickable"
data-id="{{ loc.location_count_id }}"
data-name="{{ loc.location_name }}"
data-status="{{ loc.status }}"
onclick="handleLocationClick(this)">
<td><strong>{{ loc.location_name }}</strong></td> <td><strong>{{ loc.location_name }}</strong></td>
<td> <td>
<span class="status-badge status-{{ loc.status }}"> <span class="status-badge status-{{ loc.status }}">
@@ -246,7 +253,10 @@
</div> </div>
<script> <script>
function showStatusDetails(status, sessionId) { // Store the Session ID globally to use in functions without passing it every time
const CURRENT_SESSION_ID = "{{ count_session.session_id }}";
function showStatusDetails(status) {
document.getElementById('statusModal').style.display = 'flex'; document.getElementById('statusModal').style.display = 'flex';
document.getElementById('statusDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>'; document.getElementById('statusDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>';
@@ -261,8 +271,8 @@ function showStatusDetails(status, sessionId) {
}; };
document.getElementById('statusModalTitle').textContent = titles[status] || 'Details'; document.getElementById('statusModalTitle').textContent = titles[status] || 'Details';
// Fetch details // Fetch details using the blueprint URL structure
fetch(`/session/${sessionId}/status-details/${status}`) fetch(`/session/${CURRENT_SESSION_ID}/status-details/${status}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
@@ -421,6 +431,14 @@ let currentLocationName = '';
let currentLocationStatus = ''; let currentLocationStatus = '';
let currentLocationData = null; let currentLocationData = null;
// New helper function to handle click from data attributes
function handleLocationClick(row) {
const id = row.getAttribute('data-id');
const name = row.getAttribute('data-name');
const status = row.getAttribute('data-status');
showLocationDetails(id, name, status);
}
function showLocationDetails(locationCountId, locationName, status) { function showLocationDetails(locationCountId, locationName, status) {
currentLocationId = locationCountId; currentLocationId = locationCountId;
currentLocationName = locationName; currentLocationName = locationName;
@@ -594,6 +612,7 @@ function closeFinalizeConfirm() {
} }
function confirmFinalize() { function confirmFinalize() {
// Note: The /complete endpoint is handled by blueprints/counting.py
fetch(`/location/${currentLocationId}/complete`, { fetch(`/location/${currentLocationId}/complete`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -625,6 +644,7 @@ function closeReopenConfirm() {
} }
function confirmReopen() { function confirmReopen() {
// Note: The /reopen endpoint is handled by blueprints/admin_locations.py
fetch(`/location/${currentLocationId}/reopen`, { fetch(`/location/${currentLocationId}/reopen`, {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@@ -7,11 +7,11 @@
<!-- Mode Selector (only for admins) --> <!-- Mode Selector (only for admins) -->
{% if session.role in ['owner', 'admin'] %} {% if session.role in ['owner', 'admin'] %}
<div class="mode-selector"> <div class="mode-selector">
<button class="mode-btn" onclick="window.location.href='{{ url_for('dashboard') }}'"> <a href="{{ url_for('dashboard') }}" class="mode-btn">
👔 Admin Console Admin Console
</button> </a>
<button class="mode-btn mode-btn-active"> <button class="mode-btn mode-btn-active">
📦 Scanning Mode Scanning Mode
</button> </button>
</div> </div>
{% endif %} {% endif %}
@@ -22,11 +22,11 @@
{% if sessions %} {% if sessions %}
<div class="sessions-list"> <div class="sessions-list">
{% for session in sessions %} {% for s in sessions %}
<a href="{{ url_for('count_session', session_id=session.session_id) }}" class="session-list-item"> <a href="{{ url_for('counting.count_session', session_id=s.session_id) }}" class="session-list-item">
<div class="session-list-info"> <div class="session-list-info">
<h3 class="session-list-name">{{ session.session_name }}</h3> <h3 class="session-list-name">{{ s.session_name }}</h3>
<span class="session-list-type">{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}</span> <span class="session-list-type">{{ 'Full Physical' if s.session_type == 'full_physical' else 'Cycle Count' }}</span>
</div> </div>
<div class="session-list-action"> <div class="session-list-action">
<span class="arrow-icon"></span> <span class="arrow-icon"></span>