feat(consignment): 分类家族口径支持二级分类匹配上级寄卖库存

- 新增 consignment-category-scope 作为分类范围单一事实来源(自身 + 祖先 + 子孙,二级分类对祖先库存按系列收窄)
- 寄卖库存列表/统计/筛选项按分类范围取数,二级分类(血宴/暗潮/怒嚎)可正常进入下拉并看到挂在一级分类下的货
- 一键查询待匹配看板:订单侧与库存侧都改为分类范围过滤,二级分类页签不再空白
- 手动匹配候选与提交校验统一按分类家族取数(工单分类 + 其一级分类),跨枪型仍不参与自动匹配,人工强制匹配需填原因
- 仓库搜索关键字增加系列、规格文本与当前/上级分类名,可直接搜「血宴」「苏尔南手枪」
- 新增分类范围单测,并补充看板跨层级用例
This commit is contained in:
2026-11-15 19:47:30 +08:00
parent d7606060a4
commit 8181ef50a5
11 changed files with 882 additions and 53 deletions
@@ -8,6 +8,11 @@ import {
} from './consignment-allocation-repo.js'
import { toJsonString } from './shared.js'
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.js'
import {
normalizeConsignmentCategoryScope,
pushConsignmentCategoryScopeCondition,
type ConsignmentCategoryScope,
} from '../../services/worker-platform/consignment-category-scope.js'
export type ConsignmentInventoryRow = {
id: string | number
@@ -87,6 +92,8 @@ export type ConsignmentInventoryRow = {
created_at: string
updated_at: string
category_name?: string
/** 库存所属分类的父分类 ID(NULL = 自身就是一级分类) */
category_parent_id?: string | number | null
}
export type ConsignmentSubmissionRow = {
@@ -218,7 +225,13 @@ export async function listConsignmentInventories(filters: {
buyerName?: string | undefined
shopName?: string | undefined
inboundBatch?: string | undefined
/** 兼容旧调用:只按该分类自身过滤(等价于 selfCategoryId = categoryId 的 scope) */
categoryId?: number | undefined
/**
* 寄卖分类范围(含祖先/子孙与系列收窄)。
* 传了它就以它为准,忽略 categoryId;两者都不传 = 全量。
*/
categoryScope?: ConsignmentCategoryScope | undefined
system?: string | undefined
serverZone?: string | undefined
series?: string | undefined
@@ -256,7 +269,13 @@ export async function listConsignmentInventories(filters: {
values.push(filters.inboundBatch)
conditions.push(`ci.inbound_batch = $${values.length}`)
}
if (filters.categoryId) {
if (filters.categoryScope) {
pushConsignmentCategoryScopeCondition({
scope: normalizeConsignmentCategoryScope(filters.categoryScope),
values,
conditions,
})
} else if (filters.categoryId) {
values.push(filters.categoryId)
conditions.push(`ci.category_id = $${values.length}`)
}
@@ -286,6 +305,11 @@ export async function listConsignmentInventories(filters: {
conditions.push(
`(ci.item_name ILIKE $${values.length}
OR ci.inventory_no ILIKE $${values.length}
OR ci.series ILIKE $${values.length}
OR ci.spec_values_json ->> 'series' ILIKE $${values.length}
OR ci.spec_text ILIKE $${values.length}
OR wc.name ILIKE $${values.length}
OR parent_wc.name ILIKE $${values.length}
OR ci.bound_platform_order_id ILIKE $${values.length}
OR ci.seller_contact ILIKE $${values.length}
OR ci.seller_game_id ILIKE $${values.length}
@@ -319,7 +343,8 @@ export async function listConsignmentInventories(filters: {
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
// 计数与列表必须使用同一组 join,否则新增的提报单 / 供货打手关键字会直接报错。
const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_inventories ci LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id LEFT JOIN worker_users supplier_worker ON supplier_worker.id = ci.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id ${whereClause}`
// parent_wc(一级分类名)用于「搜索分类名」:库存挂一级、工单挂二级时两边都能搜到。
const countSql = `SELECT COUNT(*)::integer AS total FROM consignment_inventories ci LEFT JOIN work_categories wc ON wc.id = ci.category_id LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id LEFT JOIN worker_users supplier_worker ON supplier_worker.id = ci.worker_id LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id ${whereClause}`
const countRes = await query<{ total: number }>(countSql, values)
const total = countRes.rows[0]?.total || 0
@@ -332,6 +357,7 @@ export async function listConsignmentInventories(filters: {
SELECT
ci.*,
wc.name AS category_name,
wc.parent_id AS category_parent_id,
cs.submission_no AS source_submission_no,
cs.created_at AS source_submission_created_at,
@@ -348,6 +374,7 @@ export async function listConsignmentInventories(filters: {
ccs.name AS contact_supporter_name
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
LEFT JOIN worker_users supplier_worker ON supplier_worker.id = ci.worker_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id
@@ -362,7 +389,10 @@ export async function listConsignmentInventories(filters: {
export async function getConsignmentInventoryStats(
filters: {
/** 兼容旧调用:只按该分类自身过滤 */
categoryId?: number | undefined
/** 寄卖分类范围(含祖先/子孙与系列收窄);传了它就以它为准 */
categoryScope?: ConsignmentCategoryScope | undefined
specFilters?: ConsignmentInventorySpecFilter[] | undefined
dateFrom?: string | undefined
dateTo?: string | undefined
@@ -371,7 +401,13 @@ export async function getConsignmentInventoryStats(
const conditions: string[] = []
const values: unknown[] = []
if (filters.categoryId) {
if (filters.categoryScope) {
pushConsignmentCategoryScopeCondition({
scope: normalizeConsignmentCategoryScope(filters.categoryScope),
values,
conditions,
})
} else if (filters.categoryId) {
values.push(filters.categoryId)
conditions.push(`ci.category_id = $${values.length}`)
}
@@ -1153,7 +1189,10 @@ export async function returnConsignmentInventoryToStock(
/** 查询当前所有可供匹配的在库物品(status = 'in_stock' 且 allow_matching = TRUE) */
export async function listActiveConsignmentInventoriesForMatching(options?: {
gameName?: string
/** 兼容旧调用:只按该分类自身过滤 */
categoryId?: number | null
/** 寄卖分类范围(含祖先/子孙与系列收窄);传了它就以它为准 */
categoryScope?: ConsignmentCategoryScope | null
}): Promise<ConsignmentInventoryRow[]> {
// 客服看到的匹配候选要显示「寄卖单号」(提报单号),所以必须带出提报单号;
// 注意列名一律加 ci. 前缀:consignment_submissions 也有 status/game_name/category_id,
@@ -1165,7 +1204,13 @@ export async function listActiveConsignmentInventoriesForMatching(options?: {
values.push(options.gameName)
conditions.push(`ci.game_name = $${values.length}`)
}
if (options?.categoryId) {
if (options?.categoryScope) {
pushConsignmentCategoryScopeCondition({
scope: normalizeConsignmentCategoryScope(options.categoryScope),
values,
conditions,
})
} else if (options?.categoryId) {
values.push(options.categoryId)
conditions.push(`ci.category_id = $${values.length}`)
}
@@ -1173,6 +1218,8 @@ export async function listActiveConsignmentInventoriesForMatching(options?: {
const sql = `
SELECT
ci.*,
wc.name AS category_name,
wc.parent_id AS category_parent_id,
cs.submission_no AS source_submission_no,
cs.created_at AS source_submission_created_at,
@@ -1180,6 +1227,7 @@ export async function listActiveConsignmentInventoriesForMatching(options?: {
supplier_worker.phone AS live_worker_phone,
ccs.name AS contact_supporter_name
FROM consignment_inventories ci
LEFT JOIN work_categories wc ON wc.id = ci.category_id
LEFT JOIN consignment_submissions cs ON cs.id = ci.source_submission_id
LEFT JOIN worker_users supplier_worker ON supplier_worker.id = ci.worker_id
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id
@@ -29,6 +29,7 @@ import {
normalizeConsignmentProfileConfig,
resolvePriceDropSuggestion,
} from './consignment-profile-service.js'
import { resolveConsignmentCategoryScope } from './consignment-category-scope.js'
import {
buildConsignmentSpecKey,
buildConsignmentSpecText,
@@ -396,6 +397,8 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) {
const shopName = query.shopName ? String(query.shopName).trim() : undefined
const inboundBatch = query.inboundBatch ? String(query.inboundBatch).trim() : undefined
const categoryId = query.categoryId ? Number(query.categoryId) : undefined
// 分类范围:选中二级分类时同时带出祖先分类下同系列的库存(库存常挂一级分类)。
const categoryScope = await resolveConsignmentCategoryScope(categoryId).catch(() => null)
const system = query.system ? String(query.system).trim() : undefined
const serverZone = query.serverZone ? String(query.serverZone).trim() : undefined
const series = query.series ? String(query.series).trim() : undefined
@@ -414,7 +417,7 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) {
buyerName,
shopName,
inboundBatch,
categoryId,
categoryScope: categoryScope || undefined,
system,
serverZone,
series,
@@ -450,8 +453,14 @@ export function normalizeInventoryDate(rawValue: unknown) {
export async function fetchAdminConsignmentStats(query: JsonObject = {}) {
const categoryId = query.categoryId ? Number(query.categoryId) : 0
const normalizedCategoryId =
Number.isInteger(categoryId) && categoryId > 0 ? categoryId : undefined
// 统计口径必须与列表完全相同,否则「血宴」页签的卡片和表格会对不上。
const categoryScope = await resolveConsignmentCategoryScope(normalizedCategoryId).catch(
() => null,
)
return getConsignmentInventoryStats({
categoryId: Number.isInteger(categoryId) && categoryId > 0 ? categoryId : undefined,
categoryScope: categoryScope || undefined,
specFilters: normalizeInventorySpecFilters(query.specFilters),
dateFrom: normalizeInventoryDate(query.dateFrom),
dateTo: normalizeInventoryDate(query.dateTo),
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildConsignmentCategoryScope,
collectCategoryAncestorIds,
collectCategoryDescendantIds,
matchesConsignmentCategoryScope,
normalizeConsignmentCategoryScope,
pushConsignmentCategoryScopeCondition,
resolveChildCategorySeriesValues,
} from './consignment-category-scope.js'
const CATEGORIES = [
{ id: 4, name: '苏尔南手枪', parent_id: null, category_key: 'shouqiang' },
{ id: 65, name: '暗潮', parent_id: 4, category_key: 'anchao' },
{ id: 66, name: '怒嚎', parent_id: 4, category_key: 'nuhao' },
{ id: 67, name: '血宴', parent_id: 4, category_key: 'xueyan' },
{ id: 3, name: '地铁逃生', parent_id: null, category_key: 'taosheng' },
{ id: 83, name: '枪械类', parent_id: 3, category_key: 'cat_gun' },
]
test('分类范围:一级分类带全部子孙,且不做系列收窄', () => {
const scope = buildConsignmentCategoryScope({ categoryId: 4, categories: CATEGORIES })
assert.equal(scope.selfCategoryId, 4)
assert.deepEqual(scope.ancestorCategoryIds, [])
assert.deepEqual(scope.descendantCategoryIds.sort(), [65, 66, 67])
assert.deepEqual(scope.ancestorSeriesValues, [])
// 一级自身与子分类库存都可见
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 4, series: '暗潮' }), true)
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 67, series: '血宴' }), true)
// 其它分类不可见
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 3 }), false)
})
test('分类范围:二级分类带祖先,但只认同系列的一级库存', () => {
const scope = buildConsignmentCategoryScope({ categoryId: 67, categories: CATEGORIES })
assert.equal(scope.selfCategoryId, 67)
assert.deepEqual(scope.ancestorCategoryIds, [4])
assert.deepEqual(scope.descendantCategoryIds, [])
assert.deepEqual(scope.ancestorSeriesValues, ['血宴'])
// 自身分类的库存一律可见(哪怕 series 为空的历史货)
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 67, series: '' }), true)
// 一级分类下同系列可见
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 4, series: '血宴' }), true)
// 一级分类下其它枪型不可见
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 4, series: '暗潮' }), false)
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 4, series: '' }), false)
})
test('分类范围:非系列型子分类不做系列收窄', () => {
const scope = buildConsignmentCategoryScope({ categoryId: 83, categories: CATEGORIES })
assert.deepEqual(scope.ancestorSeriesValues, [])
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 3, series: '任意' }), true)
})
test('分类范围:祖先链与子孙链支持多级且有环保护', () => {
assert.deepEqual(collectCategoryAncestorIds(67, CATEGORIES), [4])
assert.deepEqual(collectCategoryDescendantIds(4, CATEGORIES).sort(), [65, 66, 67])
// 自引用父级不能死循环
assert.deepEqual(collectCategoryAncestorIds(1, [{ id: 1, parent_id: 1 }]), [])
})
test('分类范围:未选中分类时不产生任何条件', () => {
const values: unknown[] = []
const conditions: string[] = []
pushConsignmentCategoryScopeCondition({
scope: {
selfCategoryId: 0,
ancestorCategoryIds: [],
descendantCategoryIds: [],
ancestorSeriesValues: [],
},
values,
conditions,
})
assert.deepEqual(values, [])
assert.deepEqual(conditions, [])
})
test('分类范围 SQL:一级分类 = 自身 + 子孙', () => {
const values: unknown[] = []
const conditions: string[] = []
const scope = buildConsignmentCategoryScope({ categoryId: 4, categories: CATEGORIES })
pushConsignmentCategoryScopeCondition({ scope, values, conditions })
assert.equal(conditions.length, 1)
assert.equal(conditions[0], '(ci.category_id = $1 OR ci.category_id = ANY($2::bigint[]))')
assert.deepEqual(values, [4, [65, 66, 67]])
})
test('分类范围 SQL:二级分类 = 自身 + 祖先同系列', () => {
const values: unknown[] = []
const conditions: string[] = []
const scope = buildConsignmentCategoryScope({ categoryId: 67, categories: CATEGORIES })
pushConsignmentCategoryScopeCondition({ scope, values, conditions })
assert.equal(conditions.length, 1)
assert.match(
conditions[0]!,
/^\(ci\.category_id = \$1 OR \(ci\.category_id = ANY\(\$2::bigint\[\]\) AND /,
)
assert.match(conditions[0]!, /= ANY\(\$3::text\[\]\)\)\)$/)
assert.deepEqual(values, [67, [4], ['血宴']])
})
test('分类范围:系列值优先用枪型映射,其次用子分类名', () => {
assert.deepEqual(
resolveChildCategorySeriesValues({
category: { id: 67, name: '血宴' },
parent: { id: 4, category_key: 'shouqiang' },
}),
['血宴'],
)
// 新增枪型:映射里没有,但父分类是苏尔南手枪、名称也是已知枪型
assert.deepEqual(
resolveChildCategorySeriesValues({
category: { id: 999, name: '怒嚎' },
parent: { id: 4, category_key: 'shouqiang' },
}),
['怒嚎'],
)
// 非枪型子分类不收窄
assert.deepEqual(
resolveChildCategorySeriesValues({
category: { id: 83, name: '枪械类' },
parent: { id: 3, category_key: 'taosheng' },
}),
[],
)
})
test('分类范围:normalize 会去重并丢弃非法值', () => {
const scope = normalizeConsignmentCategoryScope({
selfCategoryId: 67,
ancestorCategoryIds: [4, 4, 0, -1],
descendantCategoryIds: [65, 65],
ancestorSeriesValues: ['血宴', '血宴', ' '],
})
assert.deepEqual(scope.ancestorCategoryIds, [4])
assert.deepEqual(scope.descendantCategoryIds, [65])
assert.deepEqual(scope.ancestorSeriesValues, ['血宴'])
})
test('分类范围:未传 scope 时按「仅自身」降级,保持旧调用方口径', () => {
const scope = normalizeConsignmentCategoryScope(undefined, 9)
assert.equal(scope.selfCategoryId, 9)
assert.deepEqual(scope.ancestorCategoryIds, [])
assert.deepEqual(scope.descendantCategoryIds, [])
// 仅自身:别的分类一律不可见
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 9 }), true)
assert.equal(matchesConsignmentCategoryScope(scope, { categoryId: 10 }), false)
})
@@ -0,0 +1,323 @@
import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js'
import type { WorkCategoryRow } from '../../repositories/worker-platform/types.js'
import { SUERNAN_SERIES_BY_CATEGORY, SUERNAN_PISTOL_SERIES } from './consignment-sort-priority.js'
/**
* 寄卖分类范围(单一事实来源)。
*
* 业务现实:寄卖库存几乎都挂在**一级**分类上(如「苏尔南手枪」),
* 而枪型/系列只是库存的 `series` 规格;工单却常挂在**二级**分类(如「血宴」)。
* 过去各处一律用 `category_id = 选中分类` 的严格相等判定,导致:
* - 选中「血宴」查不到任何库存(库存的 category_id 是 4);
* - 选中「血宴」的一键查询待匹配订单两侧都被过滤,看板必空。
*
* 这里把「选中分类可及的数据范围」收敛成一份定义:
* - 选中一级分类:自身 + 全部子孙分类(不限系列);
* - 选中二级分类:自身 + 祖先分类,且祖先分类下的库存必须命中该子分类对应的系列
* (例如「血宴」只认 series = 血宴 的一级库存,避免把暗潮/怒嚎一起带出来)。
*
* 所有调用方(寄卖仓库列表/统计/筛选项、待匹配看板、手动匹配候选)必须共用本模块,
* 禁止再各写一份 `category_id = $n` 的判定。
*/
export type ConsignmentCategoryScope = {
/** 选中分类;0 = 不限制(全量) */
selfCategoryId: number
/** 祖先分类 ID(由近到远) */
ancestorCategoryIds: number[]
/** 子孙分类 ID */
descendantCategoryIds: number[]
/**
* 祖先分类下的库存/工单必须命中的系列值。
* 空数组 = 祖先分类不限系列(非系列型子分类)。
*/
ancestorSeriesValues: string[]
}
export type ConsignmentCategoryScopeRow = {
categoryId: number | string | null
series?: string | null
}
export const EMPTY_CONSIGNMENT_CATEGORY_SCOPE: ConsignmentCategoryScope = {
selfCategoryId: 0,
ancestorCategoryIds: [],
descendantCategoryIds: [],
ancestorSeriesValues: [],
}
/** 未传 scope 时按「仅自身」降级,保持纯函数与旧调用方的口径不变。 */
export function normalizeConsignmentCategoryScope(
scope: ConsignmentCategoryScope | null | undefined,
fallbackCategoryId = 0,
): ConsignmentCategoryScope {
if (!scope) {
const categoryId = Math.max(0, Math.floor(Number(fallbackCategoryId) || 0))
return { ...EMPTY_CONSIGNMENT_CATEGORY_SCOPE, selfCategoryId: categoryId }
}
return {
selfCategoryId: Math.max(0, Math.floor(Number(scope.selfCategoryId) || 0)),
ancestorCategoryIds: [...new Set((scope.ancestorCategoryIds || []).map(Number))].filter(
(id) => Number.isInteger(id) && id > 0,
),
descendantCategoryIds: [...new Set((scope.descendantCategoryIds || []).map(Number))].filter(
(id) => Number.isInteger(id) && id > 0,
),
ancestorSeriesValues: [
...new Set((scope.ancestorSeriesValues || []).map((value) => String(value || '').trim())),
].filter(Boolean),
}
}
/**
* 一条数据(库存或工单)是否落在该分类范围内。
* 与 `pushConsignmentCategoryScopeCondition` 的 SQL 口径必须逐条对齐。
*/
export function matchesConsignmentCategoryScope(
scopeInput: ConsignmentCategoryScope,
row: ConsignmentCategoryScopeRow,
): boolean {
const scope = normalizeConsignmentCategoryScope(scopeInput)
if (scope.selfCategoryId <= 0) return true
const categoryId = Math.floor(Number(row.categoryId) || 0)
if (categoryId <= 0) return false
if (categoryId === scope.selfCategoryId) return true
if (scope.descendantCategoryIds.includes(categoryId)) return true
if (!scope.ancestorCategoryIds.includes(categoryId)) return false
if (scope.ancestorSeriesValues.length === 0) return true
const series = String(row.series || '').trim()
return series !== '' && scope.ancestorSeriesValues.includes(series)
}
/**
* 数据是否落在该分类「家族」内:自身 + 祖先 + 子孙,**不做系列收窄**。
*
* 与 `matchesConsignmentCategoryScope` 的区别:后者是筛选/看板口径(血宴只认同系列的一级库存),
* 本函数是「可见数据范围」口径——手动匹配弹窗要能搜到二级分类及其一级分类下的全部寄卖单
* (例如血宴工单也要能搜到挂在苏尔南手枪下的暗潮/怒嚎现货,是否可匹配由匹配明细单独判定)。
*/
export function matchesConsignmentCategoryFamily(
scopeInput: ConsignmentCategoryScope | null | undefined,
categoryIdInput: number | string | null,
): boolean {
const scope = normalizeConsignmentCategoryScope(scopeInput)
if (scope.selfCategoryId <= 0) return true
const categoryId = Math.floor(Number(categoryIdInput) || 0)
if (categoryId <= 0) return false
return (
categoryId === scope.selfCategoryId ||
scope.ancestorCategoryIds.includes(categoryId) ||
scope.descendantCategoryIds.includes(categoryId)
)
}
/**
* 把分类范围写成 SQL 条件(值一律参数绑定,保持占位符顺序)。
*
* @param column 分类列,默认 `ci.category_id`
* @param seriesColumn 系列旧列,默认 `ci.series`
* @param seriesJsonColumn 系列动态规格列,默认 `ci.spec_values_json->>'series'`
*/
export function pushConsignmentCategoryScopeCondition(input: {
scope: ConsignmentCategoryScope
values: unknown[]
conditions: string[]
/** 子查询里如果没有 ci 别名,可传入自己的列引用,如 `cs.category_id` */
column?: string | undefined
seriesColumn?: string | undefined
seriesJsonColumn?: string | null | undefined
}): void {
const scope = normalizeConsignmentCategoryScope(input.scope)
if (scope.selfCategoryId <= 0) return
const column = input.column || 'ci.category_id'
const seriesColumn = input.seriesColumn || 'ci.series'
const seriesJsonColumn =
input.seriesJsonColumn === null
? null
: (input.seriesJsonColumn ?? "ci.spec_values_json->>'series'")
const parts: string[] = []
input.values.push(scope.selfCategoryId)
parts.push(`${column} = $${input.values.length}`)
if (scope.descendantCategoryIds.length > 0) {
input.values.push(scope.descendantCategoryIds)
parts.push(`${column} = ANY($${input.values.length}::bigint[])`)
}
if (scope.ancestorCategoryIds.length > 0) {
input.values.push(scope.ancestorCategoryIds)
const ancestorIndex = input.values.length
if (scope.ancestorSeriesValues.length > 0) {
input.values.push(scope.ancestorSeriesValues)
const seriesIndex = input.values.length
const seriesExpr = seriesJsonColumn
? `COALESCE(NULLIF(${seriesColumn}, ''), ${seriesJsonColumn}, '')`
: `COALESCE(NULLIF(${seriesColumn}, ''), '')`
parts.push(
`(${column} = ANY($${ancestorIndex}::bigint[]) AND ${seriesExpr} = ANY($${seriesIndex}::text[]))`,
)
} else {
parts.push(`${column} = ANY($${ancestorIndex}::bigint[])`)
}
}
input.conditions.push(`(${parts.join(' OR ')})`)
}
/** 沿 parent_id 向上收集祖先(由近到远),带环保护。 */
export function collectCategoryAncestorIds(
categoryId: number,
categories: Array<Pick<WorkCategoryRow, 'id' | 'parent_id'>>,
): number[] {
const byId = new Map<number, Pick<WorkCategoryRow, 'id' | 'parent_id'>>()
for (const category of categories) byId.set(Number(category.id), category)
const ancestors: number[] = []
const seen = new Set<number>([categoryId])
let current = byId.get(categoryId)
while (current?.parent_id) {
const parentId = Number(current.parent_id)
if (!Number.isInteger(parentId) || parentId <= 0 || seen.has(parentId)) break
seen.add(parentId)
ancestors.push(parentId)
current = byId.get(parentId)
}
return ancestors
}
/** 向下收集子孙分类 ID(广度优先)。 */
export function collectCategoryDescendantIds(
categoryId: number,
categories: Array<Pick<WorkCategoryRow, 'id' | 'parent_id'>>,
): number[] {
const childrenByParent = new Map<number, number[]>()
for (const category of categories) {
const parentId = Number(category.parent_id || 0)
if (!parentId) continue
const list = childrenByParent.get(parentId) || []
list.push(Number(category.id))
childrenByParent.set(parentId, list)
}
const result: number[] = []
const queue = [categoryId]
const seen = new Set<number>([categoryId])
while (queue.length > 0) {
const current = queue.shift()!
for (const childId of childrenByParent.get(current) || []) {
if (seen.has(childId)) continue
seen.add(childId)
result.push(childId)
queue.push(childId)
}
}
return result
}
/**
* 二级分类对应的系列值:只有「系列型」子分类才收窄祖先分类的库存。
*
* 判定顺序:
* 1. 苏尔南三枪型映射(65/66/67)—— 存量口径;
* 2. 父分类是苏尔南手枪(category_key = shouqiang)时,用子分类名当系列值,
* 兼容后续新增的枪型分类;
* 3. 其余分类(如地铁逃生下的「护甲类」)不做系列收窄,祖先库存全部可见。
*/
export function resolveChildCategorySeriesValues(input: {
category: Pick<WorkCategoryRow, 'id' | 'name'> | null | undefined
parent: Pick<WorkCategoryRow, 'id' | 'category_key'> | null | undefined
}): string[] {
const category = input.category
if (!category) return []
const mapped = SUERNAN_SERIES_BY_CATEGORY[Number(category.id)]
if (mapped) return [mapped]
if (input.parent?.category_key === 'shouqiang') {
const name = String(category.name || '').trim()
if (name && (SUERNAN_PISTOL_SERIES as readonly string[]).includes(name)) return [name]
return []
}
return []
}
/** 用已加载的分类列表同步构建范围(便于单测与批量场景)。 */
export function buildConsignmentCategoryScope(input: {
categoryId: number
categories: Array<Pick<WorkCategoryRow, 'id' | 'name' | 'parent_id' | 'category_key'>>
}): ConsignmentCategoryScope {
const categoryId = Math.max(0, Math.floor(Number(input.categoryId) || 0))
if (categoryId <= 0) return { ...EMPTY_CONSIGNMENT_CATEGORY_SCOPE }
const byId = new Map<
number,
Pick<WorkCategoryRow, 'id' | 'name' | 'parent_id' | 'category_key'>
>()
for (const category of input.categories) byId.set(Number(category.id), category)
const self = byId.get(categoryId)
const ancestorCategoryIds = collectCategoryAncestorIds(categoryId, input.categories)
const descendantCategoryIds = collectCategoryDescendantIds(categoryId, input.categories)
const parent = ancestorCategoryIds.length > 0 ? byId.get(ancestorCategoryIds[0]!) : null
const ancestorSeriesValues =
ancestorCategoryIds.length > 0
? resolveChildCategorySeriesValues({ category: self, parent: parent || null })
: []
return {
selfCategoryId: categoryId,
ancestorCategoryIds,
descendantCategoryIds,
ancestorSeriesValues,
}
}
/** 解析分类范围(不带参数时读全部分类)。 */
export async function resolveConsignmentCategoryScope(
categoryIdInput: number | string | null | undefined,
options: {
categories?: Array<Pick<WorkCategoryRow, 'id' | 'name' | 'parent_id' | 'category_key'>>
} = {},
): Promise<ConsignmentCategoryScope> {
const categoryId = Math.max(0, Math.floor(Number(categoryIdInput) || 0))
if (categoryId <= 0) return { ...EMPTY_CONSIGNMENT_CATEGORY_SCOPE }
const categories = options.categories ?? (await listCategoriesForScope())
return buildConsignmentCategoryScope({ categoryId, categories })
}
/**
* 分类表的进程内缓存。
*
* 分类层级(尤其「苏尔南手枪 → 血宴/暗潮/怒嚎」这类固定结构)几乎不变,
* 但匹配看板会对最多 500 张工单逐单解析 profile,每次都全表读一次分类表
* 会把一次只读查询变成几百次往返。缓存 30 秒并复用同一个 in-flight Promise。
*/
const CATEGORY_CACHE_TTL_MS = 30_000
let categoryCachePromise: Promise<
Array<Pick<WorkCategoryRow, 'id' | 'name' | 'parent_id' | 'category_key'>>
> | null = null
let categoryCacheExpiresAt = 0
function listCategoriesForScope(): Promise<
Array<Pick<WorkCategoryRow, 'id' | 'name' | 'parent_id' | 'category_key'>>
> {
const now = Date.now()
if (categoryCachePromise && now < categoryCacheExpiresAt) return categoryCachePromise
categoryCachePromise = listAllWorkCategories()
.then((rows) =>
rows.map((row) => ({
id: Number(row.id),
name: row.name,
parent_id: row.parent_id ? Number(row.parent_id) : null,
category_key: row.category_key,
})),
)
.catch((error) => {
// 读失败不缓存:下次调用重新读,避免把一次抖动钉死 30 秒。
categoryCachePromise = null
categoryCacheExpiresAt = 0
throw error
})
categoryCacheExpiresAt = now + CATEGORY_CACHE_TTL_MS
return categoryCachePromise
}
/** 供后台改分类后立即生效(改完分类结构可调用清缓存)。 */
export function clearConsignmentCategoryScopeCache(): void {
categoryCachePromise = null
categoryCacheExpiresAt = 0
}
@@ -53,6 +53,10 @@ import {
import { resolveOrderTemplateSpecValues } from './consignment-template-spec-service.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
import { recordConsignmentMatchLog } from './consignment-match-log-service.js'
import {
matchesConsignmentCategoryFamily,
resolveConsignmentCategoryScope,
} from './consignment-category-scope.js'
import {
sendConsignmentMatchSuccessSms,
type ConsignmentMatchSmsPayload,
@@ -144,18 +148,47 @@ export function buildConsignmentShareReward(
}
}
/**
* 库存是否属于这份寄卖配置的「分类家族」。
*
* 业务现实:寄卖库存几乎都挂**一级**分类(苏尔南手枪 4),工单却常挂**二级**分类
* (暗潮/怒嚎/血宴)。所以判定不能只比 profileId / categoryId 相等:
* - 库存 profile 与工单解析出的 profile 相同 → 归属成立;
* - 否则看分类层级:库存分类是配置分类的**子孙** → 归属成立;
* 是配置分类的**祖先** → 必须是该配置对应的系列(血宴只认同 series=血宴 的一级库存),
* 否则暗潮/怒嚎的货会被血宴配置误纳入。
*
* 该口径必须与 `consignment-category-scope` 的 SQL 条件逐条对齐:
* 手动匹配候选、一键查询待匹配看板、自动匹配、落库校验都调用本函数。
*/
export function inventoryMatchesProfile(
inventory: ConsignmentInventoryRow,
profile: ConsignmentProfileSnapshot | null,
): boolean {
if (!profile) return true
const inventoryProfileId = inventory.profile_id ? Number(inventory.profile_id) : null
if (inventoryProfileId) return inventoryProfileId === profile.profileId
// 同一份配置:直接归属(含二级分类单独建配置、库存已固化该 profileId 的情况)。
if (inventoryProfileId && inventoryProfileId === profile.profileId) return true
const inventoryCategoryId = Number(inventory.category_id || 0)
if (inventoryCategoryId) return inventoryCategoryId === profile.categoryId
// 同一个分类却挂着另一份配置:说明库存不属于当前配置,不能靠层级放行。
if (inventoryProfileId && inventoryCategoryId && inventoryCategoryId === profile.categoryId) {
return false
}
if (inventoryCategoryId) {
if (inventoryCategoryId === profile.categoryId) return true
// 配置在一级、库存在其子分类:归属成立(子分类的系列差异交给匹配明细判定)。
if ((profile.categoryDescendantIds || []).includes(inventoryCategoryId)) return true
// 库存挂在配置的祖先分类:必须命中该配置对应系列。
if (!(profile.categoryAncestorIds || []).includes(inventoryCategoryId)) return false
const requiredSeries = profile.categoryAncestorSeriesValues || []
if (requiredSeries.length === 0) return true
const series = String(resolveConsignmentInventorySpecValues(inventory).series || '').trim()
return series !== '' && requiredSeries.includes(series)
}
// 历史库存没有 profile_id/category_id(手工入库时代的数据):内置五字段配置可以
// 交给旧匹配器的品类门禁兜底,避免这些货在新匹配弹窗里彻底消失;
// 含自定义字段的配置无法判断归属,仍然要求显式分类。
if (inventoryProfileId) return false
return !profile.config.specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
}
@@ -1570,23 +1603,29 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
productName: workOrder.product_name,
parsedSeries: String(orderValues.series || ''),
})
// 手动匹配的「数据范围」= 工单分类 + 其一级分类(分类家族)。
const orderCategoryScope = await resolveConsignmentCategoryScope(workOrder.category_id).catch(
() => null,
)
const inventories = (await listActiveConsignmentInventoriesForMatching())
.filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) &&
Number(inventory.remaining_quantity ?? 1) > 0 &&
inventory.status === 'in_stock' &&
inventory.allow_matching &&
!isRematchExcludedInventory(workOrder, inventory),
)
// 跨枪型不展示、不参与匹配:暗潮单看不到怒嚎/血宴库存,反之亦然。
.filter(
(inventory) =>
!isCrossSeriesInventory(
orderSeriesInfo,
String(resolveConsignmentInventorySpecValues(inventory).series || ''),
),
)
/**
* 数据范围以「工单分类 + 其一级分类」为准(分类家族),而不是按 profileId 严格相等:
* 血宴(67) 的工单要能搜到挂在苏尔南手枪(4) 下的寄卖单;
* 反之 4 的工单也要能搜到 65/66/67 名下的现货。
* 是否可匹配仍由 `evaluateInventoryMatch` 逐字段判定(枪型不同会标成冲突,需填原因)。
*/
.filter((inventory) => {
if (matchesConsignmentCategoryFamily(orderCategoryScope, inventory.category_id)) return true
// 历史库存没有 category_id:沿用 profile 归属兜底,避免这些货在弹窗里彻底消失。
return !Number(inventory.category_id || 0) && inventoryMatchesProfile(inventory, profile)
})
const evaluated = inventories
.map((inventory) => {
const evaluation = evaluateInventoryMatch(workOrder, inventory, profile, {
@@ -1758,21 +1797,24 @@ async function runManualConsignmentMatch(
productName: workOrder.product_name,
parsedSeries: String(orderValues.series || ''),
})
// 提交兜底与候选弹窗同口径:数据范围 = 工单分类 + 其一级分类(分类家族)。
const submitCategoryScope = await resolveConsignmentCategoryScope(workOrder.category_id).catch(
() => null,
)
const inventories = (await listActiveConsignmentInventoriesForMatching())
.filter(
(inventory) =>
inventoryMatchesProfile(inventory, profile) &&
Number(inventory.remaining_quantity ?? 1) > 0 &&
!isRematchExcludedInventory(workOrder, inventory),
)
// 接口兜底:即使前端绕过候选列表直接传库存 id,也不允许跨枪型落库。
.filter(
(inventory) =>
!isCrossSeriesInventory(
orderSeriesInfo,
String(resolveConsignmentInventorySpecValues(inventory).series || ''),
),
)
.filter((inventory) => {
if (matchesConsignmentCategoryFamily(submitCategoryScope, inventory.category_id)) return true
return !Number(inventory.category_id || 0) && inventoryMatchesProfile(inventory, profile)
})
// 注意:这里不再按跨枪型硬过滤。人工匹配允许在「分类家族」范围内指定现货
// (例如血宴工单用苏尔南手枪下暗潮的货),但跨枪型会由 evaluateInventoryMatch
// 判成 conflict,必须同时填写 allowNonExact + 原因才能真正落库。
// 自动匹配仍在 `pickCandidateInventories` 里保留跨枪型排除,口径不变。
const inventoryIds = [...new Set(input.inventoryIds.map((id) => Number(id)))]
const selected = inventoryIds
.map((id) => inventories.find((inventory) => Number(inventory.id) === id))
@@ -825,3 +825,102 @@ test('看板自动匹配口径:订单写清系统且有高配库存时允许
assert.equal(result.autoMatchableCount, 1)
assert.equal(result.partialCount, 1, '非精确但数量可行要计入非精确')
})
test('看板分类范围:选中二级「血宴」时,一级分类下同系列库存参与、其它系列排除', () => {
const profile = buildProfile({
profileId: 1,
categoryId: 4,
categoryName: '苏尔南手枪',
config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: BUILT_IN_SPECS },
})
const bloodBanOrder = buildOrder({
id: 601,
product_name: '安卓血宴1钻',
category_id: 67,
material_json: JSON.stringify({ collect: { fields: { serverZone: '安卓' } } }),
})
const bloodBanInventory = buildInventory({
id: 611,
category_id: 4,
item_name: '安卓血宴1钻 苏尔南',
system: '安卓',
server_zone: '互通',
series: '血宴',
diamond_level: 1,
brilliance_count: 0,
})
const darkTideInventory = buildInventory({
id: 612,
category_id: 4,
item_name: '安卓暗潮1钻 苏尔南',
system: '安卓',
server_zone: '互通',
series: '暗潮',
diamond_level: 1,
brilliance_count: 0,
})
const result = buildPendingConsignmentMatchPairs({
orders: [bloodBanOrder],
inventories: [bloodBanInventory, darkTideInventory],
profileByOrderId: new Map([[601, profile]]),
categoryId: 67,
categoryScope: {
selfCategoryId: 67,
ancestorCategoryIds: [4],
descendantCategoryIds: [],
ancestorSeriesValues: ['血宴'],
},
})
assert.equal(result.pendingOrderCount, 1, '血宴工单必须进入血宴页签')
assert.equal(result.inStockInventoryCount, 1, '一级分类下只有同系列库存计入')
assert.deepEqual(
result.pairs.map((pair) => pair.inventoryId),
[611],
)
})
test('看板分类范围:选中一级「苏尔南手枪」时带上子分类工单', () => {
const profile = buildProfile({
profileId: 1,
categoryId: 4,
categoryName: '苏尔南手枪',
config: { ...DEFAULT_CONSIGNMENT_PROFILE_CONFIG, specs: BUILT_IN_SPECS },
})
const bloodBanOrder = buildOrder({
id: 701,
product_name: '安卓血宴1钻',
category_id: 67,
material_json: JSON.stringify({ collect: { fields: { serverZone: '安卓' } } }),
})
const bloodBanInventory = buildInventory({
id: 711,
category_id: 4,
item_name: '安卓血宴1钻 苏尔南',
system: '安卓',
server_zone: '互通',
series: '血宴',
diamond_level: 1,
brilliance_count: 0,
})
const result = buildPendingConsignmentMatchPairs({
orders: [bloodBanOrder],
inventories: [bloodBanInventory],
profileByOrderId: new Map([[701, profile]]),
categoryId: 4,
categoryScope: {
selfCategoryId: 4,
ancestorCategoryIds: [],
descendantCategoryIds: [65, 66, 67],
ancestorSeriesValues: [],
},
})
assert.equal(result.pendingOrderCount, 1)
assert.deepEqual(
result.pairs.map((pair) => pair.workOrderId),
[701],
)
})
@@ -34,6 +34,12 @@ import {
type ConsignmentOrderSeriesInfo,
} from './consignment-sort-priority.js'
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
import {
matchesConsignmentCategoryScope,
normalizeConsignmentCategoryScope,
resolveConsignmentCategoryScope,
type ConsignmentCategoryScope,
} from './consignment-category-scope.js'
export type PendingPistolMatchPair = {
workOrderId: number
@@ -361,6 +367,11 @@ export function buildPendingConsignmentMatchPairs(input: {
orderSpecOverrideByOrderId?: Map<number, ConsignmentSpecValues | null> | undefined
/** 只统计该寄卖分类下的订单与库存;0 / 未传 = 全部 */
categoryId?: number | undefined
/**
* 分类范围(含祖先/子孙与系列收窄),由调用方用 `resolveConsignmentCategoryScope` 解析。
* 传了它就以它为准;只传 categoryId 时按「仅自身」处理,保持纯函数与旧调用方口径不变。
*/
categoryScope?: ConsignmentCategoryScope | undefined
/** 只统计这一条库存(库存侧「匹配订单」用) */
inventoryId?: number | undefined
keyword?: string | undefined
@@ -369,6 +380,7 @@ export function buildPendingConsignmentMatchPairs(input: {
ordersTruncated?: boolean | undefined
}): PendingConsignmentMatchResult {
const categoryId = Math.max(0, Math.floor(Number(input.categoryId) || 0))
const categoryScope = normalizeConsignmentCategoryScope(input.categoryScope, categoryId)
const inventoryId = Math.max(0, Math.floor(Number(input.inventoryId) || 0))
const keyword = String(input.keyword || '')
.trim()
@@ -411,16 +423,21 @@ export function buildPendingConsignmentMatchPairs(input: {
}
const remainingQuantity = sharingOrder ? 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
}
const orderSpecOverride = input.orderSpecOverrideByOrderId?.get(Number(order.id)) ?? null
const { orderValues, seriesInfo: orderSeriesInfo } = resolvePendingOrderSpecs(
order,
orderSpecOverride,
)
// 按寄卖分类范围筛选:工单常挂二级分类(血宴),库存挂一级分类(苏尔南手枪),
// 因此必须用「选中分类 + 祖先/子孙 + 系列收窄」的统一口径,不能再严格相等。
if (categoryScope.selfCategoryId > 0) {
const orderCategoryId = Number(order.category_id || 0) || (profile?.categoryId ?? 0)
const inScope = matchesConsignmentCategoryScope(categoryScope, {
categoryId: orderCategoryId,
series: String(orderValues.series ?? ''),
})
if (!inScope) continue
}
preparedOrders.push({
order,
profile,
@@ -440,8 +457,15 @@ export function buildPendingConsignmentMatchPairs(input: {
if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
if (inventoryId > 0 && Number(inventory.id) !== inventoryId) return false
// 库存必须属于当前寄卖分类;没有分类的历史货无法判断归属,按分类看板时排除
if (categoryId > 0 && Number(inventory.category_id || 0) !== categoryId) return false
// 库存必须落在当前分类范围内:库存通常挂一级分类,选中「血宴」时按 series 收窄。
// 没有分类的历史货无法判断归属,按分类看板时排除。
if (categoryScope.selfCategoryId > 0) {
const inScope = matchesConsignmentCategoryScope(categoryScope, {
categoryId: inventory.category_id,
series: String(resolveConsignmentInventorySpecValues(inventory).series || ''),
})
if (!inScope) return false
}
return true
})
@@ -735,12 +759,16 @@ export async function listPendingConsignmentMatches(
}),
)
// 分类范围:选中二级分类(血宴)时同时覆盖其一级分类下同系列的库存与订单。
const categoryScope = await resolveConsignmentCategoryScope(input.categoryId).catch(() => null)
return buildPendingConsignmentMatchPairs({
orders: items,
inventories,
profileByOrderId,
orderSpecOverrideByOrderId,
categoryId: input.categoryId,
categoryScope: categoryScope || undefined,
inventoryId: input.inventoryId,
keyword: input.keyword,
includeConflicts: input.includeConflicts,
@@ -3,6 +3,8 @@ import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { nowIso } from '../../utils/time.js'
import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.js'
import { resolveConsignmentCategoryScope } from './consignment-category-scope.js'
import { SUERNAN_SERIES_BY_CATEGORY } from './consignment-sort-priority.js'
import {
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
DEFAULT_CONSIGNMENT_PRICING,
@@ -137,6 +139,17 @@ export type ConsignmentProfileSnapshot = {
versionId: number
versionNo: number
config: ConsignmentProfileConfig
/**
* 该配置可覆盖的分类范围(祖先/子孙 + 系列收窄)。
*
* 寄卖库存大多挂在**一级**分类(如「苏尔南手枪」4),配置也可能建在二级分类上;
* 只有带上范围才能判断「一级库存是否属于这份配置」。
* 缺省时消费方按「仅 categoryId 自身」降级,保证旧调用方与测试桩不受影响。
*/
categoryAncestorIds?: number[]
categoryDescendantIds?: number[]
/** 祖先分类下需要命中的系列值(空 = 祖先不限系列) */
categoryAncestorSeriesValues?: string[]
}
export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
@@ -667,6 +680,45 @@ export function buildConsignmentInventoryFilterFields(
})
}
/**
* 继承型二级分类是否「有货可查」。
*
* 库存几乎都挂在**一级**分类上(如 4 苏尔南手枪),二级分类只是系列维度;
* 所以判定时必须把祖先分类里**同系列**的库存算进来,否则血宴/暗潮/怒嚎永远为 0、
* 下拉里选不到——这正是「血宴分类匹配不到库存」的根因之一。
*/
export function resolveInheritedCategoryHasInventory(input: {
categoryId: number
parentId: number
inventoryCountByCategoryId: Map<number, number>
inventorySeriesCountByCategoryId: Map<number, Map<string, number>>
/** 该二级分类对应的系列值;缺省按「分类名」推断 */
categoryName?: string
}): boolean {
const categoryId = Number(input.categoryId || 0)
if (categoryId <= 0) return false
// 该分类自身挂了库存 → 直接算有货(无论挂在几级分类)。
if ((input.inventoryCountByCategoryId.get(categoryId) || 0) > 0) return true
const parentId = Number(input.parentId || 0)
if (parentId <= 0) return false
const seriesValues =
input.categoryName === undefined
? []
: [
SUERNAN_SERIES_BY_CATEGORY[categoryId],
...(input.categoryName ? [String(input.categoryName).trim()] : []),
].filter((value): value is string => Boolean(value))
const bySeries = input.inventorySeriesCountByCategoryId.get(parentId)
if (!bySeries) return false
if (seriesValues.length === 0) {
// 非系列型子分类:祖先分类有货就算有内容。
for (const count of bySeries.values()) if (count > 0) return true
return (input.inventoryCountByCategoryId.get(parentId) || 0) > 0
}
return seriesValues.some((series) => (bySeries.get(series) || 0) > 0)
}
/** 苏尔南手枪固定在筛选项第一位;其余分类保持配置里的排序。 */
export function sortConsignmentInventoryFilterCategories<T extends { categoryKey: string }>(
items: T[],
@@ -700,17 +752,37 @@ export async function listAdminConsignmentInventoryFilterOptions(): Promise<{
),
])
// 继承型二级分类只有真的有库存时才出现在筛选项里:
// 否则会出现「暗潮 / 怒嚎 / 血宴」这类系列子分类,点进去永远是空列表。
const inventoryCounts = await query<{ category_id: number; total: number }>(
`SELECT category_id, COUNT(*)::integer AS total
// 继承型二级分类只有在「它自己能查到货」时才进筛选项(否则点进去永远空列表)。
// 注意库存几乎都挂一级分类,所以「能查到货」必须按分类范围算:
// 血宴(67) 的库存其实是 4 + series=血宴,只看 category_id=67 会永远为 0。
const inventoryCounts = await query<{
category_id: number
series: string | null
total: number
}>(
`SELECT category_id,
COALESCE(NULLIF(series, ''), spec_values_json ->> 'series', '') AS series,
COUNT(*)::integer AS total
FROM consignment_inventories
WHERE category_id IS NOT NULL
GROUP BY category_id`,
GROUP BY category_id, COALESCE(NULLIF(series, ''), spec_values_json ->> 'series', '')`,
)
/** categoryId → 该分类自身的库存条数(不分系列) */
const inventoryCountByCategoryId = new Map<number, number>()
/** categoryId → series → 条数 */
const inventorySeriesCountByCategoryId = new Map<number, Map<string, number>>()
for (const row of inventoryCounts.rows) {
inventoryCountByCategoryId.set(Number(row.category_id), Number(row.total || 0))
const categoryId = Number(row.category_id)
const total = Number(row.total || 0)
inventoryCountByCategoryId.set(
categoryId,
(inventoryCountByCategoryId.get(categoryId) || 0) + total,
)
const series = String(row.series || '').trim()
if (!series) continue
const bySeries = inventorySeriesCountByCategoryId.get(categoryId) || new Map<string, number>()
bySeries.set(series, (bySeries.get(series) || 0) + total)
inventorySeriesCountByCategoryId.set(categoryId, bySeries)
}
const profileByCategoryId = new Map<number, ConsignmentProfileRow>()
@@ -736,7 +808,19 @@ export async function listAdminConsignmentInventoryFilterOptions(): Promise<{
: undefined
const effectiveProfile = ownProfile || inheritedProfile
if (!effectiveProfile) continue
if (!ownProfile && !inventoryCountByCategoryId.get(Number(category.id))) continue
// 继承型子分类:祖先分类下「同系列」有货才算有内容,避免点进去空列表。
if (
!ownProfile &&
!resolveInheritedCategoryHasInventory({
categoryId: Number(category.id),
parentId: parentCategory ? Number(parentCategory.id) : 0,
inventoryCountByCategoryId,
inventorySeriesCountByCategoryId,
categoryName: category.name,
})
) {
continue
}
const config = resolvePublishedProfileConfig(effectiveProfile)
items.push({
@@ -783,6 +867,9 @@ export async function resolveConsignmentProfileForCategory(
)
const row = result.rows[0]
if (!row || !row.current_version_id) continue
// 分类范围以「配置所属分类」为基准,而不是请求进来的工单分类:
// 配置建在一级(苏尔南手枪)时要覆盖其子分类库存;建在二级时要回看一级同系列库存。
const scope = await resolveConsignmentCategoryScope(Number(row.category_id)).catch(() => null)
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
@@ -790,6 +877,13 @@ export async function resolveConsignmentProfileForCategory(
versionId: Number(row.current_version_id),
versionNo: Number(row.current_version_no || 1),
config: resolvePublishedProfileConfig(row),
...(scope
? {
categoryAncestorIds: scope.ancestorCategoryIds,
categoryDescendantIds: scope.descendantCategoryIds,
categoryAncestorSeriesValues: scope.ancestorSeriesValues,
}
: {}),
}
}
return null
@@ -806,6 +900,7 @@ export async function getPublishedConsignmentProfileById(
)
const row = result.rows[0]
if (!row || !row.current_version_id) return null
const scope = await resolveConsignmentCategoryScope(Number(row.category_id)).catch(() => null)
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
@@ -813,6 +908,13 @@ export async function getPublishedConsignmentProfileById(
versionId: Number(row.current_version_id),
versionNo: Number(row.current_version_no || 1),
config: resolvePublishedProfileConfig(row),
...(scope
? {
categoryAncestorIds: scope.ancestorCategoryIds,
categoryDescendantIds: scope.descendantCategoryIds,
categoryAncestorSeriesValues: scope.ancestorSeriesValues,
}
: {}),
}
}