fix: restrict barcode import management permissions

This commit is contained in:
WuYongJie 2026-07-29 16:56:34 +08:00
parent c1151b6af5
commit d435d1f535
7 changed files with 194 additions and 75 deletions

View File

@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import { BarcodePrintHeader } from "./BarcodePrintHeader";
import { EMPTY_FILTERS } from "./barcodePrintFiltering";
function renderHeader(canManageImports: boolean) {
return render(
<BarcodePrintHeader
draftFilters={EMPTY_FILTERS}
productCategoryOptions={[]}
salesCategoryOptions={[]}
statusMessage=""
canManageImports={canManageImports}
onBarcodeImport={jest.fn()}
onBrandSkuImport={jest.fn()}
onDownloadTemplate={jest.fn()}
onFilterChange={jest.fn()}
onSearch={jest.fn()}
onReset={jest.fn()}
/>,
);
}
describe("BarcodePrintHeader", () => {
it("hides all import controls for users without import permission", () => {
renderHeader(false);
expect(screen.queryByRole("button", { name: "条码导入" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "条码导入模板下载" }),
).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "品牌货号导入" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "品牌货号导入模板下载" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "查询" })).toBeInTheDocument();
});
it("shows all import controls for designers and administrators", () => {
renderHeader(true);
expect(screen.getByRole("button", { name: "条码导入" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "条码导入模板下载" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "品牌货号导入" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "品牌货号导入模板下载" }),
).toBeInTheDocument();
});
});

View File

