CRITICAL fixes: - update_product: fetch current product first, include productId in body (was 400) - period enum: "week"/"month" → "7d"/"30d" to match backend handler - Token leakage: add axios response interceptor to strip Authorization from errors - Token expiry: add 401 detection with clear user-facing message HIGH fixes: - create_product: handle raw Guid response (was returning "unknown") - update_product: merge with existing values to avoid overwriting with defaults - Startup validation: warn if API_TOKEN is not set - Graceful shutdown: handle SIGINT/SIGTERM with server.close() - Error handler: shared module with structured API error extraction - Type safety: replace `any` with proper DTO interfaces across all tools - Promise.allSettled in cost_analysis for partial failure resilience - Timeout increased 15s → 30s for analytics queries MEDIUM fixes: - amount fields use .int() (inventory backend expects int, not float) - ingredients array requires .min(1) (prevent empty recipes) - isActive default removed (show all products by default) - pageSize default aligned to 20 (matches backend) - String length limits on name/description fields - Locale-explicit formatting (vi-VN) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
173 lines
5.9 KiB
TypeScript
173 lines
5.9 KiB
TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { z } from "zod";
|
|
import { orderApi, inventoryApi, DEFAULT_SHOP_ID } from "../services/api-client.js";
|
|
import { errorResponse, textResponse } from "../services/error-handler.js";
|
|
|
|
interface PopularItemDto {
|
|
productName: string;
|
|
quantitySold: number;
|
|
revenue: number;
|
|
}
|
|
|
|
interface PosDashboardDto {
|
|
revenue: number;
|
|
orderCount: number;
|
|
itemsSold: number;
|
|
avgOrderValue: number;
|
|
popularItems: PopularItemDto[];
|
|
hourlyRevenue: unknown[];
|
|
}
|
|
|
|
interface InventoryItemDto {
|
|
id: string;
|
|
name: string;
|
|
quantity: number;
|
|
costPerUnit: number;
|
|
unit: string;
|
|
reorderLevel: number;
|
|
}
|
|
|
|
export function registerAnalyticsTools(server: McpServer): void {
|
|
server.tool(
|
|
"popular_items",
|
|
"Get top selling products by analyzing order data. Shows product name, quantity sold, and revenue.",
|
|
{
|
|
shopId: z
|
|
.string()
|
|
.uuid()
|
|
.optional()
|
|
.describe("Shop ID (defaults to configured shop)"),
|
|
period: z
|
|
.enum(["today", "7d", "30d"])
|
|
.default("7d")
|
|
.describe("Time period: today, 7d (last 7 days), 30d (last 30 days)"),
|
|
},
|
|
async ({ shopId, period }) => {
|
|
try {
|
|
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
|
|
const response = await orderApi.get("/orders/dashboard", {
|
|
params: { shopId: resolvedShopId, period },
|
|
});
|
|
|
|
// Dashboard API returns directly (no {success, data} wrapper)
|
|
const dashboard: PosDashboardDto = response.data?.data ?? response.data ?? {};
|
|
const topItems = dashboard.popularItems ?? [];
|
|
const totalRevenue = dashboard.revenue ?? 0;
|
|
|
|
if (topItems.length === 0 && totalRevenue === 0) {
|
|
return textResponse(`No sales data found for period: ${period}.`);
|
|
}
|
|
|
|
const lines = topItems.map((item, i) => {
|
|
const rank = `#${i + 1}`;
|
|
const rev = (item.revenue ?? 0).toLocaleString("vi-VN");
|
|
return `${rank} ${item.productName} — ${item.quantitySold} sold (${rev} VND)`;
|
|
});
|
|
|
|
const summary = [
|
|
`Top Selling Products (${period})`,
|
|
`${"—".repeat(40)}`,
|
|
`Total Revenue: ${totalRevenue.toLocaleString("vi-VN")} VND`,
|
|
`Total Orders: ${dashboard.orderCount ?? 0}`,
|
|
`Items Sold: ${dashboard.itemsSold ?? 0}`,
|
|
`Avg Order Value: ${(dashboard.avgOrderValue ?? 0).toLocaleString("vi-VN")} VND`,
|
|
``,
|
|
topItems.length > 0 ? `Rankings:` : `(No product rankings available)`,
|
|
...lines,
|
|
].join("\n");
|
|
|
|
return textResponse(summary);
|
|
} catch (err) {
|
|
return errorResponse(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
server.tool(
|
|
"cost_analysis",
|
|
"Analyze cost structure by comparing inventory costs with revenue. Shows inventory value and revenue breakdown.",
|
|
{
|
|
shopId: z
|
|
.string()
|
|
.uuid()
|
|
.optional()
|
|
.describe("Shop ID (defaults to configured shop)"),
|
|
},
|
|
async ({ shopId }) => {
|
|
try {
|
|
const resolvedShopId = shopId ?? DEFAULT_SHOP_ID;
|
|
|
|
// Use Promise.allSettled for partial failure resilience
|
|
const [inventoryResult, dashboardResult] = await Promise.allSettled([
|
|
inventoryApi.get("/inventory", {
|
|
params: { shopId: resolvedShopId, take: 200 },
|
|
}),
|
|
orderApi.get("/orders/dashboard", {
|
|
params: { shopId: resolvedShopId, period: "30d" },
|
|
}),
|
|
]);
|
|
|
|
// Extract inventory data
|
|
let inventoryItems: InventoryItemDto[] = [];
|
|
let inventoryError = "";
|
|
if (inventoryResult.status === "fulfilled") {
|
|
const payload = inventoryResult.value.data?.data ?? inventoryResult.value.data;
|
|
inventoryItems = payload?.items ?? [];
|
|
} else {
|
|
inventoryError = "Inventory data unavailable";
|
|
}
|
|
|
|
// Extract dashboard data
|
|
let dashboard: Partial<PosDashboardDto> = {};
|
|
let dashboardError = "";
|
|
if (dashboardResult.status === "fulfilled") {
|
|
dashboard = dashboardResult.value.data?.data ?? dashboardResult.value.data ?? {};
|
|
} else {
|
|
dashboardError = "Revenue data unavailable";
|
|
}
|
|
|
|
const totalInventoryValue = inventoryItems.reduce(
|
|
(sum, item) => sum + (item.quantity || 0) * (item.costPerUnit || 0),
|
|
0
|
|
);
|
|
|
|
const totalRevenue = dashboard.revenue ?? 0;
|
|
|
|
const costBreakdown = inventoryItems
|
|
.filter((item) => item.costPerUnit > 0)
|
|
.sort((a, b) => b.quantity * b.costPerUnit - a.quantity * a.costPerUnit)
|
|
.slice(0, 15)
|
|
.map((item) => {
|
|
const itemCost = item.quantity * item.costPerUnit;
|
|
return ` - ${item.name}: ${item.quantity} ${item.unit} @ ${item.costPerUnit.toLocaleString("vi-VN")} = ${itemCost.toLocaleString("vi-VN")} VND`;
|
|
});
|
|
|
|
const warnings = [inventoryError, dashboardError].filter(Boolean);
|
|
|
|
const report = [
|
|
`Cost Analysis Report (Last 30 Days)`,
|
|
`${"=".repeat(45)}`,
|
|
warnings.length > 0 ? `\nWarnings: ${warnings.join(", ")}\n` : "",
|
|
`Revenue (30d): ${totalRevenue.toLocaleString("vi-VN")} VND`,
|
|
`Inventory Value: ${totalInventoryValue.toLocaleString("vi-VN")} VND`,
|
|
``,
|
|
`Orders (30d): ${dashboard.orderCount ?? "N/A"}`,
|
|
`Items Sold: ${dashboard.itemsSold ?? "N/A"}`,
|
|
`Avg Order Value: ${(dashboard.avgOrderValue ?? 0).toLocaleString("vi-VN")} VND`,
|
|
``,
|
|
`Top Cost Items (by current inventory value):`,
|
|
...(costBreakdown.length > 0 ? costBreakdown : [" (no cost data available)"]),
|
|
``,
|
|
`Inventory Items Tracked: ${inventoryItems.length}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
return textResponse(report);
|
|
} catch (err) {
|
|
return errorResponse(err);
|
|
}
|
|
}
|
|
);
|
|
}
|