POS ซิงก์ข้อมูลเครื่องแคชเชียร์ (POS Device Sync)
กลุ่ม route /api/v1/pos ให้เครื่อง POS (แอป Flutter) ดึงข้อมูลสินค้า/สมาชิก/โปรโมชัน/คูปอง/ตั้งค่าแบบ "ส่วนต่าง" (delta) จากฐานบริษัท โดยไม่ต้องเรียก API ทีละตาราง ฝั่ง back office ใช้ route คู่กันสำหรับผูกเครื่อง ดูสถานะ และสั่งประมวลผลบิลซ้ำ การรับบิลอยู่ที่ POS รับบิลขายจากเครื่องแคชเชียร์ กล่องรับบิล/ประมวลผล/ยกเลิก/ตัดสต็อกเป็นชุด (POS Bill Inbox) การตั้งค่า/กะขายอยู่ที่ POS ตั้งค่าร้าน/เครื่อง/โปรโมชัน และกะขาย รับ-ส่งเงิน (POS Config and Shift)
ทำงานอย่างไร
เส้นทางและผู้เรียก (router.go:329-348)
| route | กลุ่ม | หน้าที่ |
|---|---|---|
GET /api/v1/pos/sync |
DeviceOnly (บีบอัด Compress 5) | ดึงส่วนต่างหลัง cursor |
GET /api/v1/pos/restore |
DeviceOnly (Compress 5) | กู้กะ/บิลของเครื่องที่ล้างเครื่อง |
POST /api/v1/pos/bills, POST /api/v1/pos/media |
DeviceOnly | ส่งบิล / อัปโหลดรูปสลิป ดู POS รับบิลขายจากเครื่องแคชเชียร์ กล่องรับบิล/ประมวลผล/ยกเลิก/ตัดสต็อกเป็นชุด (POS Bill Inbox) |
GET /api/v1/pos/devices, POST /api/v1/pos/devices/{machine}/pair |
UserOnly | สถานะเครื่อง / ผูกเครื่อง |
POST /api/v1/pos/bills/reprocess |
UserOnly | ประมวลผลบิลซ้ำ ดู POS รับบิลขายจากเครื่องแคชเชียร์ กล่องรับบิล/ประมวลผล/ยกเลิก/ตัดสต็อกเป็นชุด (POS Bill Inbox) |
GET /api/v1/pos/image |
ต้องผ่าน Auth เท่านั้น | รูปย่อสินค้า/สมาชิก |
Auth(pos/sync.go:24) รับได้ 2 แบบ: โทเคนเครื่อง POS (ขึ้นต้นsmlpos1.) หรือ access token ผู้ใช้ปกติ เครื่องจะได้ claims ที่Username = "pos:" + MachineCodeและผูกกับฐานบริษัทของเครื่องเองDeviceOnly(:71) ผ่านถ้ามีเครื่องจริง หรือ เซสชันเว็บที่มีDatabaseNameใน claims -- กรณีหลังจะแสดงตัวเป็นเครื่องPOS-01(deviceFromContext:54-66) สำหรับ POS บนเบราว์เซอร์ (/pos)UserOnly(:81) ปฏิเสธเครื่อง POS จริง (403) ปล่อยแต่ผู้ใช้เว็บ- ไม่ผ่าน
permission.go/routeRightsของกลุ่ม/process-- กลุ่มนี้ใช้ middleware ของตัวเอง (ยังไม่ยืนยันว่ามีตรวจสิทธิ์ผู้ใช้ระดับเมนูเพิ่มหรือไม่)
โทเคนเครื่องและการผูกเครื่อง
- รูปแบบ:
smlpos1.+ base64url ของ JSON{p: provider, d: database, m: machine, s: secret}(posToken:237,posTokenPrefix:228) ฝั่ง DB เก็บแค่ SHA-256 ของ secret (32 ไบต์สุ่ม) ในpos_sync_machine.token_hash(PairPosDevice:249-283) ห้ามบันทึกค่าโทเคนจริงในเอกสารหรือ log PairPosDevice: รหัสเครื่องต้องตรง^[A-Za-z0-9_.-]{1,50}$; ต้องมีเครื่องในตารางpos_idก่อน (ไม่พบ = 404 "ไม่พบเครื่อง POS ... ในเมนูกำหนดเครื่อง POS"); ตรวจdoc_formatไม่ซ้ำเครื่องอื่น (checkPosDocFormatUnique:293) แล้ว upsertpos_sync_machine-- ผูกซ้ำจะเปลี่ยนโทเคนและล้างlast_cursorโทเคนเดิมใช้ไม่ได้ทันที โทเคนส่งกลับครั้งเดียว (pos/sync.go:127-128)checkPosDocFormatUnique: รูปแบบเลขที่บิลต้องมี#(ตัวเลขลำดับ) และห้ามซ้ำ (ไม่สนตัวพิมพ์) กับpos_idเครื่องอื่น เพราะเครื่อง offline ออกเลขบิลเอง ถ้าซ้ำจะชน (คอมเมนต์ :283-284) ฟังก์ชันนี้ถูกเรียกตอนบันทึกเครื่องด้วย (pos_config.go:593)VerifyPosDevice(:310): แกะโทเคน ตรวจรูปแบบp/d/mและ secret 32 ไบต์ แล้วเทียบแฮชด้วยsubtle.ConstantTimeCompareผิดทุกกรณีตอบข้อความเดียวกัน (401 "รหัสเชื่อมเครื่อง POS ไม่ถูกต้องหรือถูกยกเลิก")
ระบบ "ส่วนต่าง" (pos_sync.go:24-28)
- ตารางต้นทางมี trigger
pos_sync_log(ฟังก์ชัน :77) ที่ INSERT/UPDATE/DELETE แล้วบันทึกว่า "คีย์นี้เปลี่ยน" ลงpos_sync_change(entity, entity_key, xid xid8, changed_at)หนึ่งแถวต่อคีย์ (upsert ตีตราxidใหม่ด้วยpg_current_xact_id()) -- การแก้จากโปรแกรม SML เดสก์ท็อปถูกจับด้วยวิธีเดียวกัน - ตารางต้นทาง -> entity (
posSyncSources:118):ic_inventory/ic_inventory_barcode/ic_unit_use/images->product;ar_customer->member;pos_promotions->promotion;coupon_list->coupon;posconfig/pos_id/erp_thai_qr_config/pos_point_condition->config(คีย์*) ic_inventory/ar_customerผูก trigger เฉพาะ column ที่เครื่องใช้ (ไม่รวมbalance_qty/average_cost) เพื่อให้การคำนวณสต็อกหลังขายไม่ทำให้ทุกเครื่องโหลดสินค้าซ้ำ (คอมเมนต์ :115-117)images: ฟังก์ชันpos_sync_log_image(:99) แยกimage_idขึ้นต้นCONTRACT= รูปสมาชิก (member) นอกนั้น = รูปสินค้า (product)ensurePosSync(:149) ติดตั้ง schema + trigger ครั้งเดียวต่อฐานต่อโปรเซส (s.posSchema) และ backfill ทุกแถวเดิมเข้าpos_sync_changeหนึ่งครั้ง (posSyncBackfill:139) เพื่อให้เครื่องใหม่ (cursor ว่าง) ได้แคตตาล็อกเต็มทางเส้นเดียวกัน ตารางที่ไม่มีในฐานถูกข้าม
ดึงส่วนต่าง PosSyncPull (:399)
- Input จาก handler (
pos/sync.go:93-108): querycursor,limit; headerX-Pos-Pending-Bills(จำนวนบิลค้างในเครื่อง),X-Pos-App-Version(ตัดเหลือ 40 ตัวอักษร) limit<= 0 หรือ > 2000 = 500 (:404) อ่านเกินมา 1 แถวเพื่อรู้has_more- เงื่อนไขอ่าน:
(xid, entity, entity_key) > cursorและxid < pg_snapshot_xmin(pg_current_snapshot())-- คืนเฉพาะรายการจากทรานแซกชันที่เก่ากว่าทุกทรานแซกชันที่ยังรันอยู่ cursor จึงไม่ข้ามการเปลี่ยนที่ commit ทีหลัง ข้อแลกเปลี่ยนที่โค้ดระบุเอง: ทรานแซกชันที่เปิดค้างนานทำให้ซิงก์หยุดชั่วคราว (ponytailคอมเมนต์ :398) - cursor เป็น base64url ของ
xid|entity|key(posCursor:356-382) ผิดรูป = 400 "cursor ไม่ถูกต้อง"; cursor ว่าง = เริ่มต้นใหม่ - ผลลัพธ์
{cursor, has_more, server_time, changes[]}โดยแต่ละ change ={entity, key, rows[]}และ rows ว่าง = ให้ลบในเครื่อง ยกเว้นเครื่องใหม่ (cursor ว่าง) ไม่ส่งรายการลบ (:449) - แต่ละ entity ถูก project เป็นแถวปัจจุบันด้วย
projectPosEntity(:474):product(JOIN barcode/หน่วย/กลุ่มสินค้า, ราคาretail/wholesale/memberเป็นข้อความทศนิยม),member,promotion,coupon(ส่งเฉพาะที่ใช้ได้:last_status = 0และยังไม่หมดอายุ ที่เหลือกลายเป็นรายการลบ),config(posDeviceConfig:567 = ก้อนเดียวshop/machine/qr/point/last_doc_no) - เงินเป็นสตริงทศนิยมเสมอ (
::text) ตามกฎ ACCOUNTING NUMBER รูปภาพส่งเป็นimage_url=/api/pos/image?e=&k=&v=โดยvคือxminของแถวรูป จึงเปลี่ยน URL ทุกครั้งที่รูปเปลี่ยน (:552-559) config.last_doc_no=max(doc_no)ในpos_bill_inboxของเครื่อง เพื่อให้เครื่องรันเลขบิลต่อได้หลังติดตั้งใหม่ (:601-604)- ท้ายสุดบันทึก heartbeat ลง
pos_sync_machine(last_seen_at,pending_bills,app_version,last_cursor;last_sync_atอัปเดตเมื่อhas_more = false) -- ถ้ายังไม่มีแถวของเครื่อง จะสร้างด้วยtoken_hash = \x00และpaired_by = 'WEB-POS'(:457-468)
สถานะเครื่อง ListPosDevices (:631)
คืนทุกเครื่องจาก pos_id พร้อม paired, paired_at, last_seen_at, last_sync_at, pending_bills, last_bill_at, app_version, bill_errors (บิล status = 2 ของเครื่องนั้น) และ up_to_date = ผูกแล้ว + เคยซิงก์ + cursor ไม่ต่ำกว่าการเปลี่ยนล่าสุดบนเซิร์ฟเวอร์ รวมกับ stock_queue (แถวใน pos_stock_queue), bill_errors_total, latest_change_at
กู้คืนเครื่อง GET /pos/restore (PosRestoreData pos_media.go:136)
คืน {shift, settlements[], bills[]} ของเครื่อง: กะที่เปิดอยู่ (pos_period.period_status = 0 ล่าสุด; ไม่มี = ตั้ง shift = null), รายการรับ/ส่งเงิน poscashiersettle ตั้งแต่วันเริ่มกะ (ไม่มีกะ = วันนี้ตามวันของเซิร์ฟเวอร์), และบิลดิบจาก pos_bill_inbox (สถานะ pending|done|error|cancelled, payload ตามที่เครื่องส่ง) สูงสุด 5000 บิล (posRestoreMaxBills :131) แคตตาล็อกและเลขบิลถัดไปกลับมาทางการซิงก์ปกติ (คอมเมนต์ :112-115)
รูปภาพ (pos_media.go, pos/media.go)
GET /pos/image?e=product|member&k=<รหัส>&v=คืน รูปย่อ JPEG เสมอ พร้อมCache-Control: private, max-age=31536000, immutable(pos/media.go:25-56) ตัวต้นฉบับอยู่ใน MinIO (คีย์ต้องขึ้นต้น<ฐาน>/ของบริษัทตัวเอง -- ไม่ตามแถวที่ชี้ไปบริษัทอื่น,PosImage:28-65) หรือเป็น bytea ในimages.image_fileของ SML เดสก์ท็อป (ย่อต่อคำขอ ตามคอมเมนต์ponytailในpos/media.go)POST /pos/media?kind=slip&ref=<doc_no>รับ PNG/JPEG ไม่เกิน 5 MB (posMediaMaxBytes) รองรับเฉพาะkind=slipและต้องมีบิลนั้นของเครื่องนี้ในpos_bill_inboxแล้ว (ยังไม่มี = 409 ให้ส่งบิลก่อน) เก็บที่ MinIO ด้วยคีย์ที่อิงแฮชเนื้อหา (ส่งซ้ำได้ผลเดิม) แล้วRecordPosMediaลงsml_doc_imagesช่อง 0 แบบแทนที่แถวเดิม
ที่มาในโค้ด
backend/internal/adapter/inbound/http/router.go:329-348- ผูกกลุ่ม/api/v1/posbackend/internal/adapter/inbound/http/pos/sync.go:24-91,93-176-Auth,deviceFromContext,DeviceOnly/UserOnly,Sync,PairDevice,ListDevicesbackend/internal/adapter/inbound/http/pos/media.go:25-112-Image,PostMedia,Restorebackend/internal/application/process/pos_sync.go:30-115- schema ซิงก์ (pos_sync_change,pos_sync_machine,pos_bill_inbox,pos_stock_queue, ฟังก์ชัน trigger)backend/internal/application/process/pos_sync.go:118-210-posSyncSources,posSyncBackfill,ensurePosSync,posEntityTablebackend/internal/application/process/pos_sync.go:212-340-posPool, โทเคน,PairPosDevice,VerifyPosDevicebackend/internal/application/process/pos_sync.go:342-565- cursor,PosSyncPull,projectPosEntitybackend/internal/application/process/pos_sync.go:567-671-posDeviceConfig,ListPosDevicesbackend/internal/application/process/pos_media.go:28-194-PosImage,PosMediaImageID,RecordPosMedia,PosRestoreData
ดูเพิ่ม
- POS รับบิลขายจากเครื่องแคชเชียร์ กล่องรับบิล/ประมวลผล/ยกเลิก/ตัดสต็อกเป็นชุด (POS Bill Inbox)
- POS ตั้งค่าร้าน/เครื่อง/โปรโมชัน และกะขาย รับ-ส่งเงิน (POS Config and Shift)
- ตัวกระจายคำขอของ process (Process dispatcher)
- แนบรูปเอกสารและรูปสินค้า (Document & item pictures)
- ภาพรวม Backend Go ของ SML Ai Account (Backend overview)
- ภาพรวมกลุ่ม process ของ Backend (Process overview)