# TourApp — โจทย์ซ้อมแก้โค้ดสดพร้อมเฉลย

ตรวจจาก source ปัจจุบันและ metadata ของฐานข้อมูล `tourist_grouping_db` วันที่ 6 ตุลาคม 2026 เอกสารนี้เสนอการแก้สำหรับฝึกเท่านั้น **ยังไม่ได้แก้ source code หรือข้อมูลธุรกิจ** เลขบรรทัดอาจเปลี่ยนได้ จึงให้ค้นชื่อฟังก์ชันหรือข้อความที่ระบุเป็นหลัก

ตรวจเอกสารแล้ว: ครบ 20 ข้อและหัวข้อที่ขอทุกข้อ, ไฟล์อ้างอิง 34 ไฟล์มีอยู่จริง, SQL ทั้งสามข้อผ่าน EXPLAIN บน schema ปัจจุบัน และตัวอย่าง read-only transaction ผ่านการตรวจ syntax โดยใช้ groupId/agencyId ที่ไม่คืนข้อมูล การตรวจนี้ยืนยันชื่อและโครงสร้าง query ส่วนผลตัวเลขให้ตรวจด้วย fixture ตามวิธีเช็กแต่ละข้อ เช็ก checksum source/test/SQL/config ที่เกี่ยวข้อง 208 ไฟล์ก่อน–หลังแล้วตรงกัน

โฟลเดอร์ทำงานหลักคือ `C:\Users\Admin\Downloads\TourAppProject\travelin_app` คำสั่ง Flutter/Dart ในแต่ละข้อให้รันจากโฟลเดอร์นี้ ส่วนคำสั่ง Portal ให้รันจาก `travelin_app\portal` ด้วย PowerShell ใช้ `npm.cmd` เพื่อหลีกเลี่ยงปัญหา execution policy ของ `npm.ps1` โดยไม่ต้องเปลี่ยน dependency หรือสร้าง lockfile ใหม่

วิธีซ้อม: อ่านเฉพาะโจทย์และ Hint ตั้งเวลา ลงมือในสำเนาสำหรับฝึก แล้วเปิดเฉลยและพูดคำตอบให้กรรมการฟัง เลือกทำทีละข้อจาก baseline เดิม เพราะบางข้อแก้จุดเดียวกัน สำหรับ SQL ใช้ `:groupId` / `:agencyId` เป็น named parameters ใน Dart `connection.execute(sql, {...})`; ถ้าใช้ SQL console ให้แทนด้วยตัวเลขของข้อมูลฝึกที่ตรวจสอบแล้ว ไม่ใช่การต่อ string จาก input

Bug Hunt ข้อ 16–19 เป็น **บั๊กจำลอง** ไม่ใช่ข้อสรุปว่าระบบปัจจุบันมีบั๊กเหล่านี้ เฉลยหลายข้อแสดงการคืน guard ที่ source ปัจจุบันมีอยู่แล้ว การทดสอบที่เขียนไว้เป็นคำสั่งสำหรับหลังลงมือฝึก ไม่ใช่ผลทดสอบการแก้ที่เกิดขึ้นในงานสร้างเอกสารนี้

| ข้อ | ระดับ / เวลา | หัวข้อ | จุดหลัก |
| --- | --- | --- | --- |
| 1 | ง่าย 3–5 นาที | เปลี่ยนข้อความรอจับคู่ | Flutter UI |
| 2 | ง่าย 3–5 นาที | Countdown เป็น 04:32 | Flutter / Matching |
| 3 | ง่าย 3–5 นาที | ข้อความรอตรวจสลิป | Flutter / Payment status |
| 4 | ง่าย 3–5 นาที | Hint ความคิดเห็นรีวิว | Flutter / Review |
| 5 | ง่าย 3–5 นาที | รีวิวล่าสุดจาก 3 เป็น 4 | Backend / Dashboard |
| 6 | ง่าย 3–5 นาที | ข้อความเมื่อยังไม่มีรีวิว | React empty state |
| 7 | ง่าย 3–5 นาที | จำนวนรีวิวบนปุ่มเปิด Modal | React interaction |
| 8 | กลาง 5–10 นาที | ความคิดเห็นไม่ว่างต้องยาว 10 ตัว | Backend + Flutter validation |
| 9 | กลาง 5–10 นาที | จำกัดข้อความแชต 500 ตัว | Flutter state + Backend |
| 10 | กลาง 5–10 นาที | Filter ข้อเสนอที่รอพิจารณา | React bidding |
| 11 | กลาง 5–10 นาที | SQL นับสมาชิกแต่ละกลุ่ม | SELECT / LEFT JOIN / COUNT |
| 12 | กลาง 5–10 นาที | SQL คะแนนเฉลี่ยบริษัท | JOIN / EXISTS / AVG |
| 13 | กลาง 5–10 นาที | SQL อันดับข้อเสนอ | COUNT / eligibility |
| 14 | กลาง 5–10 นาที | Test เสียงเกินครึ่งกลุ่มใหญ่ขึ้น | Absolute Majority |
| 15 | กลาง 5–10 นาที | API วันรีวิวล่าสุด | Endpoint / JSON / React |
| 16 | ยาก 10–15 นาที | Bug Hunt: ไม่มีรีวิวแต่ขึ้น 0 ดาว | Null contract |
| 17 | ยาก 10–15 นาที | Bug Hunt: เปิดหน้าใหม่แล้วได้เวลาใหม่ | Matching deadline |
| 18 | ยาก 10–15 นาที | Bug Hunt: ครึ่งหนึ่งกลายเป็นผู้ชนะ | Voting rule |
| 19 | ยาก 10–15 นาที | Bug Hunt: commit คะแนนก่อนสรุปผล | Transaction / concurrency |
| 20 | ยาก 10–15 นาที | API บอกจำนวนเสียงที่ต้องได้ | Backend + Flutter contract |

ข้อเท็จจริงที่ใช้ในเฉลย: Gemini แปลงภาษาธรรมชาติเป็น preference; Matching Engine จัดกลุ่มจริงแบบ deterministic และ destination เป็น hard constraint ค่าเวลารอ default อยู่ที่ Backend 5 นาที รีวิวบริษัทใช้ `agency_rating` และรีวิวจริงต้องผ่านเงื่อนไข paid/completed ที่ repository กรองไว้ Demo reviews อยู่ใน memory และเปิดด้วย environment + flag ทั้งสองเงื่อนไข Endpoint Portal รีวิวใช้ **POST `/api/agency/reviews`** ตาม API ปัจจุบัน และระบุบริษัทจาก session ไม่รับบริษัทที่ผู้ใช้ส่งมาเป็นสิทธิ์เข้าถึง

## ข้อ 1 — เปลี่ยนข้อความหน้ารอจับคู่

ระดับง่าย · 3–5 นาที

### กรรมการสั่งว่า

“เปลี่ยนข้อความตอนกำลังรอเป็น ‘กำลังหาเพื่อนที่เที่ยวสไตล์เดียวกัน’ ให้ดูหน่อย”

### สิ่งที่ต้องทำ

- เปลี่ยนข้อความหลักใน waiting state
- รักษาปุ่มยกเลิกและการแสดง countdown

### จุดที่ควรหาในโปรเจกต์

`lib/widgets/matching_status_view.dart` → `MatchingStatusView._waiting()` ค้น `กำลังค้นหาเพื่อนร่วมทริป...`

### Hint

ข้อความอยู่ใน `const Text` ไม่ต้องแก้ service

### วิธีคิด

เริ่มจากข้อความที่เห็นบนจอแล้วค้น string ใน source ข้อนี้เปลี่ยนการสื่อสารบน UI จึงไม่ต้องเปลี่ยนเงื่อนไขจับกลุ่ม

## เฉลย

แก้เฉพาะ string ใน `_waiting()`:

```diff
- 'กำลังค้นหาเพื่อนร่วมทริป...',
+ 'กำลังหาเพื่อนที่เที่ยวสไตล์เดียวกัน',
```

ระวัง: อย่าเพิ่มปุ่ม Join ใน automatic matching flow และถ้า test เดิมตรวจข้อความเก่า ให้ปรับ expectation เฉพาะข้อความนี้

## วิธีเช็กว่าแก้ถูก

`flutter analyze` และ `flutter test test/widgets/matching_status_view_test.dart`

Manual: ใช้ preference ที่ยัง waiting ตรวจข้อความใหม่ ขนาดตัวอักษร และปุ่มยกเลิกทั้งจอแคบ/จอปกติ

## ตอบกรรมการว่าอย่างไร

“ผมเปลี่ยนเฉพาะข้อความใน waiting widget ครับ ส่วนการค้นหากลุ่มและเวลารอ ยังใช้ข้อมูลจาก Backend เหมือนเดิม”

## ข้อ 2 — แสดง Countdown แบบ 04:32

ระดับง่าย · 3–5 นาที

### กรรมการสั่งว่า

“เวลารอจับคู่ตอนนี้อ่านยาวไป ขอให้เหลือเป็น 04:32 และหมดเวลาเป็น 00:00”

### สิ่งที่ต้องทำ

- เปลี่ยน formatter ให้เป็นนาทีรวมและวินาทีสองหลัก
- คงการ clamp ค่าไม่ให้ติดลบ
- แก้ expectation ข้อความ countdown ใน widget test ที่เกี่ยวข้อง

### จุดที่ควรหาในโปรเจกต์

`lib/widgets/matching_status_view.dart` → `_formatRemaining(int seconds)` และ `_WaitingCountdownState.build()`; `test/widgets/matching_status_view_test.dart`

### Hint

ใช้ `~/ 60`, `% 60` และ `padLeft(2, '0')` ถ้าเกินชั่วโมงให้นาทีเป็นนาทีรวม เช่น 65:00

### วิธีคิด

แก้เฉพาะรูปแบบข้อความ ฟังก์ชัน `_syncDeadline()` และ `_remainingAt()` มีหน้าที่คำนวณเวลาจริงอยู่แล้ว

## เฉลย

ก่อนแก้ formatter มี `hours`, `minutes` และคืนข้อความ `นาที ... วินาที` ให้แทนฟังก์ชันนี้ด้วย:

```dart
String _formatRemaining(int seconds) {
  final total = seconds.clamp(0, 0x7fffffff);
  final minutes = (total ~/ 60).toString().padLeft(2, '0');
  final remainder = (total % 60).toString().padLeft(2, '0');
  return '$minutes:$remainder';
}
```

