feat(F002-P0-A): 商品上架 - Mac 端 publish-to-direv API + web 按钮

- product.service.ts: 新增 publishToDirev() 调 MCP import_products
- product.controller.ts: 新增 POST /api/products/publish-to-direv
- product.module.ts: 引入 IntegrationModule
- ProductPage.tsx: 工具栏加 '上架 B2B' 按钮 (绿色)
- 字段映射: Product(category/price/brand) → MCP(category_id/price/brandName)
This commit is contained in:
Sisyphus 2026-06-24 12:20:22 +08:00
parent 4ab0040e55
commit 672a4b41b2
4 changed files with 120 additions and 3 deletions

View File

@ -40,6 +40,14 @@ export class ProductController {
return this.productService.batchPublish(body.ids) return this.productService.batchPublish(body.ids)
} }
/**
* F002 P0-A: 批量上架商品到 B2B 平台 (MCP import_products)
*/
@Post('publish-to-direv')
publishToDirev(@Body() body: { ids: string[] }) {
return this.productService.publishToDirev(body.ids)
}
@Delete(':id') @Delete(':id')
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.productService.remove(id) return this.productService.remove(id)

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ProductController } from './product.controller' import { ProductController } from './product.controller'
import { ProductService } from './product.service' import { ProductService } from './product.service'
import { IntegrationModule } from '../integration/integration.module'
@Module({ @Module({
imports: [IntegrationModule],
controllers: [ProductController], controllers: [ProductController],
providers: [ProductService], providers: [ProductService],
exports: [ProductService], exports: [ProductService],

View File

@ -1,9 +1,15 @@
import { Injectable } from '@nestjs/common' import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
import { IntegrationService } from '../integration/integration.service'
@Injectable() @Injectable()
export class ProductService { export class ProductService {
constructor(private prisma: PrismaService) {} private readonly logger = new Logger(ProductService.name)
constructor(
private prisma: PrismaService,
private integrationService: IntegrationService,
) {}
async findAll() { async findAll() {
return this.prisma.product.findMany({ orderBy: { createdAt: 'desc' } }) return this.prisma.product.findMany({ orderBy: { createdAt: 'desc' } })
@ -45,4 +51,81 @@ export class ProductService {
async remove(id: string) { async remove(id: string) {
return this.prisma.product.delete({ where: { id } }) return this.prisma.product.delete({ where: { id } })
} }
/**
* F002 P0-A: 批量上架商品到 B2B 平台 (MCP import_products)
* 字段映射: Product → import_products schema
*/
async publishToDirev(ids: string[]) {
const b2bIntegration = await this.integrationService.findActiveMCP()
if (!b2bIntegration) {
throw new ServiceUnavailableException('未配置 B2B 集成')
}
const products = await this.prisma.product.findMany({
where: { id: { in: ids } },
})
if (products.length === 0) {
throw new ServiceUnavailableException('未找到指定商品')
}
// Category string → numeric ID mapping (B2B tb_good first_category_id)
const CATEGORY_MAP: Record<string, number> = {
'电缆': 28,
'电力电缆': 28,
'配电箱': 29,
'开关柜': 29,
'变压器': 27,
'电线': 28,
'母线槽': 30,
'桥架': 31,
}
const fallbackCategoryId = 1
const items = products.map(p => {
const catId = CATEGORY_MAP[p.category] || fallbackCategoryId
const item: any = {
name: p.name,
category_id: catId,
price: p.price,
brandName: p.brand || '',
stock: p.quantity,
}
if (p.spec) item.sku = p.spec
if (p.imageUrl) item.images = [p.imageUrl]
if (p.supplier) item.remark = `供应商:${p.supplier}`
return item
})
const result = await this.integrationService.executeTool<any>(
b2bIntegration.id,
'import_products',
{ products: items },
{ retries: 2, retryDelayMs: 1000 },
)
if (!result.success) {
const errorMsg = result.error || '未知错误'
this.logger.error(`商品上架失败 (ids=${ids.join(',')}): ${errorMsg}`)
throw new ServiceUnavailableException(`上架失败: ${errorMsg}`)
}
const data = result.data as any
const payload = data.data || data
const imported = payload.imported || 0
const errors = payload.errors || []
this.logger.log(`商品上架成功: 导入 ${imported} 条, 失败 ${errors.length} 条`)
await this.prisma.product.updateMany({
where: { id: { in: products.map(p => p.id) } },
data: { status: 'published' },
})
return {
imported,
errors,
ids: payload.ids || [],
}
}
} }

View File

@ -182,6 +182,25 @@ export default function ProductPage() {
showToast('批量同步完成') showToast('批量同步完成')
} }
const batchPublishToDirev = async () => {
if (selectedIds.size === 0) return
showToast(`正在上架 ${selectedIds.size} 个商品到 B2B 平台...`)
try {
const res = await fetch(`${API}/publish-to-direv`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: Array.from(selectedIds) }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.message || data.error || '请求失败')
setSelectedIds(new Set())
await loadProducts()
showToast(`上架成功: ${data.imported || 0} 条${data.errors?.length ? `, 失败 ${data.errors.length} 条` : ''}`)
} catch (e: any) {
showToast(`上架失败: ${e.message}`)
}
}
const removeProduct = async (id: string) => { const removeProduct = async (id: string) => {
await fetch(`${API}/${id}`, { method: 'DELETE' }) await fetch(`${API}/${id}`, { method: 'DELETE' })
showToast('已删除') showToast('已删除')
@ -451,7 +470,12 @@ export default function ProductPage() {
<button disabled={selectedIds.size === 0} onClick={batchSync} <button disabled={selectedIds.size === 0} onClick={batchSync}
className="px-3 py-1.5 text-[10px] font-medium bg-secondary text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:opacity-90"> className="px-3 py-1.5 text-[10px] font-medium bg-secondary text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:opacity-90">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
批量同步 批量同步
</button>
<button disabled={selectedIds.size === 0} onClick={batchPublishToDirev}
className="px-3 py-1.5 text-[10px] font-medium bg-emerald-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:opacity-90">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
上架 B2B
</button> </button>
<button disabled={selectedIds.size === 0} onClick={batchGenerate} <button disabled={selectedIds.size === 0} onClick={batchGenerate}
className="px-3 py-1.5 text-[10px] font-medium bg-white border border-purple-200 text-purple-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:bg-purple-50 transition-colors"> className="px-3 py-1.5 text-[10px] font-medium bg-white border border-purple-200 text-purple-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:bg-purple-50 transition-colors">