fix(supply-chain): 限价明细「负责人限价」筛选补上候选值与筛选逻辑

- 负责人限价是 SupplyChainStyleLimitPrice.ownerLimitPrice 的派生列(按货号),此前 option 接口没有对应分支,
  下拉框显示「没有可选值」、选了也不生效
- 候选值:无其他筛选时取限价表的全部去重价格(两位小数,与界面一致);有筛选时只取当前结果集里货号的限价;
  无流程限价的商品提供「空值」选项
- 筛选:选中的价格先经 ROUND(price,2) 换回货号再按 sku 过滤;选中的价格无对应货号返回空结果;
  「空值」= 货号不在限价表里
- 补 3 条单测(价格→货号、无匹配、空值)
This commit is contained in:
居居 2026-10-19 13:15:34 +08:00 committed by WuYongJie
parent ee983673d5
commit c104a029c9
3 changed files with 94 additions and 1 deletions

View File

@ -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"]}}')
})
})

View File

@ -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<Array<{ styleCode: string }>>(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
}

View File

@ -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<Array<{ price: string }>>(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)