ข้อความครอบยังเป็น `'เหลือเวลา ${_formatRemaining(_remainingSeconds)}'` จึงเห็น `เหลือเวลา 04:32` ระวัง: formatter เป็น private ให้ทดสอบผ่าน widget key `matching-waiting-countdown`; อย่าเริ่ม deadline ใหม่จาก `DateTime.now()`

## วิธีเช็กว่าแก้ถูก

`flutter analyze` และ `flutter test test/widgets/matching_status_view_test.dart`

Manual: ลอง 272, 60, 9 และ 0 วินาที ต้องเห็น 04:32, 01:00, 00:09 และ 00:00 ตามลำดับ ปล่อยให้หมดเวลาต้องยัง refresh สถานะได้

## ตอบกรรมการว่าอย่างไร

“ผมแก้ formatter เท่านั้นครับ จำนวนนาทีและวินาทีมาจาก deadline เดิม จึงไม่เปลี่ยนเวลารอจริงของระบบ”

## ข้อ 3 — อธิบายสถานะรอตรวจสลิปให้ชัด

ระดับง่าย · 3–5 นาที

### กรรมการสั่งว่า

“ถ้าส่งหลักฐานแล้วแต่ยังตรวจไม่เสร็จ ขอให้ข้อความบอกผู้ใช้ชัด ๆ ว่ายังไม่ยืนยันการชำระ”

### สิ่งที่ต้องทำ

- เปลี่ยน label เฉพาะ `waiting_verify`
- คงสถานะ paid และ failure ที่ได้จาก API

### จุดที่ควรหาในโปรเจกต์

`lib/pages/payment_page.dart` → `_PaymentStatus.build()`; `lib/services/group_service.dart` → `PaymentRecord.fromJson()`

### Hint

แก้ branch ใน switch ของ `record.status`

### วิธีคิด

สถานะรอตรวจและชำระแล้วมีความหมายต่างกัน UI ต้องสื่อจาก response จริง การเปลี่ยนคำไม่ต้องแตะ SlipOK หรือจำนวนเงิน

## เฉลย

```diff
  'waiting_verify' => (
-   'ส่งหลักฐานแล้ว กำลังรอตรวจสอบ',
+   'ส่งหลักฐานแล้ว รอตรวจสอบก่อนยืนยันการชำระเงิน',
    const Color(0xFFB45309),
    Icons.hourglass_top_rounded,
  ),
```

ระวัง: ห้ามเปลี่ยน status เป็น `paid` หลัง upload และห้ามลบ duplicate-slip/verification guard เพื่อให้เดโมดูสำเร็จ

## วิธีเช็กว่าแก้ถูก

`flutter analyze` และ `flutter test test/group_service_test.dart`

Manual: ใช้ mock response หรือบัญชีฝึก ตรวจ waiting_verify, paid และ verification_failed แยกกัน ข้อความยาวต้องไม่ล้นจอ ไม่ต้องส่งเงินจริง

## ตอบกรรมการว่าอย่างไร

“ผมปรับคำอธิบายของ waiting_verify ครับ จะขึ้นชำระแล้วได้ต่อเมื่อ Backend ยืนยันสถานะ paid การตรวจสลิปยังทำงานตามเดิม”

## ข้อ 4 — เพิ่มคำแนะนำในช่องความคิดเห็นรีวิว

ระดับง่าย · 3–5 นาที

### กรรมการสั่งว่า

“เพิ่มข้อความใต้ช่องรีวิวว่าเขียนได้ไม่เกิน 500 ตัวอักษร และไม่ควรใส่ข้อมูลติดต่อ”

### สิ่งที่ต้องทำ

- เพิ่ม helper text ในฟอร์มรีวิวหลังทริป
- รักษา `maxLength: 500` และการ trim ก่อนส่ง

### จุดที่ควรหาในโปรเจกต์

`lib/pages/group_messages_page.dart` → `_TripReviewSheetState` ช่อง key `trip-review-comment`; Backend `_submitJourneyReview()` ใน `server/gemini_proxy.dart`

### Hint

เพิ่ม `helperText` ใน `InputDecoration` เดิม

### วิธีคิด

UI มีช่องจำกัดความยาวอยู่แล้ว ส่วน Backend ก็ตรวจ 500 ตัวอักษร ข้อนี้เพิ่มคำแนะนำให้ผู้ใช้เห็นก่อนกรอก

## เฉลย

```diff
  decoration: const InputDecoration(
    labelText: 'เล่าความประทับใจเพิ่มเติม (ไม่บังคับ)',
+   helperText: 'ไม่เกิน 500 ตัวอักษร กรุณาไม่ใส่ข้อมูลติดต่อ',
+   helperMaxLines: 2,
    alignLabelWithHint: true,
  ),
```

ระวัง: คงเงื่อนไขสิทธิ์รีวิว paid/completed ฝั่ง Backend และการปกปิดข้อมูลติดต่อใน JSON รีวิวบริษัท

## วิธีเช็กว่าแก้ถูก

`flutter analyze`

Manual: เปิด review sheet ด้วยบัญชีฝึกที่มีสิทธิ์ ดู helper บนจอแคบ ลองพิมพ์ถึงขอบ 500 ตัวและลบกลับ ตรวจว่าคะแนนดาว/ปุ่มส่งยังทำงาน

## ตอบกรรมการว่าอย่างไร

“ผมเพิ่มคำแนะนำในฟอร์มโดยใช้ขีดจำกัดเดิมครับ ผู้ใช้เห็นข้อกำหนดก่อนส่ง และ Backend ยังตรวจข้อมูลซ้ำอีกชั้น”

## ข้อ 5 — เพิ่มรีวิวล่าสุดบน Dashboard เป็น 4 รายการ

ระดับง่าย · 3–5 นาที

### กรรมการสั่งว่า

“Dashboard ตอนนี้มีรีวิวล่าสุดสามใบ ขอเป็นสี่ใบ แต่คะแนนเฉลี่ยต้องคำนวณจากทั้งหมดเหมือนเดิม”

### สิ่งที่ต้องทำ

- เพิ่มจำนวน `latestReviews` เป็น 4
- ปรับ test จำนวนและลำดับ
- ตรวจ Portal ว่า card ใบที่สี่ยังเรียงได้

### จุดที่ควรหาในโปรเจกต์

`server/agency_review_service.dart` → `AgencyReviewService.readForAgency()`; `test/server/agency_review_service_test.dart` → test `latest three reviews...`; `portal/src/components/AgencyReviewsCard.tsx`

### Hint

Portal map `summary.latestReviews` อยู่แล้ว ไม่ต้อง slice ซ้ำ

### วิธีคิด

เปลี่ยนจำนวน preview หลังคำนวณ mean/count และ sort เสร็จ จึงไม่กระทบจำนวนรีวิวจริงหรือ Modal ทั้งหมด

## เฉลย

```diff
- 'latestReviews': output.take(3).toList(),
+ 'latestReviews': output.take(4).toList(),
```

ใน test เดิมมี fixture 4 รีวิวอยู่แล้ว เปลี่ยนชื่อ test เป็น `latest four...` และ expectation ID จาก `['4', '3', '2']` เป็น `['4', '3', '2', '1']` คง expectation ว่า `reviews.length == 4`

ระวัง: ห้ามเปลี่ยน `reviewCount` เป็น `latestReviews.length` เพราะ preview เป็นเพียงบางส่วน

## วิธีเช็กว่าแก้ถูก

`dart analyze server` และ `flutter test test/server/agency_review_service_test.dart`; ใน Portal: `npm.cmd run build` และ `npm.cmd run lint`

Manual: fixture มี 0, 2, 4 และ 6 รีวิว ต้องเห็น preview ไม่เกิน 4 แต่ Modal แสดงครบ และ mean/count เท่าเดิม

## ตอบกรรมการว่าอย่างไร

“ผมเปลี่ยนเฉพาะจำนวนรายการล่าสุดที่ส่งให้ Dashboard ครับ คะแนนเฉลี่ยและจำนวนรีวิวยังใช้รายการทั้งหมด จึงไม่เปลี่ยนตามจำนวนการ์ดที่แสดง”

## ข้อ 6 — Empty state รีวิวให้อ่านรู้เรื่อง

ระดับง่าย · 3–5 นาที

### กรรมการสั่งว่า

“บริษัทที่ยังไม่มีรีวิว ขอข้อความอธิบายว่าคะแนนจะมาเมื่อมีรีวิวหลังจบทริป”

### สิ่งที่ต้องทำ

- เปลี่ยนข้อความกรณี count เท่ากับ 0
- รักษาการไม่แสดงดาว/ปุ่มดูทั้งหมดในกรณีว่าง

### จุดที่ควรหาในโปรเจกต์

`portal/src/components/AgencyReviewsCard.tsx` → `RatingSummary()` และ branch `summary.reviewCount > 0`; Dashboard อยู่ `portal/src/pages/AgencyDashboardPages.tsx`

### Hint

แก้ return แรกของ RatingSummary

### วิธีคิด

ค่า averageRating เป็น null เมื่อไม่มีรีวิว จึงใช้ count ตรวจ empty state ก่อน render ตัวเลข

## เฉลย

```diff
- if (summary.reviewCount === 0) return <p className="review-empty">ยังไม่มีรีวิวจากนักท่องเที่ยว</p>;
+ if (summary.reviewCount === 0) return <p className="review-empty">ยังไม่มีรีวิว คะแนนจะแสดงเมื่อมีรีวิวจากนักท่องเที่ยวหลังจบทริป</p>;
```

ระวัง: ค่า 0 รีวิวไม่ใช่คะแนน 0 ดาว และข้อมูล demo ที่เปิดใช้อยู่จะมี count มากกว่า 0 ต้องใช้ fixture empty ที่ปิด demo เพื่อทดสอบ

## วิธีเช็กว่าแก้ถูก

ใน Portal: `npm.cmd run build` และ `npm.cmd run lint`

Manual: ใช้ response `{ averageRating: null, reviewCount: 0, latestReviews: [], reviews: [], source: 'empty', isDemo: false }` ต้องเห็นข้อความใหม่โดยไม่มีดาวและปุ่มดูทั้งหมด

## ตอบกรรมการว่าอย่างไร

“ผมตรวจจำนวนรีวิวก่อนแสดงคะแนนครับ ถ้ายังไม่มีข้อมูลจะบอกเหตุผลแทนการแสดงศูนย์ดาว ซึ่งอาจทำให้เข้าใจว่าบริษัทได้คะแนนไม่ดี”

