ข้ามไปยังเนื้อหาหลัก

ใบสั่งขาย

เมนูนี้ใช้ดู สร้าง แก้ไข และดำเนินการใบสั่งขายของลูกค้า ตั้งแต่เลือกลูกค้า, ระบุว่าสั่งแบบสายส่งหรือหน้าร้าน, เพิ่มสินค้า, สร้างใบแจ้งหนี้, และไปหน้าชำระเงิน

เอกสารหน้านี้ตั้งใจให้ developer อ่าน flow ได้โดยไม่ต้องกดเล่นระบบจริง

ทางเข้าในแอป

แอปจุดเข้าไฟล์หลัก
MobileHome -> OrderList mode default -> CustomerList mode default -> OrderDetailapps/mobile/src/screens/customerStack/customerListScreen/index.tsx, apps/mobile/src/screens/orderStack/orderDetailScreen/index.tsx
POS TerminalHomeScreen split-pane -> menu salesOrder -> customer picker/detail panelapps/pos-terminal/src/screens/HomeScreen/index.tsx, apps/pos-terminal/src/screens/HomeScreen/OrderDetailForm.tsx

API และ service ที่ใช้ร่วมกัน:

งานMobile serviceTerminal serviceEndpoint หลัก
list orderservices/orders.ts#getOrdersservices/orders.ts#getOrdersGET /orders
detail ordergetOrderDetailgetOrderDetailGET /orders/{orderId}
create/update ordercreateOrder, updateOrdercreateOrder, updateOrderPOST /orders, PATCH /orders/{orderId}
เปลี่ยนสายส่ง/หน้าร้านupdateOrderShippingTypeupdateOrderShippingTypePATCH /orders/{orderId}/shipping-type
เพิ่ม/แก้/ลบสินค้าcreateOrderItem, updateOrderItem, deleteOrderItemชุดเดียวกัน/orders/{orderId}/items
สร้างใบแจ้งหนี้services/invoices.ts#createInvoiceservices/invoices.ts#createInvoicePOST /invoices payload { orderNo }
หา invoice ของ ordergetInvoicesByOrderNogetInvoicesByOrderNoGET /orders/{orderNo}/invoices
ชำระเงินpayOrderpayOrderPOST /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 ลูกค้า

ก่อนเข้าใบสั่งขาย ระบบเช็ค:

  1. getCustomerListAccess(user, mode, branch) ต้องอนุญาตให้เปิดหน้าและแสดง primary action
  2. getSalesOrderBlockedReason(customer, user) ต้องไม่คืนเหตุผล block
  3. ต้องมี customerId หรือ id
  4. โหลด customer เต็มด้วย getCustomerSapById(customerId)
  5. เช็คใบสั่งขายเดิมของวันนี้ด้วย 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แหล่งข้อมูลหมายเหตุ
branchIdbranch storageใช้ BranchId หรือ branchId
customerId, customerCode, customerNamecustomer detailcustomerName ต่อจาก firstname/lastname
phone1, phone2customer detailใช้แสดงและส่ง payload
shippingTypecustomer detail / branchถ้าไม่ใช่ main branch บังคับเป็น 2
routeCode, routeNameShipTo addressใช้กับสายส่ง
shippingAddressShipTo addressสายส่งต้องมี ไม่งั้นสร้าง invoice ไม่ได้
billAddressBillTo addressfallback เป็น address แรก
U_BFP_Latitude, U_BFP_LongitudeShipTo addressสายส่งต้องมีพิกัดก่อนสร้าง invoice
whGrpCode, whGrpNamebranch storageผูกคลัง/กลุ่มคลังของสาขาที่เลือก
orderStatuslocalเริ่มเป็น 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:

  1. validate จำนวนเป็นตัวเลขเต็มและมากกว่า 0
  2. เช็คไม่เกิน available stock
  3. ถ้า product มี step price และ qty เข้าเงื่อนไข จะโหลด step price ก่อนคำนวณราคา
  4. ถ้า order ยังไม่มี orderId จะ createOrder(buildOrderApiPayload(detail, currentUser))
  5. หลังมี orderId จะสร้าง item ด้วย createOrderItem(orderId, orderItemPayload)
  6. payload item สร้างผ่าน buildOrderItemPayloadForSubmit(...)
  7. ถ้า 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 และ Terminal OrderDetailForm/BookingProductPicker จะ parse currentStepPrice ก่อน ถ้ามีข้อมูลนี้จะใช้เป็น 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 branchgetBranchActionIssue()
รายการสินค้า/ยอด/สถานะต้องถูกต้องgetOrderInvoiceValidationIssues(detail)
สายส่งต้องมี shipping coordinategetShippingCoordinateIssue(...)
หลัง reload detail ต้อง recheck อีกรอบgetOrderRecheckValidationIssues(...)

พฤติกรรมสำคัญ:

  • ถ้า order ยังเป็น DRAFT หรือยังไม่มี orderNo ระบบ PATCH /orders/{id} พร้อม orderStatus: NEW
  • ถ้ามี invoice อยู่แล้ว จะไม่สร้างซ้ำ และไป payment ทันที
  • ถ้า POST /invoices error แต่ 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
baseDiscountAmountusePoint + useCoupon + useCreditNote
shippingAmountshipping/fee invoices ที่ยังไม่ paid
canceledInvoiceAmountsum invoice ที่ invoiceStatus = CANCELED
totalDueamount + shippingAmount - baseDiscountAmount - canceledInvoiceAmount - discount + charge
paymentChannelCASH, TRANSFER, QR
QR payloadส่งเป็น paymentChannel: CASH
transfer slipMobile ไม่บังคับแนบสลิป; 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

เรื่องMobilePOS Terminal
Navigationstack: CustomerList -> OrderDetail -> OrderPaymentsplit-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 ถ้ามี orderIdpattern เดียวกัน แต่ refresh detail/list ใน split-pane
Paymentroute OrderPaymentorderDetailView = 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
แปลงจากใบจองราคา / currentStepPriceBookingPriceDetailScreen, BookingProductPicker, OrderDetailScreen, OrderDetailForm
สร้าง invoicerunPrimaryAction ใน Mobile, payment section opener ใน Terminal
ชำระเงินOrderPaymentScreen, payment section ใน OrderDetailForm, utils/orderPayment.ts
service contractapps/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

เอกสารที่เกี่ยวข้อง