feat(supply-chain): add product, supplier, and store management

This commit is contained in:
WuYongJie 2026-08-26 18:18:41 +08:00
parent 3f5cf79cc2
commit a34dee6825
82 changed files with 5291 additions and 147 deletions

View File

@ -0,0 +1,3 @@
ALTER TABLE "SupplyChainProductArchive"
ADD COLUMN "stableLeadTime" TEXT NOT NULL DEFAULT '',
ADD COLUMN "backupSupplier" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,55 @@
CREATE TABLE "StoreAccount" (
"id" TEXT NOT NULL,
"storeName" TEXT NOT NULL,
"operationOwner" TEXT NOT NULL DEFAULT '',
"operationDepartmentHead" TEXT NOT NULL DEFAULT '',
"operationDirector" TEXT NOT NULL DEFAULT '',
"profitCenter" TEXT NOT NULL DEFAULT '',
"accountType" TEXT NOT NULL,
"account" TEXT NOT NULL,
"passwordCiphertext" TEXT NOT NULL DEFAULT '',
"boundPhone" TEXT NOT NULL DEFAULT '',
"verificationContact" TEXT NOT NULL DEFAULT '',
"jstOrderService" TEXT NOT NULL DEFAULT '',
"accountGroupName" TEXT NOT NULL DEFAULT '',
"createdById" TEXT,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StoreAccount_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "StoreAccount_storeName_accountType_account_key" ON "StoreAccount"("storeName", "accountType", "account");
CREATE INDEX "StoreAccount_storeName_idx" ON "StoreAccount"("storeName");
CREATE INDEX "StoreAccount_account_idx" ON "StoreAccount"("account");
CREATE INDEX "StoreAccount_accountType_idx" ON "StoreAccount"("accountType");
CREATE INDEX "StoreAccount_updatedAt_idx" ON "StoreAccount"("updatedAt");
CREATE TABLE "StoreAccountChangeLog" (
"id" TEXT NOT NULL,
"storeAccountId" TEXT,
"storeName" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"actorId" TEXT,
"actorName" TEXT NOT NULL,
"changes" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StoreAccountChangeLog_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "StoreAccountChangeLog_storeAccountId_createdAt_idx" ON "StoreAccountChangeLog"("storeAccountId", "createdAt");
CREATE INDEX "StoreAccountChangeLog_createdAt_idx" ON "StoreAccountChangeLog"("createdAt");
CREATE INDEX "StoreAccountChangeLog_storeName_createdAt_idx" ON "StoreAccountChangeLog"("storeName", "createdAt");
CREATE TABLE "StoreAccountFieldPermission" (
"id" TEXT NOT NULL,
"fieldKey" TEXT NOT NULL,
"subjectKey" TEXT NOT NULL,
"visibility" TEXT NOT NULL DEFAULT 'READONLY',
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StoreAccountFieldPermission_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "StoreAccountFieldPermission_fieldKey_subjectKey_key" ON "StoreAccountFieldPermission"("fieldKey", "subjectKey");
CREATE INDEX "StoreAccountFieldPermission_fieldKey_idx" ON "StoreAccountFieldPermission"("fieldKey");
CREATE INDEX "StoreAccountFieldPermission_subjectKey_idx" ON "StoreAccountFieldPermission"("subjectKey");

View File

@ -0,0 +1,2 @@
ALTER TABLE "SupplyChainProductArchiveWarehouseConfig"
ADD COLUMN "warehouseOwnership" TEXT NOT NULL DEFAULT '';

View File

@ -556,7 +556,9 @@ model SupplyChainProductArchive {
warehouseHead String @default("")
firstSourceCode String @default("PRODUCT_DEVELOPMENT")
firstLeadTime String @default("")
stableLeadTime String @default("")
palletSpec String @default("")
backupSupplier String @default("")
dailySales String @default("")
sales3d String @default("")
sales7d String @default("")
@ -665,13 +667,14 @@ model SupplyChainProductArchiveCategoryConfig {
}
model SupplyChainProductArchiveWarehouseConfig {
id String @id @default(cuid())
warehouseName String @unique
warehouseHead String @default("")
storageType String
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
warehouseName String @unique
warehouseOwnership String @default("")
warehouseHead String @default("")
storageType String
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([storageType])
@@index([status])
@ -3351,6 +3354,62 @@ model SupplyChainPermissionOverride {
@@index([subjectKey])
}
// Store account data is maintained manually in this system.
model StoreAccount {
id String @id @default(cuid())
storeName String
operationOwner String @default("")
operationDepartmentHead String @default("")
operationDirector String @default("")
profitCenter String @default("")
accountType String
account String
passwordCiphertext String @default("") @db.Text
boundPhone String @default("")
verificationContact String @default("")
jstOrderService String @default("")
accountGroupName String @default("")
createdById String?
updatedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([storeName, accountType, account])
@@index([storeName])
@@index([account])
@@index([accountType])
@@index([updatedAt])
}
model StoreAccountChangeLog {
id String @id @default(cuid())
storeAccountId String?
storeName String
operation String
actorId String?
actorName String
changes Json
createdAt DateTime @default(now())
@@index([storeAccountId, createdAt])
@@index([createdAt])
@@index([storeName, createdAt])
}
model StoreAccountFieldPermission {
id String @id @default(cuid())
fieldKey String
subjectKey String
visibility String @default("READONLY")
updatedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([fieldKey, subjectKey])
@@index([fieldKey])
@@index([subjectKey])
}
model NewProductTerminationFeedback {
id String @id @default(cuid())

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { supplyChainProductArchiveImageExportService } from '@/server/services/supply-chain-product-archive-image-export.service'
import { parseProductArchiveFilters } from '@/server/services/supply-chain-product-archive-query'
function buildContentDisposition(fileName: string) {
return `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`
}
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const access = await supplyChainPermissionSettingsService.getUserAccess(
ctx.user,
'productArchive',
)
if (!access.download) {
return jsonError(
ErrorCode.AUTH_PERMISSION_DENIED,
'当前账号没有导出权限',
403,
ctx.requestId,
)
}
const filters = parseProductArchiveFilters(request.nextUrl.searchParams.get('filters'))
if (!filters.success) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'筛选条件格式不正确',
400,
ctx.requestId,
)
}
const payload = await supplyChainProductArchiveImageExportService.createWorkbook({
filters: filters.data,
siteOrigin: request.nextUrl.origin,
})
return new NextResponse(payload.bytes, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': buildContentDisposition(payload.fileName),
'Cache-Control': 'no-store',
'X-Exported-Row-Count': String(payload.total),
'X-Request-Id': ctx.requestId,
},
})
})
}

View File

@ -3,49 +3,13 @@ import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainProductArchiveService } from '@/server/services/supply-chain-product-archive.service'
import { PRODUCT_ARCHIVE_SALES_STATUSES, type ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
import { parseProductArchiveFilters } from '@/server/services/supply-chain-product-archive-query'
const productArchiveQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(50),
})
const archiveDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
const productArchiveFiltersSchema = z.object({
keyword: z.string().trim().min(1).max(160).optional(),
categoryL1: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
categoryL2: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
spu: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
sku: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
barcode: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
productName: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
designer: z.array(z.string().trim().min(1).max(80)).max(200).optional(),
productStatus: z.array(z.enum(PRODUCT_ARCHIVE_SALES_STATUSES)).max(3).optional(),
material: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
color: z.array(z.string().trim().min(1).max(80)).max(200).optional(),
brand: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
supplier: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
purchaser: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
inOutWarehouse: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
createTimeFrom: archiveDateSchema.optional(),
createTimeTo: archiveDateSchema.optional(),
updateTimeFrom: archiveDateSchema.optional(),
updateTimeTo: archiveDateSchema.optional(),
}).strict()
function parseFilters(raw: string | null) {
if (!raw) return { success: true as const, data: {} as ProductArchiveFilters }
try {
const parsed = productArchiveFiltersSchema.safeParse(JSON.parse(raw))
if (!parsed.success) return { success: false as const }
return { success: true as const, data: parsed.data }
} catch {
return { success: false as const }
}
}
export async function GET(request: NextRequest) {
return handleRequest(
request,
@ -64,7 +28,7 @@ export async function GET(request: NextRequest) {
)
}
const filters = parseFilters(request.nextUrl.searchParams.get('filters'))
const filters = parseProductArchiveFilters(request.nextUrl.searchParams.get('filters'))
if (!filters.success) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,

View File

@ -0,0 +1,14 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
type RouteProps = { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, props: RouteProps) {
return handleRequest(request, async (ctx) => {
const { id } = await props.params
const response = jsonSuccess(await storeAccountManagementService.revealPassword(id, ctx.user), 200, ctx.requestId)
response.headers.set('Cache-Control', 'no-store')
return response
}, { requireAuth: true })
}

View File

@ -0,0 +1,38 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
const updateSchema = z.object({
storeName: z.string().trim().max(240).optional(),
operationOwner: z.string().trim().max(240).optional(),
operationDepartmentHead: z.string().trim().max(240).optional(),
operationDirector: z.string().trim().max(240).optional(),
profitCenter: z.string().trim().max(240).optional(),
accountType: z.string().trim().max(20).optional(),
account: z.string().trim().max(240).optional(),
password: z.string().max(500).optional(),
boundPhone: z.string().trim().max(80).optional(),
verificationContact: z.string().trim().max(240).optional(),
jstOrderService: z.string().trim().max(240).optional(),
accountGroupName: z.string().trim().max(240).optional(),
}).strict()
type RouteProps = { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, props: RouteProps) {
return handleRequest(request, async (ctx) => {
const { id } = await props.params
return jsonSuccess(await storeAccountManagementService.getOne(id, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}
export async function PATCH(request: NextRequest, props: RouteProps) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = updateSchema.safeParse(body)
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺账号修改内容不正确', 400, ctx.requestId)
const { id } = await props.params
return jsonSuccess(await storeAccountManagementService.update(id, parsed.data, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,29 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { isStoreAccountBatchEditableFieldKey } from '@/lib/store-account-management'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
const schema = z.object({ ids: z.array(z.string().trim().min(1)).min(1).max(500), field: z.string(), value: z.string().max(500) }).strict()
const previewSchema = z.object({ ids: z.array(z.string().trim().min(1)).min(1).max(500), field: z.string() }).strict()
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = previewSchema.safeParse(await request.json().catch(() => null))
if (!parsed.success || !isStoreAccountBatchEditableFieldKey(parsed.data.field)) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '批量预览内容不正确', 400, ctx.requestId)
}
return jsonSuccess(await storeAccountManagementService.batchPreview({ ...parsed.data, field: parsed.data.field, user: ctx.user }), 200, ctx.requestId)
}, { requireAuth: true })
}
export async function PATCH(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = schema.safeParse(await request.json().catch(() => null))
if (!parsed.success || !isStoreAccountBatchEditableFieldKey(parsed.data.field)) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '批量修改内容不正确', 400, ctx.requestId)
}
return jsonSuccess(await storeAccountManagementService.batchUpdate({ ...parsed.data, field: parsed.data.field, user: ctx.user }), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,28 @@
import { NextRequest } from 'next/server'
import * as XLSX from 'xlsx'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
const HEADER_MAP: Record<string, string> = {
店铺名称: 'storeName', 店铺运营负责人: 'operationOwner', 店铺运营部门负责人: 'operationDepartmentHead', 店铺运营总监: 'operationDirector', 店铺所属利润中心: 'profitCenter', 账号类型: 'accountType', 账号: 'account', 密码: 'password', 绑定手机号: 'boundPhone', 手机号验证码对接人: 'verificationContact', 聚水潭订单服务号: 'jstOrderService', 账号分组名称: 'accountGroupName',
storeName: 'storeName', operationOwner: 'operationOwner', operationDepartmentHead: 'operationDepartmentHead', operationDirector: 'operationDirector', profitCenter: 'profitCenter', accountType: 'accountType', account: 'account', password: 'password', boundPhone: 'boundPhone', verificationContact: 'verificationContact', jstOrderService: 'jstOrderService', accountGroupName: 'accountGroupName',
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId)
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: false })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
const rows = rawRows.map((raw) => Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => {
const key = HEADER_MAP[header.trim()]
return key ? [[key, value]] : []
})))
return jsonSuccess(await storeAccountManagementService.importRows(rows, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,9 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
return jsonSuccess(await storeAccountManagementService.listLogs({ id: request.nextUrl.searchParams.get('id') ?? undefined, storeName: request.nextUrl.searchParams.get('storeName') ?? undefined, user: ctx.user }), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,26 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { isStoreAccountFieldKey } from '@/lib/store-account-management'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
const schema = z.object({
updates: z.array(z.object({
fieldKey: z.string(),
subjectKey: z.string().trim().min(1).max(120),
visibility: z.enum(['HIDDEN', 'READONLY', 'EDITABLE']).nullable(),
}).strict()).min(1).max(500),
}).strict()
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => jsonSuccess(await storeAccountManagementService.getFieldPermissionSettings(ctx.user), 200, ctx.requestId), { requireAuth: true })
}
export async function PUT(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = schema.safeParse(await request.json().catch(() => null))
if (!parsed.success || parsed.data.updates.some((item) => !isStoreAccountFieldKey(item.fieldKey))) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '字段权限内容不正确', 400, ctx.requestId)
return jsonSuccess(await storeAccountManagementService.saveFieldPermissions({ updates: parsed.data.updates as never, user: ctx.user }), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,40 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FILTER_KEYS } from '@/lib/store-account-management'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
const createSchema = z.object({
storeName: z.string().trim().min(1).max(240),
operationOwner: z.string().trim().max(240).optional(),
operationDepartmentHead: z.string().trim().max(240).optional(),
operationDirector: z.string().trim().max(240).optional(),
profitCenter: z.string().trim().max(240).optional(),
accountType: z.string().trim().min(1).max(20),
account: z.string().trim().min(1).max(240),
password: z.string().max(500).optional(),
boundPhone: z.string().trim().max(80).optional(),
verificationContact: z.string().trim().max(240).optional(),
jstOrderService: z.string().trim().max(240).optional(),
accountGroupName: z.string().trim().max(240).optional(),
}).strict()
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const params = request.nextUrl.searchParams
const filters = Object.fromEntries(STORE_ACCOUNT_FILTER_KEYS.map((key) => [key, params.get(key)?.trim() ?? '']))
const page = Math.max(1, Number(params.get('page') || 1) || 1)
const pageSize = Math.min(200, Math.max(1, Number(params.get('pageSize') || 50) || 50))
return jsonSuccess(await storeAccountManagementService.listPage({ page, pageSize, filters: { ...EMPTY_STORE_ACCOUNT_FILTERS, ...filters }, user: ctx.user }), 200, ctx.requestId)
}, { requireAuth: true })
}
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = createSchema.safeParse(body)
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺账号信息不完整', 400, ctx.requestId)
return jsonSuccess(await storeAccountManagementService.create(parsed.data, ctx.user), 201, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,73 @@
export {}
const mockHandleRequest = jest.fn()
const mockJsonSuccess = jest.fn()
const mockJsonError = jest.fn()
const mockUpdateSupplier = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
jsonError: (...args: unknown[]) => mockJsonError(...args),
}))
jest.mock('@/server/services/supply-chain-supplier-information.service', () => ({
supplyChainSupplierInformationService: {
updateSupplier: (...args: unknown[]) => mockUpdateSupplier(...args),
},
}))
const { PUT } = require('./route')
describe('PUT /api/supply-chain/supplier-information', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(async (_request: unknown, handler: (ctx: unknown) => unknown) =>
handler({ requestId: 'req-supplier', user: { id: 'buyer-1', name: '采购员', role: 'USER' } }),
)
mockJsonSuccess.mockImplementation((data, status, requestId) => ({ data, status, requestId }))
mockJsonError.mockImplementation((code, message, status, requestId) => ({ code, message, status, requestId }))
})
it('passes validated supplier updates and the authenticated actor to the service', async () => {
mockUpdateSupplier.mockResolvedValue({ affectedBarcodes: 2, changedValues: 2 })
const request = {
json: jest.fn().mockResolvedValue({
supplierKey: '浩远',
updates: {
purchaseOwner: '采购B',
stableLeadTime: '30天',
backupSupplier: '备选厂',
},
}),
}
const response = await PUT(request)
expect(mockHandleRequest.mock.calls[0][2]).toEqual({ requireAuth: true })
expect(mockUpdateSupplier).toHaveBeenCalledWith({
supplierKey: '浩远',
updates: {
purchaseOwner: '采购B',
stableLeadTime: '30天',
backupSupplier: '备选厂',
},
user: expect.objectContaining({ id: 'buyer-1' }),
})
expect(response).toEqual(expect.objectContaining({ status: 200 }))
})
it('rejects an edit that has no concrete field value', async () => {
const request = {
json: jest.fn().mockResolvedValue({ supplierKey: '浩远', updates: {} }),
}
const response = await PUT(request)
expect(mockUpdateSupplier).not.toHaveBeenCalled()
expect(response).toEqual(expect.objectContaining({
message: '供应商信息修改内容不正确',
status: 400,
}))
})
})

View File

