fix: prevent opening codes from becoming formal spu

This commit is contained in:
WuYongJie 2026-06-04 10:03:48 +08:00
parent 2ccaab0e72
commit 5cb25981ef
7 changed files with 194 additions and 17 deletions

View File

@ -842,7 +842,8 @@ describe("NonIntegratedDetailClient", () => {
expect(mockedSaveSharedNonIntegratedProduct).toHaveBeenCalledWith(
expect.objectContaining({
spu: "测试",
spu: "",
formalSpuAssigned: undefined,
openingReference: expect.objectContaining({
temporaryCode: "测试",
}),

View File

@ -225,6 +225,7 @@ import {
normalizeNonIntegratedProjectReview,
normalizeNonIntegratedProjectStatus,
normalizeNonIntegratedSkuHolePoints,
prepareNonIntegratedProductForFormalSpuAssignment,
replaceNonIntegratedProducts,
saveNonIntegratedProduct,
stripNonIntegratedBrandPrefix,
@ -11641,6 +11642,11 @@ function buildProductPayload(
productImage: sharedProductImage,
productImageName: sharedProductImageName,
};
const explicitFormSpu = formValues.spu.trim();
const isOpeningTemporaryFormSpu =
explicitFormSpu.length > 0 &&
stripNonIntegratedBrandPrefix(explicitFormSpu) ===
stripNonIntegratedBrandPrefix(normalizedOpeningReference.temporaryCode);
return {
id,
productDevelopmentKind,
@ -11658,10 +11664,7 @@ function buildProductPayload(
formValues.designer.trim(),
},
),
spu:
formValues.spu.trim() ||
normalizedOpeningReference.temporaryCode.trim() ||
id,
spu: explicitFormSpu || normalizedOpeningReference.temporaryCode.trim() || id,
year: autoYear,
demandFlowNo: formValues.demandFlowNo.trim(),
processTemplate: formValues.processTemplate.trim(),
@ -11770,7 +11773,8 @@ function buildProductPayload(
documentWorkspaceApprovedAt: {
...documentWorkspaceApprovedAt,
},
formalSpuAssigned: formValues.spu.trim().length > 0,
formalSpuAssigned:
explicitFormSpu.length > 0 && !isOpeningTemporaryFormSpu,
copiedFromProductId,
copiedFromSpu,
createdBy,
@ -16787,13 +16791,16 @@ export default function NonIntegratedDetailClient({
),
);
const productForSave = formalSpuAssignmentProductCategory
? prepareNonIntegratedProductForFormalSpuAssignment(nextProduct)
: nextProduct;
const savedProduct = shouldUseRemoteAssetStorage()
? await saveSharedNonIntegratedProduct(nextProduct, {
? await saveSharedNonIntegratedProduct(productForSave, {
workspaceCategory:
overrides?.workspaceCategory ?? activePermissionWorkspaceCategory,
formalSpuAssignmentProductCategory,
})
: saveNonIntegratedProduct(nextProduct);
: saveNonIntegratedProduct(productForSave);
if (!formValuesRef.current.spu.trim() && savedProduct.spu.trim()) {
setFormValuesAndRef((current) => ({
...current,
@ -24179,11 +24186,14 @@ export default function NonIntegratedDetailClient({
),
{ attachToExistingProduct: false },
);
const productForSave = formalSpuAssignmentProductCategory
? prepareNonIntegratedProductForFormalSpuAssignment(normalizedProduct)
: normalizedProduct;
const savedProduct = shouldUseRemoteAssetStorage()
? await saveSharedNonIntegratedProduct(normalizedProduct, {
? await saveSharedNonIntegratedProduct(productForSave, {
formalSpuAssignmentProductCategory,
})
: saveNonIntegratedProduct(normalizedProduct);
: saveNonIntegratedProduct(productForSave);
if (!isStockProductCreate) {
void syncSpuRuleLibraryRulesFromServer()
.then(() => setSpuEstimateRefreshToken((current) => current + 1))

View File

@ -1,4 +1,7 @@
import type { NonIntegratedProductRecord } from './nonIntegratedStorage'
import {
prepareNonIntegratedProductForFormalSpuAssignment,
type NonIntegratedProductRecord,
} from './nonIntegratedStorage'
type ApiEnvelope<T> = {
success?: boolean
@ -264,13 +267,16 @@ export async function saveSharedNonIntegratedProduct(
formalSpuAssignmentProductCategory?: string | null
} = {},
) {
const productForSave = options.formalSpuAssignmentProductCategory
? prepareNonIntegratedProductForFormalSpuAssignment(product)
: product
const response = await fetchWithTimeout(`/api/intake/non-integrated/${encodeURIComponent(product.id)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
product,
product: productForSave,
...(options.workspaceCategory
? { workspaceCategory: options.workspaceCategory }
: {}),

View File

@ -11,6 +11,7 @@ import {
NON_INTEGRATED_STORAGE_KEY,
applyBrandPrefixToNonIntegratedCode,
normalizeNonIntegratedBrand,
prepareNonIntegratedProductForFormalSpuAssignment,
saveNonIntegratedProduct,
type NonIntegratedProductRecord,
} from './nonIntegratedStorage'
@ -717,4 +718,25 @@ describe('nonIntegratedStorage', () => {
)
expect(Object.values(copiedProduct.documentWorkspaceConfirmations).every((value) => value === false)).toBe(true)
})
it('clears an opening temporary code before requesting formal SPU assignment', () => {
const product = createProduct({
id: 'XP2026060300000035-1780536232087',
spu: 'XP2026060300000035',
productCode: 'XP2026060300000035',
formalSpuAssigned: true,
openingReference: {
...createEmptyNonIntegratedOpeningReference(),
temporaryCode: 'XP2026060300000035',
productCategory: 'Side table',
},
})
const prepared = prepareNonIntegratedProductForFormalSpuAssignment(product)
expect(prepared.spu).toBe('')
expect(prepared.productCode).toBe('')
expect(prepared.formalSpuAssigned).toBeUndefined()
expect(prepared.openingReference.temporaryCode).toBe('XP2026060300000035')
})
})

View File

@ -753,6 +753,47 @@ export function getSearchableNonIntegratedSpuKeywords(spu: string) {
return Array.from(new Set([normalizedSpu, baseSpu].filter(Boolean)));
}
function hasSameNonIntegratedBaseCode(left: string, right: string) {
const normalizedLeft = stripNonIntegratedBrandPrefix(left);
const normalizedRight = stripNonIntegratedBrandPrefix(right);
return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight);
}
function isOpeningTemporarySpu(
product: Pick<NonIntegratedProductRecord, "spu" | "openingReference">,
) {
return hasSameNonIntegratedBaseCode(
product.spu,
product.openingReference?.temporaryCode ?? "",
);
}
export function prepareNonIntegratedProductForFormalSpuAssignment<
T extends NonIntegratedProductRecord,
>(product: T): T {
const normalizedSpu = product.spu.trim();
if (!normalizedSpu) return product;
const shouldClearSpu =
product.formalSpuAssigned !== true || isOpeningTemporarySpu(product);
if (!shouldClearSpu) return product;
const shouldClearProductCode =
hasSameNonIntegratedBaseCode(product.productCode, normalizedSpu) ||
hasSameNonIntegratedBaseCode(
product.productCode,
product.openingReference?.temporaryCode ?? "",
) ||
hasSameNonIntegratedBaseCode(product.productCode, product.id);
return {
...product,
spu: "",
productCode: shouldClearProductCode ? "" : product.productCode,
formalSpuAssigned: undefined,
};
}
type NonIntegratedFormalSpuSource = Pick<
NonIntegratedProductRecord,
"spu" | "openingReference"

View File

@ -796,6 +796,74 @@ describe('intakeNonIntegratedProductService', () => {
)
})
it('assigns a formal SPU when the incoming SPU is the opening temporary code', async () => {
const payload = createPayload({
id: 'XP2026060300000035-1780536232087',
spu: 'XP2026060300000035',
productCode: 'XP2026060300000035',
formalSpuAssigned: true,
productCategory: 'Side table',
productDevelopmentKind: 'product',
openingReference: {
temporaryCode: 'XP2026060300000035',
productCategory: 'Side table',
},
skuItems: [
createSkuItem({
skuSequence: 1,
skuCode: 'XP202606030000003501',
barcode: 'XP202606030000003501N01V01',
}),
],
})
mockedSpuNumberReservationService.reserveInTransaction.mockResolvedValue({
productCategory: 'Side table',
prefix: 'BJ',
index: 210,
spu: 'BJ0210',
})
mockedRepository.findDeletedBySpuInTransaction.mockResolvedValue(null as never)
mockedRepository.findBySpuInTransaction.mockResolvedValue(null as never)
mockedRepository.createInTransaction.mockResolvedValue(
createRecord({ id: 'BJ0210-1770000000000' }) as never,
)
mockedRepository.findActiveById.mockResolvedValue(
createRecord({
id: 'BJ0210-1770000000000',
spu: 'BJ0210',
spuBase: 'BJ0210',
payload: createPayload({ id: 'BJ0210-1770000000000', spu: 'BJ0210' }),
}) as never,
)
await intakeNonIntegratedProductService.save(payload, baseUser, {
formalSpuAssignmentProductCategory: 'Side table',
})
expect(mockedSpuNumberReservationService.reserveInTransaction).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
productCategory: 'Side table',
currentProductId: 'XP2026060300000035-1780536232087',
}),
)
const createArg = mockedRepository.createInTransaction.mock.calls[0]?.[1] as {
spu?: string
payload?: { spu?: string; productCode?: string; formalSpuAssigned?: boolean }
skuItems?: { createMany?: { data?: Array<Record<string, unknown>> } }
}
expect(createArg.spu).toBe('BJ0210')
expect(createArg.payload?.spu).toBe('BJ0210')
expect(createArg.payload?.productCode).toBe('BJ0210')
expect(createArg.payload?.formalSpuAssigned).toBe(true)
expect(createArg.skuItems?.createMany?.data?.[0]).toEqual(
expect.objectContaining({
skuCode: 'BJ021001',
barcode: 'BJ021001N01V01',
}),
)
})
it('分配正式 SPU 时会替换草稿临时 SKU 编码和条码', async () => {
const payload = createPayload({
id: 'draft-with-temporary-sku',

View File

@ -319,6 +319,26 @@ function normalizePreservedNonIntegratedSpu(record: { id: string; spu: string })
return readText(record.spu).toUpperCase() || record.id.trim().toUpperCase()
}
function hasSameNonIntegratedBaseCode(left: string, right: string) {
const normalizedLeft = stripNonIntegratedBrandPrefix(left)
const normalizedRight = stripNonIntegratedBrandPrefix(right)
return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight)
}
function hasIncomingFormalSpu(
productInput: Record<string, unknown>,
openingReference: Record<string, unknown>,
) {
const incomingSpu = readText(productInput.spu)
if (!incomingSpu) return false
if (productInput.formalSpuAssigned !== true) return false
return !hasSameNonIntegratedBaseCode(
incomingSpu,
readText(openingReference.temporaryCode),
)
}
function shouldPreserveFormalSkuCode(skuCode: string, formalSpu: string) {
const normalizedSkuCode = skuCode.trim().toUpperCase()
const normalizedFormalSpu = formalSpu.trim().toUpperCase()
@ -3991,12 +4011,21 @@ class IntakeNonIntegratedProductService {
NON_INTEGRATED_DEFAULT_BRAND
const assignedSpu = applyBrandPrefixToNonIntegratedCode(formalSpu, normalizedBrand)
const currentProductCode = readText(productInput.productCode)
const currentProductCodeMatchesTemporaryIdentity =
hasSameNonIntegratedBaseCode(currentProductCode, readText(productInput.spu)) ||
hasSameNonIntegratedBaseCode(
currentProductCode,
readText(openingReference.temporaryCode),
)
return {
...productInput,
id: preservedProductId || buildNonIntegratedProductId(assignedSpu),
spu: assignedSpu,
productCode: currentProductCode || assignedSpu,
productCode:
currentProductCode && !currentProductCodeMatchesTemporaryIdentity
? currentProductCode
: assignedSpu,
brand: normalizedBrand,
formalSpuAssigned: true,
skuItems: assignFormalSpuToSkuItems(productInput.skuItems, assignedSpu),
@ -4016,15 +4045,15 @@ class IntakeNonIntegratedProductService {
throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存')
}
if (readText(productInput.spu)) {
const openingReference = isRecord(productInput.openingReference)
? productInput.openingReference
: {}
if (hasIncomingFormalSpu(productInput, openingReference)) {
return this.save(productInput, user, {
workspaceCategory: options.workspaceCategory,
})
}
const openingReference = isRecord(productInput.openingReference)
? productInput.openingReference
: {}
const productCategory =
readText(options.formalSpuAssignmentProductCategory) ||
readText(productInput.productCategory) ||