fix(bom-costing): 统一 SKU 尺寸输出契约

This commit is contained in:
WuYongJie 2026-07-29 11:07:10 +08:00
parent a8dff278e0
commit bfdefd20d3
4 changed files with 101 additions and 4 deletions

View File

@ -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,

View File

@ -65,7 +65,7 @@ export const competitorSkuSchema = z.object({
lengthMm: optionalPositiveNumberSchema,
widthMm: optionalPositiveNumberSchema,
heightMm: optionalPositiveNumberSchema,
}),
}).default({}),
weightKg: optionalPositiveNumberSchema,
packaging: z.object({
lengthMm: optionalPositiveNumberSchema,

View File

@ -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({

View File

@ -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),
)
}
}