Storefront POS
Storefront POS is the Terminal-only cashier flow for walk-in sales, cart management, payment, printing, cash drawer, and optional customer display/second screen.
Change the current warehouse
Tap the branch/warehouse chip at the upper left and confirm ต้องการเลือกคลังใหม่ใช่หรือไม่. This opens เลือกคลัง directly for the saved branch. The branch stays fixed, and warehouse choices keep the existing user permissions, including main-warehouse access. Refresh and retry keep this warehouse-only view. Selecting a warehouse saves it under the same branch and returns to Storefront POS; closing returns without saving. Home and login settings retain the full branch/warehouse selection flow.
Sales-order payment methods
OrderPaymentStep shows เงินสด → โอน → แบ่งชำระ → QRcode in a single horizontally scrollable row. Each button keeps a readable width; transfer is now visible and no longer resets the selection to cash.
- แบ่งชำระ uses
paymentChannel: OTHER, with separate cash/transfer inputs and the existing transfer-slip picker. Both inputs are required, allow zero and up to two decimal places, and must sum to the net payabletotalDuebefore submittingPOST /api/payment. - Cash/transfer amount inputs display thousands separators while typing, for example
1,000and1,434,900. Decimal text such as1,000.and1,000.50is preserved while editing. Grouping is for display; calculations, QR amounts, and payment payloads use the original numeric values. - The displayed ยอดแบ่งชำระ is the net payable total after benefits and discounts, including shipping/charges and canceled invoices, calculated by the shared Mobile helper. Editing either input fills the remaining amount in the other field. An edit above the net total is rejected, preserving both previous inputs. See Sales Order: Payment Flow for the exact formula and JSON example.
- Cash, transfer, and ordinary QR requests do not gain split fields. The ordinary QR tab follows environment gating; its check button reads the order status. The separate transfer/split-payment QR flow described below is allowed in UAT.
- The cashier owns the form. The native Customer Display shows a read-only amount/customer/order summary and labels the method เงินโอน or แบ่งชำระ เงินสดและเงินโอน as appropriate. Split input keystrokes and slip editing remain local to the cashier screen.
SCB QR for transfer and split payments
The สร้าง QR Code button is available in โอน for the entire positive net payable total, and in แบ่งชำระ after entering a positive transfer amount and passing the existing payment-form validation. The order must still be WAITING_PAYMENT, with no duplicate invoices. Before requesting QR generation, the cashier reloads the order and invoices; changes to the order number, status, or payment totals require a refresh and another attempt.
- โอน creates a QR for the full
totalDueafter benefits and discounts, including the existing shipping/charge adjustments. แบ่งชำระ creates a QR only for the entered transfer component. Both use the same fresh-reference gateway service, preserving satang. The amount must be positive and have at most two decimal places. ref1is the API-issued order number with surrounding whitespace and hyphens removed, followed by the currentHHmmss. For a normal 12–13-character order number, the reference is 18–19 characters. Branch prefixes may contain letters; the earlier numeric-only validation was removed.ref2is alwaysOTHER. Reusing the same reference within the same second is rejected in the running app; wait until the next second before generating again.- These transfer/split-payment flows can generate QR in UAT. The ordinary QRcode payment method continues to follow its existing environment gate. The transfer flows use their own references and do not reuse the ordinary order QR cache.
- The native Android Customer Display shows the generated QR, amount, and reference for either method. โอน shows the full payable amount with the heading โอนเงินด้วย QR Code; แบ่งชำระ also shows the separate cash/transfer amounts. The cashier owns amount editing; the second screen displays the summary.
Example: for order 101-261002-001, a transfer of 75.25 baht, and generation time 12:34:56, the SCB QR request payload is:
{
"amount": 75.25,
"ref1": "101261002001123456",
"ref2": "OTHER"
}
The QR service obtains an SCB access token through /getTokenScb/{branchCode}, then signs and sends the payload to /getQrPayment/{branchCode}. These are payment-gateway routes, separate from the POS payment API.
Scanned slip verification for transfer QR
Scan the transfer slip on the cashier screen. The value read from the scanner is submitted to POST /api/payment/verify-slip as { "qrCode": "<scanned value>" }. For product-payment QR in either โอน or แบ่งชำระ, the response must satisfy all of these checks:
| Response field | Required value |
|---|---|
valid | true |
alreadyUsed | false |
status.code | 1000 |
slip.amount | Matches the latest generated QR transfer amount, compared in satang |
slip.ref1 | Exactly matches the latest generated QR ref1 |
slip.ref2 | Exactly matches OTHER |
slip.transRef | Nonempty transaction reference |
info.amount is descriptive metadata; validation uses slip.amount. When every check passes, the global alert displays สลิปเงินโอนถูกต้อง, and the scanned proof permits payment confirmation subject to the normal payment guards. Merely uploading a slip image does not authorize a payment that has requested a QR in either transfer flow.
Editing payment amounts, switching methods, or generating another QR invalidates the previous proof. A verification response received after the form or QR changes is discarded, so the current QR requires verification again. Payment submission keeps the selected method: โอน sends paymentChannel: TRANSFER without split fields; แบ่งชำระ sends paymentChannel: OTHER with cash/transfer components. QR ref2 stays OTHER in both cases. Before requesting a QR, the existing manual-transfer image-upload flow remains available. Once QR creation is requested, including a failed attempt, confirmation requires verified proof for the latest QR.
Test overrides removed on 2 October 2026
The app now starts at Home and opens the order selected by the cashier. Automatic entry into order 2392, the indefinite rating hold for that order, and the temporary QR payload { "amount": 1, "ref1": "SSK260928001190427", "ref2": "OTHER" } have been removed. QR generation and slip comparison use the current form and generated references.
The original paid SCB request/response remains as a regression fixture in apps/pos-terminal/src/services/__tests__/fixtures/scb-split-payment-2026-10-02.json; it is not used by the running app. The removal was verified with 75 tests, Terminal TypeScript checking, Kotlin compilation, and an Android rebuild/install (8126987d).
App Entry Points
| App | Target |
|---|---|
| Mobile | not available |
| POS Terminal | StorefrontPosStack |
Workflow Overview
Related Services / APIs
StorefrontPosStack: POS navigation, cart, checkout, printing, and second-screen stateorders.ts/invoices.ts: order, invoice, payment, and retry behavior when POS creates accounting documentsproducts.ts: product list, stock, price, and step-price contextborrow.ts: borrow list/detail, status processing, conversion, and converted-order lookup
Permissions And Guards
| Topic | Behavior |
|---|---|
| Terminal menu key | storefrontPos |
| deptCode menu set | -2, 1, 2, 3, 4, 7, 29, 21, 8, 9, 12, 20, 22, 23, 27, 28 |
| Branch rule | Terminal-only screen; customer-display/native Android changes live under apps/pos-terminal/android |
Main Workflow
- This menu exists only in POS Terminal, not Mobile.
- Second-screen/customer-display work must follow the project AGENTS instruction: verify, commit/push, then run
yarn terminal:android:8082. - Cart totals must remain consistent with payment and printed documents.
- Do not mix this fulfillment-independent POS flow with Storefront Picking.
Continuous Product Queue
The product workspace uses one FIFO queue for new sales-order and borrow-item requests, including the first item before a document identity exists.
- The selected lines appear in the cart immediately with an item-level Adding state; product selection remains available and no full-screen loading overlay is used.
- The first queued request creates the order/borrow header and stores the returned
orderId/borrowId; later queued requests use that same identity in sequence. Document numbers are display values and are never used as item-create identities. - A successful line changes to Added. If the detail read is temporarily behind an item-create response, the local line is retained and merged with the server detail so a rapidly added line does not disappear.
- Order-detail item identities accept the API's
items[].idfield (as well as legacy item-id field names). When FIFO work is complete, the persisted detail is authoritative; only lines still saving, deleting, or failed are retained locally. This prevents completed optimistic lines from appearing a second time after refresh. - While the queue or the final order-total recheck is active, customer review/confirmation is disabled. After the final sales-order item finishes, the header amount and weight are recalculated from persisted items and repaired only when stale. Borrow flows do not use the sales-order total recheck.
- Deleting a queued line cancels it before it starts; deleting an in-flight line waits for its returned item identity and then sends the normal item DELETE request.
Plus Sales: Cross-Sell Recommendations
Plus Sales in StorefrontPosStack is driven by crossSell from the existing POST /api/orders/upsell request. The same response continues to supply stepNudge for quantity/step-price hints on cart lines. The former five-product visual preview is no longer used.
When it appears
- The upsell flow runs when opening a persisted sales order with items and after successful item additions, edits (including quantity changes), or persisted-item deletions. It requires an order identity (
orderNo,orderId, ororderStorageId) and a nonempty item list. Each queued addition reads the persisted order before requesting recommendations; the FIFO queue can save the next item while the upsell response is pending. - Match each
crossSell[].productCodeto the branch's Firebase/Firestore inventoryitemCode. Names, prices, stock, and the original product object come from Firebase, not from the upsell response. Main catalog search and Product Hub category filters do not narrow the recommendations. - Normalize codes by trimming whitespace and ignoring case. Keep API order, remove duplicate/invalid codes, and skip products absent from Firebase. Like the main catalog, only products with a positive price and positive available stock (
onHand - booking) are displayed. - Exclude codes present in either
productCodeoritemCodeon persisted order items, and exclude current cart items, including optimistic additions waiting to save. This removes only the recommendation from Plus Sales; it never removes a product from the order/cart. - Hide the entire panel on both screens when there are no eligible results. It is also hidden before a response arrives, while refreshing recommendations, on request failure, in an empty cart, and in borrow mode. There is no fallback product list or five-card limit.
Older requests cannot overwrite a newer response. Changing the opened order/document mode clears the previous suggestions; the refresh guard covers the order-detail fetch as well as the upsell request.
Cashier and customer interaction
| Surface | Behavior |
|---|---|
| Main screen: React Native cashier UI | Compact cards with name and price, without images or arrow buttons. Swipe horizontally to browse. Tap an individual card to open the same add-product modal as the product list on the left; tapping alone does not save an item. |
| Second screen: native Android Customer Display | The same filtered recommendations and prices, with horizontal scrolling only. Cards cannot add or edit products or open a modal. |
The cashier continues to own product editing. Plus Sales uses the existing product-selection handler, so permissions, duplicate-item handling, booked-price restrictions, stock, weight, step price, payloads, and API error handling follow the normal add-item flow. Cancelling the modal leaves the order unchanged; submitting uses the existing save queue.
API shape used by the client
The existing request sends branchId and items[] with productCode, qty, uomCode, and whGrpCode. The following response is illustrative; RECOMMENDED-001 must exist in Firebase to produce a card. Additional recommendation fields are not used for display or price calculation.
{
"crossSell": [{ "productCode": "RECOMMENDED-001" }],
"stepNudge": [
{
"productCode": "i2-dy-10-gkh",
"current": 9,
"nextFrom": 10,
"nextPrice": 660,
"needMore": 1
}
]
}
crossSell: [] hides Plus Sales while a valid stepNudge can still appear on its matching cart line. A recommendation card is not itself a step-price hint.
Regression checks
- No upsell response, empty/invalid
crossSell, unmatched Firebase codes, or all products excluded: no Plus Sales header or empty panel on either screen. - Mixed-case/whitespace codes, repeated codes, and more than five recommendations: match correctly, deduplicate, and preserve API order without a preview cap.
- Products already in the order by either code field, including a just-added pending cart line: absent from Plus Sales; original order lines remain intact.
- Swipe over the name, price, and card whitespace in both directions without opening a modal. Tap a cashier card to open the normal editor and cancel it without saving. Customer cards remain read-only.
- Preserve the cashier card's
pointerEvents="box-only"touch target and the Gesture HandlerScrollView. Disabling touch on the whole card previously prevented reliable scrolling; a normal React NativePressableprovides the existing tap behavior. - Verify that refresh failures and late responses do not restore stale recommendations, and that
stepNudgestill renders independently.
Customer Display: Phone Lookup And Member Consent
On the customer-entry panel of the second screen, the phone lookup control stays as a search icon until exactly 10 digits have been entered. At 10 digits it changes to Confirm with a light-green background. Removing a digit immediately restores the normal search-icon styling.
Member registration requires customer consent to the mock PDPA notice before any registration form opens.
- If staff starts Register member from the cashier screen, the cashier screen shows a waiting state while the second screen displays the PDPA notice. Accept opens the existing staff-owned registration form; Decline returns both screens to customer entry.
- If the customer starts Register member from the second screen, that screen shows the same PDPA notice first. Accept continues to the existing customer-owned registration form; Decline cancels the flow.
- The current PDPA text is a placeholder. It explicitly allows for a future customer-signature step.
- The second screen is only the consent surface while staff owns the form. It never mirrors staff keystrokes or edits the form concurrently.
Customer Display: Order Review, Advertising, And Rating
During normal order entry, the customer panel no longer shows the benefits section. Its lower, edge-to-edge advertising area is measured from the remaining panel height; on a 1920x1080 display the prepared image size is approximately 538x812 px. The customer name is limited to one line and truncates with an ellipsis.
- When the cashier explicitly sends the settled order for customer review, the advertisement is replaced by the customer-confirmation button. After the customer confirms, the advertisement returns immediately.
- When the current order status is
CONFIRMED, the second screen displays five labeled emoticons: very good (5), good (4), fair (3), bad (2), and very bad (1) from left to right. The numbers document the API mapping; they are not displayed on the buttons. The screen has no star prompt, home button, or confirmation modal. - Choosing a score posts
POST /api/orders/{id}/ratingusing the current order identity and the authenticated cashier session.remarkis always an empty string. For a score of 5, the payload is:
{
"rating": 5,
"remark": ""
}
- Buttons are disabled while saving, and duplicate requests are suppressed. The thank-you screen appears only after API success; an API failure displays its error and allows another attempt. Events for another order and late responses after leaving the screen are ignored.
- The rating screen normally waits up to 60 seconds before returning to customer entry. Selecting a score cancels that countdown while the request runs. Successful submission shows thanks for 5 seconds before returning. No fixed order or indefinite test hold remains.
- Rating submission was added in
11408089; the temporary test overrides were removed in8126987d.
Borrow List And Converted Orders
The cashier entry screen includes Convert sample queue / today's fish customers. It opens the terminal borrow list using the same paginated getBorrows({ page, search }) API as Mobile. Each row shows document number and Thai borrow type, status with creation time, customer, requester, assignee, and the return/stock-cut user.
- Selecting a row loads
getBorrowDetail(borrowId)and opens the existing product workspace inborrowmode, including the persisted items and status actions. - Back from a borrow opened this way returns to the borrow list and reloads it, rather than returning to customer entry.
- Conversion follows Mobile's main-branch
SPEC, acceptance, and user-permission guards. It opens the shared item-selection modal with product name, code, quantity, visible checkboxes, and เลือกทั้งหมด. Pressing ยืนยัน posts only the selectedborrowItemIdsto/api/borrow/{id}/convert. PARTIAL_CONVERTEDdisplays แปลงและรอคืน. Detail rows with empty/missinglotshave a light red background because those items have already been converted; rows with lots retain their normal background.- Confirming return for
PARTIAL_CONVERTEDposts all remaining item IDs (items with nonemptylots) to/api/borrow/{id}/return-items, even whenhasFailedSubBorrowis true. Lots are already attached to the items; there is no lot picker. Other eligible documents keep the existing/approveor/retryrouting. - After a successful
convertBorrow, the refreshed borrow suppliesconvertedOrderId. Go to sales order loadsgetOrderDetail(convertedOrderId)and opens the same workspace inordermode. - The converted-order handoff uses the persisted order for items, pickup/shipping, addresses, route, coordinates, and document flags; it then loads the customer profile by
customerCodefor customer-specific data such as tier, points, benefits, and fallback addresses. If that lookup fails, the persisted order customer snapshot remains usable.
See Borrow Fish for the full eligibility rules, payload examples, remaining-item validation, and shared implementation map.
Cash Drawer
After a successful payment, Terminal opens the C9D cash drawer through the installed CPOS Helper. A server/QR update that confirms the current waiting order also triggers it. Refreshing the same paid order or opening an already-confirmed order does not open it again. Drawer connection failures are reported separately from payment success. See Telpo C9D Cash Drawer for the verified helper interface and setup.
Product Hub Product Filters
Storefront product cards, stock, quantity inputs, totals in the item editor, and recommendation prices use the inventory's sales unit instead of a fixed crate label. Quantity labels prefer salUomName, then the available unit name/code fields; missing units omit the suffix. Weight-based product prices (including old prices) are per กิโลกรัม, while quantities keep their sales unit. Step Price labels share Mobile's getStepDisplayUomCode helper, including its kilogram rule and code precedence. These labels do not change quantities, calculations, or API payloads.
After a cashier selects a customer and opens Create sales order, the Terminal product picker requests the Product Hub category tree first with company=BFPDB, then with company=NFFDB, both using levels=itemType,itemGroup,sizeRange. It combines both responses and uses the existing filter preparation to merge entries with the same key under the same parent at every level, sum their counts, and retain entries unique to either company. Both requests must succeed before the combined filters are displayed. Tree entries without a key or label are excluded before rendering.
- The first filter section contains All followed by Product Hub item types. Selecting an item type narrows the second section to that type's item groups.
- The second section lists item groups. Selecting the same group again clears that selection. Scrolling either filter section does not select a chip.
- Product Hub requests use
GET /pdhub/productswithcompany=BFPDB,page=1, andpageSize=200.itemTypeanditemGroupare sent only when selected; selecting both sends both keys. - The API response is an eligibility list, not the card data source. The picker matches its
itemCodevalues against the branch's Firestore inventory and displays only the intersection, preserving Product Hub ordering while using Firestore for the visible product details, price, and stock. - When filters change quickly, only the most recent Product Hub response may update the visible catalog.
Promotion Catalog and End Dates
The Terminal StorefrontPosStack product workspace loads current promotions for the selected warehouse automatically on entry and whenever the warehouse changes. Normal product cards show a gift icon before the name and promotion end-date details for matching products. Tap โปร to show only promotion matches.
- Call
GET /pdhub/promotion-master/active/current?branchCode=NF-ACRusingPRODUCT_HUB_SERVERfor the currentBASE_API_ENV: UAT useshttps://apiproduct-uat.boonsiri.co.th, and PRD useshttps://apiproduct.boonsiri.co.th. The query value is the selectedbranch.whsGrpCode(warehouse code), even whenBranchCodediffers. A missing warehouse blocks the request. - Read the response envelope
{ data: [...], error: null, status: true, time: ... }. Match eachdata[].itemCodeagainst the current warehouse's Firestore inventory using trimmed, case-insensitive codes. Ignore blank, duplicate, and unmatched codes; the first occurrence of a repeated code is used. - Display Firestore names, prices, stock, and product data. Only items with
price > 0and available stockonHand - booking > 0appear.promotionPriceandbasePricefrom the promotion response do not overwrite the inventory price. Existing search and price sorting apply to the matched cards. - Show a gift icon before the name of every matched promotion product, including those without an end date. Append the promotion's valid
endDateafter the displayed price/unit, for example ฿210/ลัง (ถึงวันที่ 30/09/2026). Format the API's calendar-date portion asDD/MM/YYYY(Gregorian year).2026-09-30T23:59:59.000Zstays30/09/2026; no timezone conversion shifts it to October 1. Missing or invalid dates omit the suffix. Both normal and promotion catalogs show the same gift icon and end-date details.
Opening โปร clears category/type/group/size selections and retains the current search. It filters using the promotions already loaded, without repeating a successful request. สินค้า or ล้างตัวกรอง returns to the normal catalog and clears search/filter selections while retaining promotion details on matching cards. Selecting a category filter also leaves the promotion view. An empty promotion result shows no products in โปร, while the normal catalog remains available. Loading and API errors appear in the catalog; tapping โปร after a failed load retries the request. Promotion loading is independent of normal-product requests, so a late result can decorate the normal catalog without switching tabs, and an old warehouse response cannot overwrite the current warehouse's promotions. Firestore stock and price updates retain promotion details.
Tapping a matching product opens the existing add-product flow using the Firestore product. Sales permissions, Step Price, payloads, and stock validation continue through that flow.
Implementation and verification
| Concern | Source |
|---|---|
Request, branchCode, response/error handling | apps/pos-terminal/src/services/productHub.ts |
| Matching, tab state, date formatting, card text | apps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/index.tsx |
| Card wrapping and date typography | apps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/styles.ts |
| API contract tests | apps/pos-terminal/src/services/__tests__/productHubPromotions.test.ts |
| Catalog, end-date and stale-response tests | apps/pos-terminal/src/screens/StorefrontPosStack/__tests__/promotions.test.tsx |
Run from the repository root:
yarn terminal:typecheck
TZ=Asia/Bangkok yarn workspace @bsr/pos-terminal jest --runInBand --watchman=false src/screens/StorefrontPosStack/__tests__/promotions.test.tsx src/services/__tests__/productHubPromotions.test.ts
The final implementation passed Terminal typecheck and all seven tests above. Physical-device visual verification remains outstanding.
Sales Order Item Weight
Existing sales-order items use the shared total-weight editor: the แก้ไขน้ำหนัก button checks item-edit permission and fillWeight, opens the weight modal, and requires a global confirmation before PATCH /api/orders/{orderId}/items/{id}/weight with only { "weight": 50.5 }.
- This action is for persisted sales-order items, not the borrow-document flow or a product being added for the first time.
saveOrderItemWeightblocks submission while item saves or the final queued-order recheck are running, showingกรุณารอให้บันทึกรายการสินค้าเสร็จก่อน.- Before PATCH, invalidate the previous customer confirmation (
customerConfirmationRequestedandcustomerConfirmed). The cashier owns the weight form; the native Customer Display does not edit its intermediate value. - After PATCH, reload the order and map its persisted items back into the cart. Preserve local rows whose persistence is still pending or failed. Prices and totals come from the server; this weight action does not run the normal item/header recalculation path.
- Successful save/reload closes the editor. The customer must review and confirm again through the existing explicit confirmation flow before continuing.
Mobile vs POS Terminal
| Topic | Mobile | POS Terminal |
|---|---|---|
| Screen shape | not available | StorefrontPosStack |
| List behavior | dedicated stack/list screen | split-pane list card in HomeScreen |
| Detail behavior | full-screen detail | right-side detail panel |
Business-rule parity
StorefrontPosStack has a terminal-specific cashier UI, not a separate sales domain. Its business behavior must stay aligned with Mobile sales orders and the Terminal order-detail form.
- UI/UX may differ for the larger screen, keyboard, split panes, and customer display.
- Item selection, step price, weight calculation, duplicate-item handling, booked-price restrictions (
currentStepPrice), stock checks, permissions, payload fields, and API error messages must remain equivalent. - Order-level
bookingPriceIdis not an item lock; follow Mobile and use it only for booking-source fallback data. - Reuse stable shared helpers such as
src/utils/orderPayload.tsinstead of maintaining separate payload rules.
Developer Handoff Map
| Area | Code |
|---|---|
| POS stack | apps/pos-terminal/src/screens/StorefrontPosStack |
| Native Android display | apps/pos-terminal/android |
| Shared order/payment services | apps/pos-terminal/src/services/orders.ts |
| Plus Sales cards and tap/swipe behavior | apps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/PlusSalesPanel.tsx |
| Cross-sell matching and exclusion | packages/utils/src/orderUpsell.ts |
| Cross-sell regression tests | apps/pos-terminal/src/screens/StorefrontPosStack/__tests__/crossSellProducts.test.ts |
| Sales-order payment UI | apps/pos-terminal/src/screens/StorefrontPosStack/OrderPaymentStep/index.tsx |
| Split-payment amount and validation | packages/utils/src/orderSplitPayment.ts, packages/utils/src/orderSalesFlow.ts |
| Storefront borrow entry/list | apps/pos-terminal/src/screens/StorefrontPosStack/index.tsx |
| Product workspace / borrow and converted-order loading | apps/pos-terminal/src/screens/StorefrontPosStack/ProductSelectionStep/index.tsx |
| Storefront fulfillment | HomeScreen/StorefrontDetailForm.tsx |