fix(bom-costing): 统一 SKU 尺寸输出契约
This commit is contained in:
parent
a8dff278e0
commit
bfdefd20d3
@ -54,6 +54,32 @@ describe('CompetitorAnalysisResultV1', () => {
|
||||
expect(invalid.success).toBe(false)
|
||||
})
|
||||
|
||||
it('SKU 没有尺寸证据时规范化为空尺寸对象', () => {
|
||||
const result = competitorAnalysisResultV1Schema.parse({
|
||||
schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
|
||||
sourceUrl: 'https://example.com/product/1',
|
||||
product: { title: '竞品', imageUrls: [] },
|
||||
skus: [{
|
||||
externalSkuId: 'sku-1',
|
||||
name: 'SKU',
|
||||
attributes: {},
|
||||
bomLines: [{
|
||||
externalLineId: 'line-1',
|
||||
category: 'PROCESSING',
|
||||
partName: '加工',
|
||||
quantity: 1,
|
||||
unit: '套',
|
||||
estimatedUnitPrice: 1,
|
||||
evidence: [],
|
||||
confidence: 0.8,
|
||||
}],
|
||||
}],
|
||||
analyzedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
expect(result.skus[0].dimensions).toEqual({})
|
||||
})
|
||||
|
||||
it('允许归档后的站内对象地址,但拒绝任意相对路径', () => {
|
||||
const base = {
|
||||
schemaVersion: BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
|
||||
|
||||
@ -65,7 +65,7 @@ export const competitorSkuSchema = z.object({
|
||||
lengthMm: optionalPositiveNumberSchema,
|
||||
widthMm: optionalPositiveNumberSchema,
|
||||
heightMm: optionalPositiveNumberSchema,
|
||||
}),
|
||||
}).default({}),
|
||||
weightKg: optionalPositiveNumberSchema,
|
||||
packaging: z.object({
|
||||
lengthMm: optionalPositiveNumberSchema,
|
||||
|
||||
@ -188,6 +188,66 @@ describe('BOM 多模态分析器', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('模型省略无证据的 SKU 尺寸对象时仍生成规范结果', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
product: { category: '书桌' },
|
||||
structureGroups: [analyzedStructureGroup()],
|
||||
skus: [
|
||||
{
|
||||
externalSkuId: 'sku-white',
|
||||
structureGroupKey: 'desk-120',
|
||||
attributes: {},
|
||||
},
|
||||
{
|
||||
externalSkuId: 'sku-black',
|
||||
structureGroupKey: 'desk-120',
|
||||
attributes: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
}],
|
||||
}), { status: 200 }),
|
||||
)
|
||||
|
||||
const result = await new BomCostingMultimodalAnalyzer().analyze({
|
||||
sourceUrl: snapshot.detailUrl,
|
||||
snapshot,
|
||||
})
|
||||
|
||||
expect(result.skus.map((sku) => sku.dimensions)).toEqual([{}, {}])
|
||||
})
|
||||
|
||||
it('模型返回其他不完整 SKU 信息时不暴露内部校验细节', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
product: { category: '书桌' },
|
||||
structureGroups: [analyzedStructureGroup()],
|
||||
skus: [
|
||||
{ externalSkuId: 'sku-white' },
|
||||
analyzedSku('sku-black'),
|
||||
],
|
||||
}),
|
||||
},
|
||||
}],
|
||||
}), { status: 200 }),
|
||||
)
|
||||
|
||||
await expect(
|
||||
new BomCostingMultimodalAnalyzer().analyze({
|
||||
sourceUrl: snapshot.detailUrl,
|
||||
snapshot,
|
||||
}),
|
||||
).rejects.toThrow('多模态分析返回的 SKU 信息不完整,请重新处理')
|
||||
})
|
||||
|
||||
it('模型漏掉 SKU 时直接失败,不补造结果', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
|
||||
@ -23,7 +23,7 @@ const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务
|
||||
3. 不得把商品售价当作成本,不得根据售价倒推材料价格。
|
||||
4. 材料价格由产品平台资料库匹配;BOARD、STEEL、ACCESSORY、PACKAGING 不填写 estimatedUnitPrice。
|
||||
5. estimatedUnitPrice 只允许用于 PROCESSING 和 LABOR,单位为人民币,并按证据强弱降低 confidence。
|
||||
6. 尺寸统一使用毫米,重量统一使用千克;没有证据的可选字段直接省略,不得编造精确数值。
|
||||
6. 尺寸统一使用毫米,重量统一使用千克;每条 SKU 必须返回 dimensions 对象,没有尺寸证据时返回空对象 {},对象内没有证据的可选字段直接省略,不得编造精确数值。
|
||||
7. 同结构 SKU 使用相同 structureGroupKey;每个结构组只在 structureGroups 中返回一次 bomLines,SKU 记录只绑定结构组,不得重复输出 BOM。
|
||||
8. BOM 分类只能是 BOARD、STEEL、ACCESSORY、PACKAGING、PROCESSING、LABOR。
|
||||
9. evidence 必须说明依据;引用图片时使用输入中真实的图片 URL。
|
||||
@ -321,6 +321,18 @@ function describeMultimodalHttpError(status: number): string {
|
||||
return `多模态分析服务返回 ${status}`
|
||||
}
|
||||
|
||||
function describeAnalysisPayloadError(error: unknown): string {
|
||||
if (error instanceof z.ZodError) {
|
||||
const hasSkuIssue = error.issues.some((issue) => issue.path[0] === 'skus')
|
||||
return hasSkuIssue
|
||||
? '多模态分析返回的 SKU 信息不完整,请重新处理'
|
||||
: '多模态分析返回的产品结构信息不完整,请重新处理'
|
||||
}
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: '多模态分析返回的信息不完整,请重新处理'
|
||||
}
|
||||
|
||||
function assertExactSkuCoverage(
|
||||
sourceSkus: MarketSkuSnapshot[],
|
||||
analyzedSkus: z.infer<typeof competitorSkuSchema>[],
|
||||
@ -552,10 +564,9 @@ export class BomCostingMultimodalAnalyzer {
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : '结构校验失败'
|
||||
throw new AppError(
|
||||
ErrorCode.BOM_COSTING_INVALID_PROVIDER,
|
||||
`多模态分析结果不完整:${details}`,
|
||||
describeAnalysisPayloadError(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user