## ข้อ 7 — ปุ่มดูรีวิวทั้งหมดบอกจำนวน

ระดับง่าย · 3–5 นาที

### กรรมการสั่งว่า

“ปุ่มดูรีวิวทั้งหมดช่วยบอกด้วยว่ามีกี่รายการ และกดแล้วต้องเปิดหน้าต่างเดิมได้”

### สิ่งที่ต้องทำ

- ใส่ `reviewCount` ลงใน label ปุ่ม
- คงการ reset scroll และเปิด dialog

### จุดที่ควรหาในโปรเจกต์

`portal/src/components/AgencyReviewsCard.tsx` → ปุ่ม class `review-all-button`, refs `dialog` และ `reviewList`

### Hint

เปลี่ยน child ของ button ให้เป็น template literal

### วิธีคิด

ใช้ count จาก API ซึ่งนับรายการทั้งหมด ไม่ใช่ preview ปุ่มอยู่ภายใน branch ที่ summary ไม่ null และมีรีวิวอยู่แล้ว

## เฉลย

```diff
- }}>ดูรีวิวทั้งหมด</button>
+ }}>{`ดูรีวิวทั้งหมด (${summary.reviewCount})`}</button>
```

คง handler:

```tsx
reviewList.current?.scrollTo({ top: 0 });
dialog.current?.showModal();
```

ระวัง: ยังคง badge ข้อมูลสาธิตสำหรับ demo และใช้ native dialog เดิม ไม่ต้องเพิ่ม dependency

## วิธีเช็กว่าแก้ถูก

ใน Portal: `npm.cmd run build` และ `npm.cmd run lint`

Manual: 6 รีวิวต้องขึ้น `(6)` แม้ preview มี 3 กดเปิด เลื่อน ปิดด้วยปุ่ม/Escape แล้วเปิดใหม่ต้องเริ่มบนสุด กรณี empty ไม่มีปุ่ม

## ตอบกรรมการว่าอย่างไร

“ผมแสดงจำนวนทั้งหมดจาก response ครับ และใช้ dialog เดิม จึงยังเปิด ปิด และเริ่มเลื่อนจากด้านบนได้เหมือนเดิม”

## ข้อ 8 — ความคิดเห็นรีวิวไม่ว่างต้องมีอย่างน้อย 10 ตัวอักษร

ระดับกลาง · 5–10 นาที

### กรรมการสั่งว่า

“ไม่เขียนความคิดเห็นก็ได้ แต่ถ้าเขียนขออย่างน้อย 10 ตัวอักษร ตรวจทั้งแอปและ API ให้หน่อย”

### สิ่งที่ต้องทำ

- Trim ก่อนตรวจ ให้ข้อความว่างยังผ่านได้
- ปฏิเสธข้อความยาว 1–9 หน่วยความยาว และเกิน 500
- ให้ Flutter แจ้งเตือนก่อนปิด review sheet และ Backend คืน 400

### จุดที่ควรหาในโปรเจกต์

`server/gemini_proxy.dart` → `_submitJourneyReview()`; `lib/pages/group_messages_page.dart` → `_TripReviewSheetState`, ปุ่ม key `submit-trip-review`

### Hint

เงื่อนไขคือ `isNotEmpty && length < 10` ไม่ใช่ `length < 10` อย่างเดียว

### วิธีคิด

Backend เป็นผู้บังคับกฎแม้มี client อื่น ส่วน Flutter ตรวจเพื่อให้ผู้ใช้แก้ข้อความได้ทันที กำหนดนิยามความยาวเหมือนกันทั้งสองฝั่ง: ในโจทย์นี้ใช้ Dart `String.length` ซึ่งเป็น UTF-16 code units ตาม validation เดิมของ Backend ไม่อ้างว่าเท่ากับจำนวนตัวอักษรที่เห็นเสมอ

## เฉลย

Backend หลัง `final comment = input['comment']?.toString().trim() ?? '';` และก่อนเปิด connection เพิ่ม:

```dart
if (comment.isNotEmpty && comment.length < 10) {
  throw const DatabaseProxyException(
    'ถ้าเขียนความคิดเห็น กรุณาเขียนอย่างน้อย 10 ตัวอักษร',
    HttpStatus.badRequest,
  );
}
```

คง `comment.length > 500` และ guard คะแนน 1–5 เดิม ใน Flutter เปลี่ยน callback เดิมที่ `Navigator.pop` ทันทีเป็น:

```dart
onPressed: () {
  final text = comment.text.trim();
  if (text.isNotEmpty && text.length < 10 || text.length > 500) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text(
        'เว้นว่างได้ หรือเขียนความคิดเห็น 10–500 ตัวอักษร',
      )),
    );
    return;
  }
  Navigator.pop(context, TripReviewDraft(
    tripRating: tripRating,
    agencyRating: agencyRating,
    comment: text,
  ));
},
```

ระวัง: ไม่เปลี่ยน paid/completed/selected-bid authorization และไม่บังคับให้ทุกคนต้องเขียน comment การเพิ่มกฎนี้มีผลกับ client เดิมด้วย ต้องอธิบายว่าเป็น requirement ใหม่

## วิธีเช็กว่าแก้ถูก

`flutter analyze` และ `dart analyze server`

Manual: ทดสอบ `''`, spaces, `'123456789'`, `'1234567890'`, 500 และ 501 ตัว ผ่านทั้ง UI และ POST `/api/journey/review` ในระบบฝึก ส่ง body พร้อม bidId, tripRating และ agencyRating ที่ถูกต้อง ข้อผิดรูปแบบต้อง 400 ก่อนเขียน DB; กรณีสำเร็จใช้บัญชีฝึกที่จ่ายแล้วและจบทริปเท่านั้น

## ตอบกรรมการว่าอย่างไร

“ผมตรวจหลัง trim ทั้งสองฝั่งครับ ช่องนี้ยังไม่บังคับกรอก แต่ถ้ากรอกต้องยาวพอ ส่วนสิทธิ์รีวิวหลังชำระเงินและจบทริปยังตรวจตามเดิม”

## ข้อ 9 — จำกัดข้อความ Group Chat เหลือ 500

ระดับกลาง · 5–10 นาที

### กรรมการสั่งว่า

“แชตกลุ่มขอข้อความละไม่เกิน 500 ตัว และถ้า API ปฏิเสธต้องเก็บข้อความไว้ให้แก้”

### สิ่งที่ต้องทำ

- เปลี่ยน UI limit และ Backend limit ให้ตรงกัน
- ตรวจความยาวใน `_sendMessage()` ก่อน set sending
- รักษาการ clear ช่องเฉพาะหลังส่งสำเร็จ และคืน sending ใน finally

### จุดที่ควรหาในโปรเจกต์

`lib/pages/group_messages_page.dart` → `_sendMessage()` และ key `group-message-field`; `server/gemini_proxy.dart` → `_sendGroupMessage()`; `lib/services/group_service.dart` → `sendMessage()`

### Hint

source ปัจจุบันจำกัด 1000 อยู่สองฝั่ง และ `_sending` ป้องกันกดซ้ำอยู่แล้ว

### วิธีคิด

ค้น endpoint ที่รับ text แล้วเทียบ validation กับช่องกรอก ถ้าแก้แค่ UI ผู้ใช้ยังส่งข้อความยาวผ่าน API โดยตรงได้

## เฉลย

Flutter:

```diff
  key: const Key('group-message-field'),
  controller: _message,
- maxLength: 1000,
+ maxLength: 500,
```

หลัง guard `if (text.isEmpty || token == null || groupId == null || _sending) { return; }` เพิ่ม:

```dart
if (text.length > 500) {
  setState(() => _error = 'ข้อความต้องไม่เกิน 500 ตัวอักษร');
  return;
}
```

Backend:

```diff
- if (text.length > 1000) {
+ if (text.length > 500) {
    throw const DatabaseProxyException(
-     'ข้อความต้องไม่เกิน 1,000 ตัวอักษร',
+     'ข้อความต้องไม่เกิน 500 ตัวอักษร',
      HttpStatus.badRequest,
    );
  }
```

ระวัง: อย่าย้าย `_message.clear()` ไปก่อน await หรือลง finally เพราะ request ล้มเหลวแล้วข้อความจะหาย อย่าลบ `_sending` guard การนับ `String.length` ยังเป็นหน่วยตามข้อ 8

## วิธีเช็กว่าแก้ถูก

`flutter analyze` และ `dart analyze server`

Manual: กรอก 500 ตัว ส่งสำเร็จในกลุ่มฝึก; POST `/api/groups/messages/send` ด้วย 501 ตัวต้อง 400 ตรวจชื่อ route จาก switch ก่อนเรียก หากจำลอง network error ต้องยังมีข้อความและปุ่มส่งกลับมาใช้งานได้ ลองกดส่งซ้ำเร็ว ๆ ต้องไม่ส่งพร้อมกันจาก UI

## ตอบกรรมการว่าอย่างไร

“ผมจำกัดทั้งช่องกรอกและ API ครับ ตอนส่งจะล็อกปุ่มชั่วคราว และล้างช่องหลังได้รับคำตอบสำเร็จเท่านั้น ถ้าล้มเหลวผู้ใช้แก้แล้วส่งใหม่ได้”

## ข้อ 10 — Filter ข้อเสนอที่กำลังรอพิจารณา

ระดับกลาง · 5–10 นาที

### กรรมการสั่งว่า

“ในรายการข้อเสนอของบริษัท เพิ่ม checkbox ให้ดูเฉพาะข้อเสนอ submitted และถ้าไม่มีให้บอกชัด ๆ”

### สิ่งที่ต้องทำ

- เพิ่ม state checkbox ค่าเริ่มต้น false
- Filter เฉพาะตารางข้อเสนอปัจจุบัน
- รักษาประวัติข้อเสนอไว้และแสดง empty state ของ filter

### จุดที่ควรหาในโปรเจกต์

`portal/src/pages/AgencyBidPages.tsx` → `AgencyBidsPage()`, `activeBids`, `historyBids`, helper `table()`

### Hint

แยก `visibleBids` จาก `activeBids` เพื่อไม่แก้ array ต้นฉบับ

### วิธีคิด

