diff --git a/README.md b/README.md index 4b60f109c..83c90146b 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ Both scripts ask whether to keep application data. Do not choose their data-purg - **Customer loyalty & CRM:** Customer directory, purchase histories, and point redemption. - **Cash drawer management:** Cash pulse on checkout and end-of-shift cash drawer reconciliations. - **Daily sales & accounting export:** Audit-ready CSV/Excel reporting with financial reconciliation identities. +- **Multi-counter ordering:** Separate order capture, cashier payment, or combined counter terminals with shared LAN queue and QR ticket lookup. ## Project status diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 0c3c5a5ca..caba748a9 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -646,16 +646,17 @@ frontend/src/ │ ├── kds/ Kitchen display, login, tabs and kanban views │ ├── layout/ Title bar, sidebar, status bar, auth guard, theme sync │ ├── orders/ Order card, history grid, refund modal -│ ├── pos/ Cart, product grid, payment, number pad, checkout modals +│ ├── pos/ Cart, product grid, payment, number pad, checkout modals, +│ │ payment queue drawer, payment ticket modal │ ├── products/ Product image uploader │ ├── providers/ I18nProvider │ ├── settings/ The settings screen's building blocks: -│ │ BetaChannelToggle, ChargesSettingsCard, CurrencyResetDialog, DatabaseSettingsTab, -│ │ GeneralSettingsTab, HealthCheckDialog, -│ │ InitializeDatabaseDialog, LocalePreferencesPanel, MasterPinPrompt, -│ │ PaymentMethodsSettings, PermissionAuditLog, PermissionMatrix, -│ │ PrintersSettingsTab, SettingsTabShell, TaxConfigurationPanel, -│ │ Toggle, WhatsAppEnableCard +│ │ BetaChannelToggle, ChargesSettingsCard, CountersSettingsTab, +│ │ CurrencyResetDialog, DatabaseSettingsTab, GeneralSettingsTab, +│ │ HealthCheckDialog, InitializeDatabaseDialog, LocalePreferencesPanel, +│ │ MasterPinPrompt, PaymentMethodsSettings, PermissionAuditLog, +│ │ PermissionMatrix, PrintersSettingsTab, SettingsTabShell, +│ │ TaxConfigurationPanel, Toggle, WhatsAppEnableCard │ ├── support/ The support hub: SupportTicketForm, DiagnosticsPanel │ ├── tables/ Floorplan editor, table move modal, table turnover badge │ ├── ui/ shadcn/ui primitives diff --git a/docs/architecture/order-lifecycle.md b/docs/architecture/order-lifecycle.md index 40953263c..6e86cf637 100644 --- a/docs/architecture/order-lifecycle.md +++ b/docs/architecture/order-lifecycle.md @@ -128,6 +128,49 @@ Lifecycle rules before and at release: collected funds requires refunding the collected amount in full through `/api/refunds` first (`409` with `REFUND_REQUIRED_BEFORE_CANCEL`), preserving bills and audit history. +## Multi-counter ordering (create here, pay there) + +FloCafe supports owner-configured multi-counter ordering (`multi_counter_enabled`, disabled by +default). In this workflow, separate physical terminals act as ordering stations, payment cashiers, +or combined stations: + +- **Named counters and roles.** The store owner defines counters with roles: `ordering` (create order + and send to payment), `payment` (manage awaiting payment queue and take tender), or `combined` + (both standard checkout and send to payment). Each POS terminal selects its active counter, + remembered locally on that device scoped to the active store. +- **Workflow guidance without ownership gating.** Counter roles guide UI defaults without restricting + underlying staff permissions: ordering counters default to order building and 'Send to payment'; + payment counters automatically open the shared payment queue and orient the cart CTA toward the + queue; combined counters retain standard direct checkout while allowing explicit 'Send to payment'. + Staff can view and act on any order across counters; orders are never ownership-gated. +- **Real orders and bills.** "Send to payment" creates real `orders` and `bills` records, never + transient held-order JSON. Orders record `dispatch_policy = 'on_payment'` and start in + `checkout_stage = 'awaiting_payment'`. +- **Supported order types.** Multi-counter checkout is supported for **takeaway and dine-in** orders. + Delivery orders cannot use "Send to payment" (`400`). Takeaway is the order type that can combine + both multi-counter checkout and scheduled kitchen release. +- **Kitchen suppression prior to release.** Orders with `dispatch_policy = 'on_payment'` set + `release_status = 'scheduled'` and defer stock deduction, KDS display, active-order heartbeats, and + automatic KOT printing until full payment is collected. +- **Shared transactional release gate.** When full payment across all bills settles: + - If unscheduled, the order is atomically released to the kitchen via `releaseScheduledOrder`. + - If scheduled for a future instant, `checkout_stage` transitions to `'paid'`, while + `release_status` stays `'scheduled'` until the scheduled instant arrives (or manual release is + triggered). + - Manual release (`POST /api/orders/:id/release`) on an unpaid `on_payment` order fails with `409` + (`ORDER_NOT_PAID`). + - Prepayment does not bypass kitchen preparation (orders with unserved kitchen items keep + `orders.status = 'pending'`) and does not clear occupied dine-in tables. +- **Paid-but-blocked tickets.** If catalog preconditions or inventory levels fail at release time + after payment, the transaction atomically transitions `release_status = 'blocked'` with + `release_blocked_reason` without reverting the committed payment. The ticket remains clearly + visible in the payment queue for explicit staff retry after restocking or refund/cancellation. +- **Shared LAN queue and QR ticket identification.** Terminals retrieve tickets via + `GET /api/orders/awaiting-payment` (cursor-paginated with counts) or lookup by ticket/order number + via `GET /api/orders/by-ticket/:ticket`. Tickets display a human-readable number and QR code + (`FLOCAFE-TICKET:`), which carries zero authentication or payment authority, and can + be printed as a thermal ticket slip (`POST /api/printers/print-payment-ticket`). + ## Discounts Three surfaces apply discounts, all gated by the discount permission for their scope and by the same @@ -299,12 +342,17 @@ its amount via `PATCH /api/bills/:id/payments/:index/method` (see [the Bills API ## Settlement and completion A bill moves `unpaid` to `partial` to `paid`; refunds move it to `partially_refunded` or `refunded`. -When a payment settles the last unpaid bill of an order, an immediate or already-released order -becomes `completed` (unless it was already completed or cancelled) and its dine-in table is released -to `available`; an unreleased scheduled order remains `pending` at payment time, then completes on -release when KDS is disabled (or no active unserved items exist) or when its last active kitchen -item transitions to `served` while KDS is enabled, provided the order remains settled and -non-refunded. A payment never resurrects a terminal order. +When a payment settles the last unpaid bill of an order with immediate dispatch +(`dispatch_policy = 'immediate'`), an already-released order becomes `completed` (unless it was +already completed or cancelled) and its dine-in table is released to `available`. Prepayment under +multi-counter ordering (`dispatch_policy = 'on_payment'`) preserves `orders.status = 'pending'` and +keeps occupied dine-in tables occupied: completing payment transitions `checkout_stage = 'paid'` and +triggers release to the kitchen (if unscheduled or due), but the order completes only after food +service concludes. An unreleased scheduled or multi-counter order remains `pending` at payment time, +then completes on release when KDS is disabled (or no active unserved items exist) or when its last +active kitchen item transitions to `served` while KDS is enabled, provided the order is not a dine-in +order with an active table and remains settled and non-refunded. A payment never resurrects a terminal +order. When a discount, an order mutation (`syncUnpaidBillsForOrder`), or a `POST /api/bills/generate` re-sync reduces the remaining balance of a partially paid bill to exactly zero (where collected funds diff --git a/docs/architecture/printing.md b/docs/architecture/printing.md index 7f14b623b..6a96a4b99 100644 --- a/docs/architecture/printing.md +++ b/docs/architecture/printing.md @@ -159,10 +159,11 @@ four whitespace-separated tokens read as a sentence. | WebUSB | `frontend/src/lib/printer/PrinterService.ts` | Encoder output, sent over the browser's WebUSB API. | | Delivery slip | `frontend/src/lib/printer/delivery-slip-encoder.ts` and `delivery-slip-web-print.ts` | A `DeliverySlipDocument`'s contact facts, rendered as bytes or HTML. | | Tax bill | `frontend/src/lib/printer/tax-bill-encoder.ts` | A raw `Bill` and `Tenant`, not a document. | +| Payment ticket slip | `printPaymentTicket` in `main/printers/thermal.ts` | Order metadata, item lines, balance due, and ESC/POS QR code (`FLOCAFE-TICKET:`) for multi-counter ordering. | -The tax bill encoder is the single exception to "renderers consume documents". It targets the -detailed per-component tax receipt, a different receipt shape from `PrintDocument` v1, and reads -the bill directly. +The tax bill encoder and payment ticket slip generator are exceptions to "renderers consume +documents": the tax bill encoder reads `Bill` directly for per-component tax detail, while +`printPaymentTicket` formats a compact payment ticket slip and QR code from order items and totals. ### Browser HTML printing diff --git a/docs/reference/api.md b/docs/reference/api.md index 13fccebdc..6edf0bfc5 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -289,6 +289,18 @@ Router: `main/routes/kitchen-stations.ts`. Full path: `/api/kitchen-stations`. | `PUT` | `/:id/users` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `user_ids` | Replaces the station's assigned `user_ids`. | | `DELETE` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | +### Counters + +Router: `main/routes/counters.ts`. Full path: `/api/counters`. + +| Method | Path | Authorization | Parameters | Response | +| -------- | ------ | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/` | any of `pos.use`, `orders.read`, `orders.create`, `payments.take`, `settings.view`, `settings.manage` (or owner) | optional query: `?include_inactive` (or `?all`) | `{ counters: [ ... ] }`, ordered by `sort_order ASC, name ASC`. Returns active counters by default, or all counters when requested. | +| `GET` | `/:id` | any of `pos.use`, `orders.read`, `orders.create`, `payments.take`, `settings.view`, `settings.manage` (or owner) | path: `id` | `{ counter }`. `404` when counter is not found. | +| `POST` | `/` | active owner with `settings.manage` | body: `name` (required, trimmed), `role` (`ordering` \| `payment` \| `combined`, default `combined`), `sort_order` (integer, default 0), `is_active` (boolean, default true) | `201` with `{ counter }`. `400` on missing name or invalid role; `403` for non-owner. | +| `PUT` | `/:id` | active owner with `settings.manage` | path: `id`; body: `name`, `role`, `sort_order`, `is_active` | `{ counter }`. `400` on invalid payload; `403` for non-owner; `404` when counter is not found. | +| `DELETE` | `/:id` | active owner with `settings.manage` | path: `id` | `{ success: true }`. `403` for non-owner; `404` when counter is not found. | + ### Orders Router: `main/routes/orders.ts`. Full path: `/api/orders`. @@ -301,10 +313,12 @@ settlement, and KDS-driven order-status reconciliation - is in | ------- | --------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter | query: `?view`, `?status`, `?board_status`, `?type`, `?today`, `?start_date`, `?end_date`, `?table_id`, `?before_id`, `?per_page`, `?search` | `{ orders, nextCursor? }`, newest page first. `view` accepts `all`, `active`, or `unpaid` and defaults to `all`; `active` (and `?status=active`) includes `pending`, `preparing`, `ready`, and `served` orders plus eligible KDS-unfinished `completed` orders (see [Order lifecycle](../architecture/order-lifecycle.md#order-status)); `unpaid` includes unbilled orders and orders with an unpaid or partial bill with a positive balance, excluding cancelled orders. `?board_status` accepts a comma-separated non-empty subset of `pending`, `preparing`, and `ready` (`400` otherwise) and narrows before pagination to active orders in those statuses plus eligible KDS-unfinished `completed` orders whose projected board status matches, preserving stored `orders.status` on returned rows. Status, board status, type, table, view, date, and search filters apply before pagination. `?before_id` pages backwards using the numeric id cursor and stable `created_at`, `id` ordering. `?search` matches order number, customer name, or phone. The Orders screen requests 15 matches per page; the API default remains 50. | | `GET` | `/scheduled` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + scheduled order read limiter | optional query: `cursor`, `limit` (max 200) | `{ orders, next_cursor, scheduled_count, blocked_count }`. Returns active unreleased (`release_status IN ('scheduled', 'blocked')`, non-cancelled, non-completed) orders ordered with `blocked` first and `scheduled_for ASC`, hydrated with items, bills, and customer details for the POS scheduled queue. `next_cursor` is returned when additional pages exist; `scheduled_count` and `blocked_count` report total active unreleased counts across all pages. | +| `GET` | `/awaiting-payment` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter | optional query: `cursor`, `limit` (max 200), `search` | `{ orders, next_cursor, awaiting_count, blocked_count, total_count }`. Returns active unreleased orders in `checkout_stage = 'awaiting_payment'` (or `release_status = 'blocked'` with `dispatch_policy = 'on_payment'`), ordered with `blocked` first and newest `created_at` DESC, hydrated with items, bills, customer, and table for the POS shared payment queue. Supports cursor pagination and text/ticket search. | +| `GET` | `/by-ticket/:ticket` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter | path: `ticket`; optional query: `by` (`id`) | `{ order, bill, bills }`. Hydrated order and associated bills. `ticket` accepts the raw order number or scanned QR payload (`FLOCAFE-TICKET:`), or numeric order id when `?by=id` is supplied. `404` when the ticket is not found or already paid. | | `GET` | `/:id` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter | path: `id` | `{ ...order, items, table }`. | -| `POST` | `/` | `ROLE_ACCESS.sales` + order write limiter | body: `items`, `table_id`, `customer_id`, `type`, `guest_count`, `special_instructions`, `scheduled_for`, `packaging_charge`, `delivery_charge`, `service_charge`, `online_platform`, `external_order_id`, `delivery_address`, `expected_payment_method`, `expected_payment_method_id`, `delivery_note`; header: `Idempotency-Key` | `201` with the created order. Repeating the same request body with an `Idempotency-Key` returns its stored response; reusing the key with a different body returns `409`. Optional `scheduled_for` defers kitchen release on `takeaway` and `delivery` orders (`400` on `dine_in`/`online`, past times, or skipped/repeated DST times in the store timezone), saving quoted prices and totals with `release_status = 'scheduled'` without touching stock, KDS, or KOT; see [Scheduled orders and kitchen release](../architecture/order-lifecycle.md#scheduled-orders-and-kitchen-release). `expected_payment_method` and `delivery_note` are stored only for `type: 'delivery'`: without an id, the method keeps its legacy string/sentinel resolution (`unknown` by default, `pending`, `cash`, `card`, or an active custom method name), and is never recorded as a payment; `400` for any other method or a note over 200 characters. A custom method whose name matches a built-in or sentinel needs its id to be identified as custom. An optional `expected_payment_method_id` must be a positive safe integer naming an **active** configured method; the server stores its canonical name and requires any supplied non-empty name to match case-insensitively. Invalid, unknown, or inactive ids return `400`. The id is a historical marker, not a foreign key, so renaming or deleting the method later never rewrites an order; only `orders.expected_payment_method_id` decides that the stored name prints literally on a delivery slip. Non-delivery orders persist both expected-method fields as `null`. An item's `variant_id` is required when the product has active variants, and `400` when it names an unknown, foreign, or inactive variant; `unit_price` is never taken from the client. | +| `POST` | `/` | `ROLE_ACCESS.sales` + order write limiter | body: `items`, `table_id`, `customer_id`, `type`, `guest_count`, `special_instructions`, `scheduled_for`, `dispatch_policy`, `checkout_stage`, `packaging_charge`, `delivery_charge`, `service_charge`, `online_platform`, `external_order_id`, `delivery_address`, `expected_payment_method`, `expected_payment_method_id`, `delivery_note`; header: `Idempotency-Key` | `201` with the created order. Repeating the same request body with an `Idempotency-Key` returns its stored response; reusing the key with a different body returns `409`. Optional `scheduled_for` defers kitchen release on `takeaway` and `delivery` orders (`400` on `dine_in`/`online`, past times, or skipped/repeated DST times in the store timezone), saving quoted prices and totals with `release_status = 'scheduled'` without touching stock, KDS, or KOT; see [Scheduled orders and kitchen release](../architecture/order-lifecycle.md#scheduled-orders-and-kitchen-release). Optional `dispatch_policy` (`immediate` or `on_payment`, default `immediate`) supports multi-counter ordering for `takeaway` and `dine_in` (`400` on `delivery`); when `on_payment`, creates the order with `checkout_stage = 'awaiting_payment'`, `release_status = 'scheduled'`, defers kitchen stock deduction, creates an initial bill, and returns `qr_payload` (`FLOCAFE-TICKET:`) and `qr_data_url` for ticket scanning; see [Multi-counter ordering](../architecture/order-lifecycle.md#multi-counter-ordering-create-here-pay-there). If supplied, optional `checkout_stage` must match the dispatch policy: `'awaiting_payment'` for `on_payment`, or `'paid'` for `immediate` (`400` otherwise). `expected_payment_method` and `delivery_note` are stored only for `type: 'delivery'`: without an id, the method keeps its legacy string/sentinel resolution (`unknown` by default, `pending`, `cash`, `card`, or an active custom method name), and is never recorded as a payment; `400` for any other method or a note over 200 characters. A custom method whose name matches a built-in or sentinel needs its id to be identified as custom. An optional `expected_payment_method_id` must be a positive safe integer naming an **active** configured method; the server stores its canonical name and requires any supplied non-empty name to match case-insensitively. Invalid, unknown, or inactive ids return `400`. The id is a historical marker, not a foreign key, so renaming or deleting the method later never rewrites an order; only `orders.expected_payment_method_id` decides that the stored name prints literally on a delivery slip. Non-delivery orders persist both expected-method fields as `null`. An item's `variant_id` is required when the product has active variants, and `400` when it names an unknown, foreign, or inactive variant; `unit_price` is never taken from the client. | | `PATCH` | `/:id/schedule` | `orders.create` or `orders.status.update` + order write limiter | path: `id`; body: `scheduled_for` | `{ order }`. Updates `scheduled_for` (validated in the store timezone). For unreleased orders in `release_status = 'scheduled'`, resets `release_status` to `'scheduled'` and clears `release_blocked_reason`; for held orders in `release_status = 'blocked'`, preserves `release_status = 'blocked'` and `release_blocked_reason` so only explicit `POST /:id/release` retries release. Records `schedule_updated` in `order_audit_log`. Allowed before release even after payment; `409` once released, completed, or cancelled. | -| `POST` | `/:id/release` | `orders.create` or `orders.status.update` + order write limiter | path: `id` | `{ order, already_released, kot_auto_printed }` on release (`order_released` in `order_audit_log`), or `409` with `code: 'RELEASE_BLOCKED'` and `{ order }` when tracked product/variant/add-on stock or catalog validation holds the whole order in `release_status = 'blocked'`. | +| `POST` | `/:id/release` | `orders.create` or `orders.status.update` + order write limiter | path: `id` | `{ order, already_released, kot_auto_printed }` on release (`order_released` in `order_audit_log`), or `409` with `code: 'RELEASE_BLOCKED'` and `{ order }` when tracked product/variant/add-on stock or catalog validation holds the whole order in `release_status = 'blocked'`. Returns `409` (`ORDER_NOT_PAID`) when an `on_payment` multi-counter order is not fully paid. | | `POST` | `/:id/items` | `ROLE_ACCESS.sales` + order write limiter | path: `id`; body: `items`, `special_instructions`; header: `Idempotency-Key` | Appends items and returns the recomputed order. Honours `Idempotency-Key`. Items resolve a `variant_id` under the same rules as order creation, priced with `variant.online_price` when the order carries an `online_platform`. Returns `409` (`SCHEDULED_ORDER_PAID`) on a scheduled order once a payment has been recorded. | | `PATCH` | `/:id/status` | `orders.status.update` (default roles: `ROLE_ACCESS.orderStatus`) + order write limiter | path: `id`; body: `status`, `reason`, `override_pin`, `free_table`, `approver_id`, `manager_id`, `user_id` | Order-level transition. Allowed targets: `pending` to `preparing`, `ready`, `served`, `completed`, `cancelled`; `preparing` to `ready`, `served`, `completed`, `cancelled`; `ready` to `served`, `completed`, `cancelled`; `served` to `completed`, `cancelled`. `completed` and `cancelled` are terminal. Repeating the current status is a no-op. See the status safeguard rules below. | | `PATCH` | `/:id/customer` | `ROLE_ACCESS.ownerManager` + order write limiter | path: `id`; body: `customer_id` | `{ order: { ...order, customer } }`. Returns `409` (`SCHEDULED_ORDER_PAID`) on a paid scheduled order. See [Order lifecycle](../architecture/order-lifecycle.md#bills) for active-order, bill-state, tax-recompute, and audit rules. | @@ -568,6 +582,7 @@ Router: `main/routes/printers.ts`. Full path: `/api/printers`. | `POST` | `/print-menu` | `catalog.view` and `printing.execute` | body: boolean `includeInactive`, `includeOutOfStock`, `includeHidden`, `includeDescriptions`, `includeModifiers`; optional `paperWidth` (`58` or `80`) and `printerId` | `200` physical-print success, or `webusb: true` with byte array for browser delivery; `400` invalid input or `printer_not_configured`, `404` unknown `printerId` (`printer_not_found`), `409` missing regional configuration, `422` empty selection, `502` print failure. Prints to `printerId` when given, otherwise to the configured default, across connection types. | | `POST` | `/print-bill` | `ROLE_ACCESS.sales` | body: `billId`, `orderId`, `isReprint`, `preview`, `useUnicode`, `arabicShaping` | Body `billId` or `orderId`, plus `isReprint`, `preview`, `useUnicode`, `arabicShaping`. `preview` returns the rendered payload without sending it to the device. | | `POST` | `/print-kot` | `printing.execute` | body: `orderId`, `stationName`, `items`, `useUnicode`, `arabicShaping` | Body `orderId`, optional `stationName` and `items`, plus `useUnicode` and `arabicShaping`. Returns `409` when `orderId` is an unreleased scheduled order. | +| `POST` | `/print-payment-ticket` | `printing.execute` | body: `orderId` (or `order_id`), optional `printerId` (or `printer_id`), `useUnicode`, `preview` | Prints a payment ticket slip with order number, totals, and QR code to the default or specified printer, or returns `{ webusb: true, bytes, printerName }` for browser delivery, or `{ preview: true }`. `400` on missing order id or when no printer is configured, `404` when order is not found, `409` if regional settings are missing, and `502` on print failure. | | `GET` | `/delivery-slip-payment/:orderId` | `printing.execute` | path: `orderId` | `200` with optional `payment` fields `status`, `amount`, `amountDue`, `formattedAmount`, `formattedAmountDue`, `method`, `methods`, and `expectedMethod` (the order's expected collection method, absent when unknown); `404` if the order does not exist. Uses the order total when no bill exists, otherwise the latest bill or all same-order bills in that bill's split group and their stored balances. | | `POST` | `/print-delivery-slip` | `printing.execute` | body: `orderId`, `useUnicode`, `arabicShaping` | Body `orderId` plus optional `useUnicode` and `arabicShaping`. Includes the full customer number when either `bill_delivery_show_customer_phone_always` or `bill_show_customer_phone` is on, and omits it only when both are off. Prints the delivery address, delivery note, order note, items, and payment status; an unpaid balance names the expected collection method. No bill is required: without a bill the slip shows the order total; otherwise it uses the selected bill balance(s), including any positive balance on refund-marked bills, as the amount due. Paid bills show the captured method(s) and total; refunds retain their refund status; see [product invariants](product-invariants.md). | @@ -665,7 +680,7 @@ Router: `main/routes/settings.ts`. Full path: `/api/settings`. | `GET` | `/bill-templates` | `ROLE_ACCESS.ownerManager` | none | - | | `PUT` | `/printing` | `ROLE_ACCESS.ownerManager` + settings write limiter | none | Body is the whole printing settings object; rejected with `400` when it is not an object. | | `GET` | `/:key` | `ROLE_ACCESS.allStaff` + settings read limiter | path: `key` | One setting. Registered after every named route, so a named key resolves to its named handler. | -| `PUT` | `/:key` | `ROLE_ACCESS.ownerManager` + settings write limiter | path: `key`; body: `value` | Body `value`. Only the allow-listed non-sensitive keys are accepted (including `pos_category_sort`, `pos_item_sort`, `require_open_shift`, `one_shift_per_staff`, and `auto_lock_minutes` in `0`, `1`, `5`, `15`, or `30`); sensitive keys need their named route. `key: one_shift_per_staff` with `value: false` returns `409 multiple_shifts_open` while more than one shift is open. `key: currency` returns `409 currency_change_requires_reset` on any value change. | +| `PUT` | `/:key` | `ROLE_ACCESS.ownerManager` + settings write limiter | path: `key`; body: `value` | Body `value`. Only the allow-listed non-sensitive keys are accepted (including `pos_category_sort`, `pos_item_sort`, `require_open_shift`, `one_shift_per_staff`, and `auto_lock_minutes` in `0`, `1`, `5`, `15`, or `30`, and `multi_counter_enabled` which requires the owner role); sensitive keys need their named route. `key: one_shift_per_staff` with `value: false` returns `409 multiple_shifts_open` while more than one shift is open. `key: currency` returns `409 currency_change_requires_reset` on any value change. | `diagnostics_consent` and `diagnostics_transmission_enabled` are owner/manager-writable Boolean settings. Their delivery gates and consent synchronization are described in [store-attributed diff --git a/docs/reference/product-invariants.md b/docs/reference/product-invariants.md index be244e2a6..adde0b723 100644 --- a/docs/reference/product-invariants.md +++ b/docs/reference/product-invariants.md @@ -503,3 +503,48 @@ npm run test:issue-248-csv **Change policy:** changing the `quote-v1` encoding rules or stripping unmarked apostrophes without existing-record proof requires an explicit product decision, because either change can silently rename catalog items or create duplicate categories and add-ons during CSV imports. + +--- + +## Multi-counter ordering and prepayment lifecycle + +**Rule:** Multi-counter ordering separates order capture ("Send to payment") from cashier settlement: + +1. **Prepayment never prematurely marks kitchen work complete or frees dine-in tables:** Completing + payment across all applicable bills for an `on_payment` order transitions its `checkout_stage` to + `'paid'` and triggers release to the kitchen (if unscheduled or scheduled instant is reached), but + leaves its kitchen `status = 'pending'` while kitchen items remain to be prepared. The order + completes only when kitchen preparation concludes (or immediately upon release for takeaway orders + when KDS is disabled) and dine-in tables remain occupied (`occupied = 1`) until table service + concludes. +2. **Orders and bills are real database entities, never held-order JSON:** An order sent to payment is + stored immediately in `orders`, `order_items`, and `bills` with quoted pricing snapshots. +3. **Multi-counter orders are never ownership-gated:** Any staff member at any payment or combined + counter can view, search, and settle any awaiting-payment order. +4. **Committed payment survives release failures:** If inventory deduction or catalog validation + fails during kitchen release after payment, the collected payment is never rolled back or + discarded; the order transitions to `release_status = 'blocked'` (`paid-but-blocked`) and remains + clearly visible in the queue for staff resolution (manual release retry or refund/cancellation). +5. **Prepayment edits are locked after initial payment:** Item additions, item edits, discounts, and + customer adjustments are permitted on an `awaiting_payment` order only while no payments have + been collected. + +**Reason:** In busy multi-counter environments (cafes, food courts, quick-service diners), prepaying +customers must not trigger kitchen completion notifications before food is prepared, and table +tracking for dine-in guests must not drop off POS maps while guests are dining. Payment transactions +cannot be rolled back due to kitchen inventory errors; staff need visibility into prepaid orders +needing stock resolution. + +**Enforced by:** `main/routes/bills.ts` (`completeExactBalanceSettlement`, `applyPaymentBatch`, +`handlePostPaymentRelease`), `main/services/scheduled-orders.ts` (`releaseScheduledOrder`, +`assertScheduledOrderUnpaidForEdit`), `main/routes/orders.ts` (`POST /api/orders`, +`GET /api/orders/awaiting-payment`, `GET /api/orders/by-ticket/:ticket`). + +**How to verify:** + +```sh +npm run test:multi-counter-ordering +``` + +**Change policy:** Altering the prepayment lifecycle to auto-complete orders or bypass inventory +release gates requires an explicit product architecture review. diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index e2e5cb1f1..1375c9708 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -201,7 +201,9 @@ category narrowing is re-evaluated inside the transaction that applies a status create orders" row above — can view and act on every order, including ones other staff created. There is no per-order `user_id` check anywhere in the authorization model. Restriction is by permission and, for kitchen operations, by KDS stage, station, and category. Accountability comes -from audit attribution, not from hiding orders between staff. +from audit attribution, not from hiding orders between staff. Multi-counter ordering follows this +invariant: counter roles (`ordering`, `payment`, `combined`) guide interface defaults, but staff +permissions remain authoritative and orders are never ownership-gated across counters. **Expenses.** Expense records are store-wide: `expenses.view` reads them, and `expenses.manage` together with view writes, voids, and replaces them. There is no per-expense ownership check, and diff --git a/frontend/e2e/database-export-import.spec.ts b/frontend/e2e/database-export-import.spec.ts index 1745f7a20..8260d69ff 100644 --- a/frontend/e2e/database-export-import.spec.ts +++ b/frontend/e2e/database-export-import.spec.ts @@ -211,7 +211,7 @@ test.describe('Database Settings JSON Export and Import', () => { const contaminatedPayload = { app: 'FloDesktop', - schema_version: '123', + schema_version: '124', data: { settings: [], categories: [], diff --git a/frontend/e2e/multi-counter-ordering.spec.ts b/frontend/e2e/multi-counter-ordering.spec.ts new file mode 100644 index 000000000..a4d69781a --- /dev/null +++ b/frontend/e2e/multi-counter-ordering.spec.ts @@ -0,0 +1,597 @@ +import fs from 'fs'; +import path from 'path'; +import { test, expect, type Page } from '@playwright/test'; +import { E2E_BASE_URL as BASE } from './helpers/urls'; +import { E2E_PASSWORD, getE2eToken, setLanguage } from './helpers/test-auth'; + +const DEFAULT_LOCAL_EVIDENCE = + '/Users/gurkiratkhaira/.no-mistakes/evidence/01M64ZVACDJJ8WG82NMYQVDPS5'; +const EVIDENCE_DIR = + process.env.EVIDENCE_DIR || + (fs.existsSync(DEFAULT_LOCAL_EVIDENCE) + ? DEFAULT_LOCAL_EVIDENCE + : path.join('/tmp', 'no-mistakes-evidence', '01M64ZVACDJJ8WG82NMYQVDPS5')); + +async function captureScreenshot(page: Page, filename: string): Promise { + try { + if (!fs.existsSync(EVIDENCE_DIR)) { + fs.mkdirSync(EVIDENCE_DIR, { recursive: true }); + } + await page.screenshot({ path: path.join(EVIDENCE_DIR, filename), fullPage: true }); + } catch (err) { + console.warn(`Could not save screenshot ${filename}:`, err); + } +} + +test('Multi-counter ordering flow: Owner config, create & pay, queue, table occupancy, release gates, and stock retry (#361)', async ({ + page, + request, +}) => { + test.setTimeout(180_000); + await page.setViewportSize({ width: 1280, height: 800 }); + + const ownerToken = getE2eToken('e2e-owner', 'owner@flo.local', 'owner'); + const ownerHeaders = { + Authorization: `Bearer ${ownerToken}`, + 'Content-Type': 'application/json', + }; + + const runId = Math.random().toString(36).slice(2, 6); + let orderingCounter: { id: string; name?: string; role?: string } = { id: '' }; + let paymentCounter: { id: string; name?: string; role?: string } = { id: '' }; + let combinedCounter: { id: string; name?: string; role?: string } = { id: '' }; + let createdTable: { id: number; name?: string } = { id: 0 }; + let dineInOrderId: number = 0; + let limitedProduct: { id: string; name?: string } = { id: '' }; + + try { + // ------------------------------------------------------------------------- + // Scenario 1: Owner configures multi-counter mode and manages named counters + // ------------------------------------------------------------------------- + await test.step('Scenario 1: Owner configures multi-counter mode and manages named counters in settings', async () => { + // Enable multi_counter_enabled + const settingRes = await request.put(`${BASE}/api/settings/multi_counter_enabled`, { + headers: ownerHeaders, + data: { value: 'true' }, + }); + expect(settingRes.ok()).toBeTruthy(); + + // Create ordering counter + const ordRes = await request.post(`${BASE}/api/counters`, { + headers: ownerHeaders, + data: { + name: `Ordering Counter ${runId}`, + role: 'ordering', + sort_order: 1, + }, + }); + expect(ordRes.ok()).toBeTruthy(); + orderingCounter = (await ordRes.json()).counter; + + // Create payment counter + const payRes = await request.post(`${BASE}/api/counters`, { + headers: ownerHeaders, + data: { + name: `Cashier Counter ${runId}`, + role: 'payment', + sort_order: 2, + }, + }); + expect(payRes.ok()).toBeTruthy(); + paymentCounter = (await payRes.json()).counter; + + // Create combined counter + const combRes = await request.post(`${BASE}/api/counters`, { + headers: ownerHeaders, + data: { + name: `Express Counter ${runId}`, + role: 'combined', + sort_order: 3, + }, + }); + expect(combRes.ok()).toBeTruthy(); + combinedCounter = (await combRes.json()).counter; + + // Login as owner + await page.goto(`${BASE}/auth/login`); + await page.locator('#email').fill('owner@flo.local'); + await page.locator('#password').fill(E2E_PASSWORD); + await page.locator('button[type="submit"]').click(); + await page.waitForURL((u) => !u.pathname.includes('/auth/login'), { timeout: 30_000 }); + await setLanguage(page, 'en'); + + // Navigate to Settings > Counters tab + await page.goto(`${BASE}/settings?tab=counters`); + await expect(page.getByText('Multi-Counter Ordering')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText(`Ordering Counter ${runId}`)).toBeVisible(); + await expect(page.getByText(`Cashier Counter ${runId}`)).toBeVisible(); + await expect(page.getByText(`Express Counter ${runId}`)).toBeVisible(); + + await captureScreenshot(page, '01-settings-counters-management.png'); + }); + + // ------------------------------------------------------------------------- + // Scenario 2: Ordering counter creates takeaway order and sends to payment + // ------------------------------------------------------------------------- + let createdOrderNumber: string = ''; + let createdOrderId: number = 0; + + await test.step('Scenario 2: Ordering counter builds takeaway order and sends to payment generating ticket and QR code', async () => { + await page.goto(`${BASE}/pos`); + await expect(page.getByTestId('pos-product-grid')).toBeVisible(); + + // Counter selector in Topbar + const counterSelect = page.getByTestId('pos-counter-selector-btn'); + await expect(counterSelect).toBeVisible(); + + // Select Ordering counter + await counterSelect.click(); + await page.getByTestId(`pos-counter-option-${orderingCounter.id}`).click(); + + // Add item to cart + await page.getByTestId('pos-product-card').first().click(); + await expect(page.getByTestId('cart-item')).toHaveCount(1); + + const cartShell = page.getByTestId('cart-shell'); + + // Primary checkout button should be "Send to payment" + const sendToPaymentBtn = cartShell.getByTestId('pos-send-to-payment-btn'); + await expect(sendToPaymentBtn).toBeVisible(); + + await captureScreenshot(page, '02-pos-ordering-counter-view.png'); + + // Click "Send to payment" + await sendToPaymentBtn.click(); + + // Payment ticket confirmation modal displays + const ticketModal = page.getByTestId('payment-ticket-confirmation-modal'); + await expect(ticketModal).toBeVisible(); + + const ticketNumberEl = page.getByTestId('payment-ticket-number'); + await expect(ticketNumberEl).toBeVisible(); + const rawText = (await ticketNumberEl.textContent())?.trim(); + createdOrderNumber = rawText?.replace(/^#/, '').trim() || ''; + expect(createdOrderNumber).toBeTruthy(); + + // QR code image is rendered with ticket payload + const qrImage = page.getByTestId('payment-ticket-qr'); + await expect(qrImage).toBeVisible(); + + // Print ticket button is present + const printBtn = page.getByTestId('print-payment-ticket-btn'); + await expect(printBtn).toBeVisible(); + + await captureScreenshot(page, '03-payment-ticket-confirmation-modal.png'); + + // Close modal + await page.getByTestId('payment-ticket-done-btn').click(); + await expect(ticketModal).toHaveCount(0); + + // Cart is cleared + await expect(page.getByTestId('cart-item')).toHaveCount(0); + + // Verify backend state: dispatch_policy is on_payment, stage is awaiting_payment, release deferred + const lookupRes = await request.get( + `${BASE}/api/orders/by-ticket/${encodeURIComponent(createdOrderNumber)}`, + { + headers: ownerHeaders, + }, + ); + expect(lookupRes.ok()).toBeTruthy(); + const ticketData = await lookupRes.json(); + expect(ticketData.order.dispatch_policy).toBe('on_payment'); + expect(ticketData.order.checkout_stage).toBe('awaiting_payment'); + expect(ticketData.order.release_status).toBe('scheduled'); + createdOrderId = ticketData.order.id; + + // Verify order is NOT released to KDS prematurely + const kdsRes = await request.get(`${BASE}/api/kds/orders`, { headers: ownerHeaders }); + const kdsBody = await kdsRes.json(); + expect(kdsBody.orders?.some((o: { id: number }) => o.id === createdOrderId)).toBeFalsy(); + }); + + // ------------------------------------------------------------------------- + // Scenario 3: Payment counter auto-opens queue and settles payment to release + // ------------------------------------------------------------------------- + await test.step('Scenario 3: Payment counter automatically opens shared waiting queue and settles payment to release order', async () => { + const counterSelect = page.getByTestId('pos-counter-selector-btn'); + await counterSelect.click(); + await page.getByTestId(`pos-counter-option-${paymentCounter.id}`).click(); + + // Payment counter automatically opens the shared waiting queue drawer + const queueDrawer = page.getByTestId('payment-queue-drawer'); + await expect(queueDrawer).toBeVisible({ timeout: 10_000 }); + + // Ticket is visible in queue + const ticketRow = page.getByTestId(`queue-ticket-${createdOrderNumber}`); + await expect(ticketRow).toBeVisible(); + + await captureScreenshot(page, '04-payment-queue-drawer-auto-opened.png'); + + // Click Pay on the ticket + const payBtn = ticketRow.getByTestId('queue-pay-btn'); + await payBtn.click(); + + // PaymentModal opens + await expect(page.getByRole('heading', { name: 'Payment', exact: true })).toBeVisible(); + await captureScreenshot(page, '05-payment-modal-settlement.png'); + + // Tender cash + await page.getByTitle('Cash', { exact: true }).click(); + await page.getByRole('button', { name: /^Pay [^A-Za-z]/ }).click(); + + const confirmButton = page + .getByRole('dialog', { name: 'Confirm' }) + .getByRole('button', { name: 'Pay', exact: true }); + if (await confirmButton.count()) { + await confirmButton.click(); + } + + const doneButton = page.getByRole('button', { name: 'Done' }); + await expect(doneButton).toBeVisible({ timeout: 10_000 }); + await doneButton.click(); + + // PaymentModal closes + await expect(page.getByRole('heading', { name: 'Payment', exact: true })).toHaveCount(0, { + timeout: 10_000, + }); + + // Verify order backend state: checkout_stage: paid, release_status: released + const orderRes = await request.get(`${BASE}/api/orders/${createdOrderId}`, { + headers: ownerHeaders, + }); + const orderData = await orderRes.json(); + expect(orderData.order.checkout_stage).toBe('paid'); + expect(orderData.order.release_status).toBe('released'); + expect(orderData.order.status).toBe('pending'); // Kitchen work is pending, NOT completed! + + // Queue drawer is cleared of this ticket + await expect(page.getByTestId(`queue-ticket-${createdOrderNumber}`)).toHaveCount(0); + + // Close queue drawer if open + const closeDrawerBtn = page.getByTestId('close-queue-drawer-btn'); + if (await closeDrawerBtn.isVisible()) { + await closeDrawerBtn.click(); + await expect(queueDrawer).toHaveCount(0); + } + + await captureScreenshot(page, '06-queue-cleared-after-payment.png'); + }); + + // ------------------------------------------------------------------------- + // Scenario 4: Combined counter retains normal checkout and offers Send to payment + // ------------------------------------------------------------------------- + await test.step('Scenario 4: Combined counter retains normal checkout while offering explicit Send to payment', async () => { + const counterSelect = page.getByTestId('pos-counter-selector-btn'); + await counterSelect.click(); + await page.getByTestId(`pos-counter-option-${combinedCounter.id}`).click(); + + // Add item to cart + await page.getByTestId('pos-product-card').first().click(); + await expect(page.getByTestId('cart-item')).toHaveCount(1); + + const cartShell = page.getByTestId('cart-shell'); + + // Both "Send to payment" and "Place Order" must be visible and available + await expect(cartShell.getByTestId('pos-send-to-payment-btn')).toBeVisible(); + await expect(cartShell.getByRole('button', { name: 'Place Order' })).toBeVisible(); + + await captureScreenshot(page, '07-pos-combined-counter-view.png'); + + // Remove item to clean cart + await cartShell.locator('button[aria-label="Remove"]').first().click(); + await expect(page.getByTestId('cart-item')).toHaveCount(0); + }); + + // ------------------------------------------------------------------------- + // Scenario 5: Dine-in table occupancy preserved through prepayment lifecycle + // ------------------------------------------------------------------------- + await test.step('Scenario 5: Dine-in table occupancy is preserved through multi-counter prepayment', async () => { + // Create a dine-in table + const tableRes = await request.post(`${BASE}/api/tables`, { + headers: ownerHeaders, + data: { + number: `MC-${runId}`, + capacity: 4, + }, + }); + expect(tableRes.ok()).toBeTruthy(); + createdTable = (await tableRes.json()).table; + + // Create dine-in on_payment order + const dineInOrderRes = await request.post(`${BASE}/api/orders`, { + headers: ownerHeaders, + data: { + type: 'dine_in', + table_id: createdTable.id, + dispatch_policy: 'on_payment', + items: [{ product_id: 'e2e-product', quantity: 2 }], + }, + }); + expect(dineInOrderRes.ok()).toBeTruthy(); + const dineInOrder = (await dineInOrderRes.json()).order; + dineInOrderId = dineInOrder.id; + + // Table is occupied + const tableAfterOrderRes = await request.get(`${BASE}/api/tables/${createdTable.id}`, { + headers: ownerHeaders, + }); + const tableAfterOrder = (await tableAfterOrderRes.json()).table; + expect(tableAfterOrder.status).toBe('occupied'); + + // Fetch bill and pay in full + const billRes = await request.get(`${BASE}/api/bills/order/${dineInOrder.id}`, { + headers: ownerHeaders, + }); + expect(billRes.ok()).toBeTruthy(); + const dineInBill = (await billRes.json()).bill; + + const payDineInRes = await request.post(`${BASE}/api/bills/${dineInBill.id}/payment`, { + headers: ownerHeaders, + data: { + method: 'cash', + amount: Number(dineInBill.total), + }, + }); + expect(payDineInRes.ok()).toBeTruthy(); + + // Order is released, checkout_stage is paid + const orderAfterPayRes = await request.get(`${BASE}/api/orders/${dineInOrder.id}`, { + headers: ownerHeaders, + }); + const orderAfterPay = (await orderAfterPayRes.json()).order; + expect(orderAfterPay.checkout_stage).toBe('paid'); + expect(orderAfterPay.release_status).toBe('released'); + expect(orderAfterPay.status).toBe('pending'); // Kitchen still working! + + // Invariant: Table remains OCCUPIED after prepayment settlement! + const tableAfterPayRes = await request.get(`${BASE}/api/tables/${createdTable.id}`, { + headers: ownerHeaders, + }); + const tableAfterPay = (await tableAfterPayRes.json()).table; + expect(tableAfterPay.status).toBe('occupied'); + }); + + // ------------------------------------------------------------------------- + // Scenario 6: Adversarial release gates and prepayment edit-lock enforcement + // ------------------------------------------------------------------------- + await test.step('Scenario 6: Adversarial release gates block unpaid orders and edit lock prevents modifying paid orders', async () => { + // 1. Delivery order with dispatch_policy: on_payment rejected + const deliveryRes = await request.post(`${BASE}/api/orders`, { + headers: ownerHeaders, + data: { + type: 'delivery', + dispatch_policy: 'on_payment', + items: [{ product_id: 'e2e-product', quantity: 1 }], + }, + }); + expect(deliveryRes.status()).toBe(400); + + // 2. Manual release of unpaid on_payment order rejected 409 + const advOrderRes = await request.post(`${BASE}/api/orders`, { + headers: ownerHeaders, + data: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: 'e2e-product', quantity: 2 }], + }, + }); + expect(advOrderRes.ok()).toBeTruthy(); + const advOrder = (await advOrderRes.json()).order; + + const manualReleaseRes = await request.post(`${BASE}/api/orders/${advOrder.id}/release`, { + headers: ownerHeaders, + }); + expect(manualReleaseRes.status()).toBe(409); + const releaseErr = await manualReleaseRes.json(); + expect(releaseErr.code).toBe('ORDER_NOT_PAID'); + + // 3. Record partial payment on the bill + const billRes = await request.get(`${BASE}/api/bills/order/${advOrder.id}`, { + headers: ownerHeaders, + }); + const advBill = (await billRes.json()).bill; + + const partialPayRes = await request.post(`${BASE}/api/bills/${advBill.id}/payment`, { + headers: ownerHeaders, + data: { + method: 'cash', + amount: Math.floor(Number(advBill.total) / 2), + }, + }); + expect(partialPayRes.ok()).toBeTruthy(); + + // 4. Edits to items rejected with 409 SCHEDULED_ORDER_PAID once payment recorded + const editItemRes = await request.post(`${BASE}/api/orders/${advOrder.id}/items`, { + headers: ownerHeaders, + data: { + items: [{ product_id: 'e2e-product', quantity: 1 }], + }, + }); + expect(editItemRes.status()).toBe(409); + const editErr = await editItemRes.json(); + expect(editErr.code).toBe('SCHEDULED_ORDER_PAID'); + + // 5. Discount edit rejected with 409 SCHEDULED_ORDER_PAID + const discountRes = await request.patch(`${BASE}/api/orders/${advOrder.id}/discount`, { + headers: ownerHeaders, + data: { + discount_type: 'flat', + discount_value: 10, + discount_reason: 'Test discount', + }, + }); + expect(discountRes.status()).toBe(409); + const discountErr = await discountRes.json(); + expect(discountErr.code).toBe('SCHEDULED_ORDER_PAID'); + }); + + // ------------------------------------------------------------------------- + // Scenario 7: Adversarial stock shortfall leaves paid-but-blocked ticket with retry + // ------------------------------------------------------------------------- + await test.step('Scenario 7: Stock shortfall during prepayment creates paid-but-blocked ticket with explicit retry', async () => { + // 1. Create product with tracked inventory, stock = 1 + const prodRes = await request.post(`${BASE}/api/products`, { + headers: ownerHeaders, + data: { + category_id: 'e2e-category', + name: `Limited Pie ${runId}`, + price: 150, + track_inventory: 1, + stock_quantity: 1, + }, + }); + expect(prodRes.ok()).toBeTruthy(); + limitedProduct = (await prodRes.json()).product; + + // 2. Create on_payment order for 1 unit + const stockOrderRes = await request.post(`${BASE}/api/orders`, { + headers: ownerHeaders, + data: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: limitedProduct.id, quantity: 1 }], + }, + }); + expect(stockOrderRes.ok()).toBeTruthy(); + const stockOrder = (await stockOrderRes.json()).order; + + // 3. Stock depleted to 0 before customer pays + const adjustRes = await request.post(`${BASE}/api/products/${limitedProduct.id}/stock`, { + headers: ownerHeaders, + data: { + action: 'set', + quantity: 0, + reason: 'Depleted by parallel purchase', + }, + }); + expect(adjustRes.ok()).toBeTruthy(); + + // 4. Customer tenders payment in full + const billRes = await request.get(`${BASE}/api/bills/order/${stockOrder.id}`, { + headers: ownerHeaders, + }); + const stockBill = (await billRes.json()).bill; + + const payRes = await request.post(`${BASE}/api/bills/${stockBill.id}/payment`, { + headers: ownerHeaders, + data: { + method: 'cash', + amount: Number(stockBill.total), + }, + }); + expect(payRes.ok()).toBeTruthy(); + + // 5. Payment is ACCEPTED, but release is BLOCKED + const verifyOrderRes = await request.get(`${BASE}/api/orders/${stockOrder.id}`, { + headers: ownerHeaders, + }); + const blockedOrder = (await verifyOrderRes.json()).order; + expect(blockedOrder.checkout_stage).toBe('paid'); + expect(blockedOrder.release_status).toBe('blocked'); + expect(blockedOrder.release_blocked_reason).toContain('Insufficient stock'); + + // 6. View in UI: Topbar shows blocked badge, drawer shows Paid but Blocked section + await page.goto(`${BASE}/pos`); + const queueBtn = page.getByTestId('pos-payment-queue-btn'); + await expect(queueBtn).toBeVisible(); + await queueBtn.click(); + + const queueDrawer = page.getByTestId('payment-queue-drawer'); + await expect(queueDrawer).toBeVisible(); + + // Blocked section and badge are visible + await expect(page.getByTestId('payment-queue-section-blocked')).toBeVisible(); + await expect( + page.getByTestId(`payment-queue-blocked-badge-${blockedOrder.id}`), + ).toBeVisible(); + + await captureScreenshot(page, '08-paid-but-blocked-queue-view.png'); + + // 7. Manual retry fails while stock is still 0 + const retryFailRes = await request.post(`${BASE}/api/orders/${blockedOrder.id}/release`, { + headers: ownerHeaders, + }); + expect(retryFailRes.status()).toBe(409); + const retryFailErr = await retryFailRes.json(); + expect(retryFailErr.code).toBe('RELEASE_BLOCKED'); + + // 8. Restock product to 5 units + const restockRes = await request.post(`${BASE}/api/products/${limitedProduct.id}/stock`, { + headers: ownerHeaders, + data: { + action: 'set', + quantity: 5, + reason: 'Emergency restock', + }, + }); + expect(restockRes.ok()).toBeTruthy(); + + // 9. Retry release button in queue drawer succeeds! + const retryBtn = page.getByTestId(`retry-release-btn-${blockedOrder.id}`); + await expect(retryBtn).toBeVisible(); + await retryBtn.click(); + + // Order transitions to released! + await expect(page.getByTestId(`payment-queue-blocked-badge-${blockedOrder.id}`)).toHaveCount( + 0, + { + timeout: 10_000, + }, + ); + + const releasedOrderRes = await request.get(`${BASE}/api/orders/${blockedOrder.id}`, { + headers: ownerHeaders, + }); + const releasedOrder = (await releasedOrderRes.json()).order; + expect(releasedOrder.release_status).toBe('released'); + + // Close drawer + const closeDrawerBtn = page.getByTestId('close-queue-drawer-btn'); + if (await closeDrawerBtn.isVisible()) { + await closeDrawerBtn.click(); + } + }); + } finally { + // Teardown: clean up counters, table, product, and restore multi_counter_enabled setting + if (orderingCounter.id) { + await request + .delete(`${BASE}/api/counters/${orderingCounter.id}`, { headers: ownerHeaders }) + .catch(() => {}); + } + if (paymentCounter.id) { + await request + .delete(`${BASE}/api/counters/${paymentCounter.id}`, { headers: ownerHeaders }) + .catch(() => {}); + } + if (combinedCounter.id) { + await request + .delete(`${BASE}/api/counters/${combinedCounter.id}`, { headers: ownerHeaders }) + .catch(() => {}); + } + if (dineInOrderId > 0) { + await request + .patch(`${BASE}/api/orders/${dineInOrderId}/status`, { + headers: ownerHeaders, + data: { status: 'completed' }, + }) + .catch(() => {}); + } + if (createdTable.id > 0) { + await request + .post(`${BASE}/api/tables/${createdTable.id}/deactivate`, { headers: ownerHeaders }) + .catch(() => {}); + } + if (limitedProduct.id) { + await request + .delete(`${BASE}/api/products/${limitedProduct.id}`, { headers: ownerHeaders }) + .catch(() => {}); + } + await request + .put(`${BASE}/api/settings/multi_counter_enabled`, { + headers: ownerHeaders, + data: { value: 'false' }, + }) + .catch(() => {}); + } +}); diff --git a/frontend/src/app/(dashboard)/pos/page.tsx b/frontend/src/app/(dashboard)/pos/page.tsx index 3e1ed2c1a..d6112d2ce 100644 --- a/frontend/src/app/(dashboard)/pos/page.tsx +++ b/frontend/src/app/(dashboard)/pos/page.tsx @@ -36,6 +36,9 @@ import PrepaidCheckoutModal, { } from '@/components/pos/PrepaidCheckoutModal'; import PosTopbar from '@/components/pos/PosTopbar'; import ScheduledOrdersDrawer from '@/components/pos/ScheduledOrdersDrawer'; +import { PaymentQueueDrawer } from '@/components/pos/PaymentQueueDrawer'; +import { PaymentTicketConfirmationModal } from '@/components/pos/PaymentTicketConfirmationModal'; +import type { Counter } from '@/lib/types'; import { canPayScheduledOrder, isPayableBill } from '@/lib/order-payment-state'; import RefundModal from '@/components/orders/RefundModal'; import { formatScheduledInstantForDisplay, parseScheduledForInput } from '@/lib/scheduled-time'; @@ -315,6 +318,21 @@ export default function POSPage() { order: Order; bills: Bill[]; } | null>(null); + + // Multi-counter ordering states + const [multiCounterEnabled, setMultiCounterEnabled] = useState(false); + const [counters, setCounters] = useState([]); + const [activeCounter, setActiveCounter] = useState(null); + const [showPaymentQueue, setShowPaymentQueue] = useState(false); + const [paymentQueueCount, setPaymentQueueCount] = useState(0); + const [paymentQueueBlockedCount, setPaymentQueueBlockedCount] = useState(0); + const [paymentQueueRefreshKey, setPaymentQueueRefreshKey] = useState(0); + const [showTicketModal, setShowTicketModal] = useState(false); + const [ticketModalOrder, setTicketModalOrder] = useState(null); + const [ticketModalQrDataUrl, setTicketModalQrDataUrl] = useState(null); + const [ticketModalQrPayload, setTicketModalQrPayload] = useState(null); + const isSendingToPaymentRef = useRef(false); + const sendToPaymentIdempotencyRef = useRef<{ fingerprint: string; key: string } | null>(null); useEffect(() => { if (currentTenant?.timezone) { setStoreTimezone(currentTenant.timezone); @@ -1315,6 +1333,120 @@ export default function POSPage() { setPrinterArabicShaping, ]); + const currentStoreId = currentTenant?.id || 'default'; + useEffect(() => { + let cancelled = false; + setActiveCounter(null); + setShowPaymentQueue(false); + setCounters([]); + + api + .get('/settings/multi_counter_enabled') + .then((res) => { + if (cancelled) return; + const isEnabled = res.data.setting?.value === 'true'; + setMultiCounterEnabled(isEnabled); + if (isEnabled) { + api + .get('/counters?is_active=true') + .then((cRes) => { + if (cancelled) return; + const activeList: Counter[] = cRes.data.counters || []; + setCounters(activeList); + const savedId = + typeof window !== 'undefined' + ? localStorage.getItem(`flocafe_active_counter_${currentStoreId}`) + : null; + if (savedId) { + const matched = activeList.find((c) => c.id === savedId); + if (matched) { + setActiveCounter(matched); + if (matched.role === 'payment') { + setShowPaymentQueue(true); + } + } else { + setActiveCounter(null); + setShowPaymentQueue(false); + } + } else { + setActiveCounter(null); + setShowPaymentQueue(false); + } + }) + .catch(() => {}); + } else { + setActiveCounter(null); + setShowPaymentQueue(false); + setCounters([]); + } + }) + .catch(() => { + if (cancelled) return; + setMultiCounterEnabled(false); + setActiveCounter(null); + setShowPaymentQueue(false); + setCounters([]); + }); + + return () => { + cancelled = true; + }; + }, [currentStoreId]); + + const handleSelectCounter = (counter: Counter | null) => { + setActiveCounter(counter); + if (multiCounterEnabled && counter?.role === 'payment') { + setShowPaymentQueue(true); + } + if (typeof window !== 'undefined') { + if (counter) { + localStorage.setItem(`flocafe_active_counter_${currentStoreId}`, counter.id); + } else { + localStorage.removeItem(`flocafe_active_counter_${currentStoreId}`); + } + } + }; + + const refreshPaymentQueueCounts = useCallback(async () => { + try { + const { data } = await api.get('/orders/awaiting-payment', { params: { limit: '1' } }); + const awaiting = Number(data.awaiting_count) || 0; + const blocked = Number(data.blocked_count) || 0; + setPaymentQueueCount(awaiting + blocked); + setPaymentQueueBlockedCount(blocked); + } catch { + // ignore + } + }, []); + + useEffect(() => { + if (!multiCounterEnabled) return; + void refreshPaymentQueueCounts(); + const interval = setInterval(() => { + void refreshPaymentQueueCounts(); + }, 15000); + return () => clearInterval(interval); + }, [multiCounterEnabled, refreshPaymentQueueCounts]); + + const handleLookupAndPayTicket = useCallback( + async (ticket: string) => { + try { + const { data } = await api.get(`/orders/by-ticket/${encodeURIComponent(ticket)}`); + if (data?.bill) { + setPaymentBill(data.bill as Bill); + toast.success(t('ticketLoaded', { number: data.order?.order_number || ticket })); + } else if (data?.order) { + await handlePayScheduledOrder(data.order as Order); + } + } catch (err: unknown) { + const errData = (err as { response?: { data?: { error?: string } } })?.response?.data; + toast.error(errData?.error || t('ticketNotFound')); + } + }, + // oxlint-disable-next-line react-hooks/exhaustive-deps + [t], + ); + const handleProductClick = (product: Product, quantity = 1) => { if (!productNeedsCustomizer(product)) { cart.addItem(product, quantity); @@ -1373,12 +1505,18 @@ export default function POSPage() { showPrintMenuModal || showKeyboardShortcutsModal || showScheduledOrders || + showPaymentQueue || + showTicketModal || !!refundModalTarget || actionsMenuOpen || isConfirmOpen; const anyModalOpen = showTablePicker || anyOtherModalOpen; useBarcodeScanner((code) => { + if (code.startsWith('FLOCAFE-TICKET:')) { + void handleLookupAndPayTicket(code); + return; + } const scan = resolveScannedProduct(code, products); if (scan) { if (scan.scaleBarcode) cart.addItem(scan.product, scan.quantity); @@ -1828,6 +1966,73 @@ export default function POSPage() { } }; + const handleSendToPayment = async () => { + if (isSendingToPaymentRef.current) return; + if (cart.orderType === 'delivery') { + toast.error(t('sendToPaymentDeliveryError')); + return; + } + if (!validateOrderPreflight()) return; + + isSendingToPaymentRef.current = true; + setSubmitting(true); + try { + const orderItems = cart.items.map(cartItemToOrderItem); + const cartFingerprint = buildCurrentCartFingerprint(orderItems); + if ( + !sendToPaymentIdempotencyRef.current || + sendToPaymentIdempotencyRef.current.fingerprint !== cartFingerprint + ) { + sendToPaymentIdempotencyRef.current = { + fingerprint: cartFingerprint, + key: + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `stp_${Date.now()}_${Math.random().toString(36).slice(2)}`, + }; + } + const idempotencyKey = sendToPaymentIdempotencyRef.current.key; + + const orderPayload = { + type: cart.orderType, + table_id: cart.orderType === 'dine_in' ? cart.tableId : undefined, + customer_id: cart.customerId || undefined, + guest_count: cart.orderType === 'dine_in' ? cart.guestCount : undefined, + special_instructions: cart.orderNotes || undefined, + scheduled_for: + cart.orderType === 'takeaway' ? cart.scheduledFor.trim() || undefined : undefined, + dispatch_policy: 'on_payment', + waived_charge_ids: Array.from(cart.waivedChargeIds), + opted_in_charge_ids: Array.from(cart.optedInChargeIds), + items: orderItems, + }; + + const { data } = await api.post('/orders', orderPayload, { + headers: { + 'Idempotency-Key': idempotencyKey, + }, + }); + sendToPaymentIdempotencyRef.current = null; + const createdOrder = data.order as Order; + + setTicketModalOrder(createdOrder); + setTicketModalQrDataUrl(data.qr_data_url || null); + setTicketModalQrPayload(data.qr_payload || null); + setShowTicketModal(true); + + cart.clearCart(); + refreshTables(); + void refreshPaymentQueueCounts(); + toast.success(t('orderSentToPaymentSuccess', { number: createdOrder.order_number })); + } catch (err: unknown) { + const errData = (err as { response?: { data?: { error?: string } } })?.response?.data; + toast.error(errData?.error || t('placeOrderFailed')); + } finally { + isSendingToPaymentRef.current = false; + setSubmitting(false); + } + }; + // Handle prepaid checkout - place order and pay in one step const handlePrepaidCheckout = async ( payments: PrepaidPayment[], @@ -2700,6 +2905,8 @@ export default function POSPage() { setCheckoutTable(null); refreshTables(); void refreshScheduledOrders(); + void refreshPaymentQueueCounts(); + setPaymentQueueRefreshKey((k) => k + 1); if (bill) { try { @@ -2784,6 +2991,10 @@ export default function POSPage() { currency, submitting, onPlaceOrder: handlePlaceOrder, + onSendToPayment: handleSendToPayment, + onOpenPaymentQueue: () => setShowPaymentQueue(true), + counterRole: activeCounter?.role || null, + multiCounterEnabled, onEditItem: setEditingCartItem, onModalOpenChange: setCartPanelModalOpen, existingOrder: pendingOrder, @@ -2803,7 +3014,11 @@ export default function POSPage() { }, onTender: () => { if (!submitting && cart.items.length > 0) { - void handlePlaceOrder(); + if (multiCounterEnabled && activeCounter?.role === 'ordering') { + void handleSendToPayment(); + } else { + void handlePlaceOrder(); + } } }, onToggleTables: () => { @@ -2812,6 +3027,12 @@ export default function POSPage() { } }, onOpenCheckout: () => { + if (multiCounterEnabled && activeCounter?.role === 'ordering') { + if (!submitting && cart.items.length > 0) { + void handleSendToPayment(); + } + return; + } if ( shouldTakePaymentNow || !isRestaurant || @@ -3157,6 +3378,13 @@ export default function POSPage() { onCustomerModalOpenChange={setTopbarCustomerModalOpen} scheduledOrderCount={totalActiveScheduled} blockedScheduledCount={totalBlockedScheduled} + multiCounterEnabled={multiCounterEnabled} + counters={counters} + activeCounter={activeCounter} + onSelectCounter={handleSelectCounter} + paymentQueueCount={paymentQueueCount} + paymentQueueBlockedCount={paymentQueueBlockedCount} + onShowPaymentQueue={() => setShowPaymentQueue(true)} onShowScheduledOrders={() => { void refreshScheduledOrders(); api @@ -3333,6 +3561,34 @@ export default function POSPage() { totalScheduledCount={totalActiveScheduled} /> + setShowPaymentQueue(false)} + onPayOrder={handlePayScheduledOrder} + refreshKey={paymentQueueRefreshKey} + onRefundOrder={(order, bills) => { + setShowPaymentQueue(false); + setRefundModalTarget({ order, bills }); + }} + onCountsChanged={(awaiting, blocked) => { + setPaymentQueueCount(awaiting + blocked); + setPaymentQueueBlockedCount(blocked); + }} + /> + + { + setShowTicketModal(false); + setTicketModalOrder(null); + setTicketModalQrDataUrl(null); + setTicketModalQrPayload(null); + }} + /> + {paymentBill && ( )} + {/* Customers group */}
@@ -4860,6 +4867,10 @@ export default function SettingsPage() { /> + + + + {/* Kitchen Display System enable toggle */} diff --git a/frontend/src/components/pos/CartPanel.tsx b/frontend/src/components/pos/CartPanel.tsx index 7d2a2529f..d0a444581 100644 --- a/frontend/src/components/pos/CartPanel.tsx +++ b/frontend/src/components/pos/CartPanel.tsx @@ -19,6 +19,7 @@ import { Wallet, StickyNote, CalendarClock, + CreditCard, X, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -51,6 +52,10 @@ interface Props { currency: string; submitting: boolean; onPlaceOrder: () => void; + onSendToPayment?: () => void; + onOpenPaymentQueue?: () => void; + counterRole?: 'ordering' | 'payment' | 'combined' | null; + multiCounterEnabled?: boolean; onEditItem?: (item: CartItem) => void; onModalOpenChange?: (open: boolean) => void; variant?: 'sidebar' | 'drawer'; @@ -169,6 +174,10 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, + onSendToPayment, + onOpenPaymentQueue, + counterRole, + multiCounterEnabled = false, onEditItem, onModalOpenChange, variant = 'sidebar', @@ -830,22 +839,128 @@ export default function CartPanel({ )} - + + {t('paymentQueue')} + + ) : ( + <> + {onOpenPaymentQueue && ( + + )} + + + ) + ) : multiCounterEnabled && onSendToPayment && counterRole === 'ordering' ? ( + + ) : multiCounterEnabled && onSendToPayment && counterRole === 'combined' ? ( + <> + + + + ) : ( + + )}
{priceOverrideTarget && ( diff --git a/frontend/src/components/pos/PaymentQueueDrawer.tsx b/frontend/src/components/pos/PaymentQueueDrawer.tsx new file mode 100644 index 000000000..e83693cab --- /dev/null +++ b/frontend/src/components/pos/PaymentQueueDrawer.tsx @@ -0,0 +1,576 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + CreditCard, + Search, + RefreshCw, + X, + AlertTriangle, + Play, + RotateCcw, + Printer, + Package, + UtensilsCrossed, + Clock, + Loader2, +} from 'lucide-react'; +import { useTranslations } from 'use-intl'; +import toast from 'react-hot-toast'; +import { Button } from '@/components/ui/button'; +import { Ltr } from '@/components/layout/Ltr'; +import api from '@/lib/api'; +import { useFormatCurrency } from '@/hooks/useFormatCurrency'; +import { tenantCan } from '@/lib/permissions'; +import { useAuthStore } from '@/store/auth'; +import { printerService } from '@/lib/printer/PrinterService'; +import type { Order, Bill } from '@/lib/types'; + +export interface AwaitingPaymentOrder { + id: number | string; + order_number: string; + type: string; + table_id?: number | null; + table_name?: string | null; + customer_name?: string | null; + customer_phone?: string | null; + total?: number; + total_amount?: number; + paid_amount: number; + balance: number; + checkout_stage: 'building' | 'awaiting_payment' | 'paid'; + release_status: 'scheduled' | 'released' | 'blocked' | 'cancelled'; + release_blocked_reason?: string | null; + scheduled_for?: string | null; + created_at: string; + items?: unknown[]; + bill_id?: number | string | null; + bill_number?: string | null; + bill_payment_status?: string | null; + bill?: Bill | null; + bills?: Bill[]; +} + +interface Props { + open: boolean; + onClose: () => void; + onPayOrder: (order: Order) => Promise | void; + onRefundOrder?: (order: Order, bills: Bill[]) => void; + onCountsChanged?: (awaiting: number, blocked: number) => void; + refreshKey?: number; +} + +export function PaymentQueueDrawer({ + open, + onClose, + onPayOrder, + onRefundOrder, + onCountsChanged, + refreshKey, +}: Props) { + const t = useTranslations('pos'); + const tOrders = useTranslations('orders'); + const tCommon = useTranslations('common'); + const fmt = useFormatCurrency(); + const { currentTenant } = useAuthStore(); + + const canPay = + tenantCan(currentTenant, 'payments.take') && tenantCan(currentTenant, 'bills.generate'); + const canRelease = + tenantCan(currentTenant, 'orders.create') || tenantCan(currentTenant, 'orders.status.update'); + const canRefund = tenantCan(currentTenant, 'refunds.initiate'); + + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [search, setSearch] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const [releasingId, setReleasingId] = useState(null); + const [printingId, setPrintingId] = useState(null); + const [awaitingCount, setAwaitingCount] = useState(0); + const [blockedCount, setBlockedCount] = useState(0); + + const searchDebounceRef = useRef | null>(null); + + const fetchQueue = useCallback( + async (cursor?: string | null, searchQuery?: string, append = false) => { + if (append) { + setLoadingMore(true); + } else { + setLoading(true); + } + + try { + const params: Record = { limit: '50' }; + if (cursor) params.cursor = cursor; + if (searchQuery && searchQuery.trim().length > 0) { + // If scanner pasted prefix FLOCAFE-TICKET:, strip it + let cleanSearch = searchQuery.trim(); + if (cleanSearch.startsWith('FLOCAFE-TICKET:')) { + cleanSearch = cleanSearch.replace('FLOCAFE-TICKET:', '').trim(); + } + params.search = cleanSearch; + } + + const { data } = await api.get('/orders/awaiting-payment', { params }); + const fetchedOrders: AwaitingPaymentOrder[] = data.orders || []; + + if (append) { + setOrders((prev) => [...prev, ...fetchedOrders]); + } else { + setOrders(fetchedOrders); + } + + setNextCursor(data.next_cursor || null); + setHasMore(Boolean(data.has_more)); + const awaiting = Number(data.awaiting_count) || 0; + const blocked = Number(data.blocked_count) || 0; + setAwaitingCount(awaiting); + setBlockedCount(blocked); + if (onCountsChanged) { + onCountsChanged(awaiting, blocked); + } + } catch (err: unknown) { + console.error('Failed to fetch awaiting payment queue:', err); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, + [onCountsChanged], + ); + + const onCountsChangedRef = useRef(onCountsChanged); + useEffect(() => { + onCountsChangedRef.current = onCountsChanged; + }, [onCountsChanged]); + + useEffect(() => { + let cancelled = false; + if (open) { + const params: Record = { limit: '50' }; + if (search.trim().length > 0) { + let clean = search.trim(); + if (clean.startsWith('FLOCAFE-TICKET:')) { + clean = clean.replace('FLOCAFE-TICKET:', '').trim(); + } + params.search = clean; + } + api + .get('/orders/awaiting-payment', { params }) + .then(({ data }) => { + if (cancelled) return; + const fetchedOrders: AwaitingPaymentOrder[] = data.orders || []; + setOrders(fetchedOrders); + setNextCursor(data.next_cursor || null); + setHasMore(Boolean(data.has_more)); + const awaiting = Number(data.awaiting_count) || 0; + const blocked = Number(data.blocked_count) || 0; + setAwaitingCount(awaiting); + setBlockedCount(blocked); + onCountsChangedRef.current?.(awaiting, blocked); + setLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + console.error('Failed to fetch awaiting payment queue:', err); + setLoading(false); + }); + } + return () => { + cancelled = true; + }; + }, [open, search, refreshKey]); + + useEffect(() => { + if (!open) return; + const interval = setInterval(() => { + void fetchQueue(null, search); + }, 5000); + return () => clearInterval(interval); + }, [open, search, fetchQueue]); + + const handleSearchChange = (value: string) => { + setSearchInput(value); + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => { + setSearch(value); + }, 300); + }; + + const handleLoadMore = () => { + if (hasMore && nextCursor && !loadingMore) { + void fetchQueue(nextCursor, search, true); + } + }; + + const handleRetryRelease = async (order: AwaitingPaymentOrder) => { + setReleasingId(order.id); + try { + await api.post(`/orders/${order.id}/release`); + toast.success(t('scheduledReleasedSuccess', { number: order.order_number })); + void fetchQueue(null, search); + } catch (err: unknown) { + const errData = (err as { response?: { data?: { error?: string } } })?.response?.data; + toast.error(errData?.error || t('placeOrderFailed')); + } finally { + setReleasingId(null); + } + }; + + const handlePrintTicket = async (order: AwaitingPaymentOrder) => { + setPrintingId(order.id); + try { + const res = await api.post('/printers/print-payment-ticket', { order_id: order.id }); + if (res.data?.webusb && Array.isArray(res.data.bytes)) { + await printerService.print(Uint8Array.from(res.data.bytes)); + } + toast.success(t('paymentTicketPrinted')); + } catch (err: unknown) { + const errData = (err as { response?: { data?: { error?: string } } })?.response?.data; + toast.error(errData?.error || t('paymentTicketPrintFailed')); + } finally { + setPrintingId(null); + } + }; + + if (!open) return null; + + const blockedOrders = orders.filter((o) => o.release_status === 'blocked'); + const waitingOrders = orders.filter((o) => o.release_status !== 'blocked'); + + const renderOrderCard = (order: AwaitingPaymentOrder) => { + const isBlocked = order.release_status === 'blocked'; + const isPaid = order.checkout_stage === 'paid'; + + return ( +
+
+
+ + #{order.order_number} + + + {order.type === 'dine_in' ? ( + <> + + {t('orderTypeDineIn')} {order.table_name ? `(${order.table_name})` : ''} + + ) : ( + <> + + {t('orderTypeTakeaway')} + + )} + + {order.scheduled_for && ( + + + {t('scheduledOrders')} + + )} + {isBlocked && ( + + + {t('scheduledPaidBlockedBadge')} + + )} +
+
+ + {fmt(order.balance > 0 ? order.balance : (order.total_amount ?? order.total ?? 0))} + + {order.paid_amount > 0 && order.balance > 0 && ( +

+ {t('partialPaid', { paid: fmt(order.paid_amount) })} +

+ )} +
+
+ + {isBlocked && order.release_blocked_reason && ( +
+

{t('releaseBlockedTitle')}:

+

{order.release_blocked_reason}

+
+ )} + +
+ + {new Date(order.created_at).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + +
+ + + {isBlocked && canRelease && ( + + )} + + {isBlocked && isPaid && canRefund && onRefundOrder && ( + + )} + + {!isPaid && canPay && ( + + )} +
+
+
+ ); + }; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+ +

{t('paymentQueue')}

+ + {awaitingCount + blockedCount} + +
+
+ + +
+
+ + {/* Search */} +
+
+ + handleSearchChange(e.target.value)} + placeholder={t('scanOrEnterTicket')} + className="w-full pl-9 pr-4 py-2 text-sm bg-card border border-border rounded-lg outline-none focus:ring-2 focus:ring-brand" + /> + {searchInput && ( + + )} +
+
+ + {/* Content list */} +
+ {loading && orders.length === 0 ? ( +
+ + {tCommon('loading')} +
+ ) : orders.length === 0 ? ( +
+ +

{t('paymentQueueEmpty')}

+

{t('paymentQueueEmptyDesc')}

+
+ ) : ( + <> + {blockedOrders.length > 0 && ( +
+

+ + {t('paidBlockedSection')} ({blockedOrders.length}) +

+
{blockedOrders.map(renderOrderCard)}
+
+ )} + + {waitingOrders.length > 0 && ( +
+

+ + {t('awaitingPaymentSection')} ({waitingOrders.length}) +

+
{waitingOrders.map(renderOrderCard)}
+
+ )} + + {hasMore && ( +
+ +
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/pos/PaymentTicketConfirmationModal.tsx b/frontend/src/components/pos/PaymentTicketConfirmationModal.tsx new file mode 100644 index 000000000..5dfc618c6 --- /dev/null +++ b/frontend/src/components/pos/PaymentTicketConfirmationModal.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslations } from 'use-intl'; +import { Printer, Check, Loader2, QrCode } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import api from '@/lib/api'; +import toast from 'react-hot-toast'; +import { useFormatCurrency } from '@/hooks/useFormatCurrency'; +import { printerService } from '@/lib/printer/PrinterService'; +import type { Order } from '@/lib/types'; + +interface Props { + open: boolean; + order: Order | null; + qrDataUrl?: string | null; + qrPayload?: string | null; + onClose: () => void; +} + +export function PaymentTicketConfirmationModal({ + open, + order, + qrDataUrl, + qrPayload, + onClose, +}: Props) { + const t = useTranslations('pos'); + const tCommon = useTranslations('common'); + const fmt = useFormatCurrency(); + const [printing, setPrinting] = useState(false); + + if (!order) return null; + + const handlePrint = async () => { + setPrinting(true); + try { + const res = await api.post('/printers/print-payment-ticket', { order_id: order.id }); + if (res.data?.webusb && Array.isArray(res.data.bytes)) { + await printerService.print(Uint8Array.from(res.data.bytes)); + } + toast.success(t('paymentTicketPrinted')); + } catch (err: unknown) { + const errData = (err as { response?: { data?: { error?: string } } })?.response?.data; + toast.error(errData?.error || t('paymentTicketPrintFailed')); + } finally { + setPrinting(false); + } + }; + + const ticketPayload = qrPayload || `FLOCAFE-TICKET:${order.order_number}`; + + return ( + !isOpen && onClose()}> + + + + + {t('paymentTicket')} + + {t('paymentTicketDesc')} + + +
+
+

+ {t('ticketNumber')} +

+

+ #{order.order_number} +

+
+ + {order.type === 'dine_in' + ? `${t('orderTypeDineIn')}${order.table?.name ? ` (${order.table.name})` : ''}` + : t('orderTypeTakeaway')} + + {fmt(order.total)} +
+
+ + {qrDataUrl ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {ticketPayload} +
+ ) : ( +
+

{ticketPayload}

+
+ )} + +

{t('paymentTicketInstruction')}

+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/pos/PosTopbar.tsx b/frontend/src/components/pos/PosTopbar.tsx index b5f815c87..01d2ce2c7 100644 --- a/frontend/src/components/pos/PosTopbar.tsx +++ b/frontend/src/components/pos/PosTopbar.tsx @@ -6,12 +6,14 @@ import CustomerTopItems from './CustomerTopItems'; import { Banknote, CalendarClock, + CreditCard, EllipsisVertical, Maximize2, Minimize2, LockOpen, Lock, Printer, + Store, UtensilsCrossed, } from 'lucide-react'; import { @@ -20,7 +22,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import type { Product } from '@/lib/types'; +import type { Product, Counter } from '@/lib/types'; import { useTranslations } from 'use-intl'; import { useAuthStore } from '@/store/auth'; @@ -45,6 +47,13 @@ interface Props { scheduledOrderCount?: number; blockedScheduledCount?: number; onShowScheduledOrders?: () => void; + multiCounterEnabled?: boolean; + counters?: Counter[]; + activeCounter?: Counter | null; + onSelectCounter?: (counter: Counter | null) => void; + paymentQueueCount?: number; + paymentQueueBlockedCount?: number; + onShowPaymentQueue?: () => void; } export default function PosTopbar({ @@ -68,8 +77,16 @@ export default function PosTopbar({ scheduledOrderCount = 0, blockedScheduledCount = 0, onShowScheduledOrders, + multiCounterEnabled = false, + counters = [], + activeCounter = null, + onSelectCounter, + paymentQueueCount = 0, + paymentQueueBlockedCount = 0, + onShowPaymentQueue, }: Props) { const t = useTranslations('pos'); + const tSettings = useTranslations('settings'); const tDashboard = useTranslations('dashboard'); const tProducts = useTranslations('products'); const lock = useAuthStore((s) => s.lock); @@ -138,6 +155,80 @@ export default function PosTopbar({ )} + {multiCounterEnabled && onShowPaymentQueue && ( + + )} + + {multiCounterEnabled && counters && counters.length > 0 && ( + + + + + + {counters.map((c) => ( + onSelectCounter?.(c)} + className="flex items-center justify-between cursor-pointer" + > + {c.name} + + {c.role} + + + ))} + + + )} +
diff --git a/frontend/src/components/settings/CountersSettingsTab.tsx b/frontend/src/components/settings/CountersSettingsTab.tsx new file mode 100644 index 000000000..e027d34e3 --- /dev/null +++ b/frontend/src/components/settings/CountersSettingsTab.tsx @@ -0,0 +1,462 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useTranslations } from 'use-intl'; +import { + Layers, + Plus, + Edit2, + Trash2, + CheckCircle2, + XCircle, + AlertCircle, + Loader2, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { SettingsTabShell } from '@/components/settings/SettingsTabShell'; +import { Toggle } from '@/components/settings/Toggle'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import api from '@/lib/api'; +import toast from 'react-hot-toast'; + +export interface CounterItem { + id: string; + name: string; + role: 'ordering' | 'payment' | 'combined'; + is_active: number | boolean; + sort_order: number; + created_at?: string; + updated_at?: string; +} + +interface Props { + isOwner: boolean; +} + +export function CountersSettingsTab({ isOwner }: Props) { + const t = useTranslations('settings'); + + const [enabled, setEnabled] = useState(false); + const [savingEnabled, setSavingEnabled] = useState(false); + const [counters, setCounters] = useState([]); + const [loading, setLoading] = useState(true); + + // Add / Edit Modal state + const [modalOpen, setModalOpen] = useState(false); + const [editingCounter, setEditingCounter] = useState(null); + const [name, setName] = useState(''); + const [role, setRole] = useState<'ordering' | 'payment' | 'combined'>('ordering'); + const [isActive, setIsActive] = useState(true); + const [sortOrder, setSortOrder] = useState(0); + const [savingCounter, setSavingCounter] = useState(false); + + // Delete confirmation state + const [deletingId, setDeletingId] = useState(null); + + const loadData = useCallback(async () => { + try { + const [settingRes, countersRes] = await Promise.all([ + api + .get('/settings/multi_counter_enabled') + .catch(() => ({ data: { setting: { value: 'false' } } })), + api + .get('/counters', { params: { include_inactive: 'true' } }) + .catch(() => ({ data: { counters: [] } })), + ]); + setEnabled(settingRes.data?.setting?.value === 'true'); + setCounters(countersRes.data?.counters || []); + } catch { + toast.error('Failed to load counters'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let cancelled = false; + Promise.all([ + api + .get('/settings/multi_counter_enabled') + .catch(() => ({ data: { setting: { value: 'false' } } })), + api + .get('/counters', { params: { include_inactive: 'true' } }) + .catch(() => ({ data: { counters: [] } })), + ]) + .then(([settingRes, countersRes]) => { + if (cancelled) return; + setEnabled(settingRes.data?.setting?.value === 'true'); + setCounters(countersRes.data?.counters || []); + setLoading(false); + }) + .catch(() => { + if (cancelled) return; + toast.error('Failed to load counters'); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const handleToggleEnabled = async (val: boolean) => { + if (!isOwner) { + toast.error(t('ownerPermissionRequired') || 'Owner permission required'); + return; + } + setSavingEnabled(true); + try { + await api.put('/settings/multi_counter_enabled', { value: String(val) }); + setEnabled(val); + toast.success( + val + ? t('multiCounterEnabled') || 'Multi-counter ordering enabled' + : t('multiCounterDisabled') || 'Multi-counter ordering disabled', + ); + } catch { + toast.error('Failed to update multi-counter setting'); + } finally { + setSavingEnabled(false); + } + }; + + const handleOpenAddModal = () => { + setEditingCounter(null); + setName(''); + setRole('ordering'); + setIsActive(true); + setSortOrder(counters.length * 10); + setModalOpen(true); + }; + + const handleOpenEditModal = (counter: CounterItem) => { + setEditingCounter(counter); + setName(counter.name); + setRole(counter.role); + setIsActive(Boolean(counter.is_active)); + setSortOrder(counter.sort_order ?? 0); + setModalOpen(true); + }; + + const handleSaveCounter = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) { + toast.error('Counter name is required'); + return; + } + setSavingCounter(true); + try { + if (editingCounter) { + await api.put(`/counters/${editingCounter.id}`, { + name: name.trim(), + role, + is_active: isActive, + sort_order: Number(sortOrder) || 0, + }); + toast.success(t('counterUpdated') || 'Counter updated'); + } else { + await api.post('/counters', { + name: name.trim(), + role, + is_active: isActive, + sort_order: Number(sortOrder) || 0, + }); + toast.success(t('counterCreated') || 'Counter created'); + } + setModalOpen(false); + void loadData(); + } catch (err: unknown) { + const errorObj = err as { response?: { data?: { error?: string } } }; + toast.error(errorObj.response?.data?.error || 'Failed to save counter'); + } finally { + setSavingCounter(false); + } + }; + + const handleDeleteCounter = async (id: string) => { + try { + await api.delete(`/counters/${id}`); + toast.success(t('counterDeleted') || 'Counter deleted'); + setDeletingId(null); + void loadData(); + } catch (err: unknown) { + const errorObj = err as { response?: { data?: { error?: string } } }; + toast.error(errorObj.response?.data?.error || 'Failed to delete counter'); + } + }; + + const getRoleLabel = (r: 'ordering' | 'payment' | 'combined') => { + switch (r) { + case 'ordering': + return t('counterRoleOrdering') || 'Ordering (Create here)'; + case 'payment': + return t('counterRolePayment') || 'Payment (Pay there)'; + case 'combined': + return t('counterRoleCombined') || 'Combined (Order & Pay)'; + } + }; + + const getRoleBadgeColor = (r: 'ordering' | 'payment' | 'combined') => { + switch (r) { + case 'ordering': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 border-blue-200 dark:border-blue-800'; + case 'payment': + return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 border-amber-200 dark:border-amber-800'; + case 'combined': + return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800'; + } + }; + + return ( + +
+ {/* Header card with toggle */} +
+
+
+
+ +
+
+

+ {t('multiCounterTitle') || 'Multi-Counter Ordering'} +

+

+ {t('multiCounterDesc') || + 'Configure separate ordering and payment counters (create order at one counter, pay at another).'} +

+
+
+ +
+
+ + {/* Counter management section */} + {enabled && ( +
+
+
+

+ {t('namedCounters') || 'Configured Counters'} +

+

+ {t('namedCountersHint') || + 'Each POS station selects its active counter to adapt its workflow.'} +

+
+ +
+ + {loading ? ( +
+ + Loading counters... +
+ ) : counters.length === 0 ? ( +
+

No counters configured yet

+

+ Add your first counter to start multi-counter ordering. +

+
+ ) : ( +
+ + + + + + + + + + + {counters.map((counter) => ( + + + + + + + ))} + +
NameRoleStatusActions
{counter.name} + + {getRoleLabel(counter.role)} + + + {counter.is_active ? ( + + Active + + ) : ( + + Inactive + + )} + + + +
+
+ )} +
+ )} +
+ + {/* Add / Edit Modal */} + + + + + {editingCounter + ? t('editCounter') || 'Edit Counter' + : t('addCounter') || 'Add Counter'} + + + {t('counterModalDesc') || + 'Configure the name and functional role for this POS counter station.'} + + + +
+
+ + setName(e.target.value)} + placeholder="e.g. Counter 1, Bar, Express Order" + className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-brand" + /> +
+ +
+ + +

+ {role === 'ordering' && 'Station builds orders and sends them to payment.'} + {role === 'payment' && 'Station opens shared queue and processes payments.'} + {role === 'combined' && + 'Station supports both normal checkout and sending to payment.'} +

+
+ +
+ setIsActive(e.target.checked)} + className="rounded border-border text-brand focus:ring-brand" + /> + +
+ + + + + +
+
+
+ + {/* Delete Confirmation Modal */} + !open && setDeletingId(null)}> + + + + + {t('confirmDeleteCounter') || 'Delete Counter?'} + + + {t('confirmDeleteCounterDesc') || + 'Are you sure you want to delete this counter? Active orders will remain unaffected.'} + + + + + + + + +
+ ); +} diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index 060186109..e28921289 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "يجب أن يكون وقت الإرسال إلى المطبخ في المستقبل ({timezone}).", "scheduledDstSkippedError": "هذا الوقت المحلي غير موجود بسبب انتقال التوقيت الصيفي في {timezone}. اختر وقتاً صالحاً بعد تغيير الساعة.", "scheduledDstAmbiguousError": "هذا الوقت المحلي يتكرر مرتين أثناء تغيير التوقيت الصيفي في {timezone}. اختر وقتاً غير ملتبس خارج الساعة المكررة.", - "scheduledInvalidTimeError": "أدخل تاريخاً ووقتاً صالحين بتوقيت المتجر ({timezone})." + "scheduledInvalidTimeError": "أدخل تاريخاً ووقتاً صالحين بتوقيت المتجر ({timezone}).", + "ticketLoaded": "تم تحميل التذكرة #{number}", + "ticketNotFound": "التذكرة غير موجودة أو تم دفعها مسبقاً", + "sendToPaymentDeliveryError": "نظام المحطات المتعددة غير متاح لطلبات التوصيل.", + "orderSentToPaymentSuccess": "تم إرسال الطلب #{number} إلى نقطة الدفع", + "sendToPayment": "إرسال للدفع", + "paymentTicketPrinted": "تمت طباعة تذكرة الدفع", + "paymentTicketPrintFailed": "فشلت طباعة تذكرة الدفع", + "partialPaid": "المدفوع: {paid}", + "releaseBlockedTitle": "الإرسال للمطبخ معلق", + "printTicket": "طباعة التذكرة", + "payTicket": "دفع", + "paymentQueue": "قائمة انتظار الدفع", + "scanOrEnterTicket": "امسح رمز التذكرة أو أدخل الرقم...", + "paymentQueueEmpty": "لا توجد تذاكر بالانتظار", + "paymentQueueEmptyDesc": "ستظهر هنا الطلبات المرسلة للدفع.", + "paidBlockedSection": "مدفوع — الإرسال للمطبخ معلق", + "awaitingPaymentSection": "في انتظار الدفع", + "paymentTicket": "تذكرة الدفع", + "paymentTicketDesc": "تم إنشاء التذكرة وإرسالها إلى قائمة انتظار الدفع.", + "ticketNumber": "رقم التذكرة", + "paymentTicketInstruction": "تفضل بأخذ هذه التذكرة إلى الصندوق لإتمام الدفع.", + "selectCounter": "اختيار المحطة" }, "printTest": { "amt": "المبلغ", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "وردية واحدة لكل موظف", "oneShiftPerStaffHint": "السماح لكل موظف بفتح وإغلاق وردية الصندوق الخاصة به بشكل مستقل.", "oneShiftPerStaffSharedDrawerWarning": "ملاحظة: إذا اشتركت ورديتان متزامنتان في درج نقدي فعلي واحد، فلن تكون عمليات جرد النقد الفردية معزولة فعلياً.", - "oneShiftPerStaffDisableBlocked": "لا يمكن تعطيل خيار وردية واحدة لكل موظف بينما توجد عدة ورديات مفتوحة. أغلق الورديات الإضافية أولاً." + "oneShiftPerStaffDisableBlocked": "لا يمكن تعطيل خيار وردية واحدة لكل موظف بينما توجد عدة ورديات مفتوحة. أغلق الورديات الإضافية أولاً.", + "tabCounters": "المحطات", + "counterRoleOrdering": "طلب فقط", + "counterRolePayment": "دفع فقط", + "counterRoleCombined": "طلب ودفع", + "ownerPermissionRequired": "يلزم الحصول على إذن المالك لإدارة المحطات.", + "multiCounterEnabled": "تم تفعيل نظام المحطات المتعددة", + "multiCounterDisabled": "تم تعطيل نظام المحطات المتعددة", + "counterUpdated": "تم تحديث المحطة", + "counterCreated": "تم إنشاء المحطة", + "counterDeleted": "تم حذف المحطة", + "multiCounterTitle": "نظام الطلب متعدد المحطات", + "multiCounterDesc": "إعداد محطات منفصلة للطلب والدفع لتقسيم سير العمل.", + "namedCounters": "المحطات المعرفة", + "namedCountersHint": "تحدد كل نقطة بيع دورها: طلب، دفع، أو كلاهما.", + "addCounter": "إضافة محطة", + "editCounter": "تعديل المحطة", + "counterModalDesc": "حدد اسم المحطة ودورها التشغيلي.", + "counterName": "اسم المحطة", + "counterRole": "دور المحطة", + "counterIsActive": "نشط", + "confirmDeleteCounter": "هل تريد حذف هذه المحطة؟", + "confirmDeleteCounterDesc": "سيتم حذف هذه المحطة نهائياً." }, "setup": { "alreadyCompleted": "تم الانتهاء من عملية الإعداد في هذا التثبيت. يتم إعادة التوجيه إلى صفحة تسجيل الدخول…", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index 608316b1c..edcf80c84 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "কিচেনে পাঠানোর নির্ধারিত সময় ভবিষ্যতে হতে হবে ({timezone})।", "scheduledDstSkippedError": "{timezone}-এ ডেলাইট-সেভিং পরিবর্তনের কারণে এই স্থানীয় সময়টি বাদ পড়েছে। ঘড়ি পরিবর্তনের পরের বৈধ সময় বেছে নিন।", "scheduledDstAmbiguousError": "{timezone}-এ ডেলাইট-সেভিং পরিবর্তনের সময় এই স্থানীয় সময়টি দুইবার ঘটে। পুনরাবৃত্ত ঘণ্টার বাইরে স্পষ্ট সময় বেছে নিন।", - "scheduledInvalidTimeError": "স্টোর টাইমজোনে ({timezone}) বৈধ তারিখ ও সময় লিখুন।" + "scheduledInvalidTimeError": "স্টোর টাইমজোনে ({timezone}) বৈধ তারিখ ও সময় লিখুন।", + "ticketLoaded": "টিকিট #{number} লোড হয়েছে", + "ticketNotFound": "টিকিট পাওয়া যায়নি বা ইতিমধ্যে পরিশোধিত", + "sendToPaymentDeliveryError": "ডেলিভারি অর্ডারের জন্য মাল্টি-কাউন্টার অর্ডার প্রযোজ্য নয়।", + "orderSentToPaymentSuccess": "অর্ডার #{number} পেমেন্টের জন্য পাঠানো হয়েছে", + "sendToPayment": "পেমেন্টে পাঠান", + "paymentTicketPrinted": "পেমেন্ট টিকিট প্রিন্ট হয়েছে", + "paymentTicketPrintFailed": "পেমেন্ট টিকিট প্রিন্ট ব্যর্থ হয়েছে", + "partialPaid": "পরিশোধিত: {paid}", + "releaseBlockedTitle": "রিলিজ অবরুদ্ধ", + "printTicket": "টিকিট প্রিন্ট করুন", + "payTicket": "পরিশোধ করুন", + "paymentQueue": "পেমেন্ট সারি", + "scanOrEnterTicket": "টিকিট কিউআর স্ক্যান করুন বা নম্বর লিখুন...", + "paymentQueueEmpty": "কোন অপেক্ষমাণ টিকিট নেই", + "paymentQueueEmptyDesc": "পেমেন্টের জন্য পাঠানো অর্ডার এখানে প্রদর্শিত হবে।", + "paidBlockedSection": "পরিশোধিত — রিলিজ অবরুদ্ধ", + "awaitingPaymentSection": "পেমেন্টের অপেক্ষায়", + "paymentTicket": "পেমেন্ট টিকিট", + "paymentTicketDesc": "টিকিট তৈরি করে পেমেন্ট সারিতে পাঠানো হয়েছে।", + "ticketNumber": "টিকিট নম্বর", + "paymentTicketInstruction": "বিল পরিশোধ করতে এই টিকিটটি পেমেন্ট কাউন্টারে নিয়ে যান।", + "selectCounter": "কাউন্টার নির্বাচন করুন" }, "printTest": { "amt": "এএমটি", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "প্রতি কর্মী একটি শিফট", "oneShiftPerStaffHint": "প্রতিটি কর্মীকে স্বাধীনভাবে তাদের নিজস্ব ক্যাশ শিফট খোলা ও বন্ধ করার অনুমতি দিন।", "oneShiftPerStaffSharedDrawerWarning": "দ্রষ্টব্য: যদি দুটি সমসাময়িক শিফট একটি একক ফিজিক্যাল ক্যাশ ড্রয়ার ভাগ করে, তবে পৃথক ড্রয়ারের নগদ গণনা ভৌতভাবে আলাদা থাকবে না।", - "oneShiftPerStaffDisableBlocked": "একাধিক শিফট খোলা থাকা অবস্থায় প্রতি কর্মী একটি শিফট নিষ্ক্রিয় করা যাবে না। প্রথমে অতিরিক্ত শিফটগুলো বন্ধ করুন।" + "oneShiftPerStaffDisableBlocked": "একাধিক শিফট খোলা থাকা অবস্থায় প্রতি কর্মী একটি শিফট নিষ্ক্রিয় করা যাবে না। প্রথমে অতিরিক্ত শিফটগুলো বন্ধ করুন।", + "tabCounters": "কাউন্টারসমূহ", + "counterRoleOrdering": "অর্ডার গ্রহণ", + "counterRolePayment": "পেমেন্ট গ্রহণ", + "counterRoleCombined": "যৌথ", + "ownerPermissionRequired": "কাউন্টার ব্যবস্থাপনায় মালিকের অনুমতি প্রয়োজন।", + "multiCounterEnabled": "মাল্টি-কাউন্টার অর্ডার প্রবাহ সক্রিয়", + "multiCounterDisabled": "মাল্টি-কাউন্টার অর্ডার প্রবাহ নিষ্ক্রিয়", + "counterUpdated": "কাউন্টার আপডেট হয়েছে", + "counterCreated": "কাউন্টার তৈরি হয়েছে", + "counterDeleted": "কাউন্টার মুছে ফেলা হয়েছে", + "multiCounterTitle": "মাল্টি-কাউন্টার অর্ডার ব্যবস্থা", + "multiCounterDesc": "আলাদা অর্ডার তৈরি ও পেমেন্ট ব্যবস্থার জন্য কাউন্টার কনফিগার করুন।", + "namedCounters": "নামযুক্ত কাউন্টারসমূহ", + "namedCountersHint": "প্রতিটি পিওএস ডিভাইস তার ভূমিকা নির্বাচন করবে: অর্ডার, পেমেন্ট বা যৌথ।", + "addCounter": "কাউন্টার যোগ করুন", + "editCounter": "কাউন্টার সম্পাদনা করুন", + "counterModalDesc": "কাউন্টারের নাম ও কার্যকারী ভূমিকা নির্ধারণ করুন।", + "counterName": "কাউন্টারের নাম", + "counterRole": "কাউন্টারের ভূমিকা", + "counterIsActive": "সক্রিয়", + "confirmDeleteCounter": "এই কাউন্টারটি মুছে ফেলবেন?", + "confirmDeleteCounterDesc": "এই কাউন্টারটি স্থায়ীভাবে অপসারণ করা হবে।" }, "setup": { "alreadyCompleted": "এই ইনস্টলে সেটআপ ইতিমধ্যেই সম্পন্ন হয়েছে। লগইন করার জন্য পুনঃনির্দেশ করা হচ্ছে...", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index 9abc2dc02..1389db4d0 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "Die geplante Küchenfreigabezeit muss in der Zukunft liegen ({timezone}).", "scheduledDstSkippedError": "Diese Ortszeit entfällt durch die Zeitumstellung in {timezone}. Wählen Sie eine gültige Zeit nach der Umstellung.", "scheduledDstAmbiguousError": "Diese Ortszeit tritt bei der Zeitumstellung in {timezone} doppelt auf. Wählen Sie eine eindeutige Zeit außerhalb der doppelten Stunde.", - "scheduledInvalidTimeError": "Geben Sie ein gültiges Datum und eine gültige Uhrzeit in der Filialzeitzone ({timezone}) ein." + "scheduledInvalidTimeError": "Geben Sie ein gültiges Datum und eine gültige Uhrzeit in der Filialzeitzone ({timezone}) ein.", + "ticketLoaded": "Ticket #{number} geladen", + "ticketNotFound": "Ticket nicht gefunden oder bereits bezahlt", + "sendToPaymentDeliveryError": "Multi-Kassen-Ablauf ist für Lieferbestellungen nicht verfügbar.", + "orderSentToPaymentSuccess": "Bestellung #{number} zur Kasse gesendet", + "sendToPayment": "Zur Zahlung senden", + "paymentTicketPrinted": "Zahlungsticket gedruckt", + "paymentTicketPrintFailed": "Drucken des Zahlungstickets fehlgeschlagen", + "partialPaid": "Bezahlt: {paid}", + "releaseBlockedTitle": "Freigabe blockiert", + "printTicket": "Ticket drucken", + "payTicket": "Bezahlen", + "paymentQueue": "Zahlungswarteschlange", + "scanOrEnterTicket": "Ticket-QR scannen oder Nummer eingeben...", + "paymentQueueEmpty": "Keine wartenden Tickets", + "paymentQueueEmptyDesc": "Bestellungen zur Zahlung erscheinen hier.", + "paidBlockedSection": "Bezahlt — Freigabe blockiert", + "awaitingPaymentSection": "Wartet auf Zahlung", + "paymentTicket": "Zahlungsticket", + "paymentTicketDesc": "Ticket erstellt und an die Zahlungswarteschlange gesendet.", + "ticketNumber": "Ticketnummer", + "paymentTicketInstruction": "Bringen Sie dieses Ticket zur Kasse, um zu bezahlen.", + "selectCounter": "Theke auswählen" }, "printTest": { "amt": "Betrag", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Eine Schicht pro Mitarbeiter", "oneShiftPerStaffHint": "Jedem Mitarbeiter erlauben, seine eigene Kassenschicht unabhängig zu öffnen und zu schließen.", "oneShiftPerStaffSharedDrawerWarning": "Hinweis: Wenn zwei gleichzeitige Schichten dieselbe physische Kassenschublade teilen, sind die einzelnen Bargeldbestände physisch nicht getrennt.", - "oneShiftPerStaffDisableBlocked": "Eine Schicht pro Mitarbeiter kann nicht deaktiviert werden, solange mehrere Schichten geöffnet sind. Schließen Sie zuerst die zusätzlichen Schichten." + "oneShiftPerStaffDisableBlocked": "Eine Schicht pro Mitarbeiter kann nicht deaktiviert werden, solange mehrere Schichten geöffnet sind. Schließen Sie zuerst die zusätzlichen Schichten.", + "tabCounters": "Theken", + "counterRoleOrdering": "Bestellannahme", + "counterRolePayment": "Zahlung", + "counterRoleCombined": "Kombiniert", + "ownerPermissionRequired": "Inhaberberechtigung erforderlich, um Theken zu verwalten.", + "multiCounterEnabled": "Multi-Theken-Ablauf aktiviert", + "multiCounterDisabled": "Multi-Theken-Ablauf deaktiviert", + "counterUpdated": "Theke aktualisiert", + "counterCreated": "Theke erstellt", + "counterDeleted": "Theke gelöscht", + "multiCounterTitle": "Multi-Theken-Ablauf", + "multiCounterDesc": "Konfigurieren Sie Bestell- und Zahlungstheken für getrennte Erfassungs- und Zahlungsabläufe.", + "namedCounters": "Benannte Theken", + "namedCountersHint": "Jedes Terminal wählt seine Thekenrolle: Bestellung, Zahlung oder Kombiniert.", + "addCounter": "Theke hinzufügen", + "editCounter": "Theke bearbeiten", + "counterModalDesc": "Legen Sie den Thekennamen und die betriebliche Rolle fest.", + "counterName": "Thekenname", + "counterRole": "Thekenrolle", + "counterIsActive": "Aktiv", + "confirmDeleteCounter": "Diese Theke löschen?", + "confirmDeleteCounterDesc": "Diese Theke wird dauerhaft entfernt." }, "setup": { "alreadyCompleted": "Die Ersteinrichtung wurde bereits abgeschlossen. Weiterleitung zur Anmeldung…", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index 390d91812..67ce567b5 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "Scheduled kitchen release time must be in the future ({timezone}).", "scheduledDstSkippedError": "That local time is skipped by a daylight-saving transition in {timezone}. Choose a valid time after the clock change.", "scheduledDstAmbiguousError": "That local time occurs twice during a daylight-saving transition in {timezone}. Choose an unambiguous time outside the repeated hour.", - "scheduledInvalidTimeError": "Enter a valid date and time in store timezone ({timezone})." + "scheduledInvalidTimeError": "Enter a valid date and time in store timezone ({timezone}).", + "ticketLoaded": "Loaded ticket #{number}", + "ticketNotFound": "Ticket not found or already paid", + "sendToPaymentDeliveryError": "Multi-counter ordering is not available for delivery orders.", + "orderSentToPaymentSuccess": "Order #{number} sent to payment", + "sendToPayment": "Send to payment", + "paymentTicketPrinted": "Payment ticket printed", + "paymentTicketPrintFailed": "Failed to print payment ticket", + "partialPaid": "Paid: {paid}", + "releaseBlockedTitle": "Release blocked", + "printTicket": "Print Ticket", + "payTicket": "Pay", + "paymentQueue": "Payment Queue", + "scanOrEnterTicket": "Scan ticket QR or enter number...", + "paymentQueueEmpty": "No waiting tickets", + "paymentQueueEmptyDesc": "Orders sent to payment will appear here.", + "paidBlockedSection": "Paid — Release Blocked", + "awaitingPaymentSection": "Awaiting Payment", + "paymentTicket": "Payment Ticket", + "paymentTicketDesc": "Ticket created and sent to the payment queue.", + "ticketNumber": "Ticket Number", + "paymentTicketInstruction": "Take this ticket to the payment counter to pay.", + "selectCounter": "Select Counter" }, "printTest": { "amt": "Amt", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "One shift per staff member", "oneShiftPerStaffHint": "Allow each staff member to open and close their own cash shift independently.", "oneShiftPerStaffSharedDrawerWarning": "Note: If two concurrent shifts share a single physical cash drawer, individual drawer cash counts will not be physically isolated.", - "oneShiftPerStaffDisableBlocked": "Cannot disable one shift per staff member while multiple shifts are open. Close extra shifts first." + "oneShiftPerStaffDisableBlocked": "Cannot disable one shift per staff member while multiple shifts are open. Close extra shifts first.", + "tabCounters": "Counters", + "counterRoleOrdering": "Ordering", + "counterRolePayment": "Payment", + "counterRoleCombined": "Combined", + "ownerPermissionRequired": "Owner permission required to manage counters.", + "multiCounterEnabled": "Multi-counter ordering enabled", + "multiCounterDisabled": "Multi-counter ordering disabled", + "counterUpdated": "Counter updated", + "counterCreated": "Counter created", + "counterDeleted": "Counter deleted", + "multiCounterTitle": "Multi-Counter Ordering", + "multiCounterDesc": "Configure ordering and payment counters for separate create and pay workflows.", + "namedCounters": "Named Counters", + "namedCountersHint": "Each POS station selects its counter role: Ordering, Payment, or Combined.", + "addCounter": "Add Counter", + "editCounter": "Edit Counter", + "counterModalDesc": "Set the counter name and operational role.", + "counterName": "Counter Name", + "counterRole": "Counter Role", + "counterIsActive": "Active", + "confirmDeleteCounter": "Delete this counter?", + "confirmDeleteCounterDesc": "This counter will be permanently removed." }, "setup": { "alreadyCompleted": "Setup has already been completed on this install. Redirecting to login…", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index b5cdea4c3..6005569d7 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "La hora programada de envío a cocina debe ser futura ({timezone}).", "scheduledDstSkippedError": "Esa hora local no existe por el cambio de horario de verano en {timezone}. Elija una hora válida después del cambio.", "scheduledDstAmbiguousError": "Esa hora local se repite durante el cambio de horario en {timezone}. Elija una hora no ambigua fuera de la hora repetida.", - "scheduledInvalidTimeError": "Introduzca una fecha y hora válidas en la zona horaria del local ({timezone})." + "scheduledInvalidTimeError": "Introduzca una fecha y hora válidas en la zona horaria del local ({timezone}).", + "ticketLoaded": "Ticket cargado #{number}", + "ticketNotFound": "Ticket no encontrado o ya pagado", + "sendToPaymentDeliveryError": "El pedido con múltiples cajas no está disponible para entregas.", + "orderSentToPaymentSuccess": "Pedido #{number} enviado a caja", + "sendToPayment": "Enviar a pago", + "paymentTicketPrinted": "Ticket de pago impreso", + "paymentTicketPrintFailed": "Error al imprimir el ticket de pago", + "partialPaid": "Pagado: {paid}", + "releaseBlockedTitle": "Liberación bloqueada", + "printTicket": "Imprimir ticket", + "payTicket": "Pagar", + "paymentQueue": "Cola de pagos", + "scanOrEnterTicket": "Escanear QR o ingresar número...", + "paymentQueueEmpty": "No hay tickets en espera", + "paymentQueueEmptyDesc": "Los pedidos enviados a pago aparecerán aquí.", + "paidBlockedSection": "Pagado — Liberación bloqueada", + "awaitingPaymentSection": "En espera de pago", + "paymentTicket": "Ticket de pago", + "paymentTicketDesc": "Ticket creado y enviado a la cola de pagos.", + "ticketNumber": "Número de ticket", + "paymentTicketInstruction": "Lleve este ticket al mostrador de pago para abonar.", + "selectCounter": "Seleccionar mostrador" }, "printTest": { "amt": "Total", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Un turno por empleado", "oneShiftPerStaffHint": "Permitir que cada empleado abra y cierre su propio turno de caja de forma independiente.", "oneShiftPerStaffSharedDrawerWarning": "Nota: Si dos turnos simultáneos comparten un único cajón físico, los conteos individuales de efectivo no estarán separados físicamente.", - "oneShiftPerStaffDisableBlocked": "No se puede desactivar un turno por empleado mientras haya varios turnos abiertos. Cierra primero los turnos adicionales." + "oneShiftPerStaffDisableBlocked": "No se puede desactivar un turno por empleado mientras haya varios turnos abiertos. Cierra primero los turnos adicionales.", + "tabCounters": "Mostradores", + "counterRoleOrdering": "Toma de pedidos", + "counterRolePayment": "Cobro", + "counterRoleCombined": "Combinado", + "ownerPermissionRequired": "Se requiere permiso de propietario para gestionar mostradores.", + "multiCounterEnabled": "Flujo de múltiples mostradores activado", + "multiCounterDisabled": "Flujo de múltiples mostradores desactivado", + "counterUpdated": "Mostrador actualizado", + "counterCreated": "Mostrador creado", + "counterDeleted": "Mostrador eliminado", + "multiCounterTitle": "Flujo de múltiples mostradores", + "multiCounterDesc": "Configure mostradores de pedidos y de cobro para separar la toma y el pago.", + "namedCounters": "Mostradores identificados", + "namedCountersHint": "Cada terminal selecciona su rol de mostrador: Pedidos, Cobro o Combinado.", + "addCounter": "Añadir mostrador", + "editCounter": "Editar mostrador", + "counterModalDesc": "Establezca el nombre y el rol operativo del mostrador.", + "counterName": "Nombre del mostrador", + "counterRole": "Rol del mostrador", + "counterIsActive": "Activo", + "confirmDeleteCounter": "¿Eliminar este mostrador?", + "confirmDeleteCounterDesc": "Este mostrador se eliminará de forma permanente." }, "setup": { "alreadyCompleted": "La configuración ya se completó en esta instalación. Redirigiendo al inicio de sesión…", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index 28d56920f..c22fd01b6 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "زمان ارسال به آشپزخانه باید در آینده باشد ({timezone}).", "scheduledDstSkippedError": "این زمان محلی به دلیل تغییر ساعت تابستانی در {timezone} وجود ندارد. زمان معتبری پس از تغییر ساعت انتخاب کنید.", "scheduledDstAmbiguousError": "این زمان محلی در تغییر ساعت تابستانی {timezone} دو بار تکرار می‌شود. زمانی بدون ابهام خارج از ساعت تکراری انتخاب کنید.", - "scheduledInvalidTimeError": "تاریخ و ساعت معتبری در منطقه زمانی فروشگاه ({timezone}) وارد کنید." + "scheduledInvalidTimeError": "تاریخ و ساعت معتبری در منطقه زمانی فروشگاه ({timezone}) وارد کنید.", + "ticketLoaded": "قبض #{number} بارگذاری شد", + "ticketNotFound": "قبض یافت نشد یا قبلاً پرداخت شده است", + "sendToPaymentDeliveryError": "جریان چند پیشخوانی برای سفارش‌های ارسالی در دسترس نیست.", + "orderSentToPaymentSuccess": "سفارش #{number} به صندوق پرداخت ارسال شد", + "sendToPayment": "ارسال به پرداخت", + "paymentTicketPrinted": "قبض پرداخت چاپ شد", + "paymentTicketPrintFailed": "چاپ قبض پرداخت ناموفق بود", + "partialPaid": "پرداخت شده: {paid}", + "releaseBlockedTitle": "ارسال مسدود است", + "printTicket": "چاپ قبض", + "payTicket": "پرداخت", + "paymentQueue": "صف پرداخت", + "scanOrEnterTicket": "اسکن کد یا ورود شماره قبض...", + "paymentQueueEmpty": "هیچ قبضی در انتظار نیست", + "paymentQueueEmptyDesc": "سفارش‌های ارسال‌شده به پرداخت در اینجا نمایش داده می‌شوند.", + "paidBlockedSection": "پرداخت شده — ارسال مسدود است", + "awaitingPaymentSection": "در انتظار پرداخت", + "paymentTicket": "قبض پرداخت", + "paymentTicketDesc": "قبض ایجاد شد و به صف پرداخت ارسال گردید.", + "ticketNumber": "شماره قبض", + "paymentTicketInstruction": "این قبض را جهت پرداخت به صندوق ببرید.", + "selectCounter": "انتخاب پیشخوان" }, "printTest": { "amt": "مبلغ", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "یک شیفت برای هر کارمند", "oneShiftPerStaffHint": "به هر یک از کارکنان اجازه دهید شیفت صندوق خود را به صورت مستقل باز و بسته کنند.", "oneShiftPerStaffSharedDrawerWarning": "توجه: اگر دو شیفت هم‌زمان از یک کشوی پول فیزیکی مشترک استفاده کنند، شمارش نقدی هر شیفت به صورت فیزیکی تفکیک نخواهد شد.", - "oneShiftPerStaffDisableBlocked": "تا زمانی که چند شیفت باز است نمی‌توان گزینه یک شیفت برای هر کارمند را غیرفعال کرد. ابتدا شیفت‌های اضافی را ببندید." + "oneShiftPerStaffDisableBlocked": "تا زمانی که چند شیفت باز است نمی‌توان گزینه یک شیفت برای هر کارمند را غیرفعال کرد. ابتدا شیفت‌های اضافی را ببندید.", + "tabCounters": "پیشخوان‌ها", + "counterRoleOrdering": "ثبت سفارش", + "counterRolePayment": "صندوق پرداخت", + "counterRoleCombined": "ترکیبی", + "ownerPermissionRequired": "برای مدیریت پیشخوان‌ها دسترسی مالک لازم است.", + "multiCounterEnabled": "سفارش‌گیری چند پیشخوانی فعال شد", + "multiCounterDisabled": "سفارش‌گیری چند پیشخوانی غیرفعال شد", + "counterUpdated": "پیشخوان به‌روزرسانی شد", + "counterCreated": "پیشخوان ایجاد شد", + "counterDeleted": "پیشخوان حذف شد", + "multiCounterTitle": "سفارش‌گیری چند پیشخوانی", + "multiCounterDesc": "تنظیم پیشخوان‌های ثبت سفارش و صندوق پرداخت برای جداسازی جریان کار.", + "namedCounters": "پیشخوان‌های تعریف‌شده", + "namedCountersHint": "هر پایگاه نقش خود را انتخاب می‌کند: سفارش‌گیری، صندوق، یا ترکیبی.", + "addCounter": "افزودن پیشخوان", + "editCounter": "ویرایش پیشخوان", + "counterModalDesc": "تعیین نام پیشخوان و نقش عملیاتی آن.", + "counterName": "نام پیشخوان", + "counterRole": "نقش پیشخوان", + "counterIsActive": "فعال", + "confirmDeleteCounter": "آیا این پیشخوان حذف شود؟", + "confirmDeleteCounterDesc": "این پیشخوان برای همیشه حذف خواهد شد." }, "setup": { "alreadyCompleted": "راه‌اندازی در این نصب پیش‌تر انجام شده است. در حال بازگشت به ورود…", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index df1821abc..2e9cc0be7 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "Dapat nasa hinaharap ang naka-iskedyul na oras ng pagpapadala sa kusina ({timezone}).", "scheduledDstSkippedError": "Nilaktawan ang lokal na oras na iyon dahil sa daylight-saving sa {timezone}. Pumili ng wastong oras pagkatapos ng pagbabago ng relo.", "scheduledDstAmbiguousError": "Dalawang beses nangyayari ang lokal na oras na iyon sa daylight-saving sa {timezone}. Pumili ng malinaw na oras sa labas ng naulit na oras.", - "scheduledInvalidTimeError": "Maglagay ng wastong petsa at oras sa timezone ng tindahan ({timezone})." + "scheduledInvalidTimeError": "Maglagay ng wastong petsa at oras sa timezone ng tindahan ({timezone}).", + "ticketLoaded": "Na-load ang ticket #{number}", + "ticketNotFound": "Hindi nahanap ang ticket o bayad na", + "sendToPaymentDeliveryError": "Hindi available ang multi-counter para sa delivery orders.", + "orderSentToPaymentSuccess": "Naipadala ang order #{number} sa cashier", + "sendToPayment": "Ipadala sa bayaran", + "paymentTicketPrinted": "Nai-print ang ticket sa pagbabayad", + "paymentTicketPrintFailed": "Pumalya ang pag-print ng ticket sa pagbabayad", + "partialPaid": "Nabayaran: {paid}", + "releaseBlockedTitle": "Nakaharang ang paglabas", + "printTicket": "I-print ang Ticket", + "payTicket": "Magbayad", + "paymentQueue": "Pila ng Pagbabayad", + "scanOrEnterTicket": "I-scan ang ticket QR o ilagay ang numero...", + "paymentQueueEmpty": "Walang naghihintay na ticket", + "paymentQueueEmptyDesc": "Lalabas dito ang mga order na ipinadala sa pagbabayad.", + "paidBlockedSection": "Bayad — Nakaharang ang paglabas", + "awaitingPaymentSection": "Naghihintay ng Pagbabayad", + "paymentTicket": "Ticket sa Pagbabayad", + "paymentTicketDesc": "Nalikha ang ticket at naipadala sa pila ng pagbabayad.", + "ticketNumber": "Numero ng Ticket", + "paymentTicketInstruction": "Dalhin ang ticket na ito sa payment counter upang magbayad.", + "selectCounter": "Pumili ng Counter" }, "printTest": { "amt": "Halaga", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Isang shift bawat kawani", "oneShiftPerStaffHint": "Payagan ang bawat kawani na magbukas at magsara ng sarili nilang cash shift nang hiwalay.", "oneShiftPerStaffSharedDrawerWarning": "Paalala: Kung ang dalawang magkasabay na shift ay gumagamit ng iisang pisikal na kaha, hindi pisikal na magkahiwalay ang bilang ng pera bawat shift.", - "oneShiftPerStaffDisableBlocked": "Hindi maaaring i-off ang isang shift bawat kawani habang maraming shift ang nakabukas. Isara muna ang mga karagdagang shift." + "oneShiftPerStaffDisableBlocked": "Hindi maaaring i-off ang isang shift bawat kawani habang maraming shift ang nakabukas. Isara muna ang mga karagdagang shift.", + "tabCounters": "Mga Counter", + "counterRoleOrdering": "Pagtanggap ng Order", + "counterRolePayment": "Pagbabayad", + "counterRoleCombined": "Pinagsama", + "ownerPermissionRequired": "Kailangan ng pahintulot ng may-ari upang pamahalaan ang mga counter.", + "multiCounterEnabled": "Naka-enable ang multi-counter ordering", + "multiCounterDisabled": "Naka-disable ang multi-counter ordering", + "counterUpdated": "Na-update ang counter", + "counterCreated": "Nalikha ang counter", + "counterDeleted": "Na-delete ang counter", + "multiCounterTitle": "Pag-order sa Maraming Counter", + "multiCounterDesc": "I-configure ang magkahiwalay na ordering at payment counter.", + "namedCounters": "Mga Pinangalanang Counter", + "namedCountersHint": "Pinipili ng bawat POS station ang gampanin: Ordering, Payment, o Combined.", + "addCounter": "Magdagdag ng Counter", + "editCounter": "I-edit ang Counter", + "counterModalDesc": "Itakda ang pangalan at operasyonal na gampanin ng counter.", + "counterName": "Pangalan ng Counter", + "counterRole": "Gampanin ng Counter", + "counterIsActive": "Aktibo", + "confirmDeleteCounter": "I-delete ang counter na ito?", + "confirmDeleteCounterDesc": "Permanenteng tatanggalin ang counter na ito." }, "setup": { "alreadyCompleted": "Nakumpleto na ang setup sa installation na ito. Nireredirect sa pag-login…", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index 66c513080..13d5a3672 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "L’heure planifiée d’envoi en cuisine doit être dans le futur ({timezone}).", "scheduledDstSkippedError": "Cette heure locale est sautée lors du changement d’heure d’été dans {timezone}. Choisissez une heure valide après le changement.", "scheduledDstAmbiguousError": "Cette heure locale se produit deux fois lors du changement d’heure dans {timezone}. Choisissez une heure non ambiguë.", - "scheduledInvalidTimeError": "Saisissez une date et une heure valides dans le fuseau du magasin ({timezone})." + "scheduledInvalidTimeError": "Saisissez une date et une heure valides dans le fuseau du magasin ({timezone}).", + "ticketLoaded": "Ticket #{number} chargé", + "ticketNotFound": "Ticket introuvable ou déjà réglé", + "sendToPaymentDeliveryError": "La commande multi-comptoirs n'est pas disponible pour les livraisons.", + "orderSentToPaymentSuccess": "Commande #{number} envoyée à la caisse", + "sendToPayment": "Envoyer à l'encaissement", + "paymentTicketPrinted": "Ticket de paiement imprimé", + "paymentTicketPrintFailed": "Échec de l'impression du ticket de paiement", + "partialPaid": "Réglé : {paid}", + "releaseBlockedTitle": "Envoi bloqué", + "printTicket": "Imprimer le ticket", + "payTicket": "Régler", + "paymentQueue": "File des paiements", + "scanOrEnterTicket": "Scanner le QR ou saisir le numéro...", + "paymentQueueEmpty": "Aucun ticket en attente", + "paymentQueueEmptyDesc": "Les commandes envoyées au paiement apparaîtront ici.", + "paidBlockedSection": "Réglé — Envoi cuisine bloqué", + "awaitingPaymentSection": "En attente de paiement", + "paymentTicket": "Ticket de paiement", + "paymentTicketDesc": "Ticket créé et envoyé dans la file d'attente.", + "ticketNumber": "Numéro de ticket", + "paymentTicketInstruction": "Présentez ce ticket à la caisse pour effectuer le règlement.", + "selectCounter": "Sélectionner le comptoir" }, "printTest": { "amt": "Montant", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Un service par employé", "oneShiftPerStaffHint": "Permettre à chaque employé d’ouvrir et de fermer son propre service de caisse de manière indépendante.", "oneShiftPerStaffSharedDrawerWarning": "Remarque : Si deux services simultanés partagent un même tiroir-caisse physique, les comptages individuels ne seront pas physiquement isolés.", - "oneShiftPerStaffDisableBlocked": "Impossible de désactiver un service par employé lorsque plusieurs services sont ouverts. Fermez d’abord les services supplémentaires." + "oneShiftPerStaffDisableBlocked": "Impossible de désactiver un service par employé lorsque plusieurs services sont ouverts. Fermez d’abord les services supplémentaires.", + "tabCounters": "Comptoirs", + "counterRoleOrdering": "Prise de commande", + "counterRolePayment": "Encaissement", + "counterRoleCombined": "Combiné", + "ownerPermissionRequired": "Autorisation propriétaire requise pour gérer les comptoirs.", + "multiCounterEnabled": "Fonctionnement multi-comptoirs activé", + "multiCounterDisabled": "Fonctionnement multi-comptoirs désactivé", + "counterUpdated": "Comptoir mis à jour", + "counterCreated": "Comptoir créé", + "counterDeleted": "Comptoir supprimé", + "multiCounterTitle": "Commande multi-comptoirs", + "multiCounterDesc": "Configurez des comptoirs de commande et d'encaissement pour séparer la saisie et le paiement.", + "namedCounters": "Comptoirs nommés", + "namedCountersHint": "Chaque poste sélectionne son rôle : Commande, Caisse ou Combiné.", + "addCounter": "Ajouter un comptoir", + "editCounter": "Modifier le comptoir", + "counterModalDesc": "Définissez le nom et le rôle du comptoir.", + "counterName": "Nom du comptoir", + "counterRole": "Rôle du comptoir", + "counterIsActive": "Actif", + "confirmDeleteCounter": "Supprimer ce comptoir ?", + "confirmDeleteCounterDesc": "Ce comptoir sera définitivement supprimé." }, "setup": { "alreadyCompleted": "La configuration a déjà été effectuée sur cette installation. Redirection vers la connexion…", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index 817af653f..37b60130e 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "किचन में भेजने का निर्धारित समय भविष्य में होना चाहिए ({timezone})।", "scheduledDstSkippedError": "{timezone} में डेलाइट-सेविंग बदलाव के कारण यह स्थानीय समय छोड़ दिया गया है। घड़ी बदलने के बाद का मान्य समय चुनें।", "scheduledDstAmbiguousError": "{timezone} में डेलाइट-सेविंग बदलाव के दौरान यह स्थानीय समय दो बार आता है। दोहराए गए घंटे के बाहर का स्पष्ट समय चुनें।", - "scheduledInvalidTimeError": "स्टोर टाइमज़ोन ({timezone}) में मान्य तारीख और समय दर्ज करें।" + "scheduledInvalidTimeError": "स्टोर टाइमज़ोन ({timezone}) में मान्य तारीख और समय दर्ज करें।", + "ticketLoaded": "टिकट #{number} लोड किया गया", + "ticketNotFound": "टिकट नहीं मिला या पहले ही भुगतान हो चुका है", + "sendToPaymentDeliveryError": "डिलीवरी ऑर्डर के लिए मल्टी-काउंटर ऑर्डरिंग उपलब्ध नहीं है।", + "orderSentToPaymentSuccess": "ऑर्डर #{number} भुगतान के लिए भेजा गया", + "sendToPayment": "भुगतान के लिए भेजें", + "paymentTicketPrinted": "भुगतान टिकट प्रिंट हो गया", + "paymentTicketPrintFailed": "भुगतान टिकट प्रिंट करने में विफल", + "partialPaid": "भुगतान किया: {paid}", + "releaseBlockedTitle": "रसोई रिलीज अवरुद्ध है", + "printTicket": "टिकट प्रिंट करें", + "payTicket": "भुगतान करें", + "paymentQueue": "भुगतान कतार", + "scanOrEnterTicket": "टिकट क्यूआर स्कैन करें या नंबर दर्ज करें...", + "paymentQueueEmpty": "कोई प्रतीक्षारत टिकट नहीं", + "paymentQueueEmptyDesc": "भुगतान के लिए भेजे गए ऑर्डर यहां दिखाई देंगे।", + "paidBlockedSection": "भुगतान हुआ — रिलीज अवरुद्ध", + "awaitingPaymentSection": "भुगतान प्रतीक्षित", + "paymentTicket": "भुगतान टिकट", + "paymentTicketDesc": "टिकट बनाया गया और भुगतान कतार में भेजा गया।", + "ticketNumber": "टिकट संख्या", + "paymentTicketInstruction": "भुगतान के लिए यह टिकट भुगतान काउंटर पर ले जाएं।", + "selectCounter": "काउंटर चुनें" }, "printTest": { "amt": "राशि", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "प्रति स्टाफ सदस्य एक शिफ्ट", "oneShiftPerStaffHint": "प्रत्येक स्टाफ सदस्य को अपनी कैश शिफ्ट स्वतंत्र रूप से खोलने और बंद करने की अनुमति दें।", "oneShiftPerStaffSharedDrawerWarning": "नोट: यदि दो समवर्ती शिफ्ट एक ही भौतिक कैश ड्रॉअर साझा करती हैं, तो व्यक्तिगत नकद गणना भौतिक रूप से अलग नहीं होगी।", - "oneShiftPerStaffDisableBlocked": "एक से अधिक शिफ्ट खुली होने पर प्रति स्टाफ सदस्य एक शिफ्ट को अक्षम नहीं किया जा सकता। पहले अतिरिक्त शिफ्ट बंद करें।" + "oneShiftPerStaffDisableBlocked": "एक से अधिक शिफ्ट खुली होने पर प्रति स्टाफ सदस्य एक शिफ्ट को अक्षम नहीं किया जा सकता। पहले अतिरिक्त शिफ्ट बंद करें।", + "tabCounters": "काउंटर", + "counterRoleOrdering": "ऑर्डरिंग", + "counterRolePayment": "भुगतान", + "counterRoleCombined": "संयुक्त", + "ownerPermissionRequired": "काउंटर प्रबंधित करने के लिए स्वामी की अनुमति आवश्यक है।", + "multiCounterEnabled": "मल्टी-काउंटर ऑर्डरिंग सक्षम है", + "multiCounterDisabled": "मल्टी-काउंटर ऑर्डरिंग अक्षम है", + "counterUpdated": "काउंटर अपडेट किया गया", + "counterCreated": "काउंटर बनाया गया", + "counterDeleted": "काउंटर हटाया गया", + "multiCounterTitle": "मल्टी-काउंटर ऑर्डरिंग", + "multiCounterDesc": "अलग ऑर्डर लेने और भुगतान के लिए काउंटर कॉन्फ़िगर करें।", + "namedCounters": "नामित काउंटर", + "namedCountersHint": "प्रत्येक पीओएस स्टेशन अपनी भूमिका चुनता है: ऑर्डरिंग, भुगतान या संयुक्त।", + "addCounter": "काउंटर जोड़ें", + "editCounter": "काउंटर संपादित करें", + "counterModalDesc": "काउंटर का नाम और परिचालन भूमिका निर्धारित करें।", + "counterName": "काउंटर का नाम", + "counterRole": "काउंटर की भूमिका", + "counterIsActive": "सक्रिय", + "confirmDeleteCounter": "क्या यह काउंटर हटाना चाहते हैं?", + "confirmDeleteCounterDesc": "यह काउंटर हमेशा के लिए हटा दिया जाएगा।" }, "setup": { "alreadyCompleted": "इस इंस्टाल पर सेटअप पहले ही पूरा हो चुका है. लॉगिन करने के लिए पुनः निर्देशित किया जा रहा है...", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index 584771d9c..98eb628fa 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "Waktu kirim ke dapur yang dijadwalkan harus di masa mendatang ({timezone}).", "scheduledDstSkippedError": "Waktu lokal tersebut dilewati oleh transisi waktu musim panas di {timezone}. Pilih waktu yang valid setelah perubahan jam.", "scheduledDstAmbiguousError": "Waktu lokal tersebut terjadi dua kali saat transisi waktu musim panas di {timezone}. Pilih waktu yang tidak ambigu.", - "scheduledInvalidTimeError": "Masukkan tanggal dan waktu yang valid dalam zona waktu toko ({timezone})." + "scheduledInvalidTimeError": "Masukkan tanggal dan waktu yang valid dalam zona waktu toko ({timezone}).", + "ticketLoaded": "Tiket #{number} dimuat", + "ticketNotFound": "Tiket tidak ditemukan atau sudah dibayar", + "sendToPaymentDeliveryError": "Alur multi-kasir tidak tersedia untuk pesanan pesan antar.", + "orderSentToPaymentSuccess": "Pesanan #{number} dikirim ke pembayaran", + "sendToPayment": "Kirim ke pembayaran", + "paymentTicketPrinted": "Tiket pembayaran berhasil dicetak", + "paymentTicketPrintFailed": "Gagal mencetak tiket pembayaran", + "partialPaid": "Dibayar: {paid}", + "releaseBlockedTitle": "Pelepasan terblokir", + "printTicket": "Cetak tiket", + "payTicket": "Bayar", + "paymentQueue": "Antrean pembayaran", + "scanOrEnterTicket": "Pindai QR tiket atau masukkan nomor...", + "paymentQueueEmpty": "Tidak ada tiket menunggu", + "paymentQueueEmptyDesc": "Pesanan yang dikirim ke pembayaran akan muncul di sini.", + "paidBlockedSection": "Lunas — Pelepasan terblokir", + "awaitingPaymentSection": "Menunggu pembayaran", + "paymentTicket": "Tiket pembayaran", + "paymentTicketDesc": "Tiket dibuat dan dikirim ke antrean pembayaran.", + "ticketNumber": "Nomor tiket", + "paymentTicketInstruction": "Bawa tiket ini ke meja kasir untuk membayar.", + "selectCounter": "Pilih kasir" }, "printTest": { "amt": "Jml", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Satu shift per staf", "oneShiftPerStaffHint": "Izinkan setiap anggota staf untuk membuka dan menutup shift kasir mereka sendiri secara mandiri.", "oneShiftPerStaffSharedDrawerWarning": "Catatan: Jika dua shift bersamaan berbagi satu laci uang fisik yang sama, perhitungan uang tunai per shift tidak terpisah secara fisik.", - "oneShiftPerStaffDisableBlocked": "Tidak dapat menonaktifkan satu shift per staf saat beberapa shift masih terbuka. Tutup shift tambahan terlebih dahulu." + "oneShiftPerStaffDisableBlocked": "Tidak dapat menonaktifkan satu shift per staf saat beberapa shift masih terbuka. Tutup shift tambahan terlebih dahulu.", + "tabCounters": "Kasir & Konter", + "counterRoleOrdering": "Pemesanan", + "counterRolePayment": "Pembayaran", + "counterRoleCombined": "Gabungan", + "ownerPermissionRequired": "Izin pemilik diperlukan untuk mengelola konter.", + "multiCounterEnabled": "Alur multi-kasir diaktifkan", + "multiCounterDisabled": "Alur multi-kasir dinonaktifkan", + "counterUpdated": "Konter diperbarui", + "counterCreated": "Konter dibuat", + "counterDeleted": "Konter dihapus", + "multiCounterTitle": "Alur Pemesanan Multi-Kasir", + "multiCounterDesc": "Konfigurasikan konter pemesanan dan pembayaran terpisah untuk alur buat di sini, bayar di sana.", + "namedCounters": "Daftar Konter", + "namedCountersHint": "Setiap perangkat POS memilih perannya: Pemesanan, Pembayaran, atau Gabungan.", + "addCounter": "Tambah konter", + "editCounter": "Edit konter", + "counterModalDesc": "Atur nama konter dan peran operasionalnya.", + "counterName": "Nama konter", + "counterRole": "Peran konter", + "counterIsActive": "Aktif", + "confirmDeleteCounter": "Hapus konter ini?", + "confirmDeleteCounterDesc": "Konter ini akan dihapus secara permanen." }, "setup": { "alreadyCompleted": "Pengaturan awal sudah selesai dilakukan pada instalasi ini. Mengalihkan ke layar masuk…", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index 1c9bd0b58..c2913bd1b 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "L’orario programmato di invio in cucina deve essere nel futuro ({timezone}).", "scheduledDstSkippedError": "Questo orario locale è saltato dal cambio dell’ora legale in {timezone}. Scegli un orario valido dopo il cambio.", "scheduledDstAmbiguousError": "Questo orario locale si ripete due volte durante il cambio dell’ora in {timezone}. Scegli un orario non ambiguo.", - "scheduledInvalidTimeError": "Inserisci data e ora valide nel fuso orario del locale ({timezone})." + "scheduledInvalidTimeError": "Inserisci data e ora valide nel fuso orario del locale ({timezone}).", + "ticketLoaded": "Ticket caricato #{number}", + "ticketNotFound": "Ticket non trovato o già pagato", + "sendToPaymentDeliveryError": "La cassa multi-banco non è disponibile per le consegne.", + "orderSentToPaymentSuccess": "Ordine #{number} inviato al pagamento", + "sendToPayment": "Invia al pagamento", + "paymentTicketPrinted": "Scontrino di pagamento stampato", + "paymentTicketPrintFailed": "Stampa dello scontrino di pagamento fallita", + "partialPaid": "Pagato: {paid}", + "releaseBlockedTitle": "Rilascio bloccato", + "printTicket": "Stampa ticket", + "payTicket": "Paga", + "paymentQueue": "Coda pagamenti", + "scanOrEnterTicket": "Scansiona QR o inserisci numero...", + "paymentQueueEmpty": "Nessun ticket in attesa", + "paymentQueueEmptyDesc": "Gli ordini inviati al pagamento appariranno qui.", + "paidBlockedSection": "Pagato — Rilascio bloccato", + "awaitingPaymentSection": "In attesa di pagamento", + "paymentTicket": "Ticket di pagamento", + "paymentTicketDesc": "Ticket creato e inviato alla coda pagamenti.", + "ticketNumber": "Numero ticket", + "paymentTicketInstruction": "Porta questo ticket alla cassa per completare il pagamento.", + "selectCounter": "Seleziona postazione" }, "printTest": { "amt": "Importo", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Un turno per dipendente", "oneShiftPerStaffHint": "Consenti a ciascun dipendente di aprire e chiudere il proprio turno di cassa in modo indipendente.", "oneShiftPerStaffSharedDrawerWarning": "Nota: Se due turni simultanei condividono un unico cassetto contanti fisico, i conteggi individuali non saranno isolati fisicamente.", - "oneShiftPerStaffDisableBlocked": "Impossibile disattivare un turno per dipendente mentre più turni sono aperti. Chiudi prima gli altri turni." + "oneShiftPerStaffDisableBlocked": "Impossibile disattivare un turno per dipendente mentre più turni sono aperti. Chiudi prima gli altri turni.", + "tabCounters": "Postazioni", + "counterRoleOrdering": "Solo ordinazione", + "counterRolePayment": "Solo pagamento", + "counterRoleCombined": "Combinato", + "ownerPermissionRequired": "È richiesto il permesso del proprietario per gestire le postazioni.", + "multiCounterEnabled": "Flusso multi-postazione abilitato", + "multiCounterDisabled": "Flusso multi-postazione disabilitato", + "counterUpdated": "Postazione aggiornata", + "counterCreated": "Postazione creata", + "counterDeleted": "Postazione eliminata", + "multiCounterTitle": "Ordinazione multi-postazione", + "multiCounterDesc": "Configura postazioni di ordinazione e pagamento separate.", + "namedCounters": "Postazioni configurate", + "namedCountersHint": "Ciascun terminale seleziona il proprio ruolo: Ordinazione, Cassa o Combinato.", + "addCounter": "Aggiungi postazione", + "editCounter": "Modifica postazione", + "counterModalDesc": "Imposta nome e ruolo della postazione.", + "counterName": "Nome postazione", + "counterRole": "Ruolo postazione", + "counterIsActive": "Attivo", + "confirmDeleteCounter": "Eliminare questa postazione?", + "confirmDeleteCounterDesc": "Questa postazione verrà rimossa definitivamente." }, "setup": { "alreadyCompleted": "La configurazione è già stata completata su questa installazione. Reindirizzamento all'accesso…", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index 71200d487..612dec74a 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "キッチン送信日時は現在より後の時刻を指定してください ({timezone})。", "scheduledDstSkippedError": "その現地時刻は {timezone} のサマータイム切替により存在しません。時計切替後の有効な時刻を選択してください。", "scheduledDstAmbiguousError": "その現地時刻は {timezone} のサマータイム切替により2回発生します。重複する時間帯を避けて選択してください。", - "scheduledInvalidTimeError": "店舗タイムゾーン ({timezone}) で有効な日時を入力してください。" + "scheduledInvalidTimeError": "店舗タイムゾーン ({timezone}) で有効な日時を入力してください。", + "ticketLoaded": "伝票 #{number} を読み込みました", + "ticketNotFound": "伝票が見つからないか、既に会計済みです", + "sendToPaymentDeliveryError": "複数カウンター運用は配達注文では利用できません。", + "orderSentToPaymentSuccess": "注文 #{number} を会計へ送信しました", + "sendToPayment": "会計へ送る", + "paymentTicketPrinted": "会計用伝票を印刷しました", + "paymentTicketPrintFailed": "会計用伝票の印刷に失敗しました", + "partialPaid": "支払済: {paid}", + "releaseBlockedTitle": "厨房送信が保留されています", + "printTicket": "伝票を印刷", + "payTicket": "会計", + "paymentQueue": "会計待ち一覧", + "scanOrEnterTicket": "QRコードをスキャンするか番号を入力...", + "paymentQueueEmpty": "会計待ちの伝票はありません", + "paymentQueueEmptyDesc": "会計へ送信された注文がここに表示されます。", + "paidBlockedSection": "会計済 — 厨房送信保留", + "awaitingPaymentSection": "会計待ち", + "paymentTicket": "会計用伝票", + "paymentTicketDesc": "伝票が作成され、会計待ち一覧に送信されました。", + "ticketNumber": "伝票番号", + "paymentTicketInstruction": "この伝票をお会計カウンターにお持ちください。", + "selectCounter": "カウンターを選択" }, "printTest": { "amt": "金額", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "スタッフごとに1シフト", "oneShiftPerStaffHint": "各スタッフが自分のレジシフトを個別に開始・終了できるようにします。", "oneShiftPerStaffSharedDrawerWarning": "注意:2つの同時シフトが1つの物理キャッシュドロワーを共有する場合、個別の現金カウントは物理的に分離されません。", - "oneShiftPerStaffDisableBlocked": "複数のシフトが開いている間は、スタッフごとに1シフトを無効にできません。先に他のシフトを閉じてください。" + "oneShiftPerStaffDisableBlocked": "複数のシフトが開いている間は、スタッフごとに1シフトを無効にできません。先に他のシフトを閉じてください。", + "tabCounters": "カウンター", + "counterRoleOrdering": "注文専用", + "counterRolePayment": "会計専用", + "counterRoleCombined": "注文・会計", + "ownerPermissionRequired": "カウンター管理にはオーナー権限が必要です。", + "multiCounterEnabled": "複数カウンター運用を有効化しました", + "multiCounterDisabled": "複数カウンター運用を無効化しました", + "counterUpdated": "カウンターを更新しました", + "counterCreated": "カウンターを作成しました", + "counterDeleted": "カウンターを削除しました", + "multiCounterTitle": "複数カウンター運用", + "multiCounterDesc": "注文受付と会計のカウンターを個別に分離して設定します。", + "namedCounters": "登録カウンター", + "namedCountersHint": "各POS端末で担当ロール(注文、会計、併用)を選択します。", + "addCounter": "カウンターを追加", + "editCounter": "カウンターを編集", + "counterModalDesc": "カウンター名と役割を設定します。", + "counterName": "カウンター名", + "counterRole": "役割", + "counterIsActive": "有効", + "confirmDeleteCounter": "このカウンターを削除しますか?", + "confirmDeleteCounterDesc": "このカウンターは完全に削除されます。" }, "setup": { "alreadyCompleted": "このインストールではセットアップが完了しています。ログインへ移動します…", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index a4d64d3a4..b1e639d43 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "예약 주방 전송 시각은 미래 시각이어야 합니다 ({timezone}).", "scheduledDstSkippedError": "해당 현지 시각은 {timezone} 일광절약시간 전환으로 건너뛰는 시각입니다. 시간 변경 이후의 유효한 시각을 선택하세요.", "scheduledDstAmbiguousError": "해당 현지 시각은 {timezone} 일광절약시간 전환 중 두 번 반복됩니다. 중복 시간대 밖의 명확한 시각을 선택하세요.", - "scheduledInvalidTimeError": "매장 시간대 ({timezone}) 기준의 올바른 날짜와 시각을 입력하세요." + "scheduledInvalidTimeError": "매장 시간대 ({timezone}) 기준의 올바른 날짜와 시각을 입력하세요.", + "ticketLoaded": "티켓 #{number} 로드 완료", + "ticketNotFound": "티켓을 찾을 수 없거나 이미 결제되었습니다", + "sendToPaymentDeliveryError": "배달 주문에는 다중 카운터 흐름을 사용할 수 없습니다.", + "orderSentToPaymentSuccess": "주문 #{number} 결제 대기 등록 완료", + "sendToPayment": "결제 대기로 보내기", + "paymentTicketPrinted": "결제 티켓 출력 완료", + "paymentTicketPrintFailed": "결제 티켓 출력 실패", + "partialPaid": "결제 금액: {paid}", + "releaseBlockedTitle": "주방 전송 차단됨", + "printTicket": "티켓 출력", + "payTicket": "결제", + "paymentQueue": "결제 대기 목록", + "scanOrEnterTicket": "티켓 QR 스캔 또는 번호 입력...", + "paymentQueueEmpty": "대기 중인 티켓이 없습니다", + "paymentQueueEmptyDesc": "결제로 전송된 주문이 여기에 표시됩니다.", + "paidBlockedSection": "결제 완료 — 주방 전송 차단됨", + "awaitingPaymentSection": "결제 대기 중", + "paymentTicket": "결제 티켓", + "paymentTicketDesc": "티켓이 생성되어 결제 대기열로 전송되었습니다.", + "ticketNumber": "티켓 번호", + "paymentTicketInstruction": "이 티켓을 결제 카운터로 가져가 결제해 주세요.", + "selectCounter": "카운터 선택" }, "printTest": { "amt": "금액", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "직원당 하나의 교대조", "oneShiftPerStaffHint": "각 직원이 자신의 현금 교대조를 독립적으로 열고 닫을 수 있도록 허용합니다.", "oneShiftPerStaffSharedDrawerWarning": "참고: 두 개의 동시 교대조가 하나의 물리적 현금 서랍을 공유하는 경우 개별 현금 집계가 물리적으로 분리되지 않습니다.", - "oneShiftPerStaffDisableBlocked": "여러 교대조가 열려 있는 동안에는 직원당 하나의 교대조 설정을 끌 수 없습니다. 먼저 추가 교대조를 닫으세요." + "oneShiftPerStaffDisableBlocked": "여러 교대조가 열려 있는 동안에는 직원당 하나의 교대조 설정을 끌 수 없습니다. 먼저 추가 교대조를 닫으세요.", + "tabCounters": "카운터", + "counterRoleOrdering": "주문 전용", + "counterRolePayment": "결제 전용", + "counterRoleCombined": "주문/결제 통합", + "ownerPermissionRequired": "카운터 관리를 위해 소유자 권한이 필요합니다.", + "multiCounterEnabled": "다중 카운터 주문 흐름 활성화됨", + "multiCounterDisabled": "다중 카운터 주문 흐름 비활성화됨", + "counterUpdated": "카운터 정보 업데이트됨", + "counterCreated": "카운터 생성됨", + "counterDeleted": "카운터 삭제됨", + "multiCounterTitle": "다중 카운터 주문 흐름", + "multiCounterDesc": "주문 카운터와 결제 카운터를 분리하여 워크플로를 구성합니다.", + "namedCounters": "등록된 카운터", + "namedCountersHint": "각 POS 기기별로 주문, 결제 또는 통합 역할을 지정합니다.", + "addCounter": "카운터 추가", + "editCounter": "카운터 편집", + "counterModalDesc": "카운터 이름과 운영 역할을 설정합니다.", + "counterName": "카운터 이름", + "counterRole": "카운터 역할", + "counterIsActive": "활성", + "confirmDeleteCounter": "이 카운터를 삭제하시겠습니까?", + "confirmDeleteCounterDesc": "이 카운터는 완전히 제거됩니다." }, "setup": { "alreadyCompleted": "이 설치에서는 초기 설정이 이미 완료되었습니다. 로그인으로 이동합니다…", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index 3f363d743..2356c01ed 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "भान्सामा पठाउने तालिकाबद्ध समय भविष्यको हुनुपर्छ ({timezone})।", "scheduledDstSkippedError": "{timezone} मा डेलाइट-सेभिङ परिवर्तनले यो स्थानीय समय फड्काउँछ। घडी परिवर्तन पछिको मान्य समय छान्नुहोस्।", "scheduledDstAmbiguousError": "{timezone} मा डेलाइट-सेभिङ परिवर्तन हुँदा यो स्थानीय समय दुई पटक आउँछ। दोहोरिने घण्टा बाहिरको स्पष्ट समय छान्नुहोस्।", - "scheduledInvalidTimeError": "स्टोर टाइमजोन ({timezone}) मा मान्य मिति र समय प्रविष्ट गर्नुहोस्।" + "scheduledInvalidTimeError": "स्टोर टाइमजोन ({timezone}) मा मान्य मिति र समय प्रविष्ट गर्नुहोस्।", + "ticketLoaded": "टिकट #{number} लोड भयो", + "ticketNotFound": "टिकट भेटिएन वा भुक्तानी भइसकेको छ", + "sendToPaymentDeliveryError": "डेलिभरी अर्डरको लागि बहु-काउन्टर व्यवस्था उपलब्ध छैन।", + "orderSentToPaymentSuccess": "अर्डर #{number} भुक्तानीको लागि पठाइयो", + "sendToPayment": "भुक्तानीमा पठाउनुहोस्", + "paymentTicketPrinted": "भुक्तानी टिकट प्रिन्ट भयो", + "paymentTicketPrintFailed": "भुक्तानी टिकट प्रिन्ट गर्न असफल", + "partialPaid": "भुक्तान गरिएको: {paid}", + "releaseBlockedTitle": "रिलिज रोकिएको छ", + "printTicket": "टिकट प्रिन्ट गर्नुहोस्", + "payTicket": "भुक्तानी गर्नुहोस्", + "paymentQueue": "भुक्तानी लाम", + "scanOrEnterTicket": "टिकट QR स्क्यान गर्नुहोस् वा नम्बर प्रविष्ट गर्नुहोस्...", + "paymentQueueEmpty": "कुनै प्रतीक्षा टिकट छैन", + "paymentQueueEmptyDesc": "भुक्तानीका लागि पठाइएका अर्डरहरू यहाँ देखिनेछन्।", + "paidBlockedSection": "भुक्तानी भयो — रिलिज रोकियो", + "awaitingPaymentSection": "भुक्तानी पर्खँदै", + "paymentTicket": "भुक्तानी टिकट", + "paymentTicketDesc": "टिकट तयार गरी भुक्तानी लाममा पठाइयो।", + "ticketNumber": "टिकट नम्बर", + "paymentTicketInstruction": "भुक्तानी गर्न यो टिकट भुक्तानी काउन्टरमा लैजानुहोस्।", + "selectCounter": "काउन्टर छान्नुहोस्" }, "printTest": { "amt": "रकम", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "प्रति कर्मचारी एक सिफ्ट", "oneShiftPerStaffHint": "प्रत्येक कर्मचारीलाई आफ्नो क्यास सिफ्ट स्वतन्त्र रूपमा खोल्न र बन्द गर्न अनुमति दिनुहोस्।", "oneShiftPerStaffSharedDrawerWarning": "नोट: यदि दुई एकैसाथ चलेका सिफ्टहरूले एउटै भौतिक क्यास ड्रअर प्रयोग गर्छन् भने व्यक्तिगत नगद गणना भौतिक रूपमा अलग हुनेछैन।", - "oneShiftPerStaffDisableBlocked": "धेरै सिफ्टहरू खुला हुँदा प्रति कर्मचारी एक सिफ्ट बन्द गर्न सकिँदैन। पहिले अतिरिक्त सिफ्टहरू बन्द गर्नुहोस्।" + "oneShiftPerStaffDisableBlocked": "धेरै सिफ्टहरू खुला हुँदा प्रति कर्मचारी एक सिफ्ट बन्द गर्न सकिँदैन। पहिले अतिरिक्त सिफ्टहरू बन्द गर्नुहोस्।", + "tabCounters": "काउन्टरहरू", + "counterRoleOrdering": "अर्डर लिने", + "counterRolePayment": "भुक्तानी लिने", + "counterRoleCombined": "संयुक्त", + "ownerPermissionRequired": "काउन्टरहरू व्यवस्थापन गर्न मालिकको अनुमति आवश्यक छ।", + "multiCounterEnabled": "बहु-काउन्टर अर्डर प्रणाली सक्षम पारियो", + "multiCounterDisabled": "बहु-काउन्टर अर्डर प्रणाली असक्षम पारियो", + "counterUpdated": "काउन्टर अद्यावधिक भयो", + "counterCreated": "काउन्टर सिर्जना गरियो", + "counterDeleted": "काउन्टर मेटाइयो", + "multiCounterTitle": "बहु-काउन्टर अर्डर प्रणाली", + "multiCounterDesc": "अर्डर लिन र भुक्तानी गर्न छुट्टाछुट्टै काउन्टरहरू कन्फिगर गर्नुहोस्।", + "namedCounters": "नामित काउन्टरहरू", + "namedCountersHint": "प्रत्येक POS उपकरणले आफ्नो भूमिका छान्छ: अर्डर, भुक्तानी वा संयुक्त।", + "addCounter": "काउन्टर थप्नुहोस्", + "editCounter": "काउन्टर सम्पादन गर्नुहोस्", + "counterModalDesc": "काउन्टरको नाम र सञ्चालन भूमिका सेट गर्नुहोस्।", + "counterName": "काउन्टरको नाम", + "counterRole": "काउन्टरको भूमिका", + "counterIsActive": "सक्रिय", + "confirmDeleteCounter": "यो काउन्टर मेटाउने?", + "confirmDeleteCounterDesc": "यो काउन्टर स्थायी रूपमा हटाइनेछ।" }, "setup": { "alreadyCompleted": "यस स्थापनामा सेटअप पहिले नै पूरा भइसकेको छ। लगइन गर्न रिडिरेक्ट गर्दै...", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index eb400e694..5a5eaad3a 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "De geplande vrijgavetijd voor de keuken moet in de toekomst liggen ({timezone}).", "scheduledDstSkippedError": "Deze lokale tijd wordt overgeslagen door de zomertijdovergang in {timezone}. Kies een geldige tijd na de klokwijziging.", "scheduledDstAmbiguousError": "Deze lokale tijd komt twee keer voor tijdens de klokovergang in {timezone}. Kies een eenduidige tijd buiten het herhaalde uur.", - "scheduledInvalidTimeError": "Voer een geldige datum en tijd in voor de tijdzone van de winkel ({timezone})." + "scheduledInvalidTimeError": "Voer een geldige datum en tijd in voor de tijdzone van de winkel ({timezone}).", + "ticketLoaded": "Bon #{number} geladen", + "ticketNotFound": "Bon niet gevonden of al betaald", + "sendToPaymentDeliveryError": "Multi-baliestroom is niet beschikbaar voor bezorgbestellingen.", + "orderSentToPaymentSuccess": "Bestelling #{number} naar kassa verzonden", + "sendToPayment": "Naar betaling sturen", + "paymentTicketPrinted": "Betaalbon afgedrukt", + "paymentTicketPrintFailed": "Afdrukken van betaalbon mislukt", + "partialPaid": "Betaald: {paid}", + "releaseBlockedTitle": "Vrijgave geblokkeerd", + "printTicket": "Bon afdrukken", + "payTicket": "Betalen", + "paymentQueue": "Betaalwachtrij", + "scanOrEnterTicket": "Scan QR-code of voer nummer in...", + "paymentQueueEmpty": "Geen wachtende bonnen", + "paymentQueueEmptyDesc": "Bestellingen die naar betaling zijn gestuurd, verschijnen hier.", + "paidBlockedSection": "Betaald — Vrijgave geblokkeerd", + "awaitingPaymentSection": "Wacht op betaling", + "paymentTicket": "Betaalbon", + "paymentTicketDesc": "Bon aangemaakt en naar de betaalwachtrij gestuurd.", + "ticketNumber": "Bonnummer", + "paymentTicketInstruction": "Neem deze bon mee naar de kassa om te betalen.", + "selectCounter": "Balie selecteren" }, "printTest": { "amt": "Bedrag", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Één dienst per medewerker", "oneShiftPerStaffHint": "Sta elke medewerker toe om zelfstandig een eigen kassadienst te openen en te sluiten.", "oneShiftPerStaffSharedDrawerWarning": "Let op: Als twee gelijktijdige diensten één fysieke kassalade delen, zijn de individuele kastellingen niet fysiek gescheiden.", - "oneShiftPerStaffDisableBlocked": "Kan één dienst per medewerker niet uitschakelen terwijl er meerdere diensten geopend zijn. Sluit eerst de extra diensten." + "oneShiftPerStaffDisableBlocked": "Kan één dienst per medewerker niet uitschakelen terwijl er meerdere diensten geopend zijn. Sluit eerst de extra diensten.", + "tabCounters": "Balies", + "counterRoleOrdering": "Bestellingen", + "counterRolePayment": "Betaling", + "counterRoleCombined": "Gecombineerd", + "ownerPermissionRequired": "Eigenaarrechten vereist om balies te beheren.", + "multiCounterEnabled": "Multi-baliestroom ingeschakeld", + "multiCounterDisabled": "Multi-baliestroom uitgeschakeld", + "counterUpdated": "Balie bijgewerkt", + "counterCreated": "Balie aangemaakt", + "counterDeleted": "Balie verwijderd", + "multiCounterTitle": "Multi-baliestroom", + "multiCounterDesc": "Stel bestel- en betaalbalies in voor gescheiden opname- en afrekenprocessen.", + "namedCounters": "Benoemde balies", + "namedCountersHint": "Elk kassa-apparaat kiest zijn eigen rol: Bestellen, Betalen of Gecombineerd.", + "addCounter": "Balie toevoegen", + "editCounter": "Balie bewerken", + "counterModalDesc": "Stel de balienaam en operationele rol in.", + "counterName": "Balienaam", + "counterRole": "Balierol", + "counterIsActive": "Actief", + "confirmDeleteCounter": "Deze balie verwijderen?", + "confirmDeleteCounterDesc": "Deze balie wordt permanent verwijderd." }, "setup": { "alreadyCompleted": "De installatie is bij deze installatie al voltooid. Omleiden naar inloggen…", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index cd3e8acd3..317826052 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "O horário agendado para a cozinha deve estar no futuro ({timezone}).", "scheduledDstSkippedError": "Esse horário local é pulado pela transição de horário de verão em {timezone}. Escolha um horário válido após a mudança.", "scheduledDstAmbiguousError": "Esse horário local ocorre duas vezes na transição de horário de verão em {timezone}. Escolha um horário sem ambiguidade.", - "scheduledInvalidTimeError": "Informe uma data e hora válidas no fuso horário da loja ({timezone})." + "scheduledInvalidTimeError": "Informe uma data e hora válidas no fuso horário da loja ({timezone}).", + "ticketLoaded": "Comprovante #{number} carregado", + "ticketNotFound": "Comprovante não encontrado ou já pago", + "sendToPaymentDeliveryError": "O fluxo de múltiplos balcões não está disponível para entregas.", + "orderSentToPaymentSuccess": "Pedido #{number} enviado para o caixa", + "sendToPayment": "Enviar para pagamento", + "paymentTicketPrinted": "Ticket de pagamento impresso", + "paymentTicketPrintFailed": "Falha ao imprimir ticket de pagamento", + "partialPaid": "Pago: {paid}", + "releaseBlockedTitle": "Liberação bloqueada", + "printTicket": "Imprimir ticket", + "payTicket": "Pagar", + "paymentQueue": "Fila de pagamentos", + "scanOrEnterTicket": "Escanear QR ou digitar número...", + "paymentQueueEmpty": "Nenhum ticket aguardando", + "paymentQueueEmptyDesc": "Os pedidos enviados para pagamento aparecerão aqui.", + "paidBlockedSection": "Pago — Liberação bloqueada", + "awaitingPaymentSection": "Aguardando pagamento", + "paymentTicket": "Ticket de pagamento", + "paymentTicketDesc": "Ticket criado e enviado para a fila de pagamentos.", + "ticketNumber": "Número do ticket", + "paymentTicketInstruction": "Apresente este ticket no balcão de pagamento para pagar.", + "selectCounter": "Selecionar balcão" }, "printTest": { "amt": "Total", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Um turno por funcionário", "oneShiftPerStaffHint": "Permitir que cada funcionário abra e feche o seu próprio turno de caixa de forma independente.", "oneShiftPerStaffSharedDrawerWarning": "Nota: Se dois turnos simultâneos partilharem uma única gaveta física, as contagens individuais de dinheiro não estarão separadas fisicamente.", - "oneShiftPerStaffDisableBlocked": "Não é possível desativar um turno por funcionário enquanto vários turnos estiverem abertos. Feche primeiro os turnos adicionais." + "oneShiftPerStaffDisableBlocked": "Não é possível desativar um turno por funcionário enquanto vários turnos estiverem abertos. Feche primeiro os turnos adicionais.", + "tabCounters": "Balcões", + "counterRoleOrdering": "Pedidos", + "counterRolePayment": "Pagamento", + "counterRoleCombined": "Combinado", + "ownerPermissionRequired": "Permissão de proprietário necessária para gerenciar balcões.", + "multiCounterEnabled": "Fluxo com múltiplos balcões ativado", + "multiCounterDisabled": "Fluxo com múltiplos balcões desativado", + "counterUpdated": "Balcão atualizado", + "counterCreated": "Balcão criado", + "counterDeleted": "Balcão excluído", + "multiCounterTitle": "Atendimento em múltiplos balcões", + "multiCounterDesc": "Configure balcões de pedidos e pagamento para fluxos separados de criação e cobrança.", + "namedCounters": "Balcões cadastrados", + "namedCountersHint": "Cada terminal seleciona sua função: Pedidos, Caixa ou Combinado.", + "addCounter": "Adicionar balcão", + "editCounter": "Editar balcão", + "counterModalDesc": "Defina o nome e a função operacional do balcão.", + "counterName": "Nome do balcão", + "counterRole": "Função do balcão", + "counterIsActive": "Ativo", + "confirmDeleteCounter": "Excluir este balcão?", + "confirmDeleteCounterDesc": "Este balcão será permanentemente removido." }, "setup": { "alreadyCompleted": "A configuração já foi concluída nesta instalação. Redirecionando para o login…", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index 4b6e483bc..f9d39f248 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "Время отправки на кухню должно быть в будущем ({timezone}).", "scheduledDstSkippedError": "Это местное время пропускается при переходе на летнее время в {timezone}. Выберите допустимое время после перевода часов.", "scheduledDstAmbiguousError": "Это местное время повторяется дважды при переводе часов в {timezone}. Выберите однозначное время вне повторяющегося часа.", - "scheduledInvalidTimeError": "Введите корректные дату и время в часовом поясе заведения ({timezone})." + "scheduledInvalidTimeError": "Введите корректные дату и время в часовом поясе заведения ({timezone}).", + "ticketLoaded": "Талон #{number} загружен", + "ticketNotFound": "Талон не найден или уже оплачен", + "sendToPaymentDeliveryError": "Многокассовый режим недоступен для заказов на доставку.", + "orderSentToPaymentSuccess": "Заказ #{number} отправлен на оплату", + "sendToPayment": "Отправить на оплату", + "paymentTicketPrinted": "Талон на оплату распечатан", + "paymentTicketPrintFailed": "Не удалось распечатать талон на оплату", + "partialPaid": "Оплачено: {paid}", + "releaseBlockedTitle": "Выпуск заблокирован", + "printTicket": "Печать талона", + "payTicket": "Оплатить", + "paymentQueue": "Очередь оплаты", + "scanOrEnterTicket": "Сканируйте QR или введите номер...", + "paymentQueueEmpty": "Нет ожидающих талонов", + "paymentQueueEmptyDesc": "Заказы, отправленные на оплату, появятся здесь.", + "paidBlockedSection": "Оплачено — Выпуск заблокирован", + "awaitingPaymentSection": "Ожидает оплаты", + "paymentTicket": "Талон на оплату", + "paymentTicketDesc": "Талон создан и отправлен в очередь на оплату.", + "ticketNumber": "Номер талона", + "paymentTicketInstruction": "Предъявите этот талон на кассе для оплаты.", + "selectCounter": "Выбрать кассу" }, "printTest": { "amt": "Сумма", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Одна смена на сотрудника", "oneShiftPerStaffHint": "Разрешить каждому сотруднику самостоятельно открывать и закрывать собственную кассовую смену.", "oneShiftPerStaffSharedDrawerWarning": "Примечание: Если две одновременные смены используют один физический денежный ящик, индивидуальный подсчет наличных не будет физически разделен.", - "oneShiftPerStaffDisableBlocked": "Нельзя отключить режим одной смены на сотрудника, пока открыто несколько смен. Сначала закройте лишние смены." + "oneShiftPerStaffDisableBlocked": "Нельзя отключить режим одной смены на сотрудника, пока открыто несколько смен. Сначала закройте лишние смены.", + "tabCounters": "Кассы", + "counterRoleOrdering": "Приём заказов", + "counterRolePayment": "Оплата", + "counterRoleCombined": "Совмещённая", + "ownerPermissionRequired": "Для управления кассами требуются права владельца.", + "multiCounterEnabled": "Многокассовый режим включён", + "multiCounterDisabled": "Многокассовый режим выключен", + "counterUpdated": "Касса обновлена", + "counterCreated": "Касса создана", + "counterDeleted": "Касса удалена", + "multiCounterTitle": "Многокассовый режим", + "multiCounterDesc": "Настройте раздельные кассы для приёма заказов и оплаты.", + "namedCounters": "Именованные кассы", + "namedCountersHint": "Каждый терминал выбирает свою роль: Приём заказов, Оплата или Совмещённая.", + "addCounter": "Добавить кассу", + "editCounter": "Редактировать кассу", + "counterModalDesc": "Укажите название кассы и её рабочую роль.", + "counterName": "Название кассы", + "counterRole": "Роль кассы", + "counterIsActive": "Активна", + "confirmDeleteCounter": "Удалить эту кассу?", + "confirmDeleteCounterDesc": "Эта касса будет удалена навсегда." }, "setup": { "alreadyCompleted": "Настройка этой установки уже завершена. Перенаправление на вход…", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index c512ea5ea..15d0f5689 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "Ora e planifikuar e dërgimit në kuzhinë duhet të jetë në të ardhmen ({timezone}).", "scheduledDstSkippedError": "Kjo orë lokale anashkalohet nga ndryshimi i orës verore në {timezone}. Zgjidhni një orë të vlefshme pas ndryshimit.", "scheduledDstAmbiguousError": "Kjo orë lokale përsëritet dy herë gjatë ndryshimit të orës në {timezone}. Zgjidhni një orë të paekuivokshme.", - "scheduledInvalidTimeError": "Vendosni një datë dhe orë të vlefshme në zonën kohore të dyqanit ({timezone})." + "scheduledInvalidTimeError": "Vendosni një datë dhe orë të vlefshme në zonën kohore të dyqanit ({timezone}).", + "ticketLoaded": "Bileta #{number} u ngarkua", + "ticketNotFound": "Bileta nuk u gjet ose është paguar tashmë", + "sendToPaymentDeliveryError": "Renditja me shumë sportele nuk është e disponueshme për dërgesat.", + "orderSentToPaymentSuccess": "Porosia #{number} u dërgua për pagesë", + "sendToPayment": "Dërgo te pagesa", + "paymentTicketPrinted": "Bileta e pagesës u printua", + "paymentTicketPrintFailed": "Dështoi printimi i biletës së pagesës", + "partialPaid": "Paguar: {paid}", + "releaseBlockedTitle": "Lëshimi u bllokua", + "printTicket": "Printo biletën", + "payTicket": "Paguaj", + "paymentQueue": "Radha e pagesave", + "scanOrEnterTicket": "Skano kodin QR ose shkruaj numrin...", + "paymentQueueEmpty": "Nuk ka bileta në pritje", + "paymentQueueEmptyDesc": "Porositë e dërguara për pagesë do të shfaqen këtu.", + "paidBlockedSection": "Paguar — Lëshimi u bllokua", + "awaitingPaymentSection": "Në pritje të pagesës", + "paymentTicket": "Biletë pagese", + "paymentTicketDesc": "Bileta u krijua dhe u dërgua në radhën e pagesave.", + "ticketNumber": "Numri i biletës", + "paymentTicketInstruction": "Dërgojeni këtë biletë te sporteli i pagesave për të paguar.", + "selectCounter": "Zgjidh sportelin" }, "printTest": { "amt": "Shuma", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Një turn për çdo punonjës", "oneShiftPerStaffHint": "Lejo çdo punonjës të hapë dhe të mbyllë në mënyrë të pavarur turnin e vet të arkës.", "oneShiftPerStaffSharedDrawerWarning": "Shënim: Nëse dy turne të njëkohshme ndajnë të njëjtin sirtar fizik të arkës, numërimet individuale të parave nuk do të jenë të ndara fizikisht.", - "oneShiftPerStaffDisableBlocked": "Nuk mund të çaktivizohet një turn për punonjës ndërkohë që disa turne janë të hapura. Mbyllni fillimisht turnet shtesë." + "oneShiftPerStaffDisableBlocked": "Nuk mund të çaktivizohet një turn për punonjës ndërkohë që disa turne janë të hapura. Mbyllni fillimisht turnet shtesë.", + "tabCounters": "Sportelet", + "counterRoleOrdering": "Marrje porosie", + "counterRolePayment": "Pagesë", + "counterRoleCombined": "E kombinuar", + "ownerPermissionRequired": "Kërkohet leja e pronarit për të menaxhuar sportelet.", + "multiCounterEnabled": "Porositja me shumë sportele u aktivizua", + "multiCounterDisabled": "Porositja me shumë sportele u çaktivizua", + "counterUpdated": "Sporteli u përditësua", + "counterCreated": "Sporteli u krijua", + "counterDeleted": "Sporteli u fshi", + "multiCounterTitle": "Porositja me shumë sportele", + "multiCounterDesc": "Konfiguroni sportele të veçanta për marrjen e porosive dhe pagesat.", + "namedCounters": "Sportelet e emërtuara", + "namedCountersHint": "Çdo pajisje POS zgjedh rolin e saj: Porosi, Pagesë ose e Kombinuar.", + "addCounter": "Shto sportel", + "editCounter": "Ndrysho sportelin", + "counterModalDesc": "Caktoni emrin dhe rolin operacional të sportelit.", + "counterName": "Emri i sportelit", + "counterRole": "Roli i sportelit", + "counterIsActive": "Aktiv", + "confirmDeleteCounter": "Fshini këtë sportel?", + "confirmDeleteCounterDesc": "Ky sportel do të fshihet përgjithmonë." }, "setup": { "alreadyCompleted": "Konfigurimi në këtë instalim është përfunduar. Po ridrejtohemi te hyrja...", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index b46c8d4ca..e860a63fd 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "เวลาส่งเข้าครัวที่ตั้งไว้ต้องอยู่ในอนาคต ({timezone})", "scheduledDstSkippedError": "เวลาท้องถิ่นนี้ถูกข้ามจากการปรับเวลาออมแสงใน {timezone} กรุณาเลือกเวลาที่ถูกต้องหลังการเปลี่ยนเวลา", "scheduledDstAmbiguousError": "เวลาท้องถิ่นนี้เกิดขึ้นซ้ำสองครั้งระหว่างการปรับเวลาออมแสงใน {timezone} กรุณาเลือกเวลาที่ไม่กำกวม", - "scheduledInvalidTimeError": "กรอกวันที่และเวลาที่ถูกต้องตามเขตเวลาร้าน ({timezone})" + "scheduledInvalidTimeError": "กรอกวันที่และเวลาที่ถูกต้องตามเขตเวลาร้าน ({timezone})", + "ticketLoaded": "โหลดตั๋ว #{number} แล้ว", + "ticketNotFound": "ไม่พบตั๋วหรือชำระเงินแล้ว", + "sendToPaymentDeliveryError": "ระบบหลายเคาน์เตอร์ไม่รองรับรายการสั่งส่งถึงที่", + "orderSentToPaymentSuccess": "ส่งออเดอร์ #{number} ไปยังจุดชำระเงินแล้ว", + "sendToPayment": "ส่งไปชำระเงิน", + "paymentTicketPrinted": "พิมพ์ตั๋วชำระเงินแล้ว", + "paymentTicketPrintFailed": "พิมพ์ตั๋วชำระเงินไม่สำเร็จ", + "partialPaid": "ชำระแล้ว: {paid}", + "releaseBlockedTitle": "การส่งเข้าครัวถูกระงับ", + "printTicket": "พิมพ์ตั๋ว", + "payTicket": "ชำระเงิน", + "paymentQueue": "คิวรอชำระเงิน", + "scanOrEnterTicket": "สแกน QR หรือกรอกหมายเลขตั๋ว...", + "paymentQueueEmpty": "ไม่มีตั๋วที่รอชำระเงิน", + "paymentQueueEmptyDesc": "ออเดอร์ที่ส่งไปชำระเงินจะปรากฏที่นี่", + "paidBlockedSection": "ชำระแล้ว — การส่งเข้าครัวถูกระงับ", + "awaitingPaymentSection": "รอชำระเงิน", + "paymentTicket": "ตั๋วชำระเงิน", + "paymentTicketDesc": "สร้างตั๋วและส่งเข้าคิวรอชำระเงินแล้ว", + "ticketNumber": "หมายเลขตั๋ว", + "paymentTicketInstruction": "นำตั๋วนี้ไปชำระเงินที่เคาน์เตอร์ชำระเงิน", + "selectCounter": "เลือกเคาน์เตอร์" }, "printTest": { "amt": "จำนวนเงิน", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "หนึ่งกะต่อพนักงานหนึ่งคน", "oneShiftPerStaffHint": "อนุญาตให้พนักงานแต่ละคนเปิดและปิดกะเงินสดของตนเองได้อย่างอิสระ", "oneShiftPerStaffSharedDrawerWarning": "หมายเหตุ: หากสองกะที่เปิดพร้อมกันใช้ลิ้นชักเก็บเงินจริงร่วมกัน ยอดนับเงินสดของแต่ละกะจะไม่แยกจากกันทางกายภาพ", - "oneShiftPerStaffDisableBlocked": "ไม่สามารถปิดใช้งานหนึ่งกะต่อพนักงานในขณะที่มีหลายกะเปิดอยู่ กรุณาปิดกะอื่นก่อน" + "oneShiftPerStaffDisableBlocked": "ไม่สามารถปิดใช้งานหนึ่งกะต่อพนักงานในขณะที่มีหลายกะเปิดอยู่ กรุณาปิดกะอื่นก่อน", + "tabCounters": "เคาน์เตอร์", + "counterRoleOrdering": "รับออเดอร์", + "counterRolePayment": "ชำระเงิน", + "counterRoleCombined": "รวมทั้งสองอย่าง", + "ownerPermissionRequired": "ต้องมีสิทธิ์เจ้าของร้านเพื่อจัดการเคาน์เตอร์", + "multiCounterEnabled": "เปิดใช้งานระบบหลายเคาน์เตอร์แล้ว", + "multiCounterDisabled": "ปิดใช้งานระบบหลายเคาน์เตอร์แล้ว", + "counterUpdated": "อัปเดตเคาน์เตอร์แล้ว", + "counterCreated": "สร้างเคาน์เตอร์แล้ว", + "counterDeleted": "ลบเคาน์เตอร์แล้ว", + "multiCounterTitle": "ระบบแยกเคาน์เตอร์สั่งและชำระเงิน", + "multiCounterDesc": "ตั้งค่าแยกเคาน์เตอร์สั่งอาหารและเคาน์เตอร์ชำระเงินเพื่อความสะดวกรวดเร็ว", + "namedCounters": "รายการเคาน์เตอร์", + "namedCountersHint": "เครื่อง POS แต่ละเครื่องสามารถเลือกบทบาท: สั่งอาหาร ชำระเงิน หรือทั้งสองอย่าง", + "addCounter": "เพิ่มเคาน์เตอร์", + "editCounter": "แก้ไขเคาน์เตอร์", + "counterModalDesc": "กำหนดชื่อและบทบาทการทำงานของเคาน์เตอร์", + "counterName": "ชื่อเคาน์เตอร์", + "counterRole": "บทบาทเคาน์เตอร์", + "counterIsActive": "เปิดใช้งาน", + "confirmDeleteCounter": "ต้องการลบเคาน์เตอร์นี้หรือไม่?", + "confirmDeleteCounterDesc": "เคาน์เตอร์นี้จะถูกลบออกอย่างถาวร" }, "setup": { "alreadyCompleted": "ตั้งค่าการติดตั้งนี้เสร็จสมบูรณ์แล้ว กำลังกลับไปหน้าเข้าสู่ระบบ…", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 4708a0828..30a50ea9c 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "Planlanan mutfağa gönderim zamanı gelecekte olmalıdır ({timezone}).", "scheduledDstSkippedError": "Bu yerel saat {timezone} yaz saati geçişinde atlanıyor. Saat değişiminden sonraki geçerli bir zamanı seçin.", "scheduledDstAmbiguousError": "Bu yerel saat {timezone} saat geçişinde iki kez yaşanıyor. Tekrarlanan saatin dışında net bir zaman seçin.", - "scheduledInvalidTimeError": "İşletme saat diliminde ({timezone}) geçerli bir tarih ve saat girin." + "scheduledInvalidTimeError": "İşletme saat diliminde ({timezone}) geçerli bir tarih ve saat girin.", + "ticketLoaded": "Fiş #{number} yüklendi", + "ticketNotFound": "Fiş bulunamadı veya zaten ödendi", + "sendToPaymentDeliveryError": "Çoklu banko akışı paket teslimat siparişleri için kullanılamaz.", + "orderSentToPaymentSuccess": "Sipariş #{number} ödemeye gönderildi", + "sendToPayment": "Ödemeye gönder", + "paymentTicketPrinted": "Ödeme fişi yazdırıldı", + "paymentTicketPrintFailed": "Ödeme fişi yazdırılamadı", + "partialPaid": "Ödenen: {paid}", + "releaseBlockedTitle": "Çıkış engellendi", + "printTicket": "Fişi yazdır", + "payTicket": "Öde", + "paymentQueue": "Ödeme kuyruğu", + "scanOrEnterTicket": "Karekod okutun veya numara girin...", + "paymentQueueEmpty": "Bekleyen fiş yok", + "paymentQueueEmptyDesc": "Ödemeye gönderilen siparişler burada görünür.", + "paidBlockedSection": "Ödendi — Çıkış engellendi", + "awaitingPaymentSection": "Ödeme bekleniyor", + "paymentTicket": "Ödeme fişi", + "paymentTicketDesc": "Fiş oluşturuldu ve ödeme kuyruğuna gönderildi.", + "ticketNumber": "Fiş numarası", + "paymentTicketInstruction": "Ödeme yapmak için bu fişi ödeme bankosuna götürün.", + "selectCounter": "Banko seç" }, "printTest": { "amt": "Tutar", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Personel başına bir vardiya", "oneShiftPerStaffHint": "Her personelin kendi kasa vardiyasını bağımsız olarak açıp kapatmasına izin ver.", "oneShiftPerStaffSharedDrawerWarning": "Not: İki eşzamanlı vardiya tek bir fiziksel nakit çekmecesini paylaşıyorsa, bireysel çekmece nakit sayımları fiziksel olarak ayrılmaz.", - "oneShiftPerStaffDisableBlocked": "Birden fazla vardiya açıkken personel başına bir vardiya devre dışı bırakılamaz. Önce diğer vardiyaları kapatın." + "oneShiftPerStaffDisableBlocked": "Birden fazla vardiya açıkken personel başına bir vardiya devre dışı bırakılamaz. Önce diğer vardiyaları kapatın.", + "tabCounters": "Bankolar", + "counterRoleOrdering": "Sipariş alma", + "counterRolePayment": "Ödeme alma", + "counterRoleCombined": "Birleşik", + "ownerPermissionRequired": "Bankoları yönetmek için işletme sahibi yetkisi gerekir.", + "multiCounterEnabled": "Çoklu banko akışı etkinleştirildi", + "multiCounterDisabled": "Çoklu banko akışı devre dışı bırakıldı", + "counterUpdated": "Banko güncellendi", + "counterCreated": "Banko oluşturuldu", + "counterDeleted": "Banko silindi", + "multiCounterTitle": "Çoklu banko akışı", + "multiCounterDesc": "Ayrı sipariş alma ve ödeme akışları için bankoları yapılandırın.", + "namedCounters": "Tanımlı bankolar", + "namedCountersHint": "Her POS cihazı kendi banko rolünü seçer: Sipariş, Ödeme veya Birleşik.", + "addCounter": "Banko ekle", + "editCounter": "Bankoyu düzenle", + "counterModalDesc": "Banko adını ve operasyonel rolünü belirleyin.", + "counterName": "Banko adı", + "counterRole": "Banko rolü", + "counterIsActive": "Aktif", + "confirmDeleteCounter": "Bu banko silinsin mi?", + "confirmDeleteCounterDesc": "Bu banko kalıcı olarak kaldırılacaktır." }, "setup": { "alreadyCompleted": "Kurulum bu cihazda zaten tamamlandı. Giriş ekranına yönlendiriliyorsunuz…", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index 2110add3e..71d1ae1a7 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "کچن میں بھیجنے کا شیڈول وقت مستقبل میں ہونا چاہیے ({timezone})۔", "scheduledDstSkippedError": "{timezone} میں ڈے لائٹ سیونگ تبدیلی کے باعث یہ مقامی وقت چھوڑ دیا گیا ہے۔ گھڑی کی تبدیلی کے بعد درست وقت منتخب کریں۔", "scheduledDstAmbiguousError": "{timezone} میں ڈے لائٹ سیونگ تبدیلی کے دوران یہ مقامی وقت دو بار آتا ہے۔ دہرائے گئے گھنٹے سے باہر واضح وقت منتخب کریں۔", - "scheduledInvalidTimeError": "اسٹور کے ٹائم زون ({timezone}) میں درست تاریخ اور وقت درج کریں۔" + "scheduledInvalidTimeError": "اسٹور کے ٹائم زون ({timezone}) میں درست تاریخ اور وقت درج کریں۔", + "ticketLoaded": "ٹکٹ #{number} لوڈ ہو گیا", + "ticketNotFound": "ٹکٹ نہیں ملا یا پہلے ہی ادا ہو چکا ہے", + "sendToPaymentDeliveryError": "ڈیلیوری آرڈرز کے لیے ملٹی کاؤنٹر فلو دستیاب نہیں ہے۔", + "orderSentToPaymentSuccess": "آرڈر #{number} ادائیگی کے لیے بھیج دیا گیا", + "sendToPayment": "ادائیگی کے لیے بھیجیں", + "paymentTicketPrinted": "ادائیگی کی پرچی پرنٹ ہو گئی", + "paymentTicketPrintFailed": "ادائیگی کی پرچی پرنٹ کرنے میں ناکامی", + "partialPaid": "ادا شدہ: {paid}", + "releaseBlockedTitle": "کچن ریلیز رکی ہوئی ہے", + "printTicket": "پرچی پرنٹ کریں", + "payTicket": "ادائیگی کریں", + "paymentQueue": "ادائیگی کی قطار", + "scanOrEnterTicket": "ٹکٹ کیو آر اسکین کریں یا نمبر درج کریں...", + "paymentQueueEmpty": "کوئی منتظر ٹکٹ نہیں", + "paymentQueueEmptyDesc": "ادائیگی کے لیے بھیجے گئے آرڈرز یہاں ظاہر ہوں گے۔", + "paidBlockedSection": "ادا شدہ — ریلیز رکی ہوئی ہے", + "awaitingPaymentSection": "ادائیگی کا انتظار", + "paymentTicket": "ادائیگی کا ٹکٹ", + "paymentTicketDesc": "ٹکٹ تیار کر کے ادائیگی کی قطار میں بھیج دیا گیا ہے۔", + "ticketNumber": "ٹکٹ نمبر", + "paymentTicketInstruction": "ادائیگی کے لیے یہ ٹکٹ کیش کاؤنٹر پر لے جائیں۔", + "selectCounter": "کاؤنٹر منتخب کریں" }, "printTest": { "amt": "رقم", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "فی اسٹاف ممبر ایک شفٹ", "oneShiftPerStaffHint": "ہر اسٹاف ممبر کو اپنی کیش شفٹ آزادانہ طور پر کھولنے اور بند کرنے کی اجازت دیں۔", "oneShiftPerStaffSharedDrawerWarning": "نوٹ: اگر دو بیک وقت شفٹیں ایک ہی فزیکل کیش دراز استعمال کرتی ہیں تو انفرادی نقد گنتی فزیکل طور پر الگ نہیں ہوگی۔", - "oneShiftPerStaffDisableBlocked": "جب ایک سے زیادہ شفٹیں کھلی ہوں تو فی اسٹاف ایک شفٹ کو غیر فعال نہیں کیا جا سکتا۔ پہلے اضافی شفٹیں بند کریں۔" + "oneShiftPerStaffDisableBlocked": "جب ایک سے زیادہ شفٹیں کھلی ہوں تو فی اسٹاف ایک شفٹ کو غیر فعال نہیں کیا جا سکتا۔ پہلے اضافی شفٹیں بند کریں۔", + "tabCounters": "کاؤنٹرز", + "counterRoleOrdering": "آرڈرنگ", + "counterRolePayment": "ادائیگی", + "counterRoleCombined": "مشترکہ", + "ownerPermissionRequired": "کاؤنٹرز کا انتظام کرنے کے لیے مالک کی اجازت درکار ہے۔", + "multiCounterEnabled": "ملٹی کاؤنٹر فلو فعال ہو گیا", + "multiCounterDisabled": "ملٹی کاؤنٹر فلو غیر فعال ہو گیا", + "counterUpdated": "کاؤنٹر اپ ڈیٹ ہو گیا", + "counterCreated": "کاؤنٹر بن گیا", + "counterDeleted": "کاؤنٹر حذف ہو گیا", + "multiCounterTitle": "ملٹی کاؤنٹر آرڈرنگ", + "multiCounterDesc": "علیحدہ آرڈرنگ اور ادائیگی کے لیے کاؤنٹرز ترتیب دیں۔", + "namedCounters": "نامزد کاؤنٹرز", + "namedCountersHint": "ہر پی او ایس اسٹیشن اپنا کردار منتخب کرتا ہے: آرڈرنگ، ادائیگی یا مشترکہ۔", + "addCounter": "کاؤنٹر شامل کریں", + "editCounter": "کاؤنٹر میں ترمیم کریں", + "counterModalDesc": "کاؤنٹر کا نام اور آپریشنل کردار مقرر کریں۔", + "counterName": "کاؤنٹر کا نام", + "counterRole": "کاؤنٹر کا کردار", + "counterIsActive": "فعال", + "confirmDeleteCounter": "کیا یہ کاؤنٹر حذف کرنا ہے؟", + "confirmDeleteCounterDesc": "یہ کاؤنٹر مستقل طور پر ہٹا دیا جائے گا۔" }, "setup": { "alreadyCompleted": "اس انسٹال کا سیٹ اپ پہلے ہی مکمل ہو چکا ہے۔ سائن ان پر بھیج رہا ہے…", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index 4dce4cf00..fb631446f 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "Thời gian gửi xuống bếp phải ở trong tương lai ({timezone}).", "scheduledDstSkippedError": "Giờ địa phương này bị bỏ qua do đổi giờ mùa hè tại {timezone}. Hãy chọn thời điểm hợp lệ sau khi đổi giờ.", "scheduledDstAmbiguousError": "Giờ địa phương này lặp lại hai lần khi đổi giờ mùa hè tại {timezone}. Hãy chọn thời điểm rõ ràng ngoài khoảng lặp.", - "scheduledInvalidTimeError": "Nhập ngày và giờ hợp lệ theo múi giờ cửa hàng ({timezone})." + "scheduledInvalidTimeError": "Nhập ngày và giờ hợp lệ theo múi giờ cửa hàng ({timezone}).", + "ticketLoaded": "Đã tải phiếu #{number}", + "ticketNotFound": "Không tìm thấy phiếu hoặc đã thanh toán", + "sendToPaymentDeliveryError": "Quy trình nhiều quầy không áp dụng cho đơn giao hàng.", + "orderSentToPaymentSuccess": "Đã gửi đơn #{number} sang quầy thu ngân", + "sendToPayment": "Gửi sang thanh toán", + "paymentTicketPrinted": "Đã in phiếu thanh toán", + "paymentTicketPrintFailed": "Không thể in phiếu thanh toán", + "partialPaid": "Đã thanh toán: {paid}", + "releaseBlockedTitle": "Chuyển bếp bị chặn", + "printTicket": "In phiếu", + "payTicket": "Thanh toán", + "paymentQueue": "Hàng đợi thanh toán", + "scanOrEnterTicket": "Quét mã QR hoặc nhập số phiếu...", + "paymentQueueEmpty": "Không có phiếu nào đang chờ", + "paymentQueueEmptyDesc": "Các đơn hàng được gửi sang thanh toán sẽ xuất hiện tại đây.", + "paidBlockedSection": "Đã thanh toán — Chuyển bếp bị chặn", + "awaitingPaymentSection": "Chờ thanh toán", + "paymentTicket": "Phiếu thanh toán", + "paymentTicketDesc": "Phiếu đã được tạo và gửi đến hàng đợi thanh toán.", + "ticketNumber": "Số phiếu", + "paymentTicketInstruction": "Mang phiếu này đến quầy thu ngân để thanh toán.", + "selectCounter": "Chọn quầy" }, "printTest": { "amt": "Số tiền", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "Mỗi nhân viên một ca", "oneShiftPerStaffHint": "Cho phép mỗi nhân viên tự mở và đóng ca thu ngân của riêng mình một cách độc lập.", "oneShiftPerStaffSharedDrawerWarning": "Lưu ý: Nếu hai ca song song dùng chung một ngăn kéo tiền vật lý, số tiền đếm riêng của từng ca sẽ không tách biệt về mặt vật lý.", - "oneShiftPerStaffDisableBlocked": "Không thể tắt chế độ mỗi nhân viên một ca khi đang có nhiều ca mở. Vui lòng đóng các ca phụ trước." + "oneShiftPerStaffDisableBlocked": "Không thể tắt chế độ mỗi nhân viên một ca khi đang có nhiều ca mở. Vui lòng đóng các ca phụ trước.", + "tabCounters": "Quầy phục vụ", + "counterRoleOrdering": "Gọi món", + "counterRolePayment": "Thu ngân", + "counterRoleCombined": "Kết hợp", + "ownerPermissionRequired": "Cần quyền chủ cửa hàng để quản lý các quầy.", + "multiCounterEnabled": "Đã bật quy trình nhiều quầy", + "multiCounterDisabled": "Đã tắt quy trình nhiều quầy", + "counterUpdated": "Đã cập nhật quầy", + "counterCreated": "Đã tạo quầy", + "counterDeleted": "Đã xóa quầy", + "multiCounterTitle": "Quy trình gọi món nhiều quầy", + "multiCounterDesc": "Thiết lập quầy gọi món và quầy thu ngân riêng biệt cho mô hình gọi một nơi, trả một nơi.", + "namedCounters": "Danh sách quầy", + "namedCountersHint": "Mỗi máy POS chọn vai trò: Gọi món, Thu ngân hoặc Kết hợp.", + "addCounter": "Thêm quầy", + "editCounter": "Sửa quầy", + "counterModalDesc": "Đặt tên quầy và vai trò vận hành.", + "counterName": "Tên quầy", + "counterRole": "Vai trò quầy", + "counterIsActive": "Hoạt động", + "confirmDeleteCounter": "Xóa quầy này?", + "confirmDeleteCounterDesc": "Quầy này sẽ bị xóa vĩnh viễn." }, "setup": { "alreadyCompleted": "Bản cài đặt này đã hoàn tất thiết lập. Đang chuyển đến đăng nhập…", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index 9ac72d286..e597f9a83 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "預約送廚時間必須為未來時間 ({timezone})。", "scheduledDstSkippedError": "此當地時間因 {timezone} 日光節約時間切換而跳過,請選擇調錶後的有效時間。", "scheduledDstAmbiguousError": "此當地時間在 {timezone} 日光節約時間切換期間重複出現兩次,請選擇明確的時間。", - "scheduledInvalidTimeError": "請輸入門市時區 ({timezone}) 的有效日期與時間。" + "scheduledInvalidTimeError": "請輸入門市時區 ({timezone}) 的有效日期與時間。", + "ticketLoaded": "已載入單據 #{number}", + "ticketNotFound": "找不到單據或已結帳", + "sendToPaymentDeliveryError": "外送訂單不支援多櫃檯流程。", + "orderSentToPaymentSuccess": "訂單 #{number} 已送至收銀台", + "sendToPayment": "送至結帳", + "paymentTicketPrinted": "付款單據已列印", + "paymentTicketPrintFailed": "列印付款單據失敗", + "partialPaid": "已付: {paid}", + "releaseBlockedTitle": "出單受阻", + "printTicket": "列印單據", + "payTicket": "結帳", + "paymentQueue": "待結帳佇列", + "scanOrEnterTicket": "掃描單據 QR 碼或輸入號碼...", + "paymentQueueEmpty": "目前沒有待結帳的單據", + "paymentQueueEmptyDesc": "送至結帳的訂單將顯示於此處。", + "paidBlockedSection": "已付款 — 出單受阻", + "awaitingPaymentSection": "等待付款", + "paymentTicket": "結帳單據", + "paymentTicketDesc": "單據已建立並送至待結帳佇列。", + "ticketNumber": "單據編號", + "paymentTicketInstruction": "請持此單據至收銀台付款。", + "selectCounter": "選擇櫃檯" }, "printTest": { "amt": "金額", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "每位員工獨立班次", "oneShiftPerStaffHint": "允許每位員工獨立開啟與關閉自己的收銀班次。", "oneShiftPerStaffSharedDrawerWarning": "注意:若兩個並行班次共用同一個實體錢箱,各自的現金清點將無法在物理上分開。", - "oneShiftPerStaffDisableBlocked": "有多個班次處於開啟狀態時,無法停用「每位員工獨立班次」。請先關閉多餘的班次。" + "oneShiftPerStaffDisableBlocked": "有多個班次處於開啟狀態時,無法停用「每位員工獨立班次」。請先關閉多餘的班次。", + "tabCounters": "櫃檯設定", + "counterRoleOrdering": "點餐", + "counterRolePayment": "收銀", + "counterRoleCombined": "點餐收銀一體", + "ownerPermissionRequired": "管理櫃檯需要店主權限。", + "multiCounterEnabled": "多櫃檯點餐模式已啟用", + "multiCounterDisabled": "多櫃檯點餐模式已停用", + "counterUpdated": "櫃檯已更新", + "counterCreated": "櫃檯已建立", + "counterDeleted": "櫃檯已刪除", + "multiCounterTitle": "多櫃檯協作流程", + "multiCounterDesc": "配置獨立的點餐櫃檯與收銀櫃檯,分離點餐與結帳流程。", + "namedCounters": "已命名櫃檯", + "namedCountersHint": "每台 POS 裝置可選擇其運作角色:點餐、收銀或複合。", + "addCounter": "新增櫃檯", + "editCounter": "編輯櫃檯", + "counterModalDesc": "設定櫃檯名稱與運作角色。", + "counterName": "櫃檯名稱", + "counterRole": "櫃檯角色", + "counterIsActive": "啟用", + "confirmDeleteCounter": "確定刪除此櫃檯?", + "confirmDeleteCounterDesc": "該櫃檯將被永久移除。" }, "setup": { "alreadyCompleted": "此安裝已完成設定。正在跳轉到登入…", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index 36de4895d..0b688a185 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -1248,7 +1248,29 @@ "scheduledPastTimeError": "预约送厨时间必须是未来时间 ({timezone})。", "scheduledDstSkippedError": "该本地时间因 {timezone} 夏令时切换而不存在,请选择调表后的有效时间。", "scheduledDstAmbiguousError": "该本地时间在 {timezone} 夏令时切换期间重复出现两次,请选择不重叠的明确时间。", - "scheduledInvalidTimeError": "请输入门店时区 ({timezone}) 下的有效日期和时间。" + "scheduledInvalidTimeError": "请输入门店时区 ({timezone}) 下的有效日期和时间。", + "ticketLoaded": "已加载票据 #{number}", + "ticketNotFound": "未找到票据或已结账", + "sendToPaymentDeliveryError": "外卖配送订单不支持多柜台流程。", + "orderSentToPaymentSuccess": "订单 #{number} 已发送至收银台", + "sendToPayment": "发送至结账", + "paymentTicketPrinted": "付款小票已打印", + "paymentTicketPrintFailed": "打印付款小票失败", + "partialPaid": "已付: {paid}", + "releaseBlockedTitle": "出单受阻", + "printTicket": "打印小票", + "payTicket": "结账", + "paymentQueue": "待结账队列", + "scanOrEnterTicket": "扫描小票二维码或输入编号...", + "paymentQueueEmpty": "暂无等待结账的票据", + "paymentQueueEmptyDesc": "发送至结账的订单将显示在此处。", + "paidBlockedSection": "已付 — 出单受阻", + "awaitingPaymentSection": "等待付款", + "paymentTicket": "结账小票", + "paymentTicketDesc": "票据已创建并发送至待结账队列。", + "ticketNumber": "小票编号", + "paymentTicketInstruction": "请凭此小票前往收银台结账。", + "selectCounter": "选择柜台" }, "printTest": { "amt": "金额", @@ -2616,7 +2638,29 @@ "oneShiftPerStaff": "每位员工独立班次", "oneShiftPerStaffHint": "允许每位员工独立开启和关闭自己的收银班次。", "oneShiftPerStaffSharedDrawerWarning": "注意:如果两个并行班次共用同一个实体钱箱,各自的现金清点将无法在物理上分开。", - "oneShiftPerStaffDisableBlocked": "有多个班次处于开启状态时,无法关闭“每位员工独立班次”。请先关闭多余的班次。" + "oneShiftPerStaffDisableBlocked": "有多个班次处于开启状态时,无法关闭“每位员工独立班次”。请先关闭多余的班次。", + "tabCounters": "柜台设置", + "counterRoleOrdering": "点单", + "counterRolePayment": "收银", + "counterRoleCombined": "点单收银一体", + "ownerPermissionRequired": "管理柜台需要店主权限。", + "multiCounterEnabled": "多柜台点单模式已启用", + "multiCounterDisabled": "多柜台点单模式已停用", + "counterUpdated": "柜台已更新", + "counterCreated": "柜台已创建", + "counterDeleted": "柜台已删除", + "multiCounterTitle": "多柜台协作流程", + "multiCounterDesc": "配置独立的点餐柜台与收银柜台,分离点单与结账流程。", + "namedCounters": "已命名柜台", + "namedCountersHint": "每台POS设备可选择其工作角色:点单、收银或综合。", + "addCounter": "添加柜台", + "editCounter": "编辑柜台", + "counterModalDesc": "设置柜台名称和运营角色。", + "counterName": "柜台名称", + "counterRole": "柜台角色", + "counterIsActive": "启用", + "confirmDeleteCounter": "确定删除此柜台?", + "confirmDeleteCounterDesc": "该柜台将被永久移除。" }, "setup": { "alreadyCompleted": "此安装已完成设置。正在跳转到登录…", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 286f04cd0..ad03b3f7b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -232,6 +232,8 @@ export interface Order { release_status?: 'scheduled' | 'released' | 'blocked'; released_at?: string | null; release_blocked_reason?: string | null; + checkout_stage?: 'building' | 'awaiting_payment' | 'paid'; + dispatch_policy?: 'immediate' | 'on_payment'; cancelled_at?: string | null; cancellation_reason?: string | null; items?: OrderItem[]; @@ -458,3 +460,13 @@ export interface CartItem { /** Owner-set selling price for this line, sent to the server when the order is placed. */ price_override?: { price: number; reason: string } | null; } + +export interface Counter { + id: string; + name: string; + role: 'ordering' | 'payment' | 'combined'; + is_active: number | boolean; + sort_order: number; + created_at?: string; + updated_at?: string; +} diff --git a/main/db.ts b/main/db.ts index b9d147eb4..34f04fd71 100644 --- a/main/db.ts +++ b/main/db.ts @@ -10133,6 +10133,37 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ ); }, }, + { + version: 124, + name: 'multi_counter_ordering', + up: () => { + const orderColumns = getColumns(db, 'orders'); + if (!orderColumns.includes('checkout_stage')) { + db.exec( + "ALTER TABLE orders ADD COLUMN checkout_stage TEXT NOT NULL DEFAULT 'paid' CHECK (checkout_stage IN ('building', 'awaiting_payment', 'paid'))", + ); + } + if (!orderColumns.includes('dispatch_policy')) { + db.exec( + "ALTER TABLE orders ADD COLUMN dispatch_policy TEXT NOT NULL DEFAULT 'immediate' CHECK (dispatch_policy IN ('immediate', 'on_payment'))", + ); + } + db.exec(` + CREATE TABLE IF NOT EXISTS counters ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('ordering', 'payment', 'combined')), + is_active INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_counters_active ON counters(is_active, sort_order); + CREATE INDEX IF NOT EXISTS idx_orders_checkout_queue ON orders(checkout_stage, release_status) WHERE status NOT IN ('cancelled', 'completed'); + `); + insertSettingIfMissing('multi_counter_enabled', 'false'); + }, + }, ]; export function backfillWhatsAppMessageAttempts( diff --git a/main/printers/thermal.ts b/main/printers/thermal.ts index 74d274550..7371a4a6c 100644 --- a/main/printers/thermal.ts +++ b/main/printers/thermal.ts @@ -22,6 +22,7 @@ import { resolveTenantCurrency, type CurrencyDisplay, type DigitMode, + type RegionalSnapshot, } from '../countries'; import { resolveTaxComponents } from '../services/tax-components'; import { @@ -181,6 +182,9 @@ export type DispatchResult = { driverName?: string; printerStatus?: number; warnings?: PrintWarning[]; + bytes?: number[] | Buffer; + preview?: boolean; + connection_type?: string; }; export type PrintFailureClass = @@ -1354,10 +1358,228 @@ export async function printDeliverySlipDetailed( } } +export function buildEscPosQrCode(payload: string): Buffer { + const payloadBytes = Buffer.from(payload, 'utf8'); + const pL = (payloadBytes.length + 3) & 0xff; + const pH = ((payloadBytes.length + 3) >> 8) & 0xff; + + return Buffer.concat([ + Buffer.from([0x1b, 0x61, 0x01]), + Buffer.from([0x1d, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00]), + Buffer.from([0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, 0x06]), + Buffer.from([0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31]), + Buffer.from([0x1d, 0x28, 0x6b, pL, pH, 0x31, 0x50, 0x30]), + payloadBytes, + Buffer.from([0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30]), + Buffer.from([0x1b, 0x61, 0x00]), + ]); +} + +export interface PaymentTicketOrder { + readonly id: number | string; + readonly order_number: string; + readonly type: string; + readonly table_id?: number | string | null; + readonly table_name?: string | null; + readonly customer_name?: string | null; + readonly total: number; + readonly created_at?: string; + readonly scheduled_for?: string | null; +} + +export interface PaymentTicketItem { + readonly product_name: string; + readonly quantity: number; + readonly unit_price?: number; + readonly total?: number; +} + +export async function printPaymentTicket( + order: PaymentTicketOrder, + items: readonly PaymentTicketItem[], + options: { + balance?: number; + useUnicode?: boolean; + targetPrinter?: any; + signal?: AbortSignal; + preview?: boolean; + } = {}, +): Promise { + try { + if (options.signal?.aborted) return { ok: false, detail: 'Print cancelled during shutdown' }; + const printer = options.targetPrinter || getPrinterConfig(); + if (!printer && !options.preview) { + return { ok: false, detail: 'No printer configured' }; + } + + const { profile, columns: cols } = resolvePrinterContext(printer || { paper_width: '80mm' }); + const storeName = getSettingValue('restaurant_name') || 'FloCafe'; + + let snapshot: RegionalSnapshot; + try { + snapshot = resolveRegionalSnapshot({ + country: getSettingValue('country') || undefined, + currency: getSettingValue('currency') || undefined, + timezone: getSettingValue('timezone') || undefined, + }); + } catch { + snapshot = resolveRegionalSnapshot({ + country: 'US', + currency: 'USD', + timezone: 'UTC', + }); + } + const currency = snapshot.currency; + const fractionDigits = snapshot.currencyFractionDigits; + const locale = snapshot.locale; + const tz = snapshot.timezone; + const prefix = resolveCurrencyPrefix(snapshot.currencySymbol || currency, false); + const formatAmount = (val: number): string => + formatCurrency(val, prefix, locale, false, fractionDigits); + + const localTime = (iso: string): string => { + try { + const d = parseDbTimestamp(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString('en-US-u-nu-latn', { timeZone: tz }); + } catch { + return iso; + } + }; + + const balance = options.balance ?? order.total; + + const padLine = (left: string, right: string) => { + const space = cols - left.length - right.length; + return space > 0 ? left + ' '.repeat(space) + right : left + ' ' + right; + }; + + const lines: string[] = [ + '{INIT}', + `{C}{B}${storeName}`, + '{C}{B}PAYMENT TICKET', + `{C}Ticket #: ${order.order_number}`, + '-'.repeat(cols), + `Type: ${order.type === 'dine_in' ? 'Dine In' : 'Takeaway'}`, + ]; + + if (order.table_name) { + lines.push(`Table: ${order.table_name}`); + } + if (order.created_at) { + lines.push(`Date: ${localTime(order.created_at)}`); + } + if (order.scheduled_for) { + lines.push(`Scheduled For: ${localTime(order.scheduled_for)}`); + } + + lines.push('-'.repeat(cols)); + for (const item of items) { + const left = `${item.quantity}x ${item.product_name}`; + const right = item.total !== undefined ? formatAmount(Number(item.total)) : ''; + lines.push(padLine(left, right)); + } + + lines.push('-'.repeat(cols)); + lines.push(padLine('Total:', formatAmount(Number(order.total)))); + lines.push(padLine('Balance Due:', formatAmount(Number(balance)))); + lines.push('-'.repeat(cols)); + lines.push('{C}Please pay at the payment counter'); + lines.push('{C}Scan or enter ticket number to pay:'); + + const qrPayload = `FLOCAFE-TICKET:${order.order_number}`; + const qrBytes = buildEscPosQrCode(qrPayload); + lines.push(`{RAW_BYTES:${qrBytes.toString('base64')}}`); + lines.push(`{C}${qrPayload}`); + lines.push(''); + lines.push('{CUT}'); + + const data = buildEscPos(lines, options.useUnicode ?? false, { + cutMode: profile.cutMode, + columns: cols, + }); + + if (options.preview) { + return { ok: true, preview: true, bytes: Array.from(data), warnings: [] }; + } + + if (printer?.connection_type === 'webusb') { + return { + ok: true, + bytes: Array.from(data), + connection_type: 'webusb', + warnings: [], + }; + } + + return await dispatchPrint(printer, data, options.signal); + } catch (error: unknown) { + console.error('[Printer] Payment ticket print error:', error); + return { ok: false, detail: error instanceof Error ? error.message : String(error) }; + } +} + +export type PaymentTicketPrintResult = PrintResult & { + bytes?: number[] | Buffer; + preview?: boolean; + connection_type?: string; +}; + +export async function printPaymentTicketDetailed( + ...args: Parameters +): Promise { + const id = correlationId(); + try { + const dispatch = await printPaymentTicket(...args); + const result: PaymentTicketPrintResult = dispatch.ok + ? { + ok: true, + correlationId: id, + stage: 'dispatch', + warnings: dispatch.warnings, + bytes: dispatch.bytes, + preview: dispatch.preview, + connection_type: dispatch.connection_type, + } + : { + ok: false, + code: 'print.payment_ticket.failed', + correlationId: id, + stage: 'dispatch', + detail: dispatch.detail, + failureClass: dispatch.failureClass || classifyPrintFailure(dispatch.detail), + platformErrorCode: + dispatch.platformErrorCode || extractPlatformErrorCode(dispatch.detail), + jobId: dispatch.jobId, + driverName: dispatch.driverName, + printerStatus: dispatch.printerStatus, + warnings: dispatch.warnings, + }; + if (!result.ok) reportPrintFailure('payment_ticket', result); + return result; + } catch (error) { + const detail = (error as Error).message; + const result: PrintResult = { + ok: false, + code: 'print.payment_ticket.failed', + correlationId: id, + stage: 'dispatch', + detail, + failureClass: classifyPrintFailure(detail), + platformErrorCode: extractPlatformErrorCode(detail), + }; + reportPrintFailure('payment_ticket', result); + return result; + } +} + /** * Report print failure via telemetry tiers (best-effort, non-blocking). */ -function reportPrintFailure(kind: 'receipt' | 'kot' | 'delivery_slip', result: PrintResult): void { +function reportPrintFailure( + kind: 'receipt' | 'kot' | 'delivery_slip' | 'payment_ticket', + result: PrintResult, +): void { let connectionType = 'unknown'; try { const configured = getPrinterConfig()?.connection_type; diff --git a/main/routes/bills.ts b/main/routes/bills.ts index f376783b1..6852f0d60 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -6,6 +6,7 @@ import { generateBillNumber, getDatabase, getSettingValue, + isOrderSettledAndNonRefunded, localDateInTimezone, now, parseDbTimestamp, @@ -27,9 +28,11 @@ import { hasPermission, requirePermission } from '../services/authorization'; import { ROLE_ACCESS, hasRole } from '../../shared/role-permissions'; import { isExcludedFromBillReceipt } from '../../shared/order-item-status'; import { + assertScheduledOrderUnpaidForEdit, hasRecordedOrderPayment, isScheduledOrderRow, isUnreleasedOrderRow, + releaseScheduledOrder, } from '../services/scheduled-orders'; import { getOpenSession, @@ -1291,6 +1294,70 @@ router.post('/generate', requirePermission('bills.generate'), (req: Request, res } }); +export function createOrSyncBillForOrder( + db: ReturnType, + orderId: number | string, +): any { + const existingBill = db + .prepare('SELECT * FROM bills WHERE order_id = ? ORDER BY id ASC LIMIT 1') + .get(orderId) as any; + + if (existingBill) { + return parseRowJson(existingBill); + } + + const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(orderId) as any; + if (!order) { + throw Object.assign(new Error(`Order ${orderId} not found`), { statusCode: 404 }); + } + + const billNumber = generateBillNumber(); + const subtotal = order.subtotal || 0; + const taxAmount = order.tax_amount || 0; + const discountAmount = order.discount_amount || 0; + const deliveryCharge = order.delivery_charge || 0; + const packagingCharge = order.packaging_charge || 0; + const serviceCharge = order.service_charge || 0; + const currency = getTenantCurrency(); + const pack = getActiveCountryPack(getSettingValue('country') || ''); + const { total, adjustment: roundOff } = applyPayableRounding(order.total || 0, pack, currency); + + const runResult = db + .prepare(` + INSERT INTO bills (bill_number, order_id, customer_id, subtotal, tax_amount, tax_breakdown, tax_snapshot, + discount_amount, discount_type, discount_value, discount_reason, + delivery_charge, packaging_charge, service_charge, charges_breakdown, round_off, total, paid_amount, balance, payment_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unpaid', ?, ?) + `) + .run( + billNumber, + orderId, + order.customer_id, + subtotal, + taxAmount, + order.tax_breakdown, + order.tax_snapshot, + discountAmount, + order.discount_type, + order.discount_value, + order.discount_reason, + deliveryCharge, + packagingCharge, + serviceCharge, + order.charges_breakdown ?? null, + roundOff, + total, + 0, + total, + now(), + now(), + ); + + return parseRowJson( + db.prepare('SELECT * FROM bills WHERE id = ?').get(runResult.lastInsertRowid), + ); +} + export function allocateMinorUnits(sourceMinor: number, weights: number[]): number[] { const n = weights.length; if (n === 0) return []; @@ -3482,6 +3549,118 @@ function queueExactBalanceAutomation( } } +export async function handlePostPaymentRelease( + db: ReturnType, + orderId: string | number, + actorUserId?: string | null, +): Promise { + const order = db + .prepare( + 'SELECT id, release_status, dispatch_policy, checkout_stage, scheduled_for FROM orders WHERE id = ?', + ) + .get(orderId) as any; + if (!order || order.dispatch_policy !== 'on_payment' || order.checkout_stage !== 'paid') { + return; + } + if (order.release_status !== 'scheduled') { + return; + } + if (order.scheduled_for) { + const scheduledTime = parseDbTimestamp(order.scheduled_for).getTime(); + if (scheduledTime > Date.now()) { + return; + } + } + try { + await releaseScheduledOrder(db, order.id, { + trigger: 'payment', + actorUserId: actorUserId || null, + }); + } catch (err) { + console.error(`[MultiCounter] Post-payment release failed for order ${order.id}:`, err); + } +} + +export function queuePostPaymentRelease( + db: ReturnType, + orderId: number | string, + actorUserId?: string | null, +): void { + if (!orderId) return; + if (!db.inTransaction) { + void handlePostPaymentRelease(db, orderId, actorUserId); + return; + } + queueMicrotask(() => { + try { + void handlePostPaymentRelease(db, orderId, actorUserId); + } catch (err) { + console.error(`[MultiCounter] Queued post-payment release failed for order ${orderId}:`, err); + } + }); +} + +export function reconcileOrderSettlement( + db: ReturnType, + orderId: number | string, + changedAt: string = now(), + actorUserId?: string | null, +): { orderFullyPaid: boolean; orderRow: any } { + const orderRow = db + .prepare( + 'SELECT id, status, table_id, customer_id, release_status, dispatch_policy, checkout_stage, scheduled_for FROM orders WHERE id = ?', + ) + .get(orderId) as any; + if (!orderRow) return { orderFullyPaid: false, orderRow: null }; + + let orderFullyPaid = false; + + if (orderRow.dispatch_policy === 'on_payment') { + if (isOrderSettledAndNonRefunded(db, orderRow.id)) { + orderFullyPaid = true; + if (orderRow.checkout_stage === 'awaiting_payment') { + db.prepare("UPDATE orders SET checkout_stage = 'paid', updated_at = ? WHERE id = ?").run( + changedAt, + orderRow.id, + ); + orderRow.checkout_stage = 'paid'; + } + queuePostPaymentRelease(db, orderRow.id, actorUserId); + } + } else { + const unpaidSibling = db + .prepare( + `SELECT 1 FROM bills WHERE order_id = ? AND payment_status NOT IN ('paid', 'refunded', 'partially_refunded') LIMIT 1`, + ) + .get(orderRow.id); + orderFullyPaid = !unpaidSibling; + if (orderFullyPaid) { + if (orderRow.checkout_stage === 'awaiting_payment') { + db.prepare("UPDATE orders SET checkout_stage = 'paid', updated_at = ? WHERE id = ?").run( + changedAt, + orderRow.id, + ); + orderRow.checkout_stage = 'paid'; + } + const canComplete = + !['cancelled', 'completed'].includes(orderRow.status) && !isUnreleasedOrderRow(orderRow); + if (canComplete) { + db.prepare( + "UPDATE orders SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", + ).run(changedAt, changedAt, orderRow.id); + if (orderRow.table_id) { + db.prepare("UPDATE tables SET status = 'available', updated_at = ? WHERE id = ?").run( + changedAt, + orderRow.table_id, + ); + } + } + } + } + + return { orderFullyPaid, orderRow }; +} + export function completeExactBalanceSettlement( db: ReturnType, billId: string | number, @@ -3521,33 +3700,12 @@ export function completeExactBalanceSettlement( const updatedBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId) as any; if (!updatedBill) return null; - const unpaidSibling = db - .prepare( - `SELECT 1 FROM bills WHERE order_id = ? AND id != ? AND payment_status NOT IN ('paid', 'refunded', 'partially_refunded') LIMIT 1`, - ) - .get(bill.order_id, bill.id); - const orderFullyPaid = !unpaidSibling; - let orderRow: any = null; - if (orderFullyPaid) { - orderRow = db - .prepare('SELECT status, table_id, customer_id, release_status FROM orders WHERE id = ?') - .get(bill.order_id) as any; - const canComplete = - orderRow && - !['cancelled', 'completed'].includes(orderRow.status) && - !isUnreleasedOrderRow(orderRow); - if (canComplete) { - db.prepare( - "UPDATE orders SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", - ).run(changedAt, changedAt, bill.order_id); - if (orderRow.table_id) { - db.prepare("UPDATE tables SET status = 'available', updated_at = ? WHERE id = ?").run( - changedAt, - orderRow.table_id, - ); - } - } - } + const { orderRow } = reconcileOrderSettlement( + db, + bill.order_id, + changedAt, + approverId ? String(approverId) : undefined, + ); const effectiveCustomerId = bill.customer_id || orderRow?.customer_id; const cashback = calculateCashback(db, bill, effectiveCustomerId); @@ -3609,6 +3767,7 @@ function applyPaymentBatch( bill: any; walletDebited: boolean; loyaltyPointsEarned: number; + orderFullyPaid?: boolean; kitchenDeliveryOverridden?: boolean; } { if (idempotencyKey && idempotencyUserId) { @@ -3717,31 +3876,13 @@ function applyPaymentBatch( `UPDATE bills SET payment_status = 'paid', paid_at = COALESCE(paid_at, ?), balance = 0, updated_at = ? WHERE id = ?`, ).run(changedAt, changedAt, billId); } - const unpaidSibling = db - .prepare( - `SELECT 1 FROM bills WHERE order_id = ? AND id != ? AND payment_status NOT IN ('paid', 'refunded', 'partially_refunded') LIMIT 1`, - ) - .get(bill.order_id, bill.id); - if (!unpaidSibling) { - const order = db - .prepare('SELECT status, table_id, customer_id, release_status FROM orders WHERE id = ?') - .get(bill.order_id) as any; - if ( - order && - !['cancelled', 'completed'].includes(order.status) && - !isUnreleasedOrderRow(order) - ) { - db.prepare( - "UPDATE orders SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", - ).run(changedAt, changedAt, bill.order_id); - if (order.table_id) { - db.prepare("UPDATE tables SET status = 'available', updated_at = ? WHERE id = ?").run( - changedAt, - order.table_id, - ); - } - } - } + const settlementResult = reconcileOrderSettlement( + db, + bill.order_id, + changedAt, + idempotencyUserId, + ); + const orderFullyPaid = settlementResult.orderFullyPaid; const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId) as any; const effectiveCustId = freshBill.customer_id; const cashback = calculateCashback(db, freshBill, effectiveCustId); @@ -3783,6 +3924,7 @@ function applyPaymentBatch( bill: parseRowJson(freshBill), walletDebited: false, loyaltyPointsEarned, + orderFullyPaid, ...(kitchenStatus.overridden ? { kitchenDeliveryOverridden: true } : {}), }; if (idempotencyKey && requestHash && idempotencyUserId) { @@ -3878,31 +4020,15 @@ function applyPaymentBatch( billId, ); let loyaltyPointsEarned = 0; + let orderFullyPaid = false; if (paymentStatus === 'paid') { - const unpaidSibling = db - .prepare( - `SELECT 1 FROM bills WHERE order_id = ? AND id != ? AND payment_status NOT IN ('paid', 'refunded', 'partially_refunded') LIMIT 1`, - ) - .get(bill.order_id, bill.id); - const orderFullyPaid = !unpaidSibling; - if (orderFullyPaid) { - // Payment must not resurrect terminal orders (VALID_TRANSITIONS.cancelled = []). - const order = db - .prepare('SELECT status, table_id, release_status FROM orders WHERE id = ?') - .get(bill.order_id) as any; - const canComplete = - order && !['cancelled', 'completed'].includes(order.status) && !isUnreleasedOrderRow(order); - if (canComplete) { - db.prepare( - "UPDATE orders SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", - ).run(changedAt, changedAt, bill.order_id); - if (order.table_id) - db.prepare("UPDATE tables SET status = 'available', updated_at = ? WHERE id = ?").run( - changedAt, - order.table_id, - ); - } - } + const settlementResult = reconcileOrderSettlement( + db, + bill.order_id, + changedAt, + idempotencyUserId, + ); + orderFullyPaid = settlementResult.orderFullyPaid; const cashback = calculateCashback(db, bill, effectiveCustomerId); const alreadyCredited = db .prepare(`SELECT id FROM loyalty_ledger WHERE bill_id = ? AND type = 'credit'`) @@ -3933,6 +4059,7 @@ function applyPaymentBatch( bill: parseRowJson(db.prepare('SELECT * FROM bills WHERE id = ?').get(billId)), walletDebited, loyaltyPointsEarned, + orderFullyPaid, ...(kitchenStatus.overridden ? { kitchenDeliveryOverridden: true } : {}), }; if (idempotencyKey && requestHash && idempotencyUserId) { @@ -4157,6 +4284,10 @@ router.post( operationCommitted = true; resolveAttempt?.(result); + if (result.orderFullyPaid) { + await handlePostPaymentRelease(db, result.bill.order_id, idempotencyUserId); + } + const billStatus = (result.bill as { payment_status?: string } | undefined)?.payment_status; if (billStatus === 'paid') { notifyKdsUpdate(); @@ -4418,6 +4549,10 @@ router.post( operationCommitted = true; resolveAttempt?.(result); + if (result.orderFullyPaid) { + await handlePostPaymentRelease(db, result.bill.order_id, idempotencyUserId); + } + const billStatus = (result.bill as { payment_status?: string } | undefined)?.payment_status; if (billStatus === 'paid') { notifyKdsUpdate(); @@ -4841,6 +4976,7 @@ router.post( { statusCode: 409, diagnosticCode: 'order_not_discountable' }, ); } + assertScheduledOrderUnpaidForEdit(db, currentBill.order_id, currentOrder); const freshTotals = calculateOrderTotals(db, currentBill.order_id); let freshDiscountAmount: number; diff --git a/main/routes/counters.ts b/main/routes/counters.ts new file mode 100644 index 000000000..9bbcc63bc --- /dev/null +++ b/main/routes/counters.ts @@ -0,0 +1,238 @@ +import { Router, Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { getDatabase, now } from '../db'; +import { requireAnyPermission, requirePermission } from '../services/authorization'; + +const router = Router(); + +function parseInteger(value: unknown, defaultValue = 0): number { + if (value === undefined || value === null || value === '') return defaultValue; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : defaultValue; +} + +export interface CounterRow { + id: string; + name: string; + role: 'ordering' | 'payment' | 'combined'; + is_active: number; + sort_order: number; + created_at: string; + updated_at: string; +} + +const ALLOWED_ROLES = new Set(['ordering', 'payment', 'combined']); + +function normalizeCounterName(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const trimmed = raw.trim(); + if (trimmed.length === 0 || trimmed.length > 100) return null; + return trimmed; +} + +function normalizeCounterRole(raw: unknown): 'ordering' | 'payment' | 'combined' | null { + if (typeof raw !== 'string') return null; + const role = raw.trim().toLowerCase(); + return ALLOWED_ROLES.has(role) ? (role as 'ordering' | 'payment' | 'combined') : null; +} + +function normalizeIsActive(raw: unknown, defaultValue = 1): number { + if (raw === undefined || raw === null) return defaultValue; + if (raw === true || raw === 1 || raw === '1' || raw === 'true') return 1; + if (raw === false || raw === 0 || raw === '0' || raw === 'false') return 0; + return defaultValue; +} + +function isOwner(req: Request): boolean { + const user = (req as Request & { user?: { role?: string } }).user; + return user?.role === 'owner'; +} + +// GET /api/counters — List counters +router.get( + '/', + requireAnyPermission( + 'pos.use', + 'orders.read', + 'orders.create', + 'payments.take', + 'settings.view', + 'settings.manage', + ), + (req: Request, res: Response) => { + try { + const db = getDatabase(); + const includeInactive = req.query.include_inactive === 'true' || req.query.all === 'true'; + + const counters = includeInactive + ? (db + .prepare('SELECT * FROM counters ORDER BY sort_order ASC, name ASC') + .all() as CounterRow[]) + : (db + .prepare('SELECT * FROM counters WHERE is_active = 1 ORDER BY sort_order ASC, name ASC') + .all() as CounterRow[]); + + res.json({ counters }); + } catch (error: unknown) { + console.error('[Counters] Error listing counters:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, +); + +// GET /api/counters/:id — Get a single counter +router.get( + '/:id', + requireAnyPermission( + 'pos.use', + 'orders.read', + 'orders.create', + 'payments.take', + 'settings.view', + 'settings.manage', + ), + (req: Request, res: Response) => { + try { + const db = getDatabase(); + const counter = db.prepare('SELECT * FROM counters WHERE id = ?').get(req.params.id) as + | CounterRow + | undefined; + if (!counter) { + return res.status(404).json({ error: 'Counter not found' }); + } + res.json({ counter }); + } catch (error: unknown) { + console.error('[Counters] Error fetching counter:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, +); + +// POST /api/counters — Create counter (owner only) +router.post('/', requirePermission('settings.manage'), (req: Request, res: Response) => { + try { + if (!isOwner(req)) { + return res + .status(403) + .json({ error: 'Only the store owner can modify counter configuration' }); + } + + const name = normalizeCounterName(req.body?.name); + if (!name) { + return res.status(400).json({ error: 'Counter name is required (1-100 characters)' }); + } + + const role = normalizeCounterRole(req.body?.role); + if (!role) { + return res.status(400).json({ error: 'Counter role must be ordering, payment, or combined' }); + } + + const isActive = normalizeIsActive(req.body?.is_active, 1); + const sortOrder = parseInteger(req.body?.sort_order, 0); + + const db = getDatabase(); + const id = `cnt_${randomUUID()}`; + const nowStr = now(); + + db.prepare(` + INSERT INTO counters (id, name, role, is_active, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(id, name, role, isActive, sortOrder, nowStr, nowStr); + + const created = db.prepare('SELECT * FROM counters WHERE id = ?').get(id) as CounterRow; + res.status(201).json({ counter: created }); + } catch (error: unknown) { + console.error('[Counters] Error creating counter:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// PUT /api/counters/:id — Update counter (owner only) +router.put('/:id', requirePermission('settings.manage'), (req: Request, res: Response) => { + try { + if (!isOwner(req)) { + return res + .status(403) + .json({ error: 'Only the store owner can modify counter configuration' }); + } + + const db = getDatabase(); + const existing = db.prepare('SELECT * FROM counters WHERE id = ?').get(req.params.id) as + | CounterRow + | undefined; + if (!existing) { + return res.status(404).json({ error: 'Counter not found' }); + } + + let name = existing.name; + if (req.body?.name !== undefined) { + const normalized = normalizeCounterName(req.body.name); + if (!normalized) { + return res.status(400).json({ error: 'Counter name must be 1-100 characters' }); + } + name = normalized; + } + + let role = existing.role; + if (req.body?.role !== undefined) { + const normalized = normalizeCounterRole(req.body.role); + if (!normalized) { + return res + .status(400) + .json({ error: 'Counter role must be ordering, payment, or combined' }); + } + role = normalized; + } + + const isActive = + req.body?.is_active !== undefined + ? normalizeIsActive(req.body.is_active, existing.is_active) + : existing.is_active; + const sortOrder = + req.body?.sort_order !== undefined + ? parseInteger(req.body.sort_order, existing.sort_order) + : existing.sort_order; + const nowStr = now(); + + db.prepare(` + UPDATE counters + SET name = ?, role = ?, is_active = ?, sort_order = ?, updated_at = ? + WHERE id = ? + `).run(name, role, isActive, sortOrder, nowStr, existing.id); + + const updated = db + .prepare('SELECT * FROM counters WHERE id = ?') + .get(existing.id) as CounterRow; + res.json({ counter: updated }); + } catch (error: unknown) { + console.error('[Counters] Error updating counter:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// DELETE /api/counters/:id — Delete counter (owner only) +router.delete('/:id', requirePermission('settings.manage'), (req: Request, res: Response) => { + try { + if (!isOwner(req)) { + return res + .status(403) + .json({ error: 'Only the store owner can modify counter configuration' }); + } + + const db = getDatabase(); + const existing = db.prepare('SELECT id FROM counters WHERE id = ?').get(req.params.id) as + | { id: string } + | undefined; + if (!existing) { + return res.status(404).json({ error: 'Counter not found' }); + } + + db.prepare('DELETE FROM counters WHERE id = ?').run(existing.id); + res.json({ success: true }); + } catch (error: unknown) { + console.error('[Counters] Error deleting counter:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export const counterRoutes = router; diff --git a/main/routes/database.ts b/main/routes/database.ts index 2ec6265f1..b14de68a7 100644 --- a/main/routes/database.ts +++ b/main/routes/database.ts @@ -594,7 +594,9 @@ router.post( row[column] !== undefined && !( tableName === 'orders' && - column === 'release_status' && + (column === 'release_status' || + column === 'checkout_stage' || + column === 'dispatch_policy') && row[column] === null ), ) diff --git a/main/routes/index.ts b/main/routes/index.ts index 4294bc755..6cd088141 100644 --- a/main/routes/index.ts +++ b/main/routes/index.ts @@ -40,6 +40,7 @@ import { whatsappRoutes } from './whatsapp'; import { supportTicketRoutes } from './support-ticket'; import { diagnosticsRoutes } from './diagnostics'; import { authorizationRoutes } from './authorization'; +import { counterRoutes } from './counters'; import { getDatabase, getSettingValue, getCachedPairingCode, setCachedPairingCode } from '../db'; import { getActiveCountryPack } from '../services/tax'; @@ -146,6 +147,7 @@ export function registerRoutes(app: Express): void { app.use('/api/support-ticket', supportTicketRoutes); app.use('/api/diagnostics', diagnosticsRoutes); app.use('/api/authorization', authorizationRoutes); + app.use('/api/counters', counterRoutes); // Tax preview. Priced on every cart change in the prepaid checkout modal, so it // has to admit every role that can run a sale; `tax-packs.view-test` (owner and diff --git a/main/routes/orders.ts b/main/routes/orders.ts index d312c8807..859d8372e 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -1,5 +1,6 @@ import { createHash } from 'crypto'; import { Router, Request, Response } from 'express'; +import QRCode from 'qrcode'; import { getDatabase, generateOrderNumber, @@ -101,6 +102,7 @@ import { getCurrencyFractionDigits, getCurrencyMinorUnitFactor } from '../countr import { checkKitchenDeliveryStatus, completeExactBalanceSettlement, + createOrSyncBillForOrder, getTenantCurrency, preVerifyKitchenDeliveryPin, syncUnpaidBillsForOrder, @@ -1536,6 +1538,217 @@ router.get( }, ); +router.get( + '/awaiting-payment', + orderReadRateLimit, + requirePermission('orders.read'), + (req: Request, res: Response) => { + try { + const db = getDatabase(); + + let limit = 50; + const rawLimit = req.query.limit; + if (typeof rawLimit === 'string') { + const parsedLimit = parseInt(rawLimit, 10); + if (Number.isFinite(parsedLimit) && parsedLimit > 0) { + limit = Math.min(parsedLimit, 200); + } + } + + const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; + + let cursor: { rank: number; created_at: string; id: number } | null = null; + if (typeof req.query.cursor === 'string' && req.query.cursor.trim()) { + try { + const decoded = JSON.parse( + Buffer.from(req.query.cursor.trim(), 'base64').toString('utf8'), + ); + if ( + typeof decoded.rank === 'number' && + typeof decoded.created_at === 'string' && + typeof decoded.id === 'number' + ) { + cursor = decoded; + } + } catch { + return res.status(400).json({ error: 'Invalid cursor', code: 'INVALID_CURSOR' }); + } + } + + const countRow = db + .prepare( + `SELECT + COUNT(CASE WHEN o.checkout_stage = 'awaiting_payment' THEN 1 END) AS awaiting_count, + COUNT(CASE WHEN o.release_status = 'blocked' AND o.dispatch_policy = 'on_payment' THEN 1 END) AS blocked_count + FROM orders o + WHERE (o.checkout_stage = 'awaiting_payment' OR (o.release_status = 'blocked' AND o.dispatch_policy = 'on_payment')) + AND o.status NOT IN ('cancelled', 'completed')`, + ) + .get() as { awaiting_count: number; blocked_count: number } | undefined; + + const awaiting_count = countRow?.awaiting_count ?? 0; + const blocked_count = countRow?.blocked_count ?? 0; + const total_count = awaiting_count + blocked_count; + + let sql = ` + SELECT + o.id, + o.order_number, + o.type, + o.table_id, + t.number AS table_name, + o.customer_id, + c.name AS customer_name, + c.phone AS customer_phone, + o.scheduled_for, + o.total, + o.total AS total_amount, + o.checkout_stage, + o.dispatch_policy, + o.release_status, + o.release_blocked_reason, + o.created_at, + o.updated_at, + b.id AS bill_id, + b.bill_number, + COALESCE(b.paid_amount, 0) AS paid_amount, + COALESCE(b.balance, o.total) AS balance, + COALESCE(b.payment_status, 'unpaid') AS bill_payment_status + FROM orders o + LEFT JOIN tables t ON t.id = o.table_id + LEFT JOIN customers c ON c.id = o.customer_id + LEFT JOIN bills b ON b.id = COALESCE( + (SELECT id FROM bills WHERE order_id = o.id AND payment_status NOT IN ('paid', 'refunded') ORDER BY id ASC LIMIT 1), + (SELECT id FROM bills WHERE order_id = o.id ORDER BY id ASC LIMIT 1) + ) + WHERE (o.checkout_stage = 'awaiting_payment' OR (o.release_status = 'blocked' AND o.dispatch_policy = 'on_payment')) + AND o.status NOT IN ('cancelled', 'completed') + `; + const params: unknown[] = []; + + if (search) { + const searchPattern = `%${search.replace(/[\\%_]/g, '\\$&')}%`; + sql += ` AND (o.order_number LIKE ? ESCAPE '\\' OR t.number LIKE ? ESCAPE '\\' OR c.name LIKE ? ESCAPE '\\' OR c.phone LIKE ? ESCAPE '\\')`; + params.push(searchPattern, searchPattern, searchPattern, searchPattern); + } + + if (cursor) { + sql += ` AND ( + (CASE WHEN o.release_status = 'blocked' THEN 0 ELSE 1 END > ?) + OR ( + (CASE WHEN o.release_status = 'blocked' THEN 0 ELSE 1 END = ?) + AND ( + (o.created_at > ?) + OR (o.created_at = ? AND o.id > ?) + ) + ) + )`; + params.push(cursor.rank, cursor.rank, cursor.created_at, cursor.created_at, cursor.id); + } + + sql += ` ORDER BY CASE WHEN o.release_status = 'blocked' THEN 0 ELSE 1 END ASC, + o.created_at ASC, + o.id ASC + LIMIT ?`; + params.push(limit + 1); + + const rows = (db.prepare(sql).all(...params) as Record[]).map(parseRowJson); + const hasMore = rows.length > limit; + const pageRows = hasMore ? rows.slice(0, limit) : rows; + const next_cursor = + hasMore && pageRows.length > 0 + ? Buffer.from( + JSON.stringify({ + rank: pageRows[pageRows.length - 1].release_status === 'blocked' ? 0 : 1, + created_at: String(pageRows[pageRows.length - 1].created_at || ''), + id: Number(pageRows[pageRows.length - 1].id), + }), + 'utf8', + ).toString('base64') + : null; + + res.json({ + orders: pageRows, + tickets: pageRows, + next_cursor, + has_more: hasMore, + awaiting_count, + blocked_count, + total_count, + counts: { + awaiting_count, + blocked_count, + total_count, + }, + }); + } catch (error: unknown) { + console.error('[API] Awaiting payment queue error:', error); + res.status(500).json({ error: 'Failed to load awaiting payment queue' }); + } + }, +); + +router.get( + '/by-ticket/:ticket', + orderReadRateLimit, + requirePermission('orders.read'), + (req: Request, res: Response) => { + try { + const db = getDatabase(); + const rawTicket = String(req.params.ticket || '').trim(); + const cleanTicket = rawTicket.replace(/^FLOCAFE-TICKET:/i, '').trim(); + + if (!cleanTicket) { + return res.status(400).json({ error: 'Ticket number is required' }); + } + + const byParam = String(req.query.by || '').trim(); + const loadById = byParam === 'id' && /^\d+$/.test(cleanTicket); + + const orderRow = db + .prepare( + `SELECT * FROM orders + WHERE ${loadById ? 'id = ?' : 'order_number = ?'} + AND status NOT IN ('cancelled', 'completed') + AND checkout_stage = 'awaiting_payment'`, + ) + .get(loadById ? Number(cleanTicket) : cleanTicket) as any; + + if (!orderRow) { + return res.status(404).json({ error: 'Ticket not found or already paid' }); + } + + const [hydrated] = batchHydrateOrders(db, [parseRowJson(orderRow)]); + const bills = ( + db + .prepare('SELECT * FROM bills WHERE order_id = ? ORDER BY id ASC') + .all(orderRow.id) as any[] + ).map(parseRowJson); + + const payableBill = bills.find( + (b: any) => b.payment_status !== 'paid' && Number(b.balance || 0) > 0, + ); + const primaryBill = payableBill || bills[0] || null; + + const allBillsSettled = + bills.length > 0 && + bills.every((b: any) => b.payment_status === 'paid' && Number(b.balance || 0) <= 0); + if (allBillsSettled) { + return res.status(404).json({ error: 'Ticket not found or already paid' }); + } + + res.json({ + order: hydrated, + bill: primaryBill, + bills, + }); + } catch (error: unknown) { + console.error('[API] Ticket lookup error:', error); + res.status(500).json({ error: 'Failed to lookup ticket' }); + } + }, +); + router.get( '/:id', orderReadRateLimit, @@ -1564,7 +1777,7 @@ router.post( '/', orderWriteRateLimit, requirePermission('orders.create'), - (req: Request, res: Response) => { + asyncHandler(async (req: Request, res: Response) => { let operationCommitted = false; try { const body = req.body || {}; @@ -1586,6 +1799,8 @@ router.post( delivery_note, waived_charge_ids, opted_in_charge_ids, + dispatch_policy, + checkout_stage, } = body; // Carries optional service charge without automatic calculation policy. const idempotencyKey = orderIdempotencyKey(req); @@ -1633,6 +1848,67 @@ router.post( ), ); } + + if ( + dispatch_policy !== undefined && + dispatch_policy !== null && + !['immediate', 'on_payment'].includes(dispatch_policy) + ) { + return res + .status(400) + .json( + structuredOperationFailure( + req, + 'fc.backend.order.create.other_validation', + 'not_applied', + 'Valid dispatch_policy is required (immediate, on_payment)', + ), + ); + } + const isOnPayment = dispatch_policy === 'on_payment'; + if (isOnPayment && type === 'delivery') { + return res + .status(400) + .json( + structuredOperationFailure( + req, + 'fc.backend.order.create.other_validation', + 'not_applied', + 'Multi-counter payment flow is not supported for delivery orders', + ), + ); + } + if (isOnPayment && !['takeaway', 'dine_in'].includes(type)) { + return res + .status(400) + .json( + structuredOperationFailure( + req, + 'fc.backend.order.create.other_validation', + 'not_applied', + 'Multi-counter payment flow is only supported for takeaway and dine-in orders', + ), + ); + } + + const derivedCheckoutStage = isOnPayment ? 'awaiting_payment' : 'paid'; + if (checkout_stage !== undefined && checkout_stage !== null) { + if ( + !['building', 'awaiting_payment', 'paid'].includes(checkout_stage) || + checkout_stage !== derivedCheckoutStage + ) { + return res + .status(400) + .json( + structuredOperationFailure( + req, + 'fc.backend.order.create.other_validation', + 'not_applied', + `Invalid checkout_stage: expected '${derivedCheckoutStage}' for dispatch_policy '${isOnPayment ? 'on_payment' : 'immediate'}'`, + ), + ); + } + } if ( guest_count !== undefined && guest_count !== null && @@ -1786,9 +2062,25 @@ router.post( idempotencyUserId, idempotencyKey, requestHash, - ) as { order: any } | null; + ) as { order: any; qr_payload?: string | null; qr_data_url?: string | null } | null; if (replayed) { - return res.status(200).json({ order: replayed.order }); + const replayedOrder = replayed.order; + const qrPayload = + replayed.qr_payload ?? + (replayedOrder?.dispatch_policy === 'on_payment' + ? `FLOCAFE-TICKET:${replayedOrder.order_number}` + : null); + let qrDataUrl = replayed.qr_data_url ?? null; + if (qrPayload && !qrDataUrl) { + qrDataUrl = await QRCode.toDataURL(qrPayload, { + errorCorrectionLevel: 'M', + width: 256, + }); + } + return res.status(200).json({ + order: replayedOrder, + ...(qrPayload ? { qr_payload: qrPayload, qr_data_url: qrDataUrl } : {}), + }); } } @@ -1814,6 +2106,7 @@ router.post( }); } const isScheduledCreation = Boolean(scheduledOrderMeta); + const shouldDeferRelease = isScheduledCreation || isOnPayment; // Free text that ends up printed on a courier slip, so it is capped and // validated the same way order notes are, at this boundary. @@ -1859,7 +2152,7 @@ router.post( for (const item of items) { validateItemNotes(db, item.special_instructions); item.addons = resolveItemAddons(db, item.product_id, item.addons, item.quantity, { - deferStockDeduction: isScheduledCreation, + deferStockDeduction: shouldDeferRelease, }); } } catch (err: any) { @@ -1961,8 +2254,9 @@ router.post( packaging_charge, delivery_charge, packaging_tax_category_id, delivery_tax_category_id, service_charge, service_charge_tax_category_id, online_platform, external_order_id, scheduled_for, release_status, released_at, release_blocked_reason, + checkout_stage, dispatch_policy, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'pending', ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, 'pending', ?, ?) `) .run( orderNumber, @@ -1985,7 +2279,9 @@ router.post( onlinePlatform || null, externalOrderId || null, scheduledOrderMeta ? scheduledOrderMeta.scheduledForUtc : null, - isScheduledCreation ? 'scheduled' : 'released', + shouldDeferRelease ? 'scheduled' : 'released', + derivedCheckoutStage, + isOnPayment ? 'on_payment' : dispatch_policy || 'immediate', now(), now(), ); @@ -2056,7 +2352,7 @@ router.post( ); unitPrice = priceOverride.price; } - const deduction = isScheduledCreation + const deduction = shouldDeferRelease ? null : resolveInventoryDeduction(product, quantity, variant); if (unitPrice < 0 || !Number.isFinite(unitPrice)) { @@ -2177,7 +2473,7 @@ router.post( subtotal += itemSubtotal; const itemCreatedAt = now(); - const recipeSnapshot = isScheduledCreation + const recipeSnapshot = shouldDeferRelease ? null : buildRecipeSnapshot(db, product.id, quantity, variant?.recipe_multiplier ?? 1); const { ingredientCost, ingredientCostKnown } = @@ -2232,7 +2528,7 @@ router.post( }); } - if (!isScheduledCreation) { + if (!shouldDeferRelease) { if (deduction) { adjustProductStock(db, { productId: deduction.productId, @@ -2321,6 +2617,19 @@ router.post( }); } + if (isOnPayment) { + createOrSyncBillForOrder(db, Number(orderId)); + recordOrderAudit(db, { + orderId: Number(orderId), + actorUserId: authenticatedUserId, + action: 'order_sent_to_payment', + details: { + dispatch_policy: 'on_payment', + checkout_stage: 'awaiting_payment', + }, + }); + } + const order = parseRowJson( db.prepare('SELECT * FROM orders WHERE id = ?').get(orderId), ) as any; @@ -2342,14 +2651,18 @@ router.post( operationCommitted = true; if (!result.idempotentReplay) { - if (isScheduledCreation) { + if (shouldDeferRelease) { notifyOrderUpdated(); } else { notifyKdsUpdate(); } cloudSync.recordOrderChanged( result.order.id, - isScheduledCreation ? 'order.scheduled' : 'order.created', + isOnPayment + ? 'order.sent_to_payment' + : isScheduledCreation + ? 'order.scheduled' + : 'order.created', ); if (result.order.customer_id) { @@ -2361,9 +2674,40 @@ router.post( } } - res - .status(result.idempotentReplay ? 200 : 201) - .json({ order: Object.assign({}, result.order, { items: result.orderItems }) }); + const qrPayload = + result.order?.dispatch_policy === 'on_payment' + ? `FLOCAFE-TICKET:${result.order.order_number}` + : null; + let qrDataUrl: string | null = null; + if (qrPayload) { + qrDataUrl = await QRCode.toDataURL(qrPayload, { + errorCorrectionLevel: 'M', + width: 256, + }); + } + + if (idempotencyKey && requestHash && qrPayload) { + try { + db.prepare( + 'UPDATE order_idempotency SET response_json = ? WHERE user_id = ? AND idempotency_key = ?', + ).run( + JSON.stringify({ + order: Object.assign({}, result.order, { items: result.orderItems }), + qr_payload: qrPayload, + qr_data_url: qrDataUrl, + }), + idempotencyUserId, + idempotencyKey, + ); + } catch { + // ignore cache update error + } + } + + res.status(result.idempotentReplay ? 200 : 201).json({ + order: Object.assign({}, result.order, { items: result.orderItems }), + ...(qrPayload ? { qr_payload: qrPayload, qr_data_url: qrDataUrl } : {}), + }); } catch (error: any) { console.error('[Orders] Create error:', error); console.error('[API] Internal error:', error); @@ -2388,7 +2732,7 @@ router.post( ), ); } - }, + }), ); router.patch( diff --git a/main/routes/printers.ts b/main/routes/printers.ts index 4b5bfbe33..6218bfd6f 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -17,6 +17,7 @@ import { printReceiptDetailed, printKOTDetailed, printDeliverySlipDetailed, + printPaymentTicketDetailed, detectConnectedPrinters, prepareReceipt, escPosToText, @@ -1529,6 +1530,144 @@ router.post( }), ); +router.post( + '/print-payment-ticket', + requirePermission('printing.execute'), + asyncHandler(async (req: Request, res: Response) => { + try { + const orderId = req.body.orderId || req.body.order_id; + const printerId = req.body.printerId || req.body.printer_id; + const { useUnicode = false, preview = false } = req.body; + if (!orderId) { + return res.status(400).json({ error: 'orderId is required' }); + } + + const db = getDatabase(); + let printer = printerId + ? db.prepare('SELECT * FROM printers WHERE id = ?').get(printerId) + : db + .prepare( + `SELECT * FROM printers + WHERE connection_type != 'webusb' + ORDER BY is_default DESC, name + LIMIT 1`, + ) + .get() || + db + .prepare( + `SELECT * FROM printers + WHERE connection_type = 'webusb' + ORDER BY is_default DESC, name + LIMIT 1`, + ) + .get(); + + if (!printer && preview === true) { + printer = { id: 0, name: 'Default 80mm Preview', paper_width: '80mm' }; + } + + if (!printer) { + return res + .status(400) + .json({ error: 'No default printer configured. Add a printer in Settings.' }); + } + + const order = getOrderWithItems(db, Number(orderId)); + if (!order) { + return res.status(404).json({ error: 'Order not found' }); + } + + let tableName: string | null = null; + if (order.table_id) { + const tableRow = db + .prepare('SELECT number FROM tables WHERE id = ?') + .get(order.table_id) as { number: string } | undefined; + tableName = tableRow?.number ?? null; + } + + const balanceRow = db + .prepare( + `SELECT COALESCE(SUM(balance), 0) AS total_balance, COUNT(*) AS bill_count + FROM bills + WHERE order_id = ? AND payment_status != 'refunded'`, + ) + .get(order.id) as { total_balance: number; bill_count: number } | undefined; + const balance = + balanceRow && balanceRow.bill_count > 0 ? Number(balanceRow.total_balance) : order.total; + + const items = (order.items || []).map( + (item: { + product_name?: string; + quantity?: number; + unit_price?: number; + total?: number; + }) => ({ + product_name: item.product_name || '', + quantity: item.quantity || 1, + unit_price: item.unit_price || 0, + total: item.total || 0, + }), + ); + + const result = await printPaymentTicketDetailed( + { + id: order.id, + order_number: order.order_number, + type: order.type, + table_id: order.table_id, + table_name: tableName, + total: order.total, + created_at: order.created_at, + scheduled_for: order.scheduled_for, + }, + items, + { + balance: Number(balance), + useUnicode, + targetPrinter: printer, + signal: getHttpRequestSignal(req), + preview: Boolean(preview), + }, + ); + + if (preview && result.ok) { + return res.json({ + success: true, + preview: true, + bytes: result.bytes || [], + warnings: result.warnings || [], + }); + } + + if (result.ok && result.connection_type === 'webusb') { + return res.json({ + success: true, + webusb: true, + bytes: Array.from(result.bytes || []), + printerName: (printer as { name?: string }).name, + warnings: result.warnings || [], + }); + } + + if (result.ok) { + return res.json({ success: true, warnings: result.warnings || [] }); + } + return res.status(502).json({ + error: result.detail || 'Payment ticket print failed. Check printer connection.', + detail: result.detail, + failure_class: result.failureClass, + code: result.code, + correlation_id: result.correlationId, + stage: result.stage, + }); + } catch (error: unknown) { + console.error('[Print Payment Ticket] Error:', error); + console.error('[API] Internal error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }), +); + export const printerRoutes = router; /** Tenant-configured language for print labels, defaulting to 'en'. */ diff --git a/main/routes/settings.ts b/main/routes/settings.ts index a62aafd7f..d0bcec80e 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -176,6 +176,7 @@ const OPTIONAL_SETTING_DEFAULTS: Record = { printer_arabic_shaping: 'false', split_checks_enabled: 'false', card_transaction_id_required: 'false', + multi_counter_enabled: 'false', require_open_shift: 'false', one_shift_per_staff: 'false', // Print language policies (#441) — inherit store language, no second @@ -1906,6 +1907,7 @@ const ALLOWED_WILDCARD_KEYS = new Set([ 'server_app_bill_printing_enabled', 'split_checks_enabled', 'card_transaction_id_required', + 'multi_counter_enabled', BILL_LANGUAGE_POLICY_KEY, KOT_LANGUAGE_POLICY_KEY, Z_REPORT_LANGUAGE_POLICY_KEY, @@ -2210,8 +2212,17 @@ router.put( if ( req.params.key === 'require_open_shift' || req.params.key === 'one_shift_per_staff' || - req.params.key === 'card_transaction_id_required' + req.params.key === 'card_transaction_id_required' || + req.params.key === 'multi_counter_enabled' ) { + if (req.params.key === 'multi_counter_enabled') { + const callerUser = (req as Request & { user?: { role?: string } }).user; + if (callerUser?.role !== 'owner') { + return res + .status(403) + .json({ error: 'Only the store owner can modify counter configuration' }); + } + } if (value !== true && value !== false && value !== 'true' && value !== 'false') { return res.status(400).json({ error: `${req.params.key} must be a boolean` }); } diff --git a/main/services/scheduled-orders.ts b/main/services/scheduled-orders.ts index 8864d8f48..ef47e4615 100644 --- a/main/services/scheduled-orders.ts +++ b/main/services/scheduled-orders.ts @@ -33,7 +33,9 @@ import { autoPrintReleasedOrderKot } from '../routes/printers'; import { parseScheduledForInput } from '../../shared/scheduled-time'; export type OrderReleaseStatus = 'scheduled' | 'released' | 'blocked'; -export type ReleaseTrigger = 'sweep' | 'startup' | 'resume' | 'manual'; +export type ReleaseTrigger = 'sweep' | 'startup' | 'resume' | 'manual' | 'payment'; +export type CheckoutStage = 'building' | 'awaiting_payment' | 'paid'; +export type DispatchPolicy = 'immediate' | 'on_payment'; export const SCHEDULED_SWEEP_INTERVAL_MS = 10_000; export const ALLOWED_SCHEDULED_ORDER_TYPES = new Set(['takeaway', 'delivery']); @@ -67,6 +69,8 @@ export interface ScheduledOrderDbRow { release_status: OrderReleaseStatus; released_at: string | null; release_blocked_reason: string | null; + checkout_stage?: CheckoutStage | null; + dispatch_policy?: DispatchPolicy | null; [key: string]: unknown; } @@ -238,15 +242,21 @@ export function assertScheduledOrderUnpaidForEdit( | { scheduled_for?: string | null; release_status?: string | null; + dispatch_policy?: string | null; + checkout_stage?: string | null; } | null | undefined, ): void { - if (!isScheduledOrderRow(order)) return; + const isGuarded = + isScheduledOrderRow(order) || + order?.dispatch_policy === 'on_payment' || + order?.checkout_stage === 'awaiting_payment'; + if (!isGuarded) return; if (hasRecordedOrderPayment(db, orderId)) { throw new ScheduledOrderError( 409, - 'Item and financial edits are locked after payment is recorded on a scheduled order. Use the refund or payment correction flow instead.', + 'Item and financial edits are locked after payment is recorded on a scheduled or awaiting-payment order. Use the refund or payment correction flow instead.', 'SCHEDULED_ORDER_PAID', ); } @@ -354,6 +364,26 @@ export async function releaseScheduledOrder( }; } + // Gate 1: If dispatch_policy === 'on_payment', full payment across all applicable bills is required. + if (currentOrder.dispatch_policy === 'on_payment') { + const isPaid = isOrderSettledAndNonRefunded(db, currentOrder.id); + if (!isPaid) { + if (isAutoTrigger) { + return { + outcome: 'skipped', + reason: 'awaiting_payment', + order: currentOrder, + }; + } + throw new ScheduledOrderError( + 409, + 'Multi-counter orders cannot be released to the kitchen before full payment is completed.', + 'ORDER_NOT_PAID', + ); + } + } + + // Gate 2: If scheduled_for is set, scheduled_for must be due (unless staff explicitly triggered manual release after payment) if (isAutoTrigger) { if (currentOrder.release_status !== 'scheduled') { return { @@ -362,20 +392,15 @@ export async function releaseScheduledOrder( order: currentOrder, }; } - if (!currentOrder.scheduled_for) { - return { - outcome: 'skipped', - reason: 'missing_scheduled_for', - order: currentOrder, - }; - } - const scheduledMs = parseDbTimestamp(currentOrder.scheduled_for).getTime(); - if (!Number.isFinite(scheduledMs) || scheduledMs > nowDate.getTime()) { - return { - outcome: 'skipped', - reason: 'not_due', - order: currentOrder, - }; + if (currentOrder.scheduled_for) { + const scheduledMs = parseDbTimestamp(currentOrder.scheduled_for).getTime(); + if (!Number.isFinite(scheduledMs) || scheduledMs > nowDate.getTime()) { + return { + outcome: 'skipped', + reason: 'not_due', + order: currentOrder, + }; + } } } else if ( currentOrder.release_status !== 'scheduled' && @@ -780,9 +805,11 @@ export async function releaseScheduledOrder( | { value?: string } | undefined; const kdsEnabled = kdsSetting?.value !== 'false'; - const shouldCompleteAtRelease = - isOrderSettledAndNonRefunded(db, currentOrder.id) && - (!kdsEnabled || !hasActiveUnservedOrderItems(db, currentOrder.id)); + const isSettled = isOrderSettledAndNonRefunded(db, currentOrder.id); + const hasUnservedKitchenItems = kdsEnabled && hasActiveUnservedOrderItems(db, currentOrder.id); + const isDineInWithTable = currentOrder.type === 'dine_in' || Boolean(currentOrder.table_id); + + const shouldCompleteAtRelease = isSettled && !hasUnservedKitchenItems && !isDineInWithTable; if (shouldCompleteAtRelease) { db.prepare( @@ -871,12 +898,15 @@ export async function sweepDueScheduledOrders( `SELECT id, scheduled_for FROM orders WHERE release_status = 'scheduled' - AND scheduled_for IS NOT NULL - AND scheduled_for <= ? AND status NOT IN ('cancelled', 'completed') - ORDER BY scheduled_for ASC, id ASC`, + AND ( + (scheduled_for IS NOT NULL AND scheduled_for <= ?) + OR + (dispatch_policy = 'on_payment' AND checkout_stage = 'paid' AND (scheduled_for IS NULL OR scheduled_for <= ?)) + ) + ORDER BY COALESCE(scheduled_for, created_at) ASC, id ASC`, ) - .all(nowIso) as Array<{ id: number; scheduled_for: string }>; + .all(nowIso, nowIso) as Array<{ id: number; scheduled_for: string }>; const summary: ScheduledSweepSummary = { checkedCount: dueRows.length, diff --git a/package.json b/package.json index fdc3228bd..5ba21a730 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "pretest": "npm run test:script-coverage && node scripts/run-test.cjs npm run test:payment-methods-split && node scripts/run-test.cjs npm run test:release-regressions && node scripts/run-test.cjs npm run test:product-variants-db && node scripts/run-test.cjs npm run test:product-variants-api", "start": "electron .", "rebuild": "HOME=~/.electron-gyp node-gyp rebuild --target=$(node -p \"require('./node_modules/electron/package.json').version\") --arch=$(node -p \"process.arch\") --dist-url=https://electronjs.org/headers --runtime=electron --directory node_modules/better-sqlite3", - "test": "node scripts/run-test.cjs npm run test:smoke && node scripts/run-test.cjs npm run test:server-port-collision && node scripts/run-test.cjs npm run test:kds-integration && node scripts/run-test.cjs npm run test:kds-broadcast-grouping && node scripts/run-test.cjs npm run test:kds-contract && node scripts/run-test.cjs npm run test:kds-frontend-conflict && node scripts/run-test.cjs npm run test:kds-window-hardening && node scripts/run-test.cjs npm run test:electron-api-contract && node scripts/run-test.cjs npm run test:titlebar-window-options && node scripts/run-test.cjs npm run test:update-ipc-sender-validation && node scripts/run-test.cjs npm run test:menu-surface && node scripts/run-test.cjs npm run test:pos-keyboard-shortcuts && node scripts/run-test.cjs npm run test:window-readiness && node scripts/run-test.cjs npm run test:window-load-retry && node scripts/run-test.cjs npm run test:cors && node scripts/run-test.cjs npm run test:csp-lan && node scripts/run-test.cjs npm run test:release-config && node scripts/run-test.cjs npm run test:update-state && node scripts/run-test.cjs npm run test:update-ui && node scripts/run-test.cjs npm run test:update-channel && node scripts/run-test.cjs npm run test:telemetry && node scripts/run-test.cjs npm run test:telemetry-install-provenance && node scripts/run-test.cjs npm run test:country-provenance && node scripts/run-test.cjs npm run test:first-run && node scripts/run-test.cjs npm run test:phase7-setup-i18n && node scripts/run-test.cjs npm run test:security && node scripts/run-test.cjs npm run test:jwt-secret-cache-identity && node scripts/run-test.cjs npm run test:jwt-secret-persist-before-cache && node scripts/run-test.cjs npm run test:jwt-secret-safe-storage && node scripts/run-test.cjs npm run test:staff-authz && node scripts/run-test.cjs npm run test:orders-authz && node scripts/run-test.cjs npm run test:order-item-cancel-restore && node scripts/run-test.cjs npm run test:order-item-edit && node scripts/run-test.cjs npm run test:item-price-override && node scripts/run-test.cjs npm run test:offline-mode && node scripts/run-test.cjs npm run test:product-variants-orders && node scripts/run-test.cjs npm run test:addon-inventory-pos-surface && node scripts/run-test.cjs npm run test:addon-inventory-orders && node scripts/run-test.cjs npm run test:addon-inventory-lifecycle && node scripts/run-test.cjs npm run test:order-totals-recompute && node scripts/run-test.cjs npm run test:issue-718-whatsapp-order-status && node scripts/run-test.cjs npm run test:order-audit-log && node scripts/run-test.cjs npm run test:authz-phase3 && node scripts/run-test.cjs npm run test:authorization-permissions && node scripts/run-test.cjs npm run test:auth-ui-deterministic && node scripts/run-test.cjs npm run test:desktop-trailing-slash-routing && node scripts/run-test.cjs npm run test:customer-auth && node scripts/run-test.cjs npm run test:customer-soft-reactivate-bindings && node scripts/run-test.cjs npm run test:customer-pagination && node scripts/run-test.cjs npm run test:customer-top-items && node scripts/run-test.cjs npm run test:server-app-server-role && node scripts/run-test.cjs npm run test:backup && node scripts/run-test.cjs npm run test:issue-278-fail-closed-db && node scripts/run-test.cjs npm run test:recovery-cloud && node scripts/run-test.cjs npm run test:cloud-account-status && node scripts/run-test.cjs npm run test:cloud-live-orders-batching && node scripts/run-test.cjs npm run test:printer && node scripts/run-test.cjs npm run test:printing-settings && node scripts/run-test.cjs npm run test:printer-width-refresh && node scripts/run-test.cjs npm run test:printer-migrations && node scripts/run-test.cjs npm run test:printer-text-size && node scripts/run-test.cjs npm run test:print-parity && node scripts/run-test.cjs npm run test:delivery-slip && node scripts/run-test.cjs npm run test:delivery-address && node scripts/run-test.cjs npm run test:thermal-capabilities && node scripts/run-test.cjs npm run test:raster && node scripts/run-test.cjs npm run test:merchant-print-templates && node scripts/run-test.cjs npm run test:merchant-template-transfer && node scripts/run-test.cjs npm run test:print-document && node scripts/run-test.cjs npm run test:menu-printing && node scripts/run-test.cjs npm run test:print-kernel && node scripts/run-test.cjs npm run test:translations && node scripts/run-test.cjs npm run test:print-labels && node scripts/run-test.cjs npm run test:locale-chunks && node scripts/run-test.cjs npm run test:rtl-foundation && node scripts/run-test.cjs npm run test:rtl-setup-auth-settings && node scripts/run-test.cjs npm run test:rtl-dashboard-pos-common && node scripts/run-test.cjs npm run test:rtl-kds-server-whatsapp && node scripts/run-test.cjs npm run test:phone && node scripts/run-test.cjs npm run test:country-localization && node scripts/run-test.cjs npm run test:e2e-argentina-flow && node scripts/run-test.cjs npm run test:charges && node scripts/run-test.cjs npm run test:charges-engine && node scripts/run-test.cjs npm run test:currency && node scripts/run-test.cjs npm run test:tax-engine && node scripts/run-test.cjs npm run test:tax-components && node scripts/run-test.cjs npm run test:tax-pack-catalog && node scripts/run-test.cjs npm run test:tax-pack-management && node scripts/run-test.cjs npm run test:manual-tax-config && node scripts/run-test.cjs npm run test:legacy-tax-pack-digest && node scripts/run-test.cjs npm run test:community-tax-packs && node scripts/run-test.cjs npm run test:support-ticket && node scripts/run-test.cjs npm run test:customer-phone-search && node scripts/run-test.cjs npm run test:phone-search-integration && node scripts/run-test.cjs npm run test:orders-search && node scripts/run-test.cjs npm run test:orders-layout-settings && node scripts/run-test.cjs npm run test:pos-catalog-sort && node scripts/run-test.cjs npm run test:pos-sort-settings && node scripts/run-test.cjs npm run test:receipt-column-width && node scripts/run-test.cjs npm run test:receipt-column-oracle && node scripts/run-test.cjs npm run test:notes-validation && node scripts/run-test.cjs npm run test:receipt-printing && node scripts/run-test.cjs npm run test:cancel-override && node scripts/run-test.cjs npm run test:refunds && node scripts/run-test.cjs npm run test:async-refund-pin-event-loop && node scripts/run-test.cjs npm run test:refund-completed-orders && node scripts/run-test.cjs npm run test:cash-closures && node scripts/run-test.cjs npm run test:cash-sessions && node scripts/run-test.cjs npm run test:kitchen-addons && node scripts/run-test.cjs npm run test:kot-order-notes && node scripts/run-test.cjs npm run test:pos-info-network-ranking && node scripts/run-test.cjs npm run test:order-item-addons && node scripts/run-test.cjs npm run test:issue-125-addon-reads && node scripts/run-test.cjs npm run test:windows-country-code-crash && node scripts/run-test.cjs npm run test:reports-insights && node scripts/run-test.cjs npm run test:reports-daily-stats-table-turn && node scripts/run-test.cjs npm run test:timezone-report-boundaries && node scripts/run-test.cjs npm run test:business-date && node scripts/run-test.cjs npm run test:dashboard-business-date && node scripts/run-test.cjs npm run test:sequence && node scripts/run-test.cjs npm run test:order-numbering-settings && node scripts/run-test.cjs npm run test:integration-happy && node scripts/run-test.cjs npm run test:integration-order-hold-payment && node scripts/run-test.cjs npm run test:integration-tax && node scripts/run-test.cjs npm run test:integration-payments && node scripts/run-test.cjs npm run test:kitchen-delivery-enforcement && node scripts/run-test.cjs npm run test:issue-214 && node scripts/run-test.cjs npm run test:issue-214-auth && node scripts/run-test.cjs npm run test:issue-214-migration && node scripts/run-test.cjs npm run test:issue-255 && node scripts/run-test.cjs npm run test:cart-variant-identity && node scripts/run-test.cjs npm run test:cart-price-override && node scripts/run-test.cjs npm run test:product-variants-screen && node scripts/run-test.cjs npm run test:variant-pos-surface && node scripts/run-test.cjs npm run test:issue-788-order-attempt-storage && node scripts/run-test.cjs npm run test:integration-lifecycle && node scripts/run-test.cjs npm run test:integration-reconciliation && node scripts/run-test.cjs npm run test:integration-loyalty && node scripts/run-test.cjs npm run test:integration-discount && node scripts/run-test.cjs npm run test:loyalty-toggle && node scripts/run-test.cjs npm run test:discount-system && node scripts/run-test.cjs npm run test:integration-discount-settings && node scripts/run-test.cjs npm run test:integration-loyalty-global && node scripts/run-test.cjs npm run test:issue-248-csv && node scripts/run-test.cjs npm run test:integration-loyalty-redemption && node scripts/run-test.cjs npm run test:bills-print-api && node scripts/run-test.cjs npm run test:issue-24 && node scripts/run-test.cjs npm run test:issue-134-routing && node scripts/run-test.cjs npm run test:server-app-station-routing && node scripts/run-test.cjs npm run test:issue-134-mgmt && node scripts/run-test.cjs npm run test:issue-137-barcode && node scripts/run-test.cjs npm run test:issue-244-product-addon-links && node scripts/run-test.cjs npm run test:issue-245-246-addon-groups && node scripts/run-test.cjs npm run test:issue-247-catalog-invariants && node scripts/run-test.cjs npm run test:issue-250-catalog-perf && node scripts/run-test.cjs npm run test:issue-252 && node scripts/run-test.cjs npm run test:inventory-ledger && node scripts/run-test.cjs npm run test:issue-printing-and-kds-resilience && node scripts/run-test.cjs npm run test:issue-258-bill-pagination && node scripts/run-test.cjs npm run test:issue-265-morocco-profile && node scripts/run-test.cjs npm run test:issue-266-currency-symbol-print && node scripts/run-test.cjs npm run test:tables-string-ids && node scripts/run-test.cjs npm run test:issue-646-floor-management && node scripts/run-test.cjs npm run test:held-orders && node scripts/run-test.cjs npm run test:product-images && node scripts/run-test.cjs npm run test:schema-health && node scripts/run-test.cjs npm run test:migration-registry && node scripts/run-test.cjs npm run test:upgrade-path && node scripts/run-test.cjs npm run test:upgrade-matrix-harness && node scripts/run-test.cjs npm run test:migration-v56-v57 && node scripts/run-test.cjs npm run test:migration-v71-repair && node scripts/run-test.cjs npm run test:migration-v80-cash-drawer-pulse && node scripts/run-test.cjs npm run test:migration-v81-cash-closures && node scripts/run-test.cjs npm run test:master-pin && node scripts/run-test.cjs npm run test:google-drive && node scripts/run-test.cjs npm run test:google-drive-qr-menu && node scripts/run-test.cjs npm run test:database-tools-api && node scripts/run-test.cjs npm run test:phone-validation && node scripts/run-test.cjs npm run test:phone-migration && node scripts/run-test.cjs npm run test:issue-133-kds-kot-toggles && node scripts/run-test.cjs npm run test:whatsapp-schema && node scripts/run-test.cjs npm run test:whatsapp-service && node scripts/run-test.cjs npm run test:whatsapp-middleware && node scripts/run-test.cjs npm run test:issue-127-password-recovery && node scripts/run-test.cjs npm run test:dev-tooling && node scripts/run-test.cjs npm run test:windows-uninstaller && node scripts/run-test.cjs npm run test:shutdown-lifecycle && node scripts/run-test.cjs npm run test:redos-hardening && node scripts/run-test.cjs npm run test:startup-cache && node scripts/run-test.cjs npm run test:service-worker && node scripts/run-test.cjs npm run test:issue-389-timezone-override && node scripts/run-test.cjs npm run test:issue-390-locale-preference-invariants && node scripts/run-test.cjs npm run test:issue-475-picker-highlight && node scripts/run-test.cjs npm run test:theme-mode-settings && node scripts/run-test.cjs npm run test:theme-fouc-script && node scripts/run-test.cjs npm run test:ui-regressions-621-623-626 && node scripts/run-test.cjs npm run test:issue-zero-balance-settlement && node scripts/run-test.cjs npm run test:daily-sales-export && node scripts/run-test.cjs npm run test:daily-invoices-export && node scripts/run-test.cjs npm run test:customer-sales-export && node scripts/run-test.cjs npm run test:card-transaction-id && node scripts/run-test.cjs npm run test:issue-terminal-state-guards && node scripts/run-test.cjs npm run test:issue-exact-balance-settlement && node scripts/run-test.cjs npm run test:financial-safety-remediation && node scripts/run-test.cjs npm run test:issue-payload-and-discount-bounds && node scripts/run-test.cjs npm run test:customer-display-config && node scripts/run-test.cjs npm run test:customer-display-settings-route && node scripts/run-test.cjs npm run test:customer-display-pairing && node scripts/run-test.cjs npm run test:customer-display-feed && node scripts/run-test.cjs npm run test:units && node scripts/run-test.cjs npm run test:supplies-service && node scripts/run-test.cjs npm run test:recipe-order-lifecycle && node scripts/run-test.cjs npm run test:ingredient-cost && node scripts/run-test.cjs npm run test:lint-engine && node scripts/run-test.cjs npm run test:oxc-contributor-checks && node scripts/run-test.cjs npm run test:expenses && node scripts/run-test.cjs npm run test:diagnostics && node scripts/run-test.cjs npm run test:reports-export && node scripts/run-test.cjs npm run test:category-addon-groups && node scripts/run-test.cjs npm run test:addon-inventory-db && node scripts/run-test.cjs npm run test:addon-inventory-api && node scripts/run-test.cjs npm run test:discount-kitchen-approver-identity && node scripts/run-test.cjs npm run test:staff-approvers-directory && node scripts/run-test.cjs npm run test:local-backup && npm run test:password-policy && npm run test:cloud-password-recovery && node scripts/run-test.cjs npm run test:scheduled-orders && node scripts/run-test.cjs npm run test:newer-db-recovery", + "test": "node scripts/run-test.cjs npm run test:smoke && node scripts/run-test.cjs npm run test:server-port-collision && node scripts/run-test.cjs npm run test:kds-integration && node scripts/run-test.cjs npm run test:kds-broadcast-grouping && node scripts/run-test.cjs npm run test:kds-contract && node scripts/run-test.cjs npm run test:kds-frontend-conflict && node scripts/run-test.cjs npm run test:kds-window-hardening && node scripts/run-test.cjs npm run test:electron-api-contract && node scripts/run-test.cjs npm run test:titlebar-window-options && node scripts/run-test.cjs npm run test:update-ipc-sender-validation && node scripts/run-test.cjs npm run test:menu-surface && node scripts/run-test.cjs npm run test:pos-keyboard-shortcuts && node scripts/run-test.cjs npm run test:window-readiness && node scripts/run-test.cjs npm run test:window-load-retry && node scripts/run-test.cjs npm run test:cors && node scripts/run-test.cjs npm run test:csp-lan && node scripts/run-test.cjs npm run test:release-config && node scripts/run-test.cjs npm run test:update-state && node scripts/run-test.cjs npm run test:update-ui && node scripts/run-test.cjs npm run test:update-channel && node scripts/run-test.cjs npm run test:telemetry && node scripts/run-test.cjs npm run test:telemetry-install-provenance && node scripts/run-test.cjs npm run test:country-provenance && node scripts/run-test.cjs npm run test:first-run && node scripts/run-test.cjs npm run test:phase7-setup-i18n && node scripts/run-test.cjs npm run test:security && node scripts/run-test.cjs npm run test:jwt-secret-cache-identity && node scripts/run-test.cjs npm run test:jwt-secret-persist-before-cache && node scripts/run-test.cjs npm run test:jwt-secret-safe-storage && node scripts/run-test.cjs npm run test:staff-authz && node scripts/run-test.cjs npm run test:orders-authz && node scripts/run-test.cjs npm run test:order-item-cancel-restore && node scripts/run-test.cjs npm run test:order-item-edit && node scripts/run-test.cjs npm run test:item-price-override && node scripts/run-test.cjs npm run test:offline-mode && node scripts/run-test.cjs npm run test:product-variants-orders && node scripts/run-test.cjs npm run test:addon-inventory-pos-surface && node scripts/run-test.cjs npm run test:addon-inventory-orders && node scripts/run-test.cjs npm run test:addon-inventory-lifecycle && node scripts/run-test.cjs npm run test:order-totals-recompute && node scripts/run-test.cjs npm run test:issue-718-whatsapp-order-status && node scripts/run-test.cjs npm run test:order-audit-log && node scripts/run-test.cjs npm run test:authz-phase3 && node scripts/run-test.cjs npm run test:authorization-permissions && node scripts/run-test.cjs npm run test:auth-ui-deterministic && node scripts/run-test.cjs npm run test:desktop-trailing-slash-routing && node scripts/run-test.cjs npm run test:customer-auth && node scripts/run-test.cjs npm run test:customer-soft-reactivate-bindings && node scripts/run-test.cjs npm run test:customer-pagination && node scripts/run-test.cjs npm run test:customer-top-items && node scripts/run-test.cjs npm run test:server-app-server-role && node scripts/run-test.cjs npm run test:backup && node scripts/run-test.cjs npm run test:issue-278-fail-closed-db && node scripts/run-test.cjs npm run test:recovery-cloud && node scripts/run-test.cjs npm run test:cloud-account-status && node scripts/run-test.cjs npm run test:cloud-live-orders-batching && node scripts/run-test.cjs npm run test:printer && node scripts/run-test.cjs npm run test:printing-settings && node scripts/run-test.cjs npm run test:printer-width-refresh && node scripts/run-test.cjs npm run test:printer-migrations && node scripts/run-test.cjs npm run test:printer-text-size && node scripts/run-test.cjs npm run test:print-parity && node scripts/run-test.cjs npm run test:delivery-slip && node scripts/run-test.cjs npm run test:delivery-address && node scripts/run-test.cjs npm run test:thermal-capabilities && node scripts/run-test.cjs npm run test:raster && node scripts/run-test.cjs npm run test:merchant-print-templates && node scripts/run-test.cjs npm run test:merchant-template-transfer && node scripts/run-test.cjs npm run test:print-document && node scripts/run-test.cjs npm run test:menu-printing && node scripts/run-test.cjs npm run test:print-kernel && node scripts/run-test.cjs npm run test:translations && node scripts/run-test.cjs npm run test:print-labels && node scripts/run-test.cjs npm run test:locale-chunks && node scripts/run-test.cjs npm run test:rtl-foundation && node scripts/run-test.cjs npm run test:rtl-setup-auth-settings && node scripts/run-test.cjs npm run test:rtl-dashboard-pos-common && node scripts/run-test.cjs npm run test:rtl-kds-server-whatsapp && node scripts/run-test.cjs npm run test:phone && node scripts/run-test.cjs npm run test:country-localization && node scripts/run-test.cjs npm run test:e2e-argentina-flow && node scripts/run-test.cjs npm run test:charges && node scripts/run-test.cjs npm run test:charges-engine && node scripts/run-test.cjs npm run test:currency && node scripts/run-test.cjs npm run test:tax-engine && node scripts/run-test.cjs npm run test:tax-components && node scripts/run-test.cjs npm run test:tax-pack-catalog && node scripts/run-test.cjs npm run test:tax-pack-management && node scripts/run-test.cjs npm run test:manual-tax-config && node scripts/run-test.cjs npm run test:legacy-tax-pack-digest && node scripts/run-test.cjs npm run test:community-tax-packs && node scripts/run-test.cjs npm run test:support-ticket && node scripts/run-test.cjs npm run test:customer-phone-search && node scripts/run-test.cjs npm run test:phone-search-integration && node scripts/run-test.cjs npm run test:orders-search && node scripts/run-test.cjs npm run test:orders-layout-settings && node scripts/run-test.cjs npm run test:pos-catalog-sort && node scripts/run-test.cjs npm run test:pos-sort-settings && node scripts/run-test.cjs npm run test:receipt-column-width && node scripts/run-test.cjs npm run test:receipt-column-oracle && node scripts/run-test.cjs npm run test:notes-validation && node scripts/run-test.cjs npm run test:receipt-printing && node scripts/run-test.cjs npm run test:cancel-override && node scripts/run-test.cjs npm run test:refunds && node scripts/run-test.cjs npm run test:async-refund-pin-event-loop && node scripts/run-test.cjs npm run test:refund-completed-orders && node scripts/run-test.cjs npm run test:cash-closures && node scripts/run-test.cjs npm run test:cash-sessions && node scripts/run-test.cjs npm run test:kitchen-addons && node scripts/run-test.cjs npm run test:kot-order-notes && node scripts/run-test.cjs npm run test:pos-info-network-ranking && node scripts/run-test.cjs npm run test:order-item-addons && node scripts/run-test.cjs npm run test:issue-125-addon-reads && node scripts/run-test.cjs npm run test:windows-country-code-crash && node scripts/run-test.cjs npm run test:reports-insights && node scripts/run-test.cjs npm run test:reports-daily-stats-table-turn && node scripts/run-test.cjs npm run test:timezone-report-boundaries && node scripts/run-test.cjs npm run test:business-date && node scripts/run-test.cjs npm run test:dashboard-business-date && node scripts/run-test.cjs npm run test:sequence && node scripts/run-test.cjs npm run test:order-numbering-settings && node scripts/run-test.cjs npm run test:integration-happy && node scripts/run-test.cjs npm run test:integration-order-hold-payment && node scripts/run-test.cjs npm run test:integration-tax && node scripts/run-test.cjs npm run test:integration-payments && node scripts/run-test.cjs npm run test:kitchen-delivery-enforcement && node scripts/run-test.cjs npm run test:issue-214 && node scripts/run-test.cjs npm run test:issue-214-auth && node scripts/run-test.cjs npm run test:issue-214-migration && node scripts/run-test.cjs npm run test:issue-255 && node scripts/run-test.cjs npm run test:cart-variant-identity && node scripts/run-test.cjs npm run test:cart-price-override && node scripts/run-test.cjs npm run test:product-variants-screen && node scripts/run-test.cjs npm run test:variant-pos-surface && node scripts/run-test.cjs npm run test:issue-788-order-attempt-storage && node scripts/run-test.cjs npm run test:integration-lifecycle && node scripts/run-test.cjs npm run test:integration-reconciliation && node scripts/run-test.cjs npm run test:integration-loyalty && node scripts/run-test.cjs npm run test:integration-discount && node scripts/run-test.cjs npm run test:loyalty-toggle && node scripts/run-test.cjs npm run test:discount-system && node scripts/run-test.cjs npm run test:integration-discount-settings && node scripts/run-test.cjs npm run test:integration-loyalty-global && node scripts/run-test.cjs npm run test:issue-248-csv && node scripts/run-test.cjs npm run test:integration-loyalty-redemption && node scripts/run-test.cjs npm run test:bills-print-api && node scripts/run-test.cjs npm run test:issue-24 && node scripts/run-test.cjs npm run test:issue-134-routing && node scripts/run-test.cjs npm run test:server-app-station-routing && node scripts/run-test.cjs npm run test:issue-134-mgmt && node scripts/run-test.cjs npm run test:issue-137-barcode && node scripts/run-test.cjs npm run test:issue-244-product-addon-links && node scripts/run-test.cjs npm run test:issue-245-246-addon-groups && node scripts/run-test.cjs npm run test:issue-247-catalog-invariants && node scripts/run-test.cjs npm run test:issue-250-catalog-perf && node scripts/run-test.cjs npm run test:issue-252 && node scripts/run-test.cjs npm run test:inventory-ledger && node scripts/run-test.cjs npm run test:issue-printing-and-kds-resilience && node scripts/run-test.cjs npm run test:issue-258-bill-pagination && node scripts/run-test.cjs npm run test:issue-265-morocco-profile && node scripts/run-test.cjs npm run test:issue-266-currency-symbol-print && node scripts/run-test.cjs npm run test:tables-string-ids && node scripts/run-test.cjs npm run test:issue-646-floor-management && node scripts/run-test.cjs npm run test:held-orders && node scripts/run-test.cjs npm run test:product-images && node scripts/run-test.cjs npm run test:schema-health && node scripts/run-test.cjs npm run test:migration-registry && node scripts/run-test.cjs npm run test:upgrade-path && node scripts/run-test.cjs npm run test:upgrade-matrix-harness && node scripts/run-test.cjs npm run test:migration-v56-v57 && node scripts/run-test.cjs npm run test:migration-v71-repair && node scripts/run-test.cjs npm run test:migration-v80-cash-drawer-pulse && node scripts/run-test.cjs npm run test:migration-v81-cash-closures && node scripts/run-test.cjs npm run test:master-pin && node scripts/run-test.cjs npm run test:google-drive && node scripts/run-test.cjs npm run test:google-drive-qr-menu && node scripts/run-test.cjs npm run test:database-tools-api && node scripts/run-test.cjs npm run test:phone-validation && node scripts/run-test.cjs npm run test:phone-migration && node scripts/run-test.cjs npm run test:issue-133-kds-kot-toggles && node scripts/run-test.cjs npm run test:whatsapp-schema && node scripts/run-test.cjs npm run test:whatsapp-service && node scripts/run-test.cjs npm run test:whatsapp-middleware && node scripts/run-test.cjs npm run test:issue-127-password-recovery && node scripts/run-test.cjs npm run test:dev-tooling && node scripts/run-test.cjs npm run test:windows-uninstaller && node scripts/run-test.cjs npm run test:shutdown-lifecycle && node scripts/run-test.cjs npm run test:redos-hardening && node scripts/run-test.cjs npm run test:startup-cache && node scripts/run-test.cjs npm run test:service-worker && node scripts/run-test.cjs npm run test:issue-389-timezone-override && node scripts/run-test.cjs npm run test:issue-390-locale-preference-invariants && node scripts/run-test.cjs npm run test:issue-475-picker-highlight && node scripts/run-test.cjs npm run test:theme-mode-settings && node scripts/run-test.cjs npm run test:theme-fouc-script && node scripts/run-test.cjs npm run test:ui-regressions-621-623-626 && node scripts/run-test.cjs npm run test:issue-zero-balance-settlement && node scripts/run-test.cjs npm run test:daily-sales-export && node scripts/run-test.cjs npm run test:daily-invoices-export && node scripts/run-test.cjs npm run test:customer-sales-export && node scripts/run-test.cjs npm run test:card-transaction-id && node scripts/run-test.cjs npm run test:issue-terminal-state-guards && node scripts/run-test.cjs npm run test:issue-exact-balance-settlement && node scripts/run-test.cjs npm run test:financial-safety-remediation && node scripts/run-test.cjs npm run test:issue-payload-and-discount-bounds && node scripts/run-test.cjs npm run test:customer-display-config && node scripts/run-test.cjs npm run test:customer-display-settings-route && node scripts/run-test.cjs npm run test:customer-display-pairing && node scripts/run-test.cjs npm run test:customer-display-feed && node scripts/run-test.cjs npm run test:units && node scripts/run-test.cjs npm run test:supplies-service && node scripts/run-test.cjs npm run test:recipe-order-lifecycle && node scripts/run-test.cjs npm run test:ingredient-cost && node scripts/run-test.cjs npm run test:lint-engine && node scripts/run-test.cjs npm run test:oxc-contributor-checks && node scripts/run-test.cjs npm run test:expenses && node scripts/run-test.cjs npm run test:diagnostics && node scripts/run-test.cjs npm run test:reports-export && node scripts/run-test.cjs npm run test:category-addon-groups && node scripts/run-test.cjs npm run test:addon-inventory-db && node scripts/run-test.cjs npm run test:addon-inventory-api && node scripts/run-test.cjs npm run test:discount-kitchen-approver-identity && node scripts/run-test.cjs npm run test:staff-approvers-directory && node scripts/run-test.cjs npm run test:local-backup && npm run test:password-policy && npm run test:cloud-password-recovery && node scripts/run-test.cjs npm run test:scheduled-orders && node scripts/run-test.cjs npm run test:newer-db-recovery && node scripts/run-test.cjs npm run test:multi-counter-ordering", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:runtime-recovery && npm run test:backend-health && npm run test:url-allowlist && npm run test:static-routes", "test:script-coverage": "node scripts/ci/validate-test-script-coverage.cjs && node tests/test-script-coverage.test.cjs && ts-node --transpile-only -P tests/tsconfig.json tests/test-harness-assertions.test.ts", "test:lint-engine": "ts-node --transpile-only -P tests/tsconfig.json tests/lint-engine-parity.test.ts", @@ -121,6 +121,7 @@ "test:issue-718-whatsapp-order-status": "node tests/run-electron-node-test.cjs tests/issue-718-whatsapp-order-status.test.ts", "test:order-audit-log": "node tests/run-electron-node-test.cjs tests/order-audit-log.test.ts", "test:scheduled-orders": "node tests/run-electron-node-test.cjs tests/issue-360-scheduled-orders.test.ts", + "test:multi-counter-ordering": "node tests/run-electron-node-test.cjs tests/multi-counter-ordering.test.ts", "test:authz-phase3": "node tests/run-electron-node-test.cjs tests/authz-matrix-phase3.test.ts", "test:authorization-permissions": "ts-node --transpile-only -P tests/tsconfig.json tests/permission-label-i18n.test.ts && ts-node --transpile-only -P tests/tsconfig.json tests/permission-matrix-self-access.test.ts && node tests/run-electron-node-test.cjs tests/authorization-permissions.test.ts && node tests/run-electron-node-test.cjs tests/authorization-api.test.ts && node tests/run-electron-node-test.cjs tests/authorization-static-audit.test.ts && node tests/run-electron-node-test.cjs tests/static-audit-posix-paths.test.ts", "test:customer-auth": "node tests/run-electron-node-test.cjs tests/customer-auth.test.ts", diff --git a/tests/backup-restore-production.test.ts b/tests/backup-restore-production.test.ts index 9e7563838..bef734091 100644 --- a/tests/backup-restore-production.test.ts +++ b/tests/backup-restore-production.test.ts @@ -973,8 +973,8 @@ async function run() { initDatabase(); assert.equal( getCurrentSchemaVersion(), - 123, - 'interrupted v114 replacement recovered and migrated to v123', + 124, + 'interrupted v114 replacement recovered and migrated to v124', ); assert.equal( fs.existsSync(journalPath), diff --git a/tests/multi-counter-ordering.test.ts b/tests/multi-counter-ordering.test.ts new file mode 100644 index 000000000..a7b67e394 --- /dev/null +++ b/tests/multi-counter-ordering.test.ts @@ -0,0 +1,1634 @@ +/** + * Multi-counter ordering flow (create here, pay there) (#361) + * + * Covers: + * - Multi-counter settings toggle (owner-only) + * - Counters configuration CRUD API with permissions + * - Order creation with dispatch_policy ('on_payment' for takeaway and dine_in, rejected for delivery) + * - Deferred release (no stock deduction, no KDS/KOT auto-print prior to payment) + * - Shared payment queue (/api/orders/awaiting-payment) with pagination and search + * - Ticket lookup by order number and QR code payload (FLOCAFE-TICKET:) + * - Release gate: manual release of unpaid on_payment order rejected (409 ORDER_NOT_PAID) + * - Prepayment lifecycle: partial payment updates balance; full payment triggers release + * - Prepayment invariants: payment never marks kitchen completed and never prematurely frees dine-in tables + * - Prepayment edit lock: items/discounts locked once payment is recorded (409 SCHEDULED_ORDER_PAID) + * - Scheduled + on_payment takeaway: full payment marks paid, release waits for scheduled instant or explicit release + * - Stock failure after payment leaves paid-but-blocked ticket; payment preserved; retry succeeds after restock + * - Concurrent payment protection + * - Thermal payment ticket slip generation with QR code (/api/printers/print-payment-ticket) + * + * Usage: node tests/run-electron-node-test.cjs tests/multi-counter-ordering.test.ts + */ + +const Module = require('module'); +const originalLoad = Module._load; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-multi-counter-361-')); + +let kotPrintCalls: Array<{ orderId: unknown; stationName: string; itemCount: number }> = []; + +Module._load = function (request: string, parent: unknown, isMain: boolean) { + if (request === 'electron') { + return { + app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' }, + powerMonitor: { on: () => {} }, + }; + } + const loaded = originalLoad.apply(this, arguments as any); + if ( + typeof request === 'string' && + (request.endsWith('/printers/thermal') || request.endsWith('\\printers\\thermal')) && + loaded && + typeof loaded.printKOTDetailed === 'function' && + !(loaded as any).__multiCounterKotWrapped + ) { + const origPrintKot = loaded.printKOTDetailed; + loaded.printKOTDetailed = async function ( + order: any, + items: any[], + stationName: string, + ...rest: any[] + ) { + kotPrintCalls.push({ + orderId: order?.id, + stationName, + itemCount: Array.isArray(items) ? items.length : 0, + }); + return { ok: true, warnings: [] }; + }; + (loaded as any).__multiCounterKotWrapped = true; + void origPrintKot; + } + return loaded; +}; + +process.env.JWT_SECRET = 'test-secret-multi-counter-361'; +process.env.KDS_PORT = '0'; + +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const { + initTestDb, + createApp, + startServer, + seedOwnerUser, + seedManagerUser, + seedCategory, + seedProduct, + seedTable, + seedCustomer, + api, + assertOrThrow, + assertEqualOrThrow, + assertIncludesOrThrow, + getResults, + resetCounters, + closeDatabase, + now, +} = require('./helpers/test-setup'); +const { getJWTSecret } = require('../main/routes/auth'); +const { validateInventoryLedgerDatabase } = require('../main/db'); + +function seedStaffUser(db: any, id: string, role: string, pin?: string) { + const email = `${id}@test.local`; + db.prepare(` + INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) + `).run( + id, + id, + email, + bcrypt.hashSync('testpass123', 10), + role, + pin ? bcrypt.hashSync(pin, 10) : null, + now(), + now(), + ); + return { + userId: id, + email, + role, + authHeader: { + Authorization: `Bearer ${jwt.sign({ userId: id, email, role }, getJWTSecret(), { expiresIn: '1h' })}`, + }, + }; +} +const { adjustProductStock } = require('../main/services/inventory'); +const { stopScheduledOrderSweep } = require('../main/services/scheduled-orders'); +const { resetPinRateLimitForTests } = require('../main/routes/orders'); +const { registerRoutes } = require('../main/routes/index'); +const { stopKdsServer } = require('../main/kds-server'); + +function adjustStock( + db: any, + productId: string, + quantityDelta: number, + reason: string, + actorUserId: string, +) { + adjustProductStock(db, { + productId, + quantityDelta, + movementType: 'adjustment', + referenceType: 'manual_adjustment', + reason, + actorUserId, + }); +} + +function setStoreSetting(db: any, key: string, value: string) { + db.prepare( + `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + ).run(key, value, now()); +} + +function productStock(db: any, id: string): number { + const row = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get(id) as any; + return Number(row?.stock_quantity ?? 0); +} + +function assertLedgerReconciles(db: any, label: string) { + assertEqualOrThrow( + validateInventoryLedgerDatabase(db), + null, + `inventory ledger reconciles (${label})`, + ); +} + +function futureWallClockInZone(timezone: string, offsetMinutes: number): string { + const target = new Date(Date.now() + offsetMinutes * 60 * 1000); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour12: false, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).formatToParts(target); + const byType: Record = {}; + for (const p of parts) byType[p.type] = p.value; + return `${byType.year}-${byType.month}-${byType.day}T${byType.hour === '24' ? '00' : byType.hour}:${byType.minute}`; +} + +async function main() { + console.log('Multi-counter ordering flow test (#361)'); + console.log('='.repeat(66)); + resetCounters(); + resetPinRateLimitForTests(); + + const db = initTestDb(); + const owner = seedOwnerUser(db); + const manager = seedManagerUser(db); + const cashier = seedStaffUser(db, 'cashier-mc', 'cashier'); + const serverUser = seedStaffUser(db, 'server-mc', 'server'); + + db.prepare('UPDATE users SET pin_hash = ? WHERE id = ?').run( + bcrypt.hashSync('9999', 10), + owner.userId, + ); + + // Store regional & kitchen settings + setStoreSetting(db, 'country', 'US'); + setStoreSetting(db, 'currency', 'USD'); + setStoreSetting(db, 'timezone', 'America/New_York'); + setStoreSetting(db, 'kds_enabled', 'true'); + setStoreSetting(db, 'kot_printing_enabled', 'true'); + setStoreSetting(db, 'auto_print_kot', 'true'); + setStoreSetting(db, 'require_kitchen_delivered_before_settlement', 'true'); + setStoreSetting(db, 'split_checks_enabled', 'true'); + + // Seed default printer for ticket printing tests + db.prepare( + `INSERT INTO printers (id, name, connection_type, ip_address, port, paper_width, text_size, is_default, cash_drawer_pulse_enabled, created_at, updated_at) + VALUES ('prn-mc-1', 'Receipt Thermal', 'network', '127.0.0.1', 9100, 'cols-42', 'normal', 1, 0, ?, ?)`, + ).run(now(), now()); + + // Seed menu catalog + seedCategory(db, 'cat-mc', 'MultiCounter Menu'); + seedTable(db, 'tbl-mc-1', 1, 4); + seedCustomer(db, 'cust-mc-1', 'Alex Counter', '+15551112222'); + seedCustomer(db, 'cust-mc-2', 'Jordan Queue', '+15553334444'); + + // Seed tracked products with ledger-backed stock + seedProduct(db, 'prod-burger', 'cat-mc', 'Counter Burger', 1200, { + track_inventory: true, + stock_quantity: 0, + }); + seedProduct(db, 'prod-fries', 'cat-mc', 'Counter Fries', 400, { + track_inventory: true, + stock_quantity: 0, + }); + seedProduct(db, 'prod-limited', 'cat-mc', 'Limited Pie', 500, { + track_inventory: true, + stock_quantity: 0, + }); + + adjustStock(db, 'prod-burger', 50, 'initial seed', owner.userId); + adjustStock(db, 'prod-fries', 50, 'initial seed', owner.userId); + adjustStock(db, 'prod-limited', 1, 'initial seed 1 left', owner.userId); + assertLedgerReconciles(db, 'after initial stock'); + + const app = createApp({}); + registerRoutes(app); + const { baseUrl, server } = await startServer(app); + + async function req(options: { path: string; method?: string; body?: any; headers?: any }) { + const { path: urlPath, ...rest } = options; + const res = await api(baseUrl, urlPath, rest); + return { status: res.status, body: res.data }; + } + + try { + // ------------------------------------------------------------------------- + // 1. Settings: multi_counter_enabled toggle + // ------------------------------------------------------------------------- + console.log('\n--- 1. Multi-counter Settings Toggle ---'); + + // Non-owner cannot toggle multi_counter_enabled + const cashierToggle = await req({ + path: '/api/settings/multi_counter_enabled', + method: 'PUT', + headers: cashier.authHeader, + body: { value: 'true' }, + }); + assertEqualOrThrow( + cashierToggle.status, + 403, + 'non-owner toggle multi_counter_enabled rejected 403', + ); + + // Owner can toggle multi_counter_enabled + const ownerToggle = await req({ + path: '/api/settings/multi_counter_enabled', + method: 'PUT', + headers: owner.authHeader, + body: { value: 'true' }, + }); + assertEqualOrThrow(ownerToggle.status, 200, 'owner can enable multi_counter_enabled'); + + const checkSetting = await req({ + path: '/api/settings/multi_counter_enabled', + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(checkSetting.status, 200, 'read multi_counter_enabled setting'); + assertEqualOrThrow(checkSetting.body?.setting?.value, 'true', 'setting is true'); + + // ------------------------------------------------------------------------- + // 2. Counters API CRUD & Permissions + // ------------------------------------------------------------------------- + console.log('\n--- 2. Counters CRUD API ---'); + + // Initial list is empty + const listInitial = await req({ + path: '/api/counters', + method: 'GET', + headers: serverUser.authHeader, + }); + assertEqualOrThrow(listInitial.status, 200, 'server can read counters'); + assertEqualOrThrow(listInitial.body?.counters?.length, 0, 'initial counters count is 0'); + + // Non-owner cannot create counter + const serverCreate = await req({ + path: '/api/counters', + method: 'POST', + headers: serverUser.authHeader, + body: { name: 'Unauthorized Counter', role: 'ordering' }, + }); + assertEqualOrThrow(serverCreate.status, 403, 'non-owner cannot create counter'); + + // Validation checks + const badRole = await req({ + path: '/api/counters', + method: 'POST', + headers: owner.authHeader, + body: { name: 'Counter 1', role: 'invalid_role' }, + }); + assertEqualOrThrow(badRole.status, 400, 'invalid counter role rejected 400'); + + const emptyName = await req({ + path: '/api/counters', + method: 'POST', + headers: owner.authHeader, + body: { name: ' ', role: 'ordering' }, + }); + assertEqualOrThrow(emptyName.status, 400, 'empty counter name rejected 400'); + + // Create 3 counters with different roles + const c1Res = await req({ + path: '/api/counters', + method: 'POST', + headers: owner.authHeader, + body: { name: 'Order Station 1', role: 'ordering', sort_order: 1 }, + }); + assertEqualOrThrow(c1Res.status, 201, 'create ordering counter 201'); + const c1 = c1Res.body.counter; + assertEqualOrThrow(c1.role, 'ordering', 'counter role is ordering'); + + const c2Res = await req({ + path: '/api/counters', + method: 'POST', + headers: owner.authHeader, + body: { name: 'Payment Station 1', role: 'payment', sort_order: 2 }, + }); + assertEqualOrThrow(c2Res.status, 201, 'create payment counter 201'); + const c2 = c2Res.body.counter; + + const c3Res = await req({ + path: '/api/counters', + method: 'POST', + headers: owner.authHeader, + body: { name: 'Express Combined', role: 'combined', sort_order: 3 }, + }); + assertEqualOrThrow(c3Res.status, 201, 'create combined counter 201'); + const c3 = c3Res.body.counter; + + // Check list ordering + const listRes = await req({ + path: '/api/counters', + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(listRes.body?.counters?.length, 3, 'counters list has 3 counters'); + assertEqualOrThrow(listRes.body.counters[0].id, c1.id, 'first counter sorted by sort_order'); + assertEqualOrThrow(listRes.body.counters[1].id, c2.id, 'second counter sorted by sort_order'); + + // Update counter + const updateRes = await req({ + path: `/api/counters/${c3.id}`, + method: 'PUT', + headers: owner.authHeader, + body: { + name: 'Express Combined Renamed', + role: 'combined', + is_active: false, + sort_order: 10, + }, + }); + assertEqualOrThrow(updateRes.status, 200, 'update counter 200'); + assertEqualOrThrow( + updateRes.body.counter.name, + 'Express Combined Renamed', + 'counter name updated', + ); + assertEqualOrThrow(updateRes.body.counter.is_active, 0, 'counter is_active is 0'); + + // Delete counter + const deleteRes = await req({ + path: `/api/counters/${c3.id}`, + method: 'DELETE', + headers: owner.authHeader, + }); + assertEqualOrThrow(deleteRes.status, 200, 'delete counter 200'); + + const listAfterDelete = await req({ + path: '/api/counters', + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow( + listAfterDelete.body?.counters?.length, + 2, + 'counters count is 2 after deletion', + ); + + // ------------------------------------------------------------------------- + // 3. Order Creation with dispatch_policy: 'on_payment' + // ------------------------------------------------------------------------- + console.log('\n--- 3. Order Creation & Deferred Release ---'); + + // Reject delivery with on_payment + const deliveryReject = await req({ + path: '/api/orders', + method: 'POST', + headers: cashier.authHeader, + body: { + type: 'delivery', + dispatch_policy: 'on_payment', + delivery_address: '123 Main St', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + assertEqualOrThrow( + deliveryReject.status, + 400, + 'delivery with dispatch_policy on_payment rejected 400', + ); + assertIncludesOrThrow( + deliveryReject.body?.error, + 'not supported for delivery orders', + 'helpful delivery rejection error', + ); + + // Reject invalid dispatch_policy + const invalidPolicyReject = await req({ + path: '/api/orders', + method: 'POST', + headers: cashier.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'invalid_policy', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + assertEqualOrThrow(invalidPolicyReject.status, 400, 'invalid dispatch_policy rejected 400'); + + // Reject invalid or mismatched checkout_stage (Item 1) + const invalidStageRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + checkout_stage: 'invalid_stage', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + assertEqualOrThrow(invalidStageRes.status, 400, 'invalid checkout_stage rejected 400'); + + const mismatchedStageRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'immediate', + checkout_stage: 'awaiting_payment', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + assertEqualOrThrow(mismatchedStageRes.status, 400, 'mismatched checkout_stage rejected 400'); + + // Idempotent POST /orders replay returns QR fields (Item 5) + const idemKeyOrder = `idem-order-test-${Date.now()}`; + const idemOrder1 = await req({ + path: '/api/orders', + method: 'POST', + headers: { ...serverUser.authHeader, 'Idempotency-Key': idemKeyOrder }, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + assertEqualOrThrow(idemOrder1.status, 201, 'idempotent initial order created 201'); + assertOrThrow(!!idemOrder1.body?.qr_payload, 'initial response includes qr_payload'); + assertOrThrow(!!idemOrder1.body?.qr_data_url, 'initial response includes qr_data_url'); + + const idemReplay = await req({ + path: '/api/orders', + method: 'POST', + headers: { ...serverUser.authHeader, 'Idempotency-Key': idemKeyOrder }, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + assertEqualOrThrow(idemReplay.status, 200, 'idempotent order replay 200'); + assertEqualOrThrow( + idemReplay.body?.order?.order_number, + idemOrder1.body?.order?.order_number, + 'replayed order number matches', + ); + assertEqualOrThrow( + idemReplay.body?.qr_payload, + idemOrder1.body?.qr_payload, + 'replayed qr_payload matches', + ); + assertOrThrow( + typeof idemReplay.body?.qr_data_url === 'string' && + idemReplay.body.qr_data_url.startsWith('data:image/png;base64,'), + 'replayed qr_data_url is present', + ); + db.prepare("UPDATE orders SET status = 'cancelled' WHERE id = ?").run(idemOrder1.body.order.id); + + // Create valid takeaway order with dispatch_policy: 'on_payment' + const initialBurgerStock = productStock(db, 'prod-burger'); + kotPrintCalls = []; + + const orderRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + customer_id: 'cust-mc-1', + items: [{ product_id: 'prod-burger', quantity: 2 }], + }, + }); + assertEqualOrThrow(orderRes.status, 201, 'takeaway on_payment order created 201'); + const order1 = orderRes.body.order; + + assertEqualOrThrow(order1.dispatch_policy, 'on_payment', 'dispatch_policy is on_payment'); + assertEqualOrThrow( + order1.checkout_stage, + 'awaiting_payment', + 'checkout_stage is awaiting_payment', + ); + assertEqualOrThrow( + order1.release_status, + 'scheduled', + 'release_status is scheduled (unreleased)', + ); + assertEqualOrThrow( + orderRes.body.qr_payload, + `FLOCAFE-TICKET:${order1.order_number}`, + 'qr_payload carries FLOCAFE-TICKET prefix and order number', + ); + assertOrThrow(typeof orderRes.body.qr_data_url === 'string', 'qr_data_url is provided'); + assertOrThrow( + orderRes.body.qr_data_url.startsWith('data:image/png;base64,'), + 'qr_data_url is PNG data url', + ); + + // Verify stock was NOT deducted + assertEqualOrThrow( + productStock(db, 'prod-burger'), + initialBurgerStock, + 'stock deferred on creation', + ); + // Verify KOT print did NOT happen + assertEqualOrThrow(kotPrintCalls.length, 0, 'kot print deferred on creation'); + + // Verify real bill created in database + const billRow = db + .prepare('SELECT * FROM bills WHERE order_id = ? AND payment_status = ?') + .get(order1.id, 'unpaid') as any; + assertOrThrow(!!billRow, 'real unpaid bill created in bills table'); + assertEqualOrThrow(Number(billRow.total), 2400, 'bill total is 2400'); + assertEqualOrThrow(Number(billRow.balance), 2400, 'bill balance is 2400'); + + // ------------------------------------------------------------------------- + // 4. Queue and Ticket Lookup + // ------------------------------------------------------------------------- + console.log('\n--- 4. Queue and Ticket Lookup ---'); + + // Create a dine-in on_payment order too + const dineInRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'dine_in', + table_id: 'tbl-mc-1', + customer_id: 'cust-mc-2', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-fries', quantity: 1 }], + }, + }); + assertEqualOrThrow(dineInRes.status, 201, 'dine_in on_payment order created 201'); + const orderDineIn = dineInRes.body.order; + + // Table must be occupied + const tblRow = db.prepare('SELECT status FROM tables WHERE id = ?').get('tbl-mc-1') as any; + assertEqualOrThrow(tblRow.status, 'occupied', 'dine_in table is occupied'); + + // Query awaiting payment queue + const queueRes = await req({ + path: '/api/orders/awaiting-payment', + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(queueRes.status, 200, 'fetch awaiting-payment queue 200'); + assertEqualOrThrow(queueRes.body?.awaiting_count, 2, 'awaiting_count is 2'); + assertEqualOrThrow(queueRes.body?.total_count, 2, 'total_count is 2'); + assertEqualOrThrow(queueRes.body?.orders?.length, 2, 'queue returns 2 orders'); + + // Pagination test (limit=1) + const page1Res = await req({ + path: '/api/orders/awaiting-payment?limit=1', + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(page1Res.body?.orders?.length, 1, 'page 1 returns 1 order'); + assertOrThrow(!!page1Res.body?.next_cursor, 'next_cursor returned for page 1'); + + const page2Res = await req({ + path: `/api/orders/awaiting-payment?limit=1&cursor=${page1Res.body.next_cursor}`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(page2Res.body?.orders?.length, 1, 'page 2 returns 1 order'); + assertOrThrow( + page1Res.body.orders[0].id !== page2Res.body.orders[0].id, + 'pages return distinct orders', + ); + + // Search by customer name in queue + const searchRes = await req({ + path: '/api/orders/awaiting-payment?search=Alex', + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(searchRes.body?.orders?.length, 1, 'search matches Alex'); + assertEqualOrThrow(searchRes.body.orders[0].id, order1.id, 'matches order1'); + + // Ticket lookup: by raw order number + const lookupRaw = await req({ + path: `/api/orders/by-ticket/${order1.order_number}`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(lookupRaw.status, 200, 'lookup by raw order number 200'); + assertEqualOrThrow(lookupRaw.body?.order?.id, order1.id, 'order matches'); + assertOrThrow(!!lookupRaw.body?.bill, 'bill hydrated in lookup'); + + // Ticket lookup: by full QR payload + const lookupQr = await req({ + path: `/api/orders/by-ticket/${encodeURIComponent(`FLOCAFE-TICKET:${order1.order_number}`)}`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(lookupQr.status, 200, 'lookup by full QR payload 200'); + assertEqualOrThrow(lookupQr.body?.order?.id, order1.id, 'order matches by QR payload'); + + // Ticket lookup: non-existent + const lookup404 = await req({ + path: '/api/orders/by-ticket/NON-EXISTENT-TICKET-9999', + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(lookup404.status, 404, 'lookup non-existent ticket 404'); + + db.prepare("UPDATE orders SET status = 'cancelled' WHERE id = ?").run(order1.id); + const lookupCancelled = await req({ + path: `/api/orders/by-ticket/${order1.order_number}`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(lookupCancelled.status, 404, 'lookup cancelled ticket 404'); + db.prepare("UPDATE orders SET status = 'pending' WHERE id = ?").run(order1.id); + + // Item 8: Non-multi-counter blocked scheduled order does NOT appear in awaiting-payment queue + const futureTimeImmediate = futureWallClockInZone('America/New_York', 15); + adjustStock(db, 'prod-limited', -productStock(db, 'prod-limited'), 'zero stock', owner.userId); + const nonMcBlockedOrder = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'immediate', + scheduled_for: futureTimeImmediate, + items: [{ product_id: 'prod-limited', quantity: 1 }], + }, + }); + assertEqualOrThrow(nonMcBlockedOrder.status, 201, 'non-MC scheduled order created'); + await req({ + path: `/api/orders/${nonMcBlockedOrder.body.order.id}/release`, + method: 'POST', + headers: manager.authHeader, + }); + const nonMcOrderBlockedRow = db + .prepare('SELECT release_status, dispatch_policy FROM orders WHERE id = ?') + .get(nonMcBlockedOrder.body.order.id) as any; + assertEqualOrThrow(nonMcOrderBlockedRow.release_status, 'blocked', 'non-MC order is blocked'); + assertEqualOrThrow( + nonMcOrderBlockedRow.dispatch_policy, + 'immediate', + 'dispatch_policy is immediate', + ); + + const queueCheckNonMc = await req({ + path: '/api/orders/awaiting-payment', + method: 'GET', + headers: cashier.authHeader, + }); + const foundNonMcInQueue = queueCheckNonMc.body?.orders?.find( + (o: any) => o.id === nonMcBlockedOrder.body.order.id, + ); + assertEqualOrThrow( + foundNonMcInQueue, + undefined, + 'non-MC blocked order NOT in awaiting-payment queue', + ); + db.prepare("UPDATE orders SET status = 'cancelled' WHERE id = ?").run( + nonMcBlockedOrder.body.order.id, + ); + adjustStock(db, 'prod-limited', 1, 'restore stock', owner.userId); + + // Item 10: Read-only ticket lookup never creates a bill + const orderWithoutBillRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + const testOrderNoBill = orderWithoutBillRes.body.order; + db.prepare('DELETE FROM bills WHERE order_id = ?').run(testOrderNoBill.id); + const billsBeforeLookup = ( + db + .prepare('SELECT COUNT(*) as n FROM bills WHERE order_id = ?') + .get(testOrderNoBill.id) as any + ).n; + assertEqualOrThrow(billsBeforeLookup, 0, 'zero bills before read-only lookup'); + + const readOnlyLookupRes = await req({ + path: `/api/orders/by-ticket/${testOrderNoBill.order_number}`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(readOnlyLookupRes.status, 200, 'lookup succeeds without bill'); + assertEqualOrThrow(readOnlyLookupRes.body?.bill, null, 'bill is null when not generated'); + const billsAfterLookup = ( + db + .prepare('SELECT COUNT(*) as n FROM bills WHERE order_id = ?') + .get(testOrderNoBill.id) as any + ).n; + assertEqualOrThrow( + billsAfterLookup, + 0, + 'zero bills after read-only lookup (never created in GET)', + ); + db.prepare("UPDATE orders SET status = 'cancelled' WHERE id = ?").run(testOrderNoBill.id); + + // Item 14: Ticket lookup by order_number vs id + const lookupByOrderNum = await req({ + path: `/api/orders/by-ticket/${order1.order_number}`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(lookupByOrderNum.status, 200, 'lookup strictly by order_number 200'); + assertEqualOrThrow( + lookupByOrderNum.body?.order?.order_number, + order1.order_number, + 'matches order_number', + ); + + const lookupWithById = await req({ + path: `/api/orders/by-ticket/${order1.id}?by=id`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow( + lookupWithById.status, + 200, + 'lookup with ?by=id matches order by internal id', + ); + assertEqualOrThrow(lookupWithById.body?.order?.id, order1.id, 'matched order id'); + + // ------------------------------------------------------------------------- + // 5. Prepayment Release Gates & Prepayment Lifecycle + // ------------------------------------------------------------------------- + console.log('\n--- 5. Release Gates & Prepayment Lifecycle ---'); + + // Manual release of unpaid order must fail with 409 ORDER_NOT_PAID + const unpaidReleaseAttempt = await req({ + path: `/api/orders/${order1.id}/release`, + method: 'POST', + headers: serverUser.authHeader, + }); + assertEqualOrThrow( + unpaidReleaseAttempt.status, + 409, + 'manual release before payment rejected 409', + ); + assertEqualOrThrow(unpaidReleaseAttempt.body?.code, 'ORDER_NOT_PAID', 'code is ORDER_NOT_PAID'); + + // Partial payment: pay $10 of $24 (1000 cents of 2400) + const partialPayRes = await req({ + path: `/api/bills/${billRow.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { + method: 'cash', + amount: 1000, + }, + }); + assertEqualOrThrow(partialPayRes.status, 200, 'partial payment 200'); + assertEqualOrThrow(partialPayRes.body?.bill?.payment_status, 'partial', 'bill is partial'); + assertEqualOrThrow(Number(partialPayRes.body?.bill?.balance), 1400, 'bill balance is 1400'); + + // Verify order still awaiting payment and scheduled + const order1AfterPartial = db + .prepare('SELECT * FROM orders WHERE id = ?') + .get(order1.id) as any; + assertEqualOrThrow( + order1AfterPartial.checkout_stage, + 'awaiting_payment', + 'stage still awaiting_payment', + ); + assertEqualOrThrow( + order1AfterPartial.release_status, + 'scheduled', + 'release_status still scheduled', + ); + assertEqualOrThrow( + productStock(db, 'prod-burger'), + initialBurgerStock, + 'stock still untouched', + ); + + // Prepayment edit lock: cannot add items or discounts once payment is recorded + const editItemAttempt = await req({ + path: `/api/orders/${order1.id}/items`, + method: 'POST', + headers: serverUser.authHeader, + body: { + items: [{ product_id: 'prod-fries', quantity: 1 }], + }, + }); + assertEqualOrThrow(editItemAttempt.status, 409, 'item edit rejected 409 once payment recorded'); + assertEqualOrThrow( + editItemAttempt.body?.code, + 'SCHEDULED_ORDER_PAID', + 'code is SCHEDULED_ORDER_PAID', + ); + + const editDiscountAttempt = await req({ + path: `/api/orders/${order1.id}/discount`, + method: 'PATCH', + headers: manager.authHeader, + body: { + discount_type: 'flat', + discount_value: 200, + discount_reason: 'Friend discount', + }, + }); + assertEqualOrThrow( + editDiscountAttempt.status, + 409, + 'order discount rejected 409 once payment recorded', + ); + + // Final payment: pay remaining $14 (1400 cents) + kotPrintCalls = []; + const finalPayRes = await req({ + path: `/api/bills/${billRow.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { + method: 'cash', + amount: 1400, + }, + }); + assertEqualOrThrow(finalPayRes.status, 200, 'final payment 200'); + assertEqualOrThrow(finalPayRes.body?.bill?.payment_status, 'paid', 'bill is paid'); + assertEqualOrThrow(Number(finalPayRes.body?.bill?.balance), 0, 'balance is 0'); + + // Verify order transitions + const order1AfterFinal = db.prepare('SELECT * FROM orders WHERE id = ?').get(order1.id) as any; + assertEqualOrThrow( + order1AfterFinal.checkout_stage, + 'paid', + 'checkout_stage transitioned to paid', + ); + assertEqualOrThrow(order1AfterFinal.release_status, 'released', 'order released to kitchen!'); + assertEqualOrThrow( + order1AfterFinal.status, + 'pending', + 'kitchen status remains pending, NOT completed!', + ); + + const lookupPaid = await req({ + path: `/api/orders/by-ticket/${order1.order_number}`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(lookupPaid.status, 404, 'lookup already paid ticket 404'); + + // Verify stock deducted + assertEqualOrThrow( + productStock(db, 'prod-burger'), + initialBurgerStock - 2, + 'stock deducted on release', + ); + assertLedgerReconciles(db, 'after order1 release'); + + // Verify dine-in prepayment invariant: table stays occupied on payment! + const dineInBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(orderDineIn.id) as any; + + const dineInPayRes = await req({ + path: `/api/bills/${dineInBill.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { + method: 'card', + amount: Number(dineInBill.total), + }, + }); + assertEqualOrThrow(dineInPayRes.status, 200, 'dine in payment 200'); + + const tblRowAfterPay = db + .prepare('SELECT status FROM tables WHERE id = ?') + .get('tbl-mc-1') as any; + assertEqualOrThrow( + tblRowAfterPay.status, + 'occupied', + 'dine-in table remains OCCUPIED after prepayment!', + ); + + const orderDineInAfterPay = db + .prepare('SELECT * FROM orders WHERE id = ?') + .get(orderDineIn.id) as any; + assertEqualOrThrow( + orderDineInAfterPay.checkout_stage, + 'paid', + 'dine in checkout stage is paid', + ); + assertEqualOrThrow(orderDineInAfterPay.status, 'pending', 'dine in status remains pending'); + + const splitDineInRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'dine_in', + table_id: 'tbl-mc-1', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-burger', quantity: 2 }], + }, + }); + assertEqualOrThrow(splitDineInRes.status, 201, 'split dine-in order created'); + const splitOrder = splitDineInRes.body.order; + const splitItem1 = splitOrder.items[0]; + + const initialSplitBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(splitOrder.id) as any; + + const splitRes = await req({ + path: `/api/bills/${initialSplitBill.id}/split-check`, + method: 'POST', + headers: cashier.authHeader, + body: { + checks: [ + { label: 'Seat 1', items: [{ order_item_id: splitItem1.id, quantity: 1 }] }, + { label: 'Seat 2', items: [{ order_item_id: splitItem1.id, quantity: 1 }] }, + ], + }, + }); + assertEqualOrThrow(splitRes.status, 201, 'dine-in check split 201'); + const [splitBill1, splitBill2] = splitRes.body.bills; + + const payBill1Res = await req({ + path: `/api/bills/${splitBill1.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { + method: 'cash', + amount: Number(splitBill1.total), + }, + }); + assertEqualOrThrow(payBill1Res.status, 200, 'first split bill paid'); + + const splitOrderAfterBill1 = db + .prepare('SELECT checkout_stage FROM orders WHERE id = ?') + .get(splitOrder.id) as any; + assertEqualOrThrow( + splitOrderAfterBill1.checkout_stage, + 'awaiting_payment', + 'stage remains awaiting_payment', + ); + + const queueAfterSplitBill1 = await req({ + path: '/api/orders/awaiting-payment', + method: 'GET', + headers: cashier.authHeader, + }); + const splitQueueRow = queueAfterSplitBill1.body?.orders?.find( + (o: any) => o.id === splitOrder.id, + ); + assertOrThrow(!!splitQueueRow, 'order present in awaiting-payment queue'); + assertEqualOrThrow(splitQueueRow.bill_id, splitBill2.id, 'queue row joins unpaid splitBill2'); + assertEqualOrThrow( + Number(splitQueueRow.balance), + Number(splitBill2.balance), + 'queue row shows splitBill2 balance', + ); + + const lookupSplitTicket = await req({ + path: `/api/orders/by-ticket/${splitOrder.order_number}`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow( + lookupSplitTicket.status, + 200, + 'ticket lookup succeeds while second check is unpaid', + ); + assertEqualOrThrow( + lookupSplitTicket.body?.bill?.id, + splitBill2.id, + 'primaryBill is splitBill2', + ); + + const payBill2Res = await req({ + path: `/api/bills/${splitBill2.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { + method: 'cash', + amount: Number(splitBill2.total), + }, + }); + assertEqualOrThrow(payBill2Res.status, 200, 'second split bill paid'); + + const splitOrderAfterAllPaid = db + .prepare('SELECT checkout_stage FROM orders WHERE id = ?') + .get(splitOrder.id) as any; + assertEqualOrThrow( + splitOrderAfterAllPaid.checkout_stage, + 'paid', + 'stage transitions to paid when all checks settled', + ); + + const lookupSplitTicketAllPaid = await req({ + path: `/api/orders/by-ticket/${splitOrder.order_number}`, + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow( + lookupSplitTicketAllPaid.status, + 404, + 'ticket lookup 404 after all split checks paid', + ); + + // Item 9: Idempotent payment replay on POST /api/bills/:id/payments + const idemPayOrderRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + const idemPayOrder = idemPayOrderRes.body.order; + const idemPayBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(idemPayOrder.id) as any; + const idemPayKey = `idem-pay-${Date.now()}`; + + const idemPay1 = await req({ + path: `/api/bills/${idemPayBill.id}/payments`, + method: 'POST', + headers: { ...cashier.authHeader, 'Idempotency-Key': idemPayKey }, + body: { + payments: [{ method: 'cash', amount: Number(idemPayBill.total) }], + }, + }); + assertEqualOrThrow(idemPay1.status, 200, 'initial batch payment 200'); + assertEqualOrThrow(idemPay1.body?.bill?.payment_status, 'paid', 'bill is paid'); + + const idemPayReplay = await req({ + path: `/api/bills/${idemPayBill.id}/payments`, + method: 'POST', + headers: { ...cashier.authHeader, 'Idempotency-Key': idemPayKey }, + body: { + payments: [{ method: 'cash', amount: Number(idemPayBill.total) }], + }, + }); + assertEqualOrThrow(idemPayReplay.status, 200, 'replayed batch payment 200'); + assertEqualOrThrow( + idemPayReplay.body?.bill?.payment_status, + 'paid', + 'replayed bill is still paid', + ); + const billPaymentsRow = db + .prepare('SELECT payment_details FROM bills WHERE id = ?') + .get(idemPayBill.id) as any; + const recordedPayments = JSON.parse(billPaymentsRow.payment_details || '[]'); + assertEqualOrThrow( + recordedPayments.length, + 1, + 'exactly one payment recorded (no duplicate charge)', + ); + + // Item 9: Concurrent payment requests on same bill + const concurrentOrderRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + const concurrentOrder = concurrentOrderRes.body.order; + const concurrentBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(concurrentOrder.id) as any; + const initialConcurrentStock = productStock(db, 'prod-burger'); + + const [payAttempt1, payAttempt2] = await Promise.all([ + req({ + path: `/api/bills/${concurrentBill.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { method: 'cash', amount: Number(concurrentBill.total) }, + }), + req({ + path: `/api/bills/${concurrentBill.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { method: 'cash', amount: Number(concurrentBill.total) }, + }), + ]); + const successCount = [payAttempt1, payAttempt2].filter((r) => r.status === 200).length; + assertEqualOrThrow(successCount, 1, 'exactly one concurrent payment succeeds 200'); + const rejectedStatus = [payAttempt1, payAttempt2].find((r) => r.status !== 200)?.status; + assertEqualOrThrow(rejectedStatus, 400, 'concurrent payment race rejected with 400'); + assertEqualOrThrow( + productStock(db, 'prod-burger'), + initialConcurrentStock - 1, + 'single stock deduction on concurrent payment', + ); + + // Item 4: Refunded split sibling keeps checkout_stage='awaiting_payment' and prevents release + const refundSiblingOrderRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'dine_in', + dispatch_policy: 'on_payment', + table_id: 'tbl-mc-1', + items: [ + { product_id: 'prod-burger', quantity: 1 }, + { product_id: 'prod-fries', quantity: 1 }, + ], + }, + }); + const refundSiblingOrder = refundSiblingOrderRes.body.order; + const refundSourceBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(refundSiblingOrder.id) as any; + const refundItems = db + .prepare('SELECT * FROM order_items WHERE order_id = ?') + .all(refundSiblingOrder.id) as any[]; + + const refundSplitRes = await req({ + path: `/api/bills/${refundSourceBill.id}/split-check`, + method: 'POST', + headers: cashier.authHeader, + body: { + checks: [ + { label: 'Guest A', items: [{ order_item_id: refundItems[0].id, quantity: 1 }] }, + { label: 'Guest B', items: [{ order_item_id: refundItems[1].id, quantity: 1 }] }, + ], + }, + }); + assertEqualOrThrow(refundSplitRes.status, 201, 'split into 2 checks'); + const [rsBill1, rsBill2] = refundSplitRes.body.bills; + + await req({ + path: `/api/bills/${rsBill1.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { method: 'cash', amount: Number(rsBill1.total) }, + }); + await req({ + path: '/api/refunds', + method: 'POST', + headers: owner.authHeader, + body: { + bill_id: rsBill1.id, + amount: Number(rsBill1.total) / 100, + method: 'cash', + reason: 'Guest A left', + approver_id: owner.userId, + override_pin: '9999', + }, + }); + + await req({ + path: `/api/bills/${rsBill2.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { method: 'cash', amount: Number(rsBill2.total) }, + }); + + const refundSiblingOrderRow = db + .prepare('SELECT checkout_stage, release_status FROM orders WHERE id = ?') + .get(refundSiblingOrder.id) as any; + assertEqualOrThrow( + refundSiblingOrderRow.checkout_stage, + 'awaiting_payment', + 'checkout_stage stays awaiting_payment when sibling refunded', + ); + assertEqualOrThrow( + refundSiblingOrderRow.release_status, + 'scheduled', + 'order remains unreleased', + ); + db.prepare("UPDATE orders SET status = 'cancelled' WHERE id = ?").run(refundSiblingOrder.id); + + // ------------------------------------------------------------------------- + // 6. Scheduled + on_payment takeaway combination + // ------------------------------------------------------------------------- + console.log('\n--- 6. Scheduled Takeaway on_payment ---'); + + const futureTime = futureWallClockInZone('America/New_York', 45); + const schedOrderRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + scheduled_for: futureTime, + items: [{ product_id: 'prod-fries', quantity: 2 }], + }, + }); + assertEqualOrThrow(schedOrderRes.status, 201, 'scheduled on_payment takeaway created 201'); + const schedOrder = schedOrderRes.body.order; + + const schedBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(schedOrder.id) as any; + + // Pay scheduled order in full before scheduled_for + const schedPayRes = await req({ + path: `/api/bills/${schedBill.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { + method: 'cash', + amount: Number(schedBill.total), + }, + }); + assertEqualOrThrow(schedPayRes.status, 200, 'paid scheduled order 200'); + + // Order must be checkout_stage = 'paid', but release_status = 'scheduled' because scheduled_for > now + const schedOrderAfterPay = db + .prepare('SELECT * FROM orders WHERE id = ?') + .get(schedOrder.id) as any; + assertEqualOrThrow(schedOrderAfterPay.checkout_stage, 'paid', 'checkout stage is paid'); + assertEqualOrThrow( + schedOrderAfterPay.release_status, + 'scheduled', + 'release_status remains scheduled until scheduled instant or manual release', + ); + + // Explicit manual release after payment succeeds! + const manualReleaseRes = await req({ + path: `/api/orders/${schedOrder.id}/release`, + method: 'POST', + headers: cashier.authHeader, + }); + assertEqualOrThrow(manualReleaseRes.status, 200, 'manual release of paid scheduled order 200'); + assertEqualOrThrow( + manualReleaseRes.body?.order?.release_status, + 'released', + 'order is released', + ); + + // Item 15: Takeaway order completion when kds_enabled is false + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('kds_enabled', 'false', datetime('now'))", + ).run(); + + const noKdsOrderRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-burger', quantity: 1 }], + }, + }); + const noKdsOrder = noKdsOrderRes.body.order; + const noKdsBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(noKdsOrder.id) as any; + + await req({ + path: `/api/bills/${noKdsBill.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { method: 'cash', amount: Number(noKdsBill.total) }, + }); + + const noKdsOrderRow = db + .prepare('SELECT status, release_status, checkout_stage FROM orders WHERE id = ?') + .get(noKdsOrder.id) as any; + assertEqualOrThrow(noKdsOrderRow.checkout_stage, 'paid', 'no-kds order checkout_stage is paid'); + assertEqualOrThrow(noKdsOrderRow.release_status, 'released', 'no-kds order is released'); + assertEqualOrThrow( + noKdsOrderRow.status, + 'completed', + 'takeaway order completed at release when kds is disabled', + ); + + // Restore kds_enabled = 'true' + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('kds_enabled', 'true', datetime('now'))", + ).run(); + + // ------------------------------------------------------------------------- + // 7. Paid-but-blocked on Stock Shortfall & Retry Release + // ------------------------------------------------------------------------- + console.log('\n--- 7. Paid-but-blocked on Stock Shortfall ---'); + + // Create on_payment order for prod-limited (currently 1 unit in stock) + assertEqualOrThrow(productStock(db, 'prod-limited'), 1, 'prod-limited has 1 in stock'); + const limitedOrderRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-limited', quantity: 1 }], + }, + }); + assertEqualOrThrow(limitedOrderRes.status, 201, 'limited stock order created 201'); + const limitedOrder = limitedOrderRes.body.order; + + const limitedBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(limitedOrder.id) as any; + + // Simulate stock shortfall before payment by another event + adjustStock(db, 'prod-limited', -1, 'other customer bought last one', owner.userId); + assertEqualOrThrow(productStock(db, 'prod-limited'), 0, 'prod-limited is now 0'); + + // Settle payment in full + const limitedPayRes = await req({ + path: `/api/bills/${limitedBill.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { + method: 'cash', + amount: Number(limitedBill.total), + }, + }); + // Payment must succeed! Never roll back or reject committed payment + assertEqualOrThrow(limitedPayRes.status, 200, 'payment succeeds despite stock shortfall'); + assertEqualOrThrow(limitedPayRes.body?.bill?.payment_status, 'paid', 'bill is marked paid'); + + // Order must transition to release_status = 'blocked' + const limitedOrderAfterPay = db + .prepare('SELECT * FROM orders WHERE id = ?') + .get(limitedOrder.id) as any; + assertEqualOrThrow(limitedOrderAfterPay.checkout_stage, 'paid', 'stage is paid'); + assertEqualOrThrow( + limitedOrderAfterPay.release_status, + 'blocked', + 'release_status is blocked (paid-but-blocked)', + ); + assertIncludesOrThrow( + limitedOrderAfterPay.release_blocked_reason, + 'Insufficient stock', + 'reason indicates insufficient stock', + ); + + // Blocked order is visible in awaiting-payment queue with blocked_count = 1 + const queueWithBlocked = await req({ + path: '/api/orders/awaiting-payment', + method: 'GET', + headers: cashier.authHeader, + }); + assertEqualOrThrow(queueWithBlocked.body?.blocked_count, 1, 'queue reports blocked_count = 1'); + const foundBlocked = queueWithBlocked.body?.orders?.find((o: any) => o.id === limitedOrder.id); + assertOrThrow(!!foundBlocked, 'blocked order found in queue'); + assertEqualOrThrow( + foundBlocked.release_status, + 'blocked', + 'queue order shows release_status blocked', + ); + + // Manual release attempt fails while stock is still 0 + const retryFail = await req({ + path: `/api/orders/${limitedOrder.id}/release`, + method: 'POST', + headers: manager.authHeader, + }); + assertEqualOrThrow(retryFail.status, 409, 'release retry fails while stock is 0'); + assertEqualOrThrow(retryFail.body?.code, 'RELEASE_BLOCKED', 'code is RELEASE_BLOCKED'); + + // Restock 5 units + adjustStock(db, 'prod-limited', 5, 'restocked batch', owner.userId); + assertEqualOrThrow(productStock(db, 'prod-limited'), 5, 'restocked to 5'); + + // Manual release retry succeeds! + const retrySuccess = await req({ + path: `/api/orders/${limitedOrder.id}/release`, + method: 'POST', + headers: manager.authHeader, + }); + assertEqualOrThrow(retrySuccess.status, 200, 'release retry succeeds after restock'); + assertEqualOrThrow(retrySuccess.body?.order?.release_status, 'released', 'order is released'); + assertEqualOrThrow( + productStock(db, 'prod-limited'), + 4, + '1 unit deducted on successful release', + ); + assertLedgerReconciles(db, 'after retry release'); + + adjustStock( + db, + 'prod-limited', + -productStock(db, 'prod-limited'), + 'clear stock for refund test', + owner.userId, + ); + const refundOrderRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'takeaway', + dispatch_policy: 'on_payment', + items: [{ product_id: 'prod-limited', quantity: 1 }], + }, + }); + assertEqualOrThrow(refundOrderRes.status, 201, 'refund test order created 201'); + const refundOrder = refundOrderRes.body.order; + const refundBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(refundOrder.id) as any; + + const refundPayRes = await req({ + path: `/api/bills/${refundBill.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { + method: 'cash', + amount: Number(refundBill.total), + }, + }); + assertEqualOrThrow(refundPayRes.status, 200, 'pay for blocked order 200'); + + const refundRes = await req({ + path: '/api/refunds', + method: 'POST', + headers: owner.authHeader, + body: { + bill_id: refundBill.id, + amount: Number(refundBill.total) / 100, + method: 'cash', + reason: 'Out of stock customer refund', + approver_id: owner.userId, + override_pin: '9999', + }, + }); + assertEqualOrThrow(refundRes.status, 201, 'refund issued 201'); + + adjustStock(db, 'prod-limited', 5, 'restock for test', owner.userId); + + const releaseRefundedAttempt = await req({ + path: `/api/orders/${refundOrder.id}/release`, + method: 'POST', + headers: manager.authHeader, + }); + assertEqualOrThrow( + releaseRefundedAttempt.status, + 409, + 'release of refunded order rejected 409', + ); + assertEqualOrThrow( + releaseRefundedAttempt.body?.code, + 'ORDER_NOT_PAID', + 'code is ORDER_NOT_PAID', + ); + + // ------------------------------------------------------------------------- + // 8. Printer Route: Print Payment Ticket + // ------------------------------------------------------------------------- + console.log('\n--- 8. Printer Ticket Route ---'); + + // Test print-payment-ticket preview + const printPreviewRes = await req({ + path: '/api/printers/print-payment-ticket', + method: 'POST', + headers: serverUser.authHeader, + body: { + orderId: order1.id, + preview: true, + }, + }); + assertEqualOrThrow(printPreviewRes.status, 200, 'ticket preview 200'); + assertEqualOrThrow(printPreviewRes.body?.preview, true, 'preview is true'); + assertOrThrow(Array.isArray(printPreviewRes.body?.bytes), 'bytes array returned'); + assertOrThrow(printPreviewRes.body.bytes.length > 50, 'bytes length > 50'); + + // Missing orderId + const badPrintReq = await req({ + path: '/api/printers/print-payment-ticket', + method: 'POST', + headers: serverUser.authHeader, + body: {}, + }); + assertEqualOrThrow(badPrintReq.status, 400, 'missing orderId rejected 400'); + + // Item 11: WebUSB printer returns webusb: true and bytes + const webusbPrinterId = 'printer-webusb-test'; + db.prepare( + "INSERT OR REPLACE INTO printers (id, name, connection_type, paper_width, is_default) VALUES (?, 'WebUSB Thermal', 'webusb', '80mm', 0)", + ).run(webusbPrinterId); + + const webusbPrintRes = await req({ + path: '/api/printers/print-payment-ticket', + method: 'POST', + headers: serverUser.authHeader, + body: { + orderId: order1.id, + printerId: webusbPrinterId, + }, + }); + assertEqualOrThrow(webusbPrintRes.status, 200, 'webusb print ticket 200'); + assertEqualOrThrow(webusbPrintRes.body?.webusb, true, 'webusb is true'); + assertOrThrow(Array.isArray(webusbPrintRes.body?.bytes), 'webusb bytes array returned'); + + // Item 13, 16, 17: Split-bill balance, currency and timezone formatting on payment ticket + const splitBalanceOrderRes = await req({ + path: '/api/orders', + method: 'POST', + headers: serverUser.authHeader, + body: { + type: 'dine_in', + dispatch_policy: 'on_payment', + table_id: 'tbl-mc-1', + items: [ + { product_id: 'prod-burger', quantity: 2 }, + { product_id: 'prod-fries', quantity: 2 }, + ], + }, + }); + const splitBalanceOrder = splitBalanceOrderRes.body.order; + const splitBalanceSourceBill = db + .prepare('SELECT * FROM bills WHERE order_id = ?') + .get(splitBalanceOrder.id) as any; + const sbItems = db + .prepare('SELECT * FROM order_items WHERE order_id = ?') + .all(splitBalanceOrder.id) as any[]; + + const splitBalanceSplitRes = await req({ + path: `/api/bills/${splitBalanceSourceBill.id}/split-check`, + method: 'POST', + headers: cashier.authHeader, + body: { + checks: [ + { label: 'Check 1', items: [{ order_item_id: sbItems[0].id, quantity: 2 }] }, + { label: 'Check 2', items: [{ order_item_id: sbItems[1].id, quantity: 2 }] }, + ], + }, + }); + assertEqualOrThrow(splitBalanceSplitRes.status, 201, 'split into 2 checks for balance test'); + const [sbCheck1] = splitBalanceSplitRes.body.bills; + + // Pay Check 1 in full + await req({ + path: `/api/bills/${sbCheck1.id}/payment`, + method: 'POST', + headers: cashier.authHeader, + body: { method: 'cash', amount: Number(sbCheck1.total) }, + }); + + // Check preview print for order - balance due should equal Check 2's balance + const sbPrintPreview = await req({ + path: '/api/printers/print-payment-ticket', + method: 'POST', + headers: serverUser.authHeader, + body: { + orderId: splitBalanceOrder.id, + preview: true, + }, + }); + assertEqualOrThrow(sbPrintPreview.status, 200, 'split balance print preview 200'); + const previewBytes = Buffer.from(sbPrintPreview.body.bytes); + const previewText = previewBytes.toString('utf8'); + assertOrThrow(previewText.includes('Balance Due:'), 'print ticket includes Balance Due'); + assertOrThrow(previewText.includes('Date:'), 'print ticket includes localized Date'); + db.prepare("UPDATE orders SET status = 'cancelled' WHERE id = ?").run(splitBalanceOrder.id); + + // ------------------------------------------------------------------------- + // Summary + // ------------------------------------------------------------------------- + const results = getResults(); + console.log('\n' + '='.repeat(66)); + console.log(`Multi-counter ordering test results: Passed ${results.passed}/${results.total}`); + assertEqualOrThrow(results.failed, 0, 'zero failed assertions'); + } finally { + stopScheduledOrderSweep(); + await stopKdsServer(); + await new Promise((resolve) => server.close(() => resolve())); + closeDatabase(); + Module._load = originalLoad; + fs.rmSync(testDir, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error('Multi-counter ordering test failed:', err); + process.exit(1); +}); diff --git a/tests/upgrade-path.test.ts b/tests/upgrade-path.test.ts index d9b0f2f05..9befcc310 100644 --- a/tests/upgrade-path.test.ts +++ b/tests/upgrade-path.test.ts @@ -1284,6 +1284,97 @@ function main() { ); closeDatabase(); + // ── v124: multi-counter ordering columns and counters table ─────────────── + const multiCounterMigrationIndex = MIGRATIONS.findIndex( + (migration: any) => migration.name === 'multi_counter_ordering', + ); + assert.ok(multiCounterMigrationIndex > 0, 'the multi_counter_ordering migration is registered'); + assert.equal( + MIGRATIONS[multiCounterMigrationIndex].version, + 124, + 'the multi_counter_ordering migration is v124', + ); + const v123SnapDb = buildIdealSchemaDb(123); + try { + const preV124Tables = ( + v123SnapDb.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ + name: string; + }> + ).map((t) => t.name); + assert.ok(!preV124Tables.includes('counters'), 'v123 schema must not include counters table'); + v123SnapDb + .prepare( + `INSERT INTO orders (order_number, type, subtotal, tax_amount, total, status, created_at, updated_at) + VALUES ('ORD-PRE-V124', 'takeaway', 150, 0, 150, 'pending', '2026-10-29 08:30:00', '2026-10-29 08:30:00')`, + ) + .run(); + fs.writeFileSync(upgradeDbPath, v123SnapDb.serialize()); + } finally { + v123SnapDb.close(); + } + + initDatabase(); + const postMultiCounterDb = getDatabase(); + assert.equal(getCurrentSchemaVersion(), latestSchemaVersion); + const postV124OrderCols = ( + postMultiCounterDb.prepare('PRAGMA table_info(orders)').all() as Array<{ name: string }> + ).map((col) => col.name); + for (const colName of ['checkout_stage', 'dispatch_policy']) { + assert.ok( + postV124OrderCols.includes(colName), + `v124 adds orders.${colName} during upgrade from v123`, + ); + } + const counterCols = ( + postMultiCounterDb.prepare('PRAGMA table_info(counters)').all() as Array<{ name: string }> + ).map((col) => col.name); + for (const colName of [ + 'id', + 'name', + 'role', + 'is_active', + 'sort_order', + 'created_at', + 'updated_at', + ]) { + assert.ok(counterCols.includes(colName), `v124 adds counters.${colName}`); + } + const postV124Indexes = ( + postMultiCounterDb.prepare('PRAGMA index_list(orders)').all() as Array<{ name: string }> + ).map((idx) => idx.name); + assert.ok( + postV124Indexes.includes('idx_orders_checkout_queue'), + 'v124 creates idx_orders_checkout_queue index on orders(checkout_stage, release_status)', + ); + const migratedPreV124Order = postMultiCounterDb + .prepare( + `SELECT checkout_stage, dispatch_policy + FROM orders WHERE order_number = 'ORD-PRE-V124'`, + ) + .get() as + | { + checkout_stage: string; + dispatch_policy: string; + } + | undefined; + assert.deepEqual( + migratedPreV124Order, + { + checkout_stage: 'paid', + dispatch_policy: 'immediate', + }, + 'existing pre-v124 orders default to checkout_stage = paid, dispatch_policy = immediate', + ); + assert.equal( + runHealthCheck().findings.length, + 0, + 'runHealthCheck() reports zero drift after v124', + ); + console.log( + ' ✓ v124 adds checkout_stage and dispatch_policy to orders, counters table, and preserves existing orders as paid/immediate', + ); + closeDatabase(); + console.log('='.repeat(60)); console.log('✅ Upgrade-path regression test passed'); }