Update ui_ver_3.py
Browse files- ui_ver_3.py +769 -769
ui_ver_3.py
CHANGED
|
@@ -1,770 +1,770 @@
|
|
| 1 |
-
import gradio as gr
|
| 2 |
-
import requests
|
| 3 |
-
import tempfile
|
| 4 |
-
import os
|
| 5 |
-
import base64
|
| 6 |
-
import pandas as pd
|
| 7 |
-
from io import BytesIO
|
| 8 |
-
import time
|
| 9 |
-
from datetime import datetime
|
| 10 |
-
import zipfile
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
API_BASE_URL = "http://127.0.0.1:8000/"
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
# Global variable để lưu danh sách tất cả sản phẩm từ search
|
| 18 |
-
all_search_results = {}
|
| 19 |
-
|
| 20 |
-
# Label cho option "Không phải sản phẩm Rạng Đông"
|
| 21 |
-
not_rd_label = "Không phải sản phẩm Rạng Đông"
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
custom_css = """
|
| 25 |
-
h1 {
|
| 26 |
-
font-family: 'Segoe UI', sans-serif;
|
| 27 |
-
font-weight: 700;
|
| 28 |
-
font-size: 2.5rem;
|
| 29 |
-
}
|
| 30 |
-
label, .gr-input, .gr-file {
|
| 31 |
-
font-family: 'Segoe UI', sans-serif;
|
| 32 |
-
font-size: 1rem;
|
| 33 |
-
}
|
| 34 |
-
.gr-button {
|
| 35 |
-
font-weight: bold;
|
| 36 |
-
background-color: #4CAF50;
|
| 37 |
-
color: white;
|
| 38 |
-
border-radius: 8px;
|
| 39 |
-
padding: 10px 16px;
|
| 40 |
-
}
|
| 41 |
-
.gr-button:hover {
|
| 42 |
-
background-color: #45a049;
|
| 43 |
-
}
|
| 44 |
-
body {
|
| 45 |
-
background-color: #f8f9fa;
|
| 46 |
-
}
|
| 47 |
-
.section-box {
|
| 48 |
-
border: 1px solid #e0e0e0;
|
| 49 |
-
border-radius: 10px;
|
| 50 |
-
padding: 15px;
|
| 51 |
-
margin: 10px 0;
|
| 52 |
-
background-color: #fafafa;
|
| 53 |
-
}
|
| 54 |
-
"""
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
# ====================== FUNCTIONS ======================
|
| 58 |
-
|
| 59 |
-
def process_zip_with_api(employee_code, zip_file, llm_model):
|
| 60 |
-
"""
|
| 61 |
-
Xử lý file ZIP chứa ảnh hóa đơn - giống tab nhập đơn hàng
|
| 62 |
-
Returns: status, api_time, total_time, df, excel_file, excel_base64
|
| 63 |
-
"""
|
| 64 |
-
if not zip_file:
|
| 65 |
-
return (
|
| 66 |
-
gr.update(value="⚠️ Vui lòng tải lên file ZIP!"),
|
| 67 |
-
0,
|
| 68 |
-
0,
|
| 69 |
-
pd.DataFrame(),
|
| 70 |
-
gr.File(visible=False),
|
| 71 |
-
"" # excel_base64 empty
|
| 72 |
-
)
|
| 73 |
-
|
| 74 |
-
start_time = time.time()
|
| 75 |
-
|
| 76 |
-
try:
|
| 77 |
-
# Handle both string path and file object from Gradio
|
| 78 |
-
file_path = zip_file.name if hasattr(zip_file, 'name') else zip_file
|
| 79 |
-
with open(file_path, "rb") as f:
|
| 80 |
-
files = {'file': (os.path.basename(file_path), f.read(), 'application/zip')}
|
| 81 |
-
|
| 82 |
-
data = {
|
| 83 |
-
'employee_code': employee_code or "default",
|
| 84 |
-
'approach': "multimodal",
|
| 85 |
-
'llm_model': llm_model,
|
| 86 |
-
'audio_model': llm_model
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
response = requests.post(API_BASE_URL + "information_extraction/", files=files, data=data, timeout=600)
|
| 90 |
-
response.raise_for_status()
|
| 91 |
-
json_resp = response.json()
|
| 92 |
-
|
| 93 |
-
# Lưu excel_base64 để dùng cho endpoint /extract-products/
|
| 94 |
-
excel_base64 = json_resp.get("excel_data_base64", "")
|
| 95 |
-
|
| 96 |
-
excel_bytes = base64.b64decode(excel_base64)
|
| 97 |
-
api_duration = json_resp.get("api_duration", 0.)
|
| 98 |
-
df = pd.read_excel(BytesIO(excel_bytes))
|
| 99 |
-
|
| 100 |
-
# Lưu file Excel để tải về
|
| 101 |
-
df_to_save = df
|
| 102 |
-
result_file_name = "result_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
|
| 103 |
-
excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
|
| 104 |
-
df_to_save.to_excel(excel_path, index=False)
|
| 105 |
-
|
| 106 |
-
print(f"[DEBUG] process_zip_with_api: excel_base64 length = {len(excel_base64)}")
|
| 107 |
-
print(f"[DEBUG] process_zip_with_api: DataFrame columns = {list(df.columns)}")
|
| 108 |
-
|
| 109 |
-
return (
|
| 110 |
-
gr.update(value="✅ Xử lý thành công!"),
|
| 111 |
-
api_duration,
|
| 112 |
-
round(time.time() - start_time, 2),
|
| 113 |
-
df,
|
| 114 |
-
gr.File(value=excel_path, visible=True),
|
| 115 |
-
excel_base64 # Return base64 để dùng cho /extract-products/
|
| 116 |
-
)
|
| 117 |
-
|
| 118 |
-
except requests.exceptions.Timeout:
|
| 119 |
-
return (
|
| 120 |
-
gr.update(value="⚠️ Request timeout - vui lòng thử lại!"),
|
| 121 |
-
0,
|
| 122 |
-
round(time.time() - start_time, 2),
|
| 123 |
-
pd.DataFrame(),
|
| 124 |
-
gr.File(visible=False),
|
| 125 |
-
""
|
| 126 |
-
)
|
| 127 |
-
except Exception as e:
|
| 128 |
-
return (
|
| 129 |
-
gr.update(value=f"❌ Lỗi: {str(e)}"),
|
| 130 |
-
0,
|
| 131 |
-
round(time.time() - start_time, 2),
|
| 132 |
-
pd.DataFrame(),
|
| 133 |
-
gr.File(visible=False),
|
| 134 |
-
""
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def get_products_from_excel_api(excel_base64, product_column="Tên sản phẩm"):
|
| 139 |
-
"""
|
| 140 |
-
Gọi API /extract-products/ để trích xuất sản phẩm từ Excel base64
|
| 141 |
-
Workflow: Frontend gửi excel_base64 (từ state) → Backend decode + extract → Return product list
|
| 142 |
-
"""
|
| 143 |
-
print(f"[DEBUG] get_products_from_excel_api: excel_base64 length = {len(excel_base64) if excel_base64 else 0}")
|
| 144 |
-
|
| 145 |
-
if not excel_base64 or len(excel_base64) == 0:
|
| 146 |
-
return "", "⚠️ Chưa có dữ liệu Excel! Hãy xử lý file ZIP trước."
|
| 147 |
-
|
| 148 |
-
try:
|
| 149 |
-
# Gọi API /extract-products/
|
| 150 |
-
data = {
|
| 151 |
-
'excel_data_base64': excel_base64,
|
| 152 |
-
'product_column': product_column
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
response = requests.post(API_BASE_URL + "extract-products/", data=data, timeout=30)
|
| 156 |
-
response.raise_for_status()
|
| 157 |
-
json_resp = response.json()
|
| 158 |
-
|
| 159 |
-
if json_resp.get("status") == "success":
|
| 160 |
-
product_list = json_resp.get("product_list", [])
|
| 161 |
-
total_products = json_resp.get("total_products", len(product_list))
|
| 162 |
-
column_name = json_resp.get("column_name", product_column)
|
| 163 |
-
|
| 164 |
-
print(f"[DEBUG] Extracted {total_products} products from column '{column_name}'")
|
| 165 |
-
print(f"[DEBUG] Sample products: {product_list[:5] if len(product_list) > 5 else product_list}")
|
| 166 |
-
|
| 167 |
-
products_text = "\n".join([str(p) for p in product_list])
|
| 168 |
-
return products_text, f"✅ Đã lấy {total_products} sản phẩm từ cột '{column_name}'"
|
| 169 |
-
else:
|
| 170 |
-
error_msg = json_resp.get("detail", "Lỗi không xác định")
|
| 171 |
-
return "", f"❌ Lỗi: {error_msg}"
|
| 172 |
-
|
| 173 |
-
except requests.exceptions.HTTPError as e:
|
| 174 |
-
# Parse error detail from response
|
| 175 |
-
try:
|
| 176 |
-
error_detail = e.response.json().get("detail", str(e))
|
| 177 |
-
except:
|
| 178 |
-
error_detail = str(e)
|
| 179 |
-
return "", f"❌ Lỗi API: {error_detail}"
|
| 180 |
-
except requests.exceptions.Timeout:
|
| 181 |
-
return "", "⚠️ Request timeout - vui lòng thử lại!"
|
| 182 |
-
except Exception as e:
|
| 183 |
-
return "", f"❌ Lỗi: {str(e)}"
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
def call_mapping_api(product_list, method, weight_value, rrf_k_value, excel_base64, use_prediction=False):
|
| 187 |
-
"""
|
| 188 |
-
Gọi API /mapping/ với danh sách sản phẩm text.
|
| 189 |
-
|
| 190 |
-
Args:
|
| 191 |
-
product_list: text, mỗi dòng 1 sản phẩm
|
| 192 |
-
use_prediction: bool, tự động predict L1/L2/L3
|
| 193 |
-
|
| 194 |
-
Returns: status, time, df, file, top5_data
|
| 195 |
-
"""
|
| 196 |
-
start_time = time.time()
|
| 197 |
-
|
| 198 |
-
empty_return = (
|
| 199 |
-
gr.update(value="⚠️ Vui lòng nhập danh sách sản phẩm trước khi mapping!"),
|
| 200 |
-
0,
|
| 201 |
-
pd.DataFrame(),
|
| 202 |
-
gr.File(visible=False),
|
| 203 |
-
{},
|
| 204 |
-
)
|
| 205 |
-
|
| 206 |
-
if not product_list or not product_list.strip():
|
| 207 |
-
return empty_return
|
| 208 |
-
|
| 209 |
-
try:
|
| 210 |
-
# Build request data
|
| 211 |
-
data = {
|
| 212 |
-
'product_list': product_list.strip(),
|
| 213 |
-
'method': method if method else 'weighted',
|
| 214 |
-
'use_prediction': use_prediction,
|
| 215 |
-
}
|
| 216 |
-
|
| 217 |
-
if method == "weighted":
|
| 218 |
-
data['dense_weight'] = weight_value
|
| 219 |
-
data['sparse_weight'] = 1.0 - weight_value
|
| 220 |
-
elif method == "rrf":
|
| 221 |
-
data['rrf_k'] = int(rrf_k_value)
|
| 222 |
-
elif method == "rrf_cross_encoder":
|
| 223 |
-
data['rrf_k'] = int(rrf_k_value)
|
| 224 |
-
else: # weighted_rrf
|
| 225 |
-
data['dense_weight'] = weight_value
|
| 226 |
-
data['sparse_weight'] = 1.0 - weight_value
|
| 227 |
-
data['rrf_k'] = int(rrf_k_value)
|
| 228 |
-
|
| 229 |
-
# Gửi Excel gốc để API merge kết quả mapping vào
|
| 230 |
-
if excel_base64:
|
| 231 |
-
data['excel_data_base64'] = excel_base64
|
| 232 |
-
data['product_column'] = 'Tên sản phẩm'
|
| 233 |
-
|
| 234 |
-
response = requests.post(API_BASE_URL + "mapping/", data=data, timeout=300)
|
| 235 |
-
response.raise_for_status()
|
| 236 |
-
json_resp = response.json()
|
| 237 |
-
|
| 238 |
-
# Ưu tiên merged Excel (giữ nguyên format gốc + thêm cột "đã chọn")
|
| 239 |
-
merged_b64 = json_resp.get("merged_excel_base64", "")
|
| 240 |
-
mapping_b64 = json_resp.get("excel_data_base64", "")
|
| 241 |
-
|
| 242 |
-
if merged_b64:
|
| 243 |
-
excel_bytes = base64.b64decode(merged_b64)
|
| 244 |
-
else:
|
| 245 |
-
excel_bytes = base64.b64decode(mapping_b64)
|
| 246 |
-
|
| 247 |
-
df = pd.read_excel(BytesIO(excel_bytes))
|
| 248 |
-
|
| 249 |
-
# Build top5 data keyed by row index for floating menu
|
| 250 |
-
top5_data = {}
|
| 251 |
-
results = json_resp.get("results", [])
|
| 252 |
-
|
| 253 |
-
# First build product_name → top5 mapping
|
| 254 |
-
product_to_top5 = {}
|
| 255 |
-
for r in results:
|
| 256 |
-
name = r.get("Tên sản phẩm gốc", "").strip()
|
| 257 |
-
top5 = [r.get(f"Top {i}", "") for i in range(1, 6)]
|
| 258 |
-
top5 = [t for t in top5 if t]
|
| 259 |
-
if name:
|
| 260 |
-
product_to_top5[name.lower()] = top5
|
| 261 |
-
|
| 262 |
-
# Then map to row indices in the DataFrame
|
| 263 |
-
product_col = None
|
| 264 |
-
for col in df.columns:
|
| 265 |
-
col_lower = col.lower().strip()
|
| 266 |
-
if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
|
| 267 |
-
product_col = col
|
| 268 |
-
break
|
| 269 |
-
|
| 270 |
-
if product_col:
|
| 271 |
-
for i in range(len(df)):
|
| 272 |
-
product_name = str(df.iloc[i].get(product_col, "")).strip()
|
| 273 |
-
if product_name.lower() in product_to_top5:
|
| 274 |
-
top5_data[i] = product_to_top5[product_name.lower()]
|
| 275 |
-
|
| 276 |
-
# Save Excel file for download
|
| 277 |
-
result_file_name = "mapping_result_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
|
| 278 |
-
excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
|
| 279 |
-
df.to_excel(excel_path, index=False)
|
| 280 |
-
|
| 281 |
-
api_duration = json_resp.get("api_duration", round(time.time() - start_time, 2))
|
| 282 |
-
total_products = json_resp.get("total_products", 0)
|
| 283 |
-
|
| 284 |
-
return (
|
| 285 |
-
gr.update(value=f"✅ Đã mapping {total_products} sản phẩm thành công!"),
|
| 286 |
-
api_duration,
|
| 287 |
-
df,
|
| 288 |
-
gr.File(value=excel_path, visible=True),
|
| 289 |
-
top5_data,
|
| 290 |
-
)
|
| 291 |
-
|
| 292 |
-
except requests.exceptions.Timeout:
|
| 293 |
-
return (
|
| 294 |
-
gr.update(value="⚠️ Request timeout - vui lòng thử lại sau!"),
|
| 295 |
-
round(time.time() - start_time, 2),
|
| 296 |
-
pd.DataFrame(),
|
| 297 |
-
gr.File(visible=False),
|
| 298 |
-
{},
|
| 299 |
-
)
|
| 300 |
-
except Exception as e:
|
| 301 |
-
return (
|
| 302 |
-
gr.update(value=f"❌ Lỗi: {str(e)}"),
|
| 303 |
-
round(time.time() - start_time, 2),
|
| 304 |
-
pd.DataFrame(),
|
| 305 |
-
gr.File(visible=False),
|
| 306 |
-
{},
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
def search_products_api(keyword):
|
| 311 |
-
"""
|
| 312 |
-
Tìm kiếm sản phẩm như Ctrl+F - substring matching.
|
| 313 |
-
Gọi API /search-products/ với keyword.
|
| 314 |
-
"""
|
| 315 |
-
if not keyword or not keyword.strip():
|
| 316 |
-
return gr.update(choices=[], value=None), "⚠️ Vui lòng nhập từ khóa tìm kiếm"
|
| 317 |
-
|
| 318 |
-
try:
|
| 319 |
-
data = {
|
| 320 |
-
'keyword': keyword.strip(),
|
| 321 |
-
}
|
| 322 |
-
|
| 323 |
-
response = requests.post(API_BASE_URL + "search-products/", data=data, timeout=30)
|
| 324 |
-
response.raise_for_status()
|
| 325 |
-
json_resp = response.json()
|
| 326 |
-
|
| 327 |
-
if json_resp.get("status") == "success":
|
| 328 |
-
product_list = json_resp.get("product_list", [])
|
| 329 |
-
total = json_resp.get("total_results", len(product_list))
|
| 330 |
-
return (
|
| 331 |
-
gr.update(choices=product_list, value=None),
|
| 332 |
-
f"✅ Tìm thấy {total} sản phẩm chứa '{keyword.strip()}'"
|
| 333 |
-
)
|
| 334 |
-
else:
|
| 335 |
-
return gr.update(choices=[], value=None), "❌ Không tìm thấy kết quả"
|
| 336 |
-
|
| 337 |
-
except requests.exceptions.Timeout:
|
| 338 |
-
return gr.update(choices=[], value=None), "⚠️ Request timeout - vui lòng thử lại"
|
| 339 |
-
except Exception as e:
|
| 340 |
-
return gr.update(choices=[], value=None), f"❌ Lỗi: {str(e)}"
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
def save_edited_excel(df_editable):
|
| 344 |
-
"""
|
| 345 |
-
Lưu DataFrame đã chỉnh sửa thành file Excel.
|
| 346 |
-
"""
|
| 347 |
-
if df_editable is None or df_editable.empty:
|
| 348 |
-
return gr.File(visible=False), "⚠️ Không có dữ liệu để lưu!"
|
| 349 |
-
|
| 350 |
-
try:
|
| 351 |
-
# Tìm cột sản phẩm để lọc
|
| 352 |
-
target_col = None
|
| 353 |
-
for col in df_editable.columns:
|
| 354 |
-
col_lower = col.lower().strip()
|
| 355 |
-
if col_lower in ('tên sản phẩm', 'ten san pham'):
|
| 356 |
-
target_col = col
|
| 357 |
-
break
|
| 358 |
-
|
| 359 |
-
# Lọc bỏ dòng "Không phải sản phẩm Rạng Đông"
|
| 360 |
-
df_filtered = df_editable.copy()
|
| 361 |
-
removed_count = 0
|
| 362 |
-
if target_col:
|
| 363 |
-
mask = df_editable[target_col] == not_rd_label
|
| 364 |
-
removed_count = int(mask.sum())
|
| 365 |
-
df_filtered = df_editable[~mask].copy()
|
| 366 |
-
else:
|
| 367 |
-
df_filtered = df_editable.copy()
|
| 368 |
-
removed_count = 0
|
| 369 |
-
|
| 370 |
-
result_file_name = "mapping_edited_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
|
| 371 |
-
excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
|
| 372 |
-
df_filtered.to_excel(excel_path, index=False)
|
| 373 |
-
|
| 374 |
-
if removed_count > 0:
|
| 375 |
-
return gr.File(value=excel_path, visible=True), f"✅ Đã lưu file Excel! (Đã xóa {removed_count} dòng 'Không phải sản phẩm Rạng Đông')"
|
| 376 |
-
return gr.File(value=excel_path, visible=True), "✅ Đã lưu file Excel!"
|
| 377 |
-
except Exception as e:
|
| 378 |
-
return gr.File(visible=False), f"❌ Lỗi lưu file: {str(e)}"
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
def on_cell_select(top5_data, df, evt: gr.SelectData):
|
| 382 |
-
"""Khi user click vào ô trong bảng mapping → hiện floating menu nếu là cột sản phẩm"""
|
| 383 |
-
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
|
| 384 |
-
return gr.update(visible=False), "", gr.update(choices=[], value=None), None, ""
|
| 385 |
-
|
| 386 |
-
row_idx = evt.index[0]
|
| 387 |
-
col_idx = evt.index[1]
|
| 388 |
-
|
| 389 |
-
# Tìm cột sản phẩm
|
| 390 |
-
product_col_idx = None
|
| 391 |
-
product_col_name = None
|
| 392 |
-
for i, col in enumerate(df.columns):
|
| 393 |
-
col_lower = col.lower().strip()
|
| 394 |
-
if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
|
| 395 |
-
product_col_idx = i
|
| 396 |
-
product_col_name = col
|
| 397 |
-
break
|
| 398 |
-
|
| 399 |
-
# Chỉ hiện floating menu khi click vào cột sản phẩm
|
| 400 |
-
if product_col_idx is None or col_idx != product_col_idx:
|
| 401 |
-
return gr.update(visible=False), "", gr.update(choices=[], value=None), None, ""
|
| 402 |
-
current_value = str(df.iloc[row_idx][product_col_name])
|
| 403 |
-
|
| 404 |
-
# Gradio Serialize JSON nên array index (int) có thể bị parse thành kiểu string trong dictionary
|
| 405 |
-
top5 = []
|
| 406 |
-
if isinstance(top5_data, dict):
|
| 407 |
-
top5 = top5_data.get(row_idx, top5_data.get(str(row_idx), []))
|
| 408 |
-
|
| 409 |
-
if not top5:
|
| 410 |
-
return (
|
| 411 |
-
gr.update(visible=True),
|
| 412 |
-
f"📝 Dòng {row_idx+1} - SP hiện tại: {current_value}",
|
| 413 |
-
gr.update(choices=["Không có đề xuất", not_rd_label], value=None),
|
| 414 |
-
row_idx,
|
| 415 |
-
current_value
|
| 416 |
-
)
|
| 417 |
-
|
| 418 |
-
# Thêm option "Không phải sản phẩm Rạng Đông" vào cuối danh sách
|
| 419 |
-
choices_with_not_rd = top5 + [not_rd_label]
|
| 420 |
-
return (
|
| 421 |
-
gr.update(visible=True),
|
| 422 |
-
f"📝 Dòng {row_idx+1} - SP hiện tại: {current_value}",
|
| 423 |
-
gr.update(choices=choices_with_not_rd, value=None),
|
| 424 |
-
row_idx,
|
| 425 |
-
current_value
|
| 426 |
-
)
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
def apply_product(df, selected_row_idx, selected_product, undo_history):
|
| 430 |
-
"""Áp dụng sản phẩm đã chọn vào cột sản phẩm tại dòng đã click"""
|
| 431 |
-
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
|
| 432 |
-
return df, undo_history, "⚠️ Không có dữ liệu"
|
| 433 |
-
|
| 434 |
-
if selected_row_idx is None or selected_product is None:
|
| 435 |
-
return df, undo_history, "⚠️ Vui lòng click vào ô sản phẩm và chọn đề xuất"
|
| 436 |
-
|
| 437 |
-
if selected_product in ["Không có đề xuất"]:
|
| 438 |
-
return df, undo_history, "⚠️ Sản phẩm không hợp lệ"
|
| 439 |
-
|
| 440 |
-
row_idx = selected_row_idx
|
| 441 |
-
if row_idx < 0 or row_idx >= len(df):
|
| 442 |
-
return df, undo_history, "❌ Dòng không hợp lệ"
|
| 443 |
-
|
| 444 |
-
# Tìm cột sản phẩm
|
| 445 |
-
target_col = None
|
| 446 |
-
for col in df.columns:
|
| 447 |
-
col_lower = col.lower().strip()
|
| 448 |
-
if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
|
| 449 |
-
target_col = col
|
| 450 |
-
break
|
| 451 |
-
|
| 452 |
-
if target_col is None:
|
| 453 |
-
return df, undo_history, "❌ Không tìm thấy cột sản phẩm"
|
| 454 |
-
|
| 455 |
-
# Lưu undo history
|
| 456 |
-
old_value = str(df.iloc[row_idx][target_col])
|
| 457 |
-
if undo_history is None:
|
| 458 |
-
undo_history = []
|
| 459 |
-
undo_history = list(undo_history) # Copy to avoid mutation
|
| 460 |
-
undo_history.append({
|
| 461 |
-
'row': row_idx,
|
| 462 |
-
'col': target_col,
|
| 463 |
-
'old_value': old_value
|
| 464 |
-
})
|
| 465 |
-
|
| 466 |
-
# Áp dụng giá trị mới
|
| 467 |
-
df_updated = df.copy()
|
| 468 |
-
df_updated.at[row_idx, target_col] = selected_product
|
| 469 |
-
|
| 470 |
-
return df_updated, undo_history, f"✅ Dòng {row_idx+1}: '{old_value}' → '{selected_product}'"
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
def undo_change(df, undo_history):
|
| 474 |
-
"""Hoàn tác thay đổi cuối cùng"""
|
| 475 |
-
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
|
| 476 |
-
return df, undo_history, "⚠️ Không có dữ liệu"
|
| 477 |
-
|
| 478 |
-
if not undo_history or len(undo_history) == 0:
|
| 479 |
-
return df, undo_history, "⚠️ Không có thay đổi nào để hoàn tác"
|
| 480 |
-
|
| 481 |
-
undo_history = list(undo_history) # Copy
|
| 482 |
-
last_change = undo_history.pop()
|
| 483 |
-
row = last_change['row']
|
| 484 |
-
col = last_change['col']
|
| 485 |
-
old_value = last_change['old_value']
|
| 486 |
-
|
| 487 |
-
df_updated = df.copy()
|
| 488 |
-
current_value = str(df_updated.iloc[row][col])
|
| 489 |
-
df_updated.at[row, col] = old_value
|
| 490 |
-
|
| 491 |
-
remaining = len(undo_history)
|
| 492 |
-
return df_updated, undo_history, f"↩ Dòng {row+1}: '{current_value}' → '{old_value}' (còn {remaining} thay đổi có thể hoàn tác)"
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
# ====================== GRADIO UI ======================
|
| 496 |
-
|
| 497 |
-
with gr.Blocks(title="NHẬP ĐƠN HÀNG ĐA PHƯƠNG THỨC") as demo:
|
| 498 |
-
|
| 499 |
-
# State variables
|
| 500 |
-
excel_base64_state = gr.State("") # Lưu excel_data_base64 từ /information_extraction/
|
| 501 |
-
top5_state = gr.State({}) # Lưu top 5 đề xuất cho mỗi sản phẩm (key: row index)
|
| 502 |
-
undo_history_state = gr.State([]) # Lưu lịch sử thay đổi để undo
|
| 503 |
-
selected_row_state = gr.State(None) # Lưu row index đang được chọn từ DataFrame click
|
| 504 |
-
|
| 505 |
-
# ====================== HEADER ======================
|
| 506 |
-
with gr.Row():
|
| 507 |
-
with gr.Column(scale=0, min_width=120):
|
| 508 |
-
gr.Image(
|
| 509 |
-
value="logo_multimodal_invoice.jpg",
|
| 510 |
-
show_label=False,
|
| 511 |
-
elem_id="logo",
|
| 512 |
-
height=100,
|
| 513 |
-
width=100,
|
| 514 |
-
container=False,
|
| 515 |
-
)
|
| 516 |
-
with gr.Column():
|
| 517 |
-
gr.Markdown(
|
| 518 |
-
"""
|
| 519 |
-
<div style='text-align: center;'>
|
| 520 |
-
<h1 style='margin-bottom: 0.5em; font-size: 1.8em;'>📄 NHẬP ĐƠN HÀNG ĐA PHƯƠNG THỨC</h1>
|
| 521 |
-
</div>
|
| 522 |
-
""",
|
| 523 |
-
elem_id="main-title"
|
| 524 |
-
)
|
| 525 |
-
|
| 526 |
-
gr.Markdown("---")
|
| 527 |
-
|
| 528 |
-
# ====================== SECTION 1: MÃ NHÂN VIÊN - LLM MODEL ======================
|
| 529 |
-
with gr.Row(equal_height=True):
|
| 530 |
-
employee_code_input = gr.Textbox(
|
| 531 |
-
label="Mã nhân viên",
|
| 532 |
-
placeholder="Nhập mã nhân viên",
|
| 533 |
-
scale=1
|
| 534 |
-
)
|
| 535 |
-
llm_model_input = gr.Dropdown(
|
| 536 |
-
["Gemini 2.5 Flash", "Gemini 2.5 Flash-Lite"],
|
| 537 |
-
value="Gemini 2.5 Flash",
|
| 538 |
-
label="Mô hình đa phương thức",
|
| 539 |
-
scale=1,
|
| 540 |
-
interactive=True
|
| 541 |
-
)
|
| 542 |
-
|
| 543 |
-
# ====================== SECTION 2: OCR + TRẠNG THÁI + CẤU HÌNH ======================
|
| 544 |
-
with gr.Row(equal_height=True):
|
| 545 |
-
# Cột 1: Upload + Xử lý
|
| 546 |
-
with gr.Column(scale=1):
|
| 547 |
-
uploaded_files = gr.File(
|
| 548 |
-
label="Tải lên file ZIP",
|
| 549 |
-
file_count="single",
|
| 550 |
-
file_types=[".zip"],
|
| 551 |
-
type="filepath"
|
| 552 |
-
)
|
| 553 |
-
process_btn = gr.Button("⚙️ Xử lý đơn hàng", variant="secondary")
|
| 554 |
-
progress = gr.Textbox(label="Trạng thái xử lý", interactive=False)
|
| 555 |
-
|
| 556 |
-
# Cột 2: Thông tin tiến trình
|
| 557 |
-
with gr.Column(scale=1):
|
| 558 |
-
current_file_text = gr.Textbox(label="Đang xử lý file", interactive=False)
|
| 559 |
-
file_time_output = gr.Textbox(label="Thời gian xử lý file", interactive=False, lines=4)
|
| 560 |
-
total_time_output = gr.Number(label="Tổng thời gian xử lý (giây)", interactive=False)
|
| 561 |
-
|
| 562 |
-
# Cột 3: Cấu hình tìm kiếm + Mapping
|
| 563 |
-
with gr.Column(scale=2):
|
| 564 |
-
gr.Markdown("#### ⚙️ Cấu hình tìm kiếm")
|
| 565 |
-
search_method = gr.Radio(
|
| 566 |
-
choices=["weighted", "rrf", "weighted_rrf", "rrf_cross_encoder"],
|
| 567 |
-
value="weighted",
|
| 568 |
-
label="Phương pháp Fusion",
|
| 569 |
-
info="Weighted: trọng số điểm | RRF: xếp hạng đảo | Weighted RRF: kết hợp | RRF+CE: rerank"
|
| 570 |
-
)
|
| 571 |
-
with gr.Group(visible=True) as weighted_config:
|
| 572 |
-
weight_slider = gr.Slider(
|
| 573 |
-
minimum=0, maximum=1, value=0.7, step=0.1,
|
| 574 |
-
label="Dense ↔ Sparse",
|
| 575 |
-
info="Kéo trái → ưu tiên Sparse (từ khóa) | Kéo phải → ưu tiên Dense (ngữ nghĩa)"
|
| 576 |
-
)
|
| 577 |
-
weight_display = gr.Markdown(value="**Dense: 0.7** | **Sparse: 0.3**")
|
| 578 |
-
with gr.Group(visible=False) as rrf_config:
|
| 579 |
-
rrf_k_slider = gr.Slider(
|
| 580 |
-
minimum=30, maximum=100, value=60, step=30,
|
| 581 |
-
label="RRF K (hằng số làm mượt)",
|
| 582 |
-
info="30: nhạy | 60: cân bằng | 100: ổn định"
|
| 583 |
-
)
|
| 584 |
-
use_prediction_checkbox = gr.Checkbox(
|
| 585 |
-
label="🤖 Tự động dự đoán danh mục (LLM Prediction)",
|
| 586 |
-
value=False,
|
| 587 |
-
info="Khi bật: tự động predict L1/L2/L3 cho mỗi sản phẩm qua LLM → filter search"
|
| 588 |
-
)
|
| 589 |
-
with gr.Row():
|
| 590 |
-
mapping_status = gr.Textbox(label="Trạng thái mapping", interactive=False, scale=2)
|
| 591 |
-
mapping_time = gr.Number(label="Thời gian (giây)", interactive=False, scale=1)
|
| 592 |
-
mapping_btn = gr.Button("🚀 BẮT ĐẦU MAPPING", variant="primary", size="lg")
|
| 593 |
-
|
| 594 |
-
# Hidden state: danh sách sản phẩm từ OCR
|
| 595 |
-
product_list = gr.Textbox(visible=True)
|
| 596 |
-
|
| 597 |
-
excel_download = gr.File(
|
| 598 |
-
label="Tải file Excel kết quả xử lý",
|
| 599 |
-
interactive=False,
|
| 600 |
-
visible=False
|
| 601 |
-
)
|
| 602 |
-
|
| 603 |
-
ocr_result = gr.Dataframe(
|
| 604 |
-
label="Kết quả nhận diện (OCR)",
|
| 605 |
-
wrap=True,
|
| 606 |
-
interactive=False
|
| 607 |
-
)
|
| 608 |
-
|
| 609 |
-
gr.Markdown("---")
|
| 610 |
-
|
| 611 |
-
# ====================== SECTION 3: KẾT QUẢ MAPPING ======================
|
| 612 |
-
gr.Markdown("### 📊 Kết quả Mapping")
|
| 613 |
-
gr.Markdown("*Click vào ô **Tên sản phẩm** để xem Top 5 đề xuất → Chọn sản phẩm → Áp dụng*")
|
| 614 |
-
|
| 615 |
-
mapping_result = gr.Dataframe(
|
| 616 |
-
label="Kết quả Mapping (click vào ô sản phẩm để thay thế)",
|
| 617 |
-
wrap=True,
|
| 618 |
-
interactive=True
|
| 619 |
-
)
|
| 620 |
-
|
| 621 |
-
# ====================== FLOATING MENU - TOP 5 ĐỀ XUẤT ======================
|
| 622 |
-
with gr.Group(visible=False) as floating_panel:
|
| 623 |
-
gr.Markdown("#### 🔄 Chọn sản phẩm thay thế")
|
| 624 |
-
floating_product_display = gr.Textbox(
|
| 625 |
-
label="Sản phẩm đang chọn",
|
| 626 |
-
interactive=False
|
| 627 |
-
)
|
| 628 |
-
floating_top5_radio = gr.Radio(
|
| 629 |
-
label="Top 5 sản phẩm đề xuất (chọn 1 để thay thế)",
|
| 630 |
-
choices=[],
|
| 631 |
-
interactive=True
|
| 632 |
-
)
|
| 633 |
-
with gr.Row():
|
| 634 |
-
floating_apply_btn = gr.Button("✅ Áp dụng", variant="primary", scale=1)
|
| 635 |
-
floating_undo_btn = gr.Button("↩ Hoàn tác", variant="secondary", scale=1)
|
| 636 |
-
floating_status = gr.Textbox(label="Trạng thái", interactive=False, scale=3)
|
| 637 |
-
|
| 638 |
-
gr.Markdown("---")
|
| 639 |
-
|
| 640 |
-
# ====================== TÌM KIẾM MỞ RỘNG ======================
|
| 641 |
-
with gr.Accordion("🔍 Tìm kiếm mở rộng (nếu Top 5
|
| 642 |
-
gr.Markdown("*Tìm kiếm như Ctrl+F trong danh sách sản phẩm → chọn sản phẩm → áp dụng vào dòng đã click ở bảng trên*")
|
| 643 |
-
|
| 644 |
-
with gr.Row():
|
| 645 |
-
search_keyword = gr.Textbox(
|
| 646 |
-
label="Từ khóa tìm kiếm (tự động cập nhật khi click vào ô sản phẩm)",
|
| 647 |
-
placeholder="Click vào ô sản phẩm ở bảng trên hoặc nhập từ khóa...",
|
| 648 |
-
interactive=True,
|
| 649 |
-
scale=2
|
| 650 |
-
)
|
| 651 |
-
search_btn = gr.Button("🔍 Tìm kiếm", variant="secondary", scale=1)
|
| 652 |
-
|
| 653 |
-
search_status = gr.Textbox(label="Trạng thái tìm kiếm", interactive=False)
|
| 654 |
-
|
| 655 |
-
search_results = gr.Dropdown(
|
| 656 |
-
label="Kết quả tìm kiếm (chọn sản phẩm)",
|
| 657 |
-
choices=[],
|
| 658 |
-
interactive=True,
|
| 659 |
-
allow_custom_value=True
|
| 660 |
-
)
|
| 661 |
-
|
| 662 |
-
apply_search_btn = gr.Button("✅ Áp dụng sản phẩm từ tìm kiếm", variant="primary")
|
| 663 |
-
|
| 664 |
-
gr.Markdown("---")
|
| 665 |
-
|
| 666 |
-
# ====================== TẢI FILE EXCEL KẾT QUẢ ======================
|
| 667 |
-
with gr.Row():
|
| 668 |
-
save_btn = gr.Button("💾 Lưu Excel đã chỉnh sửa", variant="primary")
|
| 669 |
-
save_status = gr.Textbox(label="Trạng thái lưu", interactive=False, scale=2)
|
| 670 |
-
|
| 671 |
-
mapping_download = gr.File(
|
| 672 |
-
label="Tải file Excel kết quả",
|
| 673 |
-
interactive=False,
|
| 674 |
-
visible=False
|
| 675 |
-
)
|
| 676 |
-
|
| 677 |
-
# ====================== EVENT HANDLERS ======================
|
| 678 |
-
|
| 679 |
-
# Xử lý file ZIP → hiện kết quả OCR → cập nhật product_list
|
| 680 |
-
process_btn.click(
|
| 681 |
-
fn=process_zip_with_api,
|
| 682 |
-
inputs=[employee_code_input, uploaded_files, llm_model_input],
|
| 683 |
-
outputs=[progress, file_time_output, total_time_output, ocr_result, excel_download, excel_base64_state]
|
| 684 |
-
).then(
|
| 685 |
-
fn=lambda excel_base64: get_products_from_excel_api(excel_base64, "Tên sản phẩm"),
|
| 686 |
-
inputs=[excel_base64_state],
|
| 687 |
-
outputs=[product_list, current_file_text]
|
| 688 |
-
)
|
| 689 |
-
|
| 690 |
-
# Mapping sản phẩm
|
| 691 |
-
mapping_btn.click(
|
| 692 |
-
fn=call_mapping_api,
|
| 693 |
-
inputs=[product_list, search_method, weight_slider, rrf_k_slider, excel_base64_state, use_prediction_checkbox],
|
| 694 |
-
outputs=[mapping_status, mapping_time, mapping_result, mapping_download, top5_state]
|
| 695 |
-
)
|
| 696 |
-
|
| 697 |
-
# Click vào ô sản phẩm → hiện floating menu + cập nhật ô tìm kiếm
|
| 698 |
-
mapping_result.select(
|
| 699 |
-
fn=on_cell_select,
|
| 700 |
-
inputs=[top5_state, mapping_result],
|
| 701 |
-
outputs=[floating_panel, floating_product_display, floating_top5_radio, selected_row_state, search_keyword]
|
| 702 |
-
)
|
| 703 |
-
|
| 704 |
-
# Áp dụng sản phẩm đã chọn từ Top 5
|
| 705 |
-
floating_apply_btn.click(
|
| 706 |
-
fn=apply_product,
|
| 707 |
-
inputs=[mapping_result, selected_row_state, floating_top5_radio, undo_history_state],
|
| 708 |
-
outputs=[mapping_result, undo_history_state, floating_status]
|
| 709 |
-
)
|
| 710 |
-
|
| 711 |
-
# Hoàn tác thay đổi cuối cùng
|
| 712 |
-
floating_undo_btn.click(
|
| 713 |
-
fn=undo_change,
|
| 714 |
-
inputs=[mapping_result, undo_history_state],
|
| 715 |
-
outputs=[mapping_result, undo_history_state, floating_status]
|
| 716 |
-
)
|
| 717 |
-
|
| 718 |
-
# Tìm kiếm sản phẩm mở rộng
|
| 719 |
-
search_btn.click(
|
| 720 |
-
fn=search_products_api,
|
| 721 |
-
inputs=[search_keyword],
|
| 722 |
-
outputs=[search_results, search_status]
|
| 723 |
-
)
|
| 724 |
-
|
| 725 |
-
# Áp dụng sản phẩm từ tìm kiếm mở rộng
|
| 726 |
-
apply_search_btn.click(
|
| 727 |
-
fn=apply_product,
|
| 728 |
-
inputs=[mapping_result, selected_row_state, search_results, undo_history_state],
|
| 729 |
-
outputs=[mapping_result, undo_history_state, floating_status]
|
| 730 |
-
)
|
| 731 |
-
|
| 732 |
-
# Dynamic UI: Show/hide config based on method selection
|
| 733 |
-
def update_method_visibility(method):
|
| 734 |
-
if method == "weighted":
|
| 735 |
-
return gr.update(visible=True), gr.update(visible=False)
|
| 736 |
-
elif method == "rrf":
|
| 737 |
-
return gr.update(visible=False), gr.update(visible=True)
|
| 738 |
-
elif method == "rrf_cross_encoder":
|
| 739 |
-
return gr.update(visible=False), gr.update(visible=True)
|
| 740 |
-
else: # weighted_rrf: hiện cả hai
|
| 741 |
-
return gr.update(visible=True), gr.update(visible=True)
|
| 742 |
-
|
| 743 |
-
search_method.change(
|
| 744 |
-
fn=update_method_visibility,
|
| 745 |
-
inputs=[search_method],
|
| 746 |
-
outputs=[weighted_config, rrf_config]
|
| 747 |
-
)
|
| 748 |
-
|
| 749 |
-
# Update weight display when slider changes
|
| 750 |
-
def update_weight_display(value):
|
| 751 |
-
dense = round(value, 1)
|
| 752 |
-
sparse = round(1.0 - value, 1)
|
| 753 |
-
return f"**Dense: {dense}** | **Sparse: {sparse}**"
|
| 754 |
-
|
| 755 |
-
weight_slider.change(
|
| 756 |
-
fn=update_weight_display,
|
| 757 |
-
inputs=[weight_slider],
|
| 758 |
-
outputs=[weight_display]
|
| 759 |
-
)
|
| 760 |
-
|
| 761 |
-
# Lưu Excel đã chỉnh sửa
|
| 762 |
-
save_btn.click(
|
| 763 |
-
fn=save_edited_excel,
|
| 764 |
-
inputs=[mapping_result],
|
| 765 |
-
outputs=[mapping_download, save_status]
|
| 766 |
-
)
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
if __name__ == "__main__":
|
| 770 |
demo.launch(inbrowser=True, share=False)
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import requests
|
| 3 |
+
import tempfile
|
| 4 |
+
import os
|
| 5 |
+
import base64
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
import time
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import zipfile
|
| 11 |
+
|
| 12 |
+
API_BASE_URL = "https://gradio-ocr-audio-demo-i7u7.onrender.com/"
|
| 13 |
+
|
| 14 |
+
# API_BASE_URL = "http://127.0.0.1:8000/"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# Global variable để lưu danh sách tất cả sản phẩm từ search
|
| 18 |
+
all_search_results = {}
|
| 19 |
+
|
| 20 |
+
# Label cho option "Không phải sản phẩm Rạng Đông"
|
| 21 |
+
not_rd_label = "Không phải sản phẩm Rạng Đông"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
custom_css = """
|
| 25 |
+
h1 {
|
| 26 |
+
font-family: 'Segoe UI', sans-serif;
|
| 27 |
+
font-weight: 700;
|
| 28 |
+
font-size: 2.5rem;
|
| 29 |
+
}
|
| 30 |
+
label, .gr-input, .gr-file {
|
| 31 |
+
font-family: 'Segoe UI', sans-serif;
|
| 32 |
+
font-size: 1rem;
|
| 33 |
+
}
|
| 34 |
+
.gr-button {
|
| 35 |
+
font-weight: bold;
|
| 36 |
+
background-color: #4CAF50;
|
| 37 |
+
color: white;
|
| 38 |
+
border-radius: 8px;
|
| 39 |
+
padding: 10px 16px;
|
| 40 |
+
}
|
| 41 |
+
.gr-button:hover {
|
| 42 |
+
background-color: #45a049;
|
| 43 |
+
}
|
| 44 |
+
body {
|
| 45 |
+
background-color: #f8f9fa;
|
| 46 |
+
}
|
| 47 |
+
.section-box {
|
| 48 |
+
border: 1px solid #e0e0e0;
|
| 49 |
+
border-radius: 10px;
|
| 50 |
+
padding: 15px;
|
| 51 |
+
margin: 10px 0;
|
| 52 |
+
background-color: #fafafa;
|
| 53 |
+
}
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ====================== FUNCTIONS ======================
|
| 58 |
+
|
| 59 |
+
def process_zip_with_api(employee_code, zip_file, llm_model):
|
| 60 |
+
"""
|
| 61 |
+
Xử lý file ZIP chứa ảnh hóa đơn - giống tab nhập đơn hàng
|
| 62 |
+
Returns: status, api_time, total_time, df, excel_file, excel_base64
|
| 63 |
+
"""
|
| 64 |
+
if not zip_file:
|
| 65 |
+
return (
|
| 66 |
+
gr.update(value="⚠️ Vui lòng tải lên file ZIP!"),
|
| 67 |
+
0,
|
| 68 |
+
0,
|
| 69 |
+
pd.DataFrame(),
|
| 70 |
+
gr.File(visible=False),
|
| 71 |
+
"" # excel_base64 empty
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
start_time = time.time()
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
# Handle both string path and file object from Gradio
|
| 78 |
+
file_path = zip_file.name if hasattr(zip_file, 'name') else zip_file
|
| 79 |
+
with open(file_path, "rb") as f:
|
| 80 |
+
files = {'file': (os.path.basename(file_path), f.read(), 'application/zip')}
|
| 81 |
+
|
| 82 |
+
data = {
|
| 83 |
+
'employee_code': employee_code or "default",
|
| 84 |
+
'approach': "multimodal",
|
| 85 |
+
'llm_model': llm_model,
|
| 86 |
+
'audio_model': llm_model
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
response = requests.post(API_BASE_URL + "information_extraction/", files=files, data=data, timeout=600)
|
| 90 |
+
response.raise_for_status()
|
| 91 |
+
json_resp = response.json()
|
| 92 |
+
|
| 93 |
+
# Lưu excel_base64 để dùng cho endpoint /extract-products/
|
| 94 |
+
excel_base64 = json_resp.get("excel_data_base64", "")
|
| 95 |
+
|
| 96 |
+
excel_bytes = base64.b64decode(excel_base64)
|
| 97 |
+
api_duration = json_resp.get("api_duration", 0.)
|
| 98 |
+
df = pd.read_excel(BytesIO(excel_bytes))
|
| 99 |
+
|
| 100 |
+
# Lưu file Excel để tải về
|
| 101 |
+
df_to_save = df
|
| 102 |
+
result_file_name = "result_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
|
| 103 |
+
excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
|
| 104 |
+
df_to_save.to_excel(excel_path, index=False)
|
| 105 |
+
|
| 106 |
+
print(f"[DEBUG] process_zip_with_api: excel_base64 length = {len(excel_base64)}")
|
| 107 |
+
print(f"[DEBUG] process_zip_with_api: DataFrame columns = {list(df.columns)}")
|
| 108 |
+
|
| 109 |
+
return (
|
| 110 |
+
gr.update(value="✅ Xử lý thành công!"),
|
| 111 |
+
api_duration,
|
| 112 |
+
round(time.time() - start_time, 2),
|
| 113 |
+
df,
|
| 114 |
+
gr.File(value=excel_path, visible=True),
|
| 115 |
+
excel_base64 # Return base64 để dùng cho /extract-products/
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
except requests.exceptions.Timeout:
|
| 119 |
+
return (
|
| 120 |
+
gr.update(value="⚠️ Request timeout - vui lòng thử lại!"),
|
| 121 |
+
0,
|
| 122 |
+
round(time.time() - start_time, 2),
|
| 123 |
+
pd.DataFrame(),
|
| 124 |
+
gr.File(visible=False),
|
| 125 |
+
""
|
| 126 |
+
)
|
| 127 |
+
except Exception as e:
|
| 128 |
+
return (
|
| 129 |
+
gr.update(value=f"❌ Lỗi: {str(e)}"),
|
| 130 |
+
0,
|
| 131 |
+
round(time.time() - start_time, 2),
|
| 132 |
+
pd.DataFrame(),
|
| 133 |
+
gr.File(visible=False),
|
| 134 |
+
""
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def get_products_from_excel_api(excel_base64, product_column="Tên sản phẩm"):
|
| 139 |
+
"""
|
| 140 |
+
Gọi API /extract-products/ để trích xuất sản phẩm từ Excel base64
|
| 141 |
+
Workflow: Frontend gửi excel_base64 (từ state) → Backend decode + extract → Return product list
|
| 142 |
+
"""
|
| 143 |
+
print(f"[DEBUG] get_products_from_excel_api: excel_base64 length = {len(excel_base64) if excel_base64 else 0}")
|
| 144 |
+
|
| 145 |
+
if not excel_base64 or len(excel_base64) == 0:
|
| 146 |
+
return "", "⚠️ Chưa có dữ liệu Excel! Hãy xử lý file ZIP trước."
|
| 147 |
+
|
| 148 |
+
try:
|
| 149 |
+
# Gọi API /extract-products/
|
| 150 |
+
data = {
|
| 151 |
+
'excel_data_base64': excel_base64,
|
| 152 |
+
'product_column': product_column
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
response = requests.post(API_BASE_URL + "extract-products/", data=data, timeout=30)
|
| 156 |
+
response.raise_for_status()
|
| 157 |
+
json_resp = response.json()
|
| 158 |
+
|
| 159 |
+
if json_resp.get("status") == "success":
|
| 160 |
+
product_list = json_resp.get("product_list", [])
|
| 161 |
+
total_products = json_resp.get("total_products", len(product_list))
|
| 162 |
+
column_name = json_resp.get("column_name", product_column)
|
| 163 |
+
|
| 164 |
+
print(f"[DEBUG] Extracted {total_products} products from column '{column_name}'")
|
| 165 |
+
print(f"[DEBUG] Sample products: {product_list[:5] if len(product_list) > 5 else product_list}")
|
| 166 |
+
|
| 167 |
+
products_text = "\n".join([str(p) for p in product_list])
|
| 168 |
+
return products_text, f"✅ Đã lấy {total_products} sản phẩm từ cột '{column_name}'"
|
| 169 |
+
else:
|
| 170 |
+
error_msg = json_resp.get("detail", "Lỗi không xác định")
|
| 171 |
+
return "", f"❌ Lỗi: {error_msg}"
|
| 172 |
+
|
| 173 |
+
except requests.exceptions.HTTPError as e:
|
| 174 |
+
# Parse error detail from response
|
| 175 |
+
try:
|
| 176 |
+
error_detail = e.response.json().get("detail", str(e))
|
| 177 |
+
except:
|
| 178 |
+
error_detail = str(e)
|
| 179 |
+
return "", f"❌ Lỗi API: {error_detail}"
|
| 180 |
+
except requests.exceptions.Timeout:
|
| 181 |
+
return "", "⚠️ Request timeout - vui lòng thử lại!"
|
| 182 |
+
except Exception as e:
|
| 183 |
+
return "", f"❌ Lỗi: {str(e)}"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def call_mapping_api(product_list, method, weight_value, rrf_k_value, excel_base64, use_prediction=False):
|
| 187 |
+
"""
|
| 188 |
+
Gọi API /mapping/ với danh sách sản phẩm text.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
product_list: text, mỗi dòng 1 sản phẩm
|
| 192 |
+
use_prediction: bool, tự động predict L1/L2/L3
|
| 193 |
+
|
| 194 |
+
Returns: status, time, df, file, top5_data
|
| 195 |
+
"""
|
| 196 |
+
start_time = time.time()
|
| 197 |
+
|
| 198 |
+
empty_return = (
|
| 199 |
+
gr.update(value="⚠️ Vui lòng nhập danh sách sản phẩm trước khi mapping!"),
|
| 200 |
+
0,
|
| 201 |
+
pd.DataFrame(),
|
| 202 |
+
gr.File(visible=False),
|
| 203 |
+
{},
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
if not product_list or not product_list.strip():
|
| 207 |
+
return empty_return
|
| 208 |
+
|
| 209 |
+
try:
|
| 210 |
+
# Build request data
|
| 211 |
+
data = {
|
| 212 |
+
'product_list': product_list.strip(),
|
| 213 |
+
'method': method if method else 'weighted',
|
| 214 |
+
'use_prediction': use_prediction,
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
if method == "weighted":
|
| 218 |
+
data['dense_weight'] = weight_value
|
| 219 |
+
data['sparse_weight'] = 1.0 - weight_value
|
| 220 |
+
elif method == "rrf":
|
| 221 |
+
data['rrf_k'] = int(rrf_k_value)
|
| 222 |
+
elif method == "rrf_cross_encoder":
|
| 223 |
+
data['rrf_k'] = int(rrf_k_value)
|
| 224 |
+
else: # weighted_rrf
|
| 225 |
+
data['dense_weight'] = weight_value
|
| 226 |
+
data['sparse_weight'] = 1.0 - weight_value
|
| 227 |
+
data['rrf_k'] = int(rrf_k_value)
|
| 228 |
+
|
| 229 |
+
# Gửi Excel gốc để API merge kết quả mapping vào
|
| 230 |
+
if excel_base64:
|
| 231 |
+
data['excel_data_base64'] = excel_base64
|
| 232 |
+
data['product_column'] = 'Tên sản phẩm'
|
| 233 |
+
|
| 234 |
+
response = requests.post(API_BASE_URL + "mapping/", data=data, timeout=300)
|
| 235 |
+
response.raise_for_status()
|
| 236 |
+
json_resp = response.json()
|
| 237 |
+
|
| 238 |
+
# Ưu tiên merged Excel (giữ nguyên format gốc + thêm cột "đã chọn")
|
| 239 |
+
merged_b64 = json_resp.get("merged_excel_base64", "")
|
| 240 |
+
mapping_b64 = json_resp.get("excel_data_base64", "")
|
| 241 |
+
|
| 242 |
+
if merged_b64:
|
| 243 |
+
excel_bytes = base64.b64decode(merged_b64)
|
| 244 |
+
else:
|
| 245 |
+
excel_bytes = base64.b64decode(mapping_b64)
|
| 246 |
+
|
| 247 |
+
df = pd.read_excel(BytesIO(excel_bytes))
|
| 248 |
+
|
| 249 |
+
# Build top5 data keyed by row index for floating menu
|
| 250 |
+
top5_data = {}
|
| 251 |
+
results = json_resp.get("results", [])
|
| 252 |
+
|
| 253 |
+
# First build product_name → top5 mapping
|
| 254 |
+
product_to_top5 = {}
|
| 255 |
+
for r in results:
|
| 256 |
+
name = r.get("Tên sản phẩm gốc", "").strip()
|
| 257 |
+
top5 = [r.get(f"Top {i}", "") for i in range(1, 6)]
|
| 258 |
+
top5 = [t for t in top5 if t]
|
| 259 |
+
if name:
|
| 260 |
+
product_to_top5[name.lower()] = top5
|
| 261 |
+
|
| 262 |
+
# Then map to row indices in the DataFrame
|
| 263 |
+
product_col = None
|
| 264 |
+
for col in df.columns:
|
| 265 |
+
col_lower = col.lower().strip()
|
| 266 |
+
if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
|
| 267 |
+
product_col = col
|
| 268 |
+
break
|
| 269 |
+
|
| 270 |
+
if product_col:
|
| 271 |
+
for i in range(len(df)):
|
| 272 |
+
product_name = str(df.iloc[i].get(product_col, "")).strip()
|
| 273 |
+
if product_name.lower() in product_to_top5:
|
| 274 |
+
top5_data[i] = product_to_top5[product_name.lower()]
|
| 275 |
+
|
| 276 |
+
# Save Excel file for download
|
| 277 |
+
result_file_name = "mapping_result_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
|
| 278 |
+
excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
|
| 279 |
+
df.to_excel(excel_path, index=False)
|
| 280 |
+
|
| 281 |
+
api_duration = json_resp.get("api_duration", round(time.time() - start_time, 2))
|
| 282 |
+
total_products = json_resp.get("total_products", 0)
|
| 283 |
+
|
| 284 |
+
return (
|
| 285 |
+
gr.update(value=f"✅ Đã mapping {total_products} sản phẩm thành công!"),
|
| 286 |
+
api_duration,
|
| 287 |
+
df,
|
| 288 |
+
gr.File(value=excel_path, visible=True),
|
| 289 |
+
top5_data,
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
except requests.exceptions.Timeout:
|
| 293 |
+
return (
|
| 294 |
+
gr.update(value="⚠️ Request timeout - vui lòng thử lại sau!"),
|
| 295 |
+
round(time.time() - start_time, 2),
|
| 296 |
+
pd.DataFrame(),
|
| 297 |
+
gr.File(visible=False),
|
| 298 |
+
{},
|
| 299 |
+
)
|
| 300 |
+
except Exception as e:
|
| 301 |
+
return (
|
| 302 |
+
gr.update(value=f"❌ Lỗi: {str(e)}"),
|
| 303 |
+
round(time.time() - start_time, 2),
|
| 304 |
+
pd.DataFrame(),
|
| 305 |
+
gr.File(visible=False),
|
| 306 |
+
{},
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def search_products_api(keyword):
|
| 311 |
+
"""
|
| 312 |
+
Tìm kiếm sản phẩm như Ctrl+F - substring matching.
|
| 313 |
+
Gọi API /search-products/ với keyword.
|
| 314 |
+
"""
|
| 315 |
+
if not keyword or not keyword.strip():
|
| 316 |
+
return gr.update(choices=[], value=None), "⚠️ Vui lòng nhập từ khóa tìm kiếm"
|
| 317 |
+
|
| 318 |
+
try:
|
| 319 |
+
data = {
|
| 320 |
+
'keyword': keyword.strip(),
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
response = requests.post(API_BASE_URL + "search-products/", data=data, timeout=30)
|
| 324 |
+
response.raise_for_status()
|
| 325 |
+
json_resp = response.json()
|
| 326 |
+
|
| 327 |
+
if json_resp.get("status") == "success":
|
| 328 |
+
product_list = json_resp.get("product_list", [])
|
| 329 |
+
total = json_resp.get("total_results", len(product_list))
|
| 330 |
+
return (
|
| 331 |
+
gr.update(choices=product_list, value=None),
|
| 332 |
+
f"✅ Tìm thấy {total} sản phẩm chứa '{keyword.strip()}'"
|
| 333 |
+
)
|
| 334 |
+
else:
|
| 335 |
+
return gr.update(choices=[], value=None), "❌ Không tìm thấy kết quả"
|
| 336 |
+
|
| 337 |
+
except requests.exceptions.Timeout:
|
| 338 |
+
return gr.update(choices=[], value=None), "⚠️ Request timeout - vui lòng thử lại"
|
| 339 |
+
except Exception as e:
|
| 340 |
+
return gr.update(choices=[], value=None), f"❌ Lỗi: {str(e)}"
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def save_edited_excel(df_editable):
|
| 344 |
+
"""
|
| 345 |
+
Lưu DataFrame đã chỉnh sửa thành file Excel.
|
| 346 |
+
"""
|
| 347 |
+
if df_editable is None or df_editable.empty:
|
| 348 |
+
return gr.File(visible=False), "⚠️ Không có dữ liệu để lưu!"
|
| 349 |
+
|
| 350 |
+
try:
|
| 351 |
+
# Tìm cột sản phẩm để lọc
|
| 352 |
+
target_col = None
|
| 353 |
+
for col in df_editable.columns:
|
| 354 |
+
col_lower = col.lower().strip()
|
| 355 |
+
if col_lower in ('tên sản phẩm', 'ten san pham'):
|
| 356 |
+
target_col = col
|
| 357 |
+
break
|
| 358 |
+
|
| 359 |
+
# Lọc bỏ dòng "Không phải sản phẩm Rạng Đông"
|
| 360 |
+
df_filtered = df_editable.copy()
|
| 361 |
+
removed_count = 0
|
| 362 |
+
if target_col:
|
| 363 |
+
mask = df_editable[target_col] == not_rd_label
|
| 364 |
+
removed_count = int(mask.sum())
|
| 365 |
+
df_filtered = df_editable[~mask].copy()
|
| 366 |
+
else:
|
| 367 |
+
df_filtered = df_editable.copy()
|
| 368 |
+
removed_count = 0
|
| 369 |
+
|
| 370 |
+
result_file_name = "mapping_edited_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
|
| 371 |
+
excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
|
| 372 |
+
df_filtered.to_excel(excel_path, index=False)
|
| 373 |
+
|
| 374 |
+
if removed_count > 0:
|
| 375 |
+
return gr.File(value=excel_path, visible=True), f"✅ Đã lưu file Excel! (Đã xóa {removed_count} dòng 'Không phải sản phẩm Rạng Đông')"
|
| 376 |
+
return gr.File(value=excel_path, visible=True), "✅ Đã lưu file Excel!"
|
| 377 |
+
except Exception as e:
|
| 378 |
+
return gr.File(visible=False), f"❌ Lỗi lưu file: {str(e)}"
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def on_cell_select(top5_data, df, evt: gr.SelectData):
|
| 382 |
+
"""Khi user click vào ô trong bảng mapping → hiện floating menu nếu là cột sản phẩm"""
|
| 383 |
+
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
|
| 384 |
+
return gr.update(visible=False), "", gr.update(choices=[], value=None), None, ""
|
| 385 |
+
|
| 386 |
+
row_idx = evt.index[0]
|
| 387 |
+
col_idx = evt.index[1]
|
| 388 |
+
|
| 389 |
+
# Tìm cột sản phẩm
|
| 390 |
+
product_col_idx = None
|
| 391 |
+
product_col_name = None
|
| 392 |
+
for i, col in enumerate(df.columns):
|
| 393 |
+
col_lower = col.lower().strip()
|
| 394 |
+
if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
|
| 395 |
+
product_col_idx = i
|
| 396 |
+
product_col_name = col
|
| 397 |
+
break
|
| 398 |
+
|
| 399 |
+
# Chỉ hiện floating menu khi click vào cột sản phẩm
|
| 400 |
+
if product_col_idx is None or col_idx != product_col_idx:
|
| 401 |
+
return gr.update(visible=False), "", gr.update(choices=[], value=None), None, ""
|
| 402 |
+
current_value = str(df.iloc[row_idx][product_col_name])
|
| 403 |
+
|
| 404 |
+
# Gradio Serialize JSON nên array index (int) có thể bị parse thành kiểu string trong dictionary
|
| 405 |
+
top5 = []
|
| 406 |
+
if isinstance(top5_data, dict):
|
| 407 |
+
top5 = top5_data.get(row_idx, top5_data.get(str(row_idx), []))
|
| 408 |
+
|
| 409 |
+
if not top5:
|
| 410 |
+
return (
|
| 411 |
+
gr.update(visible=True),
|
| 412 |
+
f"📝 Dòng {row_idx+1} - SP hiện tại: {current_value}",
|
| 413 |
+
gr.update(choices=["Không có đề xuất", not_rd_label], value=None),
|
| 414 |
+
row_idx,
|
| 415 |
+
current_value
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
# Thêm option "Không phải sản phẩm Rạng Đông" vào cuối danh sách
|
| 419 |
+
choices_with_not_rd = top5 + [not_rd_label]
|
| 420 |
+
return (
|
| 421 |
+
gr.update(visible=True),
|
| 422 |
+
f"📝 Dòng {row_idx+1} - SP hiện tại: {current_value}",
|
| 423 |
+
gr.update(choices=choices_with_not_rd, value=None),
|
| 424 |
+
row_idx,
|
| 425 |
+
current_value
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def apply_product(df, selected_row_idx, selected_product, undo_history):
|
| 430 |
+
"""Áp dụng sản phẩm đã chọn vào cột sản phẩm tại dòng đã click"""
|
| 431 |
+
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
|
| 432 |
+
return df, undo_history, "⚠️ Không có dữ liệu"
|
| 433 |
+
|
| 434 |
+
if selected_row_idx is None or selected_product is None:
|
| 435 |
+
return df, undo_history, "⚠️ Vui lòng click vào ô sản phẩm và chọn đề xuất"
|
| 436 |
+
|
| 437 |
+
if selected_product in ["Không có đề xuất"]:
|
| 438 |
+
return df, undo_history, "⚠️ Sản phẩm không hợp lệ"
|
| 439 |
+
|
| 440 |
+
row_idx = selected_row_idx
|
| 441 |
+
if row_idx < 0 or row_idx >= len(df):
|
| 442 |
+
return df, undo_history, "❌ Dòng không hợp lệ"
|
| 443 |
+
|
| 444 |
+
# Tìm cột sản phẩm
|
| 445 |
+
target_col = None
|
| 446 |
+
for col in df.columns:
|
| 447 |
+
col_lower = col.lower().strip()
|
| 448 |
+
if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
|
| 449 |
+
target_col = col
|
| 450 |
+
break
|
| 451 |
+
|
| 452 |
+
if target_col is None:
|
| 453 |
+
return df, undo_history, "❌ Không tìm thấy cột sản phẩm"
|
| 454 |
+
|
| 455 |
+
# Lưu undo history
|
| 456 |
+
old_value = str(df.iloc[row_idx][target_col])
|
| 457 |
+
if undo_history is None:
|
| 458 |
+
undo_history = []
|
| 459 |
+
undo_history = list(undo_history) # Copy to avoid mutation
|
| 460 |
+
undo_history.append({
|
| 461 |
+
'row': row_idx,
|
| 462 |
+
'col': target_col,
|
| 463 |
+
'old_value': old_value
|
| 464 |
+
})
|
| 465 |
+
|
| 466 |
+
# Áp dụng giá trị mới
|
| 467 |
+
df_updated = df.copy()
|
| 468 |
+
df_updated.at[row_idx, target_col] = selected_product
|
| 469 |
+
|
| 470 |
+
return df_updated, undo_history, f"✅ Dòng {row_idx+1}: '{old_value}' → '{selected_product}'"
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def undo_change(df, undo_history):
|
| 474 |
+
"""Hoàn tác thay đổi cuối cùng"""
|
| 475 |
+
if df is None or (isinstance(df, pd.DataFrame) and df.empty):
|
| 476 |
+
return df, undo_history, "⚠️ Không có dữ liệu"
|
| 477 |
+
|
| 478 |
+
if not undo_history or len(undo_history) == 0:
|
| 479 |
+
return df, undo_history, "⚠️ Không có thay đổi nào để hoàn tác"
|
| 480 |
+
|
| 481 |
+
undo_history = list(undo_history) # Copy
|
| 482 |
+
last_change = undo_history.pop()
|
| 483 |
+
row = last_change['row']
|
| 484 |
+
col = last_change['col']
|
| 485 |
+
old_value = last_change['old_value']
|
| 486 |
+
|
| 487 |
+
df_updated = df.copy()
|
| 488 |
+
current_value = str(df_updated.iloc[row][col])
|
| 489 |
+
df_updated.at[row, col] = old_value
|
| 490 |
+
|
| 491 |
+
remaining = len(undo_history)
|
| 492 |
+
return df_updated, undo_history, f"↩ Dòng {row+1}: '{current_value}' → '{old_value}' (còn {remaining} thay đổi có thể hoàn tác)"
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
# ====================== GRADIO UI ======================
|
| 496 |
+
|
| 497 |
+
with gr.Blocks(title="NHẬP ĐƠN HÀNG ĐA PHƯƠNG THỨC") as demo:
|
| 498 |
+
|
| 499 |
+
# State variables
|
| 500 |
+
excel_base64_state = gr.State("") # Lưu excel_data_base64 từ /information_extraction/
|
| 501 |
+
top5_state = gr.State({}) # Lưu top 5 đề xuất cho mỗi sản phẩm (key: row index)
|
| 502 |
+
undo_history_state = gr.State([]) # Lưu lịch sử thay đổi để undo
|
| 503 |
+
selected_row_state = gr.State(None) # Lưu row index đang được chọn từ DataFrame click
|
| 504 |
+
|
| 505 |
+
# ====================== HEADER ======================
|
| 506 |
+
with gr.Row():
|
| 507 |
+
with gr.Column(scale=0, min_width=120):
|
| 508 |
+
gr.Image(
|
| 509 |
+
value="logo_multimodal_invoice.jpg",
|
| 510 |
+
show_label=False,
|
| 511 |
+
elem_id="logo",
|
| 512 |
+
height=100,
|
| 513 |
+
width=100,
|
| 514 |
+
container=False,
|
| 515 |
+
)
|
| 516 |
+
with gr.Column():
|
| 517 |
+
gr.Markdown(
|
| 518 |
+
"""
|
| 519 |
+
<div style='text-align: center;'>
|
| 520 |
+
<h1 style='margin-bottom: 0.5em; font-size: 1.8em;'>📄 NHẬP ĐƠN HÀNG ĐA PHƯƠNG THỨC</h1>
|
| 521 |
+
</div>
|
| 522 |
+
""",
|
| 523 |
+
elem_id="main-title"
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
gr.Markdown("---")
|
| 527 |
+
|
| 528 |
+
# ====================== SECTION 1: MÃ NHÂN VIÊN - LLM MODEL ======================
|
| 529 |
+
with gr.Row(equal_height=True):
|
| 530 |
+
employee_code_input = gr.Textbox(
|
| 531 |
+
label="Mã nhân viên",
|
| 532 |
+
placeholder="Nhập mã nhân viên",
|
| 533 |
+
scale=1
|
| 534 |
+
)
|
| 535 |
+
llm_model_input = gr.Dropdown(
|
| 536 |
+
["Gemini 2.5 Flash", "Gemini 2.5 Flash-Lite"],
|
| 537 |
+
value="Gemini 2.5 Flash",
|
| 538 |
+
label="Mô hình đa phương thức",
|
| 539 |
+
scale=1,
|
| 540 |
+
interactive=True
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
# ====================== SECTION 2: OCR + TRẠNG THÁI + CẤU HÌNH ======================
|
| 544 |
+
with gr.Row(equal_height=True):
|
| 545 |
+
# Cột 1: Upload + Xử lý
|
| 546 |
+
with gr.Column(scale=1):
|
| 547 |
+
uploaded_files = gr.File(
|
| 548 |
+
label="Tải lên file ZIP",
|
| 549 |
+
file_count="single",
|
| 550 |
+
file_types=[".zip"],
|
| 551 |
+
type="filepath"
|
| 552 |
+
)
|
| 553 |
+
process_btn = gr.Button("⚙️ Xử lý đơn hàng", variant="secondary")
|
| 554 |
+
progress = gr.Textbox(label="Trạng thái xử lý", interactive=False)
|
| 555 |
+
|
| 556 |
+
# Cột 2: Thông tin tiến trình
|
| 557 |
+
with gr.Column(scale=1):
|
| 558 |
+
current_file_text = gr.Textbox(label="Đang xử lý file", interactive=False)
|
| 559 |
+
file_time_output = gr.Textbox(label="Thời gian xử lý file", interactive=False, lines=4)
|
| 560 |
+
total_time_output = gr.Number(label="Tổng thời gian xử lý (giây)", interactive=False)
|
| 561 |
+
|
| 562 |
+
# Cột 3: Cấu hình tìm kiếm + Mapping
|
| 563 |
+
with gr.Column(scale=2):
|
| 564 |
+
gr.Markdown("#### ⚙️ Cấu hình tìm kiếm")
|
| 565 |
+
search_method = gr.Radio(
|
| 566 |
+
choices=["weighted", "rrf", "weighted_rrf", "rrf_cross_encoder"],
|
| 567 |
+
value="weighted",
|
| 568 |
+
label="Phương pháp Fusion",
|
| 569 |
+
info="Weighted: trọng số điểm | RRF: xếp hạng đảo | Weighted RRF: kết hợp | RRF+CE: rerank"
|
| 570 |
+
)
|
| 571 |
+
with gr.Group(visible=True) as weighted_config:
|
| 572 |
+
weight_slider = gr.Slider(
|
| 573 |
+
minimum=0, maximum=1, value=0.7, step=0.1,
|
| 574 |
+
label="Dense ↔ Sparse",
|
| 575 |
+
info="Kéo trái → ưu tiên Sparse (từ khóa) | Kéo phải → ưu tiên Dense (ngữ nghĩa)"
|
| 576 |
+
)
|
| 577 |
+
weight_display = gr.Markdown(value="**Dense: 0.7** | **Sparse: 0.3**")
|
| 578 |
+
with gr.Group(visible=False) as rrf_config:
|
| 579 |
+
rrf_k_slider = gr.Slider(
|
| 580 |
+
minimum=30, maximum=100, value=60, step=30,
|
| 581 |
+
label="RRF K (hằng số làm mượt)",
|
| 582 |
+
info="30: nhạy | 60: cân bằng | 100: ổn định"
|
| 583 |
+
)
|
| 584 |
+
use_prediction_checkbox = gr.Checkbox(
|
| 585 |
+
label="🤖 Tự động dự đoán danh mục (LLM Prediction)",
|
| 586 |
+
value=False,
|
| 587 |
+
info="Khi bật: tự động predict L1/L2/L3 cho mỗi sản phẩm qua LLM → filter search"
|
| 588 |
+
)
|
| 589 |
+
with gr.Row():
|
| 590 |
+
mapping_status = gr.Textbox(label="Trạng thái mapping", interactive=False, scale=2)
|
| 591 |
+
mapping_time = gr.Number(label="Thời gian (giây)", interactive=False, scale=1)
|
| 592 |
+
mapping_btn = gr.Button("🚀 BẮT ĐẦU MAPPING", variant="primary", size="lg")
|
| 593 |
+
|
| 594 |
+
# Hidden state: danh sách sản phẩm từ OCR
|
| 595 |
+
product_list = gr.Textbox(visible=True)
|
| 596 |
+
|
| 597 |
+
excel_download = gr.File(
|
| 598 |
+
label="Tải file Excel kết quả xử lý",
|
| 599 |
+
interactive=False,
|
| 600 |
+
visible=False
|
| 601 |
+
)
|
| 602 |
+
|
| 603 |
+
ocr_result = gr.Dataframe(
|
| 604 |
+
label="Kết quả nhận diện (OCR)",
|
| 605 |
+
wrap=True,
|
| 606 |
+
interactive=False
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
+
gr.Markdown("---")
|
| 610 |
+
|
| 611 |
+
# ====================== SECTION 3: KẾT QUẢ MAPPING ======================
|
| 612 |
+
gr.Markdown("### 📊 Kết quả Mapping")
|
| 613 |
+
gr.Markdown("*Click vào ô **Tên sản phẩm** để xem Top 5 đề xuất → Chọn sản phẩm → Áp dụng*")
|
| 614 |
+
|
| 615 |
+
mapping_result = gr.Dataframe(
|
| 616 |
+
label="Kết quả Mapping (click vào ô sản phẩm để thay thế)",
|
| 617 |
+
wrap=True,
|
| 618 |
+
interactive=True
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
# ====================== FLOATING MENU - TOP 5 ĐỀ XUẤT ======================
|
| 622 |
+
with gr.Group(visible=False) as floating_panel:
|
| 623 |
+
gr.Markdown("#### 🔄 Chọn sản phẩm thay thế")
|
| 624 |
+
floating_product_display = gr.Textbox(
|
| 625 |
+
label="Sản phẩm đang chọn",
|
| 626 |
+
interactive=False
|
| 627 |
+
)
|
| 628 |
+
floating_top5_radio = gr.Radio(
|
| 629 |
+
label="Top 5 sản phẩm đề xuất (chọn 1 để thay thế)",
|
| 630 |
+
choices=[],
|
| 631 |
+
interactive=True
|
| 632 |
+
)
|
| 633 |
+
with gr.Row():
|
| 634 |
+
floating_apply_btn = gr.Button("✅ Áp dụng", variant="primary", scale=1)
|
| 635 |
+
floating_undo_btn = gr.Button("↩ Hoàn tác", variant="secondary", scale=1)
|
| 636 |
+
floating_status = gr.Textbox(label="Trạng thái", interactive=False, scale=3)
|
| 637 |
+
|
| 638 |
+
gr.Markdown("---")
|
| 639 |
+
|
| 640 |
+
# ====================== TÌM KIẾM MỞ RỘNG ======================
|
| 641 |
+
with gr.Accordion("🔍 Tìm kiếm mở rộng (nếu Top 5 không phù hợp)", open=False):
|
| 642 |
+
gr.Markdown("*Tìm kiếm như Ctrl+F trong danh sách sản phẩm → chọn sản phẩm → áp dụng vào dòng đã click ở bảng trên*")
|
| 643 |
+
|
| 644 |
+
with gr.Row():
|
| 645 |
+
search_keyword = gr.Textbox(
|
| 646 |
+
label="Từ khóa tìm kiếm (tự động cập nhật khi click vào ô sản phẩm)",
|
| 647 |
+
placeholder="Click vào ô sản phẩm ở bảng trên hoặc nhập từ khóa...",
|
| 648 |
+
interactive=True,
|
| 649 |
+
scale=2
|
| 650 |
+
)
|
| 651 |
+
search_btn = gr.Button("🔍 Tìm kiếm", variant="secondary", scale=1)
|
| 652 |
+
|
| 653 |
+
search_status = gr.Textbox(label="Trạng thái tìm kiếm", interactive=False)
|
| 654 |
+
|
| 655 |
+
search_results = gr.Dropdown(
|
| 656 |
+
label="Kết quả tìm kiếm (chọn sản phẩm)",
|
| 657 |
+
choices=[],
|
| 658 |
+
interactive=True,
|
| 659 |
+
allow_custom_value=True
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
apply_search_btn = gr.Button("✅ Áp dụng sản phẩm từ tìm kiếm", variant="primary")
|
| 663 |
+
|
| 664 |
+
gr.Markdown("---")
|
| 665 |
+
|
| 666 |
+
# ====================== TẢI FILE EXCEL KẾT QUẢ ======================
|
| 667 |
+
with gr.Row():
|
| 668 |
+
save_btn = gr.Button("💾 Lưu Excel đã chỉnh sửa", variant="primary")
|
| 669 |
+
save_status = gr.Textbox(label="Trạng thái lưu", interactive=False, scale=2)
|
| 670 |
+
|
| 671 |
+
mapping_download = gr.File(
|
| 672 |
+
label="Tải file Excel kết quả",
|
| 673 |
+
interactive=False,
|
| 674 |
+
visible=False
|
| 675 |
+
)
|
| 676 |
+
|
| 677 |
+
# ====================== EVENT HANDLERS ======================
|
| 678 |
+
|
| 679 |
+
# Xử lý file ZIP → hiện kết quả OCR → cập nhật product_list
|
| 680 |
+
process_btn.click(
|
| 681 |
+
fn=process_zip_with_api,
|
| 682 |
+
inputs=[employee_code_input, uploaded_files, llm_model_input],
|
| 683 |
+
outputs=[progress, file_time_output, total_time_output, ocr_result, excel_download, excel_base64_state]
|
| 684 |
+
).then(
|
| 685 |
+
fn=lambda excel_base64: get_products_from_excel_api(excel_base64, "Tên sản phẩm"),
|
| 686 |
+
inputs=[excel_base64_state],
|
| 687 |
+
outputs=[product_list, current_file_text]
|
| 688 |
+
)
|
| 689 |
+
|
| 690 |
+
# Mapping sản phẩm
|
| 691 |
+
mapping_btn.click(
|
| 692 |
+
fn=call_mapping_api,
|
| 693 |
+
inputs=[product_list, search_method, weight_slider, rrf_k_slider, excel_base64_state, use_prediction_checkbox],
|
| 694 |
+
outputs=[mapping_status, mapping_time, mapping_result, mapping_download, top5_state]
|
| 695 |
+
)
|
| 696 |
+
|
| 697 |
+
# Click vào ô sản phẩm → hiện floating menu + cập nhật ô tìm kiếm
|
| 698 |
+
mapping_result.select(
|
| 699 |
+
fn=on_cell_select,
|
| 700 |
+
inputs=[top5_state, mapping_result],
|
| 701 |
+
outputs=[floating_panel, floating_product_display, floating_top5_radio, selected_row_state, search_keyword]
|
| 702 |
+
)
|
| 703 |
+
|
| 704 |
+
# Áp dụng sản phẩm đã chọn từ Top 5
|
| 705 |
+
floating_apply_btn.click(
|
| 706 |
+
fn=apply_product,
|
| 707 |
+
inputs=[mapping_result, selected_row_state, floating_top5_radio, undo_history_state],
|
| 708 |
+
outputs=[mapping_result, undo_history_state, floating_status]
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
# Hoàn tác thay đổi cuối cùng
|
| 712 |
+
floating_undo_btn.click(
|
| 713 |
+
fn=undo_change,
|
| 714 |
+
inputs=[mapping_result, undo_history_state],
|
| 715 |
+
outputs=[mapping_result, undo_history_state, floating_status]
|
| 716 |
+
)
|
| 717 |
+
|
| 718 |
+
# Tìm kiếm sản phẩm mở rộng
|
| 719 |
+
search_btn.click(
|
| 720 |
+
fn=search_products_api,
|
| 721 |
+
inputs=[search_keyword],
|
| 722 |
+
outputs=[search_results, search_status]
|
| 723 |
+
)
|
| 724 |
+
|
| 725 |
+
# Áp dụng sản phẩm từ tìm kiếm mở rộng
|
| 726 |
+
apply_search_btn.click(
|
| 727 |
+
fn=apply_product,
|
| 728 |
+
inputs=[mapping_result, selected_row_state, search_results, undo_history_state],
|
| 729 |
+
outputs=[mapping_result, undo_history_state, floating_status]
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
# Dynamic UI: Show/hide config based on method selection
|
| 733 |
+
def update_method_visibility(method):
|
| 734 |
+
if method == "weighted":
|
| 735 |
+
return gr.update(visible=True), gr.update(visible=False)
|
| 736 |
+
elif method == "rrf":
|
| 737 |
+
return gr.update(visible=False), gr.update(visible=True)
|
| 738 |
+
elif method == "rrf_cross_encoder":
|
| 739 |
+
return gr.update(visible=False), gr.update(visible=True)
|
| 740 |
+
else: # weighted_rrf: hiện cả hai
|
| 741 |
+
return gr.update(visible=True), gr.update(visible=True)
|
| 742 |
+
|
| 743 |
+
search_method.change(
|
| 744 |
+
fn=update_method_visibility,
|
| 745 |
+
inputs=[search_method],
|
| 746 |
+
outputs=[weighted_config, rrf_config]
|
| 747 |
+
)
|
| 748 |
+
|
| 749 |
+
# Update weight display when slider changes
|
| 750 |
+
def update_weight_display(value):
|
| 751 |
+
dense = round(value, 1)
|
| 752 |
+
sparse = round(1.0 - value, 1)
|
| 753 |
+
return f"**Dense: {dense}** | **Sparse: {sparse}**"
|
| 754 |
+
|
| 755 |
+
weight_slider.change(
|
| 756 |
+
fn=update_weight_display,
|
| 757 |
+
inputs=[weight_slider],
|
| 758 |
+
outputs=[weight_display]
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
# Lưu Excel đã chỉnh sửa
|
| 762 |
+
save_btn.click(
|
| 763 |
+
fn=save_edited_excel,
|
| 764 |
+
inputs=[mapping_result],
|
| 765 |
+
outputs=[mapping_download, save_status]
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
if __name__ == "__main__":
|
| 770 |
demo.launch(inbrowser=True, share=False)
|