From 4f7c7cdf456389b90bb9701147f788cdef0f011d Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 21 May 2026 13:24:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(intake):=20=E6=8C=89=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=BC=80=E5=93=81=E4=BF=9D=E5=AD=98=E6=9D=83?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/intake/IntakeClient.test.tsx | 3 + .../src/app/intake/IntakeClient.tsx | 2 +- ...ake-non-integrated-product.service.test.ts | 154 ++++++- .../intake-non-integrated-product.service.ts | 422 +++++++++++++++++- 4 files changed, 578 insertions(+), 3 deletions(-) diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index 214383d2..5a28981f 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -2510,6 +2510,9 @@ describe('IntakeClient', () => { expect(pendingRow).not.toBeNull() expect(within(checkedRow as HTMLElement).getByText('设计卖点')).toBeInTheDocument() expect(within(pendingRow as HTMLElement).getByText('开品参考')).toBeInTheDocument() + expect( + screen.getByRole('columnheader', { name: '开品参考 外观设计师' }), + ).toBeInTheDocument() expect( screen.getByRole('columnheader', { name: '产品实拍 结构设计师' }), ).toBeInTheDocument() diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 0dfed256..db4deca5 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -489,7 +489,7 @@ const NON_INTEGRATED_FLOW_COMPLETE_CATEGORY = const NON_INTEGRATED_NODE_OWNER_LABELS: Partial< Record > = { - 开品参考: "商品企划", + 开品参考: "外观设计师", 设计卖点: "外观设计师", 产品模型: "外观/结构", 评审资料: "设计/采购", 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 a51f288c..c422c5be 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 @@ -261,6 +261,9 @@ describe('intakeNonIntegratedProductService', () => { mockedRepository.findById.mockResolvedValue( createRecord({ createdBy: 'creator-2', + payload: createPayload({ + designer: 'Teammate', + }), }) as never, ) mockedRepository.update.mockResolvedValue(createRecord() as never) @@ -286,6 +289,150 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.update).toHaveBeenCalled() }) + it('rejects purchasers from confirming product photo through the shared save API', async () => { + const existingConfirmations = { + ...createDefaultDocumentWorkspaceConfirmations(), + 产品实拍图: false, + } + const existingPayload = createPayload({ + designer: '设计A', + purchaser: '采购A', + purchaseOwner: '采购A', + uploadFiles: [ + { + id: 'photo-1', + name: '正视图.png', + category: '产品实拍图', + dataUrl: 'https://s3.example.com/photo-1.png', + mimeType: 'image/png', + }, + ], + documentWorkspaceConfirmations: existingConfirmations, + }) + const incomingPayload = createPayload({ + ...existingPayload, + documentWorkspaceConfirmations: { + ...existingConfirmations, + 产品实拍图: true, + }, + documentWorkspaceApprovedAt: { + 产品实拍图: '2026-05-21T01:00:00.000Z', + }, + }) + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ + payload: existingPayload, + }) as never, + ) + + await expect( + intakeNonIntegratedProductService.save(incomingPayload, { + ...baseUser, + id: 'purchase-1', + email: 'purchase@example.com', + name: '采购A', + businessRoles: [] as const, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部 > 采购一部', + ], + }), + ).rejects.toThrow('没有权限编辑这条非集成产品记录') + + expect(mockedRepository.update).not.toHaveBeenCalled() + }) + + it('allows purchase owners to confirm product factory through the shared save API', async () => { + const existingConfirmations = { + ...createDefaultDocumentWorkspaceConfirmations(), + 产品定厂: false, + } + 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: '设计A', + purchaser: '采购A', + purchaseOwner: '采购A', + factoryRows: existingFactoryRows, + documentWorkspaceConfirmations: existingConfirmations, + }) + const incomingPayload = createPayload({ + ...existingPayload, + factoryRows: existingFactoryRows.map((row) => ({ + ...row, + confirmed: true, + })), + documentWorkspaceConfirmations: { + ...existingConfirmations, + 产品定厂: true, + }, + documentWorkspaceApprovedAt: { + 产品定厂: '2026-05-21T02:00:00.000Z', + }, + }) + + 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 expect( + intakeNonIntegratedProductService.save(incomingPayload, { + ...baseUser, + id: 'purchase-1', + email: 'purchase@example.com', + name: '采购A', + businessRoles: [] as const, + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部 > 采购一部', + ], + }), + ).resolves.toMatchObject({ + documentWorkspaceConfirmations: expect.objectContaining({ + 产品定厂: true, + }), + }) + + expect(mockedRepository.update).toHaveBeenCalled() + }) + it('keeps existing BOM details when an autosave submits an empty BOM payload', async () => { const existingBomDetails = [ { @@ -399,7 +546,11 @@ describe('intakeNonIntegratedProductService', () => { }) mockedRepository.findBySpu.mockResolvedValue(null as never) - mockedRepository.findById.mockResolvedValue(createRecord() as never) + mockedRepository.findById.mockResolvedValue( + createRecord({ + payload, + }) as never, + ) mockedRepository.update.mockResolvedValue(createRecord() as never) mockedRepository.findActiveById.mockResolvedValue( createRecord({ @@ -433,6 +584,7 @@ describe('intakeNonIntegratedProductService', () => { id: 'viewer-1', email: 'viewer@example.com', name: 'Viewer', + role: 'ADMIN', }), ).resolves.toBeUndefined() 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 b871cbbb..731cfe31 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 @@ -1,7 +1,9 @@ import { randomUUID } from 'crypto' import { Prisma } from '@prisma/client' import { + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, + NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES, createDefaultDocumentWorkspaceConfirmations as createSharedDocumentWorkspaceConfirmations, normalizeNonIntegratedProductRecord, resolveNonIntegratedPersistedSpu, @@ -90,6 +92,8 @@ type IntakeNonIntegratedRecord = NonNullable< Awaited> > +type NonIntegratedWorkspaceCategoryKey = keyof NonIntegratedDocumentWorkspaceConfirmationMap + export type IntakeNonIntegratedProductListParams = { page?: number pageSize?: number @@ -114,6 +118,76 @@ export type IntakeNonIntegratedProductListParams = { const DEFAULT_LIST_PAGE_SIZE = 50 const MAX_LIST_PAGE_SIZE = 100 +const OPENING_REFERENCE_WORKSPACE_CATEGORY = '开品参考' +const PROJECT_REVIEW_WORKSPACE_CATEGORY = '立项评审' +const PRODUCT_DATA_WORKSPACE_CATEGORY = '产品模型' +const REVIEW_WORKSPACE_CATEGORY = '评审资料' +const PATENT_APPLICATION_WORKSPACE_CATEGORY = '专利申请' +const INSTALLATION_WORKSPACE_CATEGORY = '安装方式' +const PACKAGING_WORKSPACE_CATEGORY = '包装细节' +const PRODUCT_FACTORY_WORKSPACE_CATEGORY = '产品定厂' +const SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY = '样包测量' +const FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY = '成品寄样' +const PRODUCT_PRICING_WORKSPACE_CATEGORY = '产品核价' +const PRODUCT_LIMIT_WORKSPACE_CATEGORY = '产品限价' +const PLANNING_LAYOUT_WORKSPACE_CATEGORY = '企划布局' +const FACTORY_QUALITY_WORKSPACE_CATEGORY = '工厂质检' + +const BASIC_INFO_FIELD_KEYS = [ + 'productDevelopmentKind', + 'spu', + 'year', + 'demandFlowNo', + 'processTemplate', + 'productCategory', + 'salesCategory', + 'productPlanning', + 'demandDepartment', + 'isSelfDevelopedPatentProduct', + 'patentOwner', + 'spuOwner', + 'needsClearanceHandling', + 'operationsOwner', + 'profitCenter', + 'salesChannel', + 'salesPlatform', + 'purchaseOwner', + 'goodsType', + 'materialType', + 'productionType', + 'manualProducer', + 'shippingMode', + 'visualDirector', + 'itemId', + 'priceBand', + 'outputRequirementType', + 'visualPlanning', + 'visualPlanningType', + 'photographer', + 'graphicDesigner', + 'designer3d', + 'sampleRequired', + 'flatOutputPath', + 'output3dPath', + 'brand', + 'adaptableBrands', + 'designer', + 'operator', + 'purchaser', + 'previewImage', + 'previewImageName', + 'productCode', + 'size', + 'color', + 'tagMaterial', + 'formalSpuAssigned', + 'skuItems', + 'skuBatchCount', + 'skuBatchFlowNos', + 'skuBatchChangeTypes', + 'skuBatchConfirmedAt', +] as const + type ResolveUploadFileForPreviewResult = { record: IntakeNonIntegratedRecord product: SharedNonIntegratedProductRecord @@ -196,6 +270,352 @@ function normalizeDocumentWorkspaceNotes(value: unknown) { return Object.keys(nextNotes).length > 0 ? nextNotes : undefined } +function normalizeComparableValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeComparableValue) + } + + if (!isRecord(value)) { + return value ?? null + } + + return Object.fromEntries( + Object.keys(value) + .filter((key) => value[key] !== undefined) + .sort() + .map((key) => [key, normalizeComparableValue(value[key])]), + ) +} + +function stableStringify(value: unknown) { + return JSON.stringify(normalizeComparableValue(value)) +} + +function comparableEquals(left: unknown, right: unknown) { + return stableStringify(left) === stableStringify(right) +} + +function pickProductFields( + product: SharedNonIntegratedProductRecord, + keys: readonly string[], +) { + return Object.fromEntries(keys.map((key) => [key, product[key]])) +} + +function styleDetailsByStyleKey(product: SharedNonIntegratedProductRecord) { + return Object.fromEntries( + (product.styleDetails ?? []).map((detail) => [detail.styleKey, detail]), + ) +} + +function buildInstallationStyleSignature(product: SharedNonIntegratedProductRecord) { + return Object.fromEntries( + Object.entries(styleDetailsByStyleKey(product)).map(([styleKey, detail]) => [ + styleKey, + { + installMethod: detail.installMethod, + installSteps: detail.installSteps, + }, + ]), + ) +} + +function buildPackagingStyleSignature(product: SharedNonIntegratedProductRecord) { + return Object.fromEntries( + Object.entries(styleDetailsByStyleKey(product)).map(([styleKey, detail]) => [ + styleKey, + { + packageDetail: detail.packageDetail, + craftDetail: detail.craftDetail, + outerPackagingType: detail.outerPackagingType, + innerPackagingItems: detail.innerPackagingItems, + packagingModel3dFileName: detail.packagingModel3dFileName, + packagingModel3dDataUrl: detail.packagingModel3dDataUrl, + packagingModel3dNodeOffsets: detail.packagingModel3dNodeOffsets, + packagingModel3dNodeRotations: detail.packagingModel3dNodeRotations, + }, + ]), + ) +} + +function buildProductDataStyleSignature(product: SharedNonIntegratedProductRecord) { + return Object.fromEntries( + Object.entries(styleDetailsByStyleKey(product)).map(([styleKey, detail]) => [ + styleKey, + { + model3dFileName: detail.model3dFileName, + model3dDataUrl: detail.model3dDataUrl, + model3dNodeOffsets: detail.model3dNodeOffsets, + model3dNodeRotations: detail.model3dNodeRotations, + }, + ]), + ) +} + +function addWorkspaceCategory( + categories: Set, + category: string | undefined | null, +) { + const resolvedCategory = toDocumentWorkspaceCategory(category ?? undefined) + if (resolvedCategory) { + categories.add(resolvedCategory) + } +} + +function addChangedWorkspaceCategory( + categories: Set, + category: string, + existingValue: unknown, + nextValue: unknown, +) { + if (!comparableEquals(existingValue, nextValue)) { + addWorkspaceCategory(categories, category) + } +} + +function getUploadFileCategories(product: SharedNonIntegratedProductRecord) { + return new Set( + product.uploadFiles + .map((file) => toDocumentWorkspaceCategory(file.category)) + .filter((category): category is NonIntegratedWorkspaceCategoryKey => Boolean(category)), + ) +} + +function uploadFilesForCategory( + product: SharedNonIntegratedProductRecord, + category: NonIntegratedWorkspaceCategoryKey, +) { + return product.uploadFiles.filter( + (file) => toDocumentWorkspaceCategory(file.category) === category, + ) +} + +function uncategorizedUploadFiles(product: SharedNonIntegratedProductRecord) { + return product.uploadFiles.filter( + (file) => !toDocumentWorkspaceCategory(file.category), + ) +} + +function addChangedUploadWorkspaceCategories( + categories: Set, + existingProduct: SharedNonIntegratedProductRecord, + nextProduct: SharedNonIntegratedProductRecord, +) { + const uploadCategories = new Set([ + ...getUploadFileCategories(existingProduct), + ...getUploadFileCategories(nextProduct), + ]) + + uploadCategories.forEach((category) => { + if ( + !comparableEquals( + uploadFilesForCategory(existingProduct, category), + uploadFilesForCategory(nextProduct, category), + ) + ) { + categories.add(category) + } + }) + + if ( + !comparableEquals( + uncategorizedUploadFiles(existingProduct), + uncategorizedUploadFiles(nextProduct), + ) + ) { + categories.add(PRODUCT_DATA_WORKSPACE_CATEGORY) + } +} + +function addChangedDocumentWorkspaceMapCategories( + categories: Set, + existingProduct: SharedNonIntegratedProductRecord, + nextProduct: SharedNonIntegratedProductRecord, +) { + NON_INTEGRATED_WORKFLOW_NODE_CATEGORIES.forEach((category) => { + const existingConfirmed = existingProduct.documentWorkspaceConfirmations[category] + const nextConfirmed = nextProduct.documentWorkspaceConfirmations[category] + const existingApprovedAt = existingProduct.documentWorkspaceApprovedAt?.[category] ?? '' + const nextApprovedAt = nextProduct.documentWorkspaceApprovedAt?.[category] ?? '' + + if (existingConfirmed !== nextConfirmed || existingApprovedAt !== nextApprovedAt) { + addWorkspaceCategory(categories, category) + } + }) + + const noteCategories = new Set([ + ...Object.keys(existingProduct.documentWorkspaceNotes ?? {}), + ...Object.keys(nextProduct.documentWorkspaceNotes ?? {}), + ]) + + noteCategories.forEach((category) => { + const resolvedCategory = toDocumentWorkspaceCategory(category) + if ( + resolvedCategory && + (existingProduct.documentWorkspaceNotes?.[category] ?? '') !== + (nextProduct.documentWorkspaceNotes?.[category] ?? '') + ) { + categories.add(resolvedCategory) + } + }) +} + +function getChangedWorkspaceCategories( + existingProduct: SharedNonIntegratedProductRecord, + nextProduct: SharedNonIntegratedProductRecord, +) { + const categories = new Set() + + addChangedDocumentWorkspaceMapCategories(categories, existingProduct, nextProduct) + addChangedUploadWorkspaceCategories(categories, existingProduct, nextProduct) + + addChangedWorkspaceCategory( + categories, + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + pickProductFields(existingProduct, BASIC_INFO_FIELD_KEYS), + pickProductFields(nextProduct, BASIC_INFO_FIELD_KEYS), + ) + addChangedWorkspaceCategory( + categories, + OPENING_REFERENCE_WORKSPACE_CATEGORY, + existingProduct.openingReference, + nextProduct.openingReference, + ) + addChangedWorkspaceCategory( + categories, + PROJECT_REVIEW_WORKSPACE_CATEGORY, + { + projectReview: existingProduct.projectReview, + projectStatus: existingProduct.projectStatus, + }, + { + projectReview: nextProduct.projectReview, + projectStatus: nextProduct.projectStatus, + }, + ) + addChangedWorkspaceCategory( + categories, + REVIEW_WORKSPACE_CATEGORY, + { + reviewSheet: existingProduct.reviewSheet, + reviewSheets: existingProduct.reviewSheets, + }, + { + reviewSheet: nextProduct.reviewSheet, + reviewSheets: nextProduct.reviewSheets, + }, + ) + addChangedWorkspaceCategory( + categories, + PATENT_APPLICATION_WORKSPACE_CATEGORY, + existingProduct.patentApplication, + nextProduct.patentApplication, + ) + addChangedWorkspaceCategory( + categories, + PRODUCT_DATA_WORKSPACE_CATEGORY, + buildProductDataStyleSignature(existingProduct), + buildProductDataStyleSignature(nextProduct), + ) + addChangedWorkspaceCategory( + categories, + INSTALLATION_WORKSPACE_CATEGORY, + buildInstallationStyleSignature(existingProduct), + buildInstallationStyleSignature(nextProduct), + ) + addChangedWorkspaceCategory( + categories, + PACKAGING_WORKSPACE_CATEGORY, + buildPackagingStyleSignature(existingProduct), + buildPackagingStyleSignature(nextProduct), + ) + addChangedWorkspaceCategory( + categories, + PRODUCT_FACTORY_WORKSPACE_CATEGORY, + existingProduct.factoryRows, + nextProduct.factoryRows, + ) + addChangedWorkspaceCategory( + categories, + SAMPLE_MEASUREMENT_WORKSPACE_CATEGORY, + existingProduct.sampleMeasurementRows, + nextProduct.sampleMeasurementRows, + ) + addChangedWorkspaceCategory( + categories, + FINISHED_SAMPLE_SHIPMENT_WORKSPACE_CATEGORY, + existingProduct.finishedSampleShipmentRows, + nextProduct.finishedSampleShipmentRows, + ) + addChangedWorkspaceCategory( + categories, + PRODUCT_PRICING_WORKSPACE_CATEGORY, + { + pricingRows: existingProduct.pricingRows, + bomDetails: existingProduct.bomDetails, + }, + { + pricingRows: nextProduct.pricingRows, + bomDetails: nextProduct.bomDetails, + }, + ) + addChangedWorkspaceCategory( + categories, + PRODUCT_LIMIT_WORKSPACE_CATEGORY, + existingProduct.productLimitRows, + nextProduct.productLimitRows, + ) + addChangedWorkspaceCategory( + categories, + PLANNING_LAYOUT_WORKSPACE_CATEGORY, + existingProduct.planningLayoutRows, + nextProduct.planningLayoutRows, + ) + addChangedWorkspaceCategory( + categories, + FACTORY_QUALITY_WORKSPACE_CATEGORY, + { + qualityInspectionSheet: existingProduct.qualityInspectionSheet, + firstQualityRows: existingProduct.firstQualityRows, + }, + { + qualityInspectionSheet: nextProduct.qualityInspectionSheet, + firstQualityRows: nextProduct.firstQualityRows, + }, + ) + + return categories +} + +function assertCanWriteProductChanges( + record: IntakeNonIntegratedRecord, + user: AuthUser, + nextProduct: SharedNonIntegratedProductRecord, +) { + const existingProduct = buildHydratedProductRecord(record.payload, { + createdAt: record.createdAt, + updatedAt: record.updatedAt, + createdBy: record.createdBy, + updatedBy: record.updatedBy, + }) + + if (!existingProduct) { + intakeNonIntegratedScopeService.assertCanWriteRecord(record, user) + return + } + + const changedCategories = getChangedWorkspaceCategories(existingProduct, nextProduct) + + if (changedCategories.size === 0) { + intakeNonIntegratedScopeService.assertCanWriteRecord(record, user) + return + } + + changedCategories.forEach((category) => { + intakeNonIntegratedScopeService.assertCanWriteRecord(record, user, category) + }) +} + function normalizePresentationPreview( value: unknown, ): NonIntegratedUploadPresentationPreview | undefined { @@ -1069,7 +1489,7 @@ class IntakeNonIntegratedProductService { try { if (existingRecord) { - intakeNonIntegratedScopeService.assertCanWriteRecord(existingRecord, user) + assertCanWriteProductChanges(existingRecord, user, persistedProduct) await this.repository.update(existingRecord.id, { spu: normalizedSpu,