fix(intake): 按节点校验开品保存权限

This commit is contained in:
WuYongJie 2026-05-21 13:24:10 +08:00
parent 603b114345
commit 4f7c7cdf45
4 changed files with 578 additions and 3 deletions

View File

@ -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()

View File

@ -489,7 +489,7 @@ const NON_INTEGRATED_FLOW_COMPLETE_CATEGORY =
const NON_INTEGRATED_NODE_OWNER_LABELS: Partial<
Record<NonIntegratedDocumentStatusCategory, string>
> = {
开品参考: "商品企划",
开品参考: "外观设计师",
设计卖点: "外观设计师",
产品模型: "外观/结构",
评审资料: "设计/采购",

View File

@ -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()

View File

@ -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<ReturnType<IntakeNonIntegratedProductRepositoryClass['findActiveById']>>
>
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<NonIntegratedWorkspaceCategoryKey>,
category: string | undefined | null,
) {
const resolvedCategory = toDocumentWorkspaceCategory(category ?? undefined)
if (resolvedCategory) {
categories.add(resolvedCategory)
}
}
function addChangedWorkspaceCategory(
categories: Set<NonIntegratedWorkspaceCategoryKey>,
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<NonIntegratedWorkspaceCategoryKey>,
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<NonIntegratedWorkspaceCategoryKey>,
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<NonIntegratedWorkspaceCategoryKey>()
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,