ต่อจาก Lab Week 8 (เลเยอร์ + DAO) และ Lab Week 11 (หน้าจอ Swing + ล็อกอิน) · ต่อยอดในโปรเจกต์ MoogataLab เดิม · SQL + Java + Swing · ธีมร้านหมูกระทะ BONUS SUKI
จบ codelab นี้คุณจะมี POS 3 หน้าจอบนฐานข้อมูลที่เพิ่ม 3 ตารางใหม่ — เปิดโปรแกรม ล็อกอิน (somchai/1234 แคชเชียร์ก็เข้าได้) แล้วทำงานได้จริง:
| หน้าจอ | หน้าที่ | กฎที่ทำงานอยู่ข้างหลัง |
|---|---|---|
| PosFrame — ผังโต๊ะ | ปุ่มโต๊ะ 6 ปุ่มสีตามสถานะ (เขียวว่าง/ชมพูใช้งาน) — กดโต๊ะว่างใส่จำนวนลูกค้าเพื่อเปิดโต๊ะ | ห้ามเปิดโต๊ะที่ยังใช้งานอยู่ (Hard Lock, SC-4) — กฎอยู่ใน TableService |
| OrderFrame — สั่งอาหาร | เมนูซ้าย บิลขวา — เพิ่มรายการได้หลายรอบ บันทึกลงฐานข้อมูลทุกครั้ง | บิลที่ปิดแล้วสั่งเพิ่มไม่ได้ · ราคาเก็บแบบ snapshot ณ ตอนสั่ง |
| CheckoutDialog — เช็คบิล | พิมพ์เงินรับ เห็นเงินทอนทันที (ทศนิยม 2 ตำแหน่ง) → ยืนยันปิดบิล | ปิดบิล + คืนโต๊ะ ในจุดเดียว (SC-1: 317.79/500 ต้องทอน 182.21) |
| หน้า | เรื่อง | สิ่งที่จะได้ตอนจบหน้า |
|---|---|---|
| 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 · service | console test ผ่าน 14/14 |
| 9–14 | ส่วนที่ 2 (Step 6–11): หน้าจอ — ออกแบบ + โค้ดสลับกันทีละหน้าจอ (สร้างเองใน NetBeans แบบ Lab 11): ผังโต๊ะ · สั่งอาหาร · เช็คบิล | POS 3 หน้าจอ ผ่านทดสอบ 7 สถานการณ์ |
| 15–16 | Troubleshooting · เช็คลิสต์ส่งงาน · สรุปเรื่องราว | งานพร้อมส่ง |
ส่วนที่ 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 กลายเป็นสีชมพู"]
ui/LoginFrame ทำงาน และรัน TestEmployeeService ได้ Welcome user : somchai (แคชเชียร์)อ่านรอบแรกแบบคร่าว ๆ ก็ได้ — เมื่อถึง Step ไหนที่ใช้เรื่องนั้น จะมีลิงก์ย้อนกลับมาที่นี่ทุกครั้ง ทฤษฎีจะเกาะเมื่อเห็นของจริง
| เหตุการณ์ | โต๊ะเปลี่ยนเป็น | บิลเปลี่ยนเป็น |
|---|---|---|
| ลูกค้ามา เปิดโต๊ะ | ว่าง → ใช้งาน | (ไม่มี) → เปิด |
| ลูกค้าสั่งเพิ่ม | (ไม่เปลี่ยน) | เปิด (เพิ่มแถวใน 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 --> [*]
status ของ dining_table และ bill (Step 1) · การเปลี่ยนสถานะทำใน service (Step 5)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-acommit)
หรือย้อนหมดเหมือนไม่ได้ทำ (rollback) — คุณสมบัติ ACID: Atomic ทั้งก้อนหรือไม่ทำ · Consistent ข้อมูลสมดุล · Isolated งานไม่ยุ่งกัน · Durable สำเร็จแล้วอยู่จริง
| constraint | ความหมาย | ตัวอย่างจาก lab นี้ |
|---|---|---|
NOT NULL | ห้ามว่าง | bill.table_id ต้องมีโต๊ะเสมอ |
UNIQUE | ห้ามซ้ำ | dining_table.table_number ไม่มีโต๊ะ 4 สองตัว |
CHECK | ค่าต้องผ่านเงื่อนไข | status IN ('ว่าง','ใช้งาน','รอชำระ') — พิมพ์ผิดโดน DB ปฏิเสธ |
DEFAULT | ไม่บอก = ใช้ค่านี้ | status เกิดมาเป็น 'ว่าง' · opened_at ใส่เวลาให้เอง |
?: ลองเห็นภัยก่อน — ถ้าเขียนค้นสมาชิกแบบต่อสตริง
' OR '1'='1 เข้าไป SQL ที่รันจริงกลายเป็น WHERE phone = '' OR '1'='1' → ดึงข้อมูลทุกแถวมาโชว์!
นี่คือ SQL injection — ส่วน ? จะส่งค่าแยกจากคำสั่ง ค่าที่พิมพ์มาเป็นแค่ข้อความ ไม่มีวันกลายเป็น SQL
getAll(where, order) พร้อม LOG.warning เตือนไว้ เพื่อฝึกเท่านั้น| ชั้น | กฎแบบไหนอยู่นี่ | ตัวอย่างจาก lab นี้ | ใครบังคับ |
|---|---|---|---|
| ฐานข้อมูล (constraint) | กฎโครงสร้างข้อมูล — ผิดแล้วคำสั่งไม่ผ่านเลย | status มีได้ 3 ค่า · qty > 0 · FK ห้ามชี้แถวที่ไม่มี | CHECK / UNIQUE / FK |
| service (กฎธุรกิจ) | กฎที่ต้อง "อ่านสถานการณ์ก่อน" แล้วค่อยตัดสิน | ห้ามเปิดโต๊ะซ้ำ (SC-4) · เงินรับต้อง ≥ ยอด · บิลปิดแล้วสั่งเพิ่มไม่ได้ | if ใน TableService/BillService |
| หน้าจอ (UI) | การจัดรูปแบบและความสะดวก — ไม่ใช่กฎ | ปุ่มยืนยันเป็นสีเทาเมื่อเงินไม่พอ · แสดงเงิน %.2f · dialog เตือน | โค้ดใน ui/ |
Math.abs(a-b) < 0.001)
actionPerformed ที่เราผูกไว้ให้เองบน EDT
· JTable เป็นแค่หน้าตา ข้อมูลจริงอยู่ที่ DefaultTableModel — ล้างแล้วเติมใหม่ที่ model ตารางวาดใหม่เอง ·
dialog แบบ modal = true ล็อกหน้าอื่นจนปิด และโค้ดหลัง setVisible(true) ทำงานต่อหลังปิด dialog
double เก็บเลขเป็นฐานสอง เลขทศนิยมอย่าง 0.1 ถูกเก็บแบบ "ใกล้เคียงที่สุด" — ลองพิมพ์
System.out.println(0.1 + 0.2); จะได้ 0.30000000000000004 ระบบเงินจึงอ่อไหวถ้าเอา double ไปเทียบตรง ๆ
lab นี้รอดเพราะกัน 2 ชั้น: ราคา seed เก็บตรงเป๊ะ + ทุกจุดแสดงผลใช้ String.format("%.2f", ...) —
ทางจริงจังคือ BigDecimal (แบบฝึกหัด Part 1 ข้อ 4)refreshBill) — ผลคือปิดโปรแกรมเปิดใหม่ ข้อมูลไม่หาย และแคชเชียร์อีกเครื่องเห็นของจริงเสมอ
refreshBill() / updateChange() (Step 9, 11) · ทดสอบ "ปิดโปรแกรมกลางบิลแล้วเปิดใหม่"lbl… · ช่องกรอก txt… · ปุ่ม btn… · ตาราง tbl… · scrollpane scr… — โค้ดทุก Step ต่อไปอ้างชื่อเหล่านี้ ชื่อเพี้ยน = compile ไม่ผ่านscrMenuอ่านแผนผังนี้ออกก่อนพิมพ์ CREATE TABLE — เส้นเชื่อมทุกเส้นคือ FK ที่คุณจะเขียนใน Step 1
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 : "เปิดบิล"
| ไฟล์/โฟลเดอร์ | คืออะไร | คุณต้องทำอะไรกับมัน |
|---|---|---|
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 โฟลเดอร์สำรอง | เครื่องมือผู้สอน — นิสิตไม่ต้องแตะ |
moogata (โฟลเดอร์/db/package) (2) ชื่อเมนู
"บุฟเฟ่ต์หมูกะทะ" เป็นข้อมูลจริงในตาราง menu_item จาก seed ของ Lab Week 8 — ต้องสะกดตามฐานข้อมูลเดิมเสมอ (Step 2 ค้นหาด้วยชื่อนี้)Reciept + RecieptDetail — ต่างจากที่เราจะทำ 3 จุด: (1) เขาเก็บบิลไว้ในหน่วยความจำแล้วบันทึกครั้งเดียวตอนชำระ
บิลร้านหมูกระทะเปิดนาน 1–2 ชม. ต้องบันทึกทุกรอบที่สั่ง (2) เขาไม่มีโต๊ะ/สถานะ (3) เขาไม่มีการคิดเงินทอน — ปีนี้สอนใหม่จากธุรกิจร้านหมูกระทะจริงเปิดไฟล์ moogata.db ใน Letos → Tools → Open SQL editor แล้วพิมพ์ทีละคำสั่ง กด Execute ทีละคำสั่ง
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)
);
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 = ลบแถวที่มีคนอ้างอิงไม่ได้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 คน — ใช้ทดสอบสถานะทันที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
git add .
git commit -m "lab pos-flow: เพิ่มตาราง dining_table bill bill_item"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 เพราะยังไม่ได้สั่งอะไรเลย
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);
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);
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;
SELECT SUM(qty * unit_price) AS total FROM bill_item WHERE bill_id = 1;
677.0
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 ต้องกลับมา ว่าง แล้ว — ลูกค้ารอบใหม่มานั่งได้
ลองทำสิ่งที่แคชเชียร์ผิดพลาดเป็นเรื่อง ๆ ได้ แล้วดูว่าฐานข้อมูลขัดขวางไหม:
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 ที่ยังใช้งานอยู่ซ้ำ
ขั้น 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
git commit -am "lab pos-flow: เดิน flow เปิดโต๊ะ-สั่ง-เช็คบิล ด้วย SQL ครบรอบ"Employee.java ที่พิมพ์มาแล้วทุกประการ:
field + constructor + getters/setters + fromRS ของใหม่มี 2 อย่าง: สถานะเป็นค่าคงที่ภาษาไทย (ตรงกับ CHECK ใน Step 1) และ
BillItem คำนวณยอดของตัวเองได้ (qty × unit_price เป็นความรู้ของแถวนั้น)modelcom.example.moogata.model มี Employee.java อยู่แล้วจาก Lab 8 — ไม่ต้องสร้างใหม่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 — ตามขั้นถัดไป)
}
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;
}
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;
}
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;
}
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;
}
src/main/java/com/example/moogata/model/mvn compile ผ่านทุกไฟล์ — ถ้าไม่ผ่าน อ่าน error บรรทัดแรก: มักเป็นชื่อ getter/setter ไม่ตรงกับที่ fromRS เรียกgit commit -am "lab pos-flow: model MenuItem DiningTable Bill BillItem"Dao<T> เหมือน EmployeeDao — โครง (เปิด connection,
try/catch/finally ปิดทรัพยากร) ลอกจาก EmployeeDao ได้ทั้งเมธอด สิ่งที่ต่างมีแค่ "SQL + ค่าที่ set + สิ่งที่ return"
วิธีพิมพ์ที่เร็วและไม่หลง: เปิด EmployeeDao วางไว้ข้าง ๆ เป็นต้นแบบ แล้วพิมพ์ไฟล์ใหม่เปลี่ยนเฉพาะส่วน SQL — method ธงทุกตัวคือ SQL ที่คุณพิมพ์เองใน Step 2daoคลิกขวา package com.example.moogata.dao (มี Dao.java + EmployeeDao.java + UserDao.java อยู่แล้ว)
→ New → Java Class ชื่อ MenuItemDao — ทำซ้ำอีก 3 ไฟล์: DiningTableDao, BillDao, BillItemDao
method ธงทั้งหมด (ตรงกับ SQL ใน Step 2 ทุกตัว — ทำหน้าที่อะไรดูจากคอลัมน์ขวา):
| DAO | method ธง | คือคำสั่ง SQL ขั้นไหนของ Step 2 |
|---|---|---|
MenuItemDao | getAll() เรียงตามหมวด | ตารางเมนูหน้าจอสั่งอาหาร |
DiningTableDao | updateStatus(tableId, status, headcount) | ขั้น 2.1 / 2.6 (UPDATE dining_table) |
BillDao | save() · getOpenByTableId() · close() | ขั้น 2.1 (INSERT bill) · หาบิลของโต๊ะ · ขั้น 2.6 (UPDATE bill) |
BillItemDao | save() · findByBillId() | ขั้น 2.2–2.3 (INSERT) · ขั้น 2.4 (SELECT) |
หัวคลาส + 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.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);
}
}
BillDao.save ตั้งใจ INSERT แค่ 3 ค่า (table_id, status, opened_by)
ตามขั้น 2.1 — ยอดเงิน/เวลาปิดไม่มีสิทธิ์ใส่ตอนเปิดบิล (2) closed_at ไม่ใช้ ? แต่ให้ SQLite ใส่เวลาเองใน SQL
จึงเหมือนบรรทัด UPDATE ที่คุณพิมพ์เองในขั้น 2.6 เป๊ะ// 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/
คลิกขวา 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 + ")"));
}
ส่วนที่ 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() + " คน");
ส่วนที่ 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 รายการ
git commit -am "lab pos-flow: dao 4 ตัว + TestPosDao"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(); // เช็คบิลต้องคืนโต๊ะด้วย
...
}
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 ให้)/** เมนูพร้อมขาย (สถานะ 'มี' เท่านั้น) — กรองด้วยลูปใน 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;
}
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;
}
/** สั่งอาหาร 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;
}
สองตัวนี้ copy ทั้งไฟล์ จาก src/.../TestTableService.java และ TestBillService.java
(คลิกขวา package com.example.moogata → New → Java Class ชื่อเดียวกัน → วางทั้งไฟล์) —
ต่างจาก TestPosDao ที่พิมพ์เอง เพราะเพิ่มขั้น "ล้างข้อมูลและประกอบสถานการณ์ของตัวเอง" ที่ยาวกว่า
(ลบบิล + รีเซ็ตตัวนับ id + คืนโต๊ะก่อนรันทุกครั้ง) — แต่โครงเดียวกันทุกอย่าง อ่านประกอบตอน copy:
check(...) แม่แบบเดียวกับ TestPosDao — ค่าคาดหวังคำนวณไว้ก่อน (ยอด 677.00 · ทอน 23.00)
(4) สรุปผ่านกี่ข้อ + ปิด connection ใน finallyTestTableService — ผลที่ต้องเห็น:
[ตั้งต้น] ล้าง 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 และ รันซ้ำกี่รอบก็ได้ผลเดิม (ทุกตัวล้างข้อมูลของตัวเองก่อน)
git commit -am "lab pos-flow: service กฎธุรกิจ + Test ผ่านครบ"| สถานการณ์ | ทำอย่างไร |
|---|---|
| ทำ 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 ของเส้นทางหลัก) |
TestBillService —
ต้องได้ 7/7 PASS ถึงเริ่มหน้าจอได้ · เลือกทางลัด starter ก็เลื่อนไปอ่าน "โค้ด" ของ Step 7 / 9 / 11 ได้ทันที เพราะ TODO ในไฟล์อ้างอิงโค้ดเดียวกัน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);
}
หน้าจอที่คุณมีอยู่จาก Lab 11 ต้องตั้งชื่อ control ตรงกับตารางนี้ (โค้ดของทั้ง lab อ้างชื่อพวกนี้) — ภาพจากแอปจริงมีเลขกำกับ = เลขแถว:
lblHeader ②lblLogin ③txtLogin ④lblPassword ⑤txtPassword
(Password Field — พิมพ์แล้วเห็นจุดดำ) ⑥btnLogin ⑦btnExit| # | Control | text | Variable Name | เพิ่มเติม |
|---|---|---|---|---|
| 1 | Label | ระบบ POS BONUS SUKI | lblHeader | font: Leelawadee UI, Bold, 18 |
| 2 | Label | ชื่อเข้าใช้: | lblLogin | |
| 3 | Text Field | — | txtLogin | |
| 4 | Label | รหัสผ่าน: | lblPassword | |
| 5 | Password Field | — | txtPassword | ใช้ Password Field ไม่ใช่ Text Field |
| 6 | Button | เข้าสู่ระบบ | btnLogin | |
| 7 | Button | ออก | 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) ต้องเปิดโต๊ะ/สั่ง/เช็คบิลได้เอง
จึงไม่กันสิทธิ์แบบหน้าจัดการพนักงานPosFrame (ผังโต๊ะ)ui → New → JFrame Form (หมวด Swing GUI Forms) ชื่อ PosFrame
lblHeader ②lblUser ③–⑧btnTable1–btnTable6 ปุ่มละ 1 โต๊ะ จัด 3 ปุ่มต่อแถว 2 แถว
⑨lblSummary ⑩btnRefresh — ของคุณตอนออกแบบ ปุ่มโต๊ะยังไม่มีข้อความ/สี ก็ถูกแล้ว เพราะ refreshTables() ใส่ให้ตอนรัน (Step 7)| # | Control | text | Variable Name | เพิ่มเติม |
|---|---|---|---|---|
| 1 | Label | ผังโต๊ะร้านหมูกระทะ | lblHeader | font: Leelawadee UI, Bold, 20 |
| 2 | Label | — | lblUser | ข้อความ "พนักงาน: …" โค้ดใส่ให้ (ขั้น 7.1) — ไม่พิมพ์ใน designer |
| 3 | Button | — | btnTable1 | ขนาดราว 230 × 140 จัด 3 ปุ่มต่อแถว 2 แถว — ไม่ต้องพิมพ์ text (โค้ดใส่ "โต๊ะ N + สถานะ + สี" ตอนรัน) · ชื่อต้องมีเลข 1–6 ต่อท้ายพอดี |
| 4 | Button | — | btnTable2 | |
| 5 | Button | — | btnTable3 | |
| 6 | Button | — | btnTable4 | |
| 7 | Button | — | btnTable5 | |
| 8 | Button | — | btnTable6 | |
| 9 | Label | — | lblSummary | font: Leelawadee UI, Bold, 16 (ข้อความสรุปโค้ดใส่) |
| 10 | Button | รีเฟรช | btnRefresh |
ตั้งค่าฟอร์ม (คลิกพื้นหลัง → Properties): title ผังโต๊ะ — BONUS SUKI POS ·
defaultCloseOperation EXIT_ON_CLOSE (ปิดผังโต๊ะ = จบโปรแกรม) ·
resizable ออก · ขนาด: ลากขอบฟอร์มใน designer ให้กว้างพอสำหรับ 3 ปุ่มต่อแถว ราว ~870 × 540 (พิมพ์ตัวเลขใน Properties ไม่มีผล)
refreshTables() เพื่อวนสีตามข้อมูลโต๊ะ
ได้ความสะดวกของลูป + ได้ชื่อชัด ๆ ใน designer ดีทั้งสองอย่างPosFrame โดยตรงได้ (ยังกดอะไรไม่เกิดอะไร — ปกติ)git commit -am "lab pos-flow: ThaiFont + ล็อกอินรับแคชเชียร์ + ออกแบบผังโต๊ะ"refreshTables()
อ่านโต๊ะจากฐานข้อมูลแล้วระบายสี/ข้อความ 6 ปุ่มทุกครั้ง (2) tableClicked(n) แยกทางตามสถานะโต๊ะ:
ว่าง = เปิดโต๊ะ · ใช้งาน = เข้าหน้าสั่ง · รอชำระ = เตือน — สังเกตว่า หน้าจอไม่มีกฎธุรกิจแม้แต่บรรทัดเดียว กฎอยู่ที่ service ที่ทำไปแล้วใน Step 5private void btnTable1ActionPerformed(...) { ... } พร้อมปีกกาเปิด-ปิดให้แล้ว
(ข้างในมีบรรทัด // TODO add your handling code here: รออยู่)
— โค้ดใน lab แสดง "เมธอดเต็ม" เพื่อให้เห็นภาพรวมเท่านั้น ตอนวางจริงให้คัดลอก เฉพาะบรรทัดเนื้อความ มาแทนบรรทัด TODO
ถ้าวางทั้งก้อนรวมหัวเมธอด = เมธอดซ้อนเมธอด → compile ไม่ผ่านทันทีเพิ่ม 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));
}
เพิ่มเมธอดนี้ท้ายคลาส (ก่อนปิดคลาส) — จุดสำคัญคือบรรทัดแรก: จับปุ่ม 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 + " โต๊ะ");
}
เพิ่มเมธอดรวมตรรกะต่อท้ายคลาส — เขียนครั้งเดียว แล้ว 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 — สลับหน้าจอใหม่ทั้งหมดก็ยังได้กฎชุดเดิมกลับไปแท็บ Design ดับเบิลคลิกปุ่มในตารางตามลำดับ แล้ววาง "เนื้อใน" ตามคอลัมน์ขวา:
| ดับเบิลคลิกปุ่ม | เนื้อใน handler ที่ต้องวาง |
|---|---|
btnTable1 | tableClicked(0); |
btnTable2 | tableClicked(1); |
btnTable3 | tableClicked(2); |
btnTable4 | tableClicked(3); |
btnTable5 | tableClicked(4); |
btnTable6 | tableClicked(5); |
btnRefresh | refreshTables(); |
btnTable4 ต้องเรียก tableClicked(3) (เริ่มนับ 0) —
ถ้าเลขเพี้ยน กดโต๊ะ 4 แล้วจะเปิดโต๊ะอื่น! ตรวจง่าย ๆ: รันแล้วชี้ปุ่มไหนข้อความใน dialog ต้องเป็นเลขโต๊ะนั้นพอดีรัน LoginFrame (Shift+F6) → ล็อกอิน somchai / 1234 (แคชเชียร์ — ครั้งแรกที่เข้า POS ได้!) → ผังโต๊ะต้องขึ้นเหมือนภาพ ①–⑩ ใน Step 6 · แล้วลอง 3 กรณี:
TableService.openTable ทำให้ (INSERT bill + UPDATE dining_table) เทียบขั้น 2.1 ที่พิมพ์เองได้
git commit -am "lab pos-flow: ผังโต๊ะเปิดโต๊ะได้จริง"OrderFrameJTable มีกฎพิเศษ: ต้องอยู่ใน JScrollPane เสมอ ไม่งั้นหัวคอลัมน์หาย (NetBeans จัดให้เอง แต่เราต้องตั้งชื่อ scrollpane ด้วย)ui → New → JFrame Form ชื่อ OrderFramemain() ทั้งเมธอดทิ้ง — หน้าจอนี้เปิดจาก PosFrame เสมอ ไม่มีสิทธิ์รันเอง
(constructor ต้องรับค่า 4 ตัว ถ้า main ของ template ยังอยู่ จะเจอ error
constructor OrderFrame in class OrderFrame cannot be applied to given types ทันที)
lblHeader ②tblMenu ตารางเมนูซ้าย ③lblQty ④txtQty ⑤btnAdd
⑥tblBill ตารางบิลขวา ⑦lblTotalTitle ⑧lblTotal ⑨btnCheckout ⑩btnBack
— ภาพจากแอปจริงตอนเปิดบิลใหม่ (ยอดรวม 0.00) · คอลัมน์ของตารางไม่ต้องตั้งใน designer โค้ดแทน model ให้ (Step 9)| # | Control | text | Variable Name | เพิ่มเติม |
|---|---|---|---|---|
| 1 | Label | — | lblHeader | font: Leelawadee UI, Bold, 20 (ข้อความ "โต๊ะ 4 · 2 คน · บิล #1" โค้ดใส่ — มีข้อมูลจริงปน) |
| 2 | JTable | — | tblMenu | ลากมาแล้ว NetBeans ครอบด้วย JScrollPane ให้เอง — เปลี่ยนชื่อ scrollpane เป็น scrMenu · วางฝั่งซ้าย ราว 380 × 380 |
| 3 | Label | จำนวน: | lblQty | ใต้ตารางเมนู |
| 4 | Text Field | 1 | txtQty | ค่าเริ่มต้น 1 — คนละครั้งส่วนใหญ่สั่ง 1 อย่าง |
| 5 | Button | เพิ่มรายการ | btnAdd | |
| 6 | JTable | — | tblBill | scrollpane ตั้งชื่อ scrBill · วางฝั่งขวา ราว 380 × 300 |
| 7 | Label | ยอดรวม | lblTotalTitle | font: Leelawadee UI, Bold, 18 |
| 8 | Label | — | lblTotal | font: Leelawadee UI, Bold, 32 — ยอดเงินโค้ดใส่ (ไม่พิมพ์ใน designer) |
| 9 | Button | เช็คบิล | btnCheckout | font: Leelawadee UI, Bold, 16 |
| 10 | Button | กลับหน้าโต๊ะ | btnBack |
tblMenu + scrMenu)ตั้งค่าฟอร์ม: title ไม่ต้องตั้ง (โค้ดใส่เอง — มีเลขโต๊ะปน) · defaultCloseOperation DISPOSE_ON_CLOSE
(ปิดเฉพาะหน้าสั่งอาหาร ไม่ใช่จบโปรแกรม — ต่างจาก PosFrame) · resizable ออก · ขนาดราว ~850 × 560
scrMenu/scrBill · ลบ main แล้ว (กด Build ไม่มี error เรื่อง 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);
}
เพิ่มต่อท้ายคลาส — สังเกตว่าทั้งคู่ "ล้างแล้วเติมใหม่จาก 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 ที่รวมจากรายการจริงทุกครั้ง — หน้าจอไม่เคย "จำ" ยอดเอง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 พอดีรัน LoginFrame → ล็อกอิน somchai / 1234 → กดโต๊ะ 4 (ที่เปิดไว้ใน Step 7) → หน้าสั่งอาหารต้องเหมือนภาพ ①–⑩ ใน Step 8 (ยอดรวม 0.00) → ลองสั่ง 2 รอบ:
refreshBill() อ่านกลับมา
git commit -am "lab pos-flow: หน้าสั่งอาหาร บันทึกทุกรอบ"CheckoutDialogsaved = true แล้วหน้าเรียกถาม isSaved()
— หน้านี้ออกแบบสั้น ๆ 9 ป้าย/ช่อง/ปุ่ม แต่มีกลศจรรย์อยู่ที่โค้ด: พิมพ์เงินปุ๊บ คิดทอนปั๊บui → New → JDialog Form (หมวด Swing GUI Forms) ชื่อ CheckoutDialogpublic CheckoutDialog(java.awt.Frame parent, boolean modal) { ... } ทั้งเมธอด
(2) เมธอด main() ทั้งเมธอด — เราจะเขียน constructor ใหม่ที่รับ Bill เอง (Step 11)
· ถ้าลืมลบ constructor เดิมแล้วสร้างใหม่ชนกัน จะเจอ error
constructor CheckoutDialog(...) is already defined ให้กลับมาลบตรงนี้
lblHeader ②lblTotalTitle ③lblTotal ④lblCash ⑤txtCash
⑥lblChangeTitle ⑦lblChange ⑧btnConfirm ⑨btnCancel
— ภาพจากแอปจริงตอนพิมพ์ 700 (ทอน 23.00)| # | Control | text | Variable Name | เพิ่มเติม |
|---|---|---|---|---|
| 1 | Label | เช็คบิล โต๊ะที่ 4 | lblHeader | font: Leelawadee UI, Bold, 18 — พิมพ์คำว่า "โต๊ะที่ 4" เป็นตัวอย่างไว้ก่อน โค้ดจะเขียนทับด้วยเลขโต๊ะจริง |
| 2 | Label | ยอดรวมทั้งบิล | lblTotalTitle | font: Leelawadee UI, Plain, 16 |
| 3 | Label | — | lblTotal | font: Leelawadee UI, Bold, 40 — ยอดเงินโค้ดใส่ |
| 4 | Label | รับเงิน (บาท): | lblCash | |
| 5 | Text Field | — | txtCash | font: Leelawadee UI, Plain, 18 (ช่องพิมพ์เงินใหญ่หน่อย) |
| 6 | Label | เงินทอน | lblChangeTitle | font: Leelawadee UI, Plain, 16 |
| 7 | Label | - | lblChange | font: Leelawadee UI, Bold, 34 — ขีดล่างเป็นค่าเริ่ม (ยังไม่พิมพ์เงิน) |
| 8 | Button | ยืนยันรับเงิน ปิดบิล | btnConfirm | font: Leelawadee UI, Bold, 16 |
| 9 | Button | ยกเลิก | btnCancel |
ตั้งค่าฟอร์ม: resizable ออก · ขนาดราว ~460 × 360 · ส่วน title กับความเป็น modal ใส่ในโค้ด constructor (มีเลขโต๊ะปน + ต้อง modal = true)
DocumentListener เรียก updateChange()
ทุกครั้งที่พิมพ์ (พิมพ์ปุ๊บ คิดทอนปั๊บ), ปุ่มยืนยันล็อกไว้จนเงินพอ และ "ปิดบิลจริง" ยังคงเป็น BillService.checkout คนเดียว
จบหน้านี้ด้วยการทดสอบครบ 7 สถานการณ์เพิ่ม 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;
}
super(owner, ..., true)
คือการบอก JDialog ว่าเป็น modal ของหน้าไหน หัวข้ออะไร (2) DocumentListener ไม่ใช่ listener ของปุ่ม แต่ของ
"เนื้อความช่องกรอก" — อักษรทุกตัวที่เพิ่ม/ลบ เรียก updateChange() (3) isSaved()
คือสัญญากับหน้าเรียก: ปิดไปแบบไหน ยืนยันจริงหรือยกเลิกเพิ่มเมธอดต่อท้ายคลาส — กดยืนยันได้ "เฉพาะเงินพอ" อย่างอื่นล้วนกลับไปเป็นขีดล่าง + ปุ่มเทา:
/** คิดเงินทอนสด ๆ จากช่องกรอก — ทศนิยม 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);
}
billService.checkout — UI ช่วยให้ถูกทาง ไม่ใช่กำแพงกัน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(); // ยกเลิก — บิลยังเปิดอยู่ ลูกค้ายังสั่งเพิ่มได้
เปิด 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();
}
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 คำสั่งด้วยมือ)
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 ไม่ถูกถาม หรือลืม refreshTables | Step 11 ขั้น 11.4 (owner.refreshTables()) |
| ข้อ 7 — ปิดโปรแกรมแล้วรายการหาย | ยังไม่บันทึกทุกรอบ (เผลอทำใน memory) | Step 9 ขั้น 9.3 (addItem ทุกครั้ง) + TestBillService ข้อ 2–3 |
git commit -am "lab pos-flow: เช็คบิลครบ flow ผ่านทุกสถานการณ์"| # | อาการ | สาเหตุ / วิธีแก้ |
|---|---|---|
| 1 | no such table: dining_table | ยังไม่ได้รัน schema ขั้น 1 หรือรันกับ db ผิดไฟล์ — รัน schema-pos.sql กับ moogata.db ของโปรเจกต์ (ตรวจ path) |
| 2 | ข้อมูล test / ผังโต๊ะเพี้ยนจากการทดลองครั้งก่อน | ข้อมูลมีสถานะค้าง (เปิดโต๊ะค้าง, บิลค้าง) — รัน reset-pos.sql ใน Letos กลับสู่สถานะเพิ่งเปิดร้านทันที |
| 3 | UNIQUE constraint failed ตอน seed โต๊ะ | รัน INSERT ซ้ำโดยไม่มี OR IGNORE — ใช้ INSERT OR IGNORE ตามเอกสาร รันซ้ำปลอดภัย |
| 4 | FOREIGN KEY constraint failed ตอนล้างข้อมูล | ลบแม่ก่อนลูก — ลบลูกก่อนแม่เสมอ: bill_item → bill |
| 5 | database 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 |
| 8 | cannot find symbol: variable txtQty (หรือชื่อ control อื่น) | ชื่อตัวแปรในโค้ดไม่ตรงกับ designer — แก้ที่ designer คลิกขวา control → Change Variable Name… ให้ตรงตาราง control เป๊ะ ๆ (รวมถึงตัวพิมพ์เล็ก-ใหญ่) |
| 9 | constructor OrderFrame in class OrderFrame cannot be applied to given types | main() ของ template ยังอยู่ (เรียก new OrderFrame() ไม่มี arg) — ลบ main ทิ้งตาม Step 8 ขั้น 8.1 |
| 10 | constructor 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) |
| 15 | test FAIL ข้อที่เคยผ่าน | อ่านวงเล็บท้ายบรรทัด (บอก actual) — test ล้างข้อมูลเองก่อนรันทุกครั้ง ถ้ายัง FAIL แปลว่า service จริง ๆ กลับไปดู Step 5 |
git log --oneline)| ระดับ | เรื่อง | โจทย์ |
|---|---|---|
| ง่าย | รายงานบิลที่ปิดแล้ว | 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 นี้ด้วย: ระบบที่ล็อกอินและจัดการพนักงานได้ — แต่ร้านยังไม่ได้ขายอะไรเลย
สิ่งที่เกิดขึ้นตลอด 11 Step: เพิ่ม 3 ตาราง (โต๊ะ บิล รายการในบิล) ที่มีสถานะและ snapshot · เดิน flow ครบรอบด้วย SQL ล้วนก่อนเขียนโค้ด · ห่อ SQL เป็น model/DAO/service โดยกฎธุรกิจ (Hard Lock, เงินไม่พอ, ปิดบิลคืนโต๊ะ) อยู่ชั้น service จุดเดียว · แล้วออกแบบ+โค้ดหน้าจอ 3 หน้าเองบนกฎเดิม — ตลอดทางของเดิม (EmployeeDao, DatabaseHelper, moogata.db เดิม) ไม่ถูกแก้แม้แต่บรรทัดเดียว
บทเรียนที่ควรติดตัวออกจาก lab:
เรื่องต่อไป: สมาชิก + แต้ม (SC-2) · โปรโมชันผูกจำนวนคน (SC-3) — โครงเต็มดูได้ที่
_bmad-output/planning-artifacts/dev-docs/10-er-diagram.md (23 ตาราง) ซึ่งโต๊ะ/บิล/รายการในบิลที่เราสร้างวันนี้คือ 3 ตารางแรกของมันพอดี
labs/pos-flow/README.mdlabs/pos-flow/README-part1-data.md · Part 2 (หน้าจอ ละเอียด) — labs/pos-flow/README-part2-ui.mdteaching-domain-moogata.md · แผนที่คอร์ส — teaching-sequence.md_bmad-output/planning-artifacts/dev-docs/10-er-diagram.md