fix(master-data): prevent dirty intake sync regressions

This commit is contained in:
WuYongJie 2026-06-05 16:25:13 +08:00
parent 86df630894
commit f620762a4b
7 changed files with 170 additions and 23 deletions

View File

@ -1,6 +1,7 @@
import {
buildAssetSkuSnapshotFromProfile,
buildProductSkuAssetProfileData,
resolveAssetSkuSnapshotSyncPolicy,
type AssetSkuSnapshot,
} from '../asset-sku-snapshot'
@ -154,4 +155,84 @@ describe('asset-sku-snapshot', () => {
}),
)
})
it('资产同步策略会尊重人工锁定图片并保留可审计规则', () => {
const snapshot: AssetSkuSnapshot = {
sourceAssetVariantId: 'variant-primary',
sourceAssetFamilyId: 'family-1',
sourceAssetSeriesId: 'series-1',
model: 'DBZ-SPJK001',
modelName: '桌面板',
specCode: 'DBZ-SPJ00001',
specName: '1200 柚木色',
barcode: 'DBZ-SPJ00001N05V01',
size: { length: 1200 },
color: '柚木色',
material: '颗粒板',
imageUrl: '/new-thumb.webp',
sourceStatus: 'ACTIVE',
sourceAssetVariantIds: ['variant-primary'],
sourceVariants: [],
syncedAt: '2026-05-31T10:00:00.000Z',
lockedFields: [' imageUrl ', 'barcode'],
lockedReasons: {
imageUrl: '保留人工图',
barcode: '人工条码',
},
}
expect(
resolveAssetSkuSnapshotSyncPolicy({
reason: 'THUMBNAIL_REGENERATED',
isPrimarySource: true,
next: snapshot,
}),
).toEqual({
syncsTopLevelFields: true,
syncsAssetProfile: true,
refreshesImages: false,
marksDeletedSource: false,
marksMissingSource: false,
preservesLockedFields: ['imageUrl', 'barcode'],
})
})
it('资产同步策略会把删除来源表达成正式 SKU 副本状态', () => {
const snapshot: AssetSkuSnapshot = {
sourceAssetVariantId: 'variant-deleted',
sourceAssetFamilyId: 'family-1',
sourceAssetSeriesId: 'series-1',
model: 'DBZ-SPJK001',
modelName: '桌面板',
specCode: 'DBZ-SPJ00001',
specName: '1200 柚木色',
barcode: 'DBZ-SPJ00001N05V01',
size: { length: 1200 },
color: '柚木色',
material: '颗粒板',
imageUrl: '/old-thumb.webp',
sourceStatus: 'DELETED',
sourceAssetVariantIds: ['variant-deleted'],
sourceVariants: [],
syncedAt: '2026-05-31T10:00:00.000Z',
lockedFields: [],
lockedReasons: {},
}
expect(
resolveAssetSkuSnapshotSyncPolicy({
reason: 'VARIANT_DELETED',
isPrimarySource: true,
next: snapshot,
}),
).toEqual(
expect.objectContaining({
syncsTopLevelFields: true,
syncsAssetProfile: true,
refreshesImages: true,
marksDeletedSource: true,
marksMissingSource: false,
}),
)
})
})

View File

