Lab POS Flow — เปิดโต๊ะ · สั่งอาหาร · เช็คบิล · BONUS SUKI
เปิดเรื่อง หน้า 1 · อ่าน ~5 นาที

Lab POS Flow — เปิดโต๊ะ · สั่งอาหาร · เช็คบิล

ต่อจาก Lab Week 8 (เลเยอร์ + DAO) และ Lab Week 11 (หน้าจอ Swing + ล็อกอิน) · ต่อยอดในโปรเจกต์ MoogataLab เดิม · SQL + Java + Swing · ธีมร้านหมูกระทะ BONUS SUKI

📖 เรื่องราวของ Lab นี้
จนถึงตอนนี้ระบบของเรา "ล็อกอินและจัดการพนักงาน" ได้ — แต่ร้านหมูกระทะยังไม่ได้ขายอะไรเลย! ตอนที่เรามอง domain ของร้าน เราสรุปไว้ว่าร้านหมูกระทะต่างจากร้านกาแฟตรงที่ บิลมีชีวิต (เกิดตอนเปิดโต๊ะ จบตอนเช็คบิล) และ โต๊ะเป็นทรัพยากรที่มีสถานะ (ว่าง → ใช้งาน → กลับว่าง) — Lab นี้จะเปลี่ยนสองประโยคนั้นให้เป็นตาราง, SQL, โค้ด และหน้าจอจริง

เราจะเดิน flow จริงของร้านครบหนึ่งรอบ: ลูกค้า 2 คนมาที่โต๊ะ 4 → เปิดโต๊ะ → สั่งบุฟเฟ่ต์ 2 ท่าน → เรียกเพิ่มหม่าล่ากับชาเย็น → เช็คบิลรับมา 700 บาท ทอน 23.00 → โต๊ะกลับว่างพร้อมรับรอบใหม่ — โดยเริ่มจาก ทำด้วย SQL ล้วนให้เข้าใจข้อมูลก่อน แล้วค่อยห่อเป็น Java และหน้าจอ

สิ่งที่คุณจะสร้างได้ (What you'll build)

จบ codelab นี้คุณจะมี POS 3 หน้าจอบนฐานข้อมูลที่เพิ่ม 3 ตารางใหม่ — เปิดโปรแกรม ล็อกอิน (somchai/1234 แคชเชียร์ก็เข้าได้) แล้วทำงานได้จริง:

หน้าจอหน้าที่กฎที่ทำงานอยู่ข้างหลัง
PosFrame — ผังโต๊ะปุ่มโต๊ะ 6 ปุ่มสีตามสถานะ (เขียวว่าง/ชมพูใช้งาน) — กดโต๊ะว่างใส่จำนวนลูกค้าเพื่อเปิดโต๊ะห้ามเปิดโต๊ะที่ยังใช้งานอยู่ (Hard Lock, SC-4) — กฎอยู่ใน TableService
OrderFrame — สั่งอาหารเมนูซ้าย บิลขวา — เพิ่มรายการได้หลายรอบ บันทึกลงฐานข้อมูลทุกครั้งบิลที่ปิดแล้วสั่งเพิ่มไม่ได้ · ราคาเก็บแบบ snapshot ณ ตอนสั่ง
CheckoutDialog — เช็คบิลพิมพ์เงินรับ เห็นเงินทอนทันที (ทศนิยม 2 ตำแหน่ง) → ยืนยันปิดบิลปิดบิล + คืนโต๊ะ ในจุดเดียว (SC-1: 317.79/500 ต้องทอน 182.21)

เส้นทางทั้งหมด 16 หน้า

หน้าเรื่องสิ่งที่จะได้ตอนจบหน้า
2ทฤษฎีที่ต้องเข้าใจก่อนลงมือ (9 เรื่อง)เข้าใจ state, snapshot, transaction, SQL injection, กฎ 3 ชั้น, event-driven, single source of truth, เงินกับ double, กฎตั้งชื่อ control
3แผนผังข้อมูล + ของที่มากับ labอ่านแผนผัง 6 ตารางออก · รู้ว่าไฟล์ไหนคืออะไร แก้ตรงไหนได้
4–8ส่วนที่ 1 (Step 1–5): ข้อมูล — SQL สร้างตาราง · เดิน flow ด้วย SQL ล้วน · model · DAO · serviceconsole test ผ่าน 14/14
9–14ส่วนที่ 2 (Step 6–11): หน้าจอ — ออกแบบ + โค้ดสลับกันทีละหน้าจอ (สร้างเองใน NetBeans แบบ Lab 11): ผังโต๊ะ · สั่งอาหาร · เช็คบิลPOS 3 หน้าจอ ผ่านทดสอบ 7 สถานการณ์
15–16Troubleshooting · เช็คลิสต์ส่งงาน · สรุปเรื่องราวงานพร้อมส่ง

ส่วนที่ 1 กับส่วนที่ 2 แบ่งสอนสองครั้งได้ (รวม ~8–10 ชั่วโมง) — จบส่วนที่ 1 แล้วมี console test เป็นหลักฐานความคืบหน้า และส่วนที่ 2 คุณจะได้ ออกแบบหน้าจอเองใน NetBeans ครบทั้ง 3 หน้า (ลากวาง + ตั้งชื่อตัวแปร + ผูก event) — ทักษะเดียวกับที่เคยทำ LoginFrame/EmployeeFrame

ภาพรวม: กดปุ่มเดียว เกิดอะไรขึ้นทั้งระบบ

นี่คือเสาหลักที่ทุก Step จะประกอบเข้าด้วยกัน (Mermaid — ขยายดูได้):

flowchart LR
    A["🖱️ กดปุ่มโต๊ะ 4<br/>ใส่จำนวนลูกค้า 2"] --> B["TableService.openTable<br/>กฎ: คน ≥ 1 · โต๊ะว่าง? (SC-4)"]
    B --> C["BillDao.save<br/>INSERT INTO bill"]
    B --> D["TableDao.updateStatus<br/>UPDATE dining_table"]
    C --> E[("moogata.db")]
    D --> E
    E --> F["refreshTables()<br/>โต๊ะ 4 กลายเป็นสีชมพู"]
  
อ่านทีละชั้น: หน้าจอ (PosFrame) ไม่เขียน SQL เอง — คุยกับ service ที่มีกฎธุรกิจ แล้ว service เรียก DAO ซึ่งเป็นชั้นเดียวที่เขียน SQL ลงฐานข้อมูล · แผนภาพนี้คือช่วง 2.1 ของภาค SQL ที่คุณเคยพิมพ์เอง
ก่อนเริ่ม ต้องมี:
  • โปรเจกต์ MoogataLab ของตัวเองที่ผ่าน Lab Week 11 — มี ui/LoginFrame ทำงาน และรัน TestEmployeeService ได้ Welcome user : somchai (แคชเชียร์)
  • JDK 17+ · Apache NetBeans 22+ · Letos (ชื่อเดิม SQLiteStudio) — เครื่องมือชุดเดิม
  • Lab นี้ สร้างตารางใหม่ 3 ตาราง (เพิ่มเข้า moogata.db เดิม) — ส่วนของเดิมทุกอย่างใช้ต่อโดยไม่แก้
  • ทำส่วนที่ 1 ไม่ทันก็อยากทำหน้าจอ? มี checkpoint-part1 (โครงที่ผ่าน test แล้ว) และทางลัด starter-part2 (หน้าจอวาง layout ไว้ให้เหลือเติม TODO — ชื่อตัวแปรตรงกับแบบสร้างเองทุกตัว) — วิธีใช้อยู่ Step 6 หน้า 9
วิธีใช้ codelab นี้: กดปุ่ม "ถัดไป →" ด้านล่าง (หรือลูกศร ←/→ บนคีย์บอร์ด) เพื่อเดินทีละหน้า แต่ละ Step จบด้วย "✓ เช็ค" ให้รันพิสูจน์ก่อนไปต่อ และมีข้อความ git commit ให้เก็บงานทุกช่วง · ปุ่ม ☰ สารบัญด้านบนใช้กระโดดข้ามหน้าได้
เปิดเรื่อง หน้า 2 · ทฤษฎี 8 เรื่องที่จะใช้จริงใน lab นี้

ทฤษฎีที่ต้องเข้าใจก่อนลงมือ

อ่านรอบแรกแบบคร่าว ๆ ก็ได้ — เมื่อถึง Step ไหนที่ใช้เรื่องนั้น จะมีลิงก์ย้อนกลับมาที่นี่ทุกครั้ง ทฤษฎีจะเกาะเมื่อเห็นของจริง

1) State machine — สิ่งที่มีชีวิตมี "เส้นทางสถานะ" ของตัวเอง

โต๊ะกับบิลของร้านหมูกระทะไม่ได้เกิดแล้วจบในคำสั่งเดียวเหมือน CRUD เดิม — มันมีสถานะและเปลี่ยนได้ เฉพาะตามเหตุการณ์ (ไม่ใช่เขียนทับได้ตามใจ):
เหตุการณ์โต๊ะเปลี่ยนเป็นบิลเปลี่ยนเป็น
ลูกค้ามา เปิดโต๊ะว่าง → ใช้งาน(ไม่มี) → เปิด
ลูกค้าสั่งเพิ่ม(ไม่เปลี่ยน)เปิด (เพิ่มแถวใน bill_item)
เช็คบิลใช้งาน → ว่างเปิด → ปิด
stateDiagram-v2
    state "ว่าง" as vacant
    state "ใช้งาน" as inuse
    state "รอชำระ" as awaiting
    [*] --> vacant
    vacant --> inuse : เปิดโต๊ะ (ใส่จำนวนลูกค้า)
    inuse --> vacant : เช็คบิล
    vacant --> awaiting : ขอบิล (แบบฝึกหัด)
    awaiting --> vacant : เช็คบิล
stateDiagram-v2
    state "เปิด" as opened
    state "รอชำระ" as awaiting2
    state "ปิด" as closed
    [*] --> opened
    opened --> closed : เช็คบิล (รับเงิน ทอน)
    opened --> awaiting2 : ขอบิล (แบบฝึกหัด)
    awaiting2 --> closed : ชำระ
    closed --> [*]
สังเกตว่าบางเหตุการณ์เปลี่ยน 2 ตารางพร้อมกัน — นี่คือที่มาของทฤษฎีข้อ 3
🔗 ใช้จริงที่: คอลัมน์ status ของ dining_table และ bill (Step 1) · การเปลี่ยนสถานะทำใน service (Step 5)

2) Snapshot — ทำไมรายการในบิลต้อง "พก" ราคาเอง

ตอนสั่ง ระบบลอก item_name + unit_price จาก menu_item มาเก็บใน bill_item ทั้งที่มี menu_item_id ชี้ไปหาต้นฉบับอยู่แล้ว — ทำไมต้องลอกซ้ำ? เพราะ ราคาเมนูเปลี่ยนได้ในอนาคต แต่บิลเก่าต้องยังอ่านยอดเดิมได้ถูกต้อง การรวมเงินจึงคูณจากข้อมูลในแถวบิลเอง (qty × unit_price) ไม่ไปถามตารางเมนู
🔗 ใช้จริงที่: ตาราง bill_item (Step 1) · BillItem constructor ที่รับ MenuItem (Step 3) · คำถามขั้น 2.4-a

3) Transaction — ทำไมสองคำสั่งที่ควรคู่กัน ต้องสำเร็จพร้อมกัน

เปิดโต๊ะ = เปลี่ยนโต๊ะ + สร้างบิล · เช็คบิล = ปิดบิล + คืนโต๊ะ — ถ้าทำเสร็จครึ่งเดียว (เช่น ไฟดับกลางทาง) ข้อมูลจะ ไม่สอดคล้อง (บิลเปิดแต่โต๊ะว่าง / บิลปิดแต่โต๊ะติด) ทางแก้ของฐานข้อมูลคือ ธุรกรรม (transaction): ห่อหลายคำสั่งไว้ในกล่องเดียว สำเร็จทั้งก้อน (commit) หรือย้อนหมดเหมือนไม่ได้ทำ (rollback) — คุณสมบัติ ACID: Atomic ทั้งก้อนหรือไม่ทำ · Consistent ข้อมูลสมดุล · Isolated งานไม่ยุ่งกัน · Durable สำเร็จแล้วอยู่จริง
🔗 ใช้จริงที่: ภาค SQL เราทำ 2 คำสั่ง "ด้วยมือ" — ใน Java โค้ดของ lab นี้ยังไม่ห่อธุรกรรม (ตั้งใจ! จะพิสูจน์ช่องโหว่ใน Step 5 และเขียนแก้เป็นแบบฝึกหัดข้อ 3)

4) อ่านแผนผังตาราง: PK / FK / 1:N และ constraint 4 ตัว

PK = ที่อยู่ของแถว (ชี้ด้วยเลขเดียว ไม่ซ้ำ) · FK = คอลัมน์ที่ชี้ไปหา PK ของตารางอื่น (เส้นความสัมพันธ์) · 1:N อ่านจากปลายเส้น: โต๊ะ 1 ตัวมีบิลได้หลายใบ แต่บิล 1 ใบเป็นของโต๊ะเดียว
constraint 4 ตัว = กฎที่ DB บังคับให้เอง โดยไม่ต้องรอโปรแกรม:
constraintความหมายตัวอย่างจาก lab นี้
NOT NULLห้ามว่างbill.table_id ต้องมีโต๊ะเสมอ
UNIQUEห้ามซ้ำdining_table.table_number ไม่มีโต๊ะ 4 สองตัว
CHECKค่าต้องผ่านเงื่อนไขstatus IN ('ว่าง','ใช้งาน','รอชำระ') — พิมพ์ผิดโดน DB ปฏิเสธ
DEFAULTไม่บอก = ใช้ค่านี้status เกิดมาเป็น 'ว่าง' · opened_at ใส่เวลาให้เอง
🔗 ใช้จริงที่: CREATE TABLE ทั้ง 3 ตาราง (Step 1) · และคำถามใหญ่ของ lab: กฎแบบไหน DB คุมได้ กฎแบบไหนต้องไปอยู่ที่ service — ดูทฤษฎีข้อ 6

5) DAO + PreparedStatement — ทำไมต้อง `?` ไม่ต่อสตริง

DAO pattern = กำหนดให้ "ชั้นเดียว" ของโปรแกรมมีสิทธิ์เขียน SQL ของตารางหนึ่ง ๆ — ผิดที่ไหนรู้ทันทีว่าไปหาไฟล์ไหน
ส่วนเหตุผลของ ?: ลองเห็นภัยก่อน — ถ้าเขียนค้นสมาชิกแบบต่อสตริง
"SELECT * FROM members WHERE phone = '" + phone + "'"
ใครพิมพ์ ' OR '1'='1 เข้าไป SQL ที่รันจริงกลายเป็น WHERE phone = '' OR '1'='1' → ดึงข้อมูลทุกแถวมาโชว์! นี่คือ SQL injection — ส่วน ? จะส่งค่าแยกจากคำสั่ง ค่าที่พิมพ์มาเป็นแค่ข้อความ ไม่มีวันกลายเป็น SQL
🔗 ใช้จริงที่: DAO ทั้ง 4 ไฟล์ (Step 4) — และสังเกต method เดียวที่ lab ตั้งใจให้ต่อสตริงคือ getAll(where, order) พร้อม LOG.warning เตือนไว้ เพื่อฝึกเท่านั้น

6) กฎของร้านอยู่ 3 ชั้น — อย่าใส่ผิดชั้น

ชั้นกฎแบบไหนอยู่นี่ตัวอย่างจาก lab นี้ใครบังคับ
ฐานข้อมูล (constraint)กฎโครงสร้างข้อมูล — ผิดแล้วคำสั่งไม่ผ่านเลยstatus มีได้ 3 ค่า · qty > 0 · FK ห้ามชี้แถวที่ไม่มีCHECK / UNIQUE / FK
service (กฎธุรกิจ)กฎที่ต้อง "อ่านสถานการณ์ก่อน" แล้วค่อยตัดสินห้ามเปิดโต๊ะซ้ำ (SC-4) · เงินรับต้อง ≥ ยอด · บิลปิดแล้วสั่งเพิ่มไม่ได้if ใน TableService/BillService
หน้าจอ (UI)การจัดรูปแบบและความสะดวก — ไม่ใช่กฎปุ่มยืนยันเป็นสีเทาเมื่อเงินไม่พอ · แสดงเงิน %.2f · dialog เตือนโค้ดใน ui/
และหลักคิดการทดสอบ 2 ข้อที่ test ของ lab ใช้: (1) test ต้องล้างข้อมูลเองก่อนรัน — ไม่งั้นผลขึ้นกับ "ใครรันก่อน" ไม่ใช่ความถูกต้องของโค้ด (2) ค่าคาดหวังเขียนไว้ก่อนเทียบกับค่าจริง — test ที่แค่พิมพ์ค่าให้ดูด้วยตาไม่ใช่ test; เทียบทศนิยมต้องเผื่อคลาด (Math.abs(a-b) < 0.001)
🔗 ใช้จริงที่: ขั้น 2.7 (พิสูจน์ว่า SQL กันเปิดโต๊ะซ้ำไม่ได้) · TableService (Step 5) · Test ทั้งหมด

7) Swing event-driven — โปรแกรมหน้าจอ "รอเหตุการณ์" (ทบทวนจาก Lab 11)

