feat(consignment): 分类家族口径支持二级分类匹配上级寄卖库存
- 新增 consignment-category-scope 作为分类范围单一事实来源(自身 + 祖先 + 子孙,二级分类对祖先库存按系列收窄) - 寄卖库存列表/统计/筛选项按分类范围取数,二级分类(血宴/暗潮/怒嚎)可正常进入下拉并看到挂在一级分类下的货 - 一键查询待匹配看板:订单侧与库存侧都改为分类范围过滤,二级分类页签不再空白 - 手动匹配候选与提交校验统一按分类家族取数(工单分类 + 其一级分类),跨枪型仍不参与自动匹配,人工强制匹配需填原因 - 仓库搜索关键字增加系列、规格文本与当前/上级分类名,可直接搜「血宴」「苏尔南手枪」 - 新增分类范围单测,并补充看板跨层级用例
This commit is contained in:
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function ConsignmentPendingMatchModal({
|
||||
onClose,
|
||||
categoryId,
|
||||
categoryName,
|
||||
categoryParentName,
|
||||
canMatch = false,
|
||||
onMatched,
|
||||
}: {
|
||||
@@ -47,6 +48,8 @@ export default function ConsignmentPendingMatchModal({
|
||||
/** 只统计该寄卖分类;不传 = 全部 */
|
||||
categoryId?: number | null
|
||||
categoryName?: string
|
||||
/** 二级分类的上级分类名:用于说明「会带上上级同系列库存」 */
|
||||
categoryParentName?: string
|
||||
/** 有 consignment.fulfill 权限时才显示「选择」列 */
|
||||
canMatch?: boolean
|
||||
/** 快速匹配成功后通知外层刷新库存 */
|
||||
@@ -297,9 +300,15 @@ export default function ConsignmentPendingMatchModal({
|
||||
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货。'
|
||||
}
|
||||
description={
|
||||
canMatch
|
||||
? '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之;跨枪型不展示。点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
|
||||
: '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序;当前账号没有履约权限,只能查看。'
|
||||
categoryParentName
|
||||
? `「${categoryName}」是二级分类:会连同上级「${categoryParentName}」中同系列的库存一起匹配(跨枪型不参与)。${
|
||||
canMatch
|
||||
? '点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。'
|
||||
: '当前账号没有履约权限,只能查看。'
|
||||
}`
|
||||
: canMatch
|
||||
? '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之;跨枪型不展示。点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
|
||||
: '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序;当前账号没有履约权限,只能查看。'
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -260,16 +260,21 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
/>
|
||||
) : null}
|
||||
{!candidatesQuery.isLoading && !candidatesQuery.isError ? (
|
||||
<Space wrap size={8} style={{ marginBottom: 12 }}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手"
|
||||
style={{ width: 320 }}
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
显示 {visibleOptions.length} / 候选 {options.length} 条
|
||||
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 12 }}>
|
||||
<Space wrap size={8}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手"
|
||||
style={{ width: 320 }}
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
显示 {visibleOptions.length} / 候选 {options.length} 条
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
候选范围:本工单分类及其上级分类下的全部在库寄卖单(跨枪型会标为不匹配,需填原因才能强制匹配)。
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
@@ -1026,6 +1026,12 @@ export default function ConsignmentInventoryPanel() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{activeFilterCategory?.parentId ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
含上级「{activeFilterCategory.parentName || '一级分类'}」同系列
|
||||
{activeFilterCategory.name} 库存
|
||||
</Text>
|
||||
) : null}
|
||||
{dynamicFilters.map((field) => {
|
||||
const value = specFilters[field.key]
|
||||
if (field.type === 'select' || field.type === 'radio') {
|
||||
@@ -1640,6 +1646,9 @@ export default function ConsignmentInventoryPanel() {
|
||||
open={pendingMatchOpen}
|
||||
categoryId={categoryId}
|
||||
categoryName={activeFilterCategory?.name}
|
||||
categoryParentName={
|
||||
activeFilterCategory?.parentId ? activeFilterCategory.parentName : undefined
|
||||
}
|
||||
canMatch={canFulfillConsignment}
|
||||
onClose={() => setPendingMatchOpen(false)}
|
||||
onMatched={() => void inventoriesQuery.refetch()}
|
||||
|
||||
Reference in New Issue
Block a user