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

ใบสั่งขาย

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

เอกสารหน้านี้ตั้งใจให้ 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
แก้น้ำหนักรวมรายการupdateOrderItemWeightupdateOrderItemWeightPATCH /orders/{orderId}/items/{id}/weight
สร้างใบแจ้งหนี้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 ด้านล่าง
แก้น้ำหนักรวม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 หรือมูลค่าคะแนนคงเหลือ

  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 ได้ ส่วนการแก้น้ำหนักภายหลังใช้ flow แก้น้ำหนักแยก
  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 สำหรับการเพิ่ม/แก้รายการทั่วไป (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 แก้น้ำหนักแยกนี้

  1. เมื่อยืนยัน เปิด Global Loading และกันการส่งซ้ำ
  2. เรียก updateOrderItemWeight(orderId, itemId, weight)
  3. เรียก GET /api/orders/{orderId} และใช้ order/items ล่าสุดจากเซิร์ฟเวอร์
  4. เมื่อทั้งสองขั้นตอนสำเร็จ ปิดฟอร์มน้ำหนัก/แก้รายการ และปิด Global Loading โดยไม่ส่งค่าที่ยังไม่บันทึกในฟอร์มแก้รายการเดิมไปด้วย
  5. ถ้า PATCH หรือ reload ล้มเหลว ปิด Global Loading และแสดงข้อความ error จาก API หรือข้อความสำรอง เมื่อปิด alert จะกลับฟอร์มน้ำหนักโดยยังเก็บค่าที่กรอก กรณี reload ล้มเหลวอาจเกิดหลัง PATCH สำเร็จแล้ว

ตัวแก้ไขร่วมรอคีย์บอร์ดที่เปิดอยู่ปิดก่อนเปลี่ยนเนื้อหา Modal เพื่อหลีกเลี่ยงปัญหา TextInput/IME บน Telpo ตามที่บันทึกไว้ในโค้ด และไม่ซิ้งค์ค่าฟอร์มต่อเนื่องไปยัง Customer Display ดูการยกเลิกคำยืนยันลูกค้าและการจัดการรายการที่ยังบันทึกไม่เสร็จใน POS หน้าร้าน

โค้ดและการตรวจสอบ​

ส่วนSource
Modal ร่วม ตรวจ input ยืนยัน และ loadingpackages/rn-ui/src/OrderItemWeightEditor.tsx (useOrderItemWeightEditor, OrderItemWeightForm)
ปุ่มและการบันทึก/reload Mobileapps/mobile/src/components/OrderFlowShared/index.tsx, apps/mobile/src/screens/orderStack/orderDetailScreen/index.tsx#handleSaveItemWeight
ปุ่มและการบันทึก/reload Terminalapps/pos-terminal/src/screens/HomeScreen/BookingProductPicker.tsx, apps/pos-terminal/src/screens/HomeScreen/OrderDetailForm.tsx#handleSaveOrderItemWeight
บันทึก/reload Storefront POSapps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/index.tsx#saveOrderItemWeight
API serviceupdateOrderItemWeight ใน src/services/orders.ts ของทั้งสองแอป
Regression testsapps/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 และ 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: 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 แสดงตัวเลือกโอนด้วยแล้ว

  1. เลือก แบ่งชำระ แล้วกรอก จำนวนเงินสด และ จำนวนเงินโอน แยกกัน
  2. ตรวจ ยอดแบ่งชำระ ซึ่งเป็นยอดที่เงินสดและเงินโอนต้องรวมกันให้ครบ
  3. แนบสลิปด้วยช่องเดียวกับวิธี โอน ได้ตามต้องการ การเปลี่ยนแปลงนี้ไม่ได้เพิ่มข้อบังคับว่าต้องแนบสลิป
  4. กดยืนยัน ระบบจะไม่ส่งชำระหากช่องใดว่าง ติดลบ ไม่ใช่ตัวเลขที่ใช้ได้ มีทศนิยมเกิน 2 ตำแหน่ง หรือผลรวมไม่เท่ากับยอดสุทธิ totalDue ระบุ 0 ได้ โดยเปรียบเทียบยอดเป็นหน่วยสตางค์เพื่อลดปัญหาทศนิยมของคอมพิวเตอร์

ระหว่างพิมพ์เก็บค่าเป็นข้อความเพื่อรักษาค่าที่ยังกรอกไม่จบ เช่น 400. แล้วแปลงเป็น JSON number ตอนสร้าง payload เมื่อสลับไปช่องทางอื่นจะไม่ส่ง cash และ transfer การแบ่งชำระในหน้านี้ต้องครบยอด ไม่ใช่การรับเงินบางส่วนแบบในเมนูขนส่ง

การคำนวณยอดและ API contract​

Fieldวิธีคิด / ความหมาย
amount ในฟอร์มMobile/Storefront ใช้กติกายอดแยกบริษัทเดิมและใช้ยอดหัวใบเมื่อไม่มีสินค้า; แผงรายละเอียด Terminal ใช้ยอดหัวใบและ fallback เป็นยอดคำนวณ
baseDiscountAmountusePoint + useCoupon + useCreditNote
shippingAmountค่าขนส่งและค่าธรรมเนียมจากใบที่ยังไม่ชำระ
canceledInvoiceAmountผลรวมยอดใบแจ้งหนี้ที่ invoiceStatus = CANCELED
totalDue ที่แสดงmax(0, floor(form.amount + shippingAmount - baseDiscountAmount - canceledInvoiceAmount - discount + charge))
amount ใน payloadmax(0, floor(form.amount + shippingAmount - canceledInvoiceAmount))
discount, chargeส่วนลดเพิ่มและค่าบวกเพิ่ม ปัดลงเป็นจำนวนเต็มและส่งแยกฟิลด์
paymentChannel ตอนยืนยันชำระCASH, TRANSFER หรือ OTHER
cash, transferJSON number ส่งเฉพาะ OTHER และต้องรวมกันเท่ากับยอดสุทธิ totalDue
slipPathpath สลิปโอนสำหรับ 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 / pathBody
โหลดหรือรีเฟรชใบสั่งขายGET /api/orders/{orderId}ไม่มี
โหลดใบแจ้งหนี้ของใบสั่งขายGET /api/orders/{orderNo}/invoicesไม่มี
ยืนยันเงินสด โอน หรือแบ่งชำระPOST /api/paymentJSON ตามตัวอย่าง โดยส่งสองฟิลด์แบ่งชำระเฉพาะ OTHER
อัปโหลดสลิปPOST /api/{orderNo}/filesmultipart 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​

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

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