feat(consignment): 分类家族口径支持二级分类匹配上级寄卖库存
- 新增 consignment-category-scope 作为分类范围单一事实来源(自身 + 祖先 + 子孙,二级分类对祖先库存按系列收窄) - 寄卖库存列表/统计/筛选项按分类范围取数,二级分类(血宴/暗潮/怒嚎)可正常进入下拉并看到挂在一级分类下的货 - 一键查询待匹配看板:订单侧与库存侧都改为分类范围过滤,二级分类页签不再空白 - 手动匹配候选与提交校验统一按分类家族取数(工单分类 + 其一级分类),跨枪型仍不参与自动匹配,人工强制匹配需填原因 - 仓库搜索关键字增加系列、规格文本与当前/上级分类名,可直接搜「血宴」「苏尔南手枪」 - 新增分类范围单测,并补充看板跨层级用例
This commit is contained in:
@@ -8,6 +8,11 @@ import {
|
|||||||
} from './consignment-allocation-repo.js'
|
} from './consignment-allocation-repo.js'
|
||||||
import { toJsonString } from './shared.js'
|
import { toJsonString } from './shared.js'
|
||||||
import { isWorkOrderGiftDepositForfeitable } from '../../domain/work-order-acceptance-mode.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 = {
|
export type ConsignmentInventoryRow = {
|
||||||
id: string | number
|
id: string | number
|
||||||
@@ -87,6 +92,8 @@ export type ConsignmentInventoryRow = {
|
|||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
category_name?: string
|
category_name?: string
|
||||||
|
/** 库存所属分类的父分类 ID(NULL = 自身就是一级分类) */
|
||||||
|
category_parent_id?: string | number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConsignmentSubmissionRow = {
|
export type ConsignmentSubmissionRow = {
|
||||||
@@ -218,7 +225,13 @@ export async function listConsignmentInventories(filters: {
|
|||||||
buyerName?: string | undefined
|
buyerName?: string | undefined
|
||||||
shopName?: string | undefined
|
shopName?: string | undefined
|
||||||
inboundBatch?: string | undefined
|
inboundBatch?: string | undefined
|
||||||
|
/** 兼容旧调用:只按该分类自身过滤(等价于 selfCategoryId = categoryId 的 scope) */
|
||||||
categoryId?: number | undefined
|
categoryId?: number | undefined
|
||||||
|
/**
|
||||||
|
* 寄卖分类范围(含祖先/子孙与系列收窄)。
|
||||||
|
* 传了它就以它为准,忽略 categoryId;两者都不传 = 全量。
|
||||||
|
*/
|
||||||
|
categoryScope?: ConsignmentCategoryScope | undefined
|
||||||
system?: string | undefined
|
system?: string | undefined
|
||||||
serverZone?: string | undefined
|
serverZone?: string | undefined
|
||||||
series?: string | undefined
|
series?: string | undefined
|
||||||
@@ -256,7 +269,13 @@ export async function listConsignmentInventories(filters: {
|
|||||||
values.push(filters.inboundBatch)
|
values.push(filters.inboundBatch)
|
||||||
conditions.push(`ci.inbound_batch = $${values.length}`)
|
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)
|
values.push(filters.categoryId)
|
||||||
conditions.push(`ci.category_id = $${values.length}`)
|
conditions.push(`ci.category_id = $${values.length}`)
|
||||||
}
|
}
|
||||||
@@ -286,6 +305,11 @@ export async function listConsignmentInventories(filters: {
|
|||||||
conditions.push(
|
conditions.push(
|
||||||
`(ci.item_name ILIKE $${values.length}
|
`(ci.item_name ILIKE $${values.length}
|
||||||
OR ci.inventory_no 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.bound_platform_order_id ILIKE $${values.length}
|
||||||
OR ci.seller_contact ILIKE $${values.length}
|
OR ci.seller_contact ILIKE $${values.length}
|
||||||
OR ci.seller_game_id 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 ')}` : ''
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||||
// 计数与列表必须使用同一组 join,否则新增的提报单 / 供货打手关键字会直接报错。
|
// 计数与列表必须使用同一组 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 countRes = await query<{ total: number }>(countSql, values)
|
||||||
const total = countRes.rows[0]?.total || 0
|
const total = countRes.rows[0]?.total || 0
|
||||||
|
|
||||||
@@ -332,6 +357,7 @@ export async function listConsignmentInventories(filters: {
|
|||||||
SELECT
|
SELECT
|
||||||
ci.*,
|
ci.*,
|
||||||
wc.name AS category_name,
|
wc.name AS category_name,
|
||||||
|
wc.parent_id AS category_parent_id,
|
||||||
cs.submission_no AS source_submission_no,
|
cs.submission_no AS source_submission_no,
|
||||||
|
|
||||||
cs.created_at AS source_submission_created_at,
|
cs.created_at AS source_submission_created_at,
|
||||||
@@ -348,6 +374,7 @@ export async function listConsignmentInventories(filters: {
|
|||||||
ccs.name AS contact_supporter_name
|
ccs.name AS contact_supporter_name
|
||||||
FROM consignment_inventories ci
|
FROM consignment_inventories ci
|
||||||
LEFT JOIN work_categories wc ON wc.id = ci.category_id
|
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 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 worker_users supplier_worker ON supplier_worker.id = ci.worker_id
|
||||||
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_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(
|
export async function getConsignmentInventoryStats(
|
||||||
filters: {
|
filters: {
|
||||||
|
/** 兼容旧调用:只按该分类自身过滤 */
|
||||||
categoryId?: number | undefined
|
categoryId?: number | undefined
|
||||||
|
/** 寄卖分类范围(含祖先/子孙与系列收窄);传了它就以它为准 */
|
||||||
|
categoryScope?: ConsignmentCategoryScope | undefined
|
||||||
specFilters?: ConsignmentInventorySpecFilter[] | undefined
|
specFilters?: ConsignmentInventorySpecFilter[] | undefined
|
||||||
dateFrom?: string | undefined
|
dateFrom?: string | undefined
|
||||||
dateTo?: string | undefined
|
dateTo?: string | undefined
|
||||||
@@ -371,7 +401,13 @@ export async function getConsignmentInventoryStats(
|
|||||||
const conditions: string[] = []
|
const conditions: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
|
|
||||||
if (filters.categoryId) {
|
if (filters.categoryScope) {
|
||||||
|
pushConsignmentCategoryScopeCondition({
|
||||||
|
scope: normalizeConsignmentCategoryScope(filters.categoryScope),
|
||||||
|
values,
|
||||||
|
conditions,
|
||||||
|
})
|
||||||
|
} else if (filters.categoryId) {
|
||||||
values.push(filters.categoryId)
|
values.push(filters.categoryId)
|
||||||
conditions.push(`ci.category_id = $${values.length}`)
|
conditions.push(`ci.category_id = $${values.length}`)
|
||||||
}
|
}
|
||||||
@@ -1153,7 +1189,10 @@ export async function returnConsignmentInventoryToStock(
|
|||||||
/** 查询当前所有可供匹配的在库物品(status = 'in_stock' 且 allow_matching = TRUE) */
|
/** 查询当前所有可供匹配的在库物品(status = 'in_stock' 且 allow_matching = TRUE) */
|
||||||
export async function listActiveConsignmentInventoriesForMatching(options?: {
|
export async function listActiveConsignmentInventoriesForMatching(options?: {
|
||||||
gameName?: string
|
gameName?: string
|
||||||
|
/** 兼容旧调用:只按该分类自身过滤 */
|
||||||
categoryId?: number | null
|
categoryId?: number | null
|
||||||
|
/** 寄卖分类范围(含祖先/子孙与系列收窄);传了它就以它为准 */
|
||||||
|
categoryScope?: ConsignmentCategoryScope | null
|
||||||
}): Promise<ConsignmentInventoryRow[]> {
|
}): Promise<ConsignmentInventoryRow[]> {
|
||||||
// 客服看到的匹配候选要显示「寄卖单号」(提报单号),所以必须带出提报单号;
|
// 客服看到的匹配候选要显示「寄卖单号」(提报单号),所以必须带出提报单号;
|
||||||
// 注意列名一律加 ci. 前缀:consignment_submissions 也有 status/game_name/category_id,
|
// 注意列名一律加 ci. 前缀:consignment_submissions 也有 status/game_name/category_id,
|
||||||
@@ -1165,7 +1204,13 @@ export async function listActiveConsignmentInventoriesForMatching(options?: {
|
|||||||
values.push(options.gameName)
|
values.push(options.gameName)
|
||||||
conditions.push(`ci.game_name = $${values.length}`)
|
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)
|
values.push(options.categoryId)
|
||||||
conditions.push(`ci.category_id = $${values.length}`)
|
conditions.push(`ci.category_id = $${values.length}`)
|
||||||
}
|
}
|
||||||
@@ -1173,6 +1218,8 @@ export async function listActiveConsignmentInventoriesForMatching(options?: {
|
|||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
ci.*,
|
ci.*,
|
||||||
|
wc.name AS category_name,
|
||||||
|
wc.parent_id AS category_parent_id,
|
||||||
cs.submission_no AS source_submission_no,
|
cs.submission_no AS source_submission_no,
|
||||||
|
|
||||||
cs.created_at AS source_submission_created_at,
|
cs.created_at AS source_submission_created_at,
|
||||||
@@ -1180,6 +1227,7 @@ export async function listActiveConsignmentInventoriesForMatching(options?: {
|
|||||||
supplier_worker.phone AS live_worker_phone,
|
supplier_worker.phone AS live_worker_phone,
|
||||||
ccs.name AS contact_supporter_name
|
ccs.name AS contact_supporter_name
|
||||||
FROM consignment_inventories ci
|
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 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 worker_users supplier_worker ON supplier_worker.id = ci.worker_id
|
||||||
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id
|
LEFT JOIN consignment_contact_supporters ccs ON ccs.id = ci.contact_supporter_id
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
normalizeConsignmentProfileConfig,
|
normalizeConsignmentProfileConfig,
|
||||||
resolvePriceDropSuggestion,
|
resolvePriceDropSuggestion,
|
||||||
} from './consignment-profile-service.js'
|
} from './consignment-profile-service.js'
|
||||||
|
import { resolveConsignmentCategoryScope } from './consignment-category-scope.js'
|
||||||
import {
|
import {
|
||||||
buildConsignmentSpecKey,
|
buildConsignmentSpecKey,
|
||||||
buildConsignmentSpecText,
|
buildConsignmentSpecText,
|
||||||
@@ -396,6 +397,8 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) {
|
|||||||
const shopName = query.shopName ? String(query.shopName).trim() : undefined
|
const shopName = query.shopName ? String(query.shopName).trim() : undefined
|
||||||
const inboundBatch = query.inboundBatch ? String(query.inboundBatch).trim() : undefined
|
const inboundBatch = query.inboundBatch ? String(query.inboundBatch).trim() : undefined
|
||||||
const categoryId = query.categoryId ? Number(query.categoryId) : 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 system = query.system ? String(query.system).trim() : undefined
|
||||||
const serverZone = query.serverZone ? String(query.serverZone).trim() : undefined
|
const serverZone = query.serverZone ? String(query.serverZone).trim() : undefined
|
||||||
const series = query.series ? String(query.series).trim() : undefined
|
const series = query.series ? String(query.series).trim() : undefined
|
||||||
@@ -414,7 +417,7 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) {
|
|||||||
buyerName,
|
buyerName,
|
||||||
shopName,
|
shopName,
|
||||||
inboundBatch,
|
inboundBatch,
|
||||||
categoryId,
|
categoryScope: categoryScope || undefined,
|
||||||
system,
|
system,
|
||||||
serverZone,
|
serverZone,
|
||||||
series,
|
series,
|
||||||
@@ -450,8 +453,14 @@ export function normalizeInventoryDate(rawValue: unknown) {
|
|||||||
|
|
||||||
export async function fetchAdminConsignmentStats(query: JsonObject = {}) {
|
export async function fetchAdminConsignmentStats(query: JsonObject = {}) {
|
||||||
const categoryId = query.categoryId ? Number(query.categoryId) : 0
|
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({
|
return getConsignmentInventoryStats({
|
||||||
categoryId: Number.isInteger(categoryId) && categoryId > 0 ? categoryId : undefined,
|
categoryScope: categoryScope || undefined,
|
||||||
specFilters: normalizeInventorySpecFilters(query.specFilters),
|
specFilters: normalizeInventorySpecFilters(query.specFilters),
|
||||||
dateFrom: normalizeInventoryDate(query.dateFrom),
|
dateFrom: normalizeInventoryDate(query.dateFrom),
|
||||||
dateTo: normalizeInventoryDate(query.dateTo),
|
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 { resolveOrderTemplateSpecValues } from './consignment-template-spec-service.js'
|
||||||
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
||||||
import { recordConsignmentMatchLog } from './consignment-match-log-service.js'
|
import { recordConsignmentMatchLog } from './consignment-match-log-service.js'
|
||||||
|
import {
|
||||||
|
matchesConsignmentCategoryFamily,
|
||||||
|
resolveConsignmentCategoryScope,
|
||||||
|
} from './consignment-category-scope.js'
|
||||||
import {
|
import {
|
||||||
sendConsignmentMatchSuccessSms,
|
sendConsignmentMatchSuccessSms,
|
||||||
type ConsignmentMatchSmsPayload,
|
type ConsignmentMatchSmsPayload,
|
||||||
@@ -144,18 +148,47 @@ export function buildConsignmentShareReward(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 库存是否属于这份寄卖配置的「分类家族」。
|
||||||
|
*
|
||||||
|
* 业务现实:寄卖库存几乎都挂**一级**分类(苏尔南手枪 4),工单却常挂**二级**分类
|
||||||
|
* (暗潮/怒嚎/血宴)。所以判定不能只比 profileId / categoryId 相等:
|
||||||
|
* - 库存 profile 与工单解析出的 profile 相同 → 归属成立;
|
||||||
|
* - 否则看分类层级:库存分类是配置分类的**子孙** → 归属成立;
|
||||||
|
* 是配置分类的**祖先** → 必须是该配置对应的系列(血宴只认同 series=血宴 的一级库存),
|
||||||
|
* 否则暗潮/怒嚎的货会被血宴配置误纳入。
|
||||||
|
*
|
||||||
|
* 该口径必须与 `consignment-category-scope` 的 SQL 条件逐条对齐:
|
||||||
|
* 手动匹配候选、一键查询待匹配看板、自动匹配、落库校验都调用本函数。
|
||||||
|
*/
|
||||||
export function inventoryMatchesProfile(
|
export function inventoryMatchesProfile(
|
||||||
inventory: ConsignmentInventoryRow,
|
inventory: ConsignmentInventoryRow,
|
||||||
profile: ConsignmentProfileSnapshot | null,
|
profile: ConsignmentProfileSnapshot | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!profile) return true
|
if (!profile) return true
|
||||||
const inventoryProfileId = inventory.profile_id ? Number(inventory.profile_id) : null
|
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)
|
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(手工入库时代的数据):内置五字段配置可以
|
// 历史库存没有 profile_id/category_id(手工入库时代的数据):内置五字段配置可以
|
||||||
// 交给旧匹配器的品类门禁兜底,避免这些货在新匹配弹窗里彻底消失;
|
// 交给旧匹配器的品类门禁兜底,避免这些货在新匹配弹窗里彻底消失;
|
||||||
// 含自定义字段的配置无法判断归属,仍然要求显式分类。
|
// 含自定义字段的配置无法判断归属,仍然要求显式分类。
|
||||||
|
if (inventoryProfileId) return false
|
||||||
return !profile.config.specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
|
return !profile.config.specs.some((spec) => !isBuiltInConsignmentSpecField(spec))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1570,23 +1603,29 @@ export async function listConsignmentMatchCandidatesForWorkOrder(workOrderId: nu
|
|||||||
productName: workOrder.product_name,
|
productName: workOrder.product_name,
|
||||||
parsedSeries: String(orderValues.series || ''),
|
parsedSeries: String(orderValues.series || ''),
|
||||||
})
|
})
|
||||||
|
// 手动匹配的「数据范围」= 工单分类 + 其一级分类(分类家族)。
|
||||||
|
const orderCategoryScope = await resolveConsignmentCategoryScope(workOrder.category_id).catch(
|
||||||
|
() => null,
|
||||||
|
)
|
||||||
const inventories = (await listActiveConsignmentInventoriesForMatching())
|
const inventories = (await listActiveConsignmentInventoriesForMatching())
|
||||||
.filter(
|
.filter(
|
||||||
(inventory) =>
|
(inventory) =>
|
||||||
inventoryMatchesProfile(inventory, profile) &&
|
|
||||||
Number(inventory.remaining_quantity ?? 1) > 0 &&
|
Number(inventory.remaining_quantity ?? 1) > 0 &&
|
||||||
inventory.status === 'in_stock' &&
|
inventory.status === 'in_stock' &&
|
||||||
inventory.allow_matching &&
|
inventory.allow_matching &&
|
||||||
!isRematchExcludedInventory(workOrder, inventory),
|
!isRematchExcludedInventory(workOrder, inventory),
|
||||||
)
|
)
|
||||||
// 跨枪型不展示、不参与匹配:暗潮单看不到怒嚎/血宴库存,反之亦然。
|
/**
|
||||||
.filter(
|
* 数据范围以「工单分类 + 其一级分类」为准(分类家族),而不是按 profileId 严格相等:
|
||||||
(inventory) =>
|
* 血宴(67) 的工单要能搜到挂在苏尔南手枪(4) 下的寄卖单;
|
||||||
!isCrossSeriesInventory(
|
* 反之 4 的工单也要能搜到 65/66/67 名下的现货。
|
||||||
orderSeriesInfo,
|
* 是否可匹配仍由 `evaluateInventoryMatch` 逐字段判定(枪型不同会标成冲突,需填原因)。
|
||||||
String(resolveConsignmentInventorySpecValues(inventory).series || ''),
|
*/
|
||||||
),
|
.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
|
const evaluated = inventories
|
||||||
.map((inventory) => {
|
.map((inventory) => {
|
||||||
const evaluation = evaluateInventoryMatch(workOrder, inventory, profile, {
|
const evaluation = evaluateInventoryMatch(workOrder, inventory, profile, {
|
||||||
@@ -1758,21 +1797,24 @@ async function runManualConsignmentMatch(
|
|||||||
productName: workOrder.product_name,
|
productName: workOrder.product_name,
|
||||||
parsedSeries: String(orderValues.series || ''),
|
parsedSeries: String(orderValues.series || ''),
|
||||||
})
|
})
|
||||||
|
// 提交兜底与候选弹窗同口径:数据范围 = 工单分类 + 其一级分类(分类家族)。
|
||||||
|
const submitCategoryScope = await resolveConsignmentCategoryScope(workOrder.category_id).catch(
|
||||||
|
() => null,
|
||||||
|
)
|
||||||
const inventories = (await listActiveConsignmentInventoriesForMatching())
|
const inventories = (await listActiveConsignmentInventoriesForMatching())
|
||||||
.filter(
|
.filter(
|
||||||
(inventory) =>
|
(inventory) =>
|
||||||
inventoryMatchesProfile(inventory, profile) &&
|
|
||||||
Number(inventory.remaining_quantity ?? 1) > 0 &&
|
Number(inventory.remaining_quantity ?? 1) > 0 &&
|
||||||
!isRematchExcludedInventory(workOrder, inventory),
|
!isRematchExcludedInventory(workOrder, inventory),
|
||||||
)
|
)
|
||||||
// 接口兜底:即使前端绕过候选列表直接传库存 id,也不允许跨枪型落库。
|
.filter((inventory) => {
|
||||||
.filter(
|
if (matchesConsignmentCategoryFamily(submitCategoryScope, inventory.category_id)) return true
|
||||||
(inventory) =>
|
return !Number(inventory.category_id || 0) && inventoryMatchesProfile(inventory, profile)
|
||||||
!isCrossSeriesInventory(
|
})
|
||||||
orderSeriesInfo,
|
// 注意:这里不再按跨枪型硬过滤。人工匹配允许在「分类家族」范围内指定现货
|
||||||
String(resolveConsignmentInventorySpecValues(inventory).series || ''),
|
// (例如血宴工单用苏尔南手枪下暗潮的货),但跨枪型会由 evaluateInventoryMatch
|
||||||
),
|
// 判成 conflict,必须同时填写 allowNonExact + 原因才能真正落库。
|
||||||
)
|
// 自动匹配仍在 `pickCandidateInventories` 里保留跨枪型排除,口径不变。
|
||||||
const inventoryIds = [...new Set(input.inventoryIds.map((id) => Number(id)))]
|
const inventoryIds = [...new Set(input.inventoryIds.map((id) => Number(id)))]
|
||||||
const selected = inventoryIds
|
const selected = inventoryIds
|
||||||
.map((id) => inventories.find((inventory) => Number(inventory.id) === id))
|
.map((id) => inventories.find((inventory) => Number(inventory.id) === id))
|
||||||
|
|||||||
@@ -825,3 +825,102 @@ test('看板自动匹配口径:订单写清系统且有高配库存时允许
|
|||||||
assert.equal(result.autoMatchableCount, 1)
|
assert.equal(result.autoMatchableCount, 1)
|
||||||
assert.equal(result.partialCount, 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,
|
type ConsignmentOrderSeriesInfo,
|
||||||
} from './consignment-sort-priority.js'
|
} from './consignment-sort-priority.js'
|
||||||
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
||||||
|
import {
|
||||||
|
matchesConsignmentCategoryScope,
|
||||||
|
normalizeConsignmentCategoryScope,
|
||||||
|
resolveConsignmentCategoryScope,
|
||||||
|
type ConsignmentCategoryScope,
|
||||||
|
} from './consignment-category-scope.js'
|
||||||
|
|
||||||
export type PendingPistolMatchPair = {
|
export type PendingPistolMatchPair = {
|
||||||
workOrderId: number
|
workOrderId: number
|
||||||
@@ -361,6 +367,11 @@ export function buildPendingConsignmentMatchPairs(input: {
|
|||||||
orderSpecOverrideByOrderId?: Map<number, ConsignmentSpecValues | null> | undefined
|
orderSpecOverrideByOrderId?: Map<number, ConsignmentSpecValues | null> | undefined
|
||||||
/** 只统计该寄卖分类下的订单与库存;0 / 未传 = 全部 */
|
/** 只统计该寄卖分类下的订单与库存;0 / 未传 = 全部 */
|
||||||
categoryId?: number | undefined
|
categoryId?: number | undefined
|
||||||
|
/**
|
||||||
|
* 分类范围(含祖先/子孙与系列收窄),由调用方用 `resolveConsignmentCategoryScope` 解析。
|
||||||
|
* 传了它就以它为准;只传 categoryId 时按「仅自身」处理,保持纯函数与旧调用方口径不变。
|
||||||
|
*/
|
||||||
|
categoryScope?: ConsignmentCategoryScope | undefined
|
||||||
/** 只统计这一条库存(库存侧「匹配订单」用) */
|
/** 只统计这一条库存(库存侧「匹配订单」用) */
|
||||||
inventoryId?: number | undefined
|
inventoryId?: number | undefined
|
||||||
keyword?: string | undefined
|
keyword?: string | undefined
|
||||||
@@ -369,6 +380,7 @@ export function buildPendingConsignmentMatchPairs(input: {
|
|||||||
ordersTruncated?: boolean | undefined
|
ordersTruncated?: boolean | undefined
|
||||||
}): PendingConsignmentMatchResult {
|
}): PendingConsignmentMatchResult {
|
||||||
const categoryId = Math.max(0, Math.floor(Number(input.categoryId) || 0))
|
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 inventoryId = Math.max(0, Math.floor(Number(input.inventoryId) || 0))
|
||||||
const keyword = String(input.keyword || '')
|
const keyword = String(input.keyword || '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -411,16 +423,21 @@ export function buildPendingConsignmentMatchPairs(input: {
|
|||||||
}
|
}
|
||||||
const remainingQuantity = sharingOrder ? Math.max(0, totalQuantity - activeQuantity) : 1
|
const remainingQuantity = sharingOrder ? Math.max(0, totalQuantity - activeQuantity) : 1
|
||||||
if (remainingQuantity <= 0) continue
|
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 orderSpecOverride = input.orderSpecOverrideByOrderId?.get(Number(order.id)) ?? null
|
||||||
const { orderValues, seriesInfo: orderSeriesInfo } = resolvePendingOrderSpecs(
|
const { orderValues, seriesInfo: orderSeriesInfo } = resolvePendingOrderSpecs(
|
||||||
order,
|
order,
|
||||||
orderSpecOverride,
|
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({
|
preparedOrders.push({
|
||||||
order,
|
order,
|
||||||
profile,
|
profile,
|
||||||
@@ -440,8 +457,15 @@ export function buildPendingConsignmentMatchPairs(input: {
|
|||||||
if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false
|
if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false
|
||||||
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
|
if (Number(inventory.remaining_quantity ?? 1) <= 0) return false
|
||||||
if (inventoryId > 0 && Number(inventory.id) !== inventoryId) return false
|
if (inventoryId > 0 && Number(inventory.id) !== inventoryId) return false
|
||||||
// 库存必须属于当前寄卖分类;没有分类的历史货无法判断归属,按分类看板时排除
|
// 库存必须落在当前分类范围内:库存通常挂一级分类,选中「血宴」时按 series 收窄。
|
||||||
if (categoryId > 0 && Number(inventory.category_id || 0) !== categoryId) return false
|
// 没有分类的历史货无法判断归属,按分类看板时排除。
|
||||||
|
if (categoryScope.selfCategoryId > 0) {
|
||||||
|
const inScope = matchesConsignmentCategoryScope(categoryScope, {
|
||||||
|
categoryId: inventory.category_id,
|
||||||
|
series: String(resolveConsignmentInventorySpecValues(inventory).series || ''),
|
||||||
|
})
|
||||||
|
if (!inScope) return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -735,12 +759,16 @@ export async function listPendingConsignmentMatches(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 分类范围:选中二级分类(血宴)时同时覆盖其一级分类下同系列的库存与订单。
|
||||||
|
const categoryScope = await resolveConsignmentCategoryScope(input.categoryId).catch(() => null)
|
||||||
|
|
||||||
return buildPendingConsignmentMatchPairs({
|
return buildPendingConsignmentMatchPairs({
|
||||||
orders: items,
|
orders: items,
|
||||||
inventories,
|
inventories,
|
||||||
profileByOrderId,
|
profileByOrderId,
|
||||||
orderSpecOverrideByOrderId,
|
orderSpecOverrideByOrderId,
|
||||||
categoryId: input.categoryId,
|
categoryId: input.categoryId,
|
||||||
|
categoryScope: categoryScope || undefined,
|
||||||
inventoryId: input.inventoryId,
|
inventoryId: input.inventoryId,
|
||||||
keyword: input.keyword,
|
keyword: input.keyword,
|
||||||
includeConflicts: input.includeConflicts,
|
includeConflicts: input.includeConflicts,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { JsonObject } from '../../types/json.js'
|
|||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
import { listAllWorkCategories } from '../../repositories/worker-platform/work-category-repo.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 {
|
import {
|
||||||
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
||||||
DEFAULT_CONSIGNMENT_PRICING,
|
DEFAULT_CONSIGNMENT_PRICING,
|
||||||
@@ -137,6 +139,17 @@ export type ConsignmentProfileSnapshot = {
|
|||||||
versionId: number
|
versionId: number
|
||||||
versionNo: number
|
versionNo: number
|
||||||
config: ConsignmentProfileConfig
|
config: ConsignmentProfileConfig
|
||||||
|
/**
|
||||||
|
* 该配置可覆盖的分类范围(祖先/子孙 + 系列收窄)。
|
||||||
|
*
|
||||||
|
* 寄卖库存大多挂在**一级**分类(如「苏尔南手枪」4),配置也可能建在二级分类上;
|
||||||
|
* 只有带上范围才能判断「一级库存是否属于这份配置」。
|
||||||
|
* 缺省时消费方按「仅 categoryId 自身」降级,保证旧调用方与测试桩不受影响。
|
||||||
|
*/
|
||||||
|
categoryAncestorIds?: number[]
|
||||||
|
categoryDescendantIds?: number[]
|
||||||
|
/** 祖先分类下需要命中的系列值(空 = 祖先不限系列) */
|
||||||
|
categoryAncestorSeriesValues?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONSIGNMENT_PROFILE_CONFIG: ConsignmentProfileConfig = {
|
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 }>(
|
export function sortConsignmentInventoryFilterCategories<T extends { categoryKey: string }>(
|
||||||
items: T[],
|
items: T[],
|
||||||
@@ -700,17 +752,37 @@ export async function listAdminConsignmentInventoryFilterOptions(): Promise<{
|
|||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
// 继承型二级分类只有真的有库存时才出现在筛选项里:
|
// 继承型二级分类只有在「它自己能查到货」时才进筛选项(否则点进去永远空列表)。
|
||||||
// 否则会出现「暗潮 / 怒嚎 / 血宴」这类系列子分类,点进去永远是空列表。
|
// 注意库存几乎都挂一级分类,所以「能查到货」必须按分类范围算:
|
||||||
const inventoryCounts = await query<{ category_id: number; total: number }>(
|
// 血宴(67) 的库存其实是 4 + series=血宴,只看 category_id=67 会永远为 0。
|
||||||
`SELECT category_id, COUNT(*)::integer AS total
|
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
|
FROM consignment_inventories
|
||||||
WHERE category_id IS NOT NULL
|
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>()
|
const inventoryCountByCategoryId = new Map<number, number>()
|
||||||
|
/** categoryId → series → 条数 */
|
||||||
|
const inventorySeriesCountByCategoryId = new Map<number, Map<string, number>>()
|
||||||
for (const row of inventoryCounts.rows) {
|
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>()
|
const profileByCategoryId = new Map<number, ConsignmentProfileRow>()
|
||||||
@@ -736,7 +808,19 @@ export async function listAdminConsignmentInventoryFilterOptions(): Promise<{
|
|||||||
: undefined
|
: undefined
|
||||||
const effectiveProfile = ownProfile || inheritedProfile
|
const effectiveProfile = ownProfile || inheritedProfile
|
||||||
if (!effectiveProfile) continue
|
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)
|
const config = resolvePublishedProfileConfig(effectiveProfile)
|
||||||
items.push({
|
items.push({
|
||||||
@@ -783,6 +867,9 @@ export async function resolveConsignmentProfileForCategory(
|
|||||||
)
|
)
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
if (!row || !row.current_version_id) continue
|
if (!row || !row.current_version_id) continue
|
||||||
|
// 分类范围以「配置所属分类」为基准,而不是请求进来的工单分类:
|
||||||
|
// 配置建在一级(苏尔南手枪)时要覆盖其子分类库存;建在二级时要回看一级同系列库存。
|
||||||
|
const scope = await resolveConsignmentCategoryScope(Number(row.category_id)).catch(() => null)
|
||||||
return {
|
return {
|
||||||
profileId: Number(row.id),
|
profileId: Number(row.id),
|
||||||
categoryId: Number(row.category_id),
|
categoryId: Number(row.category_id),
|
||||||
@@ -790,6 +877,13 @@ export async function resolveConsignmentProfileForCategory(
|
|||||||
versionId: Number(row.current_version_id),
|
versionId: Number(row.current_version_id),
|
||||||
versionNo: Number(row.current_version_no || 1),
|
versionNo: Number(row.current_version_no || 1),
|
||||||
config: resolvePublishedProfileConfig(row),
|
config: resolvePublishedProfileConfig(row),
|
||||||
|
...(scope
|
||||||
|
? {
|
||||||
|
categoryAncestorIds: scope.ancestorCategoryIds,
|
||||||
|
categoryDescendantIds: scope.descendantCategoryIds,
|
||||||
|
categoryAncestorSeriesValues: scope.ancestorSeriesValues,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -806,6 +900,7 @@ export async function getPublishedConsignmentProfileById(
|
|||||||
)
|
)
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
if (!row || !row.current_version_id) return null
|
if (!row || !row.current_version_id) return null
|
||||||
|
const scope = await resolveConsignmentCategoryScope(Number(row.category_id)).catch(() => null)
|
||||||
return {
|
return {
|
||||||
profileId: Number(row.id),
|
profileId: Number(row.id),
|
||||||
categoryId: Number(row.category_id),
|
categoryId: Number(row.category_id),
|
||||||
@@ -813,6 +908,13 @@ export async function getPublishedConsignmentProfileById(
|
|||||||
versionId: Number(row.current_version_id),
|
versionId: Number(row.current_version_id),
|
||||||
versionNo: Number(row.current_version_no || 1),
|
versionNo: Number(row.current_version_no || 1),
|
||||||
config: resolvePublishedProfileConfig(row),
|
config: resolvePublishedProfileConfig(row),
|
||||||
|
...(scope
|
||||||
|
? {
|
||||||
|
categoryAncestorIds: scope.ancestorCategoryIds,
|
||||||
|
categoryDescendantIds: scope.descendantCategoryIds,
|
||||||
|
categoryAncestorSeriesValues: scope.ancestorSeriesValues,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
onClose,
|
onClose,
|
||||||
categoryId,
|
categoryId,
|
||||||
categoryName,
|
categoryName,
|
||||||
|
categoryParentName,
|
||||||
canMatch = false,
|
canMatch = false,
|
||||||
onMatched,
|
onMatched,
|
||||||
}: {
|
}: {
|
||||||
@@ -47,6 +48,8 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
/** 只统计该寄卖分类;不传 = 全部 */
|
/** 只统计该寄卖分类;不传 = 全部 */
|
||||||
categoryId?: number | null
|
categoryId?: number | null
|
||||||
categoryName?: string
|
categoryName?: string
|
||||||
|
/** 二级分类的上级分类名:用于说明「会带上上级同系列库存」 */
|
||||||
|
categoryParentName?: string
|
||||||
/** 有 consignment.fulfill 权限时才显示「选择」列 */
|
/** 有 consignment.fulfill 权限时才显示「选择」列 */
|
||||||
canMatch?: boolean
|
canMatch?: boolean
|
||||||
/** 快速匹配成功后通知外层刷新库存 */
|
/** 快速匹配成功后通知外层刷新库存 */
|
||||||
@@ -297,9 +300,15 @@ export default function ConsignmentPendingMatchModal({
|
|||||||
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货。'
|
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货。'
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
canMatch
|
categoryParentName
|
||||||
? '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之;跨枪型不展示。点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
|
? `「${categoryName}」是二级分类:会连同上级「${categoryParentName}」中同系列的库存一起匹配(跨枪型不参与)。${
|
||||||
: '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序;当前账号没有履约权限,只能查看。'
|
canMatch
|
||||||
|
? '点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。'
|
||||||
|
: '当前账号没有履约权限,只能查看。'
|
||||||
|
}`
|
||||||
|
: canMatch
|
||||||
|
? '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之;跨枪型不展示。点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。'
|
||||||
|
: '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序;当前账号没有履约权限,只能查看。'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -260,16 +260,21 @@ export default function ConsignmentWorkOrderMatchModal({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!candidatesQuery.isLoading && !candidatesQuery.isError ? (
|
{!candidatesQuery.isLoading && !candidatesQuery.isError ? (
|
||||||
<Space wrap size={8} style={{ marginBottom: 12 }}>
|
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 12 }}>
|
||||||
<Input.Search
|
<Space wrap size={8}>
|
||||||
allowClear
|
<Input.Search
|
||||||
placeholder="搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手"
|
allowClear
|
||||||
style={{ width: 320 }}
|
placeholder="搜索寄卖单号 / 库存号 / 物品 / 规格 / 打手"
|
||||||
value={keyword}
|
style={{ width: 320 }}
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
value={keyword}
|
||||||
/>
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
<Typography.Text type="secondary">
|
/>
|
||||||
显示 {visibleOptions.length} / 候选 {options.length} 条
|
<Typography.Text type="secondary">
|
||||||
|
显示 {visibleOptions.length} / 候选 {options.length} 条
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
候选范围:本工单分类及其上级分类下的全部在库寄卖单(跨枪型会标为不匹配,需填原因才能强制匹配)。
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
) : null}
|
) : 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) => {
|
{dynamicFilters.map((field) => {
|
||||||
const value = specFilters[field.key]
|
const value = specFilters[field.key]
|
||||||
if (field.type === 'select' || field.type === 'radio') {
|
if (field.type === 'select' || field.type === 'radio') {
|
||||||
@@ -1640,6 +1646,9 @@ export default function ConsignmentInventoryPanel() {
|
|||||||
open={pendingMatchOpen}
|
open={pendingMatchOpen}
|
||||||
categoryId={categoryId}
|
categoryId={categoryId}
|
||||||
categoryName={activeFilterCategory?.name}
|
categoryName={activeFilterCategory?.name}
|
||||||
|
categoryParentName={
|
||||||
|
activeFilterCategory?.parentId ? activeFilterCategory.parentName : undefined
|
||||||
|
}
|
||||||
canMatch={canFulfillConsignment}
|
canMatch={canFulfillConsignment}
|
||||||
onClose={() => setPendingMatchOpen(false)}
|
onClose={() => setPendingMatchOpen(false)}
|
||||||
onMatched={() => void inventoriesQuery.refetch()}
|
onMatched={() => void inventoriesQuery.refetch()}
|
||||||
|
|||||||
Reference in New Issue
Block a user