@teincfood/core 0.10.7 → 0.10.9
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/adapters/pending-orders.d.ts +7 -0
- package/dist/adapters/pending-orders.js +19 -0
- package/dist/db/operations.d.ts +9 -0
- package/dist/db/operations.js +13 -0
- package/dist/hooks/orders.hooks.d.ts +1 -1
- package/dist/hooks/orders.hooks.js +7 -1
- package/dist/local-node/service.d.ts +12 -0
- package/dist/local-node/service.js +77 -1
- package/dist/printer/receipt.js +109 -33
- package/dist/sync/command-queue.service.js +27 -11
- package/dist/sync/pending-orders.service.js +3 -3
- package/dist/types/api/seller.api.d.ts +17 -1
- package/dist/utils/customer-display.d.ts +8 -0
- package/dist/utils/customer-display.js +20 -11
- package/package.json +1 -1
|
@@ -14,6 +14,13 @@ export declare const usePendingOrdersStore: {
|
|
|
14
14
|
hydrated: boolean;
|
|
15
15
|
orders: SellerOrder[];
|
|
16
16
|
addOrder: (order: SellerOrder) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Replace an existing pending order in place. Used for queued
|
|
19
|
+
* transitions/payments so the optimistic overlay reflects the new state
|
|
20
|
+
* immediately. Unknown orders are ignored (a transition must never
|
|
21
|
+
* resurrect an order that was already reconciled).
|
|
22
|
+
*/
|
|
23
|
+
updateOrder: (order: SellerOrder) => void;
|
|
17
24
|
removeByOrderNumber: (orderNumber: string) => void;
|
|
18
25
|
removeByOrderId: (orderId: string) => void;
|
|
19
26
|
setHydrated: (v: boolean) => void;
|
|
@@ -30,6 +30,25 @@ export const usePendingOrdersStore = {
|
|
|
30
30
|
notify();
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
|
+
/**
|
|
34
|
+
* Replace an existing pending order in place. Used for queued
|
|
35
|
+
* transitions/payments so the optimistic overlay reflects the new state
|
|
36
|
+
* immediately. Unknown orders are ignored (a transition must never
|
|
37
|
+
* resurrect an order that was already reconciled).
|
|
38
|
+
*/
|
|
39
|
+
updateOrder: (order) => {
|
|
40
|
+
const index = orders.findIndex((o) => o.id === order.id || o.order_number === order.order_number);
|
|
41
|
+
if (index === -1)
|
|
42
|
+
return;
|
|
43
|
+
const current = orders[index];
|
|
44
|
+
if (current.status === order.status &&
|
|
45
|
+
current.is_paid === order.is_paid &&
|
|
46
|
+
current.paid_at === order.paid_at) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
orders = orders.map((o, i) => (i === index ? order : o));
|
|
50
|
+
notify();
|
|
51
|
+
},
|
|
33
52
|
removeByOrderNumber: (orderNumber) => {
|
|
34
53
|
const next = orders.filter((o) => o.order_number !== orderNumber);
|
|
35
54
|
if (next.length !== orders.length) {
|
package/dist/db/operations.d.ts
CHANGED
|
@@ -112,6 +112,15 @@ export declare function upsertServerOrder(db: SQLiteDatabase, order: import("../
|
|
|
112
112
|
preserveStatus?: boolean;
|
|
113
113
|
preservePayment?: boolean;
|
|
114
114
|
}): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* Recent orders plus their local HLC, for LAN catch-up.
|
|
117
|
+
*
|
|
118
|
+
* The HLC must travel with the row: `insertOrder` is HLC-guarded, so a peer
|
|
119
|
+
* replaying an older snapshot can never overwrite a newer local status.
|
|
120
|
+
*/
|
|
121
|
+
export declare function listOrdersWithHlc(db: SQLiteDatabase, businessId: string, limit?: number): Promise<Array<import("../types/api/seller.api").SellerOrder & {
|
|
122
|
+
hlc?: string;
|
|
123
|
+
}>>;
|
|
115
124
|
export declare function listOrders(db: SQLiteDatabase, businessId: string, filters?: {
|
|
116
125
|
status?: string;
|
|
117
126
|
q?: string;
|
package/dist/db/operations.js
CHANGED
|
@@ -376,6 +376,19 @@ export async function upsertServerOrder(db, order, opts = {}) {
|
|
|
376
376
|
ON CONFLICT(id) DO UPDATE SET business_id=excluded.business_id, order_number=excluded.order_number, callout_number=excluded.callout_number, status=excluded.status, order_type=excluded.order_type, is_pos=excluded.is_pos, items=excluded.items, subtotal_minor=excluded.subtotal_minor, total_minor=excluded.total_minor, tax_minor=excluded.tax_minor, tax_lines=excluded.tax_lines, payment=excluded.payment, paid_at=COALESCE(excluded.paid_at, orders.paid_at), customer_name=excluded.customer_name, customer_phone=excluded.customer_phone, pos_payment_method=excluded.pos_payment_method, delivery_id=excluded.delivery_id, business=excluded.business, user=excluded.user, updated_at=excluded.updated_at, hlc=excluded.hlc, sync_failed_at=NULL, sync_error=NULL;`, order.id, order.business_id, order.order_number, order.callout_number, status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items ?? []), order.subtotal_minor ?? 0, order.total_minor ?? 0, order.tax_minor ?? 0, JSON.stringify(order.tax_lines ?? []), JSON.stringify(payment), paidAt, order.customer_name, order.customer_phone, order.pos_payment_method, order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
|
|
377
377
|
notifyDbChange(order.business_id, "orders");
|
|
378
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Recent orders plus their local HLC, for LAN catch-up.
|
|
381
|
+
*
|
|
382
|
+
* The HLC must travel with the row: `insertOrder` is HLC-guarded, so a peer
|
|
383
|
+
* replaying an older snapshot can never overwrite a newer local status.
|
|
384
|
+
*/
|
|
385
|
+
export async function listOrdersWithHlc(db, businessId, limit = 200) {
|
|
386
|
+
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
|
+
return rows.map((row) => ({
|
|
388
|
+
...orderRowToSellerOrder(row),
|
|
389
|
+
hlc: row.hlc ?? row.updated_at ?? row.inserted_at,
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
379
392
|
export async function listOrders(db, businessId, filters, page = 1, pageSize = 20) {
|
|
380
393
|
const where = ["business_id = ?"];
|
|
381
394
|
const args = [businessId];
|
|
@@ -22,7 +22,7 @@ export interface SellerOrderFilters {
|
|
|
22
22
|
date_to?: string;
|
|
23
23
|
}
|
|
24
24
|
export declare function useSellerOrdersQuery(enabled?: boolean, filters?: SellerOrderFilters): import("@tanstack/react-query").UseInfiniteQueryResult<SellerOrder[], Error>;
|
|
25
|
-
export declare function useSellerOrderQuery(orderId: string, enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
|
|
25
|
+
export declare function useSellerOrderQuery(orderId: string, enabled?: boolean, placeholder?: SellerOrder | null): import("@tanstack/react-query").UseQueryResult<{
|
|
26
26
|
data: SellerOrder;
|
|
27
27
|
}, Error>;
|
|
28
28
|
export declare function useCreatePOSOrderMutation(): import("@tanstack/react-query").UseMutationResult<SellerOrder, Error, POSOrderCreateRequest, unknown>;
|
|
@@ -81,7 +81,7 @@ export function useSellerOrdersQuery(enabled = true, filters) {
|
|
|
81
81
|
select: (data) => data.pages.flatMap((p) => p.data),
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
|
-
export function useSellerOrderQuery(orderId, enabled = true) {
|
|
84
|
+
export function useSellerOrderQuery(orderId, enabled = true, placeholder) {
|
|
85
85
|
const bid = useBusinessId();
|
|
86
86
|
return useQuery({
|
|
87
87
|
queryKey: SELLER_KEYS.order(orderId, bid),
|
|
@@ -94,6 +94,12 @@ export function useSellerOrderQuery(orderId, enabled = true) {
|
|
|
94
94
|
return { data: order };
|
|
95
95
|
},
|
|
96
96
|
enabled: enabled && !!orderId && !!bid,
|
|
97
|
+
// The order list already has the full row. Seed the dialog with it so
|
|
98
|
+
// opening details is instant; the DB read still runs and replaces it.
|
|
99
|
+
placeholderData: placeholder ? { data: placeholder } : undefined,
|
|
100
|
+
// Local SQLite reads cannot recover from a transient network error; a
|
|
101
|
+
// missing row should surface immediately instead of retrying for seconds.
|
|
102
|
+
retry: false,
|
|
97
103
|
staleTime: 5 * 1000,
|
|
98
104
|
});
|
|
99
105
|
}
|
|
@@ -24,6 +24,18 @@ declare class LocalNodeServiceCore {
|
|
|
24
24
|
init(deps: LocalNodeDeps): void;
|
|
25
25
|
private requestReferenceFromPeers;
|
|
26
26
|
private requestAssetFromPeers;
|
|
27
|
+
/**
|
|
28
|
+
* Catch a newly connected peer up on orders created while the mesh was not
|
|
29
|
+
* connected (e.g. a sale rung up before Local Node was enabled).
|
|
30
|
+
*
|
|
31
|
+
* Live order events only cover changes that happen *after* the socket is
|
|
32
|
+
* up. Without this handshake a pre-existing offline sale stayed invisible
|
|
33
|
+
* on the other terminal forever. Both sides call this on connect; the
|
|
34
|
+
* cooldown dedupes the inbound/outbound callbacks for the same peer.
|
|
35
|
+
*/
|
|
36
|
+
private orderCatchupAt;
|
|
37
|
+
private static readonly ORDER_CATCHUP_COOLDOWN_MS;
|
|
38
|
+
private publishOrderCatchup;
|
|
27
39
|
private getDeps;
|
|
28
40
|
private getDeviceId;
|
|
29
41
|
private runLifecycle;
|
|
@@ -12,7 +12,9 @@ import { REFERENCE_CHANGED_EVENT_TYPE } from "../reference/events";
|
|
|
12
12
|
import { applyReferenceChangeEvent } from "../reference/events";
|
|
13
13
|
import { referenceSyncService } from "../reference/service";
|
|
14
14
|
import { openSyncDatabase } from "../db/connection";
|
|
15
|
-
import { insertOrder } from "../db/operations";
|
|
15
|
+
import { insertOrder, listOrdersWithHlc } from "../db/operations";
|
|
16
|
+
import { getPendingOrdersSnapshot } from "../adapters/pending-orders";
|
|
17
|
+
import { generateEventId } from "../utils/uuid";
|
|
16
18
|
import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
|
|
17
19
|
import { getConnectivityState } from "../adapters/connectivity";
|
|
18
20
|
import { deriveLanSecret as deriveSecretCore } from "./security";
|
|
@@ -46,6 +48,16 @@ class LocalNodeServiceCore {
|
|
|
46
48
|
this.published = false;
|
|
47
49
|
/** Whether discovery scanning was successfully started. */
|
|
48
50
|
this.discovering = false;
|
|
51
|
+
/**
|
|
52
|
+
* Catch a newly connected peer up on orders created while the mesh was not
|
|
53
|
+
* connected (e.g. a sale rung up before Local Node was enabled).
|
|
54
|
+
*
|
|
55
|
+
* Live order events only cover changes that happen *after* the socket is
|
|
56
|
+
* up. Without this handshake a pre-existing offline sale stayed invisible
|
|
57
|
+
* on the other terminal forever. Both sides call this on connect; the
|
|
58
|
+
* cooldown dedupes the inbound/outbound callbacks for the same peer.
|
|
59
|
+
*/
|
|
60
|
+
this.orderCatchupAt = new Map();
|
|
49
61
|
this.storeUnsub = null;
|
|
50
62
|
this.dialInFlight = new Set();
|
|
51
63
|
this.dialTimers = new Map();
|
|
@@ -89,6 +101,63 @@ class LocalNodeServiceCore {
|
|
|
89
101
|
}
|
|
90
102
|
return null;
|
|
91
103
|
}
|
|
104
|
+
async publishOrderCatchup(peerDeviceId) {
|
|
105
|
+
if (!this.businessId)
|
|
106
|
+
return;
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
const last = this.orderCatchupAt.get(peerDeviceId) ?? 0;
|
|
109
|
+
if (now - last < LocalNodeServiceCore.ORDER_CATCHUP_COOLDOWN_MS)
|
|
110
|
+
return;
|
|
111
|
+
this.orderCatchupAt.set(peerDeviceId, now);
|
|
112
|
+
try {
|
|
113
|
+
const db = await openSyncDatabase();
|
|
114
|
+
const rows = await listOrdersWithHlc(db, this.businessId, 200);
|
|
115
|
+
const byId = new Map();
|
|
116
|
+
for (const order of rows)
|
|
117
|
+
byId.set(order.id, order);
|
|
118
|
+
// Queued offline creations have no `orders` row yet; they live in the
|
|
119
|
+
// pending overlay. Include them so a pre-linkup sale is shared too.
|
|
120
|
+
for (const order of getPendingOrdersSnapshot().orders) {
|
|
121
|
+
if (order.business_id === this.businessId)
|
|
122
|
+
byId.set(order.id, order);
|
|
123
|
+
}
|
|
124
|
+
const peer = this.peers.get(peerDeviceId);
|
|
125
|
+
const canTarget = !!peer?.isConnected() && typeof peer.publishEvent === "function";
|
|
126
|
+
for (const order of byId.values()) {
|
|
127
|
+
if (!order?.id || !order.order_number)
|
|
128
|
+
continue;
|
|
129
|
+
const event = {
|
|
130
|
+
event_id: generateEventId(),
|
|
131
|
+
event_type: "order:sync",
|
|
132
|
+
event_version: "1",
|
|
133
|
+
entity_id: order.id,
|
|
134
|
+
user_id: "",
|
|
135
|
+
device_id: this.cachedDeviceId ?? "",
|
|
136
|
+
payload: order,
|
|
137
|
+
timestamp: order.updated_at ?? order.inserted_at ?? new Date().toISOString(),
|
|
138
|
+
};
|
|
139
|
+
if (canTarget) {
|
|
140
|
+
await peer.publishEvent(event);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
this.server?.broadcast(event);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
syncLogger.info("LocalNodeService", "Order catch-up published", {
|
|
147
|
+
peer: peerDeviceId,
|
|
148
|
+
orders: byId.size,
|
|
149
|
+
targeted: canTarget,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
syncLogger.warn("LocalNodeService", "Order catch-up failed", {
|
|
154
|
+
peer: peerDeviceId,
|
|
155
|
+
error: String(error),
|
|
156
|
+
});
|
|
157
|
+
// Allow a later reconnect to retry after a transient DB/socket failure.
|
|
158
|
+
this.orderCatchupAt.delete(peerDeviceId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
92
161
|
getDeps() {
|
|
93
162
|
if (!this.deps)
|
|
94
163
|
throw new Error("LocalNodeService not initialised: call initLocalNodeService(deps) first");
|
|
@@ -200,6 +269,7 @@ class LocalNodeServiceCore {
|
|
|
200
269
|
catch { }
|
|
201
270
|
this.peers.clear();
|
|
202
271
|
this.peerInfos.clear();
|
|
272
|
+
this.orderCatchupAt.clear();
|
|
203
273
|
if (this.server) {
|
|
204
274
|
await withTimeout(this.server.stop(), LIFECYCLE_TIMEOUT_MS).catch((e) => syncLogger.warn("LocalNodeService", "Server stop timed out", { error: String(e) }));
|
|
205
275
|
this.server = null;
|
|
@@ -359,6 +429,9 @@ class LocalNodeServiceCore {
|
|
|
359
429
|
onClientConnected: (info) => {
|
|
360
430
|
localNodeStore.getState().addConnectedClient({ deviceId: info.deviceId, deviceName: info.deviceName, host: info.host, connectedAt: new Date().toISOString() });
|
|
361
431
|
syncLogger.info("LocalNodeService", "Client connected", { deviceId: info.deviceId, connected_clients: localNodeStore.getState().connectedClients.length });
|
|
432
|
+
// Inbound connect: push this node's pre-existing orders to the new
|
|
433
|
+
// peer (covers mDNS asymmetry where only one side dials).
|
|
434
|
+
void this.publishOrderCatchup(info.deviceId);
|
|
362
435
|
},
|
|
363
436
|
onClientDisconnected: (deviceId) => {
|
|
364
437
|
localNodeStore.getState().removeConnectedClient(deviceId);
|
|
@@ -602,6 +675,8 @@ class LocalNodeServiceCore {
|
|
|
602
675
|
syncLogger.info("LocalNodeService", "Connected to peer", { deviceId: node.deviceId });
|
|
603
676
|
this.peerInfos.set(node.deviceId, { deviceId: node.deviceId, host: node.host, port: node.port });
|
|
604
677
|
localNodeStore.getState().addConnectedClient({ deviceId: node.deviceId, deviceName: node.deviceName, host: node.host, connectedAt: new Date().toISOString() });
|
|
678
|
+
// Outbound connect: push orders created before this socket existed.
|
|
679
|
+
void this.publishOrderCatchup(node.deviceId);
|
|
605
680
|
// A terminal may have missed `reference_data.changed` while it was
|
|
606
681
|
// disconnected. Compare versions with the peer on reconnect so menu,
|
|
607
682
|
// members, hours and tax changes converge even on a dark LAN.
|
|
@@ -817,6 +892,7 @@ class LocalNodeServiceCore {
|
|
|
817
892
|
}
|
|
818
893
|
getState() { return { isLocalNode: this.isLocalNode(), isConnectedToNode: this.isConnectedToNode(), electedNode: this.getElectedNode(), client: this.getClient() }; }
|
|
819
894
|
}
|
|
895
|
+
LocalNodeServiceCore.ORDER_CATCHUP_COOLDOWN_MS = 10000;
|
|
820
896
|
LocalNodeServiceCore.MISMATCH_BACKOFF_MS = 60000;
|
|
821
897
|
export const localNodeService = new LocalNodeServiceCore();
|
|
822
898
|
export function initLocalNodeService(deps) { localNodeService.init(deps); return localNodeService; }
|
package/dist/printer/receipt.js
CHANGED
|
@@ -100,7 +100,11 @@ function nameBlockEscpos(name) {
|
|
|
100
100
|
if (text.length <= 16)
|
|
101
101
|
return [GS_SIZE_DOUBLE_W, text, LF, GS_SIZE_NORMAL];
|
|
102
102
|
if (text.length <= 64) {
|
|
103
|
-
return [
|
|
103
|
+
return [
|
|
104
|
+
ESC_BOLD_ON,
|
|
105
|
+
...wrapText(text, 32).flatMap((l) => [escposText(l), LF]),
|
|
106
|
+
ESC_BOLD_OFF,
|
|
107
|
+
];
|
|
104
108
|
}
|
|
105
109
|
return wrapText(text, 32).flatMap((l) => [escposText(l), LF]);
|
|
106
110
|
}
|
|
@@ -114,11 +118,16 @@ function nameSizeClass(name) {
|
|
|
114
118
|
}
|
|
115
119
|
function currencySymbol(currency) {
|
|
116
120
|
switch (currency) {
|
|
117
|
-
case "GHS":
|
|
118
|
-
|
|
119
|
-
case "
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
case "GHS":
|
|
122
|
+
return "₵";
|
|
123
|
+
case "USD":
|
|
124
|
+
return "$";
|
|
125
|
+
case "EUR":
|
|
126
|
+
return "€";
|
|
127
|
+
case "GBP":
|
|
128
|
+
return "£";
|
|
129
|
+
default:
|
|
130
|
+
return currency ? currency + " " : "";
|
|
122
131
|
}
|
|
123
132
|
}
|
|
124
133
|
function formatMoney(amountMinor, order) {
|
|
@@ -134,7 +143,20 @@ function formatDatetime(iso) {
|
|
|
134
143
|
const d = new Date(iso);
|
|
135
144
|
// Backend: Calendar.strftime(dt, "%d %b %Y %H:%M") e.g. "03 Sep 2026 14:05"
|
|
136
145
|
const day = String(d.getDate()).padStart(2, "0");
|
|
137
|
-
const months = [
|
|
146
|
+
const months = [
|
|
147
|
+
"Jan",
|
|
148
|
+
"Feb",
|
|
149
|
+
"Mar",
|
|
150
|
+
"Apr",
|
|
151
|
+
"May",
|
|
152
|
+
"Jun",
|
|
153
|
+
"Jul",
|
|
154
|
+
"Aug",
|
|
155
|
+
"Sep",
|
|
156
|
+
"Oct",
|
|
157
|
+
"Nov",
|
|
158
|
+
"Dec",
|
|
159
|
+
];
|
|
138
160
|
const mon = months[d.getMonth()];
|
|
139
161
|
const year = d.getFullYear();
|
|
140
162
|
const hh = String(d.getHours()).padStart(2, "0");
|
|
@@ -147,7 +169,8 @@ function formatDatetime(iso) {
|
|
|
147
169
|
}
|
|
148
170
|
export function calloutNumber(order) {
|
|
149
171
|
// The backend assigns a real callout_number — always prefer it.
|
|
150
|
-
const direct = order
|
|
172
|
+
const direct = order
|
|
173
|
+
.callout_number;
|
|
151
174
|
if (typeof direct === "number" && Number.isFinite(direct))
|
|
152
175
|
return String(Math.abs(direct));
|
|
153
176
|
const num = order.order_number || "";
|
|
@@ -211,7 +234,11 @@ function paymentLineText(order) {
|
|
|
211
234
|
function escHtml(s) {
|
|
212
235
|
if (!s)
|
|
213
236
|
return "";
|
|
214
|
-
return s
|
|
237
|
+
return s
|
|
238
|
+
.replace(/&/g, "&")
|
|
239
|
+
.replace(/</g, "<")
|
|
240
|
+
.replace(/>/g, ">")
|
|
241
|
+
.replace(/"/g, """);
|
|
215
242
|
}
|
|
216
243
|
function businessAddress(business) {
|
|
217
244
|
const b = business;
|
|
@@ -238,7 +265,9 @@ function businessLogoUrl(order) {
|
|
|
238
265
|
const b = order.business;
|
|
239
266
|
if (!b)
|
|
240
267
|
return null;
|
|
241
|
-
return (b.logo_url ??
|
|
268
|
+
return (b.logo_url ??
|
|
269
|
+
b.logoUrl ??
|
|
270
|
+
null);
|
|
242
271
|
}
|
|
243
272
|
/** Fetch remote URL (or tfimg://) and return data URI, or null on failure. Uses Web fetch; works for http(s) and for cached tfimg via Rust protocol. */
|
|
244
273
|
async function fetchAsDataUri(url) {
|
|
@@ -291,7 +320,8 @@ function binaryFromBase64(b64) {
|
|
|
291
320
|
* reference. Null when none — fully offline, no network.
|
|
292
321
|
*/
|
|
293
322
|
export async function resolveLogoRaster(order) {
|
|
294
|
-
const direct = order.business
|
|
323
|
+
const direct = order.business
|
|
324
|
+
?.logo_escpos;
|
|
295
325
|
if (typeof direct === "string" && direct.length > 0)
|
|
296
326
|
return direct;
|
|
297
327
|
try {
|
|
@@ -308,30 +338,50 @@ export async function resolveLogoRaster(order) {
|
|
|
308
338
|
}
|
|
309
339
|
}
|
|
310
340
|
// ── ESC/POS ────────────────────────────────────────────────────────────
|
|
311
|
-
function doubleLineEscpos() {
|
|
312
|
-
|
|
341
|
+
function doubleLineEscpos() {
|
|
342
|
+
return "═".repeat(LINE_WIDTH) + LF;
|
|
343
|
+
}
|
|
344
|
+
function singleLineEscpos() {
|
|
345
|
+
return "─".repeat(LINE_WIDTH);
|
|
346
|
+
}
|
|
313
347
|
function customerLineEscpos(order) {
|
|
314
348
|
if (order.customer_name)
|
|
315
349
|
return `Customer: ${order.customer_name}${LF}`;
|
|
316
350
|
if (order.customer_phone)
|
|
317
351
|
return `Phone: ${order.customer_phone}${LF}`;
|
|
318
|
-
|
|
352
|
+
// POS orders link the teller, not a customer account. Never print staff as
|
|
353
|
+
// the customer; non-POS orders may fall back to the ordering user.
|
|
354
|
+
if (!order.is_pos && order.user?.full_name)
|
|
319
355
|
return `Customer: ${order.user.full_name}${LF}`;
|
|
320
356
|
return null;
|
|
321
357
|
}
|
|
322
|
-
function
|
|
323
|
-
|
|
358
|
+
function tellerLineEscpos(order) {
|
|
359
|
+
const teller = order.teller?.full_name ?? (order.is_pos ? order.user?.full_name : null);
|
|
360
|
+
return order.is_pos && teller ? `Served by: ${teller}${LF}` : null;
|
|
361
|
+
}
|
|
362
|
+
function orderTypeEscpos(order) {
|
|
363
|
+
return orderTypeText(order);
|
|
364
|
+
}
|
|
365
|
+
function paymentLineEscpos(order) {
|
|
366
|
+
return paymentLineText(order);
|
|
367
|
+
}
|
|
324
368
|
export function generateEscpos(order, logoRasterB64) {
|
|
325
369
|
const business = order.business;
|
|
326
370
|
const items = order.items || [];
|
|
327
|
-
const taxLines = (order.order_tax_lines ??
|
|
371
|
+
const taxLines = (order.order_tax_lines ??
|
|
372
|
+
order.tax_lines ??
|
|
373
|
+
[]);
|
|
328
374
|
const parts = [];
|
|
329
|
-
const push = (s) => {
|
|
330
|
-
|
|
375
|
+
const push = (s) => {
|
|
376
|
+
if (s != null)
|
|
377
|
+
parts.push(s);
|
|
378
|
+
};
|
|
331
379
|
// Sanitized text push: every human-readable segment goes through escposText
|
|
332
380
|
// (control codes and barcode data use raw push).
|
|
333
|
-
const pushT = (s) => {
|
|
334
|
-
|
|
381
|
+
const pushT = (s) => {
|
|
382
|
+
if (s != null)
|
|
383
|
+
parts.push(escposText(s));
|
|
384
|
+
};
|
|
335
385
|
// Header — implements docs/receipt-template.md §1
|
|
336
386
|
// NOTE: no logo in ESC/POS text mode — a logo URL cannot be rendered without
|
|
337
387
|
// a raster pipeline (GS v 0). HTML receipts embed the logo instead.
|
|
@@ -340,7 +390,9 @@ export function generateEscpos(order, logoRasterB64) {
|
|
|
340
390
|
try {
|
|
341
391
|
push(binaryFromBase64(logoRasterB64));
|
|
342
392
|
}
|
|
343
|
-
catch {
|
|
393
|
+
catch {
|
|
394
|
+
/* logo is decorative — never fail the receipt */
|
|
395
|
+
}
|
|
344
396
|
}
|
|
345
397
|
push(ESC_ALIGN_CENTER);
|
|
346
398
|
for (const part of nameBlockEscpos(business?.name || "Teinc Food"))
|
|
@@ -369,6 +421,9 @@ export function generateEscpos(order, logoRasterB64) {
|
|
|
369
421
|
const custLine = customerLineEscpos(order);
|
|
370
422
|
if (custLine)
|
|
371
423
|
pushT(custLine);
|
|
424
|
+
const tellerLine = tellerLineEscpos(order);
|
|
425
|
+
if (tellerLine)
|
|
426
|
+
pushT(tellerLine);
|
|
372
427
|
push(LF);
|
|
373
428
|
// Barcode — CODE128 GS k 73, sized down (default height is huge).
|
|
374
429
|
push(ESC_ALIGN_CENTER);
|
|
@@ -417,7 +472,9 @@ export function generateEscpos(order, logoRasterB64) {
|
|
|
417
472
|
pushT(`${padR("Delivery Fee:", 20)} ${padL(escposText(formatMoney(order.delivery_fee_minor, order)), 11)}${LF}`);
|
|
418
473
|
}
|
|
419
474
|
for (const tax of taxLines) {
|
|
420
|
-
const rateStr = tax.rate_basis_points
|
|
475
|
+
const rateStr = tax.rate_basis_points
|
|
476
|
+
? `(${Math.floor(tax.rate_basis_points / 100)}%)`
|
|
477
|
+
: "";
|
|
421
478
|
const label = `${tax.name || ""} ${rateStr}`.trim().slice(0, 19);
|
|
422
479
|
pushT(`${padR(label, 20)} ${padL(escposText(formatMoney(tax.tax_amount_minor, order)), 11)}${LF}`);
|
|
423
480
|
}
|
|
@@ -499,15 +556,20 @@ export async function generateHtmlAsync(order, opts) {
|
|
|
499
556
|
export function generateHtml(order, logoDataUri) {
|
|
500
557
|
const business = order.business;
|
|
501
558
|
const items = order.items || [];
|
|
502
|
-
const taxLines = (
|
|
559
|
+
const taxLines = (order.order_tax_lines ??
|
|
560
|
+
order.tax_lines ??
|
|
561
|
+
[]);
|
|
503
562
|
const callout = calloutNumber(order);
|
|
504
|
-
const logoSrc = logoDataUri ||
|
|
563
|
+
const logoSrc = logoDataUri ||
|
|
564
|
+
business?.logo_url ||
|
|
565
|
+
"";
|
|
505
566
|
const hasLogo = !!logoSrc;
|
|
506
567
|
const businessName = escHtml(business?.name || "Teinc Food");
|
|
507
568
|
const company = getCompanyInfo();
|
|
508
569
|
const addr = businessAddress(business);
|
|
509
570
|
const bPhone = business?.phone;
|
|
510
|
-
const itemRows = items
|
|
571
|
+
const itemRows = items
|
|
572
|
+
.map((item) => {
|
|
511
573
|
const qty = item.quantity || 1;
|
|
512
574
|
// unit_price is already minor? backend format_money expects minor
|
|
513
575
|
const price = formatMoney((item.unit_price ?? 0) * qty, order);
|
|
@@ -517,17 +579,24 @@ export function generateHtml(order, logoDataUri) {
|
|
|
517
579
|
? `<tr class="variant-row"><td class="item-name" colspan="2">Size: ${escHtml(vName)}</td><td class="item-price"></td></tr>`
|
|
518
580
|
: "";
|
|
519
581
|
const addons = item.order_item_addons ?? item.addons ?? [];
|
|
520
|
-
const addonRows = addons
|
|
582
|
+
const addonRows = addons
|
|
583
|
+
.map((addon) => {
|
|
521
584
|
const aName = escHtml(addonName(addon));
|
|
522
585
|
const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
|
|
523
586
|
return `<tr class="addon-row"><td class="item-name">+ ${aName}</td><td class="item-qty"></td><td class="addon-price">${formatMoney(aPriceMinor, order)}</td></tr>`;
|
|
524
|
-
})
|
|
587
|
+
})
|
|
588
|
+
.join("\n");
|
|
525
589
|
return [main, variantRow, addonRows].filter(Boolean).join("\n");
|
|
526
|
-
})
|
|
527
|
-
|
|
528
|
-
|
|
590
|
+
})
|
|
591
|
+
.join("\n");
|
|
592
|
+
const taxRowsHtml = taxLines
|
|
593
|
+
.map((tax) => {
|
|
594
|
+
const rateStr = tax.rate_basis_points
|
|
595
|
+
? `(${Math.floor(tax.rate_basis_points / 100)}%)`
|
|
596
|
+
: "";
|
|
529
597
|
return `<tr><td class="label">${escHtml(tax.name || "")} ${escHtml(rateStr)}</td><td class="amount">${formatMoney(tax.tax_amount_minor, order)}</td></tr>`;
|
|
530
|
-
})
|
|
598
|
+
})
|
|
599
|
+
.join("\n");
|
|
531
600
|
// Exact style from receipt.ex to_html/2
|
|
532
601
|
return `<!DOCTYPE html>
|
|
533
602
|
<html>
|
|
@@ -572,7 +641,14 @@ export function generateHtml(order, logoDataUri) {
|
|
|
572
641
|
<strong>Order:</strong> ${escHtml(order.order_number)}<br>
|
|
573
642
|
<strong>Date:</strong> ${escHtml(formatDatetime(order.inserted_at))}<br>
|
|
574
643
|
<strong>Type:</strong> ${escHtml(orderTypeText(order))}<br>
|
|
575
|
-
${order.customer_name
|
|
644
|
+
${order.customer_name
|
|
645
|
+
? `<strong>Customer:</strong> ${escHtml(order.customer_name)}<br>`
|
|
646
|
+
: !order.is_pos && order.user?.full_name
|
|
647
|
+
? `<strong>Customer:</strong> ${escHtml(order.user.full_name)}<br>`
|
|
648
|
+
: ""}
|
|
649
|
+
${order.is_pos && (order.teller?.full_name || order.user?.full_name)
|
|
650
|
+
? `<strong>Served by:</strong> ${escHtml(order.teller?.full_name || order.user.full_name)}<br>`
|
|
651
|
+
: ""}
|
|
576
652
|
</div>
|
|
577
653
|
<div class="callout">
|
|
578
654
|
<div class="number">${escHtml(callout)}</div>
|
|
@@ -18,6 +18,7 @@ import { assertBusinessOpenForCreate } from "../utils/business-hours";
|
|
|
18
18
|
import { extractApiErrorCode } from "../utils/error";
|
|
19
19
|
import { generateEventId } from "../utils/uuid";
|
|
20
20
|
import { buildItemSnapshots, snapshotsToOrderLines } from "../domain/orders/snapshots";
|
|
21
|
+
import { DEFAULT_ORDER_FLOW_MODE, getBusinessOrderFlowMode, initialPosOrderStatus, } from "../domain/orders/order-flow";
|
|
21
22
|
import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
|
|
22
23
|
import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
|
|
23
24
|
import { canBusinessSync } from "./sync-guard";
|
|
@@ -53,7 +54,7 @@ const PAYMENT_QUEUED_PAYLOAD = {
|
|
|
53
54
|
export function getQueuedStatus(commandType) {
|
|
54
55
|
return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
|
|
55
56
|
}
|
|
56
|
-
function buildOptimisticEvent(command) {
|
|
57
|
+
function buildOptimisticEvent(command, flowMode = DEFAULT_ORDER_FLOW_MODE) {
|
|
57
58
|
const payload = command.payload;
|
|
58
59
|
if (command.command_type === "order:create") {
|
|
59
60
|
return {
|
|
@@ -63,7 +64,7 @@ function buildOptimisticEvent(command) {
|
|
|
63
64
|
entity_id: command.entity_id ?? command.command_id,
|
|
64
65
|
user_id: command.user_id,
|
|
65
66
|
device_id: command.device_id,
|
|
66
|
-
payload: buildOptimisticOrder(command, payload),
|
|
67
|
+
payload: buildOptimisticOrder(command, payload, flowMode),
|
|
67
68
|
timestamp: new Date().toISOString(),
|
|
68
69
|
};
|
|
69
70
|
}
|
|
@@ -77,6 +78,7 @@ function buildOptimisticEvent(command) {
|
|
|
77
78
|
device_id: command.device_id,
|
|
78
79
|
payload: {
|
|
79
80
|
...payload,
|
|
81
|
+
business_id: command.business_id,
|
|
80
82
|
command_id: command.command_id,
|
|
81
83
|
is_paid: true,
|
|
82
84
|
paid_at: new Date().toISOString(),
|
|
@@ -95,6 +97,9 @@ function buildOptimisticEvent(command) {
|
|
|
95
97
|
device_id: command.device_id,
|
|
96
98
|
payload: {
|
|
97
99
|
...payload,
|
|
100
|
+
// Required by the offline read-model patch so queued transitions update
|
|
101
|
+
// the canonical orders table (not just seller_orders snapshots).
|
|
102
|
+
business_id: command.business_id,
|
|
98
103
|
command_id: command.command_id,
|
|
99
104
|
status: "pending",
|
|
100
105
|
// Used by offline read-model patching so the list reflects the queued transition.
|
|
@@ -106,7 +111,7 @@ function buildOptimisticEvent(command) {
|
|
|
106
111
|
timestamp: new Date().toISOString(),
|
|
107
112
|
};
|
|
108
113
|
}
|
|
109
|
-
function buildOptimisticOrder(command, payload) {
|
|
114
|
+
function buildOptimisticOrder(command, payload, flowMode) {
|
|
110
115
|
const snapshots = buildItemSnapshots((payload.item_snapshots ?? payload.items ?? []));
|
|
111
116
|
const totalMinor = payload.total_minor ?? 0;
|
|
112
117
|
const subtotalMinor = payload.subtotal_minor ?? totalMinor;
|
|
@@ -114,6 +119,8 @@ function buildOptimisticOrder(command, payload) {
|
|
|
114
119
|
const taxLines = payload.tax_lines ?? [];
|
|
115
120
|
const currencyCode = payload.currency_code ?? "GHS";
|
|
116
121
|
const now = new Date().toISOString();
|
|
122
|
+
const method = payload.pos_payment_method ?? "cash";
|
|
123
|
+
const isCash = method === "cash";
|
|
117
124
|
const orderNumber = payload.order_number ?? generateOrderNumberForBusiness(command.business_id);
|
|
118
125
|
return {
|
|
119
126
|
id: command.entity_id ?? command.command_id,
|
|
@@ -123,20 +130,23 @@ function buildOptimisticOrder(command, payload) {
|
|
|
123
130
|
is_pos: true,
|
|
124
131
|
customer_name: payload.customer_name ?? null,
|
|
125
132
|
customer_phone: payload.customer_phone ?? null,
|
|
126
|
-
pos_payment_method:
|
|
127
|
-
paid_at: null,
|
|
128
|
-
|
|
133
|
+
pos_payment_method: method,
|
|
134
|
+
paid_at: isCash ? now : null,
|
|
135
|
+
// Must match createLocalOrder: direct-flow cash sales are born delivered,
|
|
136
|
+
// even when the command is parked in the offline outbox. Hardcoding
|
|
137
|
+
// "accepted" made a completed sale show up on the preparing board.
|
|
138
|
+
status: initialPosOrderStatus(method, flowMode),
|
|
129
139
|
delivery_id: null,
|
|
130
140
|
delivery_status: null,
|
|
131
|
-
is_paid:
|
|
141
|
+
is_paid: isCash,
|
|
132
142
|
payment: {
|
|
133
|
-
is_paid:
|
|
143
|
+
is_paid: isCash,
|
|
134
144
|
reference: null,
|
|
135
145
|
amount_minor: totalMinor,
|
|
136
146
|
currency: currencyCode,
|
|
137
|
-
paid_at:
|
|
147
|
+
paid_at: isCash ? now : null,
|
|
138
148
|
},
|
|
139
|
-
payment_mode: "pos_cash",
|
|
149
|
+
payment_mode: isCash ? "pos_cash" : "prepaid",
|
|
140
150
|
total_minor: totalMinor,
|
|
141
151
|
subtotal_minor: subtotalMinor,
|
|
142
152
|
delivery_fee_minor: payload.delivery_fee_minor ?? 0,
|
|
@@ -268,7 +278,13 @@ class CommandQueueService {
|
|
|
268
278
|
order_number: preparedCommand.payload.order_number ?? null,
|
|
269
279
|
outbox_size: pending.length,
|
|
270
280
|
});
|
|
271
|
-
|
|
281
|
+
// Direct-flow cash sales are complete on creation. The optimistic order
|
|
282
|
+
// must use the same cached business setting as createLocalOrder, otherwise
|
|
283
|
+
// an offline direct sale appears as "accepted" and offers kitchen steps.
|
|
284
|
+
const flowMode = preparedCommand.command_type === "order:create"
|
|
285
|
+
? await getBusinessOrderFlowMode(preparedCommand.business_id)
|
|
286
|
+
: DEFAULT_ORDER_FLOW_MODE;
|
|
287
|
+
const event = buildOptimisticEvent(preparedCommand, flowMode);
|
|
272
288
|
syncLogger.info("CommandQueueService", "Optimistic event built", {
|
|
273
289
|
event_type: event.event_type,
|
|
274
290
|
entity_id: event.entity_id,
|
|
@@ -220,7 +220,7 @@ function patchPendingStatus(event) {
|
|
|
220
220
|
if (!target)
|
|
221
221
|
return;
|
|
222
222
|
const patched = { ...target, status: toStatus };
|
|
223
|
-
store.
|
|
223
|
+
store.updateOrder(patched);
|
|
224
224
|
}
|
|
225
225
|
catch { }
|
|
226
226
|
}
|
|
@@ -244,7 +244,7 @@ function patchPendingPaid(event) {
|
|
|
244
244
|
paid_at: paidAt,
|
|
245
245
|
payment: { ...t.payment, is_paid: true, paid_at: paidAt },
|
|
246
246
|
};
|
|
247
|
-
store.
|
|
247
|
+
store.updateOrder(patched);
|
|
248
248
|
}
|
|
249
249
|
catch { }
|
|
250
250
|
}
|
|
@@ -277,7 +277,7 @@ export async function applyPendingOrderEvent(event, origin = "local") {
|
|
|
277
277
|
const store = usePendingOrdersStore.getState();
|
|
278
278
|
const target = store.orders.find((o) => o.id === orderId || o.order_number === orderId);
|
|
279
279
|
if (target)
|
|
280
|
-
store.
|
|
280
|
+
store.updateOrder({ ...target, status: toStatus });
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
283
|
catch { }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PaginatedResponse } from
|
|
1
|
+
import type { PaginatedResponse } from "./pagination";
|
|
2
2
|
export interface OrderUser {
|
|
3
3
|
id: string;
|
|
4
4
|
full_name: string;
|
|
@@ -6,6 +6,19 @@ export interface OrderUser {
|
|
|
6
6
|
phone: string;
|
|
7
7
|
avatar_url: string | null;
|
|
8
8
|
}
|
|
9
|
+
/** Explicit customer identity. POS customers are typed names only, not users. */
|
|
10
|
+
export interface OrderCustomer {
|
|
11
|
+
user_id: string | null;
|
|
12
|
+
name: string | null;
|
|
13
|
+
phone: string | null;
|
|
14
|
+
avatar_url: string | null;
|
|
15
|
+
}
|
|
16
|
+
/** Business member who rang up a POS order. Distinct from the customer. */
|
|
17
|
+
export interface OrderTeller {
|
|
18
|
+
id: string;
|
|
19
|
+
full_name: string;
|
|
20
|
+
email?: string | null;
|
|
21
|
+
}
|
|
9
22
|
export interface OrderPayment {
|
|
10
23
|
is_paid: boolean;
|
|
11
24
|
reference: string | null;
|
|
@@ -123,6 +136,9 @@ export interface SellerOrder {
|
|
|
123
136
|
delivery_lat: number | null;
|
|
124
137
|
delivery_lng: number | null;
|
|
125
138
|
user: OrderUser | null;
|
|
139
|
+
/** Explicit customer vs staff split; older payloads may omit this. */
|
|
140
|
+
customer?: OrderCustomer | null;
|
|
141
|
+
teller?: OrderTeller | null;
|
|
126
142
|
items: OrderItem[];
|
|
127
143
|
inserted_at: string;
|
|
128
144
|
updated_at?: string;
|
|
@@ -16,6 +16,14 @@ type OrderLike = Pick<SellerOrder, "customer_name"> & {
|
|
|
16
16
|
avatar_url?: string;
|
|
17
17
|
phone?: string;
|
|
18
18
|
} | null;
|
|
19
|
+
customer?: {
|
|
20
|
+
name?: string | null;
|
|
21
|
+
phone?: string | null;
|
|
22
|
+
avatar_url?: string | null;
|
|
23
|
+
} | null;
|
|
24
|
+
teller?: {
|
|
25
|
+
full_name?: string | null;
|
|
26
|
+
} | null;
|
|
19
27
|
customer_phone?: string | null;
|
|
20
28
|
is_pos?: boolean;
|
|
21
29
|
};
|
|
@@ -11,30 +11,39 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export function getCustomerDisplay(order) {
|
|
13
13
|
if (order.is_pos) {
|
|
14
|
-
const
|
|
14
|
+
const typedName = order.customer?.name?.trim() || order.customer_name?.trim();
|
|
15
15
|
return {
|
|
16
|
-
name:
|
|
16
|
+
name: typedName || "Walk-in",
|
|
17
17
|
avatarUrl: null,
|
|
18
|
-
phone: order.customer_phone ?? null,
|
|
18
|
+
phone: order.customer?.phone ?? order.customer_phone ?? null,
|
|
19
19
|
isWalkIn: true,
|
|
20
|
-
tellerName: order.
|
|
20
|
+
tellerName: order.teller?.full_name?.trim() ??
|
|
21
|
+
order.user?.full_name?.trim() ??
|
|
22
|
+
null,
|
|
21
23
|
};
|
|
22
24
|
}
|
|
23
|
-
const rawName = order.
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const rawName = order.customer?.name?.trim() ||
|
|
26
|
+
order.customer_name?.trim() ||
|
|
27
|
+
order.user?.full_name?.trim() ||
|
|
28
|
+
"";
|
|
26
29
|
if (rawName) {
|
|
27
30
|
return {
|
|
28
31
|
name: rawName,
|
|
29
|
-
avatarUrl: order.user?.avatar_url ?? null,
|
|
30
|
-
phone: order.
|
|
32
|
+
avatarUrl: order.customer?.avatar_url ?? order.user?.avatar_url ?? null,
|
|
33
|
+
phone: order.customer?.phone ??
|
|
34
|
+
order.customer_phone ??
|
|
35
|
+
order.user?.phone ??
|
|
36
|
+
null,
|
|
31
37
|
isWalkIn: false,
|
|
32
38
|
};
|
|
33
39
|
}
|
|
34
40
|
return {
|
|
35
41
|
name: "Customer",
|
|
36
|
-
avatarUrl: order.user?.avatar_url ?? null,
|
|
37
|
-
phone: order.
|
|
42
|
+
avatarUrl: order.customer?.avatar_url ?? order.user?.avatar_url ?? null,
|
|
43
|
+
phone: order.customer?.phone ??
|
|
44
|
+
order.customer_phone ??
|
|
45
|
+
order.user?.phone ??
|
|
46
|
+
null,
|
|
38
47
|
isWalkIn: false,
|
|
39
48
|
};
|
|
40
49
|
}
|
package/package.json
CHANGED