โปรแกรม GUI ไม่ไล่บรรทัดจาก main ลงล่าง — main ทำแค่ "จุดติดเครื่อง" (สร้างหน้าต่าง โชว์ แล้วจบ) แล้วโปรแกรมเข้าสู่โหมดรอ: ผู้ใช้กดปุ่ม = JVM เรียก actionPerformed ที่เราผูกไว้ให้เองบน EDT · JTable เป็นแค่หน้าตา ข้อมูลจริงอยู่ที่ DefaultTableModel — ล้างแล้วเติมใหม่ที่ model ตารางวาดใหม่เอง · dialog แบบ modal = true ล็อกหน้าอื่นจนปิด และโค้ดหลัง setVisible(true) ทำงานต่อหลังปิด dialog
🔗 ใช้จริงที่: PosFrame/OrderFrame/CheckoutDialog (Step 6–8) — โครงเดียวกับ LoginFrame/EmployeeFrame ที่เคยทำ

8) เงินกับ double — 0.1 + 0.2 ≠ 0.3 และ single source of truth

double เก็บเลขเป็นฐานสอง เลขทศนิยมอย่าง 0.1 ถูกเก็บแบบ "ใกล้เคียงที่สุด" — ลองพิมพ์ System.out.println(0.1 + 0.2); จะได้ 0.30000000000000004 ระบบเงินจึงอ่อไหวถ้าเอา double ไปเทียบตรง ๆ lab นี้รอดเพราะกัน 2 ชั้น: ราคา seed เก็บตรงเป๊ะ + ทุกจุดแสดงผลใช้ String.format("%.2f", ...) — ทางจริงจังคือ BigDecimal (แบบฝึกหัด Part 1 ข้อ 4)

single source of truth: หน้าจอไม่ "จำ" ยอดเอง — ทุกครั้งที่ข้อมูลเปลี่ยน ให้อ่านจากฐานข้อมูลใหม่แล้ววาดทับ (ชื่อ method ก็ตั้งเป็น refreshBill) — ผลคือปิดโปรแกรมเปิดใหม่ ข้อมูลไม่หาย และแคชเชียร์อีกเครื่องเห็นของจริงเสมอ
🔗 ใช้จริงที่: refreshBill() / updateChange() (Step 9, 11) · ทดสอบ "ปิดโปรแกรมกลางบิลแล้วเปิดใหม่"

9) กฎการออกแบบหน้าจอใน NetBeans — ตั้งชื่อก่อน แล้วค่อยเขียนโค้ด

Lab 11 คุณเคยทำกับ LoginFrame/EmployeeFrame มาแล้ว — ทบทวนกฎ 4 ข้อที่ทำให้ "ภาพบนจอ" กับ "โค้ด" ตรงกันเสมอ:
  • ลาก control จาก Palette (Free Design — วางตรงไหนก็ได้) แล้ว คลิกขวา → Change Variable Name… ตั้งชื่อตามหน้าที่: ป้าย lbl… · ช่องกรอก txt… · ปุ่ม btn… · ตาราง tbl… · scrollpane scr… — โค้ดทุก Step ต่อไปอ้างชื่อเหล่านี้ ชื่อเพี้ยน = compile ไม่ผ่าน
  • JTable ที่ลากลงมา NetBeans ครอบด้วย JScrollPane ให้เอง (ถ้าไม่มี scrollpane หัวคอลัมน์จะหาย) — เปลี่ยนชื่อ scrollpane ด้วย เช่น scrMenu
  • ดับเบิลคลิกปุ่ม = NetBeans สร้างเมธอด event และผูกให้เอง — ห้ามพิมพ์เมธอด event ลอย ๆ เอง (ปุ่มจะเงียบเพราะไม่ได้ผูก)
  • ข้อความที่มี "ข้อมูลจริง" ปนอยู่ (ชื่อพนักงาน, เลขโต๊ะ, ยอดรวม) ไม่พิมพ์ใน Properties — ให้โค้ดเขียนตอนรัน เพราะค่ามาจากฐานข้อมูล
🔗 ใช้จริงที่: ออกแบบ PosFrame (Step 6) · OrderFrame (Step 8) · CheckoutDialog (Step 10) — แต่ละหน้ามี "ตาราง control" ให้ตั้งชื่อตามเป๊ะ และภาพหน้าจอมีเลขวงกลมกำกับ = เลขแถวในตาราง
เปิดเรื่อง หน้า 3 · แผนที่ข้อมูลและไฟล์ทั้งหมด

แผนผังข้อมูล + ของที่มากับ lab

อ่านแผนผังนี้ออกก่อนพิมพ์ CREATE TABLE — เส้นเชื่อมทุกเส้นคือ FK ที่คุณจะเขียนใน Step 1

แผนผัง 6 ตาราง (3 ตารางเดิม + 3 ตารางใหม่)

