# Absolute Majority / Early Finalization — 6 ตุลาคม 2026

เพิ่ม early finalization สำหรับโหวตเมืองและข้อเสนอ Agency โดยใช้ผู้มีสิทธิ์ทั้งหมดตามกฎเดิม ไม่มีการเปลี่ยน schema ของฐานแอปหรือ timeout

## CHANGED

| ไฟล์ | สิ่งที่เปลี่ยน |
|---|---|
| `server/absolute_majority.dart:2` | Helper กลางสำหรับ threshold และตรวจผู้ชนะคนเดียว |
| `server/voting_policy.dart:57` | Policy early finalize ของ Agency และรักษาผู้ชนะเดิมเมื่อ finalized แล้ว |
| `server/destination_poll_service.dart:137` | ตรวจ majority ก่อน quorum/timeout เดิมใน region_city; จัดลำดับ parent/member locks และใช้ locking read ตอนนับ votes |
| `server/gemini_proxy.dart:1913` | Vote handler ตรวจ majority หลัง upsert ภายใน transaction เดียว; API เพิ่ม `status`, `winnerBidId`, `voting` |
| `server/gemini_proxy.dart:2142` | แยกขั้นตอน persistence เดิมเป็น `_recordVotingWinner` เพื่อให้ early/explicit finalize ใช้ร่วมกัน รวม notification เดิม |
| `server/gemini_proxy.dart:2244` | Lock กลุ่มก่อนรอบโหวต และไม่ทำ INSERT ... SELECT ซ้ำสำหรับรอบที่มีอยู่ เพื่อหลีกเลี่ยง deadlock |
| `lib/pages/voting_page.dart:96` | Refresh state ก่อนแสดงข้อความหลัง vote; เมื่อ finalized ใช้ winner/ปุ่ม payment และหยุด countdown ตาม flow เดิม |
| `lib/pages/destination_poll_page.dart:98` | ใช้ state จาก vote response แสดงข้อความ completed เมื่อ finalized; ปุ่ม/checkbox ปิดตาม status เดิม |

Endpoint เดิมยังเป็น `/api/votes`, `/api/votes/finalize`, `/api/destination-polls`, `/api/destination-polls/vote` ไม่มี route ใหม่ ชื่อ Dart handler ของ Agency เปิดให้ integration tests เรียก implementation เดียวกับ API ได้ โดย authentication ที่ router ไม่เปลี่ยน

## MAJORITY RULE

```dart
required = (eligibleVoterCount ~/ 2) + 1;
```

| ผู้มีสิทธิ์ | เสียงที่ต้องได้ |
|---:|---:|
| 3 | 2 |
| 4 | 3 |
| 5 | 3 |
| 6 | 4 |

- Destination: สมาชิกจาก `group_members` ของกลุ่มที่ join กับ `users.status='active'` ตามกฎเดิม ไม่เพิ่มข้อกำหนด `users.group_id` ให้ flow นี้
- Agency: สมาชิกจาก `group_members` ที่ `users.status='active'` และ `users.group_id=group_members.group_id` ตามกฎเดิมของ voting round
- Early majority counts ของ Agency กรอง votes ด้วย electorate เดียวกัน; Destination reuse tally เดิมที่ deduplicate user/option และกรองสมาชิกที่หมดสิทธิ์
- ไม่ใช้จำนวนคนที่ส่ง vote มาเป็นตัวหาร ไม่เปลี่ยน join_type หรือกฎสมาชิก
- สมาชิกที่ออก/ถูกระงับไม่นับใน early majority เมื่อเดิมไม่อยู่ใน electorate แล้ว

## DESTINATION VOTE

ทันทีที่เมืองได้ majority จะใช้ `_reconcilePollRound` เดิม snapshot คะแนนและจำนวนผู้มีสิทธิ์, บันทึก `winning_option_id`, เปลี่ยน round เป็น `finalized` และ parent poll เป็น `closed` แล้วเปิด place round สำหรับเมืองนั้นตาม flow เดิม

Vote หลัง finalized ถูกปฏิเสธด้วย HTTP 409 ก่อน DELETE/INSERT ballot จึงเปลี่ยน winner ไม่ได้ API ส่ง `status=finalized`, `resultIsFinal=true`, `groupCity` และคะแนน snapshot กลับให้ Flutter

ถ้ายังไม่มี majority ใช้ quorum/deadline/tie-only round เดิม ตัวอย่าง 2-2-1 ของ 5 คนเปิดรอบตัดสินเฉพาะตัวเลือกที่เสมอ

Place/activity poll เลือกได้หลายรายการและสรุปเป็นอันดับ จึงรักษากฎเดิม ไม่บังคับให้กลายเป็น single-winner election

## AGENCY VOTE

หลัง upsert ballot ตรวจ early majority ก่อนเงื่อนไข all-voted/deadline เดิม เมื่อถึงเกณฑ์ ใช้ขั้นตอน persistence เดียวกับ explicit finalize เพื่อบันทึก `winner_bid_id`, เลือก bid เดียวเป็น `selected`, ตั้ง `status=finalized`/`finalized_at` และสร้าง notification เดิม ภายใน transaction ของ vote