รายการโหลดจาก API ของบริษัทที่ login อยู่แล้ว ข้อนี้ filter ในหน้า ไม่ต้องเพิ่ม endpoint หรือเปลี่ยนสิทธิ์การขาย ตรวจว่าผล filter ว่างกับประวัติคนละส่วน

## เฉลย

เพิ่มข้าง state เดิม และหลังประกาศ `activeBids`:

```tsx
const [submittedOnly, setSubmittedOnly] = useState(false);
// วางหลัง activeBids ใน component
const visibleBids = submittedOnly
  ? activeBids.filter((bid) => bid.status === 'submitted')
  : activeBids;
```

วาง checkbox หลัง page heading:

```tsx
<label>
  <input type="checkbox" checked={submittedOnly}
    onChange={(event) => setSubmittedOnly(event.target.checked)} />
  แสดงเฉพาะข้อเสนอที่รอพิจารณา
</label>
```

ใน branch `bids ? <>...</>` เปลี่ยนเฉพาะส่วนตารางปัจจุบันจาก `activeBids.length > 0 ? table(activeBids) : ...` เป็น:

```tsx
{visibleBids.length > 0 ? table(visibleBids) : <EmptyState
  title={submittedOnly ? 'ไม่มีข้อเสนอที่รอพิจารณา' : 'ไม่มีข้อเสนอที่กำลังใช้งาน'}
  detail="ดูประวัติด้านล่าง หรือเปลี่ยนตัวกรองเพื่อดูรายการอื่น"
/>}
```

คง branch loading, `bids.length === 0`, error และ `historyBids` เดิม ระวัง: filter นี้ไม่ใช่ authorization การแก้/ยกเลิกต้องผ่าน Backend เหมือนเดิม

## วิธีเช็กว่าแก้ถูก

ใน Portal: `npm.cmd run build` และ `npm.cmd run lint`

Manual: fixture มี submitted + selected + history ติ๊กแล้วตารางปัจจุบันเหลือ submitted แต่ history ยังอยู่ เอาติ๊กออกได้รายการเดิม และถ้า submitted เป็นศูนย์แสดง empty state ไม่ใช่หน้าโล่ง

## ตอบกรรมการว่าอย่างไร

“ผมเพิ่ม state ของตัวกรองในหน้ารายการครับ ใช้ข้อมูลบริษัทที่ API ส่งมาแล้ว และไม่ลบข้อมูลหรือเปลี่ยนเงื่อนไขที่อนุญาตให้แก้ข้อเสนอ”

## ข้อ 11 — SQL นับสมาชิกทุกกลุ่ม รวมกลุ่มที่มีศูนย์คน

ระดับกลาง · 5–10 นาที · SQL live coding 1

### กรรมการสั่งว่า

“เขียน query ให้ดูจำนวนสมาชิกของแต่ละกลุ่ม ทั้งหมดกับที่ active ขอให้กลุ่มว่างยังอยู่ในผลด้วย”

### สิ่งที่ต้องทำ

- ใช้ `tour_groups`, `group_members`, `users`
- คืน group_id, member_count และ active_member_count
- อธิบายเหตุผลที่ใช้ LEFT JOIN และ COUNT ของคีย์

### จุดที่ควรหาในโปรเจกต์

`database/travelin_automated_clustering.sql` → `group_members`; `server/gemini_proxy.dart` → `_readVotingRound()` ใช้ eligibility ของสมาชิก; ชื่อตาราง/คอลัมน์ตรวจจาก information_schema ปัจจุบันแล้ว

### Hint

`COUNT(*)` จะนับ placeholder ของ LEFT JOIN ในกลุ่มว่างด้วย

### วิธีคิด

เริ่มจาก tour_groups เพื่อเก็บทุกกลุ่มแล้ว join สมาชิก นับ membership จริงจากคีย์ ตรวจ active ตามกฎ Agency voting ที่ต้องมี users.group_id สอดคล้องกันด้วย

## เฉลย

SQL ใหม่สำหรับอ่านเท่านั้น ไม่จำเป็นต้องแทรกเข้า endpoint:

```sql
SELECT g.group_id,
       COUNT(gm.group_member_id) AS member_count,
       SUM(CASE WHEN u.status = 'active' AND u.group_id = gm.group_id
                THEN 1 ELSE 0 END) AS active_member_count
FROM tour_groups AS g
LEFT JOIN group_members AS gm ON gm.group_id = g.group_id
LEFT JOIN users AS u ON u.user_id = gm.user_id
GROUP BY g.group_id
ORDER BY g.group_id;
```

JOIN แรกเชื่อมหนึ่งกลุ่มกับ membership แต่ละคน JOIN ที่สองอ่านสถานะผู้ใช้ คอลัมน์ `group_member_id` เป็น key จริงจึงได้ 0 สำหรับกลุ่มว่าง; CASE ให้ 0 เมื่อไม่มี user หรือ inactive

ระวัง: member_count เป็นจำนวนแถว membership ส่วน active_member_count ในโจทย์นี้เป็น electorate ของ Agency voting กฎ Destination poll ปัจจุบันตรวจสมาชิก active โดยไม่ได้บังคับ `users.group_id` แบบเดียวกัน อย่ายก query นี้ไปแทนทุก subsystem โดยไม่อ่านกฎ

## วิธีเช็กว่าแก้ถูก

รัน SELECT นี้ใน SQL console; ตรวจเฉพาะกลุ่มฝึกด้วย `SELECT COUNT(*) FROM group_members WHERE group_id = :groupId;` จำนวนทั้งหมดต้องเท่ากัน

Manual: กลุ่มว่างได้ 0/0 กลุ่ม 3 คน active ครบได้ 3/3 ถ้ามีคน inactive หนึ่งคนได้ 3/2 ไม่เปลี่ยน status ของคนจริงเพื่อซ้อม ใช้ fixture เท่านั้น SQL ล้วนไม่ต้องรัน Flutter analyzer

## ตอบกรรมการว่าอย่างไร

“ผมเริ่มจากตารางกลุ่มแล้ว LEFT JOIN สมาชิกครับ กลุ่มไม่มีคนจึงยังแสดง และนับคีย์สมาชิกแทน COUNT ดาว เพื่อไม่ให้กลุ่มว่างถูกนับเป็นหนึ่งคน”

## ข้อ 12 — SQL คะแนนเฉลี่ยบริษัทจากรีวิวจริงที่มีสิทธิ์

ระดับกลาง · 5–10 นาที · SQL live coding 2

### กรรมการสั่งว่า

“เขียน SQL หาจำนวนรีวิวและคะแนนเฉลี่ยบริษัทหนึ่งแห่งให้ตรงกับ Dashboard โดยไม่รวมข้อมูลสาธิต”

### สิ่งที่ต้องทำ

- ใช้ `agency_rating` ไม่ใช้ `trip_rating`
- JOIN review → bid → package เพื่อหาเจ้าของบริษัท
- คง paid/completed eligibility และคืน null เมื่อยังไม่มีรีวิว

### จุดที่ควรหาในโปรเจกต์

`server/mysql_agency_review_repository.dart` → `MySqlAgencyReviewRepository.readForAgency()`; `server/agency_review_service.dart` → average rounding; `database/travelin_journey_features.sql` → trip_reviews

### Hint

ยก WHERE และ EXISTS จาก repository แล้วเปลี่ยน SELECT เป็น COUNT/AVG

### วิธีคิด

review ไม่มี agency_id จึงต้องหาเจ้าของ package ผ่าน bid ส่วน payments และ meetings ใช้ EXISTS เพื่อไม่ให้แถวรีวิวซ้ำจาก JOIN และค่าเฉลี่ยเพี้ยน

## เฉลย

query สำหรับตรวจสอบ read-only แยกจาก query รายการเดิม:

```sql
SELECT COUNT(*) AS review_count,
       ROUND(AVG(r.agency_rating), 1) AS average_rating
FROM trip_reviews AS r
JOIN bids AS b ON b.bid_id = r.bid_id AND b.group_id = r.group_id
JOIN tour_packages AS p ON p.package_id = b.package_id
WHERE p.agency_id = :agencyId
  AND r.agency_rating BETWEEN 1 AND 5
  AND EXISTS (
    SELECT 1 FROM payments AS pay
    WHERE pay.user_id = r.user_id AND pay.bid_id = r.bid_id
      AND pay.status = 'paid'
  )
  AND EXISTS (
    SELECT 1 FROM trip_meetings AS tm
    WHERE tm.group_id = r.group_id AND tm.bid_id = r.bid_id
      AND tm.status = 'completed'
  );
```

JOIN bid ตรวจทั้ง bid_id และ group_id, JOIN package หา agency_id ส่วน EXISTS ตรวจว่า reviewer จ่ายให้ข้อเสนอนั้นและทริปนั้น completed แล้ว ผล empty คือ count 0 และ average null โดยธรรมชาติ

ระวัง: demo อยู่ใน memory จึงไม่อยู่ใน SQL นี้ ถ้า Dashboard เป็น demo อย่าเทียบคะแนนตรง ๆ กับ DB ห้ามเปลี่ยนฐานข้อมูลเพื่อให้ query ได้ผลตามที่อยากเห็น

## วิธีเช็กว่าแก้ถูก

รัน SELECT ใน SQL console แล้วเทียบ POST `/api/agency/reviews` ของบริษัทเดียวกันเมื่อ source เป็น `real` หรือ `empty` โดยใช้ session ของบริษัทนั้น

Manual: fixture คะแนน 5,4,5 ได้ count 3 / mean 4.7 รีวิวที่ไม่ paid หรือทริปไม่ completed ต้องถูกตัดออก และบริษัทอื่นไม่ปน ไม่มี analyzer ที่ต้องรันสำหรับ query อ่านล้วน

## ตอบกรรมการว่าอย่างไร

“ผมเชื่อมจากรีวิวไปข้อเสนอและแพ็กเกจเพื่อรู้ว่าบริษัทไหนครับ ใช้คะแนนบริษัทและเงื่อนไขจ่ายแล้วกับจบทริปเหมือน repository จึงเทียบกับรีวิวจริงบน Dashboard ได้”

## ข้อ 13 — SQL เรียงข้อเสนอจากคะแนนโหวตมากที่สุด

ระดับกลาง · 5–10 นาที · SQL live coding 3

### กรรมการสั่งว่า

“ขอดูข้อเสนอในรอบเปิดของกลุ่มหนึ่ง เรียงตามคะแนน ถ้าเสมอให้ราคาถูกกว่าอยู่ก่อน แต่ยังไม่ต้องประกาศผู้ชนะ”