erDiagram
    dining_table {
        int table_id PK
        int table_number UK
        text status "ว่าง ใช้งาน รอชำระ"
        int headcount
    }
    bill {
        int bill_id PK
        int table_id FK
        text status "เปิด รอชำระ ปิด"
        text opened_at
        text closed_at
        real total
        real cash_received
        real change_amount
        int opened_by FK
    }
    bill_item {
        int bill_item_id PK
        int bill_id FK
        int menu_item_id FK
        text item_name "snapshot"
        int qty
        real unit_price "snapshot"
    }
    menu_item {
        int item_id PK
        text item_name
        real item_price
        text item_unit
        text item_status
    }
    employee {
        int employee_id PK
        text employee_name
    }
    dining_table ||--o{ bill : "มีบิล"
    bill ||--|{ bill_item : "มีรายการ"
    menu_item ||--o{ bill_item : "ถูกสั่ง"
    employee ||--o{ bill : "เปิดบิล"
📖 คู่ข้อมูลที่เป็นหัวใจของทั้ง lab: Bill 1 ↔ * BillItem — บิล 1 ใบมีรายการที่สั่งหลายแถว ทุกครั้งที่ลูกค้าสั่งเพิ่ม = เพิ่ม 1 แถวใน bill_item พร้อม snapshot ชื่อและราคา ณ ตอนสั่ง

ของที่มากับ lab — ไฟล์ไหนคืออะไร แก้ตรงไหนได้

ไฟล์/โฟลเดอร์คืออะไรคุณต้องทำอะไรกับมัน
schema-pos.sqlสคริปต์เพิ่ม 3 ตารางใหม่ (idempotent — รันซ้ำปลอดภัย)ใช้ใน Step 1 — พิมพ์ตามเอกสารก่อน แล้วเทียบกับไฟล์นี้ · ดาวน์โหลด: schema-pos.sql
reset-pos.sqlล้างข้อมูล POS คืนสถานะ "เพิ่งเปิดร้าน"ใช้บ่อยที่สุด — ก่อนเริ่มส่วนที่ 2 และเมื่อข้อมูลเพี้ยนจากการทดลอง · ดาวน์โหลด: reset-pos.sql
src/เฉลยฉบับเต็มของผู้สอนไว้เทียบเมื่อติด — ห้าม copy ทั้งโฟลเดอร์ไปส่ง
checkpoint-part1/โครงการจริงที่มีโค้ด Part 1 (ข้อมูล) ผ่าน test แล้วใช้แทนโปรเจกต์ตัวเองได้ ถ้าทำ Part 1 ไม่ทัน — วิธีใน Step 6
starter-part2/ทางลัดของส่วนที่ 2: หน้าจอทั้ง 3 วาง layout และผูก event ไว้ให้ เหลือเติม TODO (ชื่อตัวแปรตรงกับแบบสร้างเองทุกตัว)สำหรับคนที่ตามไม่ทัน — วิธีใช้อยู่ Step 6 หน้า 9
sync-copies.shสคริปต์ซิงก์โค้ดจาก src/ ไป 2 โฟลเดอร์สำรองเครื่องมือผู้สอน — นิสิตไม่ต้องแตะ
⚠ เรื่องการสะกดชื่อร้าน: ข้อความทั้งหมดใช้ "หมูกระทะ" (ตรงกับที่โชว์บนหน้าจอ) — ข้อยกเว้นที่จงใจมี 2 จุด: (1) ชื่อทางเทคนิค moogata (โฟลเดอร์/db/package) (2) ชื่อเมนู "บุฟเฟ่ต์หมูกะทะ" เป็นข้อมูลจริงในตาราง menu_item จาก seed ของ Lab Week 8 — ต้องสะกดตามฐานข้อมูลเดิมเสมอ (Step 2 ค้นหาด้วยชื่อนี้)
📖 ตัวอย่างปีก่อน (อ้างอิงเท่านั้น — ห้ามลอกตรง): ปีก่อนเคยสอนกรณีร้านกาแฟที่มี Reciept + RecieptDetail — ต่างจากที่เราจะทำ 3 จุด: (1) เขาเก็บบิลไว้ในหน่วยความจำแล้วบันทึกครั้งเดียวตอนชำระ บิลร้านหมูกระทะเปิดนาน 1–2 ชม. ต้องบันทึกทุกรอบที่สั่ง (2) เขาไม่มีโต๊ะ/สถานะ (3) เขาไม่มีการคิดเงินทอน — ปีนี้สอนใหม่จากธุรกิจร้านหมูกระทะจริง
ส่วนที่ 1 — ข้อมูล Step 1 จาก 11 · หน้า 4/16 · ~30–40 นาที

Step 1 — เพิ่ม 3 ตารางด้วย Letos: dining_table · bill · bill_item

พิมพ์ CREATE TABLE เองทีละตาราง (เฉลยอยู่ไฟล์ schema-pos.sql — ดาวน์โหลดไว้เทียบ พิมพ์เสร็จค่อยเปิดดู) การพิมพ์เองทำให้สังเกตเส้น FK ทีละเส้น — ทฤษฎีข้อ 4 หน้า 2 จะถูกใช้จริงทุกบรรทัด

เปิดไฟล์ moogata.db ใน Letos → Tools → Open SQL editor แล้วพิมพ์ทีละคำสั่ง กด Execute ทีละคำสั่ง

ขั้น 1.1 — ตารางที่ 1: dining_table (โต๊ะ — ทรัพยากรที่มีสถานะ)

CREATE TABLE IF NOT EXISTS dining_table (
    table_id     INTEGER PRIMARY KEY AUTOINCREMENT,
    table_number INTEGER   NOT NULL UNIQUE,
    status       TEXT (20) NOT NULL DEFAULT 'ว่าง'
                 CHECK (status IN ('ว่าง', 'ใช้งาน', 'รอชำระ')),
    headcount    INTEGER   NOT NULL DEFAULT 0
                 CHECK (headcount >= 0)
);

ขั้น 1.2 — ตารางที่ 2: bill (บิล — เกิดตอนเปิดโต๊ะ จบตอนเช็คบิล)

CREATE TABLE IF NOT EXISTS bill (
    bill_id       INTEGER PRIMARY KEY AUTOINCREMENT,
    table_id      INTEGER   NOT NULL
                  REFERENCES dining_table (table_id)
                  ON DELETE RESTRICT ON UPDATE RESTRICT,
    status        TEXT (20) NOT NULL DEFAULT 'เปิด'
                  CHECK (status IN ('เปิด', 'รอชำระ', 'ปิด')),
    opened_at     TEXT (30) NOT NULL DEFAULT (datetime('now', 'localtime')),
    closed_at     TEXT (30),
    total         REAL      NOT NULL DEFAULT 0
                  CHECK (total >= 0),
    cash_received REAL,
    change_amount REAL,
    opened_by     INTEGER   NOT NULL
                  REFERENCES employee (employee_id)
                  ON DELETE RESTRICT ON UPDATE RESTRICT
);
📖 อ่านให้ขาด: total / cash_received / change_amount ยังไม่มีค่าตอนเปิดบิล — จะถูกเขียนตอนเช็คบิลเท่านั้น และ opened_by ผูกกับ employee ทำให้ระบบรู้ว่าใครเปิดบิลทุกใบ · ON DELETE RESTRICT = ลบแถวที่มีคนอ้างอิงไม่ได้

ขั้น 1.3 — ตารางที่ 3: bill_item (รายการในบิล — สั่ง 1 รอบ = 1 แถว)

CREATE TABLE IF NOT EXISTS bill_item (
    bill_item_id INTEGER PRIMARY KEY AUTOINCREMENT,
    bill_id      INTEGER    NOT NULL
                 REFERENCES bill (bill_id)
                 ON DELETE RESTRICT ON UPDATE RESTRICT,
    menu_item_id INTEGER    NOT NULL
                 REFERENCES menu_item (item_id)
                 ON DELETE RESTRICT ON UPDATE RESTRICT,
    item_name    TEXT (120) NOT NULL,
    qty          INTEGER    NOT NULL CHECK (qty > 0),
    unit_price   REAL       NOT NULL CHECK (unit_price >= 0)
);

INSERT OR IGNORE INTO dining_table (table_id, table_number, status, headcount) VALUES
    (1, 1, 'ว่าง',   0),
    (2, 2, 'ว่าง',   0),
    (3, 3, 'ใช้งาน', 4),
    (4, 4, 'ว่าง',   0),
    (5, 5, 'ว่าง',   0),
    (6, 6, 'ว่าง',   0);
📖 อ่านให้ขาด: item_name กับ unit_price ใน bill_item คือ snapshot — พิมพ์ลอกมาจาก menu_item ตอนสั่ง (Step 2 จะเห็นชัด) · seed โต๊ะ 3 ให้มีลูกค้านั่งอยู่แล้ว 4 คน — ใช้ทดสอบสถานะทันที

ขั้น 1.4 — ตรวจโครงตารางและเส้น FK

PRAGMA foreign_key_list(bill);       -- ต้องเห็น 2 เส้น: dining_table, employee
PRAGMA foreign_key_list(bill_item);  -- ต้องเห็น 2 เส้น: bill, menu_item
SELECT * FROM dining_table ORDER BY table_number;

ผลที่ต้องเห็น (dining_table):

1|1|ว่าง|0
2|2|ว่าง|0
3|3|ใช้งาน|4
4|4|ว่าง|0
5|5|ว่าง|0
6|6|ว่าง|0
✓ เช็ค: FK ครบทุกเส้น + โต๊ะ 6 ตัว โต๊ะ 3 ใช้งาน 4 คน
git add .
git commit -m "lab pos-flow: เพิ่มตาราง dining_table bill bill_item"
ส่วนที่ 1 — ข้อมูล Step 2 จาก 11 · หน้า 5/16 · ~60–75 นาที

Step 2 — เดิน flow ครบรอบด้วย SQL ล้วน ★ หัวใจของทั้ง lab

ก่อนเขียน Java แม้แต่บรรทัดเดียว ให้ "เป็นแคชเชียร์" ด้วย SQL ครบหนึ่งรอบ: ลูกค้า 2 คนมาที่โต๊ะ 4 → สั่งบุฟเฟ่ต์ 2 → เรียกเพิ่มหม่าล่ากับชาเย็น → เช็คบิลรับมา 700 บาท ทุกคำสั่งใน Step นี้จะกลายเป็น method ของ DAO/Service ใน Step 4–5 — ทำช้าและเข้าใจ อย่ารีบ

ขั้น 2.1 — เปิดโต๊ะ 4 / ลูกค้า 2 คน = UPDATE โต๊ะ + INSERT บิล (ทำคู่กันด้วยมือเอง)

UPDATE dining_table SET status = 'ใช้งาน', headcount = 2 WHERE table_number = 4;

-- แคชเชียร์ somchai = employee_id 2 (ดูจาก seed ของ Lab 8)
INSERT INTO bill (table_id, status, opened_by) VALUES (4, 'เปิด', 2);

SELECT bill_id, table_id, status, opened_at FROM bill;

ผลที่ต้องเห็น:

1|4|เปิด|2026-10-10 22:22:05

สังเกต: opened_at ไม่ต้องพิมพ์ — DEFAULT ใส่เวลาให้เอง และตอนนี้บิลมี total = 0 เพราะยังไม่ได้สั่งอะไรเลย

ขั้น 2.2 — สั่งรอบแรก: ดูราคาจากตารางแม่ก่อน แล้ว "ลอก" มาเก็บ (snapshot)

SELECT item_id, item_name, item_price, item_unit FROM menu_item
WHERE item_name = 'บุฟเฟ่ต์หมูกะทะ';
1|บุฟเฟ่ต์หมูกะทะ|289.0|ท่าน
INSERT INTO bill_item (bill_id, menu_item_id, item_name, qty, unit_price)
VALUES (1, 1, 'บุฟเฟ่ต์หมูกะทะ', 2, 289.00);

ขั้น 2.3 — สั่งรอบที่สอง: ลูกค้าเรียกเพิ่ม (INSERT อีกแถว ไม่ได้แก้ของเดิม)

INSERT INTO bill_item (bill_id, menu_item_id, item_name, qty, unit_price)
VALUES (1, 3, 'น้ำซุปหม่าล่า (พิเศษ)', 1, 49.00);

INSERT INTO bill_item (bill_id, menu_item_id, item_name, qty, unit_price)
VALUES (1, 7, 'ชาเย็น', 2, 25.00);
📖 คำตอบของ "สั่งได้หลายรอบ" ในทางข้อมูล: INSERT เพิ่มทีละแถว ทุกครั้งที่ลูกค้าเรียก — แถวเก่าไม่ถูกแตะ

ขั้น 2.4 — ดูบิลทั้งใบ + เทียบ snapshot กับตารางแม่

SELECT bill_item_id, item_name, qty, unit_price, qty * unit_price AS line_total
FROM bill_item WHERE bill_id = 1;
1|บุฟเฟ่ต์หมูกะทะ|2|289.0|578.0
2|น้ำซุปหม่าล่า (พิเศษ)|1|49.0|49.0
3|ชาเย็น|2|25.0|50.0
SELECT b.item_name AS on_bill, m.item_name AS in_menu,
       b.unit_price AS price_at_order, m.item_price AS price_now
FROM bill_item b JOIN menu_item m ON b.menu_item_id = m.item_id;
คำถามขั้น 2.4-a
ถ้าพรุ่งนี้ร้านแก้ราคาบุฟเฟ่ต์เป็น 319 — บรรทัดไหนในผลลัพธ์จะเปลี่ยน บรรทัดไหนไม่เปลี่ยน เพราะอะไร?
คำตอบ: price_now เป็น 319 แต่ price_at_order ยัง 289 — เพราะบิลเก็บ snapshot ของตัวเอง (ทฤษฎีข้อ 2)

ขั้น 2.5 — รวมเงินทั้งบิล

SELECT SUM(qty * unit_price) AS total FROM bill_item WHERE bill_id = 1;
677.0

ขั้น 2.6 — เช็คบิล = เขียนบิล + คืนโต๊ะ (2 คำสั่งที่ควรเกิดคู่กัน — ทฤษฎีข้อ 3)

UPDATE bill SET status = 'ปิด', total = 677.00, cash_received = 700.00,
       change_amount = 23.00, closed_at = datetime('now', 'localtime')
WHERE bill_id = 1;

UPDATE dining_table SET status = 'ว่าง', headcount = 0 WHERE table_id = 4;

ผลที่ต้องเห็น (bill):

1|4|ปิด|2026-10-10 22:22:05|2026-10-10 22:22:05|677.0|700.0|23.0|2

โต๊ะ 4 ต้องกลับมา ว่าง แล้ว — ลูกค้ารอบใหม่มานั่งได้

ขั้น 2.7 — คำถามท้ายช่วง: SQL กัน "เปิดโต๊ะซ้ำ" ได้ไหม?

ลองทำสิ่งที่แคชเชียร์ผิดพลาดเป็นเรื่อง ๆ ได้ แล้วดูว่าฐานข้อมูลขัดขวางไหม:

INSERT INTO bill_item (bill_id, menu_item_id, item_name, qty, unit_price)
VALUES (1, 6, 'ข้าวสวย', 1, 10.00);   -- (1) ใส่รายการลงบิลที่ "ปิด" ไปแล้ว

UPDATE dining_table SET status = 'ใช้งาน', headcount = 6 WHERE table_number = 3;
SELECT * FROM dining_table WHERE table_number = 3;   -- (2) เปิดโต๊ะ 3 ที่ยังใช้งานอยู่ซ้ำ
ผลที่ต้องเห็น: ทั้งสองคำสั่งสำเร็จหมด! ฐานข้อมูลไม่ขัดขวางเลย (บิลปิดแล้วยังโดนเพิ่มรายการ, โต๊ะ 3 โดนทับเป็น 6 คน) เพราะกฎพวกนี้ไม่ใช่โครงสร้างข้อมูล มันคือ กฎธุรกิจ — และที่ที่มันควรอยู่คือเลเยอร์ service (Step 5) จำประเด็นนี้ไว้

ขั้น 2.8 — ล้างข้อมูลทดลองกลับสู่สถานะ "เพิ่งเปิดร้าน"

ขั้น 2.7 เพิ่งทำ 2 อย่างที่ต้องเยียดยาม: โต๊ะ 3 โดนทับเป็น 6 คน และมีข้าวสวยหล่นในบิลที่ปิดแล้ว — ล้างให้ครบทั้งข้อมูลและตัวนับ id:

DELETE FROM bill_item;      -- ลบลูกก่อนแม่เสมอ (FK แบบ RESTRICT จะด่าถ้าลบแม่ก่อน)
DELETE FROM bill;
DELETE FROM sqlite_sequence WHERE name IN ('bill', 'bill_item');  -- รีเซ็ตตัวนับ id
UPDATE dining_table SET status = 'ใช้งาน', headcount = 4 WHERE table_number = 3;
UPDATE dining_table SET status = 'ว่าง',   headcount = 0 WHERE table_number <> 3;

ผลที่ต้องเห็น: bill_item/bill = 0/0 · โต๊ะ 3 กลับเป็น 'ใช้งาน 4 คน' · โต๊ะอื่นว่าง — แล้วปิด Letos ให้เรียบร้อยก่อนช่วงที่ 3 (กัน db locked) คำสั่งชุดนี้มีอยู่ในไฟล์ reset-pos.sql (ดาวน์โหลดได้ที่ลิงก์) — ใช้ซ้ำได้ตลอด lab

✓ เช็ค: เดิน flow ครบรอบด้วย SQL ได้ + พิสูจน์แล้วว่า SQL กันเปิดโต๊ะซ้ำ/สั่งบิลปิดเองไม่ได้
git commit -am "lab pos-flow: เดิน flow เปิดโต๊ะ-สั่ง-เช็คบิล ด้วย SQL ครบรอบ"
ส่วนที่ 1 — ข้อมูล Step 3 จาก 11 · หน้า 6/16 · ~40–50 นาที

Step 3 — เลเยอร์ model: สร้าง 4 คลาสใหม่ด้วย NetBeans

model = "กล่องถือข้อมูล 1 แถว" ของตารางหนึ่ง ๆ — ทำตามแบบ Employee.java ที่พิมพ์มาแล้วทุกประการ: field + constructor + getters/setters + fromRS ของใหม่มี 2 อย่าง: สถานะเป็นค่าคงที่ภาษาไทย (ตรงกับ CHECK ใน Step 1) และ BillItem คำนวณยอดของตัวเองได้ (qty × unit_price เป็นความรู้ของแถวนั้น)

ขั้น 3.1 — สร้าง 4 ไฟล์ใน package model

  1. package com.example.moogata.model มี Employee.java อยู่แล้วจาก Lab 8 — ไม่ต้องสร้างใหม่
  2. คลิกขวา package model → New → Java Class ชื่อ MenuItem — แล้วทำซ้ำอีก 3 ครั้ง: DiningTable, Bill, BillItem (ชื่อต้องขึ้นต้นตัวพิมพ์ใหญ่พอดี — ชื่อไฟล์ = ชื่อคลาส)

โครงของทุกไฟล์เหมือนกันหมด — package บรรทัดเดียว, import 2 บรรทัด (สำหรับ fromRS), แล้วค่อยใส่เนื้อในของแต่ละตัว:

package com.example.moogata.model;

import java.sql.ResultSet;
import java.sql.SQLException;

public class MenuItem {

    // (field, constructor, getters/setters, method ช่วย, fromRS — ตามขั้นถัดไป)
}

ขั้น 3.2 — MenuItem (เมนู — แม่แบบของการสั่ง)

field 6 ตัว — ชื่อ field ตรงกับคอลัมน์แบบ camelCase (item_id → id, item_name → name …):

private int id;
private String name;
private double price;
private String unit;      // หน่วยขาย: ท่าน / หม้อ / จาน / แก้ว / ชิ้น
private String status;    // สถานะขาย: มี / หมด
private int categoryId;   // FK → menu_category

constructor 3 แบบเหมือน Employee (เต็ม / เมนูใหม่ยังไม่มี id / ว่างสำหรับ fromRS) — สองแบบแรก delegate กัน:

public MenuItem(int id, String name, double price, String unit, String status, int categoryId) {
    this.id = id;
    this.name = name;
    this.price = price;
    this.unit = unit;
    this.status = status;
    this.categoryId = categoryId;
}

/** สำหรับเมนูใหม่ที่ยังไม่มี id (id จะถูกสร้างตอน save ผ่าน AUTOINCREMENT) */
public MenuItem(String name, double price, String unit, String status, int categoryId) {
    this(-1, name, price, unit, status, categoryId);
}

public MenuItem() {
    this.id = -1;
}

getters/setters: พิมพ์ 1 คู่ตามตัวอย่าง แล้วเติมอีก 5 คู่ตามสูตรเดียวกัน (id → getId()/setId(), name → getName()/setName(), price, unit, status, categoryId):

public int getId() {
    return id;
}

public void setId(int id) {
    this.id = id;
}

แล้วปิดท้ายด้วย method ช่วยแสดงผล + fromRS — หัวใจที่ DAO จะเรียกใช้ (อ่านคอลัมน์ตามชื่อจริงในตาราง):

/** ราคาพร้อมทศนิยม 2 ตำแหน่ง ไว้แสดงผล เช่น 289.00 */
public String getPriceText() {
    return String.format("%.2f", price);
}

/** แปลงแถวข้อมูลจาก ResultSet เป็นวัตถุ MenuItem — แถวปัจจุบันของ rs ต้องถูกเรียก rs.next() แล้วเท่านั้น */
public static MenuItem fromRS(ResultSet rs) throws SQLException {
    MenuItem item = new MenuItem();
    item.setId(rs.getInt("item_id"));
    item.setName(rs.getString("item_name"));
    item.setPrice(rs.getDouble("item_price"));
    item.setUnit(rs.getString("item_unit"));
    item.setStatus(rs.getString("item_status"));
    item.setCategoryId(rs.getInt("category_id"));
    return item;
}

ขั้น 3.3 — DiningTable (โต๊ะ — ทรัพยากรที่มีสถานะ)

field: id, number, status, headcount + ค่าคงที่สถานะ 3 ค่า (ต้องสะกดตรงกับ CHECK ใน Step 1 ทุกตัวอักษร) + method ช่วย 1 ตัว:

public static final String STATUS_VACANT = "ว่าง";
public static final String STATUS_IN_USE = "ใช้งาน";
public static final String STATUS_AWAITING = "รอชำระ";

/** โต๊ะนี้ว่างอยู่หรือไม่ — ใช้บ่อยทั้งใน service และหน้าจอ */
public boolean isVacant() {
    return STATUS_VACANT.equals(status);
}

constructor 3 แบบ + getters/setters 4 คู่ตามสูตรเดียวกับ MenuItem และ fromRS ที่อ่าน 4 คอลัมน์ของ dining_table:

public static DiningTable fromRS(ResultSet rs) throws SQLException {
    DiningTable table = new DiningTable();
    table.setId(rs.getInt("table_id"));
    table.setNumber(rs.getInt("table_number"));
    table.setStatus(rs.getString("status"));
    table.setHeadcount(rs.getInt("headcount"));
    return table;
}

ขั้น 3.4 — Bill (บิล — มีชีวิต)

field 9 ตัว: id, tableId, status, openedAt, closedAt, total, cashReceived, changeAmount, openedBy + ค่าคงที่สถานะ (STATUS_OPEN = "เปิด", STATUS_AWAITING = "รอชำระ", STATUS_CLOSED = "ปิด") + constructor พิเศษสำหรับ "บิลที่เพิ่งเปิด" — ยอด/เวลาปิดยังไม่มี ให้ฐานข้อมูล default ใส่:

/** สำหรับบิลที่เพิ่งเปิด — ยังไม่มียอด/เวลาปิด (ให้ฐานข้อมูล default ให้) */
public Bill(int tableId, int openedBy) {
    this(-1, tableId, STATUS_OPEN, null, null, 0, 0, 0, openedBy);
}

public boolean isOpen() {
    return STATUS_OPEN.equals(status);
}

getters/setters 9 คู่ + fromRS ที่อ่านครบทุกคอลัมน์ของ bill (เทียบ CREATE TABLE ใน Step 1 ทีละบรรทัด):

public static Bill fromRS(ResultSet rs) throws SQLException {
    Bill bill = new Bill();
    bill.setId(rs.getInt("bill_id"));
    bill.setTableId(rs.getInt("table_id"));
    bill.setStatus(rs.getString("status"));
    bill.setOpenedAt(rs.getString("opened_at"));
    bill.setClosedAt(rs.getString("closed_at"));
    bill.setTotal(rs.getDouble("total"));
    bill.setCashReceived(rs.getDouble("cash_received"));
    bill.setChangeAmount(rs.getDouble("change_amount"));
    bill.setOpenedBy(rs.getInt("opened_by"));
    return bill;
}

ขั้น 3.5 — BillItem (รายการในบิล — snapshot)

field: id, billId, menuItemId, itemName, qty, unitPrice — และ constructor ตัวสำคัญที่สุดของทั้ง lab: สร้างจากเมนู = ลอกชื่อและราคามาเก็บเอง (ทฤษฎีข้อ 2 หน้า 2 — snapshot):

/** สร้างจากเมนู — ลอกชื่อและราคาจาก menu_item มาเก็บเอง (snapshot) */
public BillItem(int billId, MenuItem menuItem, int qty) {
    this(billId, menuItem.getId(), menuItem.getName(), qty, menuItem.getPrice());
}

/** ยอดของบรรทัดนี้ = จำนวน × ราคาต่อหน่วย (ตรงกับ SQL: qty * unit_price ใน Step 2) */
public double getLineTotal() {
    return qty * unitPrice;
}

/** ยอดพร้อมทศนิยม 2 ตำแหน่ง ไว้โชว์ในตารางบิล */
public String getLineTotalText() {
    return String.format("%.2f", getLineTotal());
}

constructor เต็มรับครบ 6 ค่า (id, billId, menuItemId, itemName, qty, unitPrice) + แบบไม่มี id + แบบว่าง + getters/setters 6 คู่ + fromRS:

public static BillItem fromRS(ResultSet rs) throws SQLException {
    BillItem item = new BillItem();
    item.setId(rs.getInt("bill_item_id"));
    item.setBillId(rs.getInt("bill_id"));
    item.setMenuItemId(rs.getInt("menu_item_id"));
    item.setItemName(rs.getString("item_name"));
    item.setQty(rs.getInt("qty"));
    item.setUnitPrice(rs.getDouble("unit_price"));
    return item;
}
📖 สังเกต: ทั้ง 4 ไฟล์ไม่มี SQL ไม่มี Connection เลย — model เป็นแค่ "กล่องข้อมูล + ความรู้เล็ก ๆ ของตัวเอง" (isVacant, isOpen, getLineTotal) ส่วนการดึง-บันทึกเป็นหน้าที่ของ DAO ที่จะสร้างใน Step 4 · เทียบเฉลยได้ที่ src/main/java/com/example/moogata/model/
✓ เช็ค: Build Project (F11) หรือ mvn compile ผ่านทุกไฟล์ — ถ้าไม่ผ่าน อ่าน error บรรทัดแรก: มักเป็นชื่อ getter/setter ไม่ตรงกับที่ fromRS เรียก
git commit -am "lab pos-flow: model MenuItem DiningTable Bill BillItem"
ส่วนที่ 1 — ข้อมูล Step 4 จาก 11 · หน้า 7/16 · ~60–75 นาที

Step 4 — เลเยอร์ dao: สร้าง 4 คลาส + ทดสอบแรก

ทุกตัว implements Dao<T> เหมือน EmployeeDao — โครง (เปิด connection, try/catch/finally ปิดทรัพยากร) ลอกจาก EmployeeDao ได้ทั้งเมธอด สิ่งที่ต่างมีแค่ "SQL + ค่าที่ set + สิ่งที่ return" วิธีพิมพ์ที่เร็วและไม่หลง: เปิด EmployeeDao วางไว้ข้าง ๆ เป็นต้นแบบ แล้วพิมพ์ไฟล์ใหม่เปลี่ยนเฉพาะส่วน SQL — method ธงทุกตัวคือ SQL ที่คุณพิมพ์เองใน Step 2

ขั้น 4.1 — สร้าง 4 ไฟล์ใน package dao

คลิกขวา package com.example.moogata.dao (มี Dao.java + EmployeeDao.java + UserDao.java อยู่แล้ว) → New → Java Class ชื่อ MenuItemDao — ทำซ้ำอีก 3 ไฟล์: DiningTableDao, BillDao, BillItemDao

method ธงทั้งหมด (ตรงกับ SQL ใน Step 2 ทุกตัว — ทำหน้าที่อะไรดูจากคอลัมน์ขวา):

DAOmethod ธงคือคำสั่ง SQL ขั้นไหนของ Step 2
MenuItemDaogetAll() เรียงตามหมวดตารางเมนูหน้าจอสั่งอาหาร
DiningTableDaoupdateStatus(tableId, status, headcount)ขั้น 2.1 / 2.6 (UPDATE dining_table)
BillDaosave() · getOpenByTableId() · close()ขั้น 2.1 (INSERT bill) · หาบิลของโต๊ะ · ขั้น 2.6 (UPDATE bill)
BillItemDaosave() · findByBillId()ขั้น 2.2–2.3 (INSERT) · ขั้น 2.4 (SELECT)

ขั้น 4.2 — MenuItemDao: คลาสแม่แบบ (อีก 3 ไฟล์ลอกโครงนี้)

หัวคลาส + LOG — ทุก DAO เหมือนกันหมด เปลี่ยนแค่ชื่อ model ที่ใส่ใน <>:

package com.example.moogata.dao;

import com.example.moogata.helper.DatabaseHelper;
import com.example.moogata.model.MenuItem;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

public class MenuItemDao implements Dao<MenuItem> {

    private static final Logger LOG = Logger.getLogger(MenuItemDao.class.getName());

get(id) — แม่แบบของ "SELECT 1 แถว" ทุกตัว (เทียบกับ EmployeeDao.get: เปลี่ยนแค่ SQL กับ fromRS):

@Override
public MenuItem get(int id) {
    String sql = "SELECT * FROM menu_item WHERE item_id = ?";
    Connection conn = DatabaseHelper.getConnect();
    PreparedStatement stmt = null;
    ResultSet rs = null;
    try {
        stmt = conn.prepareStatement(sql);
        stmt.setInt(1, id);
        rs = stmt.executeQuery();
        if (rs.next()) {
            return MenuItem.fromRS(rs);
        }
    } catch (SQLException ex) {
        LOG.log(Level.WARNING, "get ล้มเหลว", ex);
    } finally {
        closeQuietly(rs, stmt);
    }
    return null;
}

save(obj) — แม่แบบของ "INSERT + ดึง id ที่เพิ่งเกิด" ทุกตัว (เทียบขั้น 2.2: เราลอกชื่อ/ราคาจากตารางแม่ก่อน INSERT — ที่นี่ DAO รับ object พร้อมลอกแล้ว):

@Override
public MenuItem save(MenuItem obj) {
    String sql = "INSERT INTO menu_item (item_name, item_price, item_unit, item_status, category_id) "
            + "VALUES (?, ?, ?, ?, ?)";
    Connection conn = DatabaseHelper.getConnect();
    PreparedStatement stmt = null;
    try {
        stmt = conn.prepareStatement(sql);
        stmt.setString(1, obj.getName());
        stmt.setDouble(2, obj.getPrice());
        stmt.setString(3, obj.getUnit());
        stmt.setString(4, obj.getStatus());
        stmt.setInt(5, obj.getCategoryId());
        stmt.executeUpdate();
        obj.setId(DatabaseHelper.getInsertedId(stmt));
        return obj;
    } catch (SQLException ex) {
        LOG.log(Level.WARNING, "save(menu_item=" + obj.getName() + ") ล้มเหลว", ex);
        return null;
    } finally {
        closeQuietly(null, stmt);
    }
}

closeQuietly — ลอกจาก EmployeeDao ได้ทั้งเมธอด (ปิด ResultSet/Statement ใน finally — ทฤษฎี try-finally จาก Lab 8) จากนั้นเติมอีก 3 เมธอดตามตารางแปลงนี้:

เมธอดที่เหลือลอกจาก EmployeeDao เมธอดไหนเปลี่ยนอะไร
getAll()getAll()SQL: SELECT * FROM menu_item ORDER BY category_id, item_id (เรียงเหมือนป้ายเมนู) · Employee.fromRS → MenuItem.fromRS
getAll(where, order)getAll(where, order)เปลี่ยนชื่อตาราง + fromRS — รวมถึงบรรทัด LOG.warning เตือน SQL injection (ทฤษฎีข้อ 5)
update(obj)update(obj)SQL: UPDATE 5 คอลัมน์ item_name, item_price, item_unit, item_status, category_id WHERE item_id
delete(obj)delete(obj)SQL: DELETE FROM menu_item WHERE item_id = ?

ขั้น 4.3 — DiningTableDao และ BillDao: method ธงโค้ดเต็ม

โครงคลาสเหมือนขั้น 4.2 ทุกอย่าง (เปลี่ยน Dao<DiningTable> / Dao<Bill>) — CRUD ที่เหลือแปลงจาก EmployeeDao ตามตารางเดียวกัน ส่วนเมธอดธงต่อไปนี้พิมพ์เต็ม:

// DiningTableDao — เปลี่ยนสถานะโต๊ะ + จำนวนคนในคำสั่งเดียว (ขั้น 2.1 และ 2.6 ใช้เมธอดนี้ร่วมกัน)
public DiningTable updateStatus(int tableId, String status, int headcount) {
    String sql = "UPDATE dining_table SET status = ?, headcount = ? WHERE table_id = ?";
    Connection conn = DatabaseHelper.getConnect();
    PreparedStatement stmt = null;
    try {
        stmt = conn.prepareStatement(sql);
        stmt.setString(1, status);
        stmt.setInt(2, headcount);
        stmt.setInt(3, tableId);
        int affected = stmt.executeUpdate();
        return affected > 0 ? get(tableId) : null;   // อ่านค่าใหม่กลับไปด้วย
    } catch (SQLException ex) {
        LOG.log(Level.WARNING, "updateStatus(table_id=" + tableId + ") ล้มเหลว", ex);
        return null;
    } finally {
        closeQuietly(null, stmt);
    }
}
// BillDao — หา "บิลที่ยังเปิด" ของโต๊ะหนึ่ง (โต๊ะที่ใช้งานต้องมีบิลเปิดครั้งละ 1 ใบ)
public Bill getOpenByTableId(int tableId) {
    String sql = "SELECT * FROM bill WHERE table_id = ? AND status = 'เปิด' ORDER BY bill_id DESC LIMIT 1";
    Connection conn = DatabaseHelper.getConnect();
    PreparedStatement stmt = null;
    ResultSet rs = null;
    try {
        stmt = conn.prepareStatement(sql);
        stmt.setInt(1, tableId);
        rs = stmt.executeQuery();
        if (rs.next()) {
            return Bill.fromRS(rs);
        }
    } catch (SQLException ex) {
        LOG.log(Level.WARNING, "getOpenByTableId ล้มเหลว", ex);
    } finally {
        closeQuietly(rs, stmt);
    }
    return null;
}
// BillDao — เช็คบิล: เขียนยอด/เงินรับ/เงินทอน/เวลาปิด + เปลี่ยนสถานะ 'ปิด' พร้อมกัน (ขั้น 2.6)
public Bill close(int billId, double total, double cashReceived, double changeAmount) {
    String sql = "UPDATE bill SET status = 'ปิด', total = ?, cash_received = ?, "
            + "change_amount = ?, closed_at = datetime('now', 'localtime') WHERE bill_id = ?";
    Connection conn = DatabaseHelper.getConnect();
    PreparedStatement stmt = null;
    try {
        stmt = conn.prepareStatement(sql);
        stmt.setDouble(1, total);
        stmt.setDouble(2, cashReceived);
        stmt.setDouble(3, changeAmount);
        stmt.setInt(4, billId);
        int affected = stmt.executeUpdate();
        return affected > 0 ? get(billId) : null;
    } catch (SQLException ex) {
        LOG.log(Level.WARNING, "close(bill_id=" + billId + ") ล้มเหลว", ex);
        return null;
    } finally {
        closeQuietly(null, stmt);
    }
}
📖 สังเกต 2 จุด: (1) BillDao.save ตั้งใจ INSERT แค่ 3 ค่า (table_id, status, opened_by) ตามขั้น 2.1 — ยอดเงิน/เวลาปิดไม่มีสิทธิ์ใส่ตอนเปิดบิล (2) closed_at ไม่ใช้ ? แต่ให้ SQLite ใส่เวลาเองใน SQL จึงเหมือนบรรทัด UPDATE ที่คุณพิมพ์เองในขั้น 2.6 เป๊ะ

ขั้น 4.4 — BillItemDao: method ธงโค้ดเต็ม

// BillItemDao — บันทึกรายการที่สั่ง 1 รอบ (เรียกใหม่ได้ทุกรอบ — นี่คือ "สั่งได้หลายรอบ" ในทางข้อมูล)
@Override
public BillItem save(BillItem obj) {
    String sql = "INSERT INTO bill_item (bill_id, menu_item_id, item_name, qty, unit_price) "
            + "VALUES (?, ?, ?, ?, ?)";
    Connection conn = DatabaseHelper.getConnect();
    PreparedStatement stmt = null;
    try {
        stmt = conn.prepareStatement(sql);
        stmt.setInt(1, obj.getBillId());
        stmt.setInt(2, obj.getMenuItemId());
        stmt.setString(3, obj.getItemName());
        stmt.setInt(4, obj.getQty());
        stmt.setDouble(5, obj.getUnitPrice());
        stmt.executeUpdate();
        obj.setId(DatabaseHelper.getInsertedId(stmt));
        return obj;
    } catch (SQLException ex) {
        LOG.log(Level.WARNING, "save(bill_item=" + obj.getItemName() + ") ล้มเหลว", ex);
        return null;
    } finally {
        closeQuietly(null, stmt);
    }
}
// BillItemDao — รายการทั้งหมดของบิลหนึ่งใบ เรียงตามลำดับที่สั่ง (ขั้น 2.4)
public List<BillItem> findByBillId(int billId) {
    List<BillItem> list = new ArrayList<>();
    String sql = "SELECT * FROM bill_item WHERE bill_id = ? ORDER BY bill_item_id";
    Connection conn = DatabaseHelper.getConnect();
    PreparedStatement stmt = null;
    ResultSet rs = null;
    try {
        stmt = conn.prepareStatement(sql);
        stmt.setInt(1, billId);
        rs = stmt.executeQuery();
        while (rs.next()) {
            list.add(BillItem.fromRS(rs));
        }
    } catch (SQLException ex) {
        LOG.log(Level.WARNING, "findByBillId ล้มเหลว", ex);
    } finally {
        closeQuietly(rs, stmt);
    }
    return list;
}

CRUD ที่เหลือของอีก 3 DAO (get / getAll ×2 / update / delete) แปลงจาก EmployeeDao ด้วยสูตรเดียวกัน: เปลี่ยนชื่อตาราง + ชื่อคอลัมน์ + fromRS — เทียบเฉลยทีละไฟล์ได้ที่ src/main/java/com/example/moogata/dao/

ขั้น 4.5 — TestPosDao — test แรกที่พิมพ์เอง (ทีละส่วน รวม 77 บรรทัด)

คลิกขวา package com.example.moogata → New → Java Class ชื่อ TestPosDao — แล้วพิมพ์ตาม 4 ส่วนต่อไปนี้ไม่ต้อง copy ทั้งไฟล์ (เฉลยอยู่ src/.../TestPosDao.java ไว้เทียบทีละส่วนเมื่อติด) — เหตุผลที่ตั้งใจให้ตัวนี้พิมพ์เอง: ไฟล์เล็กที่สุด · อ่านอย่างเดียวไม่แก้ข้อมูล จึงลองผิดได้ปลอดภัย · และเป็นการทดสอบ DAO ที่เพิ่งพิมพ์เสร็จพอดี (DAO ผิด → test จับทันที)

ส่วนที่ 1 — package + import + field นับผล + เมธอด check() (แม่แบบ PASS/FAIL):

package com.example.moogata;

import com.example.moogata.dao.BillItemDao;
import com.example.moogata.dao.DiningTableDao;
import com.example.moogata.dao.MenuItemDao;
import com.example.moogata.helper.DatabaseHelper;
import com.example.moogata.model.DiningTable;
import com.example.moogata.model.MenuItem;

public class TestPosDao {

    private static int pass = 0;
    private static int total = 0;

    /** ตรวจเงื่อนไข 1 ข้อ — พิมพ์ PASS/FAIL พร้อมเหตุผลทันที */
    private static void check(String label, boolean ok, String detail) {
        total++;
        if (ok) {
            pass++;
        }
        System.out.println("[" + total + "] " + label + " ... " + (ok ? "PASS" : "FAIL")
                + (detail.isEmpty() ? "" : "  (" + detail + ")"));
    }
📖 ทำไมต้องมี check()? ถ้าไม่มี helper จะต้องเขียน if + println ซ้ำ ๆ ทุกข้อ — check() ทำให้เพิ่มข้อทดสอบใหม่ได้ในบรรทัดเดียว นับผลรวมให้อัตโนมัติ และบังคับนิสัยสำคัญ: ค่าคาดหวังเขียนไว้ในโค้ดก่อนรัน ไม่ใช่พิมพ์ค่าให้ดูแล้วตัดสินด้วยตา (ทฤษฎีข้อ 6 หน้า 2)

ส่วนที่ 2 — main(): สร้าง DAO 3 ตัว แล้วพิมพ์ข้อมูลจริงดูก่อนตรวจ:

    public static void main(String[] args) {
        try {
            MenuItemDao menuItemDao = new MenuItemDao();
            DiningTableDao tableDao = new DiningTableDao();
            BillItemDao billItemDao = new BillItemDao();

            // --- แสดงข้อมูลจริงให้ดูประกอบ ---
            System.out.println("=== เมนูทั้งหมด ===");
            for (MenuItem item : menuItemDao.getAll()) {
                System.out.printf("%2d. %s %s บาท/%s [%s]%n",
                        item.getId(), item.getName(), item.getPriceText(),
                        item.getUnit(), item.getStatus());
            }
            System.out.println();
            System.out.println("=== โต๊ะทั้งหมด ===");
            for (DiningTable table : tableDao.getAll()) {
                System.out.println("โต๊ะ " + table.getNumber() + " : " + table.getStatus()
                        + " (" + table.getHeadcount() + " คน)");
            }
            System.out.println();
            System.out.println("รายการในบิลทั้งหมดตอนนี้: " + billItemDao.getAll().size() + " แถว");
            System.out.println();

ส่วนที่ 3 — ตรวจ 3 ข้อ ตามค่าที่ seed ไว้ใน Step 1 (10 เมนู · 6 โต๊ะ · โต๊ะ 3 = ใช้งาน 4 คน):

            // --- ตรวจตามค่าที่คาดหวัง ---
            check("เมนูต้องมี 10 รายการ", menuItemDao.getAll().size() == 10,
                    "ได้ " + menuItemDao.getAll().size());
            check("โต๊ะต้องมี 6 ตัว", tableDao.getAll().size() == 6,
                    "ได้ " + tableDao.getAll().size());
            DiningTable table3 = null;
            for (DiningTable table : tableDao.getAll()) {
                if (table.getNumber() == 3) {
                    table3 = table;
                }
            }
            check("โต๊ะ 3 ต้อง 'ใช้งาน' อยู่ 4 คน (seed)",
                    table3 != null && "ใช้งาน".equals(table3.getStatus()) && table3.getHeadcount() == 4,
                    table3 == null ? "ไม่พบโต๊ะ 3" : table3.getStatus() + " " + table3.getHeadcount() + " คน");
📖 ค่าคาดหวังมาจากไหน? จาก seed ที่ INSERT เองใน Step 1 (10 เมนูมาจาก Lab Week 8 · โต๊ะ 6 ตัว · โต๊ะ 3 = ใช้งาน 4 คน) — ไม่ใช่จากโค้ด DAO — ถ้า DAO พิมพ์ผิด (WHERE หาย / fromRS ดึงคอลัมน์พลาด) test จะจับได้ทันที นี่คือเหตุผลที่ลำดับถูกต้องคือ "พิมพ์ DAO จบ → ค่อยพิมพ์ test"

ส่วนที่ 4 — สรุปผลรวม + ปิด connection ใน finally:

            System.out.println();
            System.out.println("สรุป: ผ่าน " + pass + "/" + total + " รายการ"
                    + (pass == total ? "" : "  <-- มี FAIL ให้กลับไปตรวจ DAO/seed"));
        } finally {
            DatabaseHelper.close();
        }
    }
}

รัน (คลิกไฟล์ → Shift+F6) — ท้าย output ต้องเห็น Connection closed. เสมอ (บล็อก finally สูตรเดิมจาก Lab Week 8) — ผลที่ต้องเห็น:

=== เมนูทั้งหมด ===
 1. บุฟเฟ่ต์หมูกะทะ 289.00 บาท/ท่าน [มี]
 2. บุฟเฟ่ต์ชาบู 299.00 บาท/ท่าน [มี]
 3. น้ำซุปหม่าล่า (พิเศษ) 49.00 บาท/หม้อ [มี]
 4. หมูสไลซ์ (เสริม) 60.00 บาท/จาน [มี]
 5. ลูกชิ้นทอด 40.00 บาท/จาน [หมด]
 6. ข้าวสวย 10.00 บาท/จาน [มี]
 7. ชาเย็น 25.00 บาท/แก้ว [มี]
 8. โค้ก 20.00 บาท/แก้ว [มี]
 9. ไอศกรีมวานิลลา 25.00 บาท/ชิ้น [มี]
10. แตงโมปิ้ง 20.00 บาท/จาน [มี]

=== โต๊ะทั้งหมด ===
โต๊ะ 1 : ว่าง (0 คน)   ...   โต๊ะ 3 : ใช้งาน (4 คน)   ...   โต๊ะ 6 : ว่าง (0 คน)

[1] เมนูต้องมี 10 รายการ ... PASS  (ได้ 10)
[2] โต๊ะต้องมี 6 ตัว ... PASS  (ได้ 6)
[3] โต๊ะ 3 ต้อง 'ใช้งาน' อยู่ 4 คน (seed) ... PASS  (ใช้งาน 4 คน)

สรุป: ผ่าน 3/3 รายการ
✓ เช็ค: TestPosDao ผ่าน 3/3 — ตารางใหม่อ่านได้จาก Java แล้ว
git commit -am "lab pos-flow: dao 4 ตัว + TestPosDao"
ส่วนที่ 1 — ข้อมูล Step 5 จาก 11 · หน้า 8/16 · ~60–75 นาที

Step 5 — เลเยอร์ service: กฎธุรกิจ + จุดเช็ค 14/14

ขั้น 2.7 พิสูจน์แล้วว่า SQL กัน "เปิดโต๊ะซ้ำ" และ "สั่งของลงบิลที่ปิดแล้ว" ไม่ได้ — กฎพวกนี้เขียนที่นี่ (ทฤษฎีข้อ 6: กฎธุรกิจอยู่ชั้น service) หน้าจอในส่วนที่ 2 ห้ามมีกฎเหล่านี้ซ่อนอยู่

ขั้น 5.0 — สร้าง 3 ไฟล์ใน package service

คลิกขวา package com.example.moogata.service (มี EmployeeService อยู่แล้ว) → New → Java Class: MenuService, TableService, BillService — แต่ละไฟล์เริ่มด้วย field "ถือ DAO ของตัวเอง" (service ไม่รู้จัก SQL — มันสั่ง DAO เท่านั้น):

public class MenuService {
    private final MenuItemDao menuItemDao = new MenuItemDao();
    ...
}
public class TableService {
    private final DiningTableDao tableDao = new DiningTableDao();
    private final BillDao billDao = new BillDao();     // เปิดโต๊ะต้องสร้างบิลด้วย
    ...
}
public class BillService {
    private final BillDao billDao = new BillDao();
    private final BillItemDao billItemDao = new BillItemDao();
    private final TableService tableService = new TableService();  // เช็คบิลต้องคืนโต๊ะด้วย
    ...
}
📖 สังเกต: เมธอดเก่าของ EmployeeService (login, getAll, save…) คือ "ส่งต่อ" อย่างเดียว แต่ service ใหม่ 3 ตัวนี้มี กฎ if ก่อนส่งต่อ — นั่นคือตำแหน่งของกฎธุรกิจ · import ที่ต้องใช้: com.example.moogata.model.* · com.example.moogata.dao.* · java.util.List/java.util.ArrayList (พิมพ์โค้ดแล้วโดนเส้นแดง กด Ctrl+Shift+I ให้ NetBeans จัด import ให้)

ขั้น 5.1 — MenuService (เมนูพร้อมขาย)

/** เมนูพร้อมขาย (สถานะ 'มี' เท่านั้น) — กรองด้วยลูปใน Java แทนการต่อสตริง WHERE */
public List<MenuItem> getAvailable() {
    List<MenuItem> available = new ArrayList<>();
    for (MenuItem item : menuItemDao.getAll()) {
        if ("มี".equals(item.getStatus())) {
            available.add(item);
        }
    }
    return available;
}

ขั้น 5.2 — TableService (กฎ Hard Lock SC-4 อยู่ตรงนี้)

public Bill openTable(DiningTable table, int headcount, Employee employee) {
    // กฎ 1: ต้องรู้จำนวนลูกค้าก่อนเปิดโต๊ะเสมอ
    if (table == null || employee == null || headcount < 1) {
        return null;
    }
    // กฎ 2 (SC-4): โต๊ะที่ยังใช้งานอยู่ ห้ามเปิดซ้ำ แม้เป็นพนักงานคนอื่นก็ตาม
    if (!table.isVacant()) {
        return null;
    }

    Bill bill = billDao.save(new Bill(table.getId(), employee.getId()));
    if (bill == null) {
        return null;
    }
    tableDao.updateStatus(table.getId(), DiningTable.STATUS_IN_USE, headcount);
    return bill;
}

/** เช็คบิลสำเร็จแล้ว — คืนโต๊ะเป็น 'ว่าง' (เรียกจาก BillService.checkout) */
public boolean releaseTable(int tableId) {
    return tableDao.updateStatus(tableId, DiningTable.STATUS_VACANT, 0) != null;
}

ขั้น 5.3 — BillService (สั่งได้หลายรอบ + เช็คบิล)

/** สั่งอาหาร 1 รอบ — snapshot แล้วบันทึกทันที / กฎ: บิลปิดแล้วสั่งไม่ได้ */
public BillItem addItem(Bill bill, MenuItem menuItem, int qty) {
    if (bill == null || menuItem == null || !bill.isOpen() || qty < 1) {
        return null;
    }
    return billItemDao.save(new BillItem(bill.getId(), menuItem, qty));
}

/** รวมยอดบิล — ตรงกับ SQL: SELECT SUM(qty * unit_price) */
public double getTotal(int billId) {
    double total = 0;
    for (BillItem item : billItemDao.findByBillId(billId)) {
        total += item.getLineTotal();
    }
    return total;
}

/** เช็คบิล = ตรวจเงินรับ → ปิดบิล → คืนโต๊ะ (ผ่าน TableService.releaseTable) */
public Bill checkout(Bill bill, double cashReceived) {
    if (bill == null || !bill.isOpen()) {
        return null;
    }
    double total = getTotal(bill.getId());
    if (total <= 0 || cashReceived < total) {
        return null; // ไม่มีรายการ หรือเงินรับไม่พอ
    }
    Bill closed = billDao.close(bill.getId(), total, cashReceived, cashReceived - total);
    if (closed == null) {
        return null;
    }
    tableService.releaseTable(bill.getTableId());
    return closed;
}

ขั้น 5.4–5.5 — Test สองตัว (copy จาก src — ล้างข้อมูลเองก่อนรัน จึงรันซ้ำได้)

สองตัวนี้ copy ทั้งไฟล์ จาก src/.../TestTableService.java และ TestBillService.java (คลิกขวา package com.example.moogata → New → Java Class ชื่อเดียวกัน → วางทั้งไฟล์) — ต่างจาก TestPosDao ที่พิมพ์เอง เพราะเพิ่มขั้น "ล้างข้อมูลและประกอบสถานการณ์ของตัวเอง" ที่ยาวกว่า (ลบบิล + รีเซ็ตตัวนับ id + คืนโต๊ะก่อนรันทุกครั้ง) — แต่โครงเดียวกันทุกอย่าง อ่านประกอบตอน copy:

📖 กายวิภาคของ test (ทั้ง 3 ไฟล์โครงเดียวกัน): (1) ล้าง + ประกอบสถานการณ์ของตัวเอง — DELETE bill/bill_item + รีเซ็ตตัวนับ id ทำให้รันซ้ำได้ผลเดิม และ test แต่ละตัวไม่ต้องรอกัน (2) เรียกผ่าน service เท่านั้น (เปิดโต๊ะ/สั่ง/เช็คบิล) — ไม่มี SQL ในไฟล์ test (3) check(...) แม่แบบเดียวกับ TestPosDao — ค่าคาดหวังคำนวณไว้ก่อน (ยอด 677.00 · ทอน 23.00) (4) สรุปผ่านกี่ข้อ + ปิด connection ใน finally

TestTableService — ผลที่ต้องเห็น:

[ตั้งต้น] ล้าง bill_item / bill / ตัวนับ id และคืนโต๊ะ 1, 4, 5 เป็นว่างแล้ว

[1] เปิดโต๊ะ 4 / 2 คน สำเร็จ และโต๊ะเป็น 'ใช้งาน 2 คน' ... PASS  (bill_id=1)
[2] เปิดโต๊ะ 4 ซ้ำต้องถูกปฏิเสธ [กฎ SC-4] ... PASS  (openTable คืน null)
[3] เปิดโต๊ะ 5 / 3 คน สำเร็จ ... PASS  (bill_id=2)
[4] เปิดโต๊ะ 1 ด้วยลูกค้า 0 คน ต้องถูกปฏิเสธ ... PASS  (openTable คืน null)

สรุป: ผ่าน 4/4 รายการ

TestBillService — ผลที่ต้องเห็น (เปิดบิลของตัวเอง ไม่ต้องรอ test อื่น):

[1] เปิดบิลใหม่ที่โต๊ะ 4 ... PASS  (bill_id=1)
[2] สั่งรอบที่ 1: บุฟเฟ่ต์หมูกะทะ x2 บันทึกและรวม 578.00 บาท ... PASS  (ยอด 578.00)
[3] สั่งรอบที่ 2: หม่าล่า x1 + ชาเย็น x2 ตารางบิลต้องมี 3 แถว ... PASS  (ได้ 3 แถว)
[4] ยอดรวมทั้งบิล 677.00 บาท ... PASS  (677.00)
[5] ชำระด้วย 600.00 ต้องถูกปฏิเสธ (เงินไม่พอ) ... PASS  (checkout คืน null)
[6] ชำระด้วย 700.00 ปิดบิลสำเร็จ: ยอด 677.00 ทอน 23.00 สถานะ 'ปิด' ... PASS
[7] โต๊ะ 4 หลังปิดบิลต้องกลับเป็น 'ว่าง' ... PASS  (ว่าง 0 คน)

สรุป: ผ่าน 7/7 รายการ

รันทั้ง 3 test รวมกันต้องได้ 14/14 และ รันซ้ำกี่รอบก็ได้ผลเดิม (ทุกตัวล้างข้อมูลของตัวเองก่อน)

คำถามท้ายช่วง 5 — เมื่อ "สองคำสั่งที่ควรคู่กัน" พังครึ่งทาง (ทฤษฎีข้อ 3)
openTable ทำ 2 อย่าง: billDao.save(...) ก่อน แล้ว tableDao.updateStatus(...) — ถ้าคำสั่งแรกสำเร็จแต่คำสั่งที่สองพัง ข้อมูลจะเป็นอย่างไร?
เฉลย: บิลเปิดค้างอยู่ แต่โต๊ะยัง "ว่าง" — ข้อมูลไม่สอดคล้อง และ checkout ก็มีด้านเดียวกัน: บิลปิดแล้วแต่ releaseTable พัง = โต๊ะติดค้าง (โค้ดปัจจุบันไม่ได้เช็คค่าที่ releaseTable คืน) ทางแก้จริงจังคือธุรกรรมเดียว: setAutoCommit(false) → commit / rollback — ลองเขียนเองในแบบฝึกหัดข้อ 3
✓ เช็ค: test รวม 14/14 PASS และรันซ้ำได้ผลเดิม
git commit -am "lab pos-flow: service กฎธุรกิจ + Test ผ่านครบ"
ส่วนที่ 2 — หน้าจอ Step 6 จาก 11 · หน้า 9/16 · ~40–50 นาที

Step 6 — เตรียมล็อกอินรับแคชเชียร์ + ออกแบบหน้าผังโต๊ะ PosFrame

เริ่มส่วนที่ 2 — สร้างหน้าจอเองใน NetBeans แบบเดียวกับที่เคยทำ LoginFrame/EmployeeFrame ใน Lab 11 ทีละคู่: หน้า "ออกแบบ" (ลากวาง + ตั้งชื่อตัวแปรตามตาราง control) แล้วหน้าถัดไปค่อย "โค้ด" — กฎทฤษฎีข้อ 9: ตั้งชื่อให้ตรงเป๊ะก่อน แล้วโค้ดจะต่อกันเอง

เริ่มจากไหน — เลือก 1 ทาง

สถานการณ์ทำอย่างไร
ทำ Step 1–5 เองจบแล้ว (แนะนำ)เปิด MoogataLab ตัวเอง → ทำตาม Step 6–11 สร้างหน้าจอเองใน designer — ได้ทักษะครบแบบ Lab 11
Part 1 ยังไม่จบcopy ทั้งโฟลเดอร์ checkpoint-part1/ ไปเปิดเป็น Maven Project — ข้างในมีของที่ผ่าน test แล้ว → แล้วทำตาม Step 6–11 ได้เลย
ทางลัด: ข้ามการออกแบบcopy ไฟล์ใน starter-part2/src/.../ui/ (5 ไฟล์) มาไว้ใน package ui ของโปรเจกต์ — หน้าจอวาง layout และผูก event ไว้ให้แล้ว เหลือเติม TODO ตรงจุดที่ Step 7 / 9 / 11 ระบุ (ชื่อตัวแปรทุกตัวตรงกับตาราง control ของเส้นทางหลัก)
⚠ ก่อนเริ่ม (ทั้ง 3 ทาง): รันไฟล์ reset-pos.sql (ดาวน์โหลด) ใน Letos (รีเซ็ตข้อมูล POS) แล้วรัน TestBillService — ต้องได้ 7/7 PASS ถึงเริ่มหน้าจอได้ · เลือกทางลัด starter ก็เลื่อนไปอ่าน "โค้ด" ของ Step 7 / 9 / 11 ได้ทันที เพราะ TODO ในไฟล์อ้างอิงโค้ดเดียวกัน

ขั้น 6.1 — สร้าง ThaiFont (คลาสเล็กที่กันไทยเป็นกล่อง □)

ฟอนต์ default ของ JVM (Dialog/SansSerif) บนบางเครื่อง/บาง JDK ไม่มีอักขระไทย — ข้อความไทยบนปุ่ม/ป้ายจะกลายเป็นกล่อง □ ทั้งที่คอมไพล์ผ่านและข้อมูลในฐานข้อมูลถูกต้อง คลาสนี้เลือกฟอนต์ที่ "วาดไทยได้จริง" จากลำดับผู้สมัครแล้วบังคับทั้งโปรแกรม — คลิกขวา package ui → New → Java Class ชื่อ ThaiFont แล้วพิมพ์ทั้งไฟล์ (สั้น — ไม่ถึง 40 บรรทัด):

package com.example.moogata.ui;

import java.awt.Font;
import java.util.Enumeration;
import javax.swing.UIManager;
import javax.swing.plaf.FontUIResource;

public class ThaiFont {

    /** ผู้สมัครที่มีอักขระไทย — เลือกตัวแรกที่มีในเครื่องจริง (Leelawadee UI มีใน Windows 8+) */
    private static final String[] CANDIDATES = {
        "Leelawadee UI", "Tahoma", "Noto Sans Thai", "Sarabun"
    };

    public static void apply() {
        FontUIResource thai = new FontUIResource(pickThaiFont());
        Enumeration<Object> keys = UIManager.getDefaults().keys();
        while (keys.hasMoreElements()) {
            Object key = keys.nextElement();
            Object value = UIManager.get(key);
            if (value instanceof FontUIResource) {
                UIManager.put(key, thai);
            }
        }
    }

    /** เลือกฟอนต์ตัวแรกที่วาดอักขระไทยได้จริง (ทดสอบด้วย ว U+0E27) */
    static Font pickThaiFont() {
        for (String name : CANDIDATES) {
            Font font = new Font(name, Font.PLAIN, 14);
            if (font.canDisplay('\u0E27')) {
                return font;
            }
        }
        return new Font(Font.SANS_SERIF, Font.PLAIN, 14);
    }
}

แล้วเปิด LoginFrame.java ของคุณ เพิ่ม บรรทัดแรกของ constructor (ก่อน initComponents();) — หน้าจอทุกหน้าที่ถูกสร้างหลังจากนี้จะได้ฟอนต์ไทยทั้งหมด:

public LoginFrame() {
    ThaiFont.apply(); // บังคับฟอนต์ไทยทั้งโปรแกรม — กันข้อความไทยเป็นกล่องเปล่าบนบาง JDK
    initComponents();
    setLocationRelativeTo(null);
}

ขั้น 6.2 — ตรวจ LoginFrame เดิม + เปิดเส้นทางแคชเชียร์

หน้าจอที่คุณมีอยู่จาก Lab 11 ต้องตั้งชื่อ control ตรงกับตารางนี้ (โค้ดของทั้ง lab อ้างชื่อพวกนี้) — ภาพจากแอปจริงมีเลขกำกับ = เลขแถว:

LoginFrame พร้อมป้ายชื่อตัวแปร
หน้าล็อกอินที่มีอยู่แล้ว (ป้ายกำกับ = ชื่อตัวแปรที่โค้ดจะอ้าง): ①lblHeader ②lblLogin ③txtLogin ④lblPassword ⑤txtPassword (Password Field — พิมพ์แล้วเห็นจุดดำ) ⑥btnLogin ⑦btnExit
#ControltextVariable Nameเพิ่มเติม
1Labelระบบ POS BONUS SUKIlblHeaderfont: Leelawadee UI, Bold, 18
2Labelชื่อเข้าใช้:lblLogin
3Text Field—txtLogin
4Labelรหัสผ่าน:lblPassword
5Password Field—txtPasswordใช้ Password Field ไม่ใช่ Text Field
6Buttonเข้าสู่ระบบbtnLogin
7ButtonออกbtnExit

ถ้าชื่อไม่ตรง ให้แก้ที่ designer ตอนนี้เลย (คลิกขวา control → Change Variable Name…) แล้วเปิด btnLoginActionPerformed — แทนโค้ดท้ายเมธอดเดิม (กล่องทักทายชั่วคราวของ Lab 11 หรือเงื่อนไขกัน role 2) ด้วย:

// เดิม (Lab 11):  กล่อง "ยินดีต้อนรับ ..." หรือเปิด EmployeeFrame เฉพาะผู้จัดการ
// เปลี่ยนเป็น:
if (employee.getRole() == 1) {
    EmployeeFrame employeeFrame = new EmployeeFrame(employee);
    employeeFrame.setLocationRelativeTo(this);
    employeeFrame.setVisible(true);
} else {
    PosFrame posFrame = new PosFrame(employee);   // ← หน้าจอที่กำลังจะสร้าง (ขั้น 6.3)
    posFrame.setLocationRelativeTo(this);
    posFrame.setVisible(true);
}
this.dispose(); // ปิดหน้าล็อกอิน — ไม่ปิดโปรแกรม (ไม่ใช่ System.exit)
⚠ อ่านก่อนตกใจ: บรรทัดนี้อ้างถึง PosFrame ที่ยังไม่เกิด → โปรเจกต์ compile ไม่ผ่านชั่วครู่ (cannot find symbol: class PosFrame) — ปกติมาก สร้างในขั้นถัดไปแล้วจะหาย · แคชเชียร์ (role 2) ต้องเปิดโต๊ะ/สั่ง/เช็คบิลได้เอง จึงไม่กันสิทธิ์แบบหน้าจัดการพนักงาน

ขั้น 6.3 — ออกแบบ PosFrame (ผังโต๊ะ)

  1. คลิกขวา package ui → New → JFrame Form (หมวด Swing GUI Forms) ชื่อ PosFrame
  2. ลาก control จาก Palette มาวางตามภาพ แล้ว เปลี่ยนชื่อตัวแปรให้ตรงเป๊ะ — เลขวงกลมในภาพ = เลขแถวในตารางด้านล่าง
โครง PosFrame พร้อมป้ายชื่อตัวแปร
โครงหน้าจอที่ต้องวางให้ได้ (ภาพจากแอปจริงหลังล็อกอิน — เลขวงกลม = เลขแถวในตาราง): ①lblHeader ②lblUser ③–⑧btnTable1–btnTable6 ปุ่มละ 1 โต๊ะ จัด 3 ปุ่มต่อแถว 2 แถว ⑨lblSummary ⑩btnRefresh — ของคุณตอนออกแบบ ปุ่มโต๊ะยังไม่มีข้อความ/สี ก็ถูกแล้ว เพราะ refreshTables() ใส่ให้ตอนรัน (Step 7)
#ControltextVariable Nameเพิ่มเติม
1Labelผังโต๊ะร้านหมูกระทะlblHeaderfont: Leelawadee UI, Bold, 20
2Label—lblUserข้อความ "พนักงาน: …" โค้ดใส่ให้ (ขั้น 7.1) — ไม่พิมพ์ใน designer
3Button—btnTable1ขนาดราว 230 × 140 จัด 3 ปุ่มต่อแถว 2 แถว — ไม่ต้องพิมพ์ text (โค้ดใส่ "โต๊ะ N + สถานะ + สี" ตอนรัน) · ชื่อต้องมีเลข 1–6 ต่อท้ายพอดี
4Button—btnTable2
5Button—btnTable3
6Button—btnTable4
7Button—btnTable5
8Button—btnTable6
9Label—lblSummaryfont: Leelawadee UI, Bold, 16 (ข้อความสรุปโค้ดใส่)
10ButtonรีเฟรชbtnRefresh

ตั้งค่าฟอร์ม (คลิกพื้นหลัง → Properties): title ผังโต๊ะ — BONUS SUKI POS · defaultCloseOperation EXIT_ON_CLOSE (ปิดผังโต๊ะ = จบโปรแกรม) · resizable ออก · ขนาด: ลากขอบฟอร์มใน designer ให้กว้างพอสำหรับ 3 ปุ่มต่อแถว ราว ~870 × 540 (พิมพ์ตัวเลขใน Properties ไม่มีผล)

📖 ทำไมปุ่มโต๊ะต้อง 6 ตัวแยกกัน ไม่สร้างในลูป? เพราะ designer ของ NetBeans สร้างได้ทีละ control ที่มีชื่อ — ตอนโค้ด (Step 7) เราจะจับ 6 ปุ่มมารวมเป็นอาเรย์ใน refreshTables() เพื่อวนสีตามข้อมูลโต๊ะ ได้ความสะดวกของลูป + ได้ชื่อชัด ๆ ใน designer ดีทั้งสองอย่าง
✓ เช็ค: project compile ผ่าน (error เรื่อง OrderFrame ยังไม่เกิด — ยังไม่มีใครอ้างถึง) · ชื่อตัวแปรครบ 10 ตัวตามตาราง · รัน PosFrame โดยตรงได้ (ยังกดอะไรไม่เกิดอะไร — ปกติ)
git commit -am "lab pos-flow: ThaiFont + ล็อกอินรับแคชเชียร์ + ออกแบบผังโต๊ะ"
ส่วนที่ 2 — หน้าจอ Step 7 จาก 11 · หน้า 10/16 · ~50–60 นาที

Step 7 — โค้ด PosFrame: ระบายสีโต๊ะ + เปิดโต๊ะจริง

หน้าจอวางเสร็จแต่กดแล้วเงียบ — งานของหน้านี้มี 2 เรื่อง: (1) refreshTables() อ่านโต๊ะจากฐานข้อมูลแล้วระบายสี/ข้อความ 6 ปุ่มทุกครั้ง (2) tableClicked(n) แยกทางตามสถานะโต๊ะ: ว่าง = เปิดโต๊ะ · ใช้งาน = เข้าหน้าสั่ง · รอชำระ = เตือน — สังเกตว่า หน้าจอไม่มีกฎธุรกิจแม้แต่บรรทัดเดียว กฎอยู่ที่ service ที่ทำไปแล้วใน Step 5
⚠ วางเฉพาะ "เนื้อใน" ระหว่างปีกกา — ใช้กับทุก handler ของทั้ง lab: เมื่อดับเบิลคลิก NetBeans สร้าง private void btnTable1ActionPerformed(...) { ... } พร้อมปีกกาเปิด-ปิดให้แล้ว (ข้างในมีบรรทัด // TODO add your handling code here: รออยู่) — โค้ดใน lab แสดง "เมธอดเต็ม" เพื่อให้เห็นภาพรวมเท่านั้น ตอนวางจริงให้คัดลอก เฉพาะบรรทัดเนื้อความ มาแทนบรรทัด TODO ถ้าวางทั้งก้อนรวมหัวเมธอด = เมธอดซ้อนเมธอด → compile ไม่ผ่านทันที

ขั้น 7.1 — field + constructor + main (เปิดไฟล์ PosFrame แท็บ Source)

เพิ่ม import ด้านบนไฟล์:

import com.example.moogata.model.Bill;
import com.example.moogata.model.DiningTable;
import com.example.moogata.model.Employee;
import com.example.moogata.service.BillService;
import com.example.moogata.service.TableService;
import java.util.ArrayList;
import java.util.List;
import javax.swing.JOptionPane;

เพิ่ม field กับ constructor (ใต้บรรทัด public class PosFrame extends javax.swing.JFrame { — NetBeans สร้าง constructor เปล่าให้แล้ว แทนที่ทั้งก้อน):

// สีของโต๊ะตามสถานะ — เขียวว่าง / ชมพูใช้งาน / เหลืองรอชำระ (เหมือนสีในบอร์ดคาบ domain)
private static final java.awt.Color COLOR_VACANT = new java.awt.Color(0xCDE5AD);
private static final java.awt.Color COLOR_IN_USE = new java.awt.Color(0xF2C5D5);
private static final java.awt.Color COLOR_AWAITING = new java.awt.Color(0xF6E991);

private final Employee employee;                 // ใครล็อกอินอยู่ (เปิดบิลแทนใคร)
private final TableService tableService = new TableService();
private final BillService billService = new BillService();

// ข้อมูลโต๊ะที่อ่านล่าสุดจากฐานข้อมูล — เรียงตามลำดับเดียวกับ btnTable1–6
private List<DiningTable> tables = new ArrayList<>();

public PosFrame(Employee employee) {
    this.employee = employee;
    ThaiFont.apply(); // บังคับฟอนต์ไทยทั้งโปรแกรม — กันข้อความไทยเป็นกล่องเปล่าบนบาง JDK
    initComponents();
    refreshTables();
    setLocationRelativeTo(null); // เปิดกลางหน้าจอ
}

แทนที่เนื้อใน main() ของ template ด้วยแบบสั้น (ส่ง null = รันทดสอบหน้าจอโดยไม่ล็อกอิน — lblUser จะโชว์ "(ทดสอบไม่ล็อกอิน)"):

public static void main(String args[]) {
    java.awt.EventQueue.invokeLater(() -> new PosFrame(null).setVisible(true));
}

ขั้น 7.2 — refreshTables(): อ่านโต๊ะใหม่แล้วระบายทั้ง 6 ปุ่ม

เพิ่มเมธอดนี้ท้ายคลาส (ก่อนปิดคลาส) — จุดสำคัญคือบรรทัดแรก: จับปุ่ม 6 ปุ่มที่วางไว้ใน designer มารวมเป็นอาเรย์ตามลำดับโต๊ะ:

/** อ่านโต๊ะใหม่จากฐานข้อมูล แล้วระบายสี + เขียนข้อความปุ่มใหม่ทั้งหมด
 *  public เพราะ OrderFrame เรียกกลับมาตอนปิดบิลสำเร็จ (สีโต๊ะต้องกลับเป็นเขียว) */
public void refreshTables() {
    // ปุ่มที่วางไว้ 6 ปุ่ม จับคู่เป็นอาเรย์ตามลำดับโต๊ะ เพื่อวนจับคู่กับข้อมูลจากฐานข้อมูล
    javax.swing.JButton[] tableButtons = {
        btnTable1, btnTable2, btnTable3, btnTable4, btnTable5, btnTable6
    };
    tables = tableService.getAllTables();
    int vacant = 0;
    int inUse = 0;
    for (int i = 0; i < tables.size() && i < tableButtons.length; i++) {
        DiningTable table = tables.get(i);
        javax.swing.JButton btn = tableButtons[i];
        String detail;
        java.awt.Color color;
        if (DiningTable.STATUS_IN_USE.equals(table.getStatus())) {
            detail = "ใช้งาน (" + table.getHeadcount() + " คน)";
            color = COLOR_IN_USE;
            inUse++;
        } else if (DiningTable.STATUS_AWAITING.equals(table.getStatus())) {
            detail = "รอชำระ";
            color = COLOR_AWAITING;
        } else {
            detail = "ว่าง";
            color = COLOR_VACANT;
            vacant++;
        }
        btn.setText("<html><center><b>โต๊ะ " + table.getNumber() + "</b><br>" + detail + "</center></html>");
        btn.setBackground(color);
        btn.setOpaque(true);
        btn.setBorderPainted(false);
    }
    lblSummary.setText("ว่าง " + vacant + " โต๊ะ · ใช้งาน " + inUse + " โต๊ะ");
}
📖 อ่านโค้ดเป็นเรื่อง: โต๊ะที่ 3 ต้องขึ้นชมพู "ใช้งาน (4 คน)" ทันทีที่เปิดหน้าจอ — ไม่ใช่เพราะโค้ดจำไว้ แต่เพราะ seed ในฐานข้อมูลบอกไว้ (Step 1) หน้าจอแค่ "วาดสิ่งที่ db บอก" ทุกครั้ง (single source of truth — ทฤษฎีข้อ 8)

ขั้น 7.3 — tableClicked(n): แยกทางตามสถานะโต๊ะ

เพิ่มเมธอดรวมตรรกะต่อท้ายคลาส — เขียนครั้งเดียว แล้ว handler ของทั้ง 6 ปุ่มจะเรียกมัน (ขั้น 7.4):

/** ตรรกกการกดโต๊ะรวมไว้จุดเดียว — handler ของทั้ง 6 ปุ่มเรียกมาที่นี่
 *  โดยส่งลำดับของปุ่มตัวเอง (0–5) ซึ่งตรงกับลำดับข้อมูลโต๊ะใน tables */
private void tableClicked(int index) {
    DiningTable table = tables.get(index);
    if (table.isVacant()) {
        // เปิดโต๊ะ: ถามจำนวนลูกค้าก่อน (กฎ: ต้องใส่จำนวนคน จึงเปิดโต๊ะได้)
        String input = JOptionPane.showInputDialog(this,
                "จำนวนลูกค้า (โต๊ะ " + table.getNumber() + "):",
                "เปิดโต๊ะ", JOptionPane.QUESTION_MESSAGE);
        if (input == null) {
            return; // กดยกเลิก
        }
        int headcount;
        try {
            headcount = Integer.parseInt(input.trim());
        } catch (NumberFormatException ex) {
            headcount = 0;
        }
        Bill bill = tableService.openTable(table, headcount, employee);
        if (bill == null) {
            JOptionPane.showMessageDialog(this,
                    "เปิดโต๊ะไม่สำเร็จ — ต้องระบุจำนวนลูกค้าอย่างน้อย 1 คน\nหรือโต๊ะนี้มีลูกค้านั่งอยู่แล้ว",
                    "เปิดโต๊ะไม่สำเร็จ", JOptionPane.ERROR_MESSAGE);
            return;
        }
        refreshTables();
    } else if (DiningTable.STATUS_IN_USE.equals(table.getStatus())) {
        Bill bill = billService.getOpenBillForTable(table.getId());
        if (bill == null) {
            JOptionPane.showMessageDialog(this,
                    "โต๊ะนี้ใช้งานอยู่แต่ไม่พบบิล — ตรวจฐานข้อมูล",
                    "ข้อมูลไม่สมดุล", JOptionPane.ERROR_MESSAGE);
            return;
        }
        // เปิดหน้าสั่งอาหารของบิลเดิม (สั่งเพิ่มได้เรื่อย ๆ จนกว่าจะเช็คบิล)
        OrderFrame orderFrame = new OrderFrame(this, employee, table, bill);
        orderFrame.setLocationRelativeTo(this);
        orderFrame.setVisible(true);
    } else {
        JOptionPane.showMessageDialog(this,
                "โต๊ะ " + table.getNumber() + " รอชำระเงินอยู่",
                "รอชำระ", JOptionPane.WARNING_MESSAGE);
    }
}
📖 กฎอยู่ไหน? ใน if ของหน้าจอมีแค่การ "แยกทางตามสถานะที่ db บอก" — ส่วนกฎจริง 2 ข้อ (จำนวนคน ≥ 1 และ ห้ามเปิดโต๊ะที่ใช้งานอยู่, SC-4) ซ่อนอยู่หลัง tableService.openTable(...) ที่คืน null เมื่อโดนกฎ หน้าจอทำแค่แสดงกล่องแดงแทนคำว่า null — สลับหน้าจอใหม่ทั้งหมดก็ยังได้กฎชุดเดิม

ขั้น 7.4 — ผูก 7 ปุ่ม: ดับเบิลคลิกทีละปุ่ม วางบรรทัดเดียว

กลับไปแท็บ Design ดับเบิลคลิกปุ่มในตารางตามลำดับ แล้ววาง "เนื้อใน" ตามคอลัมน์ขวา:

ดับเบิลคลิกปุ่มเนื้อใน handler ที่ต้องวาง
btnTable1tableClicked(0);
btnTable2tableClicked(1);
btnTable3tableClicked(2);
btnTable4tableClicked(3);
btnTable5tableClicked(4);
btnTable6tableClicked(5);
btnRefreshrefreshTables();
⚠ เลขในวงเล็บต้องตรงปุ่ม: btnTable4 ต้องเรียก tableClicked(3) (เริ่มนับ 0) — ถ้าเลขเพี้ยน กดโต๊ะ 4 แล้วจะเปิดโต๊ะอื่น! ตรวจง่าย ๆ: รันแล้วชี้ปุ่มไหนข้อความใน dialog ต้องเป็นเลขโต๊ะนั้นพอดี

ทดสอบ Step 7

รัน LoginFrame (Shift+F6) → ล็อกอิน somchai / 1234 (แคชเชียร์ — ครั้งแรกที่เข้า POS ได้!) → ผังโต๊ะต้องขึ้นเหมือนภาพ ①–⑩ ใน Step 6 · แล้วลอง 3 กรณี:

ใส่จำนวนลูกค้า
กรณี 1 — กดโต๊ะ 4 (ว่าง): ถามจำนวนลูกค้าก่อนเปิดโต๊ะทุกครั้ง — พิมพ์ 2 กด OK จำนวนคนคือข้อมูลธุรกิจของร้าน ไม่ใช่ขั้นตอนกวนใจ
ผังโต๊ะหลังเปิดโต๊ะ
กรณี 2 — เปิดสำเร็จ: โต๊ะ 4 เปลี่ยนเป็น "ใช้งาน (2 คน)" ทันที — เกิดจาก 2 คำสั่ง SQL ที่ TableService.openTable ทำให้ (INSERT bill + UPDATE dining_table) เทียบขั้น 2.1 ที่พิมพ์เองได้
กฎปฏิเสธ
กรณี 3 — กฎทำงาน: กดโต๊ะ 1 ใส่ 0 คน = กล่องแดง — และกฎเดียวกันนี้กัน "เปิดโต๊ะซ้ำ" (SC-4): ลองกดโต๊ะ 3 ที่ใช้งานอยู่ ใส่เลขอะไรก็ถูกปฏิเสธ
✓ เช็ค: ล็อกอินผ่านเห็นผังโต๊ะ (โต๊ะ 3 ชมพู 4 คน) · เปิดโต๊ะ 4 ได้แล้วสีเปลี่ยนทันที · ใส่ 0 คน/กดโต๊ะ 3 โดนปฏิเสธ · กดรีเฟรชสีถูกเสมอ · ปิดโปรแกรมเปิดใหม่ โต๊ะ 4 ยังชมพู (สถานะอยู่ใน db ไม่ใช่ในหน้าจอ)
git commit -am "lab pos-flow: ผังโต๊ะเปิดโต๊ะได้จริง"
ส่วนที่ 2 — หน้าจอ Step 8 จาก 11 · หน้า 11/16 · ~30–40 นาที

Step 8 — ออกแบบหน้าสั่งอาหาร OrderFrame

หน้าที่สองของคู่ design/code — หน้านี้ซับซ้อนสุดของ lab เพราะมี ตาราง 2 ตัว (เมนูซ้าย บิลขวา) และ JTable มีกฎพิเศษ: ต้องอยู่ใน JScrollPane เสมอ ไม่งั้นหัวคอลัมน์หาย (NetBeans จัดให้เอง แต่เราต้องตั้งชื่อ scrollpane ด้วย)

ขั้น 8.1 — สร้างไฟล์ + ลบ main ของ template

  1. คลิกขวา package ui → New → JFrame Form ชื่อ OrderFrame
  2. สลับไปแท็บ Source → ลบเมธอด main() ทั้งเมธอดทิ้ง — หน้าจอนี้เปิดจาก PosFrame เสมอ ไม่มีสิทธิ์รันเอง (constructor ต้องรับค่า 4 ตัว ถ้า main ของ template ยังอยู่ จะเจอ error constructor OrderFrame in class OrderFrame cannot be applied to given types ทันที)

ขั้น 8.2 — วาง control ตามภาพ แล้วตั้งชื่อตามตาราง

โครง OrderFrame พร้อมป้ายชื่อตัวแปร
โครงหน้าจอที่ต้องวางให้ได้ (เลขวงกลม = เลขแถวในตารางด้านล่าง): ①lblHeader ②tblMenu ตารางเมนูซ้าย ③lblQty ④txtQty ⑤btnAdd ⑥tblBill ตารางบิลขวา ⑦lblTotalTitle ⑧lblTotal ⑨btnCheckout ⑩btnBack — ภาพจากแอปจริงตอนเปิดบิลใหม่ (ยอดรวม 0.00) · คอลัมน์ของตารางไม่ต้องตั้งใน designer โค้ดแทน model ให้ (Step 9)
#ControltextVariable Nameเพิ่มเติม
1Label—lblHeaderfont: Leelawadee UI, Bold, 20 (ข้อความ "โต๊ะ 4 · 2 คน · บิล #1" โค้ดใส่ — มีข้อมูลจริงปน)
2JTable—tblMenuลากมาแล้ว NetBeans ครอบด้วย JScrollPane ให้เอง — เปลี่ยนชื่อ scrollpane เป็น scrMenu · วางฝั่งซ้าย ราว 380 × 380
3Labelจำนวน:lblQtyใต้ตารางเมนู
4Text Field1txtQtyค่าเริ่มต้น 1 — คนละครั้งส่วนใหญ่สั่ง 1 อย่าง
5Buttonเพิ่มรายการbtnAdd
6JTable—tblBillscrollpane ตั้งชื่อ scrBill · วางฝั่งขวา ราว 380 × 300
7LabelยอดรวมlblTotalTitlefont: Leelawadee UI, Bold, 18
8Label—lblTotalfont: Leelawadee UI, Bold, 32 — ยอดเงินโค้ดใส่ (ไม่พิมพ์ใน designer)
9Buttonเช็คบิลbtnCheckoutfont: Leelawadee UI, Bold, 16
10Buttonกลับหน้าโต๊ะbtnBack
⚠ JTable ต้องอยู่ใน JScrollPane เสมอ: ถ้าลาก JTable แล้วไม่มี scrollpane ครอบ หัวคอลัมน์จะไม่ขึ้นตอนรัน (ตามทฤษฎีข้อ 9) — ตรวจง่าย ๆ: ใน Designer ต้องเห็น "jScrollPane" เป็นเจ้าของตาราง และตั้งชื่อทั้งคู่ (tblMenu + scrMenu)

ตั้งค่าฟอร์ม: title ไม่ต้องตั้ง (โค้ดใส่เอง — มีเลขโต๊ะปน) · defaultCloseOperation DISPOSE_ON_CLOSE (ปิดเฉพาะหน้าสั่งอาหาร ไม่ใช่จบโปรแกรม — ต่างจาก PosFrame) · resizable ออก · ขนาดราว ~850 × 560

✓ เช็ค: compile ผ่าน · control ครบ 10 + scrollpane 2 ตัวชื่อ scrMenu/scrBill · ลบ main แล้ว (กด Build ไม่มี error เรื่อง constructor)
ส่วนที่ 2 — หน้าจอ Step 9 จาก 11 · หน้า 12/16 · ~50–60 นาที

Step 9 — โค้ด OrderFrame: ปุ่มทุกครั้ง = INSERT จริง

หัวใจของทั้ง lab อยู่หน้านี้: ปุ่ม "เพิ่มรายการ" ทุกครั้ง = บันทึกลงฐานข้อมูลทันที (INSERT bill_item ทีละรอบ — ขั้น 2.2–2.3 ที่พิมพ์เอง) ปิดโปรแกรมกลางทาง บิลยังอยู่ครบทุกรอบที่สั่งมา — ต่างจาก repo กาแฟปีก่อนที่เก็บในหน่วยความจำแล้วบันทึกทีเดียวตอนชำระ

ขั้น 9.1 — import + field + constructor

เพิ่ม import ด้านบนไฟล์:

import com.example.moogata.model.Bill;
import com.example.moogata.model.BillItem;
import com.example.moogata.model.DiningTable;
import com.example.moogata.model.Employee;
import com.example.moogata.model.MenuItem;
import com.example.moogata.service.BillService;
import com.example.moogata.service.MenuService;
import java.util.ArrayList;
import java.util.List;
import javax.swing.JOptionPane;
import javax.swing.table.DefaultTableModel;

เพิ่ม field และ แทนที่ constructor ของ template (public OrderFrame() ไม่รับค่า) ด้วยก้อนนี้ — constructor ตั้งชื่อหน้าต่าง + แทน model ของตารางทั้ง 2 (ปิดการแก้ไขในตาราง) + โหลดข้อมูลแรกเข้า:

private final PosFrame owner;            // หน้าผังโต๊ะที่เปิดหน้าจอนี้ (กลับไปรีเฟรชสีโต๊ะตอนปิดบิล)
private final Employee employee;
private final DiningTable table;
private final Bill bill;

private final MenuService menuService = new MenuService();
private final BillService billService = new BillService();

// เมนูของตารางซ้าย — แถวที่ n ของ tblMenu = menuItems.get(n) (เก็บ object ไว้ ไม่ต้องเทียบชื่อ)
private List<MenuItem> menuItems = new ArrayList<>();

public OrderFrame(PosFrame owner, Employee employee, DiningTable table, Bill bill) {
    this.owner = owner;
    this.employee = employee;
    this.table = table;
    this.bill = bill;
    initComponents();
    setTitle("สั่งอาหาร — โต๊ะ " + table.getNumber());
    // JTable เป็นแค่ "หน้าตา" ข้อมูลจริงอยู่ที่ model (ทฤษฎีข้อ 7) — แทน model ใหม่เพื่อปิดการแก้ไขในตาราง
    tblMenu.setModel(new DefaultTableModel(new Object[][]{}, new String[]{"เมนู", "ราคา", "หน่วย"}) {
        @Override
        public boolean isCellEditable(int row, int column) {
            return false; // ตารางไว้ดูอย่างเดียว — การสั่งทำผ่านปุ่มเพิ่มรายการ
        }
    });
    tblMenu.setSelectionMode(javax.swing.ListSelectionModel.SINGLE_SELECTION);
    tblMenu.getTableHeader().setReorderingAllowed(false);
    tblBill.setModel(new DefaultTableModel(new Object[][]{},
            new String[]{"รายการ", "จำนวน", "ราคา", "รวม"}) {
        @Override
        public boolean isCellEditable(int row, int column) {
            return false;
        }
    });
    tblBill.getTableHeader().setReorderingAllowed(false);
    loadMenu();
    refreshBill();
    setLocationRelativeTo(owner);
}

ขั้น 9.2 — loadMenu() + refreshBill(): สองเมธอด "อ่านจากฐานข้อมูลใหม่"

เพิ่มต่อท้ายคลาส — สังเกตว่าทั้งคู่ "ล้างแล้วเติมใหม่จาก db" เสมอ ไม่มีการแก้แถวเดิม (ทฤษฎีข้อ 8: single source of truth):

/** โหลดเมนูพร้อมขาย (สถานะ 'มี') จาก MenuService — เมนู 'หมด' ต้องไม่ขึ้น */
private void loadMenu() {
    DefaultTableModel model = (DefaultTableModel) tblMenu.getModel();
    model.setRowCount(0);
    menuItems = menuService.getAvailable();
    for (MenuItem item : menuItems) {
        model.addRow(new Object[]{item.getName(), item.getPriceText(), item.getUnit()});
    }
    tblMenu.getColumnModel().getColumn(0).setPreferredWidth(200);
}

/** อ่านรายการจากฐานข้อมูลใหม่ทั้งหมด — ตารางขวาคือภาพจริงของ bill_item ใน db */
private void refreshBill() {
    DefaultTableModel model = (DefaultTableModel) tblBill.getModel();
    model.setRowCount(0);
    List<BillItem> items = billService.getItems(bill.getId());
    for (BillItem item : items) {
        model.addRow(new Object[]{item.getItemName(), item.getQty(),
                String.format("%.2f", item.getUnitPrice()), item.getLineTotalText()});
    }
    lblTotal.setText(String.format("%.2f", billService.getTotal(bill.getId())) + " บาท");
}
📖 สังเกต: getLineTotalText() แสดงยอดทอนต่อบรรทัด (คำนวณที่ model — ขั้น 3.5) และยอดรวมบรรทัดสุดท้ายถาม billService.getTotal ที่รวมจากรายการจริงทุกครั้ง — หน้าจอไม่เคย "จำ" ยอดเอง

ขั้น 9.3 — ผูก 2 ปุ่ม (ดับเบิลคลิก แล้ววางเนื้อใน)

btnAdd — เนื้อใน btnAddActionPerformed: ตรวจ 2 กัน (ยังไม่เลือกเมนู / จำนวน < 1) แล้วสั่งจริง:

int row = tblMenu.getSelectedRow();
if (row < 0) {
    JOptionPane.showMessageDialog(this, "เลือกเมนูจากตารางซ้ายก่อน",
            "ยังไม่ได้เลือกเมนู", JOptionPane.WARNING_MESSAGE);
    return;
}
int qty;
try {
    qty = Integer.parseInt(txtQty.getText().trim());
} catch (NumberFormatException ex) {
    qty = 0;
}
if (qty < 1) {
    JOptionPane.showMessageDialog(this, "จำนวนต้องเป็นตัวเลขตั้งแต่ 1 ขึ้นไป",
            "จำนวนไม่ถูกต้อง", JOptionPane.WARNING_MESSAGE);
    return;
}
MenuItem chosen = menuItems.get(row);   // เมนูของแถวที่เลือก (loadMenu เก็บ object ไว้ให้แล้ว)
// บันทึกลงฐานข้อมูลทันที — กฎทำงานใน BillService หน้าจอไม่เขียน SQL เอง
BillItem saved = billService.addItem(bill, chosen, qty);
if (saved == null) {
    JOptionPane.showMessageDialog(this, "บันทึกรายการไม่สำเร็จ",
            "ผิดพลาด", JOptionPane.ERROR_MESSAGE);
    return;
}
txtQty.setText("1");
refreshBill();

btnBack — เนื้อใน btnBackActionPerformed บรรทัดเดียว:

dispose(); // กลับผังโต๊ะ — บิลยังเปิดอยู่ กดโต๊ะเดิมเพื่อกลับมาสั่งต่อได้
⚠ ปุ่มเช็คบิลยังไม่ผูก — อย่าเพิ่งพิมพ์: โค้ดของ btnCheckout อ้างถึง CheckoutDialog ที่ยังไม่เกิด (Step 10–11) พิมพ์ก่อน = compile ไม่ผ่าน — ปุ่มนี้เงียบไว้ก่อน แล้วกลับมาผูกที่ Step 11 ขั้น 11.4 พอดี

ทดสอบ Step 9

รัน LoginFrame → ล็อกอิน somchai / 1234 → กดโต๊ะ 4 (ที่เปิดไว้ใน Step 7) → หน้าสั่งอาหารต้องเหมือนภาพ ①–⑩ ใน Step 8 (ยอดรวม 0.00) → ลองสั่ง 2 รอบ:

สั่งรอบแรก
สั่งรอบแรก — บุฟเฟ่ต์หมูกะทะ จำนวน 2 (แถวแรกในตารางเมนู) กดเพิ่มรายการ: ตารางขวาขึ้นทันที — ไม่ใช่เพราะโค้ดวาดเอง แต่เพราะ INSERT ลง db จริงแล้ว refreshBill() อ่านกลับมา
สั่งรอบที่สอง
สั่งรอบที่สอง — หม่าล่า 1 + ชาเย็น 2: แถวเพิ่มเป็น 3 แถว (INSERT ทีละแถว ไม่ทับของเดิม — ขั้น 2.3) ยอดรวม 578 + 49 + 50 = 677.00 บาท รวมสดจากรายการจริง
✓ เช็ค (สำคัญ): เมนู "ลูกชิ้นทอด [หมด]" ต้องไม่ขึ้นในตารางซ้าย · สั่งเมื่อยังไม่เลือกแถว = กล่องเตือน · ใส่จำนวน 0/abc = กล่องเตือน · ปิดโปรแกรมแล้วเปิดใหม่ → กดโต๊ะ 4 อีกครั้ง → รายการทั้ง 3 แถวยังอยู่ครบ — นี่คือคำตอบว่าทำไมไม่ทำแบบ repo กาแฟปีก่อน (ทฤษฎีข้อ 8)
git commit -am "lab pos-flow: หน้าสั่งอาหาร บันทึกทุกรอบ"
ส่วนที่ 2 — หน้าจอ Step 10 จาก 11 · หน้า 13/16 · ~25–35 นาที

Step 10 — ออกแบบหน้าต่างเช็คบิล CheckoutDialog

หน้าจอสุดท้ายเป็น JDialog แบบ modal — รูปแบบเดียวกับ EmployeeDialog ของ Lab 11: เปิดทับหน้าสั่งอาหารแล้วล็อกหน้าอื่นจนปิด (ทฤษฎีข้อ 7) ทำงานเสร็จตั้ง saved = true แล้วหน้าเรียกถาม isSaved() — หน้านี้ออกแบบสั้น ๆ 9 ป้าย/ช่อง/ปุ่ม แต่มีกลศจรรย์อยู่ที่โค้ด: พิมพ์เงินปุ๊บ คิดทอนปั๊บ

ขั้น 10.1 — สร้างไฟล์ + ลบ template 2 ชิ้นก่อน

  1. คลิกขวา package ui → New → JDialog Form (หมวด Swing GUI Forms) ชื่อ CheckoutDialog
  2. สลับไปแท็บ Source → ลบ 2 ชิ้นที่ template ให้มา: (1) constructor ของ template public CheckoutDialog(java.awt.Frame parent, boolean modal) { ... } ทั้งเมธอด (2) เมธอด main() ทั้งเมธอด — เราจะเขียน constructor ใหม่ที่รับ Bill เอง (Step 11) · ถ้าลืมลบ constructor เดิมแล้วสร้างใหม่ชนกัน จะเจอ error constructor CheckoutDialog(...) is already defined ให้กลับมาลบตรงนี้

ขั้น 10.2 — วาง control ตามภาพ แล้วตั้งชื่อตามตาราง

โครง CheckoutDialog พร้อมป้ายชื่อตัวแปร
โครงหน้าจอที่ต้องวางให้ได้ (เลขวงกลม = เลขแถวในตารางด้านล่าง): ①lblHeader ②lblTotalTitle ③lblTotal ④lblCash ⑤txtCash ⑥lblChangeTitle ⑦lblChange ⑧btnConfirm ⑨btnCancel — ภาพจากแอปจริงตอนพิมพ์ 700 (ทอน 23.00)
#ControltextVariable Nameเพิ่มเติม
1Labelเช็คบิล โต๊ะที่ 4lblHeaderfont: Leelawadee UI, Bold, 18 — พิมพ์คำว่า "โต๊ะที่ 4" เป็นตัวอย่างไว้ก่อน โค้ดจะเขียนทับด้วยเลขโต๊ะจริง
2Labelยอดรวมทั้งบิลlblTotalTitlefont: Leelawadee UI, Plain, 16
3Label—lblTotalfont: Leelawadee UI, Bold, 40 — ยอดเงินโค้ดใส่
4Labelรับเงิน (บาท):lblCash
5Text Field—txtCashfont: Leelawadee UI, Plain, 18 (ช่องพิมพ์เงินใหญ่หน่อย)
6LabelเงินทอนlblChangeTitlefont: Leelawadee UI, Plain, 16
7Label-lblChangefont: Leelawadee UI, Bold, 34 — ขีดล่างเป็นค่าเริ่ม (ยังไม่พิมพ์เงิน)
8Buttonยืนยันรับเงิน ปิดบิลbtnConfirmfont: Leelawadee UI, Bold, 16
9ButtonยกเลิกbtnCancel

ตั้งค่าฟอร์ม: resizable ออก · ขนาดราว ~460 × 360 · ส่วน title กับความเป็น modal ใส่ในโค้ด constructor (มีเลขโต๊ะปน + ต้อง modal = true)

✓ เช็ค: compile ผ่าน (ยังไม่มี constructor ใด ๆ ก็ยัง build ผ่าน — แค่เรียกไม่ได้) · control ครบ 9 ชื่อตรงตาราง
ส่วนที่ 2 — หน้าจอ Step 11 จาก 11 · หน้า 14/16 · ~60–75 นาที

Step 11 — โค้ด CheckoutDialog: เงินทอน 2 ตำแหน่ง + ปิดบิล + ทดสอบครบ

จุดจบของ flow — constructor โชว์ยอดรวม, DocumentListener เรียก updateChange() ทุกครั้งที่พิมพ์ (พิมพ์ปุ๊บ คิดทอนปั๊บ), ปุ่มยืนยันล็อกไว้จนเงินพอ และ "ปิดบิลจริง" ยังคงเป็น BillService.checkout คนเดียว จบหน้านี้ด้วยการทดสอบครบ 7 สถานการณ์

ขั้น 11.1 — import + field + constructor + isSaved()

เพิ่ม import ด้านบนไฟล์:

import com.example.moogata.model.Bill;
import com.example.moogata.service.BillService;
import javax.swing.JOptionPane;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;

เพิ่ม field + constructor ใหม่ (แทนที่ที่ลบไว้ใน Step 10) + isSaved() — รูปแบบเดียวกับ EmployeeDialog:

private final Bill bill;
private final int tableNumber;   // เลขโต๊ะที่แสดงบนหน้าจอ (เลขที่ลูกค้ารู้จัก)
private final BillService billService = new BillService();
private boolean saved = false;   // ปิดบิลสำเร็จหรือยัง (หน้าเรียกใช้ isSaved())

public CheckoutDialog(java.awt.Frame owner, Bill bill, int tableNumber) {
    super(owner, "เช็คบิล โต๊ะที่ " + tableNumber, true); // true = modal — ล็อกหน้าอื่นจนปิด
    this.bill = bill;
    this.tableNumber = tableNumber;
    initComponents();
    lblHeader.setText("เช็คบิล โต๊ะที่ " + tableNumber);
    lblTotal.setText(String.format("%.2f", billService.getTotal(bill.getId())) + " บาท");
    // พิมพ์เงินปุ๊บ คิดเงินทอนปั๊บ — ผูกตัวฟังเหตุการณ์กับ "เอกสาร" ของช่องกรอก
    txtCash.getDocument().addDocumentListener(new DocumentListener() {
        public void insertUpdate(DocumentEvent evt) {
            updateChange();
        }

        public void removeUpdate(DocumentEvent evt) {
            updateChange();
        }

        public void changedUpdate(DocumentEvent evt) {
            updateChange();
        }
    });
    setLocationRelativeTo(owner);
}

/** หน้าเรียกถามหลัง dialog ปิด — true = ปิดบิลสำเร็จ ให้รีเฟรชหน้าจอ */
public boolean isSaved() {
    return saved;
}
📖 อ่านโค้ดเป็นเรื่อง: 3 จุดที่ต่างจากหน้าที่เคยทำ — (1) super(owner, ..., true) คือการบอก JDialog ว่าเป็น modal ของหน้าไหน หัวข้ออะไร (2) DocumentListener ไม่ใช่ listener ของปุ่ม แต่ของ "เนื้อความช่องกรอก" — อักษรทุกตัวที่เพิ่ม/ลบ เรียก updateChange() (3) isSaved() คือสัญญากับหน้าเรียก: ปิดไปแบบไหน ยืนยันจริงหรือยกเลิก

ขั้น 11.2 — updateChange(): คิดทอนสด ทศนิยม 2 ตำแหน่ง (SC-1)

เพิ่มเมธอดต่อท้ายคลาส — กดยืนยันได้ "เฉพาะเงินพอ" อย่างอื่นล้วนกลับไปเป็นขีดล่าง + ปุ่มเทา:

/** คิดเงินทอนสด ๆ จากช่องกรอก — ทศนิยม 2 ตำแหน่ง (SC-1) กดยืนยันได้เมื่อเงินพอ */
private void updateChange() {
    try {
        double cash = Double.parseDouble(txtCash.getText().trim());
        double total = billService.getTotal(bill.getId());
        if (cash >= total) {
            lblChange.setText(String.format("%.2f บาท", cash - total));
            btnConfirm.setEnabled(true);
            return;
        }
    } catch (NumberFormatException ex) {
        // ยังพิมพ์ไม่จบ / พิมพ์ไม่ใช่ตัวเลข — ปฏิบัติเหมือนยังไม่มีเงินรับ
    }
    lblChange.setText("-");
    btnConfirm.setEnabled(false);
}
📖 กฎอยู่ไหน? นี่คือชั้น UI (ทฤษฎีข้อ 6): การเทา/ปลดล็อกปุ่มเป็น "การจัดรูปแบบเพื่อความสะดวก" — แม้ใครจะบังคับกดปุ่มเทาได้ กฎเงินพอก็ยังถูกตรวจซ้ำที่ billService.checkout — UI ช่วยให้ถูกทาง ไม่ใช่กำแพงกัน

ขั้น 11.3 — ผูก 2 ปุ่ม (ดับเบิลคลิก แล้ววางเนื้อใน)

btnConfirm — เนื้อใน btnConfirmActionPerformed:

double cash;
try {
    cash = Double.parseDouble(txtCash.getText().trim());
} catch (NumberFormatException ex) {
    cash = 0;
}
// กฎและการปิดบิลอยู่ที่ BillService.checkout — ตรวจเงินพอ + ปิดบิล + คืนโต๊ะ
Bill closed = billService.checkout(bill, cash);
if (closed == null) {
    JOptionPane.showMessageDialog(this, "เงินรับไม่พอ — ตรวจยอดอีกครั้ง",
            "ชำระเงินไม่สำเร็จ", JOptionPane.ERROR_MESSAGE);
    return;
}
saved = true;
JOptionPane.showMessageDialog(this,
        "ปิดบิลสำเร็จ · ยอด " + String.format("%.2f", closed.getTotal())
        + " บาท · ทอน " + String.format("%.2f", closed.getChangeAmount()) + " บาท\n"
        + "โต๊ะกลับสู่สถานะว่างแล้ว",
        "เช็คบิลสำเร็จ", JOptionPane.INFORMATION_MESSAGE);
dispose();

btnCancel — เนื้อใน btnCancelActionPerformed บรรทัดเดียว:

dispose(); // ยกเลิก — บิลยังเปิดอยู่ ลูกค้ายังสั่งเพิ่มได้

ขั้น 11.4 — กลับที่ OrderFrame: ผูกปุ่มเช็คบิลที่ค้างไว้

เปิด OrderFrame ดับเบิลคลิก btnCheckout แล้ววางเนื้อใน — ทั้งเช็คบิลว่างและการรับผลจาก isSaved():

if (billService.getItems(bill.getId()).isEmpty()) {
    JOptionPane.showMessageDialog(this, "ยังไม่มีรายการอาหาร — เช็คบิลไม่ได้",
            "บิลว่าง", JOptionPane.WARNING_MESSAGE);
    return;
}
// เปิดหน้าต่างเช็คบิล (modal) — ปิดบิลสำเร็จ isSaved() จะเป็น true
CheckoutDialog dialog = new CheckoutDialog(this, bill, table.getNumber());
dialog.setLocationRelativeTo(this);
dialog.setVisible(true);   // บรรทัดนี้บล็อกจนกว่า dialog จะปิด (modal)
if (dialog.isSaved()) {
    if (owner != null) {
        owner.refreshTables(); // สีโต๊ะบนผังต้องกลับเป็นเขียว
    }
    dispose();
}

ภาพรวมการเช็คบิลหนึ่งครั้ง (Mermaid)

sequenceDiagram
    participant U as ผู้ใช้ (แคชเชียร์)
    participant D as CheckoutDialog
    participant S as BillService
    participant DB as moogata.db
    U->>D: พิมพ์เงินรับ 700
    D->>S: getTotal(bill)
    S-->>D: 677.00
    D-->>U: โชว์เงินทอน 23.00 ทันที
    U->>D: กดยืนยัน
    D->>S: checkout(bill, 700)
    S->>DB: UPDATE bill (ปิด) + UPDATE dining_table (ว่าง)
    S-->>D: บิลปิด ทอน 23.00
    D-->>U: แจ้งสำเร็จ
  

ทดสอบจริง

รัน → ล็อกอิน → กดโต๊ะ 4 → เช็คบิล → พิมพ์ 700 — เงินทอนต้องขึ้น 23.00 ทันทีที่พิมพ์เลข 0 ตัวสุดท้าย:

เช็คบิลสำเร็จ
กดยืนยัน: ปิดบิล + คิดทอน + คืนโต๊ะ เกิดพร้อมกันใน BillService.checkout ครั้งเดียว (เทียบขั้น 2.6 ที่พิมพ์ UPDATE 2 คำสั่งด้วยมือ)
หลังปิดบิล
กลับผังโต๊ะ: โต๊ะ 4 กลับสีเขียว "ว่าง" พร้อมรับลูกค้ารอบใหม่ — เกิดจาก owner.refreshTables() ที่เรียกตอน isSaved() เป็นจริง

ทดสอบครบทุกสถานการณ์ก่อนส่งงาน

#สถานการณ์ผลที่ถูกต้อง
1เปิดโต๊ะ ใส่ 0 คนปฏิเสธ (ภาพ pos-04)
2กดโต๊ะที่ "ใช้งาน"เข้าหน้าสั่งของ บิลเดิม ไม่ได้สร้างบิลใหม่ (SC-4)
3เช็คบิลตอนบิลยังว่างปฏิเสธ "ยังไม่มีรายการ"
4พิมพ์เงินน้อยกว่ายอด (เช่น 600)ปุ่มยืนยันกดไม่ได้ / ปฏิเสธ
5ชำระ 700 → ปิดบิลทอน 23.00, โต๊ะกลับเขียว
6กดโต๊ะ 4 ที่เพิ่งว่างเปิดบิลใหม่ได้ปกติ (bill ใหม่ id ถัดไป)
7ปิดโปรแกรมกลางบิลแล้วเปิดใหม่รายการไม่หาย (บันทึกทุกรอบ)

ถ้าข้อไหนไม่ผ่าน — ย้อนไปดูจุดนี้

อาการที่พบปัญหาอยู่ที่ย้อนไปดู
กดปุ่มแล้วเงียบ (ทุกปุ่มหรือบางปุ่ม)event ยังไม่ถูกผูก — ลืมดับเบิลคลิก หรือวางโค้ดผิดเมธอดStep 7 ขั้น 7.4 · Step 9 ขั้น 9.3 · Step 11 ขั้น 11.3
ข้อ 1 — เปิดโต๊ะ 0 คนผ่าน / เปิดโต๊ะซ้ำได้กฎใน TableService ไม่ทำงานStep 5 ขั้น 5.2 (รัน TestTableService ต้องได้ 4/4)
ข้อ 3 — เช็คบิลบิลว่างผ่านเงื่อนไขบิลว่างหายStep 11 ขั้น 11.4 (if getItems().isEmpty())
ข้อ 4/5 — ทอนผิด หรือปุ่มยืนยันกดได้ตลอดupdateChange ไม่ทำงานขณะพิมพ์Step 11 ขั้น 11.1 (DocumentListener) + 11.2
ข้อ 5 — ปิดบิลแล้วโต๊ะยังชมพูisSaved ไม่ถูกถาม หรือลืม refreshTablesStep 11 ขั้น 11.4 (owner.refreshTables())
ข้อ 7 — ปิดโปรแกรมแล้วรายการหายยังไม่บันทึกทุกรอบ (เผลอทำใน memory)Step 9 ขั้น 9.3 (addItem ทุกครั้ง) + TestBillService ข้อ 2–3
✓ เช็ค: ผ่านครบ 7 สถานการณ์ — ถ่ายภาพหน้าจออย่างน้อย 3 ภาพเป็นหลักฐาน (ดูรายชื่อภาพในเช็คลิสต์หน้า 16)
git commit -am "lab pos-flow: เช็คบิลครบ flow ผ่านทุกสถานการณ์"
ปิดท้าย หน้า 15/16

Troubleshooting — เจอปัญหานี้แก้อย่างไร

#อาการสาเหตุ / วิธีแก้
1no such table: dining_tableยังไม่ได้รัน schema ขั้น 1 หรือรันกับ db ผิดไฟล์ — รัน schema-pos.sql กับ moogata.db ของโปรเจกต์ (ตรวจ path)
2ข้อมูล test / ผังโต๊ะเพี้ยนจากการทดลองครั้งก่อนข้อมูลมีสถานะค้าง (เปิดโต๊ะค้าง, บิลค้าง) — รัน reset-pos.sql ใน Letos กลับสู่สถานะเพิ่งเปิดร้านทันที
3UNIQUE constraint failed ตอน seed โต๊ะรัน INSERT ซ้ำโดยไม่มี OR IGNORE — ใช้ INSERT OR IGNORE ตามเอกสาร รันซ้ำปลอดภัย
4FOREIGN KEY constraint failed ตอนล้างข้อมูลลบแม่ก่อนลูก — ลบลูกก่อนแม่เสมอ: bill_item → bill
5database is lockedเปิด moogata.db ค้างใน Letos — ปิด db ใน Letos ทุกครั้งก่อนรันโปรแกรม
6ข้อความไทยบนปุ่ม/ป้ายเป็นกล่อง □ThaiFont.apply() ไม่ได้ถูกเรียกก่อนสร้างหน้าจอแรก — ต้องเรียกเป็นบรรทัดแรกของ constructor LoginFrame (หน้าจอที่สร้างหลังจากนั้นได้ฟอนต์ทั้งหมด) — ห้ามแก้ที่ข้อมูล
7หน้าจอขึ้นแต่ กดปุ่มแล้วเงียบ ไม่มีอะไรเกิดขึ้นevent ยังไม่ถูกผูก — ลืมดับเบิลคลิกปุ่มใน designer หรือวางโค้ดทั้งก้อน (เมธอดซ้อนเมธอด) หรือข้ามใน starter ที่ยังไม่เติม TODO — ดู Step 7 ขั้น 7.4 / Step 9 ขั้น 9.3 / Step 11 ขั้น 11.3
8cannot find symbol: variable txtQty (หรือชื่อ control อื่น)ชื่อตัวแปรในโค้ดไม่ตรงกับ designer — แก้ที่ designer คลิกขวา control → Change Variable Name… ให้ตรงตาราง control เป๊ะ ๆ (รวมถึงตัวพิมพ์เล็ก-ใหญ่)
9constructor OrderFrame in class OrderFrame cannot be applied to given typesmain() ของ template ยังอยู่ (เรียก new OrderFrame() ไม่มี arg) — ลบ main ทิ้งตาม Step 8 ขั้น 8.1
10constructor CheckoutDialog(...) is already definedสร้าง constructor ใหม่ทั้งที่ยังไม่ลบของ template — ลบ CheckoutDialog(Frame parent, boolean modal) ทิ้งตาม Step 10 ขั้น 10.1
11ตารางรันแล้วไม่มีหัวคอลัมน์ / หน้าจอโชว์โต๊ะแต่จอว่างเปล่าตาราง: ไม่มี JScrollPane ครอบ (Step 8) · จอว่าง: ทำงาน directory ผิด หา moogata.db ไม่เจอ — db ต้องอยู่รากโปรเจกต์
12ปุ่มโต๊ะกดแล้วเปิดโต๊ะผิดใบเลขใน tableClicked(n) ไม่ตรงปุ่ม (เริ่มนับ 0: btnTable4 → tableClicked(3)) — ตามตารางขั้น 7.4
13ปุ่ม/ป้ายวางทับกัน หรือหลุดขอบจอFree Design วางตำแหน่งเอง — ลากจัดใหม่ตามภาพ blueprint · ตรวจ resizable ปิดและขนาดฟอร์มราวตาม properties ของแต่ละหน้า
14เงินทอนแสดง 22.999999การปัดของ double — แสดงผลด้วย String.format("%.2f", ...) เสมอ (BigDecimal เป็นแบบฝึกหัด Part 1 ข้อ 4)
15test FAIL ข้อที่เคยผ่านอ่านวงเล็บท้ายบรรทัด (บอก actual) — test ล้างข้อมูลเองก่อนรันทุกครั้ง ถ้ายัง FAIL แปลว่า service จริง ๆ กลับไปดู Step 5
ปิดท้าย หน้า 16/16

เช็คลิสต์ส่งงาน + สรุปเรื่องราวทั้ง lab

เช็คลิสต์ส่งงาน

แบบฝึกหัดต่อยอด (เลือกตามระดับ)

ระดับเรื่องโจทย์
ง่ายรายงานบิลที่ปิดแล้วTestReport: ค้น SELECT * FROM bill WHERE status = 'ปิด' + รวมยอดขายด้วย SUM
ง่ายสถานะ "รอชำระ"เพิ่มปุ่ม "ขอบิล": bill → 'รอชำระ' + โต๊ะ → 'รอชำระ' แล้ว CheckoutDialog เปิดได้เฉพาะบิลรอชำระ
กลางยกเลิกรายการปุ่มลบรายการที่เลือกในตารางบิล (BillItemDao.delete — ห้ามลบหลังบิลปิด! กฎอยู่ใน service)
กลางกันสั่งลงบิลที่ปิดแล้วเพิ่ม test ใน TestBillService พิสูจน์ว่า addItem ลงบิลที่ปิดแล้วได้ null
ยากธุรกรรมเดียวห่อ openTable/checkout ด้วย setAutoCommit(false) → commit/rollback แล้วเขียน test จำลองคำสั่งที่สองพัง พิสูจน์ว่า rollback จริง
ยากSC-1 ฉบับเต็มเพิ่มเมนูราคาทศนิยม สร้างยอด 317.79 พิสูจน์ว่ารับ 500.00 ต้องทอน 182.21 ไม่ใช่ 182.25
ยากwalk-in ไม่ผูกโต๊ะเปิดบิลโดยไม่มีโต๊ะ (table_id nullable — แก้ schema + model + UI ให้รองรับ)

สรุปเรื่องราวทั้ง lab — เราได้อะไรมาบ้าง

เริ่ม lab นี้ด้วย: ระบบที่ล็อกอินและจัดการพนักงานได้ — แต่ร้านยังไม่ได้ขายอะไรเลย

สิ่งที่เกิดขึ้นตลอด 11 Step: เพิ่ม 3 ตาราง (โต๊ะ บิล รายการในบิล) ที่มีสถานะและ snapshot · เดิน flow ครบรอบด้วย SQL ล้วนก่อนเขียนโค้ด · ห่อ SQL เป็น model/DAO/service โดยกฎธุรกิจ (Hard Lock, เงินไม่พอ, ปิดบิลคืนโต๊ะ) อยู่ชั้น service จุดเดียว · แล้วออกแบบ+โค้ดหน้าจอ 3 หน้าเองบนกฎเดิม — ตลอดทางของเดิม (EmployeeDao, DatabaseHelper, moogata.db เดิม) ไม่ถูกแก้แม้แต่บรรทัดเดียว

บทเรียนที่ควรติดตัวออกจาก lab:

  • สิ่งที่มีชีวิตต้องเก็บ "สถานะ" — และสถานะเปลี่ยนได้เฉพาะตามเหตุการณ์ผ่านกฎ
  • "สั่งได้หลายรอบ" = INSERT ทีละแถวทันที ไม่ใช่จำไว้ในหน่วยความจำแล้วบันทึกทีเดียวตอนจบ
  • บิลพกราคาเอง (snapshot) — ราคาแม่เปลี่ยนย้อนหลังไม่กระทบบิลเก่า
  • กฎ 3 ชั้น: DB คุมโครงสร้าง · service คุมกฎธุรกิจ · UI คุมการจัดรูปแบบ — ใส่ผิดชั้นระบบจะเปราะ
  • คำสั่งที่ควรคู่กัน (เปิดโต๊ะ, เช็คบิล) ยังต้องห่อเป็นธุรกรรม — ช่องโหว่นี้โค้ดปัจจุบันยังมีอยู่ รู้ตัวไว้

เรื่องต่อไป: สมาชิก + แต้ม (SC-2) · โปรโมชันผูกจำนวนคน (SC-3) — โครงเต็มดูได้ที่ _bmad-output/planning-artifacts/dev-docs/10-er-diagram.md (23 ตาราง) ซึ่งโต๊ะ/บิล/รายการในบิลที่เราสร้างวันนี้คือ 3 ตารางแรกของมันพอดี

เอกสารอ้างอิงเต็ม