@teincfood/core 0.9.10 → 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.
- package/dist/domain/orders/service.js +7 -3
- package/dist/domain/orders/snapshots.d.ts +72 -0
- package/dist/domain/orders/snapshots.js +68 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/printer/receipt.js +10 -0
- package/dist/reference/menu-record.d.ts +15 -0
- package/dist/reference/menu-record.js +20 -0
- package/dist/sync/command-queue.service.js +3 -18
- package/dist/types/api/seller.api.d.ts +2 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
110
|
-
|
|
111
|
-
const
|
|
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";
|
package/dist/printer/receipt.js
CHANGED
|
@@ -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
|
}
|
|
@@ -49,6 +49,21 @@ export interface MenuItemEnvelopeInput extends MenuItemRecordInput {
|
|
|
49
49
|
/** Extra wire-only keys (e.g. entity id). */
|
|
50
50
|
wireExtra?: Record<string, unknown>;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Image value for a menu-item UPDATE wire payload. The server is the source
|
|
54
|
+
* of truth for the stored key — never re-send a loaded URL and risk
|
|
55
|
+
* overwriting the key with a URL-shaped value. Returns `undefined` when the
|
|
56
|
+
* caller must OMIT `image_url` (server keeps its key untouched):
|
|
57
|
+
* - fresh upload → the bare S3 key;
|
|
58
|
+
* - user removed the photo (had one, now empty) → explicit null (clear it);
|
|
59
|
+
* - loaded an un-storable scheme (tfimg:/blob:/file: poison) → null (heal);
|
|
60
|
+
* - otherwise (loaded URL or bare key, no new upload) → omit (preserve).
|
|
61
|
+
*/
|
|
62
|
+
export declare function imageUrlForUpdateWire(opts: {
|
|
63
|
+
freshKey?: string | null;
|
|
64
|
+
loadedUrl?: string | null;
|
|
65
|
+
hadImage: boolean;
|
|
66
|
+
}): string | null | undefined;
|
|
52
67
|
/**
|
|
53
68
|
* Build the { wire, cacheRecord } pair for a menu item write. Callers pass
|
|
54
69
|
* absolute variant prices (backend model) — the form layer owns the field.
|
|
@@ -122,6 +122,26 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
122
122
|
function wireId(id) {
|
|
123
123
|
return id && UUID_RE.test(id) ? id : undefined;
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Image value for a menu-item UPDATE wire payload. The server is the source
|
|
127
|
+
* of truth for the stored key — never re-send a loaded URL and risk
|
|
128
|
+
* overwriting the key with a URL-shaped value. Returns `undefined` when the
|
|
129
|
+
* caller must OMIT `image_url` (server keeps its key untouched):
|
|
130
|
+
* - fresh upload → the bare S3 key;
|
|
131
|
+
* - user removed the photo (had one, now empty) → explicit null (clear it);
|
|
132
|
+
* - loaded an un-storable scheme (tfimg:/blob:/file: poison) → null (heal);
|
|
133
|
+
* - otherwise (loaded URL or bare key, no new upload) → omit (preserve).
|
|
134
|
+
*/
|
|
135
|
+
export function imageUrlForUpdateWire(opts) {
|
|
136
|
+
if (opts.freshKey)
|
|
137
|
+
return opts.freshKey;
|
|
138
|
+
const loaded = opts.loadedUrl ?? null;
|
|
139
|
+
if (!loaded)
|
|
140
|
+
return opts.hadImage ? null : undefined;
|
|
141
|
+
if (toStorableImageUrl(loaded) === null)
|
|
142
|
+
return null;
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
125
145
|
/**
|
|
126
146
|
* Build the { wire, cacheRecord } pair for a menu item write. Callers pass
|
|
127
147
|
* absolute variant prices (backend model) — the form layer owns the field.
|
|
@@ -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 ??
|
|
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
|
|
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