### สิ่งที่ต้องทำ

- COUNT votes ของสมาชิกที่มีสิทธิ์ในรอบเปิด
- เก็บข้อเสนอศูนย์คะแนนด้วย LEFT JOIN
- เรียงคะแนน ราคา เวลายื่น และ ID ให้ deterministic

### จุดที่ควรหาในโปรเจกต์

`server/gemini_proxy.dart` → `_votingCandidates(... eligibleOnly: true)`; `server/voting_policy.dart` → `selectVotingWinner()`; `database/travelin_voting_lifecycle.sql` → ballot uniqueness

### Hint

filter ผู้โหวตอยู่ใน ON/EXISTS ของ LEFT JOIN อย่าวางไว้ใน WHERE จนข้อเสนอศูนย์คะแนนหาย

### วิธีคิด

นี่เป็นอันดับเพื่อแสดงผลในรอบเปิด โดยใช้ eligibility แบบ early-majority ของ Agency voting ไม่ใช่การแทน fallback ตอนสรุปผลจริงซึ่ง source รักษากฎเดิมไว้

## เฉลย

```sql
SELECT b.bid_id, b.price, b.bid_date, COUNT(v.vote_id) AS vote_count
FROM bids AS b
JOIN group_voting_rounds AS vr ON vr.group_id = b.group_id
JOIN tour_groups AS g ON g.group_id = b.group_id
LEFT JOIN bid_votes AS v
  ON v.group_id = b.group_id AND v.bid_id = b.bid_id
  AND EXISTS (
    SELECT 1 FROM group_members AS gm
    JOIN users AS u ON u.user_id = gm.user_id
    WHERE gm.group_id = v.group_id AND gm.user_id = v.user_id
      AND u.group_id = gm.group_id AND u.status = 'active'
  )
WHERE b.group_id = :groupId
  AND vr.status = 'open'
  AND g.status <> 'cancelled'
  AND b.status = 'submitted'
  AND b.bid_date <= vr.deadline_at
GROUP BY b.bid_id, b.price, b.bid_date
ORDER BY vote_count DESC, b.price ASC, b.bid_date ASC, b.bid_id ASC;
```

JOIN round จำกัดรอบของกลุ่ม, JOIN group ตัดกลุ่ม cancelled, LEFT JOIN ballot นับเฉพาะคะแนนของ bid ในกลุ่มนั้น และ EXISTS เช็กผู้โหวตโดยไม่เพิ่มจำนวนแถว COUNT(v.vote_id) คืน 0 เมื่อไม่มี ballot

ระวัง: แถวแรกอาจได้ 2 จาก 5 เสียง จึงยังไม่ผ่าน Absolute Majority 3 เสียง ห้าม UPDATE selected จากอันดับนี้ การสรุปหลังปิดโหวตจริงใช้ `finalizeVotingRule()` ตาม lifecycle เดิม

## วิธีเช็กว่าแก้ถูก

รัน SQL read-only กับ fixture รอบ open ข้อเสนอคะแนน 2,2,1 และราคาแตกต่าง ต้องเรียงคู่เสมอตามราคา ถ้ารอบ finalized query นี้ไม่คืนรายการเพราะตั้งใจอ่านเฉพาะ open

Manual: เพิ่มข้อเสนอศูนย์คะแนนใน fixture ต้องยังปรากฏ และเทียบจำนวนกับ ballot ของสมาชิกที่มีสิทธิ์; สำหรับการแก้ rule ให้ใช้ `flutter test test/server/voting_policy_test.dart` แต่โจทย์ query นี้ไม่เปลี่ยน rule

## ตอบกรรมการว่าอย่างไร

“query นี้จัดอันดับให้เห็นคะแนนครับ แต่คะแนนมากที่สุดยังไม่แปลว่าเกินครึ่ง ระบบประกาศผู้ชนะผ่านกฎ Majority และสถานะรอบโหวตอีกที”

## ข้อ 14 — เพิ่ม Test ของกลุ่ม 8 และ 10 คน

ระดับกลาง · 5–10 นาที

### กรรมการสั่งว่า

“พิสูจน์ให้ดูว่ากลุ่มเลขคู่ได้ครึ่งหนึ่งยังไม่ชนะ ขอ test 8 คนกับ 10 คนทั้งโหวตเมืองและบริษัท”

### สิ่งที่ต้องทำ

- เพิ่ม case 4/8 ไม่ชนะ และ 5/8 ชนะ
- เพิ่ม case 5/10 ไม่ชนะ และ 6/10 ชนะ
- ใช้ parameterized loop เดิมโดยไม่เปลี่ยน production rule

### จุดที่ควรหาในโปรเจกต์

`test/server/absolute_majority_test.dart` → loop `for (final (eligible, votes, wins) in [...])`; `server/absolute_majority.dart`; `server/destination_poll_service.dart` → `DestinationPollLifecyclePolicy.decide()`; `server/voting_policy.dart` → `earlyFinalizeVotingRule()`

### Hint

loop เดิมสร้าง test สองแบบจาก tuple เดียว ใช้เพิ่มเพียงสี่ tuple

### วิธีคิด

เลือกค่าขอบที่ครึ่งพอดีและมากกว่าครึ่งหนึ่งเสียง เพื่อจับ regression ที่ใช้ ceil หรือ >= 50% การทดสอบทั้งสอง callers ยืนยันว่ากฎร่วมถูกนำไปใช้จริง

## เฉลย

ต่อท้าย list เดิมหลัง `(6, 4, true),`:

```dart
(8, 4, false),
(8, 5, true),
(10, 5, false),
(10, 6, true),
```

test `thresholds use the whole electorate...` อาจเพิ่ม assertion:

```dart
expect([8, 10].map(majorityRequired), [5, 6]);
```

ระวัง: destination branch ที่ทดสอบเป็น `region_city` แบบเลือกเมืองเดียว อย่ายก early-majority ไปใช้ `place_activity` ที่เลือกได้หลายรายการ และ electorate คือสมาชิกมีสิทธิ์ทั้งหมด

## วิธีเช็กว่าแก้ถูก

`flutter test test/server/absolute_majority_test.dart` และ `flutter analyze`

ต้องมี test เพิ่ม 8 tests จาก loop นี้ (สี่ tuples × สอง callers) ทุก case ผ่าน ลองอธิบาย 4/8 กับ 5/8 โดยไม่ใช้ฐานข้อมูลหรือ network

## ตอบกรรมการว่าอย่างไร

“ผมเพิ่ม test ที่ขอบครึ่งหนึ่งครับ กลุ่มแปดคนต้องได้ห้า และสิบคนต้องได้หก โดยทดสอบทั้งโหวตเมืองและบริษัทผ่านกฎที่ใช้จริง”

## ข้อ 15 — API คืนวันรีวิวล่าสุดและแสดงบน Dashboard

ระดับกลาง · 5–10 นาที

### กรรมการสั่งว่า

“ช่วยเพิ่มวันที่รีวิวล่าสุดใต้คะแนนบริษัท ให้ API คืนมาด้วย ถ้าไม่มีรีวิวก็ไม่แสดง”

### สิ่งที่ต้องทำ

- เพิ่ม field `latestReviewAt` แบบ nullable ใน response เดิม
- เพิ่ม TypeScript contract และแสดงวันที่ด้วย formatter เดิม
- เพิ่ม assertion ใน service tests ของกรณีมี/ไม่มีรีวิว

### จุดที่ควรหาในโปรเจกต์

`server/agency_review_service.dart` → `readForAgency()`; `server/agency_admin_api.dart` → `_agencyReviews()`; `portal/src/types.ts` → `AgencyReviewSummary`; `portal/src/components/AgencyReviewsCard.tsx` → `RatingSummary()`; `portal/src/components/format.ts` → `displayDate()`

### Hint

list `reviews` ถูก sort ใหม่สุดก่อนแล้ว ใช้ first หลัง sort

### วิธีคิด

ไม่ต้องเพิ่ม query เพราะ service มีวันที่ของรายการทั้งหมด และ endpoint ส่ง Map ของ service อยู่แล้ว field ใหม่นี้เป็น additive ไม่เปลี่ยน field เดิม

## เฉลย

Backend ใน return Map หลัง `reviewCount` เพิ่ม:

```dart
'latestReviewAt': reviews.isEmpty
    ? null
    : reviews.first.createdAt.toIso8601String(),
```

TypeScript ใน `AgencyReviewSummary` เพิ่ม `latestReviewAt: string | null;` แล้วใน `RatingSummary()` เพิ่มภายใน div เดิมใกล้จำนวนรีวิว:

```tsx
{summary.latestReviewAt && <p>
  รีวิวล่าสุด {displayDate(summary.latestReviewAt)}
</p>}
```

เพิ่ม test หลัง fixture ที่ sort IDs 4,3,2,1:

```dart
expect(result['latestReviewAt'], DateTime.utc(2026, 10, 4).toIso8601String());
```

ใน test empty เพิ่ม `expect(result['latestReviewAt'], isNull);` ไม่ต้องแก้ `_agencyReviews()` เพราะคืนผล service โดยตรง แต่ต้องตรวจว่า endpoint ยังใช้ `agency.id` จาก session และ API client `agencyApi.reviews()` ยังใช้ POST เดิม

ระวัง: วันที่ demo เป็นวันที่ตัวอย่าง ต้องคง badge `ข้อมูลสาธิต` และกรณี rollout frontend/backend คนละเวลา UI guard ต้องไม่พังเมื่อ field ยัง missing

## วิธีเช็กว่าแก้ถูก

`dart analyze server` และ `flutter test test/server/agency_review_service_test.dart test/server/agency_admin_api_test.dart`; ใน Portal: `npm.cmd run build` และ `npm.cmd run lint`

Manual: POST `/api/agency/reviews` ด้วย session บริษัทฝึก ต้องได้ ISO string หรือ null; Dashboard แสดงวันจาก field เดียวกัน และ empty ไม่แสดงวันที่ ไม่มี auth ต้องยัง 401

## ตอบกรรมการว่าอย่างไร

“ผมใช้วันที่จากรายการรีวิวที่ Backend เรียงไว้แล้วครับ เพิ่ม field ใน response และ type ของ Portal จากนั้นใช้ formatter เดิม ไม่ต้องสร้าง query หรือเปลี่ยนตาราง”

