fix(bom-costing): restore detail slices and correct cost semantics
This commit is contained in:
parent
ead86d523e
commit
772c6813ed
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "BomCostingScenario"
|
||||
ALTER COLUMN "rulesetVersion" SET DEFAULT 'dynamic-bom-cost/v4';
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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)
|
||||
: '待生成'}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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(
|
||||
'指定复查的图片已发生变化,请刷新页面后重新选择',
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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('尚未执行完整详情图恢复')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user