fix(supply-chain): isolate permissions for every menu page

This commit is contained in:
WuYongJie 2026-10-10 14:31:21 +08:00
parent 729eaf492d
commit 9b7ab9e532
23 changed files with 506 additions and 377 deletions

View File

@ -0,0 +1,85 @@
BEGIN;
-- Use the same lock as permission configuration writes. Retain every old row.
SELECT pg_advisory_xact_lock(2026081501);
LOCK TABLE "SupplyChainPermissionOverride" IN SHARE ROW EXCLUSIVE MODE;
WITH mappings("source", "target") AS (
VALUES
('businessManagement', 'ownerConfig'),
('businessManagement', 'limitManagement'),
('businessManagement', 'productionStopDemands'),
('businessManagement', 'managementBlankPage1'),
('businessManagement', 'managementBlankPage2'),
('newProductSampleProgress', 'finishedProductProgress')
)
INSERT INTO "SupplyChainPermissionOverride"
("id", "targetKey", "subjectKey", "action", "enabled", "createdBy", "updatedBy", "createdAt", "updatedAt")
SELECT
'scp-' || md5(old."id" || ':' || mappings."target"),
mappings."target", old."subjectKey", old."action", old."enabled",
old."createdBy", old."updatedBy", old."createdAt", old."updatedAt"
FROM "SupplyChainPermissionOverride" AS old
JOIN mappings ON old."targetKey" = mappings."source"
ON CONFLICT ("targetKey", "subjectKey", "action") DO NOTHING;
-- Logistics previously allowed exporting whenever effective view was true.
-- Preserve that ability once, then enforce its independent download permission.
-- Personal download=false used to suppress editing, not the view-based export.
-- Leave inherited personal view/download unset in that case and copy edit=false.
WITH legacy AS (
SELECT
"subjectKey",
bool_or("enabled") FILTER (WHERE "action" = 'view') AS v,
bool_or("enabled") FILTER (WHERE "action" = 'download') AS d,
bool_or("enabled") FILTER (WHERE "action" = 'edit') AS e,
min("id") AS "sourceId",
max("createdBy") AS "createdBy", max("updatedBy") AS "updatedBy",
min("createdAt") AS "createdAt", max("updatedAt") AS "updatedAt"
FROM "SupplyChainPermissionOverride"
WHERE "targetKey" = 'businessManagement'
GROUP BY "subjectKey"
), access AS (
SELECT *,
CASE
WHEN "subjectKey" LIKE 'user@%' THEN
CASE WHEN v IS NOT NULL THEN v
WHEN d IS DISTINCT FROM FALSE AND (d IS TRUE OR e IS TRUE) THEN TRUE
ELSE NULL END
WHEN v IS FALSE THEN FALSE
WHEN d IS FALSE THEN v IS TRUE
ELSE v IS TRUE OR d IS TRUE OR e IS TRUE
END AS "newView",
CASE
WHEN "subjectKey" LIKE 'user@%' THEN
CASE WHEN v IS FALSE THEN FALSE
WHEN v IS TRUE THEN TRUE
WHEN d IS DISTINCT FROM FALSE AND (d IS TRUE OR e IS TRUE) THEN TRUE
ELSE NULL END
WHEN v IS FALSE THEN FALSE
WHEN d IS FALSE THEN v IS TRUE
ELSE v IS TRUE OR d IS TRUE OR e IS TRUE
END AS "newDownload",
CASE
WHEN "subjectKey" LIKE 'user@%' THEN
CASE WHEN v IS FALSE OR d IS FALSE THEN FALSE ELSE e END
ELSE v IS DISTINCT FROM FALSE AND d IS DISTINCT FROM FALSE AND e IS TRUE
END AS "newEdit"
FROM legacy
)
INSERT INTO "SupplyChainPermissionOverride"
("id", "targetKey", "subjectKey", "action", "enabled", "createdBy", "updatedBy", "createdAt", "updatedAt")
SELECT
'scp-' || md5(access."sourceId" || ':logisticsArrivalSigningDuration:' || actions."action"),
'logisticsArrivalSigningDuration', access."subjectKey", actions."action", actions."enabled",
access."createdBy", access."updatedBy", access."createdAt", access."updatedAt"
FROM access
CROSS JOIN LATERAL (VALUES
('view', access."newView"),
('download', access."newDownload"),
('edit', access."newEdit")
) AS actions("action", "enabled")
WHERE actions."enabled" IS NOT NULL
ON CONFLICT ("targetKey", "subjectKey", "action") DO NOTHING;
COMMIT;

View File

