V0.8.6 - Frefractor: Counting.py
This commit is contained in:
641
app.py
641
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/<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 ====================
|
||||
|
||||
|
||||
BIN
blueprints/__pycache__/counting.cpython-313.pyc
Normal file
BIN
blueprints/__pycache__/counting.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
633
blueprints/counting.py
Normal file
633
blueprints/counting.py
Normal 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)
|
||||
})
|
||||
@@ -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/<int:session_id>/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))
|
||||
return redirect(url_for('sessions. session_detail', session_id=session_id))
|
||||
@@ -62,6 +62,8 @@
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<p>© 2026 Javier Torres. All Rights Reserved.</p>
|
||||
<p class="text-muted"><small>v{{ version }}</small></p>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
</html>
|
||||
|
||||
@@ -97,9 +97,9 @@
|
||||
{% set row_class = 'weight_discrepancy' %}
|
||||
{% endif %}
|
||||
|
||||
<div class="scan-row scan-row-{{ row_class }}"
|
||||
data-entry-id="{{ scan.entry_id }}"
|
||||
onclick="openScanDetail({{ scan.entry_id }})">
|
||||
<div class="scan-row scan-row-{{ row_class }}"
|
||||
data-entry-id="{{ scan.entry_id }}"
|
||||
onclick="openScanDetail('{{ scan.entry_id }}')">
|
||||
<div class="scan-row-lot">{{ scan.lot_number }}</div>
|
||||
<div class="scan-row-item">{{ scan.item or 'N/A' }}</div>
|
||||
<div class="scan-row-weight">{{ scan.actual_weight }} lbs</div>
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
<div class="finish-section">
|
||||
<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
|
||||
</a>
|
||||
<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() {
|
||||
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',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
@@ -260,7 +260,7 @@ function submitScan(weight) {
|
||||
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',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
@@ -560,7 +560,7 @@ function deleteFromDetail(entryId) {
|
||||
function finishLocation() {
|
||||
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',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
})
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
</a>
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
<button type="button" class="btn-close-modal" onclick="closeStartBinModal()">✕</button>
|
||||
</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">
|
||||
<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">
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
<div>
|
||||
<a href="{{ url_for('dashboard') }}" class="breadcrumb">← Back to Dashboard</a>
|
||||
<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 }}">
|
||||
{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}
|
||||
{{ 'Full Physical' if count_session.session_type == 'full_physical' else 'Cycle Count' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,6 +31,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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">
|
||||
<input type="file" name="csv_file" accept=".csv" required class="file-input">
|
||||
<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-status">
|
||||
{% if count_session.current_baseline_timestamp %}
|
||||
<span class="status-badge status-success">Last Updated:
|
||||
<div>{{ count_session.current_baseline_timestamp[:16] if count_session.current_baseline_timestamp else 'Never' }}</div>
|
||||
<span class="status-badge status-success">✓ Uploaded</span>
|
||||
<small class="baseline-time">{{ count_session.current_baseline_timestamp[:16] }}</small>
|
||||
{% else %}
|
||||
<span class="status-badge status-neutral">Not Uploaded</span>
|
||||
{% endif %}
|
||||
@@ -65,27 +67,27 @@
|
||||
<h2 class="section-title">Real-Time Statistics</h2>
|
||||
|
||||
<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-label">✓ Matched</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-label">🔵 Duplicates</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-label">⚖️ Weight Discrepancy</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-label">⚠ Wrong Location</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-label">🟣 Ghost Lots</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-label">🔴 Missing</div>
|
||||
</div>
|
||||
@@ -129,7 +131,12 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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>
|
||||
<span class="status-badge status-{{ loc.status }}">
|
||||
@@ -246,7 +253,10 @@
|
||||
</div>
|
||||
|
||||
<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('statusDetailContent').innerHTML = '<div class="loading-spinner">Loading...</div>';
|
||||
|
||||
@@ -261,8 +271,8 @@ function showStatusDetails(status, sessionId) {
|
||||
};
|
||||
document.getElementById('statusModalTitle').textContent = titles[status] || 'Details';
|
||||
|
||||
// Fetch details
|
||||
fetch(`/session/${sessionId}/status-details/${status}`)
|
||||
// Fetch details using the blueprint URL structure
|
||||
fetch(`/session/${CURRENT_SESSION_ID}/status-details/${status}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
@@ -421,6 +431,14 @@ let currentLocationName = '';
|
||||
let currentLocationStatus = '';
|
||||
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) {
|
||||
currentLocationId = locationCountId;
|
||||
currentLocationName = locationName;
|
||||
@@ -594,6 +612,7 @@ function closeFinalizeConfirm() {
|
||||
}
|
||||
|
||||
function confirmFinalize() {
|
||||
// Note: The /complete endpoint is handled by blueprints/counting.py
|
||||
fetch(`/location/${currentLocationId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -625,6 +644,7 @@ function closeReopenConfirm() {
|
||||
}
|
||||
|
||||
function confirmReopen() {
|
||||
// Note: The /reopen endpoint is handled by blueprints/admin_locations.py
|
||||
fetch(`/location/${currentLocationId}/reopen`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<!-- Mode Selector (only for admins) -->
|
||||
{% if session.role in ['owner', 'admin'] %}
|
||||
<div class="mode-selector">
|
||||
<button class="mode-btn" onclick="window.location.href='{{ url_for('dashboard') }}'">
|
||||
👔 Admin Console
|
||||
</button>
|
||||
<a href="{{ url_for('dashboard') }}" class="mode-btn">
|
||||
Admin Console
|
||||
</a>
|
||||
<button class="mode-btn mode-btn-active">
|
||||
📦 Scanning Mode
|
||||
Scanning Mode
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -22,11 +22,11 @@
|
||||
|
||||
{% if sessions %}
|
||||
<div class="sessions-list">
|
||||
{% for session in sessions %}
|
||||
<a href="{{ url_for('count_session', session_id=session.session_id) }}" class="session-list-item">
|
||||
{% for s in sessions %}
|
||||
<a href="{{ url_for('counting.count_session', session_id=s.session_id) }}" class="session-list-item">
|
||||
<div class="session-list-info">
|
||||
<h3 class="session-list-name">{{ session.session_name }}</h3>
|
||||
<span class="session-list-type">{{ 'Full Physical' if session.session_type == 'full_physical' else 'Cycle Count' }}</span>
|
||||
<h3 class="session-list-name">{{ s.session_name }}</h3>
|
||||
<span class="session-list-type">{{ 'Full Physical' if s.session_type == 'full_physical' else 'Cycle Count' }}</span>
|
||||
</div>
|
||||
<div class="session-list-action">
|
||||
<span class="arrow-icon">→</span>
|
||||
|
||||
Reference in New Issue
Block a user