@teincfood/core 0.10.20 → 0.10.22

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
  }
@@ -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() });
@@ -14,6 +14,11 @@ const ESC_ALIGN_LEFT = "\x1B\x61\x00";
14
14
  const ESC_ALIGN_CENTER = "\x1B\x61\x01";
15
15
  const ESC_BOLD_ON = "\x1B\x45\x01";
16
16
  const ESC_BOLD_OFF = "\x1B\x45\x00";
17
+ // Font A is the legible 12×24 thermal font on virtually every ESC/POS head.
18
+ const ESC_FONT_A = "\x1B\x4D\x00";
19
+ // Double-strike is independent of bold and does not change character width, so
20
+ // it deepens every glyph without breaking the 32-column layout.
21
+ const ESC_DOUBLE_STRIKE_ON = "\x1B\x47\x01";
17
22
  const GS_SIZE_NORMAL = "\x1D\x21\x00";
18
23
  const GS_SIZE_DOUBLE_W = "\x1D\x21\x11";
19
24
  const GS_BAR_HEIGHT = "\x1D\x68\x3C";
@@ -386,6 +391,7 @@ export function generateEscpos(order, logoRasterB64) {
386
391
  // NOTE: no logo in ESC/POS text mode — a logo URL cannot be rendered without
387
392
  // a raster pipeline (GS v 0). HTML receipts embed the logo instead.
388
393
  push(ESC_INIT);
394
+ push(ESC_FONT_A);
389
395
  if (logoRasterB64) {
390
396
  try {
391
397
  push(binaryFromBase64(logoRasterB64));
@@ -394,6 +400,9 @@ export function generateEscpos(order, logoRasterB64) {
394
400
  /* logo is decorative — never fail the receipt */
395
401
  }
396
402
  }
403
+ // Solid-black text: double-strike every line. This is what turns faint
404
+ // grey thermal output into a crisp receipt on low-density heads.
405
+ push(ESC_DOUBLE_STRIKE_ON);
397
406
  push(ESC_ALIGN_CENTER);
398
407
  for (const part of nameBlockEscpos(business?.name || "Teinc Food"))
399
408
  push(part);
@@ -602,33 +611,33 @@ export function generateHtml(order, logoDataUri) {
602
611
  <html>
603
612
  <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
604
613
  <style>
605
- body{font-family:monospace;max-width:320px;margin:0 auto;padding:16px;background:#fff;color:#111;}
606
- .header{text-align:center;border-top:2px solid #333;border-bottom:2px solid #333;padding:8px 0;margin-bottom:12px;}
614
+ body{font-family:"Courier New",Courier,"Liberation Mono",monospace;font-weight:600;max-width:320px;margin:0 auto;padding:16px;background:#fff;color:#000;-webkit-print-color-adjust:exact;print-color-adjust:exact;}
615
+ .header{text-align:center;border-top:2px solid #000;border-bottom:2px solid #000;padding:8px 0;margin-bottom:12px;}
607
616
  .header .logo{max-width:200px;max-height:64px;margin:4px auto;display:block;object-fit:contain;}
608
617
  .header h2{margin:4px 0 0;}
609
- .header h2.name-2x{font-size:28px;font-weight:bold;}
610
- .header h2.name-1x-bold{font-size:16px;font-weight:bold;}
611
- .header h2.name-1x{font-size:14px;}
612
- .header .address{font-size:11px;color:#555;margin:2px 0;}
618
+ .header h2.name-2x{font-size:28px;font-weight:800;}
619
+ .header h2.name-1x-bold{font-size:16px;font-weight:800;}
620
+ .header h2.name-1x{font-size:14px;font-weight:700;}
621
+ .header .address{font-size:11px;color:#000;font-weight:600;margin:2px 0;}
613
622
  .callout{text-align:center;margin:16px 0;}
614
- .callout .number{font-size:36px;font-weight:bold;letter-spacing:4px;margin:0;}
615
- .callout .label{font-size:10px;color:#555;margin:0;}
623
+ .callout .number{font-size:36px;font-weight:800;letter-spacing:4px;margin:0;}
624
+ .callout .label{font-size:10px;color:#000;font-weight:700;margin:0;}
616
625
  .order-info{font-size:12px;margin:8px 0;}
617
626
  table{width:100%;font-size:12px;border-collapse:collapse;table-layout:fixed;}
618
627
  td{padding:2px 0;vertical-align:top;overflow-wrap:anywhere;}
619
628
  .item-name{width:17ch;}
620
629
  .item-qty{width:3ch;text-align:right;}
621
630
  .item-price{width:10ch;text-align:right;}
622
- .addon-row td{font-size:11px;color:#666;padding-left:12px;}
631
+ .addon-row td{font-size:11px;color:#000;font-weight:600;padding-left:12px;}
623
632
  .addon-row td.addon-price{text-align:right;}
624
- .variant-row td{font-size:11px;color:#444;padding-left:8px;}
633
+ .variant-row td{font-size:11px;color:#000;font-weight:600;padding-left:8px;}
625
634
  .totals{margin-top:8px;}
626
635
  .totals td.label{text-align:left;width:70%;}
627
636
  .totals td.amount{text-align:right;width:30%;}
628
637
  .total-row td{padding-top:12px;}
629
- .total-row{font-weight:bold;font-size:15px;border-top:2px solid #333;border-bottom:2px solid #333;padding:4px 0;}
630
- .footer{text-align:center;margin-top:16px;padding-top:8px;font-size:12px;color:#555;}
631
- @media print{body{max-width:80mm;padding:0;}}
638
+ .total-row{font-weight:800;font-size:15px;border-top:2px solid #000;border-bottom:2px solid #000;padding:4px 0;}
639
+ .footer{text-align:center;margin-top:16px;padding-top:8px;font-size:12px;color:#000;font-weight:600;}
640
+ @media print{body{max-width:80mm;padding:0;color:#000;}}
632
641
  </style></head>
633
642
  <body>
634
643
  <div class="header">
@@ -63,7 +63,7 @@ export async function printOrderReceipt(order, opts) {
63
63
  if (isTcp) {
64
64
  try {
65
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;
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");
@@ -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,
@@ -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.
@@ -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.20",
3
+ "version": "0.10.22",
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",