diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts index 6bf3e9d..7f85cdd 100644 --- a/apps/backend/src/routes/products.ts +++ b/apps/backend/src/routes/products.ts @@ -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 }) => { const id = parseInt(params.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(); + const rawMaterialByName = new Map(); + 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 }) => { const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); diff --git a/apps/mcp-server/index.contract.test.ts b/apps/mcp-server/index.contract.test.ts index 20fc403..1a4516c 100644 --- a/apps/mcp-server/index.contract.test.ts +++ b/apps/mcp-server/index.contract.test.ts @@ -51,7 +51,7 @@ test("settings writes send only supported fields and simple product fields are a try { client = await connectMcp(`http://localhost:${backend.port}/api`); 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 === "delete_category_section")?.annotations?.destructiveHint).toBe(true); const footerLayout = await client.callTool({ name: "get_footer_layout", arguments: {} }); diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 73c594e..623d5bb 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -371,6 +371,65 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { 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", 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}`); 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": { const res = await api.post(`/products`, params); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; diff --git a/apps/mcp-server/test_mcp_bom.ts b/apps/mcp-server/test_mcp_bom.ts new file mode 100644 index 0000000..1e07032 --- /dev/null +++ b/apps/mcp-server/test_mcp_bom.ts @@ -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); +});