## ข้อ 16 — Bug Hunt: ไม่มีรีวิวแต่ขึ้น 0 ดาว หรือหน้า crash

ระดับยาก · 10–15 นาที · **บั๊กจำลอง**

### กรรมการสั่งว่า

“บริษัทใหม่ไม่มีรีวิว แต่หน้านี้ขึ้นศูนย์ดาว บางครั้งก็ error ลองหาและแก้ให้ดู”

### สิ่งที่ต้องทำ

- ไล่ response `averageRating: null`, `reviewCount: 0`
- คืน empty guard ก่อน render คะแนนและดาว
- อธิบายว่า null กับคะแนนศูนย์ต่างกัน และตรวจ Modal ด้วย

### จุดที่ควรหาในโปรเจกต์

`portal/src/components/AgencyReviewsCard.tsx` → `RatingSummary()`; `portal/src/types.ts` → `AgencyReviewSummary`; `server/agency_review_service.dart` → average; `test/server/agency_review_service_test.dart` → empty test

### Hint

มองหา `?? 0` และ non-null assertion `!` หากใช้กับ average ก่อนตรวจ count จะซ่อนความหมายของ null หรือทำให้ runtime ล้ม

### วิธีคิด

เริ่มที่ payload ก่อนตัดสินว่าเป็นปัญหา DB source ปัจจุบันคืน null เมื่อไม่มีรีวิวและมี guard ถูกต้องอยู่แล้ว สำหรับซ้อมให้จำลองการลบ guard ในสำเนาเท่านั้น

## เฉลย

ก่อนแก้ **เฉพาะบั๊กจำลอง** ที่แทน RatingSummary ในสำเนาฝึก:

```tsx
function RatingSummary({ summary }: { summary: AgencyReviewSummary }) {
  return <div>
    <strong>{summary.averageRating!.toFixed(1)}</strong>
    <RatingStars rating={summary.averageRating ?? 0} />
    <p>{summary.reviewCount} รีวิว</p>
  </div>;
}
```

หลังแก้ คืน guard ตามแนวของ source ปัจจุบัน แล้วเพิ่ม fallback สำหรับ contract ผิดรูปแบบ:

```tsx
function RatingSummary({ summary }: { summary: AgencyReviewSummary }) {
  if (summary.reviewCount === 0) {
    return <p className="review-empty">ยังไม่มีรีวิวจากนักท่องเที่ยว</p>;
  }
  const rating = summary.averageRating;
  if (rating == null || !Number.isFinite(rating)) {
    return <p role="status">ยังไม่สามารถแสดงคะแนนเฉลี่ยได้</p>;
  }
  return <div className="review-rating-summary">
    <strong>{rating.toFixed(1)}</strong>
    <div><RatingStars rating={rating} />
      <p>{summary.reviewCount} รีวิว{summary.isDemo ? 'ตัวอย่าง' : 'จากนักท่องเที่ยว'}</p>
    </div>
  </div>;
}
```

ระวัง: อย่าเปลี่ยน Backend จาก null เป็น 0 เพื่อให้ component ผ่าน และอย่าลบ branch `summary.reviewCount > 0` รอบ preview/ปุ่ม Modal กรณี count > 0 แต่ average null เป็น contract ผิดรูปแบบ ต้องตรวจ API ต่อ มิใช่แต่งคะแนนแทน

## วิธีเช็กว่าแก้ถูก

ใน Portal: `npm.cmd run build` และ `npm.cmd run lint`; Backend: `flutter test test/server/agency_review_service_test.dart`

Manual: mock count 0/average null ต้องเป็น empty; count 3/average 4.7 ต้องมีดาว; count 3/average null ต้องข้อความ fallback โดยไม่ crash และไม่แต่ง 0 ดาว ทดลอง Dashboard กับ Modal เพราะใช้ RatingSummary ร่วมกัน

## ตอบกรรมการว่าอย่างไร

“ผมพบว่าหน้าจอเอาค่า null ไปแสดงเป็นคะแนนครับ เลยตรวจจำนวนรีวิวก่อน และรับมือ response ผิดรูปแบบโดยไม่สร้างคะแนนขึ้นมาเอง”

## ข้อ 17 — Bug Hunt: กลับเข้าหน้ารอแล้วได้เวลา 5 นาทีใหม่

ระดับยาก · 10–15 นาที · **บั๊กจำลอง**

### กรรมการสั่งว่า

“รอไปแล้วสี่นาที พอเปิดหน้าใหม่ทำไมกลายเป็นห้านาทีอีก แล้วทำไมเวลาเครื่องต่างกันถึงแสดงไม่ตรง”

### สิ่งที่ต้องทำ

- เลิกสร้าง deadline ใหม่จาก default ใน Flutter
- ใช้ deadline และ serverNow จาก API เพื่อชดเชยเวลาเครื่อง
- ทดสอบ expired/กลับจาก background และ Alternative Destination

### จุดที่ควรหาในโปรเจกต์

`lib/widgets/matching_status_view.dart` → `_WaitingCountdownState._syncDeadline()`, `_remainingAt()`, `_scheduleExpiryRefresh()`; `server/matching_waiting_policy.dart` → `defaultDuration`; `server/matching_service.dart` → `matchingStatus()` / `continueWaiting()`; `test/widgets/matching_status_view_test.dart`

### Hint

Backend default 5 นาทีใช้ตอนสร้าง window ไม่ใช่ตอนเปิด widget แต่ละครั้ง

### วิธีคิด

ตรวจ payload deadline ก่อน แล้วดูว่าหน้าจอใช้ค่านั้นจริงหรือไม่ ถ้าเวลาเครื่องคลาดเคลื่อนให้ใช้ช่วง `serverDeadline - serverNow` มา anchor กับ local clock source ปัจจุบันทำแบบนี้อยู่แล้ว

## เฉลย

ก่อนแก้ **บั๊กจำลอง** ใน `_syncDeadline()`:

```dart
_deadline = _now().add(const Duration(minutes: 5));
```

หลังแก้ ใช้ตัวแปร `now`, `serverDeadline`, `serverNow` ที่มีในฟังก์ชันปัจจุบัน:

```dart
_deadline = serverDeadline == null
    ? now.add(Duration(
        seconds: widget.status.waitingRemainingSeconds.clamp(0, 0x7fffffff),
      ))
    : serverNow == null
    ? serverDeadline
    : now.add(serverDeadline.difference(serverNow));
```

คง `_remainingSeconds = _remainingAt(now);`, `_startTimer();`, `_scheduleExpiryRefresh();` และ logic `_remainingAt()` เดิมที่ clamp expired เป็น 0 คงการ cancel timer ใน dispose และ background

เมื่อครบเวลา Backend ตั้ง `alternativesAvailable` สำหรับ waiting/no group ที่ deadline ผ่านแล้ว UI แสดงรายการเมื่อ `alternativeDestinations.isNotEmpty` ผู้ใช้ต้องยืนยันทางเลือกผ่าน flow เดิม ไม่เปลี่ยน destination หรือจับกลุ่มให้เองจาก timeout

ระวัง: ตั้งค่ารอ default ใหม่ไม่ได้ย้าย deadline ของ preference ที่บันทึกแล้ว การเริ่มรอบรอใหม่เป็นหน้าที่ `continueWaiting()` ฝั่ง Backend

## วิธีเช็กว่าแก้ถูก

`flutter analyze` และ `flutter test test/widgets/matching_status_view_test.dart test/server/matching_waiting_policy_test.dart`

ใช้ tests เดิม `server deadline and server clock take precedence over remaining`, `normal parent rebuilds do not reset the deadline...`, `zero is clamped...` และ `background time jump...` ไม่ต้องรอจริง 5 นาที

Manual: window เดิมเหลือ 60 วินาที เปิดใหม่ต้องยังประมาณ 60; offset เวลาเครื่องหนึ่งชั่วโมงต้องไม่เพิ่ม/ลด window; ครบเวลา refresh แค่ครั้งเดียวต่อ window แล้วเห็นทางเลือกที่ Backend ส่งมา หรือข้อความสถานะล่าสุดถ้าไม่มีทางเลือก

## ตอบกรรมการว่าอย่างไร

“ผมใช้ deadline ของ Backend ครับ ห้านาทีเป็นค่าเริ่มต้นตอนสร้างการรอ จึงไม่ควรเริ่มใหม่เวลาเปิดหน้า และใช้ serverNow ช่วยชดเชยเวลาเครื่องที่คลาดเคลื่อน”

## ข้อ 18 — Bug Hunt: ได้ 2 จาก 4 เสียงแล้วชนะก่อนเวลา

ระดับยาก · 10–15 นาที · **บั๊กจำลอง**

### กรรมการสั่งว่า

“กลุ่มสี่คนเพิ่งโหวตสองคนให้บริษัทเดียวกัน ระบบสรุปผู้ชนะเลย แบบนี้ถูกไหม ลองแก้พร้อม test”

### สิ่งที่ต้องทำ

- คืนสูตร floor(N/2)+1
- ตรวจว่าตัวหารเป็น eligibleMemberCount ไม่ใช่คนที่โหวตแล้ว
- เพิ่ม/ใช้ test ขอบครึ่งหนึ่งและหลัง finalized

### จุดที่ควรหาในโปรเจกต์

`server/absolute_majority.dart` → `majorityRequired()` / `absoluteMajorityWinner()`; `server/voting_policy.dart` → `earlyFinalizeVotingRule()` / `validateVoteRule()`; `test/server/absolute_majority_test.dart`

### Hint

`ceil(N/2)` ให้ 2 เมื่อ N=4 แต่เกินครึ่งต้อง 3 อีกจุดที่ทำผิดได้คือใช้จำนวนผู้โหวตแทน electorate

### วิธีคิด

แยกสูตรกับข้อมูลที่ส่งให้สูตร ตัวสูตรถูกแต่ N ผิดก็สรุปเร็วได้ ตรวจ shared helper และ caller แล้วพิสูจน์ด้วย test ไม่มี majority ต้องคง fallback เดิม

## เฉลย

ก่อนแก้ **บั๊กจำลอง**:

```dart
int majorityRequired(int eligibleVoterCount) => (eligibleVoterCount / 2).ceil();
// หรือ caller ส่งจำนวนผู้โหวตแล้วแทนจำนวนสมาชิกทั้งหมด
```

หลังแก้เป็นสูตรของ source ปัจจุบัน:

