|
|
""" |
|
|
Integrated Makerspace Inventory Management System |
|
|
Smart inventory management powered by AI |
|
|
""" |
|
|
|
|
|
import google.generativeai as genai |
|
|
import chromadb |
|
|
from sentence_transformers import SentenceTransformer |
|
|
import gradio as gr |
|
|
from PIL import Image |
|
|
import json |
|
|
import re |
|
|
import os |
|
|
import pandas as pd |
|
|
from pdf2image import convert_from_path |
|
|
import pytesseract |
|
|
from rapidfuzz import process |
|
|
from transformers import AutoTokenizer, AutoModelForSequenceClassification |
|
|
import torch |
|
|
import datetime |
|
|
import itertools |
|
|
import math |
|
|
import numpy as np |
|
|
import matplotlib.pyplot as plt |
|
|
import io |
|
|
import shutil |
|
|
from typing import List, Dict, Tuple, Optional |
|
|
|
|
|
print("=" * 80) |
|
|
print("π¨ Makerspace Inventory Management System") |
|
|
print("=" * 80) |
|
|
print("Modules: Check Out | Add Items | Inventory Analysis") |
|
|
print("=" * 80 + "\n") |
|
|
|
|
|
|
|
|
GEMINI_API_KEY = "AIzaSyA5_Cx0rriZWtTr1KyEkWCJ6fVyXpUKuJw" |
|
|
genai.configure(api_key=GEMINI_API_KEY) |
|
|
gemini_model = genai.GenerativeModel('models/gemini-2.5-flash') |
|
|
print("β
Gemini API configured (gemini-2.5-flash)") |
|
|
|
|
|
|
|
|
embedding_model = SentenceTransformer('all-MiniLM-L6-v2') |
|
|
print("β
Embedding model loaded") |
|
|
|
|
|
|
|
|
ITEMS_CSV = "items.csv" |
|
|
LOCATIONS_CSV = "locations.csv" |
|
|
CHECKOUTS_CSV = "checkouts.csv" |
|
|
UPDATE_LOG_CSV = "update_log.csv" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CUSTOM_CSS = """ |
|
|
/* Global styling - Fixed desktop layout */ |
|
|
.gradio-container { |
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important; |
|
|
max-width: 1400px !important; |
|
|
margin: auto !important; |
|
|
} |
|
|
|
|
|
/* Fix all blocks to have consistent width */ |
|
|
.gradio-container .block { |
|
|
width: 100% !important; |
|
|
max-width: 100% !important; |
|
|
} |
|
|
|
|
|
/* Ensure all rows stay full width */ |
|
|
.gradio-container .row { |
|
|
width: 100% !important; |
|
|
} |
|
|
|
|
|
/* Main menu - gradient background */ |
|
|
#main-menu-container { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
|
padding: 50px 40px; |
|
|
border-radius: 16px; |
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.12); |
|
|
margin-bottom: 30px; |
|
|
width: 100%; |
|
|
} |
|
|
|
|
|
#main-menu-title { |
|
|
color: white !important; |
|
|
text-align: center; |
|
|
font-size: 2.8em !important; |
|
|
font-weight: 700 !important; |
|
|
margin-bottom: 8px !important; |
|
|
letter-spacing: -0.5px; |
|
|
} |
|
|
|
|
|
#main-menu-subtitle { |
|
|
color: rgba(255,255,255,0.95) !important; |
|
|
text-align: center; |
|
|
font-size: 1.1em !important; |
|
|
margin-bottom: 40px !important; |
|
|
font-weight: 300; |
|
|
} |
|
|
|
|
|
/* Module buttons in main menu */ |
|
|
.module-button { |
|
|
background: white !important; |
|
|
color: #667eea !important; |
|
|
border: none !important; |
|
|
padding: 32px 24px !important; |
|
|
font-size: 1.25em !important; |
|
|
font-weight: 600 !important; |
|
|
border-radius: 12px !important; |
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.1) !important; |
|
|
transition: all 0.3s ease !important; |
|
|
min-height: 120px !important; |
|
|
display: flex !important; |
|
|
align-items: center !important; |
|
|
justify-content: center !important; |
|
|
} |
|
|
|
|
|
.module-button:hover { |
|
|
transform: translateY(-3px) !important; |
|
|
box-shadow: 0 6px 24px rgba(0,0,0,0.15) !important; |
|
|
} |
|
|
|
|
|
/* Clean module pages - consistent sizing */ |
|
|
.module-page { |
|
|
background: white; |
|
|
padding: 40px; |
|
|
border-radius: 12px; |
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08); |
|
|
min-height: 600px; |
|
|
width: 100%; |
|
|
} |
|
|
|
|
|
/* Section headers */ |
|
|
.section-header { |
|
|
color: #1a202c; |
|
|
font-size: 2.2em; |
|
|
font-weight: 700; |
|
|
margin-bottom: 15px; |
|
|
padding-bottom: 15px; |
|
|
border-bottom: 3px solid #667eea; |
|
|
} |
|
|
|
|
|
/* Subsection headers */ |
|
|
.subsection-header { |
|
|
color: #2d3748; |
|
|
font-size: 1.4em; |
|
|
font-weight: 600; |
|
|
margin-top: 30px; |
|
|
margin-bottom: 15px; |
|
|
} |
|
|
|
|
|
/* Instructions box */ |
|
|
.instructions-box { |
|
|
background: #f7fafc; |
|
|
border-left: 4px solid #667eea; |
|
|
padding: 20px 25px; |
|
|
border-radius: 8px; |
|
|
margin: 20px 0; |
|
|
} |
|
|
|
|
|
.instructions-box p { |
|
|
margin: 10px 0; |
|
|
line-height: 1.7; |
|
|
} |
|
|
|
|
|
/* Buttons - consistent sizing */ |
|
|
button { |
|
|
min-height: 48px !important; |
|
|
font-size: 1.05em !important; |
|
|
font-weight: 600 !important; |
|
|
border-radius: 8px !important; |
|
|
padding: 12px 28px !important; |
|
|
transition: all 0.3s ease !important; |
|
|
} |
|
|
|
|
|
.primary-button { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; |
|
|
color: white !important; |
|
|
border: none !important; |
|
|
} |
|
|
|
|
|
.primary-button:hover { |
|
|
transform: translateY(-2px) !important; |
|
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important; |
|
|
} |
|
|
|
|
|
/* Input fields - consistent sizing */ |
|
|
input, textarea, select { |
|
|
min-height: 44px !important; |
|
|
font-size: 1em !important; |
|
|
} |
|
|
|
|
|
/* Image upload areas - better styling */ |
|
|
.image-upload-container { |
|
|
min-height: 400px !important; |
|
|
} |
|
|
|
|
|
/* Upload/Webcam tab buttons - make them visible and styled */ |
|
|
/* Use more aggressive selectors for Gradio 5.50 */ |
|
|
.tabs button, |
|
|
.tab-nav button, |
|
|
button[id*="component"], |
|
|
button[id*="upload"], |
|
|
button[id*="webcam"] { |
|
|
background: white !important; |
|
|
border: 2px solid #cbd5e0 !important; |
|
|
border-radius: 10px !important; |
|
|
padding: 14px 28px !important; |
|
|
font-size: 1em !important; |
|
|
font-weight: 600 !important; |
|
|
color: #2d3748 !important; |
|
|
transition: all 0.3s ease !important; |
|
|
min-width: 130px !important; |
|
|
margin: 4px !important; |
|
|
} |
|
|
|
|
|
.tabs button:hover, |
|
|
.tab-nav button:hover { |
|
|
background: #667eea !important; |
|
|
border-color: #667eea !important; |
|
|
color: white !important; |
|
|
transform: translateY(-2px) !important; |
|
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3) !important; |
|
|
} |
|
|
|
|
|
.tabs button.selected, |
|
|
.tab-nav button.selected, |
|
|
button[aria-selected="true"] { |
|
|
background: #667eea !important; |
|
|
border-color: #667eea !important; |
|
|
color: white !important; |
|
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4) !important; |
|
|
} |
|
|
|
|
|
/* Force ALL SVG icons to be visible with color */ |
|
|
.tabs button svg, |
|
|
.tab-nav button svg, |
|
|
button[id*="component"] svg, |
|
|
button svg { |
|
|
width: 20px !important; |
|
|
height: 20px !important; |
|
|
fill: currentColor !important; |
|
|
stroke: currentColor !important; |
|
|
opacity: 1 !important; |
|
|
visibility: visible !important; |
|
|
} |
|
|
|
|
|
.tabs button svg *, |
|
|
.tab-nav button svg *, |
|
|
button svg * { |
|
|
fill: currentColor !important; |
|
|
stroke: currentColor !important; |
|
|
opacity: 1 !important; |
|
|
} |
|
|
|
|
|
/* Specific for image component tabs */ |
|
|
div[id*="image"] button, |
|
|
.image-container button { |
|
|
background: white !important; |
|
|
color: #2d3748 !important; |
|
|
border: 2px solid #cbd5e0 !important; |
|
|
} |
|
|
|
|
|
div[id*="image"] button:hover, |
|
|
.image-container button:hover { |
|
|
background: #667eea !important; |
|
|
color: white !important; |
|
|
} |
|
|
|
|
|
div[id*="image"] button svg, |
|
|
.image-container button svg { |
|
|
fill: currentColor !important; |
|
|
stroke: currentColor !important; |
|
|
} |
|
|
|
|
|
[data-testid="image"] [role="tablist"], |
|
|
.image-container [role="tablist"], |
|
|
button[role="tab"] { |
|
|
background: transparent !important; |
|
|
} |
|
|
|
|
|
[data-testid="image"] [role="tab"], |
|
|
.image-container [role="tab"], |
|
|
button[role="tab"] { |
|
|
background: white !important; |
|
|
border: 2px solid #cbd5e0 !important; |
|
|
border-radius: 10px !important; |
|
|
padding: 14px 28px !important; |
|
|
font-size: 1em !important; |
|
|
font-weight: 600 !important; |
|
|
color: #2d3748 !important; |
|
|
transition: all 0.3s ease !important; |
|
|
min-width: 130px !important; |
|
|
margin: 4px !important; |
|
|
} |
|
|
|
|
|
[data-testid="image"] [role="tab"]:hover, |
|
|
.image-container [role="tab"]:hover, |
|
|
button[role="tab"]:hover { |
|
|
background: #667eea !important; |
|
|
border-color: #667eea !important; |
|
|
color: white !important; |
|
|
transform: translateY(-2px) !important; |
|
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3) !important; |
|
|
} |
|
|
|
|
|
[data-testid="image"] [role="tab"][aria-selected="true"], |
|
|
.image-container [role="tab"][aria-selected="true"], |
|
|
button[role="tab"][aria-selected="true"] { |
|
|
background: #667eea !important; |
|
|
border-color: #667eea !important; |
|
|
color: white !important; |
|
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4) !important; |
|
|
} |
|
|
|
|
|
/* Icon colors in tabs - force visibility */ |
|
|
[data-testid="image"] [role="tab"] svg, |
|
|
.image-container [role="tab"] svg, |
|
|
button[role="tab"] svg { |
|
|
width: 20px !important; |
|
|
height: 20px !important; |
|
|
fill: currentColor !important; |
|
|
stroke: currentColor !important; |
|
|
} |
|
|
|
|
|
[data-testid="image"] [role="tab"] svg path, |
|
|
[data-testid="image"] [role="tab"] svg line, |
|
|
[data-testid="image"] [role="tab"] svg circle, |
|
|
[data-testid="image"] [role="tab"] svg rect, |
|
|
.image-container [role="tab"] svg path, |
|
|
button[role="tab"] svg path { |
|
|
stroke: currentColor !important; |
|
|
fill: currentColor !important; |
|
|
stroke-width: 2 !important; |
|
|
} |
|
|
|
|
|
/* Ensure tab text is visible */ |
|
|
[data-testid="image"] [role="tab"] span, |
|
|
.image-container [role="tab"] span, |
|
|
button[role="tab"] span { |
|
|
color: inherit !important; |
|
|
} |
|
|
|
|
|
/* Upload area */ |
|
|
[data-testid="image"] .upload-container { |
|
|
background: white !important; |
|
|
border: 2px dashed #cbd5e0 !important; |
|
|
border-radius: 12px !important; |
|
|
padding: 40px !important; |
|
|
min-height: 350px !important; |
|
|
} |
|
|
|
|
|
[data-testid="image"] .upload-container:hover { |
|
|
border-color: #667eea !important; |
|
|
background: #f7fafc !important; |
|
|
} |
|
|
|
|
|
/* File upload button styling */ |
|
|
.file-upload button { |
|
|
background: white !important; |
|
|
border: 2px solid #e2e8f0 !important; |
|
|
color: #2d3748 !important; |
|
|
} |
|
|
|
|
|
.file-upload button:hover { |
|
|
background: #f7fafc !important; |
|
|
border-color: #667eea !important; |
|
|
} |
|
|
|
|
|
/* Dataframe tables */ |
|
|
.dataframe-container { |
|
|
min-height: 200px !important; |
|
|
max-height: 500px !important; |
|
|
} |
|
|
|
|
|
/* Markdown containers */ |
|
|
.markdown-container { |
|
|
line-height: 1.7 !important; |
|
|
} |
|
|
|
|
|
/* Accordions */ |
|
|
.gradio-accordion { |
|
|
border: 1px solid #e2e8f0 !important; |
|
|
border-radius: 8px !important; |
|
|
margin: 15px 0 !important; |
|
|
} |
|
|
|
|
|
/* Tabs */ |
|
|
.tabs { |
|
|
border-radius: 8px !important; |
|
|
margin-top: 20px !important; |
|
|
} |
|
|
|
|
|
/* Status messages styling */ |
|
|
.status-loading { |
|
|
background: #fff3cd; |
|
|
border-left: 4px solid #ffc107; |
|
|
color: #856404; |
|
|
padding: 15px; |
|
|
border-radius: 6px; |
|
|
margin: 15px 0; |
|
|
} |
|
|
|
|
|
.status-success { |
|
|
background: #d4edda; |
|
|
border-left: 4px solid #28a745; |
|
|
color: #155724; |
|
|
padding: 15px; |
|
|
border-radius: 6px; |
|
|
margin: 15px 0; |
|
|
} |
|
|
|
|
|
.status-error { |
|
|
background: #f8d7da; |
|
|
border-left: 4px solid #dc3545; |
|
|
color: #721c24; |
|
|
padding: 15px; |
|
|
border-radius: 6px; |
|
|
margin: 15px 0; |
|
|
} |
|
|
|
|
|
/* Remove mobile responsiveness - keep desktop width */ |
|
|
@media (max-width: 768px) { |
|
|
.gradio-container { |
|
|
max-width: 1400px !important; |
|
|
} |
|
|
} |
|
|
|
|
|
/* Ensure consistent spacing */ |
|
|
.gap { |
|
|
gap: 20px !important; |
|
|
} |
|
|
|
|
|
/* Column consistency */ |
|
|
.column { |
|
|
padding: 10px !important; |
|
|
} |
|
|
""" |
|
|
|
|
|
|
|
|
EXAMPLE_CHECKOUT_IMAGE = "screwdriver.png" if os.path.exists("screwdriver.png") else None |
|
|
EXAMPLE_RECEIPT_PDF = "mcmaster_receipt.pdf" if os.path.exists("mcmaster_receipt.pdf") else None |
|
|
|
|
|
if EXAMPLE_CHECKOUT_IMAGE: |
|
|
print(f"β
Example checkout image found: {EXAMPLE_CHECKOUT_IMAGE}") |
|
|
if EXAMPLE_RECEIPT_PDF: |
|
|
print(f"β
Example receipt PDF found: {EXAMPLE_RECEIPT_PDF}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_items(): |
|
|
"""Load items from CSV""" |
|
|
return pd.read_csv(ITEMS_CSV) |
|
|
|
|
|
def save_items(df): |
|
|
"""Save items to CSV""" |
|
|
df.to_csv(ITEMS_CSV, index=False) |
|
|
|
|
|
def load_locations(): |
|
|
"""Load locations from CSV""" |
|
|
return pd.read_csv(LOCATIONS_CSV) |
|
|
|
|
|
def load_checkouts(): |
|
|
"""Load checkouts from CSV""" |
|
|
return pd.read_csv(CHECKOUTS_CSV) |
|
|
|
|
|
def append_checkout(timestamp, user_id, session_id, item_id): |
|
|
"""Append a new checkout record""" |
|
|
df = load_checkouts() |
|
|
new_row = pd.DataFrame([[timestamp, user_id, session_id, item_id]], |
|
|
columns=["timestamp", "user_id", "session_id", "item_id"]) |
|
|
df = pd.concat([df, new_row], ignore_index=True) |
|
|
df.to_csv(CHECKOUTS_CSV, index=False) |
|
|
|
|
|
def get_categories(): |
|
|
"""Get list of all categories""" |
|
|
df = load_items() |
|
|
return sorted(df['category'].unique().tolist()) |
|
|
|
|
|
def rebuild_chromadb(): |
|
|
"""Rebuild ChromaDB from current inventory""" |
|
|
global chroma_client, collection |
|
|
|
|
|
df = load_items() |
|
|
chroma_client = chromadb.Client() |
|
|
|
|
|
try: |
|
|
chroma_client.delete_collection(name="makerspace_inventory") |
|
|
except: |
|
|
pass |
|
|
|
|
|
collection = chroma_client.create_collection( |
|
|
name="makerspace_inventory", |
|
|
metadata={"description": "Makerspace tool inventory"} |
|
|
) |
|
|
|
|
|
documents = [] |
|
|
metadatas = [] |
|
|
ids = [] |
|
|
|
|
|
for i, row in df.iterrows(): |
|
|
doc_text = f"{row['item_name']} {row['category']} {row['description']}" |
|
|
documents.append(doc_text) |
|
|
|
|
|
metadatas.append({ |
|
|
"item_id": row['item_id'], |
|
|
"item_name": row['item_name'], |
|
|
"category": row['category'], |
|
|
"quantity": str(row['quantity']), |
|
|
"unit": row['unit'], |
|
|
"description": row['description'], |
|
|
"location_id": row['location_id'] |
|
|
}) |
|
|
|
|
|
ids.append(f"item_{i}") |
|
|
|
|
|
collection.add( |
|
|
documents=documents, |
|
|
metadatas=metadatas, |
|
|
ids=ids |
|
|
) |
|
|
|
|
|
print(f"β
ChromaDB rebuilt with {len(df)} items") |
|
|
|
|
|
|
|
|
rebuild_chromadb() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def retrieve_top_candidates(query_text, top_k=5): |
|
|
"""Retrieve top matching items from vector database""" |
|
|
results = collection.query( |
|
|
query_texts=[query_text], |
|
|
n_results=top_k |
|
|
) |
|
|
|
|
|
candidates = [] |
|
|
if results['metadatas'] and len(results['metadatas'][0]) > 0: |
|
|
for metadata in results['metadatas'][0]: |
|
|
candidates.append({ |
|
|
'Item ID': metadata['item_id'], |
|
|
'Item Name': metadata['item_name'], |
|
|
'Category': metadata['category'], |
|
|
'Quantity': int(metadata['quantity']), |
|
|
'Unit': metadata['unit'], |
|
|
'Description': metadata['description'], |
|
|
'Location': metadata['location_id'] |
|
|
}) |
|
|
|
|
|
return candidates |
|
|
|
|
|
def detect_all_items(image): |
|
|
"""Detect ALL items in image using Gemini""" |
|
|
prompt = """Analyze this image and list EVERY distinct tool or item you see. |
|
|
|
|
|
IMPORTANT: List each item on ONE line only. Do not include additional details like "Type:", "Brand:", etc. |
|
|
|
|
|
Format your response as: |
|
|
1. [One-line description including brand and key features] |
|
|
2. [One-line description including brand and key features] |
|
|
|
|
|
Example: |
|
|
1. Digital caliper with LCD display |
|
|
2. Arduino microcontroller board |
|
|
|
|
|
If you see only one item, list just that one. |
|
|
If you see no tools/items, respond with "No items detected." |
|
|
|
|
|
Focus on items in the FOREGROUND. Ignore background objects unless they are clearly the subject.""" |
|
|
|
|
|
try: |
|
|
if isinstance(image, str): |
|
|
image = Image.open(image) |
|
|
|
|
|
response = gemini_model.generate_content([prompt, image]) |
|
|
response_text = response.text.strip() |
|
|
|
|
|
if "no items" in response_text.lower(): |
|
|
return "No items detected" |
|
|
|
|
|
items = [] |
|
|
lines = response_text.split('\n') |
|
|
|
|
|
for line in lines: |
|
|
line = line.strip() |
|
|
if re.match(r'^\d+[\.\)]\s+', line): |
|
|
item_desc = re.sub(r'^\d+[\.\)]\s+', '', line) |
|
|
if item_desc: |
|
|
items.append(item_desc.strip()) |
|
|
|
|
|
if not items and response_text: |
|
|
items = [response_text] |
|
|
|
|
|
return items if items else "Error: Could not parse items from response" |
|
|
|
|
|
except Exception as e: |
|
|
return f"Error: {str(e)}" |
|
|
|
|
|
def match_single_item_to_inventory(description): |
|
|
"""Match a single item description to inventory using ChromaDB""" |
|
|
try: |
|
|
candidates = retrieve_top_candidates(description, top_k=3) |
|
|
|
|
|
if not candidates: |
|
|
return None |
|
|
|
|
|
for candidate in candidates: |
|
|
if candidate['Quantity'] > 0: |
|
|
return candidate |
|
|
|
|
|
return None |
|
|
|
|
|
except Exception as e: |
|
|
print(f"Error matching item: {e}") |
|
|
return None |
|
|
|
|
|
def scan_items_checkout(image, manual_text): |
|
|
"""Process image or manual text and return matched items""" |
|
|
detected_items = [] |
|
|
|
|
|
if image is not None: |
|
|
detected = detect_all_items(image) |
|
|
if isinstance(detected, list): |
|
|
detected_items.extend(detected) |
|
|
elif isinstance(detected, str) and "error" not in detected.lower(): |
|
|
detected_items.append(detected) |
|
|
|
|
|
if manual_text and manual_text.strip(): |
|
|
manual_items = [item.strip() for item in manual_text.split(',') if item.strip()] |
|
|
detected_items.extend(manual_items) |
|
|
|
|
|
if not detected_items: |
|
|
return None, "β οΈ No items detected. Please upload an image or enter item names manually." |
|
|
|
|
|
matched_items = [] |
|
|
for desc in detected_items: |
|
|
match = match_single_item_to_inventory(desc) |
|
|
if match: |
|
|
matched_items.append({ |
|
|
'description': desc, |
|
|
'item': match, |
|
|
'quantity': 1 |
|
|
}) |
|
|
|
|
|
if not matched_items: |
|
|
return None, "β οΈ Could not match any detected items to inventory." |
|
|
|
|
|
return matched_items, "" |
|
|
|
|
|
def create_checkout_item_display(item_data, index): |
|
|
"""Create display text for a matched item""" |
|
|
item = item_data['item'] |
|
|
desc = item_data['description'] |
|
|
|
|
|
display = f"""<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; border-left: 4px solid #667eea; margin: 10px 0;"> |
|
|
<div style="color: #667eea; font-weight: 600; font-size: 0.9em; margin-bottom: 8px;">π DETECTED ITEM</div> |
|
|
<div style="color: #2d3748; font-size: 1.05em; margin-bottom: 12px;">{desc}</div> |
|
|
<div style="display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; font-size: 0.95em;"> |
|
|
<span style="color: #718096; font-weight: 600;">Matched Item:</span><span style="color: #2d3748;">{item['Item Name']}</span> |
|
|
<span style="color: #718096; font-weight: 600;">Category:</span><span style="color: #2d3748;">{item['Category']}</span> |
|
|
<span style="color: #718096; font-weight: 600;">Location:</span><span style="color: #2d3748;">{item['Location']}</span> |
|
|
<span style="color: #718096; font-weight: 600;">Available:</span><span style="color: #2d3748;">{item['Quantity']} {item['Unit']}</span> |
|
|
</div> |
|
|
</div>""" |
|
|
|
|
|
return display |
|
|
|
|
|
def confirm_checkout_preview(matched_items): |
|
|
"""Generate checkout confirmation preview""" |
|
|
if not matched_items: |
|
|
return "No items to check out." |
|
|
|
|
|
preview = "# π Checkout Summary\n\n" |
|
|
preview += "Please review your items before completing checkout:\n\n" |
|
|
for i, item_data in enumerate(matched_items, 1): |
|
|
item = item_data['item'] |
|
|
qty = item_data['quantity'] |
|
|
preview += f"{i}. **{item['Item Name']}** Γ {qty} (Location: {item['Location']})\n" |
|
|
|
|
|
preview += f"\n**Total Items:** {len(matched_items)}" |
|
|
|
|
|
return preview |
|
|
|
|
|
def process_checkout(matched_items, user_id_input): |
|
|
"""Process the checkout and update inventory""" |
|
|
if not matched_items: |
|
|
return "No items to check out." |
|
|
|
|
|
if not user_id_input or not user_id_input.strip(): |
|
|
user_id = f"U{np.random.randint(1, 9999):04d}" |
|
|
else: |
|
|
user_id = user_id_input.strip() |
|
|
|
|
|
df_checkouts = load_checkouts() |
|
|
if len(df_checkouts) > 0: |
|
|
last_session = df_checkouts['session_id'].max() |
|
|
session_num = int(last_session[1:]) + 1 |
|
|
else: |
|
|
session_num = 1 |
|
|
session_id = f"S{session_num:05d}" |
|
|
|
|
|
df_items = load_items() |
|
|
|
|
|
timestamp_base = datetime.datetime.now() |
|
|
checked_out = [] |
|
|
errors = [] |
|
|
|
|
|
for i, item_data in enumerate(matched_items): |
|
|
item_id = item_data['item']['Item ID'] |
|
|
qty = item_data['quantity'] |
|
|
item_name = item_data['item']['Item Name'] |
|
|
|
|
|
item_idx = df_items[df_items['item_id'] == item_id].index |
|
|
if len(item_idx) == 0: |
|
|
errors.append(f"Item {item_name} not found in inventory") |
|
|
continue |
|
|
|
|
|
item_idx = item_idx[0] |
|
|
current_qty = df_items.loc[item_idx, 'quantity'] |
|
|
|
|
|
if current_qty < qty: |
|
|
errors.append(f"Not enough {item_name} available (requested: {qty}, available: {current_qty})") |
|
|
continue |
|
|
|
|
|
df_items.loc[item_idx, 'quantity'] = current_qty - qty |
|
|
|
|
|
checkout_time = timestamp_base + datetime.timedelta(seconds=i*10) |
|
|
append_checkout( |
|
|
checkout_time.strftime("%Y-%m-%dT%H:%M:%SZ"), |
|
|
user_id, |
|
|
session_id, |
|
|
item_id |
|
|
) |
|
|
|
|
|
checked_out.append(f"β
{item_name} Γ {qty}") |
|
|
|
|
|
save_items(df_items) |
|
|
rebuild_chromadb() |
|
|
|
|
|
summary = f"# β
Checkout Complete!\n\n" |
|
|
summary += f"**Session ID:** `{session_id}`\n" |
|
|
summary += f"**User ID:** `{user_id}`\n" |
|
|
summary += f"**Timestamp:** {timestamp_base.strftime('%Y-%m-%d %H:%M:%S')}\n\n" |
|
|
|
|
|
if checked_out: |
|
|
summary += "## Items Checked Out:\n" |
|
|
for item in checked_out: |
|
|
summary += f"- {item}\n" |
|
|
|
|
|
if errors: |
|
|
summary += "\n## β οΈ Errors:\n" |
|
|
for error in errors: |
|
|
summary += f"- {error}\n" |
|
|
|
|
|
return summary |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_text_from_receipt(file_path): |
|
|
"""Extract text from PDF or image receipt""" |
|
|
if file_path is None: |
|
|
return None, [] |
|
|
|
|
|
try: |
|
|
|
|
|
import shutil |
|
|
tesseract_path = shutil.which('tesseract') |
|
|
if tesseract_path: |
|
|
pytesseract.pytesseract.tesseract_cmd = tesseract_path |
|
|
else: |
|
|
|
|
|
for path in ['/usr/bin/tesseract', '/usr/local/bin/tesseract', '/bin/tesseract']: |
|
|
if os.path.exists(path): |
|
|
pytesseract.pytesseract.tesseract_cmd = path |
|
|
break |
|
|
|
|
|
text = "" |
|
|
|
|
|
if file_path.name.lower().endswith('.pdf'): |
|
|
|
|
|
poppler_paths = [None, '/usr/bin', '/usr/local/bin', '/bin'] |
|
|
images = None |
|
|
last_error = None |
|
|
|
|
|
for poppler_path in poppler_paths: |
|
|
try: |
|
|
if poppler_path: |
|
|
images = convert_from_path(file_path.name, poppler_path=poppler_path) |
|
|
else: |
|
|
images = convert_from_path(file_path.name) |
|
|
break |
|
|
except Exception as e: |
|
|
last_error = str(e) |
|
|
continue |
|
|
|
|
|
if images is None: |
|
|
|
|
|
return [[True, f"PDF processing unavailable. Please upload images (PNG/JPG) instead. Error: {last_error}", "", ""]], [] |
|
|
|
|
|
for img in images: |
|
|
text += pytesseract.image_to_string(img) + "\n" |
|
|
else: |
|
|
img = Image.open(file_path.name) |
|
|
text = pytesseract.image_to_string(img) |
|
|
|
|
|
if not text.strip(): |
|
|
return [["No text extracted", "", "", True]], [] |
|
|
|
|
|
proposals = parse_receipt_text(text) |
|
|
|
|
|
if not proposals: |
|
|
return [["No items recognized", "", "", True]], [] |
|
|
|
|
|
table_data = [] |
|
|
for prop in proposals: |
|
|
table_data.append([ |
|
|
True, |
|
|
prop['item_name'], |
|
|
str(prop['quantity']), |
|
|
prop['match_type'] |
|
|
]) |
|
|
|
|
|
return table_data, proposals |
|
|
|
|
|
except Exception as e: |
|
|
return [[True, f"Error: {str(e)}", "", ""]], [] |
|
|
|
|
|
def parse_receipt_text(text): |
|
|
"""Parse receipt text to extract items and quantities""" |
|
|
df_items = load_items() |
|
|
item_names = df_items['item_name'].tolist() |
|
|
|
|
|
proposals = [] |
|
|
lines = text.split('\n') |
|
|
|
|
|
for line in lines: |
|
|
line = line.strip() |
|
|
if not line or len(line) < 3: |
|
|
continue |
|
|
|
|
|
qty_match = re.search(r'(\d+)\s*x?\s*(.+)', line, re.IGNORECASE) |
|
|
if qty_match: |
|
|
qty = int(qty_match.group(1)) |
|
|
item_text = qty_match.group(2).strip() |
|
|
else: |
|
|
qty = 1 |
|
|
item_text = line |
|
|
|
|
|
match = process.extractOne(item_text, item_names, score_cutoff=60) |
|
|
|
|
|
if match: |
|
|
matched_name = match[0] |
|
|
confidence = match[1] |
|
|
|
|
|
item_row = df_items[df_items['item_name'] == matched_name].iloc[0] |
|
|
|
|
|
proposals.append({ |
|
|
'item_id': item_row['item_id'], |
|
|
'item_name': matched_name, |
|
|
'quantity': qty, |
|
|
'match_type': f"Fuzzy ({confidence}%)", |
|
|
'original_text': item_text |
|
|
}) |
|
|
|
|
|
return proposals |
|
|
|
|
|
def apply_updates_from_table(table_data): |
|
|
"""Apply inventory updates from edited table""" |
|
|
|
|
|
if isinstance(table_data, pd.DataFrame): |
|
|
if table_data.empty: |
|
|
return "No updates to apply.", None |
|
|
table_data = table_data.values.tolist() |
|
|
|
|
|
if not table_data or len(table_data) == 0: |
|
|
return "No updates to apply.", None |
|
|
|
|
|
df_items = load_items() |
|
|
|
|
|
updated = [] |
|
|
errors = [] |
|
|
|
|
|
for row in table_data: |
|
|
if not row[0]: |
|
|
continue |
|
|
|
|
|
item_name = row[1] |
|
|
try: |
|
|
qty = int(row[2]) |
|
|
except: |
|
|
errors.append(f"Invalid quantity for {item_name}") |
|
|
continue |
|
|
|
|
|
item_names = df_items['item_name'].tolist() |
|
|
match = process.extractOne(item_name, item_names, score_cutoff=60) |
|
|
|
|
|
if not match: |
|
|
errors.append(f"Could not find item: {item_name}") |
|
|
continue |
|
|
|
|
|
matched_name = match[0] |
|
|
item_idx = df_items[df_items['item_name'] == matched_name].index[0] |
|
|
|
|
|
current_qty = df_items.loc[item_idx, 'quantity'] |
|
|
new_qty = current_qty + qty |
|
|
|
|
|
df_items.loc[item_idx, 'quantity'] = new_qty |
|
|
updated.append(f"β
{matched_name}: {current_qty} β {new_qty} (+{qty})") |
|
|
|
|
|
if not updated: |
|
|
return "No items were updated. " + ("\n".join(errors) if errors else ""), None |
|
|
|
|
|
save_items(df_items) |
|
|
rebuild_chromadb() |
|
|
|
|
|
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
log_entry = f"{timestamp},Receipt Upload,{len(updated)} items\n" |
|
|
with open(UPDATE_LOG_CSV, 'a') as f: |
|
|
f.write(log_entry) |
|
|
|
|
|
summary = "# β
Updates Applied!\n\n" |
|
|
for item in updated: |
|
|
summary += f"- {item}\n" |
|
|
|
|
|
if errors: |
|
|
summary += "\n## β οΈ Warnings:\n" |
|
|
for error in errors: |
|
|
summary += f"- {error}\n" |
|
|
|
|
|
return summary, df_items[['item_id', 'item_name', 'category', 'quantity', 'unit', 'location_id']] |
|
|
|
|
|
def manual_update(item_name, quantity): |
|
|
"""Manually update an item's quantity""" |
|
|
if not item_name or not quantity: |
|
|
return "β οΈ Please provide item name and quantity.", None |
|
|
|
|
|
try: |
|
|
qty = int(quantity) |
|
|
except: |
|
|
return "β οΈ Invalid quantity.", None |
|
|
|
|
|
df_items = load_items() |
|
|
|
|
|
item_names = df_items['item_name'].tolist() |
|
|
match = process.extractOne(item_name, item_names, score_cutoff=60) |
|
|
|
|
|
if not match: |
|
|
return f"β Could not find item: {item_name}", None |
|
|
|
|
|
matched_name = match[0] |
|
|
item_idx = df_items[df_items['item_name'] == matched_name].index[0] |
|
|
|
|
|
current_qty = df_items.loc[item_idx, 'quantity'] |
|
|
new_qty = current_qty + qty |
|
|
|
|
|
df_items.loc[item_idx, 'quantity'] = new_qty |
|
|
|
|
|
save_items(df_items) |
|
|
rebuild_chromadb() |
|
|
|
|
|
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
|
|
log_entry = f"{timestamp},Manual Update,{matched_name}: {current_qty} β {new_qty}\n" |
|
|
with open(UPDATE_LOG_CSV, 'a') as f: |
|
|
f.write(log_entry) |
|
|
|
|
|
summary = f"β
Updated **{matched_name}**: {current_qty} β {new_qty} (+{qty})" |
|
|
|
|
|
return summary, df_items[['item_id', 'item_name', 'category', 'quantity', 'unit', 'location_id']] |
|
|
|
|
|
def view_inventory_table(category_filter="All"): |
|
|
"""View current inventory with optional category filter""" |
|
|
df_items = load_items() |
|
|
|
|
|
if category_filter != "All": |
|
|
df_items = df_items[df_items['category'] == category_filter] |
|
|
|
|
|
return df_items[['item_id', 'item_name', 'category', 'quantity', 'unit', 'location_id']] |
|
|
|
|
|
def view_update_history(): |
|
|
"""View update history""" |
|
|
if not os.path.exists(UPDATE_LOG_CSV) or os.path.getsize(UPDATE_LOG_CSV) == 0: |
|
|
return "No update history available." |
|
|
|
|
|
with open(UPDATE_LOG_CSV, 'r') as f: |
|
|
history = f.read() |
|
|
|
|
|
return history if history.strip() else "No update history available." |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_sessions(df_chk, gap_minutes=30): |
|
|
"""Ensure sessions exist in checkout data""" |
|
|
df = df_chk.copy() |
|
|
|
|
|
if "session_id" in df.columns and df['session_id'].notna().all(): |
|
|
return df |
|
|
|
|
|
if "user_id" not in df.columns or "timestamp" not in df.columns: |
|
|
df['session_id'] = [f"S{i:05d}" for i in range(1, len(df) + 1)] |
|
|
return df |
|
|
|
|
|
df["timestamp"] = pd.to_datetime(df["timestamp"]) |
|
|
df.sort_values(["user_id", "timestamp"], inplace=True) |
|
|
|
|
|
session_ids = [] |
|
|
last_user = None |
|
|
last_time = None |
|
|
session_counter = 0 |
|
|
gap = pd.Timedelta(minutes=gap_minutes) |
|
|
|
|
|
for row in df.itertuples(): |
|
|
if (row.user_id != last_user) or (last_time is None) or ((row.timestamp - last_time) > gap): |
|
|
session_counter += 1 |
|
|
current_session_id = f"AUTO_S{session_counter:05d}" |
|
|
session_ids.append(current_session_id) |
|
|
last_user = row.user_id |
|
|
last_time = row.timestamp |
|
|
|
|
|
df["session_id"] = session_ids |
|
|
return df |
|
|
|
|
|
def baskets_from_sessions(df_chk): |
|
|
"""Create baskets from checkout sessions""" |
|
|
return df_chk.groupby("session_id")["item_id"].apply(lambda x: set(x)).tolist() |
|
|
|
|
|
def pair_metrics(baskets, min_support=0.02): |
|
|
"""Compute support, confidence, lift for item pairs""" |
|
|
n = len(baskets) |
|
|
item_counts = {} |
|
|
pair_counts = {} |
|
|
|
|
|
for b in baskets: |
|
|
for i in b: |
|
|
item_counts[i] = item_counts.get(i, 0) + 1 |
|
|
for a, b_item in itertools.combinations(sorted(b), 2): |
|
|
pair_counts[(a, b_item)] = pair_counts.get((a, b_item), 0) + 1 |
|
|
|
|
|
rows = [] |
|
|
for (a, b_item), c_ab in pair_counts.items(): |
|
|
supp = c_ab / n |
|
|
if supp < min_support: |
|
|
continue |
|
|
pa = item_counts[a] / n |
|
|
pb = item_counts[b_item] / n |
|
|
conf_a_b = supp / pa |
|
|
conf_b_a = supp / pb |
|
|
lift = supp / (pa * pb) |
|
|
rows.append((a, b_item, supp, conf_a_b, conf_b_a, lift)) |
|
|
|
|
|
df = pd.DataFrame(rows, columns=["item_a", "item_b", "support", "conf_a_b", "conf_b_a", "lift"]) |
|
|
df.sort_values(["lift", "support"], ascending=[False, False], inplace=True) |
|
|
return df, item_counts |
|
|
|
|
|
def build_distance_matrix(df_loc): |
|
|
"""Precompute Euclidean distances between all locations""" |
|
|
loc_ids = df_loc["location_id"].tolist() |
|
|
coords = {r.location_id: (float(r.x), float(r.y)) for r in df_loc.itertuples()} |
|
|
|
|
|
dist = {} |
|
|
for a in loc_ids: |
|
|
xa, ya = coords[a] |
|
|
for b in loc_ids: |
|
|
xb, yb = coords[b] |
|
|
dist[(a, b)] = math.dist((xa, ya), (xb, yb)) |
|
|
return loc_ids, coords, dist |
|
|
|
|
|
def total_weighted_distance(item2loc, W, dist): |
|
|
"""Calculate total weighted distance""" |
|
|
cost = 0.0 |
|
|
for (a, b), w in W.items(): |
|
|
la = item2loc.get(a) |
|
|
lb = item2loc.get(b) |
|
|
if la is None or lb is None: |
|
|
continue |
|
|
cost += w * dist[(la, lb)] |
|
|
return cost |
|
|
|
|
|
def delta_cost_for_move(item, from_loc, to_loc, item2loc, W, dist): |
|
|
"""Compute change in cost if item moves from from_loc to to_loc""" |
|
|
delta = 0.0 |
|
|
for (a, b), w in W.items(): |
|
|
if a == item or b == item: |
|
|
other = b if a == item else a |
|
|
other_loc = item2loc.get(other) |
|
|
if other_loc is None: |
|
|
continue |
|
|
old_d = dist[(from_loc, other_loc)] |
|
|
new_d = dist[(to_loc, other_loc)] |
|
|
delta += w * (new_d - old_d) |
|
|
return delta |
|
|
|
|
|
def greedy_relocate(df_items, df_loc, W, dist, top_k=30, max_moves=15, min_gain=0.05): |
|
|
"""Greedy relocation algorithm""" |
|
|
original_item2loc = dict(zip(df_items["item_id"], df_items["location_id"])) |
|
|
item2loc = original_item2loc.copy() |
|
|
loc2item = {loc: item for item, loc in item2loc.items()} |
|
|
|
|
|
all_locs = df_loc["location_id"].tolist() |
|
|
moved_items = set() |
|
|
used_empty_targets = set() |
|
|
recs = [] |
|
|
|
|
|
sorted_pairs = sorted(W.items(), key=lambda x: x[1], reverse=True)[:top_k] |
|
|
|
|
|
for (a, b), w in sorted_pairs: |
|
|
for item in (a, b): |
|
|
if item in moved_items: |
|
|
continue |
|
|
|
|
|
from_loc = item2loc[item] |
|
|
best_cand = None |
|
|
best_delta = 0.0 |
|
|
best_occ = None |
|
|
|
|
|
for cand in all_locs: |
|
|
if cand == from_loc: |
|
|
continue |
|
|
|
|
|
occ = loc2item.get(cand) |
|
|
|
|
|
if occ is None and cand in used_empty_targets: |
|
|
continue |
|
|
|
|
|
if occ is not None and occ in moved_items: |
|
|
continue |
|
|
|
|
|
delta = delta_cost_for_move(item, from_loc, cand, item2loc, W, dist) |
|
|
if delta < best_delta: |
|
|
best_delta = delta |
|
|
best_cand = cand |
|
|
best_occ = occ |
|
|
|
|
|
gain = -best_delta |
|
|
if best_cand is not None and gain >= min_gain: |
|
|
cand = best_cand |
|
|
occ = best_occ |
|
|
|
|
|
recs.append({ |
|
|
"move_item": item, |
|
|
"from": from_loc, |
|
|
"to": cand, |
|
|
"swap_with": occ if occ else "Empty", |
|
|
"gain": gain |
|
|
}) |
|
|
|
|
|
if occ is not None: |
|
|
item2loc[occ] = from_loc |
|
|
loc2item[from_loc] = occ |
|
|
moved_items.add(occ) |
|
|
else: |
|
|
del loc2item[from_loc] |
|
|
used_empty_targets.add(cand) |
|
|
|
|
|
item2loc[item] = cand |
|
|
loc2item[cand] = item |
|
|
moved_items.add(item) |
|
|
|
|
|
if len(recs) >= max_moves: |
|
|
break |
|
|
|
|
|
if len(recs) >= max_moves: |
|
|
break |
|
|
|
|
|
df_recs = pd.DataFrame(recs) if recs else None |
|
|
return df_recs, item2loc |
|
|
|
|
|
def run_analysis(min_support, top_k_pairs, max_moves, min_gain, progress=gr.Progress()): |
|
|
"""Run complete inventory analysis with progress tracking""" |
|
|
progress(0, desc="Loading data...") |
|
|
|
|
|
df_items = load_items() |
|
|
df_loc = load_locations() |
|
|
df_chk = load_checkouts() |
|
|
|
|
|
id_to_name = dict(zip(df_items['item_id'], df_items['item_name'])) |
|
|
|
|
|
progress(0.2, desc="Processing checkout sessions...") |
|
|
df_chk = ensure_sessions(df_chk) |
|
|
baskets = baskets_from_sessions(df_chk) |
|
|
|
|
|
progress(0.4, desc="Mining frequent item pairs...") |
|
|
df_pairs, item_counts = pair_metrics(baskets, min_support=min_support) |
|
|
|
|
|
if df_pairs.empty: |
|
|
return None, None, "β οΈ No frequent pairs found. Try lowering the minimum support threshold.", None |
|
|
|
|
|
df_pairs['item_a_name'] = df_pairs['item_a'].map(id_to_name) |
|
|
df_pairs['item_b_name'] = df_pairs['item_b'].map(id_to_name) |
|
|
|
|
|
df_pairs_display = df_pairs[['item_a_name', 'item_b_name', 'support', 'lift', 'conf_a_b', 'conf_b_a']] |
|
|
df_pairs_display.columns = ['Item A', 'Item B', 'Support', 'Lift', 'Confidence AβB', 'Confidence BβA'] |
|
|
|
|
|
progress(0.6, desc="Building distance matrix...") |
|
|
loc_ids, coords, dist = build_distance_matrix(df_loc) |
|
|
|
|
|
W = {} |
|
|
for _, row in df_pairs.iterrows(): |
|
|
W[(row['item_a'], row['item_b'])] = row['lift'] * row['support'] |
|
|
|
|
|
progress(0.7, desc="Calculating current layout cost...") |
|
|
item2loc_orig = dict(zip(df_items["item_id"], df_items["location_id"])) |
|
|
cost_before = total_weighted_distance(item2loc_orig, W, dist) |
|
|
|
|
|
progress(0.8, desc="Optimizing item placement...") |
|
|
df_recs, item2loc_new = greedy_relocate( |
|
|
df_items, df_loc, W, dist, |
|
|
top_k=int(top_k_pairs), |
|
|
max_moves=int(max_moves), |
|
|
min_gain=min_gain |
|
|
) |
|
|
|
|
|
cost_after = total_weighted_distance(item2loc_new, W, dist) |
|
|
improvement = cost_before - cost_after |
|
|
improvement_pct = (improvement / cost_before * 100) if cost_before > 0 else 0 |
|
|
|
|
|
if df_recs is not None and not df_recs.empty: |
|
|
df_recs['item_name'] = df_recs['move_item'].map(id_to_name) |
|
|
df_recs_display = df_recs[['item_name', 'from', 'to', 'swap_with', 'gain']] |
|
|
df_recs_display.columns = ['Item', 'From Location', 'To Location', 'Swap With', 'Distance Saved'] |
|
|
df_recs_display['Distance Saved'] = df_recs_display['Distance Saved'].round(2) |
|
|
else: |
|
|
df_recs_display = pd.DataFrame(columns=['Item', 'From Location', 'To Location', 'Swap With', 'Distance Saved']) |
|
|
|
|
|
progress(0.9, desc="Generating visualization...") |
|
|
|
|
|
summary = f"""# π Analysis Results |
|
|
|
|
|
## Pattern Mining Results |
|
|
We analyzed **{len(baskets)} checkout sessions** and found **{len(df_pairs)} frequent item pairs**. |
|
|
|
|
|
### Top Discovered Pattern: |
|
|
- **{df_pairs.iloc[0]['item_a_name']}** β **{df_pairs.iloc[0]['item_b_name']}** |
|
|
- Lift: **{df_pairs.iloc[0]['lift']:.2f}** (these items are {df_pairs.iloc[0]['lift']:.1f}Γ more likely to be checked out together) |
|
|
- Support: **{df_pairs.iloc[0]['support']:.1%}** (appears in {df_pairs.iloc[0]['support']:.1%} of checkouts) |
|
|
|
|
|
## Layout Optimization Results |
|
|
|
|
|
### Distance Costs: |
|
|
- **Before optimization:** {cost_before:.2f} units |
|
|
- **After optimization:** {cost_after:.2f} units |
|
|
- **Improvement:** {improvement:.2f} units ({improvement_pct:.1f}% reduction) |
|
|
|
|
|
### Recommendations: |
|
|
- **{len(df_recs) if df_recs is not None else 0} moves** suggested |
|
|
- Total distance saved: **{improvement:.2f} units** |
|
|
|
|
|
--- |
|
|
|
|
|
π‘ **How to interpret these results:** |
|
|
- **Lift > 1**: Items are frequently checked out together |
|
|
- **Higher support**: Pattern occurs more often |
|
|
- **Distance savings**: How much walking you'll save by reorganizing |
|
|
""" |
|
|
|
|
|
img = visualize_reorganization(df_items, df_loc, df_pairs, df_recs, coords, id_to_name) |
|
|
|
|
|
progress(1.0, desc="Complete!") |
|
|
|
|
|
return df_pairs_display, df_recs_display, summary, img |
|
|
|
|
|
def visualize_reorganization(df_items, df_loc, df_pairs, df_recs, coords, id_to_name): |
|
|
"""Create visualization of current layout and suggested moves""" |
|
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(18, 8)) |
|
|
|
|
|
ax1.set_title("Current Layout + Frequent Item Pairs", fontsize=16, fontweight='bold', pad=20) |
|
|
|
|
|
for loc_id, (x, y) in coords.items(): |
|
|
ax1.scatter(x, y, color='#E8E8E8', s=150, alpha=0.7, zorder=1, edgecolors='#999', linewidths=1) |
|
|
ax1.text(x, y+0.18, loc_id, fontsize=8, ha='center', va='bottom', color='#666') |
|
|
|
|
|
if df_pairs is not None and not df_pairs.empty: |
|
|
top_pairs = df_pairs.head(15) |
|
|
item2loc = dict(zip(df_items['item_id'], df_items['location_id'])) |
|
|
|
|
|
max_lift = top_pairs['lift'].max() |
|
|
|
|
|
for idx, (_, row) in enumerate(top_pairs.iterrows()): |
|
|
item_a, item_b = row['item_a'], row['item_b'] |
|
|
if item_a in item2loc and item_b in item2loc: |
|
|
loc_a = item2loc[item_a] |
|
|
loc_b = item2loc[item_b] |
|
|
if loc_a in coords and loc_b in coords: |
|
|
xa, ya = coords[loc_a] |
|
|
xb, yb = coords[loc_b] |
|
|
|
|
|
alpha = 0.3 + (row['lift'] / max_lift) * 0.4 |
|
|
linewidth = 1 + (row['lift'] / max_lift) * 2 |
|
|
|
|
|
ax1.plot([xa, xb], [ya, yb], color='#667eea', alpha=alpha, |
|
|
linewidth=linewidth, zorder=0) |
|
|
|
|
|
ax1.set_xlabel("X Coordinate", fontsize=12, fontweight='bold') |
|
|
ax1.set_ylabel("Y Coordinate", fontsize=12, fontweight='bold') |
|
|
ax1.grid(True, alpha=0.2, linestyle='--') |
|
|
ax1.set_facecolor('#F8F9FA') |
|
|
|
|
|
ax2.set_title("Suggested Reorganization", fontsize=16, fontweight='bold', pad=20) |
|
|
|
|
|
for loc_id, (x, y) in coords.items(): |
|
|
ax2.scatter(x, y, color='#E8E8E8', s=150, alpha=0.7, zorder=1, edgecolors='#999', linewidths=1) |
|
|
ax2.text(x, y+0.18, loc_id, fontsize=8, ha='center', va='bottom', color='#666') |
|
|
|
|
|
if df_recs is not None and not df_recs.empty: |
|
|
for idx, (_, rec) in enumerate(df_recs.iterrows()): |
|
|
from_loc = rec['from'] |
|
|
to_loc = rec['to'] |
|
|
if from_loc in coords and to_loc in coords: |
|
|
x1, y1 = coords[from_loc] |
|
|
x2, y2 = coords[to_loc] |
|
|
|
|
|
color = plt.cm.Reds(0.5 + idx * 0.05) |
|
|
|
|
|
ax2.annotate('', xy=(x2, y2), xytext=(x1, y1), |
|
|
arrowprops=dict(arrowstyle='->', color=color, lw=2.5, alpha=0.8), |
|
|
zorder=2) |
|
|
|
|
|
item_name = id_to_name.get(rec['move_item'], rec['move_item']) |
|
|
short_name = ' '.join(item_name.split()[:2]) |
|
|
mid_x, mid_y = (x1 + x2) / 2, (y1 + y2) / 2 |
|
|
|
|
|
ax2.text(mid_x, mid_y, short_name, fontsize=9, ha='center', |
|
|
fontweight='bold', |
|
|
bbox=dict(boxstyle='round,pad=0.4', facecolor='#FFE5E5', |
|
|
edgecolor=color, alpha=0.9, linewidth=2), |
|
|
zorder=3) |
|
|
|
|
|
ax2.set_xlabel("X Coordinate", fontsize=12, fontweight='bold') |
|
|
ax2.set_ylabel("Y Coordinate", fontsize=12, fontweight='bold') |
|
|
ax2.grid(True, alpha=0.2, linestyle='--') |
|
|
ax2.set_facecolor('#F8F9FA') |
|
|
|
|
|
plt.tight_layout() |
|
|
|
|
|
buf = io.BytesIO() |
|
|
fig.savefig(buf, format='png', dpi=120, bbox_inches='tight', facecolor='white') |
|
|
buf.seek(0) |
|
|
img = np.array(Image.open(buf)) |
|
|
plt.close(fig) |
|
|
|
|
|
return img |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Blocks(title="Makerspace Inventory System", css=CUSTOM_CSS) as demo: |
|
|
|
|
|
|
|
|
matched_items_state = gr.State(None) |
|
|
proposals_state = gr.State([]) |
|
|
|
|
|
|
|
|
with gr.Group(visible=True, elem_id="main-menu-container") as main_menu: |
|
|
gr.Markdown("# π§ Makerspace Inventory System", elem_id="main-menu-title") |
|
|
gr.Markdown("*Smart inventory management powered by AI*", elem_id="main-menu-subtitle") |
|
|
|
|
|
with gr.Row(): |
|
|
checkout_btn = gr.Button("π Check Out Items", size="lg", elem_classes=["module-button"], scale=1) |
|
|
add_items_btn = gr.Button("π¦ Add Items", size="lg", elem_classes=["module-button"], scale=1) |
|
|
analysis_btn = gr.Button("π Inventory Analysis", size="lg", elem_classes=["module-button"], scale=1) |
|
|
|
|
|
|
|
|
with gr.Group(visible=False, elem_classes=["module-page"]) as checkout_module: |
|
|
gr.Markdown("# π Check Out Items", elem_classes=["section-header"]) |
|
|
|
|
|
with gr.Accordion("π How to Use", open=False): |
|
|
gr.Markdown(""" |
|
|
**Step 1:** Upload a photo of items or type item names manually (comma-separated) |
|
|
|
|
|
**Step 2:** Review detected items and adjust quantities |
|
|
|
|
|
**Step 3:** Confirm checkout with optional user ID |
|
|
|
|
|
π‘ **Tip:** The AI automatically matches items to inventory using smart search! |
|
|
""") |
|
|
|
|
|
with gr.Group(visible=True) as checkout_screen1: |
|
|
gr.Markdown("### πΈ Scan Items", elem_classes=["subsection-header"]) |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=2): |
|
|
checkout_image = gr.Image( |
|
|
type="pil", |
|
|
label="Upload Image of Items", |
|
|
sources=["upload", "webcam"], |
|
|
height=400 |
|
|
) |
|
|
|
|
|
load_example_checkout_btn = gr.Button("πΈ Load Example", size="sm", variant="secondary", scale=0, visible=EXAMPLE_CHECKOUT_IMAGE is not None) |
|
|
|
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown(""" |
|
|
### Quick Guide |
|
|
|
|
|
**Upload:** Click the upload icon to select an image from your device |
|
|
|
|
|
**Webcam:** Click the camera icon to take a photo with your webcam |
|
|
|
|
|
π‘ Make sure items are clearly visible and well-lit |
|
|
""") |
|
|
|
|
|
checkout_manual = gr.Textbox( |
|
|
label="Or Enter Item Names Manually", |
|
|
placeholder="e.g., drill, safety glasses, Arduino", |
|
|
info="Separate multiple items with commas" |
|
|
) |
|
|
|
|
|
checkout_status = gr.Markdown("") |
|
|
checkout_scan_btn = gr.Button("π Scan & Match Items", variant="primary", size="lg") |
|
|
|
|
|
with gr.Group(visible=False) as checkout_screen2: |
|
|
gr.Markdown("### Review Selected Items", elem_classes=["subsection-header"]) |
|
|
gr.Markdown("*Verify items, adjust quantities, or remove unwanted items before proceeding*") |
|
|
|
|
|
checkout_item_controls = [] |
|
|
for i in range(10): |
|
|
with gr.Row(visible=False) as item_row: |
|
|
with gr.Column(scale=3): |
|
|
item_info = gr.Markdown("") |
|
|
with gr.Column(scale=1): |
|
|
item_qty = gr.Number(value=1, minimum=1, label="Qty") |
|
|
with gr.Column(scale=1): |
|
|
item_remove = gr.Button("ποΈ Remove", size="sm", variant="stop") |
|
|
|
|
|
checkout_item_controls.append({ |
|
|
'row': item_row, |
|
|
'info': item_info, |
|
|
'qty': item_qty, |
|
|
'remove': item_remove |
|
|
}) |
|
|
|
|
|
with gr.Row(): |
|
|
checkout_rescan_btn = gr.Button("β©οΈ Rescan", variant="secondary") |
|
|
checkout_confirm_btn = gr.Button("β
Proceed to Checkout", variant="primary", size="lg") |
|
|
|
|
|
with gr.Group(visible=False) as checkout_screen3: |
|
|
gr.Markdown("### π« Confirm Checkout", elem_classes=["subsection-header"]) |
|
|
|
|
|
checkout_preview = gr.Markdown("") |
|
|
|
|
|
checkout_user_id = gr.Textbox( |
|
|
label="User ID (Optional)", |
|
|
placeholder="Enter your ID or leave blank", |
|
|
info="Leave blank for auto-generated ID" |
|
|
) |
|
|
|
|
|
checkout_processing = gr.Markdown("") |
|
|
|
|
|
with gr.Row(): |
|
|
checkout_cancel_btn = gr.Button("β Cancel", variant="secondary") |
|
|
checkout_final_btn = gr.Button("β
Complete Checkout", variant="primary", size="lg") |
|
|
|
|
|
checkout_result = gr.Markdown("") |
|
|
|
|
|
with gr.Row(): |
|
|
checkout_return_btn = gr.Button("π Return to Main Menu", variant="secondary") |
|
|
checkout_another_btn = gr.Button("π Check Out More Items", variant="primary", visible=False) |
|
|
|
|
|
|
|
|
with gr.Group(visible=False, elem_classes=["module-page"]) as add_items_module: |
|
|
gr.Markdown("# π¦ Add Items to Inventory", elem_classes=["section-header"]) |
|
|
|
|
|
with gr.Accordion("π How to Use", open=False): |
|
|
gr.Markdown(""" |
|
|
**Receipt Upload:** |
|
|
1. Upload receipt image or PDF β AI extracts items |
|
|
2. Review and edit detected items (check/uncheck, edit quantities) |
|
|
3. Click "Apply Updates" to add to inventory |
|
|
|
|
|
**Manual Entry:** |
|
|
- Enter item name and quantity for quick updates |
|
|
- System uses fuzzy matching to find items |
|
|
|
|
|
**View & History:** |
|
|
- Browse inventory with category filters |
|
|
- Track all changes with timestamps |
|
|
""") |
|
|
|
|
|
with gr.Tab("π Receipt Upload"): |
|
|
gr.Markdown("### Upload Receipt", elem_classes=["subsection-header"]) |
|
|
|
|
|
receipt_file = gr.File(label="Upload Receipt (PDF or Image)", file_types=[".pdf", ".png", ".jpg", ".jpeg"]) |
|
|
|
|
|
load_example_receipt_btn = gr.Button("π Load Example", size="sm", variant="secondary", scale=0, visible=EXAMPLE_RECEIPT_PDF is not None) |
|
|
|
|
|
receipt_status = gr.Markdown("") |
|
|
|
|
|
gr.Markdown("### Review Detected Items", elem_classes=["subsection-header"]) |
|
|
gr.Markdown("*Check items to include, edit quantities, then apply*") |
|
|
|
|
|
add_items_table = gr.Dataframe( |
|
|
headers=["Include", "Item Name", "Quantity", "Match Confidence"], |
|
|
label="Detected Items", |
|
|
datatype=["bool", "str", "number", "str"], |
|
|
interactive=True, |
|
|
col_count=(4, "fixed") |
|
|
) |
|
|
|
|
|
add_items_status = gr.Markdown("") |
|
|
|
|
|
with gr.Row(): |
|
|
add_items_reject_btn = gr.Button("β Clear All", variant="secondary") |
|
|
add_items_confirm_btn = gr.Button("β
Apply Updates", variant="primary", interactive=False) |
|
|
|
|
|
with gr.Tab("βοΈ Manual Entry"): |
|
|
gr.Markdown("### Manually Add Items", elem_classes=["subsection-header"]) |
|
|
|
|
|
with gr.Row(): |
|
|
manual_item = gr.Textbox( |
|
|
label="Item Name", |
|
|
placeholder="e.g., Arduino Uno", |
|
|
info="Fuzzy matching will find similar items" |
|
|
) |
|
|
manual_qty = gr.Number( |
|
|
label="Quantity to Add", |
|
|
value=1, |
|
|
minimum=1 |
|
|
) |
|
|
|
|
|
manual_apply_btn = gr.Button("β Add to Inventory", variant="primary") |
|
|
manual_status = gr.Markdown("") |
|
|
manual_inventory_display = gr.Dataframe(label="Updated Inventory", visible=False) |
|
|
|
|
|
with gr.Tab("π View & History"): |
|
|
gr.Markdown("### Current Inventory", elem_classes=["subsection-header"]) |
|
|
|
|
|
with gr.Row(): |
|
|
inventory_category_filter = gr.Dropdown( |
|
|
choices=["All"] + get_categories(), |
|
|
value="All", |
|
|
label="Filter by Category" |
|
|
) |
|
|
view_inventory_btn = gr.Button("π Refresh Inventory") |
|
|
|
|
|
inventory_display = gr.Dataframe(label="Current Inventory", visible=False) |
|
|
|
|
|
gr.Markdown("### Update History", elem_classes=["subsection-header"]) |
|
|
|
|
|
view_history_btn = gr.Button("π View Update Log") |
|
|
history_display = gr.Textbox(label="Update History", lines=10, visible=False) |
|
|
|
|
|
add_items_return_btn = gr.Button("π Return to Main Menu", variant="secondary") |
|
|
|
|
|
|
|
|
with gr.Group(visible=False, elem_classes=["module-page"]) as analysis_module: |
|
|
gr.Markdown("# π Inventory Layout Analysis", elem_classes=["section-header"]) |
|
|
|
|
|
with gr.Accordion("π Understanding the Analysis", open=True): |
|
|
gr.Markdown(""" |
|
|
This module analyzes checkout patterns to optimize item placement and minimize walking distance. |
|
|
|
|
|
### π Key Metrics: |
|
|
|
|
|
**Support** - Frequency of co-occurrence (e.g., 0.10 = 10% of checkouts) |
|
|
|
|
|
**Lift** - Correlation strength (Lift > 1 = items checked out together more than random) |
|
|
|
|
|
**Confidence** - Conditional probability (e.g., 0.80 = 80% chance of B when checking A) |
|
|
|
|
|
**Distance Saved** - Walking distance reduction in grid units |
|
|
|
|
|
### π― Goal: |
|
|
Items frequently checked together should be placed closer to reduce travel time! |
|
|
""") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown("### βοΈ Parameters", elem_classes=["subsection-header"]) |
|
|
|
|
|
min_support = gr.Slider( |
|
|
minimum=0.01, |
|
|
maximum=0.2, |
|
|
value=0.02, |
|
|
step=0.01, |
|
|
label="Minimum Support", |
|
|
info="Lower = more patterns (less significant)" |
|
|
) |
|
|
|
|
|
top_k_pairs = gr.Slider( |
|
|
minimum=5, |
|
|
maximum=50, |
|
|
value=25, |
|
|
step=5, |
|
|
label="Top K Pairs", |
|
|
info="Number of frequent pairs to optimize" |
|
|
) |
|
|
|
|
|
max_moves = gr.Slider( |
|
|
minimum=5, |
|
|
maximum=30, |
|
|
value=10, |
|
|
step=1, |
|
|
label="Maximum Moves", |
|
|
info="Limit on relocations" |
|
|
) |
|
|
|
|
|
min_gain = gr.Slider( |
|
|
minimum=0.0, |
|
|
maximum=1.0, |
|
|
value=0.05, |
|
|
step=0.01, |
|
|
label="Minimum Distance Gain", |
|
|
info="Threshold for suggestions" |
|
|
) |
|
|
|
|
|
run_analysis_btn = gr.Button("π Run Analysis", variant="primary", size="lg") |
|
|
|
|
|
with gr.Column(scale=2): |
|
|
gr.Markdown("### π Summary", elem_classes=["subsection-header"]) |
|
|
analysis_summary = gr.Textbox(label="", lines=14, show_label=False) |
|
|
|
|
|
gr.Markdown("### πΊοΈ Visual Layout Comparison", elem_classes=["subsection-header"]) |
|
|
analysis_viz = gr.Image(label="", type="numpy", show_label=False) |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(): |
|
|
gr.Markdown("### π Frequent Pairs", elem_classes=["subsection-header"]) |
|
|
pairs_table = gr.Dataframe(label="", show_label=False) |
|
|
|
|
|
with gr.Column(): |
|
|
gr.Markdown("### π Recommendations", elem_classes=["subsection-header"]) |
|
|
recs_table = gr.Dataframe(label="", show_label=False) |
|
|
|
|
|
analysis_return_btn = gr.Button("π Return to Main Menu", variant="secondary") |
|
|
|
|
|
|
|
|
def show_checkout(): |
|
|
return [ |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=True), |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=False) |
|
|
] |
|
|
|
|
|
def show_add_items(): |
|
|
return [ |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=True), |
|
|
gr.update(visible=False) |
|
|
] |
|
|
|
|
|
def show_analysis(): |
|
|
return [ |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=True) |
|
|
] |
|
|
|
|
|
def return_to_menu(): |
|
|
return [ |
|
|
gr.update(visible=True), |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=False) |
|
|
] |
|
|
|
|
|
checkout_btn.click(fn=show_checkout, outputs=[main_menu, checkout_module, add_items_module, analysis_module]) |
|
|
add_items_btn.click(fn=show_add_items, outputs=[main_menu, checkout_module, add_items_module, analysis_module]) |
|
|
analysis_btn.click(fn=show_analysis, outputs=[main_menu, checkout_module, add_items_module, analysis_module]) |
|
|
|
|
|
checkout_return_btn.click(fn=return_to_menu, outputs=[main_menu, checkout_module, add_items_module, analysis_module]) |
|
|
add_items_return_btn.click(fn=return_to_menu, outputs=[main_menu, checkout_module, add_items_module, analysis_module]) |
|
|
analysis_return_btn.click(fn=return_to_menu, outputs=[main_menu, checkout_module, add_items_module, analysis_module]) |
|
|
|
|
|
|
|
|
def update_checkout_screen2(matched_items): |
|
|
updates = [] |
|
|
|
|
|
if not matched_items: |
|
|
for i in range(10): |
|
|
updates.extend([gr.update(visible=False), gr.update(value=""), gr.update(value=1)]) |
|
|
return updates |
|
|
|
|
|
for i in range(10): |
|
|
if i < len(matched_items): |
|
|
item_data = matched_items[i] |
|
|
updates.extend([ |
|
|
gr.update(visible=True), |
|
|
gr.update(value=create_checkout_item_display(item_data, i)), |
|
|
gr.update(value=item_data['quantity'], maximum=item_data['item']['Quantity']), |
|
|
]) |
|
|
else: |
|
|
updates.extend([gr.update(visible=False), gr.update(value=""), gr.update(value=1)]) |
|
|
|
|
|
return updates |
|
|
|
|
|
def remove_checkout_item(matched_items, item_idx): |
|
|
if matched_items and 0 <= item_idx < len(matched_items): |
|
|
matched_items.pop(item_idx) |
|
|
return matched_items |
|
|
|
|
|
def set_checkout_quantity(matched_items, item_idx, new_qty): |
|
|
if matched_items and 0 <= item_idx < len(matched_items): |
|
|
max_qty = matched_items[item_idx]['item']['Quantity'] |
|
|
matched_items[item_idx]['quantity'] = max(1, min(int(new_qty), max_qty)) |
|
|
return matched_items |
|
|
|
|
|
def reset_checkout(): |
|
|
return ( |
|
|
gr.update(visible=True), gr.update(visible=False), gr.update(visible=False), |
|
|
None, "", None, "", "", gr.update(visible=False) |
|
|
) |
|
|
|
|
|
def show_checkout_complete_options(): |
|
|
return gr.update(visible=False), gr.update(visible=True) |
|
|
|
|
|
checkout_scan_btn.click( |
|
|
fn=lambda: (gr.update(value="β³ Scanning...", interactive=False), "π **Processing...** AI is analyzing your image..."), |
|
|
outputs=[checkout_scan_btn, checkout_status] |
|
|
).then( |
|
|
fn=scan_items_checkout, |
|
|
inputs=[checkout_image, checkout_manual], |
|
|
outputs=[matched_items_state, checkout_status] |
|
|
).then( |
|
|
fn=lambda: gr.update(value="π Scan & Match Items", interactive=True), |
|
|
outputs=[checkout_scan_btn] |
|
|
).then( |
|
|
fn=lambda items: ( |
|
|
gr.update(visible=False) if items else gr.update(), |
|
|
gr.update(visible=True) if items else gr.update(), |
|
|
gr.update(visible=False) |
|
|
), |
|
|
inputs=[matched_items_state], |
|
|
outputs=[checkout_screen1, checkout_screen2, checkout_screen3] |
|
|
).then( |
|
|
fn=update_checkout_screen2, |
|
|
inputs=[matched_items_state], |
|
|
outputs=[checkout_item_controls[i][key] for i in range(10) for key in ['row', 'info', 'qty']] |
|
|
) |
|
|
|
|
|
for i in range(10): |
|
|
checkout_item_controls[i]['qty'].change( |
|
|
fn=lambda items, new_qty, idx=i: set_checkout_quantity(items, idx, new_qty), |
|
|
inputs=[matched_items_state, checkout_item_controls[i]['qty']], |
|
|
outputs=[matched_items_state] |
|
|
) |
|
|
|
|
|
checkout_item_controls[i]['remove'].click( |
|
|
fn=lambda items, idx=i: remove_checkout_item(items, idx), |
|
|
inputs=[matched_items_state], |
|
|
outputs=[matched_items_state] |
|
|
).then( |
|
|
fn=update_checkout_screen2, |
|
|
inputs=[matched_items_state], |
|
|
outputs=[checkout_item_controls[j][key] for j in range(10) for key in ['row', 'info', 'qty']] |
|
|
) |
|
|
|
|
|
checkout_confirm_btn.click( |
|
|
fn=confirm_checkout_preview, |
|
|
inputs=[matched_items_state], |
|
|
outputs=[checkout_preview] |
|
|
).then( |
|
|
fn=lambda: (gr.update(visible=False), gr.update(visible=False), gr.update(visible=True)), |
|
|
outputs=[checkout_screen1, checkout_screen2, checkout_screen3] |
|
|
) |
|
|
|
|
|
checkout_rescan_btn.click( |
|
|
fn=reset_checkout, |
|
|
outputs=[checkout_screen1, checkout_screen2, checkout_screen3, matched_items_state, |
|
|
checkout_status, checkout_image, checkout_manual, checkout_result, checkout_another_btn] |
|
|
) |
|
|
|
|
|
checkout_cancel_btn.click( |
|
|
fn=reset_checkout, |
|
|
outputs=[checkout_screen1, checkout_screen2, checkout_screen3, matched_items_state, |
|
|
checkout_status, checkout_image, checkout_manual, checkout_result, checkout_another_btn] |
|
|
) |
|
|
|
|
|
checkout_final_btn.click( |
|
|
fn=lambda: (gr.update(value="β³ Processing...", interactive=False), "β³ **Processing checkout...** Updating inventory..."), |
|
|
outputs=[checkout_final_btn, checkout_processing] |
|
|
).then( |
|
|
fn=process_checkout, |
|
|
inputs=[matched_items_state, checkout_user_id], |
|
|
outputs=[checkout_result] |
|
|
).then( |
|
|
fn=lambda: (gr.update(value="β
Complete Checkout", interactive=True), ""), |
|
|
outputs=[checkout_final_btn, checkout_processing] |
|
|
).then( |
|
|
fn=lambda: (gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)), |
|
|
outputs=[checkout_screen1, checkout_screen2, checkout_screen3] |
|
|
).then( |
|
|
fn=show_checkout_complete_options, |
|
|
outputs=[checkout_return_btn, checkout_another_btn] |
|
|
) |
|
|
|
|
|
checkout_another_btn.click( |
|
|
fn=reset_checkout, |
|
|
outputs=[checkout_screen1, checkout_screen2, checkout_screen3, matched_items_state, |
|
|
checkout_status, checkout_image, checkout_manual, checkout_result, checkout_another_btn] |
|
|
).then( |
|
|
fn=lambda: (gr.update(visible=True), gr.update(visible=False)), |
|
|
outputs=[checkout_return_btn, checkout_another_btn] |
|
|
) |
|
|
|
|
|
|
|
|
receipt_file.change( |
|
|
fn=lambda: "π **Processing receipt...** Extracting text with OCR...", |
|
|
outputs=[receipt_status] |
|
|
).then( |
|
|
fn=extract_text_from_receipt, |
|
|
inputs=[receipt_file], |
|
|
outputs=[add_items_table, proposals_state] |
|
|
).then( |
|
|
fn=lambda proposals: (gr.update(interactive=len(proposals) > 0), "β
Items detected! Review and edit the table below."), |
|
|
inputs=[proposals_state], |
|
|
outputs=[add_items_confirm_btn, receipt_status] |
|
|
) |
|
|
|
|
|
add_items_confirm_btn.click( |
|
|
fn=lambda: (gr.update(value="β³ Applying...", interactive=False), "β³ **Updating inventory...**"), |
|
|
outputs=[add_items_confirm_btn, add_items_status] |
|
|
).then( |
|
|
fn=apply_updates_from_table, |
|
|
inputs=[add_items_table], |
|
|
outputs=[add_items_status, inventory_display] |
|
|
).then( |
|
|
fn=lambda: (gr.update(value="β
Apply Updates", interactive=False), gr.update(visible=True)), |
|
|
outputs=[add_items_confirm_btn, inventory_display] |
|
|
) |
|
|
|
|
|
add_items_reject_btn.click( |
|
|
fn=lambda: ([["No items", "", "", ""]], "β Cleared all items.", []), |
|
|
outputs=[add_items_table, add_items_status, proposals_state] |
|
|
).then( |
|
|
fn=lambda: gr.update(interactive=False), |
|
|
outputs=[add_items_confirm_btn] |
|
|
) |
|
|
|
|
|
manual_apply_btn.click( |
|
|
fn=lambda: gr.update(value="β³ Adding...", interactive=False), |
|
|
outputs=[manual_apply_btn] |
|
|
).then( |
|
|
fn=manual_update, |
|
|
inputs=[manual_item, manual_qty], |
|
|
outputs=[manual_status, manual_inventory_display] |
|
|
).then( |
|
|
fn=lambda: (gr.update(value="β Add to Inventory", interactive=True), gr.update(visible=True)), |
|
|
outputs=[manual_apply_btn, manual_inventory_display] |
|
|
) |
|
|
|
|
|
view_inventory_btn.click( |
|
|
fn=view_inventory_table, |
|
|
inputs=[inventory_category_filter], |
|
|
outputs=[inventory_display] |
|
|
).then( |
|
|
fn=lambda: gr.update(visible=True), |
|
|
outputs=[inventory_display] |
|
|
) |
|
|
|
|
|
inventory_category_filter.change( |
|
|
fn=view_inventory_table, |
|
|
inputs=[inventory_category_filter], |
|
|
outputs=[inventory_display] |
|
|
) |
|
|
|
|
|
view_history_btn.click( |
|
|
fn=view_update_history, |
|
|
outputs=[history_display] |
|
|
).then( |
|
|
fn=lambda: gr.update(visible=True), |
|
|
outputs=[history_display] |
|
|
) |
|
|
|
|
|
|
|
|
run_analysis_btn.click( |
|
|
fn=run_analysis, |
|
|
inputs=[min_support, top_k_pairs, max_moves, min_gain], |
|
|
outputs=[pairs_table, recs_table, analysis_summary, analysis_viz] |
|
|
) |
|
|
|
|
|
|
|
|
def load_checkout_example(): |
|
|
try: |
|
|
if EXAMPLE_CHECKOUT_IMAGE: |
|
|
return Image.open(EXAMPLE_CHECKOUT_IMAGE) |
|
|
except: |
|
|
pass |
|
|
return None |
|
|
|
|
|
load_example_checkout_btn.click( |
|
|
fn=load_checkout_example, |
|
|
outputs=[checkout_image] |
|
|
) |
|
|
|
|
|
def load_receipt_example(): |
|
|
try: |
|
|
if EXAMPLE_RECEIPT_PDF: |
|
|
return EXAMPLE_RECEIPT_PDF |
|
|
except: |
|
|
pass |
|
|
return None |
|
|
|
|
|
load_example_receipt_btn.click( |
|
|
fn=load_receipt_example, |
|
|
outputs=[receipt_file] |
|
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.queue() |
|
|
demo.launch() |