diff --git a/furniture-3d-system/prisma/migrations/202606090001_add_external_intake_business_role/migration.sql b/furniture-3d-system/prisma/migrations/202606090001_add_external_intake_business_role/migration.sql new file mode 100644 index 00000000..099bfd0b --- /dev/null +++ b/furniture-3d-system/prisma/migrations/202606090001_add_external_intake_business_role/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "BusinessAccountType" ADD VALUE IF NOT EXISTS 'EXTERNAL_INTAKE'; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 817dc087..9d446eb7 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -1451,6 +1451,7 @@ enum BusinessAccountType { PURCHASING WAREHOUSE SUPPLIER + EXTERNAL_INTAKE } enum ApprovalWorkflowStatus { diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/jushuitan-product-pricing-push/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/jushuitan-product-pricing-push/route.ts index 9e34e106..593e0016 100644 --- a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/jushuitan-product-pricing-push/route.ts +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/jushuitan-product-pricing-push/route.ts @@ -51,6 +51,11 @@ export async function POST(request: NextRequest, context: RouteContext) { ) } + await intakeNonIntegratedProductService.assertCanPushProductPricing( + productId, + ctx.user, + ) + const result = await jushuitanProductPricingPushService.pushProductPricing({ product, diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index 171c616d..9202425a 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -2019,6 +2019,32 @@ describe('IntakeClient', () => { expect(screen.getByRole('button', { name: '新增产品' })).toBeInTheDocument() }) + it('allows external intake users to create normal product development records only', async () => { + const actor = userEvent.setup() + + renderSelector({ + eligibleSkuList: [], + userRole: 'USER', + userBusinessRoles: ['EXTERNAL_INTAKE'], + }) + + const rightPanel = screen.getByTestId('right-panel') + expect(within(rightPanel).getByRole('button', { name: '产品开发明细' })).toBeInTheDocument() + expect( + within(rightPanel).queryByRole('button', { name: '模块化产品明细' }), + ).not.toBeInTheDocument() + + await actor.click(screen.getByRole('button', { name: '新增产品' })) + + expect(screen.getByRole('tab', { name: '新增产品' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect(screen.getByTestId('embedded-development-kind')).toHaveTextContent( + 'product', + ) + }) + it('opens the series development list when requested from draft groups', () => { renderSelector({ eligibleSkuList: [], diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 1cf935ca..5fb50718 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -94,6 +94,7 @@ import { } from "./non-integrated/nonIntegratedApi"; import { canEditAnyNonIntegratedWorkspace, + canCreateNonIntegratedProductFromIntake, canEditNonIntegratedProductIdentity, canDeleteNonIntegratedProductByIntakePermission, isIntakeNonIntegratedDesignDepartmentUser, @@ -10410,6 +10411,8 @@ export default function IntakeClient(props: IntakeClientProps) { ); const canUseDesignControlledProductData = canAccessDesignControlledProductData(props.user); + const canCreateNonIntegratedProducts = + canCreateNonIntegratedProductFromIntake(props.user); const shouldPersistWorkspaceState = !isEditorMode(props); const shouldUseSharedNonIntegratedApi = process.env.NODE_ENV !== "test"; const [nodePermissionOverrides, setNodePermissionOverrides] = @@ -11116,7 +11119,12 @@ export default function IntakeClient(props: IntakeClientProps) { productDevelopmentKind: ProductDevelopmentKind = "product", initialCreateProductKind: NewNonIntegratedCreateKind = "new", ) { - if (!canUseDesignControlledProductData) { + const canCreateTarget = + productDevelopmentKind === "product" && + initialCreateProductKind === "new" + ? canCreateNonIntegratedProducts + : canUseDesignControlledProductData; + if (!canCreateTarget) { return; } @@ -11580,7 +11588,7 @@ export default function IntakeClient(props: IntakeClientProps) { useEffect(() => { if ( - !canUseDesignControlledProductData || + !canCreateNonIntegratedProducts || !props.initialOpenNewNonIntegratedProduct || initialOpenNewNonIntegratedProductRef.current ) { @@ -11590,7 +11598,7 @@ export default function IntakeClient(props: IntakeClientProps) { initialOpenNewNonIntegratedProductRef.current = true; openNewNonIntegratedProductWorkspace(); }, [ - canUseDesignControlledProductData, + canCreateNonIntegratedProducts, openNewNonIntegratedProductWorkspace, props.initialOpenNewNonIntegratedProduct, ]); @@ -11662,12 +11670,12 @@ export default function IntakeClient(props: IntakeClientProps) { } onOpenProductWorkspace={openProductWorkspace} emptyMessage={ - canUseDesignControlledProductData + canCreateNonIntegratedProducts ? undefined : "当前还没有产品开发明细。" } onOpenNewNonIntegratedProductWorkspace={ - canUseDesignControlledProductData + canCreateNonIntegratedProducts ? () => openNewNonIntegratedProductWorkspace("product") : undefined } @@ -11850,12 +11858,12 @@ export default function IntakeClient(props: IntakeClientProps) { } onOpenProductWorkspace={openProductWorkspace} emptyMessage={ - canUseDesignControlledProductData + canCreateNonIntegratedProducts ? undefined : "当前还没有产品开发明细。" } onOpenNewNonIntegratedProductWorkspace={ - canUseDesignControlledProductData + canCreateNonIntegratedProducts ? () => openNewNonIntegratedProductWorkspace("product") : undefined } 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 2d741b95..0a2e7a9c 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 @@ -7411,6 +7411,52 @@ describe("NonIntegratedDetailClient", () => { expect(costPriceInput).toHaveValue(""); }); + it("only shows external intake users the data and system-entry workspaces", () => { + saveNonIntegratedProduct( + createProductRecord({ + id: "DNZ0001-external-visible-workspaces", + createdBy: "external-user-id", + }), + ); + + render( + , + ); + + expect(screen.getByTestId("document-workspace-trigger-开品参考")).toBeInTheDocument(); + expect(screen.getByTestId("document-workspace-basic-info-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("document-workspace-trigger-产品定厂")).toBeInTheDocument(); + expect(screen.getByTestId("document-workspace-trigger-样包测量")).toBeInTheDocument(); + expect(screen.getByTestId("document-workspace-trigger-产品核价")).toBeInTheDocument(); + + for (const hiddenCategory of [ + "设计卖点", + "立项评审", + "产品模型", + "评审资料", + "企划布局", + "专利申请", + "产品实拍图", + "安装方式", + "包装细节", + "成品寄样", + "产品限价", + "工厂质检", + "检测报告", + ]) { + expect( + screen.queryByTestId(`document-workspace-trigger-${hiddenCategory}`), + ).not.toBeInTheDocument(); + } + }); + it("lets only admins reopen confirmed product factory rows before sample measurement is approved", async () => { const actor = userEvent.setup(); const productId = "DNZ0001-product-factory-reopen"; 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 0b626d29..b639d864 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -579,6 +579,7 @@ import { isIntakeNonIntegratedSupplyChainUser, type IntakeNonIntegratedPermissionUser, } from "@/lib/non-integrated-permissions"; +import { isExternalIntakeRecordOwner } from "@/lib/non-integrated-external-entry-permissions"; import { getIntakeNodePermissionTargetKeyByWorkspaceCategory, resolveIntakeNodePermissionAccess, @@ -8279,7 +8280,9 @@ export default function NonIntegratedDetailClient({ const isCurrentUserSupplyChainQualityGroup = isIntakeNonIntegratedQualityGroupUser(currentPermissionUser); const canUseProductPricingPushControls = - isCurrentUserAdmin || isCurrentUserSupplyChainPricingGroup; + isCurrentUserAdmin || + isCurrentUserSupplyChainPricingGroup || + isExternalIntakeRecordOwner(currentPermissionProduct, currentPermissionUser); const isCurrentUserPurchaseDownloadRestricted = hasCurrentUserContext && !isCurrentUserFullDetailEditor && diff --git a/furniture-3d-system/src/lib/business-account-types.ts b/furniture-3d-system/src/lib/business-account-types.ts index 6337fa08..c44d3436 100644 --- a/furniture-3d-system/src/lib/business-account-types.ts +++ b/furniture-3d-system/src/lib/business-account-types.ts @@ -1,7 +1,12 @@ import type { BusinessAccountType } from '@prisma/client' +import { EXTERNAL_INTAKE_BUSINESS_ROLE } from './non-integrated-external-entry-permissions' + +export type AppBusinessAccountType = + | BusinessAccountType + | typeof EXTERNAL_INTAKE_BUSINESS_ROLE export const BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{ - value: BusinessAccountType + value: AppBusinessAccountType label: string }> = [ { value: 'DESIGN', label: '设计' }, @@ -9,14 +14,16 @@ export const BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{ { value: 'PURCHASING', label: '采购' }, { value: 'WAREHOUSE', label: '仓库' }, { value: 'SUPPLIER', label: '供应商' }, + { value: EXTERNAL_INTAKE_BUSINESS_ROLE, label: '外部录入员' }, ] -export const BUSINESS_ACCOUNT_TYPE_LABELS: Record = { +export const BUSINESS_ACCOUNT_TYPE_LABELS: Record = { DESIGN: '设计', PLANNING: '企划', PURCHASING: '采购', WAREHOUSE: '仓库', SUPPLIER: '供应商', + EXTERNAL_INTAKE: '外部录入员', } export function normalizeBusinessRoles(value: unknown): BusinessAccountType[] { @@ -24,6 +31,6 @@ export function normalizeBusinessRoles(value: unknown): BusinessAccountType[] { const allowed = new Set(BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value)) return value.filter((item): item is BusinessAccountType => { - return typeof item === 'string' && allowed.has(item as BusinessAccountType) + return typeof item === 'string' && allowed.has(item as AppBusinessAccountType) }) } diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts index 388589fd..fbbcfaaf 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts @@ -149,6 +149,75 @@ describe('intake node permission settings', () => { }) }) + it('keeps external intake users on their own product development detail lane', () => { + const subjectKeys = resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: ['EXTERNAL_INTAKE'], + departmentPaths: [ + 'internal company > product research department', + 'internal company > supply chain center > purchasing department', + ], + }) + + expect(subjectKeys).toEqual(['externalIntake']) + expect( + resolveIntakeNodePermissionAccess({}, 'formalProducts', subjectKeys), + ).toMatchObject({ + view: true, + edit: true, + }) + expect( + resolveIntakeNodePermissionAccess({}, 'productDevelopmentList', subjectKeys), + ).toMatchObject({ + view: true, + edit: true, + }) + for (const targetKey of [ + 'openingReference', + 'basicInfo', + 'factory', + 'measurement', + 'pricing', + ] as const) { + expect(resolveIntakeNodePermissionAccess({}, targetKey, subjectKeys)).toMatchObject({ + view: true, + edit: true, + }) + } + for (const targetKey of [ + 'designSellingPoint', + 'projectReview', + 'productData', + 'review', + 'planningLayout', + 'patent', + 'photo', + 'installation', + 'packaging', + 'finishedSampleShipment', + 'limit', + 'quality', + 'warehouseSampling', + ] as const) { + expect(resolveIntakeNodePermissionAccess({}, targetKey, subjectKeys)).toMatchObject({ + view: false, + edit: false, + }) + } + expect( + resolveIntakeNodePermissionAccess({}, 'seriesDevelopmentList', subjectKeys), + ).toMatchObject({ + view: false, + edit: false, + }) + expect( + resolveIntakeNodePermissionAccess({}, 'productProgressDashboard', subjectKeys), + ).toMatchObject({ + view: false, + edit: false, + }) + }) + it('allows admin settings to explicitly close default permissions', () => { const overrides = sanitizeIntakeNodePermissionOverrides({ [buildIntakeNodePermissionOverrideKey('planningLayout', 'space', 'edit')]: false, diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.ts index 78c5a7ec..4a45e77b 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.ts @@ -6,6 +6,7 @@ import { import type { IntakeNonIntegratedPermissionUser, } from "@/lib/non-integrated-permissions"; +import { isExternalIntakeUser } from "@/lib/non-integrated-external-entry-permissions"; export type IntakeNodePermissionState = "edit" | "readonly" | "hidden"; export type IntakeNodePermissionAction = "view" | "download" | "edit"; @@ -22,6 +23,7 @@ export type IntakeNodePermissionSubjectKey = | "warehouse" | "warehouseQuality" | "merchandiser" + | "externalIntake" | "viewer"; export type IntakeNodePermissionTargetKey = @@ -87,6 +89,7 @@ const ALL_SUBJECT_KEYS: IntakeNodePermissionSubjectKey[] = [ "warehouse", "warehouseQuality", "merchandiser", + "externalIntake", "viewer", ]; @@ -152,6 +155,7 @@ const NODE_PERMISSION_HIDDEN: Record< warehouse: "hidden", warehouseQuality: "hidden", merchandiser: "hidden", + externalIntake: "hidden", viewer: "hidden", }; @@ -170,6 +174,7 @@ const NODE_PERMISSION_READONLY: Record< warehouse: "readonly", warehouseQuality: "readonly", merchandiser: "readonly", + externalIntake: "hidden", viewer: "readonly", }; @@ -290,6 +295,11 @@ export const ORDINARY_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = name: "跟单组", department: "供应链中心-品牌采购部-跟单组", }, + { + key: "externalIntake", + name: "外部录入员", + department: "仅可维护自己创建的产品开发明细", + }, { key: "viewer", name: "其他业务用户", @@ -321,7 +331,10 @@ export const TOP_ENTRY_PERMISSION_TARGETS: IntakeNodePermissionTarget[] = [ { key: "formalProducts", label: "产品资料", - defaults: NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT, + defaults: { + ...NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT, + externalIntake: "edit", + }, }, { key: "warehouseManage", @@ -364,6 +377,7 @@ export const PRODUCT_DEVELOPMENT_PERMISSION_TARGETS: IntakeNodePermissionTarget[ ...NODE_PERMISSION_BRAND_READONLY, design: "edit", planning: "edit", + externalIntake: "edit", }, }, { @@ -428,6 +442,7 @@ export const PRODUCT_DEVELOPMENT_PERMISSION_TARGETS: IntakeNodePermissionTarget[ defaults: { ...NODE_PERMISSION_PLAIN_VISIBLE, design: "edit", + externalIntake: "edit", }, }, { @@ -483,6 +498,7 @@ export const PRODUCT_DEVELOPMENT_PERMISSION_TARGETS: IntakeNodePermissionTarget[ ...NODE_PERMISSION_SUPPLY_VISIBLE, purchase: "edit", merchandiser: "edit", + externalIntake: "edit", }, downloads: { purchase: true, @@ -495,6 +511,7 @@ export const PRODUCT_DEVELOPMENT_PERMISSION_TARGETS: IntakeNodePermissionTarget[ defaults: { ...NODE_PERMISSION_SUPPLY_VISIBLE, pricing: "edit", + externalIntake: "edit", }, }, { @@ -516,6 +533,7 @@ export const PRODUCT_DEVELOPMENT_PERMISSION_TARGETS: IntakeNodePermissionTarget[ defaults: { ...NODE_PERMISSION_SUPPLY_VISIBLE, pricing: "edit", + externalIntake: "edit", }, }, { @@ -558,7 +576,10 @@ export const FORMAL_WORKSPACE_PERMISSION_TARGETS: IntakeNodePermissionTarget[] = { key: "productDevelopmentList", label: "产品开发明细", - defaults: NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT, + defaults: { + ...NODE_PERMISSION_FORMAL_PRODUCTS_DEFAULT, + externalIntake: "edit", + }, }, { key: "seriesDevelopmentList", @@ -807,6 +828,10 @@ export function sanitizeIntakeNodePermissionOverrides( export function resolveIntakeNodePermissionSubjectKeys( user: IntakeNonIntegratedPermissionUser, ) { + if (isExternalIntakeUser(user)) { + return ["externalIntake"] satisfies IntakeNodePermissionSubjectKey[]; + } + const keys: IntakeNodePermissionSubjectKey[] = []; const isBrandPlanningUser = hasDepartmentPath(user, isBrandPlanningDepartmentPath); const isSpaceVisualUser = hasDepartmentPath(user, isSpaceVisualDepartmentPath); diff --git a/furniture-3d-system/src/lib/non-integrated-external-entry-permissions.test.ts b/furniture-3d-system/src/lib/non-integrated-external-entry-permissions.test.ts new file mode 100644 index 00000000..9c6f0199 --- /dev/null +++ b/furniture-3d-system/src/lib/non-integrated-external-entry-permissions.test.ts @@ -0,0 +1,67 @@ +import { + EXTERNAL_INTAKE_BUSINESS_ROLE, + canExternalIntakeAccessWorkspace, + canExternalIntakeCreateRecord, + isExternalIntakeRecordOwner, + isExternalIntakeUser, + isExternalIntakeWorkspaceCategory, +} from './non-integrated-external-entry-permissions' + +describe('external intake entry permissions', () => { + it('recognizes external intake users by business role', () => { + expect( + isExternalIntakeUser({ + id: 'external-user-id', + businessRoles: [EXTERNAL_INTAKE_BUSINESS_ROLE], + }), + ).toBe(true) + expect( + isExternalIntakeUser({ + id: 'designer-user-id', + businessRoles: ['DESIGN'], + }), + ).toBe(false) + }) + + it('allows external intake users to create records', () => { + expect( + canExternalIntakeCreateRecord({ + id: 'external-user-id', + businessRoles: [EXTERNAL_INTAKE_BUSINESS_ROLE], + }), + ).toBe(true) + }) + + it('matches only records created by the external intake user', () => { + const user = { + id: 'external-user-id', + businessRoles: [EXTERNAL_INTAKE_BUSINESS_ROLE], + } + + expect(isExternalIntakeRecordOwner({ createdBy: 'external-user-id' }, user)).toBe(true) + expect(isExternalIntakeRecordOwner({ createdBy: 'other-user-id' }, user)).toBe(false) + expect(isExternalIntakeRecordOwner({ createdBy: '' }, user)).toBe(false) + }) + + it('limits external intake users to product data and system-entry workspaces', () => { + const user = { + id: 'external-user-id', + businessRoles: [EXTERNAL_INTAKE_BUSINESS_ROLE], + } + const ownRecord = { createdBy: 'external-user-id' } + + expect(isExternalIntakeWorkspaceCategory('开品参考')).toBe(true) + expect(isExternalIntakeWorkspaceCategory('基础信息')).toBe(true) + expect(isExternalIntakeWorkspaceCategory('产品定厂')).toBe(true) + expect(isExternalIntakeWorkspaceCategory('样包测量')).toBe(true) + expect(isExternalIntakeWorkspaceCategory('产品核价')).toBe(true) + expect(isExternalIntakeWorkspaceCategory('产品资料')).toBe(false) + expect(isExternalIntakeWorkspaceCategory('首批质检')).toBe(false) + + expect(canExternalIntakeAccessWorkspace(ownRecord, user, '产品核价')).toBe(true) + expect(canExternalIntakeAccessWorkspace(ownRecord, user, '产品限价')).toBe(false) + expect( + canExternalIntakeAccessWorkspace({ createdBy: 'other-user-id' }, user, '产品核价'), + ).toBe(false) + }) +}) diff --git a/furniture-3d-system/src/lib/non-integrated-external-entry-permissions.ts b/furniture-3d-system/src/lib/non-integrated-external-entry-permissions.ts new file mode 100644 index 00000000..0a5961db --- /dev/null +++ b/furniture-3d-system/src/lib/non-integrated-external-entry-permissions.ts @@ -0,0 +1,62 @@ +export const EXTERNAL_INTAKE_BUSINESS_ROLE = 'EXTERNAL_INTAKE' + +export type ExternalIntakePermissionUser = { + id?: string | null + businessRoles?: readonly string[] | null +} + +export type ExternalIntakePermissionRecord = { + createdBy?: string | null +} + +export const EXTERNAL_INTAKE_WORKSPACE_CATEGORIES = [ + '开品参考', + '基础信息', + '产品定厂', + '样包测量', + '产品核价', +] as const + +const EXTERNAL_INTAKE_WORKSPACE_CATEGORY_SET = new Set( + EXTERNAL_INTAKE_WORKSPACE_CATEGORIES, +) + +function normalizeText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +export function isExternalIntakeUser(user: ExternalIntakePermissionUser | null | undefined) { + return Boolean( + user?.businessRoles?.some((role) => role === EXTERNAL_INTAKE_BUSINESS_ROLE), + ) +} + +export function isExternalIntakeRecordOwner( + record: ExternalIntakePermissionRecord, + user: ExternalIntakePermissionUser, +) { + if (!isExternalIntakeUser(user)) return false + + const userId = normalizeText(user.id) + const createdBy = normalizeText(record.createdBy) + return Boolean(userId && createdBy === userId) +} + +export function canExternalIntakeCreateRecord(user: ExternalIntakePermissionUser) { + return isExternalIntakeUser(user) +} + +export function isExternalIntakeWorkspaceCategory(category: unknown) { + return EXTERNAL_INTAKE_WORKSPACE_CATEGORY_SET.has(normalizeText(category)) +} + +export function canExternalIntakeAccessWorkspace( + record: ExternalIntakePermissionRecord, + user: ExternalIntakePermissionUser, + category: unknown, +) { + return ( + isExternalIntakeRecordOwner(record, user) && + isExternalIntakeWorkspaceCategory(category) + ) +} diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts index b8aac417..2ac9af45 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts @@ -2,8 +2,12 @@ import { canCreateNonIntegratedProductFromIntake, canDownloadNonIntegratedWorkspace, canEditAnyNonIntegratedWorkspace, + canEditNonIntegratedProductIdentity, canEditNonIntegratedWorkspace, + canPushNonIntegratedProductPricingToJushuitan, + canUploadNonIntegratedDetachedAsset, } from './non-integrated-permissions' +import { NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY } from '@/app/intake/non-integrated/nonIntegratedStorage' const baseProduct = { createdBy: 'creator-id', @@ -53,6 +57,68 @@ describe('non-integrated intake permissions', () => { expect(canEditNonIntegratedWorkspace(baseProduct, user, '产品核价')).toBe(false) }) + it('lets external intake users maintain and push only their own records', () => { + const externalUser = { + id: 'creator-id', + name: 'ExternalUser', + role: 'USER', + businessRoles: ['EXTERNAL_INTAKE'], + departmentPaths: [], + } + const otherProduct = { + ...baseProduct, + createdBy: 'other-creator-id', + } + + expect(canCreateNonIntegratedProductFromIntake(externalUser)).toBe(true) + expect(canEditAnyNonIntegratedWorkspace(baseProduct, externalUser)).toBe(true) + expect( + canEditNonIntegratedWorkspace( + baseProduct, + externalUser, + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + ), + ).toBe(true) + expect(canEditNonIntegratedWorkspace(baseProduct, externalUser, '开品参考')).toBe(true) + expect(canEditNonIntegratedWorkspace(baseProduct, externalUser, '产品定厂')).toBe(true) + expect(canEditNonIntegratedWorkspace(baseProduct, externalUser, '样包测量')).toBe(true) + expect(canEditNonIntegratedWorkspace(baseProduct, externalUser, '产品核价')).toBe(true) + expect(canEditNonIntegratedWorkspace(baseProduct, externalUser, '产品资料')).toBe(false) + expect(canEditNonIntegratedWorkspace(baseProduct, externalUser, '设计卖点')).toBe(false) + expect(canEditNonIntegratedWorkspace(baseProduct, externalUser, '产品限价')).toBe(false) + expect(canEditNonIntegratedWorkspace(baseProduct, externalUser, '首批质检')).toBe(false) + expect(canEditNonIntegratedProductIdentity(baseProduct, externalUser)).toBe(true) + expect(canUploadNonIntegratedDetachedAsset(baseProduct, externalUser)).toBe(true) + expect( + canDownloadNonIntegratedWorkspace( + baseProduct, + externalUser, + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + ), + ).toBe(true) + expect(canDownloadNonIntegratedWorkspace(baseProduct, externalUser, '产品资料')).toBe(false) + expect(canPushNonIntegratedProductPricingToJushuitan(baseProduct, externalUser)).toBe(true) + + expect(canEditAnyNonIntegratedWorkspace(otherProduct, externalUser)).toBe(false) + expect( + canEditNonIntegratedWorkspace( + otherProduct, + externalUser, + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + ), + ).toBe(false) + expect(canEditNonIntegratedProductIdentity(otherProduct, externalUser)).toBe(false) + expect(canUploadNonIntegratedDetachedAsset(otherProduct, externalUser)).toBe(false) + expect( + canDownloadNonIntegratedWorkspace( + otherProduct, + externalUser, + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + ), + ).toBe(false) + expect(canPushNonIntegratedProductPricingToJushuitan(otherProduct, externalUser)).toBe(false) + }) + it('does not let unrelated designers download another designer product', () => { const user = { id: 'designer-other-id', diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.ts b/furniture-3d-system/src/lib/non-integrated-permissions.ts index 3fcaeaeb..07e32116 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.ts @@ -4,6 +4,12 @@ import { type NonIntegratedProductRecord, type NonIntegratedWorkflowNodeCategory, } from '@/app/intake/non-integrated/nonIntegratedStorage' +import { + canExternalIntakeAccessWorkspace, + canExternalIntakeCreateRecord, + isExternalIntakeRecordOwner, + isExternalIntakeUser, +} from './non-integrated-external-entry-permissions' export type IntakeNonIntegratedPermissionUser = { id?: string | null @@ -312,7 +318,8 @@ export function canCreateNonIntegratedProductFromIntake( ) { return ( isIntakeNonIntegratedFullEditor(user) || - isIntakeNonIntegratedDesignDepartmentUser(user) + isIntakeNonIntegratedDesignDepartmentUser(user) || + canExternalIntakeCreateRecord(user) ) } @@ -462,6 +469,9 @@ export function canEditNonIntegratedWorkspace( const normalizedCategory = normalizeWorkspaceCategory(category) if (!normalizedCategory) return false if (isIntakeNonIntegratedFullEditor(user)) return true + if (isExternalIntakeUser(user)) { + return canExternalIntakeAccessWorkspace(product, user, normalizedCategory) + } if (OPEN_LINK_EDIT_WORKSPACE_CATEGORIES.has(normalizedCategory)) return true if ( @@ -533,6 +543,9 @@ export function canDownloadNonIntegratedWorkspace( const normalizedCategory = normalizeWorkspaceCategory(category) if (!normalizedCategory) return false if (isIntakeNonIntegratedFullEditor(user)) return true + if (isExternalIntakeUser(user)) { + return canExternalIntakeAccessWorkspace(product, user, normalizedCategory) + } if ( PRICING_GROUP_WORKSPACE_CATEGORIES.has(normalizedCategory) && @@ -563,6 +576,7 @@ export function canEditAnyNonIntegratedWorkspace( user: IntakeNonIntegratedPermissionUser, ) { if (isIntakeNonIntegratedFullEditor(user)) return true + if (isExternalIntakeRecordOwner(product, user)) return true return Array.from(EDITABLE_WORKSPACE_CATEGORIES).some((category) => !OPEN_LINK_EDIT_WORKSPACE_CATEGORIES.has(category) && @@ -576,10 +590,22 @@ export function canEditNonIntegratedProductIdentity( ) { return ( isIntakeNonIntegratedFullEditor(user) || + isExternalIntakeRecordOwner(product, user) || isCurrentUserNonIntegratedProductDesigner(product, user) ) } +export function canPushNonIntegratedProductPricingToJushuitan( + product: IntakeNonIntegratedPermissionProduct, + user: IntakeNonIntegratedPermissionUser, +) { + return ( + isIntakeNonIntegratedDeleteAdmin(user) || + isIntakeNonIntegratedPricingGroupUser(user) || + isExternalIntakeRecordOwner(product, user) + ) +} + export function canDeleteNonIntegratedProductByIntakePermission( user: IntakeNonIntegratedPermissionUser, ) { diff --git a/furniture-3d-system/src/lib/role-scope.test.ts b/furniture-3d-system/src/lib/role-scope.test.ts index 3a3cfbea..cfa4efe7 100644 --- a/furniture-3d-system/src/lib/role-scope.test.ts +++ b/furniture-3d-system/src/lib/role-scope.test.ts @@ -23,6 +23,17 @@ describe('role-scope business roles', () => { expect(isReadOnlyUser({ role: 'USER', businessRoles: [] })).toBe(true) }) + it('keeps external intake users out of internal content and design workspaces', () => { + expect(canCreateContent({ role: 'USER', businessRoles: ['EXTERNAL_INTAKE'] })).toBe(false) + expect( + canOperateDesignWorkspace({ + role: 'USER', + businessRoles: ['EXTERNAL_INTAKE'], + departmentPaths: ['internal company > product research department'], + }), + ).toBe(false) + }) + it('设计业务类型取代旧设计师权限角色', () => { expect(hasBusinessRole({ role: 'USER', businessRoles: ['DESIGN'] }, 'DESIGN')).toBe(true) }) diff --git a/furniture-3d-system/src/lib/role-scope.ts b/furniture-3d-system/src/lib/role-scope.ts index 3d1ab66a..eeb3ed94 100644 --- a/furniture-3d-system/src/lib/role-scope.ts +++ b/furniture-3d-system/src/lib/role-scope.ts @@ -1,5 +1,6 @@ import type { UserRole } from '@/server/core/types' import type { BusinessAccountType } from '@prisma/client' +import { EXTERNAL_INTAKE_BUSINESS_ROLE } from './non-integrated-external-entry-permissions' export type AllowedRole = UserRole | 'SUPERVISOR_DEPARTMENT' | 'SUPERVISOR_TEAM' @@ -117,6 +118,10 @@ export function isProductResearchDesignUser(user: ScopedRoleUser): boolean { } export function canOperateDesignWorkspace(user: ScopedRoleUser): boolean { + if (!isAdmin(user) && hasBusinessRoleValue(user, EXTERNAL_INTAKE_BUSINESS_ROLE)) { + return false + } + return isAdmin(user) || isProductResearchDesignUser(user) } @@ -125,10 +130,18 @@ export function isReadOnlyUser(user: ScopedRoleUser): boolean { } export function canCreateContent(user: ScopedRoleUser): boolean { + if (hasBusinessRoleValue(user, EXTERNAL_INTAKE_BUSINESS_ROLE)) { + return false + } + return hasInternalContentBusinessRole(user) } export function hasBusinessRole(user: ScopedRoleUser, role: BusinessAccountType): boolean { + return hasBusinessRoleValue(user, role) +} + +function hasBusinessRoleValue(user: ScopedRoleUser, role: string): boolean { return Boolean(user.businessRoles?.includes(role)) } 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 4d54f299..c6e0d77d 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 @@ -369,6 +369,49 @@ describe('intakeNonIntegratedProductService', () => { expect(mockedRepository.listActive).toHaveBeenCalledWith(undefined) }) + it('limits external intake list pages to records created by the current user', async () => { + mockedRepository.listActivePage.mockResolvedValue({ + records: [], + total: 0, + } as never) + + await intakeNonIntegratedProductService.listPage( + { + ...baseUser, + id: 'external-user-id', + email: 'external@example.com', + name: 'ExternalUser', + businessRoles: ['EXTERNAL_INTAKE'] as const, + }, + { quickScope: 'created' }, + ) + + expect(mockedRepository.listActivePage).toHaveBeenCalledWith( + expect.objectContaining({ + scopeWhere: { createdBy: 'external-user-id' }, + filterWhere: { AND: [{ createdBy: 'external-user-id' }] }, + }), + ) + }) + + it('does not let external intake users read another user record by id', async () => { + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + createdBy: 'other-user-id', + }) as never, + ) + + await expect( + intakeNonIntegratedProductService.getById('non-integrated-1', { + ...baseUser, + id: 'external-user-id', + email: 'external@example.com', + name: 'ExternalUser', + businessRoles: ['EXTERNAL_INTAKE'] as const, + }), + ).resolves.toBeNull() + }) + it('读取列表时用结构化 profile 和 SKU 明细覆盖 payload 主数据', async () => { mockedRepository.listActive.mockResolvedValue([ createRecord({ @@ -3996,6 +4039,63 @@ describe('intakeNonIntegratedProductService', () => { }) }) + it('does not restore another user soft-deleted record for external intake users', async () => { + const payload = createPayload({ + id: 'external-new-record', + spu: 'DNZ0999', + createdAt: '2026-04-22T10:00:00.000Z', + updatedAt: '2026-04-22T10:00:00.000Z', + }) + const externalUser = { + ...baseUser, + id: 'external-user-id', + email: 'external@example.com', + name: 'ExternalUser', + businessRoles: ['EXTERNAL_INTAKE'] as const, + } + + mockedRepository.findBySpu.mockResolvedValue(null as never) + mockedRepository.findDeletedBySpu.mockResolvedValue( + createRecord({ + id: 'deleted-other-record', + createdBy: 'other-user-id', + deletedAt: new Date('2026-04-21T10:00:00.000Z'), + payload: createPayload({ + id: 'deleted-other-record', + spu: 'DNZ0999', + }), + }) as never, + ) + mockedRepository.findById.mockResolvedValue(null as never) + mockedRepository.create.mockResolvedValue( + createRecord({ + id: 'external-new-record', + createdBy: 'external-user-id', + payload, + }) as never, + ) + mockedRepository.findActiveById.mockResolvedValue( + createRecord({ + id: 'external-new-record', + createdBy: 'external-user-id', + payload, + }) as never, + ) + + await intakeNonIntegratedProductService.save(payload, externalUser) + + expect(mockedRepository.findDeletedBySpu).toHaveBeenCalledWith( + 'DNZ0999', + 'external-new-record', + 'Base', + ) + expect(mockedRepository.create).toHaveBeenCalled() + expect(mockedRepository.update).not.toHaveBeenCalledWith( + 'deleted-other-record', + expect.anything(), + ) + }) + it('reuses a soft-deleted record when recreating the same full SPU', async () => { const payload = createPayload({ id: 'non-integrated-recreate', diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts index 201fe64b..2dcafc34 100644 --- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-scope.service.test.ts @@ -1,6 +1,7 @@ import { intakeNodePermissionSettingsService } from '../intake-node-permission-settings.service' import { IntakeNonIntegratedScopeService } from '../intake-non-integrated-scope.service' import type { AuthUser } from '@/server/core/types' +import { NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY } from '@/app/intake/non-integrated/nonIntegratedStorage' jest.mock('../intake-node-permission-settings.service', () => ({ intakeNodePermissionSettingsService: { @@ -62,6 +63,59 @@ describe('IntakeNonIntegratedScopeService node permissions', () => { expect(service.canUploadDetachedAsset(record, baseUser)).toBe(false) }) + it('limits external intake users to records they created', async () => { + const externalUser: AuthUser = { + id: 'external-user-id', + email: 'external@example.local', + name: 'ExternalUser', + role: 'USER', + status: 'ACTIVE', + businessRoles: ['EXTERNAL_INTAKE'], + departmentPaths: [ + 'internal company > product research department', + ], + } + const ownRecord = { + id: 'own-record', + createdBy: 'external-user-id', + designer: '', + operator: '', + purchaser: '', + visibility: 'DEPARTMENT', + departmentId: null, + teamId: null, + payload: {}, + profile: null, + } + const otherRecord = { + ...ownRecord, + id: 'other-record', + createdBy: 'other-user-id', + } + + expect(service.buildReadScopeWhere(externalUser)).toEqual({ + createdBy: 'external-user-id', + }) + expect(service.canReadRecord(ownRecord, externalUser)).toBe(true) + expect(service.canReadRecord(otherRecord, externalUser)).toBe(false) + await expect( + service.canWriteWorkspaceRecordWithSettings( + ownRecord, + externalUser, + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + ), + ).resolves.toBe(true) + await expect( + service.canWriteWorkspaceRecordWithSettings( + otherRecord, + externalUser, + NON_INTEGRATED_BASIC_INFO_WORKSPACE_CATEGORY, + ), + ).resolves.toBe(false) + expect(() => service.assertCanPushProductPricing(ownRecord, externalUser)).not.toThrow() + expect(() => service.assertCanPushProductPricing(otherRecord, externalUser)).toThrow() + }) + it('uses stored payload owner fields when structured profile data is missing', async () => { const record = { id: 'payload-backed-owner-record', 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 6e3223d1..82f5cdac 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 @@ -26,6 +26,7 @@ import { buildAssetVariantBarcode, DEFAULT_SKU_VERSION_NO, } from '@/lib/open-product/asset-coding' +import { isExternalIntakeRecordOwner } from '@/lib/non-integrated-external-entry-permissions' import { prisma } from '@/lib/prisma' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' @@ -2961,6 +2962,18 @@ function isListAdminUser(user: AuthUser) { return user.role === 'SUPER_ADMIN' || user.role === 'ADMIN' } +function filterRestorableDeletedRecordForUser( + record: T | null, + user: AuthUser, +) { + if (!record) return null + if (!intakeNonIntegratedScopeService.isExternalIntakeScopeUser(user)) { + return record + } + + return isExternalIntakeRecordOwner(record, user) ? record : null +} + function getListDepartmentText(user: AuthUser) { return [ user.department?.name, @@ -3073,6 +3086,13 @@ function buildQuickScopeWhere( quickScope: IntakeNonIntegratedProductListParams['quickScope'], user: AuthUser, ): Prisma.IntakeNonIntegratedProductWhereInput | null { + if ( + (quickScope === 'created' || quickScope === 'pending') && + intakeNonIntegratedScopeService.isExternalIntakeScopeUser(user) + ) { + return { createdBy: user.id } + } + if (quickScope === 'created') { return buildResponsibleOwnerSearchWhere(user) } @@ -4966,6 +4986,19 @@ class IntakeNonIntegratedProductService { ) } + async assertCanPushProductPricing(id: string, user: AuthUser) { + const existingRecord = await this.repository.findActiveById(id) + if (!existingRecord) { + throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在') + } + + if (!intakeNonIntegratedScopeService.canReadRecord(existingRecord, user)) { + throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在') + } + + intakeNonIntegratedScopeService.assertCanPushProductPricing(existingRecord, user) + } + assertCanCreate(user: AuthUser) { if (intakeNonIntegratedScopeService.canCreateRecord(user)) { return @@ -5070,6 +5103,15 @@ class IntakeNonIntegratedProductService { existingRecord && !existingRecord.deletedAt ? (existingRecord as IntakeNonIntegratedRecord) : null + if ( + existingRecord && + !intakeNonIntegratedScopeService.canReadRecord( + existingRecord as IntakeNonIntegratedRecord, + user, + ) + ) { + throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在') + } const reservation = await spuNumberReservationService.reserveInTransaction(tx, { productCategory, currentProductId: readText(productInput.id) || null, @@ -5093,7 +5135,7 @@ class IntakeNonIntegratedProductService { }) const normalizedSpu = resolveNonIntegratedPersistedSpu(normalizedProduct) - const deletedRecordToRestore = activeExistingRecord + const deletedRecordToRestoreCandidate = activeExistingRecord ? null : await this.repository.findDeletedBySpuInTransaction( tx, @@ -5101,6 +5143,10 @@ class IntakeNonIntegratedProductService { normalizedProduct.id, normalizedProduct.brand, ) + const deletedRecordToRestore = filterRestorableDeletedRecordForUser( + deletedRecordToRestoreCandidate, + user, + ) if ( !activeExistingRecord && @@ -5364,6 +5410,12 @@ class IntakeNonIntegratedProductService { const existingRecordCandidate = isRecord(productInput) && typeof productInput.id === 'string' ? await this.repository.findById(productInput.id) : null + if ( + existingRecordCandidate && + !intakeNonIntegratedScopeService.canReadRecord(existingRecordCandidate, user) + ) { + throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在') + } const normalizedProduct = buildPersistedProductPayload( productInput, @@ -5376,13 +5428,17 @@ class IntakeNonIntegratedProductService { const normalizedSpu = resolveNonIntegratedPersistedSpu(normalizedProduct) - const deletedRecordToRestore = existingRecordCandidate + const deletedRecordToRestoreCandidate = existingRecordCandidate ? null : await this.repository.findDeletedBySpu( normalizedSpu, normalizedProduct.id, normalizedProduct.brand, ) + const deletedRecordToRestore = filterRestorableDeletedRecordForUser( + deletedRecordToRestoreCandidate, + user, + ) const existingRecord = existingRecordCandidate if (!existingRecord && !intakeNonIntegratedScopeService.canCreateRecord(user)) { diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts index 89c1af36..f8bc3c66 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts @@ -8,9 +8,14 @@ import { canEditAnyNonIntegratedWorkspace, canEditNonIntegratedWorkspace, canDownloadNonIntegratedWorkspace, + canPushNonIntegratedProductPricingToJushuitan, isIntakeNonIntegratedFullEditor, type IntakeNonIntegratedPermissionProduct, } from '@/lib/non-integrated-permissions' +import { + isExternalIntakeRecordOwner, + isExternalIntakeUser, +} from '@/lib/non-integrated-external-entry-permissions' import { getIntakeNodePermissionTargetKeyByWorkspaceCategory, resolveIntakeNodePermissionAccess, @@ -64,6 +69,10 @@ class IntakeNonIntegratedScopeService { return ADMIN_ROLES.has(user.role) } + isExternalIntakeScopeUser(user: AuthUser): boolean { + return !this.isAdmin(user) && isExternalIntakeUser(user) + } + getScopedDepartmentId(user: AuthUser): string | undefined { return user.managedDepartmentId ?? user.departmentId } @@ -73,6 +82,10 @@ class IntakeNonIntegratedScopeService { } buildReadScopeWhere(user: AuthUser): Prisma.IntakeNonIntegratedProductWhereInput | undefined { + if (this.isExternalIntakeScopeUser(user)) { + return { createdBy: user.id } + } + if (!this.isScopeEnabled()) { return undefined } @@ -108,6 +121,10 @@ class IntakeNonIntegratedScopeService { } canReadRecord(record: IntakeNonIntegratedPermissionTarget, user: AuthUser): boolean { + if (this.isExternalIntakeScopeUser(user)) { + return isExternalIntakeRecordOwner(record, user) + } + if (!this.isScopeEnabled()) return true if (this.isAdmin(user)) return true if (record.createdBy === user.id) return true @@ -184,6 +201,13 @@ class IntakeNonIntegratedScopeService { return canUploadNonIntegratedDetachedAsset(this.buildPermissionProduct(record), user) } + canPushProductPricing(record: IntakeNonIntegratedPermissionTarget, user: AuthUser): boolean { + return canPushNonIntegratedProductPricingToJushuitan( + this.buildPermissionProduct(record), + user, + ) + } + canWriteWorkspaceRecord( record: IntakeNonIntegratedPermissionTarget, user: AuthUser, @@ -317,6 +341,17 @@ class IntakeNonIntegratedScopeService { throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限下载这条非集成产品资料') } + assertCanPushProductPricing( + record: IntakeNonIntegratedPermissionTarget, + user: AuthUser, + ): void { + if (this.canPushProductPricing(record, user)) { + return + } + + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限推送这条非集成产品核价') + } + assertCanDeleteRecord(record: IntakeNonIntegratedPermissionTarget, user: AuthUser): void { if (canDeleteNonIntegratedProductByIntakePermission(user)) { return