ใบสั่งขาย
เมนูนี้ใช้ดู สร้าง แก้ไข และดำเนินการใบสั่งขายของลูกค้า ตั้งแต่เลือกลูกค้า, ระบุว่าสั่งแบบสายส่งหรือหน้าร้าน, เพิ่มสินค้า, สร้างใบแจ้งหนี้, และไปหน้าชำระเงิน
เอกสารหน้านี้ตั้งใจให้ developer อ่าน flow ได้โดยไม่ต้องกดเล่นระบบจริง
ทางเข้าในแอป
| แอป | จุดเข้า | ไฟล์หลัก |
|---|---|---|
| Mobile | Home -> OrderList mode default -> CustomerList mode default -> OrderDetail | apps/mobile/src/screens/customerStack/customerListScreen/index.tsx, apps/mobile/src/screens/orderStack/orderDetailScreen/index.tsx |
| POS Terminal | HomeScreen split-pane -> menu salesOrder -> customer picker/detail panel | apps/pos-terminal/src/screens/HomeScreen/index.tsx, apps/pos-terminal/src/screens/HomeScreen/OrderDetailForm.tsx |
API และ service ที่ใช้ร่วมกัน:
| งาน | Mobile service | Terminal service | Endpoint หลัก |
|---|---|---|---|
| list order | services/orders.ts#getOrders | services/orders.ts#getOrders | GET /orders |
| detail order | getOrderDetail | getOrderDetail | GET /orders/{orderId} |
| create/update order | createOrder, updateOrder | createOrder, updateOrder | POST /orders, PATCH /orders/{orderId} |
| เปลี่ยนสายส่ง/หน้าร้าน | updateOrderShippingType | updateOrderShippingType | PATCH /orders/{orderId}/shipping-type |
| เพิ่ม/แก้/ลบสินค้า | createOrderItem, updateOrderItem, deleteOrderItem | ชุดเดียวกัน | /orders/{orderId}/items |
| แก้น้ำหนักรวมรายการ | updateOrderItemWeight | updateOrderItemWeight | PATCH /orders/{orderId}/items/{id}/weight |
| สร้างใบแจ้งหนี้ | services/invoices.ts#createInvoice | services/invoices.ts#createInvoice | POST /invoices payload { orderNo } |
| หา invoice ของ order | getInvoicesByOrderNo | getInvoicesByOrderNo | GET /orders/{orderNo}/invoices |
| ชำระเงิน | payOrder | payOrder | POST /payment |
API payload และสิทธิประโยชน์
ทุกตัวอย่างด้านล่างส่ง Content-Type: application/json; charset=utf-8 และให้แทนค่าใน path เช่น {orderId}, {orderNo} และ {creditNoteId} ด้วยค่าที่ได้จาก API จริง ไม่ควรใช้ตัวอย่างรหัสลูกค้า/สินค้าในเอกสารนี้กับ production โดยตรง
สร้างใบสั่งขาย
POST /orders
สร้าง header ของใบสั่งขายก่อนเพิ่มสินค้า โดยปกติแอปจะสร้างเมื่อมีการเพิ่มสินค้า, บันทึกร่าง, พิมพ์เอกสารที่ต้องใช้เลขใบสั่งขาย หรือยืนยันสร้างใบแจ้งหนี้ ไม่ได้สร้างทันทีที่เลือกลูกค้า
{
"orderDate": "2026-08-03",
"branchId": 1,
"customerCode": "C17113344",
"customerName": "ร้านปิยดา หมูกะทะ",
"phone1": "082-872-6949",
"phone2": "082-872-6949",
"weight": 0,
"amount": 0,
"shippingType": "1",
"withdrawType": "NOW",
"routeCode": "SSK14",
"routeName": "ในเมืองศรีสะเกษ-กันทรารมย์ 1",
"billAddress": "73 ม.1 ต.น้ำคำ อ.เมืองศรีสะเกษ จ.ศรีสะเกษ 33000",
"shippingAddress": "73 ม.1 ต.น้ำคำ อ.เมืองศรีสะเกษ จ.ศรีสะเกษ 33000",
"remark": "",
"whGrpCode": "SSK",
"whGrpName": "คลังศรีสะเกษ",
"U_BFP_Latitude": "15.145",
"U_BFP_Longitude": "104.32662",
"userCode": "999999",
"userResponsible": "ทดสอบ ฝ่ายขาย",
"orderStatus": "DRAFT",
"tier": "ปลาทูเงิน"
}
ฟิลด์ branchId, whGrpCode, whGrpName, userCode และ userResponsible ต้องมาจากสาขาและผู้ใช้ที่ login; ข้อมูลลูกค้า/ที่อยู่/route/พิกัดต้องมาจาก customer detail ที่เลือก ไม่ควร hard-code. สำหรับสายส่ง (shippingType: "1") ต้องมี shippingAddress และพิกัดที่ใช้ได้ก่อนสร้าง invoice; หน้าร้านใช้ shippingType: "2" และระบบจะเติม dateOfPickup หากยังไม่มีค่า
การแก้ header ใช้ payload โครงสร้างเดียวกันกับ PATCH /orders/{orderId} ส่วนการสลับเฉพาะประเภทขายใช้ PATCH /orders/{orderId}/shipping-type:
{
"shippingType": "2",
"routeCode": "",
"routeName": ""
}
เพิ่ม แก้ไข และลบรายการสินค้า
| งาน | Method / endpoint | Request body |
|---|---|---|
| เพิ่มสินค้า | POST /orders/{orderId}/items | payload item ด้านล่าง |
| แก้สินค้า | PATCH /orders/{orderId}/items/{itemId} | payload item ด้านล่าง |
| แก้น้ำหนักรวม | PATCH /orders/{orderId}/items/{id}/weight | { "weight": 50.5 } ดู flow แก้น้ำหนัก |
| ลบสินค้า | DELETE /orders/{orderId}/items/{itemId} | ไม่มี body |
ตัวอย่าง payload สำหรับเพิ่มสินค้า:
{
"productCode": "b1-03-U5-B-arb-10-NM1-sgf",
"productName": "อินเดีย3-U5B(ARABIANA)(แดง)(10KG)",
"qty": 1,
"price": 690,
"amount": 690,
"weight": 10,
"limitWeight": null,
"onHand": 63,
"bookingQty": 0,
"uomCode": "กล่อง",
"uomName": "กล่อง",
"UOMCode": "กล่อง",
"UOMName": "กล่อง",
"UOMEntry": "51",
"whsCode": "SSK",
"whGrpCode": "SSK",
"whGrpName": "คลังศรีสะเกษ",
"companyCode": "BFPDB",
"companyName": "BFPDB",
"taxNo": "0335555000321",
"vatGroup": "OE00",
"weightBaseFlag": false,
"stepPriceFlag": false,
"currentStepPrice": null,
"editPriceBy": null
}
ก่อนส่ง ระบบตรวจ qty เป็นจำนวนเต็มมากกว่า 0, ไม่เกิน stock ที่ใช้ได้ และคำนวณ amount ใหม่ (qty * price สำหรับสินค้าปกติ; รายการชั่งน้ำหนักคิดตามน้ำหนัก). สินค้าชั่งน้ำหนักส่ง weightBaseFlag: true; currentStepPrice ส่งเมื่อรายการอยู่ในบริบท step price/ใบจองราคา. whsCode ต้องมาจาก inventory ของสินค้า; หากไม่มี ระบบจะ refresh inventory ก่อน retry ไม่ใช่เดาคลังจากหน้าจอ
คะแนนสมาชิก
ใช้ได้เฉพาะใบที่บันทึกแล้ว (orderId มีค่า), สถานะ DRAFT หรือ NEW, และผู้ใช้ที่ login เป็นเจ้าของใบ (userCode ตรงกับ order). ใบหนึ่งใช้คะแนนซ้ำไม่ได้จนกว่าจะลบสิทธิ์เดิม และจำนวนมูลค่าที่แลกต้องไม่เกินยอด order หรือมูลค่าคะแนนคงเหลือ
- ดึงยอดและอัตราแลกคะแนนก่อนด้วย
GET /customers/{customerCode}/point-balanceไม่มี request body. Response ที่หน้าจอใช้คือpoint,usePoint,useAmount(พร้อมข้อมูล tier/credit level หากมี) - ขอ OTP ด้วย
POST /orders/{orderNo}/otp
{ "whichPhone": "phone1" }
ใช้ phone1 หรือ phone2 ตามเบอร์ที่ผู้ใช้เลือก แล้วเก็บ token จาก response
3. ยืนยัน OTP ด้วย POST /orders/{orderNo}/validate-otp
{
"otpCode": "123456",
"token": "<token-from-otp-response>"
}
- ใช้คะแนนด้วย
POST /orders/{orderId}/point
{ "point": 100 }
ค่า point คือ จำนวนคะแนน ไม่ใช่เงินบาท: หน้าจอแปลงจากยอดเงินบาทที่ผู้ใช้ระบุด้วย เงินบาท * (usePoint / useAmount). มูลค่าสูงสุดคือ floor(point / usePoint) * useAmount. ลบคะแนนที่ใช้แล้วด้วย DELETE /orders/{orderId}/point โดยไม่มี body
คูปอง
คูปองไม่มี API สำหรับดึงรายการใน flow ใบสั่งขาย ผู้ใช้ระบุรหัสคูปอง แล้วให้ backend ตรวจสอบสิทธิ์ด้วย POST /orders/{orderId}/coupon:
{ "couponCode": "<coupon-code>" }
ใช้ได้ภายใต้สิทธิ์/สถานะเดียวกับคะแนน และต้องเป็นใบที่ยังไม่มี useCoupon > 0; หากมีคูปองอยู่แล้วต้องลบก่อนใช้ใหม่ด้วย DELETE /orders/{orderId}/coupon (ไม่มี body). การตรวจว่า code ใช้ได้, หมดอายุ หรือเข้ากับ order หรือไม่ เป็นความรับผิดชอบของ backend จาก request นี้
เครดิตโน้ต (ส่วนลดเครดิต)
ก่อนเปิดรายการ ระบบต้องตรวจว่าเป็นใบที่บันทึกแล้ว, สถานะ DRAFT หรือ NEW, ผู้ใช้เป็นเจ้าของใบ, มี customerCode, ไม่มี useCreditNote > 0 และไม่มี useCoupon > 0.
เมื่อผ่านเงื่อนไข จึงดึงรายการที่เลือกได้ด้วย:
GET /credit-notes?page=1&pageLimit=200&sort=DESC&sortBy=CreatedAt&search=&branchId={branchId}&whGrpCode={whGrpCode}&isDiscount=1&customerCode={customerCode}
เงื่อนไขสำคัญของ query คือ branchId และ whGrpCode ต้องเป็นสาขา/คลังที่เลือกอยู่, isDiscount=1 จำกัดเฉพาะเครดิตโน้ตที่ใช้เป็นส่วนลด และ customerCode ต้องตรงลูกค้าในใบสั่งขาย. ก่อนเลือกใน UI ยังตรวจ isUsed != 1 และตรวจยอดส่วนลดของเครดิตโน้ตไม่เกินยอดสินค้าของบริษัทเดียวกัน: invoiceNo ขึ้นต้น BFP เทียบยอดสินค้า BFP, ขึ้นต้น NFF เทียบยอดสินค้า NFF.
ใช้เครดิตโน้ตที่เลือกด้วย POST /orders/{orderId}/credit-notes/{creditNoteId} โดยส่ง body ว่าง {}. หาก response มี requireOtp: true ต้องทำ OTP ของเครดิตโน้ตต่อไปนี้:
POST /orders/{orderId}/credit-notes/{creditNoteId}/send-otp
{ "phone": "0828726949" }
POST /orders/{orderId}/credit-notes/{creditNoteId}/confirm-otp
{
"token": "<token-from-send-otp-response>",
"otpCode": "123456"
}
หมายเลขโทรศัพท์สำหรับเครดิตโน้ตต้องเป็นตัวเลข 10 หลัก. เมื่อ backend ไม่ต้อง OTP หรือยืนยัน OTP สำเร็จ ให้ reload GET /orders/{orderId} เพื่ออ่านยอด useCreditNote ล่าสุด. ลบเครดิตโน้ตด้วย DELETE /orders/{orderId}/credit-notes/{creditNoteId} โดยไม่มี body
ขั้นตอนหลักตั้งแต่เลือกลูกค้า
การเลือกลูกค้าและประเภทลูกค้า
หน้า CustomerList เป็นจุดเริ่มสร้างใบสั่งขายจากลูกค้า
mode = defaultคือ flow ใบสั่งขาย- filter
shippingType = 1คือกลุ่มลูกค้าสายส่ง - filter
shippingType = 2คือกลุ่มลูกค้าหน้าร้าน - route filter ใช้
routeCodeจากgetRoutesByBranch(...) - list โหลดผ่าน
getCustomerSapList({ page, search, shippingType, routeCode, isRequest }) - action สร้างใบสั่งขายอยู่ใน swipe action ของ card ลูกค้า
ก่อนเข้าใบสั่งขาย ระบบเช็ค:
getCustomerListAccess(user, mode, branch)ต้องอนุญาตให้เปิดหน้าและแสดง primary actiongetSalesOrderBlockedReason(customer, user)ต้องไม่คืนเหตุผล block- ต้องมี
customerIdหรือid - โหลด customer เต็มด้วย
getCustomerSapById(customerId) - เช็คใบสั่งขายเดิมของวันนี้ด้วย
getExistingCustomerOrderForBranch(customerDetail, branch)
ถ้ามีใบสั่งขายเดิมของวันนี้ในสาขานั้น ระบบแสดง alert:
| ผู้ใช้เลือก | ผลลัพธ์ |
|---|---|
เปิดใบเดิม | navigate ไป OrderDetail พร้อม orderId, orderNo |
สร้างใหม่ | navigate ไป OrderDetail พร้อม orderId: null, customerId |
Draft จากข้อมูลลูกค้า
เมื่อ OrderDetail ได้ customerId และยังไม่มี orderId จะโหลด customer แล้ว map เป็น draft ด้วย mapCustomerToDraftOrder(...)
field สำคัญที่ถูก seed เข้า draft:
| Field | แหล่งข้อมูล | หมายเหตุ |
|---|---|---|
branchId | branch storage | ใช้ BranchId หรือ branchId |
customerId, customerCode, customerName | customer detail | customerName ต่อจาก firstname/lastname |
phone1, phone2 | customer detail | ใช้แสดงและส่ง payload |
shippingType | customer detail / branch | ถ้าไม่ใช่ main branch บังคับเป็น 2 |
routeCode, routeName | ShipTo address | ใช้กับสายส่ง |
shippingAddress | ShipTo ที่เป็น default | สายส่งต้องมี ไม่งั้นสร้าง invoice ไม่ได้ |
billAddress | BillTo ที่เป็น default | ใช้เป็นที่อยู่เปิดบิล |
U_BFP_Latitude, U_BFP_Longitude | ShipTo address | สายส่งต้องมีพิกัดก่อนสร้าง invoice |
whGrpCode, whGrpName | branch storage | ผูกคลัง/กลุ่มคลังของสาขาที่เลือก |
orderStatus | local | เริ่มเป็น DRAFT |
สำหรับใบสั่งขายใหม่ที่ยังไม่มี orderNo ทั้ง Mobile และ POS Terminal จะเลือกที่อยู่ที่ type ตรงกับ field และมี isDefault = 1 ก่อน: ใช้ BillTo สำหรับ billAddress และ ShipTo สำหรับ shippingAddress หากไม่พบที่อยู่ default ที่ตรงกัน แต่ละ field จะ fallback ไปใช้ที่อยู่ลำดับแรกในรายการที่อยู่ของลูกค้า โดยที่อยู่จัดส่งที่เลือกยังใช้ตั้งค่า route และพิกัดเริ่มต้นด้วย
Mobile จะยังไม่ยิง POST /orders ทันทีตอนเปิด draft จากลูกค้า แต่จะสร้าง order จริงเมื่อเพิ่มสินค้า, กดบันทึก draft, พิมพ์เอกสารที่ต้องมี order id, หรือยืนยันสร้างใบแจ้งหนี้
Terminal จะสร้าง local draft ใน panel 3 ก่อนเหมือนกัน แต่ถ้าเริ่มจากการเพิ่มสินค้าจาก product flow แล้วค่อยเลือกลูกค้า Terminal จะสร้าง order header ทันทีด้วย createOrder(...), เพิ่มสินค้า, update totals, แล้วเปิด detail ใน split-pane
เพื่อกันการ trigger ซ้ำโดยไม่ตั้งใจ Mobile และ POS Terminal จะรวมการเรียก createOrder(...) ที่เกิดภายใน 2.65 วินาที: จะมีเฉพาะการเรียกแรกที่ส่ง POST /orders ส่วนการเรียกถัดไปจะรับผลลัพธ์จาก request แรก การป้องกันนี้ใช้เฉพาะการสร้าง order header เท่านั้น โดย endpoint รายการสินค้าไม่มีการเปลี่ยนแปลง
สายส่งกับหน้าร้าน
ค่า shippingType เป็นตัวแยกพฤติกรรมหลัก:
| ค่า | ความหมาย | ผลกับหน้าใบสั่งขาย |
|---|---|---|
1 | สายส่ง | ต้องมีที่อยู่จัดส่งและพิกัด, แสดงข้อมูล route/distance/shipping fee, ใช้ Google Routes คำนวณค่าขนส่ง |
2 | หน้าร้าน | ถ้าไม่มี dateOfPickup จะเติมเวลาปัจจุบัน, ไม่ต้องเช็คพิกัดจัดส่ง, flow หลัง invoice จะไป storefront/POS ได้ |
กฎค่าขนส่งสายส่ง:
- ใช้พิกัดต้นทางจาก branch coordinate ผ่าน helper ใน
@bsr/utils - ใช้ปลายทางจาก
U_BFP_LatitudeและU_BFP_Longitude - ถ้าพิกัดว่าง, ไม่ใช่ตัวเลข, เป็น
0, หรือไม่มี branch coordinate จะ alert และไม่คำนวณ - เรียก Google Routes ผ่าน
getGoogleRouteDistanceMeters(...) - ฟรี 150 กม. แรก
- ส่วนที่เกินคิด 15 บาท/กม.
- เศษระยะทางเกินหรือเท่ากับ 500 เมตรปัดขึ้นอีก 1 กม.
สลับสายส่ง/หน้าร้านในหน้าใบสั่งขาย
ผู้ใช้สลับประเภทได้จาก header/meta ของ OrderDetail
สิ่งที่ developer ต้องระวัง:
- Mobile และ Terminal ใช้ pattern เดียวกัน: update local state ก่อน แล้วค่อยยิง
updateOrderShippingType(...)ถ้ามีorderId - ถ้า request fail ต้อง rollback local detail กลับค่าเดิม
- ถ้าเปลี่ยนเป็นหน้าร้านและเพิ่งเติม
dateOfPickupต้องupdateOrder(...)อีกครั้งเพื่อ persist ค่า pickup time - ถ้าเป็นสายส่ง ต้องเช็ค shipping address และ coordinate ก่อนสร้าง invoice
- สาขาที่ไม่ใช่ main branch มี logistics fields เพิ่ม เช่น
vehicleRelease,documentMove; ถ้ายังไม่ครบจะ block action สำคัญ
เพิ่มสินค้าและ persist order
เมื่อเพิ่มสินค้าจาก product picker:
- validate จำนวนเป็นตัวเลขเต็มและมากกว่า
0 - สินค้าชั่งน้ำหนักเพิ่มรายการโดยมีน้ำหนัก
0ได้ ส่วนการแก้น้ำหนักภายหลังใช้ flow แก้น้ำหนักแยก - เช็คไม่เกิน available stock
- ถ้า product มี step price และ qty เข้าเงื่อนไข จะโหลด step price ก่อนคำนวณราคา
- ถ้า order ยังไม่มี
orderIdจะcreateOrder(buildOrderApiPayload(detail, currentUser)) - หลังมี
orderIdจะสร้าง item ด้วยcreateOrderItem(orderId, orderItemPayload) - payload item สร้างผ่าน
buildOrderItemPayloadForSubmit(...) - ถ้า payload ไม่มี
whsCodeจะ refresh inventory จาก Firestore แล้ว retry lookup ก่อน throwไม่พบข้อมูลคลังของสินค้านี้
ยอดรวม order สำหรับการเพิ่ม/แก้รายการทั่วไป (flow แก้น้ำหนักแยกใช้ยอดจากเซิร์ฟเวอร์):
- line amount สินค้าน้ำหนัก =
qty * price * weight - line amount สินค้าปกติ =
qty * price - summary ใช้
calculateOrderSummary(...) - order ที่เป็น
DRAFTหรือNEWจะ update amount/weight เมื่อโหลด detail แล้วพบว่ายอดใน header ไม่ตรงกับรายการสินค้า
Product Picker และ loading ตอนเพิ่มสินค้า
- Storefront POS จะเข้าคิว FIFO ตั้งแต่เลือกสินค้ารายการแรก แม้ยังไม่มี identity ของ order: งานแรกสร้าง header และงานถัดไปทั้งหมดใช้
orderIdที่ได้กลับมา - สินค้าที่เลือกจะแสดงพรีวิวทันทีพร้อม progress ระดับรายการ ทั้งช่วงสร้าง header และสร้าง item ไม่มี loading แบบเต็มหน้าจอหรือการบล็อกการเลือกสินค้าถัดไป
- หลังงานสุดท้ายในคิวเสร็จ ระบบจะตรวจ detail ที่บันทึกจริง คำนวณ amount และ weight ของ header ใหม่เมื่อไม่ตรงกับ item และปิดปุ่มยืนยันลูกค้าไว้จนกว่าการตรวจนี้จะเสร็จ
- detail สุดท้ายรองรับ
items[].idเป็น identity ของ item ที่ persist แล้ว เมื่อ FIFO ว่างจะใช้ผลจากเซิร์ฟเวอร์แทนบรรทัด preview ที่สำเร็จ โดยคงเฉพาะรายการที่ยังทำงานหรือผิดพลาด จึงไม่เกิดสินค้าในตะกร้าซ้ำเมื่อเพิ่มเร็ว
แก้น้ำหนักรวมของรายการสินค้าที่บันทึกแล้ว
Mobile, หน้าใบสั่งขาย POS Terminal และ Storefront POS ใช้ flow แก้น้ำหนักแยกสำหรับรายการสินค้าในใบสั่งขายที่บันทึกแล้วและมี weightBaseFlag โดยแถวน้ำหนักเปลี่ยนจากช่องกรอกเป็นปุ่ม แก้ไขน้ำหนัก
สิทธิ์และลำดับ Modal
- ใบสั่งขายต้องอยู่ในสถานะที่แก้รายการได้ และผู้ใช้ที่ login ต้องมี
fillWeight = 1สำหรับใบที่บันทึกแล้วคือDRAFTหรือNEWตามกติกาสิทธิ์แก้รายการเดิม การเป็นเจ้าของใบหรือมีeditPriceอย่างเดียวไม่ให้สิทธิ์แก้น้ำหนัก - กดปุ่มโดยไม่มีสิทธิ์จะแสดง Global Alert
คุณไม่มีสิทธิแก้ไขไม่เปิดฟอร์มน้ำหนักและไม่เรียก API โดยตรวจสิทธิ์ซ้ำเมื่อยืนยันด้วย - ผู้มีสิทธิ์จะเห็น Modal แก้ไขน้ำหนักสินค้า พร้อมชื่อสินค้า ช่องกรอกที่เริ่มต้นว่าง และคำอธิบาย
กรุณาระบุน้ำหนักของสินค้าทั้งหมด ระบบจะทำการคำนวณน้ำหนักและราคาตาม Step price ให้ - ซ่อนฟอร์มแก้รายการสินค้าชั่วคราว กด ยกเลิก หรือย้อนกลับจาก Modal จะกลับฟอร์มเดิม โดยเก็บค่าที่ยังไม่บันทึกไว้
- ปุ่ม ตกลง ใช้ได้เมื่อกรอกตัวเลขที่มีค่าจำกัด รองรับจำนวนเต็มและทศนิยม เช่น
50,50.5,0.25ไม่รับค่าว่าง ค่าติดลบ ตัวอักษร comma เลขแบบ exponent หรือทศนิยมที่ยังกรอกไม่ครบ ค่า0ผ่าน validation ฝั่งแอป แต่ API อาจปฏิเสธได้ และยังต้องผ่านการตรวจน้ำหนักก่อนสร้าง invoice - กดตกลงแล้วแสดง Global Alert ยืนยันแก้ไขน้ำหนักสินค้า พร้อมน้ำหนักรวมหน่วยกิโลกรัม กดยกเลิกใน alert จะกลับฟอร์มน้ำหนักพร้อมค่าเดิม ต้องกด ยืนยัน จึงส่ง API
API และการโหลดข้อมูลใหม่
PATCH /api/orders/{orderId}/items/{id}/weight
Content-Type: application/json; charset=utf-8
{ "weight": 50.5 }
id คือรหัสรายการสินค้าที่บันทึกในใบสั่งขาย ไม่ใช่รหัสสินค้า ส่วน weight คือ น้ำหนักรวมของสินค้าทั้งบรรทัด ส่งเป็น JSON number ตามที่กรอก เช่น จำนวน 4 และกรอกน้ำหนัก 50.5 ก็ส่ง { "weight": 50.5 } ตรง ๆ
การแก้น้ำหนักนี้ให้ API จัดการแปลงน้ำหนัก คำนวณ Step Price และยอดรวม แอปไม่หารน้ำหนักด้วยจำนวน ไม่เลือกขั้นราคาเอง ไม่สร้าง payload item เต็มชุด และไม่เรียก updateOrder เพื่อเขียนทับยอดหัวเอกสาร การตรวจจำนวน/สต็อกยังอยู่ใน flow แก้รายการทั่วไป แต่ไม่ใช้จำนวนที่ยังไม่บันทึกในฟอร์มเดิมมาตรวจใน flow แก้น้ำหนักแยกนี้
- เมื่อยืนยัน เปิด Global Loading และกันการส่งซ้ำ
- เรียก
updateOrderItemWeight(orderId, itemId, weight) - เรียก
GET /api/orders/{orderId}และใช้ order/items ล่าสุดจากเซิร์ฟเวอร์ - เมื่อทั้งสองขั้นตอนสำเร็จ ปิดฟอร์มน้ำหนัก/แก้รายการ และปิด Global Loading โดยไม่ส่งค่าที่ยังไม่บันทึกในฟอร์มแก้รายการเดิมไปด้วย
- ถ้า PATCH หรือ reload ล้มเหลว ปิด Global Loading และแสดงข้อความ error จาก API หรือข้อความสำรอง เมื่อปิด alert จะกลับฟอร์มน้ำหนักโดยยังเก็บค่าที่กรอก กรณี reload ล้มเหลวอาจเกิดหลัง PATCH สำเร็จแล้ว
ตัวแก้ไขร่วมรอคีย์บอร์ดที่เปิดอยู่ปิดก่อนเปลี่ยนเนื้อหา Modal เพื่อหลีกเลี่ยงปัญหา TextInput/IME บน Telpo ตามที่บันทึกไว้ในโค้ด และไม่ซิ้งค์ค่าฟอร์มต่อเนื่องไปยัง Customer Display ดูการยกเลิกคำยืนยันลูกค้าและการจัดการรายการที่ยังบันทึกไม่เสร็จใน POS หน้าร้าน
โค้ดและการตรวจสอบ
| ส่วน | Source |
|---|---|
| Modal ร่วม ตรวจ input ยืนยัน และ loading | packages/rn-ui/src/OrderItemWeightEditor.tsx (useOrderItemWeightEditor, OrderItemWeightForm) |
| ปุ่มและการบันทึก/reload Mobile | apps/mobile/src/components/OrderFlowShared/index.tsx, apps/mobile/src/screens/orderStack/orderDetailScreen/index.tsx#handleSaveItemWeight |
| ปุ่มและการบันทึก/reload Terminal | apps/pos-terminal/src/screens/HomeScreen/BookingProductPicker.tsx, apps/pos-terminal/src/screens/HomeScreen/OrderDetailForm.tsx#handleSaveOrderItemWeight |
| บันทึก/reload Storefront POS | apps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/index.tsx#saveOrderItemWeight |
| API service | updateOrderItemWeight ใน src/services/orders.ts ของทั้งสองแอป |
| Regression tests | apps/mobile/__tests__/orderItemWeightEditor.test.tsx, apps/mobile/__tests__/orderItemWeightApi.test.ts |
ตรวจด้วย yarn workspace @bsr/mobile test --watchman=false --runInBand orderItemWeight และ yarn typecheck โดยชุดทดสอบครอบคลุมการปฏิเสธ/ตรวจสิทธิ์ซ้ำ input ตัวเลข การยกเลิกทั้งสองจุด การยืนยัน กันส่งซ้ำ loading/error การสลับคีย์บอร์ด และ PATCH contract ของทั้งสองแอป การทดสอบบนอุปกรณ์ควรตรวจการสลับ Modal/คีย์บอร์ดและราคาล่าสุดจากเซิร์ฟเวอร์ทั้ง Mobile และเครื่อง POS ด้วย
ใบสั่งขายที่มาจากใบจองราคา
กติกาจำนวน/ราคาในหัวข้อนี้เป็นการแก้รายการทั่วไป ส่วนการแก้น้ำหนักใช้ API น้ำหนักแยก แม้เป็นสินค้าที่มาจากใบจองราคา
ใบสั่งขายสามารถถูกสร้างจาก ใบจองราคา ผ่าน POST /booking-price/{bookingPriceId}/convert ได้ กรณีนี้ order detail จะมี bookingPriceId หรือ bookingPriceStorageId ติดมากับ header และ item ที่มาจากใบจองราคาจะพก currentStepPrice ต่อเข้ามาด้วย
พฤติกรรมที่ต้องระวัง:
- เมื่อโหลด order ที่มี
bookingPriceId/bookingPriceStorageIdแต่ข้อมูล address/route/coordinate ไม่ครบ Mobile จะ fallback ไปโหลด customer SAP แล้วเติมBillTo,ShipTo, route และพิกัดจากที่อยู่ลูกค้า currentStepPriceอาจเป็น JSON string ของ step price rows หรือ fallback JSON จากbuildDefaultCurrentStepPrice(...)- Mobile
OrderDetailและ TerminalOrderDetailForm/BookingProductPickerจะ parsecurrentStepPriceก่อน ถ้ามีข้อมูลนี้จะใช้เป็น source ของ step price ตอนแก้รายการ - การแก้จำนวนของ item ที่มาจากใบจองราคาให้คงเดิมหรือลดจำนวนได้ ถ้าใส่จำนวนมากกว่าเดิมจะแสดงข้อความ
สินค้าจองราคาแก้ไขได้เฉพาะลดจำนวนเท่านั้น - เมื่อบันทึกการแก้ item ระบบเทียบจำนวนใหม่กับ step price ที่ match ก่อน ถ้าไม่เข้า range จะ fallback ไปใช้ราคาตั้งต้นของสินค้าจาก inventory
- ถ้าเพิ่มสินค้าใหม่ในใบสั่งขายโดยตรงจาก product picker ระบบยังส่ง
currentStepPriceได้เหมือนกันเมื่อ product มี step price context - developer ที่แก้ step price ต้องเช็กทั้ง flow booking price และ sales order เพราะ field นี้ข้ามเอกสาร
สร้างใบแจ้งหนี้และไปหน้าชำระเงิน
ปุ่มหลักใน detail จะเปิดขั้นตอนใบแจ้งหนี้ หรือถ้า order อยู่สถานะที่ต้องชำระแล้วจะไป payment โดยตรง
Validation ก่อนสร้าง invoice:
| เงื่อนไข | มาจาก |
|---|---|
actionState.canOpenInvoiceFlow ต้องผ่าน | getOrderActionState(...) |
| branch logistics fields ต้องครบเมื่อไม่ใช่ main branch | getBranchActionIssue() |
| รายการสินค้า/ยอด/สถานะต้องถูกต้อง | getOrderInvoiceValidationIssues(detail) |
สินค้าชั่งน้ำหนักทุกรายการต้องมีน้ำหนักไม่เท่ากับ 0 | getOrderInvoiceValidationIssues(detail) |
| สายส่งต้องมี shipping coordinate | getShippingCoordinateIssue(...) |
| หลัง reload detail ต้อง recheck อีกรอบ | getOrderRecheckValidationIssues(...) |
พฤติกรรมสำคัญ:
- ถ้า order ยังเป็น
DRAFTหรือยังไม่มีorderNoระบบPATCH /orders/{id}พร้อมorderStatus: NEW - ถ้ามี invoice อยู่แล้ว จะไม่สร้างซ้ำ และไป payment ทันที
- ถ้า
POST /invoiceserror แต่ backend อาจสร้างสำเร็จแล้ว ระบบจะเรียกGET /orders/{orderNo}/invoicesอีกรอบก่อนตัดสินว่า fail - Mobile ใช้
navigation.navigate('OrderPayment', { orderId, orderNo }) - Terminal ใช้
setOrderDetailView('payment')และเปิด payment section ใน panel 3
ขั้นตอนชำระเงิน
หน้าชำระเงินใบสั่งขายมีสามจุดที่ใช้กติกาเดียวกัน:
- Mobile:
apps/mobile/src/screens/orderStack/orderPaymentScreen/index.tsx - แผงรายละเอียด Terminal:
apps/pos-terminal/src/screens/HomeScreen/OrderDetailForm.tsx - POS หน้าร้าน:
apps/pos-terminal/src/screens/StorefrontPosStack/OrderPaymentStep/index.tsx
วิธีชำระเงินและการแบ่งชำระ
Section ชำระเงินเรียง เงินสด → โอน → แบ่งชำระ → QRcode (CASH → TRANSFER → OTHER → QR) เป็นแถวเดียว เลื่อนซ้าย–ขวาได้ ปุ่มมีความกว้างพอดีข้อความ ไม่บีบปุ่มหรือขึ้นแถวใหม่ และ Storefront POS แสดงตัวเลือกโอนด้วยแล้ว
- เลือก แบ่งชำระ แล้วกรอก จำนวนเงินสด และ จำนวนเงินโอน แยกกัน
- ตรวจ ยอดแบ่งชำระ ซึ่งเป็นยอดที่เงินสดและเงินโอนต้องรวมกันให้ครบ
- แนบสลิปด้วยช่องเดียวกับวิธี โอน ได้ตามต้องการ การเปลี่ยนแปลงนี้ไม่ได้เพิ่มข้อบังคับว่าต้องแนบสลิป
- กดยืนยัน ระบบจะไม่ส่งชำระหากช่องใดว่าง ติดลบ ไม่ใช่ตัวเลขที่ใช้ได้ มีทศนิยมเกิน 2 ตำแหน่ง หรือผลรวมไม่เท่ากับยอดสุทธิ
totalDueระบุ0ได้ โดยเปรียบเทียบยอดเป็นหน่วยสตางค์เพื่อลดปัญหาทศนิยมของคอมพิวเตอร์
ระหว่างพิมพ์เก็บค่าเป็นข้อความเพื่อรักษาค่าที่ยังกรอกไม่จบ เช่น 400. แล้วแปลงเป็น JSON number ตอนสร้าง payload เมื่อสลับไปช่องทางอื่นจะไม่ส่ง cash และ transfer การแบ่งชำระในหน้านี้ต้องครบยอด ไม่ใช่การรับเงินบางส่วนแบบในเมนูขนส่ง
การคำนวณยอดและ API contract
| Field | วิธีคิด / ความหมาย |
|---|---|
amount ในฟอร์ม | Mobile/Storefront ใช้กติกายอดแยกบริษัทเดิมและใช้ยอดหัวใบเมื่อไม่มีสินค้า; แผงรายละเอียด Terminal ใช้ยอดหัวใบและ fallback เป็นยอดคำนวณ |
baseDiscountAmount | usePoint + useCoupon + useCreditNote |
shippingAmount | ค่าขนส่งและค่าธรรมเนียมจากใบที่ยังไม่ชำระ |
canceledInvoiceAmount | ผลรวมยอดใบแจ้งหนี้ที่ invoiceStatus = CANCELED |
totalDue ที่แสดง | max(0, floor(form.amount + shippingAmount - baseDiscountAmount - canceledInvoiceAmount - discount + charge)) |
amount ใน payload | max(0, floor(form.amount + shippingAmount - canceledInvoiceAmount)) |
discount, charge | ส่วนลดเพิ่มและค่าบวกเพิ่ม ปัดลงเป็นจำนวนเต็มและส่งแยกฟิลด์ |
paymentChannel ตอนยืนยันชำระ | CASH, TRANSFER หรือ OTHER |
cash, transfer | JSON number ส่งเฉพาะ OTHER และต้องรวมกันเท่ากับยอดสุทธิ totalDue |
slipPath | path สลิปโอนสำหรับ TRANSFER หรือ OTHER หรือข้อความว่าง |
ยอดแบ่งชำระใช้ยอดสุทธิ totalDue หลังหักสิทธิประโยชน์และส่วนลด ตั้งแต่ 13 ตุลาคม 2026 กรอกเงินสดหรือเงินโอนแล้วระบบเติมยอดที่เหลืออีกช่อง หากกรอกเกินยอดสุทธิจะไม่รับค่าที่แก้และคงยอดเดิมไว้ เช่น ยอดสินค้า 1,000 + ค่าขนส่งค้างชำระ 150 − ใบแจ้งหนี้ยกเลิก 50 ได้ amount = 1100 มีสิทธิประโยชน์ 60 ส่วนลดเพิ่ม 50 และค่าบวกเพิ่ม 10 จะได้ totalDue = 1000 เงินสดกับเงินโอนจึงต้องรวมกัน 1,000 ส่วน amount, discount และ charge ใน payload ยังส่งตามสูตรเดิม
ตัวอย่าง body ของ POST /api/payment สำหรับกรณีนี้:
{
"orderNo": "SO-001",
"discount": 50,
"charge": 10,
"amount": 1100,
"remark": "แบ่งชำระเงินสดและเงินโอน",
"paymentChannel": "OTHER",
"slipPath": "slips/transfer.jpg",
"cash": 400.25,
"transfer": 599.75
}
ตัดช่องว่างหัวท้ายของ orderNo, remark และ slipPath ส่วน cashReceived, เงินทอน, URI ภาพในเครื่อง และ shippingSlipPath ไม่ได้ส่งใน payload นี้ ช่องทางเงินสดหรือโอนอย่างเดียวใช้ฟิลด์เดิมและไม่ส่งสองฟิลด์แบ่งชำระ
API ที่เรียกจากหน้าชำระเงิน
ตารางนี้รวม prefix /api แล้ว โดย host อ่านจาก packages/api/src/config.ts
| การทำงาน | Method / path | Body |
|---|---|---|
| โหลดหรือรีเฟรชใบสั่งขาย | GET /api/orders/{orderId} | ไม่มี |
| โหลดใบแจ้งหนี้ของใบสั่งขาย | GET /api/orders/{orderNo}/invoices | ไม่มี |
| ยืนยันเงินสด โอน หรือแบ่งชำระ | POST /api/payment | JSON ตามตัวอย่าง โดยส่งสองฟิลด์แบ่งชำระเฉพาะ OTHER |
| อัปโหลดสลิป | POST /api/{orderNo}/files | multipart files และ whGrpCode; ใช้ path ของไฟล์แรกที่ตอบกลับเป็น slipPath |
| ส่งสร้างใบแจ้งหนี้ใหม่ | POST /api/invoices/{orderNo}/retry | {} |
| ส่งใบค่าขนส่ง / Incoming ใหม่ | POST /api/transports/{transportId}/shipping/{shippingInvoiceId}/retry หรือ /retry-incoming | ไม่มี |
| โหลดใบแจ้งหนี้เพื่อพิมพ์ | GET /api/invoices/{invoiceId}?includeLot=true | ไม่มี |
หากเปิดขั้นตอนชำระจากหน้ารายละเอียด Terminal จะบันทึกใบสั่งขายก่อน แล้วสร้างใบแจ้งหนี้ด้วย POST /api/invoices และ { "orderNo": "..." } เมื่อยังไม่มีใบแจ้งหนี้ที่ใช้งานอยู่ ดูหัวข้อสร้างใบแจ้งหนี้ก่อนหน้านี้
การเปิดใช้ QR Payment ตาม environment
QR ใช้ flow แยก การสร้าง QR อาจเรียก POST {API_URL_QR}/getTokenScb/{branchCode} พร้อม { "userCode": "..." } แล้วเรียก POST {API_URL_QR}/getQrPayment/{branchCode} พร้อม amount, ref1, ref2 สำหรับ QR สินค้าใช้เลขใบสั่งขายที่ตัดขีดกลางในทั้งสอง ref และอาจใช้ cache ที่เลขเอกสารกับยอดตรงกันจาก Firestore qrPayment ส่วนใบค่าขนส่ง/ค่าธรรมเนียมค้างชำระสร้าง QR แยกโดยอ้างอิงเลขขนส่งและเลขใบค่าขนส่ง
ปุ่ม ตรวจสอบการชำระ โหลด GET /api/orders/{orderId} แล้วตรวจ orderStatus = CONFIRMED ไม่ได้เรียก POST /api/payment แม้ helper สร้าง payload จะยังมี fallback เดิมที่แปลง QR → CASH แต่ปุ่มตรวจสอบ QR ไม่ได้ใช้ flow นั้น
- Production สร้าง QR ได้ ส่วน UAT ต้องตั้ง
ENABLE_QR_PAYMENT = 1 - ณ การอัปเดต 2026-09-18 UAT ตั้ง
ENABLE_QR_PAYMENT = 0จึงยังเลือกแท็บ QR ได้ แต่จะหยุดก่อนเรียก API สร้าง QR การแสดงแท็บไม่ได้เปิด gateway ให้เอง - หากเปิด QR สินค้าใน UAT จะส่งยอดสร้าง QR เป็น 1 บาท ส่วน QR ค่าขนส่งใช้ยอดของใบค่าขนส่ง
หลัง payOrder(payload) สำเร็จจะโหลดข้อมูลใบสั่งขาย/การชำระใหม่ หาก slipPath ว่างจะแสดง ชำระเงินสำเร็จ กรุณาส่ง Incoming ที่หน้าใบแจ้งหนี้ หาก API แจ้ง ใบสั่งขายนี้ถูกใช้ไปเเล้ว handler ชำระเงินจะรีเฟรชใบสั่งขายแทนการแสดงข้อผิดพลาดนั้นทันที
ความต่าง Mobile กับ POS Terminal
| เรื่อง | Mobile | POS Terminal |
|---|---|---|
| Navigation | stack: CustomerList -> OrderDetail -> OrderPayment | split-pane ใน HomeScreen: menu, list/customer picker, detail/payment |
| Draft จากลูกค้า | เปิด OrderDetail พร้อม customerId; ยังไม่ create จนจำเป็น | สร้าง local draft ใน panel 3 ผ่าน openSalesOrderDraftFromCustomer(...) |
| เปิดใบเดิมของวันนี้ | alert ให้เปิดใบเดิมหรือสร้างใหม่ | alert เดียวกัน แต่เปิดผ่าน openOrderSplitDetail(...) |
| สลับสายส่ง/หน้าร้าน | local state + PATCH /shipping-type ถ้ามี orderId | pattern เดียวกัน แต่ refresh detail/list ใน split-pane |
| Payment | route OrderPayment | orderDetailView = payment ใน panel 3 |
| POS หน้าร้าน | มีหน้ารายการ/รายละเอียด storefront แยกหลัง invoice | มี POS หน้าร้านเฉพาะ Terminal เป็นเมนูแยก |
แผนที่สำหรับ developer
ถ้าต้องแก้ flow ใบสั่งขาย ให้เริ่มดูตามนี้:
| ต้องแก้เรื่อง | เริ่มที่ไฟล์ |
|---|---|
| เลือกลูกค้า/กรองสายส่งหน้าร้าน | apps/mobile/src/screens/customerStack/customerListScreen/index.tsx, apps/pos-terminal/src/components/CustomerPickerModal.tsx |
| สร้าง draft จากลูกค้า | apps/mobile/src/screens/orderStack/orderDetailScreen/helpers.ts#mapCustomerToDraftOrder, apps/pos-terminal/src/screens/HomeScreen/index.tsx#buildSalesOrderPayloadFromCustomer |
| เปิดใบเดิมของวันนี้ | getExistingCustomerOrderForBranch(...) และ handler ใน CustomerList/HomeScreen |
| เปลี่ยนสายส่ง/หน้าร้าน | handleChangeShippingType ใน OrderDetailScreen และ OrderDetailForm |
| ค่าขนส่ง/พิกัด | getGoogleRouteDistanceMeters, calculateShippingAmountFromDistance, branch coordinate helpers ใน @bsr/utils |
| เพิ่มสินค้า | useProductPickerFlowBase, buildOrderItemPayloadForSubmit, createOrderItem |
แปลงจากใบจองราคา / currentStepPrice | BookingPriceDetailScreen, BookingProductPicker, OrderDetailScreen, OrderDetailForm |
| สร้าง invoice | runPrimaryAction ใน Mobile, payment section opener ใน Terminal |
| UI ชำระเงิน | OrderPaymentScreen, OrderPaymentMethodSection, OrderDetailForm, StorefrontPosStack/OrderPaymentStep |
| ยอด การตรวจสอบ และ payload ร่วม | packages/utils/src/orderSplitPayment.ts, packages/utils/src/orderSalesFlow.ts, packages/types/src/orderPayment.ts; Mobile re-export helper ตรวจสอบและสร้าง payload ร่วม |
| service contract | apps/mobile/src/services/orders.ts, apps/mobile/src/services/invoices.ts, apps/pos-terminal/src/services/orders.ts, apps/pos-terminal/src/services/invoices.ts |