feat(consignment): 寄售仓库按寄卖分类筛选并支持动态规格过滤

This commit is contained in:
2026-11-06 16:12:26 +08:00
parent 1044948c99
commit f68375fa71
16 changed files with 1022 additions and 130 deletions
@@ -0,0 +1,66 @@
-- 144_consignment_inventory_category_backfill.sql
-- 寄售仓库按「寄卖配置分类」筛选的前置数据修复。
--
-- 背景:分类级寄卖配置(142)上线前入库的提报与库存没有写 category_id/profile_id,
-- 线上 784 条库存、877 条提报全部为空;不补的话「按分类筛选」在老数据上永远是空列表。
--
-- 口径:这批历史数据全部是苏尔南手枪(item_name 含「苏尔南」,spec_values_json 固定
-- system/serverZone/series/diamondLevel/brillianceCount 五个键),因此按该分类的
-- 已发布配置回填,幂等且只影响 category_id 为空的记录。
DO $migration$
DECLARE
v_category_id BIGINT;
v_profile_id BIGINT;
v_version_id BIGINT;
v_snapshot JSONB;
BEGIN
SELECT id INTO v_category_id
FROM work_categories
WHERE category_key = 'shouqiang'
LIMIT 1;
IF v_category_id IS NULL THEN
RETURN;
END IF;
SELECT p.id, p.current_version_id, v.config_json
INTO v_profile_id, v_version_id, v_snapshot
FROM consignment_profiles p
LEFT JOIN consignment_profile_versions v ON v.id = p.current_version_id
WHERE p.category_id = v_category_id
AND p.status = 'published'
LIMIT 1;
IF v_profile_id IS NULL THEN
RETURN;
END IF;
UPDATE consignment_inventories
SET category_id = v_category_id,
profile_id = COALESCE(profile_id, v_profile_id),
profile_version_id = COALESCE(profile_version_id, v_version_id),
profile_snapshot_json = COALESCE(profile_snapshot_json, v_snapshot)
WHERE category_id IS NULL
AND (
item_name LIKE '%苏尔南%'
OR (spec_values_json ? 'series' AND spec_values_json ? 'diamondLevel')
);
UPDATE consignment_submissions
SET category_id = v_category_id,
profile_id = COALESCE(profile_id, v_profile_id),
profile_version_id = COALESCE(profile_version_id, v_version_id),
profile_snapshot_json = COALESCE(profile_snapshot_json, v_snapshot)
WHERE category_id IS NULL
AND (
item_name LIKE '%苏尔南%'
OR (spec_values_json ? 'series' AND spec_values_json ? 'diamondLevel')
);
END $migration$;
CREATE INDEX IF NOT EXISTS idx_consignment_inventories_category
ON consignment_inventories (category_id, status);
COMMENT ON COLUMN consignment_inventories.category_id IS
'寄卖配置分类:寄售仓库按分类筛选的依据;142 之前的历史数据由 144 回填';
@@ -150,6 +150,58 @@ export type ConsignmentStatsSummary = {
bySeries: Array<{ series: string; diamond_level: number; count: number }>
}
/** 寄售仓库按寄卖配置动态规格筛选:key 走白名单,值一律参数绑定。 */
export type ConsignmentInventorySpecFilter = {
key: string
value: string
/** exact = 等值(选项/数字/日期);contains = 模糊包含(文本) */
match?: 'exact' | 'contains'
}
/**
* 内置规格 key → 库存旧列。
* 老库存的 spec_values_json 可能为空(动态规格上线前入库),此时按旧列兜底,避免筛不到历史货。
*/
const LEGACY_SPEC_COLUMN_BY_KEY: Record<string, string> = {
system: 'ci.system',
serverZone: 'ci.server_zone',
series: 'ci.series',
diamondLevel: 'ci.diamond_level',
brillianceCount: 'ci.brilliance_count',
}
const SPEC_FILTER_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,39}$/
/** 把动态规格筛选追加到 conditions(values 同步 push,保持占位符顺序)。 */
export function buildConsignmentSpecFilterConditions(
specFilters: ConsignmentInventorySpecFilter[] | undefined,
values: unknown[],
conditions: string[],
) {
for (const filter of specFilters || []) {
const key = String(filter?.key || '').trim()
const value = String(filter?.value ?? '').trim()
if (!key || !value || !SPEC_FILTER_KEY_PATTERN.test(key)) continue
values.push(key)
const keyIndex = values.length
if (filter.match === 'contains') {
values.push(`%${value}%`)
conditions.push(`ci.spec_values_json ->> $${keyIndex} ILIKE $${values.length}`)
continue
}
values.push(value)
const valueIndex = values.length
const legacyColumn = LEGACY_SPEC_COLUMN_BY_KEY[key]
conditions.push(
legacyColumn
? `COALESCE(NULLIF(ci.spec_values_json ->> $${keyIndex}, ''), ${legacyColumn}::text) = $${valueIndex}`
: `ci.spec_values_json ->> $${keyIndex} = $${valueIndex}`,
)
}
}
export async function listConsignmentInventories(filters: {
status?: string | undefined
buyerName?: string | undefined
@@ -165,6 +217,8 @@ export async function listConsignmentInventories(filters: {
keyword?: string | undefined
dateFrom?: string | undefined
dateTo?: string | undefined
/** 按寄卖配置动态规格筛选(key -> 选中值);key 为内置规格时回退库存旧列 */
specFilters?: ConsignmentInventorySpecFilter[] | undefined
page?: number | undefined
pageSize?: number | undefined
}): Promise<{ items: ConsignmentInventoryRow[]; total: number }> {
@@ -215,6 +269,7 @@ export async function listConsignmentInventories(filters: {
values.push(filters.allowMatching)
conditions.push(`ci.allow_matching = $${values.length}`)
}
buildConsignmentSpecFilterConditions(filters.specFilters, values, conditions)
if (filters.keyword) {
values.push(`%${filters.keyword.trim()}%`)
conditions.push(
@@ -290,21 +345,56 @@ export async function listConsignmentInventories(filters: {
return { items: listRes.rows, total }
}
export async function getConsignmentInventoryStats(): Promise<ConsignmentStatsSummary> {
export async function getConsignmentInventoryStats(
filters: {
categoryId?: number | undefined
specFilters?: ConsignmentInventorySpecFilter[] | undefined
dateFrom?: string | undefined
dateTo?: string | undefined
} = {},
): Promise<ConsignmentStatsSummary> {
const conditions: string[] = []
const values: unknown[] = []
if (filters.categoryId) {
values.push(filters.categoryId)
conditions.push(`ci.category_id = $${values.length}`)
}
buildConsignmentSpecFilterConditions(filters.specFilters, values, conditions)
if (filters.dateFrom) {
values.push(filters.dateFrom)
conditions.push(
`ci.inbound_at >= ($${values.length}::date::timestamp AT TIME ZONE 'Asia/Shanghai')`,
)
}
if (filters.dateTo) {
values.push(filters.dateTo)
conditions.push(
`ci.inbound_at < (($${values.length}::date + INTERVAL '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')`,
)
}
/** 统计口径跟着寄售仓库的筛选走:分类 + 动态规格 + 入库日期。 */
const whereWith = (...extra: string[]) => {
const all = [...conditions, ...extra]
return all.length > 0 ? `WHERE ${all.join(' AND ')}` : ''
}
const totalsSql = `
SELECT
COALESCE(SUM(CASE WHEN status = 'in_stock' THEN 1 ELSE 0 END), 0)::integer AS in_stock_count,
COALESCE(SUM(CASE WHEN status = 'in_stock' THEN cost_price_fen ELSE 0 END), 0)::integer AS in_stock_cost_fen,
COALESCE(SUM(CASE WHEN status = 'consumed' THEN 1 ELSE 0 END), 0)::integer AS consumed_count,
COALESCE(SUM(CASE WHEN status = 'consumed' THEN cost_price_fen ELSE 0 END), 0)::integer AS consumed_cost_fen
FROM consignment_inventories
COALESCE(SUM(CASE WHEN ci.status = 'in_stock' THEN 1 ELSE 0 END), 0)::integer AS in_stock_count,
COALESCE(SUM(CASE WHEN ci.status = 'in_stock' THEN ci.cost_price_fen ELSE 0 END), 0)::integer AS in_stock_cost_fen,
COALESCE(SUM(CASE WHEN ci.status = 'consumed' THEN 1 ELSE 0 END), 0)::integer AS consumed_count,
COALESCE(SUM(CASE WHEN ci.status = 'consumed' THEN ci.cost_price_fen ELSE 0 END), 0)::integer AS consumed_cost_fen
FROM consignment_inventories ci
${whereWith()}
`
const totalsRes = await query<{
in_stock_count: number
in_stock_cost_fen: number
consumed_count: number
consumed_cost_fen: number
}>(totalsSql)
}>(totalsSql, values)
const totals = totalsRes.rows[0] || {
in_stock_count: 0,
in_stock_cost_fen: 0,
@@ -314,12 +404,13 @@ export async function getConsignmentInventoryStats(): Promise<ConsignmentStatsSu
const buyerSql = `
SELECT
COALESCE(NULLIF(buyer_name, ''), '未知') AS buyer_name,
COALESCE(NULLIF(ci.buyer_name, ''), '未知') AS buyer_name,
COUNT(*)::integer AS count,
SUM(cost_price_fen)::integer AS total_cost_fen,
COUNT(CASE WHEN status = 'consumed' THEN 1 END)::integer AS consumed_count
FROM consignment_inventories
GROUP BY buyer_name
SUM(ci.cost_price_fen)::integer AS total_cost_fen,
COUNT(CASE WHEN ci.status = 'consumed' THEN 1 END)::integer AS consumed_count
FROM consignment_inventories ci
${whereWith()}
GROUP BY ci.buyer_name
ORDER BY count DESC
LIMIT 20
`
@@ -328,46 +419,51 @@ export async function getConsignmentInventoryStats(): Promise<ConsignmentStatsSu
count: number
total_cost_fen: number
consumed_count: number
}>(buyerSql)
}>(buyerSql, values)
const shopSql = `
SELECT
COALESCE(NULLIF(shop_name, ''), '其他') AS shop_name,
COALESCE(NULLIF(ci.shop_name, ''), '其他') AS shop_name,
COUNT(*)::integer AS count
FROM consignment_inventories
WHERE status = 'consumed' AND shop_name <> ''
GROUP BY shop_name
FROM consignment_inventories ci
${whereWith(`ci.status = 'consumed'`, `ci.shop_name <> ''`)}
GROUP BY ci.shop_name
ORDER BY count DESC
LIMIT 20
`
const shopRes = await query<{ shop_name: string; count: number }>(shopSql)
const shopRes = await query<{ shop_name: string; count: number }>(shopSql, values)
const batchSql = `
SELECT
COALESCE(NULLIF(inbound_batch, ''), '未分类') AS inbound_batch,
COALESCE(NULLIF(ci.inbound_batch, ''), '未分类') AS inbound_batch,
COUNT(*)::integer AS count,
SUM(cost_price_fen)::integer AS total_cost_fen
FROM consignment_inventories
GROUP BY inbound_batch
SUM(ci.cost_price_fen)::integer AS total_cost_fen
FROM consignment_inventories ci
${whereWith()}
GROUP BY ci.inbound_batch
ORDER BY count DESC
LIMIT 20
`
const batchRes = await query<{ inbound_batch: string; count: number; total_cost_fen: number }>(
batchSql,
values,
)
const seriesSql = `
SELECT
COALESCE(NULLIF(series, ''), '未注明') AS series,
diamond_level,
COALESCE(NULLIF(ci.series, ''), '未注明') AS series,
ci.diamond_level,
COUNT(*)::integer AS count
FROM consignment_inventories
WHERE status = 'in_stock'
GROUP BY series, diamond_level
FROM consignment_inventories ci
${whereWith(`ci.status = 'in_stock'`)}
GROUP BY ci.series, ci.diamond_level
ORDER BY count DESC
LIMIT 20
`
const seriesRes = await query<{ series: string; diamond_level: number; count: number }>(seriesSql)
const seriesRes = await query<{ series: string; diamond_level: number; count: number }>(
seriesSql,
values,
)
return {
totalInStockCount: totals.in_stock_count,
@@ -28,6 +28,7 @@ import {
} from '../../../services/worker-platform/consignment-fulfillment-service.js'
import {
archiveAdminConsignmentProfile,
listAdminConsignmentInventoryFilterOptions,
listAdminConsignmentProfiles,
publishAdminConsignmentProfile,
saveAdminConsignmentProfile,
@@ -152,6 +153,18 @@ router.get(
)
// ---------------- 库存管理与统计 ----------------
// 寄售仓库筛选选项:只返回已开启寄卖(已发布配置)的分类及其可筛选规格。
router.get(
'/worker-platform/consignment/inventory-filter-options',
requireAdminPermission('consignment.view'),
createJsonHandler(() => listAdminConsignmentInventoryFilterOptions(), {
successMessage: 'ok',
errorMessage: '读取寄售仓库筛选项失败',
scope: '[admin/consignment/inventory-filter-options]',
}),
)
router.get(
'/worker-platform/consignment/inventories',
requireAdminPermission('consignment.view'),
@@ -165,7 +178,7 @@ router.get(
router.get(
'/worker-platform/consignment/stats',
requireAdminPermission('consignment.view'),
createJsonHandler(() => fetchAdminConsignmentStats(), {
createJsonHandler((req) => fetchAdminConsignmentStats(req.query), {
successMessage: 'ok',
errorMessage: '读取寄卖统计失败',
scope: '[admin/consignment/stats]',
@@ -384,6 +397,7 @@ router.get(
createJsonHandler(
(req) =>
listPendingConsignmentMatches({
categoryId: Number(req.query.categoryId || 0) || undefined,
keyword: String(req.query.keyword || ''),
includeConflicts: String(req.query.includeConflicts || '') === 'true',
limit: Number(req.query.limit || 0) || undefined,
@@ -1,6 +1,7 @@
import { withTransaction } from '../../db/client.js'
import {
type ConsignmentInventoryRow,
type ConsignmentInventorySpecFilter,
type ConsignmentSubmissionRow,
approveConsignmentSubmissionWithInbound,
getConsignmentInventoryById,
@@ -403,6 +404,7 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) {
const dateTo = normalizeInventoryDate(query.dateTo)
const page = query.page ? Number(query.page) : 1
const pageSize = query.pageSize ? Number(query.pageSize) : 20
const specFilters = normalizeInventorySpecFilters(query.specFilters)
const res = await listConsignmentInventories({
status,
@@ -415,6 +417,7 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) {
series,
diamondLevel,
allowMatching,
specFilters,
keyword,
dateFrom,
dateTo,
@@ -442,8 +445,45 @@ export function normalizeInventoryDate(rawValue: unknown) {
: normalized
}
export async function fetchAdminConsignmentStats() {
return getConsignmentInventoryStats()
export async function fetchAdminConsignmentStats(query: JsonObject = {}) {
const categoryId = query.categoryId ? Number(query.categoryId) : 0
return getConsignmentInventoryStats({
categoryId: Number.isInteger(categoryId) && categoryId > 0 ? categoryId : undefined,
specFilters: normalizeInventorySpecFilters(query.specFilters),
dateFrom: normalizeInventoryDate(query.dateFrom),
dateTo: normalizeInventoryDate(query.dateTo),
})
}
/**
* 寄售仓库动态规格筛选:前端传 JSON 字符串,如 `{"system":"苹果","series":"暗潮"}`;
* 也接受 `{ "key": { "value": "苹果", "match": "contains" } }` 指定文本模糊匹配。
* key 的合法性由仓储层白名单校验,这里只做结构收敛。
*/
export function normalizeInventorySpecFilters(rawValue: unknown): ConsignmentInventorySpecFilter[] {
const parsed = typeof rawValue === 'string' ? safeParseJson(rawValue) : rawValue
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []
const filters: ConsignmentInventorySpecFilter[] = []
for (const [rawKey, rawFilter] of Object.entries(parsed as Record<string, unknown>)) {
const key = String(rawKey || '').trim()
if (!key) continue
const container =
rawFilter && typeof rawFilter === 'object' && !Array.isArray(rawFilter)
? (rawFilter as Record<string, unknown>)
: null
const value = String((container ? container.value : rawFilter) ?? '').trim()
if (!value) continue
filters.push({
key,
value,
match: container?.match === 'contains' ? 'contains' : 'exact',
})
if (filters.length >= 20) break
}
return filters
}
export async function getAdminConsignmentInventory(id: number | string) {
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { normalizeInventorySpecFilters } from './consignment-admin-service.js'
import {
buildConsignmentInventoryFilterFields,
normalizeConsignmentProfileConfig,
sortConsignmentInventoryFilterCategories,
} from './consignment-profile-service.js'
import { normalizeConsignmentSpecs } from './consignment-spec-service.js'
import { buildConsignmentSpecFilterConditions } from '../../repositories/worker-platform/consignment-repo.js'
function buildSpec(overrides: Record<string, unknown> = {}) {
return {
key: 'color',
label: '颜色',
type: 'select',
options: [
{ label: '红', value: 'red', enabled: true },
{ label: '蓝', value: 'blue', enabled: false },
],
sortOrder: 10,
...overrides,
}
}
// ---------------------------------------------------------------------------
// 1. 筛选字段生成
// ---------------------------------------------------------------------------
test('寄售仓库筛选字段:只保留开启筛选的非长文本字段,并按 sortOrder 排序', () => {
const config = normalizeConsignmentProfileConfig({
specs: [
buildSpec({ key: 'second', label: '第二', filterable: true, sortOrder: 20 }),
buildSpec({ key: 'first', label: '第一', filterable: true, sortOrder: 10 }),
buildSpec({ key: 'hidden', label: '不筛', filterable: false, sortOrder: 30 }),
buildSpec({
key: 'memo',
label: '长文本',
type: 'textarea',
filterable: true,
sortOrder: 40,
}),
],
})
const fields = buildConsignmentInventoryFilterFields(config)
assert.deepEqual(
fields.map((field) => field.key),
['first', 'second'],
)
})
test('寄售仓库筛选字段:选项类只带启用中的选项,文本类走模糊匹配', () => {
const config = normalizeConsignmentProfileConfig({
specs: [
buildSpec({ key: 'color', filterable: true, sortOrder: 10 }),
buildSpec({
key: 'note',
label: '备注',
type: 'text',
options: [],
filterable: true,
sortOrder: 20,
}),
],
})
const fields = buildConsignmentInventoryFilterFields(config)
const color = fields.find((field) => field.key === 'color')
assert.deepEqual(color?.options, [{ label: '红', value: 'red' }])
assert.equal(color?.match, 'exact')
const note = fields.find((field) => field.key === 'note')
assert.equal(note?.match, 'contains')
assert.deepEqual(note?.options, [])
})
// ---------------------------------------------------------------------------
// 2. 规格筛选 SQL
// ---------------------------------------------------------------------------
test('规格筛选 SQL:内置 key 回退库存旧列,避免历史库存筛不到', () => {
const values: unknown[] = []
const conditions: string[] = []
buildConsignmentSpecFilterConditions([{ key: 'system', value: '苹果' }], values, conditions)
assert.deepEqual(values, ['system', '苹果'])
assert.equal(
conditions[0],
`COALESCE(NULLIF(ci.spec_values_json ->> $1, ''), ci.system::text) = $2`,
)
})
test('规格筛选 SQL:非内置 key 只查 spec_values_json', () => {
const values: unknown[] = []
const conditions: string[] = []
buildConsignmentSpecFilterConditions([{ key: 'color', value: 'red' }], values, conditions)
assert.equal(conditions[0], `ci.spec_values_json ->> $1 = $2`)
})
test('规格筛选 SQL:文本字段用 ILIKE 模糊匹配', () => {
const values: unknown[] = []
const conditions: string[] = []
buildConsignmentSpecFilterConditions(
[{ key: 'note', value: '暗潮', match: 'contains' }],
values,
conditions,
)
assert.deepEqual(values, ['note', '%暗潮%'])
assert.equal(conditions[0], `ci.spec_values_json ->> $1 ILIKE $2`)
})
test('规格筛选 SQL:非法 key 与空值一律忽略', () => {
const values: unknown[] = []
const conditions: string[] = []
buildConsignmentSpecFilterConditions(
[
{ key: 'bad-key', value: 'x' },
{ key: 'ok', value: ' ' },
],
values,
conditions,
)
assert.equal(values.length, 0)
assert.equal(conditions.length, 0)
})
// ---------------------------------------------------------------------------
// 3. 请求参数归一化
// ---------------------------------------------------------------------------
test('规格筛选参数:字符串 JSON 归一化,含模糊匹配声明', () => {
assert.deepEqual(
normalizeInventorySpecFilters('{"system":"苹果","note":{"value":"暗潮","match":"contains"}}'),
[
{ key: 'system', value: '苹果', match: 'exact' },
{ key: 'note', value: '暗潮', match: 'contains' },
],
)
})
test('规格筛选参数:数组、空值与纯文本一律丢弃', () => {
assert.deepEqual(normalizeInventorySpecFilters(['system']), [])
assert.deepEqual(normalizeInventorySpecFilters('{"system":" "}'), [])
assert.deepEqual(normalizeInventorySpecFilters(undefined), [])
})
// ---------------------------------------------------------------------------
// 4. 筛选选项接口口径
// ---------------------------------------------------------------------------
test('寄售仓库筛选选项:只取已发布且分类启用的配置,并保留继承口径', async () => {
const source = await readFile(
new URL('./consignment-profile-service.ts', import.meta.url),
'utf8',
)
const body = source.slice(
source.indexOf('export async function listAdminConsignmentInventoryFilterOptions'),
)
assert.ok(body.includes(`p.status = 'published'`), '只允许已发布配置')
assert.ok(body.includes(`c.status = 'active'`), '停用分类不出现')
assert.ok(body.includes('p.current_version_id IS NOT NULL'), '必须指向已生成版本')
assert.ok(body.includes('inheritedProfile'), '二级分类要能继承一级分类配置')
assert.ok(body.includes('inventoryCountByCategoryId'), '无库存的继承子分类不应出现')
})
test('规格字段 filterable:选项类默认开启,其它类型默认关闭,显式值优先', () => {
const [optionField] = normalizeConsignmentSpecs([buildSpec()])
assert.equal(optionField?.filterable, true)
const [textField] = normalizeConsignmentSpecs([buildSpec({ key: 'note', type: 'text' })])
assert.equal(textField?.filterable, false)
const [disabled] = normalizeConsignmentSpecs([buildSpec({ filterable: false })])
assert.equal(disabled?.filterable, false)
const [enabledText] = normalizeConsignmentSpecs([
buildSpec({ key: 'note', type: 'text', filterable: true }),
])
assert.equal(enabledText?.filterable, true)
})
test('筛选项排序:苏尔南手枪固定排第一,其余保持配置顺序', () => {
const sorted = sortConsignmentInventoryFilterCategories([
{ categoryKey: 'default', name: '密钥' },
{ categoryKey: 'shouqiang', name: '苏尔南手枪' },
{ categoryKey: 'other', name: '其它' },
])
assert.deepEqual(
sorted.map((item) => item.name),
['苏尔南手枪', '密钥', '其它'],
)
})
@@ -380,3 +380,70 @@ test('看板新口径:无寄卖配置的订单仍按旧手枪口径兜底', ()
assert.equal(result.pairs[0]?.profileId, null)
assert.equal(result.pairs[0]?.score, 100)
})
test('看板按分类筛选:只保留该寄卖配置分类的订单', () => {
const result = buildPendingConsignmentMatchPairs({
orders: [
buildCustomOrder({ id: 401, work_order_no: 'WK401' }),
buildCustomOrder({ id: 402, work_order_no: 'WK402' }),
],
inventories: [buildCustomInventory({ id: 411 }), buildCustomInventory({ id: 412 })],
profileByOrderId: new Map([
[401, buildProfile({ profileId: 99, categoryId: 9 })],
[402, buildProfile({ profileId: 100, categoryId: 10 })],
]),
categoryId: 9,
})
assert.equal(result.pendingOrderCount, 1, '其它分类的订单不应参与统计')
assert.ok(result.pairs.every((pair) => pair.workOrderId === 401))
})
test('看板按分类筛选:排除其它分类的库存', () => {
const result = buildPendingConsignmentMatchPairs({
orders: [buildCustomOrder({ id: 421 })],
inventories: [
buildCustomInventory({ id: 431, category_id: 9 }),
buildCustomInventory({ id: 432, category_id: 10 }),
],
profileByOrderId: new Map([[421, buildProfile({ profileId: 99, categoryId: 9 })]]),
categoryId: 9,
})
assert.deepEqual(
result.pairs.map((pair) => pair.inventoryId),
[431],
)
assert.equal(result.inStockInventoryCount, 1)
})
test('看板按分类筛选:无寄卖配置的老订单按工单分类过滤', () => {
const result = buildPendingConsignmentMatchPairs({
orders: [
buildOrder({ id: 441, category_id: 4 }),
buildOrder({ id: 442, work_order_no: 'WK442', category_id: 1 }),
],
inventories: [buildInventory({ id: 451, category_id: 4 })],
profileByOrderId: new Map([
[441, null],
[442, null],
]),
categoryId: 4,
})
assert.deepEqual(
result.pairs.map((pair) => pair.workOrderId),
[441],
)
assert.equal(result.pendingOrderCount, 1)
})
test('看板按分类筛选:不传分类时保持原有全量口径', () => {
const result = buildPendingConsignmentMatchPairs({
orders: [buildCustomOrder({ id: 461 })],
inventories: [buildCustomInventory({ id: 471, category_id: 10 })],
profileByOrderId: new Map([[461, buildProfile({ profileId: 99, categoryId: 9 })]]),
})
assert.equal(result.pendingOrderCount, 1)
})
@@ -251,11 +251,14 @@ export function buildPendingConsignmentMatchPairs(input: {
orders: WorkOrderRow[]
inventories: ConsignmentInventoryRow[]
profileByOrderId: Map<number, ConsignmentProfileSnapshot | null>
/** 只统计该寄卖分类下的订单与库存;0 / 未传 = 全部 */
categoryId?: number | undefined
keyword?: string | undefined
includeConflicts?: boolean | undefined
limit?: number | undefined
ordersTruncated?: boolean | undefined
}): PendingConsignmentMatchResult {
const categoryId = Math.max(0, Math.floor(Number(input.categoryId) || 0))
const keyword = String(input.keyword || '')
.trim()
.toLowerCase()
@@ -291,12 +294,20 @@ export function buildPendingConsignmentMatchPairs(input: {
const remainingQuantity =
profile && order.sharing_enabled ? Math.max(0, totalQuantity - activeQuantity) : 1
if (remainingQuantity <= 0) continue
// 按寄卖分类筛选:有配置的订单看配置归属,没配置的老订单回退工单自身分类
if (categoryId > 0) {
const orderCategoryId = profile ? profile.categoryId : Number(order.category_id || 0)
if (orderCategoryId !== categoryId) continue
}
preparedOrders.push({ order, profile, remainingQuantity, totalQuantity })
}
const inventories = input.inventories.filter((inventory) => {
if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false
return Number(inventory.remaining_quantity ?? 1) > 0
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
// 库存必须属于当前寄卖分类;没有分类的历史货无法判断归属,按分类看板时排除
if (categoryId > 0 && Number(inventory.category_id || 0) !== categoryId) return false
return true
})
const pairs: PendingConsignmentMatchPair[] = []
@@ -412,6 +423,8 @@ export function buildPendingConsignmentMatchPairs(input: {
/** 后台「寄售仓库 → 匹配」:待抢单 × 在库待匹配现货。 */
export async function listPendingConsignmentMatches(
input: {
/** 只统计该寄卖分类;0 / 未传 = 全部 */
categoryId?: number | undefined
keyword?: string | undefined
includeConflicts?: boolean | undefined
limit?: number | undefined
@@ -446,6 +459,7 @@ export async function listPendingConsignmentMatches(
orders: items,
inventories,
profileByOrderId,
categoryId: input.categoryId,
keyword: input.keyword,
includeConflicts: input.includeConflicts,
limit: input.limit,
@@ -106,3 +106,16 @@ test('合并迁移 142 必须包含寄卖配置、库存数量/售价与分配
assert.ok(source.includes(fragment), `合并迁移 142 缺少 ${fragment}`)
}
})
test('打手端寄卖分类:苏尔南手枪固定排第一', async () => {
const source = await readFile(
new URL('./consignment-profile-service.ts', import.meta.url),
'utf8',
)
const body = source.slice(source.indexOf('export async function listWorkerConsignmentProfiles'))
assert.ok(
body.includes("ORDER BY (c.category_key = 'shouqiang') DESC"),
'苏尔南手枪必须排在打手端分类列表第一位',
)
})
@@ -509,7 +509,8 @@ export async function listWorkerConsignmentProfiles() {
`
${PROFILE_SELECT}
WHERE p.status = 'published' AND c.status = 'active'
ORDER BY c.sort_order ASC, p.id ASC
-- 苏尔南手枪固定排第一位,其余按分类配置顺序
ORDER BY (c.category_key = 'shouqiang') DESC, c.sort_order ASC, p.id ASC
`,
)
return result.rows.map((row) => {
@@ -528,6 +529,154 @@ export async function listWorkerConsignmentProfiles() {
})
}
/** 后台「寄售仓库」动态筛选项:值来自该分类已发布配置的规格字段。 */
export type ConsignmentInventoryFilterOption = { label: string; value: string }
export type ConsignmentInventoryFilterField = {
key: string
label: string
type: string
/** exact = 等值;contains = 模糊包含(文本字段) */
match: 'exact' | 'contains'
options: ConsignmentInventoryFilterOption[]
}
export type ConsignmentInventoryFilterCategory = {
categoryId: number
categoryKey: string
name: string
parentId: number | null
parentName: string
tabTitle: string
profileId: number
versionNo: number
/** own = 该分类自有已发布配置;inherited = 继承一级分类 */
source: 'own' | 'inherited'
filters: ConsignmentInventoryFilterField[]
}
/** 从已发布配置里挑出「支持筛选」的规格字段;选项类字段只保留启用中的选项。 */
export function buildConsignmentInventoryFilterFields(
config: ConsignmentProfileConfig,
): ConsignmentInventoryFilterField[] {
const fields: ConsignmentInventoryFilterField[] = []
const seen = new Set<string>()
for (const spec of config.specs) {
if (!spec.filterable || spec.type === 'textarea') continue
if (seen.has(spec.key)) continue
seen.add(spec.key)
fields.push({
key: spec.key,
label: spec.label || spec.key,
type: spec.type,
match: spec.type === 'text' ? 'contains' : 'exact',
options:
spec.type === 'select' || spec.type === 'radio'
? spec.options
.filter((option) => option.enabled)
.map((option) => ({
label: option.label,
value: option.value,
}))
: [],
})
}
return fields.sort((left, right) => {
const leftSpec = config.specs.find((spec) => spec.key === left.key)
const rightSpec = config.specs.find((spec) => spec.key === right.key)
return (leftSpec?.sortOrder || 0) - (rightSpec?.sortOrder || 0)
})
}
/** 苏尔南手枪固定在筛选项第一位;其余分类保持配置里的排序。 */
export function sortConsignmentInventoryFilterCategories<T extends { categoryKey: string }>(
items: T[],
): T[] {
return [...items].sort((left, right) => {
const leftRank = left.categoryKey === 'shouqiang' ? 0 : 1
const rightRank = right.categoryKey === 'shouqiang' ? 0 : 1
return leftRank - rightRank
})
}
/**
* 后台「寄售仓库」筛选选项:只返回已开启寄卖的分类。
*
* 口径与打手端 tab / 工单匹配保持一致:
* - 分类必须是启用状态;
* - 分类自有已发布配置优先,没有则继承一级分类的已发布配置;
* - 草稿、归档、停用分类一律不出现。
*/
export async function listAdminConsignmentInventoryFilterOptions(): Promise<{
items: ConsignmentInventoryFilterCategory[]
}> {
const [categories, profiles] = await Promise.all([
listAllWorkCategories(),
query<ConsignmentProfileRow>(
`
${PROFILE_SELECT}
WHERE p.status = 'published' AND c.status = 'active' AND p.current_version_id IS NOT NULL
ORDER BY c.sort_order ASC, p.id ASC
`,
),
])
// 继承型二级分类只有真的有库存时才出现在筛选项里:
// 否则会出现「暗潮 / 怒嚎 / 血宴」这类系列子分类,点进去永远是空列表。
const inventoryCounts = await query<{ category_id: number; total: number }>(
`SELECT category_id, COUNT(*)::integer AS total
FROM consignment_inventories
WHERE category_id IS NOT NULL
GROUP BY category_id`,
)
const inventoryCountByCategoryId = new Map<number, number>()
for (const row of inventoryCounts.rows) {
inventoryCountByCategoryId.set(Number(row.category_id), Number(row.total || 0))
}
const profileByCategoryId = new Map<number, ConsignmentProfileRow>()
for (const profile of profiles.rows) {
profileByCategoryId.set(Number(profile.category_id), profile)
}
const categoryById = new Map<number, (typeof categories)[number]>()
for (const category of categories) {
categoryById.set(Number(category.id), category)
}
const items: ConsignmentInventoryFilterCategory[] = []
for (const category of categories) {
if (category.status !== 'active') continue
const ownProfile = profileByCategoryId.get(Number(category.id))
const parentCategory = category.parent_id
? categoryById.get(Number(category.parent_id))
: undefined
const inheritedProfile =
!ownProfile && parentCategory && parentCategory.status === 'active'
? profileByCategoryId.get(Number(parentCategory.id))
: undefined
const effectiveProfile = ownProfile || inheritedProfile
if (!effectiveProfile) continue
if (!ownProfile && !inventoryCountByCategoryId.get(Number(category.id))) continue
const config = normalizeConsignmentProfileConfig(effectiveProfile.config_json)
items.push({
categoryId: Number(category.id),
categoryKey: category.category_key,
name: category.name,
parentId: category.parent_id ? Number(category.parent_id) : null,
parentName: parentCategory?.name || '',
tabTitle: effectiveProfile.tab_title || config.tabTitle || effectiveProfile.name,
profileId: Number(effectiveProfile.id),
versionNo: Number(effectiveProfile.current_version_no || 1),
source: ownProfile ? 'own' : 'inherited',
filters: buildConsignmentInventoryFilterFields(config),
})
}
return { items: sortConsignmentInventoryFilterCategories(items) }
}
/**
* 工单分类解析寄卖配置:优先二级分类自身,没有则回退一级分类。
* 只认已发布版本,返回可直接固化的快照。
@@ -40,6 +40,11 @@ export type ConsignmentSpecField = {
matchMode: ConsignmentSpecMatchMode
wildcardValues: string[]
showInItemName: boolean
/**
* 是否支持在后台「寄售仓库」作为筛选项。
* 选项类字段(select / radio)默认开启,其余类型默认关闭,由运营在寄卖配置里显式打开。
*/
filterable: boolean
sortOrder: number
}
@@ -380,6 +385,12 @@ function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField
matchMode,
wildcardValues: fallbackWildcards,
showInItemName: bool(source.showInItemName ?? source.show_in_item_name, true),
// 缺省时选项类字段可直接筛选(苏尔南等存量配置无需重新发布即可用);
// 文本/数字/日期需要运营显式打开,避免筛选栏被自由输入项淹没。
filterable: bool(
source.filterable ?? source.canFilter ?? source.can_filter,
type === 'select' || type === 'radio',
),
sortOrder: integer(source.sortOrder ?? source.sort_order, (index + 1) * 10, 0, 100_000),
}
}
@@ -22,16 +22,27 @@ function resolveScoreTag(pair: PendingPistolMatchPair) {
export default function ConsignmentPendingMatchModal({
open,
onClose,
categoryId,
categoryName,
}: {
open: boolean
onClose: () => void
/** 只统计该寄卖分类;不传 = 全部 */
categoryId?: number | null
categoryName?: string
}) {
const [keyword, setKeyword] = useState('')
const [includeConflicts, setIncludeConflicts] = useState(false)
const query = useQuery({
queryKey: ['admin-pending-pistol-matches', keyword, includeConflicts],
queryFn: () => fetchPendingPistolMatches({ keyword, includeConflicts, limit: 300 }),
queryKey: ['admin-pending-pistol-matches', categoryId ?? null, keyword, includeConflicts],
queryFn: () =>
fetchPendingPistolMatches({
categoryId: categoryId ?? undefined,
keyword,
includeConflicts,
limit: 300,
}),
enabled: open,
refetchOnWindowFocus: false,
retry: false,
@@ -187,7 +198,7 @@ export default function ConsignmentPendingMatchModal({
return (
<Modal
title="匹配 · 待抢单 × 在库现货"
title={categoryName ? `匹配 · ${categoryName} 待抢单 × 在库现货` : '匹配 · 待抢单 × 在库现货'}
open={open}
width="min(1320px, 94vw)"
footer={null}
@@ -198,11 +209,16 @@ export default function ConsignmentPendingMatchModal({
<Alert
type="info"
showIcon
message="按寄卖配置匹配全部待抢单 × 在库待匹配现货,不直接改单。"
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);拼单订单按剩余份数展示。未配置寄卖的订单仍按旧手枪口径兜底。"
message={
categoryName
? `只统计「${categoryName}」分类下的待抢单 × 在库待匹配现货,不直接改单。`
: '按寄卖配置匹配全部待抢单 × 在库待匹配现货,不直接改单。'
}
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);拼单订单按剩余份数展示。"
/>
<Space wrap size={12}>
{categoryName ? <Tag color="blue">分类:{categoryName}</Tag> : null}
<Tag>待抢单 {data?.pendingOrderCount ?? 0} 单</Tag>
<Tag>在库待匹配 {data?.inStockInventoryCount ?? 0} 件</Tag>
<Tag color="blue">匹配组合 {data?.pairCount ?? 0} 条</Tag>
@@ -267,8 +283,8 @@ export default function ConsignmentPendingMatchModal({
<Empty
description={
includeConflicts
? '没有可展示的配对'
: '没有有分匹配;可勾选「显示 0 分不匹配项」查看冲突'
? '当前分类下没有可展示的配对'
: '当前分类下没有可匹配组合;可勾选「显示 0 分不匹配项」查看冲突'
}
/>
),
@@ -155,6 +155,7 @@ function newCustomSpec(specs: ConsignmentSpecField[]): ConsignmentSpecField {
matchMode: 'exact',
wildcardValues: [],
showInItemName: true,
filterable: false,
sortOrder: (specs.length + 1) * 10,
}
}
@@ -596,11 +597,17 @@ export default function ConsignmentConfigPanel() {
function changeSpecType(index: number, type: ConsignmentSpecFieldType) {
setSpecDrafts((current) =>
current.map((spec, specIndex) =>
specIndex === index
? { ...spec, type, options: isOptionSpecField({ type }) ? spec.options : [] }
: spec,
),
current.map((spec, specIndex) => {
if (specIndex !== index) return spec
const optionField = isOptionSpecField({ type })
return {
...spec,
type,
options: optionField ? spec.options : [],
// 选项类字段默认参与寄售仓库筛选,切回其它类型时保持运营原有选择
filterable: optionField ? spec.filterable !== false : spec.filterable,
}
}),
)
}
@@ -1105,6 +1112,15 @@ export default function ConsignmentConfigPanel() {
}
/>
</div>
<div style={fieldCellStyle} className="order-service-field-required">
<Text type="secondary">支持筛选</Text>
<Switch
checked={spec.filterable === true}
checkedChildren="可筛"
unCheckedChildren="不筛"
onChange={(checked) => updateSpec(index, { filterable: checked })}
/>
</div>
</div>
</div>
))}
@@ -36,7 +36,7 @@ import PercentTable from '@/components/common/PercentTable'
import { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
import { buildAdminTablePagination } from '@/utils/admin-pagination'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal'
@@ -45,6 +45,7 @@ import {
archiveAdminConsignmentInventory,
fetchAdminConsignmentContacts,
fetchAdminConsignmentInventories,
fetchAdminConsignmentInventoryFilterOptions,
fetchAdminConsignmentStats,
returnAdminConsignmentInventory,
toggleAdminConsignmentMatching,
@@ -52,7 +53,11 @@ import {
} from '@/services/admin/consignment'
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/consignment'
import type {
ConsignmentInventory,
ConsignmentInventoryFilterField,
GiftEvidenceReviewStatus,
} from '@/types/consignment'
import { fetchAdminSession } from '@/services/admin/auth'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
@@ -62,38 +67,7 @@ import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
const SYSTEM_OPTIONS = [
{ label: '全部系统', value: '' },
{ label: '苹果 (iOS)', value: '苹果' },
{ label: '安卓 (Android)', value: '安卓' },
{ label: '通用/双端', value: '通用' },
]
const ZONE_OPTIONS = [
{ label: '全部区服', value: '' },
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
{ label: '互通', value: '互通' },
]
const SERIES_OPTIONS = [
{ label: '全部系列', value: '' },
{ label: '暗潮', value: '暗潮' },
{ label: '怒嚎', value: '怒嚎' },
{ label: '血宴', value: '血宴' },
]
const DIAMOND_OPTIONS = [
{ label: '全部钻石', value: 0 },
{ label: '1 钻', value: 1 },
{ label: '2 钻', value: 2 },
{ label: '3 钻', value: 3 },
{ label: '4 钻', value: 4 },
{ label: '5 钻', value: 5 },
{ label: '6 钻', value: 6 },
{ label: '7 钻', value: 7 },
]
// 筛选字段与选项全部由「寄卖配置」的已发布版本下发,页面不再维护写死的系统/区服/系列/钻石常量。
const BUYER_COLORS: Record<string, string> = {
小明: 'blue',
小张: 'green',
@@ -147,12 +121,10 @@ function resolveGiftEvidenceEmptyText(record: {
export default function ConsignmentInventoryPanel() {
const { message } = App.useApp()
// 筛选条件
// 筛选条件:分类与规格项全部来自「寄卖配置」已发布版本
const [status, setStatus] = useState<string>('in_stock')
const [system, setSystem] = useState<string>('')
const [serverZone, setServerZone] = useState<string>('')
const [series, setSeries] = useState<string>('')
const [diamondLevel, setDiamondLevel] = useState<number>(0)
const [categoryId, setCategoryId] = useState<number | null>(null)
const [specFilters, setSpecFilters] = useState<Record<string, string>>({})
const [allowMatching, setAllowMatching] = useState<string>('')
const [inboundRange, setInboundRange] = useState<[string, string] | null>(null)
const [keyword, setKeyword] = useState<string>('')
@@ -203,15 +175,64 @@ export default function ConsignmentInventoryPanel() {
value: item.id,
}))
// 寄售仓库筛选项:只含已开启寄卖(已发布配置)的分类及其可筛选规格
const filterOptionsQuery = useQuery({
queryKey: ['admin-consignment-inventory-filter-options'],
queryFn: () => fetchAdminConsignmentInventoryFilterOptions(),
})
const filterCategories = useMemo(
() => filterOptionsQuery.data?.data.items || [],
[filterOptionsQuery.data],
)
const categoryOptions = useMemo(
() =>
filterCategories.map((item) => ({
// 一级分类直接显示分类名;二级分类带上父级,避免同名歧义
label: item.parentName ? `${item.parentName} / ${item.name}` : item.name,
value: item.categoryId,
})),
[filterCategories],
)
// 默认「苏尔南手枪」;该分类未开启寄卖时回退到第一个已开启分类
useEffect(() => {
if (categoryId !== null || filterCategories.length === 0) return
const preferred =
filterCategories.find((item) => item.categoryKey === 'shouqiang') || filterCategories[0]!
setCategoryId(preferred.categoryId)
}, [categoryId, filterCategories])
const activeFilterCategory = filterCategories.find((item) => item.categoryId === categoryId)
const dynamicFilters = activeFilterCategory?.filters || []
// 只提交当前分类实际渲染出来的筛选项,切换分类时旧规格不会带到新分类
const specFilterPayload = useMemo(() => {
const payload: Record<string, { value: string; match: 'exact' | 'contains' }> = {}
for (const field of dynamicFilters) {
const value = specFilters[field.key]
if (value) payload[field.key] = { value, match: field.match }
}
return payload
}, [dynamicFilters, specFilters])
const specFilterParams =
Object.keys(specFilterPayload).length > 0 ? JSON.stringify(specFilterPayload) : undefined
function updateSpecFilter(field: ConsignmentInventoryFilterField, value: string) {
setSpecFilters((current) => {
const next = { ...current }
if (value) next[field.key] = value
else delete next[field.key]
return next
})
setPage(1)
}
// 数据查询
const inventoriesQuery = useQuery({
queryKey: [
'admin-consignment-inventories',
status,
system,
serverZone,
series,
diamondLevel,
categoryId,
specFilterParams,
allowMatching,
inboundRange?.[0],
inboundRange?.[1],
@@ -222,10 +243,8 @@ export default function ConsignmentInventoryPanel() {
queryFn: () =>
fetchAdminConsignmentInventories({
status: status === 'all' ? undefined : status,
system: system || undefined,
serverZone: serverZone || undefined,
series: series || undefined,
diamondLevel: diamondLevel > 0 ? diamondLevel : undefined,
categoryId: categoryId ?? undefined,
specFilters: specFilterParams,
allowMatching: allowMatching === 'yes' ? true : allowMatching === 'no' ? false : undefined,
dateFrom: inboundRange?.[0],
dateTo: inboundRange?.[1],
@@ -235,9 +254,22 @@ export default function ConsignmentInventoryPanel() {
}),
})
// 统计卡片跟随寄售仓库的分类 + 规格 + 入库日期筛选联动
const statsQuery = useQuery({
queryKey: ['admin-consignment-stats'],
queryFn: () => fetchAdminConsignmentStats(),
queryKey: [
'admin-consignment-stats',
categoryId,
specFilterParams,
inboundRange?.[0],
inboundRange?.[1],
],
queryFn: () =>
fetchAdminConsignmentStats({
categoryId: categoryId ?? undefined,
specFilters: specFilterParams,
dateFrom: inboundRange?.[0],
dateTo: inboundRange?.[1],
}),
})
const items = inventoriesQuery.data?.data.items || []
@@ -421,6 +453,23 @@ export default function ConsignmentInventoryPanel() {
</div>
),
},
{
title: '寄卖分类',
key: 'category',
width: 140,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Tag color="blue" style={{ fontSize: 11, padding: '0 6px', marginInlineEnd: 0 }}>
{record.categoryName || '未分类'}
</Tag>
{record.profileId ? (
<Text type="secondary" style={{ fontSize: 11 }}>
寄卖配置 #{record.profileId}
</Text>
) : null}
</Space>
),
},
{
title: '和平精英规格',
key: 'specs',
@@ -813,43 +862,65 @@ export default function ConsignmentInventoryPanel() {
<Radio.Button value="all">全部</Radio.Button>
</Radio.Group>
{/* 寄卖分类:选项与规格筛选项全部来自该类别的已发布寄卖配置 */}
<Select
style={{ width: 130 }}
options={SYSTEM_OPTIONS}
value={system}
onChange={(v) => {
setSystem(v)
setPage(1)
}}
/>
<Select
style={{ width: 110 }}
options={ZONE_OPTIONS}
value={serverZone}
onChange={(v) => {
setServerZone(v)
setPage(1)
}}
/>
<Select
style={{ width: 110 }}
options={SERIES_OPTIONS}
value={series}
onChange={(v) => {
setSeries(v)
setPage(1)
}}
/>
<Select
style={{ width: 100 }}
options={DIAMOND_OPTIONS}
value={diamondLevel}
onChange={(v) => {
setDiamondLevel(v)
style={{ width: 200 }}
loading={filterOptionsQuery.isLoading}
value={categoryId ?? undefined}
placeholder="寄卖分类"
options={categoryOptions}
onChange={(value) => {
setCategoryId(Number(value))
setSpecFilters({})
setPage(1)
}}
/>
{dynamicFilters.map((field) => {
const value = specFilters[field.key]
if (field.type === 'select' || field.type === 'radio') {
return (
<Select
key={field.key}
allowClear
style={{ width: 160 }}
placeholder={field.label}
value={value}
options={field.options}
onChange={(next) => updateSpecFilter(field, next || '')}
/>
)
}
if (field.type === 'number') {
return (
<InputNumber
key={field.key}
style={{ width: 140 }}
placeholder={field.label}
value={value ? Number(value) : null}
onChange={(next) => updateSpecFilter(field, next === null ? '' : String(next))}
/>
)
}
return (
<Input
key={field.key}
allowClear
style={{ width: 160 }}
placeholder={field.type === 'date' ? `${field.label} YYYY-MM-DD` : field.label}
defaultValue={value}
onChange={(event) => {
// 点小叉清空时立即生效;输入内容则在回车/失焦后提交,避免逐字请求
if (!event.target.value) updateSpecFilter(field, '')
}}
onBlur={(event) => updateSpecFilter(field, event.target.value.trim())}
onPressEnter={(event) =>
updateSpecFilter(field, (event.target as HTMLInputElement).value.trim())
}
/>
)
})}
<Select
style={{ width: 120 }}
value={allowMatching}
@@ -1411,9 +1482,11 @@ export default function ConsignmentInventoryPanel() {
onMatched={() => void inventoriesQuery.refetch()}
/>
{/* 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货 */}
{/* 全局匹配看板:按当前寄卖分类统计待抢单 × 在库待匹配现货 */}
<ConsignmentPendingMatchModal
open={pendingMatchOpen}
categoryId={categoryId}
categoryName={activeFilterCategory?.name}
onClose={() => setPendingMatchOpen(false)}
/>
@@ -3,6 +3,7 @@ import type {
AdminConsignmentProfilesResponse,
ConsignmentContactSupporter,
ConsignmentInventory,
ConsignmentInventoryFilterOptionsResponse,
ConsignmentMatchCandidatesResponse,
ConsignmentProfile,
ConsignmentStatsSummary,
@@ -22,6 +23,16 @@ export function fetchAdminConsignmentProfiles() {
)
}
/**
* 寄售仓库筛选选项:只返回已开启寄卖(已发布配置)的分类,
* 每个分类带出「支持筛选」的规格字段与可选值。
*/
export function fetchAdminConsignmentInventoryFilterOptions() {
return apiGet<ConsignmentInventoryFilterOptionsResponse>(
'/api/v1/admin/worker-platform/consignment/inventory-filter-options',
)
}
export function saveAdminConsignmentProfile(payload: Record<string, unknown>) {
return apiPost<{ profile: ConsignmentProfile }>(
'/api/v1/admin/worker-platform/consignment/profiles',
@@ -127,8 +138,8 @@ export function fetchAdminConsignmentInventories(params?: Record<string, unknown
)
}
export function fetchAdminConsignmentStats() {
return apiGet<ConsignmentStatsSummary>('/api/v1/admin/worker-platform/consignment/stats')
export function fetchAdminConsignmentStats(params?: Record<string, unknown>) {
return apiGet<ConsignmentStatsSummary>('/api/v1/admin/worker-platform/consignment/stats', params)
}
export function getAdminConsignmentInventory(id: number | string) {
@@ -230,8 +241,10 @@ export function fetchInventoryMatchableOrders(
)
}
/** 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货,按细粒度评分排序。 */
/** 全局匹配看板:待抢单 × 在库待匹配现货,按细粒度评分排序。 */
export function fetchPendingPistolMatches(params?: {
/** 只统计该寄卖分类;不传 = 全部 */
categoryId?: number
keyword?: string
includeConflicts?: boolean
limit?: number
@@ -239,6 +252,7 @@ export function fetchPendingPistolMatches(params?: {
return apiGet<PendingPistolMatchResult>(
'/api/v1/admin/worker-platform/consignment/pending-pistol-matches',
{
categoryId: params?.categoryId,
keyword: params?.keyword?.trim() || undefined,
includeConflicts: params?.includeConflicts ? 'true' : undefined,
limit: params?.limit,
+32
View File
@@ -50,6 +50,8 @@ export type ConsignmentSpecField = {
matchMode: 'exact' | 'minimum' | 'wildcard' | 'ignore'
wildcardValues: string[]
showInItemName: boolean
/** 是否在后台「寄售仓库」作为筛选项(选项类字段默认开启) */
filterable?: boolean
sortOrder: number
}
@@ -459,6 +461,36 @@ export type AdminConsignmentProfilesResponse = {
profiles: ConsignmentProfile[]
}
/** 后台「寄售仓库」筛选项:值来自该分类已发布寄卖配置的规格字段 */
export type ConsignmentInventoryFilterOption = { label: string; value: string }
export type ConsignmentInventoryFilterField = {
key: string
label: string
type: ConsignmentSpecFieldType
/** exact = 等值;contains = 模糊包含(文本字段) */
match: 'exact' | 'contains'
options: ConsignmentInventoryFilterOption[]
}
export type ConsignmentInventoryFilterCategory = {
categoryId: number
categoryKey: string
name: string
parentId: number | null
parentName: string
tabTitle: string
profileId: number
versionNo: number
/** own = 自有已发布配置;inherited = 继承一级分类 */
source: 'own' | 'inherited'
filters: ConsignmentInventoryFilterField[]
}
export type ConsignmentInventoryFilterOptionsResponse = {
items: ConsignmentInventoryFilterCategory[]
}
export type WorkerConsignmentProfile = {
profileId: number
categoryId: number
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
function readSource(relativePath: string) {
// Windows 检出为 CRLF,多行断言里的 \n 会匹配失败,先统一换行
return readFileSync(new URL(relativePath, import.meta.url).pathname, 'utf8').replace(
/\r\n/g,
'\n',
)
}
const panelSource = readSource('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx')
const configSource = readSource('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
const serviceSource = readSource('../src/services/admin/consignment.ts')
test('寄售仓库:分类下拉只来自寄卖配置,默认苏尔南手枪', () => {
assert.ok(
panelSource.includes('fetchAdminConsignmentInventoryFilterOptions'),
'必须调用寄卖配置下发的筛选项接口',
)
assert.ok(panelSource.includes("categoryKey === 'shouqiang'"), '默认选中苏尔南手枪')
assert.ok(panelSource.includes('categoryId: categoryId ?? undefined'), '分类必须透传给库存查询')
assert.ok(
!panelSource.includes('options={SYSTEM_OPTIONS}') &&
!panelSource.includes('options={DIAMOND_OPTIONS}'),
'旧的写死四筛必须移除',
)
})
test('寄售仓库:筛选项按配置动态渲染,切换分类会清空旧规格', () => {
assert.ok(panelSource.includes('dynamicFilters.map'), '筛选项必须由配置动态渲染')
assert.ok(panelSource.includes('setSpecFilters({})'), '切换分类必须清空上一个分类的规格筛选')
assert.ok(panelSource.includes('specFilters: specFilterParams'), '规格筛选必须透传给库存查询')
assert.ok(panelSource.includes('match: field.match'), '文本规格要带 contains,选项规格走 exact')
})
test('寄售仓库:表格新增「寄卖分类」列', () => {
assert.ok(panelSource.includes("title: '寄卖分类'"), '缺少寄卖分类列')
assert.ok(panelSource.includes('record.categoryName'), '分类列必须展示分类名称')
})
test('寄售仓库:统计卡片跟随分类与规格筛选联动', () => {
assert.ok(
panelSource.includes(
'categoryId: categoryId ?? undefined,\n specFilters: specFilterParams',
),
'统计查询必须带分类与规格筛选',
)
})
test('寄卖配置:规格字段提供「支持筛选」开关并随配置保存', () => {
assert.ok(configSource.includes('支持筛选'), '缺少支持筛选开关')
assert.ok(configSource.includes('filterable: checked'), '开关必须写回配置')
assert.ok(configSource.includes('filterable: false,'), '新建字段默认不参与筛选')
})
test('筛选选项接口:按已开启寄卖的分类返回', () => {
assert.ok(
serviceSource.includes('/worker-platform/consignment/inventory-filter-options'),
'缺少筛选选项接口',
)
})
test('一键查询待匹配订单:按当前寄卖分类查询', () => {
const modalSource = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
assert.ok(modalSource.includes('categoryId: categoryId ?? undefined'), '必须把分类传给待匹配接口')
assert.ok(modalSource.includes('分类:{categoryName}'), '弹窗要标明当前分类')
assert.ok(panelSource.includes('categoryId={categoryId}'), '仓库面板必须把当前分类传给弹窗')
})