Files
meezi/web/dashboard/src/lib/pos/submit-order.ts
T
soroush.asadi 3b468b48d9
CI/CD / CI · API (dotnet build + test) (push) Successful in 48s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 53s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m12s
feat(dashboard/offline): generic idempotent outbox + ID remapping
Completes offline Phase 1 (frontend). Generalises the POS-orders-only queue into
a reusable write engine and fixes the two correctness bugs in the old path.

- offline-db: generic `outbox` store (DB v3, order_queue/kv preserved) with
  enqueue/list/update/remove + a persisted client→server id map.
- outbox.ts: drains in causal order — remaps local_* ids to server ids (blocking
  an op until its creator syncs), sends each op with its idempotency key, and
  classifies failures (offline → stop; 5xx / in-progress → retry; 4xx → poison
  after 5 attempts). remap/blocked logic validated against representative cases.
- client: apiPost/Put/Patch/Delete take an optional idempotencyKey →
  `Idempotency-Key` header; ApiClientError now carries HTTP status.
- submit-order: generates ONE idempotency key per submit, used for both the
  online attempt and the queued replay → server de-dups (no more double-create);
  offline create carries createsClientId so a later add-items remaps onto the
  real order instead of spawning a second order.
- use-offline-sync: drains the outbox, one-time migrates legacy order_queue
  items, invalidates queries after a successful sync.

tsc + production build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:19:29 +03:30

224 lines
7.6 KiB
TypeScript

