feat(intake): add external intake role isolation
This commit is contained in:
parent
3ba9c451a5
commit
7276abf589
@ -0,0 +1 @@
|
||||
ALTER TYPE "BusinessAccountType" ADD VALUE IF NOT EXISTS 'EXTERNAL_INTAKE';
|
||||
@ -1451,6 +1451,7 @@ enum BusinessAccountType {
|
||||
PURCHASING
|
||||
WAREHOUSE
|
||||
SUPPLIER
|
||||
EXTERNAL_INTAKE
|
||||
}
|
||||
|
||||
enum ApprovalWorkflowStatus {
|
||||
|
||||
@ -51,6 +51,11 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
)
|
||||
}
|
||||
|
||||
await intakeNonIntegratedProductService.assertCanPushProductPricing(
|
||||
productId,
|
||||
ctx.user,
|
||||
)
|
||||
|
||||
const result =
|
||||
await jushuitanProductPricingPushService.pushProductPricing({
|
||||
product,
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId="DNZ0001-external-visible-workspaces"
|
||||
currentUserRole="USER"
|
||||
currentUserId="external-user-id"
|
||||
currentUserName="外部录入员"
|
||||
currentUserBusinessRoles={["EXTERNAL_INTAKE"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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<BusinessAccountType, string> = {
|
||||
export const BUSINESS_ACCOUNT_TYPE_LABELS: Record<AppBusinessAccountType, string> = {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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<string>(
|
||||
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)
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
) {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<T extends { createdBy?: string | null }>(
|
||||
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)) {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user