ใบสั่งขาย
เมนูนี้ใช้ดู สร้าง แก้ไข และดำเนินการใบสั่งขายของลูกค้า ตั้งแต่เลือกลูกค้า, ระบุว่าสั่งแบบสายส่งหรือหน้าร้าน, เพิ่มสินค้า, สร้างใบแจ้งหนี้, และไปหน้าชำระเงิน
เอกสารหน้านี้ตั้งใจให้ 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 |
ขั้นตอนหลักตั้งแต่เลือกลูกค้า
การเลือกลูกค้าและประเภทลูกค้า
หน้า 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 address | สายส่งต้องมี ไม่งั้นสร้าง invoice ไม่ได้ |
billAddress | BillTo address | fallback เป็น address แรก |
U_BFP_Latitude, U_BFP_Longitude | ShipTo address | สายส่งต้องมีพิกัดก่อนสร้าง invoice |
whGrpCode, whGrpName | branch storage | ผูกคลัง/กลุ่มคลังของสาขาที่เลือก |
orderStatus | local | เริ่มเป็น DRAFT |
Mobile จะยังไม่ยิง POST /orders ทันทีตอนเปิด draft จากลูกค้า แต่จะสร้าง order จริงเมื่อเพิ่มสินค้า, กดบันทึก draft, พิมพ์เอกสารที่ต้องมี order id, หรือยืนยันสร้างใบแจ้งหนี้
Terminal จะสร้าง local draft ใน panel 3 ก่อนเหมือนกัน แต่ถ้าเริ่มจากการเพิ่มสินค้าจาก product flow แล้วค่อยเลือกลูกค้า Terminal จะสร้าง order header ทันทีด้วย createOrder(...), เพิ่มสินค้า, update totals, แล้วเปิด detail ใน split-pane
สายส่งกับหน้าร้าน
ค่า 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 - เช็คไม่เกิน 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 ไม่ตรงกับรายการสินค้า
ใบสั่งขายที่มาจากใบจองราคา
ใบสั่งขายสามารถถูกสร้างจาก ใบจองราคา ผ่าน 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) |
| สายส่งต้องมี 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 |
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 |