Phong1 commited on
Commit
fca2d83
·
verified ·
1 Parent(s): e476e84

Upload ui_ver_3.py

Browse files
Files changed (1) hide show
  1. ui_ver_3.py +770 -0
ui_ver_3.py ADDED
@@ -0,0 +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
+ # API_BASE_URL = "https://gradio-ocr-audio-demo-i7u7.onrender.com/"
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)