@ -922,7 +922,7 @@ describe('intakeNonIntegratedProductService', () => {
)
})
it('分配正式 SPU 时允许 SKU 主数据在后续节点补齐', async () => {
it('分配正式 SPU 时阻断未补齐的 SKU 主数据,避免生成新的地基阻塞', async () => {
const payload = createPayload({
id: 'draft-incomplete-before-formal-spu',
spu: '',
@ -961,21 +961,17 @@ describe('intakeNonIntegratedProductService', () => {
}) as never,
)
await intakeNonIntegratedProductService.save(payload, baseUser, {
const promise = intakeNonIntegratedProductService.save(payload, baseUser, {
formalSpuAssignmentProductCategory: '餐桌',
})
const createArg = mockedRepository.createInTransaction.mock.calls[0]?.[1] as {
skuItems?: { createMany?: { data?: Array<Record<string, unknown>> } }
}
expect(createArg.skuItems?.createMany?.data?.[0]).toEqual(
expect.objectContaining({
skuCode: 'CZ001101',
barcode: 'CZ001101N01V01',
model: '',
specName: '',
}),
)
await expect(promise).rejects.toMatchObject({
code: ErrorCode.VALIDATION_REQUIRED_FIELD,
})
await expect(promise).rejects.toThrow('正式 SPU 分配存在未补齐的 SKU 主数据')
expect(mockedRepository.createInTransaction).not.toHaveBeenCalled()
expect(mockedRepository.updateInTransaction).not.toHaveBeenCalled()
})
it('分配正式 SPU 前仍必须补齐产品主数据', async () => {

View File

@ -259,6 +259,10 @@ describe('syncJstSkuStockProducts', () => {
data: expect.objectContaining({
payload: expect.objectContaining({
source: 'jst_sku',
productCategory: '桌类',
openingReference: expect.objectContaining({
productCategory: '桌类',
}),
skuItems: expect.arrayContaining([
expect.objectContaining({
barcode: 'BAR001',
@ -272,6 +276,7 @@ describe('syncJstSkuStockProducts', () => {
create: expect.objectContaining({
productName: '测试存量商品 1200*600*750',
productImageUrl: 'https://cdn.example.com/stock-a.jpg',
productCategory: '桌类',
productDevelopmentKind: 'product',
materialType: '颗粒板',
sourceSpu: 'SPU001',
@ -343,6 +348,22 @@ describe('syncJstSkuStockProducts', () => {
)
})
it('apply 遇到聚水潭来源缺分类时跳过写库,避免重新生成缺 productCategory 的主数据', async () => {
createConnectionMock(
createSourceRow({
category: '',
}),
)
const result = await syncJstSkuStockProducts({ apply: true, batchSize: 1, limit: 1 })
expect(result.created).toBe(0)
expect(result.skipped).toBe(1)
expect(result.errors[0]).toContain('聚水潭存量商品同步存在未补齐的产品主数据')
expect(mockedPrisma.intakeNonIntegratedProduct.create).not.toHaveBeenCalled()
expect(mockedPrisma.intakeNonIntegratedProduct.update).not.toHaveBeenCalled()
})
it('apply 更新存量商品时 upsert 产品 profile 并刷新结构化 SKU 明细', async () => {
createConnectionMock(createSourceRow({ name: '更新后的存量商品 1200*600*750' }))
mockedPrisma.intakeNonIntegratedProduct.findUnique.mockResolvedValue({
@ -363,12 +384,14 @@ describe('syncJstSkuStockProducts', () => {
upsert: {
create: expect.objectContaining({
productName: '更新后的存量商品 1200*600*750',
productCategory: '桌类',
materialType: '颗粒板',
sourceSpu: 'SPU001',
sourceSkuId: 'BAR001',
}),
update: expect.objectContaining({
productName: '更新后的存量商品 1200*600*750',
productCategory: '桌类',
materialType: '颗粒板',
sourceSpu: 'SPU001',
sourceSkuId: 'BAR001',

View File

@ -43,6 +43,21 @@ export interface AssetSkuSnapshot extends AssetSkuSnapshotVariant {
lockedReasons: Record<string, string>
}
export type AssetSkuSnapshotSyncReason =
| 'VARIANT_UPDATED'
| 'THUMBNAIL_REGENERATED'
| 'VARIANT_DELETED'
| 'MANUAL_RETRY'
export type AssetSkuSnapshotSyncPolicy = {
syncsTopLevelFields: boolean
syncsAssetProfile: boolean
refreshesImages: boolean
marksDeletedSource: boolean
marksMissingSource: boolean
preservesLockedFields: AssetSkuSnapshotLockableField[]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@ -348,6 +363,26 @@ export function applyLockedSnapshotFields(input: {
return normalizeSnapshot(merged)
}
export function resolveAssetSkuSnapshotSyncPolicy(input: {
reason: AssetSkuSnapshotSyncReason
isPrimarySource: boolean
next: AssetSkuSnapshot
}): AssetSkuSnapshotSyncPolicy {
const normalizedNext = normalizeSnapshot(input.next)
const lockedFields = normalizeLockedFields(normalizedNext.lockedFields)
const imageLocked = lockedFields.includes('imageUrl') || lockedFields.includes('images')
return {
syncsTopLevelFields: input.isPrimarySource,
syncsAssetProfile: true,
refreshesImages: input.isPrimarySource && !imageLocked && Boolean(normalizedNext.imageUrl),
marksDeletedSource:
input.reason === 'VARIANT_DELETED' || normalizedNext.sourceStatus === 'DELETED',
marksMissingSource: normalizedNext.sourceStatus === 'MISSING',
preservesLockedFields: lockedFields,
}
}
function parseSnapshotSyncedAt(value: string): Date {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? new Date() : parsed

View File

@ -12,6 +12,7 @@ import {
buildMissingAssetSkuSnapshotVariant,
buildAssetSkuSnapshotVariant,
buildProductSkuAssetProfileData,
resolveAssetSkuSnapshotSyncPolicy,
type AssetSkuSnapshot,
} from './asset-sku-snapshot'
@ -32,10 +33,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isImageLocked(snapshot: AssetSkuSnapshot | null): boolean {
return Boolean(snapshot?.lockedFields.includes('imageUrl') || snapshot?.lockedFields.includes('images'))
}
function upsertSourceVariant(
snapshot: AssetSkuSnapshot,
updatedVariant: ReturnType<typeof buildAssetSkuSnapshotVariant>,
@ -289,8 +286,13 @@ class AssetSkuSyncService {
...existingAttributes,
assetSkuSnapshot: nextSnapshot,
}
const syncPolicy = resolveAssetSkuSnapshotSyncPolicy({
reason: event.reason as AssetSkuSyncReason,
isPrimarySource: shouldUpdateTopLevel,
next: nextSnapshot,
})
const nextImages =
shouldUpdateTopLevel && !isImageLocked(nextSnapshot) && sourceVariant.imageUrl
syncPolicy.refreshesImages && sourceVariant.imageUrl
? [sourceVariant.imageUrl, ...sku.images.filter((item) => item !== sourceVariant.imageUrl)]
: sku.images

View File

@ -40,7 +40,7 @@ import {
preSkuRepository,
} from '../repositories/draft-group.repository'
import {
assertNonIntegratedProductProfileReadyForFormalUse,
assertNonIntegratedProductMasterDataReadyForFormalUse,
resolveNonIntegratedSkuQuality,
} from './non-integrated-sku-quality'
import {
@ -4154,7 +4154,7 @@ class IntakeNonIntegratedProductService {
if (!normalizedProduct) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存')
}
assertNonIntegratedProductProfileReadyForFormalUse(normalizedProduct, {
assertNonIntegratedProductMasterDataReadyForFormalUse(normalizedProduct, {
usageLabel: '正式 SPU 分配',
})

View File

@ -13,6 +13,7 @@ import {
buildStructuredProductProfileInput,
buildStructuredSkuItemInputs,
} from './intake-non-integrated-product.service'
import { assertNonIntegratedProductMasterDataReadyForFormalUse } from './non-integrated-sku-quality'
type JstSkuSourceField =
| 'sku_id'
@ -379,7 +380,7 @@ function buildSourceProductPayload(
temporaryCode: sourceSpu,
brand: BASIC_BRAND,
adaptableBrands: [BASIC_BRAND],
productCategory: '',
productCategory: sourceCategory,
salesCategory: '',
designer: '',
planningOwner: '',
@ -403,7 +404,7 @@ function buildSourceProductPayload(
year: '',
demandFlowNo: '',
processTemplate: STOCK_PRODUCT_LABEL,
productCategory: '',
productCategory: sourceCategory,
salesCategory: '',
productPlanning: '',
demandDepartment: '',
@ -517,10 +518,17 @@ function buildSourceProductPayload(
} as unknown as Partial<NonIntegratedProductRecord> & Record<string, unknown>
const normalizedProduct = normalizeNonIntegratedProductRecord(rawPayload)
return normalizedProduct
const product = normalizedProduct
? ({ ...rawPayload, ...normalizedProduct } as NonIntegratedProductRecord &
Record<string, unknown>)
: null
if (!product) return null
assertNonIntegratedProductMasterDataReadyForFormalUse(product, {
usageLabel: '聚水潭存量商品同步',
})
return product
}
function mergeExistingProductPayload(
@ -581,6 +589,7 @@ function mergeExistingProductPayload(
temporaryCode: sourceOpeningReference.temporaryCode,
brand: sourceOpeningReference.brand,
adaptableBrands: sourceOpeningReference.adaptableBrands,
productCategory: sourceOpeningReference.productCategory,
currentStage: sourceOpeningReference.currentStage,
size: sourceOpeningReference.size,
material: sourceOpeningReference.material,
@ -613,6 +622,7 @@ function mergeExistingProductPayload(
color: sourcePayload.color,
material: sourcePayload.material,
materialType: sourcePayload.materialType,
productCategory: sourcePayload.productCategory,
openingReference: nextOpeningReference,
previewImage: sourcePayload.previewImage || existingProduct.previewImage,
previewImageName: sourcePayload.previewImage