API จุดขาย POS (/api/v1/pos)
กลุ่ม endpoint ของระบบขายหน้าร้าน (Flutter POS บนเครื่องแคชเชียร์ + หน้าตั้งค่า POS บนเว็บ) ลงทะเบียนที่ router.go:329-349 ใต้ r.Route("/api/v1/pos") พร้อม r.Use(ex.Pos.Auth(accessTokenVerifier)) ตัวเดียวกันทั้งกลุ่ม แล้วแยกสองกลุ่มย่อยด้วย DeviceOnly / UserOnly. ภาพรวมการทำงานเชิงเอกสาร/ซิงก์อยู่ที่ POS Sync API: เครื่อง POS เชื่อมกับ Account (POS device sync), POS ซิงก์ข้อมูลเครื่องแคชเชียร์ ผูกเครื่อง/ดึงส่วนต่าง/กู้คืน/รูปภาพ (POS Device Sync), POS รับบิลขายจากเครื่องแคชเชียร์ กล่องรับบิล/ประมวลผล/ยกเลิก/ตัดสต็อกเป็นชุด (POS Bill Inbox), POS ตั้งค่าร้าน/เครื่อง/โปรโมชัน และกะขาย รับ-ส่งเงิน (POS Config and Shift); ภาพรวม API ทั้งระบบที่ ภาพรวมแค็ตตาล็อก API (API overview — วงจรคำขอ middleware รูปแบบ response และดัชนี).
ตารางเส้นทาง (11 คำสั่ง)
| method path | กลุ่ม | handler | ใช้ทำอะไร |
|---|---|---|---|
POST /shift |
ทั้งสองแบบ | ShiftAction handler.go:264 |
คำสั่งกะขาย (รับเงินทอน เปิด/ปิดกะ ยกเลิกบิล สมาชิกชั่วคราว) |
GET /image?e=&k=&v= |
ทั้งสองแบบ | Image media.go:25 |
รูปย่อสินค้า/สมาชิก |
GET /sync |
DeviceOnly + gzip | Sync sync.go:93 |
ดึงข้อมูลเปลี่ยนแปลงลงเครื่อง (สินค้า สมาชิก โปรโมชัน คูปอง) |
GET /restore |
DeviceOnly + gzip | Restore media.go:103 |
กู้สถานะเครื่อง (กะที่เปิดอยู่ เงินเข้า-ออก บิล) |
POST /bills |
DeviceOnly | PostBill sync.go:111 |
ส่งบิลขายจากเครื่องขึ้นเซิร์ฟเวอร์ (idempotent) |
POST /media?kind=slip&ref= |
DeviceOnly | PostMedia media.go:61 |
ส่งภาพสลิปของบิล |
GET /bootstrap |
UserOnly | Bootstrap handler.go:60 |
โหลดข้อมูลหน้าตั้งค่า POS ทั้งหมดในครั้งเดียว |
POST /config |
UserOnly | SaveConfig handler.go:244 |
บันทึกการตั้งค่าตามหมวด |
GET /devices |
UserOnly | ListDevices sync.go:140 |
รายการเครื่อง POS ที่เชื่อมแล้ว |
POST /devices/{machine}/pair |
UserOnly | PairDevice sync.go:129 |
ออก token ให้เครื่อง POS (เห็นครั้งเดียว) |
POST /bills/reprocess |
UserOnly | ReprocessBills sync.go:152 |
ประมวลผลบิลที่ค้าง/พลาดใหม่ |
สิทธิ์ (Auth)
Auth (sync.go:24-) รับ Authorization: Bearer <token> ได้สองชนิด:
| ชนิด token | รูปแบบ | ผลต่อ context |
|---|---|---|
| token เครื่อง POS ที่เชื่อมแล้ว | ขึ้นต้น smlpos1. (posTokenPrefix process/pos_sync.go:228) |
สร้าง claims ระดับ 0 ชื่อผู้ใช้ pos:<machine> และเก็บ PosDevice (provider, ฐานข้อมูล, รหัสเครื่อง) ใน context |
| access token ผู้ใช้ปกติ | JWT หลังเลือกบริษัท | - |
DeviceOnly(sync.go:71): ไม่ใช่เครื่อง/ไม่มี device ใน context = 403 "ใช้ได้เฉพาะเครื่อง POS ที่เชื่อมแล้ว".UserOnly(sync.go:81): ถ้าเป็นเครื่อง POS จริงจะ 403 (หน้าตั้งค่าเป็นของ back office เท่านั้น).- มีประเด็นด้านความปลอดภัยที่ทีมพัฒนาต้องตรวจ (ไม่เผยแพร่รายละเอียดบนเว็บสาธารณะ)
- header เสริมจากเครื่อง:
X-Pos-Pending-Bills(จำนวนบิลค้างส่ง),X-Pos-App-Version(ตัดที่ 40 ตัวอักษร) ใช้บันทึกสถานะเครื่องตอน sync (sync.go:96-102).
1. GET /sync — ดึงข้อมูลลงเครื่อง
- Query:
cursor(ว่าง = เริ่มใหม่),limit(ค่าเริ่มต้น 500, ถ้า <=0 หรือ >2000 ถูกปรับ; process/pos_sync.go:404). - Response 200:
{"ok":true,"data":{"cursor":"<opaque>","has_more":true,"server_time":"<UTC>","changes":[{"entity":"product","key":"<code>","rows":[...]}]}}(PosSyncPage). entityที่ส่ง:product,member,promotion,coupon(pos_sync.go:484-528). cursor เป็น base64url ของxid|entity|keyให้ client เรียกซ้ำจนhas_more=false.- ตัวอย่าง:
GET /api/v1/pos/sync?cursor=<cursor>&limit=500
Authorization: Bearer <posDeviceToken>
X-Pos-Pending-Bills: 0
X-Pos-App-Version: <appVersion>
2. POST /bills — ส่งบิลขึ้นเซิร์ฟเวอร์
- Body JSON (จำกัด 1 MB
MaxBytesReadersync.go:113): บิลมีbill_guid(UUID),doc_no(1-25 ตัวอักษร), รายการสินค้าอย่างน้อย 1 บรรทัด, การชำระเงิน ฯลฯ (parsePosBillpos_bill.go:105). ตัวเงินแปลงด้วยทศนิยมตรง ไม่ใช้ float. - Response 200:
{"ok":true,"data":{"bill_guid":"...","doc_no":"...","status":"done|cancelled|error","error":"","duplicate":false,"total_amount":"...","earned_points":...}}(ReceivePosBillpos_bill.go:123). - idempotent ด้วย
bill_guidผ่านตารางpos_bill_inbox— ส่งซ้ำได้ ผลจะมีduplicate:true. - Errors: 400 body/บิลผิดรูป (doc_no ว่าง/ยาวเกิน, ไม่มีรายการ); 409
doc_noซ้ำกับบิลอื่น หรือbill_guidเป็นของเครื่องอื่น; 403 ถ้าไม่ใช่เครื่อง. - ตัวอย่าง:
POST /api/v1/pos/bills
Authorization: Bearer <posDeviceToken>
{"bill_guid":"<uuid>","doc_no":"<docNo>","lines":[{"ic_code":"<itemCode>","qty":"1","price":"0.00"}]}
3. POST /media — ส่งภาพสลิป
- Query:
kind=slip(ชนิดอื่นถูกปฏิเสธโดยPosMediaImageID),ref=<doc_no>. Body = ไบต์ดิบ PNG หรือ JPEG ไม่เกิน 5 MB (posMediaMaxBytesmedia.go:20; อ่านด้วยMaxBytesReadermedia.go:71); ชนิดไฟล์ตรวจจากเนื้อไฟล์ด้วยhttp.DetectContentType(media.go:76). - เก็บใน MinIO ที่ key
<ชื่อฐานข้อมูล>/pos-<kind>/<hash>.<ext>(content-addressed จึงส่งซ้ำได้) พร้อม thumbnail แล้วบันทึก URL ที่ชี้ไป/api/crm/image?key=...(ให้ API โมดูลธุรกิจ /api/v1/{dashboard,crm,ai,wms,tms,procure,etax,market,autocare} (Business module endpoints) ส่วน CRMServeImageเป็นผู้ให้บริการรูป). - Response 200:
{"ok":true,"data":{"image_url":"/api/crm/image?key=..."}}. - Errors: บิลต้องอยู่ใน
pos_bill_inboxของเครื่องนี้มาก่อน มิฉะนั้น 409; ไม่มีรูป/เกิน 5 MB = 400 "ไม่มีรูป หรือรูปใหญ่เกิน 5 MB"; ไม่ใช่ PNG/JPEG = 400 "รองรับเฉพาะรูป PNG/JPEG"; เก็บ MinIO ไม่สำเร็จ = 500.
4. GET /image — รูปย่อ
- Query:
e=product|member,k=<code>(ยาวไม่เกิน 100 ตัวอักษร),v=<version>สำหรับ cache-bust. entity/key ผิด = 400. - Response: ไบต์ JPEG รูปย่อ พร้อม
Cache-Control: private, max-age=31536000, immutable(media.go:25-).
5. GET /restore — กู้สถานะเครื่อง
- Response 200:
{"ok":true,"data":PosRestore}— กะที่ยังเปิดอยู่จากpos_period, รายการเงินเข้า-ออก, บิลของเครื่อง (media.go:103-). บีบอัด gzip ระดับ 5 ที่ router.
6. GET /bootstrap — โหลดหน้าตั้งค่า
- เรียก
EnsurePosTables(สร้างตาราง POS ถ้ายังไม่มี handler.go:54) แล้วรัน SELECT 11 คำสั่งเป็น batch เดียว. - Response 200:
{"ok":true,"pos_config":...,"pos_machines":...,"qr_config":...,"point_condition":...,"promotions":...,"coupons":...,"member_groups":...,"products":[ล่าสุด 60],"settlements":[50],"recent_bills":[50],"temp_members":[50]}. - Errors: ถ้า batch ล้มเหลวตอบ 500
{"ok":false,"error":"<ข้อความ>"}. - ค่าเริ่มต้นของการตั้งค่า (เมื่อยังไม่เคยบันทึก) ฝังอยู่ในโค้ดของ handler นี้ ไม่คัดลอกมาที่นี่.
7. POST /config — บันทึกตั้งค่า
- Body JSON เป็น map (จำกัด 1 MB
decodeJSONMapUseNumberhandler.go:289; ตัวเลขอ่านเป็นjson.Numberไม่ผ่าน float). - ฟิลด์
sectionเลือกหมวด (process/pos_config.go:506-954):startup,machine/machines,qr,point,coupon,coupon_batch,promotion,promotion_toggle,promotion_delete. หมวดอื่น = 400 "ไม่รู้จักหมวดตั้งค่า POS". ค่าเงินผ่านposDec(ทศนิยมตรง). - Response 200:
{"ok":true,"section":"<section>"}.
POST /api/v1/pos/config
Authorization: Bearer <userAccessToken>
{"section":"promotion_toggle","id":"<promotionId>","active":true}
8. POST /shift — คำสั่งกะขาย
- Body JSON map (จำกัด 1 MB). ฟิลด์
action(ค่าเริ่มต้นเมื่อไม่ส่ง =receive_money;PosShiftActionprocess/pos_config.go:974-983):receive_money/open_shift(:1010),send_money/close_shift(:1059),cancel_bill(ต้องมีdoc_no→CancelPosBill),upload_temp_members(:1127),reconcile_temp_member(:1175). ไม่รู้จัก = 400 "ไม่รู้จักคำสั่งกะขาย". - รหัสเครื่อง: ถ้าเรียกด้วย token เครื่อง ระบบใช้รหัสเครื่องจาก token เสมอ.
cancel_billต้องมีdoc_no/docno(ว่าง = 400 "กรุณาระบุเลขที่บิล") และreasonเป็นทางเลือก. - Response 200: ผลลัพธ์ของคำสั่งนั้น (map) — รูปแบบขึ้นกับ action.
9. จัดการเครื่อง
GET /devices→{"ok":true,"data":[...]}(sync.go:140).POST /devices/{machine}/pair→{"ok":true,"data":{"token":"smlpos1...."}}— แสดง token ครั้งเดียว; เชื่อมซ้ำจะเพิกถอน token เดิม; รหัสเครื่องต้องตรง^[A-Za-z0-9_.-]{1,50}$และมีในpos_id(ไม่พบ = 404).POST /bills/reprocessbody{"all":false}(ไม่มี body ก็ได้). ประมวลผลบิลสถานะไม่สำเร็จ (หรือทั้งหมดเมื่อall=true) สูงสุด 5000 บิล (LIMIT 5000pos_bill.go:180). Response{"ok":true,"data":{"processed":N,"failed":M,"bills":[...]}}.
Errors ที่พบบ่อย
| status | เหตุ |
|---|---|
| 400 | JSON/body ผิดรูป, section/action ไม่รู้จัก, entity/key รูปผิด, ไฟล์ภาพเกิน 5 MB |
| 401 | ไม่มี token หรือ token หมดอายุ/ถูกเพิกถอน |
| 403 | เรียกเส้น DeviceOnly ด้วย token ที่ไม่ใช่เครื่อง หรือเส้น UserOnly ด้วย token เครื่อง |
| 404 | เครื่องไม่มีใน pos_id (pair), รูปไม่พบ |
| 409 | doc_no ซ้ำ, bill_guid ของเครื่องอื่น, บิลของ media ยังไม่อยู่ใน inbox |
| 500 | ข้อผิดพลาดภายใน |
ชั้น Next (BFF) — frontend/src/app/api/pos/[...path]/route.ts
GET/POSTส่งต่อไป/api/v1/pos/<path>; ปฏิเสธส่วนของ path ที่เป็น"",.,..ด้วย 404.- ส่งต่อเฉพาะ header
content-type,x-pos-pending-bills,x-pos-app-version. - ตอบ JSON เกิน 1 KB จะ gzip เมื่อ client ส่ง
accept-encoding; ล้มเหลวตอบfail(...,502). - ดู เส้นทาง API ฝั่ง Next.js ที่ส่งต่อไป Go backend (API proxy routes) สำหรับกติกา proxy ทั่วไป.
ผู้เรียกฝั่งหน้าเว็บ/แอป
- เว็บ:
frontend/account/pos/PosSetupAndControlScreen.tsxผ่านฟังก์ชันcallPos(:182) — bootstrap :491, config :865/896/921/949, shift :1062, devices :542, pair :1011, reprocess :1043. - แอป Flutter:
frontend/pos/lib/services/api_service.dart— sync :84, bills :97, shift :105, media ~:111, restore :121.
ข้อสังเกต
มีประเด็นด้านความปลอดภัยที่ทีมพัฒนาต้องตรวจ (ไม่เผยแพร่รายละเอียดบนเว็บสาธารณะ)
ที่มาในโค้ด
- backend/internal/adapter/inbound/http/router.go:329-349 (เส้นทาง + Compress(5))
- backend/internal/adapter/inbound/http/pos/sync.go:24 (Auth), :71 (DeviceOnly), :81 (UserOnly), :93 (Sync), :111-113 (PostBill, 1 MB), :129 (PairDevice), :140 (ListDevices), :152 (ReprocessBills)
- backend/internal/adapter/inbound/http/pos/handler.go:49-58 (resolveClaims, EnsurePosTables), :60 (Bootstrap), :244 (SaveConfig), :264 (ShiftAction), :289 (decodeJSONMapUseNumber 1 MB)
- backend/internal/adapter/inbound/http/pos/media.go:20 (posMediaMaxBytes), :25 (Image), :61 (PostMedia), :71 (MaxBytesReader), :76 (DetectContentType), :103 (Restore)
- backend/internal/application/process/pos_sync.go:228 (posTokenPrefix), :399-404 (PosSyncPull, limit), :484-528 (entities)
- backend/internal/application/process/pos_bill.go:105 (parsePosBill), :123 (ReceivePosBill), :175-180 (ReprocessPosBills, LIMIT 5000)
- backend/internal/application/process/pos_config.go:506-954 (SavePosConfig), :974 (PosShiftAction), :1010, :1059, :1127, :1175 (แต่ละ action)
- frontend/src/app/api/pos/[...path]/route.ts; frontend/account/pos/PosSetupAndControlScreen.tsx:182,491,542,865,896,921,949,1011,1043,1062; frontend/pos/lib/services/api_service.dart:84,97,105,121