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 }) => {
|
||||
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<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 }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||
|
||||
Reference in new issue
Block a user