feat(consignment): 寄售仓库按寄卖分类筛选并支持动态规格过滤
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}'), '仓库面板必须把当前分类传给弹窗')
|
||||
})
|
||||
Reference in New Issue
Block a user