Skip to main content

Sales Order

This menu is used to view, create, edit, and process customer sales orders, from customer selection, delivery/storefront order type, item entry, invoice creation, and payment.

This page is written as a developer handoff reference so another developer can understand the runtime flow without manually walking through the app.

App Entry Points​

AppEntry pointMain files
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

Shared service/API contracts:

TaskMobile serviceTerminal serviceMain endpoint
list ordersservices/orders.ts#getOrdersservices/orders.ts#getOrdersGET /orders
order detailgetOrderDetailgetOrderDetailGET /orders/{orderId}
create/update ordercreateOrder, updateOrdercreateOrder, updateOrderPOST /orders, PATCH /orders/{orderId}
change delivery/storefront typeupdateOrderShippingTypeupdateOrderShippingTypePATCH /orders/{orderId}/shipping-type
add/edit/delete itemcreateOrderItem, updateOrderItem, deleteOrderItemsame set/orders/{orderId}/items
edit total item weightupdateOrderItemWeightupdateOrderItemWeightPATCH /orders/{orderId}/items/{id}/weight
create invoiceservices/invoices.ts#createInvoiceservices/invoices.ts#createInvoicePOST /invoices payload { orderNo }
find invoices by ordergetInvoicesByOrderNogetInvoicesByOrderNoGET /orders/{orderNo}/invoices
paymentpayOrderpayOrderPOST /payment

Main Flow From Customer Selection​

Customer Selection And Customer Type​

CustomerList is the starting point for creating a sales order from a customer.

  • mode = default means sales order flow.
  • filter shippingType = 1 means delivery customers.
  • filter shippingType = 2 means storefront customers.
  • route filtering uses routeCode from getRoutesByBranch(...).
  • the list is loaded through getCustomerSapList({ page, search, shippingType, routeCode, isRequest }).
  • the create sales order action is the card swipe action.

Before opening the sales order, the app checks:

  1. getCustomerListAccess(user, mode, branch) must allow opening the screen and showing the primary action.
  2. getSalesOrderBlockedReason(customer, user) must not return a blocking reason.
  3. The selected customer must have customerId or id.
  4. Full customer data is loaded with getCustomerSapById(customerId).
  5. Today's existing sales order is checked with getExistingCustomerOrderForBranch(customerDetail, branch).

If the customer already has a sales order for the current branch today, the app shows an alert:

User choiceResult
Open existingnavigate to OrderDetail with orderId, orderNo
Create newnavigate to OrderDetail with orderId: null, customerId

Draft From Customer Data​

When OrderDetail receives customerId and does not have orderId, it loads the customer and maps it into a draft through mapCustomerToDraftOrder(...).

Important fields seeded into the draft:

FieldSourceNotes
branchIdbranch storageuses BranchId or branchId
customerId, customerCode, customerNamecustomer detailcustomerName is built from firstname/lastname
phone1, phone2customer detailused for display and payload
shippingTypecustomer detail / branchnon-main branches force 2
routeCode, routeNameShipTo addressused by delivery orders
shippingAddressdefault ShipTo addressdelivery orders require this before invoice creation
billAddressdefault BillTo addressused as the billing address
U_BFP_Latitude, U_BFP_LongitudeShipTo addressdelivery orders require coordinates before invoice creation
whGrpCode, whGrpNamebranch storagebinds the order to the selected branch warehouse group
orderStatuslocalstarts as DRAFT

For a new sales order (before it has an orderNo), both Mobile and POS Terminal first select the address whose type matches the field and whose isDefault is 1: BillTo for billAddress and ShipTo for shippingAddress. If no matching default address exists, each field falls back to the first address in the customer's address list. The selected shipping address also supplies the initial route and coordinates.

Mobile does not immediately call POST /orders when a draft is opened from a customer. The real order is created when the user adds an item, saves the draft, prints a document that requires an order id, or confirms invoice creation.

Terminal also creates a local draft in panel 3 first. If the flow starts from adding a product and then choosing a customer, Terminal creates the order header immediately with createOrder(...), adds the item, updates totals, and opens the detail in the split-pane workspace.

To suppress an accidental duplicate submission, Mobile and POS Terminal coalesce createOrder(...) calls made within 2.65 seconds: only the first call sends POST /orders, while later calls receive the first request's result. This guard applies only to order-header creation; order-item endpoints are unchanged.

Delivery vs Storefront​

shippingType is the main behavior switch:

