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

ใบสั่งขาย

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

เอกสารหน้านี้ตั้งใจให้ 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

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 / endpointRequest body
เพิ่มสินค้าPOST /orders/{orderId}/itemspayload 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 หรือมูลค่าคะแนนคงเหลือ

  1. ดึงยอดและอัตราแลกคะแนนก่อนด้วย GET /customers/{customerCode}/point-balance ไม่มี request body. Response ที่หน้าจอใช้คือ point, usePoint, useAmount (พร้อมข้อมูล tier/credit level หากมี)
  2. ขอ 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>"
}
  1. ใช้คะแนนด้วย 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 ลูกค้า

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

  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 ที่เป็น defaultสายส่งต้องมี ไม่งั้นสร้าง invoice ไม่ได้
billAddressBillTo ที่เป็น defaultใช้เป็นที่อยู่เปิดบิล
U_BFP_Latitude, U_BFP_LongitudeShipTo addressสายส่งต้องมีพิกัดก่อนสร้าง invoice
whGrpCode, whGrpNamebranch storageผูกคลัง/กลุ่มคลังของสาขาที่เลือก
orderStatuslocalเริ่มเป็น 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:

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

การเปิดใช้ 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

เรื่อง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

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