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:
parent
4ab0040e55
commit
672a4b41b2
@ -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)
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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 || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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('已删除')
|
||||||
@ -453,6 +472,11 @@ export default function ProductPage() {
|
|||||||
<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>
|
||||||
|
<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 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">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></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 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user