ValueMeaningEffect in sales order detail
1Deliveryrequires shipping address and coordinates, shows route/distance/shipping fee data, uses Google Routes to calculate shipping fee
2Storefrontfills dateOfPickup with the current time when missing, does not require shipping coordinates, can continue into storefront/POS flows after invoice creation

Delivery shipping fee rules:

  • origin coordinate comes from branch coordinate helpers in @bsr/utils
  • destination coordinate comes from U_BFP_Latitude and U_BFP_Longitude
  • empty, non-numeric, 0, or missing branch coordinates stop calculation and show an alert
  • distance is fetched through getGoogleRouteDistanceMeters(...)
  • first 150 km are free
  • chargeable distance is 15 THB/km
  • remaining distance greater than or equal to 500 meters rounds up by 1 km

Switching Delivery/Storefront In Order Detail​

Users can switch order type from the header/meta section in OrderDetail.

Developer notes:

  • Mobile and Terminal use the same pattern: update local state first, then call updateOrderShippingType(...) if orderId exists.
  • If the request fails, rollback local detail to the previous value.
  • If switching to storefront fills dateOfPickup, call updateOrder(...) again to persist pickup time.
  • Delivery orders must pass shipping address and coordinate checks before invoice creation.
  • Non-main branches include additional logistics fields such as vehicleRelease and documentMove; missing values block important actions.

Adding Items And Persisting The Order​

When an item is added from the product picker:

  1. validate quantity as an integer greater than 0
  2. weight-based items may be created with weight 0; subsequent weight edits use the dedicated weight action
  3. ensure quantity does not exceed available stock
  4. if the product has step pricing and quantity qualifies, load step price before price calculation
  5. if the order has no orderId, call createOrder(buildOrderApiPayload(detail, currentUser))
  6. after orderId exists, create the item with createOrderItem(orderId, orderItemPayload)
  7. item payload is built through buildOrderItemPayloadForSubmit(...)
  8. if payload has no whsCode, refresh inventory from Firestore and retry lookup before throwing ไม่พบข้อมูลคลังของสินค้านี้

Order totals for ordinary item create/edit operations (the dedicated weight action uses server totals):

  • weight-based item amount = qty * price * weight
  • normal item amount = qty * price
  • summary is calculated with calculateOrderSummary(...)
  • orders in DRAFT or NEW are updated when detail load detects header amount/weight differs from item totals

Product Picker And Loading Behavior​

  • Storefront POS queues new items in FIFO order from the first selection, even before an order identity exists. The first task creates the header and every later task uses its returned orderId.
  • Each selected line is previewed immediately with item-level progress; neither header creation nor item creation blocks the full order detail or prevents the next selection.
  • The final queued item triggers a persisted-detail recheck. The order header amount and weight are repaired when they differ from the totals calculated from persisted items; customer confirmation stays disabled until this finishes.
  • The final detail response uses items[].id as the persisted item identity when present. Once the FIFO is empty it replaces completed optimistic rows, retaining only rows still in progress or failed, so a rapid add cannot duplicate a line in the cart.

Editing An Existing Item's Total Weight​

Mobile, POS Terminal order detail, and Storefront POS use the same dedicated weight-edit flow for persisted sales-order items marked weightBaseFlag. The weight row shows แก้ไขน้ำหนัก (Edit weight) instead of an editable weight input.

Permission And Modal Flow​

  • The order must allow item editing and the signed-in user must have fillWeight = 1. For existing orders, this means DRAFT or NEW with the existing item-edit permission rules. Ownership or editPrice alone does not grant weight-edit permission.
  • Tapping the button without permission shows the global alert คุณไม่มีสิทธิแก้ไข; it does not open the weight form or send a request. Permission is checked again when confirming.
  • Authorized users see แก้ไขน้ำหนักสินค้า, with the item name, an initially empty input, and the explanation กรุณาระบุน้ำหนักของสินค้าทั้งหมด ระบบจะทำการคำนวณน้ำหนักและราคาตาม Step price ให้.
  • The item form is temporarily hidden. ยกเลิก (Cancel), including the modal's back action, returns to that form with its unsaved fields preserved.
  • ตกลง (OK) requires a finite numeric value: integers and decimals such as 50, 50.5, or 0.25. Empty input, negative values, letters, commas, exponent notation, and incomplete decimals cannot be submitted. 0 passes client validation; the server may still reject the value, and invoice weight checks still apply.
  • OK opens the global confirmation alert ยืนยันแก้ไขน้ำหนักสินค้า, showing the entered total in kilograms. Canceling this alert returns to the weight form with its value preserved. Only ยืนยัน sends the request.