import { apiPost } from "@/lib/api/client";
import type { Order, OrderItemLine } from "@/lib/api/types";
import type { CartItem } from "@/lib/stores/cart.store";
import { iranMobileForApi } from "@/lib/phone";
import { enqueueOutboxOp, getOutboxCount, getQueueCount } from "@/lib/offline/offline-db";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
export type SubmitOrderCart = {
getPendingLines: () => { menuItemId: string; quantity: number; notes?: string }[];
activeOrderId: string | null;
tableId: string | null;
guestName: string;
guestPhone: string;
customerId: string | null;
appliedCoupon: { id: string } | null;
};
export type SubmitOrderParams = {
cafeId: string;
orderBranchId: string | undefined;
cart: SubmitOrderCart;
reservationId: string | null;
/** Cart items (needed to build the offline mock order) */
cartItems?: CartItem[];
};
// ─── Helpers ────────────────────────────────────────────────────────────────
function isNetworkError(err: unknown): boolean {
if (err instanceof TypeError) {
const msg = err.message.toLowerCase();
return (
msg.includes("failed to fetch") ||
msg.includes("networkerror") ||
msg.includes("load failed") ||
msg.includes("network request failed")
);
}
// axios network errors surface as an Error with code ERR_NETWORK and no response.
const ax = err as { isAxiosError?: boolean; response?: unknown };
if (ax?.isAxiosError && !ax.response) return true;
return false;
}
function newLocalId(): string {
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/** A stable idempotency key used for BOTH the online attempt and any queued
* replay of the same submit, so the server de-duplicates them. */
function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
}
/** Body for a create-order POST. */
function buildCreateBody(
params: SubmitOrderParams,
pending: ReturnType<SubmitOrderCart["getPendingLines"]>
) {
const { cart, orderBranchId, reservationId } = params;
return {
orderType: "DineIn",
branchId: orderBranchId,
tableId: cart.tableId ?? undefined,
reservationId: reservationId ?? undefined,
guestName: cart.guestName.trim() || undefined,
guestPhone: iranMobileForApi(cart.guestPhone),
customerId: cart.customerId ?? undefined,
couponId: cart.appliedCoupon?.id,
items: pending,
};
}
/** Build a synthetic Order so the POS stays usable offline. Uses the supplied
* id so it matches the outbox op's createsClientId (enabling later remap). */
function buildLocalOrder(params: SubmitOrderParams, cartItems: CartItem[], orderId: string): Order {
const pending = params.cart.getPendingLines();
const items: OrderItemLine[] = pending.map((p) => {
const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId);
return {
id: newLocalId(),
menuItemId: p.menuItemId,
menuItemName: ci?.menuItem.name ?? p.menuItemId,
quantity: p.quantity,
unitPrice: ci?.menuItem.price ?? 0,
notes: p.notes,
isVoided: false,
};
});
const subtotal = items.reduce((s, i) => s + i.unitPrice * i.quantity, 0);
const taxTotal = Math.round(subtotal * 0.09);
const total = subtotal + taxTotal;
return {
id: orderId,
cafeId: params.cafeId,
branchId: params.orderBranchId,
tableId: params.cart.tableId ?? undefined,
guestName: params.cart.guestName.trim() || undefined,
guestPhone: iranMobileForApi(params.cart.guestPhone) ?? undefined,
customerId: params.cart.customerId ?? undefined,
orderType: "DineIn",
status: "Open",
subtotal,
taxTotal,
discountAmount: 0,
total,
paidAmount: 0,
createdAt: new Date().toISOString(),
displayNumber: 0,
items,
payments: [],
};
}
async function refreshQueueBadge(): Promise<void> {
const count = (await getOutboxCount()) + (await getQueueCount());
useSyncQueueStore.getState().setQueueCount(count);
}
/**
* Queue the write and return a local mock order. Two cases:
* - create: enqueue POST /orders with a fresh local id as createsClientId;
* - add items: enqueue POST /orders/{id}/items. {id} may be a local id — the
* outbox blocks then remaps it once the create syncs.
*/
async function queueAndBuildLocalOrder(
params: SubmitOrderParams,
cartItems: CartItem[],
idempotencyKey: string
): Promise<Order> {
const { cafeId, cart } = params;
const pending = cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending");
const activeId = cart.activeOrderId;
if (activeId) {
// Add items to an existing order (real server id, or a not-yet-synced local id).
await enqueueOutboxOp({
id: newLocalId(),
idempotencyKey,
method: "POST",
url: `/api/cafes/${cafeId}/orders/${activeId}/items`,
body: { items: pending },
entityType: "order_items",
createdAt: Date.now(),
});
await refreshQueueBadge();
return buildLocalOrder(params, cartItems, activeId);
}
// Create a brand-new order. createsClientId lets later add-items ops remap.
const localOrderId = newLocalId();
await enqueueOutboxOp({
id: newLocalId(),
idempotencyKey,
method: "POST",
url: `/api/cafes/${cafeId}/orders`,
body: buildCreateBody(params, pending),
entityType: "order",
createsClientId: localOrderId,
idField: "id",
createdAt: Date.now(),
});
await refreshQueueBadge();
return buildLocalOrder(params, cartItems, localOrderId);
}
// ─── Main export ──────────────────────────────────────────────────────────────
export async function submitOrderToApi({
cafeId,
orderBranchId,
cart,
reservationId,
cartItems = [],
}: SubmitOrderParams): Promise<Order> {
const params: SubmitOrderParams = { cafeId, orderBranchId, cart, reservationId, cartItems };
const pending = cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending");
const idempotencyKey = newIdempotencyKey();
const addingToLocalOrder = isLocalOrder(cart.activeOrderId);
// Fast path: online, and either a new order or adding to a real server order.
// (Adding to a still-local order must be queued so the outbox can remap its id.)
if (typeof navigator !== "undefined" && navigator.onLine && !addingToLocalOrder) {
try {
if (cart.activeOrderId) {
return await apiPost<Order>(
`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`,
{ items: pending },
{ idempotencyKey }
);
}
return await apiPost<Order>(
`/api/cafes/${cafeId}/orders`,
buildCreateBody(params, pending),
{ idempotencyKey }
);
} catch (err) {
// Only fall back to the offline queue on a genuine network failure; a real
// server/validation error must surface. The same idempotencyKey is reused
// so the server de-dups if the failed attempt actually reached it.
if (!isNetworkError(err)) throw err;
}
}
return queueAndBuildLocalOrder(params, cartItems, idempotencyKey);
}
export function orderAmountDue(order: Order): number {
return Math.max(0, order.total - (order.paidAmount ?? 0));
}
/** True when the order was created locally (offline) and not yet synced. */
export function isLocalOrder(orderId: string | null): boolean {
return !!orderId?.startsWith("local_");
}