@teincfood/core 0.9.11 → 0.9.12

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.
@@ -14,6 +14,7 @@ import { assertBusinessOpenForCreate } from "../../utils/business-hours";
14
14
  import { canTransition } from "../../utils/order-transitions";
15
15
  import { TRANSITION_ERROR_CODES, TransitionError } from "../../sync/transition-errors";
16
16
  import { validateOrderCreateInput } from "../../validation/offline";
17
+ import { buildItemSnapshots, snapshotsToOrderLines } from "./snapshots";
17
18
  import { drainOne, pullRemoteOrders } from "../../sync/sync-jobs.service";
18
19
  import { getAuthToken } from "../../adapters/auth";
19
20
  import { canBusinessSync } from "../../sync/sync-guard";
@@ -106,9 +107,12 @@ export async function createLocalOrder(businessId, input) {
106
107
  // while offline retain their original creation time.
107
108
  const now = new Date().toISOString();
108
109
  const insertedAt = input.inserted_at || now;
109
- // item_snapshots from CartPanel, else fallback
110
- const snapshots = input.item_snapshots;
111
- const items = snapshots ? snapshots.map((s, i) => ({ id: `tmp-${id}-${i}`, menu_item: { id: s.menu_item_id, name: s.name }, quantity: s.quantity, unit_price: s.unit_price_minor, subtotal: s.unit_price_minor * s.quantity })) : (input.items ?? []).map((it, i) => ({ id: `tmp-${id}-${i}`, menu_item: { id: it.menu_item_id, name: "" }, quantity: it.quantity, unit_price: 0, subtotal: 0 }));
110
+ // Sale-time snapshots (canonical builder): names are embedded once and
111
+ // never re-resolved from the menu cache — offline history is self-contained.
112
+ const snapshots = buildItemSnapshots(input.item_snapshots ??
113
+ input.items ??
114
+ []);
115
+ const items = snapshotsToOrderLines(snapshots, id);
112
116
  const totalMinor = input.total_minor ?? 0;
113
117
  const subtotalMinor = input.subtotal_minor ?? totalMinor;
114
118
  const taxMinor = input.tax_minor ?? 0;
@@ -0,0 +1,72 @@
1
+ import type { OrderItem } from "../../types/api/seller.api";
2
+ /**
3
+ * Order Item Snapshots — the single contract for sale-time history.
4
+ *
5
+ * Every order line carries its own immutable snapshot (item/variant/addon
6
+ * names as of sale time) so order history never depends on the live menu:
7
+ * sizes, extras, and items can be renamed or deleted without touching past
8
+ * orders — on any client, online or offline.
9
+ *
10
+ * Both POS apps build their outbox payloads through `buildItemSnapshots`:
11
+ * it accepts either app's cart-line shape (rich lines with
12
+ * `selected_variant`/`selected_addons`, or thin lines with just the menu
13
+ * item) plus already-built snapshots (idempotent), and always emits the
14
+ * full canonical shape. The local order builders and the offline receipt
15
+ * renderer consume only this shape — they never re-resolve names from the
16
+ * menu cache.
17
+ */
18
+ export interface SnapshotAddon {
19
+ name: string;
20
+ price_minor: number;
21
+ }
22
+ export interface OrderItemSnapshot {
23
+ menu_item_id: string;
24
+ name: string;
25
+ quantity: number;
26
+ unit_price_minor: number;
27
+ variant_id?: string;
28
+ variant_name?: string;
29
+ addons: SnapshotAddon[];
30
+ }
31
+ /** Cart line shapes from the POS apps (superset tolerated). */
32
+ export interface SnapshotCartLine {
33
+ menu_item?: {
34
+ id: string;
35
+ name: string;
36
+ } | null;
37
+ menu_item_id?: string;
38
+ name?: string;
39
+ quantity?: number;
40
+ unit_price_minor?: number;
41
+ unit_price?: number;
42
+ subtotal?: number;
43
+ selected_variant?: {
44
+ id?: string;
45
+ name?: string;
46
+ } | null;
47
+ variant_id?: string;
48
+ variant_name?: string;
49
+ selected_addons?: Array<{
50
+ addon_id?: string;
51
+ id?: string;
52
+ name?: string;
53
+ price_minor?: number;
54
+ price?: number;
55
+ }> | null;
56
+ addons?: Array<{
57
+ name?: string;
58
+ price_minor?: number;
59
+ price?: number;
60
+ }> | null;
61
+ }
62
+ /**
63
+ * Normalize any POS cart line (or an already-built snapshot) into the
64
+ * canonical snapshot. Missing variant/addon info yields `undefined` / `[]`
65
+ * — never a menu-cache lookup.
66
+ */
67
+ export declare function buildItemSnapshots(lines: SnapshotCartLine[] | null | undefined): OrderItemSnapshot[];
68
+ /**
69
+ * Expand snapshots into local order-line objects (the `SellerOrder["items"]`
70
+ * shape): names embedded, ids kept for reference, zero menu lookups.
71
+ */
72
+ export declare function snapshotsToOrderLines(snapshots: OrderItemSnapshot[] | null | undefined, commandId: string): OrderItem[];
@@ -0,0 +1,68 @@
1
+ function asString(value) {
2
+ return typeof value === "string" ? value : "";
3
+ }
4
+ function asNumber(value, fallback = 0) {
5
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
6
+ }
7
+ /**
8
+ * Normalize any POS cart line (or an already-built snapshot) into the
9
+ * canonical snapshot. Missing variant/addon info yields `undefined` / `[]`
10
+ * — never a menu-cache lookup.
11
+ */
12
+ export function buildItemSnapshots(lines) {
13
+ if (!Array.isArray(lines))
14
+ return [];
15
+ return lines.map((line) => {
16
+ const menuItemId = asString(line.menu_item?.id ?? line.menu_item_id);
17
+ const name = asString(line.menu_item?.name ?? line.name);
18
+ const quantity = asNumber(line.quantity, 1);
19
+ const unitPrice = asNumber(line.unit_price_minor ?? line.unit_price, 0);
20
+ const subtotal = asNumber(line.subtotal, unitPrice * quantity);
21
+ const variantId = line.selected_variant?.id ?? line.variant_id;
22
+ const variantName = line.selected_variant?.name ?? line.variant_name;
23
+ const rawAddons = line.selected_addons ?? line.addons ?? [];
24
+ const addons = Array.isArray(rawAddons)
25
+ ? rawAddons.map((a) => ({
26
+ name: asString(a.name),
27
+ price_minor: asNumber(a.price_minor ?? a.price, 0),
28
+ }))
29
+ : [];
30
+ const explicitPrice = line.unit_price_minor ?? line.unit_price;
31
+ const unitPriceMinor = typeof explicitPrice === "number" && Number.isFinite(explicitPrice)
32
+ ? explicitPrice
33
+ : subtotal && quantity
34
+ ? Math.round(subtotal / quantity)
35
+ : 0;
36
+ const snapshot = {
37
+ menu_item_id: menuItemId,
38
+ name,
39
+ quantity,
40
+ unit_price_minor: unitPriceMinor,
41
+ addons,
42
+ };
43
+ if (variantId)
44
+ snapshot.variant_id = variantId;
45
+ if (typeof variantName === "string" && variantName !== "")
46
+ snapshot.variant_name = variantName;
47
+ return snapshot;
48
+ });
49
+ }
50
+ /**
51
+ * Expand snapshots into local order-line objects (the `SellerOrder["items"]`
52
+ * shape): names embedded, ids kept for reference, zero menu lookups.
53
+ */
54
+ export function snapshotsToOrderLines(snapshots, commandId) {
55
+ if (!Array.isArray(snapshots))
56
+ return [];
57
+ return snapshots.map((s, index) => ({
58
+ id: `tmp-${commandId}-${index}`,
59
+ menu_item: { id: s.menu_item_id, name: s.name },
60
+ quantity: s.quantity,
61
+ unit_price: s.unit_price_minor,
62
+ subtotal: s.unit_price_minor * s.quantity,
63
+ variant_id: s.variant_id ?? null,
64
+ variant_name: s.variant_name ?? null,
65
+ variant: s.variant_name ? { id: s.variant_id, name: s.variant_name } : null,
66
+ addons: (s.addons ?? []).map((a) => ({ name: a.name, price_minor: a.price_minor })),
67
+ }));
68
+ }
package/dist/index.d.ts CHANGED
@@ -135,6 +135,7 @@ export * from "./sync/transition-endpoints";
135
135
  export * from "./utils/business-hours";
136
136
  export * from "./hooks/business-hours.hooks";
137
137
  export * from "./domain/orders/service";
138
+ export * from "./domain/orders/snapshots";
138
139
  export { phoenixSocket } from "./realtime/phoenix-socket";
139
140
  export { eventsService } from "./realtime/events.service";
140
141
  export { orderEventsService } from "./realtime/order-events.service";
package/dist/index.js CHANGED
@@ -148,6 +148,7 @@ export * from "./hooks/business-hours.hooks";
148
148
  // components/OrderStatusCard was folded into order-transitions (compat re-export lives in apps)
149
149
  // ── Domain (offline-first primary DB) ──
150
150
  export * from "./domain/orders/service";
151
+ export * from "./domain/orders/snapshots";
151
152
  // ── Realtime (single source — PHX WebSocket + business:{id}) ──
152
153
  export { phoenixSocket } from "./realtime/phoenix-socket";
153
154
  export { eventsService } from "./realtime/events.service";
@@ -161,6 +161,11 @@ export function calloutNumber(order) {
161
161
  return String(parseInt(digits, 10));
162
162
  }
163
163
  function itemName(item) {
164
+ // Snapshot-first: the sale-time name survives menu renames/deletes.
165
+ // The embedded menu join is fallback for pre-snapshot rows.
166
+ const snapshot = item.name;
167
+ if (typeof snapshot === "string" && snapshot !== "")
168
+ return snapshot;
164
169
  return item.menu_item?.name || "(deleted item)";
165
170
  }
166
171
  function addonName(addon) {
@@ -171,6 +176,11 @@ function addonName(addon) {
171
176
  return "Add-on";
172
177
  }
173
178
  function variantName(item) {
179
+ // Snapshot-first: the sale-time size name survives menu edits. The live
180
+ // variant join is fallback for pre-snapshot rows.
181
+ const snapshot = item.variant_name;
182
+ if (typeof snapshot === "string" && snapshot !== "")
183
+ return snapshot;
174
184
  const n = item.variant?.name;
175
185
  return n ? n : null;
176
186
  }
@@ -16,6 +16,7 @@ import { TransitionError, isReplayDeferred, TRANSITION_ERROR_CODES } from "./tra
16
16
  import { assertBusinessOpenForCreate } from "../utils/business-hours";
17
17
  import { extractApiErrorCode } from "../utils/error";
18
18
  import { generateEventId } from "../utils/uuid";
19
+ import { buildItemSnapshots, snapshotsToOrderLines } from "../domain/orders/snapshots";
19
20
  import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
20
21
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
21
22
  import { canBusinessSync } from "./sync-guard";
@@ -102,8 +103,7 @@ function buildOptimisticEvent(command) {
102
103
  };
103
104
  }
104
105
  function buildOptimisticOrder(command, payload) {
105
- const snapshots = payload.item_snapshots ?? null;
106
- const items = payload.items ?? [];
106
+ const snapshots = buildItemSnapshots((payload.item_snapshots ?? payload.items ?? []));
107
107
  const totalMinor = payload.total_minor ?? 0;
108
108
  const subtotalMinor = payload.subtotal_minor ?? totalMinor;
109
109
  const taxMinor = payload.tax_minor ?? 0;
@@ -149,22 +149,7 @@ function buildOptimisticOrder(command, payload) {
149
149
  delivery_lat: null,
150
150
  delivery_lng: null,
151
151
  user: null,
152
- items: (snapshots ? snapshots.map((s, index) => ({
153
- id: `tmp-${command.command_id}-${index}`,
154
- menu_item: { id: s.menu_item_id, name: s.name },
155
- quantity: s.quantity,
156
- unit_price: s.unit_price_minor,
157
- subtotal: s.unit_price_minor * s.quantity,
158
- variant_name: s.variant_name,
159
- addons: (s.addons ?? []).map((a) => ({ name: a.name, price_minor: a.price_minor })),
160
- })) : items.map((item, index) => ({
161
- id: `tmp-${command.command_id}-${index}`,
162
- menu_item: { id: item.menu_item_id, name: "" },
163
- quantity: item.quantity,
164
- unit_price: 0,
165
- subtotal: 0,
166
- addons: [],
167
- }))),
152
+ items: snapshotsToOrderLines(snapshots, command.command_id),
168
153
  inserted_at: now,
169
154
  };
170
155
  }
@@ -39,6 +39,8 @@ export interface OrderItem {
39
39
  id?: string;
40
40
  name?: string;
41
41
  } | null;
42
+ /** Sale-time size name snapshot — history never re-resolves the menu. */
43
+ variant_name?: string | null;
42
44
  /** Backend shape for addon lines. */
43
45
  order_item_addons?: OrderItemAddon[];
44
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
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",