API Contract And Refresh​

PATCH /api/orders/{orderId}/items/{id}/weight
Content-Type: application/json; charset=utf-8
{ "weight": 50.5 }

id is the persisted order-item identifier, not a product identifier. weight is the total weight of that entire line, sent as a JSON number unchanged. For example, entering 50.5 for a line with quantity 4 still sends { "weight": 50.5 }.

This action delegates weight conversion, Step Price, and totals to the API. It does not divide the input by quantity, choose a Step Price locally, build a full item payload, or call updateOrder to overwrite header totals. Quantity/stock validation remains part of ordinary item editing and is not run against unsaved quantity fields during this separate weight action.

  1. After confirmation, show Global Loading and prevent duplicate submissions.
  2. Call updateOrderItemWeight(orderId, itemId, weight).
  3. Reload with GET /api/orders/{orderId} and use the returned order/items.
  4. After both operations succeed, close the weight/item editor and hide Global Loading. Unsaved edits in the original item form are not submitted by this action.
  5. If PATCH or reload fails, hide Global Loading and show the API error (or a fallback message). Dismissing the error restores the weight form with its input preserved. A reload failure can occur after PATCH has already succeeded.

The shared editor waits for a visible keyboard to hide before changing modal content, avoiding the documented Telpo TextInput/IME teardown race. It does not continuously sync form fields to the native Customer Display. See Storefront POS for confirmation invalidation and pending-item handling.

Implementation And Verification​

AreaSource
Shared modal, input validation, confirmation and loadingpackages/rn-ui/src/OrderItemWeightEditor.tsx (useOrderItemWeightEditor, OrderItemWeightForm)
Mobile button and save/reloadapps/mobile/src/components/OrderFlowShared/index.tsx, apps/mobile/src/screens/orderStack/orderDetailScreen/index.tsx#handleSaveItemWeight
Terminal button and save/reloadapps/pos-terminal/src/screens/HomeScreen/BookingProductPicker.tsx, apps/pos-terminal/src/screens/HomeScreen/OrderDetailForm.tsx#handleSaveOrderItemWeight
Storefront POS save/reloadapps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/index.tsx#saveOrderItemWeight
API serviceupdateOrderItemWeight in both apps' src/services/orders.ts
Regression testsapps/mobile/__tests__/orderItemWeightEditor.test.tsx, apps/mobile/__tests__/orderItemWeightApi.test.ts

Run yarn workspace @bsr/mobile test --watchman=false --runInBand orderItemWeight and yarn typecheck. The focused suite covers permission denial/recheck, numeric input, both cancellation paths, confirmation, duplicate submission, loading/error recovery, keyboard transitions, and the exact PATCH contract for both apps. Device verification should also check modal/keyboard transitions and refreshed server-calculated prices on Mobile and the POS device.

Sales Orders Created From Booking Price​

A sales order can be created from Booking Price through POST /booking-price/{bookingPriceId}/convert. In that case, the order header carries bookingPriceId or bookingPriceStorageId, and booking-origin items carry currentStepPrice into the sales order item payload.

The following price/quantity rules describe ordinary item editing. Weight changes use the dedicated weight endpoint, including for booking-origin items.

Important behavior:

  • when loading an order with bookingPriceId/bookingPriceStorageId, Mobile falls back to SAP customer data if bill address, shipping address, route, or coordinates are missing
  • currentStepPrice can be a JSON string of step-price rows or fallback JSON from buildDefaultCurrentStepPrice(...)
  • Mobile OrderDetail and Terminal OrderDetailForm/BookingProductPicker parse currentStepPrice first and use it as the step-price source when editing the row
  • when editing quantity on a booking-origin item, it may stay unchanged or be reduced; increasing beyond the original quantity shows สินค้าจองราคาแก้ไขได้เฉพาะลดจำนวนเท่านั้น
  • on save, the new quantity is matched against step price first; if no range matches, the item falls back to the product base price from inventory
  • adding a new product directly in sales order can still send currentStepPrice when the selected product has step-price context
  • developers changing step price must check both booking price and sales order because this field crosses document boundaries

Creating Invoice And Opening Payment​

The main detail action opens the invoice flow, or opens payment directly when the order status already expects payment.

Validation before invoice creation:

