feat: 동률 처리 - 즉석 추첨 또는 수동 선택

- 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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-04-25 20:12:46 +09:00
parent aac609eb59
commit f689da3c6e

186
app.py
View File

@@ -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
winner_label = fmt_team(winner_team, titles)
st.success(
f"**우승: {winner_label}** — {winner_votes}표 (2위와 {diff}표 차이)"
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}**{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