ShinyaJ commited on
Commit
1b707be
·
verified ·
1 Parent(s): 17c0dad

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +7 -7
  2. app.py +263 -202
  3. requirements.txt +2 -1
README.md CHANGED
@@ -4,16 +4,16 @@ emoji: 🎲
4
  colorFrom: pink
5
  colorTo: blue
6
  sdk: gradio
7
- sdk_version: "4.44.0"
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
  # Ward Ranking Cleaner & Random Assigner (Gradio)
13
 
14
- ## วิธีใช้งาน
15
- 1. อัปโหลดไฟล์ .csv หรือ .xlsx ที่มีข้อมูลนักศึกษา/ผู้เรียน
16
- 2. เลือกวอร์ดที่ต้องใช้ แล้วกรอก capacity
17
- 3. ใส่ชื่อคอลัมน์จริงของ NAME, ID และคอลัมน์วอร์ดที่เลือก
18
- 4. กด **Clean data** ดูพรีวิว ดาวน์โหลด cleaned.csv
19
- 5. กด **Assign** → สุ่มจัดสรรตามอันดับ → ดาวน์โหลด assigned.csv / not_assigned.csv
 
4
  colorFrom: pink
5
  colorTo: blue
6
  sdk: gradio
7
+ sdk_version: "4.44.1"
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
  # Ward Ranking Cleaner & Random Assigner (Gradio)
13
 