```dart
int majorityRequired(int eligibleVoterCount) => (eligibleVoterCount ~/ 2) + 1;
```

ใน `earlyFinalizeVotingRule()` ต้องคง:

```dart
final winner = absoluteMajorityWinner(
  eligibleVoterCount: eligibleMemberCount,
  voteCounts: {
    for (final candidate in candidates) candidate.bidId: candidate.voteCount,
  },
);
```

เพิ่ม test ลงภายใน main เดิม ถ้าต้องการ case เดี่ยวสำหรับอธิบาย:

```dart
test('four eligible members need three votes even if only two voted', () {
  expect(majorityRequired(4), 3);
  expect(absoluteMajorityWinner(
    eligibleVoterCount: 4, voteCounts: {11: 2, 12: 0},
  ), isNull);
  expect(absoluteMajorityWinner(
    eligibleVoterCount: 4, voteCounts: {11: 3, 12: 0},
  ), 11);
});
```

source เดิมมี guard electorate <= 0 และ contradictory tallies คืน null อยู่แล้ว รวมถึง finalized winner immutable และ validateVoteRule ปฏิเสธ non-open ห้ามลบ guards เหล่านี้ และห้ามใช้สูตรนี้กับ place_activity แบบหลายตัวเลือก

## วิธีเช็กว่าแก้ถูก

`dart analyze server` และ `flutter test test/server/absolute_majority_test.dart test/server/voting_policy_test.dart`

ตรวจ 1/3 ไม่ชนะ, 2/3 ชนะ, 2/4 ไม่ชนะ, 3/4 ชนะ, 2-2-1 จาก 5 ไม่ผ่าน majority และ 0 สมาชิกไม่สรุป ทดสอบ `recorded agency winner is immutable and later votes are rejected` ต้องยังผ่าน

## ตอบกรรมการว่าอย่างไร

“สองจากสี่คือครึ่งพอดีจึงยังไม่ชนะครับ ผมใช้จำนวนสมาชิกที่มีสิทธิ์ทั้งหมดเป็นฐาน ต้องได้สามเสียง และหลังประกาศผลแล้วไม่อนุญาตให้คะแนนใหม่เปลี่ยนผู้ชนะ”

## ข้อ 19 — Bug Hunt: บันทึกคะแนนแล้ว COMMIT ก่อนเลือกผู้ชนะ

ระดับยาก · 10–15 นาที · **บั๊กจำลอง**

### กรรมการสั่งว่า

“ถ้าบันทึกคะแนนไปแล้ว แต่ขั้นตอนประกาศผลล้มเหลว เราจะได้ข้อมูลค้างไหม ลองแก้ขอบเขต transaction ให้ดู”

### สิ่งที่ต้องทำ

- ย้าย COMMIT กลับหลังตรวจ majority และบันทึกสถานะรอบ
- คง lock กลุ่มและ round ก่อนรับคะแนน
- อธิบาย rollback และ request พร้อมกันด้วย test ที่มีอยู่

### จุดที่ควรหาในโปรเจกต์

`server/gemini_proxy.dart` → `submitAgencyVote()`, `_lockAgencyVotingGroup()`, `_readVotingRound(lock: true)`, `_tryEarlyFinalizeVoting()`, `_recordVotingWinner()`; `test/server/absolute_majority_database_test.dart`

### Hint

transaction ต้องครอบ state transition ทั้งชุด ไม่ใช่ครอบเฉพาะ INSERT ballot

### วิธีคิด

ไล่ลำดับ START → lock → validate → UPSERT → majority → status → COMMIT ถ้า commit ก่อน majority การ ROLLBACK ตอนขั้นตอนถัดไปล้มเหลวจะย้อนคะแนนที่ commit ไปแล้วไม่ได้ source ปัจจุบันมีลำดับถูกต้องอยู่แล้ว ข้อนี้ฝึกคืนตำแหน่งบรรทัดในสำเนา

## เฉลย

ก่อนแก้ **บั๊กจำลอง** หลัง UPSERT เดิม:

```dart
await connection.execute('COMMIT'); // จำลองว่ามีคนย้ายมาเร็วเกินไป
await _tryEarlyFinalizeVoting(connection, groupId);
await _refreshVotingRoundStatus(connection, groupId);
voteState = _votingRoundJson(await _readVotingRound(connection, groupId));
```

หลังแก้ คืนเป็น source ปัจจุบัน:

```dart
await _tryEarlyFinalizeVoting(connection, groupId);
await _refreshVotingRoundStatus(connection, groupId);
voteState = _votingRoundJson(await _readVotingRound(connection, groupId));
await connection.execute('COMMIT');
```

ทั้งหมดอยู่ใน nested try เดิม ซึ่ง catch ทำ:

```dart
} catch (_) {
  await connection.execute('ROLLBACK');
  rethrow;
}
```

ต้องคง START TRANSACTION ก่อน `_requireJoinedGroup()`, group lock ก่อน `_ensureVotingRound()` และ round read `lock: true` ก่อน `validateVoteRule()` ที่ reject หลัง finalize; ยังปิด connection ใน finally ภายนอกเหมือนเดิม `_recordVotingWinner()` อัปเดต selected bid, finalized round และ notifications ผ่าน connection เดียวกัน

ตัวอย่าง SQL read-only สำหรับอธิบาย consistent snapshot ของสถานะ (ไม่ใช่การแทน lock ใน write handler):

```sql
START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;
SELECT group_id, status, winner_bid_id
FROM group_voting_rounds WHERE group_id = :groupId;
SELECT bid_id, status FROM bids WHERE group_id = :groupId;
COMMIT;
```

สอง SELECT อ่าน snapshot เดียวกันสำหรับตาราง InnoDB ภายใต้ REPEATABLE READ ที่ใช้กับการฝึก; ถ้า session เปลี่ยน isolation ไว้ต้องตรวจ `SELECT @@session.tx_isolation;` ก่อน ประเด็นนี้ต่างจาก write handler ซึ่งต้องล็อกก่อนตรวจสถานะล่าสุดเพื่อกันสอง request เปลี่ยน state พร้อมกัน

ระวัง: คงลำดับ lock เดิม ไม่เพิ่ม lock แบบกลับลำดับ และไม่สร้าง “retry ทั้งก้อน” ที่เพิ่ม notification ซ้ำ ไม่ทดสอบ write/concurrency กับฐานข้อมูลใช้งานจริง

## วิธีเช็กว่าแก้ถูก

`dart analyze server` และ `flutter test test/server/absolute_majority_test.dart test/server/voting_policy_test.dart`

เมื่อตั้ง **fixture test DB แยกไว้ก่อนสอบ** จึงรัน `flutter test test/server/absolute_majority_database_test.dart` tests นี้เปิดจริงเฉพาะ schema `tourapp_majority_test_*` และ environment `TRAVELIN_MAJORITY_TEST_DB_*` ที่กำหนดครบ ไม่มี fixture จะ skip ซึ่งไม่ใช่หลักฐานว่า concurrency ผ่าน อย่าเปลี่ยน guard เพื่อรันกับ tourist_grouping_db

Manual ใน fixture: บังคับ exception หลัง UPSERT แต่ก่อน COMMIT โดยใช้ breakpoint/throw ชั่วคราวในสำเนาฝึก แล้วอ่าน DB ใหม่ต้องไม่เหลือคะแนนหรือ winner บางส่วน ถอน throw ก่อนจบ และรัน case `agency simultaneous final votes preserve one winner` กับ test คะแนนซ้ำ ต้องได้ winner เดียวและ ballot เดียวต่อสมาชิก

## ตอบกรรมการว่าอย่างไร

“ผมให้คะแนนกับผลโหวตอยู่ใน transaction เดียวกันครับ ถ้าขั้นตอนกลางล้มเหลวจะ rollback ทั้งชุด และล็อกกลุ่มกับรอบก่อนตรวจสถานะเพื่อให้ request พร้อมกันไม่สรุปคนละผู้ชนะ”

## ข้อ 20 — ให้ API และ Flutter แสดงว่า ‘ต้องได้อย่างน้อยกี่เสียง’

ระดับยาก · 10–15 นาที

### กรรมการสั่งว่า

“หน้าโหวตบริษัทช่วยบอกว่ากลุ่มนี้ต้องได้อย่างน้อยกี่เสียงถึงสรุปก่อนเวลา ขอให้ Backend ส่งค่านี้มา และแอปเก่ากับ API เก่าต้องยังอ่านได้”

### สิ่งที่ต้องทำ

- เพิ่ม JSON field `majorityRequired` จาก helper จริง
- เพิ่ม field / constructor / parsing ใน VotingSummary ทุกจุด
- แสดงใน status card เฉพาะรอบเปิด และรองรับ API เก่าที่ไม่มี field
- เพิ่ม model tests สำหรับ field ใหม่/missing และสมาชิกศูนย์

### จุดที่ควรหาในโปรเจกต์

`server/gemini_proxy.dart` → `_votingRoundJson()`, `_groupOffers()` ผ่าน POST `/api/offers`; `server/absolute_majority.dart` → `majorityRequired()`; `lib/services/group_service.dart` → `VotingSummary`; `lib/pages/voting_page.dart` → `_VotingStatusCard.build()`; `test/group_service_test.dart`

### Hint

อย่าใช้ voterCount คำนวณ threshold ค่า 0 สมาชิกควรแสดงว่าไม่มี threshold และมี round-null response ที่ต้องแก้ด้วย

### วิธีคิด

เริ่มจาก contract แล้วไล่ producer → model → UI อย่าเปลี่ยนกฎรับคะแนนเพื่อแสดง field ใหม่ ใช้ helper ร่วมฝั่ง Backend; fallback Flutter ใช้เพื่อแสดงผลของ API เก่าเท่านั้น Backend ยังเป็นผู้ตัดสินผลจริง

## เฉลย

1. เพิ่ม `import 'absolute_majority.dart';` ใน `server/gemini_proxy.dart` (source ปัจจุบัน import voting_policy แต่ยังไม่ได้ import helper โดยตรง)

ใน `_votingRoundJson()` branch round == null เพิ่ม `'majorityRequired': 0,` ส่วน branch ปกติ หลังประกาศ remainingSeconds เพิ่ม:

```dart
final eligible = _toNonNegativeInt(round['eligible_member_count']) ?? 0;
```

เปลี่ยนสองบรรทัด eligibleMemberCount เดิมและเพิ่ม field:

