diff --git a/app/modules/warehouse/routes.py b/app/modules/warehouse/routes.py index 529149c..7d05385 100644 --- a/app/modules/warehouse/routes.py +++ b/app/modules/warehouse/routes.py @@ -9,6 +9,11 @@ from app.modules.warehouse.warehouse import ( assign_box_to_location, move_box_to_new_location, get_cp_inventory_list, search_cp_code, search_by_box_number, get_cp_details ) +from app.modules.warehouse.warehouse_orders import ( + get_unassigned_orders, get_orders_by_box, search_orders_by_cp_code, + assign_order_to_box, move_order_to_box, unassign_order_from_box, + get_all_boxes_summary +) import logging logger = logging.getLogger(__name__) @@ -119,6 +124,15 @@ def reports(): return render_template('modules/warehouse/reports.html') +@warehouse_bp.route('/set-orders-on-boxes', methods=['GET', 'POST']) +def set_orders_on_boxes(): + """Set orders on boxes - assign or move orders between boxes""" + if 'user_id' not in session: + return redirect(url_for('main.login')) + + return render_template('modules/warehouse/set_orders_on_boxes.html') + + @warehouse_bp.route('/test-barcode', methods=['GET']) def test_barcode(): """Test barcode printing functionality""" @@ -218,6 +232,119 @@ def api_get_locations(): return jsonify({'success': True, 'locations': locations}), 200 +# ============================================================================ +# API Routes for Orders Management +# ============================================================================ + +@warehouse_bp.route('/api/unassigned-orders', methods=['GET'], endpoint='api_unassigned_orders') +def api_unassigned_orders(): + """Get all unassigned orders""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + try: + limit = request.args.get('limit', 100, type=int) + offset = request.args.get('offset', 0, type=int) + + orders = get_unassigned_orders(limit, offset) + return jsonify({'success': True, 'orders': orders, 'count': len(orders)}), 200 + except Exception as e: + logger.error(f"Error getting unassigned orders: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@warehouse_bp.route('/api/orders-by-box', methods=['POST'], endpoint='api_orders_by_box') +def api_orders_by_box(): + """Get all orders assigned to a specific box""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + box_id = data.get('box_id') + + if not box_id: + return jsonify({'success': False, 'error': 'Box ID is required'}), 400 + + success, data_resp, status_code = get_orders_by_box(box_id) + return jsonify({'success': success, **data_resp}), status_code + + +@warehouse_bp.route('/api/search-orders', methods=['POST'], endpoint='api_search_orders') +def api_search_orders(): + """Search for orders by CP code""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + cp_code = data.get('cp_code', '').strip() + + if not cp_code: + return jsonify({'success': False, 'error': 'CP code is required'}), 400 + + orders = search_orders_by_cp_code(cp_code) + return jsonify({'success': True, 'orders': orders, 'count': len(orders)}), 200 + + +@warehouse_bp.route('/api/assign-order-to-box', methods=['POST'], endpoint='api_assign_order_to_box') +def api_assign_order_to_box(): + """Assign an order to a box""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + order_id = data.get('order_id') + box_id = data.get('box_id') + + if not order_id or not box_id: + return jsonify({'success': False, 'error': 'Order ID and box ID are required'}), 400 + + success, message, status_code = assign_order_to_box(order_id, box_id) + return jsonify({'success': success, 'message': message}), status_code + + +@warehouse_bp.route('/api/move-order-to-box', methods=['POST'], endpoint='api_move_order_to_box') +def api_move_order_to_box(): + """Move an order to a different box""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + order_id = data.get('order_id') + new_box_id = data.get('new_box_id') + + if not order_id or not new_box_id: + return jsonify({'success': False, 'error': 'Order ID and destination box ID are required'}), 400 + + success, message, status_code = move_order_to_box(order_id, new_box_id) + return jsonify({'success': success, 'message': message}), status_code + + +@warehouse_bp.route('/api/unassign-order', methods=['POST'], endpoint='api_unassign_order') +def api_unassign_order(): + """Remove an order from its box""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + data = request.get_json() + order_id = data.get('order_id') + + if not order_id: + return jsonify({'success': False, 'error': 'Order ID is required'}), 400 + + success, message, status_code = unassign_order_from_box(order_id) + return jsonify({'success': success, 'message': message}), status_code + + +@warehouse_bp.route('/api/boxes-summary', methods=['GET'], endpoint='api_boxes_summary') +def api_boxes_summary(): + """Get summary of all boxes with order counts""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': 'Unauthorized'}), 401 + + boxes = get_all_boxes_summary() + return jsonify({'success': True, 'boxes': boxes}), 200 + + # ============================================================================ # API Routes for CP Inventory View # ============================================================================ diff --git a/app/modules/warehouse/warehouse.py b/app/modules/warehouse/warehouse.py index 8d59f87..6afde00 100644 --- a/app/modules/warehouse/warehouse.py +++ b/app/modules/warehouse/warehouse.py @@ -452,6 +452,7 @@ def get_cp_inventory_list(limit=100, offset=0): cursor = conn.cursor() # Get all CP codes with their box and location info (latest entries first) + # NOTE: Use box's CURRENT location (from boxes_crates), not the historical scan location cursor.execute(""" SELECT s.id, @@ -468,7 +469,7 @@ def get_cp_inventory_list(limit=100, offset=0): SUM(s.rejected_quantity) as total_rejected FROM scanfg_orders s LEFT JOIN boxes_crates bc ON s.box_id = bc.id - LEFT JOIN warehouse_locations wl ON s.location_id = wl.id + LEFT JOIN warehouse_locations wl ON bc.location_id = wl.id GROUP BY SUBSTRING(s.CP_full_code, 1, 10), s.box_id ORDER BY MAX(s.created_at) DESC LIMIT %s OFFSET %s @@ -476,7 +477,15 @@ def get_cp_inventory_list(limit=100, offset=0): # Convert tuples to dicts using cursor description columns = [col[0] for col in cursor.description] - results = [{columns[i]: row[i] for i in range(len(columns))} for row in cursor.fetchall()] + results = [] + for row in cursor.fetchall(): + row_dict = {columns[i]: row[i] for i in range(len(columns))} + # Convert time and date fields to strings to avoid JSON serialization issues + if row_dict.get('latest_date'): + row_dict['latest_date'] = str(row_dict['latest_date']) + if row_dict.get('latest_time'): + row_dict['latest_time'] = str(row_dict['latest_time']) + results.append(row_dict) cursor.close() return results if results else [] @@ -503,6 +512,7 @@ def search_cp_code(cp_code_search): search_term = cp_code_search.replace('-', '').strip().upper() # Search for matching CP codes + # NOTE: Use box's CURRENT location (from boxes_crates), not the historical scan location cursor.execute(""" SELECT s.id, @@ -519,7 +529,7 @@ def search_cp_code(cp_code_search): SUM(s.rejected_quantity) as total_rejected FROM scanfg_orders s LEFT JOIN boxes_crates bc ON s.box_id = bc.id - LEFT JOIN warehouse_locations wl ON s.location_id = wl.id + LEFT JOIN warehouse_locations wl ON bc.location_id = wl.id WHERE REPLACE(s.CP_full_code, '-', '') LIKE %s GROUP BY SUBSTRING(s.CP_full_code, 1, 10), s.box_id ORDER BY MAX(s.created_at) DESC @@ -527,7 +537,15 @@ def search_cp_code(cp_code_search): # Convert tuples to dicts using cursor description columns = [col[0] for col in cursor.description] - results = [{columns[i]: row[i] for i in range(len(columns))} for row in cursor.fetchall()] + results = [] + for row in cursor.fetchall(): + row_dict = {columns[i]: row[i] for i in range(len(columns))} + # Convert time and date fields to strings to avoid JSON serialization issues + if row_dict.get('latest_date'): + row_dict['latest_date'] = str(row_dict['latest_date']) + if row_dict.get('latest_time'): + row_dict['latest_time'] = str(row_dict['latest_time']) + results.append(row_dict) cursor.close() return results if results else [] @@ -569,7 +587,7 @@ def search_by_box_number(box_number_search): s.created_at FROM scanfg_orders s LEFT JOIN boxes_crates bc ON s.box_id = bc.id - LEFT JOIN warehouse_locations wl ON s.location_id = wl.id + LEFT JOIN warehouse_locations wl ON bc.location_id = wl.id WHERE bc.box_number LIKE %s ORDER BY s.created_at DESC LIMIT 500 @@ -577,7 +595,17 @@ def search_by_box_number(box_number_search): # Convert tuples to dicts using cursor description columns = [col[0] for col in cursor.description] - results = [{columns[i]: row[i] for i in range(len(columns))} for row in cursor.fetchall()] + results = [] + for row in cursor.fetchall(): + row_dict = {columns[i]: row[i] for i in range(len(columns))} + # Convert time and date fields to strings to avoid JSON serialization issues + if row_dict.get('date'): + row_dict['date'] = str(row_dict['date']) + if row_dict.get('time'): + row_dict['time'] = str(row_dict['time']) + if row_dict.get('created_at'): + row_dict['created_at'] = str(row_dict['created_at']) + results.append(row_dict) cursor.close() return results if results else [] @@ -602,6 +630,7 @@ def get_cp_details(cp_code): cursor = conn.cursor() # Search for all entries with this CP base + # NOTE: Use box's CURRENT location (from boxes_crates), not the historical scan location cursor.execute(""" SELECT s.id, @@ -622,7 +651,7 @@ def get_cp_details(cp_code): s.created_at FROM scanfg_orders s LEFT JOIN boxes_crates bc ON s.box_id = bc.id - LEFT JOIN warehouse_locations wl ON s.location_id = wl.id + LEFT JOIN warehouse_locations wl ON bc.location_id = wl.id WHERE SUBSTRING(s.CP_full_code, 1, 10) = %s ORDER BY s.created_at DESC LIMIT 1000 @@ -630,7 +659,17 @@ def get_cp_details(cp_code): # Convert tuples to dicts using cursor description columns = [col[0] for col in cursor.description] - results = [{columns[i]: row[i] for i in range(len(columns))} for row in cursor.fetchall()] + results = [] + for row in cursor.fetchall(): + row_dict = {columns[i]: row[i] for i in range(len(columns))} + # Convert time and date fields to strings to avoid JSON serialization issues + if row_dict.get('date'): + row_dict['date'] = str(row_dict['date']) + if row_dict.get('time'): + row_dict['time'] = str(row_dict['time']) + if row_dict.get('created_at'): + row_dict['created_at'] = str(row_dict['created_at']) + results.append(row_dict) cursor.close() return results if results else [] diff --git a/app/modules/warehouse/warehouse_orders.py b/app/modules/warehouse/warehouse_orders.py new file mode 100644 index 0000000..d5bfced --- /dev/null +++ b/app/modules/warehouse/warehouse_orders.py @@ -0,0 +1,392 @@ +""" +Warehouse Orders Management - Helper Functions +Provides functions for managing orders on boxes +""" +import logging +import pymysql +from app.database import get_db + +logger = logging.getLogger(__name__) + + +def get_unassigned_orders(limit=100, offset=0): + """Get all orders not yet assigned to any box + + Returns: + List of unassigned orders + """ + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute(""" + SELECT + id, + CP_full_code, + operator_code, + quality_code, + date, + time, + approved_quantity, + rejected_quantity, + created_at + FROM scanfg_orders + WHERE box_id IS NULL + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, (limit, offset)) + + columns = [col[0] for col in cursor.description] + results = [] + for row in cursor.fetchall(): + row_dict = {columns[i]: row[i] for i in range(len(columns))} + if row_dict.get('date'): + row_dict['date'] = str(row_dict['date']) + if row_dict.get('time'): + row_dict['time'] = str(row_dict['time']) + if row_dict.get('created_at'): + row_dict['created_at'] = str(row_dict['created_at']) + results.append(row_dict) + cursor.close() + + return results if results else [] + except Exception as e: + logger.error(f"Error getting unassigned orders: {e}") + return [] + + +def get_orders_by_box(box_id): + """Get all orders assigned to a specific box + + Args: + box_id: ID of the box + + Returns: + Tuple: (success: bool, data: dict, status_code: int) + """ + try: + conn = get_db() + cursor = conn.cursor() + + # Get box info + cursor.execute(""" + SELECT id, box_number, status, location_id + FROM boxes_crates + WHERE id = %s + """, (box_id,)) + + box = cursor.fetchone() + + if not box: + cursor.close() + return False, {'error': 'Box not found'}, 404 + + # Get all orders in this box + cursor.execute(""" + SELECT + id, + CP_full_code, + operator_code, + quality_code, + date, + time, + approved_quantity, + rejected_quantity, + created_at + FROM scanfg_orders + WHERE box_id = %s + ORDER BY created_at DESC + """, (box_id,)) + + orders = cursor.fetchall() + columns = [col[0] for col in cursor.description] + + orders_list = [] + for order in orders: + order_dict = {columns[i]: order[i] for i in range(len(columns))} + if order_dict.get('date'): + order_dict['date'] = str(order_dict['date']) + if order_dict.get('time'): + order_dict['time'] = str(order_dict['time']) + if order_dict.get('created_at'): + order_dict['created_at'] = str(order_dict['created_at']) + orders_list.append(order_dict) + + # Get location info if box has a location + location_info = None + if box[3]: # location_id + cursor.execute(""" + SELECT id, location_code, size, description + FROM warehouse_locations + WHERE id = %s + """, (box[3],)) + loc = cursor.fetchone() + if loc: + location_info = { + 'id': loc[0], + 'location_code': loc[1], + 'size': loc[2], + 'description': loc[3] + } + + box_data = { + 'id': box[0], + 'box_number': box[1], + 'status': box[2], + 'location_id': box[3], + 'location': location_info, + 'orders_count': len(orders_list) + } + + cursor.close() + + return True, {'box': box_data, 'orders': orders_list}, 200 + except Exception as e: + logger.error(f"Error getting orders by box: {e}") + return False, {'error': str(e)}, 500 + + +def search_orders_by_cp_code(cp_code): + """Search for orders by CP code + + Args: + cp_code: CP code to search for (can be partial) + + Returns: + List of matching orders + """ + try: + conn = get_db() + cursor = conn.cursor() + + search_term = cp_code.replace('-', '').strip().upper() + + cursor.execute(""" + SELECT + id, + CP_full_code, + operator_code, + quality_code, + date, + time, + approved_quantity, + rejected_quantity, + box_id, + created_at + FROM scanfg_orders + WHERE REPLACE(CP_full_code, '-', '') LIKE %s + ORDER BY created_at DESC + LIMIT 100 + """, (f"{search_term}%",)) + + columns = [col[0] for col in cursor.description] + results = [] + for row in cursor.fetchall(): + row_dict = {columns[i]: row[i] for i in range(len(columns))} + if row_dict.get('date'): + row_dict['date'] = str(row_dict['date']) + if row_dict.get('time'): + row_dict['time'] = str(row_dict['time']) + if row_dict.get('created_at'): + row_dict['created_at'] = str(row_dict['created_at']) + results.append(row_dict) + cursor.close() + + return results if results else [] + except Exception as e: + logger.error(f"Error searching orders: {e}") + return [] + + +def assign_order_to_box(order_id, box_id): + """Assign an order to a box + + Args: + order_id: ID of the order (scanfg_orders.id) + box_id: ID of the box to assign to + + Returns: + Tuple: (success: bool, message: str, status_code: int) + """ + try: + if not order_id or not box_id: + return False, 'Order ID and box ID are required', 400 + + conn = get_db() + cursor = conn.cursor() + + # Verify order exists + cursor.execute("SELECT CP_full_code FROM scanfg_orders WHERE id = %s", (order_id,)) + order = cursor.fetchone() + + if not order: + cursor.close() + return False, 'Order not found', 404 + + # Verify box exists + cursor.execute("SELECT box_number FROM boxes_crates WHERE id = %s", (box_id,)) + box = cursor.fetchone() + + if not box: + cursor.close() + return False, 'Box not found', 404 + + # Assign order to box + cursor.execute(""" + UPDATE scanfg_orders + SET box_id = %s + WHERE id = %s + """, (box_id, order_id)) + + conn.commit() + cursor.close() + + return True, f'Order "{order[0]}" assigned to box "{box[0]}"', 200 + except Exception as e: + logger.error(f"Error assigning order to box: {e}") + return False, f'Error: {str(e)}', 500 + + +def move_order_to_box(order_id, new_box_id): + """Move an order from one box to another + + Args: + order_id: ID of the order + new_box_id: ID of the destination box + + Returns: + Tuple: (success: bool, message: str, status_code: int) + """ + try: + if not order_id or not new_box_id: + return False, 'Order ID and destination box ID are required', 400 + + conn = get_db() + cursor = conn.cursor() + + # Get order info + cursor.execute(""" + SELECT CP_full_code, box_id FROM scanfg_orders WHERE id = %s + """, (order_id,)) + + order = cursor.fetchone() + + if not order: + cursor.close() + return False, 'Order not found', 404 + + old_box_id = order[1] + + # Verify new box exists + cursor.execute("SELECT box_number FROM boxes_crates WHERE id = %s", (new_box_id,)) + new_box = cursor.fetchone() + + if not new_box: + cursor.close() + return False, 'Destination box not found', 404 + + # Get old box info if it exists + old_box_number = None + if old_box_id: + cursor.execute("SELECT box_number FROM boxes_crates WHERE id = %s", (old_box_id,)) + old_box = cursor.fetchone() + if old_box: + old_box_number = old_box[0] + + # Move order to new box + cursor.execute(""" + UPDATE scanfg_orders + SET box_id = %s + WHERE id = %s + """, (new_box_id, order_id)) + + conn.commit() + cursor.close() + + if old_box_number: + message = f'Order "{order[0]}" moved from "{old_box_number}" to "{new_box[0]}"' + else: + message = f'Order "{order[0]}" moved to "{new_box[0]}"' + + return True, message, 200 + except Exception as e: + logger.error(f"Error moving order to box: {e}") + return False, f'Error: {str(e)}', 500 + + +def unassign_order_from_box(order_id): + """Remove order from its assigned box + + Args: + order_id: ID of the order + + Returns: + Tuple: (success: bool, message: str, status_code: int) + """ + try: + conn = get_db() + cursor = conn.cursor() + + # Get order info + cursor.execute(""" + SELECT CP_full_code, box_id FROM scanfg_orders WHERE id = %s + """, (order_id,)) + + order = cursor.fetchone() + + if not order: + cursor.close() + return False, 'Order not found', 404 + + # Remove from box + cursor.execute(""" + UPDATE scanfg_orders + SET box_id = NULL + WHERE id = %s + """, (order_id,)) + + conn.commit() + cursor.close() + + return True, f'Order "{order[0]}" removed from box', 200 + except Exception as e: + logger.error(f"Error unassigning order: {e}") + return False, f'Error: {str(e)}', 500 + + +def get_all_boxes_summary(): + """Get summary of all boxes with order counts + + Returns: + List of boxes with order counts + """ + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute(""" + SELECT + bc.id, + bc.box_number, + bc.status, + wl.location_code, + wl.id as location_id, + COUNT(so.id) as order_count + FROM boxes_crates bc + LEFT JOIN warehouse_locations wl ON bc.location_id = wl.id + LEFT JOIN scanfg_orders so ON bc.id = so.box_id + GROUP BY bc.id, bc.box_number, bc.status, wl.location_code, wl.id + ORDER BY bc.box_number ASC + """) + + columns = [col[0] for col in cursor.description] + results = [] + for row in cursor.fetchall(): + row_dict = {columns[i]: row[i] for i in range(len(columns))} + results.append(row_dict) + cursor.close() + + return results if results else [] + except Exception as e: + logger.error(f"Error getting boxes summary: {e}") + return [] diff --git a/app/templates/modules/warehouse/index.html b/app/templates/modules/warehouse/index.html index f50047e..37acc5b 100644 --- a/app/templates/modules/warehouse/index.html +++ b/app/templates/modules/warehouse/index.html @@ -30,6 +30,22 @@ + +
Assign, move, or view orders on boxes and manage order-to-box relationships.
+ + Manage Orders + +