fix(master-data): prevent dirty intake sync regressions
This commit is contained in:
parent
86df630894
commit
f620762a4b
@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 分配',
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user