fix: restrict barcode import management permissions
This commit is contained in:
parent
c1151b6af5
commit
d435d1f535
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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}
|
||||
|
||||
@ -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="删除导入数据"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, '') : ''
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user