diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 1ef10f67..dff4dfe8 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -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 { // 客服看到的匹配候选要显示「寄卖单号」(提报单号),所以必须带出提报单号; // 注意列名一律加 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 diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index 7bcda1ad..bc31900c 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -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), diff --git a/apps/backend/src/services/worker-platform/consignment-category-scope.test.ts b/apps/backend/src/services/worker-platform/consignment-category-scope.test.ts new file mode 100644 index 00000000..6d6f2144 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-category-scope.test.ts @@ -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) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-category-scope.ts b/apps/backend/src/services/worker-platform/consignment-category-scope.ts new file mode 100644 index 00000000..d69ee2f0 --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-category-scope.ts @@ -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>, +): number[] { + const byId = new Map>() + for (const category of categories) byId.set(Number(category.id), category) + const ancestors: number[] = [] + const seen = new Set([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>, +): number[] { + const childrenByParent = new Map() + 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([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 | null | undefined + parent: Pick | 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> +}): 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 + >() + 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> + } = {}, +): Promise { + 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> +> | null = null +let categoryCacheExpiresAt = 0 + +function listCategoriesForScope(): Promise< + Array> +> { + 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 +} diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 7af36cd3..ddd88012 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -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)) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts index d1a096be..4bd45d30 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts @@ -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], + ) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts index e97f23b2..737eac82 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -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 | 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, diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index 41ff240b..0215bbf7 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -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 + inventorySeriesCountByCategoryId: Map> + /** 该二级分类对应的系列值;缺省按「分类名」推断 */ + 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( 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() + /** categoryId → series → 条数 */ + const inventorySeriesCountByCategoryId = new Map>() 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() + bySeries.set(series, (bySeries.get(series) || 0) + total) + inventorySeriesCountByCategoryId.set(categoryId, bySeries) } const profileByCategoryId = new Map() @@ -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, + } + : {}), } } diff --git a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx index 1e37bddd..e79f7f8d 100644 --- a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx @@ -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 + ? '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序:完美匹配在前,同钻高璀/高钻同璀/高钻高璀次之;跨枪型不展示。点「选择」会打开手动匹配并预选该库存,确认后直接占用库存。数量不足或规格不匹配时需要人工补选。' + : '默认展示全部配对,按 枪型 → 钻石 → 璀璨 排序;当前账号没有履约权限,只能查看。' } /> diff --git a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx index f5b77c93..ffd5b533 100644 --- a/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentWorkOrderMatchModal.tsx @@ -260,16 +260,21 @@ export default function ConsignmentWorkOrderMatchModal({ /> ) : null} {!candidatesQuery.isLoading && !candidatesQuery.isError ? ( - - setKeyword(event.target.value)} - /> - - 显示 {visibleOptions.length} / 候选 {options.length} 条 + + + setKeyword(event.target.value)} + /> + + 显示 {visibleOptions.length} / 候选 {options.length} 条 + + + + 候选范围:本工单分类及其上级分类下的全部在库寄卖单(跨枪型会标为不匹配,需填原因才能强制匹配)。 ) : null} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index feba890f..cc53b878 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -1026,6 +1026,12 @@ export default function ConsignmentInventoryPanel() { }} /> + {activeFilterCategory?.parentId ? ( + + 含上级「{activeFilterCategory.parentName || '一级分类'}」同系列 + {activeFilterCategory.name} 库存 + + ) : 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()}