From c104a029c9aa36348e968bb379ab5ad4d8f4e1a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 13:15:34 +0800 Subject: [PATCH 01/12] =?UTF-8?q?fix(supply-chain):=20=E9=99=90=E4=BB=B7?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E3=80=8C=E8=B4=9F=E8=B4=A3=E4=BA=BA=E9=99=90?= =?UTF-8?q?=E4=BB=B7=E3=80=8D=E7=AD=9B=E9=80=89=E8=A1=A5=E4=B8=8A=E5=80=99?= =?UTF-8?q?=E9=80=89=E5=80=BC=E4=B8=8E=E7=AD=9B=E9=80=89=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 负责人限价是 SupplyChainStyleLimitPrice.ownerLimitPrice 的派生列(按货号),此前 option 接口没有对应分支, 下拉框显示「没有可选值」、选了也不生效 - 候选值:无其他筛选时取限价表的全部去重价格(两位小数,与界面一致);有筛选时只取当前结果集里货号的限价; 无流程限价的商品提供「空值」选项 - 筛选:选中的价格先经 ROUND(price,2) 换回货号再按 sku 过滤;选中的价格无对应货号返回空结果; 「空值」= 货号不在限价表里 - 补 3 条单测(价格→货号、无匹配、空值) --- ...y-chain-product-archive-text-rules.test.ts | 35 ++++++++++++++++++- .../supply-chain-product-archive-filters.ts | 32 +++++++++++++++++ ...supply-chain-product-archive.repository.ts | 28 +++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-text-rules.test.ts b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-text-rules.test.ts index 2e8ec02a..a328cb8f 100644 --- a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-text-rules.test.ts +++ b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-text-rules.test.ts @@ -11,17 +11,24 @@ import { } from '../supply-chain-product-archive-filters' jest.mock('@/lib/prisma', () => ({ - prisma: { $queryRaw: jest.fn() }, + prisma: { + $queryRaw: jest.fn(), + supplyChainStyleLimitPrice: { findMany: jest.fn() }, + }, })) const mockedQueryRaw = (jest.requireMock('@/lib/prisma') as { prisma: { $queryRaw: jest.Mock } }).prisma.$queryRaw +const mockedStyleLimitFindMany = (jest.requireMock('@/lib/prisma') as { + prisma: { supplyChainStyleLimitPrice: { findMany: jest.Mock } } +}).prisma.supplyChainStyleLimitPrice.findMany beforeEach(() => { clearNormalCodeValueCache() mockedQueryRaw.mockReset() mockedQueryRaw.mockResolvedValue([{ value: 'DNZ0411' }, { value: 'XG0022' }]) + mockedStyleLimitFindMany.mockReset() }) describe('normal archive code pattern', () => { @@ -241,4 +248,30 @@ describe('buildProductArchiveWhereWithTextRules normal code', () => { expect(serialized).toContain('整装') expect(serialized).toContain('组装') }) + + // 「负责人限价」是 SupplyChainStyleLimitPrice 的派生列:按价格换回货号再过滤。 + it('filters 负责人限价 by mapping the selected price back to 货号', async () => { + mockedQueryRaw.mockResolvedValueOnce([{ styleCode: 'SJ004833' }, { styleCode: 'SJ004834' }]) + const where = await buildProductArchiveWhereWithTextRules({ opsPriceLimit: ['213.80'] }) as { AND?: unknown[] } + const serialized = JSON.stringify(where.AND) + + expect(serialized).toContain('{"sku":{"in":["SJ004833","SJ004834"]}}') + }) + + it('returns no rows when the selected 负责人限价 has no matching 货号', async () => { + mockedQueryRaw.mockResolvedValueOnce([]) + const where = await buildProductArchiveWhereWithTextRules({ opsPriceLimit: ['123.45'] }) as { AND?: unknown[] } + + expect(JSON.stringify(where.AND)).toContain('__NO_MATCH__') + }) + + it('treats 负责人限价空值 as 货号不在限价表里', async () => { + mockedStyleLimitFindMany.mockResolvedValueOnce([{ styleCode: 'SJ004833' }, { styleCode: 'SJ004834' }]) + const where = await buildProductArchiveWhereWithTextRules({ + opsPriceLimit: [PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE], + }) as { AND?: unknown[] } + const serialized = JSON.stringify(where.AND) + + expect(serialized).toContain('"NOT":{"sku":{"in":["SJ004833","SJ004834"]}}') + }) }) diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts index dd5fbff8..cbee24b8 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-filters.ts @@ -605,6 +605,38 @@ export async function buildProductArchiveWhereWithTextRules( if (clause) extra.push(clause) } } + // 「负责人限价」:派生自 SupplyChainStyleLimitPrice.ownerLimitPrice(按货号), + // 先把选中的价格换回货号,再按 sku 过滤;「空值」= 不在限价表里的货号。 + if (omitFilter !== 'opsPriceLimit') { + const rawValues = (filters.opsPriceLimit ?? []).filter(Boolean) + if (rawValues.length > 0) { + const includesEmpty = rawValues.includes(PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE) + const prices = rawValues + .filter((value) => value !== PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE) + .map((value) => Number(String(value).replace(/[^\d.-]/g, ''))) + .filter((value) => Number.isFinite(value)) + const priceClauses: Prisma.SupplyChainProductArchiveWhereInput[] = [] + if (prices.length > 0) { + const rows = await prisma.$queryRaw>(Prisma.sql` + SELECT DISTINCT "styleCode" + FROM "SupplyChainStyleLimitPrice" + WHERE "ownerLimitPrice" IS NOT NULL + AND ROUND("ownerLimitPrice"::numeric, 2) IN (${Prisma.join(prices)}) + `) + const styleCodes = rows.map((row) => row.styleCode).filter(Boolean) + // 选中的价格在限价表里没有对应货号时返回空结果(而不是忽略条件) + priceClauses.push({ sku: { in: styleCodes.length > 0 ? styleCodes : ['__NO_MATCH__'] } }) + } + if (includesEmpty) { + const limitedStyles = await prisma.supplyChainStyleLimitPrice.findMany({ select: { styleCode: true } }) + priceClauses.push({ + NOT: { sku: { in: limitedStyles.map((row) => row.styleCode).filter(Boolean) } }, + }) + } + if (priceClauses.length === 1) extra.push(priceClauses[0]) + else if (priceClauses.length > 1) extra.push({ OR: priceClauses }) + } + } const asyncClauses = [...extra, ...ownerClauses] return asyncClauses.length > 0 ? { AND: [base, ...asyncClauses] } : base } diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts index 75c475b4..2e80d2b9 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive.repository.ts @@ -140,6 +140,34 @@ class SupplyChainProductArchiveRepositoryClass implements SupplyChainProductArch const values = index.spuSet.size > 0 ? ['有', '无'] : ['无'] return pageOptions(values, params) } + // 「负责人限价」是派生列(SupplyChainStyleLimitPrice.ownerLimitPrice,按货号), + // 候选值 = 当前筛选范围内实际出现的限价(两位小数,与界面展示一致)。 + if (params.field === 'opsPriceLimit') { + const query = await preparePeriodArchiveQuery(params.filters, params.field) + const unfiltered = query.ids === null && Object.keys(query.where).length === 0 + if (unfiltered) { + const rows = await prisma.$queryRaw>(Prisma.sql` + SELECT DISTINCT ROUND("ownerLimitPrice"::numeric, 2)::text AS price + FROM "SupplyChainStyleLimitPrice" + WHERE "ownerLimitPrice" IS NOT NULL + ORDER BY price + `) + // 没有流程限价的商品占多数,「空值」始终是一个有效候选 + return pageOptions([...rows.map((row) => row.price), PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE], params) + } + const records = await readSelectedArchives(query, { sku: true }) + const styleCodes = [...new Set(records.map((record) => String(record.sku ?? '').trim()).filter(Boolean))] + const limitRows = styleCodes.length + ? await prisma.supplyChainStyleLimitPrice.findMany({ + where: { styleCode: { in: styleCodes }, ownerLimitPrice: { not: null } }, + select: { styleCode: true, ownerLimitPrice: true }, + }) + : [] + const values = [...new Set(limitRows.map((row) => Number(row.ownerLimitPrice).toFixed(2)))] + const coveredStyles = new Set(limitRows.map((row) => row.styleCode)) + const hasMissingLimit = styleCodes.some((styleCode) => !coveredStyles.has(styleCode)) + return pageOptions(hasMissingLimit ? [...values, PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE] : values, params) + } const field = FILTER_OPTION_DATABASE_FIELDS[params.field as keyof typeof FILTER_OPTION_DATABASE_FIELDS] if (!field) return { values: [], hasMore: false, total: 0 } const query = await preparePeriodArchiveQuery(params.filters, params.field) From 177d11dd47fa3dcc07f3e4765c38d56c948b1d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 13:17:36 +0800 Subject: [PATCH 02/12] =?UTF-8?q?style(supply-chain):=20=E5=88=97=E5=90=8D?= =?UTF-8?q?=E3=80=8C=E9=9D=9E=E8=B4=9F=E8=B4=A3=E4=BA=BA=E9=99=90=E4=BB=B7?= =?UTF-8?q?=EF=BC=88=E6=96=B0=EF=BC=89=E3=80=8D=E6=94=B9=E4=B8=BA=E3=80=8C?= =?UTF-8?q?=E9=9D=9E=E8=B4=9F=E8=B4=A3=E4=BA=BA=E9=99=90=E4=BB=B7=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 限价明细页与产品全量档案页的限价分组同步改名(导出列头随列定义一起生效) --- .../src/app/supply-chain/database/product-archive/columns.ts | 2 +- .../supply-chain/management/custom-table/limit-table-columns.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts index 9cc4a985..c62ee67f 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/columns.ts @@ -139,7 +139,7 @@ export const ARCHIVE_COLUMNS: ArchiveColumnGroup[] = [ group: '限价', columns: [ // 负责人限价在「运营信息」分组里已有(运营负责人限价),这里不再重复一列 - { prop: 'nonOwnerLimitPriceNew', label: '非负责人限价(新)', width: 130 }, + { prop: 'nonOwnerLimitPriceNew', label: '非负责人限价', width: 130 }, { prop: 'jxLimitPrice', label: '京喜限价', width: 100 }, { prop: 'alibabaPageLimitPrice', label: '阿里页面限价', width: 110 }, { prop: 'distributorPrice89', label: '分销商89折', width: 100 }, diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts index a01e6176..52055905 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-table-columns.ts @@ -68,7 +68,7 @@ export const LIMIT_TABLE_COLUMNS: LimitTableColumn[] = [ // 限价 { key: 'ownerLimitPrice', label: '负责人限价', group: 'limit', width: 110, kind: 'archive', prop: 'opsPriceLimit', numeric: true, filterKey: 'opsPriceLimit' }, - { key: 'nonOwnerLimitPriceNew', label: '非负责人限价(新)', group: 'limit', width: 140, kind: 'archive', prop: 'nonOwnerLimitPriceNew', numeric: true }, + { key: 'nonOwnerLimitPriceNew', label: '非负责人限价', group: 'limit', width: 140, kind: 'archive', prop: 'nonOwnerLimitPriceNew', numeric: true }, { key: 'jxLimitPrice', label: '京喜限价', group: 'limit', width: 106, kind: 'archive', prop: 'jxLimitPrice', numeric: true }, { key: 'alibabaPageLimitPrice', label: '阿里页面限价', group: 'limit', width: 122, kind: 'archive', prop: 'alibabaPageLimitPrice', numeric: true }, { key: 'distributorPrice89', label: '分销商89折', group: 'limit', width: 116, kind: 'archive', prop: 'distributorPrice89', numeric: true }, From c2f005712b7d140f2b1ad994ff89fd8419bd085a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 13:46:16 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat(supply-chain):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=BB=84=E5=90=88=E9=99=90=E4=BB=B7=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../combo-limits/options/route.ts | 107 ++++++ .../limit-management/combo-limits/route.ts | 129 +++++++ .../custom-table/ComboLimitSection.tsx | 357 ++++++++++++++++++ .../custom-table/CustomTableView.test.tsx | 45 +++ .../custom-table/LimitFilterSelect.tsx | 39 +- .../management/custom-table/LimitPriceTab.tsx | 86 +++-- .../custom-table/LimitTableHeaderCell.tsx | 16 +- .../custom-table/useComboLimitData.ts | 82 ++++ .../custom-table/useLimitTableSettings.ts | 2 + .../supply-chain-combo-limit-price.test.ts | 113 ++++++ .../src/lib/supply-chain-combo-limit-price.ts | 157 ++++++++ .../jst-combo-limit-source.repository.ts | 127 +++++++ .../supply-chain-combo-limit.service.test.ts | 72 ++++ .../supply-chain-combo-limit.service.ts | 126 +++++++ 14 files changed, 1422 insertions(+), 36 deletions(-) create mode 100644 furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts create mode 100644 furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/management/custom-table/useComboLimitData.ts create mode 100644 furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts create mode 100644 furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts create mode 100644 furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts diff --git a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts new file mode 100644 index 00000000..d316f310 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts @@ -0,0 +1,107 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { + COMBO_LIMIT_FIELD_KEYS, + type ComboLimitFieldKey, + type ComboLimitFilters, +} from '@/lib/supply-chain-combo-limit-price' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainComboLimitService } from '@/server/services/supply-chain-combo-limit.service' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const querySchema = z.object({ + field: z.enum(COMBO_LIMIT_FIELD_KEYS as [ComboLimitFieldKey, ...ComboLimitFieldKey[]]), + q: z.string().max(200).optional(), + offset: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(200), +}) + +const filtersSchema = z.object({ + comboCode: z.array(z.string().max(200)).max(200).optional(), + ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(), + nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(), + jxLimitPrice: z.array(z.string().max(40)).max(200).optional(), + alibabaPageLimitPrice: z.array(z.string().max(40)).max(200).optional(), + distributorPrice89: z.array(z.string().max(40)).max(200).optional(), + distributorPrice85: z.array(z.string().max(40)).max(200).optional(), + distributorPrice80: z.array(z.string().max(40)).max(200).optional(), +}).strict() + +function parseJson(value: string | null) { + if (!value) return { success: true as const, data: null } + try { + return { success: true as const, data: JSON.parse(value) as unknown } + } catch { + return { success: false as const, data: null } + } +} + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement') + if (!access.view) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId) + } + + const query = querySchema.safeParse({ + field: request.nextUrl.searchParams.get('field') ?? undefined, + q: request.nextUrl.searchParams.get('q') ?? undefined, + offset: request.nextUrl.searchParams.get('offset') ?? undefined, + limit: request.nextUrl.searchParams.get('limit') ?? undefined, + }) + if (!query.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段或分页参数不正确', 400, ctx.requestId) + } + + const filtersPayload = parseJson(request.nextUrl.searchParams.get('filters')) + if (!filtersPayload.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId) + } + const filters = filtersPayload.data === null + ? { success: true as const, data: {} as ComboLimitFilters } + : filtersSchema.safeParse(filtersPayload.data) + if (!filters.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId) + } + + const data = await supplyChainComboLimitService.listOptions({ + ...query.data, + filters: filters.data, + }) + return jsonSuccess(data, 200, ctx.requestId) + }, { requireAuth: true }) +} + +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement') + if (!access.view) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId) + } + + const query = querySchema.safeParse({ + field: request.nextUrl.searchParams.get('field') ?? undefined, + q: request.nextUrl.searchParams.get('q') ?? undefined, + offset: request.nextUrl.searchParams.get('offset') ?? undefined, + limit: request.nextUrl.searchParams.get('limit') ?? undefined, + }) + if (!query.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选字段或分页参数不正确', 400, ctx.requestId) + } + + const filters = filtersSchema.safeParse(await request.json().catch(() => null)) + if (!filters.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId) + } + const data = await supplyChainComboLimitService.listOptions({ + ...query.data, + filters: filters.data, + }) + return jsonSuccess(data, 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts new file mode 100644 index 00000000..30ff68a0 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts @@ -0,0 +1,129 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { + COMBO_LIMIT_FIELD_KEYS, + type ComboLimitFieldKey, + type ComboLimitFilters, + type ComboLimitSort, +} from '@/lib/supply-chain-combo-limit-price' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainComboLimitService } from '@/server/services/supply-chain-combo-limit.service' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const listQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(200).default(50), +}) + +const filtersSchema = z.object({ + comboCode: z.array(z.string().max(200)).max(200).optional(), + ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(), + nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(), + jxLimitPrice: z.array(z.string().max(40)).max(200).optional(), + alibabaPageLimitPrice: z.array(z.string().max(40)).max(200).optional(), + distributorPrice89: z.array(z.string().max(40)).max(200).optional(), + distributorPrice85: z.array(z.string().max(40)).max(200).optional(), + distributorPrice80: z.array(z.string().max(40)).max(200).optional(), +}).strict() + +const sortSchema = z.object({ + field: z.enum(COMBO_LIMIT_FIELD_KEYS as [ComboLimitFieldKey, ...ComboLimitFieldKey[]]), + direction: z.enum(['asc', 'desc']), +}).strict() + +function parseJson(value: string | null) { + if (!value) return { success: true as const, data: null } + try { + return { success: true as const, data: JSON.parse(value) as unknown } + } catch { + return { success: false as const, data: null } + } +} + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement') + if (!access.view) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId) + } + + const page = listQuerySchema.safeParse({ + page: request.nextUrl.searchParams.get('page') ?? undefined, + pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined, + }) + if (!page.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId) + } + + const filtersPayload = parseJson(request.nextUrl.searchParams.get('filters')) + if (!filtersPayload.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId) + } + const filters = filtersPayload.data === null + ? { success: true as const, data: {} as ComboLimitFilters } + : filtersSchema.safeParse(filtersPayload.data) + if (!filters.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId) + } + + const sortPayload = parseJson(request.nextUrl.searchParams.get('sort')) + if (!sortPayload.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId) + } + const sort = sortPayload.data === null + ? { success: true as const, data: null as ComboLimitSort } + : sortSchema.safeParse(sortPayload.data) + if (!sort.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId) + } + + const data = await supplyChainComboLimitService.listPage({ + ...page.data, + filters: filters.data, + sort: sort.data, + forceRefresh: request.nextUrl.searchParams.get('refresh') === '1', + }) + return jsonSuccess(data, 200, ctx.requestId) + }, { requireAuth: true }) +} + +// 批量筛选项较多时 URL 会超长,前端改用 POST 传完整 filters / sort。 +export async function POST(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess(ctx.user, 'limitManagement') + if (!access.view) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '没有限价管理查看权限', 403, ctx.requestId) + } + + const page = listQuerySchema.safeParse({ + page: request.nextUrl.searchParams.get('page') ?? undefined, + pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined, + }) + if (!page.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '分页参数不正确', 400, ctx.requestId) + } + + const body = await request.json().catch(() => null) as { filters?: unknown; sort?: unknown } | null + const filters = filtersSchema.safeParse(body?.filters ?? {}) + if (!filters.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件格式不正确', 400, ctx.requestId) + } + const sort = sortSchema.safeParse(body?.sort ?? null) + if (body?.sort !== null && body?.sort !== undefined && !sort.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '排序参数不正确', 400, ctx.requestId) + } + + const data = await supplyChainComboLimitService.listPage({ + ...page.data, + filters: filters.data, + sort: sort.success ? sort.data : null, + forceRefresh: request.nextUrl.searchParams.get('refresh') === '1', + }) + return jsonSuccess(data, 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx new file mode 100644 index 00000000..8eb23411 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx @@ -0,0 +1,357 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { + COMBO_LIMIT_FIELDS, + isComboLimitFieldKey, + type ComboLimitFieldKey, + type ComboLimitFilters, + type ComboLimitRow, + type ComboLimitSort, +} from '@/lib/supply-chain-combo-limit-price' +import type { LimitTableColumn } from './limit-table-columns' +import { LimitFilterSelect } from './LimitFilterSelect' +import { LimitTableHeaderCell } from './LimitTableHeaderCell' +import type { LimitTableFilterField } from './useLimitTableSettings' +import styles from './limit-price.module.css' + +const OPTIONS_ENDPOINT = '/api/supply-chain/limit-management/combo-limits/options' +const WIDTH_STORAGE_PREFIX = 'supply-chain.limit-management.combo-widths.v1' + +const COMBO_COLUMNS: LimitTableColumn[] = COMBO_LIMIT_FIELDS.map((field) => ({ + key: field.key, + label: field.label, + group: 'limit', + width: field.key === 'comboCode' ? 280 : 122, + kind: 'archive', + numeric: field.numeric, +})) + +const COMBO_FILTER_FIELDS: LimitTableFilterField[] = COMBO_LIMIT_FIELDS.map((field) => ({ + key: field.key, + label: field.label, + optionsEndpoint: OPTIONS_ENDPOINT, +})) + +const COLUMN_MAP = new Map(COMBO_COLUMNS.map((column) => [column.key, column])) +const FIELD_LABEL_MAP = new Map(COMBO_LIMIT_FIELDS.map((field) => [field.key, field.label])) + +function storageKey(userId: string) { + return `${WIDTH_STORAGE_PREFIX}:${userId || 'anonymous'}` +} + +function readWidths(userId: string): Record { + if (typeof window === 'undefined') return {} + try { + const raw = window.localStorage.getItem(storageKey(userId)) + if (!raw) return {} + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') return {} + return Object.fromEntries( + Object.entries(parsed).flatMap(([key, value]) => { + const width = Number(value) + return COLUMN_MAP.has(key) && Number.isFinite(width) && width >= 48 && width <= 600 + ? [[key, Math.round(width)]] + : [] + }), + ) + } catch { + return {} + } +} + +export function ComboLimitSection({ + userId, + rows, + page, + pageSize, + total, + totalPages, + loading, + error, + filters, + sort, + onPageChange, + onPageSizeChange, + onFiltersChange, + onSortChange, +}: { + userId: string + rows: ComboLimitRow[] + page: number + pageSize: number + total: number + totalPages: number + loading: boolean + error: string + filters: ComboLimitFilters + sort: ComboLimitSort + onPageChange: (page: number) => void + onPageSizeChange: (pageSize: 50 | 100) => void + onFiltersChange: (filters: ComboLimitFilters) => void + onSortChange: (sort: ComboLimitSort) => void +}) { + const [openFilterKey, setOpenFilterKey] = useState(null) + const [openSortKey, setOpenSortKey] = useState(null) + const [widths, setWidths] = useState>(() => readWidths(userId)) + const [liveWidths, setLiveWidths] = useState | null>(null) + + useEffect(() => { + if (!openSortKey) return + const onDocumentMouseDown = (event: MouseEvent) => { + const target = event.target as HTMLElement | null + if (target?.closest('[data-limit-sort-cell]') || target?.closest('[data-limit-sort-menu]')) return + setOpenSortKey(null) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpenSortKey(null) + } + document.addEventListener('mousedown', onDocumentMouseDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('mousedown', onDocumentMouseDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [openSortKey]) + + const contextFilters = useMemo( + () => filters as Record, + [filters], + ) + + const activeFilterChips = useMemo(() => COMBO_LIMIT_FIELDS.flatMap((field) => { + const values = filters[field.key] ?? [] + if (!values.length) return [] + return [{ + key: field.key, + label: field.label, + text: values.length > 3 ? `${values.slice(0, 3).join(',')}…(共 ${values.length} 个)` : values.join(','), + }] + }), [filters]) + + const updateFilter = (key: string, values: string[]) => { + if (!isComboLimitFieldKey(key)) return + const next = { ...filters } + if (values.length) next[key] = values + else delete next[key] + onFiltersChange(next) + onPageChange(1) + } + + const clearAllFilters = () => { + onFiltersChange({}) + onPageChange(1) + } + + const columnWidth = (column: LimitTableColumn) => + liveWidths?.[column.key] ?? widths[column.key] ?? column.width + + const totalWidth = COMBO_COLUMNS.reduce((sum, column) => sum + columnWidth(column), 0) + + const startHeaderResize = ( + column: LimitTableColumn, + event: { clientX: number; preventDefault: () => void; stopPropagation: () => void }, + ) => { + event.preventDefault() + event.stopPropagation() + const startX = event.clientX + const startWidth = columnWidth(column) + const base = { ...widths } + let latest = startWidth + setLiveWidths(base) + const onMove = (moveEvent: MouseEvent) => { + latest = Math.max(48, Math.min(600, Math.round(startWidth + moveEvent.clientX - startX))) + setLiveWidths({ ...base, [column.key]: latest }) + } + const onUp = () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + const next = { ...base, [column.key]: latest } + setWidths(next) + setLiveWidths(null) + try { + window.localStorage.setItem(storageKey(userId), JSON.stringify(next)) + } catch {} + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + } + + const commitPageInput = (value: string) => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return null + } + const next = Math.min(Math.max(Math.trunc(parsed), 1), Math.max(1, totalPages)) + if (next !== page) onPageChange(next) + return next + } + + return ( + <> +
+ 筛选 + {COMBO_FILTER_FIELDS.map((field) => ( + )[field.key] ?? []} + contextFilters={contextFilters} + onChange={(values) => updateFilter(field.key, values)} + /> + ))} +
+ + {activeFilterChips.length > 0 && ( +
+ 已选条件 + {activeFilterChips.map((chip) => ( + + {FIELD_LABEL_MAP.get(chip.key as ComboLimitFieldKey) ?? chip.label} + 属于:{chip.text} + + + ))} + +
+ )} + +
+ 0 ? styles.tableLoading : ''}`} + style={{ width: Math.max(totalWidth, 880) }} + > + + {COMBO_COLUMNS.map((column) => ( + + ))} + + + + {COMBO_COLUMNS.map((column) => ( + setOpenSortKey((current) => (current === column.key ? null : column.key))} + onCloseMenu={() => setOpenSortKey(null)} + onSort={(direction) => { + onSortChange({ + field: column.key as ComboLimitFieldKey, + direction, + }) + setOpenSortKey(null) + onPageChange(1) + }} + onClearSort={() => { + onSortChange(null) + setOpenSortKey(null) + }} + filterField={COMBO_FILTER_FIELDS.find((field) => field.key === column.key)} + filterValues={(filters as Record)[column.key] ?? []} + filterContext={contextFilters} + filterOpen={openFilterKey === column.key} + onToggleFilter={() => setOpenFilterKey((current) => (current === column.key ? null : column.key))} + onCloseFilter={() => setOpenFilterKey(null)} + onFilterChange={(values) => updateFilter(column.key, values)} + onResizeStart={(event) => startHeaderResize(column, event)} + /> + ))} + + + + {loading && rows.length === 0 && ( + + )} + {!loading && error && ( + + )} + {!loading && !error && rows.length === 0 && ( + + )} + {rows.map((row) => ( + + {COMBO_COLUMNS.map((column) => { + const value = isComboLimitFieldKey(column.key) ? row[column.key] : '' + return ( + + ) + })} + + ))} + +
组合限价加载中…
{error}
没有符合条件的组合限价
+ {value || —} +
+
+ +
+ + { event.currentTarget.value = event.currentTarget.value.replace(/[^\d]/g, '') }} + onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} + onBlur={(event) => { + const next = commitPageInput(event.currentTarget.value) + event.currentTarget.value = String(next ?? page) + }} + /> + / {totalPages} + + + 共 {total} 条,每页 + + 条 + +
+ + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx index 41c31e94..3fbf50ca 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx @@ -161,6 +161,37 @@ function mockLimitManagementFetch(rows: SupplyChainProductArchiveRow[]) { }, }) } + if (url.includes('/api/supply-chain/limit-management/combo-limits/options')) { + return jsonResponse({ + success: true, + data: { + field: 'comboCode', + values: ['COMBO-AB'], + offset: 0, + limit: 200, + hasMore: false, + total: 1, + }, + }) + } + if (url.includes('/api/supply-chain/limit-management/combo-limits')) { + return jsonResponse({ + success: true, + data: { + rows: [{ + comboCode: 'COMBO-AB', + ownerLimitPrice: '69.00', + nonOwnerLimitPriceNew: '79.00', + jxLimitPrice: '50.00', + alibabaPageLimitPrice: '73.47', + distributorPrice89: '65.39', + distributorPrice85: '62.45', + distributorPrice80: '58.78', + }], + pageInfo: { page: 1, pageSize: 50, total: 1, totalPages: 1 }, + }, + }) + } if (url.includes('/api/supply-chain/limit-management/products')) { return jsonResponse({ success: true, @@ -225,6 +256,20 @@ describe('限价管理', () => { expect(screen.getByRole('button', { name: '导出明细(含图片)' })).toBeInTheDocument() }) + it('品牌商品旁展示组合限价页,并只展示组合编码与限价价格字段', async () => { + render() + await screen.findByText('测试岩板餐桌') + + fireEvent.click(screen.getByRole('button', { name: '组合限价' })) + + expect(await screen.findByText('COMBO-AB')).toBeInTheDocument() + for (const label of ['组合编码', '负责人限价', '非负责人限价', '京喜限价', '阿里页面限价', '分销商89折', '分销商85折', '分销商8折']) { + expect(screen.getByRole('columnheader', { name: new RegExp(`^${label}`) })).toBeInTheDocument() + } + expect(screen.getByText('69.00')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument() + }) + it('限价页按《限价抬头及逻辑》显示分组表头,并展示产品全量档案数据', async () => { render() diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx index cc8ac17c..212dfadb 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx @@ -3,12 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown } from 'lucide-react' import type { ApiResponse } from '@/server/core/types' -import type { ProductArchiveFilterOptions } from '@/lib/supply-chain-product-archive' import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE } from '@/lib/supply-chain-product-archive' import type { LimitTableFilterField } from './useLimitTableSettings' import styles from './limit-price.module.css' const OPTION_LIMIT = 200 +const POST_FILTERS_THRESHOLD = 4000 /** 「空值」在接口里是哨兵值,界面上统一显示成「空值」。 */ function optionLabel(value: string) { @@ -19,12 +19,14 @@ type PanelProps = { field: LimitTableFilterField selected: string[] onChange: (values: string[]) => void + /** 其他已选筛选条件,用于让候选值随筛选范围联动 */ + contextFilters?: Record /** 传入时在底部显示「收起」按钮 */ onCollapse?: () => void } /** 选项加载 + 搜索 + 多选列表:顶部筛选栏与表头筛选浮层共用。 */ -export function FilterOptionPanel({ field, selected, onChange, onCollapse }: PanelProps) { +export function FilterOptionPanel({ field, selected, onChange, contextFilters, onCollapse }: PanelProps) { const [search, setSearch] = useState('') const [options, setOptions] = useState(field.fixedOptions ?? []) const [loading, setLoading] = useState(false) @@ -41,11 +43,21 @@ export function FilterOptionPanel({ field, selected, onChange, onCollapse }: Pan }) const keyword = search.trim() if (keyword) params.set('q', keyword) - const response = await fetch( - `/api/supply-chain/limit-management/options?${params.toString()}`, - { signal: controller.signal }, - ) - const payload = (await response.json()) as ApiResponse + const filtersText = contextFilters && Object.keys(contextFilters).length + ? JSON.stringify(contextFilters) + : '' + const endpoint = field.optionsEndpoint ?? '/api/supply-chain/limit-management/options' + const getParams = new URLSearchParams(params) + if (filtersText) getParams.set('filters', filtersText) + const response = filtersText.length > POST_FILTERS_THRESHOLD + ? await fetch(`${endpoint}?${params.toString()}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: filtersText, + signal: controller.signal, + }) + : await fetch(`${endpoint}?${getParams.toString()}`, { signal: controller.signal }) + const payload = (await response.json()) as ApiResponse<{ values: string[] }> if (!response.ok || !payload.success || !payload.data) throw new Error('加载失败') setOptions(payload.data.values) } catch { @@ -58,7 +70,7 @@ export function FilterOptionPanel({ field, selected, onChange, onCollapse }: Pan controller.abort() window.clearTimeout(timer) } - }, [field.key, field.fixedOptions, search]) + }, [contextFilters, field.fixedOptions, field.key, field.optionsEndpoint, search]) const displayOptions = useMemo(() => { const keyword = search.trim().toLowerCase() @@ -117,9 +129,10 @@ type Props = { field: LimitTableFilterField selected: string[] onChange: (values: string[]) => void + contextFilters?: Record } -export function LimitFilterSelect({ field, selected, onChange }: Props) { +export function LimitFilterSelect({ field, selected, onChange, contextFilters }: Props) { const [open, setOpen] = useState(false) const wrapRef = useRef(null) @@ -152,7 +165,13 @@ export function LimitFilterSelect({ field, selected, onChange }: Props) { {open && (
- setOpen(false)} /> + setOpen(false)} + />
)} diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx index 04fcb266..4ba17a38 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx @@ -28,13 +28,17 @@ import { } from './useLimitTableSettings' import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE } from '@/lib/supply-chain-product-archive' import { useLimitPriceData } from './useLimitPriceData' +import { useComboLimitData } from './useComboLimitData' +import { ComboLimitSection } from './ComboLimitSection' +import type { ComboLimitFilters, ComboLimitSort } from '@/lib/supply-chain-combo-limit-price' import styles from './limit-price.module.css' -type ProductKind = 'bulk' | 'brand' +type ProductKind = 'bulk' | 'brand' | 'combo' const PRODUCT_KIND_LABELS: Record = { bulk: '大货商品', brand: '品牌商品', + combo: '组合限价', } /** 品牌商品 = 品牌含「顾家 / 索菲亚 / 朴美」;其余(含其他品牌)都归大货商品。 */ @@ -149,6 +153,8 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c const [productKind, setProductKind] = useState('bulk') const [filters, setFilters] = useState(EMPTY_FILTERS) + const [comboFilters, setComboFilters] = useState({}) + const [comboSort, setComboSort] = useState(null) // 列宽拖拽:拖动过程中用本地临时宽度,松开鼠标后一次性持久化到个人设置。 const [liveWidths, setLiveWidths] = useState | null>(null) const [page, setPage] = useState(1) @@ -193,7 +199,7 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c const effectiveFilters = useMemo(() => { const next: ProductArchiveFilters = { ...filters } - const tabBrands = brandPartition?.[productKind] ?? [] + const tabBrands = productKind === 'combo' ? [] : (brandPartition?.[productKind] ?? []) if (tabBrands.length) { const selected = filters.brand ?? [] const intersection = selected.length @@ -208,8 +214,18 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c page, settings.pageSize, effectiveFilters, - hydrated && brandPartitionSettled, + hydrated && brandPartitionSettled && productKind !== 'combo', ) + const comboPageSize: 50 | 100 = settings.pageSize === 50 ? 50 : 100 + const comboData = useComboLimitData( + page, + comboPageSize, + comboFilters, + comboSort, + hydrated && productKind === 'combo', + ) + const activeLoading = productKind === 'combo' ? comboData.loading : loading + const activeTotalPages = productKind === 'combo' ? comboData.pageInfo.totalPages : pageInfo.totalPages // 限价明细导出:两种(不含图片 / 含图片),走全局下载中心后台生成, // 进度显示在右上角下载中心里,页面不再挂浮动进度卡。 @@ -341,8 +357,8 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c } useEffect(() => { - if (page > pageInfo.totalPages) setPage(Math.max(1, pageInfo.totalPages)) - }, [page, pageInfo.totalPages]) + if (page > activeTotalPages) setPage(Math.max(1, activeTotalPages)) + }, [activeTotalPages, page]) useEffect(() => { setPageInput(String(page)) @@ -435,12 +451,12 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c type="button" className={styles.iconButton} title="刷新" - aria-label="刷新限价商品" - onClick={() => reload()} + aria-label={productKind === 'combo' ? '刷新组合限价' : '刷新限价商品'} + onClick={() => productKind === 'combo' ? comboData.reload() : reload()} > - + - {canExport && ( + {productKind !== 'combo' && canExport && ( <> {active && ( <> @@ -220,6 +227,7 @@ export function LimitTableHeaderCell({ field={filterField} selected={filterValues ?? []} onChange={(values) => onFilterChange?.(values)} + contextFilters={filterContext} /> , document.body, diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/useComboLimitData.ts b/furniture-3d-system/src/app/supply-chain/management/custom-table/useComboLimitData.ts new file mode 100644 index 00000000..7dc31894 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/useComboLimitData.ts @@ -0,0 +1,82 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ApiResponse } from '@/server/core/types' +import type { + ComboLimitFilters, + ComboLimitPage, + ComboLimitSort, +} from '@/lib/supply-chain-combo-limit-price' + +const POST_FILTERS_THRESHOLD = 4000 + +const EMPTY_PAGE: ComboLimitPage = { + rows: [], + pageInfo: { page: 1, pageSize: 50, total: 0, totalPages: 1 }, +} + +export function useComboLimitData( + page: number, + pageSize: number, + filters: ComboLimitFilters, + sort: ComboLimitSort, + enabled = true, +) { + const [result, setResult] = useState(EMPTY_PAGE) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [reloadKey, setReloadKey] = useState(0) + const forceRefreshRef = useRef(false) + + useEffect(() => { + if (!enabled) return + const controller = new AbortController() + let cancelled = false + + async function load() { + setLoading(true) + setError('') + try { + const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) + if (forceRefreshRef.current) params.set('refresh', '1') + forceRefreshRef.current = false + const filtersText = Object.keys(filters).length ? JSON.stringify(filters) : '' + const sortText = sort ? JSON.stringify(sort) : '' + const endpoint = `/api/supply-chain/limit-management/combo-limits?${params.toString()}` + const response = filtersText.length > POST_FILTERS_THRESHOLD + ? await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filters, sort }), + signal: controller.signal, + }) + : await fetch(`${endpoint}${filtersText ? `&filters=${encodeURIComponent(filtersText)}` : ''}${sortText ? `&sort=${encodeURIComponent(sortText)}` : ''}`, { + signal: controller.signal, + }) + const payload = (await response.json()) as ApiResponse + if (!response.ok || !payload.success || !payload.data) { + throw new Error(payload.error?.message || '组合限价加载失败') + } + if (!cancelled) setResult(payload.data) + } catch (loadError) { + if (cancelled || controller.signal.aborted) return + setError(loadError instanceof Error ? loadError.message : '组合限价加载失败') + } finally { + if (!cancelled && !controller.signal.aborted) setLoading(false) + } + } + + void load() + return () => { + cancelled = true + controller.abort() + } + }, [enabled, filters, page, pageSize, reloadKey, sort]) + + const reload = useCallback(() => { + forceRefreshRef.current = true + setReloadKey((key) => key + 1) + }, []) + + return { ...result, loading, error, reload } +} diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/useLimitTableSettings.ts b/furniture-3d-system/src/app/supply-chain/management/custom-table/useLimitTableSettings.ts index 8da63950..8e430df7 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/useLimitTableSettings.ts +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/useLimitTableSettings.ts @@ -29,6 +29,8 @@ export type LimitTableFilterField = { key: string label: string fixedOptions?: string[] + /** 非产品档案筛选项可指定自己的候选值接口 */ + optionsEndpoint?: string } export const LIMIT_TABLE_FILTER_FIELDS: LimitTableFilterField[] = [ diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts new file mode 100644 index 00000000..e5f75e3c --- /dev/null +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts @@ -0,0 +1,113 @@ +import { + buildComboLimitRow, + comboLimitOptions, + filterComboLimitRows, + paginateComboLimitRows, + sortComboLimitRows, +} from './supply-chain-combo-limit-price' + +describe('supply-chain combo limit price', () => { + it('formats the A(50) + B(19) combination as 69.00 and derives the other price fields', () => { + const row = buildComboLimitRow({ + combineCode: 'COMBO-AB', + itemCount: 2, + costCount: 2, + ownerTotal: 69, + nonOwnerTotal: 79, + costTotal: 37, + }) + + expect(row).toEqual({ + comboCode: 'COMBO-AB', + ownerLimitPrice: '69.00', + nonOwnerLimitPriceNew: '79.00', + jxLimitPrice: '50.00', + alibabaPageLimitPrice: '73.47', + distributorPrice89: '65.39', + distributorPrice85: '62.45', + distributorPrice80: '58.78', + }) + }) + + it('leaves Jingxi blank when any child product has no cost price', () => { + const row = buildComboLimitRow({ + combineCode: 'COMBO-MISSING-COST', + itemCount: 3, + costCount: 2, + ownerTotal: 120, + nonOwnerTotal: 136, + costTotal: 80, + }) + + expect(row.jxLimitPrice).toBe('') + expect(row.ownerLimitPrice).toBe('120.00') + }) + + it('filters by selected values, sorts the full result and paginates', () => { + const rows = [ + buildComboLimitRow({ + combineCode: 'COMBO-2', + itemCount: 1, + costCount: 1, + ownerTotal: 90, + nonOwnerTotal: 100, + costTotal: 50, + }), + buildComboLimitRow({ + combineCode: 'COMBO-1', + itemCount: 1, + costCount: 1, + ownerTotal: 50, + nonOwnerTotal: 60, + costTotal: 30, + }), + buildComboLimitRow({ + combineCode: 'OTHER', + itemCount: 1, + costCount: 1, + ownerTotal: 10, + nonOwnerTotal: 20, + costTotal: 10, + }), + ] + + const filtered = filterComboLimitRows(rows, { + comboCode: ['COMBO-2', 'COMBO-1'], + }) + const sorted = sortComboLimitRows(filtered, { + field: 'ownerLimitPrice', + direction: 'desc', + }) + const page = paginateComboLimitRows(sorted, 1, 1) + + expect(page.rows.map((row) => row.comboCode)).toEqual(['COMBO-2']) + expect(page.pageInfo).toEqual({ page: 1, pageSize: 1, total: 2, totalPages: 2 }) + }) + + it('creates searchable filter options from distinct values', () => { + const rows = [ + buildComboLimitRow({ + combineCode: 'COMBO-AB', + itemCount: 2, + costCount: 2, + ownerTotal: 69, + nonOwnerTotal: 79, + costTotal: 37, + }), + buildComboLimitRow({ + combineCode: 'COMBO-AC', + itemCount: 2, + costCount: 2, + ownerTotal: 80, + nonOwnerTotal: 90, + costTotal: 40, + }), + ] + + const result = comboLimitOptions(rows, 'comboCode', { q: 'ab', limit: 20 }) + + expect(result.values).toEqual(['COMBO-AB']) + expect(result.total).toBe(1) + expect(result.hasMore).toBe(false) + }) +}) diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts new file mode 100644 index 00000000..2582e523 --- /dev/null +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts @@ -0,0 +1,157 @@ +export const COMBO_LIMIT_FIELDS = [ + { key: 'comboCode', label: '组合编码', numeric: false }, + { key: 'ownerLimitPrice', label: '负责人限价', numeric: true }, + { key: 'nonOwnerLimitPriceNew', label: '非负责人限价', numeric: true }, + { key: 'jxLimitPrice', label: '京喜限价', numeric: true }, + { key: 'alibabaPageLimitPrice', label: '阿里页面限价', numeric: true }, + { key: 'distributorPrice89', label: '分销商89折', numeric: true }, + { key: 'distributorPrice85', label: '分销商85折', numeric: true }, + { key: 'distributorPrice80', label: '分销商8折', numeric: true }, +] as const + +export type ComboLimitFieldKey = (typeof COMBO_LIMIT_FIELDS)[number]['key'] + +export type ComboLimitRow = Record + +export type ComboLimitFilters = Partial> + +export type ComboLimitSort = { + field: ComboLimitFieldKey + direction: 'asc' | 'desc' +} | null + +export type ComboLimitAggregate = { + combineCode: string + itemCount: number + costCount: number + ownerTotal: number + nonOwnerTotal: number + costTotal: number | null +} + +export type ComboLimitPage = { + rows: ComboLimitRow[] + pageInfo: { + page: number + pageSize: number + total: number + totalPages: number + } +} + +export type ComboLimitOptionResult = { + field: ComboLimitFieldKey + values: string[] + offset: number + limit: number + hasMore: boolean + total: number +} + +export const COMBO_LIMIT_FIELD_KEYS = COMBO_LIMIT_FIELDS.map((field) => field.key) + +const COMBO_LIMIT_FIELD_KEY_SET = new Set(COMBO_LIMIT_FIELD_KEYS) + +export function isComboLimitFieldKey(value: string): value is ComboLimitFieldKey { + return COMBO_LIMIT_FIELD_KEY_SET.has(value) +} + +function roundPrice(value: number) { + return Math.round((value + Number.EPSILON) * 100) / 100 +} + +export function formatComboLimitPrice(value: number | null | undefined) { + return typeof value === 'number' && Number.isFinite(value) ? roundPrice(value).toFixed(2) : '' +} + +/** + * 组合价按组合明细中的子项限价汇总,子项数量由 qty 体现。 + * A(50)×1 + B(19)×1 = 69;若 B 的数量为 2,则为 50 + 19×2 = 88。 + */ +export function buildComboLimitRow(input: ComboLimitAggregate): ComboLimitRow { + const nonOwnerTotal = input.nonOwnerTotal + const alibabaTotal = nonOwnerTotal * 0.93 + // 任一子项缺少成本价时,京喜组合价无法完整汇总,留空而不是按部分子项少算。 + const jxTotal = input.costCount === input.itemCount && input.costTotal !== null + ? input.costTotal / 0.74 + : null + return { + comboCode: input.combineCode, + ownerLimitPrice: formatComboLimitPrice(input.ownerTotal), + nonOwnerLimitPriceNew: formatComboLimitPrice(nonOwnerTotal), + jxLimitPrice: formatComboLimitPrice(jxTotal), + alibabaPageLimitPrice: formatComboLimitPrice(alibabaTotal), + distributorPrice89: formatComboLimitPrice(alibabaTotal * 0.89), + distributorPrice85: formatComboLimitPrice(alibabaTotal * 0.85), + distributorPrice80: formatComboLimitPrice(alibabaTotal * 0.8), + } +} + +export function filterComboLimitRows(rows: ComboLimitRow[], filters: ComboLimitFilters) { + const active = Object.entries(filters).flatMap(([field, values]) => { + if (!isComboLimitFieldKey(field)) return [] + const normalized = [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))] + return normalized.length ? [{ field, values: new Set(normalized) }] : [] + }) + if (!active.length) return rows + return rows.filter((row) => active.every(({ field, values }) => values.has(row[field]))) +} + +export function sortComboLimitRows(rows: ComboLimitRow[], sort: ComboLimitSort) { + if (!sort) return rows + const factor = sort.direction === 'asc' ? 1 : -1 + return rows + .map((row, index) => ({ row, index })) + .sort((left, right) => { + const a = left.row[sort.field] + const b = right.row[sort.field] + if (!a && !b) return left.index - right.index + if (!a) return 1 + if (!b) return -1 + if (sort.field !== 'comboCode') { + const aNumber = Number(a) + const bNumber = Number(b) + if (Number.isFinite(aNumber) && Number.isFinite(bNumber) && aNumber !== bNumber) { + return (aNumber - bNumber) * factor + } + } + return a.localeCompare(b, 'zh-Hans-CN', { numeric: true }) * factor + }) + .map((item) => item.row) +} + +export function paginateComboLimitRows(rows: ComboLimitRow[], pageValue: number, pageSizeValue: number): ComboLimitPage { + const pageSize = Math.min(200, Math.max(1, Math.floor(pageSizeValue))) + const total = rows.length + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + const page = Math.min(Math.max(1, Math.floor(pageValue)), totalPages) + const offset = (page - 1) * pageSize + return { + rows: rows.slice(offset, offset + pageSize), + pageInfo: { page, pageSize, total, totalPages }, + } +} + +export function comboLimitOptions( + rows: ComboLimitRow[], + field: ComboLimitFieldKey, + input: { q?: string; offset?: number; limit?: number }, +): ComboLimitOptionResult { + const q = input.q?.trim().toLocaleLowerCase('zh-Hans-CN') ?? '' + const allValues = [...new Set(rows.map((row) => row[field]).filter(Boolean))] + .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN', { numeric: true })) + const matched = q + ? allValues.filter((value) => value.toLocaleLowerCase('zh-Hans-CN').includes(q)) + : allValues + const offset = Math.max(0, Math.floor(input.offset ?? 0)) + const limit = Math.min(1000, Math.max(1, Math.floor(input.limit ?? 200))) + const values = matched.slice(offset, offset + limit) + return { + field, + values, + offset, + limit, + hasMore: offset + values.length < matched.length, + total: matched.length, + } +} diff --git a/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts b/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts new file mode 100644 index 00000000..316e9f13 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts @@ -0,0 +1,127 @@ +import sql from 'mssql' +import type { ComboLimitAggregate } from '@/lib/supply-chain-combo-limit-price' + +export type ComboLimitPricedProduct = { + barcode: string + ownerLimitPrice: number + costPrice: number | null +} + +function requiredEnv(name: string) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`Missing required environment variable: ${name}`) + return value +} + +function safeIdentifier(value: string, label: string) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new Error(`${label} is not a valid SQL Server identifier`) + return value +} + +class JstComboLimitSourceRepository { + private pool: sql.ConnectionPool | null = null + + private async getPool() { + if (this.pool?.connected) return this.pool + this.pool = await new sql.ConnectionPool({ + server: requiredEnv('JST_REPORT_DB_HOST'), + port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'), + database: requiredEnv('JST_REPORT_DB_NAME'), + user: requiredEnv('JST_REPORT_DB_USER'), + password: requiredEnv('JST_REPORT_DB_PASSWORD'), + options: { + encrypt: (process.env.JST_REPORT_DB_ENCRYPT?.trim() || 'true') !== 'false', + trustServerCertificate: (process.env.JST_REPORT_DB_TRUST_SERVER_CERTIFICATE?.trim() || 'true') !== 'false', + }, + pool: { min: 0, max: 4, idleTimeoutMillis: 30_000 }, + requestTimeout: 300_000, + connectionTimeout: 20_000, + }).connect() + return this.pool + } + + /** + * 只返回所有子项都能匹配到负责人限价的组合。 + * 数量和负责人/非负责人限价在 SQL Server 内聚合,避免把数万条组合明细逐行传到应用层。 + */ + async listCompleteComboLimits(products: ComboLimitPricedProduct[]): Promise { + if (!products.length) return [] + const schema = safeIdentifier(process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt', 'JST_REPORT_DB_SCHEMA') + const pool = await this.getPool() + const result = await pool.request() + .input('products', sql.NVarChar(sql.MAX), JSON.stringify(products)) + .query<{ + combineCode: string + itemCount: number + costCount: number + ownerTotal: number + nonOwnerTotal: number + costTotal: number | null + }>(` + WITH prices AS ( + SELECT + barcode, + ownerPrice, + costPrice + FROM OPENJSON(@products) + WITH ( + barcode nvarchar(120) '$.barcode', + ownerPrice decimal(18,4) '$.ownerLimitPrice', + costPrice decimal(18,4) '$.costPrice' + ) + ), + comboItemTotals AS ( + SELECT combine_sku_id, COUNT(*) AS itemCount + FROM [${schema}].[jst_comboProductItem] + WHERE combine_sku_id IS NOT NULL + AND LTRIM(RTRIM(CAST(combine_sku_id AS nvarchar(200)))) <> '' + AND qty > 0 + GROUP BY combine_sku_id + ), + comboPrices AS ( + SELECT + i.combine_sku_id, + COUNT(*) AS matchedCount, + COUNT(p.costPrice) AS costCount, + SUM(p.ownerPrice * i.qty) AS ownerTotal, + SUM(( + p.ownerPrice + + CASE WHEN p.ownerPrice * 0.05 > 10 THEN p.ownerPrice * 0.05 ELSE 10 END + ) * i.qty) AS nonOwnerTotal, + SUM(p.costPrice * i.qty) AS costTotal + FROM [${schema}].[jst_comboProductItem] i + JOIN prices p ON p.barcode = CAST(i.src_sku_id AS nvarchar(120)) + WHERE i.combine_sku_id IS NOT NULL + AND LTRIM(RTRIM(CAST(i.combine_sku_id AS nvarchar(200)))) <> '' + AND i.qty > 0 + GROUP BY i.combine_sku_id + ) + SELECT + CAST(cp.combine_sku_id AS nvarchar(200)) AS combineCode, + CAST(t.itemCount AS int) AS itemCount, + CAST(cp.costCount AS int) AS costCount, + CAST(cp.ownerTotal AS decimal(28,4)) AS ownerTotal, + CAST(cp.nonOwnerTotal AS decimal(28,4)) AS nonOwnerTotal, + CAST(cp.costTotal AS decimal(28,4)) AS costTotal + FROM comboPrices cp + JOIN comboItemTotals t ON t.combine_sku_id = cp.combine_sku_id + WHERE cp.matchedCount = t.itemCount + `) + return result.recordset.map((row) => ({ + combineCode: String(row.combineCode ?? '').trim(), + itemCount: Number(row.itemCount), + costCount: Number(row.costCount), + ownerTotal: Number(row.ownerTotal), + nonOwnerTotal: Number(row.nonOwnerTotal), + costTotal: row.costTotal === null ? null : Number(row.costTotal), + })).filter((row) => row.combineCode) + } + + async close() { + if (!this.pool) return + await this.pool.close() + this.pool = null + } +} + +export const jstComboLimitSourceRepository = new JstComboLimitSourceRepository() diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts new file mode 100644 index 00000000..e4e6d0c1 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts @@ -0,0 +1,72 @@ +jest.mock('@/server/repositories/jst-combo-limit-source.repository', () => ({ + jstComboLimitSourceRepository: { + listCompleteComboLimits: jest.fn().mockResolvedValue([]), + }, +})) + +import { SupplyChainComboLimitService } from '../supply-chain-combo-limit.service' + +function aggregate(combineCode: string, ownerTotal: number) { + return { + combineCode, + itemCount: 2, + costCount: 2, + ownerTotal, + nonOwnerTotal: ownerTotal + 20, + costTotal: ownerTotal * 0.5, + } +} + +describe('SupplyChainComboLimitService', () => { + it('caches the aggregated combo rows and supports an explicit refresh', async () => { + const repository = { + listCompleteComboLimits: jest.fn() + .mockResolvedValueOnce([aggregate('COMBO-B', 80), aggregate('COMBO-A', 69)]) + .mockResolvedValueOnce([aggregate('COMBO-C', 100)]), + } + const loader = jest.fn().mockResolvedValue([ + { barcode: 'A', ownerLimitPrice: 50, costPrice: 10 }, + { barcode: 'B', ownerLimitPrice: 19, costPrice: 10 }, + ]) + let now = 1_000 + const service = new SupplyChainComboLimitService(repository, loader, () => now) + + const first = await service.listPage({ page: 1, pageSize: 50 }) + now += 1_000 + const cached = await service.listPage({ page: 1, pageSize: 50 }) + + expect(first.rows.map((row) => row.comboCode)).toEqual(['COMBO-A', 'COMBO-B']) + expect(cached.rows).toEqual(first.rows) + expect(loader).toHaveBeenCalledTimes(1) + expect(repository.listCompleteComboLimits).toHaveBeenCalledTimes(1) + + const refreshed = await service.listPage({ page: 1, pageSize: 50, forceRefresh: true }) + expect(refreshed.rows.map((row) => row.comboCode)).toEqual(['COMBO-C']) + expect(loader).toHaveBeenCalledTimes(2) + }) + + it('builds options using the other active filters but not the current field', async () => { + const repository = { + listCompleteComboLimits: jest.fn().mockResolvedValue([ + aggregate('COMBO-A', 69), + aggregate('COMBO-B', 80), + aggregate('COMBO-C', 90), + ]), + } + const service = new SupplyChainComboLimitService( + repository, + jest.fn().mockResolvedValue([{ barcode: 'A', ownerLimitPrice: 50, costPrice: 10 }]), + () => 1_000, + ) + + const result = await service.listOptions({ + field: 'comboCode', + filters: { + comboCode: ['COMBO-A'], + ownerLimitPrice: ['80.00'], + }, + }) + + expect(result.values).toEqual(['COMBO-B']) + }) +}) diff --git a/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts new file mode 100644 index 00000000..26e4df8b --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts @@ -0,0 +1,126 @@ +import { prisma } from '@/lib/prisma' +import { + buildComboLimitRow, + comboLimitOptions, + filterComboLimitRows, + paginateComboLimitRows, + sortComboLimitRows, + type ComboLimitAggregate, + type ComboLimitFieldKey, + type ComboLimitFilters, + type ComboLimitPage, + type ComboLimitRow, + type ComboLimitSort, +} from '@/lib/supply-chain-combo-limit-price' +import { + jstComboLimitSourceRepository, + type ComboLimitPricedProduct, +} from '@/server/repositories/jst-combo-limit-source.repository' + +type ComboSourceRepository = Pick +type PricedProductLoader = () => Promise + +const CACHE_TTL_MS = 5 * 60_000 + +type CacheEntry = { + expiresAt: number + rows: ComboLimitRow[] +} + +type PriceSourceRow = { + barcode: string + ownerLimitPrice: string | number + costPrice: string | null +} + +function parseCostPrice(value: string | null | undefined) { + const parsed = Number(String(value ?? '').replace(/[^\d.-]/g, '')) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + +async function loadPricedArchiveProducts(): Promise { + const rows = await prisma.$queryRaw` + SELECT + a."barcode" AS "barcode", + l."ownerLimitPrice"::text AS "ownerLimitPrice", + a."costPrice" AS "costPrice" + FROM "SupplyChainProductArchive" a + INNER JOIN "SupplyChainStyleLimitPrice" l ON l."styleCode" = a."sku" + WHERE l."ownerLimitPrice" > 0 + ` + return rows.flatMap((row) => { + const barcode = String(row.barcode ?? '').trim() + const ownerLimitPrice = Number(row.ownerLimitPrice) + if (!barcode || !Number.isFinite(ownerLimitPrice) || ownerLimitPrice <= 0) return [] + return [{ + barcode, + ownerLimitPrice, + costPrice: parseCostPrice(row.costPrice), + }] + }) +} + +export class SupplyChainComboLimitService { + private cache: CacheEntry | null = null + private inFlight: Promise | null = null + + constructor( + private readonly repository: ComboSourceRepository = jstComboLimitSourceRepository, + private readonly loadPricedProducts: PricedProductLoader = loadPricedArchiveProducts, + private readonly now: () => number = Date.now, + ) {} + + private async computeRows() { + const products = await this.loadPricedProducts() + const aggregates: ComboLimitAggregate[] = await this.repository.listCompleteComboLimits(products) + return aggregates + .map(buildComboLimitRow) + .sort((left, right) => left.comboCode.localeCompare(right.comboCode, 'zh-Hans-CN', { numeric: true })) + } + + private async loadRows(forceRefresh = false) { + const now = this.now() + if (!forceRefresh && this.cache && this.cache.expiresAt > now) return this.cache.rows + if (this.inFlight) return this.inFlight + this.inFlight = this.computeRows() + .then((rows) => { + this.cache = { rows, expiresAt: this.now() + CACHE_TTL_MS } + return rows + }) + .finally(() => { + this.inFlight = null + }) + return this.inFlight + } + + async listPage(input: { + page: number + pageSize: number + filters?: ComboLimitFilters + sort?: ComboLimitSort + forceRefresh?: boolean + }): Promise { + const rows = await this.loadRows(input.forceRefresh) + const filtered = filterComboLimitRows(rows, input.filters ?? {}) + return paginateComboLimitRows( + sortComboLimitRows(filtered, input.sort ?? null), + input.page, + input.pageSize, + ) + } + + async listOptions(input: { + field: ComboLimitFieldKey + q?: string + offset?: number + limit?: number + filters?: ComboLimitFilters + }) { + const rows = await this.loadRows() + const filters = { ...(input.filters ?? {}) } + delete filters[input.field] + return comboLimitOptions(filterComboLimitRows(rows, filters), input.field, input) + } +} + +export const supplyChainComboLimitService = new SupplyChainComboLimitService() From de26559e3e0e1290913a5e5595e000a48749af68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 13:53:39 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(supply-chain):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=BB=84=E5=90=88=E9=99=90=E4=BB=B7=E7=AD=9B=E9=80=89=E4=B8=8E?= =?UTF-8?q?=E8=BF=90=E8=90=A5=E8=B4=9F=E8=B4=A3=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../management/custom-table/ComboLimitSection.tsx | 12 +++++++----- .../custom-table/CustomTableView.test.tsx | 7 ++++++- .../lib/supply-chain-combo-limit-price.test.ts | 8 ++++++++ .../src/lib/supply-chain-combo-limit-price.ts | 4 ++++ .../jst-combo-limit-source.repository.ts | 15 +++++++++++++++ .../supply-chain-combo-limit.service.test.ts | 7 ++++--- .../services/supply-chain-combo-limit.service.ts | 3 +++ 7 files changed, 47 insertions(+), 9 deletions(-) diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx index 8eb23411..ebbdc3ff 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx @@ -28,11 +28,13 @@ const COMBO_COLUMNS: LimitTableColumn[] = COMBO_LIMIT_FIELDS.map((field) => ({ numeric: field.numeric, })) -const COMBO_FILTER_FIELDS: LimitTableFilterField[] = COMBO_LIMIT_FIELDS.map((field) => ({ - key: field.key, - label: field.label, - optionsEndpoint: OPTIONS_ENDPOINT, -})) +const COMBO_FILTER_FIELDS: LimitTableFilterField[] = COMBO_LIMIT_FIELDS + .filter((field) => field.key === 'comboCode') + .map((field) => ({ + key: field.key, + label: field.label, + optionsEndpoint: OPTIONS_ENDPOINT, + })) const COLUMN_MAP = new Map(COMBO_COLUMNS.map((column) => [column.key, column])) const FIELD_LABEL_MAP = new Map(COMBO_LIMIT_FIELDS.map((field) => [field.key, field.label])) diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx index 3fbf50ca..a4cfd966 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx @@ -180,6 +180,7 @@ function mockLimitManagementFetch(rows: SupplyChainProductArchiveRow[]) { data: { rows: [{ comboCode: 'COMBO-AB', + opsHead: '运营甲', ownerLimitPrice: '69.00', nonOwnerLimitPriceNew: '79.00', jxLimitPrice: '50.00', @@ -263,10 +264,14 @@ describe('限价管理', () => { fireEvent.click(screen.getByRole('button', { name: '组合限价' })) expect(await screen.findByText('COMBO-AB')).toBeInTheDocument() - for (const label of ['组合编码', '负责人限价', '非负责人限价', '京喜限价', '阿里页面限价', '分销商89折', '分销商85折', '分销商8折']) { + for (const label of ['组合编码', '运营负责人', '负责人限价', '非负责人限价', '京喜限价', '阿里页面限价', '分销商89折', '分销商85折', '分销商8折']) { expect(screen.getByRole('columnheader', { name: new RegExp(`^${label}`) })).toBeInTheDocument() } expect(screen.getByText('69.00')).toBeInTheDocument() + expect(screen.getByText('运营甲')).toBeInTheDocument() + expect(document.querySelectorAll('[data-limit-filter]')).toHaveLength(1) + const columnLabels = screen.getAllByRole('columnheader').map((cell) => (cell.textContent ?? '').replace(/[↕↑↓▾]/g, '').trim()) + expect(columnLabels.indexOf('运营负责人')).toBe(columnLabels.indexOf('组合编码') + 1) expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument() }) diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts index e5f75e3c..6a45fbc5 100644 --- a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts @@ -10,6 +10,7 @@ describe('supply-chain combo limit price', () => { it('formats the A(50) + B(19) combination as 69.00 and derives the other price fields', () => { const row = buildComboLimitRow({ combineCode: 'COMBO-AB', + opsHead: '运营甲、运营乙', itemCount: 2, costCount: 2, ownerTotal: 69, @@ -19,6 +20,7 @@ describe('supply-chain combo limit price', () => { expect(row).toEqual({ comboCode: 'COMBO-AB', + opsHead: '运营甲、运营乙', ownerLimitPrice: '69.00', nonOwnerLimitPriceNew: '79.00', jxLimitPrice: '50.00', @@ -32,6 +34,7 @@ describe('supply-chain combo limit price', () => { it('leaves Jingxi blank when any child product has no cost price', () => { const row = buildComboLimitRow({ combineCode: 'COMBO-MISSING-COST', + opsHead: '运营甲', itemCount: 3, costCount: 2, ownerTotal: 120, @@ -47,6 +50,7 @@ describe('supply-chain combo limit price', () => { const rows = [ buildComboLimitRow({ combineCode: 'COMBO-2', + opsHead: '运营乙', itemCount: 1, costCount: 1, ownerTotal: 90, @@ -55,6 +59,7 @@ describe('supply-chain combo limit price', () => { }), buildComboLimitRow({ combineCode: 'COMBO-1', + opsHead: '运营甲', itemCount: 1, costCount: 1, ownerTotal: 50, @@ -63,6 +68,7 @@ describe('supply-chain combo limit price', () => { }), buildComboLimitRow({ combineCode: 'OTHER', + opsHead: '运营丙', itemCount: 1, costCount: 1, ownerTotal: 10, @@ -88,6 +94,7 @@ describe('supply-chain combo limit price', () => { const rows = [ buildComboLimitRow({ combineCode: 'COMBO-AB', + opsHead: '运营甲', itemCount: 2, costCount: 2, ownerTotal: 69, @@ -96,6 +103,7 @@ describe('supply-chain combo limit price', () => { }), buildComboLimitRow({ combineCode: 'COMBO-AC', + opsHead: '运营乙', itemCount: 2, costCount: 2, ownerTotal: 80, diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts index 2582e523..0a3f31f6 100644 --- a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts @@ -1,5 +1,6 @@ export const COMBO_LIMIT_FIELDS = [ { key: 'comboCode', label: '组合编码', numeric: false }, + { key: 'opsHead', label: '运营负责人', numeric: false }, { key: 'ownerLimitPrice', label: '负责人限价', numeric: true }, { key: 'nonOwnerLimitPriceNew', label: '非负责人限价', numeric: true }, { key: 'jxLimitPrice', label: '京喜限价', numeric: true }, @@ -22,6 +23,8 @@ export type ComboLimitSort = { export type ComboLimitAggregate = { combineCode: string + /** 同一组合的多个子项可能由不同运营负责,去重后用「、」连接 */ + opsHead: string itemCount: number costCount: number ownerTotal: number @@ -77,6 +80,7 @@ export function buildComboLimitRow(input: ComboLimitAggregate): ComboLimitRow { : null return { comboCode: input.combineCode, + opsHead: input.opsHead, ownerLimitPrice: formatComboLimitPrice(input.ownerTotal), nonOwnerLimitPriceNew: formatComboLimitPrice(nonOwnerTotal), jxLimitPrice: formatComboLimitPrice(jxTotal), diff --git a/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts b/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts index 316e9f13..d3f75d1c 100644 --- a/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts +++ b/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts @@ -3,6 +3,7 @@ import type { ComboLimitAggregate } from '@/lib/supply-chain-combo-limit-price' export type ComboLimitPricedProduct = { barcode: string + opsHead: string ownerLimitPrice: number costPrice: number | null } @@ -52,6 +53,7 @@ class JstComboLimitSourceRepository { .input('products', sql.NVarChar(sql.MAX), JSON.stringify(products)) .query<{ combineCode: string + opsHeadList: string | null itemCount: number costCount: number ownerTotal: number @@ -61,11 +63,13 @@ class JstComboLimitSourceRepository { WITH prices AS ( SELECT barcode, + opsHead, ownerPrice, costPrice FROM OPENJSON(@products) WITH ( barcode nvarchar(120) '$.barcode', + opsHead nvarchar(60) '$.opsHead', ownerPrice decimal(18,4) '$.ownerLimitPrice', costPrice decimal(18,4) '$.costPrice' ) @@ -83,6 +87,10 @@ class JstComboLimitSourceRepository { i.combine_sku_id, COUNT(*) AS matchedCount, COUNT(p.costPrice) AS costCount, + STRING_AGG( + CASE WHEN LTRIM(RTRIM(p.opsHead)) <> '' THEN p.opsHead END, + N'、' + ) AS opsHeadList, SUM(p.ownerPrice * i.qty) AS ownerTotal, SUM(( p.ownerPrice @@ -98,6 +106,7 @@ class JstComboLimitSourceRepository { ) SELECT CAST(cp.combine_sku_id AS nvarchar(200)) AS combineCode, + CAST(cp.opsHeadList AS nvarchar(1000)) AS opsHeadList, CAST(t.itemCount AS int) AS itemCount, CAST(cp.costCount AS int) AS costCount, CAST(cp.ownerTotal AS decimal(28,4)) AS ownerTotal, @@ -109,6 +118,12 @@ class JstComboLimitSourceRepository { `) return result.recordset.map((row) => ({ combineCode: String(row.combineCode ?? '').trim(), + opsHead: [...new Set( + String(row.opsHeadList ?? '') + .split('、') + .map((value) => value.trim()) + .filter(Boolean), + )].join('、'), itemCount: Number(row.itemCount), costCount: Number(row.costCount), ownerTotal: Number(row.ownerTotal), diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts index e4e6d0c1..0360da32 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts @@ -9,6 +9,7 @@ import { SupplyChainComboLimitService } from '../supply-chain-combo-limit.servic function aggregate(combineCode: string, ownerTotal: number) { return { combineCode, + opsHead: '运营甲', itemCount: 2, costCount: 2, ownerTotal, @@ -25,8 +26,8 @@ describe('SupplyChainComboLimitService', () => { .mockResolvedValueOnce([aggregate('COMBO-C', 100)]), } const loader = jest.fn().mockResolvedValue([ - { barcode: 'A', ownerLimitPrice: 50, costPrice: 10 }, - { barcode: 'B', ownerLimitPrice: 19, costPrice: 10 }, + { barcode: 'A', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }, + { barcode: 'B', opsHead: '运营乙', ownerLimitPrice: 19, costPrice: 10 }, ]) let now = 1_000 const service = new SupplyChainComboLimitService(repository, loader, () => now) @@ -55,7 +56,7 @@ describe('SupplyChainComboLimitService', () => { } const service = new SupplyChainComboLimitService( repository, - jest.fn().mockResolvedValue([{ barcode: 'A', ownerLimitPrice: 50, costPrice: 10 }]), + jest.fn().mockResolvedValue([{ barcode: 'A', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }]), () => 1_000, ) diff --git a/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts index 26e4df8b..e2f3683e 100644 --- a/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts @@ -29,6 +29,7 @@ type CacheEntry = { type PriceSourceRow = { barcode: string + opsHead: string ownerLimitPrice: string | number costPrice: string | null } @@ -42,6 +43,7 @@ async function loadPricedArchiveProducts(): Promise { const rows = await prisma.$queryRaw` SELECT a."barcode" AS "barcode", + a."opsHead" AS "opsHead", l."ownerLimitPrice"::text AS "ownerLimitPrice", a."costPrice" AS "costPrice" FROM "SupplyChainProductArchive" a @@ -54,6 +56,7 @@ async function loadPricedArchiveProducts(): Promise { if (!barcode || !Number.isFinite(ownerLimitPrice) || ownerLimitPrice <= 0) return [] return [{ barcode, + opsHead: String(row.opsHead ?? '').trim(), ownerLimitPrice, costPrice: parseCostPrice(row.costPrice), }] From 05e1f94e46d896010de0f7190bdcef48f04855c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 13:58:44 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix(supply-chain):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=BB=84=E5=90=88=E9=99=90=E4=BB=B7=E7=AD=9B=E9=80=89=E4=BA=A4?= =?UTF-8?q?=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../management/custom-table/ComboLimitSection.tsx | 2 +- .../custom-table/CustomTableView.test.tsx | 3 ++- .../custom-table/LimitTableHeaderCell.tsx | 9 +++++++-- .../lib/supply-chain-combo-limit-price.test.ts | 11 ++++++++--- .../src/lib/supply-chain-combo-limit-price.ts | 15 +++++++++++++-- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx index ebbdc3ff..aeb8993c 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx @@ -29,7 +29,7 @@ const COMBO_COLUMNS: LimitTableColumn[] = COMBO_LIMIT_FIELDS.map((field) => ({ })) const COMBO_FILTER_FIELDS: LimitTableFilterField[] = COMBO_LIMIT_FIELDS - .filter((field) => field.key === 'comboCode') + .filter((field) => field.key === 'comboCode' || field.key === 'opsHead') .map((field) => ({ key: field.key, label: field.label, diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx index a4cfd966..36643a75 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx @@ -269,7 +269,8 @@ describe('限价管理', () => { } expect(screen.getByText('69.00')).toBeInTheDocument() expect(screen.getByText('运营甲')).toBeInTheDocument() - expect(document.querySelectorAll('[data-limit-filter]')).toHaveLength(1) + expect(document.querySelectorAll('[data-limit-filter]')).toHaveLength(2) + expect(screen.getByRole('button', { name: '筛选运营负责人' })).toBeInTheDocument() const columnLabels = screen.getAllByRole('columnheader').map((cell) => (cell.textContent ?? '').replace(/[↕↑↓▾]/g, '').trim()) expect(columnLabels.indexOf('运营负责人')).toBe(columnLabels.indexOf('组合编码') + 1) expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument() diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx index 0a076e54..62529091 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx @@ -114,13 +114,18 @@ export function LimitTableHeaderCell({ if (filterButtonRef.current?.contains(target)) return onCloseFilter?.() } + const onScroll = (event: Event) => { + const target = event.target + if (target instanceof Node && filterPanelRef.current?.contains(target)) return + onCloseFilter?.() + } const close = () => onCloseFilter?.() document.addEventListener('mousedown', onMouseDown) - window.addEventListener('scroll', close, true) + window.addEventListener('scroll', onScroll, true) window.addEventListener('resize', close) return () => { document.removeEventListener('mousedown', onMouseDown) - window.removeEventListener('scroll', close, true) + window.removeEventListener('scroll', onScroll, true) window.removeEventListener('resize', close) } }, [filterOpen, onCloseFilter]) diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts index 6a45fbc5..aa44a05f 100644 --- a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts @@ -88,13 +88,14 @@ describe('supply-chain combo limit price', () => { expect(page.rows.map((row) => row.comboCode)).toEqual(['COMBO-2']) expect(page.pageInfo).toEqual({ page: 1, pageSize: 1, total: 2, totalPages: 2 }) + expect(filterComboLimitRows(rows, { opsHead: ['运营甲'] }).map((row) => row.comboCode)).toEqual(['COMBO-1']) }) - it('creates searchable filter options from distinct values', () => { + it('creates searchable filter options and splits every owner in a multi-owner combo', () => { const rows = [ buildComboLimitRow({ combineCode: 'COMBO-AB', - opsHead: '运营甲', + opsHead: '运营甲、运营乙', itemCount: 2, costCount: 2, ownerTotal: 69, @@ -103,7 +104,7 @@ describe('supply-chain combo limit price', () => { }), buildComboLimitRow({ combineCode: 'COMBO-AC', - opsHead: '运营乙', + opsHead: '运营丙', itemCount: 2, costCount: 2, ownerTotal: 80, @@ -117,5 +118,9 @@ describe('supply-chain combo limit price', () => { expect(result.values).toEqual(['COMBO-AB']) expect(result.total).toBe(1) expect(result.hasMore).toBe(false) + const ownerOptions = comboLimitOptions(rows, 'opsHead', {}).values + expect(ownerOptions).toHaveLength(3) + expect(ownerOptions).toEqual(expect.arrayContaining(['运营甲', '运营乙', '运营丙'])) + expect(filterComboLimitRows(rows, { opsHead: ['运营乙'] }).map((row) => row.comboCode)).toEqual(['COMBO-AB']) }) }) diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts index 0a3f31f6..45228a1c 100644 --- a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts @@ -98,7 +98,15 @@ export function filterComboLimitRows(rows: ComboLimitRow[], filters: ComboLimitF return normalized.length ? [{ field, values: new Set(normalized) }] : [] }) if (!active.length) return rows - return rows.filter((row) => active.every(({ field, values }) => values.has(row[field]))) + return rows.filter((row) => active.every(({ field, values }) => { + if (field === 'opsHead') { + return row.opsHead + .split('、') + .map((value) => value.trim()) + .some((value) => values.has(value)) + } + return values.has(row[field]) + })) } export function sortComboLimitRows(rows: ComboLimitRow[], sort: ComboLimitSort) { @@ -142,7 +150,10 @@ export function comboLimitOptions( input: { q?: string; offset?: number; limit?: number }, ): ComboLimitOptionResult { const q = input.q?.trim().toLocaleLowerCase('zh-Hans-CN') ?? '' - const allValues = [...new Set(rows.map((row) => row[field]).filter(Boolean))] + const fieldValues = field === 'opsHead' + ? rows.flatMap((row) => row.opsHead.split('、').map((value) => value.trim()).filter(Boolean)) + : rows.map((row) => row[field]).filter(Boolean) + const allValues = [...new Set(fieldValues)] .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN', { numeric: true })) const matched = q ? allValues.filter((value) => value.toLocaleLowerCase('zh-Hans-CN').includes(q)) From a843b643ed6acc7879d2dfda8e8f751a6a661f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 14:02:16 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix(supply-chain):=20=E6=94=BE=E8=A1=8C?= =?UTF-8?q?=E7=BB=84=E5=90=88=E8=BF=90=E8=90=A5=E8=B4=9F=E8=B4=A3=E4=BA=BA?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../supply-chain/limit-management/combo-limits/options/route.ts | 1 + .../app/api/supply-chain/limit-management/combo-limits/route.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts index d316f310..da1243ed 100644 --- a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts @@ -23,6 +23,7 @@ const querySchema = z.object({ const filtersSchema = z.object({ comboCode: z.array(z.string().max(200)).max(200).optional(), + opsHead: z.array(z.string().max(60)).max(200).optional(), ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(), nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(), jxLimitPrice: z.array(z.string().max(40)).max(200).optional(), diff --git a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts index 30ff68a0..2c566b4b 100644 --- a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts @@ -22,6 +22,7 @@ const listQuerySchema = z.object({ const filtersSchema = z.object({ comboCode: z.array(z.string().max(200)).max(200).optional(), + opsHead: z.array(z.string().max(60)).max(200).optional(), ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(), nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(), jxLimitPrice: z.array(z.string().max(40)).max(200).optional(), From 9360c553a22061aace3da4c8a521bdc22c9c7e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 14:07:06 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix(supply-chain):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=BB=84=E5=90=88=E5=8D=95=E8=A1=8C=E8=A1=A8=E5=A4=B4=E9=81=AE?= =?UTF-8?q?=E6=8C=A1=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../management/custom-table/ComboLimitSection.tsx | 1 + .../management/custom-table/CustomTableView.test.tsx | 1 + .../management/custom-table/LimitTableHeaderCell.tsx | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx index aeb8993c..eff8ed8c 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/ComboLimitSection.tsx @@ -248,6 +248,7 @@ export function ComboLimitSection({ numeric={column.numeric} hint="组合限价 = 各子商品限价 × 组合明细数量后相加" sortScope="all" + stickyTop={0} sort={sort ? { key: sort.field, direction: sort.direction } : null} menuOpen={openSortKey === column.key} onToggleMenu={() => setOpenSortKey((current) => (current === column.key ? null : column.key))} diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx index 36643a75..cde9c190 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx @@ -271,6 +271,7 @@ describe('限价管理', () => { expect(screen.getByText('运营甲')).toBeInTheDocument() expect(document.querySelectorAll('[data-limit-filter]')).toHaveLength(2) expect(screen.getByRole('button', { name: '筛选运营负责人' })).toBeInTheDocument() + expect((screen.getByRole('columnheader', { name: /^组合编码/ }) as HTMLElement).style.top).toBe('0px') const columnLabels = screen.getAllByRole('columnheader').map((cell) => (cell.textContent ?? '').replace(/[↕↑↓▾]/g, '').trim()) expect(columnLabels.indexOf('运营负责人')).toBe(columnLabels.indexOf('组合编码') + 1) expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument() diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx index 62529091..46434218 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitTableHeaderCell.tsx @@ -29,6 +29,8 @@ type Props = { numeric?: boolean hint?: string sortScope?: 'page' | 'all' + /** 单行表头传 0;双行表头(含分组行)默认沿用 CSS 的 30px */ + stickyTop?: number onToggleMenu: () => void onCloseMenu: () => void onSort: (direction: 'asc' | 'desc') => void @@ -52,6 +54,7 @@ export function LimitTableHeaderCell({ numeric, hint, sortScope = 'page', + stickyTop, onToggleMenu, onCloseMenu, onSort, @@ -135,6 +138,7 @@ export function LimitTableHeaderCell({ return ( Date: Mon, 19 Oct 2026 14:10:57 +0800 Subject: [PATCH 08/12] =?UTF-8?q?style(supply-chain):=20=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E8=A1=A8=E5=A4=B4=E5=88=97=E5=AE=BD=E6=8B=96=E6=8B=BD=E7=83=AD?= =?UTF-8?q?=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../supply-chain/management/custom-table/limit-price.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-price.module.css b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-price.module.css index 424e2ac6..bbb1c26b 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-price.module.css +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/limit-price.module.css @@ -79,7 +79,7 @@ .sortMenuDivider{height:1px;margin:4px 6px;background:#f0f0f1} /* 表头筛选浮层与列宽拖拽手柄 */ -.headResizeHandle{position:absolute;top:0;right:-2px;bottom:0;z-index:8;width:5px;cursor:col-resize} +.headResizeHandle{position:absolute;top:0;right:-13px;bottom:0;z-index:8;width:5px;cursor:col-resize} .headResizeHandle:hover{background:rgba(24,24,27,.16)} .headerFilterPanel{position:fixed;z-index:130;display:flex;width:240px;max-height:340px;flex-direction:column;border:1px solid #e4e4e7;border-radius:6px;background:#fff;box-shadow:0 14px 36px rgba(24,24,27,.16);padding:6px} .headerFilterTitle{flex:0 0 auto;padding:2px 6px 6px;color:#71717a;font-size:11px;font-weight:600} From c62d951228f72fc0da87f51ff4703030d96b2458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 14:15:05 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(supply-chain):=20=E9=99=90=E4=BB=B7?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=80=99=E9=80=89=E5=80=BC=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../custom-table/CustomTableView.test.tsx | 19 +++++++++++++++++++ .../management/custom-table/LimitPriceTab.tsx | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx index cde9c190..4ca559fc 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx @@ -277,6 +277,25 @@ describe('限价管理', () => { expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument() }) + it('条码候选值跟随已选商品状态,避免选出与明细条件冲突的条码', async () => { + render() + await screen.findByText('测试岩板餐桌') + + fireEvent.click(screen.getByRole('button', { name: '筛选条码', exact: true })) + await screen.findByRole('dialog', { name: '筛选条码' }) + + let optionRequest = '' + await waitFor(() => { + optionRequest = (global.fetch as jest.Mock).mock.calls + .map((call) => String(call[0])) + .filter((url) => url.includes('/limit-management/options') && url.includes('field=barcode')) + .at(-1) ?? '' + expect(optionRequest).not.toBe('') + }) + const filters = new URL(optionRequest, 'http://localhost').searchParams.get('filters') ?? '{}' + expect(JSON.parse(filters)).toEqual({ productStatus: ['销售'] }) + }) + it('限价页按《限价抬头及逻辑》显示分组表头,并展示产品全量档案数据', async () => { render() diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx index 4ba17a38..17fe0734 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitPriceTab.tsx @@ -430,6 +430,10 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c const visibleFilterFields = settings.filterFields .map((key) => LIMIT_TABLE_FILTER_FIELD_MAP.get(key)) .filter((field): field is NonNullable => Boolean(field)) + const filterContext = useMemo( + () => filters as Record, + [filters], + ) return (
@@ -526,6 +530,7 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c key={field.key} field={field} selected={(filters as Record)[field.key] ?? []} + contextFilters={filterContext} onChange={(values) => updateFilterValue(field.key, values)} /> ))} @@ -622,6 +627,7 @@ export function LimitPriceTab({ userId, canExport = false }: { userId: string; c filterValues={column.filterKey ? ((filters as Record)[column.filterKey] ?? []) : undefined} + filterContext={filterContext} filterOpen={openFilterKey === column.key} onToggleFilter={() => setOpenFilterKey((current) => (current === column.key ? null : column.key))} onCloseFilter={() => setOpenFilterKey(null)} From 2b4eb17ec7b75e23060eef9e34604e40455a0c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 14:17:20 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(supply-chain):=20=E6=8E=92=E9=99=A4?= =?UTF-8?q?=E5=81=9C=E4=BA=A7=E9=80=80=E5=B8=82=E7=BB=84=E5=90=88=E9=99=90?= =?UTF-8?q?=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../supply-chain-combo-limit-price.test.ts | 8 +++++++ .../src/lib/supply-chain-combo-limit-price.ts | 7 ++++++ .../jst-combo-limit-source.repository.ts | 1 + .../supply-chain-combo-limit.service.test.ts | 24 ++++++++++++++++--- .../supply-chain-combo-limit.service.ts | 7 +++++- 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts index aa44a05f..aa484e0e 100644 --- a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.test.ts @@ -2,11 +2,19 @@ import { buildComboLimitRow, comboLimitOptions, filterComboLimitRows, + isComboComponentEligible, paginateComboLimitRows, sortComboLimitRows, } from './supply-chain-combo-limit-price' describe('supply-chain combo limit price', () => { + it('excludes products that are stopped or discontinued from combinations', () => { + expect(isComboComponentEligible('销售')).toBe(true) + expect(isComboComponentEligible(' 新品 ')).toBe(true) + expect(isComboComponentEligible('停产')).toBe(false) + expect(isComboComponentEligible(' 退市 ')).toBe(false) + }) + it('formats the A(50) + B(19) combination as 69.00 and derives the other price fields', () => { const row = buildComboLimitRow({ combineCode: 'COMBO-AB', diff --git a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts index 45228a1c..057fa35f 100644 --- a/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts +++ b/furniture-3d-system/src/lib/supply-chain-combo-limit-price.ts @@ -10,6 +10,13 @@ export const COMBO_LIMIT_FIELDS = [ { key: 'distributorPrice80', label: '分销商8折', numeric: true }, ] as const +const COMBO_EXCLUDED_PRODUCT_STATUSES = new Set(['停产', '退市']) + +/** 组合中任一子商品停产或退市时,整个组合不再展示限价。 */ +export function isComboComponentEligible(productStatus: string) { + return !COMBO_EXCLUDED_PRODUCT_STATUSES.has(productStatus.trim()) +} + export type ComboLimitFieldKey = (typeof COMBO_LIMIT_FIELDS)[number]['key'] export type ComboLimitRow = Record diff --git a/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts b/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts index d3f75d1c..1fe43806 100644 --- a/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts +++ b/furniture-3d-system/src/server/repositories/jst-combo-limit-source.repository.ts @@ -3,6 +3,7 @@ import type { ComboLimitAggregate } from '@/lib/supply-chain-combo-limit-price' export type ComboLimitPricedProduct = { barcode: string + productStatus: string opsHead: string ownerLimitPrice: number costPrice: number | null diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts index 0360da32..290c6287 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-combo-limit.service.test.ts @@ -26,8 +26,8 @@ describe('SupplyChainComboLimitService', () => { .mockResolvedValueOnce([aggregate('COMBO-C', 100)]), } const loader = jest.fn().mockResolvedValue([ - { barcode: 'A', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }, - { barcode: 'B', opsHead: '运营乙', ownerLimitPrice: 19, costPrice: 10 }, + { barcode: 'A', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }, + { barcode: 'B', productStatus: '销售', opsHead: '运营乙', ownerLimitPrice: 19, costPrice: 10 }, ]) let now = 1_000 const service = new SupplyChainComboLimitService(repository, loader, () => now) @@ -56,7 +56,7 @@ describe('SupplyChainComboLimitService', () => { } const service = new SupplyChainComboLimitService( repository, - jest.fn().mockResolvedValue([{ barcode: 'A', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }]), + jest.fn().mockResolvedValue([{ barcode: 'A', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }]), () => 1_000, ) @@ -70,4 +70,22 @@ describe('SupplyChainComboLimitService', () => { expect(result.values).toEqual(['COMBO-B']) }) + + it('removes stopped and discontinued child products before building combinations', async () => { + const repository = { + listCompleteComboLimits: jest.fn().mockResolvedValue([]), + } + const loader = jest.fn().mockResolvedValue([ + { barcode: 'ACTIVE', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }, + { barcode: 'STOPPED', productStatus: '停产', opsHead: '运营乙', ownerLimitPrice: 19, costPrice: 10 }, + { barcode: 'DISCONTINUED', productStatus: '退市', opsHead: '运营丙', ownerLimitPrice: 20, costPrice: 10 }, + ]) + const service = new SupplyChainComboLimitService(repository, loader, () => 1_000) + + await service.listPage({ page: 1, pageSize: 50 }) + + expect(repository.listCompleteComboLimits).toHaveBeenCalledWith([ + { barcode: 'ACTIVE', productStatus: '销售', opsHead: '运营甲', ownerLimitPrice: 50, costPrice: 10 }, + ]) + }) }) diff --git a/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts index e2f3683e..8767cee2 100644 --- a/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-combo-limit.service.ts @@ -3,6 +3,7 @@ import { buildComboLimitRow, comboLimitOptions, filterComboLimitRows, + isComboComponentEligible, paginateComboLimitRows, sortComboLimitRows, type ComboLimitAggregate, @@ -29,6 +30,7 @@ type CacheEntry = { type PriceSourceRow = { barcode: string + productStatus: string opsHead: string ownerLimitPrice: string | number costPrice: string | null @@ -43,6 +45,7 @@ async function loadPricedArchiveProducts(): Promise { const rows = await prisma.$queryRaw` SELECT a."barcode" AS "barcode", + a."productStatus" AS "productStatus", a."opsHead" AS "opsHead", l."ownerLimitPrice"::text AS "ownerLimitPrice", a."costPrice" AS "costPrice" @@ -56,6 +59,7 @@ async function loadPricedArchiveProducts(): Promise { if (!barcode || !Number.isFinite(ownerLimitPrice) || ownerLimitPrice <= 0) return [] return [{ barcode, + productStatus: String(row.productStatus ?? '').trim(), opsHead: String(row.opsHead ?? '').trim(), ownerLimitPrice, costPrice: parseCostPrice(row.costPrice), @@ -74,7 +78,8 @@ export class SupplyChainComboLimitService { ) {} private async computeRows() { - const products = await this.loadPricedProducts() + const products = (await this.loadPricedProducts()) + .filter((product) => isComboComponentEligible(product.productStatus)) const aggregates: ComboLimitAggregate[] = await this.repository.listCompleteComboLimits(products) return aggregates .map(buildComboLimitRow) From c691669101b1f8cb2f85b32eb03c435b31d057bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Mon, 19 Oct 2026 14:29:51 +0800 Subject: [PATCH 11/12] =?UTF-8?q?feat(supply-chain):=20=E9=99=90=E4=BB=B7?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=94=AF=E6=8C=81=E6=89=B9=E9=87=8F=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../combo-limits/options/route.ts | 4 +-- .../limit-management/combo-limits/route.ts | 4 +-- .../custom-table/CustomTableView.test.tsx | 24 ++++++++++++++ .../custom-table/LimitFilterSelect.tsx | 32 ++++++++++++++++++- .../custom-table/limit-filter-batch.test.ts | 17 ++++++++++ .../custom-table/limit-filter-batch.ts | 11 +++++++ .../custom-table/limit-price.module.css | 7 ++++ 7 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 furniture-3d-system/src/app/supply-chain/management/custom-table/limit-filter-batch.test.ts create mode 100644 furniture-3d-system/src/app/supply-chain/management/custom-table/limit-filter-batch.ts diff --git a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts index da1243ed..a6264985 100644 --- a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/options/route.ts @@ -22,8 +22,8 @@ const querySchema = z.object({ }) const filtersSchema = z.object({ - comboCode: z.array(z.string().max(200)).max(200).optional(), - opsHead: z.array(z.string().max(60)).max(200).optional(), + comboCode: z.array(z.string().max(200)).max(20000).optional(), + opsHead: z.array(z.string().max(60)).max(20000).optional(), ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(), nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(), jxLimitPrice: z.array(z.string().max(40)).max(200).optional(), diff --git a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts index 2c566b4b..e12eac90 100644 --- a/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/limit-management/combo-limits/route.ts @@ -21,8 +21,8 @@ const listQuerySchema = z.object({ }) const filtersSchema = z.object({ - comboCode: z.array(z.string().max(200)).max(200).optional(), - opsHead: z.array(z.string().max(60)).max(200).optional(), + comboCode: z.array(z.string().max(200)).max(20000).optional(), + opsHead: z.array(z.string().max(60)).max(20000).optional(), ownerLimitPrice: z.array(z.string().max(40)).max(200).optional(), nonOwnerLimitPriceNew: z.array(z.string().max(40)).max(200).optional(), jxLimitPrice: z.array(z.string().max(40)).max(200).optional(), diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx index 4ca559fc..a295669f 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/CustomTableView.test.tsx @@ -272,6 +272,8 @@ describe('限价管理', () => { expect(document.querySelectorAll('[data-limit-filter]')).toHaveLength(2) expect(screen.getByRole('button', { name: '筛选运营负责人' })).toBeInTheDocument() expect((screen.getByRole('columnheader', { name: /^组合编码/ }) as HTMLElement).style.top).toBe('0px') + fireEvent.click(screen.getByRole('button', { name: '筛选组合编码', exact: true })) + expect(await screen.findByRole('button', { name: '批量查询' })).toBeInTheDocument() const columnLabels = screen.getAllByRole('columnheader').map((cell) => (cell.textContent ?? '').replace(/[↕↑↓▾]/g, '').trim()) expect(columnLabels.indexOf('运营负责人')).toBe(columnLabels.indexOf('组合编码') + 1) expect(screen.queryByRole('button', { name: '导出明细' })).not.toBeInTheDocument() @@ -296,6 +298,28 @@ describe('限价管理', () => { expect(JSON.parse(filters)).toEqual({ productStatus: ['销售'] }) }) + it('support batch paste queries and apply all pasted values together', async () => { + render() + await screen.findByText('测试岩板餐桌') + + fireEvent.click(screen.getByRole('button', { name: '筛选条码', exact: true })) + const dialog = await screen.findByRole('dialog', { name: '筛选条码' }) + fireEvent.click(within(dialog).getByRole('button', { name: '批量查询' })) + fireEvent.change(within(dialog).getByRole('textbox', { name: '批量查询条码' }), { + target: { value: 'BAR-001\nBAR-002,BAR-003' }, + }) + fireEvent.click(within(dialog).getByRole('button', { name: '查询' })) + + await waitFor(() => { + const latestProductRequest = (global.fetch as jest.Mock).mock.calls + .map((call) => String(call[0])) + .filter((url) => url.includes('/limit-management/products')) + .at(-1) ?? '' + const filters = new URL(latestProductRequest, 'http://localhost').searchParams.get('filters') ?? '{}' + expect((JSON.parse(filters) as { barcode?: string[] }).barcode).toEqual(['BAR-001', 'BAR-002', 'BAR-003']) + }) + }) + it('限价页按《限价抬头及逻辑》显示分组表头,并展示产品全量档案数据', async () => { render() diff --git a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx index 212dfadb..74da4fba 100644 --- a/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx +++ b/furniture-3d-system/src/app/supply-chain/management/custom-table/LimitFilterSelect.tsx @@ -5,6 +5,7 @@ import { ChevronDown } from 'lucide-react' import type { ApiResponse } from '@/server/core/types' import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE } from '@/lib/supply-chain-product-archive' import type { LimitTableFilterField } from './useLimitTableSettings' +import { parseBatchFilterValues } from './limit-filter-batch' import styles from './limit-price.module.css' const OPTION_LIMIT = 200 @@ -30,6 +31,8 @@ export function FilterOptionPanel({ field, selected, onChange, contextFilters, o const [search, setSearch] = useState('') const [options, setOptions] = useState(field.fixedOptions ?? []) const [loading, setLoading] = useState(false) + const [batchOpen, setBatchOpen] = useState(false) + const [batchText, setBatchText] = useState('') useEffect(() => { if (field.fixedOptions) return @@ -89,6 +92,15 @@ export function FilterOptionPanel({ field, selected, onChange, contextFilters, o ) } + const applyBatch = () => { + const values = parseBatchFilterValues(batchText) + .map((value) => value === '空值' ? PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE : value) + if (!values.length) return + onChange([...new Set([...selected, ...values])].slice(0, 20000)) + setBatchText('') + setBatchOpen(false) + } + return ( <> setSearch(event.target.value)} /> + {batchOpen && ( +
+