@ -34,10 +34,12 @@ export function SupplyChainLayout({
children,
visibleTargetKeys,
canEditOwnerConfigLabels = false,
editableTabRoutes,
}: {
children: ReactNode
visibleTargetKeys: readonly SupplyChainPermissionTargetKey[]
canEditOwnerConfigLabels?: boolean
editableTabRoutes?: readonly string[]
}) {
const homePath = visibleTargetKeys
.map((targetKey) => SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES[targetKey])
@ -77,7 +79,7 @@ export function SupplyChainLayout({
/>
) : null}
<div className="sc-layout-main">
<SupplyChainTabBar canEditOwnerConfigLabels={canEditOwnerConfigLabels} />
<SupplyChainTabBar canEditOwnerConfigLabels={canEditOwnerConfigLabels} editableTabRoutes={editableTabRoutes} />
<div className="sc-layout-content">{children}</div>
</div>
</div>

View File

@ -66,7 +66,7 @@ describe('SupplyChainSidebar', () => {
'/supply-chain/management/logistics-arrival-signing-duration',
)
render(<SupplyChainSidebar visibleTargetKeys={['businessManagement']} />)
render(<SupplyChainSidebar visibleTargetKeys={['logisticsArrivalSigningDuration']} />)
expect(screen.getByRole('button', { name: '经营管理' })).toHaveAttribute(
'aria-expanded',
@ -82,7 +82,7 @@ describe('SupplyChainSidebar', () => {
'/supply-chain/management/production-stop-demands',
)
render(<SupplyChainSidebar visibleTargetKeys={['businessManagement']} />)
render(<SupplyChainSidebar visibleTargetKeys={['productionStopDemands']} />)
expect(
screen.getByRole('link', { name: '停产需求管理' }),

View File

@ -31,6 +31,11 @@ beforeEach(() => {
})
describe('SupplyChainTabBar owner config labels', () => {
it('does not grant editing of other tab labels through owner-config permission', () => {
mockActivePath = '/supply-chain/management/custom-table'
render(<SupplyChainTabBar editableTabRoutes={['/supply-chain/management/owner-config']} />)
expect(screen.queryByRole('button', { name: '修改限价管理页签名称' })).not.toBeInTheDocument()
})
it('allows the development page title to be renamed and persisted', () => {
render(<SupplyChainTabBar canEditOwnerConfigLabels />)

View File

@ -13,8 +13,10 @@ const CUSTOM_TABLE_PATH = '/supply-chain/management/custom-table'
export function SupplyChainTabBar({
canEditOwnerConfigLabels = false,
editableTabRoutes,
}: {
canEditOwnerConfigLabels?: boolean
editableTabRoutes?: readonly string[]
}) {
const { tabs, activePath, clickTab, closeTab } = useSupplyChainTabs()
const { labels, updateLabel } = useOwnerConfigLabels()
@ -27,6 +29,7 @@ export function SupplyChainTabBar({
<div className="sc-tabbar">
{visibleTabs.map((tab) => {
const active = tab.path === activePath
const canEditLabel = editableTabRoutes?.includes(tab.path) ?? canEditOwnerConfigLabels
const isOwnerConfig = tab.path === OWNER_CONFIG_PATH
const isCustomTable = tab.path === CUSTOM_TABLE_PATH
const blankPage = MANAGEMENT_BLANK_PAGES.find((page) => page.route === tab.path)
@ -41,21 +44,21 @@ export function SupplyChainTabBar({
<EditableSupplyChainLabel
value={labels.pageTitle}
editLabel="修改页签名称"
allowEdit={active && canEditOwnerConfigLabels}
allowEdit={active && canEditLabel}
onSave={(value) => updateLabel('pageTitle', value)}
/>
) : isCustomTable ? (
<EditableSupplyChainLabel
value={customTableTitle}
editLabel="修改限价管理页签名称"
allowEdit={active && canEditOwnerConfigLabels}
allowEdit={active && canEditLabel}
onSave={updateCustomTableTitle}
/>
) : blankPage ? (
<EditableSupplyChainLabel
value={blankPageTitles[blankPage.key] ?? blankPage.label}
editLabel={`修改${blankPageTitles[blankPage.key] ?? blankPage.label}页签名称`}
allowEdit={active && canEditOwnerConfigLabels}
allowEdit={active && canEditLabel}
onSave={(value) => updateBlankPageTitle(blankPage.key, value)}
/>
) : tab.title}

View File

@ -142,7 +142,7 @@ describe('supply chain menu', () => {
})
it('places the logistics arrival signing duration dashboard under business management', () => {
expect(flattenSupplyChainMenu(filterSupplyChainMenu(['businessManagement']))).toEqual(
expect(flattenSupplyChainMenu(filterSupplyChainMenu(['logisticsArrivalSigningDuration']))).toEqual(
expect.arrayContaining([expect.objectContaining({
label: '物流到货签收时长看板',
module: '经营管理',
@ -152,7 +152,7 @@ describe('supply chain menu', () => {
})
it('places production stop demands under business management', () => {
expect(flattenSupplyChainMenu(filterSupplyChainMenu(['businessManagement']))).toEqual(
expect(flattenSupplyChainMenu(filterSupplyChainMenu(['productionStopDemands']))).toEqual(
expect.arrayContaining([expect.objectContaining({
label: '停产需求管理',
module: '经营管理',

View File

@ -5,8 +5,10 @@ import type { SupplyChainPermissionTargetKey } from '@/lib/supply-chain-permissi
import { MANAGEMENT_BLANK_PAGES } from '@/lib/supply-chain-management-blank-pages'
// 商品SABC打标(blank-page-3)的查看/导出走独立权限节点(默认全员开放,
// 可在用户管理后台关闭);其余经营管理空白页仍跟随「经营管理」节点。
// 可在用户管理后台关闭);每个空白页使用自己的权限节点。
const BLANK_PAGE_TARGET_KEYS: Record<string, SupplyChainPermissionTargetKey> = {
'blank-page-1': 'managementBlankPage1',
'blank-page-2': 'managementBlankPage2',
'blank-page-3': 'productSabc',
}
@ -77,7 +79,7 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
{
items: [
{ label: '打样进度明细', route: '/supply-chain/new-products/sample-progress', targetKey: 'newProductSampleProgress' },
{ label: '成品进度明细', route: '/supply-chain/purchase/finished-product-progress', targetKey: 'newProductSampleProgress' },
{ label: '成品进度明细', route: '/supply-chain/purchase/finished-product-progress', targetKey: 'finishedProductProgress' },
{ label: '供应商新品推荐', route: '/supply-chain/purchase/supplier-recommended-products', targetKey: 'purchaseSupplierRecommendedProducts' },
{ label: '供应商信息', route: '/supply-chain/purchase/supplier-information', targetKey: 'purchaseSupplierInformation' },
{ label: '日常事项', route: '/supply-chain/purchase/daily-affairs', targetKey: 'purchaseDailyAffairs' },
@ -96,28 +98,28 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
{
label: '人员信息',
route: '/supply-chain/management/owner-config',
targetKey: 'businessManagement',
targetKey: 'ownerConfig',
},
{
label: '限价管理',
route: '/supply-chain/management/custom-table',
targetKey: 'businessManagement',
targetKey: 'limitManagement',
},
{
label: '停产需求管理',
route: '/supply-chain/management/production-stop-demands',
targetKey: 'businessManagement',
targetKey: 'productionStopDemands',
},
...MANAGEMENT_BLANK_PAGES.map((page) => ({
label: page.label,
route: page.route,
targetKey: BLANK_PAGE_TARGET_KEYS[page.key] ?? 'businessManagement' as const,
targetKey: BLANK_PAGE_TARGET_KEYS[page.key]!,
})),
{ label: '员工工时统计', route: '/supply-chain/management/employee-hours', targetKey: 'employeeHours' },
{
label: '物流到货签收时长看板',
route: '/supply-chain/management/logistics-arrival-signing-duration',
targetKey: 'businessManagement',
targetKey: 'logisticsArrivalSigningDuration',
},
],
},

View File

@ -5,6 +5,7 @@ import { includeSampleProgressTargetForBusinessEditor } from '@/lib/sample-progr
import { requireSession } from '@/lib/server-session'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { SupplyChainLayout } from './components/SupplyChainLayout'
import { SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES, type SupplyChainPermissionTargetKey } from '@/lib/supply-chain-permission-settings'
// 浏览器标签页标题统一为工作区名,子页面不再各自覆盖,内部切换标签页时标题保持不变
export const metadata: Metadata = {
@ -24,17 +25,18 @@ export default async function SupplyChainRootLayout({ children }: { children: Re
authUser,
await supplyChainPermissionSettingsService.getVisibleTargetKeys(authUser),
)
const businessManagementAccess = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'businessManagement',
)
const labelTargets: SupplyChainPermissionTargetKey[] = ['ownerConfig', 'limitManagement', 'managementBlankPage1', 'managementBlankPage2', 'productSabc']
const editableTabRoutes = process.env.NODE_ENV === 'production' ? [] : (await Promise.all(
labelTargets.map(async (target) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(authUser, target)
return access.edit ? SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES[target] : undefined
}),
)).filter((route): route is string => Boolean(route))
return (
<SupplyChainLayout
visibleTargetKeys={visibleTargetKeys}
canEditOwnerConfigLabels={
process.env.NODE_ENV !== 'production' && businessManagementAccess.edit
}
editableTabRoutes={editableTabRoutes}
>
{children}
</SupplyChainLayout>

View File

@ -31,7 +31,10 @@ export default async function ManagementBlankPage({ params }: { params: Promise<
)
}
const access = await supplyChainPermissionSettingsService.getUserAccess(authUser, 'businessManagement')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
definition.key === 'blank-page-1' ? 'managementBlankPage1' : 'managementBlankPage2',
)
if (!access.view) redirect('/supply-chain')
return (

View File

@ -7,7 +7,7 @@ export default async function CustomTablePage() {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'businessManagement',
'limitManagement',
)
if (!access.view) redirect('/supply-chain')

View File

@ -23,7 +23,7 @@ export default async function LogisticsArrivalSigningDurationPage({
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'businessManagement',
'logisticsArrivalSigningDuration',
)
if (!access.view) redirect('/supply-chain')
@ -56,7 +56,7 @@ export default async function LogisticsArrivalSigningDurationPage({
initialData={initialData}
defaultExcludeKeywords={defaultExcludeKeywords}
canManageExcludeKeywords={canManageSupplyChainPermissions(authUser)}
canDownload={access.view}
canDownload={access.download}
dateRangeCleared={dateRangeCleared}
/>
)

View File

@ -7,7 +7,7 @@ export default async function OwnerConfigPage() {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'businessManagement',
'ownerConfig',
)
if (!access.view) redirect('/supply-chain')
const canEditDevelopmentData = process.env.NODE_ENV !== 'production' && access.edit

View File

@ -1,13 +1,11 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { filterSupplyChainMenu } from '../config/supply-chain-menu'
export default async function BusinessManagementPage() {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'businessManagement',
)
if (!access.view) redirect('/supply-chain')
redirect('/supply-chain/management/owner-config')
const visible = await supplyChainPermissionSettingsService.getVisibleTargetKeys(authUser)
const management = filterSupplyChainMenu(visible).find((module) => module.key === 'management')
redirect(management?.sections?.[0]?.items[0]?.route ?? '/supply-chain')
}

View File

@ -0,0 +1,41 @@
import { SUPPLY_CHAIN_MENU, filterSupplyChainMenu, flattenSupplyChainMenu } from '@/app/supply-chain/config/supply-chain-menu'
import { PERMISSION_MATRIX_SECTIONS } from '@/app/users/components/permissionMatrixGroups'
import { getSupplyChainApiPermissionRequirement, getSupplyChainPermissionTargetForPath, resolveSupplyChainPermissionAccess } from './supply-chain-permission-settings'
describe('independent supply-chain page permissions', () => {
const pages = SUPPLY_CHAIN_MENU.flatMap((module) => [
...(module.route ? [{ label: module.label, route: module.route, targetKey: module.targetKey! }] : []),
...(module.sections?.flatMap((section) => section.items) ?? []),
])
it('gives every menu page its own target, route mapping and permission row', () => {
expect(pages).toHaveLength(26)
expect(new Set(pages.map((page) => page.targetKey)).size).toBe(pages.length)
const matrixRows = PERMISSION_MATRIX_SECTIONS.find((section) => section.id === 'supply')!.groups.flatMap((group) => group.rows)
for (const page of pages) {
expect(getSupplyChainPermissionTargetForPath(page.route)).toBe(page.targetKey)
expect(matrixRows.filter((row) => row.targetKey === page.targetKey)).toEqual([
expect.objectContaining({ label: page.label }),
])
expect(flattenSupplyChainMenu(filterSupplyChainMenu([page.targetKey])).map((item) => item.route)).toEqual([page.route])
}
})
it('does not reuse old management or sample grants for the new nodes', () => {
const overrides = { 'businessManagement:purchase:edit': true, 'newProductSampleProgress:purchase:edit': true }
for (const target of ['ownerConfig', 'limitManagement', 'productionStopDemands', 'logisticsArrivalSigningDuration', 'managementBlankPage1', 'managementBlankPage2', 'finishedProductProgress'] as const) {
expect(resolveSupplyChainPermissionAccess(overrides, target, ['purchase'])).toEqual({ view: false, download: false, edit: false })
}
})
it('keeps logistics download and operation separate from visibility', () => {
const overrides = { 'logisticsArrivalSigningDuration:purchase:view': true }
expect(resolveSupplyChainPermissionAccess(overrides, 'logisticsArrivalSigningDuration', ['purchase'])).toEqual({ view: true, download: false, edit: false })
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/management/logistics-arrival-signing-duration/export', 'POST')).toEqual({ targetKey: 'logisticsArrivalSigningDuration', action: 'download' })
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/management/logistics-arrival-signing-duration/settings', 'PUT')).toEqual({ targetKey: 'logisticsArrivalSigningDuration', action: 'edit' })
})
it('guards finished-product mutations using its own node', () => {
expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/finished-product-progress/bulk', 'PATCH')).toEqual({ targetKey: 'finishedProductProgress', action: 'edit' })
})
})

View File

@ -0,0 +1,168 @@
import type { SupplyChainPermissionTargetKey } from './supply-chain-permission-targets'
export function getSupplyChainPermissionTargetForPath(pathname: string): SupplyChainPermissionTargetKey | null {
if (pathname === '/supply-chain/dashboard') return 'dashboard' as const
if (pathname === '/supply-chain/management/owner-config') return 'ownerConfig' as const
if (pathname === '/supply-chain/management/custom-table') return 'limitManagement' as const
// 商品SABC打标:明细走独立节点(默认开放),「各部门数据分类」子页走对应模块节点
if (pathname.startsWith('/supply-chain/management/blank-pages/blank-page-3/departments')) {
return 'departmentDistribution' as const
}
if (pathname.startsWith('/supply-chain/management/blank-pages/blank-page-3')) {
return 'productSabc' as const
}
if (pathname === '/supply-chain/management/blank-pages/blank-page-1') return 'managementBlankPage1' as const
if (pathname === '/supply-chain/management/blank-pages/blank-page-2') return 'managementBlankPage2' as const
if (pathname === '/supply-chain/management/employee-hours') return 'employeeHours' as const
if (pathname === '/supply-chain/management/logistics-arrival-signing-duration') return 'logisticsArrivalSigningDuration' as const
if (pathname === '/supply-chain/management/production-stop-demands') return 'productionStopDemands' as const
if (pathname === '/supply-chain/database/product-archive') return 'productArchive' as const
if (pathname === '/supply-chain/database/category-config') return 'categoryConfig' as const
if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const
if (pathname === '/supply-chain/database/store-account-management') return 'storeAccountManagement' as const
if (pathname === '/supply-chain/new-products/overview') return 'newProductOverview' as const
if (pathname === '/supply-chain/new-products/1688-dropship-data') return 'newProduct1688DropshipData' as const
if (pathname === '/supply-chain/new-products/termination-feedback') return 'newProductTerminationFeedback' as const
if (pathname === '/supply-chain/new-products/unified-table') return 'newProductUnifiedTable' as const
if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/finished-product-progress') return 'finishedProductProgress' as const
if (pathname === '/supply-chain/purchase/supplier-information') return 'purchaseSupplierInformation' as const
if (pathname === '/supply-chain/purchase/supplier-recommended-products') return 'purchaseSupplierRecommendedProducts' as const
if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const
if (pathname === '/supply-chain/purchase/price-change') return 'purchasePriceChange' as const
if (pathname === '/supply-chain/script-monitor') return 'scriptMonitor' as const
if (pathname === '/supply-chain/new-products/dropship-issues') return 'newProductDropshipIssues' as const
if (pathname === '/supply-chain/operation-logs') return 'operationLogs' as const
return null
}
export function getSupplyChainApiPermissionRequirement(pathname: string, method: string) {
if (pathname === '/api/supply-chain/permission-settings') return null
const action = method.toUpperCase() === 'GET' ? 'view' : 'edit'
if (pathname.startsWith('/api/supply-chain/management/logistics-arrival-signing-duration')) {
if (pathname.endsWith('/export')) {
// 导出独立校验下载权限。
return { targetKey: 'logisticsArrivalSigningDuration' as const, action: 'download' as const }
}
return { targetKey: 'logisticsArrivalSigningDuration' as const, action }
}
if (pathname.startsWith('/api/supply-chain/management/employee-hours')) {
return { targetKey: 'employeeHours' as const, action: 'view' as const }
}
if (pathname.startsWith('/api/supply-chain/management/production-stop-demands')) {
// 指定确认人的确认接口与列表只读权限在服务层单独校验(确认人可能没有停产需求管理权限)。
if (pathname === '/api/supply-chain/management/production-stop-demands/confirmations') {
return null
}
// 需求状态流转:重新提交需停产编辑权限,强制确认/走流程需流程负责人权限,均在服务层校验。
if (/^\/api\/supply-chain\/management\/production-stop-demands\/demands\/(status|[^/]+\/status)$/.test(pathname)) {
return null
}
if (pathname === '/api/supply-chain/management/production-stop-demands' && method.toUpperCase() === 'GET') {
return null
}
return { targetKey: 'productionStopDemands' as const, action: pathname.endsWith('/export') ? 'download' as const : action }
}
if (pathname === '/api/supply-chain/1688-dropship-data/filter-options') {
return { targetKey: 'newProduct1688DropshipData' as const, action: method.toUpperCase() === 'POST' ? 'view' as const : action }
}
if (pathname === '/api/supply-chain/1688-dropship-data/source-image') {
return { targetKey: 'newProduct1688DropshipData' as const, action }
}
if (pathname === '/api/supply-chain/1688-dropship-data/sourcing-list') {
return { targetKey: 'newProduct1688DropshipData' as const, action: 'view' as const }
}
if (pathname === '/api/supply-chain/1688-dropship-data/sourcing-export') {
return { targetKey: 'newProduct1688DropshipData' as const, action: 'download' as const }
}
if (pathname === '/api/supply-chain/1688-dropship-data/sourcing-import') {
return { targetKey: 'newProduct1688DropshipData' as const, action: 'edit' as const }
}
if (pathname.startsWith('/api/supply-chain/new-products/dropship-issues')) {
return { targetKey: 'newProductDropshipIssues' as const, action }
}
if (pathname.startsWith('/api/supply-chain/product-archive/dashboard')) {
return { targetKey: 'dashboard' as const, action }
}
if (pathname.startsWith('/api/supply-chain/product-archive')) {
return {
targetKey: 'productArchive' as const,
action: pathname.endsWith('/image-search') ? 'view' as const : action,
}
}
// 限价管理:数据复用产品全量档案,接口使用独立的限价查看权限。
if (pathname.startsWith('/api/supply-chain/limit-management')) {
return { targetKey: 'limitManagement' as const, action: 'view' as const }
}
// 商品SABC打标 · 商品明细:可见 / 下载(默认开放)
if (pathname.startsWith('/api/supply-chain/product-sabc/export')) {
return { targetKey: 'productSabc' as const, action: 'download' as const }
}
// 商品SABC打标 · 各部门数据分类:可见 / 下载 走独立模块节点
if (pathname.startsWith('/api/supply-chain/product-sabc/departments/settings')) {
return {
targetKey: 'departmentDistributionConfig' as const,
action: method.toUpperCase() === 'GET' ? 'view' as const : 'edit' as const,
}
}
if (pathname.startsWith('/api/supply-chain/product-sabc/departments/export')) {
return { targetKey: 'departmentDistribution' as const, action: 'download' as const }
}
if (pathname.startsWith('/api/supply-chain/product-sabc/departments')) {
return {
targetKey: 'departmentDistribution' as const,
action: method.toUpperCase() === 'GET' ? 'view' as const : 'edit' as const,
}
}
// 商品SABC打标:商品明细与执行核算分别使用对应的独立节点。
if (pathname.startsWith('/api/supply-chain/product-sabc') && method.toUpperCase() !== 'GET') {
return { targetKey: 'productSabcCalculation' as const, action: 'edit' as const }
}
if (pathname.startsWith('/api/supply-chain/product-sabc')) {
return { targetKey: 'productSabc' as const, action }
}
if (pathname.startsWith('/api/supply-chain/dingtalk-workflow-monitor')) {
return { targetKey: 'ownerConfig' as const, action }
}
if (pathname.startsWith('/api/supply-chain/category-config')) {
return {
targetKey: 'categoryConfig' as const,
action: pathname.endsWith('/export') ? 'download' as const : action,
}
}
if (pathname.startsWith('/api/supply-chain/warehouse-config')) {
return { targetKey: 'warehouseConfig' as const, action }
}
if (pathname === '/api/supply-chain/management/owner-config/export') {
return { targetKey: 'ownerConfig' as const, action: 'download' as const }
}
if (pathname.startsWith('/api/supply-chain/management/owner-config')) {
return { targetKey: 'ownerConfig' as const, action }
}
if (pathname.startsWith('/api/supply-chain/store-account-management')) {
return { targetKey: 'storeAccountManagement' as const, action }
}
if (pathname.startsWith('/api/supply-chain/finished-product-progress')) {
return {
targetKey: 'finishedProductProgress' as const,
action: pathname.endsWith('/template') ? 'download' as const : action,
}
}
if (pathname.startsWith('/api/supply-chain/new-product-termination-feedback')) {
return { targetKey: 'newProductTerminationFeedback' as const, action }
}
if (pathname.startsWith('/api/supply-chain/supplier-information')) {
return { targetKey: 'purchaseSupplierInformation' as const, action }
}
if (pathname.startsWith('/api/supply-chain/supplier-recommended-products')) {
return {
targetKey: 'purchaseSupplierRecommendedProducts' as const,
action: pathname.endsWith('/template') || pathname.endsWith('/export') ? 'download' as const : pathname.endsWith('/selection') || pathname.endsWith('/dashboard') ? 'view' as const : action,
}
}
if (pathname.startsWith('/api/supply-chain/script-monitor')) {
return { targetKey: 'scriptMonitor' as const, action }
}
return undefined
}

View File

@ -15,10 +15,10 @@ describe('supply chain permission settings', () => {
it('requires business management view for owner queries and edit for mutations', () => {
const path = '/api/supply-chain/management/owner-config'
expect(getSupplyChainApiPermissionRequirement(path, 'GET'))
.toEqual({ targetKey: 'businessManagement', action: 'view' })
.toEqual({ targetKey: 'ownerConfig', action: 'view' })
for (const method of ['POST', 'PATCH', 'DELETE']) {
expect(getSupplyChainApiPermissionRequirement(`${path}/config-1`, method))
.toEqual({ targetKey: 'businessManagement', action: 'edit' })
.toEqual({ targetKey: 'ownerConfig', action: 'edit' })
}
})
@ -26,7 +26,7 @@ describe('supply chain permission settings', () => {
expect(getSupplyChainApiPermissionRequirement(
'/api/supply-chain/management/owner-config/export',
'GET',
)).toEqual({ targetKey: 'businessManagement', action: 'download' })
)).toEqual({ targetKey: 'ownerConfig', action: 'download' })
})
it('keeps ordinary business types closed until an administrator explicitly opens them', () => {
@ -230,19 +230,19 @@ describe('supply chain permission settings', () => {
).toBe('purchaseSupplierInformation')
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/management'),
).toBe('businessManagement')
).toBeNull()
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/management/owner-config'),
).toBe('businessManagement')
).toBe('ownerConfig')
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/management/custom-table'),
).toBe('businessManagement')
).toBe('limitManagement')
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/management/blank-pages/blank-page-1'),
).toBe('businessManagement')
).toBe('managementBlankPage1')
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/management/blank-pages/future-page'),
).toBe('businessManagement')
).toBeNull()
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/script-monitor'),
).toBe('scriptMonitor')
@ -341,32 +341,32 @@ describe('supply chain permission settings', () => {
it('maps the logistics arrival signing duration dashboard and its APIs to business management', () => {
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/management/logistics-arrival-signing-duration'),
).toBe('businessManagement')
).toBe('logisticsArrivalSigningDuration')
expect(
getSupplyChainApiPermissionRequirement(
'/api/supply-chain/management/logistics-arrival-signing-duration',
'GET',
),
).toEqual({ targetKey: 'businessManagement', action: 'view' })
).toEqual({ targetKey: 'logisticsArrivalSigningDuration', action: 'view' })
// 导出按 view 放行(能否导出由页面自身控制),写入类接口按 edit 校验。
expect(
getSupplyChainApiPermissionRequirement(
'/api/supply-chain/management/logistics-arrival-signing-duration/export',
'POST',
),
).toEqual({ targetKey: 'businessManagement', action: 'view' })
).toEqual({ targetKey: 'logisticsArrivalSigningDuration', action: 'download' })
expect(
getSupplyChainApiPermissionRequirement(
'/api/supply-chain/management/logistics-arrival-signing-duration/settings',
'PUT',
),
).toEqual({ targetKey: 'businessManagement', action: 'edit' })
).toEqual({ targetKey: 'logisticsArrivalSigningDuration', action: 'edit' })
})
it('maps production stop demand routes and API exemptions', () => {
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/management/production-stop-demands'),
).toBe('businessManagement')
).toBe('productionStopDemands')
expect(
getSupplyChainApiPermissionRequirement(
'/api/supply-chain/management/production-stop-demands',
@ -378,7 +378,7 @@ describe('supply chain permission settings', () => {
'/api/supply-chain/management/production-stop-demands',
'POST',
),
).toEqual({ targetKey: 'businessManagement', action: 'edit' })
).toEqual({ targetKey: 'productionStopDemands', action: 'edit' })
expect(
getSupplyChainApiPermissionRequirement(
'/api/supply-chain/management/production-stop-demands/confirmations',

View File

@ -8,7 +8,9 @@ import {
import { isExternalIntakeUser } from '@/lib/non-integrated-external-entry-permissions'
import { isAdmin, isSupervisor } from '@/lib/role-scope'
import { isSupplierAccount } from '@/lib/supplier-account'
import { isManagementBlankPagePath } from '@/lib/supply-chain-management-blank-pages'
import { SUPPLY_CHAIN_DEFAULT_OPEN_TARGETS, SUPPLY_CHAIN_PERMISSION_TARGETS, type SupplyChainPermissionTargetKey } from './supply-chain-permission-targets'
export * from './supply-chain-permission-targets'
export { getSupplyChainPermissionTargetForPath, getSupplyChainApiPermissionRequirement } from './supply-chain-permission-paths'
export type SupplyChainPermissionAction = 'view' | 'download' | 'edit'
export type SupplyChainPermissionSubjectKey = Exclude<
@ -18,34 +20,6 @@ export type SupplyChainPermissionSubjectKey = Exclude<
export type SupplyChainPermissionRoleKey =
| SupplyChainPermissionSubjectKey
| IntakeNodePermissionDynamicRoleKey
export type SupplyChainPermissionTargetKey =
| 'workspace'
| 'dashboard'
| 'businessManagement'
| 'employeeHours'
| 'productArchive'
| 'categoryConfig'
| 'warehouseConfig'
| 'storeAccountManagement'
| 'newProductOverview'
| 'newProduct1688DropshipData'
| 'newProductTerminationFeedback'
| 'newProductUnifiedTable'
| 'newProductSampleProgress'
| 'purchaseSupplierInformation'
| 'purchaseSupplierRecommendedProducts'
| 'purchaseDailyAffairs'
| 'purchasePriceChange'
| 'scriptMonitor'
| 'newProductDropshipIssues'
| 'operationLogs'
// 页面内的动作权限:商品SABC打标「执行核算」(只走权限配置,不占侧边栏菜单)
| 'productSabcCalculation'
| 'productSabc'
// 模块权限:商品SABC打标「各部门数据分类」(可见/下载分开控制)
| 'departmentDistribution'
// 商品SABC打标「各部门数据分类」的配置(品牌范围/兜底规则/目标参数)
| 'departmentDistributionConfig'
export type SupplyChainPermissionSubject = Omit<
IntakeNodePermissionSubject,
@ -54,12 +28,6 @@ export type SupplyChainPermissionSubject = Omit<
key: SupplyChainPermissionSubjectKey
}
export type SupplyChainPermissionTarget = {
key: SupplyChainPermissionTargetKey
label: string
description: string
}
export type SupplyChainPermissionOverrideMap = Record<string, boolean>
export type SupplyChainPermissionPrincipalKey =
| SupplyChainPermissionRoleKey
@ -84,49 +52,6 @@ export const SUPPLY_CHAIN_PERMISSION_ACTION_LABELS: Record<
edit: '操作',
}
export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
{ key: 'dashboard', label: '工作台', description: '供应链工作台总览' },
{ key: 'businessManagement', label: '经营管理', description: '后续经营类功能统一入口' },
{ key: 'employeeHours', label: '员工工时统计', description: '员工本人、部门或全公司数据范围单独设置' },
{ key: 'productArchive', label: '产品全量档案', description: '商品、供应链和运营档案' },
{ key: 'categoryConfig', label: '类目配置', description: '产品全量档案类目维护' },
{ key: 'warehouseConfig', label: '仓库配置', description: '出入库仓库维护' },
{ key: 'storeAccountManagement', label: '店铺账号管理', description: '店铺账号人工维护与权限控制' },
{ key: 'newProductOverview', label: '新品经营总览', description: '新品经营指标和阶段时效分析' },
{ key: 'newProduct1688DropshipData', label: '1688代发数据明细', description: '1688 导单店铺销售、履约和退款明细' },
{ key: 'newProductTerminationFeedback', label: '新品终止任务反馈', description: '维护已撤销、已拒绝和已终止流程的原因' },
{ key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' },
{ key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' },
{ key: 'purchaseSupplierInformation', label: '供应商信息', description: '采购供应商信息汇总' },
{ key: 'purchaseSupplierRecommendedProducts', label: '供应商新品推荐', description: '供应商推荐新品维护' },
{ key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' },
{ key: 'purchasePriceChange', label: '产品变价', description: '采购价格变更协作' },
{ key: 'scriptMonitor', label: '脚本监控', description: '跨机器脚本任务、状态和运行结果' },
{ key: 'newProductDropshipIssues', label: '1688代发异常事项', description: '1688代发异常事项' },
{ key: 'operationLogs', label: '操作日志', description: '供应链操作审计' },
{ key: 'productSabcCalculation', label: '商品SABC打标 · 执行核算', description: '每月核算与重新生成商品SABC打标数据(页面内按钮)' },
{ key: 'departmentDistribution', label: '商品SABC打标 · 各部门数据分类', description: '按利润中心汇总的部门数据(可见、下载分别控制)' },
{ key: 'productSabc', label: '商品SABC打标 · 商品明细', description: '商品SABC打标明细页与明细导出(默认全员开放,可在用户管理后台关闭)' },
{ key: 'departmentDistributionConfig', label: '商品SABC打标 · 部门数据配置', description: '维护品牌范围、负责人兜底与目标参数(操作列控制)' },
]
/**
* 只用于权限配置、不在侧边栏展示的额外权限节点。
* 按菜单模块归组,权限页会把它挂到对应模块下面。
*/
export const SUPPLY_CHAIN_PERMISSION_EXTRA_TARGETS: Record<string, SupplyChainPermissionTargetKey[]> = {
management: ['productSabcCalculation', 'departmentDistribution', 'departmentDistributionConfig'],
}
/**
* 默认开放的权限节点:不做任何配置时所有人都可以查看/导出;
* 只有管理员在「用户管理后台」里显式把「可见/下载」关掉,才会受限。
*/
export const SUPPLY_CHAIN_DEFAULT_OPEN_TARGETS = new Set<SupplyChainPermissionTargetKey>([
'productSabc',
'departmentDistribution',
])
/**
* 按角色的默认权限(只针对少数节点):
* 商品企划(企划部/商品计划部)默认可以维护「部门数据配置」;
@ -144,34 +69,6 @@ export function getSupplyChainTargetRoleDefaultAccess(
return null
}
export const SUPPLY_CHAIN_PERMISSION_TARGET_KEYS = SUPPLY_CHAIN_PERMISSION_TARGETS.map(
(target) => target.key,
) as SupplyChainPermissionTargetKey[]
export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial<
Record<SupplyChainPermissionTargetKey, string>
> = {
dashboard: '/supply-chain/dashboard',
businessManagement: '/supply-chain/management/owner-config',
employeeHours: '/supply-chain/management/employee-hours',
productArchive: '/supply-chain/database/product-archive',
categoryConfig: '/supply-chain/database/category-config',
warehouseConfig: '/supply-chain/database/warehouse-config',
storeAccountManagement: '/supply-chain/database/store-account-management',
newProductOverview: '/supply-chain/new-products/overview',
newProduct1688DropshipData: '/supply-chain/new-products/1688-dropship-data',
newProductTerminationFeedback: '/supply-chain/new-products/termination-feedback',
newProductUnifiedTable: '/supply-chain/new-products/unified-table',
newProductSampleProgress: '/supply-chain/new-products/sample-progress',
purchaseSupplierInformation: '/supply-chain/purchase/supplier-information',
purchaseSupplierRecommendedProducts: '/supply-chain/purchase/supplier-recommended-products',
purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs',
purchasePriceChange: '/supply-chain/purchase/price-change',
scriptMonitor: '/supply-chain/script-monitor',
newProductDropshipIssues: '/supply-chain/new-products/dropship-issues',
operationLogs: '/supply-chain/operation-logs',
}
const SUPPLY_CHAIN_PERMISSION_SUBJECT_KEYS = new Set<SupplyChainPermissionSubjectKey>([
'customerService',
'design',
@ -190,7 +87,7 @@ const SUPPLY_CHAIN_PERMISSION_SUBJECT_KEYS = new Set<SupplyChainPermissionSubjec
])
const SUPPLY_CHAIN_PERMISSION_TARGET_KEY_SET = new Set<SupplyChainPermissionTargetKey>(
['workspace', ...SUPPLY_CHAIN_PERMISSION_TARGETS.map((target) => target.key)],
['workspace', 'businessManagement', ...SUPPLY_CHAIN_PERMISSION_TARGETS.map((target) => target.key)],
)
const SUPPLY_CHAIN_PERMISSION_ACTION_SET = new Set<SupplyChainPermissionAction>([
'view',
@ -384,170 +281,3 @@ export function resolveSupplyChainPermissionAccess(
return { view, download, edit }
}
export function getSupplyChainPermissionTargetForPath(pathname: string) {
if (pathname === '/supply-chain/dashboard') return 'dashboard' as const
if (pathname === '/supply-chain/management') return 'businessManagement' as const
if (pathname === '/supply-chain/management/owner-config') return 'businessManagement' as const
if (pathname === '/supply-chain/management/custom-table') return 'businessManagement' as const
// 商品SABC打标:明细走独立节点(默认开放),「各部门数据分类」子页走对应模块节点
if (pathname.startsWith('/supply-chain/management/blank-pages/blank-page-3/departments')) {
return 'departmentDistribution' as const
}
if (pathname.startsWith('/supply-chain/management/blank-pages/blank-page-3')) {
return 'productSabc' as const
}
if (isManagementBlankPagePath(pathname)) return 'businessManagement' as const
if (pathname === '/supply-chain/management/employee-hours') return 'employeeHours' as const
if (pathname === '/supply-chain/management/logistics-arrival-signing-duration') return 'businessManagement' as const
if (pathname === '/supply-chain/management/production-stop-demands') return 'businessManagement' as const
if (pathname === '/supply-chain/database/product-archive') return 'productArchive' as const
if (pathname === '/supply-chain/database/category-config') return 'categoryConfig' as const
if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const
if (pathname === '/supply-chain/database/store-account-management') return 'storeAccountManagement' as const
if (pathname === '/supply-chain/new-products/overview') return 'newProductOverview' as const
if (pathname === '/supply-chain/new-products/1688-dropship-data') return 'newProduct1688DropshipData' as const
if (pathname === '/supply-chain/new-products/termination-feedback') return 'newProductTerminationFeedback' as const
if (pathname === '/supply-chain/new-products/unified-table') return 'newProductUnifiedTable' as const
if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/finished-product-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/supplier-information') return 'purchaseSupplierInformation' as const
if (pathname === '/supply-chain/purchase/supplier-recommended-products') return 'purchaseSupplierRecommendedProducts' as const
if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const
if (pathname === '/supply-chain/purchase/price-change') return 'purchasePriceChange' as const
if (pathname === '/supply-chain/script-monitor') return 'scriptMonitor' as const
if (pathname === '/supply-chain/new-products/dropship-issues') return 'newProductDropshipIssues' as const
if (pathname === '/supply-chain/operation-logs') return 'operationLogs' as const
return null
}
export function getSupplyChainApiPermissionRequirement(pathname: string, method: string) {
if (pathname === '/api/supply-chain/permission-settings') return null
const action = method.toUpperCase() === 'GET' ? 'view' : 'edit'
if (pathname.startsWith('/api/supply-chain/management/logistics-arrival-signing-duration')) {
if (pathname.endsWith('/export')) {
// 该看板允许所有能看的人导出当前筛选明细。
return { targetKey: 'businessManagement' as const, action: 'view' as const }
}
return { targetKey: 'businessManagement' as const, action }
}
if (pathname.startsWith('/api/supply-chain/management/employee-hours')) {
return { targetKey: 'employeeHours' as const, action: 'view' as const }
}
if (pathname.startsWith('/api/supply-chain/management/production-stop-demands')) {
// 指定确认人的确认接口与列表只读权限在服务层单独校验(确认人可能没有经营管理权限)。
if (pathname === '/api/supply-chain/management/production-stop-demands/confirmations') {
return null
}
// 需求状态流转:重新提交需经营管理编辑权限,强制确认/走流程需流程负责人权限,均在服务层校验。
if (/^\/api\/supply-chain\/management\/production-stop-demands\/demands\/(status|[^/]+\/status)$/.test(pathname)) {
return null
}
if (pathname === '/api/supply-chain/management/production-stop-demands' && method.toUpperCase() === 'GET') {
return null
}
return { targetKey: 'businessManagement' as const, action }
}
if (pathname === '/api/supply-chain/1688-dropship-data/filter-options') {
return { targetKey: 'newProduct1688DropshipData' as const, action: method.toUpperCase() === 'POST' ? 'view' as const : action }
}
if (pathname === '/api/supply-chain/1688-dropship-data/source-image') {
return { targetKey: 'newProduct1688DropshipData' as const, action }
}
if (pathname === '/api/supply-chain/1688-dropship-data/sourcing-list') {
return { targetKey: 'newProduct1688DropshipData' as const, action: 'view' as const }
}
if (pathname === '/api/supply-chain/1688-dropship-data/sourcing-export') {
return { targetKey: 'newProduct1688DropshipData' as const, action: 'download' as const }
}
if (pathname === '/api/supply-chain/1688-dropship-data/sourcing-import') {
return { targetKey: 'newProduct1688DropshipData' as const, action: 'edit' as const }
}
if (pathname.startsWith('/api/supply-chain/new-products/dropship-issues')) {
return { targetKey: 'newProductDropshipIssues' as const, action }
}
if (pathname.startsWith('/api/supply-chain/product-archive/dashboard')) {
return { targetKey: 'dashboard' as const, action }
}
if (pathname.startsWith('/api/supply-chain/product-archive')) {
return {
targetKey: 'productArchive' as const,
action: pathname.endsWith('/image-search') ? 'view' as const : action,
}
}
// 限价管理(经营管理):数据复用产品全量档案,权限跟随页面入口;接口均为只读查询。
if (pathname.startsWith('/api/supply-chain/limit-management')) {
return { targetKey: 'businessManagement' as const, action: 'view' as const }
}
// 商品SABC打标 · 商品明细:可见 / 下载(默认开放)
if (pathname.startsWith('/api/supply-chain/product-sabc/export')) {
return { targetKey: 'productSabc' as const, action: 'download' as const }
}
// 商品SABC打标 · 各部门数据分类:可见 / 下载 走独立模块节点
if (pathname.startsWith('/api/supply-chain/product-sabc/departments/settings')) {
return {
targetKey: 'departmentDistributionConfig' as const,
action: method.toUpperCase() === 'GET' ? 'view' as const : 'edit' as const,
}
}
if (pathname.startsWith('/api/supply-chain/product-sabc/departments/export')) {
return { targetKey: 'departmentDistribution' as const, action: 'download' as const }
}
if (pathname.startsWith('/api/supply-chain/product-sabc/departments')) {
return {
targetKey: 'departmentDistribution' as const,
action: method.toUpperCase() === 'GET' ? 'view' as const : 'edit' as const,
}
}
// 商品SABC打标:读接口按「经营管理」可见,写接口(执行核算)按独立权限节点校验
if (pathname.startsWith('/api/supply-chain/product-sabc') && method.toUpperCase() !== 'GET') {
return { targetKey: 'productSabcCalculation' as const, action: 'edit' as const }
}
if (pathname.startsWith('/api/supply-chain/product-sabc')) {
return { targetKey: 'productSabc' as const, action }
}
if (pathname.startsWith('/api/supply-chain/dingtalk-workflow-monitor')) {
return { targetKey: 'businessManagement' as const, action }
}
if (pathname.startsWith('/api/supply-chain/category-config')) {
return {
targetKey: 'categoryConfig' as const,
action: pathname.endsWith('/export') ? 'download' as const : action,
}
}
if (pathname.startsWith('/api/supply-chain/warehouse-config')) {
return { targetKey: 'warehouseConfig' as const, action }
}
if (pathname === '/api/supply-chain/management/owner-config/export') {
return { targetKey: 'businessManagement' as const, action: 'download' as const }
}
if (pathname.startsWith('/api/supply-chain/management/owner-config')) {
return { targetKey: 'businessManagement' as const, action }
}
if (pathname.startsWith('/api/supply-chain/store-account-management')) {
return { targetKey: 'storeAccountManagement' as const, action }
}
if (pathname.startsWith('/api/supply-chain/finished-product-progress')) {
return {
targetKey: 'newProductSampleProgress' as const,
action: pathname.endsWith('/template') ? 'download' as const : action,
}
}
if (pathname.startsWith('/api/supply-chain/new-product-termination-feedback')) {
return { targetKey: 'newProductTerminationFeedback' as const, action }
}
if (pathname.startsWith('/api/supply-chain/supplier-information')) {
return { targetKey: 'purchaseSupplierInformation' as const, action }
}
if (pathname.startsWith('/api/supply-chain/supplier-recommended-products')) {
return {
targetKey: 'purchaseSupplierRecommendedProducts' as const,
action: pathname.endsWith('/template') || pathname.endsWith('/export') ? 'download' as const : pathname.endsWith('/selection') || pathname.endsWith('/dashboard') ? 'view' as const : action,
}
}
if (pathname.startsWith('/api/supply-chain/script-monitor')) {
return { targetKey: 'scriptMonitor' as const, action }
}
return undefined
}

View File

@ -0,0 +1,132 @@
export type SupplyChainPermissionTargetKey =
| 'workspace'
| 'dashboard'
// Historical key is accepted only to retain existing permission records.
| 'businessManagement'
| 'ownerConfig'
| 'limitManagement'
| 'productionStopDemands'
| 'logisticsArrivalSigningDuration'
| 'managementBlankPage1'
| 'managementBlankPage2'
| 'finishedProductProgress'
| 'employeeHours'
| 'productArchive'
| 'categoryConfig'
| 'warehouseConfig'
| 'storeAccountManagement'
| 'newProductOverview'
| 'newProduct1688DropshipData'
| 'newProductTerminationFeedback'
| 'newProductUnifiedTable'
| 'newProductSampleProgress'
| 'purchaseSupplierInformation'
| 'purchaseSupplierRecommendedProducts'
| 'purchaseDailyAffairs'
| 'purchasePriceChange'
| 'scriptMonitor'
| 'newProductDropshipIssues'
| 'operationLogs'
// 页面内的动作权限:商品SABC打标「执行核算」(只走权限配置,不占侧边栏菜单)
| 'productSabcCalculation'
| 'productSabc'
// 模块权限:商品SABC打标「各部门数据分类」(可见/下载分开控制)
| 'departmentDistribution'
// 商品SABC打标「各部门数据分类」的配置(品牌范围/兜底规则/目标参数)
| 'departmentDistributionConfig'
export type SupplyChainPermissionTarget = {
key: SupplyChainPermissionTargetKey
label: string
description: string
}
export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
{ key: 'dashboard', label: '工作台', description: '供应链工作台总览' },
{ key: 'ownerConfig', label: '人员信息', description: '人员目录查看与导出' },
{ key: 'limitManagement', label: '限价管理', description: '限价查询与维护' },
{ key: 'productionStopDemands', label: '停产需求管理', description: '停产需求提单与处理' },
{ key: 'logisticsArrivalSigningDuration', label: '物流到货签收时长看板', description: '物流签收查询与明细导出' },
{ key: 'managementBlankPage1', label: '空白页 1', description: '经营管理空白页 1' },
{ key: 'managementBlankPage2', label: '空白页 2', description: '经营管理空白页 2' },
{ key: 'finishedProductProgress', label: '成品进度明细', description: '成品进度查看、导出与维护' },
{ key: 'employeeHours', label: '员工工时统计', description: '员工本人、部门或全公司数据范围单独设置' },
{ key: 'productArchive', label: '产品全量档案', description: '商品、供应链和运营档案' },
{ key: 'categoryConfig', label: '类目配置', description: '产品全量档案类目维护' },
{ key: 'warehouseConfig', label: '仓库配置', description: '出入库仓库维护' },
{ key: 'storeAccountManagement', label: '店铺账号管理', description: '店铺账号人工维护与权限控制' },
{ key: 'newProductOverview', label: '新品经营总览', description: '新品经营指标和阶段时效分析' },
{ key: 'newProduct1688DropshipData', label: '1688代发数据明细', description: '1688 导单店铺销售、履约和退款明细' },
{ key: 'newProductTerminationFeedback', label: '新品终止任务反馈', description: '维护已撤销、已拒绝和已终止流程的原因' },
{ key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' },
{ key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' },
{ key: 'purchaseSupplierInformation', label: '供应商信息', description: '采购供应商信息汇总' },
{ key: 'purchaseSupplierRecommendedProducts', label: '供应商新品推荐', description: '供应商推荐新品维护' },
{ key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' },
{ key: 'purchasePriceChange', label: '产品变价', description: '采购价格变更协作' },
{ key: 'scriptMonitor', label: '脚本监控', description: '跨机器脚本任务、状态和运行结果' },
{ key: 'newProductDropshipIssues', label: '1688代发异常事项', description: '1688代发异常事项' },
{ key: 'operationLogs', label: '操作日志', description: '供应链操作审计' },
{ key: 'productSabcCalculation', label: '商品SABC打标 · 执行核算', description: '每月核算与重新生成商品SABC打标数据(页面内按钮)' },
{ key: 'departmentDistribution', label: '商品SABC打标 · 各部门数据分类', description: '按利润中心汇总的部门数据(可见、下载分别控制)' },
{ key: 'productSabc', label: '商品SABC打标 · 商品明细', description: '商品SABC打标明细页与明细导出(默认全员开放,可在用户管理后台关闭)' },
{ key: 'departmentDistributionConfig', label: '商品SABC打标 · 部门数据配置', description: '维护品牌范围、负责人兜底与目标参数(操作列控制)' },
]
/**
* 只用于权限配置、不在侧边栏展示的额外权限节点。
* 按菜单模块归组,权限页会把它挂到对应模块下面。
*/
export const SUPPLY_CHAIN_PERMISSION_EXTRA_TARGETS: Record<string, SupplyChainPermissionTargetKey[]> = {
management: ['productSabcCalculation', 'departmentDistribution', 'departmentDistributionConfig'],
}
/**
* 默认开放的权限节点:不做任何配置时所有人都可以查看/导出;
* 只有管理员在「用户管理后台」里显式把「可见/下载」关掉,才会受限。
*/
export const SUPPLY_CHAIN_DEFAULT_OPEN_TARGETS = new Set<SupplyChainPermissionTargetKey>([
'productSabc',
'departmentDistribution',
])
export const SUPPLY_CHAIN_PERMISSION_TARGET_KEYS = SUPPLY_CHAIN_PERMISSION_TARGETS.map(
(target) => target.key,
) as SupplyChainPermissionTargetKey[]
export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial<
Record<SupplyChainPermissionTargetKey, string>
> = {
productSabc: '/supply-chain/management/blank-pages/blank-page-3',
dashboard: '/supply-chain/dashboard',
ownerConfig: '/supply-chain/management/owner-config',
limitManagement: '/supply-chain/management/custom-table',
productionStopDemands: '/supply-chain/management/production-stop-demands',
logisticsArrivalSigningDuration: '/supply-chain/management/logistics-arrival-signing-duration',
managementBlankPage1: '/supply-chain/management/blank-pages/blank-page-1',
managementBlankPage2: '/supply-chain/management/blank-pages/blank-page-2',
finishedProductProgress: '/supply-chain/purchase/finished-product-progress',
employeeHours: '/supply-chain/management/employee-hours',
productArchive: '/supply-chain/database/product-archive',
categoryConfig: '/supply-chain/database/category-config',
warehouseConfig: '/supply-chain/database/warehouse-config',
storeAccountManagement: '/supply-chain/database/store-account-management',
newProductOverview: '/supply-chain/new-products/overview',
newProduct1688DropshipData: '/supply-chain/new-products/1688-dropship-data',
newProductTerminationFeedback: '/supply-chain/new-products/termination-feedback',
newProductUnifiedTable: '/supply-chain/new-products/unified-table',
newProductSampleProgress: '/supply-chain/new-products/sample-progress',
purchaseSupplierInformation: '/supply-chain/purchase/supplier-information',
purchaseSupplierRecommendedProducts: '/supply-chain/purchase/supplier-recommended-products',
purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs',
purchasePriceChange: '/supply-chain/purchase/price-change',
scriptMonitor: '/supply-chain/script-monitor',
newProductDropshipIssues: '/supply-chain/new-products/dropship-issues',
operationLogs: '/supply-chain/operation-logs',
}

View File

@ -10,7 +10,7 @@ export class FinishedProductBulkUpdateService {
constructor(private readonly repository = finishedProductBulkUpdateRepository) {}
async update(input: unknown, user: AuthUser) {
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'newProductSampleProgress')
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'finishedProductProgress')
if (!access.edit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有成品进度明细编辑权限')
const parsed = finishedProductBulkUpdateSchema.safeParse(input)
if (!parsed.success) throw new AppError(ErrorCode.VALIDATION_FAILED, parsed.error.issues[0]?.message ?? '批量修改参数不正确')

View File

@ -8,7 +8,7 @@ import { supplyChainPermissionSettingsService } from './supply-chain-permission-
export const finishedProductDirectoryService = {
async listOptions(search: string, user: AuthUser, kind?: 'supplier' | 'brand'): Promise<FinishedProductEditOptions> {
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'newProductSampleProgress')
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'finishedProductProgress')
if (!access.edit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有成品进度明细编辑权限')
const directory = kind === 'brand' ? { suppliers: [] } : await jstPlanningDirectoryService.listOptions()
const keyword = search.trim().toLocaleLowerCase()

View File

@ -99,7 +99,7 @@ export class FinishedProductProgressService {
constructor(private readonly repository: FinishedProductProgressRepository = finishedProductProgressRepository) {}
async getAccess(user: AuthUser) {
return supplyChainPermissionSettingsService.getUserAccess(user, 'newProductSampleProgress')
return supplyChainPermissionSettingsService.getUserAccess(user, 'finishedProductProgress')
}
private async requireAccess(user: AuthUser, action: 'view' | 'download' | 'edit') {

View File

@ -126,7 +126,7 @@ class ProductionStopDemandsService {
user: AuthUser,
): Promise<{ canView: boolean; canEdit: boolean; isApprover: boolean }> {
const [access, approver] = await Promise.all([
supplyChainPermissionSettingsService.getUserAccess(user, 'businessManagement'),
supplyChainPermissionSettingsService.getUserAccess(user, 'productionStopDemands'),
productionStopDemandRepository.findApproverByUserId(user.id),
])
return { canView: access.view, canEdit: access.edit, isApprover: Boolean(approver?.isActive) }
@ -627,7 +627,7 @@ class ProductionStopDemandsService {
/** 需求驳回后二次修改并重新提交确认:清空本轮确认票,重新计算截止时间并推送。 */
async resubmitDemand(demandId: string, user: AuthUser): Promise<ProductionStopConfirmationInfo> {
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'businessManagement')
const access = await supplyChainPermissionSettingsService.getUserAccess(user, 'productionStopDemands')
if (!access.edit) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有该供应链功能的权限')
}

View File

@ -11,6 +11,7 @@ import {
resolveSupplyChainPermissionAccess,
resolveSupplyChainPermissionSubjectKeys,
sanitizeSupplyChainPermissionOverrides,
SUPPLY_CHAIN_PERMISSION_TARGET_KEYS,
type SupplyChainPermissionAction,
type SupplyChainPermissionOverrideMap,
type SupplyChainPermissionPrincipalKey,
@ -82,7 +83,12 @@ class SupplyChainPermissionSettingsService {
targetKey: SupplyChainPermissionTargetKey,
) {
const roleDefaultAccess = getSupplyChainRoleDefaultAccess(user)
if (roleDefaultAccess) return roleDefaultAccess
if (roleDefaultAccess) {
// Supervisors could already export this dashboard through its old view guard.
return targetKey === 'logisticsArrivalSigningDuration'
? { ...roleDefaultAccess, download: roleDefaultAccess.view }
: roleDefaultAccess
}
// 少数节点按角色默认放行(如「部门数据配置」默认给商品企划)
const targetRoleDefaultAccess = getSupplyChainTargetRoleDefaultAccess(user, targetKey)
@ -120,61 +126,13 @@ class SupplyChainPermissionSettingsService {
async getVisibleTargetKeys(user: SupplyChainPermissionUser) {
if (getSupplyChainRoleDefaultAccess(user)?.view) {
return [
'dashboard',
'businessManagement',
'employeeHours',
'productArchive',
'categoryConfig',
'warehouseConfig',
'storeAccountManagement',
'newProductOverview',
'newProduct1688DropshipData',
'newProductTerminationFeedback',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseSupplierInformation',
'purchaseSupplierRecommendedProducts',
'purchaseDailyAffairs',
'purchasePriceChange',
'scriptMonitor',
'newProductDropshipIssues',
'operationLogs',
'productSabc',
'productSabcCalculation',
'departmentDistribution',
'departmentDistributionConfig',
] satisfies SupplyChainPermissionTargetKey[]
return [...SUPPLY_CHAIN_PERMISSION_TARGET_KEYS]
}
try {
const overrides = await this.readOverrides()
const subjectKeys = resolveSupplyChainPermissionSubjectKeys(user)
const targetKeys: SupplyChainPermissionTargetKey[] = [
'dashboard',
'businessManagement',
'employeeHours',
'productArchive',
'categoryConfig',
'warehouseConfig',
'storeAccountManagement',
'newProductOverview',
'newProduct1688DropshipData',
'newProductTerminationFeedback',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseSupplierInformation',
'purchaseSupplierRecommendedProducts',
'purchaseDailyAffairs',
'purchasePriceChange',
'scriptMonitor',
'newProductDropshipIssues',
'operationLogs',
'productSabc',
'productSabcCalculation',
'departmentDistribution',
'departmentDistributionConfig',
]
const targetKeys = SUPPLY_CHAIN_PERMISSION_TARGET_KEYS
return targetKeys.filter((targetKey) =>
resolveSupplyChainPermissionAccess(
overrides,