@ -0,0 +1,43 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service'
const updateSchema = z.object({
supplierKey: z.string().trim().min(1).max(180),
updates: z.object({
purchaseOwner: z.string().trim().min(1).max(160).optional(),
supplierName: z.string().trim().min(1).max(180).optional(),
minimumOrderQuantity: z.string().trim().min(1).max(120).optional(),
firstLeadTime: z.string().trim().min(1).max(120).optional(),
stableLeadTime: z.string().trim().min(1).max(120).optional(),
palletSpec: z.string().trim().min(1).max(240).optional(),
backupSupplier: z.string().trim().min(1).max(180).optional(),
supplierType: z.string().trim().min(1).max(160).optional(),
}).strict(),
}).strict().refine((input) => Object.keys(input.updates).length > 0, {
message: '请至少填写一项要修改的内容',
path: ['updates'],
})
export async function PUT(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json().catch(() => null)
const parsed = updateSchema.safeParse(body)
if (!parsed.success) {
return jsonError(
ErrorCode.VALIDATION_INVALID_FORMAT,
'供应商信息修改内容不正确',
400,
ctx.requestId,
)
}
const result = await supplyChainSupplierInformationService.updateSupplier({
...parsed.data,
user: ctx.user,
})
return jsonSuccess(result, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -6,6 +6,7 @@ import { supplyChainProductArchiveWarehouseConfigService } from '@/server/servic
const updateSchema = z.object({
warehouseName: z.string().trim().min(1).max(160),
warehouseOwnership: z.enum(['FACTORY', 'JIANSHANG']),
warehouseHead: z.string().trim().max(80).default(''),
storageType: z.enum(['INBOUND', 'DROPSHIP']),
status: z.enum(['ACTIVE', 'INACTIVE']),

View File

@ -6,6 +6,7 @@ import { supplyChainProductArchiveWarehouseConfigService } from '@/server/servic
const statusSchema = z.enum(['ACTIVE', 'INACTIVE', 'UNCONFIGURED'])
const storageTypeSchema = z.enum(['INBOUND', 'DROPSHIP'])
const ownershipSchema = z.enum(['FACTORY', 'JIANSHANG'])
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(100),
@ -14,6 +15,7 @@ const listQuerySchema = z.object({
})
const createSchema = z.object({
warehouseName: z.string().trim().min(1).max(160),
warehouseOwnership: ownershipSchema,
warehouseHead: z.string().trim().max(80).default(''),
storageType: storageTypeSchema,
}).strict()
@ -37,7 +39,7 @@ export async function POST(request: NextRequest) {
let body: unknown
try { body = await request.json() } catch { return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '请求体格式不正确', 400, ctx.requestId) }
const parsed = createSchema.safeParse(body)
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '仓库名称和入库/代发不能为空', 400, ctx.requestId)
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '仓库名称、仓库归属和入库/代发不能为空', 400, ctx.requestId)
return jsonSuccess(await supplyChainProductArchiveWarehouseConfigService.create(parsed.data), 201, ctx.requestId)
},
{ requireAuth: true, allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR'], allowedBusinessRoles: ['MANAGEMENT'] },

View File

@ -8,6 +8,7 @@ import {
Database,
PackagePlus,
ShoppingCart,
TrendingUp,
type LucideIcon,
} from 'lucide-react'
import type { SupplyChainPermissionTargetKey } from '@/lib/supply-chain-permission-settings'
@ -21,6 +22,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
Database,
PackagePlus,
ShoppingCart,
TrendingUp,
ClipboardList,
}

View File

@ -1,15 +1,16 @@
'use client'
import { X } from 'lucide-react'
import { useSupplyChainTabs } from './supply-chain-tabs'
import { isSupplyChainTabRoute, useSupplyChainTabs } from './supply-chain-tabs'
export function SupplyChainTabBar() {
const { tabs, activePath, clickTab, closeTab } = useSupplyChainTabs()
const visibleTabs = tabs.filter((tab) => isSupplyChainTabRoute(tab.path))
return (
<div className="sc-tabbar-wrap">
<div className="sc-tabbar">
{tabs.map((tab) => {
{visibleTabs.map((tab) => {
const active = tab.path === activePath
return (
<div

View File

@ -28,6 +28,13 @@ const DEFAULT_HOME_TAB: SupplyChainTab = {
closable: false,
}
// 一级分类只作为侧边栏入口,不在顶部标签栏占用一个标签。
const SUPPLY_CHAIN_NON_TAB_ROUTES = new Set(['/supply-chain/management'])
export function isSupplyChainTabRoute(path: string) {
return !SUPPLY_CHAIN_NON_TAB_ROUTES.has(path)
}
export function SupplyChainTabsProvider({
children,
homeTab = DEFAULT_HOME_TAB,
@ -43,7 +50,7 @@ export function SupplyChainTabsProvider({
const ensureTab = useCallback((path: string, title: string) => {
setTabs((current) => {
// 工作台不重复加
if (path === homeTab.path) return current
if (path === homeTab.path || !isSupplyChainTabRoute(path)) return current
if (current.some((t) => t.path === path)) return current
return [...current, { path, title, closable: true }]
})
@ -90,8 +97,10 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record<string, string> = {
'/supply-chain/database/product-archive': '产品全量档案',
'/supply-chain/database/category-config': '类目配置',
'/supply-chain/database/warehouse-config': '仓库配置',
'/supply-chain/database/store-account-management': '店铺账号管理',
'/supply-chain/new-products/unified-table': '新品开发进度总表',
'/supply-chain/new-products/sample-progress': '打样进度明细',
'/supply-chain/purchase/supplier-information': '供应商信息',
'/supply-chain/purchase/daily-affairs': '日常事项',
'/supply-chain/purchase/price-change': '产品变价',
'/supply-chain/operation-logs': '操作日志',

View File

@ -1,6 +1,17 @@
import { filterSupplyChainMenu, flattenSupplyChainMenu } from './supply-chain-menu'
import { filterSupplyChainMenu, flattenSupplyChainMenu, SUPPLY_CHAIN_MENU } from './supply-chain-menu'
describe('supply chain menu', () => {
it('keeps the top-level modules in the requested order', () => {
expect(SUPPLY_CHAIN_MENU.map((module) => module.key)).toEqual([
'dashboard',
'database',
'new-products',
'purchase',
'management',
'operation-logs',
])
})
it('places the unified table under database and sample progress first under purchase management', () => {
const items = flattenSupplyChainMenu()
@ -16,16 +27,34 @@ describe('supply chain menu', () => {
module: '采购管理',
route: '/supply-chain/new-products/sample-progress',
}),
expect.objectContaining({
label: '供应商信息',
module: '采购管理',
route: '/supply-chain/purchase/supplier-information',
}),
]),
)
expect(items.filter((item) => item.module === '采购管理').map((item) => item.label)).toEqual([
'打样进度明细',
'供应商信息',
'日常事项',
'产品变价',
])
})
it('keeps warehouse config and store account management as database peers', () => {
const database = SUPPLY_CHAIN_MENU.find((module) => module.key === 'database')
expect(database?.sections).toHaveLength(1)
expect(database?.sections?.[0]?.items.map((item) => item.label)).toEqual([
'产品全量档案',
'新品开发进度总表',
'类目配置',
'仓库配置',
'店铺账号管理',
])
})
it('only exposes the nodes opened for the current user', () => {
const menu = filterSupplyChainMenu(['productArchive'])
@ -39,4 +68,14 @@ describe('supply chain menu', () => {
'/supply-chain/operation-logs',
)
})
it('exposes business management as a top-level route', () => {
expect(flattenSupplyChainMenu()).toEqual(
expect.arrayContaining([expect.objectContaining({
label: '经营管理',
module: '经营管理',
route: '/supply-chain/management',
})]),
)
})
})

View File

@ -41,11 +41,8 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
{ label: '产品全量档案', route: '/supply-chain/database/product-archive', targetKey: 'productArchive' },
{ label: '新品开发进度总表', route: '/supply-chain/new-products/unified-table', targetKey: 'newProductUnifiedTable' },
{ label: '类目配置', route: '/supply-chain/database/category-config', targetKey: 'categoryConfig' },
],
},
{
items: [
{ label: '仓库配置', route: '/supply-chain/database/warehouse-config', targetKey: 'warehouseConfig' },
{ label: '店铺账号管理', route: '/supply-chain/database/store-account-management', targetKey: 'storeAccountManagement' },
],
},
],
@ -71,12 +68,20 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [
{
items: [
{ label: '打样进度明细', route: '/supply-chain/new-products/sample-progress', targetKey: 'newProductSampleProgress' },
{ label: '供应商信息', route: '/supply-chain/purchase/supplier-information', targetKey: 'purchaseSupplierInformation' },
{ label: '日常事项', route: '/supply-chain/purchase/daily-affairs', targetKey: 'purchaseDailyAffairs' },
{ label: '产品变价', route: '/supply-chain/purchase/price-change', targetKey: 'purchasePriceChange' },
],
},
],
},
{
key: 'management',
label: '经营管理',
icon: 'TrendingUp',
route: '/supply-chain/management',
targetKey: 'businessManagement',
},
{
key: 'operation-logs',
label: '操作日志',

View File

@ -9,6 +9,7 @@ import {
canResolveProductArchiveMaterialImage,
hasProductDevelopmentSkuImage,
resolveProductDevelopmentSkuImageUrl,
selectProductArchiveImageUrl,
} from './productArchiveImagePriority'
export function ProductArchiveMaterialImageCell({
@ -25,19 +26,21 @@ export function ProductArchiveMaterialImageCell({
const anchorRef = useRef<HTMLSpanElement>(null)
const [inView, setInView] = useState(false)
const [failedMaterialImageUrl, setFailedMaterialImageUrl] = useState('')
const [failedSourceImageUrl, setFailedSourceImageUrl] = useState('')
const [failedJstSkuImageUrl, setFailedJstSkuImageUrl] = useState('')
const [failedProductDevelopmentSkuImageUrl, setFailedProductDevelopmentSkuImageUrl] = useState('')
const hasWideRowSkuImage = hasProductDevelopmentSkuImage(row)
const canResolveMaterialImage = !hasWideRowSkuImage && canResolveProductArchiveMaterialImage(row)
const archiveSourceImageUrl = (() => {
const canResolveMaterialImage = canResolveProductArchiveMaterialImage(row)
const jstSkuImageUrl = (() => {
if (!canRequestJstImageFallback(row)) return ''
const params = new URLSearchParams({ barcode: row.barcode })
return `/api/supply-chain/product-archive/source-image?${params.toString()}`
})()
const sourceImageUrl = hasWideRowSkuImage
const productDevelopmentSkuImageUrl = hasProductDevelopmentSkuImage(row)
? resolveProductDevelopmentSkuImageUrl(row)
: archiveSourceImageUrl
const canResolveImage = Boolean(canResolveMaterialImage || sourceImageUrl)
: ''
const canResolveImage = Boolean(
canResolveMaterialImage || jstSkuImageUrl || productDevelopmentSkuImageUrl,
)
useEffect(() => {
if (!canResolveImage || inView) return
@ -68,16 +71,25 @@ export function ProductArchiveMaterialImageCell({
&& imageState.image?.previewUrl !== failedMaterialImageUrl
? imageState.image
: null
const materialLookupSettled = hasWideRowSkuImage
|| !canResolveMaterialImage
const materialLookupSettled = !canResolveMaterialImage
|| imageState.status === 'resolved'
|| imageState.status === 'not-found'
|| imageState.status === 'error'
|| Boolean(failedMaterialImageUrl)
const useSourceImage = !resolvedImage && materialLookupSettled
const availableJstSkuImageUrl = materialLookupSettled
&& jstSkuImageUrl !== failedJstSkuImageUrl
? jstSkuImageUrl
: ''
const availableProductDevelopmentSkuImageUrl = materialLookupSettled
&& productDevelopmentSkuImageUrl !== failedProductDevelopmentSkuImageUrl
? productDevelopmentSkuImageUrl
: ''
const imageUrl = inView
? resolvedImage?.previewUrl
|| (useSourceImage && sourceImageUrl !== failedSourceImageUrl ? sourceImageUrl
: '')
? selectProductArchiveImageUrl({
materialLibraryImageUrl: resolvedImage?.previewUrl,
jstSkuImageUrl: availableJstSkuImageUrl,
productDevelopmentSkuImageUrl: availableProductDevelopmentSkuImageUrl,
})
: ''
if (!imageUrl) {
return (
@ -103,11 +115,11 @@ export function ProductArchiveMaterialImageCell({
}
const matchLabel = resolvedImage?.source === 'barcode' ? '条码匹配' : 'SKU 匹配'
const title = resolvedImage
const title = imageUrl === resolvedImage?.previewUrl
? `${resolvedImage.viewLabel || '素材库图片'} · ${matchLabel}`
: hasWideRowSkuImage
? '新品 SKU 图片'
: '聚水潭 SKU 图片'
: imageUrl === availableJstSkuImageUrl
? '聚水潭 SKU 图片'
: '新品 SKU 图片'
return (
<button
@ -125,11 +137,15 @@ export function ProductArchiveMaterialImageCell({
height={44}
className="archive-product-image"
onError={() => {
if (resolvedImage) {
if (imageUrl === resolvedImage?.previewUrl) {
setFailedMaterialImageUrl(resolvedImage.previewUrl)
return
}
setFailedSourceImageUrl(sourceImageUrl)
if (imageUrl === availableJstSkuImageUrl) {
setFailedJstSkuImageUrl(availableJstSkuImageUrl)
return
}
setFailedProductDevelopmentSkuImageUrl(productDevelopmentSkuImageUrl)
}}
/>

View File

@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Settings, ArrowLeft, ArrowRight, FilterX, GripVertical, RefreshCw, ScanSearch } from 'lucide-react'
import { Settings, ArrowLeft, ArrowRight, Download, FilterX, GripVertical, RefreshCw, ScanSearch } from 'lucide-react'
import { GROUP_COLORS } from './columns'
import { useColumnSettings } from './useColumnSettings'
import { ColumnSettingsPanel } from './ColumnSettingsPanel'
@ -16,13 +16,14 @@ import { parseProductArchiveFilters, useProductArchiveViewPreferences } from './
import { ProductArchiveDetailDialog } from './ProductArchiveDetailDialog'
import { ProductArchiveChangeLogDialog } from './ProductArchiveChangeLogDialog'
import { useProductArchivePageImages } from './useProductArchivePageImages'
import { useProductArchiveImageExport } from './useProductArchiveImageExport'
import { ProductArchiveThemeStyles } from './ProductArchiveThemeStyles'
import type {
ProductArchiveFilters,
SupplyChainProductArchiveRow,
} from '@/lib/supply-chain-product-archive'
export function ProductArchiveView() {
export function ProductArchiveView({ canDownload = false }: { canDownload?: boolean }) {
const columnSettings = useColumnSettings()
const { effectiveColumns } = columnSettings
const [showColumnSettings, setShowColumnSettings] = useState(false)
@ -96,6 +97,7 @@ export function ProductArchiveView() {
const activeFilterCount = Object.values(filters).filter((value) =>
Array.isArray(value) ? value.length > 0 : Boolean(value),
).length
const { exporting, exportImages } = useProductArchiveImageExport(filters)
const changeFilters = useCallback((nextFilters: ProductArchiveFilters) => {
setFilters(nextFilters)
@ -140,14 +142,27 @@ export function ProductArchiveView() {
清除筛选 {activeFilterCount}
</button>
)}
<button className="archive-toolbar-action" onClick={() => setShowImageSearch(true)}>
<ScanSearch size={14} strokeWidth={1.8} />
以图搜图
</button>
<button className="archive-toolbar-action" onClick={() => setShowColumnSettings(true)}>
<Settings size={14} strokeWidth={1.8} />
列设置
</button>
<div className="archive-toolbar-actions">
{canDownload ? (
<button
type="button"
className="archive-toolbar-action"
disabled={exporting}
onClick={() => void exportImages()}
>
<Download size={14} strokeWidth={1.8} />
{exporting ? '导出中...' : '导出图片'}
</button>
) : null}
<button className="archive-toolbar-action" onClick={() => setShowImageSearch(true)}>
<ScanSearch size={14} strokeWidth={1.8} />
以图搜图
</button>
<button className="archive-toolbar-action" onClick={() => setShowColumnSettings(true)}>
<Settings size={14} strokeWidth={1.8} />
列设置
</button>
</div>
</div>
{/* 表格 */}
@ -334,8 +349,13 @@ export function ProductArchiveView() {
}
.archive-toolbar-title { font-size: 15px; font-weight: 600; color: #27272a; }
.archive-toolbar-sub { font-size: 12px; color: #a1a1aa; }
.archive-toolbar-action {
.archive-toolbar-actions {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.archive-toolbar-action {
display: inline-flex;
align-items: center;
gap: 4px;
@ -349,6 +369,7 @@ export function ProductArchiveView() {
transition: background 0.15s, border-color 0.15s;
}
.archive-toolbar-action:hover { background: #f4f4f5; border-color: #a1a1aa; }
.archive-toolbar-action:disabled { cursor: wait; opacity: 0.6; }
.archive-toolbar-clear-filter {
display: inline-flex;
align-items: center;

View File

@ -80,9 +80,10 @@ export const ARCHIVE_COLUMNS: ArchiveColumnGroup[] = [
{
group: '仓库信息',
columns: [
{ prop: 'warehouseType', label: '仓库属性', width: 100 },
{ prop: 'inOutWarehouse', label: '出入库仓库(采购类型)', width: 170 },
{ prop: 'warehouseHead', label: '仓库负责人', width: 110 },
{ prop: 'warehouseOwnership', label: '仓库归属', width: 100 },
{ prop: 'warehouseType', label: '仓库属性', width: 100 },
],
},
{

View File

@ -9,5 +9,5 @@ export default async function ProductArchivePage() {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(authUser, 'productArchive')
if (!access.view) redirect('/supply-chain')
return <ProductArchiveView />
return <ProductArchiveView canDownload={access.download} />
}

View File

@ -3,7 +3,9 @@ import {
canRequestJstImageFallback,
canResolveProductArchiveMaterialImage,
hasProductDevelopmentSkuImage,
resolveArchiveImageUrlForCurrentSite,
resolveProductDevelopmentSkuImageUrl,
selectProductArchiveImageUrl,
} from './productArchiveImagePriority'
function row(input: Partial<SupplyChainProductArchiveRow>) {
@ -49,4 +51,33 @@ describe('product archive image candidates', () => {
expect(canRequestJstImageFallback(row({ barcode: 'BARCODE-1' }))).toBe(false)
expect(canRequestJstImageFallback(row({ image: 'https://images.example.com/image.webp' }))).toBe(false)
})
it('keeps object-storage image URLs on the current site', () => {
expect(resolveArchiveImageUrlForCurrentSite(
'https://cp.jiansun.vip/api/storage/object?key=products%2Fimage.webp',
)).toBe('/api/storage/object?key=products%2Fimage.webp')
expect(resolveArchiveImageUrlForCurrentSite(
'https://cdn.example.com/products/image.webp',
)).toBe('https://cdn.example.com/products/image.webp')
})
it('uses material library, JST SKU, and product-development SKU images in order', () => {
const candidates = {
materialLibraryImageUrl: 'https://images.example.com/material.webp',
jstSkuImageUrl: 'https://images.example.com/jst.webp',
productDevelopmentSkuImageUrl: 'https://images.example.com/new-sku.webp',
}
expect(selectProductArchiveImageUrl(candidates)).toBe(candidates.materialLibraryImageUrl)
expect(selectProductArchiveImageUrl({
...candidates,
materialLibraryImageUrl: '',
})).toBe(candidates.jstSkuImageUrl)
expect(selectProductArchiveImageUrl({
...candidates,
materialLibraryImageUrl: '',
jstSkuImageUrl: '',
})).toBe(candidates.productDevelopmentSkuImageUrl)
expect(selectProductArchiveImageUrl({})).toBe('')
})
})

View File

@ -2,7 +2,7 @@ import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-ar
const OBJECT_STORAGE_ROUTE_PATH = '/api/storage/object'
function resolveArchiveImageUrlForCurrentSite(image: string) {
export function resolveArchiveImageUrlForCurrentSite(image: string) {
try {
const parsed = new URL(image, 'http://archive-image.local')
if (parsed.pathname === OBJECT_STORAGE_ROUTE_PATH && parsed.searchParams.has('key')) {
@ -14,6 +14,18 @@ function resolveArchiveImageUrlForCurrentSite(image: string) {
return image
}
export function selectProductArchiveImageUrl(input: {
materialLibraryImageUrl?: string
jstSkuImageUrl?: string
productDevelopmentSkuImageUrl?: string
}) {
return [
input.materialLibraryImageUrl,
input.jstSkuImageUrl,
input.productDevelopmentSkuImageUrl,
].map((value) => value?.trim() ?? '').find(Boolean) ?? ''
}
export function hasProductDevelopmentSkuImage(row: SupplyChainProductArchiveRow) {
return row.firstSourceCode === 'PRODUCT_DEVELOPMENT' && Boolean(row.image.trim())
}

View File

@ -0,0 +1,94 @@
import {
migrateWarehouseColumnOrder,
migrateWarehouseColumnOrderOnce,
type ColumnSetting,
} from './useColumnSettings'
describe('product archive warehouse column order migration', () => {
it('migrates the cached order shown on the existing page', () => {
const settings: ColumnSetting[] = [{
group: '仓库信息',
columns: [
{ prop: 'warehouseType', label: '仓库属性', width: 130, visible: false },
{ prop: 'inOutWarehouse', label: '出入库仓库(采购类型)', width: 170, visible: true },
{ prop: 'warehouseHead', label: '仓库负责人', width: 110, visible: true },
{ prop: 'warehouseOwnership', label: '仓库归属', width: 90, visible: true },
{ prop: 'customWarehouse', label: '自定义', width: 120, visible: true, isCustom: true },
],
}]
const migrated = migrateWarehouseColumnOrder(settings)
expect(migrated[0]?.columns.map((column) => column.prop)).toEqual([
'inOutWarehouse',
'warehouseHead',
'warehouseOwnership',
'warehouseType',
'customWarehouse',
])
expect(migrated[0]?.columns.find((column) => column.prop === 'warehouseType')).toEqual(
expect.objectContaining({ width: 130, visible: false }),
)
})
it('does not change other groups', () => {
const otherGroup: ColumnSetting = {
group: '基础信息',
columns: [
{ prop: 'barcode', label: '条码', width: 140, visible: true },
{ prop: 'model', label: '型号', width: 120, visible: false },
],
}
const migrated = migrateWarehouseColumnOrder([otherGroup])
expect(migrated[0]).toBe(otherGroup)
})
it('persists the migrated columns before marking the migration complete', () => {
const settings: ColumnSetting[] = [{
group: '仓库信息',
columns: [
{ prop: 'warehouseType', label: '仓库属性', width: 100, visible: true },
{ prop: 'inOutWarehouse', label: '出入库仓库(采购类型)', width: 170, visible: true },
{ prop: 'warehouseHead', label: '仓库负责人', width: 110, visible: true },
{ prop: 'warehouseOwnership', label: '仓库归属', width: 100, visible: true },
],
}]
const storage = {
getItem: jest.fn(() => null),
setItem: jest.fn(),
}
migrateWarehouseColumnOrderOnce(settings, storage)
expect(storage.setItem.mock.calls.map(([key]) => key)).toEqual([
'caigou_archive_columns',
'caigou_archive_columns_warehouse_order_v3',
])
expect(JSON.parse(storage.setItem.mock.calls[0]?.[1] ?? '[]')[0].columns)
.toEqual(expect.arrayContaining([
expect.objectContaining({ prop: 'warehouseOwnership', visible: true }),
expect.objectContaining({ prop: 'warehouseType', width: 100 }),
]))
})
it('preserves later manual sorting after the one-time migration', () => {
const settings: ColumnSetting[] = [{
group: '仓库信息',
columns: [
{ prop: 'warehouseType', label: '仓库属性', width: 100, visible: true },
{ prop: 'warehouseOwnership', label: '仓库归属', width: 100, visible: true },
],
}]
const storage = {
getItem: jest.fn(() => '1'),
setItem: jest.fn(),
}
const migrated = migrateWarehouseColumnOrderOnce(settings, storage)
expect(migrated).toBe(settings)
expect(storage.setItem).not.toHaveBeenCalled()
})
})

View File

@ -18,6 +18,55 @@ export type ColumnSetting = {
}
const STORAGE_KEY = 'caigou_archive_columns'
const WAREHOUSE_ORDER_MIGRATION_KEY = 'caigou_archive_columns_warehouse_order_v3'
const WAREHOUSE_COLUMN_ORDER = [
'inOutWarehouse',
'warehouseHead',
'warehouseOwnership',
'warehouseType',
] as const
export function migrateWarehouseColumnOrder(
settings: ColumnSetting[],
): ColumnSetting[] {
const orderByProp = new Map<string, number>(
WAREHOUSE_COLUMN_ORDER.map((prop, index) => [prop, index]),
)
return settings.map((group) => {
if (group.group !== '仓库信息') return group
return {
...group,
columns: group.columns
.map((column, index) => ({ column, index }))
.sort((left, right) => {
const leftOrder = orderByProp.get(left.column.prop)
const rightOrder = orderByProp.get(right.column.prop)
if (leftOrder === undefined && rightOrder === undefined) {
return left.index - right.index
}
if (leftOrder === undefined) return 1
if (rightOrder === undefined) return -1
return leftOrder - rightOrder
})
.map(({ column }) => column),
}
})
}
export function migrateWarehouseColumnOrderOnce(
settings: ColumnSetting[],
storage: Pick<Storage, 'getItem' | 'setItem'>,
): ColumnSetting[] {
if (storage.getItem(WAREHOUSE_ORDER_MIGRATION_KEY) === '1') {
return settings
}
const migrated = migrateWarehouseColumnOrder(settings)
storage.setItem(STORAGE_KEY, JSON.stringify(migrated))
storage.setItem(WAREHOUSE_ORDER_MIGRATION_KEY, '1')
return migrated
}
export function persistColumnSettings(settings: ColumnSetting[]) {
if (typeof window === 'undefined') return
@ -54,7 +103,7 @@ function loadSettings(): ColumnSetting[] {
const saved = JSON.parse(raw) as ColumnSetting[]
if (!Array.isArray(saved)) return defaults
// 合并:以 defaults 为准,用 saved 的 visible/顺序覆盖
return defaults.map((dg) => {
const merged = defaults.map((dg) => {
const sg = saved.find((s) => s.group === dg.group)
if (!sg) return dg
// 字段顺序按 saved,但字段定义以 defaults 为准(处理增删)
@ -95,6 +144,7 @@ function loadSettings(): ColumnSetting[] {
columns: [...orderedCols, ...customColumns],
}
})
return migrateWarehouseColumnOrderOnce(merged, localStorage)
} catch {
return defaults
}

View File

@ -0,0 +1,57 @@
'use client'
import { useCallback, useState } from 'react'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
function exportFileName(contentDisposition: string | null) {
const encoded = contentDisposition?.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (!encoded) return '产品档案图片URL.xlsx'
try {
return decodeURIComponent(encoded)
} catch {
return '产品档案图片URL.xlsx'
}
}
async function exportErrorMessage(response: Response) {
const payload = await response.json().catch(() => null) as {
error?: { message?: string }
} | null
return payload?.error?.message || '图片 URL 导出失败'
}
export function useProductArchiveImageExport(filters: ProductArchiveFilters) {
const [exporting, setExporting] = useState(false)
const exportImages = useCallback(async () => {
if (exporting) return
setExporting(true)
try {
const params = new URLSearchParams()
if (Object.keys(filters).length > 0) {
params.set('filters', JSON.stringify(filters))
}
const query = params.size > 0 ? `?${params.toString()}` : ''
const response = await fetch(
`/api/supply-chain/product-archive/image-export${query}`,
)
if (!response.ok) throw new Error(await exportErrorMessage(response))
const url = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = url
link.download = exportFileName(response.headers.get('Content-Disposition'))
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (error) {
window.alert(error instanceof Error ? error.message : '图片 URL 导出失败')
} finally {
setExporting(false)
}
}, [exporting, filters])
return { exporting, exportImages }
}

View File

@ -10,7 +10,6 @@ import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-ar
import type { ApiResponse } from '@/server/core/types'
import {
canResolveProductArchiveMaterialImage,
hasProductDevelopmentSkuImage,
} from './productArchiveImagePriority'
const PAGE_STABLE_MS = 600
@ -30,7 +29,7 @@ type CachedImage = {
}
function canResolveMaterialImage(row: SupplyChainProductArchiveRow) {
return !hasProductDevelopmentSkuImage(row) && canResolveProductArchiveMaterialImage(row)
return canResolveProductArchiveMaterialImage(row)
}
function cacheKeyForRow(row: SupplyChainProductArchiveRow) {

View File

@ -0,0 +1,247 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import type {
StoreAccountBatchEditableFieldKey,
StoreAccountBatchPreview,
StoreAccountBatchPreviewRow,
} from '@/lib/store-account-management'
type BatchField = { key: StoreAccountBatchEditableFieldKey; label: string }
type Props = {
ids: string[]
fields: BatchField[]
field: StoreAccountBatchEditableFieldKey
value: string
onFieldChange: (value: StoreAccountBatchEditableFieldKey) => void
onValueChange: (value: string) => void
onClose: () => void
onSave: () => Promise<void>
}
async function readPreviewResponse(response: Response): Promise<StoreAccountBatchPreview> {
const payload = await response.json().catch(() => null) as {
success?: boolean
data?: StoreAccountBatchPreview
error?: { message?: string }
} | null
if (!response.ok || !payload?.success || !payload.data) {
throw new Error(payload?.error?.message || '批量修改预览加载失败')
}
return payload.data
}
function displayFieldValue(field: StoreAccountBatchEditableFieldKey, value: string) {
if (!value.trim()) return '未填写'
if (field === 'accountType') return value === 'MAIN' ? '主账号' : value === 'SUB' ? '子账号' : value
return value
}
function displayAccount(row: StoreAccountBatchPreviewRow) {
const type = row.accountType === 'MAIN' ? '主账号' : row.accountType === 'SUB' ? '子账号' : ''
if (row.account === null && !type) return '不可见'
return [type, row.account ?? '账号不可见'].filter(Boolean).join(' · ')
}
export function StoreAccountBatchUpdateDialog({
ids,
fields,
field,
value,
onFieldChange,
onValueChange,
onClose,
onSave,
}: Props) {
const [preview, setPreview] = useState<StoreAccountBatchPreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(true)
const [previewError, setPreviewError] = useState('')
const [saving, setSaving] = useState(false)
const fieldLabel = fields.find((item) => item.key === field)?.label ?? field
const normalizedNextValue = value.trim()
const nextDisplayValue = normalizedNextValue ? displayFieldValue(field, normalizedNextValue) : '待输入'
useEffect(() => {
const controller = new AbortController()
let active = true
setPreview(null)
setPreviewError('')
setPreviewLoading(true)
void fetch('/api/supply-chain/store-account-management/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, field }),
signal: controller.signal,
}).then(readPreviewResponse).then((result) => {
if (active) setPreview(result)
}).catch((error) => {
if (active && !(error instanceof Error && error.name === 'AbortError')) {
setPreviewError(error instanceof Error ? error.message : '批量修改预览加载失败')
}
}).finally(() => {
if (active) setPreviewLoading(false)
})
return () => {
active = false
controller.abort()
}
}, [field, ids])
const counts = useMemo(() => {
if (!preview || !normalizedNextValue) return { changed: 0, unchanged: 0 }
return preview.rows.reduce((current, row) => {
if (row.currentValue.trim() === normalizedNextValue) current.unchanged += 1
else current.changed += 1
return current
}, { changed: 0, unchanged: 0 })
}, [normalizedNextValue, preview])
const save = async () => {
setSaving(true)
try {
await onSave()
} catch (error) {
window.alert(error instanceof Error ? error.message : '批量修改失败')
} finally {
setSaving(false)
}
}
return (
<div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
<div className="modal batch-modal">
<header>
<div>
<h2>批量修改店铺账号</h2>
<p>已勾选 {ids.length} 条账号,不会修改密码。</p>
</div>
<button type="button" onClick={onClose} aria-label="关闭"><X size={17} /></button>
</header>
{fields.length === 0 ? (
<p className="batch-empty">当前没有可批量修改的字段权限。</p>
) : (
<>
<label className="batch-field-control">
修改字段
<select
value={field}
onChange={(event) => {
onFieldChange(event.target.value as StoreAccountBatchEditableFieldKey)
onValueChange('')
}}
>
{fields.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}
</select>
</label>
<section className="batch-preview-section">
<div className="batch-preview-heading">
<h3>修改前后预览</h3>
<span>{previewLoading ? '正在读取...' : `已读取 ${preview?.found ?? 0} / ${ids.length} 条`}</span>
</div>
<div className="batch-preview-table-wrap">
<table>
<thead>
<tr>
<th>店铺名称</th>
<th>账号</th>
<th>修改前:{fieldLabel}</th>
<th>统一修改后</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{previewLoading ? (
<tr><td colSpan={5} className="batch-preview-message">正在读取修改前内容...</td></tr>
) : previewError ? (
<tr><td colSpan={5} className="batch-preview-message error">{previewError}</td></tr>
) : preview?.rows.length ? preview.rows.map((row) => {
const unchanged = Boolean(normalizedNextValue) && row.currentValue.trim() === normalizedNextValue
return (
<tr key={row.id}>
<td title={row.storeName ?? '不可见'}>{row.storeName ?? '不可见'}</td>
<td title={displayAccount(row)}>{displayAccount(row)}</td>
<td title={displayFieldValue(field, row.currentValue)}>{displayFieldValue(field, row.currentValue)}</td>
<td title={nextDisplayValue} className={!normalizedNextValue ? 'pending-value' : ''}>{nextDisplayValue}</td>
<td><span className={`batch-change-status ${!normalizedNextValue ? 'pending' : unchanged ? 'unchanged' : 'changed'}`}>{!normalizedNextValue ? '待输入' : unchanged ? '无变化' : '将修改'}</span></td>
</tr>
)
}) : (
<tr><td colSpan={5} className="batch-preview-message">没有可修改的账号记录。</td></tr>
)}
</tbody>
</table>
</div>
{preview && preview.missing > 0 && <p className="batch-preview-warning">有 {preview.missing} 条账号已不存在,本次不会修改。</p>}
</section>
<label className="batch-next-control">
统一修改为
{field === 'accountType' ? (
<select autoFocus value={value} onChange={(event) => onValueChange(event.target.value)}>
<option value="">请选择账号类型</option>
<option value="MAIN">主账号</option>
<option value="SUB">子账号</option>
</select>
) : (
<input autoFocus value={value} onChange={(event) => onValueChange(event.target.value)} placeholder={`请输入新的${fieldLabel}`} />
)}
</label>
<p className="batch-change-summary">
{normalizedNextValue ? `预计修改 ${counts.changed} 条,${counts.unchanged} 条无变化。` : '输入新值后,可在上方逐条核对修改结果。'}
</p>
</>
)}
<div className="modal-actions">
<button type="button" onClick={onClose}>取消</button>
<button
type="button"
className="primary"
disabled={fields.length === 0 || previewLoading || Boolean(previewError) || !preview?.rows.length || !normalizedNextValue || saving}
onClick={() => void save()}
>
{saving ? '修改中...' : `确认修改${normalizedNextValue ? `(${counts.changed})` : ''}`}
</button>
</div>
</div>
<style jsx>{`
.batch-modal { width: min(940px, calc(100vw - 32px)); }
header > div { min-width: 0; }
header p { margin: 5px 0 0; color: #71717a; font-size: 12px; }
.batch-field-control { width: 280px; }
.batch-preview-section { margin-top: 16px; }
.batch-preview-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.batch-preview-heading h3 { margin: 0; color: #27272a; font-size: 13px; font-weight: 650; }
.batch-preview-heading span { color: #71717a; font-size: 12px; }
.batch-preview-table-wrap { max-height: 310px; overflow: auto; border: 1px solid #e4e4e7; border-radius: 5px; }
table { width: 100%; min-width: 820px; border-collapse: collapse; table-layout: fixed; font-size: 12px; }
th { position: sticky; top: 0; z-index: 1; height: 38px; padding: 0 10px; border-bottom: 1px solid #d4d4d8; background: #f4f4f5; color: #52525b; text-align: left; white-space: nowrap; }
th:nth-child(1) { width: 18%; }
th:nth-child(2) { width: 21%; }
th:nth-child(3), th:nth-child(4) { width: 23%; }
th:nth-child(5) { width: 15%; }
td { height: 42px; padding: 0 10px; overflow: hidden; border-bottom: 1px solid #f0f0f1; color: #3f3f46; text-overflow: ellipsis; white-space: nowrap; }
tbody tr:last-child td { border-bottom: 0; }
.pending-value { color: #a1a1aa; }
.batch-change-status { display: inline-flex; min-width: 48px; height: 22px; align-items: center; justify-content: center; border-radius: 4px; font-size: 11px; }
.batch-change-status.pending { background: #f4f4f5; color: #71717a; }
.batch-change-status.unchanged { background: #f4f4f5; color: #52525b; }
.batch-change-status.changed { background: #eff6ff; color: #1d4ed8; }
.batch-preview-message { height: 120px; color: #a1a1aa; text-align: center; }
.batch-preview-message.error, .batch-preview-warning { color: #b91c1c; }
.batch-preview-warning { margin: 7px 0 0; font-size: 12px; }
.batch-next-control { margin-top: 16px; }
.batch-change-summary { margin: 8px 0 0; color: #71717a; font-size: 12px; }
.batch-empty { margin: 24px 0; color: #71717a; font-size: 13px; text-align: center; }
button:disabled { opacity: .45; cursor: not-allowed; }
`}</style>
</div>
)
}

View File

@ -0,0 +1,92 @@
'use client'
import { ArrowRight, Eye, History, X } from 'lucide-react'
import {
formatStoreAccountLogValue,
getStoreAccountLogFieldLabel,
getStoreAccountLogOperationLabel,
normalizeStoreAccountLogChanges,
type StoreAccountChangeLogEntry,
} from '@/lib/store-account-management'
function formatTime(value: string) {
const date = new Date(value)
return Number.isNaN(date.getTime())
? '-'
: new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(date)
}
export function StoreAccountLogDialog({
logs,
onClose,
}: {
logs: StoreAccountChangeLogEntry[]
onClose: () => void
}) {
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-zinc-950/30 p-4"
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<section className="flex max-h-[calc(100vh-32px)] w-[min(780px,calc(100vw-32px))] min-h-0 flex-col overflow-hidden rounded-[8px] border border-zinc-200 bg-white shadow-[0_20px_60px_rgba(24,24,27,0.2)]">
<header className="flex h-16 shrink-0 items-center justify-between border-b border-zinc-200 px-5 sm:px-6">
<div>
<h2 className="text-[17px] font-semibold text-zinc-900">更新日志</h2>
<p className="mt-1 text-[11px] text-zinc-500">账号资料的新增、修改与敏感信息查看记录</p>
</div>
<button type="button" className="flex h-8 w-8 items-center justify-center rounded-[5px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" onClick={onClose} title="关闭">
<X size={17} />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 sm:px-6">
{logs.length === 0 ? (
<div className="flex min-h-56 flex-col items-center justify-center text-zinc-400">
<History size={24} strokeWidth={1.5} />
<p className="mt-3 text-[12px]">暂无更新日志</p>
</div>
) : logs.map((entry) => {
const changes = normalizeStoreAccountLogChanges(entry.changes)
const passwordViewed = entry.operation === 'VIEW_PASSWORD'
return (
<article key={entry.id} className="border-b border-zinc-200 py-5 last:border-b-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="inline-flex h-6 items-center rounded-[4px] bg-zinc-900 px-2 text-[11px] font-semibold text-white">
{getStoreAccountLogOperationLabel(entry.operation)}
</span>
<span className="text-[11px] text-zinc-500">{entry.actorName || '未知操作人'} · {formatTime(entry.createdAt)}</span>
</div>
{passwordViewed ? (
<div className="mt-3 flex min-h-11 items-center gap-2 rounded-[5px] bg-zinc-50 px-3 text-[12px] text-zinc-700">
<Eye size={15} className="text-zinc-500" />
<span>已查看密码</span>
</div>
) : changes.length === 0 ? (
<p className="mt-3 text-[12px] text-zinc-400">无字段变更明细</p>
) : (
<div className="mt-3 divide-y divide-zinc-100 border-y border-zinc-100">
{changes.map((change, index) => (
<div key={`${change.field}-${index}`} className="grid gap-2 py-3 sm:grid-cols-[170px_minmax(0,1fr)] sm:items-center">
<span className="text-[12px] font-medium text-zinc-800">{getStoreAccountLogFieldLabel(change.field)}</span>
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_18px_minmax(0,1fr)] items-center gap-2 text-[11px]">
<span className="min-w-0 break-all rounded-[4px] bg-zinc-100 px-2 py-1.5 text-zinc-600">
<b className="mr-1 font-medium text-zinc-400">修改前</b>{formatStoreAccountLogValue(change.previousValue)}
</span>
<ArrowRight size={14} className="text-zinc-400" />
<span className="min-w-0 break-all rounded-[4px] bg-emerald-50 px-2 py-1.5 text-emerald-800">
<b className="mr-1 font-medium text-emerald-600">修改后</b>{formatStoreAccountLogValue(change.nextValue)}
</span>
</div>
</div>
))}
</div>
)}
</article>
)
})}
</div>
</section>
</div>
)
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,309 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Save, Search, UserRound, Users, X } from 'lucide-react'
import {
isStoreAccountFieldVisibility,
resolveStoreAccountFieldVisibility,
STORE_ACCOUNT_FIELDS,
type StoreAccountFieldKey,
type StoreAccountFieldPermissionRecord,
type StoreAccountFieldPermissionUpdate,
type StoreAccountFieldPermissionValue,
} from '@/lib/store-account-management'
import {
resolveSupplyChainPermissionSubjectKeys,
type SupplyChainPermissionOverrideMap,
} from '@/lib/supply-chain-permission-settings'
import {
usePermissionManagedUsers,
type PermissionManagedUser,
} from '@/app/users/components/usePermissionManagedUsers'
import {
getStoreAccountFieldPermissionBusinessTypes,
getStoreAccountPageAccessLabel,
NO_STORE_ACCOUNT_PAGE_ACCESS,
resolveStoreAccountUserPageAccess,
} from './storeAccountPermissionScope'
type PermissionSettings = {
permissions: StoreAccountFieldPermissionRecord[]
pagePermissionOverrides: SupplyChainPermissionOverrideMap
}
const VISIBILITY_OPTIONS: Array<{ value: Exclude<StoreAccountFieldPermissionValue, null>; label: string }> = [
{ value: 'HIDDEN', label: '不可见' },
{ value: 'READONLY', label: '只读' },
{ value: 'EDITABLE', label: '可编辑' },
]
const VISIBILITY_LABELS: Record<Exclude<StoreAccountFieldPermissionValue, null>, string> = {
HIDDEN: '不可见',
READONLY: '只读',
EDITABLE: '可编辑',
}
function permissionKey(subjectKey: string, fieldKey: StoreAccountFieldKey) {
return `${subjectKey}::${fieldKey}`
}
async function readResponse(response: Response) {
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '请求失败')
return payload.data
}
function userSubtitle(user: PermissionManagedUser) {
return user.department?.name || user.email || '未设置部门'
}
export function StoreAccountPermissionDialog({
onClose,
onSaved,
}: {
onClose: () => void
onSaved: () => void | Promise<void>
}) {
const managedUsers = usePermissionManagedUsers()
const [roleKey, setRoleKey] = useState('operation')
const [selectedUserId, setSelectedUserId] = useState('')
const [search, setSearch] = useState('')
const [permissions, setPermissions] = useState<StoreAccountFieldPermissionRecord[]>([])
const [pagePermissionOverrides, setPagePermissionOverrides] = useState<SupplyChainPermissionOverrideMap>({})
const [changes, setChanges] = useState<Record<string, StoreAccountFieldPermissionUpdate>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const configurableFields = STORE_ACCOUNT_FIELDS.filter((field) => !field.system)
const loadSettings = async () => {
setLoading(true)
setMessage('')
try {
const data = await readResponse(await fetch('/api/supply-chain/store-account-management/permissions', { cache: 'no-store' })) as PermissionSettings
setPermissions(data.permissions)
setPagePermissionOverrides(data.pagePermissionOverrides)
setChanges({})
} catch (error) {
setMessage(error instanceof Error ? error.message : '字段权限读取失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadSettings()
}, [])
const storedValues = useMemo(() => Object.fromEntries(permissions.map((item) => [
permissionKey(item.subjectKey, item.fieldKey),
item.visibility,
])) as Record<string, Exclude<StoreAccountFieldPermissionValue, null>>, [permissions])
const businessTypeOptions = useMemo(() => getStoreAccountFieldPermissionBusinessTypes(
pagePermissionOverrides,
managedUsers.users,
), [managedUsers.users, pagePermissionOverrides])
const activeBusinessType = businessTypeOptions.find((businessType) => businessType.key === roleKey) ?? null
useEffect(() => {
if (activeBusinessType) return
setRoleKey(businessTypeOptions[0]?.key ?? '')
setSelectedUserId('')
setSearch('')
}, [activeBusinessType, businessTypeOptions])
const eligibleUsers = useMemo(() => activeBusinessType?.users ?? [], [activeBusinessType])
const visibleUsers = useMemo(() => {
const normalized = search.trim().toLocaleLowerCase()
if (!normalized) return eligibleUsers
return eligibleUsers.filter((user) => `${user.name} ${user.email} ${user.department?.name ?? ''}`.toLocaleLowerCase().includes(normalized))
}, [eligibleUsers, search])
const selectedUser = eligibleUsers.find((user) => user.id === selectedUserId) ?? null
const selectedUserPageAccess = selectedUser
? resolveStoreAccountUserPageAccess(pagePermissionOverrides, selectedUser)
: null
const activePageAccess = selectedUserPageAccess ?? activeBusinessType?.pageAccess ?? NO_STORE_ACCOUNT_PAGE_ACCESS
const getValue = (subjectKey: string, fieldKey: StoreAccountFieldKey): StoreAccountFieldPermissionValue => {
const key = permissionKey(subjectKey, fieldKey)
return Object.prototype.hasOwnProperty.call(changes, key)
? changes[key]!.visibility
: storedValues[key] ?? null
}
const setValue = (subjectKey: string, fieldKey: StoreAccountFieldKey, visibility: StoreAccountFieldPermissionValue) => {
const key = permissionKey(subjectKey, fieldKey)
const stored = storedValues[key] ?? null
setChanges((current) => {
const next = { ...current }
if (stored === visibility) delete next[key]
else next[key] = { subjectKey, fieldKey, visibility }
return next
})
setMessage('')
}
const getEffectiveValue = (
fieldKey: StoreAccountFieldKey,
roleValue: StoreAccountFieldPermissionValue,
personalValue: StoreAccountFieldPermissionValue,
) => {
const roleValues = selectedUser
? resolveSupplyChainPermissionSubjectKeys(selectedUser)
.map((subjectKey) => getValue(subjectKey, fieldKey))
.filter(isStoreAccountFieldVisibility)
: roleValue && isStoreAccountFieldVisibility(roleValue) ? [roleValue] : []
return resolveStoreAccountFieldVisibility({
fieldKey,
pageView: activePageAccess.view,
pageEdit: activePageAccess.edit,
fallback: fieldKey === 'password' ? 'HIDDEN' : activePageAccess.edit ? 'EDITABLE' : 'READONLY',
roleValues,
personalValue,
})
}
const save = async () => {
const updates = Object.values(changes)
if (updates.length === 0) return
setSaving(true)
setMessage('')
try {
await readResponse(await fetch('/api/supply-chain/store-account-management/permissions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates }),
}))
await loadSettings()
await onSaved()
setMessage('权限已保存')
} catch (error) {
setMessage(error instanceof Error ? error.message : '字段权限保存失败')
} finally {
setSaving(false)
}
}
const selectRole = (nextRoleKey: string) => {
setRoleKey(nextRoleKey)
setSelectedUserId('')
setSearch('')
}
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-zinc-950/30 p-4" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
<section className="flex h-[min(760px,calc(100vh-32px))] w-[min(1080px,calc(100vw-32px))] min-h-0 flex-col overflow-hidden rounded-[8px] border border-zinc-200 bg-white shadow-[0_20px_60px_rgba(24,24,27,0.2)]">
<header className="flex h-16 shrink-0 items-center justify-between border-b border-zinc-200 px-6">
<div>
<h2 className="text-[17px] font-semibold text-zinc-900">字段权限</h2>
<p className="mt-1 text-[11px] text-zinc-500">业务类型默认 · 人员例外</p>
</div>
<button type="button" className="flex h-8 w-8 items-center justify-center rounded-[5px] text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" onClick={onClose} title="关闭">
<X size={17} />
</button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 grid-rows-[260px_minmax(0,1fr)] md:grid-cols-[250px_minmax(0,1fr)] md:grid-rows-1">
<aside className="flex min-h-0 flex-col border-b border-zinc-200 bg-zinc-50 md:border-b-0 md:border-r">
<div className="border-b border-zinc-200 p-4">
<label className="block text-[11px] font-semibold text-zinc-500">
业务类型
<select className="mt-2 h-9 w-full rounded-[5px] border border-zinc-300 bg-white px-2.5 text-[13px] text-zinc-800 outline-none focus:border-zinc-600 disabled:bg-zinc-100 disabled:text-zinc-400" value={roleKey} disabled={loading || businessTypeOptions.length === 0} onChange={(event) => selectRole(event.target.value)}>
{businessTypeOptions.length === 0
? <option value="">暂无已开放业务类型</option>
: businessTypeOptions.map((subject) => <option key={subject.key} value={subject.key}>{subject.label}</option>)}
</select>
</label>
</div>
<button type="button" disabled={!activeBusinessType} className={`flex min-h-14 items-center gap-3 border-b border-zinc-200 px-4 text-left disabled:cursor-not-allowed disabled:opacity-50 ${selectedUserId ? 'bg-white text-zinc-600 hover:bg-zinc-100' : 'bg-zinc-900 text-white'}`} onClick={() => setSelectedUserId('')}>
<Users size={16} />
<span><b className="block text-[12px]">业务类型默认</b><small className={`mt-0.5 block text-[10px] ${selectedUserId ? 'text-zinc-400' : 'text-zinc-300'}`}>{activeBusinessType ? getStoreAccountPageAccessLabel(activeBusinessType.pageAccess) : '暂无可配置对象'}</small></span>
</button>
<div className="flex min-h-0 flex-1 flex-col p-3">
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-[11px] font-semibold text-zinc-500">人员例外</span>
<span className="text-[10px] text-zinc-400">{eligibleUsers.length} 人</span>
</div>
<div className="flex h-8 shrink-0 items-center gap-2 rounded-[5px] border border-zinc-300 bg-white px-2 text-zinc-400 focus-within:border-zinc-600">
<Search size={13} />
<input className="min-w-0 flex-1 border-0 bg-transparent text-[12px] text-zinc-800 outline-none" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索人员" />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-y-auto">
{managedUsers.loading ? <p className="px-2 py-5 text-center text-[11px] text-zinc-400">人员读取中...</p> : managedUsers.error ? <p className="px-2 py-5 text-center text-[11px] text-red-600">{managedUsers.error}</p> : visibleUsers.length === 0 ? <p className="px-2 py-5 text-center text-[11px] text-zinc-400">暂无有页面权限的人员</p> : visibleUsers.map((user) => {
const exceptionCount = configurableFields.filter((field) => getValue(`user@${user.id}`, field.key) !== null).length
const selected = user.id === selectedUserId
const userPageAccess = resolveStoreAccountUserPageAccess(pagePermissionOverrides, user)
return <button key={user.id} type="button" className={`flex w-full items-center gap-2.5 rounded-[5px] px-2.5 py-2 text-left ${selected ? 'bg-zinc-200 text-zinc-950' : 'text-zinc-700 hover:bg-zinc-100'}`} onClick={() => setSelectedUserId(user.id)}>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white text-zinc-500 ring-1 ring-zinc-200"><UserRound size={14} /></span>
<span className="min-w-0 flex-1"><b className="block truncate text-[12px] font-medium">{user.name}</b><small className="block truncate text-[10px] text-zinc-400">{userSubtitle(user)} · {getStoreAccountPageAccessLabel(userPageAccess)}</small></span>
{exceptionCount > 0 && <span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[9px] text-white">{exceptionCount}</span>}
</button>
})}
</div>
</div>
</aside>
<main className="flex min-h-0 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200 px-5">
<div>
<h3 className="text-[14px] font-semibold text-zinc-900">{selectedUser ? selectedUser.name : activeBusinessType ? `${activeBusinessType.label}默认权限` : '暂无可配置对象'}</h3>
<p className="mt-0.5 text-[10px] text-zinc-500">{selectedUser ? `${userSubtitle(selectedUser)} · ${getStoreAccountPageAccessLabel(activePageAccess)}` : getStoreAccountPageAccessLabel(activePageAccess)}</p>
</div>
{message && <span className={`text-[11px] ${message === '权限已保存' ? 'text-emerald-700' : 'text-red-600'}`}>{message}</span>}
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full min-w-[650px] border-collapse text-[12px]">
<thead className="sticky top-0 z-10 bg-zinc-100 text-zinc-600">
<tr>
<th className="h-10 border-b border-r border-zinc-200 px-4 text-left font-semibold">字段</th>
<th className="h-10 w-[190px] border-b border-r border-zinc-200 px-4 text-left font-semibold">业务类型权限</th>
{selectedUser && <th className="h-10 w-[190px] border-b border-r border-zinc-200 px-4 text-left font-semibold">个人例外</th>}
{selectedUser && <th className="h-10 w-[130px] border-b border-zinc-200 px-4 text-left font-semibold">本层结果</th>}
</tr>
</thead>
<tbody>
{configurableFields.map((field) => {
const roleValue = getValue(roleKey, field.key)
const personalValue = selectedUser ? getValue(`user@${selectedUser.id}`, field.key) : null
const effectiveValue = getEffectiveValue(field.key, roleValue, personalValue)
return <tr key={field.key} className="border-b border-zinc-100 hover:bg-zinc-50">
<td className="h-12 border-r border-zinc-100 px-4 font-medium text-zinc-800">{field.label}{field.sensitive && <span className="ml-2 text-[9px] font-normal text-red-600">保密</span>}</td>
<td className="border-r border-zinc-100 px-3">
<select aria-label={`${field.label}业务类型权限`} className="h-8 w-full rounded-[4px] border border-zinc-300 bg-white px-2 text-[12px] text-zinc-700 outline-none focus:border-zinc-600" value={roleValue ?? ''} disabled={loading || saving || !activeBusinessType} onChange={(event) => setValue(roleKey, field.key, (event.target.value || null) as StoreAccountFieldPermissionValue)}>
<option value="" aria-label="未配置"></option>
{VISIBILITY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</td>
{selectedUser && <td className="border-r border-zinc-100 px-3">
<select aria-label={`${selectedUser.name}${field.label}个人例外`} className="h-8 w-full rounded-[4px] border border-zinc-300 bg-white px-2 text-[12px] text-zinc-700 outline-none focus:border-zinc-600" value={personalValue ?? ''} disabled={loading || saving} onChange={(event) => setValue(`user@${selectedUser.id}`, field.key, (event.target.value || null) as StoreAccountFieldPermissionValue)}>
<option value="">继承业务类型</option>
{VISIBILITY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</td>}
{selectedUser && <td className="px-4"><span className={`inline-flex rounded-full px-2 py-1 text-[10px] font-semibold ${effectiveValue === 'HIDDEN' ? 'bg-zinc-100 text-zinc-500' : effectiveValue === 'EDITABLE' ? 'bg-emerald-50 text-emerald-700' : 'bg-sky-50 text-sky-700'}`}>{VISIBILITY_LABELS[effectiveValue]}</span></td>}
</tr>
})}
</tbody>
</table>
</div>
</main>
</div>
<footer className="flex h-16 shrink-0 items-center justify-between border-t border-zinc-200 bg-white px-6">
<span className="text-[11px] text-zinc-500">{Object.keys(changes).length > 0 ? `待保存 ${Object.keys(changes).length} 项` : '没有未保存修改'}</span>
<div className="flex items-center gap-2">
<button type="button" className="h-8 rounded-[5px] border border-zinc-300 bg-white px-3 text-[12px] text-zinc-700 hover:bg-zinc-50" onClick={onClose}>取消</button>
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-[5px] bg-zinc-900 px-3 text-[12px] text-white disabled:cursor-not-allowed disabled:opacity-40" disabled={loading || saving || Object.keys(changes).length === 0} onClick={() => void save()}><Save size={14} />{saving ? '保存中...' : '保存'}</button>
</div>
</footer>
</section>
</div>
)
}

View File

@ -0,0 +1,23 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FILTER_KEYS, type StoreAccountFilters } from '@/lib/store-account-management'
import { StoreAccountManagementView } from './StoreAccountManagementView'
export const dynamic = 'force-dynamic'
export const metadata = { title: '店铺账号管理 · 供应链中心' }
function first(value: string | string[] | undefined) { return (Array.isArray(value) ? value[0] : value ?? '').trim() }
export default async function StoreAccountManagementPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(authUser, 'storeAccountManagement')
if (!access.view) redirect('/supply-chain')
const params = await searchParams
const filters = Object.fromEntries(STORE_ACCOUNT_FILTER_KEYS.map((key) => [key, first(params[key]).slice(0, 120)])) as StoreAccountFilters
const page = Math.max(1, Number(first(params.page)) || 1)
const pageSize = [50, 100, 200].includes(Number(first(params.pageSize))) ? Number(first(params.pageSize)) : 50
const data = await storeAccountManagementService.listPage({ page, pageSize, filters: { ...EMPTY_STORE_ACCOUNT_FILTERS, ...filters }, user: authUser })
return <StoreAccountManagementView initialData={data} initialFilters={filters} canEdit={access.edit} canManagePermissions={access.edit && (authUser.role === 'ADMIN' || authUser.role === 'SUPER_ADMIN')} />
}

View File

@ -0,0 +1,37 @@
import type { PermissionManagedUser } from '@/app/users/components/usePermissionManagedUsers'
import { getStoreAccountFieldPermissionBusinessTypes } from './storeAccountPermissionScope'
function createUser(input: Partial<PermissionManagedUser> & Pick<PermissionManagedUser, 'id' | 'name'>): PermissionManagedUser {
return {
email: `${input.id}@example.com`,
role: 'USER',
status: 'ACTIVE',
...input,
}
}
describe('getStoreAccountFieldPermissionBusinessTypes', () => {
it('keeps only real business types and users with final page access', () => {
const users = [
createUser({ id: 'operation-allowed', name: '运营可见', departmentPaths: ['运营中心'] }),
createUser({ id: 'operation-blocked', name: '运营屏蔽', departmentPaths: ['运营中心'] }),
createUser({ id: 'planning-exception', name: '企划例外', departmentPaths: ['品牌中心>商品企划部'] }),
createUser({ id: 'space-exception', name: '空间例外', departmentPaths: ['品牌中心>空间视觉部'] }),
]
const result = getStoreAccountFieldPermissionBusinessTypes({
'storeAccountManagement:operation:view': true,
'storeAccountManagement:user@operation-blocked:view': false,
'storeAccountManagement:user@planning-exception:view': true,
'storeAccountManagement:user@space-exception:view': true,
}, users)
expect(result.map((item) => item.key)).toEqual(['planning', 'operation'])
expect(result.find((item) => item.key === 'operation')?.users.map((user) => user.id)).toEqual([
'operation-allowed',
])
expect(result.find((item) => item.key === 'planning')?.pageAccess.view).toBe(false)
expect(result.find((item) => item.key === 'planning')?.users.map((user) => user.id)).toEqual([
'planning-exception',
])
})
})

View File

@ -0,0 +1,58 @@
import type { PermissionManagedUser } from '@/app/users/components/usePermissionManagedUsers'
import { STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES } from '@/lib/store-account-management'
import {
getSupplyChainRoleDefaultAccess,
resolveSupplyChainPermissionAccess,
resolveSupplyChainPermissionSubjectKeys,
type SupplyChainPermissionOverrideMap,
} from '@/lib/supply-chain-permission-settings'
export type StoreAccountPageAccess = {
view: boolean
download: boolean
edit: boolean
}
export const NO_STORE_ACCOUNT_PAGE_ACCESS: StoreAccountPageAccess = {
view: false,
download: false,
edit: false,
}
export function getStoreAccountPageAccessLabel(access: StoreAccountPageAccess) {
if (access.edit) return '页面可操作'
if (access.view) return '页面只读'
return '页面未开放'
}
export function resolveStoreAccountUserPageAccess(
overrides: SupplyChainPermissionOverrideMap,
user: PermissionManagedUser,
) {
return getSupplyChainRoleDefaultAccess(user) ?? resolveSupplyChainPermissionAccess(
overrides,
'storeAccountManagement',
resolveSupplyChainPermissionSubjectKeys(user),
user.id,
)
}
export function getStoreAccountFieldPermissionBusinessTypes(
overrides: SupplyChainPermissionOverrideMap,
users: PermissionManagedUser[],
) {
return STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES
.map((businessType) => {
const pageAccess = resolveSupplyChainPermissionAccess(
overrides,
'storeAccountManagement',
[businessType.key],
)
const eligibleUsers = users.filter((user) => (
resolveSupplyChainPermissionSubjectKeys(user).includes(businessType.key)
&& resolveStoreAccountUserPageAccess(overrides, user).view
))
return { ...businessType, pageAccess, users: eligibleUsers }
})
.filter((businessType) => businessType.pageAccess.view || businessType.users.length > 0)
}

View File

@ -6,9 +6,11 @@ import { SupplyChainConfigThemeStyles } from '../components/SupplyChainConfigThe
type Status = 'ACTIVE' | 'INACTIVE'
type StorageType = 'INBOUND' | 'DROPSHIP'
type WarehouseOwnership = 'FACTORY' | 'JIANSHANG'
type Item = {
id: string | null
warehouseName: string
warehouseOwnership: WarehouseOwnership | null
warehouseHead: string
storageType: StorageType | null
status: Status | null
@ -19,6 +21,7 @@ type PageInfo = { page: number; pageSize: number; total: number; totalPages: num
type Draft = {
id?: string
warehouseName: string
warehouseOwnership: WarehouseOwnership | ''
warehouseHead: string
storageType: StorageType | ''
status: Status
@ -96,6 +99,7 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
warehouseName: draft.warehouseName,
warehouseOwnership: draft.warehouseOwnership,
warehouseHead: draft.warehouseHead,
storageType: draft.storageType,
...(draft.id ? { status: draft.status } : {}),
@ -113,13 +117,14 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) {
}
const toggleStatus = async (item: Item) => {
if (!canEdit || !item.id || !item.storageType || !item.status) return
if (!canEdit || !item.id || !item.warehouseOwnership || !item.storageType || !item.status) return
try {
await readResponse(await fetch(`/api/supply-chain/warehouse-config/${encodeURIComponent(item.id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
warehouseName: item.warehouseName,
warehouseOwnership: item.warehouseOwnership ?? (item.warehouseName.startsWith('工厂-') ? 'FACTORY' : ''),
warehouseHead: item.warehouseHead,
storageType: item.storageType,
status: item.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE',
@ -134,11 +139,11 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) {
return (
<div className="warehouse-config-page">
<header className="warehouse-config-toolbar">
<div><h1>产品全量档案 - 仓库配置</h1><p>按出入库仓库匹配入库或代发</p></div>
<div><h1>产品全量档案 - 仓库配置</h1><p>按出入库仓库匹配仓库归属、负责人和入库/代发</p></div>
<div className="warehouse-config-toolbar-meta">
<span>共 {pageInfo.total} 条</span>
{canEdit ? (
<button type="button" className="warehouse-config-button primary" onClick={() => setDraft({ warehouseName: '', warehouseHead: '', storageType: '', status: 'ACTIVE' })}>
<button type="button" className="warehouse-config-button primary" onClick={() => setDraft({ warehouseName: '', warehouseOwnership: '', warehouseHead: '', storageType: '', status: 'ACTIVE' })}>
<Plus size={15} /> 新增配置
</button>
) : null}
@ -154,13 +159,14 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) {
<div className="warehouse-config-table-wrap">
<table className="warehouse-config-table">
<thead><tr><th>仓库名称</th><th>仓库负责人</th><th>入库/代发</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead>
<thead><tr><th>仓库名称</th><th>仓库归属</th><th>仓库负责人</th><th>入库/代发</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody>
{loading && <tr><td colSpan={6} className="warehouse-config-empty">正在加载...</td></tr>}
{!loading && error && <tr><td colSpan={6} className="warehouse-config-empty error">{error}</td></tr>}
{!loading && !error && items.length === 0 && <tr><td colSpan={6} className="warehouse-config-empty">暂无配置</td></tr>}
{loading && <tr><td colSpan={7} className="warehouse-config-empty">正在加载...</td></tr>}
{!loading && error && <tr><td colSpan={7} className="warehouse-config-empty error">{error}</td></tr>}
{!loading && !error && items.length === 0 && <tr><td colSpan={7} className="warehouse-config-empty">暂无配置</td></tr>}
{!loading && !error && items.map((item) => <tr key={item.id ?? `warehouse:${item.warehouseName}`}>
<td className="warehouse-config-name">{item.warehouseName}</td>
<td>{item.warehouseOwnership === 'FACTORY' ? '工厂' : item.warehouseOwnership === 'JIANSHANG' ? '简尚' : '未配置'}</td>
<td>{item.warehouseHead || '-'}</td>
<td>{item.storageType === 'INBOUND' ? '入库' : item.storageType === 'DROPSHIP' ? '代发' : '-'}</td>
<td><span className={`warehouse-config-status ${item.status?.toLowerCase() ?? 'unconfigured'}`}>{item.status === 'ACTIVE' ? '启用' : item.status === 'INACTIVE' ? '停用' : '未配置'}</span></td>
@ -168,8 +174,8 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) {
<td className="warehouse-config-actions">
{canEdit ? (
<>
<button type="button" onClick={() => setDraft({ id: item.id ?? undefined, warehouseName: item.warehouseName, warehouseHead: item.warehouseHead, storageType: item.storageType ?? '', status: item.status ?? 'ACTIVE' })}><Edit3 size={14} /> {item.configured ? '编辑' : '配置'}</button>
{item.configured && <button type="button" onClick={() => void toggleStatus(item)}>{item.status === 'ACTIVE' ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}{item.status === 'ACTIVE' ? '停用' : '启用'}</button>}
<button type="button" onClick={() => setDraft({ id: item.id ?? undefined, warehouseName: item.warehouseName, warehouseOwnership: item.warehouseOwnership ?? (item.warehouseName.startsWith('工厂-') ? 'FACTORY' : ''), warehouseHead: item.warehouseHead, storageType: item.storageType ?? '', status: item.status ?? 'ACTIVE' })}><Edit3 size={14} /> {item.configured ? '编辑' : '配置'}</button>
{item.configured && item.warehouseOwnership && <button type="button" onClick={() => void toggleStatus(item)}>{item.status === 'ACTIVE' ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}{item.status === 'ACTIVE' ? '停用' : '启用'}</button>}
</>
) : (
<span className="warehouse-config-readonly">仅查看</span>
@ -189,7 +195,8 @@ export function WarehouseConfigView({ canEdit }: { canEdit: boolean }) {
{draft && <div className="warehouse-config-modal-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setDraft(null) }}>
<form className="warehouse-config-modal" onSubmit={saveDraft}>
<div className="warehouse-config-modal-header"><h2>{draft.id ? '编辑仓库配置' : '新增仓库配置'}</h2><button type="button" title="关闭" aria-label="关闭" onClick={() => setDraft(null)}><X size={17} /></button></div>
<label>仓库名称<input autoFocus required maxLength={160} value={draft.warehouseName} onChange={(event) => setDraft({ ...draft, warehouseName: event.target.value })} /></label>
<label>仓库名称<input autoFocus required maxLength={160} value={draft.warehouseName} onChange={(event) => { const warehouseName = event.target.value; setDraft({ ...draft, warehouseName, warehouseOwnership: draft.warehouseOwnership || (warehouseName.startsWith('工厂-') ? 'FACTORY' : '') }) }} /></label>
<label>仓库归属<select required value={draft.warehouseOwnership} onChange={(event) => setDraft({ ...draft, warehouseOwnership: event.target.value as WarehouseOwnership | '' })}><option value="" disabled>请选择</option><option value="FACTORY">工厂</option><option value="JIANSHANG">简尚</option></select></label>
<label>仓库负责人<input maxLength={80} value={draft.warehouseHead} onChange={(event) => setDraft({ ...draft, warehouseHead: event.target.value })} /></label>
<label>入库/代发<select required value={draft.storageType} onChange={(event) => setDraft({ ...draft, storageType: event.target.value as StorageType | '' })}><option value="" disabled>请选择</option><option value="INBOUND">入库</option><option value="DROPSHIP">代发</option></select></label>
{draft.id && <label>状态<select value={draft.status} onChange={(event) => setDraft({ ...draft, status: event.target.value as Status })}><option value="ACTIVE">启用</option><option value="INACTIVE">停用</option></select></label>}

View File

@ -0,0 +1,23 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
export const metadata = { title: '经营管理 · 供应链中心' }
export default async function BusinessManagementPage() {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'businessManagement',
)
if (!access.view) redirect('/supply-chain')
return (
<main className="flex h-full min-h-0 flex-col bg-[var(--sc-bg)] text-[var(--sc-ink)]">
<header className="flex min-h-[54px] items-center border-b border-[var(--sc-border)] bg-[var(--sc-surface)] px-[18px] py-[9px]">
<h1 className="m-0 text-[17px] font-bold">经营管理</h1>
</header>
<section aria-label="经营管理内容" />
</main>
)
}

View File

@ -0,0 +1,280 @@
'use client'
import { FormEvent, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type {
SupplyChainSupplierInformationEditableField,
SupplyChainSupplierInformationRow,
SupplyChainSupplierInformationUpdates,
} from '@/lib/supply-chain-supplier-information'
type EditValues = Record<SupplyChainSupplierInformationEditableField, string>
function initialValue(values: readonly string[]) {
return values.length === 1 ? values[0] : ''
}
function CurrentValues({ values }: { values: readonly string[] }) {
if (values.length === 0) return <span className="supplier-edit-current empty">当前无值</span>
if (values.length === 1) return <span className="supplier-edit-current">当前:{values[0]}</span>
return (
<details className="supplier-edit-current multiple">
<summary>当前多个值({values.length} 项)</summary>
<div>{values.map((value) => <span key={value}>{value}</span>)}</div>
</details>
)
}
async function readResponse(response: Response) {
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) {
throw new Error(payload?.error?.message || '保存失败,请稍后重试')
}
return payload.data
}
export function SupplierInformationEditDialog({
row,
onClose,
onSaved,
}: {
row: SupplyChainSupplierInformationRow
onClose: () => void
onSaved: () => void
}) {
const [values, setValues] = useState<EditValues>({
purchaseOwner: initialValue(row.purchaserValues),
supplierName: row.supplierName,
minimumOrderQuantity: initialValue(row.minimumOrderQuantityValues),
firstLeadTime: initialValue(row.firstLeadTimeValues),
stableLeadTime: initialValue(row.stableLeadTimeValues),
palletSpec: initialValue(row.palletSpecValues),
backupSupplier: initialValue(row.backupSupplierValues),
supplierType: initialValue(row.supplierTypeValues),
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !saving) onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, saving])
const setField = (field: SupplyChainSupplierInformationEditableField, value: string) => {
setValues((current) => ({ ...current, [field]: value }))
}
const save = async (event: FormEvent) => {
event.preventDefault()
if (saving) return
const updates: SupplyChainSupplierInformationUpdates = {}
const addValue = (
field: SupplyChainSupplierInformationEditableField,
currentValues: readonly string[],
) => {
const value = values[field].trim()
if (!value) return
if (currentValues.length === 1 && currentValues[0] === value) return
updates[field] = value
}
addValue('purchaseOwner', row.purchaserValues)
addValue('supplierName', [row.supplierName])
addValue('minimumOrderQuantity', row.minimumOrderQuantityValues)
addValue('firstLeadTime', row.firstLeadTimeValues)
addValue('stableLeadTime', row.stableLeadTimeValues)
addValue('palletSpec', row.palletSpecValues)
addValue('backupSupplier', row.backupSupplierValues)
addValue('supplierType', row.supplierTypeValues)
if (Object.keys(updates).length === 0) {
setError('没有需要保存的修改;多个值字段可填写新值后统一覆盖')
return
}
setSaving(true)
setError(null)
try {
await readResponse(await fetch('/api/supply-chain/supplier-information', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplierKey: row.supplierKey, updates }),
}))
onSaved()
} catch (cause) {
setError(cause instanceof Error ? cause.message : '保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
const editableFields: Array<{
field: SupplyChainSupplierInformationEditableField
label: string
currentValues: readonly string[]
placeholder: string
}> = [
{ field: 'purchaseOwner', label: '采购', currentValues: row.purchaserValues, placeholder: '填写采购姓名' },
{ field: 'supplierName', label: '供应商', currentValues: [row.supplierName], placeholder: '填写供应商名称' },
{ field: 'minimumOrderQuantity', label: '产品起订量', currentValues: row.minimumOrderQuantityValues, placeholder: '填写产品起订量' },
{ field: 'firstLeadTime', label: '首单生产周期', currentValues: row.firstLeadTimeValues, placeholder: '填写首单生产周期' },
{ field: 'stableLeadTime', label: '稳定生产周期', currentValues: row.stableLeadTimeValues, placeholder: '填写稳定生产周期' },
{ field: 'palletSpec', label: '托盘参数', currentValues: row.palletSpecValues, placeholder: '填写托盘参数' },
{ field: 'backupSupplier', label: '替换供应商', currentValues: row.backupSupplierValues, placeholder: '填写替换供应商' },
{ field: 'supplierType', label: '供应商类型', currentValues: row.supplierTypeValues, placeholder: '填写供应商类型' },
]
return createPortal(
<div
className="supplier-edit-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !saving) onClose()
}}
>
<form
className="supplier-edit-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="supplier-edit-title"
onSubmit={save}
>
<header>
<div>
<h2 id="supplier-edit-title">编辑供应商信息</h2>
<p>{row.supplierName} · 关联 {row.barcodeCount} 个条码</p>
</div>
<button type="button" title="关闭" aria-label="关闭" onClick={onClose} disabled={saving}>
<X size={17} />
</button>
</header>
<div className="supplier-edit-notice">
保存后会统一更新该供应商关联的全部条码;留空表示不修改。
</div>
<div className="supplier-edit-grid">
{editableFields.map((item, index) => (
<label key={item.field}>
<span>{item.label}</span>
<CurrentValues values={item.currentValues} />
<input
autoFocus={index === 0}
value={values[item.field]}
onChange={(event) => setField(item.field, event.target.value)}
placeholder={item.currentValues.length > 1 ? '留空不修改;填写后统一覆盖' : item.placeholder}
maxLength={item.field === 'palletSpec' ? 240 : 180}
/>
</label>
))}
</div>
{error ? <p className="supplier-edit-error" role="alert">{error}</p> : null}
<footer>
<button type="button" onClick={onClose} disabled={saving}>取消</button>
<button type="submit" className="primary" disabled={saving}>
{saving ? '保存中...' : '确认保存'}
</button>
</footer>
<style jsx global>{`
.supplier-edit-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(24, 24, 27, 0.28);
}
.supplier-edit-dialog {
display: flex;
width: min(760px, calc(100vw - 40px));
max-height: calc(100vh - 40px);
flex-direction: column;
overflow: auto;
border: 1px solid #e4e4e7;
border-radius: 8px;
padding: 20px;
background: #fff;
box-shadow: 0 18px 50px rgba(24, 24, 27, 0.18);
color: #27272a;
}
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
h2 { margin: 0; font-size: 17px; font-weight: 680; }
header p { margin: 5px 0 0; color: #71717a; font-size: 12px; }
header button {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border: 0;
background: transparent;
color: #71717a;
cursor: pointer;
}
.supplier-edit-notice {
margin-top: 16px;
border-left: 3px solid #64748b;
padding: 8px 10px;
background: #f8fafc;
color: #52525b;
font-size: 12px;
}
.supplier-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 15px 18px;
margin-top: 16px;
}
.supplier-edit-grid label { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
.supplier-edit-grid label > span:first-child { color: #52525b; font-size: 12px; font-weight: 650; }
.supplier-edit-grid input {
width: 100%;
height: 36px;
box-sizing: border-box;
border: 1px solid #d4d4d8;
border-radius: 5px;
padding: 0 10px;
outline: none;
background: #fff;
color: #27272a;
font-size: 13px;
}
.supplier-edit-grid input:focus { border-color: #52525b; box-shadow: 0 0 0 2px rgba(82,82,91,.09); }
:global(.supplier-edit-current) { min-height: 18px; color: #71717a; font-size: 11px; }
:global(.supplier-edit-current.empty) { color: #a1a1aa; }
:global(.supplier-edit-current.multiple summary) { cursor: pointer; color: #315f75; font-weight: 650; }
:global(.supplier-edit-current.multiple div) { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
:global(.supplier-edit-current.multiple div span) { border: 1px solid #e4e4e7; border-radius: 3px; padding: 2px 5px; background: #fafafa; color: #52525b; }
.supplier-edit-error { margin: 14px 0 0; color: #b91c1c; font-size: 12px; }
footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
footer button {
height: 34px;
border: 1px solid #d4d4d8;
border-radius: 5px;
padding: 0 14px;
background: #fff;
color: #52525b;
cursor: pointer;
}
footer button.primary { border-color: #27272a; background: #27272a; color: #fff; }
footer button:disabled { cursor: wait; opacity: 0.55; }
@media (max-width: 680px) {
.supplier-edit-backdrop { padding: 12px; }
.supplier-edit-dialog { width: 100%; max-height: calc(100vh - 24px); padding: 16px; }
.supplier-edit-grid { grid-template-columns: 1fr; }
}
`}</style>
</form>
</div>,
document.body,
)
}

View File

@ -0,0 +1,167 @@
'use client'
import Link from 'next/link'
import { Filter, Search, X } from 'lucide-react'
import type { SupplyChainSupplierInformationFilters } from '@/lib/supply-chain-supplier-information'
const FIELDS: Array<{
key: keyof SupplyChainSupplierInformationFilters
label: string
placeholder: string
}> = [
{ key: 'purchaser', label: '采购', placeholder: '搜索采购' },
{ key: 'supplier', label: '供应商', placeholder: '搜索供应商' },
{ key: 'minimumOrderQuantity', label: '产品起订量', placeholder: '搜索起订量' },
{ key: 'firstLeadTime', label: '首单生产周期', placeholder: '搜索首单周期' },
{ key: 'stableLeadTime', label: '稳定生产周期', placeholder: '搜索稳定周期' },
{ key: 'palletSpec', label: '托盘参数', placeholder: '搜索托盘参数' },
{ key: 'backupSupplier', label: '替换供应商', placeholder: '搜索替换供应商' },
{ key: 'supplierType', label: '供应商类型', placeholder: '搜索供应商类型' },
]
export function SupplierInformationFilters({
filters,
pageSize,
}: {
filters: SupplyChainSupplierInformationFilters
pageSize: number
}) {
const activeCount = Object.values(filters).filter(Boolean).length
const clearHref = pageSize === 50
? '/supply-chain/purchase/supplier-information'
: `/supply-chain/purchase/supplier-information?pageSize=${pageSize}`
return (
<form className="supplier-filters" method="get">
{pageSize !== 50 ? <input type="hidden" name="pageSize" value={pageSize} /> : null}
<div className="supplier-filter-heading">
<Filter size={14} aria-hidden="true" />
<span>筛选</span>
{activeCount > 0 ? <b>{activeCount}</b> : null}
</div>
<div className="supplier-filter-grid">
{FIELDS.map((field) => (
<label key={field.key}>
<span>{field.label}</span>
<div>
<Search size={13} aria-hidden="true" />
<input
type="search"
name={field.key}
defaultValue={filters[field.key]}
placeholder={field.placeholder}
aria-label={`${field.label}筛选`}
maxLength={120}
/>
</div>
</label>
))}
</div>
<div className="supplier-filter-actions">
{activeCount > 0 ? (
<Link href={clearHref} className="supplier-clear-filters">
<X size={14} />
清空
</Link>
) : null}
<button type="submit">查询</button>
</div>
<style jsx>{`
.supplier-filters {
display: flex;
align-items: flex-end;
gap: 12px;
padding: 12px 18px;
overflow-x: auto;
border-bottom: 1px solid var(--sc-border);
background: var(--sc-surface);
}
.supplier-filter-heading {
display: flex;
height: 34px;
align-items: center;
gap: 6px;
color: var(--sc-muted);
font-size: 12px;
font-weight: 650;
white-space: nowrap;
}
.supplier-filter-heading b {
display: grid;
min-width: 19px;
height: 19px;
place-items: center;
border-radius: 3px;
background: var(--sc-surface-strong);
color: var(--sc-ink);
font-size: 11px;
}
.supplier-filter-grid {
display: grid;
min-width: 1120px;
flex: 1;
grid-template-columns: repeat(8, minmax(132px, 1fr));
gap: 8px;
}
.supplier-filter-grid label {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
color: var(--sc-muted);
font-size: 11px;
}
.supplier-filter-grid label > div {
display: flex;
height: 32px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid var(--sc-border);
border-radius: 4px;
color: var(--sc-subtle);
}
.supplier-filter-grid input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--sc-ink);
font-size: 12px;
}
.supplier-filter-actions {
display: flex;
flex: 0 0 auto;
height: 34px;
align-items: center;
gap: 7px;
}
.supplier-filter-actions button,
:global(.supplier-clear-filters) {
display: inline-flex;
height: 34px;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid var(--sc-border);
border-radius: 4px;
padding: 0 12px;
background: var(--sc-surface);
color: var(--sc-muted);
font-size: 12px;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.supplier-filter-actions button {
border-color: #3f3f46;
background: #3f3f46;
color: #fff;
font-weight: 650;
}
`}</style>
</form>
)
}

View File

@ -0,0 +1,192 @@
'use client'
import { Pencil } from 'lucide-react'
import type { SupplyChainSupplierInformationRow } from '@/lib/supply-chain-supplier-information'
function ValueCell({ values }: { values: readonly string[] }) {
if (values.length === 0) return <span className="supplier-empty-value">-</span>
if (values.length === 1) return <span className="supplier-single-value">{values[0]}</span>
return (
<details className="supplier-multi-value">
<summary>{values.length} 项</summary>
<div className="supplier-multi-list">
{values.map((value) => <span key={value}>{value}</span>)}
</div>
</details>
)
}
export function SupplierInformationTable({
rows,
startIndex,
canEdit,
onEdit,
}: {
rows: SupplyChainSupplierInformationRow[]
startIndex: number
canEdit: boolean
onEdit: (row: SupplyChainSupplierInformationRow) => void
}) {
return (
<div className="supplier-table-wrap">
<table className="supplier-table">
<colgroup>
<col style={{ width: 58 }} />
<col style={{ width: 120 }} />
<col style={{ width: 220 }} />
<col style={{ width: 140 }} />
<col style={{ width: 150 }} />
<col style={{ width: 150 }} />
<col style={{ width: 140 }} />
<col style={{ width: 170 }} />
<col style={{ width: 140 }} />
{canEdit ? <col style={{ width: 84 }} /> : null}
</colgroup>
<thead>
<tr>
<th>序号</th>
<th>采购</th>
<th>供应商</th>
<th>产品起订量</th>
<th>首单生产周期</th>
<th>稳定生产周期</th>
<th>托盘参数</th>
<th>替换供应商</th>
<th>供应商类型</th>
{canEdit ? <th>操作</th> : null}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={row.supplierKey}>
<td className="supplier-index-cell">{startIndex + rowIndex + 1}</td>
<td><ValueCell values={row.purchaserValues} /></td>
<td className="supplier-name-cell">
<strong>{row.supplierName}</strong>
<span>{row.barcodeCount} 个条码</span>
</td>
<td><ValueCell values={row.minimumOrderQuantityValues} /></td>
<td><ValueCell values={row.firstLeadTimeValues} /></td>
<td><ValueCell values={row.stableLeadTimeValues} /></td>
<td><ValueCell values={row.palletSpecValues} /></td>
<td><ValueCell values={row.backupSupplierValues} /></td>
<td><ValueCell values={row.supplierTypeValues} /></td>
{canEdit ? (
<td className="supplier-action-cell">
<button
type="button"
title={`编辑 ${row.supplierName}`}
onClick={() => onEdit(row)}
>
<Pencil size={13} />
编辑
</button>
</td>
) : null}
</tr>
))}
</tbody>
</table>
{rows.length === 0 ? <div className="supplier-empty">暂无供应商数据</div> : null}
<style jsx>{`
.supplier-table-wrap {
min-height: 0;
flex: 1;
overflow: auto;
padding-bottom: 68px;
background: var(--sc-surface);
}
.supplier-table {
width: 100%;
min-width: 1288px;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
}
.supplier-table th {
position: sticky;
z-index: 2;
top: 0;
height: 42px;
padding: 0 12px;
border-right: 1px solid var(--sc-border);
border-bottom: 1px solid var(--sc-border);
background: var(--sc-surface-strong);
color: var(--sc-muted);
text-align: left;
font-weight: 650;
white-space: nowrap;
}
.supplier-table td {
height: 52px;
padding: 8px 12px;
border-right: 1px solid var(--sc-border);
border-bottom: 1px solid var(--sc-border);
background: var(--sc-surface);
vertical-align: middle;
}
.supplier-table tbody tr:hover td { background: var(--sc-hover); }
.supplier-index-cell { color: var(--sc-subtle); text-align: center; }
.supplier-name-cell strong,
.supplier-name-cell span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.supplier-name-cell strong { color: var(--sc-ink); font-size: 13px; font-weight: 650; }
.supplier-name-cell span { margin-top: 3px; color: var(--sc-subtle); font-size: 11px; }
.supplier-empty-value { color: var(--sc-subtle); }
.supplier-single-value {
display: block;
overflow: hidden;
color: var(--sc-ink);
text-overflow: ellipsis;
white-space: nowrap;
}
.supplier-multi-value { position: relative; }
.supplier-multi-value summary {
width: fit-content;
cursor: pointer;
color: #315f75;
font-weight: 650;
list-style: none;
}
.supplier-multi-value summary::-webkit-details-marker { display: none; }
.supplier-multi-list { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.supplier-multi-list span {
display: inline-flex;
min-height: 22px;
align-items: center;
border: 1px solid var(--sc-border);
border-radius: 4px;
padding: 2px 6px;
background: var(--sc-surface-strong);
color: var(--sc-ink);
white-space: nowrap;
}
.supplier-action-cell button {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: #315f75;
font-size: 12px;
cursor: pointer;
}
.supplier-action-cell button:hover { color: #183b4b; }
.supplier-empty {
display: grid;
min-height: 240px;
place-items: center;
color: var(--sc-subtle);
font-size: 13px;
}
`}</style>
</div>
)
}

View File

@ -0,0 +1,245 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import type {
SupplyChainSupplierInformationFilters,
SupplyChainSupplierInformationPage,
SupplyChainSupplierInformationRow,
} from '@/lib/supply-chain-supplier-information'
import { SupplierInformationEditDialog } from './SupplierInformationEditDialog'
import { SupplierInformationFilters } from './SupplierInformationFilters'
import { SupplierInformationTable } from './SupplierInformationTable'
type SupplierInformationViewProps = {
data: SupplyChainSupplierInformationPage
filters: SupplyChainSupplierInformationFilters
canEdit: boolean
}
function pageHref(
page: number,
filters: SupplyChainSupplierInformationFilters,
pageSize: number,
) {
const params = new URLSearchParams()
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, value)
})
if (page > 1) params.set('page', String(page))
if (pageSize !== 50) params.set('pageSize', String(pageSize))
const query = params.toString()
return query
? `/supply-chain/purchase/supplier-information?${query}`
: '/supply-chain/purchase/supplier-information'
}
export function SupplierInformationView({
data,
filters,
canEdit,
}: SupplierInformationViewProps) {
const router = useRouter()
const { rows, pageInfo } = data
const [pageInput, setPageInput] = useState(String(pageInfo.page))
const [editingRow, setEditingRow] = useState<SupplyChainSupplierInformationRow | null>(null)
const hasPrevious = pageInfo.page > 1
const hasNext = pageInfo.page < pageInfo.totalPages
useEffect(() => {
setPageInput(String(pageInfo.page))
}, [pageInfo.page])
const goToPage = (page: number, pageSize = pageInfo.pageSize) => {
router.push(pageHref(page, filters, pageSize))
}
const commitPageInput = () => {
const parsed = Number(pageInput)
if (!Number.isFinite(parsed)) {
setPageInput(String(pageInfo.page))
return
}
const nextPage = Math.min(Math.max(1, Math.trunc(parsed)), pageInfo.totalPages)
setPageInput(String(nextPage))
if (nextPage !== pageInfo.page) goToPage(nextPage)
}
return (
<main className="supplier-information-page">
<header className="supplier-toolbar">
<div className="supplier-title-wrap">
<h1>供应商信息</h1>
<span>共 {pageInfo.total} 家</span>
</div>
</header>
<SupplierInformationFilters filters={filters} pageSize={pageInfo.pageSize} />
<SupplierInformationTable
rows={rows}
startIndex={(pageInfo.page - 1) * pageInfo.pageSize}
canEdit={canEdit}
onEdit={setEditingRow}
/>
<footer className="supplier-pagination">
<span className="supplier-page-total">共 {pageInfo.total} 家</span>
<button
type="button"
className="supplier-page-btn"
disabled={!hasPrevious}
onClick={() => goToPage(pageInfo.page - 1)}
>
<ArrowLeft size={12} strokeWidth={1.8} />
上一页
</button>
<span className="supplier-page-num">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
className="supplier-page-input"
aria-label="跳转页码"
value={pageInput}
onChange={(event) => setPageInput(event.target.value.replace(/\D/g, ''))}
onBlur={commitPageInput}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitPageInput()
}
}}
/>
<span className="supplier-page-sep">/</span>
<span>{pageInfo.totalPages} 页</span>
</span>
<button
type="button"
className="supplier-page-btn"
disabled={!hasNext}
onClick={() => goToPage(pageInfo.page + 1)}
>
下一页
<ArrowRight size={12} strokeWidth={1.8} />
</button>
<span className="supplier-page-size-wrap">
每页
<select
className="supplier-page-size"
value={pageInfo.pageSize}
onChange={(event) => goToPage(1, Number(event.target.value))}
>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
家
</span>
</footer>
{editingRow ? (
<SupplierInformationEditDialog
key={editingRow.supplierKey}
row={editingRow}
onClose={() => setEditingRow(null)}
onSaved={() => {
setEditingRow(null)
router.refresh()
}}
/>
) : null}
<style jsx>{`
.supplier-information-page {
position: relative;
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
background: var(--sc-bg);
color: var(--sc-ink);
}
.supplier-toolbar {
display: flex;
min-height: 54px;
align-items: center;
justify-content: space-between;
padding: 9px 18px;
border-bottom: 1px solid var(--sc-border);
background: var(--sc-surface);
}
.supplier-title-wrap {
display: flex;
align-items: baseline;
gap: 10px;
}
.supplier-title-wrap h1 { margin: 0; font-size: 17px; font-weight: 700; }
.supplier-title-wrap span { color: var(--sc-subtle); font-size: 12px; }
.supplier-pagination {
position: absolute;
bottom: 24px;
left: 50%;
z-index: 30;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
transform: translateX(-50%);
border: 1px solid var(--sc-border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04);
color: var(--sc-muted);
font-size: 13px;
white-space: nowrap;
backdrop-filter: blur(16px);
}
.supplier-page-total { margin-right: 8px; }
.supplier-page-btn {
display: inline-flex;
height: 30px;
align-items: center;
gap: 4px;
border: 1px solid var(--sc-border);
border-radius: 4px;
padding: 0 12px;
background: var(--sc-surface);
color: var(--sc-ink);
font-size: 13px;
cursor: pointer;
}
.supplier-page-btn:hover:not(:disabled) { background: var(--sc-hover); }
.supplier-page-btn:disabled { color: var(--sc-subtle); cursor: not-allowed; opacity: 0.45; }
.supplier-page-num,
.supplier-page-size-wrap { display: inline-flex; align-items: center; gap: 4px; }
.supplier-page-input,
.supplier-page-size {
height: 28px;
border: 1px solid var(--sc-border);
border-radius: 4px;
outline: none;
background: var(--sc-surface);
color: var(--sc-ink);
font-size: 13px;
}
.supplier-page-input { width: 52px; padding: 0 6px; font-weight: 600; text-align: center; }
.supplier-page-size { padding: 0 8px; cursor: pointer; }
.supplier-page-input:focus,
.supplier-page-size:focus { border-color: var(--sc-muted); box-shadow: 0 0 0 2px rgba(82, 82, 91, 0.12); }
.supplier-page-sep { color: var(--sc-subtle); }
.supplier-page-size-wrap { gap: 6px; margin-left: 8px; }
@media (max-width: 700px) {
.supplier-pagination {
bottom: 12px;
max-width: calc(100% - 24px);
gap: 6px;
padding: 7px 10px;
overflow-x: auto;
}
.supplier-page-total { display: none; }
}
`}</style>
</main>
)
}

View File

@ -0,0 +1,53 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import { supplyChainSupplierInformationService } from '@/server/services/supply-chain-supplier-information.service'
import {
SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS,
type SupplyChainSupplierInformationFilters,
} from '@/lib/supply-chain-supplier-information'
import { SupplierInformationView } from './SupplierInformationView'
export const dynamic = 'force-dynamic'
export const metadata = { title: '供应商信息 · 供应链中心' }
type SupplierInformationPageProps = {
searchParams: Promise<{
page?: string | string[]
pageSize?: string | string[]
} & Partial<Record<keyof SupplyChainSupplierInformationFilters, string | string[]>>>
}
function firstValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] ?? '' : value ?? ''
}
export default async function SupplierInformationPage({
searchParams,
}: SupplierInformationPageProps) {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'purchaseSupplierInformation',
)
if (!access.view) redirect('/supply-chain')
const params = await searchParams
const filters = Object.fromEntries(
SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS.map((key) => [
key,
firstValue(params[key]).trim().slice(0, 120),
]),
) as SupplyChainSupplierInformationFilters
const parsedPage = Number(firstValue(params.page))
const page = Number.isFinite(parsedPage) ? Math.max(1, Math.trunc(parsedPage)) : 1
const parsedPageSize = Number(firstValue(params.pageSize))
const pageSize = [50, 100, 200].includes(parsedPageSize) ? parsedPageSize : 50
const data = await supplyChainSupplierInformationService.listPage({
page,
pageSize,
filters,
})
return <SupplierInformationView data={data} filters={filters} canEdit={access.edit} />
}

View File

@ -0,0 +1,32 @@
import 'server-only'
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
const PREFIX = 'v1:'
function getKey() {
const secret = process.env.STORE_ACCOUNT_ENCRYPTION_KEY?.trim()
|| (process.env.NODE_ENV === 'production' ? '' : process.env.JWT_SECRET?.trim())
if (!secret || secret.length < 32) {
throw new Error('STORE_ACCOUNT_ENCRYPTION_KEY must be configured with at least 32 characters')
}
return createHash('sha256').update(secret).digest()
}
export function encryptStoreAccountPassword(value: string) {
if (!value) return ''
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${PREFIX}${iv.toString('base64url')}.${tag.toString('base64url')}.${ciphertext.toString('base64url')}`
}
export function decryptStoreAccountPassword(value: string) {
if (!value) return ''
if (!value.startsWith(PREFIX)) throw new Error('Unsupported store account password format')
const [ivPart, tagPart, dataPart] = value.slice(PREFIX.length).split('.')
if (!ivPart || !tagPart || !dataPart) throw new Error('Invalid store account password')
const decipher = createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivPart, 'base64url'))
decipher.setAuthTag(Buffer.from(tagPart, 'base64url'))
return Buffer.concat([decipher.update(Buffer.from(dataPart, 'base64url')), decipher.final()]).toString('utf8')
}

View File

@ -0,0 +1,85 @@
import {
formatStoreAccountLogValue,
getStoreAccountLogFieldLabel,
getStoreAccountLogOperationLabel,
normalizeStoreAccountLogChanges,
resolveStoreAccountFieldVisibility,
} from './store-account-management'
describe('store account field permissions', () => {
it('uses a personal field exception before the business type setting', () => {
expect(resolveStoreAccountFieldVisibility({
fieldKey: 'operationOwner',
pageView: true,
pageEdit: true,
fallback: 'EDITABLE',
roleValues: ['HIDDEN'],
personalValue: 'READONLY',
})).toBe('READONLY')
})
it('inherits the most permissive business type setting without a personal exception', () => {
expect(resolveStoreAccountFieldVisibility({
fieldKey: 'operationOwner',
pageView: true,
pageEdit: true,
fallback: 'READONLY',
roleValues: ['HIDDEN', 'EDITABLE'],
})).toBe('EDITABLE')
})
it('never lets a field exception exceed the page permission boundary', () => {
expect(resolveStoreAccountFieldVisibility({
fieldKey: 'operationOwner',
pageView: true,
pageEdit: false,
fallback: 'READONLY',
roleValues: ['HIDDEN'],
personalValue: 'EDITABLE',
})).toBe('READONLY')
expect(resolveStoreAccountFieldVisibility({
fieldKey: 'operationOwner',
pageView: false,
pageEdit: false,
fallback: 'EDITABLE',
roleValues: ['EDITABLE'],
personalValue: 'EDITABLE',
})).toBe('HIDDEN')
})
it('keeps system time fields read-only', () => {
expect(resolveStoreAccountFieldVisibility({
fieldKey: 'createdAt',
pageView: true,
pageEdit: true,
fallback: 'READONLY',
roleValues: [],
personalValue: 'EDITABLE',
})).toBe('READONLY')
})
})
describe('store account log presentation', () => {
it('translates operations, fields and account values into business labels', () => {
expect(getStoreAccountLogOperationLabel('UPDATE')).toBe('修改账号')
expect(getStoreAccountLogOperationLabel('VIEW_PASSWORD')).toBe('查看密码')
expect(getStoreAccountLogOperationLabel('UNKNOWN')).toBe('其他操作')
expect(getStoreAccountLogFieldLabel('operationOwner')).toBe('店铺运营负责人')
expect(getStoreAccountLogFieldLabel('unknownField')).toBe('其他字段')
expect(formatStoreAccountLogValue('MAIN')).toBe('主账号')
expect(formatStoreAccountLogValue('')).toBe('空')
})
it('keeps only valid field changes from historical log payloads', () => {
expect(normalizeStoreAccountLogChanges([
{ field: 'operationOwner', previousValue: '甲', nextValue: '乙' },
null,
{ previousValue: '缺少字段' },
])).toEqual([{
field: 'operationOwner',
previousValue: '甲',
nextValue: '乙',
}])
expect(normalizeStoreAccountLogChanges({})).toEqual([])
})
})

View File

@ -0,0 +1,229 @@
export const STORE_ACCOUNT_FIELD_KEYS = [
'storeName',
'operationOwner',
'operationDepartmentHead',
'operationDirector',
'profitCenter',
'accountType',
'account',
'password',
'boundPhone',
'verificationContact',
'jstOrderService',
'accountGroupName',
'createdAt',
'updatedAt',
] as const
export type StoreAccountFieldKey = (typeof STORE_ACCOUNT_FIELD_KEYS)[number]
export type StoreAccountFieldVisibility = 'HIDDEN' | 'READONLY' | 'EDITABLE'
export type StoreAccountFieldPermissionValue = StoreAccountFieldVisibility | null
export type StoreAccountType = 'MAIN' | 'SUB'
export type StoreAccountBatchEditableFieldKey = Exclude<StoreAccountFieldKey, 'password' | 'createdAt' | 'updatedAt'>
export const STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES = [
{ key: 'design', label: '设计' },
{ key: 'planning', label: '企划' },
{ key: 'brandCenter', label: '策划' },
{ key: 'purchase', label: '采购' },
{ key: 'merchandiser', label: '采购-跟单' },
{ key: 'pricing', label: '采购-核价' },
{ key: 'quality', label: '采购-质检' },
{ key: 'operation', label: '运营' },
{ key: 'warehouse', label: '仓库' },
{ key: 'customerService', label: '客服' },
{ key: 'management', label: '经管' },
{ key: 'viewer', label: '普通用户' },
] as const
export type StoreAccountFieldPermissionBusinessTypeKey =
(typeof STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES)[number]['key']
export const STORE_ACCOUNT_FIELDS: Array<{
key: StoreAccountFieldKey
label: string
sensitive?: boolean
system?: boolean
}> = [
{ key: 'storeName', label: '店铺名称' },
{ key: 'operationOwner', label: '店铺运营负责人' },
{ key: 'operationDepartmentHead', label: '店铺运营部门负责人' },
{ key: 'operationDirector', label: '店铺运营总监' },
{ key: 'profitCenter', label: '店铺所属利润中心' },
{ key: 'accountType', label: '账号类型' },
{ key: 'account', label: '账号' },
{ key: 'password', label: '密码', sensitive: true },
{ key: 'boundPhone', label: '绑定手机号' },
{ key: 'verificationContact', label: '手机号验证码对接人' },
{ key: 'jstOrderService', label: '聚水潭订单服务号' },
{ key: 'accountGroupName', label: '账号分组名称' },
{ key: 'createdAt', label: '店铺创建时间', system: true },
{ key: 'updatedAt', label: '店铺修改时间', system: true },
]
export const STORE_ACCOUNT_EDITABLE_FIELD_KEYS = STORE_ACCOUNT_FIELDS
.filter((field) => !field.system)
.map((field) => field.key)
export const STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS = STORE_ACCOUNT_EDITABLE_FIELD_KEYS
.filter((key): key is StoreAccountBatchEditableFieldKey => key !== 'password')
export const STORE_ACCOUNT_FILTER_KEYS = ['search', 'storeName', 'accountType', 'operationOwner', 'accountGroupName'] as const
export type StoreAccountFilterKey = (typeof STORE_ACCOUNT_FILTER_KEYS)[number]
export type StoreAccountFilters = Record<StoreAccountFilterKey, string>
export const EMPTY_STORE_ACCOUNT_FILTERS: StoreAccountFilters = {
search: '',
storeName: '',
accountType: '',
operationOwner: '',
accountGroupName: '',
}
export type StoreAccountFieldAccess = Record<StoreAccountFieldKey, StoreAccountFieldVisibility>
export type StoreAccountFieldPermissionRecord = {
fieldKey: StoreAccountFieldKey
subjectKey: string
visibility: StoreAccountFieldVisibility
}
export type StoreAccountFieldPermissionUpdate = {
fieldKey: StoreAccountFieldKey
subjectKey: string
visibility: StoreAccountFieldPermissionValue
}
export type StoreAccountChangeLogEntry = {
id: string
operation: string
actorName: string
createdAt: string
changes: unknown
}
export type StoreAccountChangeLogChange = {
field: string
previousValue: unknown
nextValue: unknown
}
const STORE_ACCOUNT_LOG_OPERATION_LABELS: Record<string, string> = {
CREATE: '新增账号',
UPDATE: '修改账号',
IMPORT_CREATE: '导入新增',
IMPORT_UPDATE: '导入更新',
BATCH_UPDATE: '批量修改',
VIEW_PASSWORD: '查看密码',
}
export type StoreAccountRow = {
id: string
storeName: string
operationOwner: string
operationDepartmentHead: string
operationDirector: string
profitCenter: string
accountType: StoreAccountType
account: string
passwordConfigured: boolean
boundPhone: string
verificationContact: string
jstOrderService: string
accountGroupName: string
createdAt: string
updatedAt: string
}
export type StoreAccountPage = {
rows: StoreAccountRow[]
storeNames: string[]
pageInfo: { page: number; pageSize: number; total: number; totalPages: number }
fieldAccess: StoreAccountFieldAccess
}
export type StoreAccountBatchPreviewRow = {
id: string
storeName: string | null
accountType: StoreAccountType | null
account: string | null
currentValue: string
}
export type StoreAccountBatchPreview = {
rows: StoreAccountBatchPreviewRow[]
requested: number
found: number
missing: number
}
export function getStoreAccountFieldLabel(key: StoreAccountFieldKey) {
return STORE_ACCOUNT_FIELDS.find((field) => field.key === key)?.label ?? key
}
export function getStoreAccountLogOperationLabel(operation: string) {
return STORE_ACCOUNT_LOG_OPERATION_LABELS[operation] ?? '其他操作'
}
export function getStoreAccountLogFieldLabel(field: string) {
return isStoreAccountFieldKey(field) ? getStoreAccountFieldLabel(field) : '其他字段'
}
export function formatStoreAccountLogValue(value: unknown) {
if (value === null || value === undefined || String(value).trim() === '') return '空'
if (value === 'MAIN') return '主账号'
if (value === 'SUB') return '子账号'
return String(value)
}
export function normalizeStoreAccountLogChanges(value: unknown): StoreAccountChangeLogChange[] {
if (!Array.isArray(value)) return []
return value.flatMap((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
const record = item as Record<string, unknown>
if (typeof record.field !== 'string') return []
return [{
field: record.field,
previousValue: record.previousValue,
nextValue: record.nextValue,
}]
})
}
export function isStoreAccountFieldKey(value: string): value is StoreAccountFieldKey {
return (STORE_ACCOUNT_FIELD_KEYS as readonly string[]).includes(value)
}
export function isStoreAccountBatchEditableFieldKey(value: string): value is StoreAccountBatchEditableFieldKey {
return (STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS as readonly string[]).includes(value)
}
export function isStoreAccountFieldVisibility(value: unknown): value is StoreAccountFieldVisibility {
return value === 'HIDDEN' || value === 'READONLY' || value === 'EDITABLE'
}
export function isStoreAccountFieldPermissionSubjectKey(value: string) {
return /^[A-Za-z][A-Za-z0-9_-]{0,119}$/.test(value) || /^user@[A-Za-z0-9_-]{1,114}$/.test(value)
}
export function resolveStoreAccountFieldVisibility(input: {
fieldKey: StoreAccountFieldKey
pageView: boolean
pageEdit: boolean
fallback: StoreAccountFieldVisibility
roleValues: StoreAccountFieldVisibility[]
personalValue?: StoreAccountFieldVisibility | null
}) {
if (!input.pageView) return 'HIDDEN' as const
let visibility = input.fallback
if (input.roleValues.includes('EDITABLE')) visibility = 'EDITABLE'
else if (input.roleValues.includes('READONLY')) visibility = 'READONLY'
else if (input.roleValues.includes('HIDDEN')) visibility = 'HIDDEN'
if (input.personalValue) visibility = input.personalValue
if (visibility === 'EDITABLE' && (!input.pageEdit || input.fieldKey === 'createdAt' || input.fieldKey === 'updatedAt')) {
return 'READONLY' as const
}
return visibility
}

View File

@ -109,6 +109,18 @@ describe('supply chain permission settings', () => {
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/new-products/sample-progress'),
).toBe('newProductSampleProgress')
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/purchase/supplier-information'),
).toBe('purchaseSupplierInformation')
expect(
getSupplyChainPermissionTargetForPath('/supply-chain/management'),
).toBe('businessManagement')
expect(
getSupplyChainApiPermissionRequirement(
'/api/supply-chain/supplier-information',
'PUT',
),
).toEqual({ targetKey: 'purchaseSupplierInformation', action: 'edit' })
})
it('keeps newly registered pages closed until a role or account opens them', () => {

View File

@ -20,13 +20,16 @@ export type SupplyChainPermissionRoleKey =
export type SupplyChainPermissionTargetKey =
| 'workspace'
| 'dashboard'
| 'businessManagement'
| 'productArchive'
| 'categoryConfig'
| 'warehouseConfig'
| 'storeAccountManagement'
| 'newProductOverview'
| 'newProductTerminationFeedback'
| 'newProductUnifiedTable'
| 'newProductSampleProgress'
| 'purchaseSupplierInformation'
| 'purchaseDailyAffairs'
| 'purchasePriceChange'
| 'operationLogs'
@ -70,13 +73,16 @@ export const SUPPLY_CHAIN_PERMISSION_ACTION_LABELS: Record<
export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
{ key: 'dashboard', label: '工作台', description: '供应链工作台总览' },
{ key: 'businessManagement', label: '经营管理', description: '后续经营类功能统一入口' },
{ key: 'productArchive', label: '产品全量档案', description: '商品、供应链和运营档案' },
{ key: 'categoryConfig', label: '类目配置', description: '产品全量档案类目维护' },
{ key: 'warehouseConfig', label: '仓库配置', description: '出入库仓库维护' },
{ key: 'storeAccountManagement', label: '店铺账号管理', description: '店铺账号人工维护与权限控制' },
{ key: 'newProductOverview', label: '新品经营总览', description: '新品经营指标和阶段时效分析' },
{ key: 'newProductTerminationFeedback', label: '新品终止任务反馈', description: '维护已撤销、已拒绝和已终止流程的原因' },
{ key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' },
{ key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' },
{ key: 'purchaseSupplierInformation', label: '供应商信息', description: '采购供应商信息汇总' },
{ key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' },
{ key: 'purchasePriceChange', label: '产品变价', description: '采购价格变更协作' },
{ key: 'operationLogs', label: '操作日志', description: '供应链操作审计' },
@ -90,13 +96,16 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial<
Record<SupplyChainPermissionTargetKey, string>
> = {
dashboard: '/supply-chain/dashboard',
businessManagement: '/supply-chain/management',
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',
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',
purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs',
purchasePriceChange: '/supply-chain/purchase/price-change',
operationLogs: '/supply-chain/operation-logs',
@ -300,13 +309,16 @@ export function resolveSupplyChainPermissionAccess(
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/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/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/supplier-information') return 'purchaseSupplierInformation' 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/operation-logs') return 'operationLogs' as const
@ -332,8 +344,14 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
if (pathname.startsWith('/api/supply-chain/warehouse-config')) {
return { targetKey: 'warehouseConfig' as const, action }
}
if (pathname.startsWith('/api/supply-chain/store-account-management')) {
return { targetKey: 'storeAccountManagement' 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 }
}
return undefined
}

View File

@ -43,7 +43,9 @@ export const PRODUCT_ARCHIVE_CHANGE_FIELDS = [
{ key: 'warehouseHead', label: '仓库负责人' },
{ key: 'minimumOrderQuantity', label: '产品起订量' },
{ key: 'firstLeadTime', label: '首单生产周期' },
{ key: 'stableLeadTime', label: '稳定生产周期' },
{ key: 'palletSpec', label: '托盘参数' },
{ key: 'backupSupplier', label: '替换供应商' },
{ key: 'dailySales', label: '采购日销' },
{ key: 'sales3d', label: '3日日销' },
{ key: 'sales7d', label: '7日日销' },

View File

@ -1,6 +1,31 @@
export const WAREHOUSE_CONFIG_ACTIVE = 'ACTIVE' as const
export const WAREHOUSE_CONFIG_INACTIVE = 'INACTIVE' as const
export const PRODUCT_ARCHIVE_WAREHOUSE_OWNERSHIPS = ['FACTORY', 'JIANSHANG'] as const
export type ProductArchiveWarehouseOwnership =
(typeof PRODUCT_ARCHIVE_WAREHOUSE_OWNERSHIPS)[number]
export function isProductArchiveWarehouseOwnership(
value: string | null | undefined,
): value is ProductArchiveWarehouseOwnership {
return (PRODUCT_ARCHIVE_WAREHOUSE_OWNERSHIPS as readonly string[]).includes(value ?? '')
}
export function productArchiveWarehouseOwnershipLabel(
value: ProductArchiveWarehouseOwnership | string | null | undefined,
) {
if (value === 'FACTORY') return '工厂'
if (value === 'JIANSHANG') return '简尚'
return '未配置'
}
export function inferProductArchiveWarehouseOwnership(
warehouseName: string | null | undefined,
): ProductArchiveWarehouseOwnership | null {
return warehouseName?.trim().startsWith('工厂-') ? 'FACTORY' : null
}
export type ProductArchiveWarehouseConfigStatus =
| typeof WAREHOUSE_CONFIG_ACTIVE
| typeof WAREHOUSE_CONFIG_INACTIVE
@ -8,6 +33,7 @@ export type ProductArchiveWarehouseConfigStatus =
export type ProductArchiveWarehouseConfig = {
id: string
warehouseName: string
warehouseOwnership: ProductArchiveWarehouseOwnership | null
warehouseHead: string
storageType: 'INBOUND' | 'DROPSHIP'
status: ProductArchiveWarehouseConfigStatus

View File

@ -33,6 +33,7 @@ export type SupplyChainProductArchiveRow = {
orderType: string
inOutWarehouse: string
warehouseType: string
warehouseOwnership: string
warehouseHead: string
storageType: string
isSeasonal: string

View File

@ -0,0 +1,77 @@
export type SupplyChainSupplierInformationRow = {
supplierKey: string
supplierName: string
barcodeCount: number
purchaserValues: string[]
minimumOrderQuantityValues: string[]
firstLeadTimeValues: string[]
stableLeadTimeValues: string[]
palletSpecValues: string[]
backupSupplierValues: string[]
supplierTypeValues: string[]
}
export const SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS = [
'purchaser',
'supplier',
'minimumOrderQuantity',
'firstLeadTime',
'stableLeadTime',
'palletSpec',
'backupSupplier',
'supplierType',
] as const
export type SupplyChainSupplierInformationFilterKey =
(typeof SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTER_KEYS)[number]
export type SupplyChainSupplierInformationFilters = Record<
SupplyChainSupplierInformationFilterKey,
string
>
export const EMPTY_SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTERS:
SupplyChainSupplierInformationFilters = {
purchaser: '',
supplier: '',
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
palletSpec: '',
backupSupplier: '',
supplierType: '',
}
export const SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS = [
'purchaseOwner',
'supplierName',
'minimumOrderQuantity',
'firstLeadTime',
'stableLeadTime',
'palletSpec',
'backupSupplier',
'supplierType',
] as const
export type SupplyChainSupplierInformationEditableField =
(typeof SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS)[number]
export type SupplyChainSupplierInformationUpdates = Partial<
Record<SupplyChainSupplierInformationEditableField, string>
>
export type SupplyChainSupplierInformationUpdateResult = {
affectedBarcodes: number
changedValues: number
supplierKey: string
}
export type SupplyChainSupplierInformationPage = {
rows: SupplyChainSupplierInformationRow[]
pageInfo: {
page: number
pageSize: number
total: number
totalPages: number
}
}

View File

@ -7,24 +7,31 @@ import {
function createTransaction() {
return {
supplyChainProductArchiveImageCache: {
upsert: jest.fn(),
deleteMany: jest.fn(),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
upsert: jest.fn().mockResolvedValue({}),
},
}
}
describe('product-development archive image cache sync', () => {
it('keeps product-development images in the archive row instead of the material cache', async () => {
function input(overrides: Partial<Parameters<typeof syncProductDevelopmentArchiveImageCache>[1]> = {}) {
return {
archiveId: 'archive-1',
barcode: 'BARCODE-1',
spu: 'SPU-1',
sku: 'SKU-1',
image: null,
imageFieldApplied: true,
...overrides,
}
}
describe('product-development archive SKU image cache cleanup', () => {
it('keeps the SKU image in the archive row instead of the material cache', async () => {
const tx = createTransaction()
await syncProductDevelopmentArchiveImageCache(tx as never, {
archiveId: 'archive-1',
barcode: 'BARCODE-1',
spu: 'SPU-1',
sku: 'SKU-1',
await syncProductDevelopmentArchiveImageCache(tx as never, input({
image: '/api/storage/object?key=sku-1.jpeg',
imageFieldApplied: true,
})
}))
expect(tx.supplyChainProductArchiveImageCache.deleteMany).toHaveBeenCalledWith({
where: {
@ -40,17 +47,10 @@ describe('product-development archive image cache sync', () => {
expect(tx.supplyChainProductArchiveImageCache.upsert).not.toHaveBeenCalled()
})
it('clears an old wide-row cache when the current SKU image becomes empty', async () => {
it('clears an old wide-row cache when the SKU image is empty', async () => {
const tx = createTransaction()
await syncProductDevelopmentArchiveImageCache(tx as never, {
archiveId: 'archive-1',
barcode: 'BARCODE-1',
spu: 'SPU-1',
sku: 'SKU-1',
image: null,
imageFieldApplied: true,
})
await syncProductDevelopmentArchiveImageCache(tx as never, input())
expect(tx.supplyChainProductArchiveImageCache.deleteMany).toHaveBeenCalledWith({
where: {
@ -66,19 +66,26 @@ describe('product-development archive image cache sync', () => {
expect(tx.supplyChainProductArchiveImageCache.upsert).not.toHaveBeenCalled()
})
it('keeps the existing cache when source priority rejects the image field', async () => {
it('does not clear legacy SKU caches when source priority protects the image field', async () => {
const tx = createTransaction()
await syncProductDevelopmentArchiveImageCache(tx as never, {
archiveId: 'archive-1',
barcode: 'BARCODE-1',
spu: 'SPU-1',
sku: 'SKU-1',
await syncProductDevelopmentArchiveImageCache(tx as never, input({
image: '/api/storage/object?key=sku-1.jpeg',
imageFieldApplied: false,
})
}))
expect(tx.supplyChainProductArchiveImageCache.deleteMany).not.toHaveBeenCalledWith({
where: {
archiveId: 'archive-1',
viewKey: {
in: [
PRODUCT_DEVELOPMENT_IMAGE_VIEW_KEY,
LEGACY_PRODUCT_DEVELOPMENT_IMAGE_VIEW_KEY,
],
},
},
})
expect(tx.supplyChainProductArchiveImageCache.upsert).not.toHaveBeenCalled()
expect(tx.supplyChainProductArchiveImageCache.deleteMany).not.toHaveBeenCalled()
})
})

View File

@ -88,6 +88,7 @@ describe('product-development archive image source boundary', () => {
tx,
expect.objectContaining({
archiveId: 'archive-1',
image: 'https://new-product.example.com/sku.webp',
imageFieldApplied: false,
}),
)

View File

@ -0,0 +1,101 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
$transaction: jest.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { SupplyChainSupplierInformationRepositoryClass } from '../supply-chain-supplier-information.repository'
const mockPrisma = prisma as unknown as {
$queryRaw: jest.Mock
$transaction: jest.Mock
}
const EMPTY_FILTERS = {
purchaser: '',
supplier: '',
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
palletSpec: '',
backupSupplier: '',
supplierType: '',
}
function queryText(query: { sql?: string; text?: string }) {
return query.sql ?? query.text ?? String(query)
}
describe('SupplyChainSupplierInformationRepository', () => {
beforeEach(() => jest.clearAllMocks())
it('combines filters against any barcode value in the supplier group', async () => {
mockPrisma.$queryRaw
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ total: 0 }])
const repository = new SupplyChainSupplierInformationRepositoryClass()
await repository.listPage({
skip: 0,
take: 50,
filters: {
...EMPTY_FILTERS,
purchaser: '采购A',
minimumOrderQuantity: '20',
stableLeadTime: '30天',
palletSpec: '托盘B',
backupSupplier: '备选厂',
},
})
const query = mockPrisma.$queryRaw.mock.calls[0][0] as {
sql?: string
text?: string
values?: unknown[]
}
const sql = queryText(query)
expect(sql).toContain('HAVING')
expect(sql).toContain('BOOL_OR("purchaseOwner" ILIKE')
expect(sql).toContain('BOOL_OR("minimumOrderQuantity" ILIKE')
expect(sql).toContain('BOOL_OR("stableLeadTime" ILIKE')
expect(sql).toContain('BOOL_OR("palletSpec" ILIKE')
expect(sql).toContain('BOOL_OR("backupSupplier" ILIKE')
expect(query.values).toEqual(expect.arrayContaining([
'%采购A%', '%20%', '%30天%', '%托盘B%', '%备选厂%',
]))
})
it('locks matching archive rows before one bulk update', async () => {
const records = [{
id: 'archive-1',
barcode: 'BC-001',
supplierName: '浩远',
purchaseOwner: '采购A',
minimumOrderQuantity: '10',
firstLeadTime: '7',
stableLeadTime: '',
palletSpec: '标准盘',
backupSupplier: '',
supplierType: '成品',
}]
const tx = {
$queryRaw: jest.fn().mockResolvedValue(records),
supplyChainProductArchive: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
}
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
const repository = new SupplyChainSupplierInformationRepositoryClass()
await expect(repository.updateSupplierFields({
supplierKey: '浩远',
updates: { purchaseOwner: '采购B', stableLeadTime: '30天', backupSupplier: '备选厂' },
})).resolves.toEqual(records)
expect(queryText(tx.$queryRaw.mock.calls[0][0])).toContain('FOR UPDATE')
expect(tx.supplyChainProductArchive.updateMany).toHaveBeenCalledWith({
where: { id: { in: ['archive-1'] } },
data: { purchaseOwner: '采购B', stableLeadTime: '30天', backupSupplier: '备选厂' },
})
})
})

View File

@ -0,0 +1,197 @@
import { prisma } from '@/lib/prisma'
import type { StoreAccountFilterKey, StoreAccountFilters, StoreAccountFieldPermissionUpdate, StoreAccountType } from '@/lib/store-account-management'
export type StoreAccountRecord = {
id: string
storeName: string
operationOwner: string
operationDepartmentHead: string
operationDirector: string
profitCenter: string
accountType: string
account: string
passwordCiphertext: string
boundPhone: string
verificationContact: string
jstOrderService: string
accountGroupName: string
createdAt: Date
updatedAt: Date
}
export type StoreAccountChangeRecord = {
id: string
storeAccountId: string | null
storeName: string
operation: string
actorId: string | null
actorName: string
changes: unknown
createdAt: Date
}
export type StoreAccountFieldPermissionRecord = {
fieldKey: string
subjectKey: string
visibility: string
}
const ACCOUNT_SELECT = {
id: true,
storeName: true,
operationOwner: true,
operationDepartmentHead: true,
operationDirector: true,
profitCenter: true,
accountType: true,
account: true,
passwordCiphertext: true,
boundPhone: true,
verificationContact: true,
jstOrderService: true,
accountGroupName: true,
createdAt: true,
updatedAt: true,
} as const
function contains(value: string) {
return { contains: value, mode: 'insensitive' as const }
}
function splitStoreNames(value: string) {
return [...new Set(value.split(/[,,;;]+/).map((item) => item.trim()).filter(Boolean))].slice(0, 500)
}
function buildWhere(filters: StoreAccountFilters) {
const and: Array<Record<string, unknown>> = []
if (filters.search) {
const searchTokens = [...new Set(filters.search.split(/[\s,,;;]+/).map((value) => value.trim()).filter(Boolean))].slice(0, 100)
and.push({ OR: searchTokens.flatMap((token) => [
{ storeName: contains(token) },
{ account: contains(token) },
{ operationOwner: contains(token) },
{ operationDepartmentHead: contains(token) },
{ operationDirector: contains(token) },
{ profitCenter: contains(token) },
{ boundPhone: contains(token) },
{ verificationContact: contains(token) },
{ jstOrderService: contains(token) },
{ accountGroupName: contains(token) },
]) })
}
const map: Partial<Record<StoreAccountFilterKey, string>> = {
accountType: 'accountType',
operationOwner: 'operationOwner',
accountGroupName: 'accountGroupName',
}
const selectedStoreNames = splitStoreNames(filters.storeName)
if (selectedStoreNames.length > 0) and.push({ storeName: { in: selectedStoreNames } })
for (const [key, field] of Object.entries(map)) {
const value = filters[key as StoreAccountFilterKey]
if (value) and.push({ [field]: contains(value) })
}
return and.length ? { AND: and } : undefined
}
export class StoreAccountManagementRepository {
async listPage(params: { skip: number; take: number; filters: StoreAccountFilters }) {
const where = buildWhere(params.filters)
const [rows, total] = await Promise.all([
prisma.storeAccount.findMany({ where, select: ACCOUNT_SELECT, orderBy: [{ storeName: 'asc' }, { account: 'asc' }], skip: params.skip, take: params.take }),
prisma.storeAccount.count({ where }),
])
return { rows, total }
}
async listStoreNames() {
const rows = await prisma.storeAccount.findMany({
select: { storeName: true },
distinct: ['storeName'],
orderBy: { storeName: 'asc' },
})
return rows.map((row) => row.storeName.trim()).filter(Boolean)
}
async findById(id: string) {
return prisma.storeAccount.findUnique({ where: { id }, select: ACCOUNT_SELECT })
}
async findByKey(params: { storeName: string; accountType: StoreAccountType; account: string }) {
return prisma.storeAccount.findUnique({
where: { storeName_accountType_account: params },
select: ACCOUNT_SELECT,
})
}
async create(data: {
storeName: string
operationOwner?: string
operationDepartmentHead?: string
operationDirector?: string
profitCenter?: string
accountType: StoreAccountType
account: string
passwordCiphertext?: string
boundPhone?: string
verificationContact?: string
jstOrderService?: string
accountGroupName?: string
actorId: string
}) {
const { actorId, ...values } = data
return prisma.storeAccount.create({ data: { ...values, passwordCiphertext: values.passwordCiphertext ?? '', createdById: actorId, updatedById: actorId }, select: ACCOUNT_SELECT })
}
async update(id: string, data: Record<string, string> & { updatedById: string }) {
return prisma.storeAccount.update({ where: { id }, data, select: ACCOUNT_SELECT })
}
async listByIds(ids: string[]) {
return prisma.storeAccount.findMany({ where: { id: { in: ids } }, select: ACCOUNT_SELECT })
}
async upsertByKey(data: {
storeName: string
accountType: StoreAccountType
account: string
values: Record<string, string>
actorId: string
}) {
return prisma.storeAccount.upsert({
where: { storeName_accountType_account: { storeName: data.storeName, accountType: data.accountType, account: data.account } },
create: { ...data.values, storeName: data.storeName, accountType: data.accountType, account: data.account, createdById: data.actorId, updatedById: data.actorId },
update: { ...data.values, updatedById: data.actorId },
select: ACCOUNT_SELECT,
})
}
async createLog(data: { storeAccountId: string | null; storeName: string; operation: string; actorId: string; actorName: string; changes: unknown }) {
return prisma.storeAccountChangeLog.create({ data: { ...data, changes: data.changes as object } })
}
async listLogs(params: { storeAccountId?: string; storeName?: string; take: number }) {
return prisma.storeAccountChangeLog.findMany({
where: params.storeAccountId ? { storeAccountId: params.storeAccountId } : params.storeName ? { storeName: params.storeName } : undefined,
orderBy: { createdAt: 'desc' },
take: params.take,
})
}
async listFieldPermissions() {
return prisma.storeAccountFieldPermission.findMany({ select: { fieldKey: true, subjectKey: true, visibility: true } })
}
async saveFieldPermissions(params: { updates: StoreAccountFieldPermissionUpdate[]; updatedById: string }) {
return prisma.$transaction(params.updates.map((item) => item.visibility === null
? prisma.storeAccountFieldPermission.deleteMany({
where: { fieldKey: item.fieldKey, subjectKey: item.subjectKey },
})
: prisma.storeAccountFieldPermission.upsert({
where: { fieldKey_subjectKey: { fieldKey: item.fieldKey, subjectKey: item.subjectKey } },
create: { ...item, visibility: item.visibility, updatedById: params.updatedById },
update: { visibility: item.visibility, updatedById: params.updatedById },
})))
}
}
export const storeAccountManagementRepository = new StoreAccountManagementRepository()

View File

@ -0,0 +1,46 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
import { buildProductArchiveWhere } from './supply-chain-product-archive.repository'
const productArchiveImageExportSelect = {
barcode: true,
firstSourceCode: true,
fieldSourceMap: true,
image: true,
imageCache: {
select: {
previewUrl: true,
source: true,
viewKey: true,
status: true,
},
},
} satisfies Prisma.SupplyChainProductArchiveSelect
export type ProductArchiveImageExportRecord =
Prisma.SupplyChainProductArchiveGetPayload<{
select: typeof productArchiveImageExportSelect
}>
export interface SupplyChainProductArchiveImageExportRepository {
listRows(filters?: ProductArchiveFilters): Promise<ProductArchiveImageExportRecord[]>
}
class SupplyChainProductArchiveImageExportRepositoryClass
implements SupplyChainProductArchiveImageExportRepository
{
async listRows(filters?: ProductArchiveFilters) {
return prisma.supplyChainProductArchive.findMany({
where: buildProductArchiveWhere(filters),
select: productArchiveImageExportSelect,
orderBy: [
{ businessCreatedAt: 'desc' },
{ barcode: 'asc' },
],
})
}
}
export const supplyChainProductArchiveImageExportRepository =
new SupplyChainProductArchiveImageExportRepositoryClass()

View File

@ -8,6 +8,7 @@ import type { ProductArchiveWarehouseConfigStatus } from '@/lib/supply-chain-pro
const warehouseConfigSelect = {
id: true,
warehouseName: true,
warehouseOwnership: true,
warehouseHead: true,
storageType: true,
status: true,
@ -29,12 +30,14 @@ export interface SupplyChainProductArchiveWarehouseConfigRepository {
listActive(): Promise<ProductArchiveWarehouseConfigRecord[]>
create(input: {
warehouseName: string
warehouseOwnership: string
warehouseHead: string
storageType: ProductArchiveStorageType
}): Promise<ProductArchiveWarehouseConfigRecord>
update(input: {
id: string
warehouseName: string
warehouseOwnership: string
warehouseHead: string
storageType: ProductArchiveStorageType
status: ProductArchiveWarehouseConfigStatus
@ -87,6 +90,7 @@ class SupplyChainProductArchiveWarehouseConfigRepositoryClass
async create(input: {
warehouseName: string
warehouseOwnership: string
warehouseHead: string
storageType: ProductArchiveStorageType
}) {
@ -99,6 +103,7 @@ class SupplyChainProductArchiveWarehouseConfigRepositoryClass
async update(input: {
id: string
warehouseName: string
warehouseOwnership: string
warehouseHead: string
storageType: ProductArchiveStorageType
status: ProductArchiveWarehouseConfigStatus
@ -107,6 +112,7 @@ class SupplyChainProductArchiveWarehouseConfigRepositoryClass
where: { id: input.id },
data: {
warehouseName: input.warehouseName,
warehouseOwnership: input.warehouseOwnership,
warehouseHead: input.warehouseHead,
storageType: input.storageType,
status: input.status,

View File

@ -11,6 +11,14 @@ const productArchiveSelect = {
spu: true,
sku: true,
image: true,
imageCache: {
select: {
previewUrl: true,
source: true,
viewKey: true,
status: true,
},
},
firstSourceCode: true,
businessCreatedAt: true,
categoryL1: true,

View File

@ -0,0 +1,192 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import type {
SupplyChainSupplierInformationFilters,
SupplyChainSupplierInformationUpdates,
} from '@/lib/supply-chain-supplier-information'
export type SupplyChainSupplierAggregateRecord = {
supplierKey: string
supplierName: string
barcodeCount: number
purchaserValues: string[]
minimumOrderQuantityValues: string[]
firstLeadTimeValues: string[]
stableLeadTimeValues: string[]
palletSpecValues: string[]
backupSupplierValues: string[]
supplierTypeValues: string[]
}
export type SupplyChainSupplierArchiveRecord = {
id: string
barcode: string
supplierName: string
purchaseOwner: string
minimumOrderQuantity: string
firstLeadTime: string
stableLeadTime: string
palletSpec: string
backupSupplier: string
supplierType: string
}
export interface SupplyChainSupplierInformationRepository {
listPage(params: {
skip: number
take: number
filters: SupplyChainSupplierInformationFilters
}): Promise<{
records: SupplyChainSupplierAggregateRecord[]
total: number
}>
updateSupplierFields(params: {
supplierKey: string
updates: SupplyChainSupplierInformationUpdates
}): Promise<SupplyChainSupplierArchiveRecord[]>
}
function likePattern(value: string) {
return `%${value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
}
function buildAggregateFilterClause(filters: SupplyChainSupplierInformationFilters) {
const clauses: Prisma.Sql[] = []
if (filters.purchaser) {
clauses.push(Prisma.sql`BOOL_OR("purchaseOwner" ILIKE ${likePattern(filters.purchaser)} ESCAPE '\\')`)
}
if (filters.supplier) {
clauses.push(Prisma.sql`BOOL_OR("supplierName" ILIKE ${likePattern(filters.supplier)} ESCAPE '\\')`)
}
if (filters.minimumOrderQuantity) {
clauses.push(Prisma.sql`BOOL_OR("minimumOrderQuantity" ILIKE ${likePattern(filters.minimumOrderQuantity)} ESCAPE '\\')`)
}
if (filters.firstLeadTime) {
clauses.push(Prisma.sql`BOOL_OR("firstLeadTime" ILIKE ${likePattern(filters.firstLeadTime)} ESCAPE '\\')`)
}
if (filters.stableLeadTime) {
clauses.push(Prisma.sql`BOOL_OR("stableLeadTime" ILIKE ${likePattern(filters.stableLeadTime)} ESCAPE '\\')`)
}
if (filters.palletSpec) {
clauses.push(Prisma.sql`BOOL_OR("palletSpec" ILIKE ${likePattern(filters.palletSpec)} ESCAPE '\\')`)
}
if (filters.backupSupplier) {
clauses.push(Prisma.sql`BOOL_OR("backupSupplier" ILIKE ${likePattern(filters.backupSupplier)} ESCAPE '\\')`)
}
if (filters.supplierType) {
clauses.push(Prisma.sql`BOOL_OR("supplierType" ILIKE ${likePattern(filters.supplierType)} ESCAPE '\\')`)
}
return clauses.length > 0
? Prisma.sql`HAVING ${Prisma.join(clauses, ' AND ')}`
: Prisma.empty
}
export class SupplyChainSupplierInformationRepositoryClass
implements SupplyChainSupplierInformationRepository
{
async listPage(params: {
skip: number
take: number
filters: SupplyChainSupplierInformationFilters
}) {
const filterClause = buildAggregateFilterClause(params.filters)
const groupedSuppliers = Prisma.sql`
SELECT
LOWER(REGEXP_REPLACE(BTRIM("supplierName"), E'\\s+', '', 'g')) AS "supplierKey",
MIN(BTRIM("supplierName")) AS "supplierName",
COUNT(DISTINCT "barcode")::int AS "barcodeCount",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("purchaseOwner"))
FILTER (WHERE NULLIF(BTRIM("purchaseOwner"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "purchaserValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("minimumOrderQuantity"))
FILTER (WHERE NULLIF(BTRIM("minimumOrderQuantity"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "minimumOrderQuantityValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("firstLeadTime"))
FILTER (WHERE NULLIF(BTRIM("firstLeadTime"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "firstLeadTimeValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("stableLeadTime"))
FILTER (WHERE NULLIF(BTRIM("stableLeadTime"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "stableLeadTimeValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("palletSpec"))
FILTER (WHERE NULLIF(BTRIM("palletSpec"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "palletSpecValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("backupSupplier"))
FILTER (WHERE NULLIF(BTRIM("backupSupplier"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "backupSupplierValues",
COALESCE(
ARRAY_AGG(DISTINCT BTRIM("supplierType"))
FILTER (WHERE NULLIF(BTRIM("supplierType"), '') IS NOT NULL),
ARRAY[]::text[]
) AS "supplierTypeValues"
FROM "SupplyChainProductArchive"
WHERE NULLIF(BTRIM("supplierName"), '') IS NOT NULL
GROUP BY LOWER(REGEXP_REPLACE(BTRIM("supplierName"), E'\\s+', '', 'g'))
${filterClause}
`
const [records, totals] = await Promise.all([
prisma.$queryRaw<SupplyChainSupplierAggregateRecord[]>(Prisma.sql`
WITH "groupedSuppliers" AS (${groupedSuppliers})
SELECT *
FROM "groupedSuppliers"
ORDER BY "supplierName" ASC
OFFSET ${params.skip}
LIMIT ${params.take}
`),
prisma.$queryRaw<Array<{ total: number }>>(Prisma.sql`
WITH "groupedSuppliers" AS (${groupedSuppliers})
SELECT COUNT(*)::int AS "total"
FROM "groupedSuppliers"
`),
])
return { records, total: totals[0]?.total ?? 0 }
}
async updateSupplierFields(params: {
supplierKey: string
updates: SupplyChainSupplierInformationUpdates
}) {
return prisma.$transaction(async (tx) => {
const records = await tx.$queryRaw<SupplyChainSupplierArchiveRecord[]>(Prisma.sql`
SELECT
"id",
"barcode",
"supplierName",
"purchaseOwner",
"minimumOrderQuantity",
"firstLeadTime",
"stableLeadTime",
"palletSpec",
"backupSupplier",
"supplierType"
FROM "SupplyChainProductArchive"
WHERE LOWER(REGEXP_REPLACE(BTRIM("supplierName"), E'\\s+', '', 'g')) = ${params.supplierKey}
ORDER BY "barcode" ASC
FOR UPDATE
`)
if (records.length === 0) return []
await tx.supplyChainProductArchive.updateMany({
where: { id: { in: records.map((record) => record.id) } },
data: params.updates,
})
return records
})
}
}
export const supplyChainSupplierInformationRepository =
new SupplyChainSupplierInformationRepositoryClass()

View File

@ -260,6 +260,41 @@ describe('product archive material image service', () => {
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('ignores a historical product-image cache when the material lookup misses', async () => {
const cacheRepository = {
...createImageCacheRepository(),
findByIdentifiers: jest.fn().mockResolvedValue({
id: 'cache-product-fallback',
archiveId: 'archive-product-fallback',
barcode: 'BARCODE-PRODUCT-FALLBACK',
spu: '',
sku: 'SKU-PRODUCT-FALLBACK',
previewUrl: 'https://image.example.com/product-fallback.webp',
source: 'product',
viewKey: 'product-catalog-wide-product',
viewLabel: '产品图片',
status: 'RESOLVED',
checkedAt: new Date(),
error: '',
}),
} satisfies SupplyChainProductArchiveImageCacheRepository
const fetchImpl = jest.fn(async () => jsonResponse(404, {
error: { code: 'WHITE_BACKGROUND_NOT_FOUND' },
})) as unknown as typeof fetch
const service = new ProductArchiveMaterialImageService(
fetchImpl,
config,
Date.now,
cacheRepository,
)
await expect(service.resolve({
archiveId: 'archive-product-fallback',
sku: 'SKU-PRODUCT-FALLBACK',
})).resolves.toBeNull()
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('uses the latest barcode image when the barcode lookup succeeds', async () => {
const fetchImpl = jest.fn(async () => jsonResponse(200, {
data: {

View File

@ -0,0 +1,119 @@
import { StoreAccountManagementService } from '../store-account-management.service'
import type { StoreAccountManagementRepository } from '@/server/repositories/store-account-management.repository'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
describe('StoreAccountManagementService field permission settings', () => {
afterEach(() => {
jest.restoreAllMocks()
})
it('passes a null personal value to the repository to restore business type inheritance', async () => {
const saveFieldPermissions = jest.fn().mockResolvedValue([])
const service = new StoreAccountManagementService({ saveFieldPermissions } as unknown as StoreAccountManagementRepository)
const result = await service.saveFieldPermissions({
updates: [{
fieldKey: 'operationOwner',
subjectKey: 'user@user-1',
visibility: null,
}],
user: {
id: 'admin-1',
email: 'admin@example.com',
name: '管理员',
role: 'ADMIN',
status: 'ACTIVE',
},
})
expect(saveFieldPermissions).toHaveBeenCalledWith({
updates: [{
fieldKey: 'operationOwner',
subjectKey: 'user@user-1',
visibility: null,
}],
updatedById: 'admin-1',
})
expect(result).toEqual({ saved: 0, inherited: 1 })
})
it('rejects system fields and unknown business types', async () => {
const saveFieldPermissions = jest.fn().mockResolvedValue([])
const service = new StoreAccountManagementService({ saveFieldPermissions } as unknown as StoreAccountManagementRepository)
const user = {
id: 'admin-1',
email: 'admin@example.com',
name: '管理员',
role: 'ADMIN' as const,
status: 'ACTIVE' as const,
}
await expect(service.saveFieldPermissions({
updates: [{ fieldKey: 'createdAt', subjectKey: 'operation', visibility: 'READONLY' }],
user,
})).rejects.toThrow('字段权限内容不正确')
await expect(service.saveFieldPermissions({
updates: [{ fieldKey: 'operationOwner', subjectKey: 'unknownRole', visibility: 'READONLY' }],
user,
})).rejects.toThrow('字段权限内容不正确')
await expect(service.saveFieldPermissions({
updates: [{ fieldKey: 'operationOwner', subjectKey: 'space', visibility: 'READONLY' }],
user,
})).rejects.toThrow('字段权限内容不正确')
expect(saveFieldPermissions).not.toHaveBeenCalled()
})
it('returns only store account page permissions to the field permission dialog', async () => {
const listFieldPermissions = jest.fn().mockResolvedValue([{
fieldKey: 'operationOwner',
subjectKey: 'operation',
visibility: 'READONLY',
}])
jest.spyOn(supplyChainPermissionSettingsService, 'getSettings').mockResolvedValue({
overrides: {
'storeAccountManagement:operation:view': true,
'storeAccountManagement:user@user-1:edit': true,
'productArchive:operation:view': true,
},
})
const service = new StoreAccountManagementService({ listFieldPermissions } as unknown as StoreAccountManagementRepository)
const result = await service.getFieldPermissionSettings({
id: 'admin-1',
email: 'admin@example.com',
name: '管理员',
role: 'ADMIN',
status: 'ACTIVE',
})
expect(result.permissions).toHaveLength(1)
expect(result.pagePermissionOverrides).toEqual({
'storeAccountManagement:operation:view': true,
'storeAccountManagement:user@user-1:edit': true,
})
})
it('does not allow a non-admin to read or save field permission settings', async () => {
const listFieldPermissions = jest.fn()
const saveFieldPermissions = jest.fn()
const service = new StoreAccountManagementService({
listFieldPermissions,
saveFieldPermissions,
} as unknown as StoreAccountManagementRepository)
const user = {
id: 'user-1',
email: 'user@example.com',
name: '普通用户',
role: 'USER' as const,
status: 'ACTIVE' as const,
}
await expect(service.getFieldPermissionSettings(user)).rejects.toThrow('只有管理员可以查看字段权限配置')
await expect(service.saveFieldPermissions({
updates: [{ fieldKey: 'operationOwner', subjectKey: 'operation', visibility: 'READONLY' }],
user,
})).rejects.toThrow('只有管理员可以配置字段权限')
expect(listFieldPermissions).not.toHaveBeenCalled()
expect(saveFieldPermissions).not.toHaveBeenCalled()
})
})

View File

@ -26,8 +26,33 @@ jest.mock('@/server/repositories/supply-chain-product-archive-clearance-stock.re
import { recordProductArchiveFieldChanges } from '../supply-chain-product-archive-change-log.service'
import { SupplyChainProductArchiveEnrichmentSyncService } from '../supply-chain-product-archive-enrichment-sync.service'
import { buildProductArchiveEnrichmentUpdates } from '../supply-chain-product-archive-enrichment-sync.mapper'
describe('supply-chain product archive enrichment sync', () => {
it('does not clear archive warehouse fields when no configuration matches', () => {
const updates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-1',
barcode: 'BAR-001',
spu: 'SPU-001',
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: 'WH-1',
},
{
clearanceStocks: [],
suppliers: [],
stocks: [],
warehouses: [],
prices: [],
catalogCosts: [],
warehouseConfigs: [],
},
)
expect(updates.some((update) => 'warehouseHead' in update.patch || 'storageType' in update.patch)).toBe(false)
})
it('persists every external group and logs only later value changes', async () => {
const target = {
id: 'archive-1',
@ -123,9 +148,10 @@ describe('supply-chain product archive enrichment sync', () => {
},
configRepository: {
listActive: jest.fn().mockResolvedValue([{
id: 'warehouse-config-1',
warehouseName: 'WH-1',
warehouseHead: '负责人',
id: 'warehouse-config-1',
warehouseName: 'WH-1',
warehouseOwnership: 'FACTORY',
warehouseHead: '负责人',
storageType: 'INBOUND',
}]),
},

View File

@ -0,0 +1,102 @@
import ExcelJS from 'exceljs'
import type { ProductArchiveImageExportRecord } from '@/server/repositories/supply-chain-product-archive-image-export.repository'
import {
resolveProductArchiveImageExportUrl,
SupplyChainProductArchiveImageExportService,
} from '../supply-chain-product-archive-image-export.service'
function record(
values: Partial<ProductArchiveImageExportRecord>,
): ProductArchiveImageExportRecord {
return {
barcode: 'BAR-001',
firstSourceCode: 'JST_SKU',
fieldSourceMap: {},
image: null,
imageCache: null,
...values,
}
}
describe('product archive image export', () => {
it('uses the same image source priority as the archive page', () => {
expect(resolveProductArchiveImageExportUrl(record({
firstSourceCode: 'PRODUCT_DEVELOPMENT',
image: 'https://images.example.com/new-sku.webp',
imageCache: {
previewUrl: 'https://images.example.com/material.webp',
source: 'sku',
viewKey: 'sku-zip',
status: 'RESOLVED',
},
}))).toBe('https://images.example.com/material.webp')
expect(resolveProductArchiveImageExportUrl(record({
image: 'https://images.example.com/jst.webp',
imageCache: {
previewUrl: 'https://images.example.com/material.webp',
source: 'barcode',
viewKey: 'latest',
status: 'RESOLVED',
},
}))).toBe('https://images.example.com/material.webp')
expect(resolveProductArchiveImageExportUrl(record({
image: 'https://images.example.com/jst.webp',
}))).toBe('https://images.example.com/jst.webp')
expect(resolveProductArchiveImageExportUrl(record({
imageCache: {
previewUrl: 'https://cp.jiansun.vip/api/storage/object?key=product.webp',
source: 'product',
viewKey: 'product-catalog-wide-product',
status: 'RESOLVED',
},
}))).toBe('')
expect(resolveProductArchiveImageExportUrl(record({
firstSourceCode: 'PRODUCT_DEVELOPMENT',
image: 'https://images.example.com/new-sku.webp',
}))).toBe('https://images.example.com/new-sku.webp')
})
it('exports all matched barcodes and image URLs as two text columns', async () => {
const repository = {
listRows: jest.fn().mockResolvedValue([
record({
barcode: '001234567890',
imageCache: {
previewUrl: 'https://cp.jiansun.vip/api/storage/object?key=folder%2Fone.webp',
source: 'barcode',
viewKey: 'latest',
status: 'RESOLVED',
},
}),
record({ barcode: 'BAR-EMPTY' }),
]),
}
const service = new SupplyChainProductArchiveImageExportService(
repository,
() => new Date('2026-08-25T09:30:45.000Z'),
)
const payload = await service.createWorkbook({
filters: { spu: ['SPU-001'] },
siteOrigin: 'http://localhost:3001',
})
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.load(payload.bytes)
const worksheet = workbook.getWorksheet('图片URL')!
expect(repository.listRows).toHaveBeenCalledWith({ spu: ['SPU-001'] })
expect(payload.total).toBe(2)
expect(payload.fileName).toMatch(/^产品档案图片URL-20260825-/)
expect(worksheet.getRow(1).values).toEqual([, '条码', '图片URL'])
expect(worksheet.getRow(2).values).toEqual([
,
'001234567890',
'http://localhost:3001/api/storage/object?key=folder%2Fone.webp',
])
expect(worksheet.getRow(3).values).toEqual([, 'BAR-EMPTY', ''])
})
})

View File

@ -0,0 +1,26 @@
import { parseProductArchiveFilters } from '../supply-chain-product-archive-query'
describe('product archive filter query parser', () => {
it('keeps supported filters for list and image export queries', () => {
expect(parseProductArchiveFilters(JSON.stringify({
keyword: '茶几',
categoryL1: ['桌类'],
barcode: ['BAR-001'],
createTimeFrom: '2026-08-01',
}))).toEqual({
success: true,
data: {
keyword: '茶几',
categoryL1: ['桌类'],
barcode: ['BAR-001'],
createTimeFrom: '2026-08-01',
},
})
})
it('rejects malformed or unknown filter values', () => {
expect(parseProductArchiveFilters('{')).toEqual({ success: false })
expect(parseProductArchiveFilters(JSON.stringify({ unknown: true }))).toEqual({ success: false })
expect(parseProductArchiveFilters(JSON.stringify({ productStatus: ['停售'] }))).toEqual({ success: false })
})
})

View File

@ -48,7 +48,7 @@ function createSourceRecord() {
package_volume_cm3: '1',
package_set_count: '1',
purchase_order_method: '非订单式',
archive_shipping_method: '代发',
factory_warehouse: '成品仓',
sku_image_url: 'https://cp.jiansun.vip/api/storage/object?key=sku-1.jpeg',
updated_at: new Date('2026-07-17T08:00:00.000Z'),
}
@ -98,14 +98,15 @@ describe('supply-chain product archive wide-catalog sync', () => {
)
})
it('does not use the product-level image_url when the current SKU has no image', () => {
it('leaves the image empty when the current SKU has no image', () => {
const mapped = mapProductCatalogWideArchiveSource({
...createSourceRecord(),
archive_image: null,
sku_image_url: 'https://image.example.com/shared-spu-image.webp',
sku_image_url: null,
})
expect(mapped?.archive.image).toBeNull()
expect(mapped).not.toHaveProperty('productImageUrl')
})
it('maps Prisma Decimal measurements returned by the wide catalog query', () => {

View File

@ -5,11 +5,13 @@ import type {
ProductArchiveWarehouseConfigRecord,
SupplyChainProductArchiveWarehouseConfigRepository,
} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository'
import { inferProductArchiveWarehouseOwnership } from '@/lib/supply-chain-product-archive-warehouse-config'
function configuration(overrides: Partial<ProductArchiveWarehouseConfigRecord> = {}) {
return {
id: 'config-1',
warehouseName: '杭州仓',
warehouseOwnership: '',
warehouseHead: '负责人甲',
storageType: 'INBOUND',
status: 'ACTIVE',
@ -32,6 +34,11 @@ function repository(
}
describe('SupplyChainProductArchiveWarehouseConfigService', () => {
it('infers factory ownership only from the factory warehouse prefix', () => {
expect(inferProductArchiveWarehouseOwnership('工厂-常州博一代发仓')).toBe('FACTORY')
expect(inferProductArchiveWarehouseOwnership('横林二仓')).toBeNull()
})
it('combines archive warehouse names with saved configurations', async () => {
const service = new SupplyChainProductArchiveWarehouseConfigService(repository({
warehouseNames: ['杭州仓', '上海仓'],
@ -87,4 +94,21 @@ describe('SupplyChainProductArchiveWarehouseConfigService', () => {
expect(result.items[0].configured).toBe(false)
expect(result.pageInfo).toEqual({ page: 1, pageSize: 1, total: 2, totalPages: 2 })
})
it('shows inferred ownership for unconfigured warehouse rows', async () => {
const service = new SupplyChainProductArchiveWarehouseConfigService(repository({
warehouseNames: ['工厂-常州仓', '横林二仓'],
configurations: [],
}))
const result = await service.list({})
expect(result.items).toHaveLength(2)
expect(result.items.find((item) => item.warehouseName === '横林二仓')).toEqual(
expect.objectContaining({ warehouseOwnership: null }),
)
expect(result.items.find((item) => item.warehouseName === '工厂-常州仓')).toEqual(
expect.objectContaining({ warehouseOwnership: 'FACTORY' }),
)
})
})

View File

@ -1,5 +1,6 @@
import {
calculateJstPublicAvailableStock,
resolveProductArchiveWarehouseFields,
SupplyChainProductArchiveService,
} from '../supply-chain-product-archive.service'
import {
@ -22,6 +23,37 @@ describe('supply-chain product archive field mapping', () => {
).toBe('60')
})
it('infers ownership and applies configured warehouse fields without clearing fallbacks', () => {
expect(resolveProductArchiveWarehouseFields({
warehouseName: '工厂-常州仓',
currentWarehouseHead: '当前采购',
currentStorageType: '入库',
})).toEqual({
warehouseOwnership: '工厂',
warehouseHead: '当前采购',
storageType: '入库',
})
expect(resolveProductArchiveWarehouseFields({
warehouseName: '横林二仓',
currentWarehouseHead: '颜回',
currentStorageType: '代发',
config: {
id: 'legacy-config',
warehouseName: '横林二仓',
warehouseOwnership: '',
warehouseHead: '历史负责人',
storageType: 'INBOUND',
status: 'ACTIVE',
createdAt: new Date('2026-08-01T00:00:00.000Z'),
updatedAt: new Date('2026-08-02T00:00:00.000Z'),
},
})).toEqual({
warehouseOwnership: '未配置',
warehouseHead: '历史负责人',
storageType: '入库',
})
})
it('only exposes the three confirmed sales statuses', () => {
expect(normalizeProductArchiveSalesStatus('销售')).toBe('销售')
expect(normalizeProductArchiveSalesStatus('停产')).toBe('停产')
@ -49,6 +81,8 @@ describe('supply-chain product archive field mapping', () => {
id: 'sku-row-1',
inOutWarehouse: 'WH-1',
image: null,
imageCache: null,
firstSourceCode: 'PRODUCT_DEVELOPMENT',
sku: 'SKU-001',
barcode: 'BAR-001',
spu: 'SPU-001',
@ -113,9 +147,22 @@ describe('supply-chain product archive field mapping', () => {
},
]),
}
const warehouseConfigRepository = {
listActive: jest.fn().mockResolvedValue([{
id: 'warehouse-config-1',
warehouseName: 'wh-1',
warehouseOwnership: 'JIANSHANG',
warehouseHead: '配置负责人',
storageType: 'DROPSHIP',
status: 'ACTIVE',
createdAt: new Date('2026-08-01T00:00:00.000Z'),
updatedAt: new Date('2026-08-02T00:00:00.000Z'),
}]),
}
const service = new SupplyChainProductArchiveService(
repository as never,
inventoryRepository,
warehouseConfigRepository,
)
const page = await service.listPage({ page: 1, pageSize: 50 })
@ -141,8 +188,9 @@ describe('supply-chain product archive field mapping', () => {
packType: '双包',
orderType: '订单式',
warehouseType: '成品发货仓-真实库存',
warehouseHead: '负责人',
storageType: '入库',
warehouseOwnership: '简尚',
warehouseHead: '配置负责人',
storageType: '代发',
supplier: '源沃家具',
supplierType: '家具厂',
supplierStatus: '合作',

View File

@ -0,0 +1,195 @@
import { SupplyChainSupplierInformationService } from '../supply-chain-supplier-information.service'
import type { SupplyChainSupplierInformationRepository } from '@/server/repositories/supply-chain-supplier-information.repository'
function createRepository(
overrides: Partial<SupplyChainSupplierInformationRepository> = {},
): SupplyChainSupplierInformationRepository {
return {
listPage: jest.fn().mockResolvedValue({ total: 0, records: [] }),
updateSupplierFields: jest.fn().mockResolvedValue([]),
...overrides,
}
}
describe('SupplyChainSupplierInformationService', () => {
it('returns one normalized supplier row including maintained supplier fields', async () => {
const repository = createRepository({
listPage: jest.fn().mockResolvedValue({
total: 1,
records: [{
supplierKey: '浩远',
supplierName: '浩远',
barcodeCount: 12,
purchaserValues: ['采购B', '采购A', '采购A'],
minimumOrderQuantityValues: ['20'],
firstLeadTimeValues: ['15', '7'],
stableLeadTimeValues: ['30天'],
palletSpecValues: ['60', '30'],
backupSupplierValues: ['备选厂B', '备选厂A'],
supplierTypeValues: ['成品供应商'],
}],
}),
})
const service = new SupplyChainSupplierInformationService(repository)
const result = await service.listPage({
page: 1,
pageSize: 50,
filters: {
purchaser: ' 采购A ',
supplier: ' 浩远 ',
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
palletSpec: '',
backupSupplier: '',
supplierType: '',
},
})
expect(repository.listPage).toHaveBeenCalledWith({
skip: 0,
take: 50,
filters: {
purchaser: '采购A',
supplier: '浩远',
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
palletSpec: '',
backupSupplier: '',
supplierType: '',
},
})
expect(result.rows[0]).toEqual(expect.objectContaining({
purchaserValues: ['采购A', '采购B'],
firstLeadTimeValues: ['7', '15'],
palletSpecValues: ['30', '60'],
stableLeadTimeValues: ['30天'],
backupSupplierValues: ['备选厂A', '备选厂B'],
}))
expect(result.pageInfo).toEqual({
page: 1,
pageSize: 50,
total: 1,
totalPages: 1,
})
})
it('bounds pagination before querying the repository', async () => {
const repository = createRepository({
listPage: jest.fn().mockResolvedValue({ total: 250, records: [] }),
})
const service = new SupplyChainSupplierInformationService(repository)
const result = await service.listPage({ page: -2, pageSize: 500 })
expect(repository.listPage).toHaveBeenCalledWith({
skip: 0,
take: 200,
filters: {
purchaser: '',
supplier: '',
minimumOrderQuantity: '',
firstLeadTime: '',
stableLeadTime: '',
palletSpec: '',
backupSupplier: '',
supplierType: '',
},
})
expect(result.pageInfo.totalPages).toBe(2)
})
it('updates only supplied values and records each changed barcode field', async () => {
const repository = createRepository({
updateSupplierFields: jest.fn().mockResolvedValue([
{
id: 'archive-1',
barcode: 'BC-001',
supplierName: '浩远',
purchaseOwner: '采购A',
minimumOrderQuantity: '10',
firstLeadTime: '7',
stableLeadTime: '',
palletSpec: '标准盘',
backupSupplier: '',
supplierType: '成品',
},
{
id: 'archive-2',
barcode: 'BC-002',
supplierName: '浩远',
purchaseOwner: '采购B',
minimumOrderQuantity: '10',
firstLeadTime: '7',
stableLeadTime: '',
palletSpec: '标准盘',
backupSupplier: '',
supplierType: '成品',
},
]),
})
const recordChanges = jest.fn().mockResolvedValue(undefined)
const service = new SupplyChainSupplierInformationService(repository, recordChanges)
const result = await service.updateSupplier({
supplierKey: ' 浩 远 ',
updates: {
purchaseOwner: ' 采购C ',
minimumOrderQuantity: ' ',
stableLeadTime: ' 30天 ',
backupSupplier: ' 备选厂 ',
supplierType: '成品',
},
user: { id: 'buyer-1', name: '采购员', role: 'USER' } as never,
})
expect(repository.updateSupplierFields).toHaveBeenCalledWith({
supplierKey: '浩远',
updates: {
purchaseOwner: '采购C',
stableLeadTime: '30天',
backupSupplier: '备选厂',
supplierType: '成品',
},
})
expect(recordChanges).toHaveBeenCalledTimes(2)
expect(recordChanges).toHaveBeenNthCalledWith(1, expect.objectContaining({
barcode: 'BC-001',
changes: [
{
field: 'purchaseOwner',
fieldLabel: '采购',
previousValue: '采购A',
nextValue: '采购C',
},
{
field: 'stableLeadTime',
fieldLabel: '稳定生产周期',
previousValue: '',
nextValue: '30天',
},
{
field: 'backupSupplier',
fieldLabel: '替换供应商',
previousValue: '',
nextValue: '备选厂',
},
],
}))
expect(result).toEqual({ affectedBarcodes: 2, changedValues: 6, supplierKey: '浩远' })
})
it('does not write when every edit field is blank', async () => {
const repository = createRepository()
const service = new SupplyChainSupplierInformationService(repository, jest.fn())
await expect(service.updateSupplier({
supplierKey: '浩远',
updates: { purchaseOwner: ' ', palletSpec: '' },
user: { id: 'buyer-1', name: '采购员', role: 'USER' } as never,
})).rejects.toThrow('请至少填写一项要修改的内容')
expect(repository.updateSupplierFields).not.toHaveBeenCalled()
})
})

View File

@ -222,6 +222,7 @@ export class ProductArchiveMaterialImageService {
&& !persistedIsProductDevelopmentImage
&& persisted?.status === 'RESOLVED'
&& persisted.previewUrl
&& persisted.source !== 'product'
) {
const image = {
previewUrl: persisted.previewUrl,

View File

@ -0,0 +1,317 @@
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import {
EMPTY_STORE_ACCOUNT_FILTERS,
STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS,
STORE_ACCOUNT_EDITABLE_FIELD_KEYS,
STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES,
STORE_ACCOUNT_FIELDS,
isStoreAccountFieldPermissionSubjectKey,
isStoreAccountFieldVisibility,
resolveStoreAccountFieldVisibility,
type StoreAccountBatchEditableFieldKey,
type StoreAccountBatchPreview,
type StoreAccountFieldAccess,
type StoreAccountFieldKey,
type StoreAccountFieldPermissionUpdate,
type StoreAccountFilters,
type StoreAccountPage,
type StoreAccountType,
} from '@/lib/store-account-management'
import { decryptStoreAccountPassword, encryptStoreAccountPassword } from '@/lib/store-account-crypto'
import { canManageSupplyChainPermissions, resolveSupplyChainPermissionSubjectKeys } from '@/lib/supply-chain-permission-settings'
import { supplyChainPermissionSettingsService } from './supply-chain-permission-settings.service'
import {
storeAccountManagementRepository,
type StoreAccountManagementRepository,
type StoreAccountRecord,
} from '@/server/repositories/store-account-management.repository'
const DEFAULT_MAX_PAGE_SIZE = 200
function clean(value: unknown, max = 240) {
return String(value ?? '').trim().slice(0, max)
}
function normalizeType(value: unknown): StoreAccountType {
const type = clean(value, 20).toUpperCase()
if (type === 'MAIN' || type === '主账号' || type === '主账户') return 'MAIN'
if (type === 'SUB' || type === '子账号' || type === '子账户') return 'SUB'
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '账号类型只能是主账号或子账号')
}
function normalizeFilters(input?: Partial<StoreAccountFilters>) {
return Object.fromEntries(Object.keys(EMPTY_STORE_ACCOUNT_FILTERS).map((key) => [key, clean(input?.[key as keyof StoreAccountFilters], 120)])) as StoreAccountFilters
}
function defaultFieldAccess(canEdit: boolean): StoreAccountFieldAccess {
return Object.fromEntries(STORE_ACCOUNT_FIELDS.map((field) => [
field.key,
field.key === 'password' ? 'HIDDEN' : field.system ? 'READONLY' : canEdit ? 'EDITABLE' : 'READONLY',
])) as StoreAccountFieldAccess
}
const FIELD_PERMISSION_BUSINESS_SUBJECT_KEYS = new Set(
STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES.map((subject) => subject.key),
)
const STORE_ACCOUNT_PAGE_PERMISSION_PREFIX = 'storeAccountManagement:'
export class StoreAccountManagementService {
constructor(private readonly repository: StoreAccountManagementRepository = storeAccountManagementRepository) {}
async getFieldAccess(user: AuthUser) {
const target = await supplyChainPermissionSettingsService.getUserAccess(user, 'storeAccountManagement')
const access = defaultFieldAccess(target.edit)
if (!target.view) {
return {
target,
access: Object.fromEntries(STORE_ACCOUNT_FIELDS.map((field) => [field.key, 'HIDDEN'])) as StoreAccountFieldAccess,
}
}
const records = await this.repository.listFieldPermissions()
const roleKeys = resolveSupplyChainPermissionSubjectKeys(user)
const userKey = user.id ? `user@${user.id}` : null
for (const field of STORE_ACCOUNT_FIELDS) {
const personal = userKey ? records.find((record) => record.fieldKey === field.key && record.subjectKey === userKey) : null
const values = records
.filter((record) => record.fieldKey === field.key && roleKeys.includes(record.subjectKey as never))
.map((record) => record.visibility)
.filter(isStoreAccountFieldVisibility)
const personalValue = isStoreAccountFieldVisibility(personal?.visibility) ? personal.visibility : null
access[field.key] = resolveStoreAccountFieldVisibility({
fieldKey: field.key,
pageView: target.view,
pageEdit: target.edit,
fallback: access[field.key],
roleValues: values,
personalValue,
})
}
// 密码默认不可见,只有管理员明确设置为可编辑时才允许填写。
if (canManageSupplyChainPermissions(user) && !records.some((record) => record.fieldKey === 'password' && record.subjectKey === userKey)) {
access.password = target.edit ? 'EDITABLE' : 'READONLY'
}
return { target, access }
}
async listPage(params: { page?: number; pageSize?: number; filters?: Partial<StoreAccountFilters>; user: AuthUser }): Promise<StoreAccountPage> {
const page = Math.max(1, Math.trunc(params.page || 1))
const pageSize = Math.min(DEFAULT_MAX_PAGE_SIZE, Math.max(1, Math.trunc(params.pageSize || 50)))
const filters = normalizeFilters(params.filters)
const { target, access } = await this.getFieldAccess(params.user)
if (!target.view) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有店铺账号管理权限')
const [result, storeNames] = await Promise.all([
this.repository.listPage({ skip: (page - 1) * pageSize, take: pageSize, filters }),
this.repository.listStoreNames(),
])
const rows = result.rows.map((record) => this.toRow(record, access))
return { rows, storeNames, pageInfo: { page, pageSize, total: result.total, totalPages: Math.max(1, Math.ceil(result.total / pageSize)) }, fieldAccess: access }
}
async getOne(id: string, user: AuthUser) {
const { target, access } = await this.getFieldAccess(user)
if (!target.view) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有店铺账号管理权限')
const record = await this.repository.findById(clean(id, 80))
if (!record) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '店铺账号不存在')
return { row: this.toRow(record, access), fieldAccess: access }
}
async create(input: Record<string, unknown>, user: AuthUser) {
const { target, access } = await this.getFieldAccess(user)
if (!target.edit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺账号的权限')
const values = this.prepareValues(input, access, true)
if (await this.repository.findByKey({ storeName: values.storeName, accountType: values.accountType, account: values.account })) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '相同店铺、账号类型和账号已存在')
}
const record = await this.repository.create({ ...values, actorId: user.id })
await this.log(record, 'CREATE', user, this.changeSet({}, values, access))
return this.toRow(record, access)
}
async update(id: string, input: Record<string, unknown>, user: AuthUser) {
const { target, access } = await this.getFieldAccess(user)
if (!target.edit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺账号的权限')
const current = await this.repository.findById(clean(id, 80))
if (!current) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '店铺账号不存在')
const values = this.prepareValues(input, access, false)
if (Object.keys(values).length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少填写一项可修改字段')
const next = await this.repository.update(current.id, { ...values, updatedById: user.id })
await this.log(next, 'UPDATE', user, this.changeSet(current, values, access))
return this.toRow(next, access)
}
async importRows(rows: Array<Record<string, unknown>>, user: AuthUser) {
const { target, access } = await this.getFieldAccess(user)
if (!target.edit) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺账号的权限')
if (rows.length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据')
if (rows.length > 5000) throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '单次最多导入5000行')
let created = 0
let updated = 0
for (const input of rows) {
const values = this.prepareValues(input, access, true)
const previous = await this.repository.findByKey({ storeName: values.storeName, accountType: values.accountType as StoreAccountType, account: values.account })
const record = await this.repository.upsertByKey({ storeName: values.storeName, accountType: values.accountType as StoreAccountType, account: values.account, values, actorId: user.id })
if (previous) updated += 1
else created += 1
await this.log(record, previous ? 'IMPORT_UPDATE' : 'IMPORT_CREATE', user, this.changeSet(previous ?? {}, values, access))
}
return { created, updated }
}
async batchPreview(input: { ids: string[]; field: StoreAccountBatchEditableFieldKey; user: AuthUser }): Promise<StoreAccountBatchPreview> {
const { target, access } = await this.getFieldAccess(input.user)
if (!target.edit || access[input.field] !== 'EDITABLE' || !STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS.includes(input.field)) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有批量查看该字段的权限')
}
const ids = [...new Set(input.ids.map((id) => clean(id, 80)).filter(Boolean))].slice(0, 500)
if (ids.length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要预览的账号')
const currentRows = await this.repository.listByIds(ids)
const rowsById = new Map(currentRows.map((row) => [row.id, row]))
const rows = ids.flatMap((id) => {
const record = rowsById.get(id)
if (!record) return []
return [{
id: record.id,
storeName: access.storeName === 'HIDDEN' ? null : record.storeName,
accountType: access.accountType === 'HIDDEN' ? null : normalizeType(record.accountType),
account: access.account === 'HIDDEN' ? null : record.account,
currentValue: clean(record[input.field], 500),
}]
})
return { rows, requested: ids.length, found: rows.length, missing: ids.length - rows.length }
}
async batchUpdate(input: { ids: string[]; field: StoreAccountBatchEditableFieldKey; value: unknown; user: AuthUser }) {
const { target, access } = await this.getFieldAccess(input.user)
if (!target.edit || access[input.field] !== 'EDITABLE' || !STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS.includes(input.field)) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有批量修改该字段的权限')
}
const ids = [...new Set(input.ids.map((id) => clean(id, 80)).filter(Boolean))].slice(0, 500)
if (ids.length === 0) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要修改的账号')
const currentRows = await this.repository.listByIds(ids)
const normalized = clean(input.value)
if (input.field === 'accountType') normalizeType(normalized)
const data: Record<string, string> = { [input.field]: input.field === 'accountType' ? normalizeType(normalized) : normalized }
let changed = 0
for (const current of currentRows) {
const next = await this.repository.update(current.id, { ...data, updatedById: input.user.id })
const changes = this.changeSet(current, data, access)
if (changes.length > 0) {
changed += 1
await this.log(next, 'BATCH_UPDATE', input.user, changes)
}
}
return { affected: currentRows.length, changed }
}
async listLogs(params: { id?: string; storeName?: string; user: AuthUser }) {
const { target, access } = await this.getFieldAccess(params.user)
if (!target.view) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有店铺账号管理权限')
const logs = await this.repository.listLogs({ storeAccountId: params.id, storeName: params.storeName, take: 200 })
return logs.map((log) => ({
...log,
changes: Array.isArray(log.changes)
? log.changes.filter((change) => {
if (!change || typeof change !== 'object' || Array.isArray(change)) return false
const field = (change as { field?: unknown }).field
return typeof field === 'string' && access[field as StoreAccountFieldKey] !== 'HIDDEN'
})
: [],
}))
}
async revealPassword(id: string, user: AuthUser) {
const { target, access } = await this.getFieldAccess(user)
if (!target.view || access.password === 'HIDDEN') {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有查看密码的权限')
}
const record = await this.repository.findById(clean(id, 80))
if (!record) throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '店铺账号不存在')
await this.log(record, 'VIEW_PASSWORD', user, [{ field: 'password', previousValue: '', nextValue: '已查看' }])
return { password: decryptStoreAccountPassword(record.passwordCiphertext) }
}
async saveFieldPermissions(input: { updates: StoreAccountFieldPermissionUpdate[]; user: AuthUser }) {
if (!canManageSupplyChainPermissions(input.user)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有管理员可以配置字段权限')
const updates = Array.from(new Map(input.updates.map((item) => [`${item.fieldKey}:${item.subjectKey}`, item])).values())
const invalid = updates.some((item) => (
!STORE_ACCOUNT_FIELDS.some((field) => field.key === item.fieldKey && !field.system)
|| !isStoreAccountFieldPermissionSubjectKey(item.subjectKey)
|| (!item.subjectKey.startsWith('user@') && !FIELD_PERMISSION_BUSINESS_SUBJECT_KEYS.has(item.subjectKey as never))
|| (item.visibility !== null && !isStoreAccountFieldVisibility(item.visibility))
))
if (invalid) throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '字段权限内容不正确')
await this.repository.saveFieldPermissions({ updates, updatedById: input.user.id })
return { saved: updates.filter((item) => item.visibility !== null).length, inherited: updates.filter((item) => item.visibility === null).length }
}
async getFieldPermissionSettings(user: AuthUser) {
if (!canManageSupplyChainPermissions(user)) throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '只有管理员可以查看字段权限配置')
const [permissions, pagePermissionSettings] = await Promise.all([
this.repository.listFieldPermissions(),
supplyChainPermissionSettingsService.getSettings(user),
])
const pagePermissionOverrides = Object.fromEntries(
Object.entries(pagePermissionSettings.overrides).filter(([key]) => (
key.startsWith(STORE_ACCOUNT_PAGE_PERMISSION_PREFIX)
)),
)
return { fields: STORE_ACCOUNT_FIELDS, permissions, pagePermissionOverrides }
}
private prepareValues(input: Record<string, unknown>, access: StoreAccountFieldAccess, required: boolean) {
const values: Record<string, string> = {}
const fields = STORE_ACCOUNT_EDITABLE_FIELD_KEYS
for (const field of fields) {
if (access[field] !== 'EDITABLE' || input[field] === undefined) continue
const value = clean(input[field])
if (field === 'password') {
if (value) values.passwordCiphertext = encryptStoreAccountPassword(value)
}
else if (field === 'accountType') values.accountType = normalizeType(value)
else values[field] = value
}
if (required) {
if (!values.storeName || !values.account || !values.accountType) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '店铺名称、账号类型和账号为必填项')
}
return values as Record<string, string> & { storeName: string; accountType: StoreAccountType; account: string }
}
private toRow(record: StoreAccountRecord, access: StoreAccountFieldAccess) {
const value = (key: StoreAccountFieldKey, fallback = '') => access[key] === 'HIDDEN' ? '' : fallback
return {
id: record.id,
storeName: value('storeName', record.storeName),
operationOwner: value('operationOwner', record.operationOwner),
operationDepartmentHead: value('operationDepartmentHead', record.operationDepartmentHead),
operationDirector: value('operationDirector', record.operationDirector),
profitCenter: value('profitCenter', record.profitCenter),
accountType: value('accountType', record.accountType) as StoreAccountType,
account: value('account', record.account),
passwordConfigured: access.password !== 'HIDDEN' && Boolean(record.passwordCiphertext),
boundPhone: value('boundPhone', record.boundPhone),
verificationContact: value('verificationContact', record.verificationContact),
jstOrderService: value('jstOrderService', record.jstOrderService),
accountGroupName: value('accountGroupName', record.accountGroupName),
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
}
}
private changeSet(previous: Record<string, unknown>, values: Record<string, unknown>, access: StoreAccountFieldAccess) {
return Object.entries(values).flatMap(([key, next]) => {
const field = key === 'passwordCiphertext' ? 'password' : key
if (access[field as StoreAccountFieldKey] === 'HIDDEN') return []
const old = key === 'passwordCiphertext' ? previous.passwordCiphertext : previous[key]
if (key !== 'passwordCiphertext' && old === next) return []
return [{ field, previousValue: key === 'passwordCiphertext' ? (old ? '已设置' : '未设置') : old ?? '', nextValue: key === 'passwordCiphertext' ? (next ? '已设置' : '未设置') : next }]
})
}
private async log(record: StoreAccountRecord, operation: string, user: AuthUser, changes: unknown) {
await this.repository.createLog({ storeAccountId: record.id, storeName: record.storeName, operation, actorId: user.id, actorName: user.name, changes })
}
}
export const storeAccountManagementService = new StoreAccountManagementService()

View File

@ -107,13 +107,16 @@ class SupplyChainPermissionSettingsService {
if (getSupplyChainRoleDefaultAccess(user)?.view) {
return [
'dashboard',
'businessManagement',
'productArchive',
'categoryConfig',
'warehouseConfig',
'storeAccountManagement',
'newProductOverview',
'newProductTerminationFeedback',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseSupplierInformation',
'purchaseDailyAffairs',
'purchasePriceChange',
'operationLogs',
@ -125,13 +128,16 @@ class SupplyChainPermissionSettingsService {
const subjectKeys = resolveSupplyChainPermissionSubjectKeys(user)
const targetKeys: SupplyChainPermissionTargetKey[] = [
'dashboard',
'businessManagement',
'productArchive',
'categoryConfig',
'warehouseConfig',
'storeAccountManagement',
'newProductOverview',
'newProductTerminationFeedback',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseSupplierInformation',
'purchaseDailyAffairs',
'purchasePriceChange',
'operationLogs',

View File

@ -199,20 +199,20 @@ export function buildProductArchiveEnrichmentUpdates(
{ warehouseType: 'remark2' },
),
},
{
...(warehouseConfig ? [{
patch: {
warehouseHead: warehouseConfig?.warehouseHead ?? '',
storageType: warehouseConfig?.storageType ?? '',
warehouseHead: warehouseConfig.warehouseHead,
storageType: warehouseConfig.storageType,
},
source: source(
WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE,
CONFIG_SOURCE_SYSTEM,
PUBLIC_SOURCE_SCHEMA,
'SupplyChainProductArchiveWarehouseConfig',
warehouseConfig?.id || target.inOutWarehouse || target.barcode,
warehouseConfig.id,
{ warehouseHead: 'warehouseHead', storageType: 'storageType' },
),
},
}] : []),
{
patch: {
purchasePrice: price?.purchasePrice ?? '',

View File

@ -0,0 +1,126 @@
import ExcelJS from 'exceljs'
import type { ProductArchiveFilters } from '@/lib/supply-chain-product-archive'
import {
JST_SKU_ARCHIVE_SOURCE_CODE,
PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE,
parseProductArchiveFieldSourceMap,
} from '@/lib/supply-chain-product-archive-field-source'
import {
type ProductArchiveImageExportRecord,
supplyChainProductArchiveImageExportRepository,
type SupplyChainProductArchiveImageExportRepository,
} from '@/server/repositories/supply-chain-product-archive-image-export.repository'
const OBJECT_STORAGE_ROUTE_PATH = '/api/storage/object'
const PRODUCT_DEVELOPMENT_CACHE_VIEW_KEYS = new Set([
'product-catalog-wide',
'product-catalog-wide-sku',
'product-catalog-wide-product',
])
function trim(value: string | null | undefined) {
return value?.trim() ?? ''
}
export function resolveProductArchiveImageExportUrl(
record: ProductArchiveImageExportRecord,
) {
const sourceImage = trim(record.image)
const cacheImage = record.imageCache?.status === 'RESOLVED'
&& record.imageCache.source !== 'product'
&& !PRODUCT_DEVELOPMENT_CACHE_VIEW_KEYS.has(record.imageCache.viewKey ?? '')
? trim(record.imageCache.previewUrl)
: ''
const imageSourceCode =
parseProductArchiveFieldSourceMap(record.fieldSourceMap).image?.sourceCode
|| record.firstSourceCode
const jstSkuImage = imageSourceCode === JST_SKU_ARCHIVE_SOURCE_CODE
? sourceImage
: ''
const productDevelopmentSkuImage = imageSourceCode === PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE
? sourceImage
: ''
return cacheImage || jstSkuImage || productDevelopmentSkuImage || ''
}
export function resolveProductArchiveImageExportUrlForSite(
imageUrl: string,
siteOrigin: string,
) {
const normalized = imageUrl.trim()
if (!normalized) return ''
try {
const parsed = new URL(normalized, siteOrigin)
if (parsed.pathname === OBJECT_STORAGE_ROUTE_PATH && parsed.searchParams.has('key')) {
return new URL(`${parsed.pathname}${parsed.search}`, siteOrigin).toString()
}
return parsed.toString()
} catch {
return normalized
}
}
function fileTimestamp(now: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
'-',
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds()),
].join('')
}
export class SupplyChainProductArchiveImageExportService {
constructor(
private readonly repository: SupplyChainProductArchiveImageExportRepository =
supplyChainProductArchiveImageExportRepository,
private readonly now: () => Date = () => new Date(),
) {}
async createWorkbook(params: {
filters?: ProductArchiveFilters
siteOrigin: string
}) {
const records = await this.repository.listRows(params.filters)
const workbook = new ExcelJS.Workbook()
workbook.creator = '产品全量档案'
workbook.created = this.now()
const worksheet = workbook.addWorksheet('图片URL')
worksheet.columns = [
{ header: '条码', key: 'barcode', width: 28 },
{ header: '图片URL', key: 'imageUrl', width: 80 },
]
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.autoFilter = { from: 'A1', to: 'B1' }
worksheet.getRow(1).font = { bold: true }
worksheet.getRow(1).alignment = { vertical: 'middle' }
worksheet.getColumn('barcode').numFmt = '@'
worksheet.getColumn('imageUrl').numFmt = '@'
for (const record of records) {
worksheet.addRow({
barcode: record.barcode,
imageUrl: resolveProductArchiveImageExportUrlForSite(
resolveProductArchiveImageExportUrl(record),
params.siteOrigin,
),
})
}
const buffer = await workbook.xlsx.writeBuffer()
return {
bytes: buffer,
fileName: `产品档案图片URL-${fileTimestamp(this.now())}.xlsx`,
total: records.length,
}
}
}
export const supplyChainProductArchiveImageExportService =
new SupplyChainProductArchiveImageExportService()

View File

@ -0,0 +1,41 @@
import { z } from 'zod'
import {
PRODUCT_ARCHIVE_SALES_STATUSES,
type ProductArchiveFilters,
} from '@/lib/supply-chain-product-archive'
const archiveDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
const productArchiveFiltersSchema = z.object({
keyword: z.string().trim().min(1).max(160).optional(),
categoryL1: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
categoryL2: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
spu: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
sku: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
barcode: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
productName: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
designer: z.array(z.string().trim().min(1).max(80)).max(200).optional(),
productStatus: z.array(z.enum(PRODUCT_ARCHIVE_SALES_STATUSES)).max(3).optional(),
material: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
color: z.array(z.string().trim().min(1).max(80)).max(200).optional(),
brand: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
supplier: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
purchaser: z.array(z.string().trim().min(1).max(120)).max(200).optional(),
inOutWarehouse: z.array(z.string().trim().min(1).max(160)).max(200).optional(),
createTimeFrom: archiveDateSchema.optional(),
createTimeTo: archiveDateSchema.optional(),
updateTimeFrom: archiveDateSchema.optional(),
updateTimeTo: archiveDateSchema.optional(),
}).strict()
export function parseProductArchiveFilters(raw: string | null) {
if (!raw) return { success: true as const, data: {} as ProductArchiveFilters }
try {
const parsed = productArchiveFiltersSchema.safeParse(JSON.parse(raw))
if (!parsed.success) return { success: false as const }
return { success: true as const, data: parsed.data }
} catch {
return { success: false as const }
}
}

View File

@ -7,6 +7,10 @@ import {
import {
WAREHOUSE_CONFIG_ACTIVE,
WAREHOUSE_CONFIG_INACTIVE,
inferProductArchiveWarehouseOwnership,
isProductArchiveWarehouseOwnership,
type ProductArchiveWarehouseOwnership,
productArchiveWarehouseOwnershipLabel,
type ProductArchiveWarehouseConfigStatus,
} from '@/lib/supply-chain-product-archive-warehouse-config'
import {
@ -20,6 +24,7 @@ const WAREHOUSE_NAME_MAX_LENGTH = 160
export type WarehouseConfigDTO = {
id: string | null
warehouseName: string
warehouseOwnership: ProductArchiveWarehouseOwnership | null
warehouseHead: string
storageType: ProductArchiveStorageType | null
status: ProductArchiveWarehouseConfigStatus | null
@ -39,6 +44,10 @@ function mapRecord(record: ProductArchiveWarehouseConfigRecord): WarehouseConfig
return {
id: record.id,
warehouseName: record.warehouseName,
warehouseOwnership:
isProductArchiveWarehouseOwnership(record.warehouseOwnership)
? record.warehouseOwnership as ProductArchiveWarehouseOwnership
: inferProductArchiveWarehouseOwnership(record.warehouseName),
warehouseHead: record.warehouseHead,
storageType: record.storageType as ProductArchiveStorageType,
status: record.status as ProductArchiveWarehouseConfigStatus,
@ -56,6 +65,7 @@ function mapUnconfigured(warehouseName: string): WarehouseConfigDTO {
return {
id: null,
warehouseName,
warehouseOwnership: inferProductArchiveWarehouseOwnership(warehouseName),
warehouseHead: '',
storageType: null,
status: null,
@ -89,6 +99,13 @@ function cleanStorageType(value: string): ProductArchiveStorageType {
return value as ProductArchiveStorageType
}
function cleanOwnership(value: string): ProductArchiveWarehouseOwnership {
if (!isProductArchiveWarehouseOwnership(value)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${productArchiveWarehouseOwnershipLabel(value)}仓库归属不正确`)
}
return value as ProductArchiveWarehouseOwnership
}
function cleanStatus(value: string): ProductArchiveWarehouseConfigStatus {
if (value !== WAREHOUSE_CONFIG_ACTIVE && value !== WAREHOUSE_CONFIG_INACTIVE) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '状态不正确')
@ -152,12 +169,14 @@ export class SupplyChainProductArchiveWarehouseConfigService {
async create(input: {
warehouseName: string
warehouseOwnership: string
warehouseHead: string
storageType: string
}) {
try {
return mapRecord(await this.repository.create({
warehouseName: cleanWarehouseName(input.warehouseName),
warehouseOwnership: cleanOwnership(input.warehouseOwnership),
warehouseHead: cleanWarehouseHead(input.warehouseHead),
storageType: cleanStorageType(input.storageType),
}))
@ -172,6 +191,7 @@ export class SupplyChainProductArchiveWarehouseConfigService {
async update(input: {
id: string
warehouseName: string
warehouseOwnership: string
warehouseHead: string
storageType: string
status: string
@ -185,6 +205,7 @@ export class SupplyChainProductArchiveWarehouseConfigService {
return mapRecord(await this.repository.update({
id,
warehouseName: cleanWarehouseName(input.warehouseName),
warehouseOwnership: cleanOwnership(input.warehouseOwnership),
warehouseHead: cleanWarehouseHead(input.warehouseHead),
storageType: cleanStorageType(input.storageType),
status: cleanStatus(input.status),

View File

@ -72,6 +72,7 @@ export function mapProductArchiveRow({
orderType: record.orderType,
inOutWarehouse: record.inOutWarehouse,
warehouseType: record.warehouseType,
warehouseOwnership: '',
warehouseHead: record.warehouseHead,
storageType: storageTypeLabel(record.storageType),
isSeasonal: record.isSeasonal,

View File

@ -16,7 +16,16 @@ import {
import {
mapProductArchiveRow,
normalizeArchiveIdentity,
normalizeWarehouseName,
} from './supply-chain-product-archive.mapper'
import {
inferProductArchiveWarehouseOwnership,
productArchiveWarehouseOwnershipLabel,
} from '@/lib/supply-chain-product-archive-warehouse-config'
import {
supplyChainProductArchiveWarehouseConfigRepository,
type ProductArchiveWarehouseConfigRecord,
} from '@/server/repositories/supply-chain-product-archive-warehouse-config.repository'
function inventoryInteger(value: string) {
const normalized = value.trim()
@ -35,6 +44,25 @@ export function calculateJstPublicAvailableStock(
).toString()
}
export function resolveProductArchiveWarehouseFields(input: {
warehouseName: string
currentWarehouseHead: string
currentStorageType: string
config?: ProductArchiveWarehouseConfigRecord
}) {
const configured = input.config
return {
warehouseOwnership: productArchiveWarehouseOwnershipLabel(
configured?.warehouseOwnership
?? inferProductArchiveWarehouseOwnership(input.warehouseName),
),
warehouseHead: configured?.warehouseHead ?? input.currentWarehouseHead,
storageType: configured?.storageType
? configured.storageType === 'INBOUND' ? '入库' : '代发'
: input.currentStorageType,
}
}
export class SupplyChainProductArchiveService {
constructor(
private readonly repository: SupplyChainProductArchiveRepository =
@ -43,6 +71,10 @@ export class SupplyChainProductArchiveService {
JstInventoryPublicAvailableRepository,
'listByBarcodes'
> = jstInventoryPublicAvailableRepository,
private readonly warehouseConfigRepository: Pick<
typeof supplyChainProductArchiveWarehouseConfigRepository,
'listActive'
> | null = null,
) {}
async listPage(params: {
@ -57,6 +89,17 @@ export class SupplyChainProductArchiveService {
take: pageSize,
filters: params.filters,
})
let warehouseConfigs: ProductArchiveWarehouseConfigRecord[] = []
if (this.warehouseConfigRepository) {
try {
warehouseConfigs = await this.warehouseConfigRepository.listActive()
} catch (error) {
console.warn('[product-archive] warehouse config lookup failed', error)
}
}
const warehouseConfigByName = new Map(
warehouseConfigs.map((config) => [normalizeWarehouseName(config.warehouseName), config]),
)
let inventoryProfiles: JstInventoryPublicAvailableProfile[] = []
try {
inventoryProfiles = await this.inventoryRepository.listByBarcodes(
@ -73,11 +116,22 @@ export class SupplyChainProductArchiveService {
)
const now = new Date()
return {
rows: records.map((record) => ({
...mapProductArchiveRow({ record, now }),
stockJianshang:
inventoryByBarcode.get(normalizeArchiveIdentity(record.barcode)) ?? '',
})),
rows: records.map((record) => {
const row = mapProductArchiveRow({ record, now })
const config = warehouseConfigByName.get(normalizeWarehouseName(record.inOutWarehouse))
const warehouseFields = resolveProductArchiveWarehouseFields({
warehouseName: record.inOutWarehouse,
currentWarehouseHead: record.warehouseHead,
currentStorageType: row.storageType,
config,
})
return {
...row,
...warehouseFields,
stockJianshang:
inventoryByBarcode.get(normalizeArchiveIdentity(record.barcode)) ?? '',
}
}),
pageInfo: {
page,
pageSize,
@ -106,4 +160,8 @@ export class SupplyChainProductArchiveService {
}
export const supplyChainProductArchiveService =
new SupplyChainProductArchiveService()
new SupplyChainProductArchiveService(
undefined,
undefined,
supplyChainProductArchiveWarehouseConfigRepository,
)

View File

@ -0,0 +1,136 @@
import {
EMPTY_SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTERS,
SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS,
type SupplyChainSupplierInformationFilters,
type SupplyChainSupplierInformationPage,
type SupplyChainSupplierInformationUpdates,
} from '@/lib/supply-chain-supplier-information'
import {
PRODUCT_ARCHIVE_CHANGE_FIELDS,
type ProductArchiveFieldChange,
} from '@/lib/supply-chain-product-archive-change-log'
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import {
supplyChainSupplierInformationRepository,
type SupplyChainSupplierInformationRepository,
} from '@/server/repositories/supply-chain-supplier-information.repository'
import {
archiveChangeActorFromUser,
recordProductArchiveFieldChanges,
} from '@/server/services/supply-chain-product-archive-change-log.service'
const EDITABLE_FIELD_LABELS = Object.fromEntries(
PRODUCT_ARCHIVE_CHANGE_FIELDS.map((field) => [field.key, field.label]),
) as Record<keyof SupplyChainSupplierInformationUpdates, string>
function normalizeValues(values: readonly string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
.sort((left, right) => left.localeCompare(right, 'zh-CN', { numeric: true }))
}
export class SupplyChainSupplierInformationService {
constructor(
private readonly repository: SupplyChainSupplierInformationRepository =
supplyChainSupplierInformationRepository,
private readonly recordChanges: typeof recordProductArchiveFieldChanges =
recordProductArchiveFieldChanges,
) {}
async listPage(params: {
page: number
pageSize: number
filters?: Partial<SupplyChainSupplierInformationFilters>
}) {
const page = Math.max(1, Math.trunc(params.page) || 1)
const pageSize = Math.min(200, Math.max(1, Math.trunc(params.pageSize) || 50))
const filters = Object.fromEntries(
Object.keys(EMPTY_SUPPLY_CHAIN_SUPPLIER_INFORMATION_FILTERS).map((key) => [
key,
params.filters?.[key as keyof SupplyChainSupplierInformationFilters]
?.trim()
.slice(0, 120) ?? '',
]),
) as SupplyChainSupplierInformationFilters
const { records, total } = await this.repository.listPage({
skip: (page - 1) * pageSize,
take: pageSize,
filters,
})
return {
rows: records.map((record) => ({
...record,
purchaserValues: normalizeValues(record.purchaserValues),
minimumOrderQuantityValues: normalizeValues(record.minimumOrderQuantityValues),
firstLeadTimeValues: normalizeValues(record.firstLeadTimeValues),
stableLeadTimeValues: normalizeValues(record.stableLeadTimeValues),
palletSpecValues: normalizeValues(record.palletSpecValues),
backupSupplierValues: normalizeValues(record.backupSupplierValues),
supplierTypeValues: normalizeValues(record.supplierTypeValues),
})),
pageInfo: {
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
} satisfies SupplyChainSupplierInformationPage
}
async updateSupplier(input: {
supplierKey: string
updates: SupplyChainSupplierInformationUpdates
user: AuthUser
}) {
const supplierKey = normalizeSupplierKey(input.supplierKey)
const updates = Object.fromEntries(
SUPPLY_CHAIN_SUPPLIER_INFORMATION_EDITABLE_FIELDS.flatMap((field) => {
const value = input.updates[field]?.trim()
return value ? [[field, value]] : []
}),
) as SupplyChainSupplierInformationUpdates
if (!supplierKey || Object.keys(updates).length === 0) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请至少填写一项要修改的内容')
}
const records = await this.repository.updateSupplierFields({ supplierKey, updates })
if (records.length === 0) {
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '供应商关联的产品档案不存在')
}
let changedValues = 0
for (const record of records) {
const changes = Object.entries(updates).flatMap(([field, nextValue]) => {
const previousValue = record[field as keyof SupplyChainSupplierInformationUpdates]
if (previousValue === nextValue) return []
return [{
field: field as keyof SupplyChainSupplierInformationUpdates,
fieldLabel: EDITABLE_FIELD_LABELS[field as keyof SupplyChainSupplierInformationUpdates],
previousValue,
nextValue,
} satisfies ProductArchiveFieldChange]
})
if (changes.length === 0) continue
changedValues += changes.length
await this.recordChanges({
barcode: record.barcode,
actor: archiveChangeActorFromUser(input.user),
changes,
})
}
return {
affectedBarcodes: records.length,
changedValues,
supplierKey: normalizeSupplierKey(updates.supplierName ?? records[0].supplierName),
}
}
}
export function normalizeSupplierKey(value: string) {
return value.trim().replace(/\s+/g, '').toLocaleLowerCase('zh-CN')
}
export const supplyChainSupplierInformationService =
new SupplyChainSupplierInformationService()