feat(mcp): add BOM management tools (list_products_bom_status, get_product_bom, set_product_bom)
This commit is contained in:
1 parent
b030c08f68
commit
4fcdd8a816
4 files changed
+538
-1
No files matched your search
@@ -431,6 +431,95 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
.get("/bom-status", async ({ query }) => {
|
||||||
|
const search = query.search?.trim();
|
||||||
|
const filterStatus = query.status?.trim() || "all";
|
||||||
|
const limit = query.limit ? parseInt(query.limit) : 50;
|
||||||
|
|
||||||
|
const where: any = { isDeleted: false };
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
|
{ translations: { some: { name: { contains: search, mode: "insensitive" } } } },
|
||||||
|
{ brand: { name: { contains: search, mode: "insensitive" } } },
|
||||||
|
{ variants: { some: { sku: { contains: search, mode: "insensitive" } } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = await prisma.product.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
brand: { select: { id: true, name: true } },
|
||||||
|
category: { select: { id: true, name: true } },
|
||||||
|
translations: { select: { langCode: true, name: true } },
|
||||||
|
variants: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
sku: true,
|
||||||
|
option1: true,
|
||||||
|
option2: true,
|
||||||
|
option3: true,
|
||||||
|
price: true,
|
||||||
|
bomItems: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
rawMaterialId: true,
|
||||||
|
quantity: true,
|
||||||
|
rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = products.map((p) => {
|
||||||
|
const thName = p.translations.find((t) => t.langCode === "th")?.name;
|
||||||
|
const productName = p.name || thName || p.translations[0]?.name || `Product #${p.id}`;
|
||||||
|
const totalVariants = p.variants.length;
|
||||||
|
const configuredVariants = p.variants.filter((v) => v.bomItems && v.bomItems.length > 0).length;
|
||||||
|
|
||||||
|
let bomStatus = "MISSING";
|
||||||
|
if (configuredVariants === totalVariants && totalVariants > 0) {
|
||||||
|
bomStatus = "CONFIGURED";
|
||||||
|
} else if (configuredVariants > 0) {
|
||||||
|
bomStatus = "PARTIAL";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
name: productName,
|
||||||
|
brand: p.brand?.name || null,
|
||||||
|
category: p.category?.name || null,
|
||||||
|
totalVariants,
|
||||||
|
configuredVariants,
|
||||||
|
bomStatus,
|
||||||
|
variants: p.variants.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
sku: v.sku,
|
||||||
|
options: [v.option1, v.option2, v.option3].filter(Boolean).join(" / "),
|
||||||
|
price: v.price,
|
||||||
|
hasBom: v.bomItems.length > 0,
|
||||||
|
bomItemCount: v.bomItems.length,
|
||||||
|
totalBomCost: Math.round(v.bomItems.reduce((acc, b) => acc + (b.rawMaterial?.pricePerUnit || 0) * b.quantity, 0) * 100) / 100,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = filterStatus === "all"
|
||||||
|
? mapped
|
||||||
|
: mapped.filter((p) => p.bomStatus.toLowerCase() === filterStatus.toLowerCase());
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
count: filtered.length,
|
||||||
|
products: filtered,
|
||||||
|
};
|
||||||
|
})
|
||||||
.get("/:id", async ({ params, query, status }) => {
|
.get("/:id", async ({ params, query, status }) => {
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||||
@@ -499,6 +588,263 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.get("/:id/bom", async ({ params, status }) => {
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||||
|
|
||||||
|
const [product, rawMaterials] = await Promise.all([
|
||||||
|
prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
brand: true,
|
||||||
|
category: true,
|
||||||
|
translations: true,
|
||||||
|
variants: {
|
||||||
|
include: {
|
||||||
|
bomItems: {
|
||||||
|
include: {
|
||||||
|
rawMaterial: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { price: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.rawMaterial.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, unit: true, pricePerUnit: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!product || product.isDeleted) return status(404, { message: "Product not found" });
|
||||||
|
|
||||||
|
const thName = product.translations.find((t) => t.langCode === "th")?.name;
|
||||||
|
const productName = product.name || thName || product.translations[0]?.name || `Product #${product.id}`;
|
||||||
|
|
||||||
|
const variants = product.variants.map((v) => {
|
||||||
|
const items = (v.bomItems || []).map((b) => {
|
||||||
|
const rm = b.rawMaterial;
|
||||||
|
const lineCost = (rm?.pricePerUnit || 0) * b.quantity;
|
||||||
|
return {
|
||||||
|
id: b.id,
|
||||||
|
rawMaterialId: b.rawMaterialId,
|
||||||
|
name: rm?.name || `Material #${b.rawMaterialId}`,
|
||||||
|
unit: rm?.unit || "piece",
|
||||||
|
pricePerUnit: rm?.pricePerUnit || 0,
|
||||||
|
quantity: b.quantity,
|
||||||
|
lineCost: Math.round(lineCost * 100) / 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBomCost = items.reduce((acc, i) => acc + i.lineCost, 0);
|
||||||
|
const retailPrice = Number(v.price) || 0;
|
||||||
|
const profit = retailPrice - totalBomCost;
|
||||||
|
const marginPercent = retailPrice > 0 ? (profit / retailPrice) * 100 : 0;
|
||||||
|
const optionLabel = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
sku: v.sku,
|
||||||
|
optionLabel: optionLabel || "Standard",
|
||||||
|
price: retailPrice,
|
||||||
|
stock: v.stock,
|
||||||
|
bomItems: items,
|
||||||
|
totalBomCost: Math.round(totalBomCost * 100) / 100,
|
||||||
|
estimatedProfit: Math.round(profit * 100) / 100,
|
||||||
|
marginPercent: Math.round(marginPercent * 10) / 10,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalVariants = variants.length;
|
||||||
|
const configuredVariants = variants.filter((v) => v.bomItems.length > 0).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
productId: product.id,
|
||||||
|
productName,
|
||||||
|
brand: product.brand?.name || null,
|
||||||
|
category: product.category?.name || null,
|
||||||
|
bomStatus:
|
||||||
|
configuredVariants === totalVariants && totalVariants > 0
|
||||||
|
? "CONFIGURED"
|
||||||
|
: configuredVariants > 0
|
||||||
|
? "PARTIAL"
|
||||||
|
: "MISSING",
|
||||||
|
summary: {
|
||||||
|
totalVariants,
|
||||||
|
configuredVariants,
|
||||||
|
},
|
||||||
|
variants,
|
||||||
|
availableRawMaterials: rawMaterials,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.put("/:id/bom", async ({ params, body, status }) => {
|
||||||
|
const id = parseInt(params.id);
|
||||||
|
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||||
|
|
||||||
|
const payload = body as any;
|
||||||
|
const incomingVariants = Array.isArray(payload) ? payload : (payload.variants || []);
|
||||||
|
if (!Array.isArray(incomingVariants) || incomingVariants.length === 0) {
|
||||||
|
return status(400, { message: "variants array is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [product, rawMaterials] = await Promise.all([
|
||||||
|
prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
brand: true,
|
||||||
|
translations: true,
|
||||||
|
variants: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.rawMaterial.findMany(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!product || product.isDeleted) return status(404, { message: "Product not found" });
|
||||||
|
|
||||||
|
const rawMaterialById = new Map<number, (typeof rawMaterials)[0]>();
|
||||||
|
const rawMaterialByName = new Map<string, (typeof rawMaterials)[0]>();
|
||||||
|
for (const rm of rawMaterials) {
|
||||||
|
rawMaterialById.set(rm.id, rm);
|
||||||
|
rawMaterialByName.set(rm.name.trim().toLowerCase(), rm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan: Array<{
|
||||||
|
targetVariant: (typeof product.variants)[0];
|
||||||
|
bomItemsToCreate: Array<{ rawMaterialId: number; quantity: number }>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const unresolvedVariants: any[] = [];
|
||||||
|
const unresolvedMaterials: any[] = [];
|
||||||
|
|
||||||
|
for (const iv of incomingVariants) {
|
||||||
|
const vId = Number(iv.variantId || iv.id);
|
||||||
|
const vSku = String(iv.sku || "").trim().toLowerCase();
|
||||||
|
const vOption = String(iv.option1 || iv.option || iv.name || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
let targetVariant = product.variants.find((v) => v.id === vId);
|
||||||
|
if (!targetVariant && vSku) {
|
||||||
|
targetVariant = product.variants.find((v) => (v.sku || "").trim().toLowerCase() === vSku);
|
||||||
|
}
|
||||||
|
if (!targetVariant && vOption) {
|
||||||
|
targetVariant = product.variants.find((v) => (v.option1 || "").trim().toLowerCase() === vOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetVariant) {
|
||||||
|
unresolvedVariants.push(iv);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bomItemsToCreate: Array<{ rawMaterialId: number; quantity: number }> = [];
|
||||||
|
const items = Array.isArray(iv.bomItems) ? iv.bomItems : [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const rmId = Number(item.rawMaterialId || item.id);
|
||||||
|
const rmName = String(item.name || item.rawMaterialName || "").trim().toLowerCase();
|
||||||
|
const qty = Number(item.quantity) || 0;
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
|
||||||
|
let matchedRm = rawMaterialById.get(rmId);
|
||||||
|
if (!matchedRm && rmName) {
|
||||||
|
matchedRm = rawMaterialByName.get(rmName);
|
||||||
|
if (!matchedRm) {
|
||||||
|
for (const rm of rawMaterials) {
|
||||||
|
if (rm.name.toLowerCase().includes(rmName) || rmName.includes(rm.name.toLowerCase())) {
|
||||||
|
matchedRm = rm;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedRm) {
|
||||||
|
unresolvedMaterials.push({ variantSku: targetVariant.sku, item });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bomItemsToCreate.push({
|
||||||
|
rawMaterialId: matchedRm.id,
|
||||||
|
quantity: qty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.push({ targetVariant, bomItemsToCreate });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.length === 0) {
|
||||||
|
return status(400, {
|
||||||
|
message: "No matching variants could be resolved from payload",
|
||||||
|
unresolvedVariants,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const p of plan) {
|
||||||
|
await tx.bomItem.deleteMany({
|
||||||
|
where: { productVariantId: p.targetVariant.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.bomItemsToCreate.length > 0) {
|
||||||
|
await tx.bomItem.createMany({
|
||||||
|
data: p.bomItemsToCreate.map((b) => ({
|
||||||
|
productVariantId: p.targetVariant.id,
|
||||||
|
rawMaterialId: b.rawMaterialId,
|
||||||
|
quantity: b.quantity,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedVariants = await prisma.productVariant.findMany({
|
||||||
|
where: { productId: id },
|
||||||
|
include: {
|
||||||
|
bomItems: {
|
||||||
|
include: { rawMaterial: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { price: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = updatedVariants.map((v) => {
|
||||||
|
const items = v.bomItems.map((b) => ({
|
||||||
|
rawMaterialId: b.rawMaterialId,
|
||||||
|
name: b.rawMaterial.name,
|
||||||
|
unit: b.rawMaterial.unit,
|
||||||
|
pricePerUnit: b.rawMaterial.pricePerUnit,
|
||||||
|
quantity: b.quantity,
|
||||||
|
lineCost: Math.round(b.rawMaterial.pricePerUnit * b.quantity * 100) / 100,
|
||||||
|
}));
|
||||||
|
const totalBomCost = items.reduce((sum, i) => sum + i.lineCost, 0);
|
||||||
|
const profit = (v.price || 0) - totalBomCost;
|
||||||
|
const margin = (v.price || 0) > 0 ? (profit / (v.price || 0)) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
variantId: v.id,
|
||||||
|
sku: v.sku,
|
||||||
|
option: [v.option1, v.option2, v.option3].filter(Boolean).join(" / "),
|
||||||
|
retailPrice: v.price,
|
||||||
|
totalBomCost: Math.round(totalBomCost * 100) / 100,
|
||||||
|
estimatedProfit: Math.round(profit * 100) / 100,
|
||||||
|
marginPercent: Math.round(margin * 10) / 10,
|
||||||
|
bomItems: items,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const thName = product.translations.find((t) => t.langCode === "th")?.name;
|
||||||
|
const productName = product.name || thName || product.translations[0]?.name || `Product #${product.id}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully updated BOM for ${plan.length} variants on product #${id} (${productName})`,
|
||||||
|
productId: id,
|
||||||
|
productName,
|
||||||
|
variantsUpdated: plan.length,
|
||||||
|
variants: report,
|
||||||
|
unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
.get("/:id/detail-sections", async ({ params, query, status }) => {
|
.get("/:id/detail-sections", async ({ params, query, status }) => {
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ test("settings writes send only supported fields and simple product fields are a
|
|||||||
try {
|
try {
|
||||||
client = await connectMcp(`http://localhost:${backend.port}/api`);
|
client = await connectMcp(`http://localhost:${backend.port}/api`);
|
||||||
const tools = (await client.listTools()).tools;
|
const tools = (await client.listTools()).tools;
|
||||||
expect(tools).toHaveLength(113);
|
expect(tools).toHaveLength(116);
|
||||||
expect(tools.find((item) => item.name === "get_footer_layout")?.annotations?.readOnlyHint).toBe(true);
|
expect(tools.find((item) => item.name === "get_footer_layout")?.annotations?.readOnlyHint).toBe(true);
|
||||||
expect(tools.find((item) => item.name === "delete_category_section")?.annotations?.destructiveHint).toBe(true);
|
expect(tools.find((item) => item.name === "delete_category_section")?.annotations?.destructiveHint).toBe(true);
|
||||||
const footerLayout = await client.callTool({ name: "get_footer_layout", arguments: {} });
|
const footerLayout = await client.callTool({ name: "get_footer_layout", arguments: {} });
|
||||||
|
|||||||
@@ -371,6 +371,65 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
required: ["id"]
|
required: ["id"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "list_products_bom_status",
|
||||||
|
description: "List products and their BOM (Bill of Materials) setup status (whether variants have formulas defined). Useful for finding products needing BOM configuration.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
search: { type: "string", description: "Search by product name, brand, or SKU" },
|
||||||
|
status: { type: "string", enum: ["all", "missing", "configured", "partial"], description: "Filter by BOM status. Default is 'all'." },
|
||||||
|
limit: { type: "number", description: "Maximum products to return. Default 20." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_product_bom",
|
||||||
|
description: "Get full BOM formula details, current material costs, gross profit, margin %, and available raw materials for a product. Use this to inspect a product's variants and determine required materials.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
productId: { type: "number", description: "Product ID" }
|
||||||
|
},
|
||||||
|
required: ["productId"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set_product_bom",
|
||||||
|
description: "Set or update the BOM (Bill of Materials) formula for a product's variants. Matches variants by variantId or sku, and raw materials by rawMaterialId or name. Replaces the variant's BOM items and recalculates cost/margin.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
productId: { type: "number", description: "Product ID" },
|
||||||
|
variants: {
|
||||||
|
type: "array",
|
||||||
|
description: "Array of variant BOM formulas to apply",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
variantId: { type: "number", description: "Variant ID (optional if sku provided)" },
|
||||||
|
sku: { type: "string", description: "Variant SKU (optional if variantId provided)" },
|
||||||
|
bomItems: {
|
||||||
|
type: "array",
|
||||||
|
description: "List of raw material ingredients/packaging for this variant",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
rawMaterialId: { type: "number", description: "Raw material ID (optional if name provided)" },
|
||||||
|
name: { type: "string", description: "Raw material name (optional if rawMaterialId provided)" },
|
||||||
|
quantity: { type: "number", description: "Quantity of raw material used per unit" }
|
||||||
|
},
|
||||||
|
required: ["quantity"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["bomItems"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["productId", "variants"]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "update_category_translation",
|
name: "update_category_translation",
|
||||||
description: "Add or update translations for a specific category. Do NOT use this tool for products.",
|
description: "Add or update translations for a specific category. Do NOT use this tool for products.",
|
||||||
@@ -1449,6 +1508,22 @@ async function handleToolRequest(request: CallToolRequest) {
|
|||||||
const res = await api.get(`/products/${params.id}${lang}`);
|
const res = await api.get(`/products/${params.id}${lang}`);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
}
|
}
|
||||||
|
case "list_products_bom_status": {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.search) query.set("search", String(params.search));
|
||||||
|
if (params.status) query.set("status", String(params.status));
|
||||||
|
query.set("limit", String(params.limit ?? 20));
|
||||||
|
const res = await api.get(`/products/bom-status?${query.toString()}`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "get_product_bom": {
|
||||||
|
const res = await api.get(`/products/${params.productId}/bom`);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
|
case "set_product_bom": {
|
||||||
|
const res = await api.put(`/products/${params.productId}/bom`, { variants: params.variants });
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
case "create_product": {
|
case "create_product": {
|
||||||
const res = await api.post(`/products`, params);
|
const res = await api.post(`/products`, params);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Connecting to MCP server...");
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: process.execPath,
|
||||||
|
args: [join(import.meta.dir, "index.ts")],
|
||||||
|
cwd: import.meta.dir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MCP_BACKEND_URL: "http://localhost:3051/api",
|
||||||
|
},
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new Client({ name: "bom-test-client", version: "1.0.0" });
|
||||||
|
await client.connect(transport);
|
||||||
|
console.log("Connected to MCP server!");
|
||||||
|
|
||||||
|
// 1. List tools and verify BOM tools are registered
|
||||||
|
const tools = (await client.listTools()).tools;
|
||||||
|
console.log(`Total MCP tools registered: ${tools.length}`);
|
||||||
|
|
||||||
|
const bomTools = ["list_products_bom_status", "get_product_bom", "set_product_bom"];
|
||||||
|
for (const name of bomTools) {
|
||||||
|
const found = tools.find((t) => t.name === name);
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(`Tool ${name} not found!`);
|
||||||
|
}
|
||||||
|
console.log(`✓ Found tool: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Call list_products_bom_status
|
||||||
|
console.log("\nCalling list_products_bom_status for search='ซากุระ'...");
|
||||||
|
const statusRes = await client.callTool({
|
||||||
|
name: "list_products_bom_status",
|
||||||
|
arguments: { search: "ซากุระ", limit: 5 },
|
||||||
|
});
|
||||||
|
console.log("Status result:", (statusRes.content as any)[0].text.slice(0, 300) + "...");
|
||||||
|
|
||||||
|
// 3. Call get_product_bom for Product #250
|
||||||
|
console.log("\nCalling get_product_bom for productId=250...");
|
||||||
|
const getBomRes = await client.callTool({
|
||||||
|
name: "get_product_bom",
|
||||||
|
arguments: { productId: 250 },
|
||||||
|
});
|
||||||
|
const bomData = JSON.parse((getBomRes.content as any)[0].text);
|
||||||
|
console.log(`Product Name: ${bomData.productName}`);
|
||||||
|
console.log(`Variants count: ${bomData.variants.length}`);
|
||||||
|
console.log(`Available Raw Materials count: ${bomData.availableRawMaterials.length}`);
|
||||||
|
|
||||||
|
// 4. Call set_product_bom for Product #250
|
||||||
|
console.log("\nCalling set_product_bom for Product #250 with 4 variants...");
|
||||||
|
const setBomRes = await client.callTool({
|
||||||
|
name: "set_product_bom",
|
||||||
|
arguments: {
|
||||||
|
productId: 250,
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
variantId: 3206,
|
||||||
|
sku: "4-1-30ML",
|
||||||
|
bomItems: [
|
||||||
|
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 2.4 },
|
||||||
|
{ rawMaterialId: 91, name: "DPG", quantity: 27.6 },
|
||||||
|
{ rawMaterialId: 92, name: "ขวดพร้อมฝา Oilozone 30ml", quantity: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variantId: 3207,
|
||||||
|
sku: "4-1-60ML",
|
||||||
|
bomItems: [
|
||||||
|
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 4.8 },
|
||||||
|
{ rawMaterialId: 91, name: "DPG", quantity: 55.2 },
|
||||||
|
{ rawMaterialId: 93, name: "ขวดพร้อมฝา Oilozone 60ml", quantity: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variantId: 3208,
|
||||||
|
sku: "4-1-85ML",
|
||||||
|
bomItems: [
|
||||||
|
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 6.8 },
|
||||||
|
{ rawMaterialId: 91, name: "DPG", quantity: 78.2 },
|
||||||
|
{ rawMaterialId: 94, name: "ขวดพร้อมฝา Oilozone 85ml", quantity: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variantId: 3209,
|
||||||
|
sku: "4-1-120ML",
|
||||||
|
bomItems: [
|
||||||
|
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 9.6 },
|
||||||
|
{ rawMaterialId: 91, name: "DPG", quantity: 110.4 },
|
||||||
|
{ rawMaterialId: 95, name: "ขวดพร้อมฝา Oilozone 120ml", quantity: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setResult = JSON.parse((setBomRes.content as any)[0].text);
|
||||||
|
console.log("\nSet BOM result:");
|
||||||
|
console.log(`Success: ${setResult.success}`);
|
||||||
|
console.log(`Message: ${setResult.message}`);
|
||||||
|
for (const v of setResult.variants) {
|
||||||
|
console.log(` - Variant: ${v.option} (SKU: ${v.sku}) | Retail: ฿${v.retailPrice} | BOM Cost: ฿${v.totalBomCost} | Margin: ${v.marginPercent}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
console.log("\nAll BOM MCP tests passed successfully!");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Test failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user