```dart
'eligibleMemberCount': eligible,
'majorityRequired': eligible > 0 ? majorityRequired(eligible) : 0,
```

2. ใน `VotingSummary` ของ `lib/services/group_service.dart` เพิ่ม default parameter ลง constructor ปกติ, initializer ลง empty, field และ getter ลง class, parsing ลง factory **อย่าเพิ่ม field โดยลืม constructor empty**:

```dart
// constructor ปกติ: ข้าง this.eligibleMemberCount = 0
this.majorityRequired = 0,

// constructor .empty(): ข้าง eligibleMemberCount = 0
majorityRequired = 0,

// fields/getter ใน class
final int majorityRequired;
int get requiredVotes => eligibleMemberCount <= 0
    ? 0
    : majorityRequired > 0
    ? majorityRequired
    : (eligibleMemberCount ~/ 2) + 1;

// factory fromJson: ข้าง eligibleMemberCount mapping
majorityRequired: _asInt(
  _valueFrom(json, const ['majorityRequired', 'majority_required']),
),
```

นี่เป็นรายการส่วนที่ต้องเพิ่มคนละตำแหน่ง ไม่ใช่ block เดียวสำหรับวางต่อกัน default 0 ทำให้ callers เดิมไม่ต้องส่ง named parameter ใหม่ `_asInt()` เดิมรับ int/num/numeric-string/missing แล้ว

3. ใน branch open ของ `_VotingStatusCard.build()` เปลี่ยน detail จาก:

```dart
'โหวตแล้ว ${voting.voterCount}/${voting.eligibleMemberCount} คน · '
    'เหลือเวลา ${_formatRemaining(remainingSeconds)}',
```

เป็น:

```dart
'โหวตแล้ว ${voting.voterCount}/${voting.eligibleMemberCount} คน · '
    '${voting.requiredVotes > 0 ? 'ต้องได้อย่างน้อย ${voting.requiredVotes} เสียงเพื่อสรุปก่อนเวลา · ' : ''}'
    'เหลือเวลา ${_formatRemaining(remainingSeconds)}',
```

4. เพิ่ม tests ลงภายใน main ของ `test/group_service_test.dart` ที่ import VotingSummary ผ่าน group_service อยู่แล้ว:

```dart
test('voting threshold maps the additive field and supports old APIs', () {
  expect(VotingSummary.fromJson({
    'eligibleMemberCount': 4, 'majorityRequired': 3,
  }).requiredVotes, 3);
  expect(VotingSummary.fromJson({
    'eligible_member_count': 6, 'majority_required': '4',
  }).requiredVotes, 4);
  expect(VotingSummary.fromJson({
    'eligibleMemberCount': 5,
  }).requiredVotes, 3);
  expect(const VotingSummary.empty().requiredVotes, 0);
});
```

ระวัง: field นี้ไม่ใช่จำนวนเสียงที่ผู้นำ “ยังขาด” แต่เป็น threshold ทั้งหมด คง canVote/canFinalize/status จาก Backend และไม่ประกาศ winner จากข้อความใน UI ไม่มี round/สมาชิกศูนย์ต้องไม่แสดงข้อความว่าต้องได้หนึ่งเสียง

## วิธีเช็กว่าแก้ถูก

`dart analyze server`, `flutter analyze` และ `flutter test test/group_service_test.dart test/server/absolute_majority_test.dart test/widgets/absolute_majority_vote_test.dart`

Manual: ใน fixture POST `/api/offers` กลุ่ม 3/4/5/6 คนต้องได้ majorityRequired 2/3/3/4 ตามลำดับ เปิด Flutter ดูข้อความรอบ open ทดสอบ API เก่าโดยใช้ MockClient ลบ field ใหม่แล้วหน้าจอยังคำนวณเพื่อแสดงได้ ส่ง vote จน finalized แล้ว card ต้องเปลี่ยนเป็นผู้ชนะตาม response จริง

เมื่อพร้อม fixture DB แยก สามารถเพิ่ม assertions ใน `test/server/absolute_majority_database_test.dart` บน response `voting` ที่คืนหลัง vote เพื่อเช็ก field Backend ด้วย; อย่าอ้างว่า model test เพียงอย่างเดียวทดสอบ HTTP handler ครบ

## ตอบกรรมการว่าอย่างไร

“ผมส่ง threshold จาก helper เดียวกับกฎโหวตครับ แล้วเพิ่ม mapping และข้อความใน Flutter ถ้าเจอ API เก่ายังมี fallback สำหรับแสดงผล แต่ผู้ชนะและสิทธิ์โหวตยังตัดสินจาก Backend”

# ชุดเดาข้อสอบ — 10 ข้อที่ควรซ้อมก่อน

ลำดับนี้เป็นการคาดการณ์จากจุดเด่นของโปรเจกต์และขนาดงานที่ให้แก้สดได้ ไม่ใช่ข้อสอบที่ยืนยันแล้ว

| อันดับ | ข้อที่ควรซ้อม | เหตุผล |
| --- | --- | --- |
| 1 | ข้อ 2 — Countdown 04:32 | เห็นผลทันทีบนจอ แก้สั้น และกรรมการต่อยอดถาม source of truth ได้ |
| 2 | ข้อ 18 — Majority ครึ่งพอดี | เป็นกฎธุรกิจเด่นของงาน แยกคนที่เข้าใจเกินครึ่งจริงจากการจำสูตร |
| 3 | ข้อ 11 — SQL จำนวนสมาชิก | JOIN/COUNT เป็นพื้นฐานฐานข้อมูล และโยงไป membership ที่ใช้จริง |
| 4 | ข้อ 5 — รีวิวล่าสุด 4 ใบ | เปลี่ยนเพียงไม่กี่บรรทัด แต่ถามได้เรื่อง preview กับข้อมูลทั้งหมด |
| 5 | ข้อ 12 — SQL ค่าเฉลี่ยบริษัท | ตรวจทั้งความเข้าใจตาราง คะแนนคนละประเภท และเงื่อนไขข้อมูลจริง |
| 6 | ข้อ 8 — Validation รีวิว | ทดสอบ valid/invalid ได้เร็ว และเห็นความต่างของ UI กับ Backend |
| 7 | ข้อ 17 — Deadline ไม่ reset | เกี่ยวกับ default 5 นาทีและ timeout ที่อธิบายพร้อม test clock ได้ |
| 8 | ข้อ 16 — Null กับ empty rating | Bug Hunt เห็นผลชัด ไม่ต้องเปลี่ยน schema หรือสร้างข้อมูลธุรกิจ |
| 9 | ข้อ 10 — Filter submitted | เป็น React state/filter/empty state ที่ทำสดได้และเชื่อม E-Bidding |
| 10 | ข้อ 20 — API majorityRequired | ใช้วัดความเข้าใจ contract หลายชั้น เหมาะเมื่อกรรมการให้เวลามากขึ้น |

ถ้ามีเวลาซ้อมหนึ่งชั่วโมง ให้เริ่ม 2 → 18 → 11 → 5 → 8 แล้วพูดคำตอบแต่ละข้อ ถ้ามีเวลาเพิ่มให้ซ้อม SQL 12/13 และ contract ข้อ 20 สำหรับการถามต่อเชิงลึก

# 5 เรื่องที่ผมควรจำก่อนเข้าสอบ

1. **Backend entry point และ route** — `server/gemini_proxy.dart` → `main()` และ API switch Backend ใช้พอร์ต 8787; บัญชีบริษัท/แอดมินแยก handler ใน `server/agency_admin_api.dart` รีวิวบริษัทเป็น POST `/api/agency/reviews` → `_agencyReviews()` ที่หา agency จาก session ก่อนเรียก service

2. **AI, Matching และเวลารอ** — Matching จริงอยู่ `lib/matching/matching_engine.dart` → `MatchingEngine` / `MatchingClusterPlanner`; orchestration อยู่ `server/matching_service.dart` → `matchingStatus()` / `continueWaiting()` ค่า default 5 นาทีอยู่ `server/matching_waiting_policy.dart` → `MatchingWaitingPolicy.defaultDuration` หน้าจออ่าน deadline ผ่าน `lib/widgets/matching_status_view.dart` → `_syncDeadline()` Gemini สกัด preference ไม่ได้สุ่มตัดสินสมาชิกกลุ่ม และ alternative destination ต้องให้ผู้ใช้ยืนยัน

3. **Majority และ transaction** — `server/absolute_majority.dart` → `majorityRequired()` / `absoluteMajorityWinner()` สูตร floor(สมาชิกมีสิทธิ์ทั้งหมด/2)+1; Agency lifecycle อยู่ `server/voting_policy.dart` → `earlyFinalizeVotingRule()` / `validateVoteRule()` และ persistence อยู่ `server/gemini_proxy.dart` → `submitAgencyVote()` เมืองใช้ `server/destination_poll_service.dart` → `DestinationPollLifecyclePolicy.decide()` การเลือกสถานที่หลายรายการไม่ใช้ early majority แบบเมือง

4. **Flutter API URL, model และ payment status** — `lib/core/api_config.dart` → `ApiConfig.baseUrl` อ่าน `TRAVELIN_API_BASE_URL` จาก dart-define; Android debug default `127.0.0.1:8787` ต้องมี adb reverse ตาม launcher โมเดลโหวต/ชำระอยู่ `lib/services/group_service.dart` → `VotingSummary` / `PaymentRecord` หน้าชำระอยู่ `lib/pages/payment_page.dart` → `_PaymentStatus` paid มาจาก verification ของ Backend ไม่ใช่เพียงการอัปโหลดหลักฐาน

5. **Agency Dashboard และรีวิวจริง/สาธิต** — `portal/src/pages/AgencyDashboardPages.tsx` → `AgencyDashboardPage()` ใช้ `portal/src/components/AgencyReviewsCard.tsx`; API client อยู่ `portal/src/lib/api.ts` → `agencyApi.reviews()`; ข้อมูลจริงอ่าน `server/mysql_agency_review_repository.dart` ส่วน mean/latest/demo อยู่ `server/agency_review_service.dart` → `AgencyReviewService` / `DemoAgencyReviewSeed` Demo อยู่ใน memory เปิดได้เฉพาะ environment ที่อนุญาตพร้อม flag และไม่ใช้ทับรีวิวจริง Portal ตรวจด้วย `npm.cmd run build` / `npm.cmd run lint`; Flutter package นี้ใช้ `flutter test ...` ตาม test paths ในแต่ละข้อ


