feat(intake): extend barcode import permissions

This commit is contained in:
WuYongJie 2026-09-21 10:54:14 +08:00
parent 4450ac5ce6
commit d4778d29e9
8 changed files with 219 additions and 49 deletions

View File

@ -3,11 +3,15 @@ import { BarcodePrintHeader } from "./BarcodePrintHeader";
import { EMPTY_FILTERS } from "./barcodePrintFiltering";
function renderHeader({
canManageImports,
canImportRecords,
canDeleteImports = canImportRecords,
canEditImports = canImportRecords,
canExport = false,
onExport = jest.fn(),
}: {
canManageImports: boolean;
canImportRecords: boolean;
canDeleteImports?: boolean;
canEditImports?: boolean;
canExport?: boolean;
onExport?: () => void;
}) {
@ -17,7 +21,9 @@ function renderHeader({
productCategoryOptions={[]}
salesCategoryOptions={[]}
statusMessage=""
canManageImports={canManageImports}
canImportRecords={canImportRecords}
canDeleteImports={canDeleteImports}
canEditImports={canEditImports}
canExport={canExport}
onBarcodeImport={jest.fn()}
onBrandSkuImport={jest.fn()}
@ -34,7 +40,7 @@ function renderHeader({
describe("BarcodePrintHeader", () => {
it("hides all import controls for users without import permission", () => {
renderHeader({ canManageImports: false });
renderHeader({ canImportRecords: false });
expect(screen.queryByRole("button", { name: "条码导入" })).not.toBeInTheDocument();
expect(
@ -55,7 +61,7 @@ describe("BarcodePrintHeader", () => {
});
it("shows all import controls for designers and administrators", () => {
renderHeader({ canManageImports: true });
renderHeader({ canImportRecords: true });
expect(screen.getByRole("button", { name: "条码导入" })).toBeInTheDocument();
expect(
@ -71,7 +77,7 @@ describe("BarcodePrintHeader", () => {
});
it("places bulk delete and edit actions after the import template actions", () => {
renderHeader({ canManageImports: true });
renderHeader({ canImportRecords: true });
const brandSkuTemplate = screen.getByRole("button", {
name: "品牌货号导入模板下载",
@ -91,10 +97,23 @@ describe("BarcodePrintHeader", () => {
it("shows the export action for administrators", () => {
const onExport = jest.fn();
renderHeader({ canManageImports: true, canExport: true, onExport });
renderHeader({ canImportRecords: true, canExport: true, onExport });
fireEvent.click(screen.getByRole("button", { name: "导出数据" }));
expect(onExport).toHaveBeenCalledTimes(1);
});
it("allows purchasing and warehouse users to import and delete without bulk edit", () => {
renderHeader({
canImportRecords: true,
canDeleteImports: true,
canEditImports: false,
});
expect(screen.getByRole("button", { name: "条码导入" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "品牌货号导入" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "删除选中" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "修改选中" })).not.toBeInTheDocument();
});
});

View File

@ -29,7 +29,9 @@ export function BarcodePrintHeader({
productCategoryOptions,
salesCategoryOptions,
statusMessage,
canManageImports,
canImportRecords,
canDeleteImports,
canEditImports,
canExport,
selectedRowCount = 0,
exporting = false,
@ -48,7 +50,9 @@ export function BarcodePrintHeader({
productCategoryOptions: string[];
salesCategoryOptions: string[];
statusMessage: string;
canManageImports: boolean;
canImportRecords: boolean;
canDeleteImports: boolean;
canEditImports: boolean;
canExport: boolean;
selectedRowCount?: number;
exporting?: boolean;
@ -68,7 +72,7 @@ export function BarcodePrintHeader({
}) {
return (
<div className="shrink-0 border-b border-[var(--obs-shell-border)] bg-[var(--obs-shell-header-bg)] px-3 py-2.5">
{canManageImports || canExport ? (
{canImportRecords || canDeleteImports || canEditImports || canExport ? (
<div className="flex flex-wrap items-center justify-end gap-2">
{canExport ? (
<BarcodePrintActionButton
@ -80,7 +84,7 @@ export function BarcodePrintHeader({
<span>{exporting ? "正在导出" : "导出数据"}</span>
</BarcodePrintActionButton>
) : null}
{canManageImports ? (
{canImportRecords ? (
<>
<BarcodePrintActionButton disabled={actionDisabled} onClick={onBarcodeImport}>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
@ -98,29 +102,33 @@ export function BarcodePrintHeader({
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>品牌货号导入模板下载</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton
disabled={actionDisabled || selectedRowCount === 0}
intent="danger"
onClick={onDeleteSelected}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
<span>删除选中{selectedRowCount ? `(${selectedRowCount} 行)` : ""}</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton
disabled={actionDisabled || selectedRowCount === 0}
onClick={onEditSelected}
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
<span>修改选中{selectedRowCount ? `(${selectedRowCount} 行)` : ""}</span>
</BarcodePrintActionButton>
</>
) : null}
{canDeleteImports ? (
<BarcodePrintActionButton
disabled={actionDisabled || selectedRowCount === 0}
intent="danger"
onClick={onDeleteSelected}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
<span>删除选中{selectedRowCount ? `(${selectedRowCount} 行)` : ""}</span>
</BarcodePrintActionButton>
) : null}
{canEditImports ? (
<BarcodePrintActionButton
disabled={actionDisabled || selectedRowCount === 0}
onClick={onEditSelected}
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
<span>修改选中{selectedRowCount ? `(${selectedRowCount} 行)` : ""}</span>
</BarcodePrintActionButton>
) : null}
</div>
) : null}
<div
className={`flex flex-wrap items-center gap-x-3 gap-y-2 ${
canManageImports || canExport ? "mt-3" : ""
canImportRecords || canDeleteImports || canEditImports || canExport ? "mt-3" : ""
}`}
>
<BarcodePrintInput

View File

@ -35,7 +35,7 @@ export function BarcodePrintTable({
onStartDataColumnResize,
onBarcodePrint,
onCertificatePrint,
canManageImports,
canDeleteImports,
isSupplier = false,
onDeleteImportedData,
}: {
@ -59,7 +59,7 @@ export function BarcodePrintTable({
) => void;
onBarcodePrint: (row: BarcodePrintRow) => void;
onCertificatePrint: (row: BarcodePrintRow) => void;
canManageImports: boolean;
canDeleteImports: boolean;
isSupplier?: boolean;
onDeleteImportedData: (importRecordIds: string[] | undefined) => void;
}) {
@ -91,7 +91,7 @@ export function BarcodePrintTable({
aria-label="选择当前筛选结果"
checked={allVisibleSelected}
onChange={onToggleVisibleRows}
disabled={selectableRows.length === 0}
disabled={!canDeleteImports || selectableRows.length === 0}
className="h-3.5 w-3.5"
/>
</th>
@ -173,7 +173,7 @@ export function BarcodePrintTable({
}行`}
checked={selectedRowIds.has(row.id)}
onChange={() => onToggleRow(row.id)}
disabled={!row.importRecordIds?.length}
disabled={!canDeleteImports || !row.importRecordIds?.length}
className="h-3.5 w-3.5"
/>
</td>
@ -227,7 +227,7 @@ export function BarcodePrintTable({
>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{canManageImports && row.importRecordIds?.length ? (
{canDeleteImports && row.importRecordIds?.length ? (
<button
type="button"
aria-label="删除导入数据"

View File

@ -9,7 +9,12 @@ import {
type MouseEvent as ReactMouseEvent,
} from "react";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { canManageBarcodePrintImports, isAdmin } from "@/lib/role-scope";
import {
canDeleteBarcodePrintImports,
canEditBarcodePrintImports,
canImportBarcodePrintRecords,
isAdmin,
} from "@/lib/role-scope";
import {
BARCODE_PRINT_IMPORT_ACCEPT,
downloadBarcodePrintImportTemplate,
@ -120,7 +125,11 @@ function buildAppliedFilters(filters: BarcodePrintFilterState) {
const UNBOUND_SUPPLIER_MESSAGE =
"当前供应商账号未绑定供应商名称或编码,请联系管理员绑定后查看条码打印数据。";
const IMPORT_PERMISSION_DENIED_MESSAGE =
"只有设计师和管理员可以导入或删除条码数据。";
"当前账号没有条码导入权限。";
const DELETE_PERMISSION_DENIED_MESSAGE =
"当前账号没有删除条码导入数据的权限。";
const EDIT_PERMISSION_DENIED_MESSAGE =
"当前账号没有修改条码导入数据的权限。";
export function BarcodePrintWorkspace({
products: _products,
@ -135,7 +144,9 @@ export function BarcodePrintWorkspace({
const supplierCode = user.supplierCode?.trim().toUpperCase() ?? "";
const supplierName = user.supplierName?.trim() ?? "";
const isUnboundSupplier = isSupplierUser && !supplierName && !supplierCode;
const canManageImports = canManageBarcodePrintImports(user);
const canImportRecords = canImportBarcodePrintRecords(user);
const canDeleteImports = canDeleteBarcodePrintImports(user);
const canEditImports = canEditBarcodePrintImports(user);
const canExport = isAdmin(user);
const [rows, setRows] = useState<BarcodePrintRow[]>([]);
const [totalRowCount, setTotalRowCount] = useState(0);
@ -380,7 +391,7 @@ export function BarcodePrintWorkspace({
async function handleImportFile(kind: BarcodePrintImportKind, file: File | null) {
if (!file) return;
if (!canManageImports) {
if (!canImportRecords) {
setStatusMessage(IMPORT_PERMISSION_DENIED_MESSAGE);
return;
}
@ -456,7 +467,7 @@ export function BarcodePrintWorkspace({
}
async function handleDownloadTemplate(kind: BarcodePrintImportKind) {
if (!canManageImports) {
if (!canImportRecords) {
setStatusMessage(IMPORT_PERMISSION_DENIED_MESSAGE);
return;
}
@ -497,8 +508,8 @@ export function BarcodePrintWorkspace({
async function deleteImportedData(importRecordIds: string[] | undefined) {
if (!importRecordIds?.length) return;
if (!canManageImports) {
setStatusMessage(IMPORT_PERMISSION_DENIED_MESSAGE);
if (!canDeleteImports) {
setStatusMessage(DELETE_PERMISSION_DENIED_MESSAGE);
return;
}
@ -519,6 +530,10 @@ export function BarcodePrintWorkspace({
async function deleteSelectedData() {
if (selectedImportRecordIds.length === 0) return;
if (!canDeleteImports) {
setStatusMessage(DELETE_PERMISSION_DENIED_MESSAGE);
return;
}
if (
!window.confirm(
`确认删除选中的 ${selectedImportRecordIds.length} 条导入数据吗?`,
@ -545,11 +560,19 @@ export function BarcodePrintWorkspace({
function openBulkEdit() {
if (selectedImportRecordIds.length === 0) return;
if (!canEditImports) {
setStatusMessage(EDIT_PERMISSION_DENIED_MESSAGE);
return;
}
setBulkEditOpen(true);
}
async function submitBulkEdit({ fields }: BarcodePrintBulkEditSubmit) {
if (selectedImportRecordIds.length === 0) return;
if (!canEditImports) {
setStatusMessage(EDIT_PERMISSION_DENIED_MESSAGE);
return;
}
setBulkEditSaving(true);
try {
@ -759,7 +782,7 @@ export function BarcodePrintWorkspace({
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-[8px] border border-[var(--obs-shell-border)] bg-[var(--obs-shell-panel-bg)] text-[var(--obs-shell-title)] shadow-[0_1px_2px_rgba(17,19,21,0.04)]">
{canManageImports ? (
{canImportRecords ? (
<>
<input
ref={barcodeImportInputRef}
@ -790,7 +813,9 @@ export function BarcodePrintWorkspace({
productCategoryOptions={productCategoryOptions}
salesCategoryOptions={salesCategoryOptions}
statusMessage={isUnboundSupplier ? UNBOUND_SUPPLIER_MESSAGE : statusMessage}
canManageImports={canManageImports}
canImportRecords={canImportRecords}
canDeleteImports={canDeleteImports}
canEditImports={canEditImports}
canExport={canExport}
selectedRowCount={selectedRows.length}
exporting={exporting}
@ -843,7 +868,7 @@ export function BarcodePrintWorkspace({
onStartDataColumnResize={startDataColumnResize}
onBarcodePrint={(row) => void handleBarcodePrint(row.id)}
onCertificatePrint={(row) => handleCertificatePrint(row.id)}
canManageImports={canManageImports}
canDeleteImports={canDeleteImports}
isSupplier={isSupplierUser}
onDeleteImportedData={(ids) => void deleteImportedData(ids)}
/>

View File

@ -1,5 +1,8 @@
import {
canDeleteBarcodePrintImports,
canCreateContent,
canEditBarcodePrintImports,
canImportBarcodePrintRecords,
canOperateDesignWorkspace,
hasBusinessRole,
isReadOnlyUser,
@ -81,4 +84,26 @@ describe('role-scope business roles', () => {
it('保留无部门旧设计账号的设计工作区兜底权限', () => {
expect(canOperateDesignWorkspace({ role: 'USER', businessRoles: ['DESIGN'] })).toBe(true)
})
it.each(['PURCHASING', 'WAREHOUSE'])(
'%s 可以导入和删除条码导入记录,但不能批量修改',
(businessRole) => {
const user = { role: 'USER', businessRoles: [businessRole] }
expect(canImportBarcodePrintRecords(user)).toBe(true)
expect(canDeleteBarcodePrintImports(user)).toBe(true)
expect(canEditBarcodePrintImports(user)).toBe(false)
},
)
it('设计师和管理员保留条码导入记录的完整维护权限', () => {
const designer = { role: 'USER', businessRoles: ['DESIGN'] }
const admin = { role: 'ADMIN', businessRoles: [] }
for (const user of [designer, admin]) {
expect(canImportBarcodePrintRecords(user)).toBe(true)
expect(canDeleteBarcodePrintImports(user)).toBe(true)
expect(canEditBarcodePrintImports(user)).toBe(true)
}
})
})

View File

@ -78,7 +78,20 @@ export function isDesigner(user: ScopedRoleUser): boolean {
return hasBusinessRole(user, 'DESIGN')
}
export function canManageBarcodePrintImports(user: ScopedRoleUser): boolean {
export function canImportBarcodePrintRecords(user: ScopedRoleUser): boolean {
return (
isAdmin(user) ||
isDesigner(user) ||
hasBusinessRole(user, 'PURCHASING') ||
hasBusinessRole(user, 'WAREHOUSE')
)
}
export function canDeleteBarcodePrintImports(user: ScopedRoleUser): boolean {
return canImportBarcodePrintRecords(user)
}
export function canEditBarcodePrintImports(user: ScopedRoleUser): boolean {
return isAdmin(user) || isDesigner(user)
}

View File

@ -11,6 +11,7 @@ var mockQueryRaw = jest.fn()
var mockListForBarcodePrint = jest.fn()
var mockListForBarcodePrintPage = jest.fn()
var mockListForBarcodePrintSkuCodes = jest.fn()
var mockOperationLogRecord = jest.fn()
jest.mock('@/lib/prisma', () => ({
prisma: {
@ -32,6 +33,17 @@ jest.mock('../intake-non-integrated-product.service', () => ({
},
}))
jest.mock('../operation-log.service', () => ({
OperationLogAction: {
INTAKE_PRODUCT_DELETE: 'INTAKE_PRODUCT_DELETE',
INTAKE_PRODUCT_WRITE: 'INTAKE_PRODUCT_WRITE',
},
OperationLogStatus: { SUCCESS: 'SUCCESS' },
operationLogService: {
record: (...args: unknown[]) => mockOperationLogRecord(...args),
},
}))
import { barcodePrintService } from '../barcode-print.service'
function buildUser(overrides: Partial<AuthUser> = {}): AuthUser {
@ -145,6 +157,7 @@ beforeEach(() => {
)
mockDeleteMany = jest.fn(() => Promise.resolve({ count: 0 }))
mockQueryRaw = jest.fn(() => Promise.resolve([]))
mockOperationLogRecord = jest.fn(() => Promise.resolve())
})
describe('barcodePrintService', () => {
@ -514,7 +527,11 @@ describe('barcodePrintService', () => {
const nonImportManagers = [
['供应商', buildUser({ businessRoles: ['SUPPLIER'], supplierCode: 'N1280' })],
['主管', buildUser({ role: 'SUPERVISOR' })],
] as const
const purchasingAndWarehouseUsers = [
['采购', buildUser({ businessRoles: ['PURCHASING'] })],
['仓库', buildUser({ businessRoles: ['WAREHOUSE'] })],
] as const
it.each(nonImportManagers)('%s不能导入条码数据', async (_label, user) => {
@ -539,6 +556,39 @@ describe('barcodePrintService', () => {
expect(mockDeleteMany).not.toHaveBeenCalled()
})
it.each(purchasingAndWarehouseUsers)('%s可以导入条码数据', async (_label, user) => {
await barcodePrintService.saveImportRecords(user, [
{
kind: 'barcode',
skuCode: 'CS000201A',
updates: {
skuCode: 'CS000201A',
barcode: '690000000009',
supplierName: '临沂美诗家居',
},
},
])
expect(mockCreateMany).toHaveBeenCalled()
})
it.each(purchasingAndWarehouseUsers)('%s可以删除导入数据', async (_label, user) => {
await barcodePrintService.deleteImportRecords(user, ['record-1'])
expect(mockDeleteMany).toHaveBeenCalledWith({
where: {
id: { in: ['record-1'] },
sourceSkuItemId: null,
},
})
})
it.each(purchasingAndWarehouseUsers)('%s不能批量修改导入数据', async (_label, user) => {
await expect(
barcodePrintService.updateImportRecords(user, ['record-1'], { barcode: 'NEW' }),
).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
})
it('设计师可以删除导入数据', async () => {
await barcodePrintService.deleteImportRecords(
buildUser({ businessRoles: ['DESIGN'] }),
@ -554,4 +604,30 @@ describe('barcodePrintService', () => {
},
})
})
it('删除成功后写入条码打印操作日志', async () => {
mockDeleteMany.mockResolvedValueOnce({ count: 2 })
await barcodePrintService.deleteImportRecords(
buildUser({
id: 'warehouse-user',
name: '仓库人员',
businessRoles: ['WAREHOUSE'],
}),
['record-1', 'record-2'],
)
expect(mockOperationLogRecord).toHaveBeenCalledWith({
action: 'INTAKE_PRODUCT_DELETE',
status: 'SUCCESS',
actorId: 'warehouse-user',
actorName: '仓库人员',
workspaceCategory: '条码打印',
message: '删除 2 条手动导入条码数据',
details: {
importRecordIds: ['record-1', 'record-2'],
deleted: 2,
},
})
})
})

View File

@ -14,7 +14,11 @@ import {
} from '@/app/intake/barcode-print/barcodePrintRows'
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import { canManageBarcodePrintImports } from '@/lib/role-scope'
import {
canDeleteBarcodePrintImports,
canEditBarcodePrintImports,
canImportBarcodePrintRecords,
} from '@/lib/role-scope'
import {
barcodePrintImportRepository,
type BarcodePrintImportListFilters,
@ -446,10 +450,10 @@ class BarcodePrintService {
}
async saveImportRecords(user: AuthUser, records: SaveBarcodePrintImportRecordInput[]) {
if (!canManageBarcodePrintImports(user)) {
if (!canImportBarcodePrintRecords(user)) {
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
'只有设计师和管理员可以导入条码与品牌货号。',
'只有设计师、采购、仓库和管理员可以导入条码与品牌货号。',
)
}
@ -501,10 +505,10 @@ class BarcodePrintService {
}
async deleteImportRecords(user: AuthUser, ids: unknown[]) {
if (!canManageBarcodePrintImports(user)) {
if (!canDeleteBarcodePrintImports(user)) {
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
'只有设计师和管理员可以删除导入数据。',
'只有设计师、采购、仓库和管理员可以删除导入数据。',
)
}
@ -548,7 +552,7 @@ class BarcodePrintService {
ids: unknown,
rawFields: unknown,
) {
if (!canManageBarcodePrintImports(user)) {
if (!canEditBarcodePrintImports(user)) {
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
'只有设计师和管理员可以修改导入数据。',