14
+ - Auto-detect column mapping (Thai/English keywords + fuzzy)
15
+ - Or map by **column numbers** based on the "Available columns" list
16
+ - Clean to keep only `NAME`, `ID`, and selected ward ranking columns (parse ranks → ints)
17
+ - Assign students by rank round (1→2→3…) with random tie-breaking, respecting **capacity**
18
+ - Pre-check: `#students <= total capacity` (shortage allowed, **not exceed**)
19
+
app.py CHANGED
@@ -5,17 +5,24 @@ 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 = [
@@ -29,6 +36,20 @@ WARD_CHOICES = [
29
  ("Obstetrics", "สูติศาสตร์"),
30
  ]
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def read_table(file) -> Tuple[Optional[pd.DataFrame], str]:
33
  if file is None:
34
  return None, "กรุณาอัปโหลดไฟล์ก่อน (.csv หรือ .xlsx)"
@@ -46,40 +67,16 @@ def read_table(file) -> Tuple[Optional[pd.DataFrame], str]:
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)
@@ -91,102 +88,127 @@ def parse_rank(value) -> Optional[int]:
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)
@@ -196,90 +218,116 @@ def random_assign(cleaned: pd.DataFrame,
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 = {}
@@ -289,39 +337,38 @@ def on_assign(file, selected_wards, capacity_df, mapping_df, flexible, seed):
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(
@@ -331,46 +378,60 @@ with gr.Blocks(title=APP_TITLE) as demo:
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__":
 
5
  import re
6
  from io import BytesIO
7
  from typing import List, Dict, Tuple, Optional
8
+ try:
9
+ from rapidfuzz import process as rf_process
10
+ HAS_FUZZ = True
11
+ except Exception:
12
+ HAS_FUZZ = False
13
 
14
+ APP_TITLE = "Ward Ranking Cleaner & Random Assigner (Auto-map + Number Mapping)"
15
  DESCRIPTION = """
16
+ **Flow**
17
+ 1) อัปโหลดไฟล์ .csv/.xlsx
18
+ 2) เลือกวอร์ดที่ใช้ + ใส่ capacity
19
+ 3) ตรวจหัวคอลัมน์ที่อ่านได้ (Available columns)
20
+ 4) **เลือกวิธี mapping**:
21
+ - Auto-detect (คำไทย/อังกฤษ + fuzzy) → ระบบเติมให้อัตโนมัติ
22
+ - หรือกรอก **หมายเลขคอลัมน์** ตามรายการ Available columns (เลขเริ่ม 1)
23
+ 5) Clean เหลือเฉพาะ NAME, ID, และคอลัมน์วอร์ดที่เลือก (ค่าจัดอันดับถูกแปลงเป็นตัวเลข)
24
+ 6) Assign → สุ่มตามลำดับอันดับ โดยเคารพ capacity
25
+ - **จะตรวจว่าจำนวนนักศึกษา <= ผลรวม capacity** (ขาดได้ แต่ห้ามเกิน)
26
  """
27
 
28
  WARD_CHOICES = [
 
36
  ("Obstetrics", "สูติศาสตร์"),
37
  ]
38
 
39
+ # Keyword dictionary for auto mapping
40
+ AUTO_MAP = {
41
+ "NAME": ["ชื่อ-สกุล", "ชื่อ - สกุล", "fullname", "full name", "name", "student name"],
42
+ "ID": ["รหัสนักศึกษา", "รหัส", "student id", "id", "studentid"],
43
+ "Medical": ["อายุรศาสตร์", "medical"],
44
+ "Medical_1": ["อายุรศาสตร์_1", "medical_1", "med_1"],
45
+ "Medical_2": ["อายุรศาสตร์_2", "medical_2", "med_2"],
46
+ "Surgical": ["ศัลยศาสตร์", "surgical", "surgery"],
47
+ "Pediatric": ["เด็ก", "pediatric", "pediatrics"],
48
+ "Community": ["ชุมชน", "community"],
49
+ "Psychiatric": ["จิตเวช", "psychiatric"],
50
+ "Obstetrics": ["สูติศาสตร์", "obstetrics", "obgyn", "ob/gyn"],
51
+ }
52
+
53
  def read_table(file) -> Tuple[Optional[pd.DataFrame], str]:
54
  if file is None:
55
  return None, "กรุณาอัปโหลดไฟล์ก่อน (.csv หรือ .xlsx)"
 
67
  return None, "รองรับเฉพาะ .csv หรือ .xlsx เท่านั้น"
68
  except Exception as e:
69
  return None, f"อ่านไฟล์ไม่สำเร็จ: {e}"
 
70
  df.columns = [str(c).strip() for c in df.columns]
71
  return df, ""
72
 
73
+ def available_columns_text(df: pd.DataFrame) -> str:
74
+ lines = ["Available columns:"]
75
+ for i, c in enumerate(df.columns, start=1):
76
+ lines.append(f"{i}. {c}")
77
+ return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  def parse_rank(value) -> Optional[int]:
 
 
 
 
80
  if pd.isna(value):
81
  return None
82
  s = str(value)
 
88
  return None
89
  return None
90
 
91
+ def auto_map_columns(df: pd.DataFrame, selected_wards: List[str]) -> Dict[str, int]:
92
+ """Return mapping as index (1-based) for NAME, ID, and selected ward columns.
93
+ Use keyword dictionary and fuzzy fallback (if available)."""
94
+ cols = list(df.columns)
95
+ col_lower = [c.lower() for c in cols]
96
+ result: Dict[str, int] = {}
97
+
98
+ def find_by_keywords(keywords: List[str]) -> Optional[int]:
99
+ for kw in keywords:
100
+ kw_low = kw.lower()
101
+ # contains search
102
+ for idx, c_low in enumerate(col_lower):
103
+ if kw_low in c_low:
104
+ return idx + 1 # 1-based
105
+ # fuzzy fallback
106
+ if HAS_FUZZ:
107
+ best_idx = None
108
+ best_score = -1
109
+ for idx, c in enumerate(cols):
110
+ for kw in keywords:
111
+ match = rf_process.extractOne(kw, [c], score_cutoff=85)
112
+ if match:
113
+ _, score, _ = match
114
+ if score > best_score:
115
+ best_score = score
116
+ best_idx = idx + 1
117
+ if best_idx is not None:
118
+ return best_idx
119
+ return None
120
+
121
+ # NAME / ID
122
+ n_idx = find_by_keywords(AUTO_MAP["NAME"])
123
+ if n_idx: result["NAME"] = n_idx
124
+ i_idx = find_by_keywords(AUTO_MAP["ID"])
125
+ if i_idx: result["ID"] = i_idx
126
+
127
+ # wards
128
+ for w in selected_wards:
129
+ kws = AUTO_MAP.get(w, [w])
130
+ w_idx = find_by_keywords(kws)
131
+ if w_idx:
132
+ result[w] = w_idx
133
+
134
+ return result
135
+
136
+ def build_cleaned_from_indices(df: pd.DataFrame,
137
+ mapping_indices: Dict[str, int]) -> pd.DataFrame:
138
  """
139
+ mapping_indices: {Field -> 1-based column index in df}
140
+ Keep only NAME, ID, and ward columns. Convert ward values to Int (ranks).
141
  """
142
+ # Resolve names
143
+ def idx_to_name(k: str) -> str:
144
+ idx = mapping_indices.get(k, None)
145
+ if idx is None: return ""
146
+ if not (1 <= idx <= len(df.columns)): return ""
147
+ return df.columns[idx - 1]
148
+
149
+ name_col = idx_to_name("NAME")
150
+ id_col = idx_to_name("ID")
151
+ if not name_col or not id_col:
152
  missing = []
153
+ if not name_col: missing.append("NAME")
154
+ if not id_col: missing.append("ID")
155
  raise ValueError(f"หาไม่พบคอลัมน์บังคับ: {', '.join(missing)}")
156
 
157
+ # collect ward columns
158
+ ward_cols_src = []
159
+ ward_cols_dst = []
160
+ for w, _th in WARD_CHOICES:
161
+ if w in mapping_indices:
162
+ c = idx_to_name(w)
163
+ if c:
164
+ ward_cols_src.append(c)
165
+ ward_cols_dst.append(w)
166
+
167
+ keep_cols = [name_col, id_col] + ward_cols_src
168
+ cleaned = df[keep_cols].copy()
169
+ rename_map = {name_col: "NAME", id_col: "ID"}
170
+ rename_map.update({src: dst for src, dst in zip(ward_cols_src, ward_cols_dst)})
171
+ cleaned = cleaned.rename(columns=rename_map)
172
+
173
+ # parse ranks
174
+ for c in cleaned.columns:
175
+ if c not in ("NAME", "ID"):
176
+ cleaned[c] = cleaned[c].apply(parse_rank).astype("Int64")
177
+ # order
178
+ ordered = ["NAME", "ID"] + [c for c in cleaned.columns if c not in ("NAME", "ID")]
179
+ cleaned = cleaned[ordered]
180
+ return cleaned
 
 
 
 
 
 
 
 
 
181
 
182
  def random_assign(cleaned: pd.DataFrame,
183
  capacities: Dict[str, int],
184
  seed: Optional[int] = None) -> Tuple[pd.DataFrame, pd.DataFrame, Dict[str, int]]:
 
 
 
 
 
185
  rng = np.random.default_rng(seed)
186
  wards = [w for w in cleaned.columns if w not in ("NAME", "ID")]
 
187
  cap = {w: int(capacities.get(w, 0)) for w in wards}
188
 
189
+ assigned = pd.Series(index=cleaned.index, data=pd.NA, dtype="object")
190
+ choice_no = pd.Series(index=cleaned.index, data=pd.NA, dtype="Int64")
 
191
 
 
192
  max_rank = 0
193
  for w in wards:
194
+ m = cleaned[w].max(skipna=True)
195
+ if pd.notna(m):
196
+ max_rank = max(max_rank, int(m))
197
 
 
198
  for r in range(1, max_rank + 1):
 
199
  if all(c <= 0 for c in cap.values()):
200
  break
 
201
  for w in wards:
202
  if cap[w] <= 0:
203
  continue
 
204
  mask = (assigned.isna()) & (cleaned[w] == r)
205
  candidates = cleaned.index[mask].tolist()
206
+ if not candidates:
207
  continue
208
  if len(candidates) <= cap[w]:
209
  pick = candidates
210
  else:
211
  pick = list(rng.choice(candidates, size=cap[w], replace=False))
 
212
  assigned.loc[pick] = w
213
  choice_no.loc[pick] = r
214
  cap[w] -= len(pick)
 
218
  result["ChoiceNumber"] = choice_no
219
 
220
  not_assigned = result[result["AssignedWard"].isna()].copy()
221
+ return result.fillna(""), not_assigned.fillna(""), cap
 
 
222
 
223
+ # ===== Gradio callbacks =====
224
 
225
  def update_capacity_table(selected_wards: List[str]) -> pd.DataFrame:
226
  rows = []
227
  for w, th in WARD_CHOICES:
228
  if selected_wards and w in selected_wards:
229
  rows.append([w, th, 0])
 
 
230
  return pd.DataFrame(rows, columns=["Ward", "Thai Name", "Capacity"])
231
 
232
+ def on_upload(file, selected_wards):
 
 
 
 
 
 
 
 
 
 
233
  df, msg = read_table(file)
234
  if df is None:
235
+ return gr.update(value=msg, visible=True), "", None, None, None
236
+ # Show available columns
237
+ avail = available_columns_text(df)
238
+ # Auto-detect mapping (indices)
239
+ auto_idx = auto_map_columns(df, selected_wards or [])
240
+ # Prepare number inputs defaults
241
+ def idx_or_blank(key):
242
+ return int(auto_idx[key]) if key in auto_idx else None
243
+ name_num = idx_or_blank("NAME")
244
+ id_num = idx_or_blank("ID")
245
+ ward_nums = {w: idx_or_blank(w) for w, _ in WARD_CHOICES}
246
+ return gr.update(value="✓ อ่านไฟล์สำเร็จ", visible=True), avail, name_num, id_num, ward_nums
247
+
248
+ def collect_mapping_numbers(name_num, id_num, ward_nums, selected_wards, n_cols):
249
+ """Validate numeric mapping and build mapping dict {Field: index}"""
250
+ errors = []
251
+ mapping = {}
252
+ def valid(num, label):
253
+ if num is None:
254
+ errors.append(f"- กรุณาใส่หมายเลขของ {label}")
255
+ return None
256
+ try:
257
+ num = int(num)
258
+ except Exception:
259
+ errors.append(f"- {label} ต้องเป็นตัวเลข")
260
+ return None
261
+ if not (1 <= num <= n_cols):
262
+ errors.append(f"- {label} ต้องอยู่ระหว่าง 1–{n_cols}")
263
+ return None
264
+ return num
265
 
266
+ nn = valid(name_num, "NAME")
267
+ ii = valid(id_num, "ID")
268
+ if nn: mapping["NAME"] = nn
269
+ if ii: mapping["ID"] = ii
270
 
271
+ for w in selected_wards:
272
+ wn = valid(ward_nums.get(w, None), f"{w}")
273
+ if wn:
274
+ mapping[w] = wn
275
 
276
+ return errors, mapping
 
277
 
278
+ def on_clean(file, selected_wards, capacity_df, name_num, id_num,
279
+ med_num, med1_num, med2_num, surg_num, ped_num, comm_num, psy_num, obs_num):
280
+ if not selected_wards:
281
+ return gr.update(value="กรุณาเลือกวอร์ดอย่างน้อย 1", visible=True), None, None, None
282
+
283
+ df, msg = read_table(file)
284
+ if df is None:
285
+ return gr.update(value=msg, visible=True), None, None, None
286
+
287
+ n_cols = len(df.columns)
288
+ ward_nums = {
289
+ "Medical": med_num, "Medical_1": med1_num, "Medical_2": med2_num,
290
+ "Surgical": surg_num, "Pediatric": ped_num, "Community": comm_num,
291
+ "Psychiatric": psy_num, "Obstetrics": obs_num
292
+ }
293
+ errors, mapping_idx = collect_mapping_numbers(name_num, id_num, ward_nums, selected_wards, n_cols)
294
+ if errors:
295
+ return gr.update(value="❌ Mapping ไม่ครบ/ไม่ถูกต้อง:\n" + "\n".join(errors), visible=True), None, None, None
296
 
297
  try:
298
+ cleaned = build_cleaned_from_indices(df, mapping_idx)
299
  except Exception as e:
300
+ return gr.update(value=f"❌ เกิดข้อผิดพลาด: {e}", visible=True), None, None, None
301
 
 
 
 
 
 
302
  buf = BytesIO()
303
  cleaned.to_csv(buf, index=False, encoding="utf-8-sig")
304
  buf.seek(0)
305
+ info = "✓ Cleaning สำเร็จ"
306
+ return gr.update(value=info, visible=True), cleaned.head(30), ("cleaned.csv", buf), len(cleaned)
307
 
308
+ def on_assign(file, selected_wards, capacity_df, name_num, id_num,
309
+ med_num, med1_num, med2_num, surg_num, ped_num, comm_num, psy_num, obs_num, seed):
310
+ # Clean first to get the cleaned df and student count
311
+ status, cleaned_preview, cleaned_file, n_students = on_clean(file, selected_wards, capacity_df, name_num, id_num,
312
+ med_num, med1_num, med2_num, surg_num, ped_num, comm_num, psy_num, obs_num)
313
  if cleaned_preview is None:
314
  return status, None, None, None, None
315
 
316
+ # Recreate full cleaned df (not just head) for assignment
 
317
  df, _ = read_table(file)
318
+ n_cols = len(df.columns)
319
+ ward_nums = {
320
+ "Medical": med_num, "Medical_1": med1_num, "Medical_2": med2_num,
321
+ "Surgical": surg_num, "Pediatric": ped_num, "Community": comm_num,
322
+ "Psychiatric": psy_num, "Obstetrics": obs_num
323
+ }
324
+ _errors, mapping_idx = collect_mapping_numbers(name_num, id_num, ward_nums, selected_wards, n_cols)
325
+ cleaned = build_cleaned_from_indices(df, mapping_idx)
326
+
327
+ # Build capacity map
 
 
 
328
  cap_df = capacity_df.copy()
329
+ if cap_df is None or cap_df.empty:
330
+ return gr.update(value="กรุณากรอก capacity ก่อน", visible=True), None, None, None, None
331
  cap_df.columns = ["Ward", "Thai Name", "Capacity"]
332
  cap_df = cap_df[cap_df["Ward"].isin([c for c in cleaned.columns if c not in ("NAME", "ID")])]
333
  cap_map = {}
 
337
  except Exception:
338
  cap_map[str(row["Ward"])] = 0
339
 
340
+ total_capacity = sum(cap_map.values())
341
+ # Pre-check: students must be <= total capacity (ขาดได้แต่ห้ามเกิน)
342
+ if n_students is None:
343
+ n_students = len(cleaned)
344
+ if n_students > total_capacity:
345
+ msg = f"❌ จำนวนผู้สมัคร {n_students} คน มากกว่า capacity รวม {total_capacity} ที่กำหนด (ขาดได้แต่ห้ามเกิน)"
346
+ return gr.update(value=msg, visible=True), None, None, None, None
347
 
348
+ assigned, not_assigned, leftover = random_assign(cleaned, cap_map, seed=int(seed) if str(seed).strip().isdigit() else None)
 
 
 
349
 
350
+ out_all = BytesIO()
351
+ assigned.to_csv(out_all, index=False, encoding="utf-8-sig"); out_all.seek(0)
352
  out_un = BytesIO()
353
+ not_assigned.to_csv(out_un, index=False, encoding="utf-8-sig"); out_un.seek(0)
 
 
354
  leftover_text = "ความจุคงเหลือ:\n" + "\n".join([f"- {k}: {v}" for k, v in leftover.items()])
355
 
356
  return status, assigned.head(30), ("assigned.csv", out_all), ("not_assigned.csv", out_un), leftover_text
357
 
 
358
  with gr.Blocks(title=APP_TITLE) as demo:
359
  gr.Markdown(f"# {APP_TITLE}")
360
  gr.Markdown(DESCRIPTION)
361
 
362
  with gr.Row():
363
+ file = gr.File(file_count="single", file_types=[".csv", ".xlsx"], label="อัปโหลดข้อมูล (.csv/.xlsx)")
364
 
365
  with gr.Accordion("1) เลือกวอร์ดที่ต้องใช้", open=True):
366
  selected_wards = gr.CheckboxGroup(
367
  choices=[w for w, _ in WARD_CHOICES],
368
  label="เลือกวอร์ด (เลือกได้หลายข้อ)",
369
+ value=["Medical", "Surgical", "Pediatric", "Community", "Psychiatric", "Obstetrics"]
 
 
 
 
370
  )
371
+ gr.Markdown("คำแปล: " + ", ".join([f"**{w}** = {th}" for w, th in WARD_CHOICES]))
372
 
373
  with gr.Accordion("2) กำหนด Capacity ต่อวอร์ด", open=True):
374
  capacity_df = gr.Dataframe(
 
378
  col_count=3,
379
  interactive=True,
380
  wrap=True,
381
+ label="กรอกเฉพาะแถวของวอร์ดที่เลือก"
382
  )
383
  selected_wards.change(fn=update_capacity_table, inputs=selected_wards, outputs=capacity_df)
384
 
385
+ with gr.Accordion("3) ตรวจหัวคอลัมน์ & เลือก mapping (Auto/ตัวเลข)", open=True):
386
+ status = gr.Markdown(visible=False)
387
+ available = gr.Code(label="Available columns (เลขเริ่มที่ 1)", language="markdown", interactive=False)
388
+ auto_btn = gr.Button("อ่านไฟล์ & Auto-detect mapping")
389
+ # numeric mapping inputs
390
+ name_num = gr.Number(label="หมายเลขคอลัมน์สำหรับ NAME", precision=0)
391
+ id_num = gr.Number(label="หมายเลขคอลัมน์สำหรับ ID", precision=0)
392
+ with gr.Row():
393
+ med_num = gr.Number(label="หมายเลขคอลัมน์ Medical", precision=0)
394
+ med1_num = gr.Number(label="หมายเลขคอลัมน์ Medical_1", precision=0)
395
+ med2_num = gr.Number(label="หมายเลขคอลัมน์ Medical_2", precision=0)
396
+ with gr.Row():
397
+ surg_num = gr.Number(label="หมายเลขคอลัมน์ Surgical", precision=0)
398
+ ped_num = gr.Number(label="หมายเลขคอลัมน์ Pediatric", precision=0)
399
+ comm_num = gr.Number(label="หมายเลขคอลัมน์ Community", precision=0)
400
+ with gr.Row():
401
+ psy_num = gr.Number(label="หมายเลขคอลัมน์ Psychiatric", precision=0)
402
+ obs_num = gr.Number(label="หมายเลขคอลัมน์ Obstetrics", precision=0)
403
+
404
+ auto_btn.click(fn=on_upload, inputs=[file, selected_wards],
405
+ outputs=[status, available, name_num, id_num,
406
+ {"Medical": med_num, "Medical_1": med1_num, "Medical_2": med2_num,
407
+ "Surgical": surg_num, "Pediatric": ped_num, "Community": comm_num,
408
+ "Psychiatric": psy_num, "Obstetrics": obs_num}])
409
 
410
  with gr.Row():
411
+ clean_btn = gr.Button("Clean data (ดูพรีวิว)", variant="primary")
412
+ seed = gr.Textbox(label="Random seed (เว้นว่างเพื่อสุ่มใหม่)", value="")
413
 
414
+ preview = gr.Dataframe(label="พรีวิวข้อมูลที่ผ่านการ clean (หัว 30 แถว)", visible=True)
 
415
  cleaned_file = gr.File(label="ดาวน์โหลดไฟล์ cleaned.csv")
 
 
 
 
 
 
416
 
417
  clean_btn.click(
418
  fn=on_clean,
419
+ inputs=[file, selected_wards, capacity_df, name_num, id_num,
420
+ med_num, med1_num, med2_num, surg_num, ped_num, comm_num, psy_num, obs_num],
421
+ outputs=[status, preview, cleaned_file, gr.State()]
422
  )
423
+
424
+ assign_btn = gr.Button("Assign (สุ่มตามลำดับอันดับ)")
425
+ assigned_preview = gr.Dataframe(label="ตัวอย่างผลการจัดสรร (หัว 30 แถว)")
426
+ assigned_file = gr.File(label="ดาวน์โหลดไฟล์ assigned.csv")
427
+ not_assigned_file = gr.File(label="ดาวน์โหลดไฟล์ not_assigned.csv")
428
+ leftover_text = gr.Textbox(label="สรุปความจุคงเหลือ", interactive=False)
429
+
430
  assign_btn.click(
431
  fn=on_assign,
432
+ inputs=[file, selected_wards, capacity_df, name_num, id_num,
433
+ med_num, med1_num, med2_num, surg_num, ped_num, comm_num, psy_num, obs_num, seed],
434
+ outputs=[status, assigned_preview, assigned_file, not_assigned_file, leftover_text]
435
  )
436
 
437
  if __name__ == "__main__":
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
- gradio==4.44.0
2
  pandas==2.2.2
3
  openpyxl==3.1.5
4
  numpy==2.0.2
 
 
1
+ gradio==4.44.1
2
  pandas==2.2.2
3
  openpyxl==3.1.5
4
  numpy==2.0.2
5
+ rapidfuzz==3.9.7