feat(supply-chain): add product, supplier, and store management
This commit is contained in:
parent
3f5cf79cc2
commit
a34dee6825
@ -0,0 +1,3 @@
|
||||
ALTER TABLE "SupplyChainProductArchive"
|
||||
ADD COLUMN "stableLeadTime" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN "backupSupplier" TEXT NOT NULL DEFAULT '';
|
||||
@ -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");
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SupplyChainProductArchiveWarehouseConfig"
|
||||
ADD COLUMN "warehouseOwnership" TEXT NOT NULL DEFAULT '';
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
}
|
||||
@ -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']),
|
||||
|
||||
@ -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'] },
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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': '操作日志',
|
||||
|
||||
@ -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',
|
||||
})]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: '操作日志',
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')} />
|
||||
}
|
||||
@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
@ -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>}
|
||||
|
||||
23
furniture-3d-system/src/app/supply-chain/management/page.tsx
Normal file
23
furniture-3d-system/src/app/supply-chain/management/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
32
furniture-3d-system/src/lib/store-account-crypto.ts
Normal file
32
furniture-3d-system/src/lib/store-account-crypto.ts
Normal 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')
|
||||
}
|
||||
85
furniture-3d-system/src/lib/store-account-management.test.ts
Normal file
85
furniture-3d-system/src/lib/store-account-management.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
229
furniture-3d-system/src/lib/store-account-management.ts
Normal file
229
furniture-3d-system/src/lib/store-account-management.ts
Normal 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
|
||||
}
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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日日销' },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -33,6 +33,7 @@ export type SupplyChainProductArchiveRow = {
|
||||
orderType: string
|
||||
inOutWarehouse: string
|
||||
warehouseType: string
|
||||
warehouseOwnership: string
|
||||
warehouseHead: string
|
||||
storageType: string
|
||||
isSeasonal: string
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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: '备选厂' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
@ -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: {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}]),
|
||||
},
|
||||
|
||||
@ -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', ''])
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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', () => {
|
||||
|
||||
@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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: '合作',
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -222,6 +222,7 @@ export class ProductArchiveMaterialImageService {
|
||||
&& !persistedIsProductDevelopmentImage
|
||||
&& persisted?.status === 'RESOLVED'
|
||||
&& persisted.previewUrl
|
||||
&& persisted.source !== 'product'
|
||||
) {
|
||||
const image = {
|
||||
previewUrl: persisted.previewUrl,
|
||||
|
||||
@ -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()
|
||||
@ -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',
|
||||
|
||||
@ -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 ?? '',
|
||||
|
||||
@ -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()
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user