@teincfood/core 0.1.4 → 0.1.6
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/services.d.ts +1 -0
- package/dist/adapters/services.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/reference/capabilities.d.ts +21 -0
- package/dist/reference/capabilities.js +81 -0
- package/dist/reference/service.js +8 -0
- package/dist/reference/types.d.ts +1 -1
- package/dist/sync/command-queue.service.d.ts +1 -0
- package/dist/sync/command-queue.service.js +75 -20
- package/dist/sync/order-snapshots.service.d.ts +7 -0
- package/dist/sync/order-snapshots.service.js +33 -7
- package/dist/sync/pending-orders.service.js +26 -1
- package/dist/utils/customer-display.d.ts +32 -0
- package/dist/utils/customer-display.js +55 -0
- package/dist/utils/order-filter.d.ts +25 -0
- package/dist/utils/order-filter.js +41 -0
- package/package.json +1 -1
|
@@ -46,6 +46,7 @@ export declare const businessService: {
|
|
|
46
46
|
fetchBusinessTaxRules: (_businessId: string) => Promise<unknown>;
|
|
47
47
|
fetchBusinessById: (_businessId: string) => Promise<unknown>;
|
|
48
48
|
fetchOperatingHours: (_businessId: string) => Promise<unknown>;
|
|
49
|
+
fetchMyBusinessMembership: (_businessId: string) => Promise<unknown>;
|
|
49
50
|
};
|
|
50
51
|
export declare const rolesService: {
|
|
51
52
|
fetchBusinessRoles: () => Promise<unknown>;
|
|
@@ -81,6 +81,9 @@ export const businessService = {
|
|
|
81
81
|
fetchOperatingHours: async (_businessId) => {
|
|
82
82
|
notImpl("businessService.fetchOperatingHours");
|
|
83
83
|
},
|
|
84
|
+
fetchMyBusinessMembership: async (_businessId) => {
|
|
85
|
+
notImpl("businessService.fetchMyBusinessMembership");
|
|
86
|
+
},
|
|
84
87
|
};
|
|
85
88
|
export const rolesService = {
|
|
86
89
|
fetchBusinessRoles: async () => {
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export { formatDate, formatRelativeTime } from "./utils/formatDate";
|
|
|
20
20
|
export * from "./utils/validation";
|
|
21
21
|
export * from "./utils/responsive";
|
|
22
22
|
export * from "./utils/copyToClipBoard";
|
|
23
|
+
export * from "./utils/customer-display";
|
|
24
|
+
export * from "./utils/order-filter";
|
|
23
25
|
export * from "./db/migrations";
|
|
24
26
|
export * from "./db/operations";
|
|
25
27
|
export * as dbOps from "./db/operations";
|
|
@@ -54,6 +56,7 @@ export { emitReferenceChanged, applyReferenceChangeEvent, syncReferenceDataOnRec
|
|
|
54
56
|
export * from "./reference/events";
|
|
55
57
|
export * from "./reference/service";
|
|
56
58
|
export { referenceSyncService } from "./reference/service";
|
|
59
|
+
export * from "./reference/capabilities";
|
|
57
60
|
export * from "./adapters/sqlite";
|
|
58
61
|
export * from "./adapters/kv";
|
|
59
62
|
export * from "./adapters/connectivity";
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,8 @@ export { formatDate, formatRelativeTime } from "./utils/formatDate";
|
|
|
21
21
|
export * from "./utils/validation";
|
|
22
22
|
export * from "./utils/responsive";
|
|
23
23
|
export * from "./utils/copyToClipBoard";
|
|
24
|
+
export * from "./utils/customer-display";
|
|
25
|
+
export * from "./utils/order-filter";
|
|
24
26
|
// ── DB ──
|
|
25
27
|
export * from "./db/migrations";
|
|
26
28
|
export * from "./db/operations";
|
|
@@ -55,6 +57,7 @@ export { emitReferenceChanged, applyReferenceChangeEvent, syncReferenceDataOnRec
|
|
|
55
57
|
export * from "./reference/events";
|
|
56
58
|
export * from "./reference/service";
|
|
57
59
|
export { referenceSyncService } from "./reference/service";
|
|
60
|
+
export * from "./reference/capabilities";
|
|
58
61
|
// ── Adapters (for host apps to supply) ──
|
|
59
62
|
export * from "./adapters/sqlite";
|
|
60
63
|
export * from "./adapters/kv";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capabilities reference — offline-first.
|
|
3
|
+
*
|
|
4
|
+
* Standardises business-membership capabilities for both mobile and desktop.
|
|
5
|
+
* The backend `GET /businesses/:id/my-membership` is the source of truth;
|
|
6
|
+
* the result is mirrored to `reference_data` key `capabilities` so drawer/nav
|
|
7
|
+
* filtering works offline and survives restarts.
|
|
8
|
+
*
|
|
9
|
+
* Host apps wire `businessService.fetchMyBusinessMembership` via adapters/services.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Standard hook for both apps — businessId comes from the host's active business store.
|
|
13
|
+
* Returns the same shape as the previous app-level useMyBusinessCapabilities.
|
|
14
|
+
*/
|
|
15
|
+
export declare function useBusinessCapabilities(businessId?: string | null): import("@tanstack/react-query").UseQueryResult<{
|
|
16
|
+
role: string;
|
|
17
|
+
source: string;
|
|
18
|
+
capabilities: string[];
|
|
19
|
+
status: string;
|
|
20
|
+
}, Error>;
|
|
21
|
+
export declare function useHasCapabilityFor(businessId: string | undefined, capability: string): boolean;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capabilities reference — offline-first.
|
|
3
|
+
*
|
|
4
|
+
* Standardises business-membership capabilities for both mobile and desktop.
|
|
5
|
+
* The backend `GET /businesses/:id/my-membership` is the source of truth;
|
|
6
|
+
* the result is mirrored to `reference_data` key `capabilities` so drawer/nav
|
|
7
|
+
* filtering works offline and survives restarts.
|
|
8
|
+
*
|
|
9
|
+
* Host apps wire `businessService.fetchMyBusinessMembership` via adapters/services.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useState } from "react";
|
|
12
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
|
+
import { openSyncDatabase } from "../db/connection";
|
|
14
|
+
import { getReferenceData, setReferenceData } from "./operations";
|
|
15
|
+
import { businessService } from "../adapters/services";
|
|
16
|
+
const MEMBERSHIP_KEYS = {
|
|
17
|
+
all: ["my-membership"],
|
|
18
|
+
byBusiness: (id) => [...MEMBERSHIP_KEYS.all, id],
|
|
19
|
+
};
|
|
20
|
+
function useCapabilitiesPlaceholder(businessId) {
|
|
21
|
+
const [cached, setCached] = useState(null);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!businessId) {
|
|
24
|
+
setCached(null);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let cancelled = false;
|
|
28
|
+
(async () => {
|
|
29
|
+
try {
|
|
30
|
+
const db = await openSyncDatabase();
|
|
31
|
+
const rec = await getReferenceData(db, businessId, "capabilities");
|
|
32
|
+
if (!cancelled && rec?.payload)
|
|
33
|
+
setCached(rec.payload);
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
})();
|
|
37
|
+
return () => {
|
|
38
|
+
cancelled = true;
|
|
39
|
+
};
|
|
40
|
+
}, [businessId]);
|
|
41
|
+
return cached;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Standard hook for both apps — businessId comes from the host's active business store.
|
|
45
|
+
* Returns the same shape as the previous app-level useMyBusinessCapabilities.
|
|
46
|
+
*/
|
|
47
|
+
export function useBusinessCapabilities(businessId) {
|
|
48
|
+
const queryClient = useQueryClient();
|
|
49
|
+
void queryClient;
|
|
50
|
+
const cached = useCapabilitiesPlaceholder(businessId ?? null);
|
|
51
|
+
return useQuery({
|
|
52
|
+
queryKey: MEMBERSHIP_KEYS.byBusiness(businessId ?? ""),
|
|
53
|
+
queryFn: async () => {
|
|
54
|
+
const res = (await businessService.fetchMyBusinessMembership(businessId));
|
|
55
|
+
try {
|
|
56
|
+
const db = await openSyncDatabase();
|
|
57
|
+
await setReferenceData(db, businessId, {
|
|
58
|
+
key: "capabilities",
|
|
59
|
+
version: `membership-${Date.now()}`,
|
|
60
|
+
payload: res,
|
|
61
|
+
downloadedAt: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
return res;
|
|
66
|
+
},
|
|
67
|
+
enabled: !!businessId,
|
|
68
|
+
staleTime: Infinity,
|
|
69
|
+
placeholderData: cached ?? undefined,
|
|
70
|
+
select: (data) => ({
|
|
71
|
+
role: data.data.role,
|
|
72
|
+
source: data.data.source,
|
|
73
|
+
capabilities: data.data.capabilities,
|
|
74
|
+
status: data.data.status,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
export function useHasCapabilityFor(businessId, capability) {
|
|
79
|
+
const { data } = useBusinessCapabilities(businessId);
|
|
80
|
+
return data?.capabilities.includes(capability) ?? false;
|
|
81
|
+
}
|
|
@@ -23,6 +23,7 @@ const DEFAULT_SYNC_KEYS = [
|
|
|
23
23
|
"business:profile",
|
|
24
24
|
"operating-hours",
|
|
25
25
|
"subscription:entitlements",
|
|
26
|
+
"capabilities",
|
|
26
27
|
];
|
|
27
28
|
class ReferenceSyncService {
|
|
28
29
|
constructor() {
|
|
@@ -204,6 +205,13 @@ class ReferenceSyncService {
|
|
|
204
205
|
version: this.hashPayload(response),
|
|
205
206
|
};
|
|
206
207
|
}
|
|
208
|
+
case "capabilities": {
|
|
209
|
+
const response = await businessService.fetchMyBusinessMembership(businessId);
|
|
210
|
+
return {
|
|
211
|
+
payload: response,
|
|
212
|
+
version: this.hashPayload(response),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
207
215
|
default:
|
|
208
216
|
throw new Error(`No fallback fetch available for ${key}`);
|
|
209
217
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* It is synchronized separately from transactional data through the
|
|
9
9
|
* ReferenceSyncService.
|
|
10
10
|
*/
|
|
11
|
-
export type ReferenceDataKey = "menu:items" | "menu:categories" | "roles" | "members" | "tax-rules" | "business:profile" | "operating-hours" | "subscription:entitlements";
|
|
11
|
+
export type ReferenceDataKey = "menu:items" | "menu:categories" | "roles" | "members" | "tax-rules" | "business:profile" | "operating-hours" | "subscription:entitlements" | "capabilities";
|
|
12
12
|
export interface ReferenceDataRecord<T = unknown> {
|
|
13
13
|
key: ReferenceDataKey;
|
|
14
14
|
version: string;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Command, DomainEvent, PersistedCommand } from "./types";
|
|
12
12
|
import type { SyncEngine } from "./engine";
|
|
13
|
+
export declare function getQueuedStatus(commandType: string): string | null;
|
|
13
14
|
declare class CommandQueueService {
|
|
14
15
|
private replaying;
|
|
15
16
|
private syncEngine;
|
|
@@ -14,8 +14,34 @@ import { syncLogger } from "../utils/sync-logger";
|
|
|
14
14
|
import { generateEventId } from "../utils/uuid";
|
|
15
15
|
import { generateLocalOrderNumber, generateLocalCalloutNumber, } from "./order-number";
|
|
16
16
|
const MAX_RETRIES = 5;
|
|
17
|
+
const QUEUED_STATUS_BY_COMMAND = {
|
|
18
|
+
"order:accept": "accepted",
|
|
19
|
+
"order:reject": "cancelled",
|
|
20
|
+
"order:start_preparing": "preparing",
|
|
21
|
+
"order:mark_ready": "ready",
|
|
22
|
+
"order:complete_pickup": "delivered",
|
|
23
|
+
"order:cancel": "cancelled",
|
|
24
|
+
"delivery:assign": "assigned",
|
|
25
|
+
"delivery:confirm_handover": "delivered",
|
|
26
|
+
};
|
|
27
|
+
export function getQueuedStatus(commandType) {
|
|
28
|
+
return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
|
|
29
|
+
}
|
|
17
30
|
function buildOptimisticEvent(command) {
|
|
18
31
|
const payload = command.payload;
|
|
32
|
+
if (command.command_type === "order:create") {
|
|
33
|
+
return {
|
|
34
|
+
event_id: generateEventId(),
|
|
35
|
+
event_type: `${command.command_type.replace(":", ".")}_queued`,
|
|
36
|
+
event_version: "1.0",
|
|
37
|
+
entity_id: command.entity_id ?? command.command_id,
|
|
38
|
+
user_id: command.user_id,
|
|
39
|
+
device_id: command.device_id,
|
|
40
|
+
payload: buildOptimisticOrder(command, payload),
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const queuedStatus = getQueuedStatus(command.command_type);
|
|
19
45
|
return {
|
|
20
46
|
event_id: generateEventId(),
|
|
21
47
|
event_type: `${command.command_type.replace(":", ".")}_queued`,
|
|
@@ -23,13 +49,15 @@ function buildOptimisticEvent(command) {
|
|
|
23
49
|
entity_id: command.entity_id ?? command.command_id,
|
|
24
50
|
user_id: command.user_id,
|
|
25
51
|
device_id: command.device_id,
|
|
26
|
-
payload:
|
|
27
|
-
|
|
28
|
-
:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
payload: {
|
|
53
|
+
...payload,
|
|
54
|
+
command_id: command.command_id,
|
|
55
|
+
status: "pending",
|
|
56
|
+
// Used by offline read-model patching so the list reflects the queued transition.
|
|
57
|
+
to_status: queuedStatus,
|
|
58
|
+
order_id: command.entity_id,
|
|
59
|
+
delivery_id: command.entity_id,
|
|
60
|
+
},
|
|
33
61
|
timestamp: new Date().toISOString(),
|
|
34
62
|
};
|
|
35
63
|
}
|
|
@@ -136,17 +164,17 @@ class CommandQueueService {
|
|
|
136
164
|
order_number: event.payload.order_number ?? null,
|
|
137
165
|
status: event.payload.status,
|
|
138
166
|
});
|
|
139
|
-
// Persist
|
|
140
|
-
// rehydrated
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
167
|
+
// Persist optimistic events so they survive restarts and can be
|
|
168
|
+
// rehydrated offline. For order:create this is the full order; for
|
|
169
|
+
// transitions it carries to_status so the read model can reflect the
|
|
170
|
+
// queued status immediately.
|
|
171
|
+
try {
|
|
172
|
+
await insertEvent(db, { event, origin: "local" });
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
syncLogger.error("CommandQueueService", "Failed to persist optimistic event", {
|
|
176
|
+
error: String(error),
|
|
177
|
+
});
|
|
150
178
|
}
|
|
151
179
|
return event;
|
|
152
180
|
}
|
|
@@ -188,6 +216,24 @@ class CommandQueueService {
|
|
|
188
216
|
}
|
|
189
217
|
catch (error) {
|
|
190
218
|
const message = error instanceof Error ? error.message : String(error);
|
|
219
|
+
// Distinguish retryable (network/5xx/timeout) vs non-retryable (4xx validation)
|
|
220
|
+
// so a rejected order does not block the entire outbox.
|
|
221
|
+
const apiErr = error;
|
|
222
|
+
const isNonRetryable = apiErr?.name === "ApiError" &&
|
|
223
|
+
typeof apiErr.status === "number" &&
|
|
224
|
+
apiErr.status >= 400 &&
|
|
225
|
+
apiErr.status < 500;
|
|
226
|
+
if (isNonRetryable) {
|
|
227
|
+
await updateCommandStatus(db, command.command_id, "failed", `permanent:${message}`);
|
|
228
|
+
syncLogger.warn("CommandQueueService", "Queued command rejected by server (not retrying)", {
|
|
229
|
+
command_id: command.command_id,
|
|
230
|
+
command_type: command.command_type,
|
|
231
|
+
status: apiErr.status,
|
|
232
|
+
error: message,
|
|
233
|
+
});
|
|
234
|
+
// Do not throw — allow replay to continue to next command.
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
191
237
|
await updateCommandStatus(db, command.command_id, "failed", message);
|
|
192
238
|
syncLogger.command(parsedCommand, "failed", { error: message });
|
|
193
239
|
throw error;
|
|
@@ -218,8 +264,17 @@ class CommandQueueService {
|
|
|
218
264
|
try {
|
|
219
265
|
await this.processCommand(command);
|
|
220
266
|
}
|
|
221
|
-
catch {
|
|
222
|
-
|
|
267
|
+
catch (e) {
|
|
268
|
+
const apiErr = e;
|
|
269
|
+
const isNonRetryable = apiErr?.name === "ApiError" &&
|
|
270
|
+
typeof apiErr.status === "number" &&
|
|
271
|
+
apiErr.status >= 400 &&
|
|
272
|
+
apiErr.status < 500;
|
|
273
|
+
if (isNonRetryable) {
|
|
274
|
+
// Already marked permanent in processCommand — continue to next.
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
// Network/5xx — stop to preserve ordering; retry later.
|
|
223
278
|
break;
|
|
224
279
|
}
|
|
225
280
|
}
|
|
@@ -22,6 +22,13 @@ declare class OrderSnapshotsService {
|
|
|
22
22
|
* duplicates).
|
|
23
23
|
*/
|
|
24
24
|
persistOrderEvent(event: DomainEvent, origin: "local" | "lan" | "cloud"): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Patch the latest status for a queued transition (e.g. accept_queued)
|
|
27
|
+
* carrying to_status/order_id. Used so offline devices see the new status
|
|
28
|
+
* immediately and after restart via the durable snapshot.
|
|
29
|
+
*/
|
|
30
|
+
applyQueuedStatusEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
|
|
31
|
+
private applyOrderStatusToReadModelInternal;
|
|
25
32
|
/**
|
|
26
33
|
* Patch the latest status of a known order inside every list snapshot for the
|
|
27
34
|
* business, so offline devices see live status changes without a refetch.
|
|
@@ -50,17 +50,29 @@ class OrderSnapshotsService {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
-
* Patch the latest status
|
|
54
|
-
*
|
|
53
|
+
* Patch the latest status for a queued transition (e.g. accept_queued)
|
|
54
|
+
* carrying to_status/order_id. Used so offline devices see the new status
|
|
55
|
+
* immediately and after restart via the durable snapshot.
|
|
55
56
|
*/
|
|
56
|
-
async
|
|
57
|
+
async applyQueuedStatusEvent(event, origin = "local") {
|
|
57
58
|
const payload = event.payload;
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
if (!orderId || !toStatus || event.event_type !== "order.status_changed") {
|
|
59
|
+
const toStatus = payload?.to_status;
|
|
60
|
+
const orderId = payload?.order_id ?? event.entity_id;
|
|
61
|
+
if (!toStatus || !orderId)
|
|
62
62
|
return;
|
|
63
|
+
try {
|
|
64
|
+
const db = await openSyncDatabase();
|
|
65
|
+
await insertEvent(db, { event, origin });
|
|
66
|
+
await this.applyOrderStatusToReadModelInternal(db, orderId, toStatus, event);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
syncLogger.error("OrderSnapshots", "Failed to apply queued status", {
|
|
70
|
+
error: String(error),
|
|
71
|
+
event_type: event.event_type,
|
|
72
|
+
});
|
|
63
73
|
}
|
|
74
|
+
}
|
|
75
|
+
async applyOrderStatusToReadModelInternal(db, orderId, toStatus, event) {
|
|
64
76
|
try {
|
|
65
77
|
const lists = await getEntitySnapshotsByType(db, "seller_orders");
|
|
66
78
|
for (const list of lists) {
|
|
@@ -85,6 +97,20 @@ class OrderSnapshotsService {
|
|
|
85
97
|
});
|
|
86
98
|
}
|
|
87
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Patch the latest status of a known order inside every list snapshot for the
|
|
102
|
+
* business, so offline devices see live status changes without a refetch.
|
|
103
|
+
*/
|
|
104
|
+
async applyOrderStatusToReadModel(db, event) {
|
|
105
|
+
const payload = event.payload;
|
|
106
|
+
const orderId = payload?.order_id;
|
|
107
|
+
const toStatus = payload?.to_status ??
|
|
108
|
+
payload?.status;
|
|
109
|
+
if (!orderId || !toStatus || event.event_type !== "order.status_changed") {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await this.applyOrderStatusToReadModelInternal(db, orderId, toStatus, event);
|
|
113
|
+
}
|
|
88
114
|
/**
|
|
89
115
|
* Write through a fetched order list so it is available offline. Used by the
|
|
90
116
|
* seller and POS order queries. `scope` distinguishes complementary lists
|
|
@@ -26,6 +26,16 @@ const ORDER_STATE_EVENT_TYPES = new Set([
|
|
|
26
26
|
"delivery.status_changed",
|
|
27
27
|
"delivery.assigned",
|
|
28
28
|
]);
|
|
29
|
+
const QUEUED_STATUS_EVENT_TYPES = new Set([
|
|
30
|
+
"order.accept_queued",
|
|
31
|
+
"order.reject_queued",
|
|
32
|
+
"order.start_preparing_queued",
|
|
33
|
+
"order.mark_ready_queued",
|
|
34
|
+
"order.complete_pickup_queued",
|
|
35
|
+
"order.cancel_queued",
|
|
36
|
+
"delivery.assign_queued",
|
|
37
|
+
"delivery.confirm_handover_queued",
|
|
38
|
+
]);
|
|
29
39
|
let invalidator = null;
|
|
30
40
|
/**
|
|
31
41
|
* Register a callback used to invalidate TanStack Query caches when a pending
|
|
@@ -88,7 +98,8 @@ export async function hydratePendingOrders() {
|
|
|
88
98
|
*/
|
|
89
99
|
export async function applyOrderStateChangeEvent(event, origin = "local") {
|
|
90
100
|
try {
|
|
91
|
-
|
|
101
|
+
const isQueued = QUEUED_STATUS_EVENT_TYPES.has(event.event_type);
|
|
102
|
+
if (!ORDER_STATE_EVENT_TYPES.has(event.event_type) && !isQueued)
|
|
92
103
|
return;
|
|
93
104
|
syncLogger.debug("OrderState", "Order state changed", {
|
|
94
105
|
origin,
|
|
@@ -97,6 +108,12 @@ export async function applyOrderStateChangeEvent(event, origin = "local") {
|
|
|
97
108
|
});
|
|
98
109
|
// Persist the event into the durable journal and patch the offline read
|
|
99
110
|
// model so every terminal keeps the same order states after a restart.
|
|
111
|
+
// Queued transitions carry to_status/order_id so they patch instantly offline.
|
|
112
|
+
if (isQueued) {
|
|
113
|
+
await orderSnapshotsService.applyQueuedStatusEvent(event, origin);
|
|
114
|
+
invalidator?.();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
100
117
|
await orderSnapshotsService.persistOrderEvent(event, origin);
|
|
101
118
|
invalidator?.();
|
|
102
119
|
}
|
|
@@ -118,6 +135,14 @@ export async function applyPendingOrderEvent(event, origin = "local") {
|
|
|
118
135
|
await insertEvent(db, { event, origin });
|
|
119
136
|
return;
|
|
120
137
|
}
|
|
138
|
+
if (QUEUED_STATUS_EVENT_TYPES.has(event.event_type)) {
|
|
139
|
+
// Queued transitions: patch the read model so listing reflects new status offline.
|
|
140
|
+
await orderSnapshotsService.applyQueuedStatusEvent(event, origin);
|
|
141
|
+
const db = await openSyncDatabase();
|
|
142
|
+
await insertEvent(db, { event, origin });
|
|
143
|
+
invalidator?.();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
121
146
|
if (event.event_type === "order.created") {
|
|
122
147
|
const payload = event.payload;
|
|
123
148
|
const localOrderNumber = payload?.local_order_number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer display helpers — offline-first, shared by mobile + desktop.
|
|
3
|
+
* SellerOrder shape comes from backend order_serializer (customer_name, is_pos, user{full_name, avatar_url, phone}).
|
|
4
|
+
*/
|
|
5
|
+
import type { SellerOrder } from "../types/api/seller.api";
|
|
6
|
+
export type CustomerDisplay = {
|
|
7
|
+
name: string;
|
|
8
|
+
avatarUrl: string | null;
|
|
9
|
+
phone: string | null;
|
|
10
|
+
isWalkIn: boolean;
|
|
11
|
+
tellerName?: string | null;
|
|
12
|
+
};
|
|
13
|
+
type OrderLike = Pick<SellerOrder, "customer_name"> & {
|
|
14
|
+
user?: {
|
|
15
|
+
full_name?: string;
|
|
16
|
+
avatar_url?: string;
|
|
17
|
+
phone?: string;
|
|
18
|
+
} | null;
|
|
19
|
+
customer_phone?: string | null;
|
|
20
|
+
is_pos?: boolean;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Single source of truth for customer identity.
|
|
24
|
+
* - is_pos:true (POS walk-in): customer is walk-in/typed name, never the staff user.
|
|
25
|
+
* Staff is the teller (order.user) — do not show staff avatar as customer.
|
|
26
|
+
* Returns tellerName so UI can optionally show "Teller: <name>" underneath.
|
|
27
|
+
* - is_pos:false (WhatsApp / online / AI agent): customer_name || user.full_name || "Customer" + avatar from user.avatar_url
|
|
28
|
+
*/
|
|
29
|
+
export declare function getCustomerDisplay(order: OrderLike): CustomerDisplay;
|
|
30
|
+
export declare function getCustomerSearchKeys(order: OrderLike): string[];
|
|
31
|
+
export declare function matchesCustomerSearch(order: OrderLike, qLower: string): boolean;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer display helpers — offline-first, shared by mobile + desktop.
|
|
3
|
+
* SellerOrder shape comes from backend order_serializer (customer_name, is_pos, user{full_name, avatar_url, phone}).
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Single source of truth for customer identity.
|
|
7
|
+
* - is_pos:true (POS walk-in): customer is walk-in/typed name, never the staff user.
|
|
8
|
+
* Staff is the teller (order.user) — do not show staff avatar as customer.
|
|
9
|
+
* Returns tellerName so UI can optionally show "Teller: <name>" underneath.
|
|
10
|
+
* - is_pos:false (WhatsApp / online / AI agent): customer_name || user.full_name || "Customer" + avatar from user.avatar_url
|
|
11
|
+
*/
|
|
12
|
+
export function getCustomerDisplay(order) {
|
|
13
|
+
if (order.is_pos) {
|
|
14
|
+
const walkName = order.customer_name?.trim() ? order.customer_name.trim() : "Walk-in";
|
|
15
|
+
return {
|
|
16
|
+
name: walkName,
|
|
17
|
+
avatarUrl: null,
|
|
18
|
+
phone: order.customer_phone ?? null,
|
|
19
|
+
isWalkIn: true,
|
|
20
|
+
tellerName: order.user?.full_name?.trim() ?? null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const rawName = order.customer_name?.trim()
|
|
24
|
+
? order.customer_name.trim()
|
|
25
|
+
: order.user?.full_name?.trim() ?? "";
|
|
26
|
+
if (rawName) {
|
|
27
|
+
return {
|
|
28
|
+
name: rawName,
|
|
29
|
+
avatarUrl: order.user?.avatar_url ?? null,
|
|
30
|
+
phone: order.customer_phone ?? order.user?.phone ?? null,
|
|
31
|
+
isWalkIn: false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
name: "Customer",
|
|
36
|
+
avatarUrl: order.user?.avatar_url ?? null,
|
|
37
|
+
phone: order.customer_phone ?? order.user?.phone ?? null,
|
|
38
|
+
isWalkIn: false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function getCustomerSearchKeys(order) {
|
|
42
|
+
return [
|
|
43
|
+
order.customer_name ?? "",
|
|
44
|
+
order.user?.full_name ?? "",
|
|
45
|
+
order.customer_phone ?? "",
|
|
46
|
+
order.user?.phone ?? "",
|
|
47
|
+
order.user?.avatar_url ?? "",
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
export function matchesCustomerSearch(order, qLower) {
|
|
51
|
+
if (!qLower)
|
|
52
|
+
return true;
|
|
53
|
+
const hay = `${order.customer_name ?? ""} ${order.user?.full_name ?? ""} ${order.customer_phone ?? ""} ${order.user?.phone ?? ""}`.toLowerCase();
|
|
54
|
+
return hay.includes(qLower);
|
|
55
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline order list helpers — shared filtering + pagination for
|
|
3
|
+
* usePOSOrdersQuery / useSellerOrdersQuery snapshot fallbacks.
|
|
4
|
+
*/
|
|
5
|
+
import type { SellerOrder } from "../types/api/seller.api";
|
|
6
|
+
export declare function filterOrdersOffline(orders: SellerOrder[], filters?: {
|
|
7
|
+
status?: string;
|
|
8
|
+
order_type?: string;
|
|
9
|
+
is_pos?: string;
|
|
10
|
+
q?: string;
|
|
11
|
+
search?: string;
|
|
12
|
+
}): SellerOrder[];
|
|
13
|
+
export declare function filterSellerOrdersOffline(orders: SellerOrder[], filters?: {
|
|
14
|
+
status?: string;
|
|
15
|
+
q?: string;
|
|
16
|
+
}): SellerOrder[];
|
|
17
|
+
export declare function paginateOffline<T>(orders: T[], page: number, pageSize?: number): {
|
|
18
|
+
data: T[];
|
|
19
|
+
pagination: {
|
|
20
|
+
page: number;
|
|
21
|
+
page_size: number;
|
|
22
|
+
total_pages: number;
|
|
23
|
+
total_count: number;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline order list helpers — shared filtering + pagination for
|
|
3
|
+
* usePOSOrdersQuery / useSellerOrdersQuery snapshot fallbacks.
|
|
4
|
+
*/
|
|
5
|
+
import { matchesCustomerSearch } from "./customer-display";
|
|
6
|
+
export function filterOrdersOffline(orders, filters) {
|
|
7
|
+
let filtered = orders;
|
|
8
|
+
const statusSet = filters?.status ? new Set(filters.status.split(",").map((s) => s.trim())) : null;
|
|
9
|
+
if (statusSet)
|
|
10
|
+
filtered = filtered.filter((o) => statusSet.has(o.status));
|
|
11
|
+
if (filters?.order_type)
|
|
12
|
+
filtered = filtered.filter((o) => (o.order_type ?? "pos") === filters.order_type);
|
|
13
|
+
if (filters?.is_pos !== undefined) {
|
|
14
|
+
const wantPos = filters.is_pos === "true" || filters.is_pos === "1";
|
|
15
|
+
filtered = filtered.filter((o) => !!o.is_pos === wantPos);
|
|
16
|
+
}
|
|
17
|
+
const q = (filters?.q ?? filters?.search ?? "").toLowerCase();
|
|
18
|
+
if (q) {
|
|
19
|
+
filtered = filtered.filter((o) => o.order_number.toLowerCase().includes(q) ||
|
|
20
|
+
matchesCustomerSearch(o, q));
|
|
21
|
+
}
|
|
22
|
+
return filtered;
|
|
23
|
+
}
|
|
24
|
+
export function filterSellerOrdersOffline(orders, filters) {
|
|
25
|
+
let filtered = orders;
|
|
26
|
+
if (filters?.status) {
|
|
27
|
+
const set = new Set(filters.status.split(",").map((s) => s.trim()));
|
|
28
|
+
filtered = filtered.filter((o) => set.has(o.status));
|
|
29
|
+
}
|
|
30
|
+
if (filters?.q) {
|
|
31
|
+
const q = filters.q.toLowerCase();
|
|
32
|
+
filtered = filtered.filter((o) => o.order_number.toLowerCase().includes(q) || matchesCustomerSearch(o, q));
|
|
33
|
+
}
|
|
34
|
+
return filtered;
|
|
35
|
+
}
|
|
36
|
+
export function paginateOffline(orders, page, pageSize = 20) {
|
|
37
|
+
const start = (page - 1) * pageSize;
|
|
38
|
+
const slice = orders.slice(start, start + pageSize);
|
|
39
|
+
const totalPages = Math.max(1, Math.ceil(orders.length / pageSize));
|
|
40
|
+
return { data: slice, pagination: { page, page_size: pageSize, total_pages: totalPages, total_count: orders.length } };
|
|
41
|
+
}
|
package/package.json
CHANGED