@teincfood/core 0.10.21 → 0.10.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -473,4 +473,28 @@ export const MIGRATIONS = [
473
473
  catch { }
474
474
  },
475
475
  },
476
+ {
477
+ version: 14,
478
+ up: async (db) => {
479
+ // Fresh local data for the DD-NNNN-MMM order-number cutover.
480
+ //
481
+ // Old local rows, queued commands and cached reference data can carry
482
+ // stale order numbers/statuses and would otherwise be replayed under
483
+ // the new sequence. Accounts will sign in again after this update, so a
484
+ // clean local slate is intentional: everything is re-hydrated from the
485
+ // server on the next login/sync.
486
+ //
487
+ // The per-business callout sequence lives in KV, not SQLite, and is
488
+ // deliberately left untouched so a reset can never reuse an existing
489
+ // server-side order number.
490
+ await db.execAsync(`
491
+ DELETE FROM sync_jobs;
492
+ DELETE FROM commands;
493
+ DELETE FROM events;
494
+ DELETE FROM entity_snapshots;
495
+ DELETE FROM orders;
496
+ DELETE FROM reference_data;
497
+ `);
498
+ },
499
+ },
476
500
  ];
@@ -384,7 +384,7 @@ export async function upsertServerOrder(db, order, opts = {}) {
384
384
  * replaying an older snapshot can never overwrite a newer local status.
385
385
  */
386
386
  export async function listOrdersWithHlc(db, businessId, limit = 200) {
387
- const rows = await db.getAllAsync(`SELECT * FROM orders WHERE business_id = ? AND deleted_at IS NULL ORDER BY inserted_at DESC LIMIT ?;`, businessId, limit);
387
+ const rows = await db.getAllAsync(`SELECT * FROM orders WHERE business_id = ? AND deleted_at IS NULL ORDER BY inserted_at DESC, rowid DESC LIMIT ?;`, businessId, limit);
388
388
  return rows.map((row) => ({
389
389
  ...orderRowToSellerOrder(row),
390
390
  hlc: row.hlc ?? row.updated_at ?? row.inserted_at,
@@ -433,7 +433,7 @@ export async function listOrders(db, businessId, filters, page = 1, pageSize = 2
433
433
  const countRow = await db.getFirstAsync(`SELECT COUNT(*) as c FROM orders WHERE ${whereSql};`, ...args);
434
434
  const total = countRow?.c ?? 0;
435
435
  const offset = (page - 1) * pageSize;
436
- const rows = await db.getAllAsync(`SELECT * FROM orders WHERE ${whereSql} ORDER BY inserted_at DESC LIMIT ? OFFSET ?;`, ...args, pageSize, offset);
436
+ const rows = await db.getAllAsync(`SELECT * FROM orders WHERE ${whereSql} ORDER BY inserted_at DESC, rowid DESC LIMIT ? OFFSET ?;`, ...args, pageSize, offset);
437
437
  return { data: rows.map(orderRowToSellerOrder), total };
438
438
  }
439
439
  export async function updateOrderStatus(db, businessId, orderId, toStatus, hlc) {
@@ -33,9 +33,10 @@ export declare function getBusinessOrderFlowMode(businessId: string, db?: SQLite
33
33
  /**
34
34
  * Status a POS/business-app order starts in.
35
35
  *
36
- * Cash sales in direct flow are complete on creation. Anything that still
37
- * needs money (mobile money / card) must stay `awaiting_payment` first — the
38
- * backend moves it to `accepted` (staged) or `delivered` (direct) once the
36
+ * Cash sales in direct flow are complete on creation, except delivery orders:
37
+ * those must always enter the rider pipeline. Anything that still needs money
38
+ * (mobile money / card) must stay `awaiting_payment` first — the backend moves
39
+ * it to `accepted` (staged/delivery) or `delivered` (direct walk-in) once the
39
40
  * payment lands.
40
41
  */
41
- export declare function initialPosOrderStatus(paymentMethod: string | null | undefined, mode: OrderFlowMode): "accepted" | "awaiting_payment" | "delivered";
42
+ export declare function initialPosOrderStatus(paymentMethod: string | null | undefined, mode: OrderFlowMode, orderType?: string | null | undefined): "accepted" | "awaiting_payment" | "delivered";
@@ -53,14 +53,17 @@ export async function getBusinessOrderFlowMode(businessId, db) {
53
53
  /**
54
54
  * Status a POS/business-app order starts in.
55
55
  *
56
- * Cash sales in direct flow are complete on creation. Anything that still
57
- * needs money (mobile money / card) must stay `awaiting_payment` first — the
58
- * backend moves it to `accepted` (staged) or `delivered` (direct) once the
56
+ * Cash sales in direct flow are complete on creation, except delivery orders:
57
+ * those must always enter the rider pipeline. Anything that still needs money
58
+ * (mobile money / card) must stay `awaiting_payment` first — the backend moves
59
+ * it to `accepted` (staged/delivery) or `delivered` (direct walk-in) once the
59
60
  * payment lands.
60
61
  */
61
- export function initialPosOrderStatus(paymentMethod, mode) {
62
+ export function initialPosOrderStatus(paymentMethod, mode, orderType = "takeout") {
62
63
  const method = paymentMethod ?? "cash";
63
64
  if (method !== "cash")
64
65
  return "awaiting_payment";
66
+ if (orderType === "delivery")
67
+ return "accepted";
65
68
  return mode === "direct" ? "delivered" : "accepted";
66
69
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Local Orders Service — SQLite is real DB (like backend Orders).
3
- * Device-partitioned by UUID PK + DD-RRR-CCC per business.
3
+ * Device-partitioned by UUID PK + DD-NNNN-MMM per business.
4
4
  * POS orders start in the business's configured flow (staged → accepted,
5
5
  * direct → delivered); online payment requires connectivity.
6
6
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Local Orders Service — SQLite is real DB (like backend Orders).
3
- * Device-partitioned by UUID PK + DD-RRR-CCC per business.
3
+ * Device-partitioned by UUID PK + DD-NNNN-MMM per business.
4
4
  * POS orders start in the business's configured flow (staged → accepted,
5
5
  * direct → delivered); online payment requires connectivity.
6
6
  */
@@ -80,6 +80,7 @@ export async function createLocalOrder(businessId, input) {
80
80
  // Offline validation mirror — backend Order.changeset rules, pre-commit.
81
81
  validateOrderCreateInput(input);
82
82
  const method = input.pos_payment_method ?? "cash";
83
+ const orderType = input.order_type ?? "takeout";
83
84
  // Business-configurable fulfilment flow (cached business profile, offline).
84
85
  // "direct" completes walk-in sales the moment they are rung up; "staged"
85
86
  // keeps accept → preparing → ready → completed.
@@ -144,8 +145,10 @@ export async function createLocalOrder(businessId, input) {
144
145
  id,
145
146
  order_number: orderNumber,
146
147
  callout_number: callout,
147
- status: isPos ? initialPosOrderStatus(method, flowMode) : "pending",
148
- order_type: input.order_type ?? "takeout",
148
+ status: isPos
149
+ ? initialPosOrderStatus(method, flowMode, orderType)
150
+ : "pending",
151
+ order_type: orderType,
149
152
  is_pos: isPos,
150
153
  customer_name: input.customer_name ?? null,
151
154
  customer_phone: input.customer_phone ?? null,
@@ -222,7 +225,10 @@ export async function transitionLocalOrder(businessId, orderId, action) {
222
225
  }
223
226
  else {
224
227
  const flowMode = await getBusinessOrderFlowMode(businessId, db);
225
- toStatus = flowMode === "direct" ? "delivered" : "accepted";
228
+ toStatus =
229
+ flowMode === "direct" && order.order_type !== "delivery"
230
+ ? "delivered"
231
+ : "accepted";
226
232
  }
227
233
  }
228
234
  await updateOrderPayment(db, businessId, orderId, { is_paid: true, paid_at: new Date().toISOString() });
@@ -43,6 +43,8 @@ export interface LocalNodeClient {
43
43
  contentType?: string | null;
44
44
  error?: string | null;
45
45
  }>;
46
+ /** Mirror locally cached image bytes to a connected node. */
47
+ putAsset?(businessId: string, url: string, base64: string): Promise<void>;
46
48
  publishEvent?(event: DomainEvent): Promise<void> | void;
47
49
  }
48
50
  export interface TransportAdapter {
@@ -24,6 +24,11 @@ declare class LocalNodeServiceCore {
24
24
  init(deps: LocalNodeDeps): void;
25
25
  private requestReferenceFromPeers;
26
26
  private requestAssetFromPeers;
27
+ /**
28
+ * Push locally cached image bytes to every connected node so terminals that
29
+ * cannot reach S3 (dark LAN) can fetch the same offline image.
30
+ */
31
+ putAsset(businessId: string, url: string, base64: string): Promise<boolean>;
27
32
  /**
28
33
  * Catch a newly connected peer up on orders created while the mesh was not
29
34
  * connected (e.g. a sale rung up before Local Node was enabled).
@@ -101,6 +101,30 @@ class LocalNodeServiceCore {
101
101
  }
102
102
  return null;
103
103
  }
104
+ /**
105
+ * Push locally cached image bytes to every connected node so terminals that
106
+ * cannot reach S3 (dark LAN) can fetch the same offline image.
107
+ */
108
+ async putAsset(businessId, url, base64) {
109
+ if (!businessId || !url || !base64)
110
+ return false;
111
+ let sent = false;
112
+ for (const peer of this.peers.values()) {
113
+ if (!peer.isConnected() || !peer.putAsset)
114
+ continue;
115
+ try {
116
+ await peer.putAsset(businessId, url, base64);
117
+ sent = true;
118
+ }
119
+ catch (error) {
120
+ syncLogger.debug("LocalNodeService", "asset put failed", {
121
+ url,
122
+ error: String(error),
123
+ });
124
+ }
125
+ }
126
+ return sent;
127
+ }
104
128
  async publishOrderCatchup(peerDeviceId) {
105
129
  if (!this.businessId)
106
130
  return;
@@ -181,7 +181,7 @@ export function calloutNumber(order) {
181
181
  const num = order.order_number || "";
182
182
  if (!num)
183
183
  return "----";
184
- // Strip non-digits first: local DD-RRR-CCC numbers end in "-CCC" and
184
+ // Strip non-digits first: local DD-NNNN-MMM numbers end in "-MMM" and
185
185
  // parseInt("-005") would give -5 (the old "-5" bug).
186
186
  const digits = num.slice(-4).replace(/\D/g, "");
187
187
  if (!digits)
@@ -7,7 +7,7 @@ import { generateEscpos, generateHtml } from "./receipt";
7
7
  import type { SellerOrder } from "../types/api/seller.api";
8
8
  declare function fetchReceipt(orderId: string, format?: "html" | "escpos"): Promise<string>;
9
9
  /**
10
- * Generate receipt locally without network — used for offline queued DD-RRR-CCC orders.
10
+ * Generate receipt locally without network — used for offline queued DD-NNNN-MMM orders.
11
11
  * Pass a base64 logo raster (see resolveLogoRaster) to include the thermal header logo.
12
12
  */
13
13
  export declare function generateLocalReceipt(order: SellerOrder, format?: "html" | "escpos", logoDataUri?: string | null, logoRasterB64?: string | null): string;
@@ -31,7 +31,7 @@ async function fetchReceipt(orderId, format = "html") {
31
31
  return resp.text();
32
32
  }
33
33
  /**
34
- * Generate receipt locally without network — used for offline queued DD-RRR-CCC orders.
34
+ * Generate receipt locally without network — used for offline queued DD-NNNN-MMM orders.
35
35
  * Pass a base64 logo raster (see resolveLogoRaster) to include the thermal header logo.
36
36
  */
37
37
  export function generateLocalReceipt(order, format = "escpos", logoDataUri, logoRasterB64) {
@@ -62,8 +62,8 @@ export async function printOrderReceipt(order, opts) {
62
62
  let html = null;
63
63
  if (isTcp) {
64
64
  try {
65
- // Prefer backend escpos when we have a real UUID (not DD-RRR-CCC)
66
- const isLocal = /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(order.order_number) || /^[0-9A-F-]{36}$/.test(order.id) === false && order.id === order.order_number;
65
+ // Prefer backend escpos when we have a real UUID (not a local order number)
66
+ const isLocal = /^[0-9]{2}-[0-9]{3,4}-[0-9]{3}$/.test(order.order_number) || /^[0-9A-F-]{36}$/.test(order.id) === false && order.id === order.order_number;
67
67
  if (!isLocal || order.id) {
68
68
  try {
69
69
  escpos = await fetchReceipt(order.id, "escpos");
@@ -28,7 +28,7 @@ export declare const orderRepository: {
28
28
  * Single create path: build an `order:create` command and route it through
29
29
  * the SyncEngine (cloud → LAN node → queue). Every path returns an
30
30
  * order-shaped payload: cloud/LAN execute the real create, queue returns
31
- * the optimistic order (same UUID + DD-RRR-CCC, replayed idempotently).
31
+ * the optimistic order (same UUID + DD-NNNN-MMM, replayed idempotently).
32
32
  * Direct `createLocalOrder` bypasses sync — never call it from UI flows.
33
33
  */
34
34
  createOrder(input: CreateOrderInput): Promise<SellerOrder>;
@@ -42,7 +42,7 @@ export const orderRepository = {
42
42
  * Single create path: build an `order:create` command and route it through
43
43
  * the SyncEngine (cloud → LAN node → queue). Every path returns an
44
44
  * order-shaped payload: cloud/LAN execute the real create, queue returns
45
- * the optimistic order (same UUID + DD-RRR-CCC, replayed idempotently).
45
+ * the optimistic order (same UUID + DD-NNNN-MMM, replayed idempotently).
46
46
  * Direct `createLocalOrder` bypasses sync — never call it from UI flows.
47
47
  */
48
48
  async createOrder(input) {
@@ -19,7 +19,7 @@ import { extractApiErrorCode } from "../utils/error";
19
19
  import { generateEventId } from "../utils/uuid";
20
20
  import { buildItemSnapshots, snapshotsToOrderLines } from "../domain/orders/snapshots";
21
21
  import { DEFAULT_ORDER_FLOW_MODE, getBusinessOrderFlowMode, initialPosOrderStatus, } from "../domain/orders/order-flow";
22
- import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
22
+ import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, parseOrderNumber, } from "./order-number";
23
23
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
24
24
  import { useAuthStore } from "../adapters/auth";
25
25
  import { canBusinessSync } from "./sync-guard";
@@ -121,6 +121,7 @@ function buildOptimisticOrder(command, payload, flowMode) {
121
121
  const currencyCode = payload.currency_code ?? "GHS";
122
122
  const now = new Date().toISOString();
123
123
  const method = payload.pos_payment_method ?? "cash";
124
+ const orderType = payload.order_type ?? "takeout";
124
125
  const isCash = method === "cash";
125
126
  const authUser = useAuthStore.getState().user;
126
127
  const teller = authUser
@@ -133,11 +134,15 @@ function buildOptimisticOrder(command, payload, flowMode) {
133
134
  }
134
135
  : null;
135
136
  const orderNumber = payload.order_number ?? generateOrderNumberForBusiness(command.business_id);
137
+ // Keep the optimistic callout in lock-step with the order number that was
138
+ // already generated by createLocalOrder. Calling the sequence peek here
139
+ // would point one order ahead.
140
+ const parsedCallout = parseOrderNumber(orderNumber)?.callout;
136
141
  return {
137
142
  id: command.entity_id ?? command.command_id,
138
143
  order_number: orderNumber,
139
- callout_number: generateLocalCalloutNumber(command.business_id),
140
- order_type: payload.order_type ?? "takeout",
144
+ callout_number: parsedCallout ? Number(parsedCallout) : generateLocalCalloutNumber(command.business_id),
145
+ order_type: orderType,
141
146
  is_pos: true,
142
147
  customer_name: payload.customer_name ?? null,
143
148
  customer_phone: payload.customer_phone ?? null,
@@ -146,7 +151,7 @@ function buildOptimisticOrder(command, payload, flowMode) {
146
151
  // Must match createLocalOrder: direct-flow cash sales are born delivered,
147
152
  // even when the command is parked in the offline outbox. Hardcoding
148
153
  // "accepted" made a completed sale show up on the preparing board.
149
- status: initialPosOrderStatus(method, flowMode),
154
+ status: initialPosOrderStatus(method, flowMode, orderType),
150
155
  delivery_id: null,
151
156
  delivery_status: null,
152
157
  is_paid: isCash,
@@ -189,7 +194,7 @@ function injectLocalOrderNumber(command) {
189
194
  const payload = command.payload;
190
195
  if (payload.order_number)
191
196
  return command;
192
- // 0.7.0: business-scoped DD-RRR-CCC; requires leased device_number
197
+ // Business-scoped DD-NNNN-MMM; requires a leased device_number
193
198
  const orderNumber = generateOrderNumberForBusiness(command.business_id);
194
199
  return {
195
200
  ...command,
@@ -437,7 +442,7 @@ class CommandQueueService {
437
442
  try {
438
443
  const payload = command.payload;
439
444
  const on = payload.order_number;
440
- const parsed = on ? on.match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/) : null;
445
+ const parsed = on ? on.match(/^([0-9]{2})-([0-9]{3,4})-([0-9]{3})$/) : null;
441
446
  if (parsed) {
442
447
  const deviceNumber = parsed[1];
443
448
  const { orderNumber: newOn } = generateOrderNumber(deviceNumber, command.business_id);
@@ -1,15 +1,22 @@
1
1
  /**
2
- * Universal order number: DD-RRR-CCC (10 chars)
2
+ * Universal order number: DD-NNNN-MMM (11 chars)
3
3
  * DD = device number 01-99 (leased per business)
4
- * RRR = random 100-999 per order (crypto)
5
- * CCC = callout 001-999 global monotonic per device, wraps to 001 (new RRR on wrap)
6
- * Example: "10-123-001"
4
+ * NNNN = block number 0001-9999, increments after MMM reaches 999
5
+ * MMM = order sequence 001-999 inside the current block
6
+ *
7
+ * Examples: "10-0001-001" ... "10-0001-999", "10-0002-001".
8
+ *
9
+ * The sequence is persisted per business (per device install), so it survives
10
+ * restarts. Legacy DD-RRR-CCC numbers (7-digit order part) are still accepted
11
+ * when reading old rows; only new numbers use the block format.
7
12
  */
13
+ /** Current (last generated) callout number, for receipt/display fallbacks. */
8
14
  export declare function peekCallout(businessId?: string | null): number;
9
15
  export declare function generateOrderNumber(deviceNumber: string, businessId?: string | null): {
10
16
  orderNumber: string;
11
17
  callout: number;
12
- random: number;
18
+ block: number;
19
+ sequence: number;
13
20
  };
14
21
  export declare function generateLocalOrderNumber(): string;
15
22
  export declare function generateOrderNumberForBusiness(businessId: string): string;
@@ -18,7 +25,7 @@ export declare function isLocalOrderNumber(orderNumber: string): boolean;
18
25
  export declare function isOfflineOrderNumber(orderNumber: string): boolean;
19
26
  export declare function parseOrderNumber(orderNumber: string): {
20
27
  deviceNumber: string;
21
- random: string;
28
+ block: string;
22
29
  callout: string;
23
30
  } | null;
24
31
  export declare function resetCalloutForTests(businessId?: string | null): void;
@@ -1,60 +1,67 @@
1
1
  /**
2
- * Universal order number: DD-RRR-CCC (10 chars)
2
+ * Universal order number: DD-NNNN-MMM (11 chars)
3
3
  * DD = device number 01-99 (leased per business)
4
- * RRR = random 100-999 per order (crypto)
5
- * CCC = callout 001-999 global monotonic per device, wraps to 001 (new RRR on wrap)
6
- * Example: "10-123-001"
4
+ * NNNN = block number 0001-9999, increments after MMM reaches 999
5
+ * MMM = order sequence 001-999 inside the current block
6
+ *
7
+ * Examples: "10-0001-001" ... "10-0001-999", "10-0002-001".
8
+ *
9
+ * The sequence is persisted per business (per device install), so it survives
10
+ * restarts. Legacy DD-RRR-CCC numbers (7-digit order part) are still accepted
11
+ * when reading old rows; only new numbers use the block format.
7
12
  */
8
13
  import { getItemSync as getItem, setItemSync as setItem } from "../adapters/kv";
9
14
  const CALLOUT_SEQ_KEY = "teinc_callout_seq";
10
- const ORDER_RANDOM_KEY = "teinc_last_random"; // last RRR for wrap logic debug
11
- function cryptoRandom100to999() {
12
- if (typeof crypto !== "undefined" && crypto.getRandomValues) {
13
- const arr = new Uint32Array(1);
14
- crypto.getRandomValues(arr);
15
- return 100 + (arr[0] % 900);
16
- }
17
- return 100 + Math.floor(Math.random() * 900);
18
- }
15
+ const ORDERS_PER_BLOCK = 999;
16
+ const MAX_BLOCKS = 9999;
17
+ const MAX_SEQUENCE = ORDERS_PER_BLOCK * MAX_BLOCKS;
19
18
  function calloutKeyForBusiness(businessId) {
20
19
  return businessId ? `${CALLOUT_SEQ_KEY}:${businessId}` : CALLOUT_SEQ_KEY;
21
20
  }
22
- function getNextCallout(businessId) {
21
+ /** Last consumed sequence number for this business (0 when brand new). */
22
+ function getStoredSequence(businessId) {
23
+ const key = calloutKeyForBusiness(businessId);
24
+ // Per-business isolation matters: the legacy global key is a mirror only,
25
+ // never the starting point for another business (that would leak a
26
+ // sequence across tenants on the same install).
27
+ return Number(getItem(key) ?? 0) || 0;
28
+ }
29
+ function sequenceParts(sequence) {
30
+ const zero = Math.max(0, sequence - 1);
31
+ return {
32
+ block: Math.floor(zero / ORDERS_PER_BLOCK) + 1,
33
+ callout: (zero % ORDERS_PER_BLOCK) + 1,
34
+ };
35
+ }
36
+ function getNextSequence(businessId) {
23
37
  const key = calloutKeyForBusiness(businessId);
24
- const stored = getItem(key);
25
- let next = (stored ?? 0) + 1;
26
- if (next > 999)
27
- next = 1;
38
+ const next = getStoredSequence(businessId) + 1;
39
+ if (next > MAX_SEQUENCE) {
40
+ throw new Error(`Order number sequence exhausted for business ${businessId ?? "global"}`);
41
+ }
28
42
  setItem(key, next);
29
- // migrate legacy global once: keep global in sync for backward compat
43
+ // Keep the legacy global key in sync for older readers, but never let a
44
+ // different business reset it downwards.
30
45
  if (businessId) {
31
- const legacy = getItem(CALLOUT_SEQ_KEY) ?? 0;
46
+ const legacy = Number(getItem(CALLOUT_SEQ_KEY) ?? 0) || 0;
32
47
  if (legacy < next)
33
48
  setItem(CALLOUT_SEQ_KEY, next);
34
49
  }
35
50
  return next;
36
51
  }
52
+ /** Current (last generated) callout number, for receipt/display fallbacks. */
37
53
  export function peekCallout(businessId) {
38
- const key = calloutKeyForBusiness(businessId);
39
- const v = getItem(key);
40
- if (v != null)
41
- return v;
42
- const legacy = getItem(CALLOUT_SEQ_KEY);
43
- return legacy ?? 0;
54
+ const stored = getStoredSequence(businessId);
55
+ return stored > 0 ? sequenceParts(stored).callout : 0;
44
56
  }
45
57
  export function generateOrderNumber(deviceNumber, businessId) {
46
58
  if (!/^(0[1-9]|[1-9][0-9])$/.test(deviceNumber)) {
47
59
  throw new Error(`Invalid deviceNumber ${deviceNumber} — must be 01-99`);
48
60
  }
49
- const callout = getNextCallout(businessId);
50
- let random = cryptoRandom100to999();
51
- // On wrap to 001, ensure new random (already new per order, but keep hook)
52
- if (callout === 1) {
53
- random = cryptoRandom100to999();
54
- }
55
- setItem(ORDER_RANDOM_KEY, random);
56
- const orderNumber = `${deviceNumber}-${String(random).padStart(3, "0")}-${String(callout).padStart(3, "0")}`;
57
- return { orderNumber, callout, random };
61
+ const sequence = getNextSequence(businessId);
62
+ const { block, callout } = sequenceParts(sequence);
63
+ const orderNumber = `${deviceNumber}-${String(block).padStart(4, "0")}-${String(callout).padStart(3, "0")}`;
64
+ return { orderNumber, callout, block, sequence };
58
65
  }
59
66
  // No legacy fallback — 0.7.0 requires business-scoped device lease via device-lease.service.
60
67
  // Kept for internal compat only; callers must supply businessId via generateOrderNumberForBusiness.
@@ -70,22 +77,28 @@ export function generateOrderNumberForBusiness(businessId) {
70
77
  return generateOrderNumber(v, businessId).orderNumber;
71
78
  }
72
79
  export function generateLocalCalloutNumber(businessId) {
73
- // kept for SellerOrder.callout_number compatibility — returns next CCC
74
- const next = peekCallout(businessId) + 1;
75
- return next > 999 ? 1 : next;
80
+ // Kept for SellerOrder.callout_number compatibility — returns the callout
81
+ // that the next generated order will use, without consuming it.
82
+ const sequence = getStoredSequence(businessId) + 1;
83
+ return sequenceParts(sequence).callout;
76
84
  }
77
- // New canonical check — unified for DD-RRR-CCC
85
+ // Accept both the new DD-NNNN-MMM format and legacy DD-RRR-CCC rows.
78
86
  export function isLocalOrderNumber(orderNumber) {
79
- return /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(orderNumber);
87
+ return /^[0-9]{2}-[0-9]{3,4}-[0-9]{3}$/.test(orderNumber);
80
88
  }
81
89
  export function isOfflineOrderNumber(orderNumber) {
82
90
  return isLocalOrderNumber(orderNumber);
83
91
  }
84
92
  export function parseOrderNumber(orderNumber) {
85
- const m = orderNumber.match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/);
93
+ const m = orderNumber.match(/^([0-9]{2})-([0-9]{3,4})-([0-9]{3})$/);
86
94
  if (!m)
87
95
  return null;
88
- return { deviceNumber: m[1], random: m[2], callout: m[3] };
96
+ return {
97
+ deviceNumber: m[1],
98
+ // Normalise legacy 3-digit middles to the new 4-digit block shape.
99
+ block: m[2].padStart(4, "0"),
100
+ callout: m[3],
101
+ };
89
102
  }
90
103
  // Test helpers
91
104
  export function resetCalloutForTests(businessId) {
@@ -27,16 +27,6 @@ export declare function registerPendingOrdersInvalidator(fn: () => void): () =>
27
27
  * pipeline, so peers' UIs converge without waiting for a cloud echo.
28
28
  */
29
29
  export declare function notifyPendingOrdersChanged(): void;
30
- /**
31
- * Merge optimistic (queued, unconfirmed) state into a page of listed orders.
32
- * Called by list queryFns (page 1 only) so offline work is visible:
33
- * 1. Pending creations (order.create_queued, local or LAN) missing from the
34
- * SQLite page are prepended — deduped by id/order_number.
35
- * 2. Queued transitions only patch the snapshot read model, never the
36
- * orders table — overlay snapshot statuses onto base rows so the list
37
- * reflects the queued status immediately (converges on replay).
38
- * Reactivity comes from the invalidator: every apply path above refetches.
39
- */
40
30
  export declare function mergePendingIntoList(base: SellerOrder[], businessId: string, page: number): Promise<SellerOrder[]>;
41
31
  /**
42
32
  * Load persisted unsynced order:create_queued events into the store.
@@ -70,6 +70,9 @@ export function notifyPendingOrdersChanged() {
70
70
  * reflects the queued status immediately (converges on replay).
71
71
  * Reactivity comes from the invalidator: every apply path above refetches.
72
72
  */
73
+ function sortByCreatedDesc(orders) {
74
+ return [...orders].sort((a, b) => String(b.inserted_at ?? "").localeCompare(String(a.inserted_at ?? "")));
75
+ }
73
76
  export async function mergePendingIntoList(base, businessId, page) {
74
77
  if (page !== 1)
75
78
  return base;
@@ -88,7 +91,7 @@ export async function mergePendingIntoList(base, businessId, page) {
88
91
  const db = await openSyncDatabase();
89
92
  const snaps = await getEntitySnapshotsByType(db, "seller_orders", businessId);
90
93
  if (!snaps.length)
91
- return merged;
94
+ return sortByCreatedDesc(merged);
92
95
  const statusById = new Map();
93
96
  for (const s of snaps) {
94
97
  const arr = s.payload?.data ?? [];
@@ -98,7 +101,7 @@ export async function mergePendingIntoList(base, businessId, page) {
98
101
  }
99
102
  }
100
103
  if (!statusById.size)
101
- return merged;
104
+ return sortByCreatedDesc(merged);
102
105
  let patched = false;
103
106
  merged = merged.map((o) => {
104
107
  const want = statusById.get(String(o.id));
@@ -113,7 +116,7 @@ export async function mergePendingIntoList(base, businessId, page) {
113
116
  }
114
117
  }
115
118
  catch { }
116
- return merged;
119
+ return sortByCreatedDesc(merged);
117
120
  }
118
121
  /**
119
122
  * Load persisted unsynced order:create_queued events into the store.
@@ -25,7 +25,7 @@ export interface StartupReadiness {
25
25
  ready: boolean;
26
26
  /** Required keys with no cached payload. */
27
27
  missingKeys: ReferenceDataKey[];
28
- /** True when no DD-RRR-CCC device number is available (needs one online lease). */
28
+ /** True when no DD-NNNN-MMM device number is available (needs one online lease). */
29
29
  needsLease: boolean;
30
30
  /**
31
31
  * True when an entitlement envelope is absent or does not evaluate as
@@ -481,7 +481,7 @@ export async function drainOne(businessId, entityId) {
481
481
  const is409 = rest.status === 409 && rest.code === "ORDER_NUMBER_TAKEN";
482
482
  if (is409) {
483
483
  const on = (payload.order_number ?? payload.local_order_number);
484
- const parsed = on ? String(on).match(/^([0-9]{2})-([0-9]{3})-([0-9]{3})$/) : null;
484
+ const parsed = on ? String(on).match(/^([0-9]{2})-([0-9]{3,4})-([0-9]{3})$/) : null;
485
485
  if (parsed) {
486
486
  const newOn = generateOrderNumber(parsed[1], businessId).orderNumber;
487
487
  const newPayload = { ...payload, order_number: newOn, local_order_number: newOn };
@@ -25,7 +25,7 @@ function isLocalOrderEvent(event) {
25
25
  if (!payload)
26
26
  return false;
27
27
  const localOrderNumber = payload.local_order_number;
28
- return typeof localOrderNumber === "string" && /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(localOrderNumber);
28
+ return typeof localOrderNumber === "string" && /^[0-9]{2}-[0-9]{3,4}-[0-9]{3}$/.test(localOrderNumber);
29
29
  }
30
30
  const RECONCILE_DEBOUNCE_MS = 400;
31
31
  export function useSyncReconciliation() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.21",
3
+ "version": "0.10.23",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",