fix(bom-costing): restore detail slices and correct cost semantics

This commit is contained in:
WuYongJie 2026-08-10 10:43:00 +08:00
parent ead86d523e
commit 772c6813ed
18 changed files with 1506 additions and 54 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE "BomCostingScenario"
ALTER COLUMN "rulesetVersion" SET DEFAULT 'dynamic-bom-cost/v4';

View File

@ -1209,7 +1209,7 @@ model BomCostingScenario {
dataAsOf DateTime?
syncRunId String?
syncRun BomCostingReferenceSyncRun? @relation(fields: [syncRunId], references: [id], onDelete: SetNull)
rulesetVersion String @default("dynamic-bom-cost/v3")
rulesetVersion String @default("dynamic-bom-cost/v4")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -1951,12 +1951,13 @@ describe('BomCostingWorkbenchClient', () => {
/>,
)
expect(await screen.findByText('已知部分 ¥500.00')).toBeInTheDocument()
expect((await screen.findAllByText('成本待补全')).length).toBeGreaterThan(0)
expect(screen.getAllByText('已计部分 ¥500.00(不是整件成本)').length).toBeGreaterThan(0)
expect(screen.getAllByText('数据不足').length).toBeGreaterThan(0)
expect(screen.getByText('1 行没有可验证价格来源')).toBeInTheDocument()
expect(screen.getByText(/最近同步未成功:上游连接超时/)).toBeInTheDocument()
const skuButton = screen.getByRole('button', { name: /黑胡桃色 120cm/ })
expect(within(skuButton).getByText('已知 ¥500.00')).toBeInTheDocument()
expect(within(skuButton).getByText('待补成本')).toBeInTheDocument()
expect(screen.getByText('旧规则留档:¥1,300.00')).toBeInTheDocument()
await waitFor(() => {

View File

@ -1159,17 +1159,28 @@ function DynamicCostingPanel({
</div>
<div className="mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-[27px] font-black leading-none tabular-nums text-[#2d3335]">
{selectedScenario.totalCost !== null ? formatMoney(selectedScenario.totalCost) : `已知部分 ${formatMoney(selectedScenario.knownCost)}`}
{selectedScenario.totalCost !== null
? formatMoney(selectedScenario.totalCost)
: '成本待补全'}
</span>
{selectedScenario.confidence !== null ? (
<span className="text-[9px] text-[#7c8688]">置信度 {Math.round(selectedScenario.confidence * 100)}%</span>
) : null}
</div>
{selectedScenario.totalCost === null &&
selectedScenario.knownCost !== null &&
selectedScenario.knownCost > 0 ? (
<p className="mt-2 text-[10px] tabular-nums text-[#7c8688]">
已计部分 {formatMoney(selectedScenario.knownCost)}(不是整件成本)
</p>
) : null}
</div>
<div className="text-left text-[10px] sm:text-right">
<div className="text-[#7b8587]">竞品售价</div>
<div className="mt-0.5 font-bold tabular-nums">{formatMoney(selectedScenario.competitorPrice)}</div>
</div>
{selectedScenario.competitorPrice !== null ? (
<div className="text-left text-[10px] sm:text-right">
<div className="text-[#7b8587]">竞品售价</div>
<div className="mt-0.5 font-bold tabular-nums">{formatMoney(selectedScenario.competitorPrice)}</div>
</div>
) : null}
{scenarioKind === 'MATERIAL_SUBSTITUTION' ? (
<div className="text-left text-[10px] sm:text-right">
<div className="text-[#7b8587]">较同材质</div>
@ -2474,20 +2485,26 @@ function ResultCanvas({
{selectedDynamicScenario
? selectedDynamicScenario.totalCost !== null && selectedDynamicScenario.totalCost !== undefined
? formatMoney(selectedDynamicScenario.totalCost)
: selectedDynamicScenario.knownCost !== null && selectedDynamicScenario.knownCost !== undefined
? `已知 ${formatMoney(selectedDynamicScenario.knownCost)}`
: '待生成'
: '成本待补全'
: requiresRecalculation || hasPendingCost
? pendingCostLabel
: `约 ${formatMoney(selectedSku.estimatedCost)}`}
</div>
{selectedDynamicScenario &&
selectedDynamicScenario.totalCost === null &&
selectedDynamicScenario.knownCost !== null &&
selectedDynamicScenario.knownCost > 0 ? (
<div className="mt-2 text-[9px] font-semibold tabular-nums text-white/75">
已计部分 {formatMoney(selectedDynamicScenario.knownCost)}(不是整件成本)
</div>
) : null}
{selectedDynamicScenario ? (
<div className="mt-2 flex items-center justify-between gap-2 text-[9px] text-white/65">
<span>{dynamicStatusLabel(selectedDynamicScenario.status)}</span>
<span>镜像完成于 {formatActivityTime(selectedDynamicScenario.dataAsOf)}</span>
</div>
) : null}
{selectedDynamicScenario ? (
{selectedDynamicScenario && selectedDynamicScenario.competitorPrice !== null ? (
<div className="mt-2 text-[9px] font-semibold tabular-nums text-white/75">
竞品售价 {formatMoney(selectedDynamicScenario.competitorPrice)}
</div>
@ -2548,7 +2565,7 @@ function ResultCanvas({
>
{selectedDynamicScenario
? selectedDynamicScenario.unresolvedCount > 0
? `${selectedDynamicScenario.unresolvedCount} 条明细没有可验证的公司价格来源,仅显示已知部分。`
? `${selectedDynamicScenario.unresolvedCount} 条明细没有可验证的公司价格来源;已计部分只用于排查,不代表整件成本。`
: `规则 ${selectedDynamicScenario.rulesetVersion};板材来自基础资料库整板裁切或公司历史 BOM,其余明细来自可审计的公司资料。`
: hasPendingCost
? 'SKU 结构已就绪;补齐对应成本资料后会形成完整成本。'
@ -2877,8 +2894,8 @@ function ResultCanvas({
>
{skuDynamicScenario?.totalCost !== null && skuDynamicScenario?.totalCost !== undefined
? formatMoney(skuDynamicScenario.totalCost)
: skuDynamicScenario?.knownCost !== null && skuDynamicScenario?.knownCost !== undefined
? `已知 ${formatMoney(skuDynamicScenario.knownCost)}`
: skuDynamicScenario
? '待补成本'
: sku.estimatedCost !== null
? formatMoney(sku.estimatedCost)
: '待生成'}

View File

@ -10,7 +10,7 @@ import { Prisma } from '@prisma/client'
* `estimatedUnitPrice` field.
*/
export const DYNAMIC_BOM_COST_RULESET_VERSION = 'dynamic-bom-cost/v3' as const
export const DYNAMIC_BOM_COST_RULESET_VERSION = 'dynamic-bom-cost/v4' as const
export type DynamicBomCategory =
| 'BOARD'

View File

@ -1525,13 +1525,17 @@ export class BomCostingRepository {
})
}
async completeSourceSnapshot(sourceSnapshotId: string) {
async completeSourceSnapshot(
sourceSnapshotId: string,
rawSnapshot?: Prisma.InputJsonValue,
) {
return prisma.bomCostingSourceSnapshot.update({
where: { id: sourceSnapshotId },
data: {
status: BomCostingSourceStatus.READY,
completedAt: new Date(),
errorMessage: '',
...(rawSnapshot === undefined ? {} : { rawSnapshot }),
},
include: {
assets: {

View File

@ -0,0 +1,160 @@
/** @jest-environment node */
import sharp from 'sharp'
import {
planBomCostingDetailImageStitches,
planBomCostingPixelBoundaryStitches,
stitchBomCostingDetailImages,
} from './bom-costing-detail-image-stitcher'
async function createContinuousPixelSlices() {
const width = 480
const height = 720
const pixels = Buffer.alloc(width * height * 3)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const offset = (y * width + x) * 3
const verticalBand = Math.floor(x / 40) % 2 === 0 ? 45 : 165
const heightShade = Math.floor(y / 36)
pixels[offset] = Math.min(255, verticalBand + heightShade)
pixels[offset + 1] = Math.min(255, 40 + verticalBand + heightShade)
pixels[offset + 2] = Math.min(255, 80 + verticalBand + heightShade)
}
}
const full = await sharp(pixels, {
raw: { width, height, channels: 3 },
}).png().toBuffer()
return Promise.all([
sharp(full).extract({ left: 0, top: 0, width, height: 360 })
.jpeg({ quality: 92 })
.toBuffer(),
sharp(full).extract({ left: 0, top: 360, width, height: 360 })
.jpeg({ quality: 92 })
.toBuffer(),
])
}
describe('BOM 详情图连续切片恢复', () => {
const topUrl = 'https://img.alicdn.com/imgextra/i3/shop/wardrobe~crop,0,0,2480,4960~_!!shop.jpg'
const bottomUrl = 'https://img.alicdn.com/imgextra/i3/shop/wardrobe~crop,0,4960,2480,3788~_!!shop.jpg'
it('只根据同一原图且纵坐标连续的明确裁切信息生成合成计划', () => {
expect(planBomCostingDetailImageStitches([
topUrl,
bottomUrl,
'https://img.alicdn.com/imgextra/i3/shop/other.jpg',
])).toEqual([
expect.objectContaining({
sourceUrls: [topUrl, bottomUrl],
sourceIndexes: [0, 1],
x: 0,
y: 0,
width: 2_480,
height: 8_748,
fingerprint: expect.stringMatching(/^[a-f0-9]{24}$/),
}),
])
expect(planBomCostingDetailImageStitches([
topUrl,
'https://img.alicdn.com/imgextra/i3/shop/wardrobe~crop,0,5000,2480,3788~_!!shop.jpg',
])).toEqual([])
})
it('按从上到下的顺序生成一张完整图片', async () => {
const plan = planBomCostingDetailImageStitches([
'https://img.alicdn.com/panel~crop,0,0,10,4~_!!shop.png',
'https://img.alicdn.com/panel~crop,0,4,10,3~_!!shop.png',
])[0]!
const top = await sharp({
create: { width: 10, height: 4, channels: 3, background: '#ff0000' },
}).png().toBuffer()
const bottom = await sharp({
create: { width: 10, height: 3, channels: 3, background: '#0000ff' },
}).png().toBuffer()
const stitched = await stitchBomCostingDetailImages(plan, [top, bottom])
const decoded = await sharp(stitched.buffer)
.raw()
.toBuffer({ resolveWithObject: true })
const topPixel = decoded.data.subarray(0, 3)
const bottomPixelOffset = (decoded.info.height - 1) * decoded.info.width * 3
const bottomPixel = decoded.data.subarray(bottomPixelOffset, bottomPixelOffset + 3)
expect(stitched).toMatchObject({
contentType: 'image/jpeg',
width: 10,
height: 7,
})
expect(topPixel[0]).toBeGreaterThan(200)
expect(topPixel[2]).toBeLessThan(60)
expect(bottomPixel[0]).toBeLessThan(60)
expect(bottomPixel[2]).toBeGreaterThan(200)
})
it('识别不同文件之间高置信度连续的画面边界', async () => {
const urls = [
'https://img.alicdn.com/detail-top.jpg',
'https://img.alicdn.com/detail-bottom.jpg',
]
const [top, bottom] = await createContinuousPixelSlices()
const plans = await planBomCostingPixelBoundaryStitches({
detailImageUrls: urls,
sourceBuffers: new Map([
[urls[0], top],
[urls[1], bottom],
]),
})
expect(plans).toEqual([
expect.objectContaining({
sourceUrls: urls,
sourceIndexes: [0, 1],
strategy: 'PIXEL_BOUNDARY_CONTINUITY_V1',
width: 480,
height: 720,
}),
])
const stitched = await stitchBomCostingDetailImages(
plans[0]!,
[top, bottom],
)
expect(stitched).toMatchObject({ width: 480, height: 720 })
})
it('不合并无关图片、纯色说明条或已由裁切坐标接管的图片', async () => {
const urls = [
'https://img.alicdn.com/detail-a.jpg',
'https://img.alicdn.com/detail-b.jpg',
]
const [top] = await createContinuousPixelSlices()
const unrelated = await sharp(top).flop().jpeg({ quality: 92 }).toBuffer()
const blank = await sharp({
create: { width: 480, height: 360, channels: 3, background: '#ffffff' },
}).jpeg().toBuffer()
await expect(planBomCostingPixelBoundaryStitches({
detailImageUrls: urls,
sourceBuffers: new Map([
[urls[0], top],
[urls[1], unrelated],
]),
})).resolves.toEqual([])
await expect(planBomCostingPixelBoundaryStitches({
detailImageUrls: urls,
sourceBuffers: new Map([
[urls[0], blank],
[urls[1], blank],
]),
})).resolves.toEqual([])
await expect(planBomCostingPixelBoundaryStitches({
detailImageUrls: urls,
sourceBuffers: new Map([
[urls[0], top],
[urls[1], top],
]),
excludedSourceIndexes: new Set([0, 1]),
})).resolves.toEqual([])
})
})

View File

@ -0,0 +1,485 @@
import { createHash } from 'node:crypto'
import sharp from 'sharp'
const CROP_SLICE_PATTERN = /^(.*)~crop,(\d+),(\d+),(\d+),(\d+)~(.*)$/i
const MAX_STITCH_SLICE_COUNT = 8
const MAX_STITCH_SOURCE_PIXELS = 40_000_000
const MAX_STITCH_OUTPUT_WIDTH = 2_000
const MAX_STITCH_OUTPUT_HEIGHT = 16_000
const PIXEL_BOUNDARY_ANALYSIS_WIDTH = 256
const MIN_PIXEL_STITCH_WIDTH = 320
const MIN_PIXEL_STITCH_HEIGHT = 256
const MIN_PIXEL_BOUNDARY_EDGE_ENERGY = 3
const MAX_PIXEL_BOUNDARY_MAE = 25
const MAX_PIXEL_BOUNDARY_INTERNAL_RATIO = 2.25
const MIN_PIXEL_BOUNDARY_LUMINANCE_CORRELATION = 0.9
const MIN_PIXEL_BOUNDARY_GRADIENT_CORRELATION = 0.75
export const BOM_COSTING_DETAIL_IMAGE_PREPROCESS_VERSION =
'detail-image-stitch/v2'
export type BomCostingDetailImageStitchStrategy =
| 'EXPLICIT_CROP_SEQUENCE_V1'
| 'PIXEL_BOUNDARY_CONTINUITY_V1'
interface ParsedCropSlice {
sourceUrl: string
sourceIndex: number
sourceIdentity: string
x: number
y: number
width: number
height: number
}
export interface BomCostingDetailImageStitchPlan {
sourceUrls: string[]
sourceIndexes: number[]
sourceIdentity: string
x: number
y: number
width: number
height: number
fingerprint: string
strategy: BomCostingDetailImageStitchStrategy
}
export interface BomCostingStitchedDetailImage {
buffer: Buffer
contentType: 'image/jpeg'
width: number
height: number
}
function parseCropSlice(sourceUrl: string, sourceIndex: number): ParsedCropSlice | null {
const match = sourceUrl.match(CROP_SLICE_PATTERN)
if (!match) return null
const [, prefix, rawX, rawY, rawWidth, rawHeight, suffix] = match
const [x, y, width, height] = [rawX, rawY, rawWidth, rawHeight].map(Number)
if (
!prefix ||
suffix === undefined ||
!Number.isInteger(x) ||
!Number.isInteger(y) ||
!Number.isInteger(width) ||
!Number.isInteger(height) ||
width <= 0 ||
height <= 0
) {
return null
}
return {
sourceUrl,
sourceIndex,
sourceIdentity: `${prefix}\u0000${suffix}`,
x,
y,
width,
height,
}
}
function slicesAreContinuous(
previous: ParsedCropSlice,
next: ParsedCropSlice,
) {
return (
previous.sourceIdentity === next.sourceIdentity &&
previous.x === next.x &&
previous.width === next.width &&
previous.y + previous.height === next.y
)
}
interface PixelBoundarySignature {
sourceUrl: string
sourceIndex: number
width: number
height: number
firstRow: number[]
secondRow: number[]
penultimateRow: number[]
lastRow: number[]
}
function meanAbsoluteDifference(left: number[], right: number[]) {
if (left.length === 0 || left.length !== right.length) return Number.POSITIVE_INFINITY
let total = 0
for (let index = 0; index < left.length; index += 1) {
total += Math.abs(left[index]! - right[index]!)
}
return total / left.length
}
function pearsonCorrelation(left: number[], right: number[]) {
if (left.length === 0 || left.length !== right.length) return 0
let leftTotal = 0
let rightTotal = 0
for (let index = 0; index < left.length; index += 1) {
leftTotal += left[index]!
rightTotal += right[index]!
}
const leftMean = leftTotal / left.length
const rightMean = rightTotal / right.length
let numerator = 0
let leftVariance = 0
let rightVariance = 0
for (let index = 0; index < left.length; index += 1) {
const leftDelta = left[index]! - leftMean
const rightDelta = right[index]! - rightMean
numerator += leftDelta * rightDelta
leftVariance += leftDelta * leftDelta
rightVariance += rightDelta * rightDelta
}
if (leftVariance === 0 || rightVariance === 0) return 0
return numerator / Math.sqrt(leftVariance * rightVariance)
}
function horizontalGradient(row: number[]) {
return row.slice(1).map((value, index) => value - row[index]!)
}
function readLuminanceRow(
pixels: Buffer,
width: number,
rowIndex: number,
) {
const row: number[] = []
for (let x = 0; x < width; x += 1) {
const offset = (rowIndex * width + x) * 3
row.push(
0.2126 * pixels[offset]! +
0.7152 * pixels[offset + 1]! +
0.0722 * pixels[offset + 2]!,
)
}
return row
}
function readRotatedDimensions(metadata: sharp.Metadata) {
const swapsAxes = metadata.orientation !== undefined &&
metadata.orientation >= 5 &&
metadata.orientation <= 8
return {
width: swapsAxes ? metadata.height : metadata.width,
height: swapsAxes ? metadata.width : metadata.height,
}
}
async function buildPixelBoundarySignature(
sourceUrl: string,
sourceIndex: number,
buffer: Buffer,
): Promise<PixelBoundarySignature | null> {
const metadata = await sharp(buffer, {
limitInputPixels: MAX_STITCH_SOURCE_PIXELS,
}).metadata()
const dimensions = readRotatedDimensions(metadata)
if (
!dimensions.width ||
!dimensions.height ||
dimensions.width < MIN_PIXEL_STITCH_WIDTH ||
dimensions.height < MIN_PIXEL_STITCH_HEIGHT
) {
return null
}
const normalized = await sharp(buffer, {
limitInputPixels: MAX_STITCH_SOURCE_PIXELS,
})
.rotate()
.resize({ width: PIXEL_BOUNDARY_ANALYSIS_WIDTH })
.flatten({ background: '#ffffff' })
.toColourspace('srgb')
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
if (normalized.info.height < 2 || normalized.info.channels !== 3) return null
return {
sourceUrl,
sourceIndex,
width: dimensions.width,
height: dimensions.height,
firstRow: readLuminanceRow(
normalized.data,
normalized.info.width,
0,
),
secondRow: readLuminanceRow(
normalized.data,
normalized.info.width,
1,
),
penultimateRow: readLuminanceRow(
normalized.data,
normalized.info.width,
normalized.info.height - 2,
),
lastRow: readLuminanceRow(
normalized.data,
normalized.info.width,
normalized.info.height - 1,
),
}
}
function pixelBoundariesAreContinuous(
previous: PixelBoundarySignature,
next: PixelBoundarySignature,
) {
if (previous.width !== next.width) return false
const boundaryMae = meanAbsoluteDifference(previous.lastRow, next.firstRow)
const internalMae = (
meanAbsoluteDifference(previous.penultimateRow, previous.lastRow) +
meanAbsoluteDifference(next.firstRow, next.secondRow)
) / 2
const edgeEnergy = (
meanAbsoluteDifference(
previous.lastRow.slice(1),
previous.lastRow.slice(0, -1),
) +
meanAbsoluteDifference(
next.firstRow.slice(1),
next.firstRow.slice(0, -1),
)
) / 2
const internalRatio = boundaryMae / Math.max(internalMae, 0.1)
const luminanceCorrelation = pearsonCorrelation(
previous.lastRow,
next.firstRow,
)
const gradientCorrelation = pearsonCorrelation(
horizontalGradient(previous.lastRow),
horizontalGradient(next.firstRow),
)
const cleanVisualSeam = (
boundaryMae <= MAX_PIXEL_BOUNDARY_MAE &&
(
boundaryMae <= 2 ||
internalRatio <= MAX_PIXEL_BOUNDARY_INTERNAL_RATIO
) &&
luminanceCorrelation >=
MIN_PIXEL_BOUNDARY_LUMINANCE_CORRELATION &&
gradientCorrelation >= MIN_PIXEL_BOUNDARY_GRADIENT_CORRELATION
)
const structuredContentCrossesSeam = (
boundaryMae <= 15 &&
internalRatio <= 1.5 &&
luminanceCorrelation >= 0.8 &&
gradientCorrelation >= 0.62 &&
edgeEnergy >= 10
)
return (
edgeEnergy >= MIN_PIXEL_BOUNDARY_EDGE_ENERGY &&
(cleanVisualSeam || structuredContentCrossesSeam)
)
}
export function planBomCostingDetailImageStitches(
detailImageUrls: string[],
): BomCostingDetailImageStitchPlan[] {
const slices = detailImageUrls.map(parseCropSlice)
const plans: BomCostingDetailImageStitchPlan[] = []
let cursor = 0
while (cursor < slices.length) {
const first = slices[cursor]
if (!first) {
cursor += 1
continue
}
const group = [first]
let nextIndex = cursor + 1
while (
nextIndex < slices.length &&
group.length < MAX_STITCH_SLICE_COUNT
) {
const next = slices[nextIndex]
const previous = group[group.length - 1]
if (!next || !previous || !slicesAreContinuous(previous, next)) break
group.push(next)
nextIndex += 1
}
if (group.length >= 2) {
const sourceUrls = group.map((slice) => slice.sourceUrl)
plans.push({
sourceUrls,
sourceIndexes: group.map((slice) => slice.sourceIndex),
sourceIdentity: first.sourceIdentity,
x: first.x,
y: first.y,
width: first.width,
height: group.reduce((sum, slice) => sum + slice.height, 0),
fingerprint: createHash('sha256')
.update(`EXPLICIT_CROP_SEQUENCE_V1\n${sourceUrls.join('\n')}`)
.digest('hex')
.slice(0, 24),
strategy: 'EXPLICIT_CROP_SEQUENCE_V1',
})
cursor = nextIndex
continue
}
cursor += 1
}
return plans
}
export async function planBomCostingPixelBoundaryStitches(input: {
detailImageUrls: string[]
sourceBuffers: Map<string, Buffer>
excludedSourceIndexes?: ReadonlySet<number>
}): Promise<BomCostingDetailImageStitchPlan[]> {
const signatures: Array<PixelBoundarySignature | null> = []
for (let index = 0; index < input.detailImageUrls.length; index += 1) {
const sourceUrl = input.detailImageUrls[index]!
const buffer = input.sourceBuffers.get(sourceUrl)
if (input.excludedSourceIndexes?.has(index) || !buffer) {
signatures.push(null)
continue
}
try {
signatures.push(await buildPixelBoundarySignature(sourceUrl, index, buffer))
} catch {
signatures.push(null)
}
}
const plans: BomCostingDetailImageStitchPlan[] = []
let cursor = 0
while (cursor < signatures.length - 1) {
const first = signatures[cursor]
const next = signatures[cursor + 1]
if (!first || !next || !pixelBoundariesAreContinuous(first, next)) {
cursor += 1
continue
}
const group = [first, next]
let nextIndex = cursor + 2
while (
nextIndex < signatures.length &&
group.length < MAX_STITCH_SLICE_COUNT
) {
const candidate = signatures[nextIndex]
const previous = group[group.length - 1]
if (
!candidate ||
!previous ||
!pixelBoundariesAreContinuous(previous, candidate)
) {
break
}
group.push(candidate)
nextIndex += 1
}
const sourceUrls = group.map((signature) => signature.sourceUrl)
const fingerprint = createHash('sha256')
.update(`PIXEL_BOUNDARY_CONTINUITY_V1\n${sourceUrls.join('\n')}`)
.digest('hex')
.slice(0, 24)
plans.push({
sourceUrls,
sourceIndexes: group.map((signature) => signature.sourceIndex),
sourceIdentity: `pixel-boundary:${fingerprint}`,
x: 0,
y: 0,
width: first.width,
height: group.reduce((sum, signature) => sum + signature.height, 0),
fingerprint,
strategy: 'PIXEL_BOUNDARY_CONTINUITY_V1',
})
cursor = nextIndex
}
return plans
}
export async function stitchBomCostingDetailImages(
plan: BomCostingDetailImageStitchPlan,
sourceBuffers: Buffer[],
): Promise<BomCostingStitchedDetailImage> {
if (sourceBuffers.length !== plan.sourceUrls.length || sourceBuffers.length < 2) {
throw new Error('详情图切片数量不完整,未生成合成图')
}
const normalized = await Promise.all(sourceBuffers.map(async (buffer, index) => {
const output = await sharp(buffer, {
limitInputPixels: MAX_STITCH_SOURCE_PIXELS,
})
.rotate()
.flatten({ background: '#ffffff' })
.toColourspace('srgb')
.png()
.toBuffer({ resolveWithObject: true })
const parsed = parseCropSlice(plan.sourceUrls[index]!, plan.sourceIndexes[index] ?? index)
if (
!output.info.width ||
!output.info.height ||
(plan.strategy === 'EXPLICIT_CROP_SEQUENCE_V1' && !parsed)
) {
throw new Error('详情图切片尺寸无法识别,未生成合成图')
}
return {
buffer: output.data,
width: output.info.width,
height: output.info.height,
expectedCropHeight: parsed?.height ?? null,
}
}))
const sourceWidth = normalized[0]!.width
for (const image of normalized) {
if (image.width !== sourceWidth) {
throw new Error('详情图切片的实际尺寸与裁切坐标不一致,未生成合成图')
}
if (
plan.strategy === 'EXPLICIT_CROP_SEQUENCE_V1' &&
image.expectedCropHeight !== null
) {
const scale = sourceWidth / plan.width
const expectedHeight = image.expectedCropHeight * scale
const heightTolerance = Math.max(4, expectedHeight * 0.02)
if (Math.abs(image.height - expectedHeight) > heightTolerance) {
throw new Error('详情图切片的实际尺寸与裁切坐标不一致,未生成合成图')
}
}
}
const sourceHeight = normalized.reduce((sum, image) => sum + image.height, 0)
if (sourceWidth * sourceHeight > MAX_STITCH_SOURCE_PIXELS) {
throw new Error('连续详情图合成后的尺寸过大,未生成合成图')
}
const outputScale = Math.min(
1,
MAX_STITCH_OUTPUT_WIDTH / sourceWidth,
MAX_STITCH_OUTPUT_HEIGHT / sourceHeight,
)
const targetWidth = Math.max(1, Math.round(sourceWidth * outputScale))
const targetHeight = Math.max(1, Math.round(sourceHeight * outputScale))
let top = 0
const composite = normalized.map((image) => {
const entry = { input: image.buffer, left: 0, top }
top += image.height
return entry
})
const combined = await sharp({
create: {
width: sourceWidth,
height: sourceHeight,
channels: 3,
background: '#ffffff',
},
})
.composite(composite)
.png()
.toBuffer()
const output = await sharp(combined, {
limitInputPixels: MAX_STITCH_SOURCE_PIXELS,
})
.resize(targetWidth, targetHeight, { fit: 'fill' })
.jpeg({ quality: 92, chromaSubsampling: '4:4:4' })
.toBuffer({ resolveWithObject: true })
return {
buffer: output.data,
contentType: 'image/jpeg',
width: output.info.width,
height: output.info.height,
}
}

View File

@ -35,6 +35,8 @@ const snapshot: MarketDetailSnapshot = {
attributes: { 颜色: '白色', 尺寸: '120cm' },
priceRaw: '19900',
originalPriceRaw: '23900',
priceUnit: 'CENT',
originalPriceUnit: 'CENT',
quantity: 20,
},
{
@ -45,6 +47,8 @@ const snapshot: MarketDetailSnapshot = {
attributes: { 颜色: '黑色', 尺寸: '120cm' },
priceRaw: '20900',
originalPriceRaw: '24900',
priceUnit: 'CENT',
originalPriceUnit: 'CENT',
quantity: 10,
},
],
@ -527,6 +531,9 @@ describe('BOM 多模态分析器', () => {
expect(synthesisRequestBody.messages?.[0]?.content).toEqual(
expect.stringContaining('STRUCTURAL_INFERENCE'),
)
expect(synthesisRequestBody.messages?.[0]?.content).toEqual(
expect.stringContaining('由整机尺寸与结构推导,待复核'),
)
expect(synthesisRequestBody.messages?.[1]?.content).toEqual(
expect.stringContaining('"extractedImageFacts"'),
)
@ -549,6 +556,7 @@ describe('BOM 多模态分析器', () => {
颜色: '白色',
尺寸: '120cm',
marketPriceRaw: '19900',
marketPriceUnit: 'CENT',
},
})
})

View File

@ -72,11 +72,11 @@ const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务
9. BOM 分类只能是 BOARD、STEEL、ACCESSORY、PACKAGING、PROCESSING、LABOR。
10. evidence 必须说明依据;引用图片时只能照抄逐图事实中的 url(这是系统图片引用,不得编造、改写或填写其他图片地址)。
11. confidence 必须在 0 到 1 之间。
12. BOARD 的 lengthMm/widthMm 表示单块板件长宽,heightMm 只能表示板厚;来源明确标注板厚时,同时把厚度写入 materialSpec。没有板厚证据时必须省略 heightMm,并在 evidence.note 写明“板厚待确认”,不能把整机高度或部件外形高度填成板厚。系统会在后续按基础资料库整板尺寸做裁切排版,模型不要自行填写板价。
12. BOARD 的 lengthMm/widthMm 表示单块板件长宽,heightMm 只能表示板厚;来源明确标注板厚时,同时把厚度写入 materialSpec。没有板厚证据时必须省略 heightMm,并在 evidence.note 写明“板厚待确认”,不能把整机高度或部件外形高度填成板厚。若图片没有直接标出单块尺寸,但 SKU 元数据或图片事实已经确认整机长宽高与柜体、桌体、层板等装配结构,应把主要板件拆成少量可计价行,依据装配关系保守推导单块长宽和数量;这类行的 evidence.basis 必须是 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须写明“由整机尺寸与结构推导,待复核”。系统会在后续按基础资料库整板尺寸做裁切排版,模型不要自行填写板价。
13. PACKAGING 必须按纸箱、泡沫、珍珠棉、护角、打包带等实际包材逐行拆分;packagingKind 必须与部件一致,尺寸表示单件包材规格,quantity 表示单套产品实际用量。没有直接包装证据时,根据整机尺寸、重量、易损部位和运输保护需要给出保守的最小包装方案,basis 使用 STRUCTURAL_INFERENCE,confidence 不得高于 0.55,并明确待确认项。
14. materialName 使用可用于资料库匹配的材种或材料名称,例如“橡胶木”“刨花板”“碳钢”“瓦楞纸箱”,不要把颜色、营销等级或整段商品描述当作材种。SKU 元数据明确为 FAS 级橡胶木、实木、无镜等时,不能默认替换为另一种材质或保留不应存在的镜子部件。
15. 每条 evidence 必须设置 basis:图片直接可见为 IMAGE;图片标注、详情文字或商品文案为 SOURCE_TEXT;SKU 名称、规格或属性为 SKU_METADATA;由装配或机构功能必需性推断的隐藏部件为 STRUCTURAL_INFERENCE。
16. STRUCTURAL_INFERENCE 只允许用于实现已确认结构或功能所必需的隐藏五金、连接件或保护包材;必须写明推断原因、数量估算和待确认项,evidence.confidence 与 BOM 行 confidence 都不得高于 0.55。
16. STRUCTURAL_INFERENCE 只允许用于实现已确认结构或功能所必需的主要板件几何、隐藏五金、连接件或保护包材;必须写明推断原因、数量估算和待确认项。主要板件推导的 evidence.confidence 与 BOM 行 confidence 均不得高于 0.45,其余结构推导均不得高于 0.55。
17. 安装知识只用于提醒检查,不是当前商品的事实。不得因为知识库列出了某种五金,就在缺少当前商品结构或功能依据时把它写进 BOM。
18. 系统会从已归档的逐图事实中挑选与 BOM 最相关的产品级证据,并附上当前批次关联的 SKU 图片事实;不得遗漏本次输入中给出的事实。
19. SKU 名称或属性中的“折叠、升降、推拉、抽屉、脚轮、电动”等功能词必须触发对应机构检查;同时区分收拢态、展开态与整机尺寸。
@ -95,7 +95,7 @@ const COMPACT_COSTING_SYSTEM_PROMPT = `你是家具产品成本工程师。当
1. 必须至少有一条材料项(BOARD、STEEL 或 ACCESSORY)、一条 PROCESSING、一条 LABOR 和一条 PACKAGING。
2. 每条必须包含 externalLineId、category、partName、quantity、unit、evidence、confidence。estimatedUnitPrice 可以省略;即使输出也仅作分析对照,不会作为公司正式成本价格来源。
3. 公司基础资料库和历史 BOM 本地镜像是唯一正式价格来源。materialName 只写可匹配的材料,例如“橡胶木”“不锈钢”“银镜”“万向轮”“瓦楞纸箱”,不要把营销文案当材料。
4. 板材有明确“单块板件”长宽时才使用“㎡”并填写 lengthMm、widthMm;来源明确标注板厚时填写 heightMm 和 materialSpec。没有可见裁切尺寸时,使用“件”或“套”的保守部件描述,绝不能把整机尺寸当成板件尺寸。系统会在后续按整板尺寸与裁切利用率计算成本,模型不要自行填写板价。
4. 板材有明确“单块板件”长宽时使用“㎡”并填写 lengthMm、widthMm;来源明确标注板厚时填写 heightMm 和 materialSpec。若图片没有直接标出单块尺寸,但 SKU 元数据或图片事实已经确认整机长宽高与柜体、桌体、层板等装配结构,应把主要板件拆成少量可计价行,依据装配关系保守推导单块长宽和数量;这类行的 evidence.basis 必须是 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须写明“由整机尺寸与结构推导,待复核”。不得把整机高度填成板厚。只有连整机尺寸或结构都不足时,才使用“件”或“套”并保持成本待补。系统会在后续按整板尺寸与裁切利用率计算成本,模型不要自行填写板价。
5. 只写与该 SKU 已确认结构直接相关的部件。隐藏五金、人工、包材可以作最小必要推断,但 evidence.basis 必须是 STRUCTURAL_INFERENCE,且 evidence.confidence 和该行 confidence 均不得高于 0.55。
6. evidence.imageUrl 只能使用输入 facts 中的 imageRef,不能编造地址。所有尺寸用毫米;没有证据的可选尺寸字段直接省略。
7. SKU 之间不允许组合、拆分、补造或遗漏。若材料、尺寸、是否含镜、部件或包装有差异,必须在本 SKU 的 BOM 中体现。
@ -932,6 +932,8 @@ function mergeSourceSkuTruth(
...source.attributes,
marketPriceRaw: source.priceRaw,
marketOriginalPriceRaw: source.originalPriceRaw,
marketPriceUnit: source.priceUnit ?? undefined,
marketOriginalPriceUnit: source.originalPriceUnit ?? undefined,
marketQuantity: source.quantity,
},
}

View File

@ -21,7 +21,10 @@ import {
type BomCostingSourceFactSnapshot,
} from '@/lib/bom-costing/fact-contracts'
import { buildBomCostingImageIdentity } from '@/lib/bom-costing/image-identity'
import { readObjectStorageKeyFromUrl } from '@/lib/storage'
import {
buildStableObjectAccessUrl,
readObjectStorageKeyFromUrl,
} from '@/lib/storage'
import { AppError } from '@/server/core/errors'
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
import {
@ -41,6 +44,7 @@ import { isBomCostingMultimodalTransientError } from './bom-costing-multimodal-g
import {
isLegacyMarketSkuSnapshotPossiblyTruncated,
marketDetailClient,
marketDetailSnapshotSchema,
normalizeMarketSkuExternalId,
type MarketDetailSnapshot,
} from './market-detail-client'
@ -95,6 +99,9 @@ function buildSourceAssetFingerprint(
Awaited<ReturnType<typeof bomCostingRepository.findSourceSnapshotById>>
>,
) {
const parsedSnapshot = marketDetailSnapshotSchema.safeParse(
source.rawSnapshot,
)
const input = [
...source.assets
.filter(
@ -109,6 +116,16 @@ function buildSourceAssetFingerprint(
JSON.stringify(asset.externalSkuIds),
].join(':'))
.sort(),
...(parsedSnapshot.success
? parsedSnapshot.data.detailImageComposites
.map((composite) => [
'DERIVED_DETAIL',
composite.storageKey,
composite.strategy,
composite.sourceUrls.join(','),
].join(':'))
.sort()
: []),
].join('\n')
return createHash('sha256').update(input).digest('hex')
}
@ -207,7 +224,7 @@ function replaceAnalysisImageUrlsWithStableArchive(
? readObjectStorageKeyFromUrl(evidence.imageUrl)
: null
const stableImageUrl = key
? stableUrlByStorageKey.get(key)
? stableUrlByStorageKey.get(key) ?? buildStableObjectAccessUrl(key)
: null
return stableImageUrl
? { ...evidence, imageUrl: stableImageUrl }
@ -223,24 +240,43 @@ function buildAnalysisImagePlan(
source: NonNullable<
Awaited<ReturnType<typeof bomCostingRepository.findSourceSnapshotById>>
> | null,
snapshot: MarketDetailSnapshot | null,
) {
const assetByStorageKey = new Map(
(source?.assets ?? [])
.filter((asset) => asset.storageKey)
.map((asset) => [asset.storageKey, asset] as const),
)
const assetByOriginalUrl = new Map(
(source?.assets ?? []).map((asset) => [asset.originalUrl, asset] as const),
)
const compositeByStorageKey = new Map(
(snapshot?.detailImageComposites ?? []).map((composite) => [
composite.storageKey,
composite,
] as const),
)
return images.map((image, index) => {
const key = readObjectStorageKeyFromUrl(image.url)
const asset = key ? assetByStorageKey.get(key) : null
const composite = key ? compositeByStorageKey.get(key) : null
const relatedAssets = asset
? [asset]
: composite
? composite.sourceUrls
.map((url) => assetByOriginalUrl.get(url))
.filter((item): item is NonNullable<typeof item> => Boolean(item))
: []
return {
sequence: index + 1,
assetId: asset?.id ?? null,
assetId: relatedAssets[0]?.id ?? null,
assetIds: relatedAssets.map((item) => item.id),
kind: image.kind,
label: image.label,
externalSkuIds: asset
? normalizeArchivedSkuIds(asset.externalSkuIds)
: [],
externalSkuIds: Array.from(new Set(relatedAssets.flatMap((item) =>
normalizeArchivedSkuIds(item.externalSkuIds),
))),
}
})
}
@ -560,6 +596,7 @@ export class BomCostingProcessingService {
const analysisImages = buildAnalysisImagePlan(
analysisInputStats.images,
archivedSource,
marketSnapshot,
)
const analysisPlanByIdentity = new Map(
analysisInputStats.images.map((image, index) => [
@ -615,13 +652,19 @@ export class BomCostingProcessingService {
const selectedAssetIds = readStringArray(
analysisRun.selectedAssetIds,
)
const matchedSelectedAssetIds = new Set<string>()
const selectedIdentities = new Set(
analysisInputStats.images.flatMap((image) => {
const planned = analysisPlanByIdentity.get(
buildBomCostingImageIdentity(image.url),
)
return planned?.assetId &&
selectedAssetIds.includes(planned.assetId)
const selectedForImage = planned?.assetIds.filter((assetId) =>
selectedAssetIds.includes(assetId),
) ?? []
selectedForImage.forEach((assetId) =>
matchedSelectedAssetIds.add(assetId),
)
return selectedForImage.length > 0
? [buildBomCostingImageIdentity(image.url)]
: []
}),
@ -629,7 +672,7 @@ export class BomCostingProcessingService {
if (
analysisRun.mode ===
BomCostingAnalysisRunMode.REANALYZE_SELECTED &&
selectedIdentities.size !== selectedAssetIds.length
matchedSelectedAssetIds.size !== selectedAssetIds.length
) {
throw new Error(
'指定复查的图片已发生变化,请刷新页面后重新选择',

View File

@ -101,12 +101,19 @@ function candidate(input: {
}
}
function mockBase(input: { bomLines: ReturnType<typeof line>[]; marketPrice?: string }) {
function mockBase(input: {
bomLines: ReturnType<typeof line>[]
marketPrice?: string
marketPriceUnit?: 'CENT' | 'YUAN'
}) {
jest.spyOn(bomCostingScenarioRepository, 'findSkuForScenario').mockResolvedValue({
id: 'sku-1',
caseId: 'case-1',
externalSkuId: 'COMPETITOR-SKU-1',
attributes: { marketPriceRaw: input.marketPrice ?? '600' },
attributes: {
marketPriceRaw: input.marketPrice ?? '600',
marketPriceUnit: input.marketPriceUnit ?? 'YUAN',
},
bomLines: input.bomLines,
} as never)
jest.spyOn(bomCostingScenarioRepository, 'listEnabledMaterials').mockResolvedValue([{
@ -378,6 +385,83 @@ describe('BomCostingScenarioService', () => {
expect(bomCostingScenarioRepository.saveScenarioPair).not.toHaveBeenCalled()
})
it('按上游明确的分单位换算竞品售价,不把 835200 分显示成 835200 元', async () => {
mockBase({
bomLines: [line({
id: 'hardware',
category: 'ACCESSORY',
partName: '连接件',
materialName: '五金',
unit: '件',
})],
marketPrice: '835200',
marketPriceUnit: 'CENT',
})
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([
candidate({
id: 'hardware-reference',
category: 'ACCESSORY',
partName: '连接件',
materialName: '五金',
unit: '件',
unitPrice: 10,
}),
])
await new BomCostingScenarioService().calculateSameMaterial({
caseId: 'case-1',
skuId: 'sku-1',
})
expect(bomCostingScenarioRepository.saveSameMaterialScenario).toHaveBeenCalledWith(
expect.objectContaining({ competitorPrice: 8_352 }),
)
})
it('不因材质同为橡木就把抽屉或挂衣杆误配成橡木木腿', async () => {
mockBase({
bomLines: [line({
id: 'drawer',
category: 'ACCESSORY',
partName: '抽屉组件',
materialName: '橡木',
unit: '套',
})],
})
jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockResolvedValue([
candidate({
id: 'oak-leg-reference',
category: 'ACCESSORY',
partName: '4.5*7CM橡木木腿',
materialName: '橡木',
unit: '个',
unitPrice: 3,
amount: 12,
quantity: 4,
}),
])
await new BomCostingScenarioService().calculateSameMaterial({
caseId: 'case-1',
skuId: 'sku-1',
})
expect(bomCostingScenarioRepository.saveSameMaterialScenario).toHaveBeenCalledWith(
expect.objectContaining({
sameMaterial: expect.objectContaining({
status: 'INSUFFICIENT_DATA',
total: null,
knownCost: 0,
unresolvedCount: 1,
lines: [expect.objectContaining({
sourceLineId: 'drawer',
subtotal: null,
})],
}),
}),
)
})
it('批量生成成本时隔离单个 SKU 失败,不把整条任务判为失败', async () => {
jest.spyOn(bomCostingScenarioRepository, 'listCaseSkuIds').mockResolvedValue([
{ id: 'sku-ok', externalSkuId: 'EXT-OK' },

View File

@ -80,9 +80,19 @@ function sameMaterialTargets(
return Array.from(new Set([...sourceTerms, ...directoryTerms]))
}
function readMarketPrice(value: unknown) {
function readMarketPrice(value: unknown, unit: unknown) {
const parsed = parseDynamicNumber(value)
return parsed !== null && parsed > 0 ? parsed : null
if (parsed === null || parsed <= 0) return null
const normalizedUnit = typeof unit === 'string' ? unit.trim().toUpperCase() : ''
if (normalizedUnit === 'CENT') return parsed / 100
if (normalizedUnit === 'YUAN') return parsed
// Historical snapshots predate the explicit unit field. The market Open API
// uses integer minor units, while manually entered/test values normally use
// decimal yuan. Limit this compatibility inference to bare 4+ digit values
// so a legacy "600" yuan value is not silently changed to 6 yuan.
const raw = typeof value === 'string' ? value.trim().replace(/,/g, '') : ''
return /^\d{4,}$/.test(raw) ? parsed / 100 : parsed
}
function normalizeMaterialCategory(value: unknown) {
@ -408,7 +418,7 @@ function candidatePrice(candidate: BomCostingScenarioReferenceCandidate): Candid
function scoreCandidate(
candidate: BomCostingScenarioReferenceCandidate,
line: { partName: string; materialName: string; materialCode: string; materialSpec: string; processName?: string; unit: string },
line: { category: string; partName: string; materialName: string; materialCode: string; materialSpec: string; processName?: string; unit: string },
targetMaterials: string[],
) {
const part = normalize(line.partName)
@ -426,8 +436,31 @@ function scoreCandidate(
candidatePartNumber.includes(normalize(targetMaterial)))) {
return -1
}
const candidatePart = normalize(candidate.partName)
const candidateProcess = normalize(candidate.processName)
const containsEither = (left: string, right: string) => (
left.length >= 2 && right.length >= 2 &&
(left.includes(right) || right.includes(left))
)
const genericMaterial = /^(木|木材|实木|橡木|胡桃木|板材|金属|五金|钢材|配件|包材)$/
const componentIdentityMatches =
containsEither(part, candidatePart) ||
containsEither(process, candidateProcess) ||
Boolean(code && (candidateMaterialCode === code || candidatePartNumber === code)) ||
Boolean(
material &&
material === candidateMaterial &&
!genericMaterial.test(material),
)
if (
!MATERIAL_IDENTITY_REQUIRED_CATEGORIES.has(line.category) &&
!componentIdentityMatches
) {
return -1
}
let score = 0
if (part && normalize(candidate.partName) === part) score += 50
if (part && candidatePart === part) score += 50
else if (containsEither(part, candidatePart)) score += 30
else if (part && candidateText.includes(part)) score += 20
if (material && candidateMaterial === material) score += 35
else if (material && candidateText.includes(material)) score += 15
@ -471,7 +504,7 @@ function weightedMedian(values: Array<{ value: number; weight: number }>) {
function quoteFromCandidates(
candidates: BomCostingScenarioReferenceCandidate[],
line: { id: string; partName: string; materialName: string; materialCode: string; materialSpec: string; processName?: string; unit: string; lengthMm?: number | null; widthMm?: number | null },
line: { id: string; category: string; partName: string; materialName: string; materialCode: string; materialSpec: string; processName?: string; unit: string; lengthMm?: number | null; widthMm?: number | null },
targetMaterials: string[],
): DynamicBomPriceQuote | null {
const ranked = candidates
@ -740,6 +773,17 @@ function readMaterialAliases(value: unknown) {
}
function serializeScenario(record: BomCostingScenarioRecord) {
const totalCost = record.totalCost === null ? null : Number(record.totalCost)
const storedCompetitorPrice = record.competitorPrice === null
? null
: Number(record.competitorPrice)
const competitorPrice =
record.rulesetVersion === 'dynamic-bom-cost/v3' &&
storedCompetitorPrice !== null &&
Number.isInteger(storedCompetitorPrice) &&
storedCompetitorPrice >= 1_000
? storedCompetitorPrice / 100
: storedCompetitorPrice
return {
id: record.id,
skuId: record.skuId,
@ -749,15 +793,17 @@ function serializeScenario(record: BomCostingScenarioRecord) {
revision: record.revision,
isCurrent: record.isCurrent,
status: record.status,
totalCost: record.totalCost === null ? null : Number(record.totalCost),
totalCost,
knownCost: record.knownCost === null ? null : Number(record.knownCost),
materialCost: record.materialCost === null ? null : Number(record.materialCost),
processingCost: record.processingCost === null ? null : Number(record.processingCost),
laborCost: record.laborCost === null ? null : Number(record.laborCost),
packagingCost: record.packagingCost === null ? null : Number(record.packagingCost),
lossCost: record.lossCost === null ? null : Number(record.lossCost),
competitorPrice: record.competitorPrice === null ? null : Number(record.competitorPrice),
priceDelta: record.priceDelta === null ? null : Number(record.priceDelta),
competitorPrice,
priceDelta: totalCost === null || competitorPrice === null
? null
: Number((totalCost - competitorPrice).toFixed(2)),
confidence: record.confidence === null ? null : Number(record.confidence),
warning: record.warning,
unresolvedCount: record.unresolvedCount,
@ -866,8 +912,16 @@ export class BomCostingScenarioService {
...readMaterialAliases(target.aliases),
].filter(Boolean)))
: []
const marketPrice = sku.attributes && typeof sku.attributes === 'object' && !Array.isArray(sku.attributes)
? readMarketPrice((sku.attributes as Record<string, unknown>).marketPriceRaw ?? (sku.attributes as Record<string, unknown>).marketOriginalPriceRaw)
const marketAttributes = sku.attributes && typeof sku.attributes === 'object' && !Array.isArray(sku.attributes)
? sku.attributes as Record<string, unknown>
: null
const marketPrice = marketAttributes
? readMarketPrice(
marketAttributes.marketPriceRaw ?? marketAttributes.marketOriginalPriceRaw,
marketAttributes.marketPriceRaw === null || marketAttributes.marketPriceRaw === undefined
? marketAttributes.marketOriginalPriceUnit
: marketAttributes.marketPriceUnit,
)
: null
const rows = sku.bomLines.map((line) => toDynamicRow(line, sku.externalSkuId, targetCategory))
const sameMaterialPrices: Record<string, DynamicBomPriceQuote> = {}
@ -875,6 +929,7 @@ export class BomCostingScenarioService {
for (const line of sku.bomLines) {
const base = {
id: line.id,
category: line.category,
partName: line.partName,
materialName: line.materialName,
materialCode: line.materialCode,
@ -966,6 +1021,7 @@ export class BomCostingScenarioService {
})
const relatedQuote = quoteFromCandidates(relatedCandidates, {
id: line.id,
category: line.category,
partName: line.partName,
materialName: line.materialName,
materialCode: line.materialCode,

View File

@ -5,11 +5,13 @@ import {
BomCostingSourceAssetStatus,
BomCostingSourceStatus,
} from '@prisma/client'
import sharp from 'sharp'
import { bomCostingRepository } from '@/server/repositories/bom-costing.repository'
import {
BomCostingSourceArchiveService,
buildMarketSnapshotFromStoredSource,
} from './bom-costing-source-archive.service'
import { BOM_COSTING_DETAIL_IMAGE_PREPROCESS_VERSION } from './bom-costing-detail-image-stitcher'
import type { MarketDetailSnapshot } from './market-detail-client'
const mockUpload = jest.fn()
@ -31,6 +33,8 @@ const snapshot: MarketDetailSnapshot = {
detailUrl: 'https://item.taobao.com/item.htm?id=1053559401376',
mainImages: ['https://img.example.com/main.jpg'],
detailImages: ['https://img.example.com/detail.jpg'],
detailImagePreprocessVersion:
BOM_COSTING_DETAIL_IMAGE_PREPROCESS_VERSION,
skuImages: ['https://img.example.com/sku.jpg'],
skus: [{
externalSkuId: 'sku-1',
@ -96,6 +100,33 @@ function createSource(assets: ReturnType<typeof createAsset>[]) {
}
}
async function createContinuousDetailSlices() {
const width = 480
const height = 720
const pixels = Buffer.alloc(width * height * 3)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const offset = (y * width + x) * 3
const verticalBand = Math.floor(x / 40) % 2 === 0 ? 45 : 165
const heightShade = Math.floor(y / 36)
pixels[offset] = Math.min(255, verticalBand + heightShade)
pixels[offset + 1] = Math.min(255, 40 + verticalBand + heightShade)
pixels[offset + 2] = Math.min(255, 80 + verticalBand + heightShade)
}
}
const full = await sharp(pixels, {
raw: { width, height, channels: 3 },
}).png().toBuffer()
return Promise.all([
sharp(full).extract({ left: 0, top: 0, width, height: 360 })
.jpeg({ quality: 92 })
.toBuffer(),
sharp(full).extract({ left: 0, top: 360, width, height: 360 })
.jpeg({ quality: 92 })
.toBuffer(),
])
}
describe('BomCostingSourceArchiveService', () => {
afterEach(() => {
jest.restoreAllMocks()
@ -170,6 +201,170 @@ describe('BomCostingSourceArchiveService', () => {
})
})
it('归档时生成明确连续裁切详情图的合成对象并记录来源', async () => {
const topUrl = 'https://img.alicdn.com/panel~crop,0,0,10,4~_!!shop.png'
const bottomUrl = 'https://img.alicdn.com/panel~crop,0,4,10,3~_!!shop.png'
const cropSnapshot: MarketDetailSnapshot = {
...snapshot,
mainImages: [],
skuImages: [],
detailImages: [topUrl, bottomUrl],
skus: snapshot.skus.map((sku) => ({ ...sku, imageUrl: null })),
}
const assets = [
createAsset('asset-detail-top', BomCostingSourceAssetKind.DETAIL, topUrl, 0),
createAsset('asset-detail-bottom', BomCostingSourceAssetKind.DETAIL, bottomUrl, 1),
]
const source = createSource(assets)
jest.spyOn(bomCostingRepository, 'saveFetchedSourceSnapshot').mockResolvedValue(
source as never,
)
let storedCount = 0
jest.spyOn(bomCostingRepository, 'markSourceAssetStored').mockImplementation(
async () => ({
...source,
storedImageCount: ++storedCount,
} as never),
)
jest.spyOn(bomCostingRepository, 'markSourceAssetFailed').mockResolvedValue(null)
jest.spyOn(bomCostingRepository, 'findSourceSnapshotById').mockResolvedValue({
...source,
storedImageCount: 2,
assets: assets.map((asset, index) => ({
...asset,
status: BomCostingSourceAssetStatus.STORED,
storedUrl: `/api/storage/object?key=stored-${index}`,
storageKey: `stored-${index}`,
})),
} as never)
const completeSpy = jest.spyOn(
bomCostingRepository,
'completeSourceSnapshot',
).mockResolvedValue({
...source,
status: BomCostingSourceStatus.READY,
storedImageCount: 2,
} as never)
const top = await sharp({
create: { width: 10, height: 4, channels: 3, background: '#ff0000' },
}).png().toBuffer()
const bottom = await sharp({
create: { width: 10, height: 3, channels: 3, background: '#0000ff' },
}).png().toBuffer()
jest.spyOn(global, 'fetch').mockImplementation(async (input) =>
new Response(String(input).includes('crop,0,0,10,4') ? top : bottom, {
status: 200,
headers: { 'content-type': 'image/png' },
}),
)
mockUpload.mockImplementation(async (_buffer, key) => ({
url: `/api/storage/object?key=${encodeURIComponent(String(key))}`,
storageKey: key,
}))
await new BomCostingSourceArchiveService().archive({
sourceSnapshotId: 'source-1',
snapshot: cropSnapshot,
})
expect(mockUpload).toHaveBeenCalledTimes(3)
expect(mockUpload.mock.calls.some(([, key]) =>
String(key).includes('/derived/detail-1-2-'),
)).toBe(true)
expect(completeSpy).toHaveBeenCalledWith(
'source-1',
expect.objectContaining({
detailImagePreprocessVersion:
BOM_COSTING_DETAIL_IMAGE_PREPROCESS_VERSION,
detailImageComposites: [expect.objectContaining({
sourceUrls: [topUrl, bottomUrl],
sourceIndexes: [0, 1],
strategy: 'EXPLICIT_CROP_SEQUENCE_V1',
width: 10,
height: 7,
})],
}),
)
})
it('归档时恢复不同文件之间画面连续的详情图', async () => {
const topUrl = 'https://img.alicdn.com/detail-top.jpg'
const bottomUrl = 'https://img.alicdn.com/detail-bottom.jpg'
const slicedSnapshot: MarketDetailSnapshot = {
...snapshot,
mainImages: [],
skuImages: [],
detailImages: [topUrl, bottomUrl],
skus: snapshot.skus.map((sku) => ({ ...sku, imageUrl: null })),
}
const assets = [
createAsset('asset-detail-top', BomCostingSourceAssetKind.DETAIL, topUrl, 0),
createAsset('asset-detail-bottom', BomCostingSourceAssetKind.DETAIL, bottomUrl, 1),
]
const source = createSource(assets)
jest.spyOn(bomCostingRepository, 'saveFetchedSourceSnapshot').mockResolvedValue(
source as never,
)
let storedCount = 0
jest.spyOn(bomCostingRepository, 'markSourceAssetStored').mockImplementation(
async () => ({
...source,
storedImageCount: ++storedCount,
} as never),
)
jest.spyOn(bomCostingRepository, 'markSourceAssetFailed').mockResolvedValue(null)
jest.spyOn(bomCostingRepository, 'findSourceSnapshotById').mockResolvedValue({
...source,
storedImageCount: 2,
assets: assets.map((asset, index) => ({
...asset,
status: BomCostingSourceAssetStatus.STORED,
storedUrl: `/api/storage/object?key=stored-${index}`,
storageKey: `stored-${index}`,
})),
} as never)
const completeSpy = jest.spyOn(
bomCostingRepository,
'completeSourceSnapshot',
).mockResolvedValue({
...source,
status: BomCostingSourceStatus.READY,
storedImageCount: 2,
} as never)
const [top, bottom] = await createContinuousDetailSlices()
jest.spyOn(global, 'fetch').mockImplementation(async (input) =>
new Response(String(input).includes('detail-top') ? top : bottom, {
status: 200,
headers: { 'content-type': 'image/jpeg' },
}),
)
mockUpload.mockImplementation(async (_buffer, key) => ({
url: `/api/storage/object?key=${encodeURIComponent(String(key))}`,
storageKey: key,
}))
await new BomCostingSourceArchiveService().archive({
sourceSnapshotId: 'source-1',
snapshot: slicedSnapshot,
})
expect(mockUpload).toHaveBeenCalledTimes(3)
expect(completeSpy).toHaveBeenCalledWith(
'source-1',
expect.objectContaining({
detailImagePreprocessVersion:
BOM_COSTING_DETAIL_IMAGE_PREPROCESS_VERSION,
detailImageComposites: [expect.objectContaining({
sourceUrls: [topUrl, bottomUrl],
sourceIndexes: [0, 1],
strategy: 'PIXEL_BOUNDARY_CONTINUITY_V1',
width: 480,
height: 720,
})],
}),
)
})
it('复用资料时只向模型提供对象存储直读地址', async () => {
const assets = [
{
@ -212,6 +407,44 @@ describe('BomCostingSourceArchiveService', () => {
expect(restored.detailImages).toEqual([])
})
it('向模型提供恢复后的连续详情长图,同时保留原切片归档', async () => {
const topUrl = 'https://img.alicdn.com/panel~crop,0,0,2480,4960~_!!shop.jpg'
const bottomUrl = 'https://img.alicdn.com/panel~crop,0,4960,2480,3788~_!!shop.jpg'
const assets = [
createAsset('asset-detail-top', BomCostingSourceAssetKind.DETAIL, topUrl, 0),
createAsset('asset-detail-bottom', BomCostingSourceAssetKind.DETAIL, bottomUrl, 1),
].map((asset, index) => ({
...asset,
status: BomCostingSourceAssetStatus.STORED,
storedUrl: `/api/storage/object?key=stored-${index}`,
storageKey: `stored-${index}`,
}))
mockCreateReadUrl.mockImplementation(
(key: string) => `https://storage.example.com/${key}?signed=1`,
)
const restored = await buildMarketSnapshotFromStoredSource({
...createSource(assets),
rawSnapshot: {
...snapshot,
detailImages: [topUrl, bottomUrl],
detailImageComposites: [{
sourceUrls: [topUrl, bottomUrl],
sourceIndexes: [0, 1],
storageKey: 'derived/detail-1-2.jpg',
width: 2_000,
height: 7_055,
strategy: 'EXPLICIT_CROP_SEQUENCE_V1',
}],
},
} as never)
expect(assets).toHaveLength(2)
expect(restored.detailImages).toEqual([
'https://storage.example.com/derived/detail-1-2.jpg?signed=1',
])
})
it('旧快照只刷新 SKU 元数据,图片集合未变化时复用原归档对象', async () => {
const assets = [
createAsset(
@ -340,4 +573,74 @@ describe('BomCostingSourceArchiveService', () => {
expect(refreshSpy).not.toHaveBeenCalled()
expect(mockUpload).not.toHaveBeenCalled()
})
it('旧归档缺少明确裁切图片的合成结果时要求重新归档', async () => {
const topUrl = 'https://img.alicdn.com/panel~crop,0,0,2480,4960~_!!shop.jpg'
const bottomUrl = 'https://img.alicdn.com/panel~crop,0,4960,2480,3788~_!!shop.jpg'
const cropSnapshot: MarketDetailSnapshot = {
...snapshot,
detailImages: [topUrl, bottomUrl],
}
const assets = [
createAsset('asset-detail-top', BomCostingSourceAssetKind.DETAIL, topUrl, 0),
createAsset('asset-detail-bottom', BomCostingSourceAssetKind.DETAIL, bottomUrl, 1),
createAsset('asset-main', BomCostingSourceAssetKind.MAIN, snapshot.mainImages[0], 2),
createAsset('asset-sku', BomCostingSourceAssetKind.SKU, snapshot.skuImages[0], 3),
].map((asset, index) => ({
...asset,
status: BomCostingSourceAssetStatus.STORED,
storedUrl: `/api/storage/object?key=stored-${index}`,
storageKey: `stored-${index}`,
}))
await expect(new BomCostingSourceArchiveService().refreshMetadata({
source: {
...createSource(assets),
status: BomCostingSourceStatus.READY,
rawSnapshot: cropSnapshot,
} as never,
snapshot: cropSnapshot,
})).rejects.toThrow('缺少连续详情图合成结果')
})
it('旧归档未执行跨文件画面恢复时要求重新归档一次', async () => {
const assets = [
createAsset(
'asset-main',
BomCostingSourceAssetKind.MAIN,
snapshot.mainImages[0],
0,
),
createAsset(
'asset-sku',
BomCostingSourceAssetKind.SKU,
snapshot.skuImages[0],
1,
),
createAsset(
'asset-detail',
BomCostingSourceAssetKind.DETAIL,
snapshot.detailImages[0],
2,
),
].map((asset, index) => ({
...asset,
status: BomCostingSourceAssetStatus.STORED,
storedUrl: `/api/storage/object?key=stored-${index}`,
storageKey: `stored-${index}`,
}))
const legacySnapshot = {
...snapshot,
detailImagePreprocessVersion: null,
}
await expect(new BomCostingSourceArchiveService().refreshMetadata({
source: {
...createSource(assets),
status: BomCostingSourceStatus.READY,
rawSnapshot: legacySnapshot,
} as never,
snapshot: legacySnapshot,
})).rejects.toThrow('尚未执行完整详情图恢复')
})
})

View File

@ -15,8 +15,15 @@ import {
} from '@/server/repositories/bom-costing.repository'
import {
marketDetailSnapshotSchema,
type MarketDetailImageComposite,
type MarketDetailSnapshot,
} from './market-detail-client'
import {
BOM_COSTING_DETAIL_IMAGE_PREPROCESS_VERSION,
planBomCostingDetailImageStitches,
planBomCostingPixelBoundaryStitches,
stitchBomCostingDetailImages,
} from './bom-costing-detail-image-stitcher'
const DEFAULT_IMAGE_LIMIT_BYTES = 16 * 1024 * 1024
@ -201,6 +208,48 @@ function mapStoredImageUrl(
return storedByOriginalUrl.get(sourceUrl) ?? null
}
function findDetailImageSequenceStart(
detailImages: string[],
sourceUrls: string[],
) {
if (sourceUrls.length < 2) return -1
for (let start = 0; start <= detailImages.length - sourceUrls.length; start += 1) {
if (sourceUrls.every((url, offset) => detailImages[start + offset] === url)) {
return start
}
}
return -1
}
function buildAnalysisDetailImages(input: {
detailImages: string[]
composites: MarketDetailImageComposite[]
storedByOriginalUrl: Map<string, string>
createObjectReadUrl: (storageKey: string) => string
}) {
const result: string[] = []
let index = 0
while (index < input.detailImages.length) {
const composite = input.composites.find((candidate) =>
candidate.sourceUrls.every(
(url, offset) => input.detailImages[index + offset] === url,
),
)
if (composite) {
result.push(input.createObjectReadUrl(composite.storageKey))
index += composite.sourceUrls.length
continue
}
const storedUrl = mapStoredImageUrl(
input.detailImages[index] ?? null,
input.storedByOriginalUrl,
)
if (storedUrl) result.push(storedUrl)
index += 1
}
return result
}
export async function buildMarketSnapshotFromStoredSource(
source: SourceWithAssets,
): Promise<MarketDetailSnapshot> {
@ -226,7 +275,12 @@ export async function buildMarketSnapshotFromStoredSource(
const snapshot = marketDetailSnapshotSchema.parse({
...original,
mainImages: mapUrls(original.mainImages),
detailImages: mapUrls(original.detailImages),
detailImages: buildAnalysisDetailImages({
detailImages: original.detailImages,
composites: original.detailImageComposites,
storedByOriginalUrl,
createObjectReadUrl,
}),
skuImages: mapUrls(original.skuImages),
skus: original.skus.map((sku) => ({
...sku,
@ -266,6 +320,52 @@ export class BomCostingSourceArchiveService {
)
}
const previousSnapshot = marketDetailSnapshotSchema.parse(
input.source.rawSnapshot,
)
if (
input.snapshot.detailImages.length > 0 &&
previousSnapshot.detailImagePreprocessVersion !==
BOM_COSTING_DETAIL_IMAGE_PREPROCESS_VERSION
) {
throw new BomCostingSourceImagesChangedError(
'旧竞品归档尚未执行完整详情图恢复,需要重新归档后再分析',
)
}
const plannedStitches = planBomCostingDetailImageStitches(
input.snapshot.detailImages,
)
const preservedComposites = previousSnapshot.detailImageComposites.map(
(previous) => {
const start = findDetailImageSequenceStart(
input.snapshot.detailImages,
previous.sourceUrls,
)
if (start < 0) {
throw new BomCostingSourceImagesChangedError(
'竞品详情图的连续关系已经变化,需要重新归档后再分析',
)
}
return {
...previous,
sourceIndexes: previous.sourceUrls.map((_, offset) => start + offset),
}
},
)
const preservedCompositeBySource = new Map(
preservedComposites.map((composite) => [
composite.sourceUrls.join('\u0000'),
composite,
] as const),
)
for (const plan of plannedStitches) {
if (!preservedCompositeBySource.has(plan.sourceUrls.join('\u0000'))) {
throw new BomCostingSourceImagesChangedError(
'旧竞品归档缺少连续详情图合成结果,需要重新归档后再分析',
)
}
}
const assets = seeds.map((seed) => {
const existing = existingByOriginalUrl.get(seed.originalUrl)
if (
@ -291,7 +391,12 @@ export class BomCostingSourceArchiveService {
shopName: input.snapshot.shopName ?? '',
detailUrl: input.snapshot.detailUrl,
skuCount: input.snapshot.skus.length,
rawSnapshot: input.snapshot as unknown as Prisma.InputJsonValue,
rawSnapshot: {
...input.snapshot,
detailImageComposites: preservedComposites,
detailImagePreprocessVersion:
BOM_COSTING_DETAIL_IMAGE_PREPROCESS_VERSION,
} as unknown as Prisma.InputJsonValue,
assets,
})
}
@ -305,6 +410,7 @@ export class BomCostingSourceArchiveService {
deleteObjectWithResult,
uploadBufferToObjectStorageWithMetadata,
} = await import('@/lib/storage')
const downloadedDetailImages = new Map<string, Buffer>()
const seeds = buildAssetSeeds(input.snapshot)
if (seeds.length === 0) {
throw new Error('竞品资料没有可归档的图片')
@ -367,6 +473,9 @@ export class BomCostingSourceArchiveService {
byteSize: image.buffer.length,
})
if (!updated) throw new Error('图片归档状态已变化')
if (asset.kind === BomCostingSourceAssetKind.DETAIL) {
downloadedDetailImages.set(asset.originalUrl, image.buffer)
}
await input.onProgress?.({
phase: 'SETTLED',
assetId: asset.id,
@ -419,7 +528,79 @@ export class BomCostingSourceArchiveService {
if (!latest || latest.storedImageCount === 0) {
throw new Error('竞品图片全部归档失败,未调用多模态分析')
}
return bomCostingRepository.completeSourceSnapshot(input.sourceSnapshotId)
const explicitPlans = planBomCostingDetailImageStitches(
input.snapshot.detailImages,
)
const explicitSourceIndexes = new Set(
explicitPlans.flatMap((plan) => plan.sourceIndexes),
)
const pixelBoundaryPlans = await planBomCostingPixelBoundaryStitches({
detailImageUrls: input.snapshot.detailImages,
sourceBuffers: downloadedDetailImages,
excludedSourceIndexes: explicitSourceIndexes,
})
const stitchPlans = [...explicitPlans, ...pixelBoundaryPlans]
.sort((left, right) => left.sourceIndexes[0]! - right.sourceIndexes[0]!)
const detailImageComposites: MarketDetailImageComposite[] = []
for (const plan of stitchPlans) {
const buffers = plan.sourceUrls.map((url) =>
downloadedDetailImages.get(url),
)
if (buffers.some((buffer) => !buffer)) continue
try {
const stitched = await stitchBomCostingDetailImages(
plan,
buffers as Buffer[],
)
const key = [
'bom-costing',
'sources',
source.channel,
source.externalItemId,
'derived',
`detail-${plan.sourceIndexes[0]! + 1}-${plan.sourceIndexes.at(-1)! + 1}-${plan.fingerprint}.jpg`,
].join('/')
const uploaded = await uploadBufferToObjectStorageWithMetadata(
stitched.buffer,
key,
stitched.contentType,
)
detailImageComposites.push({
sourceUrls: plan.sourceUrls,
sourceIndexes: plan.sourceIndexes,
storageKey: uploaded.storageKey,
width: stitched.width,
height: stitched.height,
strategy: plan.strategy,
})
} catch (error) {
console.warn('[BOM costing] detail image slices were not stitched', {
sourceSnapshotId: input.sourceSnapshotId,
sourceIndexes: plan.sourceIndexes,
strategy: plan.strategy,
errorName: error instanceof Error ? error.name : 'UnknownError',
errorMessage: error instanceof Error ? error.message : '详情图合成失败',
})
}
}
const hasFailedDetailAsset = latest.assets.some(
(asset) =>
asset.kind === BomCostingSourceAssetKind.DETAIL &&
asset.status !== BomCostingSourceAssetStatus.STORED,
)
const preprocessingComplete =
!hasFailedDetailAsset &&
detailImageComposites.length === stitchPlans.length
return bomCostingRepository.completeSourceSnapshot(
input.sourceSnapshotId,
{
...input.snapshot,
detailImageComposites,
detailImagePreprocessVersion: preprocessingComplete
? BOM_COSTING_DETAIL_IMAGE_PREPROCESS_VERSION
: null,
} as unknown as Prisma.InputJsonValue,
)
}
}

View File

@ -41,7 +41,11 @@ import {
getCompetitorAnalysisProvider,
} from './bom-costing-provider'
import { bomCostingProcessingService } from './bom-costing-processing.service'
import { parseMarketProductIdentity } from './market-detail-client'
import {
buildCanonicalMarketProductUrl,
canonicalizeMarketProductUrl,
parseMarketProductIdentity,
} from './market-detail-client'
const CATEGORY_LABELS: Record<BomCostingLineCategory, string> = {
BOARD: '板材',
@ -213,7 +217,7 @@ function serializeCaseDetail(record: BomCostingCaseDetailRecord) {
})
return {
id: record.id,
sourceUrl: record.sourceUrl,
sourceUrl: canonicalizeMarketProductUrl(record.sourceUrl),
sourceHost: record.sourceHost,
title: record.title,
providerCode: record.providerCode,
@ -388,7 +392,7 @@ export class BomCostingService {
return {
id: record.id,
title: record.title,
sourceUrl: record.sourceUrl,
sourceUrl: canonicalizeMarketProductUrl(record.sourceUrl),
sourceHost: record.sourceHost,
status: record.status,
progress: record.progress,
@ -448,16 +452,23 @@ export class BomCostingService {
)
}
let canonicalSourceUrl = parsedUrl.toString()
let canonicalSourceHost = parsedUrl.hostname
let sourceSnapshot: Awaited<
ReturnType<typeof bomCostingRepository.upsertSourceSnapshot>
> | null = null
if (provider.requiresMarketSnapshot) {
const identity = parseMarketProductIdentity(parsedUrl.toString())
canonicalSourceUrl = buildCanonicalMarketProductUrl(
identity,
parsedUrl.toString(),
)
canonicalSourceHost = new URL(canonicalSourceUrl).hostname
const sourceKey = `${identity.channel}:${identity.itemId}`
sourceSnapshot = await bomCostingRepository.upsertSourceSnapshot({
sourceKey,
sourceUrl: parsedUrl.toString(),
sourceHost: parsedUrl.hostname,
sourceUrl: canonicalSourceUrl,
sourceHost: canonicalSourceHost,
channel: identity.channel,
externalItemId: identity.itemId,
})
@ -482,11 +493,11 @@ export class BomCostingService {
sourceSnapshot?.status === BomCostingSourceStatus.READY
const providerTaskId = `bom-${nanoid(16)}`
const record = await bomCostingRepository.createQueuedCase({
sourceUrl: parsedUrl.toString(),
sourceHost: parsedUrl.hostname,
sourceUrl: canonicalSourceUrl,
sourceHost: canonicalSourceHost,
sourceSnapshotId: sourceSnapshot?.id ?? null,
sourceCacheHit,
title: sourceSnapshot?.title || parsedUrl.hostname,
title: sourceSnapshot?.title || canonicalSourceHost,
providerCode: provider.code,
providerTaskId,
analysisSchemaVersion: BOM_COSTING_RESULT_RULESET_VERSION,

View File

@ -2,6 +2,7 @@
import { AppError, ErrorCode } from '@/server/core/errors'
import {
canonicalizeMarketProductUrl,
isLegacyMarketSkuSnapshotPossiblyTruncated,
MarketDetailClient,
marketDetailSnapshotSchema,
@ -40,6 +41,12 @@ describe('市场商品详情客户端', () => {
)
})
it('清除天猫经营后台追踪参数,只保留可分享的标准商品链接', () => {
expect(canonicalizeMarketProductUrl(
'https://detail.tmall.com/item.htm?b_s_f=sycm&id=861534013571&b_spm=123.456',
)).toBe('https://detail.tmall.com/item.htm?id=861534013571')
})
it('只把缺少完整性标记且达到旧版上限的历史快照识别为待刷新', () => {
const legacySnapshot = {
itemId: '893282881382',
@ -83,7 +90,7 @@ describe('市场商品详情客户端', () => {
channel: 'taobao',
title: '测试商品',
shopName: '测试店铺',
detailUrl: 'https://item.taobao.com/item.htm?id=902511696710',
detailUrl: 'https://detail.tmall.com/item.htm?b_s_f=sycm&id=902511696710&b_spm=123.456',
mainImages: ['http://img.example.com/main.jpg'],
detailImages: ['https://img.example.com/detail.jpg'],
skuImages: [],
@ -129,6 +136,7 @@ describe('市场商品详情客户端', () => {
itemId: '902511696710',
channel: 'taobao',
title: '测试商品',
detailUrl: 'https://detail.tmall.com/item.htm?id=902511696710',
mainImages: ['https://img.example.com/main.jpg'],
requestId: 'request-1',
skuTotalRows: 1,
@ -140,6 +148,8 @@ describe('市场商品详情客户端', () => {
imageUrl: 'https://img.example.com/white.jpg',
attributes: { 颜色: '白色', 尺寸: '120cm' },
priceRaw: '19900',
priceUnit: 'CENT',
originalPriceUnit: 'CENT',
}],
})
const endpoint = fetchSpy.mock.calls[0]?.[0] as URL

View File

@ -14,6 +14,8 @@ export interface MarketSkuSnapshot {
attributes: Record<string, string>
priceRaw: string | null
originalPriceRaw: string | null
priceUnit?: 'CENT' | 'YUAN' | null
originalPriceUnit?: 'CENT' | 'YUAN' | null
quantity: number | null
}
@ -25,6 +27,8 @@ export interface MarketDetailSnapshot {
detailUrl: string
mainImages: string[]
detailImages: string[]
detailImageComposites?: MarketDetailImageComposite[]
detailImagePreprocessVersion?: string | null
skuImages: string[]
skus: MarketSkuSnapshot[]
skuTotalRows?: number | null
@ -33,6 +37,17 @@ export interface MarketDetailSnapshot {
requestId: string | null
}
export interface MarketDetailImageComposite {
sourceUrls: string[]
sourceIndexes: number[]
storageKey: string
width: number
height: number
strategy:
| 'EXPLICIT_CROP_SEQUENCE_V1'
| 'PIXEL_BOUNDARY_CONTINUITY_V1'
}
export interface MarketDetailReadOptions {
/**
* A newly created calculation must verify the current product options rather
@ -72,6 +87,8 @@ const marketSkuSnapshotSchema = z.object({
attributes: z.record(z.string(), z.string()),
priceRaw: z.string().nullable(),
originalPriceRaw: z.string().nullable(),
priceUnit: z.enum(['CENT', 'YUAN']).nullable().optional().default(null),
originalPriceUnit: z.enum(['CENT', 'YUAN']).nullable().optional().default(null),
quantity: z.number().nullable(),
}).transform((sku) => {
const externalSkuId = normalizeMarketSkuExternalId(sku.externalSkuId)
@ -90,6 +107,18 @@ export const marketDetailSnapshotSchema = z.object({
detailUrl: z.string().url(),
mainImages: z.array(z.string().url()),
detailImages: z.array(z.string().url()),
detailImageComposites: z.array(z.object({
sourceUrls: z.array(z.string().url()).min(2),
sourceIndexes: z.array(z.number().int().nonnegative()).min(2),
storageKey: z.string().trim().min(1),
width: z.number().int().positive(),
height: z.number().int().positive(),
strategy: z.enum([
'EXPLICIT_CROP_SEQUENCE_V1',
'PIXEL_BOUNDARY_CONTINUITY_V1',
]),
})).optional().default([]),
detailImagePreprocessVersion: z.string().nullable().optional().default(null),
skuImages: z.array(z.string().url()),
skus: z.array(marketSkuSnapshotSchema).min(1),
skuTotalRows: z.number().int().nonnegative().nullable().optional().default(null),
@ -237,6 +266,47 @@ export function parseMarketProductIdentity(sourceUrl: string): MarketProductIden
return { itemId, channel }
}
export function buildCanonicalMarketProductUrl(
identity: MarketProductIdentity,
preferredUrl?: string | null,
) {
const itemId = encodeURIComponent(identity.itemId)
if (identity.channel === '1688') {
return `https://detail.1688.com/offer/${itemId}.html`
}
if (identity.channel === 'jd') {
return `https://item.jd.com/${itemId}.html`
}
if (identity.channel === 'douyin') {
return `https://haohuo.jinritemai.com/views/product/item2?id=${itemId}`
}
let preferTmall = false
if (preferredUrl) {
try {
const hostname = new URL(preferredUrl).hostname.toLowerCase()
preferTmall = hostname === 'tmall.com' || hostname.endsWith('.tmall.com')
} catch {
preferTmall = false
}
}
return preferTmall
? `https://detail.tmall.com/item.htm?id=${itemId}`
: `https://item.taobao.com/item.htm?id=${itemId}`
}
export function canonicalizeMarketProductUrl(
sourceUrl: string,
identity?: MarketProductIdentity,
) {
try {
const resolvedIdentity = identity ?? parseMarketProductIdentity(sourceUrl)
return buildCanonicalMarketProductUrl(resolvedIdentity, sourceUrl)
} catch {
return sourceUrl
}
}
function readUpstreamMessage(payload: unknown): string | null {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
const record = payload as Record<string, unknown>
@ -337,6 +407,15 @@ function normalizeSkuRows(
attributes,
priceRaw: row.skuPrice ?? null,
originalPriceRaw: row.skuOriginalPrice ?? null,
// The Open API contract returns skuPrice/skuOriginalPrice in the
// platform's minor currency unit. Keep the raw value for audit, and
// carry the unit explicitly so downstream code never mistakes cents for
// yuan again.
priceUnit: row.skuPrice === null || row.skuPrice === undefined ? null : 'CENT',
originalPriceUnit:
row.skuOriginalPrice === null || row.skuOriginalPrice === undefined
? null
: 'CENT',
quantity: row.skuQuantity ?? null,
})
}
@ -537,7 +616,13 @@ export class MarketDetailClient {
channel: parsed.data.data.channel,
title: parsed.data.data.title.trim(),
shopName: parsed.data.data.shopName?.trim() || null,
detailUrl: normalizeHttpUrl(parsed.data.data.detailUrl) ?? sourceUrl,
detailUrl: buildCanonicalMarketProductUrl(
{
itemId: parsed.data.data.itemId,
channel: parsed.data.data.channel,
},
normalizeHttpUrl(parsed.data.data.detailUrl) ?? sourceUrl,
),
mainImages,
detailImages,
skuImages,