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:
182
app.py
182
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
|
||||
|
||||
Reference in New Issue
Block a user