ConditionSource
actionState.canOpenInvoiceFlow must passgetOrderActionState(...)
branch logistics fields must be complete for non-main branchesgetBranchActionIssue()
item/amount/status validation must passgetOrderInvoiceValidationIssues(detail)
every weight-based item must have a weight other than 0getOrderInvoiceValidationIssues(detail)
delivery orders must have shipping coordinatesgetShippingCoordinateIssue(...)
latest detail must pass recheckgetOrderRecheckValidationIssues(...)

Important behavior:

  • if the order is still DRAFT or has no orderNo, the app calls PATCH /orders/{id} with orderStatus: NEW
  • if invoices already exist, the app does not create duplicates and opens payment immediately
  • if POST /invoices errors, the backend may still have created the invoice; the app calls GET /orders/{orderNo}/invoices again before failing
  • Mobile uses navigation.navigate('OrderPayment', { orderId, orderNo })
  • Terminal uses setOrderDetailView('payment') and opens the payment section in panel 3

Payment Flow​

Sales-order payment is available in three surfaces:

  • Mobile: apps/mobile/src/screens/orderStack/orderPaymentScreen/index.tsx
  • Terminal detail panel: apps/pos-terminal/src/screens/HomeScreen/OrderDetailForm.tsx
  • Storefront POS: apps/pos-terminal/src/screens/StorefrontPosStack/OrderPaymentStep/index.tsx

Payment methods and split payment​

The payment section shows เงินสด → โอน → แบ่งชำระ → QRcode (CASH → TRANSFER → OTHER → QR) in one horizontally scrollable row. Buttons keep their readable width instead of wrapping or shrinking. Storefront POS now exposes the transfer option as well.

  1. Choose แบ่งชำระ to enter จำนวนเงินสด and จำนวนเงินโอน separately.
  2. Review ยอดแบ่งชำระ, the amount those two fields must add up to.
  3. Attach a transfer slip using the same upload control as โอน, if needed. This change does not make slips mandatory.
  4. Confirm payment. The app blocks submission if either amount is blank, negative, non-finite, has more than two decimal places, or their sum does not equal the net payable totalDue. Explicit 0 is allowed; comparisons use satang to avoid floating-point rounding errors.

Inputs remain text while editing, preserving intermediate decimal input such as 400.. The request sends JSON numbers. Switching to another method omits cash and transfer from that request. Split payment here must cover the whole net payable amount; it is not the partial collection flow used in Transport.

Amounts and API contract​

FieldCalculation / meaning
Form amountMobile/Storefront use the existing company-total rules, falling back to the header when there are no items; the Terminal detail panel uses the order header with calculated totals as fallback
baseDiscountAmountusePoint + useCoupon + useCreditNote
shippingAmountunpaid shipping and fee invoices
canceledInvoiceAmountsum of invoice amounts where invoiceStatus = CANCELED
Displayed totalDuemax(0, floor(form.amount + shippingAmount - baseDiscountAmount - canceledInvoiceAmount - discount + charge))
Payload amountmax(0, floor(form.amount + shippingAmount - canceledInvoiceAmount))
discount, chargeadditional discount and charge, each floored to an integer and sent separately
paymentChannel for manual paymentCASH, TRANSFER, or OTHER
cash, transferJSON numbers, included only for OTHER; their sum must equal net payable totalDue
slipPathuploaded transfer-slip path for TRANSFER or OTHER, or an empty string

The split target is the net payable totalDue after benefits and discounts. From 13 October 2026, editing either amount fills the remainder in the other field, and an edit above the net total is rejected without replacing the previous amounts. For example, product amount 1,000 + unpaid shipping 150 − canceled invoices 50 gives payload amount = 1100. With benefits of 60, additional discount 50, and charge 10, totalDue = 1000, so split components must total 1,000. Payload amount, discount, and charge keep their existing calculations.

Example POST /api/payment body for that order:

{
"orderNo": "SO-001",
"discount": 50,
"charge": 10,
"amount": 1100,
"remark": "Split cash and transfer",
"paymentChannel": "OTHER",
"slipPath": "slips/transfer.jpg",
"cash": 400.25,
"transfer": 599.75
}

orderNo, remark, and slipPath are trimmed. cashReceived, change, local image URIs, and shippingSlipPath are not included in this payment payload. Cash/transfer-only requests keep the existing fields and omit both split fields.

Requests from this screen​

The following paths include the configured /api prefix. The host comes from packages/api/src/config.ts.

