fix: prevent opening codes from becoming formal spu
This commit is contained in:
parent
2ccaab0e72
commit
5cb25981ef
@ -842,7 +842,8 @@ describe("NonIntegratedDetailClient", () => {
|
||||
|
||||
expect(mockedSaveSharedNonIntegratedProduct).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spu: "测试",
|
||||
spu: "",
|
||||
formalSpuAssigned: undefined,
|
||||
openingReference: expect.objectContaining({
|
||||
temporaryCode: "测试",
|
||||
}),
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 }
|
||||
: {}),
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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) ||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user