Merge remote-tracking branch 'origin/codex/wuyongjie-bom-category-skills-v7' into codex/wuyongjie-promote-bom-optional-20260813

This commit is contained in:
WuYongJie 2026-08-13 04:49:02 +08:00
commit 439b427259
7 changed files with 1089 additions and 39 deletions

View File

@ -12,6 +12,10 @@ jest.mock('@/lib/prisma', () => ({
},
bomCostingScenarioLine: {
createMany: jest.fn(),
findMany: jest.fn(),
},
bomCostingSku: {
findMany: jest.fn(),
},
bomCostingReferenceRow: {
findMany: jest.fn(),
@ -37,6 +41,8 @@ const substrateFindMany = prisma.substrate.findMany as jest.Mock
const transaction = prisma.$transaction as jest.Mock
const scenarioUpdateMany = prisma.bomCostingScenario.updateMany as jest.Mock
const scenarioDeleteMany = prisma.bomCostingScenario.deleteMany as jest.Mock
const scenarioLineFindMany = prisma.bomCostingScenarioLine.findMany as jest.Mock
const skuFindMany = prisma.bomCostingSku.findMany as jest.Mock
describe('BomCostingScenarioRepository', () => {
beforeEach(() => {
@ -225,6 +231,66 @@ describe('BomCostingScenarioRepository', () => {
expect(JSON.stringify(relaxedQuery.where.AND)).toContain('开料打磨')
})
it('批次 SKU 查询一次返回成本族、外形和板材基准字段', async () => {
skuFindMany.mockResolvedValue([])
const repository = new BomCostingScenarioRepository()
await repository.listCaseSkuIds('case-1')
expect(skuFindMany).toHaveBeenCalledWith(expect.objectContaining({
where: { caseId: 'case-1', case: { deletedAt: null } },
select: expect.objectContaining({
id: true,
structureGroupKey: true,
costFamilyKey: true,
lengthMm: true,
widthMm: true,
heightMm: true,
bomLines: {
where: { category: 'BOARD' },
select: { lengthMm: true, widthMm: true, quantity: true },
},
}),
}))
})
it('兄弟 SKU 历史依据同时按结构组和成本族隔离', async () => {
scenarioLineFindMany.mockResolvedValue([])
const repository = new BomCostingScenarioRepository()
await repository.listSiblingScenarioReferenceSkus({
caseId: 'case-1',
skuId: 'sku-current',
structureGroupKey: 'wardrobe-base',
costFamilyKey: 'ash-natural',
})
expect(scenarioLineFindMany).toHaveBeenCalledWith(expect.objectContaining({
where: {
scenario: {
caseId: 'case-1',
isCurrent: true,
sku: {
id: { not: 'sku-current' },
structureGroupKey: 'wardrobe-base',
costFamilyKey: 'ash-natural',
},
},
},
}))
})
it('成本族缺失时拒绝读取结构组兄弟历史,避免跨材质串用', async () => {
const repository = new BomCostingScenarioRepository()
await expect(repository.listSiblingScenarioReferenceSkus({
caseId: 'case-1',
structureGroupKey: 'wardrobe-base',
costFamilyKey: '',
})).resolves.toEqual([])
expect(scenarioLineFindMany).not.toHaveBeenCalled()
})
it('板材尺寸未命中时不取消尺寸限制重查', async () => {
referenceFindMany.mockResolvedValue([])
const repository = new BomCostingScenarioRepository()

View File

@ -397,7 +397,23 @@ export class BomCostingScenarioRepository {
return prisma.bomCostingSku.findMany({
where: { caseId, case: { deletedAt: null } },
orderBy: { sortOrder: 'asc' },
select: { id: true, externalSkuId: true, structureGroupKey: true },
select: {
id: true,
externalSkuId: true,
structureGroupKey: true,
costFamilyKey: true,
lengthMm: true,
widthMm: true,
heightMm: true,
bomLines: {
where: { category: 'BOARD' },
select: {
lengthMm: true,
widthMm: true,
quantity: true,
},
},
},
})
}
@ -745,9 +761,11 @@ export class BomCostingScenarioRepository {
caseId: string
skuId?: string
structureGroupKey?: string
costFamilyKey?: string
}) {
const structureGroupKey = input.structureGroupKey?.trim()
if (!structureGroupKey) return []
const costFamilyKey = input.costFamilyKey?.trim()
if (!structureGroupKey || !costFamilyKey) return []
const rows = await prisma.bomCostingScenarioLine.findMany({
where: {
scenario: {
@ -756,6 +774,7 @@ export class BomCostingScenarioRepository {
sku: {
...(input.skuId ? { id: { not: input.skuId } } : {}),
structureGroupKey,
costFamilyKey,
},
},
},

View File

@ -1068,8 +1068,8 @@ describe('BomCostingScenarioService', () => {
it('同结构组在运行前冻结同一批候选依据,正序倒序结果一致', async () => {
const forward = [
{ id: 'sku-a', externalSkuId: 'EXT-A', structureGroupKey: 'group-frozen' },
{ id: 'sku-b', externalSkuId: 'EXT-B', structureGroupKey: 'group-frozen' },
{ id: 'sku-a', externalSkuId: 'EXT-A', structureGroupKey: 'group-frozen', costFamilyKey: 'family-frozen', lengthMm: null, widthMm: null, heightMm: null, bomLines: [] },
{ id: 'sku-b', externalSkuId: 'EXT-B', structureGroupKey: 'group-frozen', costFamilyKey: 'family-frozen', lengthMm: null, widthMm: null, heightMm: null, bomLines: [] },
]
jest.spyOn(bomCostingScenarioRepository, 'listCaseSkuIds')
.mockResolvedValueOnce([...forward].reverse())
@ -1083,6 +1083,7 @@ describe('BomCostingScenarioService', () => {
id: 'sku-a',
name: '同结构桌子',
structureGroupKey: 'group-frozen',
costFamilyKey: 'family-frozen',
heightMm: null,
bomLines: [],
} as never)
@ -1123,9 +1124,166 @@ describe('BomCostingScenarioService', () => {
expect(bomCostingScenarioRepository.listSiblingScenarioReferenceSkus).toHaveBeenNthCalledWith(1, {
caseId: 'case-1',
structureGroupKey: 'group-frozen',
costFamilyKey: 'family-frozen',
})
})
it('同成本族 partial BOARD 按冻结外形相对缩放且反序结果一致,不与其他成本族串用', async () => {
const familySku = (
id: string,
externalSkuId: string,
costFamilyKey: string,
lengthMm: number,
) => ({
id,
externalSkuId,
structureGroupKey: 'wardrobe-base',
costFamilyKey,
lengthMm,
widthMm: 600,
heightMm: 2_000,
bomLines: [
{ lengthMm: 2_000, widthMm: 600, quantity: 2 },
{ lengthMm: 2_000, widthMm: null, quantity: 1 },
],
})
const forward = [
familySku('sku-small', 'EXT-SMALL', 'ash-natural', 1_200),
familySku('sku-large', 'EXT-LARGE', 'ash-natural', 1_800),
familySku('sku-oak', 'EXT-OAK', 'oak-natural', 2_400),
]
jest.spyOn(bomCostingScenarioRepository, 'listCaseSkuIds')
.mockResolvedValueOnce([...forward].reverse() as never)
.mockResolvedValueOnce(forward as never)
jest.spyOn(bomCostingScenarioRepository, 'findLatestSyncStatuses').mockResolvedValue({
latestSuccessful: { id: 'run-success' },
} as never)
jest.spyOn(bomCostingScenarioRepository, 'listSiblingScenarioReferenceSkus').mockResolvedValue([])
jest.spyOn(bomCostingScenarioRepository, 'findSkuForScenario').mockImplementation(async ({ skuId }) => ({
...forward.find((sku) => sku.id === skuId),
name: '衣柜',
}) as never)
const service = new BomCostingScenarioService()
const calculate = jest.spyOn(service, 'calculateSameMaterial').mockResolvedValue({
sameMaterial: { totalCost: 88 },
} as never)
await service.calculateSameMaterialForCase('case-1')
const reverseContexts = new Map(calculate.mock.calls.map(([input]) => [
input.skuId,
input.frozenPartialBoardFamilyScale,
]))
calculate.mockClear()
await service.calculateSameMaterialForCase('case-1')
const forwardContexts = new Map(calculate.mock.calls.map(([input]) => [
input.skuId,
input.frozenPartialBoardFamilyScale,
]))
expect(reverseContexts).toEqual(forwardContexts)
expect(forwardContexts.get('sku-small')).toEqual(expect.objectContaining({
structureGroupKey: 'wardrobe-base',
costFamilyKey: 'ash-natural',
metricSource: 'OUTER_THREE_AXIS_SURFACE',
metricAxes: ['lengthMm', 'widthMm', 'heightMm'],
sampleCount: 2,
}))
expect(forwardContexts.get('sku-large')!.appliedScale)
.toBeGreaterThan(forwardContexts.get('sku-small')!.appliedScale)
expect(forwardContexts.get('sku-oak')).toBeUndefined()
expect(bomCostingScenarioRepository.listSiblingScenarioReferenceSkus)
.toHaveBeenCalledWith(expect.objectContaining({
structureGroupKey: 'wardrobe-base',
costFamilyKey: 'ash-natural',
}))
expect(bomCostingScenarioRepository.listSiblingScenarioReferenceSkus)
.toHaveBeenCalledWith(expect.objectContaining({
structureGroupKey: 'wardrobe-base',
costFamilyKey: 'oak-natural',
}))
})
it('partial BOARD 使用冻结族内外形系数缩放整套正残差,同时保留直接价', async () => {
const bomLines = [
line({ id: 'priced', category: 'BOARD', partName: '已定价板', materialName: '橡胶木', quantity: 1, lengthMm: 2_000, widthMm: 500 }),
line({ id: 'missing', category: 'BOARD', partName: '缺尺寸板', materialName: '橡胶木', quantity: 1, lengthMm: 2_000, widthMm: null }),
]
mockBase({ bomLines })
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockImplementation(async (input) => (
input.partName === '已定价板'
? [candidate({ id: 'priced-ref', category: 'BOARD', partName: '已定价板', materialName: '橡胶木', unitPrice: 40, lengthMm: 2_000, widthMm: 500 })]
: []
))
jest.spyOn(bomCostingScenarioRepository, 'findCategoryCohortCandidates').mockImplementation(async (input) => (
input.category === 'BOARD'
? [1, 2, 3].map((index) => candidate({ id: `profile-${index}`, category: 'BOARD', partName: '整套板材', sourceSku: `REF-${index}`, unitPrice: null, amount: 100 }))
: []
))
await new BomCostingScenarioService().calculateSameMaterial({
caseId: 'case-1',
skuId: 'sku-1',
frozenPartialBoardFamilyScale: {
structureGroupKey: 'group-1',
costFamilyKey: 'family-1',
metricSource: 'OUTER_TWO_AXIS_AREA',
metricAxes: ['lengthMm', 'heightMm'],
currentMetric: 3_600_000,
familyMedianMetric: 3_000_000,
rawScale: 1.2,
appliedScale: 1.2,
sampleCount: 3,
clamp: { min: 0.5, max: 2 },
},
})
const saved = jest.mocked(bomCostingScenarioRepository.saveSameMaterialScenario).mock.calls[0]![0]
const lines = new Map(saved.sameMaterial.lines.map((entry) => [entry.sourceLineId, entry]))
expect(saved.sameMaterial.total).toBe(120)
expect(lines.get('priced')).toMatchObject({ subtotal: 40, unitPrice: 40 })
expect(lines.get('missing')).toMatchObject({
subtotal: 80,
calculationRule: 'reference-profile-category-gap-allocation/v4-partial-family-scaled',
confidence: 0.38,
rawSnapshot: {
calculation: {
priceSourceAudit: expect.objectContaining({
familyScaleApplied: true,
partialPhysicalQuantityExcludedFromScale: true,
familyScaleMetricUsesHistoricalPhysicalQuantity: false,
partialKnownBoardAreaUsedForScale: false,
unscaledBoardFallbackReason: 'PARTIAL_FACE_DIMENSIONS_FAMILY_RELATIVE_SCALE',
alreadyPricedCategoryCost: 40,
remainingCategoryEstimate: 80,
}),
},
},
})
})
it('结构组或成本族任一缺失时每个 SKU 独立,不生成族内缩放基准', async () => {
const siblings = jest.spyOn(bomCostingScenarioRepository, 'listSiblingScenarioReferenceSkus')
.mockResolvedValue([])
jest.spyOn(bomCostingScenarioRepository, 'listCaseSkuIds').mockResolvedValue([
{ id: 'sku-no-family-a', externalSkuId: 'A', structureGroupKey: 'shared', costFamilyKey: '', lengthMm: 1_000, widthMm: 500, heightMm: 800, bomLines: [] },
{ id: 'sku-no-family-b', externalSkuId: 'B', structureGroupKey: 'shared', costFamilyKey: '', lengthMm: 2_000, widthMm: 500, heightMm: 800, bomLines: [] },
{ id: 'sku-no-structure-a', externalSkuId: 'C', structureGroupKey: '', costFamilyKey: 'family', lengthMm: 1_000, widthMm: 500, heightMm: 800, bomLines: [] },
{ id: 'sku-no-structure-b', externalSkuId: 'D', structureGroupKey: '', costFamilyKey: 'family', lengthMm: 2_000, widthMm: 500, heightMm: 800, bomLines: [] },
] as never)
const service = new BomCostingScenarioService()
const calculate = jest.spyOn(service, 'calculateSameMaterial').mockResolvedValue({
sameMaterial: { totalCost: 88 },
} as never)
await service.calculateSameMaterialForCase('case-1')
expect(calculate.mock.calls).toHaveLength(4)
expect(calculate.mock.calls.every(([input]) => (
input.frozenPartialBoardFamilyScale === undefined
))).toBe(true)
expect(siblings).not.toHaveBeenCalled()
})
it('关联工艺和同结构工艺都缺价时明确标记数据不足', async () => {
const bomLines = [
line({
@ -2153,7 +2311,7 @@ describe('BomCostingScenarioService', () => {
historicalMedianCategoryTotal: 100,
currentPhysicalQuantity: 2.5,
areaScaleApplied: false,
unscaledBoardFallbackReason: 'PARTIAL_FACE_DIMENSIONS',
unscaledBoardFallbackReason: 'PARTIAL_FACE_DIMENSIONS_NO_COMPARABLE_FAMILY_BASELINE',
partialPhysicalQuantityExcludedFromScale: true,
appliedPhysicalScale: 1,
alreadyPricedCategoryCost: 40,

View File

@ -1727,6 +1727,96 @@ type CategoryProfileFallbackWarning = {
warning: string
}
type PartialBoardFamilyScaleContext = {
structureGroupKey: string
costFamilyKey: string
metricSource: 'OUTER_THREE_AXIS_SURFACE' | 'OUTER_TWO_AXIS_AREA' | 'OUTER_SINGLE_AXIS'
metricAxes: Array<'lengthMm' | 'widthMm' | 'heightMm'>
currentMetric: number
familyMedianMetric: number
rawScale: number
appliedScale: number
sampleCount: number
clamp: { min: number; max: number }
}
type FamilyScaleSku = {
id: string
lengthMm: Prisma.Decimal | null
widthMm: Prisma.Decimal | null
heightMm: Prisma.Decimal | null
}
const PARTIAL_BOARD_FAMILY_SCALE_MIN = 0.5
const PARTIAL_BOARD_FAMILY_SCALE_MAX = 2
function buildPartialBoardFamilyScaleContexts(groups: Array<{
structureGroupKey: string
costFamilyKey: string
skus: Array<{ id: string }>
}>, skuDetails: ReadonlyMap<string, FamilyScaleSku>) {
const contexts = new Map<string, PartialBoardFamilyScaleContext>()
const axes = ['lengthMm', 'widthMm', 'heightMm'] as const
for (const group of groups) {
if (!group.structureGroupKey || !group.costFamilyKey || group.skus.length < 2) continue
const details = group.skus.flatMap((sku) => {
const detail = skuDetails.get(sku.id)
return detail ? [detail] : []
})
const measurableDetails = details.filter((sku) => axes.some((axis) => {
const value = decimalNumber(sku[axis])
return value !== null && value > 0
}))
if (measurableDetails.length < 2) continue
const commonAxes = axes.filter((axis) => measurableDetails.every((sku) => {
const value = decimalNumber(sku[axis])
return value !== null && value > 0
}))
if (commonAxes.length === 0) continue
const values = measurableDetails.map((sku) => {
const dimensions = commonAxes.map((axis) => decimalNumber(sku[axis])!)
const metric = dimensions.length >= 3
? dimensions[0]! * dimensions[1]! +
dimensions[0]! * dimensions[2]! +
dimensions[1]! * dimensions[2]!
: dimensions.length === 2
? dimensions[0]! * dimensions[1]!
: dimensions[0]!
return { skuId: sku.id, metric }
}).filter((value) => value.metric > 0)
if (values.length < 2) continue
const familyMedianMetric = percentile(values.map((value) => value.metric), 0.5)
if (familyMedianMetric === null || familyMedianMetric <= 0) continue
const metricSource = commonAxes.length >= 3
? 'OUTER_THREE_AXIS_SURFACE'
: commonAxes.length === 2
? 'OUTER_TWO_AXIS_AREA'
: 'OUTER_SINGLE_AXIS'
for (const value of values) {
const rawScale = value.metric / familyMedianMetric
contexts.set(value.skuId, {
structureGroupKey: group.structureGroupKey,
costFamilyKey: group.costFamilyKey,
metricSource,
metricAxes: [...commonAxes],
currentMetric: value.metric,
familyMedianMetric,
rawScale,
appliedScale: Math.min(
PARTIAL_BOARD_FAMILY_SCALE_MAX,
Math.max(PARTIAL_BOARD_FAMILY_SCALE_MIN, rawScale),
),
sampleCount: values.length,
clamp: {
min: PARTIAL_BOARD_FAMILY_SCALE_MIN,
max: PARTIAL_BOARD_FAMILY_SCALE_MAX,
},
})
}
}
return contexts
}
async function fillCategoryProfileFallbacks(input: {
syncRunId: string
lines: Array<{
@ -1742,6 +1832,7 @@ async function fillCategoryProfileFallbacks(input: {
previewLines: DynamicBomLineCostResult[]
familyReferenceSkus: string[]
similarReferenceSkus: string[]
partialBoardFamilyScale?: PartialBoardFamilyScaleContext
}) {
const previewById = new Map(input.previewLines.map((line) => [line.lineId, line]))
const missingByCategory = new Map<string, typeof input.lines>()
@ -1781,23 +1872,33 @@ async function fillCategoryProfileFallbacks(input: {
}
for (const line of missingLines) handledLineIds.add(line.id)
const currentPhysicalQuantity = currentCategoryPhysicalQuantity(category, categoryLines)
const partialBoardFamilyScale = partialBoardDimensionFallback
? input.partialBoardFamilyScale ?? null
: null
const familyScaleApplied = partialBoardFamilyScale !== null
const areaScaleApplied = (
!partialBoardDimensionFallback &&
currentPhysicalQuantity !== null &&
estimate.medianPhysicalQuantity !== null &&
estimate.medianPhysicalQuantity > 0
)
const rawPhysicalScale = areaScaleApplied
? currentPhysicalQuantity / estimate.medianPhysicalQuantity!
: 1
const rawPhysicalScale = familyScaleApplied
? partialBoardFamilyScale.rawScale
: areaScaleApplied
? currentPhysicalQuantity / estimate.medianPhysicalQuantity!
: 1
// Similar BOMs are evidence, not an exact CAD match. Preserve physical
// scaling while limiting incomplete structures or extreme source records
// from multiplying a whole category without bound.
const physicalScale = Math.min(3, Math.max(0.35, rawPhysicalScale))
const physicalScale = familyScaleApplied
? partialBoardFamilyScale.appliedScale
: Math.min(3, Math.max(0.35, rawPhysicalScale))
const allBoardDimensionsMissingFallback = category === 'BOARD' && (
boardCoverage?.billableCount ?? 0
) > 0 && boardCoverage?.completeFaceCount === 0
const unscaledBoardFallback = partialBoardDimensionFallback || allBoardDimensionsMissingFallback
const unscaledBoardFallback = (
partialBoardDimensionFallback && !familyScaleApplied
) || allBoardDimensionsMissingFallback
if (allBoardDimensionsMissingFallback) {
warnings.push({
category,
@ -1809,7 +1910,9 @@ async function fillCategoryProfileFallbacks(input: {
warnings.push({
category,
lineIds: missingLines.map((line) => line.id),
warning: '部分板材面尺寸缺失,未使用已知板件的部分面积缩放整套历史 BOM;当前按相似整套历史 BOM 分类中位数进行未缩放预估,并仅将高于已定价成本的正残差分配给缺价行,需补充缺失尺寸后复核',
warning: familyScaleApplied
? `部分板材面尺寸缺失,未使用已知板件的部分面积与历史整套面积直接相比;当前仅按同成本族 ${partialBoardFamilyScale.sampleCount} 个 SKU 的共同有效外形轴(${partialBoardFamilyScale.metricAxes.join('、')})相对比例缩放历史分类中位数(系数 ${partialBoardFamilyScale.appliedScale.toFixed(3)}),并只向缺价行分配正残差;该结果可信度较低,需补充缺失尺寸后复核`
: '部分板材面尺寸缺失,未使用已知板件的部分面积缩放整套历史 BOM;当前因同成本族缺少可比尺寸基准,按相似整套历史 BOM 分类中位数进行未缩放预估,并仅将高于已定价成本的正残差分配给缺价行,需补充缺失尺寸后复核',
})
}
const estimatedCategoryTotal = estimate.total * physicalScale
@ -1866,10 +1969,12 @@ async function fillCategoryProfileFallbacks(input: {
totalAmount: allocated,
sourceType: 'LOCAL_MIRROR',
sourceId: estimate.sourceId,
sourceLabel: `${estimate.basis === 'SKU_FAMILY' ? '同款 SKU' : estimate.basis === 'SIMILAR_HISTORY' ? '相似产品' : '同分类'}整套历史 BOM 分类总额补齐(${estimate.sampleCount} 套${areaScaleApplied ? ',按当前板件面积缩放' : partialBoardDimensionFallback ? ',部分板件尺寸缺失、未缩放' : allBoardDimensionsMissingFallback ? ',当前板件尺寸全缺、未缩放' : ''})`,
sourceLabel: `${estimate.basis === 'SKU_FAMILY' ? '同款 SKU' : estimate.basis === 'SIMILAR_HISTORY' ? '相似产品' : '同分类'}整套历史 BOM 分类总额补齐(${estimate.sampleCount} 套${areaScaleApplied ? ',按当前板件面积缩放' : familyScaleApplied ? ',部分板件尺寸缺失、按同成本族相对尺寸缩放' : partialBoardDimensionFallback ? ',部分板件尺寸缺失、未缩放' : allBoardDimensionsMissingFallback ? ',当前板件尺寸全缺、未缩放' : ''})`,
materialName: line.materialName,
referenceSku: estimate.referenceSkus[0] ?? '',
confidence: unscaledBoardFallback
confidence: familyScaleApplied
? 0.42
: unscaledBoardFallback
? 0.38
: estimate.basis === 'SKU_FAMILY'
? 0.62
@ -1885,6 +1990,8 @@ async function fillCategoryProfileFallbacks(input: {
sourceSyncedAt: estimate.sourceSyncedAt,
calculationRule: areaScaleApplied
? 'reference-profile-category-gap-allocation/v2-area-scaled'
: familyScaleApplied
? 'reference-profile-category-gap-allocation/v4-partial-family-scaled'
: partialBoardDimensionFallback
? 'reference-profile-category-gap-allocation/v3-partial-unscaled'
: allBoardDimensionsMissingFallback
@ -1914,8 +2021,14 @@ async function fillCategoryProfileFallbacks(input: {
currentPhysicalQuantity,
physicalUnit: estimate.physicalUnit,
areaScaleApplied,
familyScaleApplied,
partialBoardFamilyScale,
familyScaleMetricUsesHistoricalPhysicalQuantity: false,
partialKnownBoardAreaUsedForScale: false,
unscaledBoardFallbackReason: partialBoardDimensionFallback
? 'PARTIAL_FACE_DIMENSIONS'
? familyScaleApplied
? 'PARTIAL_FACE_DIMENSIONS_FAMILY_RELATIVE_SCALE'
: 'PARTIAL_FACE_DIMENSIONS_NO_COMPARABLE_FAMILY_BASELINE'
: allBoardDimensionsMissingFallback
? 'ALL_FACE_DIMENSIONS_MISSING'
: null,
@ -2592,6 +2705,7 @@ export class BomCostingScenarioService {
/** Frozen before a case-wide batch starts; omitted for a standalone SKU run. */
frozenFamilyReferenceSkus?: readonly string[]
frozenStructuralReferenceSkus?: readonly string[]
frozenPartialBoardFamilyScale?: PartialBoardFamilyScaleContext
frozenSyncStatuses?: Awaited<ReturnType<
typeof bomCostingScenarioRepository.findLatestSyncStatuses
>>
@ -2610,6 +2724,7 @@ export class BomCostingScenarioService {
sameMaterialOnly: boolean
frozenFamilyReferenceSkus?: readonly string[]
frozenStructuralReferenceSkus?: readonly string[]
frozenPartialBoardFamilyScale?: PartialBoardFamilyScaleContext
frozenSyncStatuses?: Awaited<ReturnType<
typeof bomCostingScenarioRepository.findLatestSyncStatuses
>>
@ -2640,6 +2755,7 @@ export class BomCostingScenarioService {
caseId: input.caseId,
skuId: input.skuId,
structureGroupKey: sku.structureGroupKey,
costFamilyKey: sku.costFamilyKey,
}),
])
const syncRun = syncStatuses.latestSuccessful
@ -2822,6 +2938,7 @@ export class BomCostingScenarioService {
previewLines: preview.sameMaterial.lines,
familyReferenceSkus,
similarReferenceSkus,
partialBoardFamilyScale: input.frozenPartialBoardFamilyScale,
})
for (const line of scenarioLines) {
if (sameMaterialPrices[line.id] || categoryFallback.handledLineIds.has(line.id)) continue
@ -3166,31 +3283,46 @@ export class BomCostingScenarioService {
left.id.localeCompare(right.id)
))
const groupedSkus = Array.from(stableSkus.reduce((groups, sku) => {
const groupKey = sku.structureGroupKey?.trim() || sku.id
const structureGroupKey = sku.structureGroupKey?.trim() || ''
const costFamilyKey = sku.costFamilyKey?.trim() || ''
const groupKey = structureGroupKey && costFamilyKey
? `${structureGroupKey}\u001f${costFamilyKey}`
: `isolated:${sku.id}`
const group = groups.get(groupKey) ?? []
group.push(sku)
groups.set(groupKey, group)
return groups
}, new Map<string, typeof skus>()).entries())
.sort(([left], [right]) => left.localeCompare(right, 'zh-CN'))
.map(([groupKey, group]) => ({ groupKey, skus: group }))
// Freeze every structure group's historical evidence before any SKU is
.map(([groupKey, group]) => ({
groupKey,
structureGroupKey: group[0]?.structureGroupKey?.trim() || '',
costFamilyKey: group[0]?.costFamilyKey?.trim() || '',
skus: group,
}))
// Freeze every structure + cost-family group's historical evidence before any SKU is
// saved. A completed sibling from this same run must never become a price
// source for the next sibling merely because it happened to run first.
const hasStructureGroups = groupedSkus.some((group) => (
Boolean(group.skus[0]?.structureGroupKey?.trim())
Boolean(group.structureGroupKey) && Boolean(group.costFamilyKey)
))
const frozenSyncStatuses = hasStructureGroups
? await bomCostingScenarioRepository.findLatestSyncStatuses()
: null
const frozenSyncRun = frozenSyncStatuses?.latestSuccessful ?? null
const frozenSkuDetails = new Map(stableSkus.map((sku) => [sku.id, sku] as const))
const frozenPartialBoardFamilyScales = buildPartialBoardFamilyScaleContexts(
groupedSkus,
frozenSkuDetails,
)
const frozenReferenceContexts = new Map(await Promise.all(groupedSkus.map(async (group) => {
const structureGroupKey = group.skus[0]?.structureGroupKey?.trim()
const [historicalReferences, representativeSku] = structureGroupKey
const structureGroupKey = group.structureGroupKey
const [historicalReferences, representativeSku] = structureGroupKey && group.costFamilyKey
? await Promise.all([
bomCostingScenarioRepository.listSiblingScenarioReferenceSkus({
caseId,
structureGroupKey,
costFamilyKey: group.costFamilyKey,
}),
group.skus[0]
? bomCostingScenarioRepository.findSkuForScenario({
@ -3258,6 +3390,7 @@ export class BomCostingScenarioService {
frozenReferenceContexts.get(groupKey)?.familyReferences ?? [],
frozenStructuralReferenceSkus:
frozenReferenceContexts.get(groupKey)?.structuralReferences ?? [],
frozenPartialBoardFamilyScale: frozenPartialBoardFamilyScales.get(sku.id),
frozenSyncStatuses: sku.structureGroupKey?.trim()
? frozenSyncStatuses ?? undefined
: undefined,

View File

@ -71,6 +71,43 @@ function factSnapshotFor(
}
}
function scopedGroupingFactSnapshot(input: {
skuId?: string
observedTexts?: string[]
facts: Array<{
kind: 'SKU_DIFFERENCE' | 'MATERIAL'
subject: string
value: string
confidence?: number
note?: string
}>
}): BomCostingSourceFactSnapshot {
const skuId = input.skuId ?? 'sku-1'
return {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: [{
imageId: 'scoped-grouping-evidence',
sourceImageUrl: 'https://img.example.com/scoped-grouping.jpg',
kind: 'SKU',
label: `SKU图:${skuId}`,
externalSkuIds: [skuId],
observedTexts: input.observedTexts ?? [],
facts: input.facts.map((fact, index) => ({
factId: `scoped-grouping-fact-${index + 1}`,
kind: fact.kind,
subject: fact.subject,
value: fact.value,
relatedSkuIds: [skuId],
basis: 'SOURCE_TEXT',
confidence: fact.confidence ?? 0.95,
note: fact.note,
})),
}],
failedImages: [],
extractedAt: new Date().toISOString(),
}
}
function unscopedVariantSummaryFactSnapshot(): BomCostingSourceFactSnapshot {
return {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
@ -484,6 +521,16 @@ describe('BOM v7 运行时品类 Skill 注册表', () => {
.toEqual(expect.arrayContaining(['DOOR_COUNT_3', 'DRAWER_PRESENT', 'HANGING_LAYOUT']))
expect(grouped.get('6035533053450')?.structureDifferenceKeys)
.toEqual(expect.arrayContaining(['DOOR_COUNT_4', 'DRAWER_PRESENT', 'HANGING_LAYOUT']))
expect(grouped.get('6035533053452')?.structureGroupKey)
.toBe('wardrobe:structure:door-4.hanging')
expect(grouped.get('6035533053452')?.structureDifferenceKeys)
.not.toEqual(expect.arrayContaining([
'DRAWER_PRESENT',
'DRAWER_COUNT_1',
'DRAWER_COUNT_2',
'DRAWER_COUNT_3',
'DRAWER_COUNT_4',
]))
expect(grouped.get('6035533053456')?.structureDifferenceKeys)
.toEqual(expect.arrayContaining(['DOOR_COUNT_4', 'DOUBLE_SHORT_HANGING', 'STACKED_LAYOUT']))
expect(grouped.get('6035533053459')?.structureDifferenceKeys)
@ -503,6 +550,341 @@ describe('BOM v7 运行时品类 Skill 注册表', () => {
expect(new Set(blueprint.structureGroups.flatMap((group) => group.skuIds)).size).toBe(27)
})
it('生产 SKU 对比事实只采纳当前 A 款,B 款两个抽屉和 note 不污染分组', () => {
const source = yuanShiMuYuWardrobeSnapshot(PRODUCTION_WARDROBE_27_SKUS)
const factSnapshot: BomCostingSourceFactSnapshot = {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: [{
imageId: 'IMG_0015',
sourceImageUrl: 'https://img.example.com/IMG_0015.jpg',
kind: 'SKU',
label: 'SKU图:6035533053452',
externalSkuIds: ['6035533053452'],
observedTexts: [
'型号Y154E03',
'四门连体衣柜A款(1760*570*2000mm)',
'Y154E03176AT1',
],
facts: [{
factId: 'IMG_0015_fact_007',
kind: 'SKU_DIFFERENCE',
subject: '与四门连体衣柜B款的结构差异',
value: 'A款标注尺寸为1760*570*2000mm,图片中未见抽屉;B款标注尺寸为1560*570*2000mm或1560*570*2200mm,图片中左侧设有两个抽屉',
relatedSkuIds: ['6035533053452'],
basis: 'SOURCE_TEXT',
note: '对照款B款还可搭配顶柜,不能归到当前SKU。',
confidence: 0.93,
}],
}],
failedImages: [],
extractedAt: new Date().toISOString(),
}
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const currentSku = source.skus.find((sku) => sku.externalSkuId === '6035533053452')!
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!,
snapshot: source,
sku: currentSku,
factSnapshot,
})
expect(grouping.structureGroupKey)
.toBe('wardrobe:structure:door-4.hanging.no-drawer')
expect(grouping.structureDifferenceKeys)
.toEqual(expect.arrayContaining(['DOOR_COUNT_4', 'HANGING_LAYOUT', 'NO_DRAWER']))
expect(grouping.structureDifferenceKeys)
.not.toEqual(expect.arrayContaining([
'DRAWER_PRESENT',
'DRAWER_COUNT_1',
'DRAWER_COUNT_2',
'DRAWER_COUNT_3',
'DRAWER_COUNT_4',
'TOP_CABINET',
]))
})
it('subject 同时写 A款与B款 时仍按 observedTexts 采纳当前 A 款子句', () => {
const source = snapshot('四门平开门挂衣衣柜', ['四门平开门挂衣款'])
const factSnapshot = scopedGroupingFactSnapshot({
observedTexts: ['四门连体衣柜A款'],
facts: [{
kind: 'SKU_DIFFERENCE',
subject: 'A款与B款结构差异',
value: 'A款图片中未见抽屉;B款左侧设有两个抽屉',
}],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.structureDifferenceKeys).toContain('NO_DRAWER')
expect(grouping.structureDifferenceKeys).not.toContain('DRAWER_COUNT_2')
})
it('对比图同时出现 A/B 且 SKU 身份未知时不把任一款当作当前款', () => {
const source = snapshot('四门平开门衣柜', ['型号Y154E03 四门挂衣款'])
const factSnapshot = scopedGroupingFactSnapshot({
observedTexts: ['四门连体衣柜A款', '四门连体衣柜B款'],
facts: [{
kind: 'SKU_DIFFERENCE',
subject: 'A款与B款结构差异',
value: 'A款设有1个抽屉;B款设有2个抽屉',
}],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.structureDifferenceKeys).not.toEqual(expect.arrayContaining([
'DRAWER_PRESENT',
'DRAWER_COUNT_1',
'DRAWER_COUNT_2',
]))
})
it('对比图同时出现 A/B 但 SKU 名称明确 A 款时只采纳 A 款子句', () => {
const source = snapshot('四门平开门衣柜', ['型号Y154E03 A款 四门挂衣款'])
const factSnapshot = scopedGroupingFactSnapshot({
observedTexts: ['四门连体衣柜A款', '四门连体衣柜B款'],
facts: [{
kind: 'SKU_DIFFERENCE',
subject: 'A款与B款结构差异',
value: 'A款设有1个抽屉;B款设有2个抽屉',
}],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.structureDifferenceKeys).toContain('DRAWER_COUNT_1')
expect(grouping.structureDifferenceKeys).not.toContain('DRAWER_COUNT_2')
})
it('显式绑定当前 SKU 且无 A/B 标记的事实可细化名称中的泛化带抽', () => {
const source = snapshot('白蜡木衣柜', ['四门平开门带抽挂衣款'])
const factSnapshot = scopedGroupingFactSnapshot({
facts: [{
kind: 'SKU_DIFFERENCE',
subject: '抽屉数量',
value: '2个抽屉',
}],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.structureDifferenceKeys).toContain('DRAWER_COUNT_2')
expect(grouping.structureDifferenceKeys).not.toContain('DRAWER_PRESENT')
})
it('高可信当前 SKU 无抽事实可否定名称中的泛化带抽,但低可信事实不能覆盖', () => {
const source = snapshot('白蜡木衣柜', ['四门平开门带抽挂衣款'])
const group = (confidence: number) => {
const factSnapshot = scopedGroupingFactSnapshot({
facts: [{
kind: 'SKU_DIFFERENCE',
subject: '抽屉结构',
value: '未见抽屉',
confidence,
}],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
return buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
}
expect(group(0.95).structureDifferenceKeys).toContain('NO_DRAWER')
expect(group(0.6).structureDifferenceKeys).toContain('DRAWER_PRESENT')
})
it('名称已明确一抽时,作用域事实中的二抽不得改写精确卖家规格', () => {
const source = snapshot('白蜡木衣柜', ['四门平开门1抽挂衣款'])
const factSnapshot = scopedGroupingFactSnapshot({
facts: [{ kind: 'SKU_DIFFERENCE', subject: '抽屉数量', value: '2个抽屉' }],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.structureDifferenceKeys).toContain('DRAWER_COUNT_1')
expect(grouping.structureDifferenceKeys).not.toContain('DRAWER_COUNT_2')
})
it('公共事实同时包含主材与辅材时只用明确主材建立成本族', () => {
const source = snapshot('现代四门平开门挂衣衣柜')
const factSnapshot: BomCostingSourceFactSnapshot = {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: [{
imageId: 'public-materials',
sourceImageUrl: 'https://img.example.com/public-materials.jpg',
kind: 'DETAIL',
label: '商品材质说明',
externalSkuIds: [],
observedTexts: ['柜体主材白蜡木,柜体辅材松木'],
facts: [
{
factId: 'public-main-material',
kind: 'MATERIAL',
subject: '柜体主材',
value: '白蜡木',
relatedSkuIds: [],
basis: 'SOURCE_TEXT',
confidence: 0.98,
},
{
factId: 'public-auxiliary-material',
kind: 'MATERIAL',
subject: '柜体辅材',
value: '松木',
relatedSkuIds: [],
basis: 'SOURCE_TEXT',
confidence: 0.97,
},
],
}],
failedImages: [],
extractedAt: new Date().toISOString(),
}
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!,
snapshot: source,
sku: source.skus[0]!,
factSnapshot,
})
expect(grouping.materialFamily).toBe('白蜡木')
expect(grouping.costDifferenceKeys).toContain('MATERIAL_ASH_WOOD')
expect(grouping.costDifferenceKeys).not.toContain('MATERIAL_PINE')
expect(grouping.costGroupKey).toContain(':ash-wood:')
})
it('SKU 作用域主材决定成本族,辅材不覆盖主材', () => {
const source = snapshot('现代四门平开门挂衣衣柜')
const factSnapshot = scopedGroupingFactSnapshot({
facts: [
{ kind: 'MATERIAL', subject: '柜体主材', value: '白蜡木' },
{ kind: 'MATERIAL', subject: '柜体辅材', value: '松木' },
],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.materialFamily).toBe('白蜡木')
expect(grouping.costDifferenceKeys).toContain('MATERIAL_ASH_WOOD')
expect(grouping.costDifferenceKeys).not.toContain('MATERIAL_PINE')
})
it('SKU 作用域 MATERIAL 辅材中的部件名不激活结构差异', () => {
const source = snapshot('现代四门平开门衣柜', ['四门平开门挂衣款'])
const factSnapshot = scopedGroupingFactSnapshot({
facts: [{ kind: 'MATERIAL', subject: '抽屉辅材', value: '白蜡木' }],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.structureDifferenceKeys).not.toEqual(expect.arrayContaining([
'DRAWER_PRESENT',
'DRAWER_COUNT_1',
'DRAWER_COUNT_2',
]))
expect(grouping.materialFamily).toBe('')
})
it('SKU 名称明确白蜡木主体和松木辅材时成本族保持白蜡木', () => {
const source = snapshot('白蜡木主体搭配松木背板实木衣柜', [
'白蜡木主体+松木辅材推拉门衣柜',
])
const factSnapshot = scopedGroupingFactSnapshot({
facts: [
{ kind: 'MATERIAL', subject: '柜体侧板', value: 'FAS级白蜡木' },
{ kind: 'MATERIAL', subject: '柜体背板', value: '松木' },
],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.materialFamily).toBe('白蜡木')
expect(grouping.costDifferenceKeys).toContain('MATERIAL_ASH_WOOD')
expect(grouping.costDifferenceKeys).not.toContain('MATERIAL_PINE')
})
it('部件作用域且角色未指定的材质事实不能定义或冲突 SKU 成本族', () => {
const source = snapshot('现代实木衣柜', ['四门平开门挂衣款'])
const factSnapshot = scopedGroupingFactSnapshot({
facts: [
{ kind: 'MATERIAL', subject: '柜体侧板', value: '白蜡木' },
{ kind: 'MATERIAL', subject: '柜体背板', value: '松木' },
],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.materialFamily).toBe('')
expect(grouping.costGroupKey).toContain(':base:base:base')
})
it('SKU 作用域两个明确主材冲突时成本族退回 base', () => {
const source = snapshot('现代四门平开门挂衣衣柜')
const factSnapshot = scopedGroupingFactSnapshot({
facts: [
{ kind: 'MATERIAL', subject: '柜体主材', value: '白蜡木' },
{ kind: 'MATERIAL', subject: '主体材质', value: '松木' },
],
})
const selection = selectBomCostingCategorySkill(
buildBomCostingCategorySkillContext({ snapshot: source, factSnapshot }),
)
const grouping = buildBomCostingCategoryGroupingKeys({
skill: selection.skill!, snapshot: source, sku: source.skus[0]!, factSnapshot,
})
expect(grouping.materialFamily).toBe('')
expect(grouping.costDifferenceKeys).not.toEqual(expect.arrayContaining([
'MATERIAL_ASH_WOOD',
'MATERIAL_PINE',
]))
expect(grouping.costGroupKey).toContain(':base:base:base')
})
it.each([
['开放式五层书架', 'CABINET_BOOKSHELF'],
['厨房落地五层置物架', 'STORAGE_RACK'],

View File

@ -52,27 +52,129 @@ function unique<T>(values: readonly T[]) {
return Array.from(new Set(values))
}
function buildSkuText(input: BomCostingCategoryGroupingInput) {
const applicableFacts = input.factSnapshot
? listBomCostingFactRecords(input.factSnapshot)
.filter((record) => (
bomCostingFactIsExplicitlyBoundToSku(record, input.sku.externalSkuId)
))
.map(({ fact }) => `${fact.subject} ${fact.value} ${fact.note ?? ''}`)
: []
function buildSkuIdentityText(input: BomCostingCategoryGroupingInput) {
return normalizeText([
input.sku.name,
input.sku.size,
...Object.entries(input.sku.attributes).flatMap(([key, value]) => [key, value]),
...applicableFacts,
].filter(Boolean).join(' '))
}
const CURRENT_VARIANT_MARKER = /(?:当前(?:sku|款)|本(?:sku|款)|该(?:sku|款))/i
const NAMED_VARIANT_MARKER = /([a-z]|甲|乙|丙|丁)款/gi
const EXPLICIT_COMPARISON_MARKER = /(?:与(?:[a-z甲乙丙丁]款|对照款|另一款|其他款)|对照款|另一款|其他款)/i
function readNamedVariantMarkers(text: string) {
return unique(Array.from(text.matchAll(NAMED_VARIANT_MARKER), (match) => (
`${match[1]!.toLocaleLowerCase('zh-CN')}款`
)))
}
/**
* SKU_DIFFERENCE facts can describe the current variant and one or more
* comparison variants in the same value. Only clauses whose owner can be
* proven to be the current SKU may participate in grouping. The image OCR and
* SKU identity identify the current A/B/甲/乙 marker; a marker introduced by
* “与 B 款的差异” is a comparison marker and must not leak back into this SKU.
*/
function buildSkuDifferenceFactText(input: {
subject: string
value: string
skuIdentityText: string
observedTexts: readonly string[]
}) {
const identityMarkers = readNamedVariantMarkers(input.skuIdentityText)
const observedMarkers = readNamedVariantMarkers(input.observedTexts.join(' '))
// A comparison image commonly contains both A and B labels. The image text
// proves that the variants exist, not which one the current SKU represents.
// Use it only when it uniquely names one marker; otherwise require the SKU
// identity or an explicit “当前/本/该款” clause.
const currentMarkers = identityMarkers.length === 1
? identityMarkers
: observedMarkers.length === 1
? observedMarkers
: []
const clauses = input.value
.split(/[;;。\n]+/)
.map((clause) => clause.trim())
.filter(Boolean)
const trustedClauses = clauses.filter((clause) => {
if (CURRENT_VARIANT_MARKER.test(clause)) return true
const clauseMarkers = readNamedVariantMarkers(clause)
if (clauseMarkers.length === 0) return false
return currentMarkers.length > 0 && clauseMarkers.some((marker) => (
currentMarkers.includes(marker)
))
})
return trustedClauses.join(' ')
}
type GroupingFactBasis = 'IMAGE' | 'SOURCE_TEXT' | 'VISUAL_ESTIMATE'
type MaterialRole = 'PRIMARY' | 'AUXILIARY' | 'UNSPECIFIED'
interface SkuGroupingFactEvidence {
text: string
confidence: number
basis: GroupingFactBasis
materialRole: MaterialRole
materialOnly: boolean
}
const PRIMARY_MATERIAL_ROLE = /(?:主材|主体材质|主体材料|主要材质|主料|柜体材质|框架材质|台面材质|座面材质)/
const AUXILIARY_MATERIAL_ROLE = /(?:辅材|辅助材质|次要材质|背板材质|抽屉材质|五金材质|连接件材质|包材|包装材质)/
function readMaterialRole(subject: string): MaterialRole {
const normalizedSubject = normalizeText(subject)
if (PRIMARY_MATERIAL_ROLE.test(normalizedSubject)) return 'PRIMARY'
if (AUXILIARY_MATERIAL_ROLE.test(normalizedSubject)) return 'AUXILIARY'
return 'UNSPECIFIED'
}
function buildSkuFactEvidence(
input: BomCostingCategoryGroupingInput,
skuIdentityText: string,
) {
return input.factSnapshot
? listBomCostingFactRecords(input.factSnapshot)
.filter((record) => (
bomCostingFactIsExplicitlyBoundToSku(record, input.sku.externalSkuId)
))
.map(({ fact, image }) => {
// Notes are explanatory model prose and can mention comparison SKUs;
// they are evidence for review, never a source of grouping keywords.
let text = `${fact.subject} ${fact.value}`
if (fact.kind === 'SKU_DIFFERENCE') {
const namedMarkers = readNamedVariantMarkers(`${fact.subject} ${fact.value}`)
const isCrossVariantComparison = EXPLICIT_COMPARISON_MARKER.test(fact.subject) ||
namedMarkers.length > 1
if (isCrossVariantComparison) {
text = buildSkuDifferenceFactText({
subject: fact.subject,
value: fact.value,
skuIdentityText,
observedTexts: image.observedTexts,
})
}
}
return {
text: normalizeText(text),
confidence: fact.confidence,
basis: fact.basis,
materialRole: fact.kind === 'MATERIAL'
? readMaterialRole(fact.subject)
: 'UNSPECIFIED',
materialOnly: fact.kind === 'MATERIAL',
} satisfies SkuGroupingFactEvidence
})
: []
}
function buildPublicGroupingText(input: BomCostingCategoryGroupingInput) {
const unscopedFacts = input.factSnapshot
? listBomCostingFactRecords(input.factSnapshot)
.filter(isBomCostingFactUnscoped)
.map(({ fact }) => `${fact.subject} ${fact.value} ${fact.note ?? ''}`)
.map(({ fact }) => `${fact.subject} ${fact.value}`)
: []
return normalizeText([
input.snapshot.title,
@ -80,6 +182,31 @@ function buildPublicGroupingText(input: BomCostingCategoryGroupingInput) {
].filter(Boolean).join(' '))
}
function buildPublicMaterialText(input: BomCostingCategoryGroupingInput) {
const records = input.factSnapshot
? listBomCostingFactRecords(input.factSnapshot).filter(isBomCostingFactUnscoped)
: []
const primaryMaterialFacts = records.filter(({ fact }) => (
readMaterialRole(fact.subject) === 'PRIMARY'
))
// An explicit subject such as “柜体主材” is stronger than a marketing title
// and than component-level materials. Supporting materials remain available
// in the fact snapshot for line pricing, but cannot redefine the product's
// main material family.
if (primaryMaterialFacts.length > 0) {
return normalizeText(primaryMaterialFacts.map(({ fact }) => (
`${fact.subject} ${fact.value}`
)).join(' '))
}
const nonAuxiliaryFacts = records.filter(({ fact }) => (
readMaterialRole(fact.subject) !== 'AUXILIARY'
))
return normalizeText([
input.snapshot.title,
...nonAuxiliaryFacts.map(({ fact }) => `${fact.subject} ${fact.value}`),
].filter(Boolean).join(' '))
}
export function buildBomCostingCategorySkillContext(input: {
snapshot: BomCostingCategorySkillContext['snapshot']
factSnapshot?: BomCostingCategorySkillContext['factSnapshot']
@ -175,6 +302,8 @@ function buildCandidateStructureDraft(
const structureDifferences = resolveDifferences(
skill,
skuAndFactText,
[],
context.productText,
context.productText,
).filter((difference) => difference.groupKind === 'STRUCTURE')
const structureDifferenceKeys = structureDifferences.map((difference) => difference.differenceKey)
@ -559,10 +688,61 @@ function matchesDifference(
return difference.terms.some((term) => text.includes(normalizeText(term)))
}
function identityMaterialRole(
difference: BomCostingCategoryDifferenceDefinition,
text: string,
): MaterialRole {
const terms = difference.terms.map(normalizeText).filter(Boolean)
const primary = terms.some((term) => [
`${term}主体`,
`主体${term}`,
`${term}主材`,
`主材${term}`,
`${term}主体材质`,
`主体材质${term}`,
].some((phrase) => text.includes(phrase)))
if (primary) return 'PRIMARY'
const auxiliary = terms.some((term) => [
`${term}辅材`,
`辅材${term}`,
`${term}背板`,
`背板${term}`,
`${term}抽屉`,
`抽屉${term}`,
].some((phrase) => text.includes(phrase)))
return auxiliary ? 'AUXILIARY' : 'UNSPECIFIED'
}
function resolveIdentityMaterial(
skill: BomCostingCategorySkill,
text: string,
) {
const matches = skill.differences.filter((difference) => (
difference.groupKind === 'COST' &&
difference.exclusiveGroup === 'MATERIAL' &&
matchesDifference(difference, text)
))
if (matches.length === 0) return { difference: null, conflicted: false }
const primary = matches.filter((difference) => (
identityMaterialRole(difference, text) === 'PRIMARY'
))
if (primary.length === 1) return { difference: primary[0]!, conflicted: false }
if (primary.length > 1) return { difference: null, conflicted: true }
const nonAuxiliary = matches.filter((difference) => (
identityMaterialRole(difference, text) !== 'AUXILIARY'
))
if (nonAuxiliary.length === 1) {
return { difference: nonAuxiliary[0]!, conflicted: false }
}
return { difference: null, conflicted: nonAuxiliary.length > 1 }
}
function resolveDifferences(
skill: BomCostingCategorySkill,
skuText: string,
skuIdentityText: string,
skuFactEvidence: readonly SkuGroupingFactEvidence[],
publicText: string,
publicMaterialText: string,
) {
const resolveFromText = (text: string) => {
const matched = skill.differences.filter((difference) => matchesDifference(difference, text))
@ -580,7 +760,107 @@ function resolveDifferences(
}
return [...independent, ...exclusive.values()]
}
const skuMatches = resolveFromText(skuText)
const identityMaterial = resolveIdentityMaterial(skill, skuIdentityText)
const identityMatches = [
...resolveFromText(skuIdentityText).filter((difference) => (
difference.exclusiveGroup !== 'MATERIAL'
)),
...(identityMaterial.difference ? [identityMaterial.difference] : []),
]
const identityExclusive = new Map(identityMatches.flatMap((difference) => (
difference.exclusiveGroup ? [[difference.exclusiveGroup, difference] as const] : []
)))
const independentFactMatches = new Map<string, BomCostingCategoryDifferenceDefinition>()
const exclusiveFactMatches = new Map<string, Array<{
difference: BomCostingCategoryDifferenceDefinition
evidence: SkuGroupingFactEvidence
}>>()
for (const evidence of skuFactEvidence) {
if (!evidence.text || evidence.confidence < 0.55) continue
for (const difference of resolveFromText(evidence.text)) {
if (evidence.materialOnly && difference.exclusiveGroup !== 'MATERIAL') {
continue
}
if (difference.exclusiveGroup === 'MATERIAL' && evidence.materialRole === 'AUXILIARY') {
continue
}
if (!difference.exclusiveGroup) {
independentFactMatches.set(difference.differenceKey, difference)
continue
}
const entries = exclusiveFactMatches.get(difference.exclusiveGroup) ?? []
entries.push({ difference, evidence })
exclusiveFactMatches.set(difference.exclusiveGroup, entries)
}
}
const resolvedFactExclusive = new Map<string, BomCostingCategoryDifferenceDefinition>()
const conflictedExclusiveGroups = new Set<string>(
identityMaterial.conflicted ? ['MATERIAL'] : [],
)
for (const [group, entries] of exclusiveFactMatches) {
const candidates = group === 'MATERIAL'
? entries.filter(({ evidence }) => evidence.materialRole === 'PRIMARY')
: entries
// Component-scoped or role-unspecified material facts remain available to
// blueprint line binding, but cannot define or conflict the whole SKU's
// cost family.
if (candidates.length === 0) continue
const distinctKeys = new Set(candidates.map(({ difference }) => difference.differenceKey))
// Two explicit primary materials are a real evidence conflict. Silently
// choosing the definition with the larger static priority would corrupt
// the whole cost family; leave it unresolved/base for review instead.
if (group === 'MATERIAL' && distinctKeys.size > 1) {
identityExclusive.delete(group)
conflictedExclusiveGroups.add(group)
continue
}
const polarity = new Set(candidates.map(({ difference }) => (
difference.differenceKey.startsWith('NO_') ? 'NEGATIVE' : 'POSITIVE'
)))
// A direct positive/negative contradiction is unresolved. A generic
// positive plus a precise positive count is not a contradiction.
if (polarity.size > 1) continue
const winner = [...candidates].sort((left, right) => (
(right.difference.priority ?? 0) - (left.difference.priority ?? 0) ||
right.evidence.confidence - left.evidence.confidence ||
(right.evidence.basis === 'SOURCE_TEXT' ? 1 : 0) -
(left.evidence.basis === 'SOURCE_TEXT' ? 1 : 0)
))[0]
if (winner) resolvedFactExclusive.set(group, winner.difference)
}
for (const [group, factDifference] of resolvedFactExclusive) {
const identityDifference = identityExclusive.get(group)
if (!identityDifference) {
identityExclusive.set(group, factDifference)
continue
}
if (identityDifference.differenceKey === factDifference.differenceKey) continue
if (group === 'MATERIAL') {
identityExclusive.delete(group)
conflictedExclusiveGroups.add(group)
continue
}
// A high-confidence scoped fact may refine a generic seller label such as
// “带抽款” into “2 抽”, or negate it with direct “未见抽屉” evidence.
// It cannot replace an already precise name such as “1 抽” with “2 抽”.
const factEntries = exclusiveFactMatches.get(group) ?? []
const supportingConfidence = Math.max(0, ...factEntries
.filter(({ difference }) => difference.differenceKey === factDifference.differenceKey)
.map(({ evidence }) => evidence.confidence))
const identityIsGeneric = (identityDifference.priority ?? 0) < 100
const factIsMoreSpecific = (factDifference.priority ?? 0) >= 100
if (identityIsGeneric && factIsMoreSpecific && supportingConfidence >= 0.75) {
identityExclusive.set(group, factDifference)
}
}
const skuMatches = [
...identityMatches.filter((difference) => !difference.exclusiveGroup),
...independentFactMatches.values(),
...identityExclusive.values(),
]
const matchedGroups = new Set(skuMatches.flatMap((difference) => difference.exclusiveGroup ?? []))
// Product titles and unscoped facts often summarize several variants. They
// may provide a common material default, but must never activate per-SKU
@ -588,9 +868,11 @@ function resolveDifferences(
const publicMaterialMatches = skill.differences.filter((difference) => (
difference.groupKind === 'COST' &&
difference.exclusiveGroup === 'MATERIAL' &&
matchesDifference(difference, publicText)
matchesDifference(difference, publicMaterialText)
))
const publicMatches = !matchedGroups.has('MATERIAL') && publicMaterialMatches.length === 1
const publicMatches = !matchedGroups.has('MATERIAL') &&
!conflictedExclusiveGroups.has('MATERIAL') &&
publicMaterialMatches.length === 1
? publicMaterialMatches
: []
const byKey = new Map([...publicMatches, ...skuMatches].map((difference) => [
@ -644,9 +926,19 @@ function readFinish(text: string) {
export function buildBomCostingCategoryGroupingKeys(
input: BomCostingCategoryGroupingInput,
): BomCostingCategoryGroupingKeys {
const skuText = buildSkuText(input)
const skuIdentityText = buildSkuIdentityText(input)
const skuFactEvidence = buildSkuFactEvidence(input, skuIdentityText)
const skuFactText = skuFactEvidence.map(({ text }) => text).join('')
const skuText = `${skuIdentityText}${skuFactText}`
const publicText = buildPublicGroupingText(input)
const differences = resolveDifferences(input.skill, skuText, publicText)
const publicMaterialText = buildPublicMaterialText(input)
const differences = resolveDifferences(
input.skill,
skuIdentityText,
skuFactEvidence,
publicText,
publicMaterialText,
)
const structureDifferences = differences
.filter((difference) => difference.groupKind === 'STRUCTURE')
.sort((left, right) => left.differenceKey.localeCompare(right.differenceKey))

View File

@ -30,7 +30,7 @@ const drawerCountDifferences = [1, 2, 3, 4].map((count) => ({
]),
...(count === 2 ? ['双抽', '双抽屉'] : []),
],
excludedTerms: ['不带抽屉', '不带抽', '无抽屉', '无抽'],
excludedTerms: ['不带抽屉', '不带抽', '无抽屉', '无抽', '未见抽屉', '未发现抽屉', '没有抽屉'],
exclusiveGroup: 'DRAWER_LAYOUT',
priority: 100 + count,
description: `明确 ${count} 个抽屉时形成独立结构组。`,
@ -235,9 +235,9 @@ export const wardrobeCategorySkill = {
{ relationType: 'MOVES_WITH', fromComponentKey: 'motion-drawer', toComponentKey: 'drawer-box', rule: '每个抽屉盒必须关联成对导轨。' },
],
differences: [
{ differenceKey: 'NO_DRAWER', token: 'no-drawer', groupKind: 'STRUCTURE', terms: ['不带抽屉', '不带抽', '无抽屉', '无抽'], exclusiveGroup: 'DRAWER_LAYOUT', priority: 200, description: '明确无抽屉形成无抽结构组。' },
{ differenceKey: 'NO_DRAWER', token: 'no-drawer', groupKind: 'STRUCTURE', terms: ['不带抽屉', '不带抽', '无抽屉', '无抽', '未见抽屉', '未发现抽屉', '没有抽屉'], exclusiveGroup: 'DRAWER_LAYOUT', priority: 200, description: '明确无抽屉形成无抽结构组。' },
...drawerCountDifferences,
{ differenceKey: 'DRAWER_PRESENT', token: 'drawer', groupKind: 'STRUCTURE', terms: ['带抽屉', '带抽款', '带抽', '含抽屉', '抽屉款', '抽+挂', '抽挂', '抽屉'], excludedTerms: ['不带抽屉', '不带抽', '无抽屉', '无抽'], exclusiveGroup: 'DRAWER_LAYOUT', priority: 50, description: '有抽屉但数量不明时形成独立结构组。' },
{ differenceKey: 'DRAWER_PRESENT', token: 'drawer', groupKind: 'STRUCTURE', terms: ['带抽屉', '带抽款', '带抽', '含抽屉', '抽屉款', '抽+挂', '抽挂', '抽屉'], excludedTerms: ['不带抽屉', '不带抽', '无抽屉', '无抽', '未见抽屉', '未发现抽屉', '没有抽屉'], exclusiveGroup: 'DRAWER_LAYOUT', priority: 50, description: '有抽屉但数量不明时形成独立结构组。' },
{ differenceKey: 'SLIDING_DOOR', token: 'sliding-door', groupKind: 'STRUCTURE', terms: ['推拉门', '移门'], exclusiveGroup: 'DOOR_MODE', priority: 100, description: '推拉门机构形成独立结构组。' },
{ differenceKey: 'HINGED_DOOR', token: 'hinged-door', groupKind: 'STRUCTURE', terms: ['平开门', '对开门', '单开门'], exclusiveGroup: 'DOOR_MODE', priority: 90, description: '平开门机构形成独立结构组。' },
...doorCountDifferences,