shopline-mcp 1.0.0
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/LICENSE +21 -0
- package/README.md +87 -0
- package/README.zh-TW.md +87 -0
- package/dist/client.d.ts +26 -0
- package/dist/client.js +144 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +37 -0
- package/dist/config.js.map +1 -0
- package/dist/generated/endpoints.d.ts +137 -0
- package/dist/generated/endpoints.js +139 -0
- package/dist/generated/endpoints.js.map +1 -0
- package/dist/generated/toolSpecs.d.ts +4607 -0
- package/dist/generated/toolSpecs.js +5733 -0
- package/dist/generated/toolSpecs.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/schemas.d.ts +3 -0
- package/dist/schemas.js +53 -0
- package/dist/schemas.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +11 -0
- package/dist/server.js.map +1 -0
- package/dist/shared/helpers.d.ts +19 -0
- package/dist/shared/helpers.js +83 -0
- package/dist/shared/helpers.js.map +1 -0
- package/dist/tools/custom.d.ts +4 -0
- package/dist/tools/custom.js +741 -0
- package/dist/tools/custom.js.map +1 -0
- package/dist/tools/generic.d.ts +2 -0
- package/dist/tools/generic.js +150 -0
- package/dist/tools/generic.js.map +1 -0
- package/dist/tools/register.d.ts +4 -0
- package/dist/tools/register.js +43 -0
- package/dist/tools/register.js.map +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
import { apiGet, fetchAllPages } from "../client.js";
|
|
2
|
+
import { asArray, asRecord, dateOnly, daysBetween, getTranslation, increment, itemsFrom, moneyToFloat, pageCountForLimit, parseDate, percent, round, sortObjectByValueDesc, sumQuantity, VALID_ORDER_STATUSES, } from "../shared/helpers.js";
|
|
3
|
+
function stringArg(args, key, fallback = "") {
|
|
4
|
+
return String(args[key] ?? fallback);
|
|
5
|
+
}
|
|
6
|
+
function numberArg(args, key, fallback) {
|
|
7
|
+
const value = Number(args[key] ?? fallback);
|
|
8
|
+
return Number.isFinite(value) ? value : fallback;
|
|
9
|
+
}
|
|
10
|
+
function periodParams(startDate, endDate) {
|
|
11
|
+
return {
|
|
12
|
+
created_after: `${startDate}T00:00:00Z`,
|
|
13
|
+
created_before: `${endDate}T23:59:59Z`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
async function searchOrders(startDate, endDate, maxPages = 200) {
|
|
17
|
+
return fetchAllPages("orders_search", periodParams(startDate, endDate), undefined, maxPages);
|
|
18
|
+
}
|
|
19
|
+
function validRevenueOrders(orders) {
|
|
20
|
+
return orders.filter((order) => VALID_ORDER_STATUSES.has(String(order.status ?? "")));
|
|
21
|
+
}
|
|
22
|
+
function filterChannel(orders, channel) {
|
|
23
|
+
if (channel === "online")
|
|
24
|
+
return orders.filter((order) => order.created_from === "shop");
|
|
25
|
+
if (channel === "pos")
|
|
26
|
+
return orders.filter((order) => order.created_from === "pos");
|
|
27
|
+
return orders;
|
|
28
|
+
}
|
|
29
|
+
function filterStore(orders, storeName) {
|
|
30
|
+
if (!storeName)
|
|
31
|
+
return orders;
|
|
32
|
+
const needle = String(storeName);
|
|
33
|
+
return orders.filter((order) => {
|
|
34
|
+
const channel = asRecord(order.channel);
|
|
35
|
+
return getTranslation(channel.created_by_channel_name).includes(needle);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function orderStoreName(order) {
|
|
39
|
+
if (order.created_from === "pos") {
|
|
40
|
+
const channel = asRecord(order.channel);
|
|
41
|
+
return getTranslation(channel.created_by_channel_name) || "未知門市";
|
|
42
|
+
}
|
|
43
|
+
return "線上官網";
|
|
44
|
+
}
|
|
45
|
+
function orderSummary(order) {
|
|
46
|
+
const channel = asRecord(order.channel);
|
|
47
|
+
const payment = asRecord(order.order_payment);
|
|
48
|
+
const delivery = asRecord(order.order_delivery);
|
|
49
|
+
return {
|
|
50
|
+
id: order.id,
|
|
51
|
+
order_number: order.order_number,
|
|
52
|
+
status: order.status,
|
|
53
|
+
channel: order.created_from === "pos" ? "POS" : "線上",
|
|
54
|
+
store_name: getTranslation(channel.created_by_channel_name) || null,
|
|
55
|
+
total: moneyToFloat(order.total),
|
|
56
|
+
subtotal: moneyToFloat(order.subtotal),
|
|
57
|
+
discount: moneyToFloat(order.order_discount),
|
|
58
|
+
payment_type: getTranslation(payment.name_translations),
|
|
59
|
+
payment_status: payment.status,
|
|
60
|
+
delivery_type: getTranslation(delivery.name_translations),
|
|
61
|
+
delivery_status: delivery.delivery_status,
|
|
62
|
+
customer_name: order.customer_name,
|
|
63
|
+
items_count: asArray(order.subtotal_items).length,
|
|
64
|
+
created_at: order.created_at,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
async function queryOrders(args) {
|
|
68
|
+
const startDate = stringArg(args, "start_date");
|
|
69
|
+
const endDate = stringArg(args, "end_date");
|
|
70
|
+
const params = periodParams(startDate, endDate);
|
|
71
|
+
if (args.status)
|
|
72
|
+
params.status = args.status;
|
|
73
|
+
let orders = await fetchAllPages("orders_search", params, undefined, 20);
|
|
74
|
+
orders = filterChannel(orders, args.channel ?? "all");
|
|
75
|
+
orders = filterStore(orders, args.store_name);
|
|
76
|
+
const maxResults = numberArg(args, "max_results", 100);
|
|
77
|
+
const results = orders.slice(0, maxResults).map(orderSummary);
|
|
78
|
+
return { total_found: orders.length, returned: results.length, orders: results };
|
|
79
|
+
}
|
|
80
|
+
async function getSalesSummary(args) {
|
|
81
|
+
const startDate = stringArg(args, "start_date");
|
|
82
|
+
const endDate = stringArg(args, "end_date");
|
|
83
|
+
let orders = await searchOrders(startDate, endDate);
|
|
84
|
+
const status = String(args.status ?? "completed");
|
|
85
|
+
if (status === "completed")
|
|
86
|
+
orders = validRevenueOrders(orders);
|
|
87
|
+
else if (status)
|
|
88
|
+
orders = orders.filter((order) => order.status === status);
|
|
89
|
+
orders = filterChannel(orders, args.channel ?? "all");
|
|
90
|
+
orders = filterStore(orders, args.store_name);
|
|
91
|
+
let totalRevenue = 0;
|
|
92
|
+
let totalSubtotal = 0;
|
|
93
|
+
let totalDiscount = 0;
|
|
94
|
+
let totalItemsQty = 0;
|
|
95
|
+
const paymentBreakdown = {};
|
|
96
|
+
const deliveryBreakdown = {};
|
|
97
|
+
const storeBreakdown = {};
|
|
98
|
+
for (const order of orders) {
|
|
99
|
+
const revenue = moneyToFloat(order.total);
|
|
100
|
+
totalRevenue += revenue;
|
|
101
|
+
totalSubtotal += moneyToFloat(order.subtotal);
|
|
102
|
+
totalDiscount += moneyToFloat(order.order_discount);
|
|
103
|
+
totalItemsQty += sumQuantity(order.subtotal_items);
|
|
104
|
+
const paymentName = getTranslation(asRecord(order.order_payment).name_translations);
|
|
105
|
+
const deliveryName = getTranslation(asRecord(order.order_delivery).name_translations);
|
|
106
|
+
if (paymentName)
|
|
107
|
+
increment(paymentBreakdown, paymentName);
|
|
108
|
+
if (deliveryName)
|
|
109
|
+
increment(deliveryBreakdown, deliveryName);
|
|
110
|
+
const store = orderStoreName(order);
|
|
111
|
+
storeBreakdown[store] ??= { revenue: 0, orders: 0 };
|
|
112
|
+
storeBreakdown[store].revenue += revenue;
|
|
113
|
+
storeBreakdown[store].orders += 1;
|
|
114
|
+
}
|
|
115
|
+
const orderCount = orders.length;
|
|
116
|
+
return {
|
|
117
|
+
period: `${startDate} ~ ${endDate}`,
|
|
118
|
+
status_filter: status,
|
|
119
|
+
channel_filter: args.channel ?? "all",
|
|
120
|
+
order_count: orderCount,
|
|
121
|
+
total_revenue: round(totalRevenue),
|
|
122
|
+
total_subtotal: round(totalSubtotal),
|
|
123
|
+
total_discount: round(totalDiscount),
|
|
124
|
+
net_revenue: round(totalRevenue),
|
|
125
|
+
total_items_qty: totalItemsQty,
|
|
126
|
+
avg_order_value: orderCount ? round(totalRevenue / orderCount) : 0,
|
|
127
|
+
avg_item_price: totalItemsQty ? round(totalRevenue / totalItemsQty) : 0,
|
|
128
|
+
payment_breakdown: sortObjectByValueDesc(paymentBreakdown),
|
|
129
|
+
delivery_breakdown: sortObjectByValueDesc(deliveryBreakdown),
|
|
130
|
+
store_breakdown: Object.fromEntries(Object.entries(storeBreakdown).sort((a, b) => b[1].revenue - a[1].revenue)),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function getTopProducts(args) {
|
|
134
|
+
const startDate = stringArg(args, "start_date");
|
|
135
|
+
const endDate = stringArg(args, "end_date");
|
|
136
|
+
const sortBy = String(args.sort_by ?? "revenue");
|
|
137
|
+
let orders = validRevenueOrders(await searchOrders(startDate, endDate));
|
|
138
|
+
orders = filterChannel(orders, args.channel ?? "all");
|
|
139
|
+
const stats = {};
|
|
140
|
+
for (const order of orders) {
|
|
141
|
+
for (const rawItem of asArray(order.subtotal_items)) {
|
|
142
|
+
const item = asRecord(rawItem);
|
|
143
|
+
const sku = String(item.sku ?? "");
|
|
144
|
+
const title = getTranslation(item.title_translations);
|
|
145
|
+
const fields = asArray(asRecord(item.fields_translations)["zh-hant"]);
|
|
146
|
+
const objectData = asRecord(item.object_data);
|
|
147
|
+
const key = sku || title;
|
|
148
|
+
stats[key] ??= { title, sku, brand: objectData.brand ?? "", color: "", size: "", quantity: 0, revenue: 0 };
|
|
149
|
+
stats[key].title = title;
|
|
150
|
+
stats[key].sku = sku;
|
|
151
|
+
stats[key].brand = objectData.brand ?? "";
|
|
152
|
+
stats[key].color = fields[0] ?? "";
|
|
153
|
+
stats[key].size = fields[1] ?? "";
|
|
154
|
+
stats[key].quantity += Number(item.quantity ?? 1);
|
|
155
|
+
stats[key].revenue += moneyToFloat(item.total);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const topN = numberArg(args, "top_n", 20);
|
|
159
|
+
const sorted = Object.values(stats).sort((a, b) => Number(b[sortBy] ?? 0) - Number(a[sortBy] ?? 0));
|
|
160
|
+
return {
|
|
161
|
+
period: `${startDate} ~ ${endDate}`,
|
|
162
|
+
sort_by: sortBy,
|
|
163
|
+
total_skus: Object.keys(stats).length,
|
|
164
|
+
top_products: sorted.slice(0, topN).map((product, index) => ({ ...product, revenue: round(product.revenue), rank: index + 1 })),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
async function getSalesTrend(args) {
|
|
168
|
+
const startDate = stringArg(args, "start_date");
|
|
169
|
+
const endDate = stringArg(args, "end_date");
|
|
170
|
+
const granularity = String(args.granularity ?? "daily");
|
|
171
|
+
let orders = validRevenueOrders(await searchOrders(startDate, endDate));
|
|
172
|
+
orders = filterChannel(orders, args.channel ?? "all");
|
|
173
|
+
const trend = {};
|
|
174
|
+
for (const order of orders) {
|
|
175
|
+
const created = String(order.created_at ?? "");
|
|
176
|
+
if (!created)
|
|
177
|
+
continue;
|
|
178
|
+
const date = parseDate(created);
|
|
179
|
+
let key = dateOnly(created);
|
|
180
|
+
if (granularity === "monthly")
|
|
181
|
+
key = key.slice(0, 7);
|
|
182
|
+
if (granularity === "weekly") {
|
|
183
|
+
const first = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
|
184
|
+
key = `${date.getUTCFullYear()}-W${String(Math.floor(daysBetween(first, date) / 7)).padStart(2, "0")}`;
|
|
185
|
+
}
|
|
186
|
+
const bucket = (trend[key] ??= { revenue: 0, orders: 0, items: 0 });
|
|
187
|
+
bucket.revenue += moneyToFloat(order.total);
|
|
188
|
+
bucket.orders += 1;
|
|
189
|
+
bucket.items += sumQuantity(order.subtotal_items);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
period: `${startDate} ~ ${endDate}`,
|
|
193
|
+
granularity,
|
|
194
|
+
data_points: Object.keys(trend).length,
|
|
195
|
+
trend: Object.entries(trend)
|
|
196
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
197
|
+
.map(([date, value]) => ({
|
|
198
|
+
date,
|
|
199
|
+
revenue: round(value.revenue),
|
|
200
|
+
orders: value.orders,
|
|
201
|
+
items: value.items,
|
|
202
|
+
avg_order_value: value.orders ? round(value.revenue / value.orders) : 0,
|
|
203
|
+
})),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function getChannelComparison(args) {
|
|
207
|
+
const startDate = stringArg(args, "start_date");
|
|
208
|
+
const endDate = stringArg(args, "end_date");
|
|
209
|
+
const orders = validRevenueOrders(await searchOrders(startDate, endDate));
|
|
210
|
+
const channels = {};
|
|
211
|
+
for (const order of orders) {
|
|
212
|
+
const name = orderStoreName(order);
|
|
213
|
+
const bucket = (channels[name] ??= { revenue: 0, orders: 0, items: 0, discount: 0 });
|
|
214
|
+
bucket.revenue += moneyToFloat(order.total);
|
|
215
|
+
bucket.orders += 1;
|
|
216
|
+
bucket.discount += moneyToFloat(order.order_discount);
|
|
217
|
+
bucket.items += sumQuantity(order.subtotal_items);
|
|
218
|
+
}
|
|
219
|
+
const totalRevenue = Object.values(channels).reduce((sum, value) => sum + value.revenue, 0);
|
|
220
|
+
return {
|
|
221
|
+
period: `${startDate} ~ ${endDate}`,
|
|
222
|
+
total_revenue: round(totalRevenue),
|
|
223
|
+
channels: Object.entries(channels)
|
|
224
|
+
.sort((a, b) => b[1].revenue - a[1].revenue)
|
|
225
|
+
.map(([channel, value]) => ({
|
|
226
|
+
channel,
|
|
227
|
+
revenue: round(value.revenue),
|
|
228
|
+
orders: value.orders,
|
|
229
|
+
items: value.items,
|
|
230
|
+
discount: round(value.discount),
|
|
231
|
+
avg_order_value: value.orders ? round(value.revenue / value.orders) : 0,
|
|
232
|
+
revenue_share: percent(value.revenue, totalRevenue),
|
|
233
|
+
})),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async function getOrderDetail(args) {
|
|
237
|
+
const data = await apiGet("order_detail", undefined, { order_id: args.order_id });
|
|
238
|
+
const order = "order_number" in data ? data : asRecord(data.item) || data;
|
|
239
|
+
const payment = asRecord(order.order_payment);
|
|
240
|
+
const delivery = asRecord(order.order_delivery);
|
|
241
|
+
const channel = asRecord(order.channel);
|
|
242
|
+
const items = asArray(order.subtotal_items).map((rawItem) => {
|
|
243
|
+
const item = asRecord(rawItem);
|
|
244
|
+
const fields = asArray(asRecord(item.fields_translations)["zh-hant"]);
|
|
245
|
+
const objectData = asRecord(item.object_data);
|
|
246
|
+
return {
|
|
247
|
+
title: getTranslation(item.title_translations),
|
|
248
|
+
sku: item.sku,
|
|
249
|
+
quantity: item.quantity ?? 1,
|
|
250
|
+
price: moneyToFloat(item.price),
|
|
251
|
+
sale_price: moneyToFloat(item.price_sale),
|
|
252
|
+
item_total: moneyToFloat(item.total),
|
|
253
|
+
cost: moneyToFloat(item.cost),
|
|
254
|
+
brand: objectData.brand ?? "",
|
|
255
|
+
color: fields[0] ?? "",
|
|
256
|
+
size: fields[1] ?? "",
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
const promotions = asArray(order.promotion_items).map((rawPromotion) => {
|
|
260
|
+
const item = asRecord(rawPromotion);
|
|
261
|
+
const promotion = asRecord(item.promotion);
|
|
262
|
+
return {
|
|
263
|
+
title: getTranslation(promotion.title_translations),
|
|
264
|
+
discount_type: promotion.discount_type,
|
|
265
|
+
discounted_amount: moneyToFloat(item.discounted_amount),
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
order_number: order.order_number,
|
|
270
|
+
status: order.status,
|
|
271
|
+
channel: order.created_from === "pos" ? "POS" : "線上",
|
|
272
|
+
store_name: getTranslation(channel.created_by_channel_name) || null,
|
|
273
|
+
created_at: order.created_at,
|
|
274
|
+
customer_name: order.customer_name,
|
|
275
|
+
customer_id: order.customer_id,
|
|
276
|
+
subtotal: moneyToFloat(order.subtotal),
|
|
277
|
+
discount: moneyToFloat(order.order_discount),
|
|
278
|
+
total: moneyToFloat(order.total),
|
|
279
|
+
payment_type: getTranslation(payment.name_translations),
|
|
280
|
+
payment_status: payment.status,
|
|
281
|
+
delivery_type: getTranslation(delivery.name_translations),
|
|
282
|
+
delivery_status: delivery.delivery_status,
|
|
283
|
+
delivery_city: asRecord(order.delivery_address).city,
|
|
284
|
+
items,
|
|
285
|
+
promotions,
|
|
286
|
+
utm_data: order.utm_data ?? {},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function getRefundSummary(args) {
|
|
290
|
+
const startDate = stringArg(args, "start_date");
|
|
291
|
+
const endDate = stringArg(args, "end_date");
|
|
292
|
+
const returnOrders = await fetchAllPages("return_orders", periodParams(startDate, endDate), undefined, 50);
|
|
293
|
+
const statusBreakdown = {};
|
|
294
|
+
const itemStats = {};
|
|
295
|
+
let totalRefund = 0;
|
|
296
|
+
let completedCount = 0;
|
|
297
|
+
let pendingCount = 0;
|
|
298
|
+
for (const returnOrder of returnOrders) {
|
|
299
|
+
const status = String(returnOrder.status ?? "");
|
|
300
|
+
increment(statusBreakdown, status);
|
|
301
|
+
if (status === "completed") {
|
|
302
|
+
completedCount += 1;
|
|
303
|
+
totalRefund += moneyToFloat(returnOrder.total);
|
|
304
|
+
}
|
|
305
|
+
else if (status === "pending") {
|
|
306
|
+
pendingCount += 1;
|
|
307
|
+
}
|
|
308
|
+
for (const rawItem of asArray(returnOrder.items)) {
|
|
309
|
+
const item = asRecord(rawItem);
|
|
310
|
+
const objectData = asRecord(item.object_data);
|
|
311
|
+
const title = getTranslation(objectData.title_translations);
|
|
312
|
+
const sku = String(objectData.sku ?? "");
|
|
313
|
+
const key = sku || title || "unknown";
|
|
314
|
+
itemStats[key] ??= { title, sku, brand: String(objectData.brand ?? ""), quantity: 0, refund_amount: 0 };
|
|
315
|
+
itemStats[key].quantity += Number(item.quantity ?? 1);
|
|
316
|
+
itemStats[key].refund_amount += moneyToFloat(item.total);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
period: `${startDate} ~ ${endDate}`,
|
|
321
|
+
total_return_orders: returnOrders.length,
|
|
322
|
+
completed_returns: completedCount,
|
|
323
|
+
pending_returns: pendingCount,
|
|
324
|
+
total_refund_amount: round(totalRefund),
|
|
325
|
+
status_breakdown: sortObjectByValueDesc(statusBreakdown),
|
|
326
|
+
top_refund_items: Object.values(itemStats).sort((a, b) => b.refund_amount - a.refund_amount).slice(0, 20),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async function getArchivedOrders(args) {
|
|
330
|
+
const startDate = stringArg(args, "start_date");
|
|
331
|
+
const endDate = stringArg(args, "end_date");
|
|
332
|
+
const maxResults = numberArg(args, "max_results", 100);
|
|
333
|
+
const orders = await fetchAllPages("orders_archived", periodParams(startDate, endDate), undefined, 20);
|
|
334
|
+
const results = orders.slice(0, maxResults).map(orderSummary);
|
|
335
|
+
return { total_found: orders.length, returned: results.length, orders: results };
|
|
336
|
+
}
|
|
337
|
+
async function getProductList(args) {
|
|
338
|
+
let products = await fetchAllPages("products", {}, undefined, 10);
|
|
339
|
+
const keyword = args.keyword ? String(args.keyword).toLowerCase() : "";
|
|
340
|
+
const brand = args.brand ? String(args.brand).toLowerCase() : "";
|
|
341
|
+
if (keyword) {
|
|
342
|
+
products = products.filter((product) => {
|
|
343
|
+
const title = getTranslation(product.title_translations).toLowerCase();
|
|
344
|
+
const sku = String(product.sku ?? "").toLowerCase();
|
|
345
|
+
return title.includes(keyword) || sku.includes(keyword);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
if (brand)
|
|
349
|
+
products = products.filter((product) => String(product.brand ?? "").toLowerCase().includes(brand));
|
|
350
|
+
const maxResults = numberArg(args, "max_results", 50);
|
|
351
|
+
const results = products.slice(0, maxResults).map((product) => {
|
|
352
|
+
const variations = asArray(product.variations).map(asRecord);
|
|
353
|
+
const totalQty = variations.length ? sumQuantity(variations, 0) : Number(product.quantity ?? 0);
|
|
354
|
+
const supplier = asRecord(product.supplier);
|
|
355
|
+
return {
|
|
356
|
+
id: product.id,
|
|
357
|
+
title: getTranslation(product.title_translations),
|
|
358
|
+
sku: product.sku,
|
|
359
|
+
brand: product.brand,
|
|
360
|
+
supplier: supplier.name ?? "",
|
|
361
|
+
price: moneyToFloat(product.price),
|
|
362
|
+
price_sale: moneyToFloat(product.price_sale),
|
|
363
|
+
cost: moneyToFloat(product.cost),
|
|
364
|
+
quantity: totalQty,
|
|
365
|
+
category_ids: product.category_ids ?? [],
|
|
366
|
+
status: product.status,
|
|
367
|
+
variants_count: variations.length,
|
|
368
|
+
tags: product.tags ?? [],
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
return { total_found: products.length, returned: results.length, products: results };
|
|
372
|
+
}
|
|
373
|
+
async function getProductVariants(args) {
|
|
374
|
+
const productId = stringArg(args, "product_id");
|
|
375
|
+
const products = await fetchAllPages("products", {}, undefined, 10);
|
|
376
|
+
const product = products.find((entry) => entry.id === productId);
|
|
377
|
+
if (!product)
|
|
378
|
+
return { error: `Product ${productId} not found` };
|
|
379
|
+
const fieldTitles = asArray(product.field_titles).map((field) => getTranslation(asRecord(field).name_translations));
|
|
380
|
+
const variants = asArray(product.variations).map((rawVariation) => {
|
|
381
|
+
const variation = asRecord(rawVariation);
|
|
382
|
+
const fields = asArray(asRecord(variation.fields_translations)["zh-hant"]);
|
|
383
|
+
const feed = asRecord(variation.feed_variations);
|
|
384
|
+
return {
|
|
385
|
+
id: variation.id,
|
|
386
|
+
sku: variation.sku,
|
|
387
|
+
color: "color" in feed ? getTranslation(feed.color) : fields[0] ?? "",
|
|
388
|
+
size: "size" in feed ? getTranslation(feed.size) : fields[1] ?? "",
|
|
389
|
+
price: moneyToFloat(variation.price),
|
|
390
|
+
price_sale: moneyToFloat(variation.price_sale),
|
|
391
|
+
cost: moneyToFloat(variation.cost),
|
|
392
|
+
quantity: Number(variation.quantity ?? 0),
|
|
393
|
+
total_orderable_quantity: variation.total_orderable_quantity ?? 0,
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
product_id: productId,
|
|
398
|
+
title: getTranslation(product.title_translations),
|
|
399
|
+
brand: product.brand,
|
|
400
|
+
dimensions: fieldTitles,
|
|
401
|
+
variants_count: variants.length,
|
|
402
|
+
total_quantity: sumQuantity(variants, 0),
|
|
403
|
+
variants,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
async function getInventoryOverview(args) {
|
|
407
|
+
let products = await fetchAllPages("products", {}, undefined, 10);
|
|
408
|
+
if (args.brand) {
|
|
409
|
+
const brand = String(args.brand).toLowerCase();
|
|
410
|
+
products = products.filter((product) => String(product.brand ?? "").toLowerCase().includes(brand));
|
|
411
|
+
}
|
|
412
|
+
let totalQuantity = 0;
|
|
413
|
+
let totalCostValue = 0;
|
|
414
|
+
let totalSkus = 0;
|
|
415
|
+
let outOfStockSkus = 0;
|
|
416
|
+
let lowStockSkus = 0;
|
|
417
|
+
const brandBreakdown = {};
|
|
418
|
+
const productSummary = [];
|
|
419
|
+
for (const product of products) {
|
|
420
|
+
const brand = String(product.brand ?? "未設定");
|
|
421
|
+
const variations = asArray(product.variations).map(asRecord);
|
|
422
|
+
let productQty = 0;
|
|
423
|
+
let productSkuCount = 0;
|
|
424
|
+
let productOos = 0;
|
|
425
|
+
brandBreakdown[brand] ??= { quantity: 0, skus: 0, oos: 0 };
|
|
426
|
+
if (variations.length) {
|
|
427
|
+
for (const variation of variations) {
|
|
428
|
+
const qty = Number(variation.quantity ?? 0);
|
|
429
|
+
totalSkus += 1;
|
|
430
|
+
productSkuCount += 1;
|
|
431
|
+
productQty += qty;
|
|
432
|
+
totalQuantity += qty;
|
|
433
|
+
totalCostValue += moneyToFloat(variation.cost) * qty;
|
|
434
|
+
if (qty === 0) {
|
|
435
|
+
outOfStockSkus += 1;
|
|
436
|
+
productOos += 1;
|
|
437
|
+
brandBreakdown[brand].oos += 1;
|
|
438
|
+
}
|
|
439
|
+
else if (qty <= 3) {
|
|
440
|
+
lowStockSkus += 1;
|
|
441
|
+
}
|
|
442
|
+
brandBreakdown[brand].quantity += qty;
|
|
443
|
+
brandBreakdown[brand].skus += 1;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const qty = Number(product.quantity ?? 0);
|
|
448
|
+
totalSkus += 1;
|
|
449
|
+
productSkuCount = 1;
|
|
450
|
+
productQty = qty;
|
|
451
|
+
totalQuantity += qty;
|
|
452
|
+
if (qty === 0) {
|
|
453
|
+
outOfStockSkus += 1;
|
|
454
|
+
productOos = 1;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
productSummary.push({
|
|
458
|
+
title: getTranslation(product.title_translations),
|
|
459
|
+
brand,
|
|
460
|
+
total_quantity: productQty,
|
|
461
|
+
sku_count: productSkuCount,
|
|
462
|
+
out_of_stock_skus: productOos,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
total_products: products.length,
|
|
467
|
+
total_skus: totalSkus,
|
|
468
|
+
total_quantity: totalQuantity,
|
|
469
|
+
total_cost_value: round(totalCostValue),
|
|
470
|
+
out_of_stock_skus: outOfStockSkus,
|
|
471
|
+
low_stock_skus: lowStockSkus,
|
|
472
|
+
brand_breakdown: Object.fromEntries(Object.entries(brandBreakdown).sort((a, b) => b[1].quantity - a[1].quantity)),
|
|
473
|
+
products: productSummary.sort((a, b) => Number(a.total_quantity ?? 0) - Number(b.total_quantity ?? 0)),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
async function getLowStockAlerts(args) {
|
|
477
|
+
const threshold = numberArg(args, "threshold", 5);
|
|
478
|
+
const products = await fetchAllPages("products", {}, undefined, 10);
|
|
479
|
+
const alerts = [];
|
|
480
|
+
for (const product of products) {
|
|
481
|
+
const title = getTranslation(product.title_translations);
|
|
482
|
+
for (const rawVariation of asArray(product.variations)) {
|
|
483
|
+
const variation = asRecord(rawVariation);
|
|
484
|
+
const qty = Number(variation.quantity ?? 0);
|
|
485
|
+
if (qty <= threshold) {
|
|
486
|
+
const fields = asArray(asRecord(variation.fields_translations)["zh-hant"]);
|
|
487
|
+
alerts.push({
|
|
488
|
+
product_title: title,
|
|
489
|
+
sku: variation.sku,
|
|
490
|
+
color: fields[0] ?? "",
|
|
491
|
+
size: fields[1] ?? "",
|
|
492
|
+
quantity: qty,
|
|
493
|
+
status: qty === 0 ? "缺貨" : "低庫存",
|
|
494
|
+
brand: product.brand,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
alerts.sort((a, b) => Number(a.quantity ?? 0) - Number(b.quantity ?? 0));
|
|
500
|
+
return {
|
|
501
|
+
threshold,
|
|
502
|
+
total_alerts: alerts.length,
|
|
503
|
+
out_of_stock: alerts.filter((alert) => alert.quantity === 0).length,
|
|
504
|
+
low_stock: alerts.filter((alert) => Number(alert.quantity ?? 0) > 0).length,
|
|
505
|
+
alerts,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
async function getWarehouses() {
|
|
509
|
+
const data = await apiGet("warehouses", { per_page: 50 });
|
|
510
|
+
const warehouses = itemsFrom(data, "items").map((warehouse) => {
|
|
511
|
+
const record = asRecord(warehouse);
|
|
512
|
+
return { id: record.id, name: record.name, status: record.status };
|
|
513
|
+
});
|
|
514
|
+
return { total: warehouses.length, warehouses };
|
|
515
|
+
}
|
|
516
|
+
async function getRfmAnalysis(args) {
|
|
517
|
+
const startDate = stringArg(args, "start_date");
|
|
518
|
+
const endDate = stringArg(args, "end_date");
|
|
519
|
+
const rDaysThreshold = numberArg(args, "r_days_threshold", 30);
|
|
520
|
+
const fThreshold = numberArg(args, "f_threshold", 2);
|
|
521
|
+
const mThreshold = numberArg(args, "m_threshold", 5000);
|
|
522
|
+
const orders = validRevenueOrders(await searchOrders(startDate, endDate));
|
|
523
|
+
const now = new Date(`${endDate}T23:59:59Z`);
|
|
524
|
+
const customers = {};
|
|
525
|
+
for (const order of orders) {
|
|
526
|
+
const customerId = String(order.customer_id ?? "");
|
|
527
|
+
if (!customerId)
|
|
528
|
+
continue;
|
|
529
|
+
customers[customerId] ??= { name: String(order.customer_name ?? ""), orders: [], total_spent: 0 };
|
|
530
|
+
customers[customerId].orders.push(String(order.created_at ?? ""));
|
|
531
|
+
customers[customerId].total_spent += moneyToFloat(order.total);
|
|
532
|
+
}
|
|
533
|
+
const labels = {
|
|
534
|
+
HHH: "最佳客戶",
|
|
535
|
+
HHL: "高頻低消",
|
|
536
|
+
HLH: "近期高消",
|
|
537
|
+
HLL: "近期新客",
|
|
538
|
+
LHH: "流失高價值",
|
|
539
|
+
LHL: "流失高頻",
|
|
540
|
+
LLH: "流失高消",
|
|
541
|
+
LLL: "流失低價值",
|
|
542
|
+
};
|
|
543
|
+
const segmentCounts = {};
|
|
544
|
+
const rfmData = Object.entries(customers).map(([customerId, data]) => {
|
|
545
|
+
const dates = [...data.orders].sort();
|
|
546
|
+
const latest = dates.at(-1) ?? "";
|
|
547
|
+
const recency = latest ? daysBetween(parseDate(latest), now) : 999;
|
|
548
|
+
const frequency = dates.length;
|
|
549
|
+
const monetary = data.total_spent;
|
|
550
|
+
const segment = `${recency <= rDaysThreshold ? "H" : "L"}${frequency >= fThreshold ? "H" : "L"}${monetary >= mThreshold ? "H" : "L"}`;
|
|
551
|
+
increment(segmentCounts, segment);
|
|
552
|
+
return {
|
|
553
|
+
customer_id: customerId,
|
|
554
|
+
customer_name: data.name,
|
|
555
|
+
recency_days: recency,
|
|
556
|
+
frequency,
|
|
557
|
+
monetary: round(monetary),
|
|
558
|
+
segment,
|
|
559
|
+
segment_label: labels[segment] ?? segment,
|
|
560
|
+
};
|
|
561
|
+
});
|
|
562
|
+
rfmData.sort((a, b) => b.monetary - a.monetary);
|
|
563
|
+
return {
|
|
564
|
+
period: `${startDate} ~ ${endDate}`,
|
|
565
|
+
thresholds: { recency_days: rDaysThreshold, frequency: fThreshold, monetary: mThreshold },
|
|
566
|
+
total_customers: rfmData.length,
|
|
567
|
+
segment_distribution: Object.fromEntries(Object.entries(segmentCounts)
|
|
568
|
+
.sort((a, b) => b[1] - a[1])
|
|
569
|
+
.map(([segment, count]) => [`${segment} (${labels[segment] ?? segment})`, count])),
|
|
570
|
+
top_customers: rfmData.slice(0, 20),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
async function getRepurchaseAnalysis(args) {
|
|
574
|
+
const startDate = stringArg(args, "start_date");
|
|
575
|
+
const endDate = stringArg(args, "end_date");
|
|
576
|
+
const orders = validRevenueOrders(await searchOrders(startDate, endDate));
|
|
577
|
+
const customerOrders = {};
|
|
578
|
+
const customerRevenue = {};
|
|
579
|
+
for (const order of orders) {
|
|
580
|
+
const customerId = String(order.customer_id ?? "");
|
|
581
|
+
if (!customerId)
|
|
582
|
+
continue;
|
|
583
|
+
customerOrders[customerId] ??= [];
|
|
584
|
+
customerOrders[customerId].push(String(order.created_at ?? ""));
|
|
585
|
+
customerRevenue[customerId] = (customerRevenue[customerId] ?? 0) + moneyToFloat(order.total);
|
|
586
|
+
}
|
|
587
|
+
const totalCustomers = Object.keys(customerOrders).length;
|
|
588
|
+
const newCustomers = Object.values(customerOrders).filter((dates) => dates.length === 1).length;
|
|
589
|
+
const returningCustomers = totalCustomers - newCustomers;
|
|
590
|
+
const gaps = [];
|
|
591
|
+
for (const dates of Object.values(customerOrders)) {
|
|
592
|
+
const sorted = [...dates].sort();
|
|
593
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
594
|
+
const gap = daysBetween(parseDate(sorted[i - 1] ?? ""), parseDate(sorted[i] ?? ""));
|
|
595
|
+
if (gap > 0)
|
|
596
|
+
gaps.push(gap);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const newRevenue = Object.entries(customerOrders)
|
|
600
|
+
.filter(([, dates]) => dates.length === 1)
|
|
601
|
+
.reduce((sum, [id]) => sum + (customerRevenue[id] ?? 0), 0);
|
|
602
|
+
const returningRevenue = Object.entries(customerOrders)
|
|
603
|
+
.filter(([, dates]) => dates.length >= 2)
|
|
604
|
+
.reduce((sum, [id]) => sum + (customerRevenue[id] ?? 0), 0);
|
|
605
|
+
const totalRevenue = newRevenue + returningRevenue;
|
|
606
|
+
return {
|
|
607
|
+
period: `${startDate} ~ ${endDate}`,
|
|
608
|
+
total_orders: orders.length,
|
|
609
|
+
total_customers: totalCustomers,
|
|
610
|
+
new_customers: newCustomers,
|
|
611
|
+
returning_customers: returningCustomers,
|
|
612
|
+
repurchase_rate: percent(returningCustomers, totalCustomers),
|
|
613
|
+
avg_repurchase_days: gaps.length ? round(gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length, 1) : 0,
|
|
614
|
+
median_repurchase_days: gaps.length ? [...gaps].sort((a, b) => a - b)[Math.floor(gaps.length / 2)] : 0,
|
|
615
|
+
new_customer_revenue: round(newRevenue),
|
|
616
|
+
returning_customer_revenue: round(returningRevenue),
|
|
617
|
+
new_customer_revenue_share: percent(newRevenue, totalRevenue),
|
|
618
|
+
returning_customer_revenue_share: percent(returningRevenue, totalRevenue),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
async function getCustomerGeoAnalysis(args) {
|
|
622
|
+
const startDate = stringArg(args, "start_date");
|
|
623
|
+
const endDate = stringArg(args, "end_date");
|
|
624
|
+
let orders = validRevenueOrders(await searchOrders(startDate, endDate));
|
|
625
|
+
orders = filterChannel(orders, args.channel ?? "all");
|
|
626
|
+
const cityStats = {};
|
|
627
|
+
for (const order of orders) {
|
|
628
|
+
const city = String(asRecord(order.delivery_address).city ?? "未填寫");
|
|
629
|
+
cityStats[city] ??= { orders: 0, revenue: 0, customers: new Set() };
|
|
630
|
+
cityStats[city].orders += 1;
|
|
631
|
+
cityStats[city].revenue += moneyToFloat(order.total);
|
|
632
|
+
if (order.customer_id)
|
|
633
|
+
cityStats[city].customers.add(String(order.customer_id));
|
|
634
|
+
}
|
|
635
|
+
const totalOrders = Object.values(cityStats).reduce((sum, city) => sum + city.orders, 0);
|
|
636
|
+
return {
|
|
637
|
+
period: `${startDate} ~ ${endDate}`,
|
|
638
|
+
total_orders: totalOrders,
|
|
639
|
+
total_cities: Object.keys(cityStats).length,
|
|
640
|
+
cities: Object.entries(cityStats)
|
|
641
|
+
.sort((a, b) => b[1].orders - a[1].orders)
|
|
642
|
+
.map(([city, value]) => ({
|
|
643
|
+
city,
|
|
644
|
+
orders: value.orders,
|
|
645
|
+
revenue: round(value.revenue),
|
|
646
|
+
unique_customers: value.customers.size,
|
|
647
|
+
order_share: percent(value.orders, totalOrders),
|
|
648
|
+
})),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
async function listCustomers(args) {
|
|
652
|
+
const maxResults = numberArg(args, "max_results", 50);
|
|
653
|
+
const searchKeyword = args.search_keyword ? String(args.search_keyword) : "";
|
|
654
|
+
const customers = searchKeyword
|
|
655
|
+
? itemsFrom(await apiGet("customers_search", { keyword: searchKeyword, per_page: Math.min(maxResults, 50) }), "items")
|
|
656
|
+
: await fetchAllPages("customers", {}, undefined, pageCountForLimit(maxResults));
|
|
657
|
+
const results = customers.slice(0, maxResults).map((rawCustomer) => {
|
|
658
|
+
const customer = asRecord(rawCustomer);
|
|
659
|
+
return {
|
|
660
|
+
id: customer.id,
|
|
661
|
+
name: customer.name,
|
|
662
|
+
email: customer.email,
|
|
663
|
+
phone: customer.phone,
|
|
664
|
+
gender: customer.gender,
|
|
665
|
+
birthday: customer.birthday,
|
|
666
|
+
tags: customer.tags ?? [],
|
|
667
|
+
membership_tier: customer.membership_tier_id,
|
|
668
|
+
total_spent: moneyToFloat(customer.total_spent),
|
|
669
|
+
orders_count: customer.orders_count ?? 0,
|
|
670
|
+
created_at: customer.created_at,
|
|
671
|
+
};
|
|
672
|
+
});
|
|
673
|
+
return { total_found: customers.length, returned: results.length, customers: results };
|
|
674
|
+
}
|
|
675
|
+
async function getCustomerProfile(args) {
|
|
676
|
+
const customerId = args.customer_id;
|
|
677
|
+
const pathParams = { customer_id: customerId };
|
|
678
|
+
const detail = await apiGet("customer_detail", undefined, pathParams);
|
|
679
|
+
const customer = "name" in detail ? detail : asRecord(detail.item);
|
|
680
|
+
const load = async (endpointKey, mapper, error) => {
|
|
681
|
+
try {
|
|
682
|
+
const data = await apiGet(endpointKey, undefined, pathParams);
|
|
683
|
+
return itemsFrom(data, "items").map((item) => mapper(asRecord(item)));
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return [{ error }];
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
return {
|
|
690
|
+
profile: {
|
|
691
|
+
id: customer.id,
|
|
692
|
+
name: customer.name,
|
|
693
|
+
email: customer.email,
|
|
694
|
+
phone: customer.phone,
|
|
695
|
+
gender: customer.gender,
|
|
696
|
+
birthday: customer.birthday,
|
|
697
|
+
tags: customer.tags ?? [],
|
|
698
|
+
total_spent: moneyToFloat(customer.total_spent),
|
|
699
|
+
orders_count: customer.orders_count ?? 0,
|
|
700
|
+
membership_tier_id: customer.membership_tier_id,
|
|
701
|
+
created_at: customer.created_at,
|
|
702
|
+
updated_at: customer.updated_at,
|
|
703
|
+
},
|
|
704
|
+
store_credits: await load("customer_store_credit_history", (record) => ({
|
|
705
|
+
amount: moneyToFloat(record.amount),
|
|
706
|
+
balance: moneyToFloat(record.balance),
|
|
707
|
+
type: record.type,
|
|
708
|
+
note: record.note,
|
|
709
|
+
created_at: record.created_at,
|
|
710
|
+
}), "無法取得儲值金紀錄"),
|
|
711
|
+
member_points: await load("customer_member_points", (record) => ({ points: record.points ?? 0, balance: record.balance ?? 0, type: record.type, note: record.note, created_at: record.created_at }), "無法取得會員點數紀錄"),
|
|
712
|
+
tier_history: await load("customer_membership_tier_history", (record) => ({ from_tier: record.from_tier, to_tier: record.to_tier, reason: record.reason, created_at: record.created_at }), "無法取得會員等級變動紀錄"),
|
|
713
|
+
promotions: await load("customer_promotions", (record) => ({
|
|
714
|
+
id: record.id,
|
|
715
|
+
title: getTranslation(record.title_translations),
|
|
716
|
+
status: record.status,
|
|
717
|
+
discount_type: record.discount_type,
|
|
718
|
+
}), "無法取得客戶優惠"),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
export const customHandlers = {
|
|
722
|
+
query_orders: queryOrders,
|
|
723
|
+
get_sales_summary: getSalesSummary,
|
|
724
|
+
get_top_products: getTopProducts,
|
|
725
|
+
get_sales_trend: getSalesTrend,
|
|
726
|
+
get_channel_comparison: getChannelComparison,
|
|
727
|
+
get_order_detail: getOrderDetail,
|
|
728
|
+
get_refund_summary: getRefundSummary,
|
|
729
|
+
get_archived_orders: getArchivedOrders,
|
|
730
|
+
get_product_list: getProductList,
|
|
731
|
+
get_product_variants: getProductVariants,
|
|
732
|
+
get_inventory_overview: getInventoryOverview,
|
|
733
|
+
get_low_stock_alerts: getLowStockAlerts,
|
|
734
|
+
get_warehouses: getWarehouses,
|
|
735
|
+
get_rfm_analysis: getRfmAnalysis,
|
|
736
|
+
get_repurchase_analysis: getRepurchaseAnalysis,
|
|
737
|
+
get_customer_geo_analysis: getCustomerGeoAnalysis,
|
|
738
|
+
list_customers: listCustomers,
|
|
739
|
+
get_customer_profile: getCustomerProfile,
|
|
740
|
+
};
|
|
741
|
+
//# sourceMappingURL=custom.js.map
|