@teincfood/core 0.10.14 → 0.10.16
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/auth.d.ts +3 -0
- package/dist/db/migrations.js +25 -0
- package/dist/db/operations.d.ts +1 -0
- package/dist/db/operations.js +8 -7
- package/dist/domain/orders/service.js +13 -2
- package/dist/hooks/analytics.hooks.d.ts +65 -4
- package/dist/hooks/analytics.hooks.js +213 -62
- package/dist/sync/command-queue.service.js +13 -1
- package/dist/test-helpers/memory-driver.js +3 -3
- package/package.json +1 -1
package/dist/adapters/auth.d.ts
CHANGED
package/dist/db/migrations.js
CHANGED
|
@@ -448,4 +448,29 @@ export const MIGRATIONS = [
|
|
|
448
448
|
catch { }
|
|
449
449
|
},
|
|
450
450
|
},
|
|
451
|
+
{
|
|
452
|
+
version: 13,
|
|
453
|
+
up: async (db) => {
|
|
454
|
+
// Online orders need their prepaid/pay-on-delivery mode locally so the
|
|
455
|
+
// analytics payment breakdown mirrors the backend. POS orders keep
|
|
456
|
+
// `pos_payment_method` as the authoritative method.
|
|
457
|
+
await db.execAsync(`
|
|
458
|
+
ALTER TABLE orders ADD COLUMN payment_mode TEXT;
|
|
459
|
+
`);
|
|
460
|
+
// Best-effort backfill: POS rows are prepaid at the order level; online
|
|
461
|
+
// rows without a value are assumed prepaid until the next pull.
|
|
462
|
+
try {
|
|
463
|
+
await db.execAsync(`
|
|
464
|
+
UPDATE orders
|
|
465
|
+
SET payment_mode = CASE
|
|
466
|
+
WHEN is_pos = 1 THEN 'prepaid'
|
|
467
|
+
WHEN pos_payment_method IS NULL THEN 'prepaid'
|
|
468
|
+
ELSE pos_payment_method
|
|
469
|
+
END
|
|
470
|
+
WHERE payment_mode IS NULL;
|
|
471
|
+
`);
|
|
472
|
+
}
|
|
473
|
+
catch { }
|
|
474
|
+
},
|
|
475
|
+
},
|
|
451
476
|
];
|
package/dist/db/operations.d.ts
CHANGED
package/dist/db/operations.js
CHANGED
|
@@ -256,6 +256,7 @@ function orderRowToSellerOrder(row) {
|
|
|
256
256
|
customer_name: row.customer_name,
|
|
257
257
|
customer_phone: row.customer_phone,
|
|
258
258
|
pos_payment_method: row.pos_payment_method,
|
|
259
|
+
payment_mode: row.payment_mode ?? "prepaid",
|
|
259
260
|
delivery_id: row.delivery_id,
|
|
260
261
|
business: row.business ? JSON.parse(row.business) : null,
|
|
261
262
|
delivery_address: null,
|
|
@@ -283,10 +284,10 @@ export async function insertOrder(db, order) {
|
|
|
283
284
|
const paidAt = order.paid_at ??
|
|
284
285
|
order.payment?.paid_at ??
|
|
285
286
|
(isOrderPaid(order) ? updatedAt : null);
|
|
286
|
-
await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc)
|
|
287
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
288
|
-
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
|
|
289
|
-
WHERE excluded.hlc >= orders.hlc OR orders.hlc IS NULL;`, order.id, order.business_id, order.order_number, order.callout_number, order.status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items), order.subtotal_minor, order.total_minor, order.tax_minor, JSON.stringify(order.tax_lines), JSON.stringify(order.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);
|
|
287
|
+
await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc)
|
|
288
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
289
|
+
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, payment_mode=excluded.payment_mode, 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
|
|
290
|
+
WHERE excluded.hlc >= orders.hlc OR orders.hlc IS NULL;`, order.id, order.business_id, order.order_number, order.callout_number, order.status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items), order.subtotal_minor, order.total_minor, order.tax_minor, JSON.stringify(order.tax_lines), JSON.stringify(order.payment), paidAt, order.customer_name, order.customer_phone, order.pos_payment_method, order.payment_mode ?? null, order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
|
|
290
291
|
notifyDbChange(order.business_id, "orders");
|
|
291
292
|
}
|
|
292
293
|
export async function getOrderById(db, businessId, idOrNumber) {
|
|
@@ -371,9 +372,9 @@ export async function upsertServerOrder(db, order, opts = {}) {
|
|
|
371
372
|
order.inserted_at ??
|
|
372
373
|
new Date().toISOString();
|
|
373
374
|
const updatedAt = order.updated_at ?? order.inserted_at;
|
|
374
|
-
await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc)
|
|
375
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
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);
|
|
375
|
+
await db.runAsync(`INSERT INTO orders (id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc)
|
|
376
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
377
|
+
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, payment_mode=excluded.payment_mode, 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.payment_mode ?? (order.is_pos ? "prepaid" : "prepaid"), order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
|
|
377
378
|
notifyDbChange(order.business_id, "orders");
|
|
378
379
|
}
|
|
379
380
|
/**
|
|
@@ -18,7 +18,7 @@ import { validateOrderCreateInput } from "../../validation/offline";
|
|
|
18
18
|
import { buildItemSnapshots, snapshotsToOrderLines } from "./snapshots";
|
|
19
19
|
import { getBusinessOrderFlowMode, initialPosOrderStatus } from "./order-flow";
|
|
20
20
|
import { drainOne, pullRemoteOrders } from "../../sync/sync-jobs.service";
|
|
21
|
-
import { getAuthToken } from "../../adapters/auth";
|
|
21
|
+
import { getAuthToken, useAuthStore } from "../../adapters/auth";
|
|
22
22
|
import { canBusinessSync } from "../../sync/sync-guard";
|
|
23
23
|
import { syncLogger } from "../../utils/sync-logger";
|
|
24
24
|
import { getItem } from "../../adapters/kv";
|
|
@@ -130,6 +130,16 @@ export async function createLocalOrder(businessId, input) {
|
|
|
130
130
|
const taxMinor = input.tax_minor ?? 0;
|
|
131
131
|
const taxLines = input.tax_lines ?? [];
|
|
132
132
|
const currencyCode = input.currency_code ?? "GHS";
|
|
133
|
+
const authUser = useAuthStore.getState().user;
|
|
134
|
+
const teller = authUser
|
|
135
|
+
? {
|
|
136
|
+
id: authUser.id,
|
|
137
|
+
full_name: authUser.full_name ?? authUser.email ?? "Staff",
|
|
138
|
+
email: authUser.email ?? "",
|
|
139
|
+
phone: authUser.phone ?? "",
|
|
140
|
+
avatar_url: authUser.avatar_url ?? null,
|
|
141
|
+
}
|
|
142
|
+
: null;
|
|
133
143
|
const order = {
|
|
134
144
|
id,
|
|
135
145
|
order_number: orderNumber,
|
|
@@ -161,7 +171,8 @@ export async function createLocalOrder(businessId, input) {
|
|
|
161
171
|
delivery_lng: null,
|
|
162
172
|
delivery_id: null,
|
|
163
173
|
delivery_status: null,
|
|
164
|
-
user:
|
|
174
|
+
user: teller,
|
|
175
|
+
teller,
|
|
165
176
|
items: items,
|
|
166
177
|
inserted_at: insertedAt,
|
|
167
178
|
};
|
|
@@ -21,10 +21,16 @@ interface LocalOrderRow {
|
|
|
21
21
|
status: string;
|
|
22
22
|
total_minor: number;
|
|
23
23
|
subtotal_minor?: number;
|
|
24
|
+
currency?: string;
|
|
24
25
|
inserted_at: string;
|
|
26
|
+
is_pos?: boolean;
|
|
25
27
|
payment_mode?: string;
|
|
26
28
|
pos_payment_method?: string;
|
|
27
29
|
order_type?: string;
|
|
30
|
+
user?: {
|
|
31
|
+
id?: string;
|
|
32
|
+
full_name?: string | null;
|
|
33
|
+
} | null;
|
|
28
34
|
items?: Array<{
|
|
29
35
|
quantity?: number;
|
|
30
36
|
subtotal?: number;
|
|
@@ -52,6 +58,7 @@ interface AggregateOptions {
|
|
|
52
58
|
export declare function filterByPeriod(orders: LocalOrderRow[], period: AnalyticsPeriod, now?: Date, range?: AnalyticsRange): LocalOrderRow[];
|
|
53
59
|
export declare function aggregateLocal(orders: LocalOrderRow[], period: AnalyticsPeriod, opts: AggregateOptions, range?: AnalyticsRange): {
|
|
54
60
|
period: AnalyticsPeriod;
|
|
61
|
+
currency: string;
|
|
55
62
|
total_revenue_minor: number;
|
|
56
63
|
total_orders: number;
|
|
57
64
|
average_order_value_minor: number;
|
|
@@ -71,16 +78,33 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: Analytic
|
|
|
71
78
|
count: number;
|
|
72
79
|
date: string;
|
|
73
80
|
}[];
|
|
81
|
+
by_channel: {
|
|
82
|
+
revenue_minor: number;
|
|
83
|
+
count: number;
|
|
84
|
+
channel: string;
|
|
85
|
+
percentage: number;
|
|
86
|
+
}[];
|
|
87
|
+
by_teller: {
|
|
88
|
+
user_id: string;
|
|
89
|
+
name: string;
|
|
90
|
+
revenue_minor: number;
|
|
91
|
+
count: number;
|
|
92
|
+
average_order_value_minor: number;
|
|
93
|
+
}[];
|
|
94
|
+
by_hour: {
|
|
95
|
+
hour: number;
|
|
96
|
+
revenue_minor: number;
|
|
97
|
+
count: number;
|
|
98
|
+
}[];
|
|
74
99
|
offline: boolean;
|
|
75
100
|
total_refunds_minor?: undefined;
|
|
76
101
|
top_items?: undefined;
|
|
77
102
|
orders_by_status?: undefined;
|
|
78
103
|
revenue_by_day?: undefined;
|
|
79
104
|
} | {
|
|
80
|
-
by_payment_method?: undefined;
|
|
81
|
-
by_order_type?: undefined;
|
|
82
105
|
by_day?: undefined;
|
|
83
106
|
period: AnalyticsPeriod;
|
|
107
|
+
currency: string;
|
|
84
108
|
total_revenue_minor: number;
|
|
85
109
|
total_orders: number;
|
|
86
110
|
average_order_value_minor: number;
|
|
@@ -99,8 +123,45 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: Analytic
|
|
|
99
123
|
date: string;
|
|
100
124
|
revenue_minor: number;
|
|
101
125
|
}[];
|
|
126
|
+
by_channel: {
|
|
127
|
+
revenue_minor: number;
|
|
128
|
+
count: number;
|
|
129
|
+
channel: string;
|
|
130
|
+
percentage: number;
|
|
131
|
+
}[];
|
|
132
|
+
by_payment_method: {
|
|
133
|
+
revenue_minor: number;
|
|
134
|
+
count: number;
|
|
135
|
+
method: string;
|
|
136
|
+
percentage: number;
|
|
137
|
+
}[];
|
|
138
|
+
by_order_type: {
|
|
139
|
+
revenue_minor: number;
|
|
140
|
+
count: number;
|
|
141
|
+
type: string;
|
|
142
|
+
}[];
|
|
143
|
+
by_teller: {
|
|
144
|
+
user_id: string;
|
|
145
|
+
name: string;
|
|
146
|
+
revenue_minor: number;
|
|
147
|
+
count: number;
|
|
148
|
+
average_order_value_minor: number;
|
|
149
|
+
}[];
|
|
150
|
+
by_hour: {
|
|
151
|
+
hour: number;
|
|
152
|
+
revenue_minor: number;
|
|
153
|
+
count: number;
|
|
154
|
+
}[];
|
|
102
155
|
offline: boolean;
|
|
103
156
|
};
|
|
104
|
-
|
|
105
|
-
|
|
157
|
+
interface AnalyticsPayload {
|
|
158
|
+
total_orders?: number;
|
|
159
|
+
[key: string]: unknown;
|
|
160
|
+
}
|
|
161
|
+
interface AnalyticsResponse {
|
|
162
|
+
data?: AnalyticsPayload;
|
|
163
|
+
[key: string]: unknown;
|
|
164
|
+
}
|
|
165
|
+
export declare function useSellerAnalyticsQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<AnalyticsResponse, Error>;
|
|
166
|
+
export declare function useRevenueSummaryQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<AnalyticsResponse, Error>;
|
|
106
167
|
export {};
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
* Offline-first: local SQLite aggregates serve instantly; the network
|
|
4
4
|
* refreshes silently when online.
|
|
5
5
|
*/
|
|
6
|
-
import { useQuery } from "@tanstack/react-query";
|
|
7
|
-
import { useSyncExternalStore } from "react";
|
|
6
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
8
8
|
import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
|
|
9
9
|
import { analyticsService } from "../adapters/services";
|
|
10
10
|
import { listLocalOrders } from "../domain/orders/service";
|
|
11
|
+
import { pullRemoteOrders } from "../sync/sync-jobs.service";
|
|
12
|
+
import { syncLogger } from "../utils/sync-logger";
|
|
11
13
|
import { useIsEffectivelyOnline } from "./use-infinite-list";
|
|
12
14
|
export const SELLER_ANALYTICS_KEYS = {
|
|
13
15
|
all: ["seller-analytics"],
|
|
@@ -96,10 +98,25 @@ export function aggregateLocal(orders, period, opts, range) {
|
|
|
96
98
|
const byMethod = new Map();
|
|
97
99
|
const byType = new Map();
|
|
98
100
|
const byDay = new Map();
|
|
101
|
+
const byChannel = new Map();
|
|
102
|
+
const byTeller = new Map();
|
|
103
|
+
const byHour = new Map();
|
|
99
104
|
const itemQty = new Map();
|
|
100
105
|
const itemRev = new Map();
|
|
101
106
|
for (const o of revenueOrders) {
|
|
102
|
-
|
|
107
|
+
// `is_pos` is the only online/local discriminator — same as backend.
|
|
108
|
+
const channel = o.is_pos ? "local" : "online";
|
|
109
|
+
const ch = byChannel.get(channel) ?? { revenue_minor: 0, count: 0 };
|
|
110
|
+
ch.revenue_minor += o.total_minor ?? 0;
|
|
111
|
+
ch.count += 1;
|
|
112
|
+
byChannel.set(channel, ch);
|
|
113
|
+
// POS uses the tendered method; online orders cannot truthfully be
|
|
114
|
+
// labelled card/MoMo from order data, so prepaid stays "online_prepaid".
|
|
115
|
+
const method = o.is_pos
|
|
116
|
+
? (o.pos_payment_method ?? "cash")
|
|
117
|
+
: o.payment_mode === "pay_on_delivery"
|
|
118
|
+
? "pay_on_delivery"
|
|
119
|
+
: "online_prepaid";
|
|
103
120
|
const m = byMethod.get(method) ?? { revenue_minor: 0, count: 0 };
|
|
104
121
|
m.revenue_minor += o.total_minor ?? 0;
|
|
105
122
|
m.count += 1;
|
|
@@ -114,6 +131,25 @@ export function aggregateLocal(orders, period, opts, range) {
|
|
|
114
131
|
d.revenue_minor += o.total_minor ?? 0;
|
|
115
132
|
d.count += 1;
|
|
116
133
|
byDay.set(day, d);
|
|
134
|
+
if (o.is_pos) {
|
|
135
|
+
const key = o.user?.id ?? "unassigned";
|
|
136
|
+
const teller = byTeller.get(key) ?? {
|
|
137
|
+
name: o.user?.full_name ?? "Unassigned",
|
|
138
|
+
revenue_minor: 0,
|
|
139
|
+
count: 0,
|
|
140
|
+
};
|
|
141
|
+
teller.revenue_minor += o.total_minor ?? 0;
|
|
142
|
+
teller.count += 1;
|
|
143
|
+
byTeller.set(key, teller);
|
|
144
|
+
}
|
|
145
|
+
const hourDate = new Date(o.inserted_at ?? "");
|
|
146
|
+
if (!Number.isNaN(hourDate.getTime())) {
|
|
147
|
+
const hour = String(hourDate.getUTCHours());
|
|
148
|
+
const h = byHour.get(hour) ?? { revenue_minor: 0, count: 0 };
|
|
149
|
+
h.revenue_minor += o.total_minor ?? 0;
|
|
150
|
+
h.count += 1;
|
|
151
|
+
byHour.set(hour, h);
|
|
152
|
+
}
|
|
117
153
|
for (const it of o.items ?? []) {
|
|
118
154
|
const name = it.menu_item?.name ?? "Unknown";
|
|
119
155
|
const qty = it.quantity ?? 0;
|
|
@@ -131,30 +167,63 @@ export function aggregateLocal(orders, period, opts, range) {
|
|
|
131
167
|
cancelled: filtered.filter((o) => o.status === "cancelled").length,
|
|
132
168
|
pending: filtered.filter((o) => o.status === "pending").length,
|
|
133
169
|
};
|
|
170
|
+
const by_channel = [...byChannel.entries()]
|
|
171
|
+
.map(([channel, v]) => ({
|
|
172
|
+
channel,
|
|
173
|
+
...v,
|
|
174
|
+
percentage: pct(v.revenue_minor),
|
|
175
|
+
}))
|
|
176
|
+
.sort((a, b) => b.revenue_minor - a.revenue_minor);
|
|
177
|
+
const by_payment_method = [...byMethod.entries()]
|
|
178
|
+
.map(([method, v]) => ({
|
|
179
|
+
method,
|
|
180
|
+
...v,
|
|
181
|
+
percentage: pct(v.revenue_minor),
|
|
182
|
+
}))
|
|
183
|
+
.sort((a, b) => b.revenue_minor - a.revenue_minor);
|
|
184
|
+
const by_order_type = [...byType.entries()]
|
|
185
|
+
.map(([type, v]) => ({ type, ...v }))
|
|
186
|
+
.sort((a, b) => b.revenue_minor - a.revenue_minor || a.type.localeCompare(b.type));
|
|
187
|
+
const by_teller = [...byTeller.entries()]
|
|
188
|
+
.map(([user_id, v]) => ({
|
|
189
|
+
user_id,
|
|
190
|
+
name: v.name,
|
|
191
|
+
revenue_minor: v.revenue_minor,
|
|
192
|
+
count: v.count,
|
|
193
|
+
average_order_value_minor: v.count
|
|
194
|
+
? Math.floor(v.revenue_minor / v.count)
|
|
195
|
+
: 0,
|
|
196
|
+
}))
|
|
197
|
+
.sort((a, b) => b.revenue_minor - a.revenue_minor);
|
|
198
|
+
const by_hour = [...byHour.entries()]
|
|
199
|
+
.map(([hour, v]) => ({
|
|
200
|
+
hour: Number(hour),
|
|
201
|
+
revenue_minor: v.revenue_minor,
|
|
202
|
+
count: v.count,
|
|
203
|
+
}))
|
|
204
|
+
.sort((a, b) => a.hour - b.hour);
|
|
205
|
+
const currency = revenueOrders[0]?.currency ?? "GHS";
|
|
134
206
|
if (opts.forRevenueSummary) {
|
|
135
207
|
return {
|
|
136
208
|
period,
|
|
209
|
+
currency,
|
|
137
210
|
total_revenue_minor,
|
|
138
211
|
total_orders,
|
|
139
212
|
average_order_value_minor,
|
|
140
|
-
by_payment_method
|
|
141
|
-
|
|
142
|
-
method,
|
|
143
|
-
...v,
|
|
144
|
-
percentage: pct(v.revenue_minor),
|
|
145
|
-
}))
|
|
146
|
-
.sort((a, b) => b.revenue_minor - a.revenue_minor),
|
|
147
|
-
by_order_type: [...byType.entries()]
|
|
148
|
-
.map(([type, v]) => ({ type, ...v }))
|
|
149
|
-
.sort((a, b) => b.revenue_minor - a.revenue_minor),
|
|
213
|
+
by_payment_method,
|
|
214
|
+
by_order_type,
|
|
150
215
|
by_day: [...byDay.entries()]
|
|
151
216
|
.map(([date, v]) => ({ date, ...v }))
|
|
152
217
|
.sort((a, b) => a.date.localeCompare(b.date)),
|
|
218
|
+
by_channel,
|
|
219
|
+
by_teller,
|
|
220
|
+
by_hour,
|
|
153
221
|
offline: true,
|
|
154
222
|
};
|
|
155
223
|
}
|
|
156
224
|
return {
|
|
157
225
|
period,
|
|
226
|
+
currency,
|
|
158
227
|
total_revenue_minor,
|
|
159
228
|
total_orders,
|
|
160
229
|
average_order_value_minor,
|
|
@@ -171,48 +240,130 @@ export function aggregateLocal(orders, period, opts, range) {
|
|
|
171
240
|
revenue_by_day: [...byDay.entries()]
|
|
172
241
|
.map(([date, v]) => ({ date, revenue_minor: v.revenue_minor }))
|
|
173
242
|
.sort((a, b) => a.date.localeCompare(b.date)),
|
|
243
|
+
by_channel,
|
|
244
|
+
by_payment_method,
|
|
245
|
+
by_order_type,
|
|
246
|
+
by_teller,
|
|
247
|
+
by_hour,
|
|
174
248
|
offline: true,
|
|
175
249
|
};
|
|
176
250
|
}
|
|
177
251
|
async function localOrders(businessId) {
|
|
178
|
-
const
|
|
179
|
-
|
|
252
|
+
const pageSize = 500;
|
|
253
|
+
const first = await listLocalOrders(businessId, {}, 1, pageSize);
|
|
254
|
+
const rows = [...(first.data ?? [])];
|
|
255
|
+
const total = first.total ?? rows.length;
|
|
256
|
+
for (let page = 2; rows.length < total; page += 1) {
|
|
257
|
+
const next = await listLocalOrders(businessId, {}, page, pageSize);
|
|
258
|
+
const batch = next.data ?? [];
|
|
259
|
+
if (batch.length === 0)
|
|
260
|
+
break;
|
|
261
|
+
rows.push(...batch);
|
|
262
|
+
}
|
|
263
|
+
return rows;
|
|
180
264
|
}
|
|
181
265
|
function hasCustomRange(period, range) {
|
|
182
266
|
return (period !== "custom" || Boolean(range?.date_from && range?.date_to));
|
|
183
267
|
}
|
|
268
|
+
const ORDER_PULL_COOLDOWN_MS = 15000;
|
|
269
|
+
const lastOrderPullAt = new Map();
|
|
270
|
+
/**
|
|
271
|
+
* Refresh the local order read model in the background. Analytics renders
|
|
272
|
+
* from SQLite immediately; this pull is what makes a freshly opened screen
|
|
273
|
+
* converge with the server without blocking the first paint.
|
|
274
|
+
*/
|
|
275
|
+
async function syncOrdersForAnalytics(businessId, queryClient) {
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
const last = lastOrderPullAt.get(businessId) ?? 0;
|
|
278
|
+
if (now - last < ORDER_PULL_COOLDOWN_MS)
|
|
279
|
+
return;
|
|
280
|
+
lastOrderPullAt.set(businessId, now);
|
|
281
|
+
try {
|
|
282
|
+
await pullRemoteOrders(businessId);
|
|
283
|
+
await Promise.all([
|
|
284
|
+
queryClient.invalidateQueries({ queryKey: SELLER_ANALYTICS_KEYS.all }),
|
|
285
|
+
queryClient.invalidateQueries({ queryKey: REVENUE_KEYS.all }),
|
|
286
|
+
]);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
syncLogger.debug("Analytics", "background order sync failed", {
|
|
290
|
+
businessId,
|
|
291
|
+
err: String(error),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Keep local data when it knows about more orders than the server snapshot
|
|
297
|
+
* (e.g. an offline order still in the outbox). Otherwise the server snapshot
|
|
298
|
+
* wins because it may include older history the device has never pulled.
|
|
299
|
+
*/
|
|
300
|
+
function preferRemote(current, remote) {
|
|
301
|
+
const localOrders = current?.data?.total_orders ?? 0;
|
|
302
|
+
const remoteOrders = remote.data?.total_orders ?? 0;
|
|
303
|
+
return remoteOrders >= localOrders ? remote : (current ?? remote);
|
|
304
|
+
}
|
|
305
|
+
async function buildLocalAnalytics(businessId, period, range) {
|
|
306
|
+
try {
|
|
307
|
+
return {
|
|
308
|
+
data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return {
|
|
313
|
+
data: aggregateLocal([], period, { forRevenueSummary: false }, range),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function buildLocalRevenue(businessId, period, range) {
|
|
318
|
+
try {
|
|
319
|
+
return {
|
|
320
|
+
data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return {
|
|
325
|
+
data: aggregateLocal([], period, { forRevenueSummary: true }, range),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
184
329
|
export function useSellerAnalyticsQuery(period = "today", enabled = true, range) {
|
|
185
330
|
const businessId = useBusinessId();
|
|
186
331
|
const online = useIsEffectivelyOnline();
|
|
332
|
+
const queryClient = useQueryClient();
|
|
187
333
|
const customReady = hasCustomRange(period, range);
|
|
334
|
+
const queryKey = SELLER_ANALYTICS_KEYS.analytics(businessId ?? "", period, range);
|
|
335
|
+
const active = enabled && Boolean(businessId) && customReady;
|
|
336
|
+
// Local-first: read SQLite on every query run. The network is a background
|
|
337
|
+
// refresh only, so opening the screen never waits on the server.
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
if (!active || !online || !businessId)
|
|
340
|
+
return;
|
|
341
|
+
void syncOrdersForAnalytics(businessId, queryClient);
|
|
342
|
+
}, [active, online, businessId, queryClient]);
|
|
188
343
|
return useQuery({
|
|
189
|
-
queryKey
|
|
344
|
+
queryKey,
|
|
190
345
|
queryFn: async () => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
catch {
|
|
210
|
-
return {
|
|
211
|
-
data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
|
|
212
|
-
};
|
|
346
|
+
const local = await buildLocalAnalytics(businessId, period, range);
|
|
347
|
+
if (online && businessId) {
|
|
348
|
+
// Do not await. Once the server snapshot arrives, merge it into the
|
|
349
|
+
// cache only when it is at least as complete as the local model.
|
|
350
|
+
void analyticsService
|
|
351
|
+
.fetchSellerAnalytics(businessId, period, range)
|
|
352
|
+
.then((remote) => {
|
|
353
|
+
const remoteResponse = remote;
|
|
354
|
+
const current = queryClient.getQueryData(queryKey);
|
|
355
|
+
queryClient.setQueryData(queryKey, preferRemote(current, remoteResponse));
|
|
356
|
+
})
|
|
357
|
+
.catch((error) => {
|
|
358
|
+
syncLogger.debug("Analytics", "background analytics refresh failed", {
|
|
359
|
+
businessId,
|
|
360
|
+
err: String(error),
|
|
361
|
+
});
|
|
362
|
+
});
|
|
213
363
|
}
|
|
364
|
+
return local;
|
|
214
365
|
},
|
|
215
|
-
enabled:
|
|
366
|
+
enabled: active,
|
|
216
367
|
staleTime: 30000,
|
|
217
368
|
...OFFLINE_FIRST,
|
|
218
369
|
});
|
|
@@ -220,34 +371,34 @@ export function useSellerAnalyticsQuery(period = "today", enabled = true, range)
|
|
|
220
371
|
export function useRevenueSummaryQuery(period = "today", enabled = true, range) {
|
|
221
372
|
const businessId = useBusinessId();
|
|
222
373
|
const online = useIsEffectivelyOnline();
|
|
374
|
+
const queryClient = useQueryClient();
|
|
223
375
|
const customReady = hasCustomRange(period, range);
|
|
376
|
+
const queryKey = REVENUE_KEYS.summary(businessId ?? "", period, range);
|
|
377
|
+
const active = enabled && Boolean(businessId) && customReady;
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
if (!active || !online || !businessId)
|
|
380
|
+
return;
|
|
381
|
+
void syncOrdersForAnalytics(businessId, queryClient);
|
|
382
|
+
}, [active, online, businessId, queryClient]);
|
|
224
383
|
return useQuery({
|
|
225
|
-
queryKey
|
|
384
|
+
queryKey,
|
|
226
385
|
queryFn: async () => {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
try {
|
|
240
|
-
// Same contract as analytics: trust the backend period filter and
|
|
241
|
-
// only fall back to SQLite when the request itself fails.
|
|
242
|
-
return await analyticsService.fetchRevenueSummary(businessId, period, range);
|
|
243
|
-
}
|
|
244
|
-
catch {
|
|
245
|
-
return {
|
|
246
|
-
data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
|
|
247
|
-
};
|
|
386
|
+
const local = await buildLocalRevenue(businessId, period, range);
|
|
387
|
+
if (online && businessId) {
|
|
388
|
+
void analyticsService
|
|
389
|
+
.fetchRevenueSummary(businessId, period, range)
|
|
390
|
+
.then((remote) => {
|
|
391
|
+
const remoteResponse = remote;
|
|
392
|
+
const current = queryClient.getQueryData(queryKey);
|
|
393
|
+
queryClient.setQueryData(queryKey, preferRemote(current, remoteResponse));
|
|
394
|
+
})
|
|
395
|
+
.catch((error) => {
|
|
396
|
+
syncLogger.debug("Analytics", "background revenue refresh failed", { businessId, err: String(error) });
|
|
397
|
+
});
|
|
248
398
|
}
|
|
399
|
+
return local;
|
|
249
400
|
},
|
|
250
|
-
enabled:
|
|
401
|
+
enabled: active,
|
|
251
402
|
staleTime: 30000,
|
|
252
403
|
...OFFLINE_FIRST,
|
|
253
404
|
});
|
|
@@ -21,6 +21,7 @@ import { buildItemSnapshots, snapshotsToOrderLines } from "../domain/orders/snap
|
|
|
21
21
|
import { DEFAULT_ORDER_FLOW_MODE, getBusinessOrderFlowMode, initialPosOrderStatus, } from "../domain/orders/order-flow";
|
|
22
22
|
import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
|
|
23
23
|
import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
|
|
24
|
+
import { useAuthStore } from "../adapters/auth";
|
|
24
25
|
import { canBusinessSync } from "./sync-guard";
|
|
25
26
|
import { backoffDelayMs } from "./backoff";
|
|
26
27
|
import { reportSyncFailure } from "../adapters/sync-failure";
|
|
@@ -121,6 +122,16 @@ function buildOptimisticOrder(command, payload, flowMode) {
|
|
|
121
122
|
const now = new Date().toISOString();
|
|
122
123
|
const method = payload.pos_payment_method ?? "cash";
|
|
123
124
|
const isCash = method === "cash";
|
|
125
|
+
const authUser = useAuthStore.getState().user;
|
|
126
|
+
const teller = authUser
|
|
127
|
+
? {
|
|
128
|
+
id: authUser.id,
|
|
129
|
+
full_name: authUser.full_name ?? authUser.email ?? "Staff",
|
|
130
|
+
email: authUser.email ?? "",
|
|
131
|
+
phone: authUser.phone ?? "",
|
|
132
|
+
avatar_url: authUser.avatar_url ?? null,
|
|
133
|
+
}
|
|
134
|
+
: null;
|
|
124
135
|
const orderNumber = payload.order_number ?? generateOrderNumberForBusiness(command.business_id);
|
|
125
136
|
return {
|
|
126
137
|
id: command.entity_id ?? command.command_id,
|
|
@@ -162,7 +173,8 @@ function buildOptimisticOrder(command, payload, flowMode) {
|
|
|
162
173
|
delivery_address: null,
|
|
163
174
|
delivery_lat: null,
|
|
164
175
|
delivery_lng: null,
|
|
165
|
-
user:
|
|
176
|
+
user: teller,
|
|
177
|
+
teller,
|
|
166
178
|
items: snapshotsToOrderLines(snapshots, command.command_id),
|
|
167
179
|
inserted_at: now,
|
|
168
180
|
};
|
|
@@ -189,7 +189,7 @@ export function createMemoryDriver() {
|
|
|
189
189
|
}
|
|
190
190
|
// orders insert (primary DB)
|
|
191
191
|
if (s.includes("INSERT INTO orders")) {
|
|
192
|
-
const [id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc] = args;
|
|
192
|
+
const [id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc] = args;
|
|
193
193
|
const existing = tables.orders.find((r) => r.id === id);
|
|
194
194
|
// LWW check via hlc (if existing and incoming older, skip) — only for
|
|
195
195
|
// the local/LAN write path. The server write path (`upsertServerOrder`)
|
|
@@ -198,10 +198,10 @@ export function createMemoryDriver() {
|
|
|
198
198
|
if (existing) {
|
|
199
199
|
if (lwwGuarded && hlc && existing.hlc && hlc < existing.hlc)
|
|
200
200
|
return { lastInsertRowId: 0, changes: 0 };
|
|
201
|
-
Object.assign(existing, { business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? existing.paid_at ?? null, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc });
|
|
201
|
+
Object.assign(existing, { business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? existing.paid_at ?? null, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc });
|
|
202
202
|
return { lastInsertRowId: 0, changes: 1 };
|
|
203
203
|
}
|
|
204
|
-
tables.orders.push({ id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? null, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc, deleted_at: null });
|
|
204
|
+
tables.orders.push({ id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? null, customer_name, customer_phone, pos_payment_method, payment_mode, delivery_id, business, user, inserted_at, updated_at, hlc, deleted_at: null });
|
|
205
205
|
return { lastInsertRowId: 1, changes: 1 };
|
|
206
206
|
}
|
|
207
207
|
// sync_jobs insert
|
package/package.json
CHANGED