ActionMethod / pathBody
Load or refresh orderGET /api/orders/{orderId}none
Load related invoicesGET /api/orders/{orderNo}/invoicesnone
Confirm cash, transfer, or split paymentPOST /api/paymentpayment JSON above; split fields only for OTHER
Upload slipPOST /api/{orderNo}/filesmultipart files and whGrpCode; use the first returned file's path as slipPath
Retry invoice creationPOST /api/invoices/{orderNo}/retry{}
Retry shipping invoice / incomingPOST /api/transports/{transportId}/shipping/{shippingInvoiceId}/retry or /retry-incomingnone
Load invoice for printingGET /api/invoices/{invoiceId}?includeLot=truenone

Opening payment from the Terminal order-detail form also saves the order first, then creates an invoice with POST /api/invoices and { "orderNo": "..." } if no active invoice exists. See the invoice-creation section above.

QR and environment controls​

QR is a separate path. Generating QR may call POST {API_URL_QR}/getTokenScb/{branchCode} with { "userCode": "..." }, then POST {API_URL_QR}/getQrPayment/{branchCode} with amount, ref1, and ref2. Product QR uses the order number without hyphens for both references and can reuse a matching Firestore qrPayment cache entry. Unpaid shipping/fee invoices use separate QR codes with transport and shipping-invoice references.

The ตรวจสอบการชำระ button reloads GET /api/orders/{orderId} and checks for orderStatus = CONFIRMED; it does not submit POST /api/payment. The payload helper retains its legacy QR → CASH fallback, but that is not the QR check button's request flow.

  • Production permits QR generation. UAT requires ENABLE_QR_PAYMENT = 1.
  • At the 2026-09-18 update, UAT has ENABLE_QR_PAYMENT = 0: selecting QR remains possible, but generation stops before a QR API call. Showing the QR tab does not enable the gateway.
  • If UAT product QR is enabled, its gateway amount is 1 baht; shipping QR uses the shipping invoice amount.

After payOrder(payload) succeeds, the app reloads the order/payment data. If slipPath is empty, it shows ชำระเงินสำเร็จ กรุณาส่ง Incoming ที่หน้าใบแจ้งหนี้. If the API reports ใบสั่งขายนี้ถูกใช้ไปเเล้ว, the payment handlers refresh the order instead of immediately showing that error.

Mobile vs POS Terminal Differences​

TopicMobilePOS Terminal
Navigationstack: CustomerList -> OrderDetail -> OrderPaymentsplit-pane in HomeScreen: menu, list/customer picker, detail/payment
Draft from customeropens OrderDetail with customerId; does not create until necessarycreates local draft in panel 3 through openSalesOrderDraftFromCustomer(...)
Existing order todayalert asks to open existing or create newsame alert, but opens through openOrderSplitDetail(...)
Switching delivery/storefrontlocal state + PATCH /shipping-type when orderId existssame pattern, then refreshes detail/list in split-pane
PaymentOrderPayment routeorderDetailView = payment in panel 3
Storefront POShas separate storefront list/detail after invoicehas Terminal-only storefront POS menu

Developer Handoff Map​

When changing sales order behavior, start here:

Change areaStart with
customer selection / delivery-storefront filterapps/mobile/src/screens/customerStack/customerListScreen/index.tsx, apps/pos-terminal/src/components/CustomerPickerModal.tsx
draft from customerapps/mobile/src/screens/orderStack/orderDetailScreen/helpers.ts#mapCustomerToDraftOrder, apps/pos-terminal/src/screens/HomeScreen/index.tsx#buildSalesOrderPayloadFromCustomer
existing order todaygetExistingCustomerOrderForBranch(...) and handlers in CustomerList/HomeScreen
delivery/storefront switchhandleChangeShippingType in OrderDetailScreen and OrderDetailForm
shipping fee / coordinatesgetGoogleRouteDistanceMeters, calculateShippingAmountFromDistance, branch coordinate helpers in @bsr/utils
adding itemsuseProductPickerFlowBase, buildOrderItemPayloadForSubmit, createOrderItem
booking price conversion/currentStepPriceBookingPriceDetailScreen, BookingProductPicker, OrderDetailScreen, OrderDetailForm
invoice creationrunPrimaryAction in Mobile, payment section opener in Terminal
payment UIOrderPaymentScreen, OrderPaymentMethodSection, OrderDetailForm, StorefrontPosStack/OrderPaymentStep
shared payment amount, validation, payloadpackages/utils/src/orderSplitPayment.ts, packages/utils/src/orderSalesFlow.ts, packages/types/src/orderPayment.ts; Mobile re-exports shared validation/payload helpers
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