@ -21,6 +21,7 @@ export function BarcodePrintHeader({
productCategoryOptions,
salesCategoryOptions,
statusMessage,
canManageImports,
onBarcodeImport,
actionDisabled = false,
onBrandSkuImport,
@ -33,6 +34,7 @@ export function BarcodePrintHeader({
productCategoryOptions: string[];
salesCategoryOptions: string[];
statusMessage: string;
canManageImports: boolean;
actionDisabled?: boolean;
onBarcodeImport: () => void;
onBrandSkuImport: () => void;
@ -46,26 +48,32 @@ 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">
<div className="flex flex-wrap items-center justify-end gap-2">
<BarcodePrintActionButton disabled={actionDisabled} onClick={onBarcodeImport}>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
<span>条码导入</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton onClick={() => onDownloadTemplate("barcode")}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>条码导入模板下载</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton disabled={actionDisabled} onClick={onBrandSkuImport}>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
<span>品牌货号导入</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton onClick={() => onDownloadTemplate("brandSku")}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>品牌货号导入模板下载</span>
</BarcodePrintActionButton>
</div>
{canManageImports ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<BarcodePrintActionButton disabled={actionDisabled} onClick={onBarcodeImport}>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
<span>条码导入</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton onClick={() => onDownloadTemplate("barcode")}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>条码导入模板下载</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton disabled={actionDisabled} onClick={onBrandSkuImport}>
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
<span>品牌货号导入</span>
</BarcodePrintActionButton>
<BarcodePrintActionButton onClick={() => onDownloadTemplate("brandSku")}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
<span>品牌货号导入模板下载</span>
</BarcodePrintActionButton>
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<div
className={`flex flex-wrap items-center gap-x-3 gap-y-2 ${
canManageImports ? "mt-3" : ""
}`}
>
<BarcodePrintInput
label="SPU"
value={draftFilters.spu}

View File

@ -34,6 +34,7 @@ export function BarcodePrintTable({
onStartDataColumnResize,
onBarcodePrint,
onCertificatePrint,
canManageImports,
onDeleteImportedData,
}: {
pagedRows: BarcodePrintRow[];
@ -56,6 +57,7 @@ export function BarcodePrintTable({
) => void;
onBarcodePrint: (row: BarcodePrintRow) => void;
onCertificatePrint: (row: BarcodePrintRow) => void;
canManageImports: boolean;
onDeleteImportedData: (importRecordIds: string[] | undefined) => void;
}) {
return (
@ -213,7 +215,7 @@ export function BarcodePrintTable({
>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{row.importRecordIds?.length ? (
{canManageImports && row.importRecordIds?.length ? (
<button
type="button"
aria-label="删除导入数据"

View File

@ -8,6 +8,7 @@ import {
type MouseEvent as ReactMouseEvent,
} from "react";
import type { NonIntegratedProductRecord } from "@/app/intake/non-integrated/nonIntegratedStorage";
import { canManageBarcodePrintImports } from "@/lib/role-scope";
import {
BARCODE_PRINT_IMPORT_ACCEPT,
downloadBarcodePrintImportTemplate,
@ -71,6 +72,7 @@ import type {
type BarcodePrintWorkspaceUser = {
id: string;
role?: string | null;
businessRoles?: string[];
supplierCode?: string | null;
};
@ -88,6 +90,8 @@ type PendingCertificateEdit = {
const UNBOUND_SUPPLIER_MESSAGE =
"当前供应商账号未绑定供应商编码,请联系管理员绑定后查看条码打印数据。";
const IMPORT_PERMISSION_DENIED_MESSAGE =
"只有设计师和管理员可以导入或删除条码数据。";
export function BarcodePrintWorkspace({
products,
@ -101,6 +105,7 @@ export function BarcodePrintWorkspace({
const isSupplierUser = user.businessRoles?.includes("SUPPLIER") ?? false;
const supplierCode = user.supplierCode?.trim().toUpperCase() ?? "";
const isUnboundSupplier = isSupplierUser && !supplierCode;
const canManageImports = canManageBarcodePrintImports(user);
const [rows, setRows] = useState<BarcodePrintRow[]>([]);
const [loadingRows, setLoadingRows] = useState(false);
const [draftFilters, setDraftFilters] =
@ -257,6 +262,11 @@ export function BarcodePrintWorkspace({
async function handleImportFile(kind: BarcodePrintImportKind, file: File | null) {
if (!file) return;
if (!canManageImports) {
setStatusMessage(IMPORT_PERMISSION_DENIED_MESSAGE);
return;
}
if (isUnboundSupplier) {
setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
return;
@ -321,6 +331,11 @@ export function BarcodePrintWorkspace({
}
async function handleDownloadTemplate(kind: BarcodePrintImportKind) {
if (!canManageImports) {
setStatusMessage(IMPORT_PERMISSION_DENIED_MESSAGE);
return;
}
const label = kind === "barcode" ? "条码导入模板" : "品牌货号导入模板";
setStatusMessage("");
@ -357,6 +372,11 @@ export function BarcodePrintWorkspace({
async function deleteImportedData(importRecordIds: string[] | undefined) {
if (!importRecordIds?.length) return;
if (!canManageImports) {
setStatusMessage(IMPORT_PERMISSION_DENIED_MESSAGE);
return;
}
try {
const nextData = await deleteBarcodePrintImportRecords(importRecordIds);
setRows(nextData.rows);
@ -544,33 +564,38 @@ 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)]">
<input
ref={barcodeImportInputRef}
type="file"
accept={BARCODE_PRINT_IMPORT_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = "";
void handleImportFile("barcode", file);
}}
/>
<input
ref={brandSkuImportInputRef}
type="file"
accept={BARCODE_PRINT_IMPORT_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = "";
void handleImportFile("brandSku", file);
}}
/>
{canManageImports ? (
<>
<input
ref={barcodeImportInputRef}
type="file"
accept={BARCODE_PRINT_IMPORT_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = "";
void handleImportFile("barcode", file);
}}
/>
<input
ref={brandSkuImportInputRef}
type="file"
accept={BARCODE_PRINT_IMPORT_ACCEPT}
className="hidden"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = "";
void handleImportFile("brandSku", file);
}}
/>
</>
) : null}
<BarcodePrintHeader
draftFilters={draftFilters}
productCategoryOptions={productCategoryOptions}
salesCategoryOptions={salesCategoryOptions}
statusMessage={statusMessage}
canManageImports={canManageImports}
actionDisabled={isUnboundSupplier}
onBarcodeImport={() => {
if (isUnboundSupplier) setStatusMessage(UNBOUND_SUPPLIER_MESSAGE);
@ -607,6 +632,7 @@ export function BarcodePrintWorkspace({
onStartDataColumnResize={startDataColumnResize}
onBarcodePrint={(row) => void handleBarcodePrint(row.id)}
onCertificatePrint={(row) => handleCertificatePrint(row.id)}
canManageImports={canManageImports}
onDeleteImportedData={(ids) => void deleteImportedData(ids)}
/>
<BarcodePrintPagination

View File

@ -78,6 +78,10 @@ export function isDesigner(user: ScopedRoleUser): boolean {
return hasBusinessRole(user, 'DESIGN')
}
export function canManageBarcodePrintImports(user: ScopedRoleUser): boolean {
return isAdmin(user) || isDesigner(user)
}
function normalizeDepartmentPath(value: unknown) {
return typeof value === 'string' ? value.trim().replace(/\s+/g, '') : ''
}

View File

@ -1,4 +1,5 @@
import type { AuthUser } from '@/server/core/types'
import { ErrorCode } from '@/server/core/errors'
var mockProducts: unknown[] = []
var mockImportRecords: unknown[] = []
@ -143,12 +144,10 @@ describe('barcodePrintService', () => {
expect(mockFindMany).not.toHaveBeenCalled()
})
it('供应商导入时按当前供应商编码写入正式导入表', async () => {
it('设计师和管理员可以导入条码数据', async () => {
await barcodePrintService.saveImportRecords(
buildUser({
businessRoles: ['SUPPLIER'],
supplierCode: 'N1280',
supplierName: '临沂美诗家居',
businessRoles: ['DESIGN'],
}),
[
{
@ -176,43 +175,59 @@ describe('barcodePrintService', () => {
},
},
create: expect.objectContaining({
supplierCode: 'N1280',
supplierName: '临沂美诗家居',
createdBy: 'user-1',
}),
}),
)
})
it('供应商不能导入自己不可见的货号', async () => {
await expect(
barcodePrintService.saveImportRecords(
buildUser({
businessRoles: ['SUPPLIER'],
supplierCode: 'N1280',
}),
[
{
kind: 'barcode',
skuCode: 'OTHER001',
updates: {
skuCode: 'OTHER001',
barcode: '690000000009',
},
await barcodePrintService.saveImportRecords(
buildUser({ role: 'ADMIN' }),
[
{
kind: 'brandSku',
skuCode: 'CS000201A',
updates: {
skuCode: 'CS000201A',
brandSkuCode: 'BRAND-001',
},
],
),
).rejects.toMatchObject({
message: expect.stringContaining('不属于当前供应商'),
})
},
],
)
expect(mockUpsert).toHaveBeenCalledTimes(2)
})
it('供应商删除导入数据时自动带供应商编码条件', async () => {
const nonImportManagers = [
['供应商', buildUser({ businessRoles: ['SUPPLIER'], supplierCode: 'N1280' })],
['主管', buildUser({ role: 'SUPERVISOR' })],
['采购', buildUser({ businessRoles: ['PURCHASING'] })],
] as const
it.each(nonImportManagers)('%s不能导入条码数据', async (_label, user) => {
await expect(
barcodePrintService.saveImportRecords(user, [
{
kind: 'barcode',
skuCode: 'CS000201A',
updates: { skuCode: 'CS000201A', barcode: '690000000009' },
},
]),
).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
expect(mockUpsert).not.toHaveBeenCalled()
})
it.each(nonImportManagers)('%s不能删除导入数据', async (_label, user) => {
await expect(
barcodePrintService.deleteImportRecords(user, ['record-1']),
).rejects.toMatchObject({ code: ErrorCode.AUTH_PERMISSION_DENIED })
expect(mockDeleteMany).not.toHaveBeenCalled()
})
it('设计师可以删除导入数据', async () => {
await barcodePrintService.deleteImportRecords(
buildUser({
businessRoles: ['SUPPLIER'],
supplierCode: 'N1280',
}),
buildUser({ businessRoles: ['DESIGN'] }),
['record-1'],
)
@ -221,7 +236,6 @@ describe('barcodePrintService', () => {
id: {
in: ['record-1'],
},
supplierCode: 'N1280',
},
})
})

View File

@ -8,6 +8,7 @@ import { mergeBarcodePrintImportRows } from '@/app/intake/barcode-print/barcodeP
import { prisma } from '@/lib/prisma'
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import { canManageBarcodePrintImports } from '@/lib/role-scope'
import { intakeNonIntegratedProductService } from './intake-non-integrated-product.service'
import { intakeNonIntegratedScopeService } from './intake-non-integrated-scope.service'
@ -196,6 +197,13 @@ class BarcodePrintService {
}
async saveImportRecords(user: AuthUser, records: SaveBarcodePrintImportRecordInput[]) {
if (!canManageBarcodePrintImports(user)) {
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
'只有设计师和管理员可以导入条码与品牌货号。',
)
}
if (!Array.isArray(records) || records.length === 0) {
throw new AppError(
ErrorCode.VALIDATION_REQUIRED_FIELD,
@ -260,6 +268,13 @@ class BarcodePrintService {
}
async deleteImportRecords(user: AuthUser, ids: unknown[]) {
if (!canManageBarcodePrintImports(user)) {
throw new AppError(
ErrorCode.AUTH_PERMISSION_DENIED,
'只有设计师和管理员可以删除导入数据。',
)
}
const normalizedIds = Array.isArray(ids)
? Array.from(
new Set(