ใบสั่งขาย
เมนูนี้ใช้ดู สร้าง แก้ไข และดำเนินการใบสั่งขายของลูกค้า ตั้งแต่เลือกลูกค้า, ระบุว่าสั่งแบบสายส่งหรือหน้าร้าน, เพิ่มสินค้า, สร้างใบแจ้งหนี้, และไปหน้าชำระเงิน
เอกสารหน้านี้ตั้งใจให้ 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 |
| สร้างใบแจ้งหนี้ | 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 ด้านล่าง |
| ลบสินค้า | 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ได้ - เช็คไม่เกิน 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:
- line amount สินค้าน้ำหนัก =
qty * price * weight - line amount สินค้าปกติ =
qty * price - summary ใช้
calculateOrderSummary(...) - order ที่เป็น
DRAFTหรือNEWจะ update amount/weight เมื่อโหลด detail แล้วพบว่ายอดใน header ไม่ตรงกับรายการสินค้า
Product Picker และ loading ตอนเพิ่มสินค้า
- เมื่อมี identity ของ order ที่ persist แล้ว (
orderIdหรือorderStorageId) การเพิ่มสินค้าจะไม่แสดง loading แบบเต็มหน้าจอที่บังหน้า detail อีกต่อไป โดยใช้ progress ระดับ local/item แทน เพื่อให้ user ทำงานต่อได้ - หลังเพิ่มสินค้าสำเร็จ product picker จะยังพร้อมใช้งานใน flow เพิ่มสินค้าต่อเนื่อง ทำให้เลือกและกดเพิ่มสินค้าได้หลายรายการโดยไม่ต้องเปิด picker ใหม่
- สำหรับ draft ใหม่ จะแสดง loading ที่บังเฉพาะช่วงสร้าง header ของ order เท่านั้น เมื่อ API คืน identity กลับมาแล้ว สินค้ารายการถัดไปจะใช้ endpoint เพิ่ม item ของ order เดิม
ใบสั่งขายที่มาจากใบจองราคา
ใบสั่งขายสามารถถูกสร้างจาก ใบจองราคา ผ่าน 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 payment อยู่ที่ apps/mobile/src/screens/orderStack/orderPaymentScreen/index.tsx
Terminal payment อยู่ใน apps/pos-terminal/src/screens/HomeScreen/OrderDetailForm.tsx
ทั้งสองฝั่งใช้หลักเดียวกัน:
| Field | วิธีคิด |
|---|---|
amount | ยอดสินค้าใน order หรือ sum item amount |
baseDiscountAmount | usePoint + useCoupon + useCreditNote |
shippingAmount | shipping/fee invoices ที่ยังไม่ paid |
canceledInvoiceAmount | sum invoice ที่ invoiceStatus = CANCELED |
totalDue | amount + shippingAmount - baseDiscountAmount - canceledInvoiceAmount - discount + charge |
paymentChannel | CASH, TRANSFER, QR |
| QR payload | ส่งเป็น paymentChannel: CASH |
| transfer slip | Mobile ไม่บังคับแนบสลิป; Terminal ส่ง slipPath เฉพาะช่องทาง TRANSFER |
การเปิดใช้ QR Payment ตาม environment
QR Payment ควบคุมจาก packages/api/src/config.ts:
prdสร้าง QR Payment ได้เสมอuatสร้างได้เมื่อENABLE_QR_PAYMENT = 1เท่านั้น- เมื่อ UAT ตั้ง
ENABLE_QR_PAYMENT = 0การเลือกหรือกดสร้าง QR จะแจ้งว่ายังไม่เปิดใช้ และจะไม่เรียก QR API; การพิมพ์ใบแจ้งหนี้ยังทำต่อได้โดยไม่แสดง QR Payment
payload ที่ส่ง POST /payment:
{
orderNo,
discount,
charge,
amount: amount + shippingAmount - canceledInvoiceAmount,
remark,
paymentChannel: paymentChannel === 'QR' ? 'CASH' : paymentChannel,
slipPath,
}
หลัง payOrder(payload) สำเร็จ:
- ถ้าไม่มี
slipPathจะแสดง success:ชำระเงินสำเร็จ กรุณาส่ง Incoming ที่หน้าใบแจ้งหนี้ - reload order detail/payment data
- ถ้า error message มี
ใบสั่งขายนี้ถูกใช้ไปเเล้วฝั่ง Mobile reload data แทนการ alert ทันที
ความต่าง 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 |
| ชำระเงิน | OrderPaymentScreen, payment section ใน OrderDetailForm, utils/orderPayment.ts |
| 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 |