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
| App | Entry point | Main files |
|---|---|---|
| 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 |
Shared service/API contracts:
| Task | Mobile service | Terminal service | Main endpoint |
|---|---|---|---|
| list orders | services/orders.ts#getOrders | services/orders.ts#getOrders | GET /orders |
| order detail | getOrderDetail | getOrderDetail | GET /orders/{orderId} |
| create/update order | createOrder, updateOrder | createOrder, updateOrder | POST /orders, PATCH /orders/{orderId} |
| change delivery/storefront type | updateOrderShippingType | updateOrderShippingType | PATCH /orders/{orderId}/shipping-type |
| add/edit/delete item | createOrderItem, updateOrderItem, deleteOrderItem | same set | /orders/{orderId}/items |
| edit total item weight | updateOrderItemWeight | updateOrderItemWeight | PATCH /orders/{orderId}/items/{id}/weight |
| create invoice | services/invoices.ts#createInvoice | services/invoices.ts#createInvoice | POST /invoices payload { orderNo } |
| find invoices by order | getInvoicesByOrderNo | getInvoicesByOrderNo | GET /orders/{orderNo}/invoices |
| payment | payOrder | payOrder | POST /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 = defaultmeans sales order flow.- filter
shippingType = 1means delivery customers. - filter
shippingType = 2means storefront customers. - route filtering uses
routeCodefromgetRoutesByBranch(...). - 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:
getCustomerListAccess(user, mode, branch)must allow opening the screen and showing the primary action.getSalesOrderBlockedReason(customer, user)must not return a blocking reason.- The selected customer must have
customerIdorid. - Full customer data is loaded with
getCustomerSapById(customerId). - 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 choice | Result |
|---|---|
Open existing | navigate to OrderDetail with orderId, orderNo |
Create new | navigate 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:
| Field | Source | Notes |
|---|---|---|
branchId | branch storage | uses BranchId or branchId |
customerId, customerCode, customerName | customer detail | customerName is built from firstname/lastname |
phone1, phone2 | customer detail | used for display and payload |
shippingType | customer detail / branch | non-main branches force 2 |
routeCode, routeName | ShipTo address | used by delivery orders |
shippingAddress | default ShipTo address | delivery orders require this before invoice creation |
billAddress | default BillTo address | used as the billing address |
U_BFP_Latitude, U_BFP_Longitude | ShipTo address | delivery orders require coordinates before invoice creation |
whGrpCode, whGrpName | branch storage | binds the order to the selected branch warehouse group |
orderStatus | local | starts 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:
| Value | Meaning | Effect in sales order detail |
|---|---|---|
1 | Delivery | requires shipping address and coordinates, shows route/distance/shipping fee data, uses Google Routes to calculate shipping fee |
2 | Storefront | fills 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_LatitudeandU_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(...)iforderIdexists. - If the request fails, rollback local detail to the previous value.
- If switching to storefront fills
dateOfPickup, callupdateOrder(...)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
vehicleReleaseanddocumentMove; missing values block important actions.
Adding Items And Persisting The Order
When an item is added from the product picker:
- validate quantity as an integer greater than
0 - weight-based items may be created with weight
0; subsequent weight edits use the dedicated weight action - ensure quantity does not exceed available stock
- if the product has step pricing and quantity qualifies, load step price before price calculation
- if the order has no
orderId, callcreateOrder(buildOrderApiPayload(detail, currentUser)) - after
orderIdexists, create the item withcreateOrderItem(orderId, orderItemPayload) - item payload is built through
buildOrderItemPayloadForSubmit(...) - 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
DRAFTorNEWare 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[].idas 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 meansDRAFTorNEWwith the existing item-edit permission rules. Ownership oreditPricealone 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, or0.25. Empty input, negative values, letters, commas, exponent notation, and incomplete decimals cannot be submitted.0passes 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.
- After confirmation, show Global Loading and prevent duplicate submissions.
- Call
updateOrderItemWeight(orderId, itemId, weight). - Reload with
GET /api/orders/{orderId}and use the returned order/items. - 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.
- 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
| Area | Source |
|---|---|
| Shared modal, input validation, confirmation and loading | packages/rn-ui/src/OrderItemWeightEditor.tsx (useOrderItemWeightEditor, OrderItemWeightForm) |
| Mobile button and save/reload | apps/mobile/src/components/OrderFlowShared/index.tsx, apps/mobile/src/screens/orderStack/orderDetailScreen/index.tsx#handleSaveItemWeight |
| Terminal button and save/reload | apps/pos-terminal/src/screens/HomeScreen/BookingProductPicker.tsx, apps/pos-terminal/src/screens/HomeScreen/OrderDetailForm.tsx#handleSaveOrderItemWeight |
| Storefront POS save/reload | apps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/index.tsx#saveOrderItemWeight |
| API service | updateOrderItemWeight in both apps' src/services/orders.ts |
| Regression tests | apps/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 currentStepPricecan be a JSON string of step-price rows or fallback JSON frombuildDefaultCurrentStepPrice(...)- Mobile
OrderDetailand TerminalOrderDetailForm/BookingProductPickerparsecurrentStepPricefirst 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
currentStepPricewhen 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:
| Condition | Source |
|---|---|
actionState.canOpenInvoiceFlow must pass | getOrderActionState(...) |
| branch logistics fields must be complete for non-main branches | getBranchActionIssue() |
| item/amount/status validation must pass | getOrderInvoiceValidationIssues(detail) |
every weight-based item must have a weight other than 0 | getOrderInvoiceValidationIssues(detail) |
| delivery orders must have shipping coordinates | getShippingCoordinateIssue(...) |
| latest detail must pass recheck | getOrderRecheckValidationIssues(...) |
Important behavior:
- if the order is still
DRAFTor has noorderNo, the app callsPATCH /orders/{id}withorderStatus: NEW - if invoices already exist, the app does not create duplicates and opens payment immediately
- if
POST /invoiceserrors, the backend may still have created the invoice; the app callsGET /orders/{orderNo}/invoicesagain 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.
- Choose แบ่งชำระ to enter จำนวนเงินสด and จำนวนเงินโอน separately.
- Review ยอดแบ่งชำระ, the amount those two fields must add up to.
- Attach a transfer slip using the same upload control as โอน, if needed. This change does not make slips mandatory.
- 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. Explicit0is 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
| Field | Calculation / meaning |
|---|---|
Form amount | Mobile/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 |
baseDiscountAmount | usePoint + useCoupon + useCreditNote |
shippingAmount | unpaid shipping and fee invoices |
canceledInvoiceAmount | sum of invoice amounts where invoiceStatus = CANCELED |
Displayed totalDue | max(0, floor(form.amount + shippingAmount - baseDiscountAmount - canceledInvoiceAmount - discount + charge)) |
Payload amount | max(0, floor(form.amount + shippingAmount - canceledInvoiceAmount)) |
discount, charge | additional discount and charge, each floored to an integer and sent separately |
paymentChannel for manual payment | CASH, TRANSFER, or OTHER |
cash, transfer | JSON numbers, included only for OTHER; their sum must equal net payable totalDue |
slipPath | uploaded 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.
| Action | Method / path | Body |
|---|---|---|
| Load or refresh order | GET /api/orders/{orderId} | none |
| Load related invoices | GET /api/orders/{orderNo}/invoices | none |
| Confirm cash, transfer, or split payment | POST /api/payment | payment JSON above; split fields only for OTHER |
| Upload slip | POST /api/{orderNo}/files | multipart files and whGrpCode; use the first returned file's path as slipPath |
| Retry invoice creation | POST /api/invoices/{orderNo}/retry | {} |
| Retry shipping invoice / incoming | POST /api/transports/{transportId}/shipping/{shippingInvoiceId}/retry or /retry-incoming | none |
| Load invoice for printing | GET /api/invoices/{invoiceId}?includeLot=true | none |
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
| Topic | Mobile | POS Terminal |
|---|---|---|
| Navigation | stack: CustomerList -> OrderDetail -> OrderPayment | split-pane in HomeScreen: menu, list/customer picker, detail/payment |
| Draft from customer | opens OrderDetail with customerId; does not create until necessary | creates local draft in panel 3 through openSalesOrderDraftFromCustomer(...) |
| Existing order today | alert asks to open existing or create new | same alert, but opens through openOrderSplitDetail(...) |
| Switching delivery/storefront | local state + PATCH /shipping-type when orderId exists | same pattern, then refreshes detail/list in split-pane |
| Payment | OrderPayment route | orderDetailView = payment in panel 3 |
| Storefront POS | has separate storefront list/detail after invoice | has Terminal-only storefront POS menu |
Developer Handoff Map
When changing sales order behavior, start here:
| Change area | Start with |
|---|---|
| customer selection / delivery-storefront filter | apps/mobile/src/screens/customerStack/customerListScreen/index.tsx, apps/pos-terminal/src/components/CustomerPickerModal.tsx |
| draft from customer | apps/mobile/src/screens/orderStack/orderDetailScreen/helpers.ts#mapCustomerToDraftOrder, apps/pos-terminal/src/screens/HomeScreen/index.tsx#buildSalesOrderPayloadFromCustomer |
| existing order today | getExistingCustomerOrderForBranch(...) and handlers in CustomerList/HomeScreen |
| delivery/storefront switch | handleChangeShippingType in OrderDetailScreen and OrderDetailForm |
| shipping fee / coordinates | getGoogleRouteDistanceMeters, calculateShippingAmountFromDistance, branch coordinate helpers in @bsr/utils |
| adding items | useProductPickerFlowBase, buildOrderItemPayloadForSubmit, createOrderItem |
| booking price conversion/currentStepPrice | BookingPriceDetailScreen, BookingProductPicker, OrderDetailScreen, OrderDetailForm |
| invoice creation | runPrimaryAction in Mobile, payment section opener in Terminal |
| payment UI | OrderPaymentScreen, OrderPaymentMethodSection, OrderDetailForm, StorefrontPosStack/OrderPaymentStep |
| shared payment amount, validation, payload | packages/utils/src/orderSplitPayment.ts, packages/utils/src/orderSalesFlow.ts, packages/types/src/orderPayment.ts; Mobile re-exports shared validation/payload helpers |
| 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 |