Merge remote-tracking branch 'origin/codex/wuyongjie-bom-category-skills-v7' into codex/wuyongjie-promote-bom-optional-20260813
This commit is contained in:
commit
439b427259
@ -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()
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user