Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-bom-recalc-rootfix-20260805

This commit is contained in:
WuYongJie 2026-08-05 16:28:53 +08:00
commit cc01261d96
13 changed files with 193 additions and 11 deletions

View File

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

View File

@ -2000,6 +2000,7 @@ enum BusinessAccountType {
PURCHASING
OPERATIONS
WAREHOUSE
MANAGEMENT
SUPPLIER
EXTERNAL_INTAKE
}

View File

@ -23,6 +23,10 @@ export async function GET(request: NextRequest) {
const data = await supplyChainProductArchiveDashboardService.getDashboard(parsed.data)
return jsonSuccess(data, 200, ctx.requestId)
},
{ requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'] },
{
requireAuth: true,
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
allowedBusinessRoles: ['MANAGEMENT'],
},
)
}

View File

@ -78,6 +78,7 @@ export async function GET(request: NextRequest) {
{
requireAuth: true,
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
allowedBusinessRoles: ['MANAGEMENT'],
},
)
}

View File

@ -2242,9 +2242,16 @@ function DetailTableRow({
onPreviewImage: (image: StyleImagePreview) => void
}) {
const timingFields = buildTimingDetailFields(item)
const overtimeStages = getOvertimeStages(item)
const isOvertime = item.overallTimeoutFlag || overtimeStages.length > 0
const timeoutFollowupSummary = getTimeoutFollowupSummary(overtimeStages, timeoutFollowups)
const currentOvertimeStages = getOvertimeStages(item)
const historicalOvertimeStages = getHistoricalOvertimeStages(item)
const followupDisplayStages = getFollowupDisplayStages(
currentOvertimeStages,
historicalOvertimeStages,
timeoutFollowups,
)
const isOvertime =
item.overallTimeoutFlag || currentOvertimeStages.length > 0 || historicalOvertimeStages.length > 0
const timeoutFollowupSummary = getTimeoutFollowupSummary(followupDisplayStages, timeoutFollowups)
const competitorLink = formatCompetitorLinkSummary(item.formSummary.competitorLink)
const primaryStyleImage = item.styleImages[0]
const openingCode = formatCellText(item.formSummary.openingCode || item.openingCode)

View File

@ -17,6 +17,7 @@ export const BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{
{ value: 'PURCHASING', label: '采购' },
{ value: 'OPERATIONS', label: '运营' },
{ value: 'WAREHOUSE', label: '仓库' },
{ value: 'MANAGEMENT', label: '经管' },
{ value: 'SUPPLIER', label: '供应商' },
{ value: EXTERNAL_INTAKE_BUSINESS_ROLE, label: '外部录入员' },
]
@ -30,6 +31,7 @@ export const BUSINESS_ACCOUNT_TYPE_LABELS: Record<AppBusinessAccountType, string
PURCHASING: '采购',
OPERATIONS: '运营',
WAREHOUSE: '仓库',
MANAGEMENT: '经管',
SUPPLIER: '供应商',
EXTERNAL_INTAKE: '外部录入员',
}

View File

