diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index bddae3be..2f862f25 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -842,7 +842,8 @@ describe("NonIntegratedDetailClient", () => { expect(mockedSaveSharedNonIntegratedProduct).toHaveBeenCalledWith( expect.objectContaining({ - spu: "测试", + spu: "", + formalSpuAssigned: undefined, openingReference: expect.objectContaining({ temporaryCode: "测试", }), diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index 95e63784..d1ece4fb 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -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)) diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts index b1d6bcca..893db72e 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedApi.ts @@ -1,4 +1,7 @@ -import type { NonIntegratedProductRecord } from './nonIntegratedStorage' +import { + prepareNonIntegratedProductForFormalSpuAssignment, + type NonIntegratedProductRecord, +} from './nonIntegratedStorage' type ApiEnvelope = { 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 } : {}), diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts index 4ec9c1ec..6a704592 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.test.ts @@ -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') + }) }) diff --git a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts index 8bb0b09d..f76ba644 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts +++ b/furniture-3d-system/src/app/intake/non-integrated/nonIntegratedStorage.ts @@ -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, +) { + 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" diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts index afb70ec1..ee7792ac 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts @@ -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> } } + } + 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', diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index 6d0b39dc..902113de 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -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, + openingReference: Record, +) { + 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) ||