feat(intake): add external intake role isolation

This commit is contained in:
WuYongJie 2026-06-09 13:01:49 +08:00
parent 3ba9c451a5
commit 7276abf589
20 changed files with 697 additions and 16 deletions

View File

@ -0,0 +1 @@
ALTER TYPE "BusinessAccountType" ADD VALUE IF NOT EXISTS 'EXTERNAL_INTAKE';

View File

@ -1451,6 +1451,7 @@ enum BusinessAccountType {
PURCHASING
WAREHOUSE
SUPPLIER
EXTERNAL_INTAKE
}
enum ApprovalWorkflowStatus {

View File

@ -51,6 +51,11 @@ export async function POST(request: NextRequest, context: RouteContext) {
)
}
await intakeNonIntegratedProductService.assertCanPushProductPricing(
productId,
ctx.user,
)
const result =
await jushuitanProductPricingPushService.pushProductPricing({
product,

View File

@ -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: [],

View File

@ -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
}

View File

@ -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";

View File

@ -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 &&

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -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,
) {

View File

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

View File

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

View File

@ -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',

View File

@ -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',

View File

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

View File

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