feat(intake): allow designers to delete their own product records

product intake list delete button now also resolves for designers (DESIGN business role or design department paths), restricted to records they created; admins keep full delete scope; server DELETE route and client row button share the same record-level predicate; delete stays soft with operation logs.
This commit is contained in:
WuYongJie 2026-09-03 13:03:49 +08:00
parent 74ac36f34d
commit 925868fb65
8 changed files with 108 additions and 18 deletions

View File

@ -1,5 +1,5 @@
import {
canDeleteNonIntegratedProductByIntakePermission,
canDeleteNonIntegratedProductRecordByIntakePermission,
isIntakeNonIntegratedDesignDepartmentUser,
type IntakeNonIntegratedPermissionUser,
} from "@/lib/non-integrated-permissions";
@ -8,8 +8,11 @@ type IntakeProductPermissionUser = IntakeNonIntegratedPermissionUser & {
role: string;
};
export function canDeleteIntakeProducts(role: string): boolean {
return canDeleteNonIntegratedProductByIntakePermission({ role });
export function canDeleteIntakeProductRecord(
user: IntakeProductPermissionUser,
product: { createdBy?: string | null },
): boolean {
return canDeleteNonIntegratedProductRecordByIntakePermission(user, product);
}
export function canManageSpuRuleLibrary(role: string): boolean {

View File

@ -133,7 +133,7 @@ export function buildIntakePrimaryDomainProps(params: {
nonIntegratedProducts: NonIntegratedProductRecord[];
productDevelopmentProgressProducts: NonIntegratedProductRecord[];
initialNonIntegratedPageInfo: IntakeClientProps["initialNonIntegratedPageInfo"];
canDeleteNonIntegratedProducts: boolean;
canDeleteIntakeProductByRecord: (product: NonIntegratedProductRecord) => boolean;
canCreateNonIntegratedProducts: boolean;
canUseDesignControlledProductData: boolean;
canViewProductDevelopmentList: boolean;
@ -181,7 +181,7 @@ export function buildIntakePrimaryDomainProps(params: {
productDevelopmentProgressProducts:
params.productDevelopmentProgressProducts,
initialNonIntegratedPageInfo: params.initialNonIntegratedPageInfo,
canDeleteProducts: params.canDeleteNonIntegratedProducts,
canDeleteProducts: params.canDeleteIntakeProductByRecord,
canCreateProducts: params.canCreateNonIntegratedProducts,
canUseDesignControlledProductData:
params.canUseDesignControlledProductData,

View File

@ -227,7 +227,7 @@ export function NonIntegratedPendingDetail({
}: {
initialProducts: NonIntegratedProductRecord[];
initialPageInfo?: NonIntegratedProductPageInfo | null;
canDeleteProducts: boolean;
canDeleteProducts: (product: NonIntegratedProductRecord) => boolean;
currentUser: NonIntegratedListPermissionUser;
productDevelopmentKind?: ProductDevelopmentKind;
fixedProductKind?: NonIntegratedProductKindFilter;
@ -1702,7 +1702,7 @@ export function NonIntegratedPendingDetail({
<span className="sr-only">复制</span>
</Button>
) : null}
{canDeleteProducts && canEditProduct ? (
{canDeleteProducts(product) && canEditProduct ? (
<Button
variant="plain"
size="small"

View File

@ -33,7 +33,7 @@ type ProductDevelopmentDomainProps = {
stock?: NonIntegratedProductPageInfo;
series?: NonIntegratedProductPageInfo;
};
canDeleteProducts: boolean;
canDeleteProducts: (product: NonIntegratedProductRecord) => boolean;
canCreateProducts: boolean;
canUseDesignControlledProductData: boolean;
canViewProductDevelopmentList: boolean;

View File

@ -26,7 +26,7 @@ import { buildIntakeClientDerivedData } from "@/app/intake/intakeClientDerivedDa
import { canCreateNonIntegratedProductFromIntake } from "@/lib/non-integrated-permissions";
import {
canAccessDesignControlledProductData,
canDeleteIntakeProducts,
canDeleteIntakeProductRecord,
canManageSpuRuleLibrary,
} from "@/app/intake/intakeClientPermissions";
import {
@ -39,9 +39,9 @@ import {
export function useIntakeClientViewModel(
props: IntakeClientProps,
): IntakeClientWorkspaceViewProps {
const canDeleteNonIntegratedProducts = canDeleteIntakeProducts(
props.user.role,
);
const canDeleteIntakeProductByRecord = (
product: { createdBy?: string | null },
) => canDeleteIntakeProductRecord(props.user, product);
const canUseDesignControlledProductData =
canAccessDesignControlledProductData(props.user);
const canCreateNonIntegratedProducts =
@ -231,7 +231,7 @@ export function useIntakeClientViewModel(
nonIntegratedProducts,
productDevelopmentProgressProducts,
initialNonIntegratedPageInfo: props.initialNonIntegratedPageInfo,
canDeleteNonIntegratedProducts,
canDeleteIntakeProductByRecord,
canCreateNonIntegratedProducts,
canUseDesignControlledProductData,
canViewProductDevelopmentList,

View File

@ -1,5 +1,6 @@
import {
canCreateNonIntegratedProductFromIntake,
canDeleteNonIntegratedProductRecordByIntakePermission,
canDownloadNonIntegratedWorkspace,
canEditAnyNonIntegratedWorkspace,
canEditNonIntegratedProductIdentity,
@ -478,3 +479,74 @@ describe('non-integrated intake permissions', () => {
expect(canEditNonIntegratedWorkspace(baseProduct, purchasingUser, '工厂质检')).toBe(false)
})
})
describe('non-integrated intake product delete permission', () => {
const designer = {
id: 'designer-id',
name: '希文',
role: 'USER',
businessRoles: [] as string[],
departmentPaths: ['江苏简尚品牌管理有限公司 > 产品研发部 > 基本盘 > 桌类组'],
}
const ownProduct = { createdBy: 'designer-id' }
const othersProduct = { createdBy: 'creator-id' }
it('keeps full delete access for admins', () => {
expect(
canDeleteNonIntegratedProductRecordByIntakePermission(
{ id: 'admin-1', name: 'AdminA', role: 'SUPER_ADMIN' },
othersProduct,
),
).toBe(true)
expect(
canDeleteNonIntegratedProductRecordByIntakePermission(
{ id: 'admin-2', name: 'AdminB', role: 'ADMIN' },
othersProduct,
),
).toBe(true)
})
it('lets designers delete only products they created', () => {
expect(
canDeleteNonIntegratedProductRecordByIntakePermission(designer, ownProduct),
).toBe(true)
expect(
canDeleteNonIntegratedProductRecordByIntakePermission(designer, othersProduct),
).toBe(false)
})
it('accepts explicitly assigned DESIGN business role as designer', () => {
const assignedDesigner = {
id: 'designer-2',
name: 'DesignerB',
role: 'USER',
businessRoles: ['DESIGN'],
}
expect(
canDeleteNonIntegratedProductRecordByIntakePermission(assignedDesigner, {
createdBy: 'designer-2',
}),
).toBe(true)
expect(
canDeleteNonIntegratedProductRecordByIntakePermission(assignedDesigner, {
createdBy: 'someone-else',
}),
).toBe(false)
})
it('keeps other roles without delete access', () => {
const purchasingUser = {
id: 'purchase-role-id',
name: '采购B',
role: 'USER',
businessRoles: ['PURCHASING'],
}
expect(
canDeleteNonIntegratedProductRecordByIntakePermission(purchasingUser, {
createdBy: 'purchase-role-id',
}),
).toBe(false)
})
})

View File

@ -674,8 +674,17 @@ export function canPushNonIntegratedProductPricingToJushuitan(
)
}
export function canDeleteNonIntegratedProductByIntakePermission(
export function canDeleteNonIntegratedProductRecordByIntakePermission(
user: IntakeNonIntegratedPermissionUser,
product: { createdBy?: string | null },
) {
return isIntakeNonIntegratedDeleteAdmin(user)
if (isIntakeNonIntegratedDeleteAdmin(user)) {
return true
}
return (
isIntakeNonIntegratedDesignDepartmentUser(user) &&
Boolean(user.id) &&
product.createdBy === user.id
)
}

View File

@ -4,7 +4,8 @@ import type { AuthUser } from '@/server/core/types'
import {
canUploadNonIntegratedDetachedAsset,
canCreateNonIntegratedProductFromIntake,
canDeleteNonIntegratedProductByIntakePermission,
canDeleteNonIntegratedProductRecordByIntakePermission,
isIntakeNonIntegratedDesignDepartmentUser,
canEditAnyNonIntegratedWorkspace,
canEditNonIntegratedWorkspace,
canDownloadNonIntegratedWorkspace,
@ -686,11 +687,16 @@ class IntakeNonIntegratedScopeService {
}
assertCanDeleteRecord(record: IntakeNonIntegratedPermissionTarget, user: AuthUser): void {
if (canDeleteNonIntegratedProductByIntakePermission(user)) {
if (canDeleteNonIntegratedProductRecordByIntakePermission(user, record)) {
return
}
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限删除这条非集成产品记录')
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
isIntakeNonIntegratedDesignDepartmentUser(user)
? '设计师仅可删除自己创建的产品'
: '没有权限删除这条非集成产品记录',
)
}
}