Flutter refresh offers/state ทันที แสดง winner, ปิดปุ่ม vote, หยุด countdown และแสดงปุ่มไปหน้าชำระเงินเดิมโดยไม่ต้องกด finalize เพิ่ม

ถ้าไม่มี majority ยังเปิดต่อ ถ้าทุกคนโหวตครบหรือหมดเวลา จะปิดรอบและรอ explicit finalize ตามกฎเดิม รวม tie-break: คะแนนมากกว่า → ราคาต่ำกว่า → ส่งก่อน → bid ID

## CONCURRENCY

- Agency: ล็อก `tour_groups` ก่อน voting round แล้วล็อก electorate/tally ขณะตรวจ majority; request ที่ตามมารอ transaction ก่อนหน้าและเห็น finalized ก่อนแก้ ballot
- Destination: parent polls ล็อกก่อน member rows ทั้ง read/vote paths; latest round และ tally ใช้ `FOR UPDATE` เพื่ออ่าน votes ที่ commit ล่าสุด
- Winner และสถานะบันทึกใน transaction เดียว มี guard ของ terminal state/conditional status update เดิม
- Agency explicit finalize ซ้ำคืน winner เดิม พร้อม `alreadyFinalized=true`
- Notification ใช้ dedupe key เดิม และ one-vote constraints เดิมยังใช้อยู่
- Vote ซ้ำก่อนปิดรอบยัง update/replace ballot เดิม ไม่เพิ่มคะแนนของคนเดิม

## TESTS

เพิ่ม 35 cases: unit policy 19, widget 2, MariaDB transaction integration 14

### Cases ใหม่

- Destination และ Agency: 1/3 ยังไม่ finalize; 2/3 finalize
- Destination และ Agency: 2/4 ยังไม่ finalize; 3/4 finalize
- Destination และ Agency: 3/5 finalize
- Unit policies เพิ่ม 3/6 ไม่ finalize และ 4/6 finalize
- 2-2-1 ไม่มี early majority และ fallback/tie-break เดิมยังทำงาน
- ผู้ชนะคงเดิมเมื่อ vote/finalize หลังประกาศผล
- Vote ซ้ำของสมาชิกหนึ่งคนยังมี ballot เดียว
- สมาชิกที่ออก/ถูกระงับและ duplicate rows ไม่เพิ่ม early majority
- Concurrent final votes ผ่าน real MySQL row locks/transactions: มี city winner/selected bid เพียงหนึ่งผล ไม่มี deadlock และไม่มี notification ซ้ำ
- City widget แสดง Tokyo เมื่อ 2/3, ปิด checkbox, เปิดสถานที่ของเมืองเดิม
- Agency widget refresh ทันที หยุด countdown ปิด vote/finalize button และเปิด payment route เดิม

### ผลรันสุดท้าย

- `dart analyze server`: No issues found
- `flutter analyze`: No issues found
- Voting/poll policies, contracts, migration checks และ Flutter tests ที่เกี่ยวข้อง: **87 passed**
- Real MariaDB integration: **14 passed**
- Regression ชุดกว้าง `test/server` + widgets/app tests: **219 passed, 15 skipped, 1 failed**
- Failure ที่เหลือเป็น test เดิม `matching_waiting_policy_test.dart: default waiting window is one minute`: test คาด 1 นาที แต่ `MatchingWaitingPolicy.defaultDuration` ปัจจุบันเป็น 5 นาที ไฟล์ Matching และ timeout ไม่ได้แก้ตามข้อห้ามของงานนี้
- 15 skipped ในชุดกว้างเป็น opt-in database tests ที่ต้องกำหนด isolated schema; majority integration 14 cases ถูกรันแยกจริงผ่าน runner แล้ว

คำสั่งรันซ้ำ:

```powershell
dart analyze server
flutter analyze
flutter test test/server/absolute_majority_test.dart test/server/voting_policy_test.dart test/server/voting_migration_test.dart test/server/destination_poll_policy_test.dart test/server/destination_poll_contract_test.dart test/widgets/absolute_majority_vote_test.dart test/widgets/destination_poll_entry_test.dart test/widget_test.dart
python work/run_majority_database_tests.py
```

Runner สร้างฐานชั่วคราวชื่อ unique `tourapp_majority_test_*` จาก schema-only dump และสร้างบัญชีชั่วคราวที่มีสิทธิ์เฉพาะฐานนั้น จากนั้นลบฐาน/บัญชีที่สร้างเมื่อจบ ตรวจยืนยันว่าไม่มี test schema/account ค้าง และ checksum ของ `tourist_grouping_db` ก่อน/หลังตรงกันทั้ง 33 tables

Logs: `work/absolute_majority_tests.log`, `work/absolute_majority_database_tests.log`, `work/absolute_majority_regression_tests.log`

## NOT CHANGED

Matching algorithm, AI parsing, membership rules, Payment validation/processing, SlipOK, PromptPay, DB schema/migrations, timeout, tie-breaking และ multi-select place ranking คงเดิม

ไม่ได้เปิด backend กับฐานแอปเพื่อสร้าง vote/payment ทดสอบ ไม่ได้ส่ง Gemini/SlipOK request และไม่ได้แสดง secret
