From 16d7f42506ca0bb055bb61a90aa60c0cc6474fa4 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 5 Jun 2026 10:30:59 +0800 Subject: [PATCH 1/2] fix(intake): allow sku details after spu assignment --- ...ake-non-integrated-product.service.test.ts | 60 ++++++++++++++++++- .../intake-non-integrated-product.service.ts | 4 +- 2 files changed, 59 insertions(+), 5 deletions(-) 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 46505da2..b54b7bb7 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 @@ -922,12 +922,12 @@ describe('intakeNonIntegratedProductService', () => { ) }) - it('分配正式 SPU 前必须补齐 SKU 主数据', async () => { + it('分配正式 SPU 时允许 SKU 主数据在后续节点补齐', async () => { const payload = createPayload({ id: 'draft-incomplete-before-formal-spu', spu: '', productCategory: '餐桌', - productDevelopmentKind: '', + productDevelopmentKind: 'product', openingReference: { productCategory: '餐桌', }, @@ -949,6 +949,60 @@ describe('intakeNonIntegratedProductService', () => { }) mockedRepository.findDeletedBySpuInTransaction.mockResolvedValue(null as never) mockedRepository.findBySpuInTransaction.mockResolvedValue(null as never) + mockedRepository.createInTransaction.mockResolvedValue( + createRecord({ id: 'CZ0011-1770000000000' }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'CZ0011-1770000000000', + spu: 'CZ0011', + spuBase: 'CZ0011', + payload: createPayload({ id: 'CZ0011-1770000000000', spu: 'CZ0011' }), + }) as never, + ) + + await intakeNonIntegratedProductService.save(payload, baseUser, { + formalSpuAssignmentProductCategory: '餐桌', + }) + + const createArg = mockedRepository.createInTransaction.mock.calls[0]?.[1] as { + skuItems?: { createMany?: { data?: Array> } } + } + expect(createArg.skuItems?.createMany?.data?.[0]).toEqual( + expect.objectContaining({ + skuCode: 'CZ001101', + barcode: 'CZ001101N01V01', + model: '', + specName: '', + }), + ) + }) + + it('分配正式 SPU 前仍必须补齐产品主数据', async () => { + const payload = createPayload({ + id: 'draft-incomplete-product-before-formal-spu', + spu: '', + productCategory: '', + productDevelopmentKind: 'product', + openingReference: { + productCategory: '', + }, + skuItems: [ + createSkuItem({ + skuSequence: 1, + skuCode: '', + barcode: '', + }), + ], + }) + mockedSpuNumberReservationService.reserveInTransaction.mockResolvedValue({ + productCategory: '餐桌', + prefix: 'CZ', + index: 12, + spu: 'CZ0012', + }) + mockedRepository.findDeletedBySpuInTransaction.mockResolvedValue(null as never) + mockedRepository.findBySpuInTransaction.mockResolvedValue(null as never) const promise = intakeNonIntegratedProductService.save(payload, baseUser, { formalSpuAssignmentProductCategory: '餐桌', @@ -957,7 +1011,7 @@ describe('intakeNonIntegratedProductService', () => { await expect(promise).rejects.toMatchObject({ code: ErrorCode.VALIDATION_REQUIRED_FIELD, }) - await expect(promise).rejects.toThrow('正式 SPU 分配存在未补齐的 SKU 主数据') + await expect(promise).rejects.toThrow('正式 SPU 分配存在未补齐的产品主数据') expect(mockedRepository.createInTransaction).not.toHaveBeenCalled() expect(mockedRepository.updateInTransaction).not.toHaveBeenCalled() 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 ee2079da..20f2d00a 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 @@ -40,7 +40,7 @@ import { preSkuRepository, } from '../repositories/draft-group.repository' import { - assertNonIntegratedProductMasterDataReadyForFormalUse, + assertNonIntegratedProductProfileReadyForFormalUse, resolveNonIntegratedSkuQuality, } from './non-integrated-sku-quality' import { @@ -4129,7 +4129,7 @@ class IntakeNonIntegratedProductService { if (!normalizedProduct) { throw new AppError(ErrorCode.VALIDATION_FAILED, '非集成产品数据不完整,无法保存') } - assertNonIntegratedProductMasterDataReadyForFormalUse(normalizedProduct, { + assertNonIntegratedProductProfileReadyForFormalUse(normalizedProduct, { usageLabel: '正式 SPU 分配', }) From 4737184f59b467f84ad8dc4abef01a4fa82f1b2b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 5 Jun 2026 11:59:09 +0800 Subject: [PATCH 2/2] fix(intake): scope derived downstream saves --- ...ake-non-integrated-product.service.test.ts | 142 ++++++++++++++++++ .../intake-non-integrated-product.service.ts | 35 ++++- 2 files changed, 172 insertions(+), 5 deletions(-) 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 b54b7bb7..d18f0bed 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 @@ -1926,6 +1926,148 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.update).toHaveBeenCalled() }) + it('keeps downstream factory rows when a designer batch save carries derived factory changes', async () => { + const existingFactoryRows = [ + { + skuId: 'sku-1', + confirmed: false, + model: 'K01', + skuCode: 'DNZ000101', + setCount: '1', + barcode: 'DNZ000101N1280V01', + specName: '款式一', + size: '', + color: '', + material: '', + installMethod: '', + supplierName: '供应商A', + supplierCode: 'N1280', + warehouse: '', + minimumOrderQuantity: '', + leadTime: '', + assemblyMode: '', + productionType: '', + installationService: '', + supplierCategory: '', + purchaseOrderMethod: '', + executeStandard: '', + }, + ] + const existingPayload = createPayload({ + designer: 'Creator', + openingReference: { + designer: 'Creator', + structureDesigner: 'Creator', + }, + skuItems: [createSkuItem({ id: 'sku-1', skuCode: 'DNZ000101' })], + skuBatchCount: 1, + skuBatchFlowNos: {}, + skuBatchChangeTypes: {}, + factoryRows: existingFactoryRows, + }) + const incomingPayload = createPayload({ + ...existingPayload, + skuBatchCount: 2, + skuBatchFlowNos: { '2': 'FLOW-002' }, + skuBatchChangeTypes: { '2': '新增' }, + factoryRows: existingFactoryRows.map((row) => ({ + ...row, + supplierName: '供应商B', + })), + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ payload: existingPayload }) as never, + ) + mockedRepository.update.mockResolvedValue( + createRecord({ payload: incomingPayload }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ payload: incomingPayload }) as never, + ) + + await intakeNonIntegratedProductService.save( + incomingPayload, + baseUser, + { workspaceCategory: '产品定厂' }, + ) + + const updatePayload = mockedRepository.update.mock.calls[0]?.[1]?.payload as + | Record + | undefined + expect(updatePayload).toEqual( + expect.objectContaining({ + skuBatchCount: 2, + skuBatchFlowNos: { '2': 'FLOW-002' }, + skuBatchChangeTypes: { '2': '新增' }, + factoryRows: existingFactoryRows, + }), + ) + }) + + it('treats writable workspace autosaves with only downstream derived changes as no-ops', async () => { + const existingFactoryRows = [ + { + skuId: 'sku-1', + confirmed: false, + model: 'K01', + skuCode: 'DNZ000101', + setCount: '1', + barcode: 'DNZ000101N1280V01', + specName: '款式一', + size: '', + color: '', + material: '', + installMethod: '', + supplierName: '供应商A', + supplierCode: 'N1280', + warehouse: '', + minimumOrderQuantity: '', + leadTime: '', + assemblyMode: '', + productionType: '', + installationService: '', + supplierCategory: '', + purchaseOrderMethod: '', + executeStandard: '', + }, + ] + const existingPayload = createPayload({ + designer: 'Creator', + openingReference: { + designer: 'Creator', + structureDesigner: 'Creator', + }, + factoryRows: existingFactoryRows, + }) + const incomingPayload = createPayload({ + ...existingPayload, + factoryRows: existingFactoryRows.map((row) => ({ + ...row, + supplierName: '供应商B', + })), + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ payload: existingPayload }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ payload: existingPayload }) as never, + ) + + await expect( + intakeNonIntegratedProductService.save( + incomingPayload, + baseUser, + { workspaceCategory: '基础信息' }, + ), + ).resolves.toEqual(expect.objectContaining({ id: 'non-integrated-1' })) + + expect(mockedRepository.update).not.toHaveBeenCalled() + }) + it('allows space visual department users to save planning layout through the shared save API', async () => { const skuItems = [ { 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 20f2d00a..038fb6ac 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 @@ -1455,6 +1455,25 @@ function getRequestedWritableCategories( return category ? new Set([category]) : fallbackCategories } +function getWritableChangedCategories( + changedCategories: Set, + writableCategories: Set, +) { + return new Set( + Array.from(changedCategories).filter((category) => + writableCategories.has(category), + ), + ) +} + +function canTreatRequestedWorkspaceSaveAsNoop( + requestedCategory: string | null | undefined, + writableCategories: Set, +) { + const category = toDocumentWorkspaceCategory(requestedCategory ?? undefined) + return Boolean(category && writableCategories.has(category)) +} + function isExplicitPlanningLayoutWriteRequest( requestedCategory: string | null | undefined, ) { @@ -1618,6 +1637,10 @@ async function prepareProductForPermittedWrite( deniedChangedCategories.push(category) } + const effectivePermittedCategories = + permittedCategories.size > 0 + ? permittedCategories + : getWritableChangedCategories(changedCategories, writableCategories) const preserveOptions = { allowProductFactorySkuSync: @@ -1629,7 +1652,7 @@ async function prepareProductForPermittedWrite( existingProduct, nextProduct, changedCategories, - permittedCategories, + effectivePermittedCategories, preserveOptions, ) ) { @@ -1638,6 +1661,11 @@ async function prepareProductForPermittedWrite( options.workspaceCategory, writableCategories, ) || + (effectivePermittedCategories.size === 0 && + canTreatRequestedWorkspaceSaveAsNoop( + options.workspaceCategory, + writableCategories, + )) || canTreatStockReadonlyCompatibilitySaveAsNoop( isStockProduct, changedCategories, @@ -1664,10 +1692,7 @@ async function prepareProductForPermittedWrite( } const categoriesToPreserve = TRACKED_WORKSPACE_CATEGORIES.filter( - (category) => - !writableCategories.has(category) || - !requestedWritableCategories.has(category) || - !changedCategories.has(category), + (category) => !effectivePermittedCategories.has(category), ) const scopedNextProduct = categoriesToPreserve.reduce( (currentProduct, category) =>