@ -89,6 +89,21 @@ describe('intake node permission settings', () => {
],
}),
).toEqual(['operation'])
expect(
resolveIntakeNodePermissionSubjectKeys({
role: 'USER',
businessRoles: [],
departmentPaths: ['江苏简尚品牌管理有限公司 > 经营管理中心 > 数据分析组'],
}),
).toEqual(['management'])
expect(
resolveIntakeNodePermissionSubjectKeys({
role: 'USER',
businessRoles: ['MANAGEMENT'],
}),
).toEqual(['management'])
})
it('uses the same matrix for default view, download, and admin-opened overrides', () => {
@ -180,6 +195,20 @@ describe('intake node permission settings', () => {
})
})
it('keeps management hidden until an administrator opens an action', () => {
const managementKeys = ['management'] as const
expect(
resolveIntakeNodePermissionAccess({}, 'productData', managementKeys),
).toMatchObject({ view: false, download: false, edit: false })
const overrides = {
[buildIntakeNodePermissionOverrideKey('productData', 'management', 'view')]: true,
}
expect(
resolveIntakeNodePermissionAccess(overrides, 'productData', managementKeys),
).toMatchObject({ view: true, download: false, edit: false })
})
it('keeps external intake users on their own product development detail lane', () => {
const subjectKeys = resolveIntakeNodePermissionSubjectKeys({
role: 'USER',

View File

@ -26,6 +26,7 @@ export type IntakeNodePermissionSubjectKey =
| "warehouse"
| "warehouseQuality"
| "merchandiser"
| "management"
| "externalIntake"
| "viewer";
@ -98,6 +99,7 @@ const ALL_SUBJECT_KEYS: IntakeNodePermissionSubjectKey[] = [
"warehouse",
"warehouseQuality",
"merchandiser",
"management",
"externalIntake",
"viewer",
];
@ -170,6 +172,7 @@ const NODE_PERMISSION_HIDDEN: Record<
warehouse: "hidden",
warehouseQuality: "hidden",
merchandiser: "hidden",
management: "hidden",
externalIntake: "hidden",
viewer: "hidden",
};
@ -191,6 +194,7 @@ const NODE_PERMISSION_READONLY: Record<
warehouse: "readonly",
warehouseQuality: "readonly",
merchandiser: "readonly",
management: "hidden",
externalIntake: "hidden",
viewer: "readonly",
};
@ -345,6 +349,11 @@ export const ORDINARY_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] =
name: "跟单组",
department: "供应链中心-品牌采购部-跟单组",
},
{
key: "management",
name: "经管",
department: "经营管理中心",
},
{
key: "externalIntake",
name: "外部录入员",
@ -845,6 +854,10 @@ function isOperationDepartmentPath(path: string) {
return /运营/.test(path);
}
function isManagementDepartmentPath(path: string) {
return /经营管理中心/.test(path);
}
function isCustomerServiceDepartmentPath(path: string) {
return /客服中心/.test(path);
}
@ -980,6 +993,10 @@ export function resolveIntakeNodePermissionSubjectKeys(
pushUniqueSubject(keys, "operation");
}
if (hasDepartmentPath(user, isManagementDepartmentPath)) {
pushUniqueSubject(keys, "management");
}
if (hasDepartmentPath(user, isCustomerServiceDepartmentPath)) {
pushUniqueSubject(keys, "customerService");
}
@ -1008,6 +1025,10 @@ export function resolveIntakeNodePermissionSubjectKeys(
pushUniqueSubject(keys, "customerService");
}
if (hasBusinessRoleFallback(user, "MANAGEMENT")) {
pushUniqueSubject(keys, "management");
}
if (keys.length === 0) {
keys.push("viewer");
}

View File

@ -7,6 +7,7 @@ export const RELEASE_NOTE_BUSINESS_ROLE_OPTIONS = [
{ value: 'PURCHASING', label: '采购' },
{ value: 'OPERATIONS', label: '运营' },
{ value: 'WAREHOUSE', label: '仓库' },
{ value: 'MANAGEMENT', label: '经管' },
{ value: 'SUPPLIER', label: '供应商' },
{ value: 'EXTERNAL_INTAKE', label: '外部录入' },
] as const

View File

@ -18,6 +18,21 @@ describe('workspace route permissions', () => {
expect(canAccessWorkspacePath('/supply-chain', { role: 'ADMIN', businessRoles: [] })).toBe(true)
})
it('allows management users into supply chain only', () => {
const managementUser = {
role: 'USER',
businessRoles: ['MANAGEMENT'],
}
expect(getWorkspaceAccessLevel(managementUser)).toBe('management')
expect(canAccessSupplyChainWorkspace(managementUser)).toBe(true)
expect(canAccessWorkspacePath('/supply-chain', managementUser)).toBe(true)
expect(canAccessWorkspacePath('/supply-chain/dashboard', managementUser)).toBe(true)
expect(canAccessWorkspacePath('/asset-center', managementUser)).toBe(false)
expect(canAccessWorkspacePath('/warehouse', managementUser)).toBe(false)
expect(canAccessWorkspacePath('/users', managementUser)).toBe(false)
})
it('keeps space visual users in formal products only even if their business role is design', () => {
const spaceVisualUser = {
role: 'USER',
@ -195,6 +210,12 @@ describe('workspace route permissions', () => {
expect(
canAccessWorkspacePathByAccessLevel('/supply-chain/dashboard', 'full', [], 'USER'),
).toBe(false)
expect(
canAccessWorkspacePathByAccessLevel('/supply-chain/dashboard', 'management', [], 'USER'),
).toBe(true)
expect(
canAccessWorkspacePathByAccessLevel('/supply-chain/dashboard', 'formal-products-only', [], 'USER'),
).toBe(false)
})
it('redirects special accounts to product data and blocked internal users to the portal', () => {

View File

@ -25,7 +25,11 @@ const USER_MANAGEMENT_ALLOWED_ROLES: AllowedRole[] = [
'SUPERVISOR_TEAM',
]
export type WorkspaceAccessLevel = 'full' | 'formal-products-only' | 'supplier-barcode-print-only'
export type WorkspaceAccessLevel =
| 'full'
| 'management'
| 'formal-products-only'
| 'supplier-barcode-print-only'
export interface WorkspacePermissionUser {
role?: string | null
@ -63,8 +67,8 @@ export function canAccessSupplyChainWorkspace(
) {
return Boolean(
canAccessHomePortal(user) &&
user?.role &&
SUPPLY_CHAIN_ALLOWED_ROLES.has(user.role),
((user?.role && SUPPLY_CHAIN_ALLOWED_ROLES.has(user.role)) ||
user?.businessRoles?.includes('MANAGEMENT')),
)
}
@ -77,7 +81,10 @@ export function getWorkspaceAccessLevel(
): WorkspaceAccessLevel {
if (isSupplierAccount(user)) return 'supplier-barcode-print-only'
if (isExternalIntakeUser(user)) return 'formal-products-only'
return hasFullWorkspaceAccess(user) ? 'full' : 'formal-products-only'
if (hasFullWorkspaceAccess(user)) return 'full'
return user?.businessRoles?.includes('MANAGEMENT')
? 'management'
: 'formal-products-only'
}
export function isRestrictedWorkspacePathAllowed(pathname: string) {
@ -174,7 +181,7 @@ export function canAccessWorkspacePathByAccessLevel(
return accessLevel !== 'supplier-barcode-print-only'
}
if (isSupplyChainPath(pathname)) {
return Boolean(role && SUPPLY_CHAIN_ALLOWED_ROLES.has(role))
return accessLevel === 'management' || Boolean(role && SUPPLY_CHAIN_ALLOWED_ROLES.has(role))
}
if (!accessLevel || accessLevel === 'full') return true
if (accessLevel === 'supplier-barcode-print-only') {

View File

@ -1,15 +1,31 @@
export {}
const mockResolveObjectStorageUrlForClientAccess = jest.fn()
const mockAuthenticateRequest = jest.fn()
jest.mock('@/lib/storage', () => ({
resolveObjectStorageUrlForClientAccess: (...args: unknown[]) =>
mockResolveObjectStorageUrlForClientAccess(...args),
}))
jest.mock('@/lib/auth-middleware', () => ({
authenticateRequest: (...args: unknown[]) => mockAuthenticateRequest(...args),
}))
jest.mock('next/server', () => ({
NextResponse: {
json: (body: unknown, init?: { status?: number }) => ({
status: init?.status ?? 200,
json: async () => body,
}),
},
}))
import type { NextRequest } from 'next/server'
import {
isSupplierAllowedApiRequest,
handleRequest,
jsonSuccess,
success,
successWithPagination,
} from './response'
@ -140,3 +156,65 @@ describe('supplier request boundary', () => {
expect(isSupplierAllowedApiRequest(createRequest(path, method))).toBe(expected)
})
})
describe('request business-role authorization', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('allows a management user through an explicitly allowed read scope', async () => {
mockAuthenticateRequest.mockResolvedValue({
success: true,
userInfo: {
id: 'management-user',
email: 'management@example.com',
name: 'Management User',
role: 'USER',
status: 'ACTIVE',
businessRoles: ['MANAGEMENT'],
},
})
const response = await handleRequest(
createRequest('/api/supply-chain/product-archive', 'GET'),
async (ctx) => jsonSuccess({ userId: ctx.user.id }, 200, ctx.requestId),
{
requireAuth: true,
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
allowedBusinessRoles: ['MANAGEMENT'],
},
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
success: true,
data: { userId: 'management-user' },
})
})
it('keeps an ordinary user out of a management-only read scope', async () => {
mockAuthenticateRequest.mockResolvedValue({
success: true,
userInfo: {
id: 'ordinary-user',
email: 'ordinary@example.com',
name: 'Ordinary User',
role: 'USER',
status: 'ACTIVE',
businessRoles: ['GENERAL'],
},
})
const response = await handleRequest(
createRequest('/api/supply-chain/product-archive', 'GET'),
async (ctx) => jsonSuccess({ userId: ctx.user.id }, 200, ctx.requestId),
{
requireAuth: true,
allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'],
allowedBusinessRoles: ['MANAGEMENT'],
},
)
expect(response.status).toBe(403)
})
})

View File

@ -10,7 +10,8 @@ import type { ApiResponse, PaginationMeta, ResponseMeta, AuthUser, RequestContex
import { AppError, ErrorCode, ERROR_MESSAGES, isAppError } from './errors'
import { HTTP_STATUS, ENV } from './constants'
import type { AllowedRole } from '@/lib/role-scope'
import { canCreateContent, canOperateDesignWorkspace, hasAllowedRole } from '@/lib/role-scope'
import { canCreateContent, canOperateDesignWorkspace, hasAllowedRole, hasBusinessRole } from '@/lib/role-scope'
import type { BusinessAccountType } from '@prisma/client'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
import { isSupplierAccount } from '@/lib/supplier-account'
@ -195,6 +196,8 @@ interface HandleRequestOptions {
requireAuth?: boolean
/** 允许的角色 */
allowedRoles?: AllowedRole[]
/** 允许的业务类型 */
allowedBusinessRoles?: BusinessAccountType[]
/** 是否需要内部业务写入权限 */
requireInternalContentWrite?: boolean
/** 是否需要设计工作区操作权限 */
@ -251,7 +254,13 @@ export async function handleRequest<T>(
}
// 角色检查
if (options.allowedRoles && !hasAllowedRole(user, options.allowedRoles)) {
const hasRoleScope = Boolean(options.allowedRoles?.length)
const hasBusinessRoleScope = Boolean(options.allowedBusinessRoles?.length)
const isAllowedByRole = hasRoleScope && hasAllowedRole(user, options.allowedRoles!)
const isAllowedByBusinessRole = Boolean(
options.allowedBusinessRoles?.some((businessRole) => hasBusinessRole(user!, businessRole)),
)
if ((hasRoleScope || hasBusinessRoleScope) && !isAllowedByRole && !isAllowedByBusinessRole) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'没有权限执行此操作',