Upload 3 files
Browse files- README.md +19 -12
- app.py +377 -0
- requirements.txt +4 -0
README.md
CHANGED
|
@@ -1,12 +1,19 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# Ward Ranking Cleaner & Random Assigner (Gradio)
|
| 3 |
+
|
| 4 |
+
## วิธีใช้งานใน Hugging Face Spaces
|
| 5 |
+
1. สร้าง Space ใหม่ เลือก SDK = **Gradio**
|
| 6 |
+
2. อัปโหลดไฟล์เหล่านี้:
|
| 7 |
+
- `app.py`
|
| 8 |
+
- `requirements.txt`
|
| 9 |
+
- (ถ้ามีข้อมูลประกอบอื่น ๆ ให้ใส่เพิ่มได้)
|
| 10 |
+
3. กด Commit → ระบบจะ build และให้ลิงก์ถาวร
|
| 11 |
+
|
| 12 |
+
## ขั้นตอนใช้งาน
|
| 13 |
+
1) อัปโหลดไฟล์ .csv หรือ .xlsx ที่มีข้อมูลนักศึกษา/ผู้เรียน
|
| 14 |
+
2) เลือกวอร์ดที่จะใช้ แล้วกำหนด Capacity
|
| 15 |
+
3) ใส่ชื่อคอลัมน์จริงในไฟล์ของคุณสำหรับ NAME, ID และคอลัมน์วอร์ดแต่ละอัน (ใส่ชื่อจริง หรือใช้ regex/คำบางส่วนร่วมกับโหมดยืดหยุ่น)
|
| 16 |
+
4) กด **Clean data** เพื่อดูพรีวิว (ระบบจะ keep เฉพาะ NAME, ID, และคอลัมน์วอร์ดที่เลือก พร้อมแปลงอันดับให้เป็นตัวเลข)
|
| 17 |
+
5) กด **Assign** เพื่อสุ่มจัดสรรทีละอันดับ 1,2,3,... ตาม Capacity
|
| 18 |
+
|
| 19 |
+
> การดึง "อันดับ" จะใช้ตัวเลขที่พบในสตริง เช่น `1st`, `อันดับ 3`, `4th` เป็นต้น
|
app.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import gradio as gr
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
import re
|
| 6 |
+
from io import BytesIO
|
| 7 |
+
from typing import List, Dict, Tuple, Optional
|
| 8 |
+
|
| 9 |
+
APP_TITLE = "Ward Ranking Cleaner & Random Assigner (Flexible Columns)"
|
| 10 |
+
DESCRIPTION = """
|
| 11 |
+
1) เลือก **วอร์ด** ที่จะใช้ (จากรายการ 8 วอร์ดด้านล่าง) และใส่ **capacity** แต่ละวอร์ด
|
| 12 |
+
2) ระบุ **หัวคอลัมน์ในไฟล์** ของคุณสำหรับ: NAME, ID และคอลัมน์คะแนน/อันดับของแต่ละวอร์ด (ชื่อคอลัมน์จริงในไฟล์)
|
| 13 |
+
3) อัปโหลดไฟล์ .csv หรือ .xlsx แล้วกด **Clean data** เพื่อดูตารางที่เหลือเฉพาะ NAME, ID และคอลัมน์วอร์ดที่เลือก (คอลัมน์อื่นจะถูก drop)
|
| 14 |
+
4) กด **Assign (สุ่มตามลำดับอันดับ)** เพื่อสุ่มจัดสรรทีละอันดับ 1 → 2 → 3 ... ตาม capacity ของแต่ละวอร์ด
|
| 15 |
+
5) ดาวน์โหลด CSV ผลลัพธ์ได้
|
| 16 |
+
|
| 17 |
+
- การอ่าน "อันดับ" จะดึง **ตัวเลข** จากสตริง (เช่น `1st`, `อันดับ 3`, `4th`) — ถ้าหาเลขไม่เจอจะถือว่าเป็นค่าว่าง
|
| 18 |
+
- ถ้าคุณมีคอลัมน์ชื่อไม่แน่นอน สามารถใส่ชื่อที่แน่ใจลงไป หรือใช้ชื่อบางส่วนแล้วยกให้ **โหมดจับคู่ยืดหยุ่น** (regex) ช่วยค้นหา
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
WARD_CHOICES = [
|
| 22 |
+
("Medical", "อายุรศาสตร์ ภาคปกติ"),
|
| 23 |
+
("Medical_1", "อายุรศาสตร์_1"),
|
| 24 |
+
("Medical_2", "อายุรศาสตร์_2"),
|
| 25 |
+
("Surgical", "ศัลยศาสตร์"),
|
| 26 |
+
("Pediatric", "เด็ก"),
|
| 27 |
+
("Community", "ชุมชน"),
|
| 28 |
+
("Psychiatric", "จิตเวช"),
|
| 29 |
+
("Obstetrics", "สูติศาสตร์"),
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
def read_table(file) -> Tuple[Optional[pd.DataFrame], str]:
|
| 33 |
+
if file is None:
|
| 34 |
+
return None, "กรุณาอัปโหลดไฟล์ก่อน (.csv หรือ .xlsx)"
|
| 35 |
+
name = file.name.lower() if hasattr(file, "name") else ""
|
| 36 |
+
try:
|
| 37 |
+
if name.endswith(".csv"):
|
| 38 |
+
df = pd.read_csv(file.name if hasattr(file, "name") else file)
|
| 39 |
+
elif name.endswith(".xlsx"):
|
| 40 |
+
df = pd.read_excel(file.name if hasattr(file, "name") else file)
|
| 41 |
+
else:
|
| 42 |
+
# ลองเดาว่าเป็น csv
|
| 43 |
+
try:
|
| 44 |
+
df = pd.read_csv(file)
|
| 45 |
+
except Exception:
|
| 46 |
+
return None, "รองรับเฉพาะ .csv หรือ .xlsx เท่านั้น"
|
| 47 |
+
except Exception as e:
|
| 48 |
+
return None, f"อ่านไฟล์ไม่สำเร็จ: {e}"
|
| 49 |
+
# ปรับชื่อคอลัมน์ (trim)
|
| 50 |
+
df.columns = [str(c).strip() for c in df.columns]
|
| 51 |
+
return df, ""
|
| 52 |
+
|
| 53 |
+
def find_column(df: pd.DataFrame, key: str, flexible: bool) -> Optional[str]:
|
| 54 |
+
"""
|
| 55 |
+
ค้นหาคอลัมน์ตามชื่อที่ผู้ใช้กรอก:
|
| 56 |
+
- ถ้า flexible=False → ค้นหาแบบตรงตัว (case-sensitive แบบเดิม แต่เราทำ trim แล้ว)
|
| 57 |
+
- ถ้า flexible=True → จับคู่แบบยืดหยุ่น: ถ้า key มีอักขระพิเศษ ถือเป็น regex; ถ้าไม่ ก็มองเป็นสตริงย่อยที่ต้องพบในชื่อคอลัมน์
|
| 58 |
+
คืนชื่อคอลัมน์จริงถ้าพบ (ตัวแรกที่พบ), ไม่งั้นคืน None
|
| 59 |
+
"""
|
| 60 |
+
cols = list(df.columns)
|
| 61 |
+
if not flexible:
|
| 62 |
+
return key if key in cols else None
|
| 63 |
+
# โหมดยืดหยุ่น
|
| 64 |
+
# ถ้า key เป็นสตริงธรรมดา ให้ค้นหาแบบ "มี key เป็นส่วนหนึ่งของชื่อคอลัมน์" (case-insensitive)
|
| 65 |
+
try:
|
| 66 |
+
pattern = re.compile(key, flags=re.IGNORECASE)
|
| 67 |
+
for c in cols:
|
| 68 |
+
if re.search(pattern, c):
|
| 69 |
+
return c
|
| 70 |
+
except re.error:
|
| 71 |
+
# ถ้า regex ไม่ valid ให้ fallback เป็น contains (case-insensitive)
|
| 72 |
+
low = key.lower()
|
| 73 |
+
for c in cols:
|
| 74 |
+
if low in c.lower():
|
| 75 |
+
return c
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
def parse_rank(value) -> Optional[int]:
|
| 79 |
+
"""
|
| 80 |
+
รับค่าจากคอลั��น์อันดับ เช่น '1st', 'อันดับ 3', '2', 'third' (จะไม่รองรับคำภาษาอังกฤษเต็ม)
|
| 81 |
+
คืนเป็น int ถ้าพบเลข, ถ้าไม่พบคืน None
|
| 82 |
+
"""
|
| 83 |
+
if pd.isna(value):
|
| 84 |
+
return None
|
| 85 |
+
s = str(value)
|
| 86 |
+
m = re.search(r'(\d+)', s)
|
| 87 |
+
if m:
|
| 88 |
+
try:
|
| 89 |
+
return int(m.group(1))
|
| 90 |
+
except ValueError:
|
| 91 |
+
return None
|
| 92 |
+
return None
|
| 93 |
+
|
| 94 |
+
def build_cleaned(df: pd.DataFrame,
|
| 95 |
+
name_key: str,
|
| 96 |
+
id_key: str,
|
| 97 |
+
ward_to_key: Dict[str, str],
|
| 98 |
+
flexible_match: bool) -> Tuple[pd.DataFrame, List[str]]:
|
| 99 |
+
"""
|
| 100 |
+
สร้างตาราง cleaned: เก็บเฉพาะ NAME, ID, และคอลัมน์วอร์ดที่เลือก
|
| 101 |
+
แปลงค่าคอลัมน์วอร์ดเป็น int (ตัวเลขอันดับ) ถ้าทำไม่ได้จะเป็น NaN
|
| 102 |
+
"""
|
| 103 |
+
messages = []
|
| 104 |
+
# หา NAME / ID
|
| 105 |
+
name_col = find_column(df, name_key.strip(), flexible_match)
|
| 106 |
+
id_col = find_column(df, id_key.strip(), flexible_match)
|
| 107 |
+
if name_col is None or id_col is None:
|
| 108 |
+
missing = []
|
| 109 |
+
if name_col is None: missing.append("NAME")
|
| 110 |
+
if id_col is None: missing.append("ID")
|
| 111 |
+
raise ValueError(f"หาไม่พบคอลัมน์บังคับ: {', '.join(missing)}")
|
| 112 |
+
|
| 113 |
+
keep_cols = [name_col, id_col]
|
| 114 |
+
renamed = {name_col: "NAME", id_col: "ID"}
|
| 115 |
+
# หาและแปลงคอลัมน์วอร์ด
|
| 116 |
+
for ward, key in ward_to_key.items():
|
| 117 |
+
key = key.strip()
|
| 118 |
+
if not key:
|
| 119 |
+
continue
|
| 120 |
+
col = find_column(df, key, flexible_match)
|
| 121 |
+
if col is None:
|
| 122 |
+
messages.append(f"⚠️ ไม่พบคอลัมน์ของวอร์ด '{ward}' จากคีย์ '{key}' (ข้ามวอร์ดนี้)")
|
| 123 |
+
continue
|
| 124 |
+
keep_cols.append(col)
|
| 125 |
+
renamed[col] = ward # เปลี่ยนชื่อคอลัมน์เป็นชื่อวอร์ดมาตรฐาน
|
| 126 |
+
|
| 127 |
+
# unique และรักษาลำดับ
|
| 128 |
+
seen = set()
|
| 129 |
+
keep_unique = []
|
| 130 |
+
for c in keep_cols:
|
| 131 |
+
if c not in seen:
|
| 132 |
+
seen.add(c)
|
| 133 |
+
keep_unique.append(c)
|
| 134 |
+
|
| 135 |
+
cleaned = df[keep_unique].rename(columns=renamed).copy()
|
| 136 |
+
|
| 137 |
+
# แปลงอันดับเป็น int
|
| 138 |
+
ward_cols = [c for c in cleaned.columns if c not in ("NAME", "ID")]
|
| 139 |
+
for c in ward_cols:
|
| 140 |
+
cleaned[c] = cleaned[c].apply(parse_rank).astype("Int64")
|
| 141 |
+
|
| 142 |
+
# จัดเรียงคอลัมน์
|
| 143 |
+
cleaned = cleaned[["NAME", "ID"] + ward_cols]
|
| 144 |
+
|
| 145 |
+
return cleaned, messages
|
| 146 |
+
|
| 147 |
+
def random_assign(cleaned: pd.DataFrame,
|
| 148 |
+
capacities: Dict[str, int],
|
| 149 |
+
seed: Optional[int] = None) -> Tuple[pd.DataFrame, pd.DataFrame, Dict[str, int]]:
|
| 150 |
+
"""
|
| 151 |
+
สุ่มจัดสรรแบบรอบเลือกอันดับ: เริ่มจากอันดับ 1 → 2 → 3 → ...
|
| 152 |
+
- ในแต่ละอันดับและแต่ละวอร์ด: ถ้าเกิน capacity ที่เหลือ ให้สุ่มเลือก
|
| 153 |
+
- คืนผลลัพธ์: assignments, not_assigned, leftover_capacities
|
| 154 |
+
"""
|
| 155 |
+
rng = np.random.default_rng(seed)
|
| 156 |
+
wards = [w for w in cleaned.columns if w not in ("NAME", "ID")]
|
| 157 |
+
# กำหนด capacity ที่ใช้จริง เฉพาะวอร์ดที่อยู่ในตาราง
|
| 158 |
+
cap = {w: int(capacities.get(w, 0)) for w in wards}
|
| 159 |
+
|
| 160 |
+
# เตรียมข้อมูลทำงาน
|
| 161 |
+
assigned = pd.Series(index=cleaned.index, data=pd.NA, dtype="object") # ชื่อวอร์ดที่ได้
|
| 162 |
+
choice_no = pd.Series(index=cleaned.index, data=pd.NA, dtype="Int64") # อันดับที่ได้
|
| 163 |
+
|
| 164 |
+
# หาค่า max rank ที่ปรากฏ (เช่น 1..6)
|
| 165 |
+
max_rank = 0
|
| 166 |
+
for w in wards:
|
| 167 |
+
max_w = cleaned[w].max(skipna=True)
|
| 168 |
+
if pd.notna(max_w):
|
| 169 |
+
max_rank = max(max_rank, int(max_w))
|
| 170 |
+
|
| 171 |
+
# วนทีละอันดับ
|
| 172 |
+
for r in range(1, max_rank + 1):
|
| 173 |
+
# ข้ามถ้าทุกวอร์ดเต็มแล้ว
|
| 174 |
+
if all(c <= 0 for c in cap.values()):
|
| 175 |
+
break
|
| 176 |
+
# สำหรับแต่ละวอร์ด
|
| 177 |
+
for w in wards:
|
| 178 |
+
if cap[w] <= 0:
|
| 179 |
+
continue
|
| 180 |
+
# ผู้สมัครที่ยังไม่ได้รับการจัดสรร และเลือกวอร์ดนี้ที่อันดับ r
|
| 181 |
+
mask = (assigned.isna()) & (cleaned[w] == r)
|
| 182 |
+
candidates = cleaned.index[mask].tolist()
|
| 183 |
+
if len(candidates) == 0:
|
| 184 |
+
continue
|
| 185 |
+
if len(candidates) <= cap[w]:
|
| 186 |
+
pick = candidates
|
| 187 |
+
else:
|
| 188 |
+
pick = list(rng.choice(candidates, size=cap[w], replace=False))
|
| 189 |
+
# ทำการจัดสรร
|
| 190 |
+
assigned.loc[pick] = w
|
| 191 |
+
choice_no.loc[pick] = r
|
| 192 |
+
cap[w] -= len(pick)
|
| 193 |
+
|
| 194 |
+
result = cleaned.copy()
|
| 195 |
+
result["AssignedWard"] = assigned
|
| 196 |
+
result["ChoiceNumber"] = choice_no
|
| 197 |
+
|
| 198 |
+
not_assigned = result[result["AssignedWard"].isna()].copy()
|
| 199 |
+
# แปลง NA ให้ดูง่ายขึ้นใน preview
|
| 200 |
+
result_preview = result.copy()
|
| 201 |
+
result_preview = result_preview.fillna("")
|
| 202 |
+
|
| 203 |
+
return result_preview, not_assigned.fillna(""), cap
|
| 204 |
+
|
| 205 |
+
def update_capacity_table(selected_wards: List[str]) -> pd.DataFrame:
|
| 206 |
+
rows = []
|
| 207 |
+
for w, th in WARD_CHOICES:
|
| 208 |
+
if selected_wards and w in selected_wards:
|
| 209 |
+
rows.append([w, th, 0])
|
| 210 |
+
if not rows:
|
| 211 |
+
return pd.DataFrame(columns=["Ward", "Thai Name", "Capacity"])
|
| 212 |
+
return pd.DataFrame(rows, columns=["Ward", "Thai Name", "Capacity"])
|
| 213 |
+
|
| 214 |
+
def update_mapping_table(selected_wards: List[str]) -> pd.DataFrame:
|
| 215 |
+
rows = [["NAME", ""], ["ID", ""]]
|
| 216 |
+
for w, th in WARD_CHOICES:
|
| 217 |
+
if selected_wards and w in selected_wards:
|
| 218 |
+
rows.append([w, ""])
|
| 219 |
+
return pd.DataFrame(rows, columns=["Field", "Your Column Header (exact or regex)"])
|
| 220 |
+
|
| 221 |
+
def on_clean(file, selected_wards, capacity_df, mapping_df, flexible):
|
| 222 |
+
if not selected_wards:
|
| 223 |
+
return gr.update(value="กรุณาเลือกวอร์ดอย่างน้อย 1", visible=True), None, None
|
| 224 |
+
# อ่านไฟล์
|
| 225 |
+
df, msg = read_table(file)
|
| 226 |
+
if df is None:
|
| 227 |
+
return gr.update(value=msg, visible=True), None, None
|
| 228 |
+
|
| 229 |
+
# ดึงชื่อคอลัมน์ที่ผู้ใช้ระบุ
|
| 230 |
+
mapping_df = mapping_df.copy()
|
| 231 |
+
mapping_df.columns = ["Field", "Key"]
|
| 232 |
+
mapping = {row["Field"]: str(row["Key"]).strip() for _, row in mapping_df.iterrows() if str(row["Field"]).strip()}
|
| 233 |
+
|
| 234 |
+
name_key = mapping.get("NAME", "")
|
| 235 |
+
id_key = mapping.get("ID", "")
|
| 236 |
+
|
| 237 |
+
if not name_key or not id_key:
|
| 238 |
+
return gr.update(value="กรุณาใส่หัวคอลัมน์ของ NAME และ ID", visible=True), None, None
|
| 239 |
+
|
| 240 |
+
ward_to_key = {}
|
| 241 |
+
for w in selected_wards:
|
| 242 |
+
ward_to_key[w] = mapping.get(w, "")
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
cleaned, messages = build_cleaned(df, name_key, id_key, ward_to_key, bool(flexible))
|
| 246 |
+
except Exception as e:
|
| 247 |
+
return gr.update(value=f"❌ เกิดข้อผิดพลาด: {e}", visible=True), None, None
|
| 248 |
+
|
| 249 |
+
info = "✓ Cleaning สำเร็จ"
|
| 250 |
+
if messages:
|
| 251 |
+
info += "\n" + "\n".join(messages)
|
| 252 |
+
|
| 253 |
+
# เตรียมไฟล์ดาวน์โหลด
|
| 254 |
+
buf = BytesIO()
|
| 255 |
+
cleaned.to_csv(buf, index=False, encoding="utf-8-sig")
|
| 256 |
+
buf.seek(0)
|
| 257 |
+
|
| 258 |
+
return gr.update(value=info, visible=True), cleaned.head(30), ("cleaned.csv", buf)
|
| 259 |
+
|
| 260 |
+
def on_assign(file, selected_wards, capacity_df, mapping_df, flexible, seed):
|
| 261 |
+
# ต้อง clean ก่อน (เราอ่านไฟล์เดิมแล้ว clean ในฟังก์ชันนี้อีกครั้งเพื่อความแน่นอน)
|
| 262 |
+
status, cleaned_preview, cleaned_file = on_clean(file, selected_wards, capacity_df, mapping_df, flexible)
|
| 263 |
+
if cleaned_preview is None:
|
| 264 |
+
return status, None, None, None, None
|
| 265 |
+
|
| 266 |
+
# โหลด cleaned จากไฟล์ใน memory อีกครั้งเพื่อความแม่นยำ
|
| 267 |
+
# แต่เรามีเฉพาะ preview; จึง clean ซ้ำเพื่อได้ dataframe เต็ม
|
| 268 |
+
df, _ = read_table(file)
|
| 269 |
+
mapping_df = mapping_df.copy()
|
| 270 |
+
mapping_df.columns = ["Field", "Key"]
|
| 271 |
+
mapping = {row["Field"]: str(row["Key"]).strip() for _, row in mapping_df.iterrows() if str(row["Field"]).strip()}
|
| 272 |
+
name_key = mapping.get("NAME", "")
|
| 273 |
+
id_key = mapping.get("ID", "")
|
| 274 |
+
ward_to_key = {w: mapping.get(w, "") for w in selected_wards}
|
| 275 |
+
cleaned, _ = build_cleaned(df, name_key, id_key, ward_to_key, bool(flexible))
|
| 276 |
+
|
| 277 |
+
# capacities
|
| 278 |
+
if capacity_df is None or len(capacity_df) == 0:
|
| 279 |
+
return gr.update(value="กรุณากรอก capacity ก่อน", visible=True), None, None, None, None
|
| 280 |
+
|
| 281 |
+
# ทำให้แน่ใจว่ามีคอลัมน์ตามชื่อที่เราคาด
|
| 282 |
+
cap_df = capacity_df.copy()
|
| 283 |
+
cap_df.columns = ["Ward", "Thai Name", "Capacity"]
|
| 284 |
+
cap_df = cap_df[cap_df["Ward"].isin([c for c in cleaned.columns if c not in ("NAME", "ID")])]
|
| 285 |
+
cap_map = {}
|
| 286 |
+
for _, row in cap_df.iterrows():
|
| 287 |
+
try:
|
| 288 |
+
cap_map[str(row["Ward"])] = int(row["Capacity"])
|
| 289 |
+
except Exception:
|
| 290 |
+
cap_map[str(row["Ward"])] = 0
|
| 291 |
+
|
| 292 |
+
assigned, not_assigned, leftover = random_assign(cleaned, cap_map, seed=seed if seed not in (None, "") else None)
|
| 293 |
+
|
| 294 |
+
# สร้างไฟล์ด��วน์โหลด
|
| 295 |
+
out_all = BytesIO()
|
| 296 |
+
assigned.to_csv(out_all, index=False, encoding="utf-8-sig")
|
| 297 |
+
out_all.seek(0)
|
| 298 |
+
|
| 299 |
+
out_un = BytesIO()
|
| 300 |
+
not_assigned.to_csv(out_un, index=False, encoding="utf-8-sig")
|
| 301 |
+
out_un.seek(0)
|
| 302 |
+
|
| 303 |
+
leftover_text = "ความจุคงเหลือ:\n" + "\n".join([f"- {k}: {v}" for k, v in leftover.items()])
|
| 304 |
+
|
| 305 |
+
return status, assigned.head(30), ("assigned.csv", out_all), ("not_assigned.csv", out_un), leftover_text
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
with gr.Blocks(title=APP_TITLE) as demo:
|
| 309 |
+
gr.Markdown(f"# {APP_TITLE}")
|
| 310 |
+
gr.Markdown(DESCRIPTION)
|
| 311 |
+
|
| 312 |
+
with gr.Row():
|
| 313 |
+
file = gr.File(file_count="single", file_types=[".csv", ".xlsx"], label="อัปโหลดข้อมูลนักศึกษา/ผู้เรียน (.csv / .xlsx)")
|
| 314 |
+
|
| 315 |
+
with gr.Accordion("1) เลือกวอร์ดที่ต้องใช้", open=True):
|
| 316 |
+
selected_wards = gr.CheckboxGroup(
|
| 317 |
+
choices=[w for w, _ in WARD_CHOICES],
|
| 318 |
+
label="เลือกวอร์ด (เลือกได้หลายข้อ)",
|
| 319 |
+
value=["Medical", "Surgical"] # ค่าเริ่มต้นเล็กน้อย
|
| 320 |
+
)
|
| 321 |
+
gr.Markdown(
|
| 322 |
+
"คำแปล (อ้างอิง): " +
|
| 323 |
+
", ".join([f"**{w}** = {th}" for w, th in WARD_CHOICES])
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
with gr.Accordion("2) กำหนด Capacity ต่อวอร์ด", open=True):
|
| 327 |
+
capacity_df = gr.Dataframe(
|
| 328 |
+
headers=["Ward", "Thai Name", "Capacity"],
|
| 329 |
+
value=[],
|
| 330 |
+
row_count=(0, "dynamic"),
|
| 331 |
+
col_count=3,
|
| 332 |
+
interactive=True,
|
| 333 |
+
wrap=True,
|
| 334 |
+
label="กรอกแค่แถวของวอร์ดที่เลือก"
|
| 335 |
+
)
|
| 336 |
+
selected_wards.change(fn=update_capacity_table, inputs=selected_wards, outputs=capacity_df)
|
| 337 |
+
|
| 338 |
+
with gr.Accordion("3) ระบุหัวคอลัมน์จริงในไฟล์ของคุณ", open=True):
|
| 339 |
+
gr.Markdown("ใส่ชื่อคอลัมน์ **จริง** ที่อยู่ในไฟล์ของคุณ (จะใช้แมตช์ตรงตัว หรือเปิดโหมดยืดหยุ่นก็ได้)")
|
| 340 |
+
mapping_df = gr.Dataframe(
|
| 341 |
+
headers=["Field", "Your Column Header (exact or regex)"],
|
| 342 |
+
value=[["NAME",""],["ID",""]],
|
| 343 |
+
row_count=(2, "dynamic"),
|
| 344 |
+
col_count=2,
|
| 345 |
+
interactive=True,
|
| 346 |
+
wrap=True
|
| 347 |
+
)
|
| 348 |
+
selected_wards.change(fn=update_mapping_table, inputs=selected_wards, outputs=mapping_df)
|
| 349 |
+
flexible = gr.Checkbox(label="เปิดโหมดจับคู่คอลัมน์แบบยืดหยุ่น (regex / contains)", value=True)
|
| 350 |
+
|
| 351 |
+
with gr.Row():
|
| 352 |
+
clean_btn = gr.Button("Clean data (ดูพรีวิว)")
|
| 353 |
+
assign_btn = gr.Button("Assign (สุ่มตามลำดับอันดับ)")
|
| 354 |
+
|
| 355 |
+
info = gr.Markdown(visible=False)
|
| 356 |
+
preview = gr.Dataframe(label="พรีวิวข้อมูลที่ผ่านการ clean (แสดงหัว 30 แถว)", visible=True)
|
| 357 |
+
cleaned_file = gr.File(label="ดาวน์โหลดไฟล์ cleaned.csv")
|
| 358 |
+
assigned_preview = gr.Dataframe(label="ตัวอย่างผลการจัดสรร (หัว 30 แถว)", visible=True)
|
| 359 |
+
assigned_file = gr.File(label="ดาวน์โหลดไฟล์ assigned.csv")
|
| 360 |
+
not_assigned_file = gr.File(label="ดาวน์โหลดไฟล์ not_assigned.csv")
|
| 361 |
+
leftover_text = gr.Textbox(label="สรุปความจุคงเหลือ", interactive=False)
|
| 362 |
+
|
| 363 |
+
seed = gr.Textbox(label="Random seed (เว้นว่างเพื่อให้สุ่มใหม่ทุกครั้ง)", value="")
|
| 364 |
+
|
| 365 |
+
clean_btn.click(
|
| 366 |
+
fn=on_clean,
|
| 367 |
+
inputs=[file, selected_wards, capacity_df, mapping_df, flexible],
|
| 368 |
+
outputs=[info, preview, cleaned_file]
|
| 369 |
+
)
|
| 370 |
+
assign_btn.click(
|
| 371 |
+
fn=on_assign,
|
| 372 |
+
inputs=[file, selected_wards, capacity_df, mapping_df, flexible, seed],
|
| 373 |
+
outputs=[info, assigned_preview, assigned_file, not_assigned_file, leftover_text]
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
if __name__ == "__main__":
|
| 377 |
+
demo.launch()
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==4.44.0
|
| 2 |
+
pandas==2.2.2
|
| 3 |
+
openpyxl==3.1.5
|
| 4 |
+
numpy==2.0.2
|