fix: scope non-integrated product factory saves
This commit is contained in:
parent
1b4ea170aa
commit
f3e7d93aa3
@ -3446,6 +3446,204 @@ describe('intakeNonIntegratedProductService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes purchase owner product factory saves when a full payload carries other node edits', async () => {
|
||||
const existingConfirmations = {
|
||||
...createDefaultDocumentWorkspaceConfirmations(),
|
||||
基础信息: false,
|
||||
开品参考: false,
|
||||
产品定厂: false,
|
||||
}
|
||||
const existingSku = createSkuItem({
|
||||
id: 'sku-1',
|
||||
skuCode: 'DNZ040901',
|
||||
barcode: 'DNZ040901N1280V01',
|
||||
manufacturer: '供应商A',
|
||||
manufacturerCode: 'N1280',
|
||||
minimumOrderQuantity: '',
|
||||
specName: '款式一',
|
||||
})
|
||||
const existingFactoryRows = [
|
||||
{
|
||||
skuId: 'sku-1',
|
||||
confirmed: false,
|
||||
model: 'K01',
|
||||
skuCode: 'DNZ040901',
|
||||
setCount: '1',
|
||||
barcode: 'DNZ040901N1280V01',
|
||||
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',
|
||||
openingReference: {
|
||||
productName: '旧品名',
|
||||
productImage: 'https://s3.example.com/opening-old.jpg',
|
||||
structureDesigner: '设计A',
|
||||
},
|
||||
skuItems: [existingSku],
|
||||
factoryRows: existingFactoryRows,
|
||||
uploadFiles: [
|
||||
{
|
||||
id: 'opening-old',
|
||||
name: 'old.jpg',
|
||||
category: '开品参考',
|
||||
dataUrl: 'https://s3.example.com/opening-old.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
],
|
||||
documentWorkspaceConfirmations: existingConfirmations,
|
||||
})
|
||||
const incomingPayload = createPayload({
|
||||
...existingPayload,
|
||||
designer: '误带设计B',
|
||||
openingReference: {
|
||||
productName: '误带新品名',
|
||||
productImage: 'https://s3.example.com/opening-new.jpg',
|
||||
structureDesigner: '误带设计B',
|
||||
},
|
||||
skuItems: [
|
||||
{
|
||||
...existingSku,
|
||||
barcode: 'DNZ040901N1281V01',
|
||||
manufacturer: '供应商B',
|
||||
manufacturerCode: 'N1281',
|
||||
minimumOrderQuantity: '480',
|
||||
specName: '误带款式二',
|
||||
},
|
||||
],
|
||||
factoryRows: existingFactoryRows.map((row) => ({
|
||||
...row,
|
||||
barcode: 'DNZ040901N1281V01',
|
||||
supplierName: '供应商B',
|
||||
supplierCode: 'N1281',
|
||||
minimumOrderQuantity: '480',
|
||||
confirmed: true,
|
||||
})),
|
||||
uploadFiles: [
|
||||
...(existingPayload.uploadFiles as Array<Record<string, unknown>>),
|
||||
{
|
||||
id: 'opening-new',
|
||||
name: 'new.jpg',
|
||||
category: '开品参考',
|
||||
dataUrl: 'https://s3.example.com/opening-new.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
],
|
||||
documentWorkspaceConfirmations: {
|
||||
...existingConfirmations,
|
||||
基础信息: true,
|
||||
开品参考: true,
|
||||
产品定厂: true,
|
||||
},
|
||||
documentWorkspaceApprovedAt: {
|
||||
基础信息: '2026-05-21T01:00:00.000Z',
|
||||
开品参考: '2026-05-21T01:30:00.000Z',
|
||||
产品定厂: '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: ['PURCHASING'] as const,
|
||||
departmentPaths: [
|
||||
'江苏简尚品牌管理有限公司 > 供应链中心 > 基本盘 采购部 > 采购一部',
|
||||
],
|
||||
},
|
||||
{ workspaceCategory: '产品定厂' },
|
||||
),
|
||||
).resolves.toEqual(expect.objectContaining({ id: 'non-integrated-1' }))
|
||||
|
||||
const updatePayload = mockedRepository.update.mock.calls[0]?.[1]?.payload as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const updateConfirmations =
|
||||
updatePayload?.documentWorkspaceConfirmations as Record<string, boolean>
|
||||
const updateApprovedAt =
|
||||
updatePayload?.documentWorkspaceApprovedAt as Record<string, string>
|
||||
const updateOpeningReference =
|
||||
updatePayload?.openingReference as Record<string, unknown>
|
||||
const updateUploadFiles =
|
||||
updatePayload?.uploadFiles as Array<Record<string, unknown>>
|
||||
|
||||
expect(updatePayload).toEqual(
|
||||
expect.objectContaining({
|
||||
designer: '设计A',
|
||||
skuItems: [
|
||||
expect.objectContaining({
|
||||
barcode: 'DNZ040901N1281V01',
|
||||
manufacturer: '供应商B',
|
||||
manufacturerCode: 'N1281',
|
||||
minimumOrderQuantity: '480',
|
||||
specName: '款式一',
|
||||
}),
|
||||
],
|
||||
factoryRows: [
|
||||
expect.objectContaining({
|
||||
barcode: 'DNZ040901N1281V01',
|
||||
supplierName: '供应商B',
|
||||
supplierCode: 'N1281',
|
||||
minimumOrderQuantity: '480',
|
||||
confirmed: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(updateOpeningReference).toEqual(
|
||||
expect.objectContaining({
|
||||
productName: '旧品名',
|
||||
productImage: 'https://s3.example.com/opening-old.jpg',
|
||||
structureDesigner: '设计A',
|
||||
}),
|
||||
)
|
||||
expect(updateUploadFiles.map((file) => file.id)).toEqual(['opening-old'])
|
||||
expect(updateConfirmations.基础信息).toBe(false)
|
||||
expect(updateConfirmations.开品参考).toBe(false)
|
||||
expect(updateConfirmations.产品定厂).toBe(true)
|
||||
expect(updateApprovedAt?.基础信息).toBeUndefined()
|
||||
expect(updateApprovedAt?.开品参考).toBeUndefined()
|
||||
expect(updateApprovedAt?.产品定厂).toBe('2026-05-21T02:00:00.000Z')
|
||||
})
|
||||
|
||||
it('keeps downstream factory rows when a designer batch save carries derived factory changes', async () => {
|
||||
const existingFactoryRows = [
|
||||
{
|
||||
|
||||
@ -1729,6 +1729,36 @@ function getWritableChangedCategories(
|
||||
)
|
||||
}
|
||||
|
||||
function logNonIntegratedScopedWriteDenied({
|
||||
changedCategories,
|
||||
deniedChangedCategories,
|
||||
productFactoryTargetSkuIds,
|
||||
record,
|
||||
requestedCategory,
|
||||
user,
|
||||
writableCategories,
|
||||
}: {
|
||||
changedCategories: Iterable<string>
|
||||
deniedChangedCategories: Iterable<string>
|
||||
productFactoryTargetSkuIds: ReadonlySet<string>
|
||||
record: IntakeNonIntegratedRecord
|
||||
requestedCategory?: string | null
|
||||
user: AuthUser
|
||||
writableCategories: Iterable<string>
|
||||
}) {
|
||||
console.warn('[IntakeNonIntegratedProduct] scoped save denied', {
|
||||
changedCategories: Array.from(changedCategories),
|
||||
deniedChangedCategories: Array.from(deniedChangedCategories),
|
||||
productId: record.id,
|
||||
requestedCategory: requestedCategory ?? null,
|
||||
spu: record.spu,
|
||||
targetSkuCount: productFactoryTargetSkuIds.size,
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
writableCategories: Array.from(writableCategories),
|
||||
})
|
||||
}
|
||||
|
||||
function canTreatRequestedWorkspaceSaveAsNoop(
|
||||
requestedCategory: string | null | undefined,
|
||||
writableCategories: Set<NonIntegratedWorkspaceCategoryKey>,
|
||||
@ -1930,6 +1960,91 @@ async function prepareProductForPermittedWrite(
|
||||
? Array.from(productFactoryTargetSkuIds)
|
||||
: undefined
|
||||
const isStockProduct = isStockNonIntegratedProduct(existingProduct)
|
||||
|
||||
if (requestedCategory) {
|
||||
const canWriteRequestedCategory =
|
||||
!isStockProduct || requestedCategory === PLANNING_LAYOUT_WORKSPACE_CATEGORY
|
||||
? await intakeNonIntegratedScopeService.canWriteWorkspaceRecordWithSettings(
|
||||
record,
|
||||
user,
|
||||
requestedCategory,
|
||||
targetSkuItemIdsForCategory(requestedCategory),
|
||||
)
|
||||
: false
|
||||
|
||||
if (canWriteRequestedCategory) {
|
||||
const preserveOptions = {
|
||||
allowProductFactorySkuSync:
|
||||
!isStockProduct && requestedCategory === PRODUCT_FACTORY_WORKSPACE_CATEGORY,
|
||||
productFactoryTargetSkuIds,
|
||||
}
|
||||
const categoriesToPreserve = TRACKED_WORKSPACE_CATEGORIES.filter(
|
||||
(category) => category !== requestedCategory,
|
||||
)
|
||||
const scopedNextProduct = categoriesToPreserve.reduce(
|
||||
(currentProduct, category) =>
|
||||
preserveWorkspaceCategoryFromProduct(
|
||||
currentProduct,
|
||||
category,
|
||||
existingProduct,
|
||||
preserveOptions,
|
||||
),
|
||||
nextProductForWrite,
|
||||
)
|
||||
const scopedChangedCategories = getChangedWorkspaceCategories(
|
||||
existingProduct,
|
||||
scopedNextProduct,
|
||||
preserveOptions,
|
||||
)
|
||||
const deniedScopedCategories = Array.from(scopedChangedCategories).filter(
|
||||
(category) => category !== requestedCategory,
|
||||
)
|
||||
|
||||
if (deniedScopedCategories.length > 0) {
|
||||
logNonIntegratedScopedWriteDenied({
|
||||
changedCategories: scopedChangedCategories,
|
||||
deniedChangedCategories: deniedScopedCategories,
|
||||
productFactoryTargetSkuIds,
|
||||
record,
|
||||
requestedCategory,
|
||||
user,
|
||||
writableCategories: [requestedCategory],
|
||||
})
|
||||
throwNonIntegratedWriteDenied()
|
||||
}
|
||||
|
||||
if (scopedChangedCategories.size === 0) {
|
||||
return {
|
||||
product: existingProduct,
|
||||
preserveRecordColumns: true,
|
||||
skipUpdate: true,
|
||||
}
|
||||
}
|
||||
|
||||
await intakeNonIntegratedScopeService.assertCanWriteRecord(
|
||||
record,
|
||||
user,
|
||||
requestedCategory,
|
||||
targetSkuItemIdsForCategory(requestedCategory),
|
||||
)
|
||||
|
||||
return {
|
||||
product: categoriesToPreserve.reduce(
|
||||
(currentProduct, category) =>
|
||||
preserveWorkspaceCategoryFromPayload(
|
||||
currentProduct,
|
||||
category,
|
||||
record.payload,
|
||||
existingProduct,
|
||||
preserveOptions,
|
||||
),
|
||||
scopedNextProduct,
|
||||
),
|
||||
preserveRecordColumns: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changedCategories = getChangedWorkspaceCategories(existingProduct, nextProductForWrite)
|
||||
|
||||
if (changedCategories.size === 0) {
|
||||
@ -2046,6 +2161,15 @@ async function prepareProductForPermittedWrite(
|
||||
throwNonIntegratedWriteDenied()
|
||||
}
|
||||
|
||||
logNonIntegratedScopedWriteDenied({
|
||||
changedCategories,
|
||||
deniedChangedCategories,
|
||||
productFactoryTargetSkuIds,
|
||||
record,
|
||||
requestedCategory,
|
||||
user,
|
||||
writableCategories,
|
||||
})
|
||||
await intakeNonIntegratedScopeService.assertCanWriteRecord(
|
||||
record,
|
||||
user,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user