From f689da3c6e1eb993e18634e546a6562fc23c0174 Mon Sep 17 00:00:00 2001
From: th-kim0823
Date: Sat, 25 Apr 2026 20:12:46 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8F=99=EB=A5=A0=20=EC=B2=98=EB=A6=AC?=
=?UTF-8?q?=20-=20=EC=A6=89=EC=84=9D=20=EC=B6=94=EC=B2=A8=20=EB=98=90?=
=?UTF-8?q?=EB=8A=94=20=EC=88=98=EB=8F=99=20=EC=84=A0=ED=83=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- DB tie_breaks 테이블 (category, winner_team, method, decided_at)
- compute_winners()가 동률 시 status='tie' 반환, tied 후보 표시
- 어드민: 동률 부문에 🎲 추첨 버튼 + 수동 선택 라디오 + 결정 취소
- 우승 표시에 결정 방식 태그 (🎲 추첨 / 🖊️ 수동)
- ceremony: 동률 미해결 발견 시 진입 차단, 어드민 처리 유도
흐름:
1. 어드민에서 동률 알림 확인
2. 즉석 추첨 또는 수동 선택으로 결정
3. ceremony 진입하면 정상 reveal
Co-Authored-By: Claude Opus 4.7 (1M context)
---
app.py | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 165 insertions(+), 17 deletions(-)
diff --git a/app.py b/app.py
index 172cf9e..4472426 100644
--- a/app.py
+++ b/app.py
@@ -62,6 +62,16 @@ def get_conn():
cols = [r[1] for r in conn.execute("PRAGMA table_info(votes)").fetchall()]
if "employee_id" not in cols:
conn.execute("ALTER TABLE votes ADD COLUMN employee_id TEXT")
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS tie_breaks (
+ category TEXT PRIMARY KEY,
+ winner_team TEXT NOT NULL,
+ method TEXT NOT NULL, -- 'random' or 'manual'
+ decided_at TEXT NOT NULL
+ )
+ """
+ )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS team_titles (
@@ -99,11 +109,48 @@ def fmt_team(team, titles):
return f"{team} — {t}" if t else team
+def get_tie_breaks():
+ conn = get_conn()
+ rows = conn.execute("SELECT category, winner_team FROM tie_breaks").fetchall()
+ conn.close()
+ return dict(rows)
+
+
+def save_tie_break(category, winner, method):
+ conn = get_conn()
+ conn.execute(
+ "INSERT INTO tie_breaks (category, winner_team, method, decided_at) "
+ "VALUES (?, ?, ?, ?) "
+ "ON CONFLICT(category) DO UPDATE SET "
+ "winner_team = excluded.winner_team, "
+ "method = excluded.method, "
+ "decided_at = excluded.decided_at",
+ (category, winner, method, datetime.now().isoformat(timespec="seconds")),
+ )
+ conn.commit()
+ conn.close()
+
+
+def clear_tie_break(category):
+ conn = get_conn()
+ conn.execute("DELETE FROM tie_breaks WHERE category = ?", (category,))
+ conn.commit()
+ conn.close()
+
+
def compute_winners():
"""
- 우선순위 기반 1팀 1상 보장.
- PRIZE_PRIORITY 순으로 결정, 이미 수상한 팀은 후순위 상에서 제외.
- return: dict[col] = (winner_team, winner_votes, diff_with_2nd, all_rows_excluded)
+ 우선순위 기반 1팀 1상 + 동률 처리.
+ 동률 발견 시 tie_breaks 저장 결정 사용. 미결정이면 status='tie' 반환.
+ return: (winners dict, rankings dict)
+ winners[col] = {
+ "status": "ok"|"tie"|"empty",
+ "team": str, # ok 시
+ "votes": int,
+ "diff": int, # ok 시 2위와 차이
+ "tied": [str], # tie 시 동률 후보들
+ "method": str|None, # tie_break 적용 방식
+ }
"""
conn = get_conn()
rankings = {}
@@ -112,6 +159,11 @@ def compute_winners():
f"SELECT {col} AS team, COUNT(*) AS c FROM votes "
f"GROUP BY {col} ORDER BY c DESC, team ASC"
).fetchall()
+ tie_decisions = dict(
+ conn.execute(
+ "SELECT category, winner_team || '||' || method FROM tie_breaks"
+ ).fetchall()
+ )
conn.close()
winners = {}
@@ -120,12 +172,38 @@ def compute_winners():
rows = rankings[col]
filtered = [(t, c) for t, c in rows if t not in excluded]
if not filtered:
- winners[col] = None
+ winners[col] = {"status": "empty"}
continue
- winner, votes = filtered[0]
- runner = filtered[1][1] if len(filtered) > 1 else 0
- winners[col] = (winner, votes, votes - runner, filtered)
+
+ top_votes = filtered[0][1]
+ tied = [t for t, c in filtered if c == top_votes]
+
+ if len(tied) > 1:
+ decision = tie_decisions.get(col, "")
+ chosen, _, method = decision.partition("||")
+ if chosen and chosen in tied:
+ winner = chosen
+ else:
+ winners[col] = {
+ "status": "tie",
+ "tied": tied,
+ "votes": top_votes,
+ }
+ continue
+ else:
+ winner = filtered[0][0]
+ method = ""
+
+ runner_votes = next((c for t, c in filtered if t != winner), 0)
+ winners[col] = {
+ "status": "ok",
+ "team": winner,
+ "votes": top_votes,
+ "diff": top_votes - runner_votes,
+ "method": method,
+ }
excluded.add(winner)
+
return winners, rankings
@@ -280,9 +358,22 @@ def render_admin():
)
winners, rankings = compute_winners()
- awarded_teams = {w[0] for w in winners.values() if w}
+ awarded_teams = {
+ w["team"] for w in winners.values() if w.get("status") == "ok"
+ }
public_lines = []
+ # 동률 미해결 부문 먼저 표시 (진행자 액션 필요)
+ pending_ties = [
+ (col, label, prize)
+ for col, label, prize in CATEGORIES
+ if winners.get(col, {}).get("status") == "tie"
+ ]
+ if pending_ties:
+ st.error(
+ f"⚠️ 동률 미해결 {len(pending_ties)}건. 시상 전 추첨/선택 필요."
+ )
+
for col, label, prize in CATEGORIES:
rows = rankings[col]
st.markdown(f"### {label} ({prize})")
@@ -290,18 +381,61 @@ def render_admin():
st.caption("표 없음")
continue
- result = winners.get(col)
- if not result:
+ result = winners.get(col, {})
+ status = result.get("status")
+
+ if status == "empty":
st.warning("후보 없음 (모두 우선순위 상 수상)")
continue
- winner_team, winner_votes, diff, _ = result
+ if status == "tie":
+ tied = result["tied"]
+ votes = result["votes"]
+ tied_labels = ", ".join(fmt_team(t, titles) for t in tied)
+ st.warning(f"🟰 동률 ({votes}표): {tied_labels}")
+ ca, cb = st.columns([1, 2])
+ with ca:
+ if st.button("🎲 즉석 추첨", key=f"draw_{col}"):
+ import random as _r
+ chosen = _r.choice(tied)
+ save_tie_break(col, chosen, "random")
+ st.rerun()
+ with cb:
+ manual = st.selectbox(
+ "수동 선택",
+ tied,
+ index=None,
+ key=f"manual_{col}",
+ format_func=lambda t: fmt_team(t, titles),
+ placeholder="팀 선택",
+ )
+ if manual and st.button("확정", key=f"manual_btn_{col}"):
+ save_tie_break(col, manual, "manual")
+ st.rerun()
+ continue
+
+ # status == "ok"
+ winner_team = result["team"]
+ winner_votes = result["votes"]
+ diff = result["diff"]
+ method = result.get("method", "")
winner_label = fmt_team(winner_team, titles)
+ method_tag = ""
+ if method == "random":
+ method_tag = " 🎲(추첨)"
+ elif method == "manual":
+ method_tag = " 🖊️(수동)"
st.success(
- f"**우승: {winner_label}** — {winner_votes}표 (2위와 {diff}표 차이)"
+ f"**우승: {winner_label}**{method_tag} — {winner_votes}표 "
+ f"(2위와 {diff}표 차이)"
)
+ if method:
+ if st.button(f"동률 결정 취소 (재추첨)", key=f"clear_{col}"):
+ clear_tie_break(col)
+ st.rerun()
public_lines.append(
- f"- {label} 우승: **{winner_label}** ({winner_votes}표, 2위와 {diff}표 차이)"
+ f"- {label} 우승: **{winner_label}** "
+ f"({winner_votes}표, 2위와 {diff}표 차이)"
)
with st.expander("전체 분포 — raw (제외 적용 전)"):
@@ -379,13 +513,27 @@ def render_ceremony():
titles = get_titles()
winners, _ = compute_winners()
+ # 동률 미해결 있으면 ceremony 차단
+ pending = [
+ (col, label) for col, label, _ in CATEGORIES
+ if winners.get(col, {}).get("status") == "tie"
+ ]
+ if pending:
+ st.error("⚠️ 동률 미해결 — 어드민 페이지에서 추첨/선택 후 재진입")
+ for col, label in pending:
+ tied = winners[col]["tied"]
+ tied_labels = ", ".join(fmt_team(t, titles) for t in tied)
+ st.write(f"- {label}: {tied_labels}")
+ return
+
# CATEGORIES 순서로 reveal (손선풍기 → 양우산 → 팜레스트)
results = []
for col, label, prize in CATEGORIES:
- result = winners.get(col)
- if result:
- winner, votes, diff, _ = result
- results.append((label, prize, winner, votes, diff))
+ result = winners.get(col, {})
+ if result.get("status") == "ok":
+ results.append(
+ (label, prize, result["team"], result["votes"], result["diff"])
+ )
if "ceremony_step" not in st.session_state:
st.session_state.ceremony_step = 0