feat(consignment): 统一寄卖匹配看板口径并收口占用与重匹配
- 看板与引擎统一数量、模板规格、正向规格信号判定,纳入 internal_matching/unassigned - 库存侧改走新引擎匹配,移除旧 /fulfill 手动入口与旧弹窗 - 验收时将 reserved allocation 收口为 consumed,补份额级收口 - 拼单重新匹配通过 material 排除全部原库存/原卖家 - 开发/测试环境放宽迁移顺序校验,生产保持严格;修正集成测试份额报酬口径
This commit is contained in:
@@ -31,6 +31,13 @@ export async function runDatabaseMigrations() {
|
||||
|
||||
console.info(`[db:migrate] 迁移目录: ${MIGRATIONS_DIR}`)
|
||||
|
||||
// 分支联调库可能已应用过 main 的 147-158,而本分支的寄卖迁移编号是 147-152;
|
||||
// node-pg-migrate 的 checkOrder 会按名称逐位比较并直接抛错,导致服务起不来。
|
||||
// 生产保持严格顺序;开发/测试允许顺序放宽(迁移文件本身仍需幂等)。
|
||||
const strictMigrationOrder =
|
||||
String(process.env.DB_MIGRATION_STRICT_ORDER || '').trim() === '1' ||
|
||||
String(process.env.NODE_ENV || '').trim() === 'production'
|
||||
|
||||
await runner({
|
||||
databaseUrl: createMigrationDatabaseConfig(),
|
||||
dir: MIGRATIONS_DIR,
|
||||
@@ -39,7 +46,7 @@ export async function runDatabaseMigrations() {
|
||||
migrationsSchema: 'public',
|
||||
schema: 'public',
|
||||
singleTransaction: true,
|
||||
checkOrder: true,
|
||||
checkOrder: strictMigrationOrder,
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: (message) => {
|
||||
|
||||
@@ -281,6 +281,20 @@ export async function markConsignmentAllocationsConsumedWithClient(
|
||||
)
|
||||
}
|
||||
|
||||
/** 单个拼单份额验收:只收口这一份占用的库存,避免整单尚在履约时提前标记。 */
|
||||
export async function markConsignmentAllocationConsumedForShareWithClient(
|
||||
client: PoolClient,
|
||||
shareId: number,
|
||||
now: string,
|
||||
): Promise<void> {
|
||||
await client.query(
|
||||
`UPDATE consignment_inventory_allocations
|
||||
SET status = 'consumed', updated_at = $1
|
||||
WHERE work_order_share_id = $2 AND status = 'reserved'`,
|
||||
[now, shareId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条库存当前的有效占用(一库存可拆给多张订单)。
|
||||
* 卖家操作必须据此定位具体订单/份额,不能再只看 bound_work_order_id。
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
settleConsignmentSupplierPayoutByWorkOrderWithClient,
|
||||
} from './consignment-deposit-repo.js'
|
||||
import {
|
||||
markConsignmentAllocationConsumedForShareWithClient,
|
||||
markConsignmentAllocationsConsumedWithClient,
|
||||
releaseConsignmentAllocationForShareWithClient,
|
||||
releaseConsignmentAllocationsForWorkOrderWithClient,
|
||||
} from './consignment-allocation-repo.js'
|
||||
@@ -240,6 +242,9 @@ export async function acceptWorkOrderAndSettle(input: {
|
||||
now: input.now,
|
||||
})
|
||||
}
|
||||
// 验收即交付完成:占用从 reserved 收口为 consumed,
|
||||
// 否则卖家端会一直显示「占用中」,售后释放路径也可能把已交付的货退回可匹配池。
|
||||
await markConsignmentAllocationsConsumedWithClient(client, input.workOrderId, input.now)
|
||||
|
||||
if (shares.length > 0) {
|
||||
// 单独验收过的份额已经完成资金结算,统一验收时只处理尚未结算的份额。
|
||||
@@ -787,6 +792,8 @@ export async function acceptWorkOrderShareAndSettle(input: {
|
||||
`UPDATE work_order_shares SET status = 'accepted', accepted_at = $1, updated_at = $1 WHERE id = $2`,
|
||||
[input.now, input.shareId],
|
||||
)
|
||||
// 份额验收即这一份交付完成:只收口该份额的占用,母单剩余份额仍按 reserved 统计。
|
||||
await markConsignmentAllocationConsumedForShareWithClient(client, input.shareId, input.now)
|
||||
if (workerId > 0) {
|
||||
// 等级升级要跑全量已验收订单计数,挪到事务提交后执行,避免拖垮验收事务。
|
||||
upgradeWorkerIds.push(workerId)
|
||||
|
||||
@@ -413,6 +413,7 @@ router.get(
|
||||
(req) =>
|
||||
listPendingConsignmentMatches({
|
||||
categoryId: Number(req.query.categoryId || 0) || undefined,
|
||||
inventoryId: Number(req.query.inventoryId || 0) || undefined,
|
||||
keyword: String(req.query.keyword || ''),
|
||||
includeConflicts: String(req.query.includeConflicts || '') === 'true',
|
||||
limit: Number(req.query.limit || 0) || undefined,
|
||||
|
||||
@@ -53,6 +53,23 @@ test('重新匹配排除:原库存与原卖家都不再参与,且匹配成
|
||||
assert.equal(isRematchExcludedInventory({} as never, { id: 12, worker_id: 7 }), false)
|
||||
})
|
||||
|
||||
test('拼单重新匹配:material 里的全部原库存/原卖家都必须排除', () => {
|
||||
const excluded = {
|
||||
rematch_excluded_worker_id: 7,
|
||||
rematch_excluded_inventory_id: 11,
|
||||
material_json: JSON.stringify({
|
||||
consignment: {
|
||||
rematchExcludedInventoryIds: [11, 12, 13],
|
||||
rematchExcludedWorkerIds: [7, 8, 9],
|
||||
},
|
||||
}),
|
||||
}
|
||||
assert.equal(isRematchExcludedInventory(excluded, { id: 12, worker_id: 99 }), true)
|
||||
assert.equal(isRematchExcludedInventory(excluded, { id: 13, worker_id: 99 }), true)
|
||||
assert.equal(isRematchExcludedInventory(excluded, { id: 14, worker_id: 8 }), true)
|
||||
assert.equal(isRematchExcludedInventory(excluded, { id: 14, worker_id: 10 }), false)
|
||||
})
|
||||
|
||||
test('超时重新匹配与匹配日志:链路必须接线到调度与后台接口', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const [timeoutSrc, engineSrc, routes, logSrc] = await Promise.all([
|
||||
|
||||
@@ -143,16 +143,55 @@ export function inventoryMatchesProfile(
|
||||
/**
|
||||
* 超时「重新匹配」排除的原卖家/原库存:
|
||||
* 同一单重新进入匹配池后,不允许原卖家或原库存立刻再次匹配,避免死循环。
|
||||
*
|
||||
* 拼单工单会同时占用多个卖家/多条库存,单列字段只能记住一个;
|
||||
* 因此 `startConsignmentRematch` 会把完整集合写进
|
||||
* `material_json.consignment.rematchExcludedInventoryIds / rematchExcludedWorkerIds`,
|
||||
* 这里两处都读,保证「看板、候选列表、自动匹配、手动落库」口径一致。
|
||||
*/
|
||||
function resolveRematchExclusions(
|
||||
workOrder: Pick<WorkOrderRow, 'rematch_excluded_worker_id' | 'rematch_excluded_inventory_id'> & {
|
||||
material_json?: unknown
|
||||
},
|
||||
): { inventoryIds: Set<number>; workerIds: Set<number> } {
|
||||
const inventoryIds = new Set<number>()
|
||||
const workerIds = new Set<number>()
|
||||
|
||||
const singleInventoryId = Number(workOrder.rematch_excluded_inventory_id || 0)
|
||||
const singleWorkerId = Number(workOrder.rematch_excluded_worker_id || 0)
|
||||
// 没有重新匹配标记时直接返回,避免看板/候选循环里对每一对都解析 material JSON。
|
||||
if (singleInventoryId <= 0 && singleWorkerId <= 0) {
|
||||
return { inventoryIds, workerIds }
|
||||
}
|
||||
if (singleInventoryId > 0) inventoryIds.add(singleInventoryId)
|
||||
if (singleWorkerId > 0) workerIds.add(singleWorkerId)
|
||||
|
||||
const material = parseMaterial(workOrder.material_json)
|
||||
const consignment = parseMaterial(material.consignment)
|
||||
const collectIds = (value: unknown, target: Set<number>) => {
|
||||
if (!Array.isArray(value)) return
|
||||
for (const item of value) {
|
||||
const id = Number(item || 0)
|
||||
if (id > 0) target.add(id)
|
||||
}
|
||||
}
|
||||
collectIds(consignment.rematchExcludedInventoryIds, inventoryIds)
|
||||
collectIds(consignment.rematchExcludedWorkerIds, workerIds)
|
||||
|
||||
return { inventoryIds, workerIds }
|
||||
}
|
||||
|
||||
export function isRematchExcludedInventory(
|
||||
workOrder: Pick<WorkOrderRow, 'rematch_excluded_worker_id' | 'rematch_excluded_inventory_id'>,
|
||||
workOrder: Pick<WorkOrderRow, 'rematch_excluded_worker_id' | 'rematch_excluded_inventory_id'> & {
|
||||
material_json?: unknown
|
||||
},
|
||||
inventory: Pick<ConsignmentInventoryRow, 'id' | 'worker_id'>,
|
||||
): boolean {
|
||||
const excludedInventoryId = Number(workOrder.rematch_excluded_inventory_id || 0)
|
||||
const excludedWorkerId = Number(workOrder.rematch_excluded_worker_id || 0)
|
||||
if (!excludedInventoryId && !excludedWorkerId) return false
|
||||
if (excludedInventoryId && Number(inventory.id) === excludedInventoryId) return true
|
||||
if (excludedWorkerId && Number(inventory.worker_id || 0) === excludedWorkerId) return true
|
||||
const { inventoryIds, workerIds } = resolveRematchExclusions(workOrder)
|
||||
if (inventoryIds.size === 0 && workerIds.size === 0) return false
|
||||
if (inventoryIds.has(Number(inventory.id))) return true
|
||||
if (Number(inventory.worker_id || 0) > 0 && workerIds.has(Number(inventory.worker_id)))
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -189,6 +228,15 @@ type InventoryMatchEvaluation = {
|
||||
details: ReturnType<typeof evaluateConsignmentSpecMatch>['details']
|
||||
}
|
||||
|
||||
/** 把逐字段判定收敛成看板/前端共用的三态。 */
|
||||
export function resolveMatchType(
|
||||
evaluation: InventoryMatchEvaluation,
|
||||
): 'exact' | 'partial' | 'conflict' {
|
||||
if (evaluation.exact) return 'exact'
|
||||
if (evaluation.conflicts.length > 0) return 'conflict'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
type ConsignmentMatchInventoryCandidate = {
|
||||
inventory: ConsignmentInventoryRow
|
||||
evaluation: InventoryMatchEvaluation
|
||||
@@ -612,7 +660,13 @@ export async function fallbackConsignmentWorkOrderToHall(
|
||||
*/
|
||||
export async function startConsignmentRematch(
|
||||
workOrderId: number,
|
||||
input: { excludeWorkerId?: number | null; excludeInventoryId?: number | null } = {},
|
||||
input: {
|
||||
excludeWorkerId?: number | null
|
||||
excludeInventoryId?: number | null
|
||||
/** 拼单重新匹配:本次占用的全部卖家/库存都要排除,不能只记第一条 */
|
||||
excludeWorkerIds?: number[]
|
||||
excludeInventoryIds?: number[]
|
||||
} = {},
|
||||
now = nowIso(),
|
||||
): Promise<boolean> {
|
||||
const workOrder = await getWorkOrderById(workOrderId)
|
||||
@@ -622,6 +676,20 @@ export async function startConsignmentRematch(
|
||||
await fallbackConsignmentWorkOrderToHall(workOrderId, now)
|
||||
return false
|
||||
}
|
||||
const excludedInventoryIds = [
|
||||
...new Set(
|
||||
[...(input.excludeInventoryIds || []), Number(input.excludeInventoryId || 0)].filter(
|
||||
(id) => Number(id) > 0,
|
||||
),
|
||||
),
|
||||
]
|
||||
const excludedWorkerIds = [
|
||||
...new Set(
|
||||
[...(input.excludeWorkerIds || []), Number(input.excludeWorkerId || 0)].filter(
|
||||
(id) => Number(id) > 0,
|
||||
),
|
||||
),
|
||||
]
|
||||
// 与首次进入内部匹配同口径:0 表示不等待、直接发大厅。
|
||||
const delayMinutes = resolveInternalMatchDelayMinutes(profile.config.internalMatch)
|
||||
if (delayMinutes <= 0) {
|
||||
@@ -649,13 +717,33 @@ export async function startConsignmentRematch(
|
||||
[
|
||||
profile.profileId,
|
||||
deadline,
|
||||
input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
|
||||
input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
|
||||
excludedWorkerIds[0] ?? null,
|
||||
excludedInventoryIds[0] ?? null,
|
||||
now,
|
||||
workOrderId,
|
||||
],
|
||||
)
|
||||
if (!result.rows[0]) return false
|
||||
// 单列字段只够兼容旧逻辑;完整排除集合写进 material,拼单的所有原卖家/原库存都会生效。
|
||||
await query(
|
||||
`
|
||||
UPDATE work_orders
|
||||
SET material_json = jsonb_set(
|
||||
COALESCE(material_json, '{}'::jsonb),
|
||||
'{consignment}',
|
||||
COALESCE(material_json -> 'consignment', '{}'::jsonb) || $1::jsonb,
|
||||
true
|
||||
)
|
||||
WHERE id = $2
|
||||
`,
|
||||
[
|
||||
JSON.stringify({
|
||||
rematchExcludedInventoryIds: excludedInventoryIds,
|
||||
rematchExcludedWorkerIds: excludedWorkerIds,
|
||||
}),
|
||||
workOrderId,
|
||||
],
|
||||
)
|
||||
await query(
|
||||
`
|
||||
INSERT INTO work_order_events (
|
||||
@@ -670,8 +758,10 @@ export async function startConsignmentRematch(
|
||||
profileId: profile.profileId,
|
||||
delayMinutes,
|
||||
deadline,
|
||||
excludedWorkerId: input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
|
||||
excludedInventoryId: input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
|
||||
excludedWorkerId: excludedWorkerIds[0] ?? null,
|
||||
excludedInventoryId: excludedInventoryIds[0] ?? null,
|
||||
excludedWorkerIds,
|
||||
excludedInventoryIds,
|
||||
}),
|
||||
now,
|
||||
],
|
||||
@@ -688,8 +778,10 @@ export async function startConsignmentRematch(
|
||||
payload: {
|
||||
delayMinutes,
|
||||
deadline,
|
||||
excludedWorkerId: input.excludeWorkerId ? Number(input.excludeWorkerId) : null,
|
||||
excludedInventoryId: input.excludeInventoryId ? Number(input.excludeInventoryId) : null,
|
||||
excludedWorkerId: excludedWorkerIds[0] ?? null,
|
||||
excludedInventoryId: excludedInventoryIds[0] ?? null,
|
||||
excludedWorkerIds,
|
||||
excludedInventoryIds,
|
||||
},
|
||||
})
|
||||
return true
|
||||
@@ -702,7 +794,7 @@ export async function startConsignmentRematch(
|
||||
* 旧口径的宽松匹配会把缺失系统/区服/系列全部判成 unknown 并在 conflicts 里丢掉,
|
||||
* 如果不加这道门槛,标题只写「苏尔南手枪」的订单会被自动匹配到任意在库库存。
|
||||
*/
|
||||
function hasPositiveSpecSignal(
|
||||
export function hasPositiveSpecSignal(
|
||||
workOrder: WorkOrderRow,
|
||||
evaluation: InventoryMatchEvaluation,
|
||||
): boolean {
|
||||
@@ -1104,6 +1196,16 @@ async function applySharingMatch(
|
||||
let firstShareFeeFenPerUnit = 0
|
||||
let firstShareNetFenPerUnit = 0
|
||||
const smsPayloads: ConsignmentMatchSmsPayload[] = []
|
||||
// 拼单会同时占用多条库存/多个卖家;重新匹配时必须把它们全部排除,
|
||||
// 单列 rematch_excluded_* 只能记住第一条,所以完整集合写进 material 供下次排除。
|
||||
const matchedInventoryIds = [
|
||||
...new Set(plan.map(({ inventory }) => Number(inventory.id)).filter((id) => id > 0)),
|
||||
]
|
||||
const matchedWorkerIds = [
|
||||
...new Set(
|
||||
plan.map(({ inventory }) => Number(inventory.worker_id || 0)).filter((id) => id > 0),
|
||||
),
|
||||
]
|
||||
for (const { inventory, take } of plan) {
|
||||
// 拼单寄卖的份额报酬按「库存售价」而不是订单单价结算:
|
||||
// 卖家交付的是寄卖货,整单与拼单必须同口径(净售价 × 数量),
|
||||
@@ -1204,6 +1306,8 @@ async function applySharingMatch(
|
||||
? {
|
||||
consignment: {
|
||||
inventoryId: firstInventoryId,
|
||||
inventoryIds: matchedInventoryIds,
|
||||
workerIds: matchedWorkerIds,
|
||||
quantity: firstInventoryQuantity,
|
||||
profileId: profile?.profileId ?? null,
|
||||
matchedAt: now,
|
||||
@@ -1227,6 +1331,8 @@ async function applySharingMatch(
|
||||
profileId: profile?.profileId ?? null,
|
||||
totalQuantity,
|
||||
matchedQuantity,
|
||||
inventoryIds: matchedInventoryIds,
|
||||
workerIds: matchedWorkerIds,
|
||||
matchedAt: now,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1059,8 +1059,8 @@ async function main() {
|
||||
assert(shares.rows.length === 1, `shares=${shares.rows.length}`)
|
||||
assert(Number(shares.rows[0]!.quantity) === 3, `share quantity=${shares.rows[0]!.quantity}`)
|
||||
assert(
|
||||
Number(shares.rows[0]!.share_reward) === 19950,
|
||||
`share reward=${shares.rows[0]!.share_reward},应按 3×7000 扣 5% 服务费`,
|
||||
Number(shares.rows[0]!.share_reward) === 17100,
|
||||
`share reward=${shares.rows[0]!.share_reward},应按 3×6000 库存净售价扣 5% 服务费`,
|
||||
)
|
||||
|
||||
const inv = await query<Record<string, any>>(
|
||||
|
||||
+203
-1
@@ -367,7 +367,8 @@ test('看板新口径:库存 profile 与订单配置不一致时跳过', () =>
|
||||
profileByOrderId: new Map([[101, buildProfile()]]),
|
||||
})
|
||||
assert.equal(result.pairCount, 0)
|
||||
assert.equal(result.inStockInventoryCount, 0)
|
||||
// 在库待匹配按「在库+可匹配+有余量」统计,不再等于「产生了配对的库存数」
|
||||
assert.equal(result.inStockInventoryCount, 1)
|
||||
})
|
||||
|
||||
test('看板新口径:无寄卖配置的订单仍按旧手枪口径兜底', () => {
|
||||
@@ -447,3 +448,204 @@ test('看板按分类筛选:不传分类时保持原有全量口径', () => {
|
||||
|
||||
assert.equal(result.pendingOrderCount, 1)
|
||||
})
|
||||
|
||||
test('看板数量口径:库存余量不够整单需求时不能用 100 分冒充可匹配', () => {
|
||||
const order = buildOrder({
|
||||
id: 501,
|
||||
sharing_enabled: false,
|
||||
sharing_total_quantity: 3,
|
||||
})
|
||||
const result = buildPendingConsignmentMatchPairs({
|
||||
orders: [order],
|
||||
inventories: [buildInventory({ id: 502, remaining_quantity: 1, quantity: 3 })],
|
||||
profileByOrderId: new Map([[501, null]]),
|
||||
})
|
||||
|
||||
assert.equal(result.pairCount, 1)
|
||||
const pair = result.pairs[0]!
|
||||
assert.equal(pair.requiredQuantity, 3)
|
||||
assert.equal(pair.quantitySufficient, false)
|
||||
assert.equal(pair.autoMatchable, false, '数量不足时自动匹配不会选中,看板不能标成可自动匹配')
|
||||
assert.equal(pair.remainingQuantity, 1, '非拼单不展示拼单剩余份数')
|
||||
assert.equal(result.exactCount, 0, '数量不足的精确项不计入精确')
|
||||
assert.equal(result.quantityShortCount, 1)
|
||||
assert.equal(result.autoMatchableCount, 0)
|
||||
})
|
||||
|
||||
test('看板数量口径:拼单按整单汇总余量判断能否一次拼满', () => {
|
||||
const profile = buildProfile({
|
||||
profileId: 99,
|
||||
categoryId: 9,
|
||||
config: {
|
||||
...DEFAULT_CONSIGNMENT_PROFILE_CONFIG,
|
||||
specs: [],
|
||||
matching: { mode: 'manual', exactOnly: true, preferLowReward: true },
|
||||
},
|
||||
})
|
||||
const order = buildOrder({
|
||||
id: 511,
|
||||
category_id: 9,
|
||||
sharing_enabled: true,
|
||||
sharing_total_quantity: 3,
|
||||
active_share_quantity: 1,
|
||||
})
|
||||
|
||||
const enough = buildPendingConsignmentMatchPairs({
|
||||
orders: [order],
|
||||
inventories: [
|
||||
buildInventory({
|
||||
id: 512,
|
||||
category_id: 9,
|
||||
remaining_quantity: 2,
|
||||
quantity: 2,
|
||||
worker_id: 9,
|
||||
}),
|
||||
buildInventory({
|
||||
id: 513,
|
||||
category_id: 9,
|
||||
remaining_quantity: 1,
|
||||
quantity: 1,
|
||||
worker_id: 9,
|
||||
}),
|
||||
],
|
||||
profileByOrderId: new Map([[511, profile]]),
|
||||
})
|
||||
assert.ok(
|
||||
enough.pairs.every((pair) => pair.quantitySufficient),
|
||||
'2+1 能拼满剩余 2 份,两条库存都应标记为数量可行',
|
||||
)
|
||||
|
||||
const short = buildPendingConsignmentMatchPairs({
|
||||
orders: [order],
|
||||
inventories: [
|
||||
buildInventory({ id: 514, category_id: 9, remaining_quantity: 1, quantity: 1, worker_id: 9 }),
|
||||
],
|
||||
profileByOrderId: new Map([[511, profile]]),
|
||||
})
|
||||
assert.equal(short.pairs[0]?.quantitySufficient, false)
|
||||
assert.equal(short.quantityShortCount, 1)
|
||||
})
|
||||
|
||||
test('看板状态口径:内部匹配中的工单必须出现在看板里', () => {
|
||||
const result = buildPendingConsignmentMatchPairs({
|
||||
orders: [
|
||||
buildOrder({ id: 521, status: 'internal_matching' }),
|
||||
buildOrder({ id: 522, work_order_no: 'WK522', status: 'unassigned' }),
|
||||
buildOrder({ id: 523, work_order_no: 'WK523', status: 'in_progress' }),
|
||||
],
|
||||
inventories: [buildInventory({ id: 524 })],
|
||||
profileByOrderId: new Map([
|
||||
[521, null],
|
||||
[522, null],
|
||||
[523, null],
|
||||
]),
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
result.pairs.map((pair) => [pair.workOrderId, pair.workOrderStatus]),
|
||||
[
|
||||
[522, 'unassigned'],
|
||||
[521, 'internal_matching'],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('看板模板口径:模板能解析出订单规格时按 exact 展示,与引擎一致', () => {
|
||||
const templateSpec = {
|
||||
key: 'weaponSkin',
|
||||
label: '枪皮',
|
||||
type: 'text' as const,
|
||||
required: true,
|
||||
options: [],
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
matchMode: 'exact' as const,
|
||||
wildcardValues: [],
|
||||
showInItemName: true,
|
||||
sortOrder: 1,
|
||||
}
|
||||
const templateProfile = buildProfile({
|
||||
config: {
|
||||
...DEFAULT_CONSIGNMENT_PROFILE_CONFIG,
|
||||
specSource: 'template',
|
||||
specs: [templateSpec],
|
||||
},
|
||||
})
|
||||
const order = buildCustomOrder({ id: 531, product_rule_id: 8801, material_json: '{}' })
|
||||
const inventory = buildCustomInventory({ id: 532 })
|
||||
|
||||
const withoutOverride = buildPendingConsignmentMatchPairs({
|
||||
orders: [order],
|
||||
inventories: [inventory],
|
||||
profileByOrderId: new Map([[531, templateProfile]]),
|
||||
})
|
||||
assert.equal(withoutOverride.pairs[0]?.matchType, 'partial', '没有模板解析结果时按 unknown 处理')
|
||||
|
||||
const withOverride = buildPendingConsignmentMatchPairs({
|
||||
orders: [order],
|
||||
inventories: [inventory],
|
||||
profileByOrderId: new Map([[531, templateProfile]]),
|
||||
orderSpecOverrideByOrderId: new Map([[531, { weaponSkin: '龙纹' }]]),
|
||||
})
|
||||
assert.equal(withOverride.pairs[0]?.matchType, 'exact')
|
||||
assert.equal(withOverride.pairs[0]?.score, 100)
|
||||
})
|
||||
|
||||
test('看板自动匹配口径:非精确必须同时满足配置允许与正向规格信号', () => {
|
||||
const order = buildOrder({
|
||||
id: 541,
|
||||
product_name: '和平精英 苏尔南手枪',
|
||||
material_json: '{}',
|
||||
})
|
||||
const partialInventory = buildInventory({ id: 542, diamond_level: 1 })
|
||||
const allowPartialProfile = buildProfile({
|
||||
profileId: 9,
|
||||
categoryId: 1,
|
||||
config: {
|
||||
...DEFAULT_CONSIGNMENT_PROFILE_CONFIG,
|
||||
specs: [],
|
||||
matching: { mode: 'auto', exactOnly: false, preferLowReward: true },
|
||||
},
|
||||
})
|
||||
|
||||
const result = buildPendingConsignmentMatchPairs({
|
||||
orders: [order],
|
||||
inventories: [partialInventory],
|
||||
profileByOrderId: new Map([[541, allowPartialProfile]]),
|
||||
})
|
||||
|
||||
const pair = result.pairs[0]!
|
||||
assert.equal(pair.matchType, 'partial')
|
||||
assert.equal(
|
||||
pair.autoMatchable,
|
||||
false,
|
||||
'订单没写系统/区服/系列时没有正向规格信号,自动匹配不会选中',
|
||||
)
|
||||
})
|
||||
|
||||
test('看板自动匹配口径:订单写清系统且有高配库存时允许自动非精确', () => {
|
||||
const order = buildOrder({ id: 551, product_name: '苹果微信区 苏尔南手枪 暗潮 1钻' })
|
||||
const higherInventory = buildInventory({ id: 552, diamond_level: 3 })
|
||||
const allowPartialProfile = buildProfile({
|
||||
profileId: 9,
|
||||
categoryId: 1,
|
||||
config: {
|
||||
...DEFAULT_CONSIGNMENT_PROFILE_CONFIG,
|
||||
specs: [],
|
||||
matching: { mode: 'auto', exactOnly: false, preferLowReward: true },
|
||||
},
|
||||
})
|
||||
|
||||
const result = buildPendingConsignmentMatchPairs({
|
||||
orders: [order],
|
||||
inventories: [higherInventory],
|
||||
profileByOrderId: new Map([[551, allowPartialProfile]]),
|
||||
})
|
||||
|
||||
const pair = result.pairs[0]!
|
||||
assert.equal(pair.matchType, 'partial')
|
||||
assert.equal(pair.quantitySufficient, true)
|
||||
assert.equal(pair.autoMatchable, true, '高配充低配有正向规格信号,应允许自动匹配')
|
||||
assert.equal(result.autoMatchableCount, 1)
|
||||
assert.equal(result.partialCount, 1, '非精确但数量可行要计入非精确')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import { isFriendGiftAcceptanceMode } from '../../domain/work-order-acceptance-mode.js'
|
||||
import {
|
||||
listActiveConsignmentInventoriesForMatching,
|
||||
type ConsignmentInventoryRow,
|
||||
@@ -12,11 +13,16 @@ import {
|
||||
} from './consignment-matching-service.js'
|
||||
import {
|
||||
evaluateInventoryMatch,
|
||||
hasPositiveSpecSignal,
|
||||
inventoryMatchesProfile,
|
||||
isRematchExcludedInventory,
|
||||
planConsignmentSharingTakes,
|
||||
resolveMatchType,
|
||||
resolveOrderSpecOverride,
|
||||
resolveProfileForWorkOrder,
|
||||
} from './consignment-match-engine-service.js'
|
||||
import type { ConsignmentProfileSnapshot } from './consignment-profile-service.js'
|
||||
import type { ConsignmentSpecValues } from './consignment-spec-service.js'
|
||||
import { resolveCollectSubmittedFieldValues } from './work-order-material-mappers.js'
|
||||
|
||||
export type PendingPistolMatchPair = {
|
||||
@@ -55,6 +61,17 @@ const OPEN_ORDER_FETCH_LIMIT = 500
|
||||
const DEFAULT_RESULT_LIMIT = 300
|
||||
const MAX_RESULT_LIMIT = 1000
|
||||
|
||||
/**
|
||||
* 看板纳入的工单状态:
|
||||
* - `internal_matching`:手动匹配模式下工单会在这里等运营挑库存,看板必须能看到;
|
||||
* - `open` / `unassigned`:已发大厅但还没被接走,仍可由寄卖现货内部消化。
|
||||
*/
|
||||
const PENDING_BOARD_ORDER_STATUSES = new Set<string>([
|
||||
WORK_ORDER_STATUS.INTERNAL_MATCHING,
|
||||
WORK_ORDER_STATUS.OPEN,
|
||||
WORK_ORDER_STATUS.UNASSIGNED,
|
||||
])
|
||||
|
||||
function buildPairHaystack(pair: PendingPistolMatchPair): string {
|
||||
return [
|
||||
pair.workOrderNo,
|
||||
@@ -192,15 +209,33 @@ export type PendingConsignmentMatchPair = PendingPistolMatchPair & {
|
||||
remainingQuantity: number
|
||||
totalQuantity: number
|
||||
profileId: number | null
|
||||
/** 订单还需要多少件/份;非拼单=订单总数量,拼单=总份数-已匹配份数 */
|
||||
requiredQuantity: number
|
||||
/** 该条库存的可匹配余量 */
|
||||
availableQuantity: number
|
||||
/** 该条库存的可匹配余量是否够覆盖 requiredQuantity(拼单看整单汇总) */
|
||||
quantitySufficient: boolean
|
||||
/** 自动匹配是否真会选中它:精确,或配置允许非精确且存在正向规格信号 */
|
||||
autoMatchable: boolean
|
||||
/** 是否为拼单工单 */
|
||||
sharingEnabled: boolean
|
||||
/** 工单当前状态(看板同时展示 internal_matching 与 open/unassigned) */
|
||||
workOrderStatus: string
|
||||
}
|
||||
|
||||
export type PendingConsignmentMatchResult = {
|
||||
pendingOrderCount: number
|
||||
inStockInventoryCount: number
|
||||
pairCount: number
|
||||
/** 规格精确且数量可行的组合 */
|
||||
exactCount: number
|
||||
/** 规格非精确且数量可行的组合 */
|
||||
partialCount: number
|
||||
conflictCount: number
|
||||
/** 规格可行但库存余量不够的组合 */
|
||||
quantityShortCount: number
|
||||
/** 自动匹配真会选中的组合(精确,或允许非精确且有正向信号) */
|
||||
autoMatchableCount: number
|
||||
truncated: boolean
|
||||
pairs: PendingConsignmentMatchPair[]
|
||||
}
|
||||
@@ -244,21 +279,30 @@ function scoreLegacy(
|
||||
* 新口径的待抢单看板:与内部匹配引擎完全一致。
|
||||
*
|
||||
* - 有寄卖配置的工单:按 profile 过滤库存、跑 `evaluateInventoryMatch`,支持自定义规格;
|
||||
* - 模板驱动配置:套用与引擎相同的订单侧规格覆盖(`product_rule_id` → 模板商品名);
|
||||
* - 拼单工单:按 `sharing_total_quantity - active_share_quantity` 展示剩余份数;
|
||||
* - 没有寄卖配置的工单:保留旧手枪分类器兜底,避免配置缺失时看板直接空掉。
|
||||
*
|
||||
* 看板只做「预防」:是否真能落到自动/手动匹配,还要看数量与正向规格信号,
|
||||
* 因此每对结果都带 `quantitySufficient / autoMatchable`,避免运营看到 100 分却匹配不上。
|
||||
*/
|
||||
export function buildPendingConsignmentMatchPairs(input: {
|
||||
orders: WorkOrderRow[]
|
||||
inventories: ConsignmentInventoryRow[]
|
||||
profileByOrderId: Map<number, ConsignmentProfileSnapshot | null>
|
||||
/** 模板驱动配置的订单侧规格(由调用方异步解析;缺省时按 unknown 处理,不参与自动匹配) */
|
||||
orderSpecOverrideByOrderId?: Map<number, ConsignmentSpecValues | null> | undefined
|
||||
/** 只统计该寄卖分类下的订单与库存;0 / 未传 = 全部 */
|
||||
categoryId?: number | undefined
|
||||
/** 只统计这一条库存(库存侧「匹配订单」用) */
|
||||
inventoryId?: number | undefined
|
||||
keyword?: string | undefined
|
||||
includeConflicts?: boolean | undefined
|
||||
limit?: number | undefined
|
||||
ordersTruncated?: boolean | undefined
|
||||
}): PendingConsignmentMatchResult {
|
||||
const categoryId = Math.max(0, Math.floor(Number(input.categoryId) || 0))
|
||||
const inventoryId = Math.max(0, Math.floor(Number(input.inventoryId) || 0))
|
||||
const keyword = String(input.keyword || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
@@ -273,12 +317,16 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
profile: ConsignmentProfileSnapshot | null
|
||||
remainingQuantity: number
|
||||
totalQuantity: number
|
||||
requiredQuantity: number
|
||||
friendGift: boolean
|
||||
sharingOrder: boolean
|
||||
}> = []
|
||||
for (const order of input.orders) {
|
||||
if (String(order.status || '') !== WORK_ORDER_STATUS.OPEN) continue
|
||||
if (!PENDING_BOARD_ORDER_STATUSES.has(String(order.status || ''))) continue
|
||||
const profile = input.profileByOrderId.get(Number(order.id)) ?? null
|
||||
const totalQuantity = Math.max(1, Number(order.sharing_total_quantity || 1))
|
||||
const activeQuantity = Math.max(0, Number(order.active_share_quantity || 0))
|
||||
const sharingOrder = Boolean(profile) && Boolean(order.sharing_enabled) && totalQuantity > 1
|
||||
if (!profile) {
|
||||
// 兜底:没有寄卖配置的订单仍按旧手枪口径展示,且保持“已有份额不参与”的旧规则。
|
||||
if (activeQuantity > 0) continue
|
||||
@@ -291,28 +339,59 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
].join(' ')
|
||||
if (detectItemCategory(text) !== 'pistol_suernan') continue
|
||||
}
|
||||
const remainingQuantity =
|
||||
profile && order.sharing_enabled ? Math.max(0, totalQuantity - activeQuantity) : 1
|
||||
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
|
||||
}
|
||||
preparedOrders.push({ order, profile, remainingQuantity, totalQuantity })
|
||||
preparedOrders.push({
|
||||
order,
|
||||
profile,
|
||||
remainingQuantity,
|
||||
totalQuantity,
|
||||
// 整单(含多数量)要一次占用订单总数量;拼单只补剩余份数。
|
||||
requiredQuantity: sharingOrder ? remainingQuantity : totalQuantity,
|
||||
friendGift: isFriendGiftAcceptanceMode(order.acceptance_mode),
|
||||
sharingOrder,
|
||||
})
|
||||
}
|
||||
|
||||
const inventories = input.inventories.filter((inventory) => {
|
||||
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
|
||||
return true
|
||||
})
|
||||
|
||||
const pairs: PendingConsignmentMatchPair[] = []
|
||||
const matchedInventoryIds = new Set<number>()
|
||||
for (const { order, profile, remainingQuantity, totalQuantity } of preparedOrders) {
|
||||
for (const {
|
||||
order,
|
||||
profile,
|
||||
remainingQuantity,
|
||||
totalQuantity,
|
||||
requiredQuantity,
|
||||
friendGift,
|
||||
sharingOrder,
|
||||
} of preparedOrders) {
|
||||
const orderSpecOverride = input.orderSpecOverrideByOrderId?.get(Number(order.id)) ?? null
|
||||
const allowPartial = profile
|
||||
? profile.config.matching.mode === 'auto' && profile.config.matching.exactOnly === false
|
||||
: false
|
||||
|
||||
type EvaluatedInventory = {
|
||||
inventory: ConsignmentInventoryRow
|
||||
matchType: 'exact' | 'partial' | 'conflict'
|
||||
score: number
|
||||
reason: string
|
||||
conflicts: string[]
|
||||
matchDetails: PendingConsignmentMatchDetail[]
|
||||
positiveSpecSignal: boolean
|
||||
}
|
||||
const evaluated: EvaluatedInventory[] = []
|
||||
for (const inventory of inventories) {
|
||||
// 超时重新匹配的排除项:看板同样不能把它算成可匹配。
|
||||
if (isRematchExcludedInventory(order, inventory)) continue
|
||||
@@ -321,10 +400,13 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
let reason: string
|
||||
let conflicts: string[]
|
||||
let matchDetails: PendingConsignmentMatchDetail[]
|
||||
let positiveSpecSignal = true
|
||||
|
||||
if (profile) {
|
||||
if (!inventoryMatchesProfile(inventory, profile)) continue
|
||||
const evaluation = evaluateInventoryMatch(order, inventory, profile)
|
||||
const evaluation = evaluateInventoryMatch(order, inventory, profile, { orderSpecOverride })
|
||||
// 与引擎同口径:只有正向规格信号存在时,非精确才可能被自动匹配选中。
|
||||
positiveSpecSignal = hasPositiveSpecSignal(order, evaluation)
|
||||
if (evaluation.details.length === 0) {
|
||||
// 内置五字段沿用旧评分,保证存量口径与 0-100 分不变。
|
||||
const legacy = scoreLegacy(order, inventory)
|
||||
@@ -334,11 +416,7 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
conflicts = legacy.conflicts
|
||||
matchDetails = []
|
||||
} else {
|
||||
matchType = evaluation.exact
|
||||
? 'exact'
|
||||
: evaluation.conflicts.length > 0
|
||||
? 'conflict'
|
||||
: 'partial'
|
||||
matchType = resolveMatchType(evaluation)
|
||||
score = scoreFromDetails(evaluation.details)
|
||||
reason = buildEvaluationReason(evaluation)
|
||||
conflicts = evaluation.conflicts
|
||||
@@ -360,8 +438,43 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
matchDetails = []
|
||||
}
|
||||
|
||||
matchedInventoryIds.add(Number(inventory.id))
|
||||
if (matchType === 'conflict' && !includeConflicts) continue
|
||||
evaluated.push({
|
||||
inventory,
|
||||
matchType,
|
||||
score,
|
||||
reason,
|
||||
conflicts,
|
||||
matchDetails,
|
||||
positiveSpecSignal,
|
||||
})
|
||||
}
|
||||
|
||||
// 拼单是「多条库存凑满」:数量是否足够必须按整单可用余量汇总,
|
||||
// 不能只看某一条库存是否 >= 剩余份数,否则会把 2+1 可拼满的订单全标成数量不足。
|
||||
let sharingCoverageSufficient = true
|
||||
if (sharingOrder) {
|
||||
// 数量可行性只看「规格不是冲突」:非精确可以由运营填原因强制匹配,
|
||||
// 自动匹配是否能选中由下面的 autoMatchable 单独判断。
|
||||
const candidates = evaluated
|
||||
.filter((item) => item.matchType !== 'conflict')
|
||||
.map(({ inventory }) => ({ inventory }))
|
||||
const { outstanding } = planConsignmentSharingTakes(candidates, remainingQuantity, friendGift)
|
||||
sharingCoverageSufficient = outstanding === 0
|
||||
}
|
||||
for (const item of evaluated) {
|
||||
const availableQuantity = Math.max(0, Number(item.inventory.remaining_quantity ?? 1))
|
||||
// 整单/好友赠送必须一条库存吃满;拼单看整单汇总后能否一次拼满。
|
||||
const quantitySufficient = sharingOrder
|
||||
? sharingCoverageSufficient
|
||||
: friendGift
|
||||
? availableQuantity === requiredQuantity
|
||||
: availableQuantity >= requiredQuantity
|
||||
const autoMatchable =
|
||||
item.matchType !== 'conflict' &&
|
||||
quantitySufficient &&
|
||||
(item.matchType === 'exact' || (allowPartial && item.positiveSpecSignal))
|
||||
|
||||
if (item.matchType === 'conflict' && !includeConflicts) continue
|
||||
|
||||
const pair: PendingConsignmentMatchPair = {
|
||||
workOrderId: Number(order.id),
|
||||
@@ -369,23 +482,31 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
platformOrderId: String(order.platform_order_id || ''),
|
||||
productName: String(order.product_name || ''),
|
||||
workOrderCreatedAt: order.created_at ? String(order.created_at) : null,
|
||||
inventoryId: Number(inventory.id),
|
||||
consignmentNo: String(inventory.source_submission_no || inventory.inventory_no || ''),
|
||||
inventoryNo: String(inventory.inventory_no || ''),
|
||||
itemName: String(inventory.item_name || ''),
|
||||
inventoryInboundAt: inventory.inbound_at
|
||||
? String(inventory.inbound_at)
|
||||
: inventory.created_at
|
||||
? String(inventory.created_at)
|
||||
inventoryId: Number(item.inventory.id),
|
||||
consignmentNo: String(
|
||||
item.inventory.source_submission_no || item.inventory.inventory_no || '',
|
||||
),
|
||||
inventoryNo: String(item.inventory.inventory_no || ''),
|
||||
itemName: String(item.inventory.item_name || ''),
|
||||
inventoryInboundAt: item.inventory.inbound_at
|
||||
? String(item.inventory.inbound_at)
|
||||
: item.inventory.created_at
|
||||
? String(item.inventory.created_at)
|
||||
: null,
|
||||
matchType,
|
||||
score,
|
||||
reason,
|
||||
conflicts,
|
||||
matchDetails,
|
||||
matchType: item.matchType,
|
||||
score: item.score,
|
||||
reason: item.reason,
|
||||
conflicts: item.conflicts,
|
||||
matchDetails: item.matchDetails,
|
||||
remainingQuantity,
|
||||
totalQuantity,
|
||||
profileId: profile?.profileId ?? null,
|
||||
requiredQuantity,
|
||||
availableQuantity,
|
||||
quantitySufficient,
|
||||
autoMatchable,
|
||||
sharingEnabled: sharingOrder,
|
||||
workOrderStatus: String(order.status || ''),
|
||||
}
|
||||
|
||||
if (keyword && !buildPairHaystack(pair).includes(keyword)) continue
|
||||
@@ -393,9 +514,17 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const exactCount = pairs.filter((pair) => pair.matchType === 'exact').length
|
||||
const partialCount = pairs.filter((pair) => pair.matchType === 'partial').length
|
||||
const exactCount = pairs.filter(
|
||||
(pair) => pair.matchType === 'exact' && pair.quantitySufficient,
|
||||
).length
|
||||
const partialCount = pairs.filter(
|
||||
(pair) => pair.matchType === 'partial' && pair.quantitySufficient,
|
||||
).length
|
||||
const conflictCount = pairs.filter((pair) => pair.matchType === 'conflict').length
|
||||
const quantityShortCount = pairs.filter(
|
||||
(pair) => pair.matchType !== 'conflict' && !pair.quantitySufficient,
|
||||
).length
|
||||
const autoMatchableCount = pairs.filter((pair) => pair.autoMatchable).length
|
||||
|
||||
pairs.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score
|
||||
@@ -410,28 +539,36 @@ export function buildPendingConsignmentMatchPairs(input: {
|
||||
|
||||
return {
|
||||
pendingOrderCount: preparedOrders.length,
|
||||
inStockInventoryCount: matchedInventoryIds.size,
|
||||
inStockInventoryCount: inventories.length,
|
||||
pairCount: pairs.length,
|
||||
exactCount,
|
||||
partialCount,
|
||||
conflictCount,
|
||||
quantityShortCount,
|
||||
autoMatchableCount,
|
||||
truncated: pairs.length > limit || input.ordersTruncated === true,
|
||||
pairs: pairs.slice(0, limit),
|
||||
}
|
||||
}
|
||||
|
||||
/** 后台「寄售仓库 → 匹配」:待抢单 × 在库待匹配现货。 */
|
||||
/** 后台「寄售仓库 → 匹配」:待内部匹配 / 待抢单 × 在库待匹配现货。 */
|
||||
export async function listPendingConsignmentMatches(
|
||||
input: {
|
||||
/** 只统计该寄卖分类;0 / 未传 = 全部 */
|
||||
categoryId?: number | undefined
|
||||
/** 只统计这一条库存(库存侧「匹配订单」用) */
|
||||
inventoryId?: number | undefined
|
||||
keyword?: string | undefined
|
||||
includeConflicts?: boolean | undefined
|
||||
limit?: number | undefined
|
||||
} = {},
|
||||
): Promise<PendingConsignmentMatchResult> {
|
||||
const { items, total } = await listWorkOrders({
|
||||
statuses: [WORK_ORDER_STATUS.OPEN],
|
||||
statuses: [
|
||||
WORK_ORDER_STATUS.INTERNAL_MATCHING,
|
||||
WORK_ORDER_STATUS.OPEN,
|
||||
WORK_ORDER_STATUS.UNASSIGNED,
|
||||
],
|
||||
page: 1,
|
||||
pageSize: OPEN_ORDER_FETCH_LIMIT,
|
||||
includeTotal: true,
|
||||
@@ -455,11 +592,27 @@ export async function listPendingConsignmentMatches(
|
||||
}),
|
||||
)
|
||||
|
||||
// 模板驱动配置的订单侧规格必须与引擎同源解析,否则看板会把「标题没写规格、
|
||||
// 但模板能解析出规格」的订单误判成 partial。
|
||||
const orderSpecOverrideByOrderId = new Map<number, ConsignmentSpecValues | null>()
|
||||
await Promise.all(
|
||||
items.map(async (order) => {
|
||||
const profile = profileByOrderId.get(Number(order.id)) ?? null
|
||||
if (profile?.config.specSource !== 'template') return
|
||||
orderSpecOverrideByOrderId.set(
|
||||
Number(order.id),
|
||||
await resolveOrderSpecOverride(order, profile).catch(() => null),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return buildPendingConsignmentMatchPairs({
|
||||
orders: items,
|
||||
inventories,
|
||||
profileByOrderId,
|
||||
orderSpecOverrideByOrderId,
|
||||
categoryId: input.categoryId,
|
||||
inventoryId: input.inventoryId,
|
||||
keyword: input.keyword,
|
||||
includeConflicts: input.includeConflicts,
|
||||
limit: input.limit,
|
||||
|
||||
@@ -20,21 +20,40 @@ import {
|
||||
startConsignmentRematch,
|
||||
} from './consignment-match-engine-service.js'
|
||||
|
||||
/** 从工单物料里取本次匹配占用的寄卖库存;重新匹配时用于排除原库存。 */
|
||||
function resolveConsignmentInventoryId(material: unknown): number | null {
|
||||
/** 从工单物料里取本次匹配占用的寄卖库存/卖家;拼单会占多条库存、多个卖家。 */
|
||||
function resolveConsignmentExclusions(material: unknown): {
|
||||
inventoryIds: number[]
|
||||
workerIds: number[]
|
||||
} {
|
||||
let source = material
|
||||
if (typeof material === 'string') {
|
||||
try {
|
||||
source = JSON.parse(material)
|
||||
} catch {
|
||||
return null
|
||||
return { inventoryIds: [], workerIds: [] }
|
||||
}
|
||||
}
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return null
|
||||
const consignment = (source as Record<string, unknown>).consignment
|
||||
if (!consignment || typeof consignment !== 'object' || Array.isArray(consignment)) return null
|
||||
const inventoryId = Number((consignment as Record<string, unknown>).inventoryId || 0)
|
||||
return inventoryId > 0 ? inventoryId : null
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||
return { inventoryIds: [], workerIds: [] }
|
||||
}
|
||||
const record = source as Record<string, unknown>
|
||||
const collect = (value: unknown): number[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.map((item) => Number(item || 0)).filter((id) => id > 0)
|
||||
}
|
||||
const inventoryIds = new Set<number>()
|
||||
const workerIds = new Set<number>()
|
||||
for (const block of [record.consignment, record.consignmentSharing]) {
|
||||
if (!block || typeof block !== 'object' || Array.isArray(block)) continue
|
||||
const item = block as Record<string, unknown>
|
||||
const singleInventoryId = Number(item.inventoryId || 0)
|
||||
if (singleInventoryId > 0) inventoryIds.add(singleInventoryId)
|
||||
const singleWorkerId = Number(item.workerId || 0)
|
||||
if (singleWorkerId > 0) workerIds.add(singleWorkerId)
|
||||
for (const id of collect(item.inventoryIds)) inventoryIds.add(id)
|
||||
for (const id of collect(item.workerIds)) workerIds.add(id)
|
||||
}
|
||||
return { inventoryIds: [...inventoryIds], workerIds: [...workerIds] }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +64,12 @@ function resolveConsignmentInventoryId(material: unknown): number | null {
|
||||
*/
|
||||
async function startRematchOrFallback(
|
||||
workOrderId: number,
|
||||
input: { excludeWorkerId?: number | null; excludeInventoryId?: number | null },
|
||||
input: {
|
||||
excludeWorkerId?: number | null
|
||||
excludeInventoryId?: number | null
|
||||
excludeWorkerIds?: number[]
|
||||
excludeInventoryIds?: number[]
|
||||
},
|
||||
now: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -76,11 +100,18 @@ export async function settleOverdueWorkOrders(options: { limit?: number; workerI
|
||||
processedCount += 1
|
||||
if (isRematchTimeoutPolicy(policy)) {
|
||||
// 重新匹配:回内部匹配池,并排除原卖家/原库存,避免同一卖家立刻接回同一单。
|
||||
const exclusions = resolveConsignmentExclusions(workOrder.material_json)
|
||||
const assignedWorkerId = Number(workOrder.assigned_worker_id || 0)
|
||||
await startRematchOrFallback(
|
||||
Number(workOrder.id),
|
||||
{
|
||||
excludeWorkerId: Number(workOrder.assigned_worker_id || 0) || null,
|
||||
excludeInventoryId: resolveConsignmentInventoryId(workOrder.material_json),
|
||||
excludeWorkerId: assignedWorkerId || null,
|
||||
excludeInventoryId: exclusions.inventoryIds[0] ?? null,
|
||||
excludeWorkerIds: [
|
||||
...exclusions.workerIds,
|
||||
...(assignedWorkerId > 0 ? [assignedWorkerId] : []),
|
||||
],
|
||||
excludeInventoryIds: exclusions.inventoryIds,
|
||||
},
|
||||
nowIso(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Alert, App, Button, Checkbox, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import {
|
||||
matchAdminConsignmentWorkOrder,
|
||||
fetchPendingPistolMatches,
|
||||
} from '@/services/admin/consignment'
|
||||
import type { PendingPistolMatchPair } from '@/types/consignment'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
/**
|
||||
* 库存侧「匹配订单」:从一条在库现货出发,复用待匹配看板的统一判定
|
||||
* (profile、模板规格、数量、重新匹配排除),落库走新引擎 `/consignment/match`。
|
||||
*
|
||||
* 旧 `/fulfill` 链路只做整单、且不认数量拆分,这里不再暴露。
|
||||
*/
|
||||
export default function ConsignmentInventoryMatchModal({
|
||||
open,
|
||||
inventoryId,
|
||||
inventoryLabel,
|
||||
onClose,
|
||||
onMatched,
|
||||
}: {
|
||||
open: boolean
|
||||
inventoryId: number | null
|
||||
inventoryLabel?: string
|
||||
onClose: () => void
|
||||
onMatched?: () => void
|
||||
}) {
|
||||
const { message } = App.useApp()
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [allowNonExact, setAllowNonExact] = useState(false)
|
||||
const [reason, setReason] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-consignment-inventory-match-orders', inventoryId ?? null],
|
||||
queryFn: () =>
|
||||
fetchPendingPistolMatches({
|
||||
inventoryId: Number(inventoryId),
|
||||
includeConflicts: true,
|
||||
limit: 1000,
|
||||
}),
|
||||
enabled: open && Boolean(inventoryId),
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
})
|
||||
const pairs = useMemo(() => query.data?.data?.pairs ?? [], [query.data])
|
||||
|
||||
const selected = useMemo(
|
||||
() => pairs.find((pair) => pair.workOrderId === selectedId) || null,
|
||||
[pairs, selectedId],
|
||||
)
|
||||
const selectedBlocked = selected
|
||||
? selected.quantitySufficient === false || (selected.matchType === 'conflict' && !allowNonExact)
|
||||
: true
|
||||
const needReason = Boolean(selected && selected.matchType !== 'exact')
|
||||
|
||||
const columns: TableColumnsType<PendingPistolMatchPair> = [
|
||||
{
|
||||
title: '选择',
|
||||
key: 'select',
|
||||
width: 64,
|
||||
render: (_, record) => {
|
||||
const disabled =
|
||||
record.quantitySufficient === false || (record.matchType === 'conflict' && !allowNonExact)
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
type={selectedId === record.workOrderId ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
onClick={() => setSelectedId(record.workOrderId)}
|
||||
>
|
||||
{selectedId === record.workOrderId ? '已选' : '选择'}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '工单',
|
||||
key: 'order',
|
||||
width: 220,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text copyable={{ text: record.workOrderNo }} style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
{record.workOrderNo || '-'}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{record.platformOrderId || '-'} · {record.workOrderStatus || '-'}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品名',
|
||||
key: 'productName',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Text ellipsis={{ tooltip: record.productName }} style={{ maxWidth: '100%' }}>
|
||||
{record.productName || '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '规格判定',
|
||||
key: 'match',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
if (record.quantitySufficient === false) return <Tag color="orange">数量不足</Tag>
|
||||
if (record.matchType === 'exact') return <Tag color="green">精确</Tag>
|
||||
if (record.matchType === 'conflict') return <Tag>不匹配</Tag>
|
||||
return <Tag color="blue">非精确</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
key: 'quantity',
|
||||
width: 130,
|
||||
render: (_, record) => (
|
||||
<Text
|
||||
type={record.quantitySufficient === false ? 'danger' : 'secondary'}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{record.sharingEnabled
|
||||
? `拼单还需 ${record.requiredQuantity ?? 1} 份 / 该库存剩 ${record.availableQuantity ?? '-'} 件`
|
||||
: `需求 ${record.requiredQuantity ?? 1} 件 / 该库存剩 ${record.availableQuantity ?? '-'} 件`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '匹配说明',
|
||||
key: 'reason',
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text style={{ fontSize: 12 }}>{record.reason || '-'}</Text>
|
||||
{record.conflicts.length > 0 ? (
|
||||
<Text type="danger" style={{ fontSize: 11 }}>
|
||||
{record.conflicts.join(';')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function handleClose() {
|
||||
setSelectedId(null)
|
||||
setAllowNonExact(false)
|
||||
setReason('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selected || !inventoryId) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await matchAdminConsignmentWorkOrder({
|
||||
workOrderId: selected.workOrderId,
|
||||
inventoryIds: [Number(inventoryId)],
|
||||
allowNonExact: allowNonExact || selected.matchType !== 'exact',
|
||||
reason: reason.trim(),
|
||||
})
|
||||
if (res.data?.matched === false) {
|
||||
message.warning('工单状态或库存已变化,请刷新后重试')
|
||||
await query.refetch()
|
||||
return
|
||||
}
|
||||
message.success('已用该寄卖库存匹配工单')
|
||||
setSelectedId(null)
|
||||
setAllowNonExact(false)
|
||||
setReason('')
|
||||
onMatched?.()
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '匹配失败,请刷新后重试')
|
||||
void query.refetch()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`匹配订单 · ${inventoryLabel || `库存 #${inventoryId ?? ''}`}`}
|
||||
open={open}
|
||||
width="min(1200px, 94vw)"
|
||||
onCancel={handleClose}
|
||||
okText="确认匹配"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ disabled: !selected || selectedBlocked || (needReason && !reason.trim()) }}
|
||||
confirmLoading={submitting}
|
||||
onOk={() => void handleSubmit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="按寄卖配置与数量判定候选工单;多数量库存会按剩余量扣减,不会被整批消耗。"
|
||||
description="精确匹配可直接提交;非精确或规格冲突必须勾选强制匹配并填写原因。拼单工单必须一次拼满,单条库存不够时请到工单侧匹配。"
|
||||
/>
|
||||
<Space wrap size={12}>
|
||||
<Checkbox
|
||||
checked={allowNonExact}
|
||||
onChange={(event) => setAllowNonExact(event.target.checked)}
|
||||
>
|
||||
允许非精确/冲突强制匹配
|
||||
</Checkbox>
|
||||
<Input
|
||||
style={{ width: 360 }}
|
||||
placeholder="强制匹配原因(非精确必填)"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
disabled={!allowNonExact && selected?.matchType === 'exact'}
|
||||
/>
|
||||
<Button loading={query.isFetching} onClick={() => void query.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
{query.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="读取可匹配工单失败"
|
||||
description={query.error instanceof Error ? query.error.message : '请稍后重试'}
|
||||
/>
|
||||
) : null}
|
||||
<PercentTable<PendingPistolMatchPair>
|
||||
rowKey={(record) => `${record.workOrderId}-${record.inventoryId}`}
|
||||
size="small"
|
||||
loading={query.isFetching}
|
||||
dataSource={pairs}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
locale={{
|
||||
emptyText: <Empty description="这条库存当前没有可匹配的工单" />,
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { SearchOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
fetchInventoryMatchableOrders,
|
||||
fetchWorkOrderConsignmentMatchOptions,
|
||||
fulfillWorkOrderWithConsignment,
|
||||
} from '@/services/admin/consignment'
|
||||
import type { ConsignmentMatchOptionView, ConsignmentMatchableOrder } from '@/types/consignment'
|
||||
import {
|
||||
canSubmitManualMatch,
|
||||
describeManualMatchType,
|
||||
filterManualMatchOptions,
|
||||
resolveManualMatchRequirement,
|
||||
type ManualMatchType,
|
||||
} from '@/utils/consignment-manual-match'
|
||||
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { notifyVoucherConsume } from '@/utils/voucher-consume'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
type Selected = {
|
||||
workOrderId: number | string
|
||||
workOrderLabel: string
|
||||
workOrderCreatedAt?: string
|
||||
inventoryId: number | string
|
||||
inventoryLabel: string
|
||||
inventoryInboundAt?: string
|
||||
matchType: ManualMatchType
|
||||
conflicts: string[]
|
||||
/** 供货打手(用于回显供货来源) */
|
||||
workerId?: number | null
|
||||
workerName?: string
|
||||
/** 对接客服(用于履约前核对归属) */
|
||||
contactSupporterName?: string
|
||||
}
|
||||
|
||||
export type ConsignmentManualMatchModalProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 订单侧入口:从订单出发选现货 */
|
||||
workOrder?: { workOrderId: number | string; label: string; createdAt?: string } | null
|
||||
/** 库存侧入口:从现货出发选订单 */
|
||||
inventory?: { inventoryId: number | string; label: string; inboundAt?: string } | null
|
||||
onMatched?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 「手动指定现货」弹窗:订单侧与库存侧共用一套交互与落地逻辑。
|
||||
*
|
||||
* 统一走内部现货直接履约(原子占库、免外派佣金、不冻结重复押金、跳过打手资料直接进入待加好友)。
|
||||
*/
|
||||
export default function ConsignmentManualMatchModal({
|
||||
open,
|
||||
onClose,
|
||||
workOrder,
|
||||
inventory,
|
||||
onMatched,
|
||||
}: ConsignmentManualMatchModalProps) {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [options, setOptions] = useState<ConsignmentMatchOptionView[]>([])
|
||||
const [orders, setOrders] = useState<ConsignmentMatchableOrder[]>([])
|
||||
const [inventoryLabel, setInventoryLabel] = useState('')
|
||||
const [inventoryInboundAt, setInventoryInboundAt] = useState('')
|
||||
const [inventoryWorker, setInventoryWorker] = useState<{
|
||||
workerId: number | null
|
||||
workerName: string
|
||||
} | null>(null)
|
||||
const [inventoryContact, setInventoryContact] = useState<{
|
||||
contactSupporterName: string
|
||||
} | null>(null)
|
||||
const [selected, setSelected] = useState<Selected | null>(null)
|
||||
const [confirmedPartial, setConfirmedPartial] = useState(false)
|
||||
const [confirmedMismatch, setConfirmedMismatch] = useState(false)
|
||||
const [matchNote, setMatchNote] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const mode: 'order' | 'inventory' = inventory ? 'inventory' : 'order'
|
||||
|
||||
const resetState = () => {
|
||||
setKeyword('')
|
||||
setSelected(null)
|
||||
setConfirmedPartial(false)
|
||||
setConfirmedMismatch(false)
|
||||
setMatchNote('')
|
||||
setInventoryInboundAt('')
|
||||
setInventoryContact(null)
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (inventory) {
|
||||
// 关键字与分页透传后端:库存侧默认只拉 50 条时,第 51 单以后永远搜不到
|
||||
const res = await fetchInventoryMatchableOrders(inventory.inventoryId, {
|
||||
keyword: keyword.trim() || undefined,
|
||||
pageSize: 100,
|
||||
})
|
||||
setOrders(res.data?.orders || [])
|
||||
setInventoryLabel(
|
||||
res.data?.inventory
|
||||
? `${res.data.inventory.itemName}(${res.data.inventory.sourceSubmissionNo || res.data.inventory.inventoryNo})`
|
||||
: inventory.label,
|
||||
)
|
||||
setInventoryInboundAt(res.data?.inventory?.inboundAt || inventory.inboundAt || '')
|
||||
setInventoryWorker({
|
||||
workerId: res.data?.inventory?.workerId ?? null,
|
||||
workerName: res.data?.inventory?.workerName || '',
|
||||
})
|
||||
setInventoryContact({
|
||||
contactSupporterName: res.data?.inventory?.contactSupporterName || '',
|
||||
})
|
||||
} else if (workOrder) {
|
||||
const res = await fetchWorkOrderConsignmentMatchOptions(workOrder.workOrderId)
|
||||
setOptions(res.data?.options || [])
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '读取可匹配数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 库存侧:关键字防抖透传后端搜索(本地过滤只作用在已拉取的 100 条内)
|
||||
useEffect(() => {
|
||||
if (!open || !inventory) return
|
||||
const timer = setTimeout(() => {
|
||||
void load()
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keyword])
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
filterManualMatchOptions(options, keyword, (item) => [
|
||||
item.inventory.itemName,
|
||||
item.inventory.sourceSubmissionNo,
|
||||
item.inventory.inventoryNo,
|
||||
item.inventory.system,
|
||||
item.inventory.serverZone,
|
||||
item.inventory.series,
|
||||
item.inventory.workerName,
|
||||
item.inventory.buyerName,
|
||||
item.inventory.sellerGameId,
|
||||
item.inventory.id,
|
||||
]),
|
||||
[options, keyword],
|
||||
)
|
||||
const filteredOrders = useMemo(
|
||||
() =>
|
||||
filterManualMatchOptions(orders, keyword, (item) => [
|
||||
item.workOrderNo,
|
||||
item.workOrderId,
|
||||
item.productName,
|
||||
item.requiredSpec.system,
|
||||
item.requiredSpec.serverZone,
|
||||
item.requiredSpec.series,
|
||||
item.status,
|
||||
]),
|
||||
[orders, keyword],
|
||||
)
|
||||
|
||||
const requirement = resolveManualMatchRequirement(selected?.matchType || 'exact')
|
||||
const canSubmit =
|
||||
Boolean(selected) &&
|
||||
canSubmitManualMatch({
|
||||
matchType: selected?.matchType || 'exact',
|
||||
confirmedPartial,
|
||||
confirmedMismatch,
|
||||
matchNote,
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selected) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fulfillWorkOrderWithConsignment({
|
||||
workOrderId: selected.workOrderId,
|
||||
inventoryId: selected.inventoryId,
|
||||
allowPartial: selected.matchType !== 'exact' || confirmedPartial,
|
||||
allowSpecMismatch: selected.matchType === 'conflict' || confirmedMismatch,
|
||||
matchNote: matchNote.trim(),
|
||||
})
|
||||
|
||||
// 核销失败时共享工具会弹警告(状态已变更但凭证没消耗),此时不能再弹"成功"掩盖
|
||||
const voucher = (res.data as any)?.voucherConsume
|
||||
notifyVoucherConsume(voucher)
|
||||
if (!voucher || voucher.ok) {
|
||||
message.success('已使用内部寄售现货直接履约,已进入待加好友阶段')
|
||||
}
|
||||
onMatched?.()
|
||||
resetState()
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
// 列表数据过期(并发占用 / 打开弹窗后订单规格被改):刷新列表按最新判定重来,
|
||||
// 否则运营会对着已失效的候选反复重试、循环撞同一个错
|
||||
const code = String(err?.errorCode || '')
|
||||
if (
|
||||
code === 'consignment_inventory_race_condition' ||
|
||||
code === 'consignment_inventory_unavailable'
|
||||
) {
|
||||
message.error('该现货刚被其他操作占用或已下架,已刷新列表,请重新选择')
|
||||
setSelected(null)
|
||||
void load()
|
||||
return
|
||||
}
|
||||
if (
|
||||
code === 'consignment_spec_mismatch' ||
|
||||
code === 'consignment_spec_partial_requires_confirm'
|
||||
) {
|
||||
message.error('订单或现货的规格已变化(列表数据过期),已刷新,请按最新判定重新确认')
|
||||
setSelected(null)
|
||||
void load()
|
||||
return
|
||||
}
|
||||
message.error(err.message || '内部现货履约失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={mode === 'order' ? `手动指定现货 · ${workOrder?.label || ''}` : '手动匹配订单'}
|
||||
open={open}
|
||||
width={860}
|
||||
okText="确认内部现货履约"
|
||||
okButtonProps={{
|
||||
disabled: !canSubmit,
|
||||
loading: submitting,
|
||||
}}
|
||||
onOk={() => void handleSubmit()}
|
||||
onCancel={() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}}
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) {
|
||||
void load()
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="手动匹配将直接选用内部寄售现货履约(原子占库、免外派佣金、不冻结重复押金、自动跳过打手资料)。"
|
||||
description={
|
||||
mode === 'order'
|
||||
? '下面是该订单的全部在库现货(含规格不一致的),核对差异后直接内部履约。'
|
||||
: `当前现货:${inventoryLabel || inventory?.label || ''}${
|
||||
inventoryInboundAt ? `(审核入库:${formatDateTime(inventoryInboundAt)})` : ''
|
||||
}。下面是可匹配的订单列表,核对后直接内部履约。`
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder={
|
||||
mode === 'order'
|
||||
? '搜索物品名 / 寄卖单号 (SUB...) / 仓库号 (INV...) / 规格 / 供货打手'
|
||||
: '搜索工单号 (WK... / WO...) / 平台单号 / 标题 / 规格'
|
||||
}
|
||||
prefix={<SearchOutlined />}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<div style={{ maxHeight: 320, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{mode === 'order' ? (
|
||||
filteredOptions.length === 0 ? (
|
||||
<Empty description="没有可匹配的现货" />
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const meta = describeManualMatchType(option.matchType)
|
||||
const isSelected =
|
||||
selected?.inventoryId != null &&
|
||||
String(selected.inventoryId) === String(option.inventory.id)
|
||||
return (
|
||||
<div
|
||||
key={option.inventory.id}
|
||||
style={{
|
||||
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginBottom: 8,
|
||||
background: isSelected ? '#fff7e6' : '#fff',
|
||||
}}
|
||||
>
|
||||
<Space
|
||||
style={{ width: '100%', justifyContent: 'space-between' }}
|
||||
align="start"
|
||||
>
|
||||
<Space direction="vertical" size={2}>
|
||||
<Space size={6} wrap>
|
||||
<Tag color="cyan">
|
||||
{option.inventory.sourceSubmissionNo || option.inventory.inventoryNo}
|
||||
</Tag>
|
||||
<Text strong>{option.inventory.itemName}</Text>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
寄卖单号{' '}
|
||||
{option.inventory.sourceSubmissionNo || option.inventory.inventoryNo} ·{' '}
|
||||
{option.inventory.system || '未指定'}/
|
||||
{option.inventory.serverZone || '未指定'}/
|
||||
{option.inventory.series || '未指定'}{' '}
|
||||
{option.inventory.diamondLevel
|
||||
? `${option.inventory.diamondLevel}钻`
|
||||
: ''}{' '}
|
||||
· 货源 {option.inventory.workerName || '未关联打手'} · 结算价 ¥
|
||||
{(Number(option.inventory.costPriceFen || 0) / 100).toFixed(2)} ·
|
||||
对接客服 {formatConsignmentContactSupporter(option.inventory).text}
|
||||
{option.inventory.inboundAt || option.inventory.createdAt ? (
|
||||
<>
|
||||
{' '}
|
||||
· 审核入库{' '}
|
||||
{formatDateTime(
|
||||
option.inventory.inboundAt || option.inventory.createdAt,
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
{option.conflicts.length > 0 ? (
|
||||
<Text style={{ fontSize: 12, color: '#cf1322' }}>
|
||||
差异:{option.conflicts.join(';')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{option.reason || '规格完全一致'}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Button
|
||||
size="small"
|
||||
type={isSelected ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setSelected({
|
||||
workOrderId: workOrder?.workOrderId || 0,
|
||||
workOrderLabel: workOrder?.label || '',
|
||||
workOrderCreatedAt: workOrder?.createdAt,
|
||||
inventoryId: option.inventory.id,
|
||||
inventoryLabel: option.inventory.itemName,
|
||||
inventoryInboundAt:
|
||||
option.inventory.inboundAt || option.inventory.createdAt,
|
||||
matchType: option.matchType,
|
||||
conflicts: option.conflicts,
|
||||
workerId: option.inventory.workerId,
|
||||
workerName: option.inventory.workerName,
|
||||
contactSupporterName: option.inventory.contactSupporterName,
|
||||
})
|
||||
setConfirmedPartial(false)
|
||||
setConfirmedMismatch(false)
|
||||
setMatchNote('')
|
||||
}}
|
||||
>
|
||||
{isSelected ? '已选' : '选它'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<Empty description="没有待匹配的订单" />
|
||||
) : (
|
||||
filteredOrders.map((order) => {
|
||||
const meta = describeManualMatchType(order.matchType)
|
||||
const isSelected =
|
||||
selected?.workOrderId != null &&
|
||||
String(selected.workOrderId) === String(order.workOrderId)
|
||||
return (
|
||||
<div
|
||||
key={order.workOrderId}
|
||||
style={{
|
||||
border: isSelected ? '1px solid #fa8c16' : '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginBottom: 8,
|
||||
background: isSelected ? '#fff7e6' : '#fff',
|
||||
}}
|
||||
>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
|
||||
<Space direction="vertical" size={2}>
|
||||
<Space size={6} wrap>
|
||||
<Tag color="purple">{order.workOrderNo || `#${order.workOrderId}`}</Tag>
|
||||
<Text strong>{order.productName}</Text>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
{order.status === 'open' ? (
|
||||
<Tag color="blue">大厅待抢</Tag>
|
||||
) : order.status === 'unassigned' ? (
|
||||
<Tag color="orange">待指派</Tag>
|
||||
) : order.status === 'pending_material' ? (
|
||||
<Tag color="gold">待补资料</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
要求:{order.requiredSpec.system || '未指定'}/
|
||||
{order.requiredSpec.serverZone || '未指定'}/
|
||||
{order.requiredSpec.series || '未指定'}{' '}
|
||||
{order.requiredSpec.diamondLevel
|
||||
? `${order.requiredSpec.diamondLevel}钻`
|
||||
: ''}{' '}
|
||||
· 报酬 ¥{(order.rewardAmountFen / 100).toFixed(2)}
|
||||
{order.createdAt ? (
|
||||
<> · 创建时间 {formatDateTime(order.createdAt)}</>
|
||||
) : null}
|
||||
</Text>
|
||||
{order.conflicts.length > 0 ? (
|
||||
<Text style={{ fontSize: 12, color: '#cf1322' }}>
|
||||
差异:{order.conflicts.join(';')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{order.reason || '规格相符'}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Button
|
||||
size="small"
|
||||
type={isSelected ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
setSelected({
|
||||
workOrderId: order.workOrderId,
|
||||
workOrderLabel: `${order.workOrderNo || `#${order.workOrderId}`} · ${order.productName}`,
|
||||
workOrderCreatedAt: order.createdAt,
|
||||
inventoryId: inventory?.inventoryId || 0,
|
||||
inventoryLabel,
|
||||
inventoryInboundAt,
|
||||
matchType: order.matchType,
|
||||
conflicts: order.conflicts,
|
||||
workerId: inventoryWorker?.workerId ?? null,
|
||||
workerName: inventoryWorker?.workerName || '',
|
||||
contactSupporterName: inventoryContact?.contactSupporterName || '',
|
||||
})
|
||||
setConfirmedPartial(false)
|
||||
setConfirmedMismatch(false)
|
||||
setMatchNote('')
|
||||
}}
|
||||
>
|
||||
{isSelected ? '已选' : '选它'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
{selected ? (
|
||||
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 12 }}>
|
||||
<Descriptions size="small" column={1} style={{ marginBottom: 8 }}>
|
||||
<Descriptions.Item label="订单">
|
||||
<Space size={6} wrap>
|
||||
<span>{selected.workOrderLabel || '—'}</span>
|
||||
{selected.workOrderCreatedAt ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
(创建于 {formatDateTime(selected.workOrderCreatedAt)})
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="现货">
|
||||
<Space size={6} wrap>
|
||||
<span>{selected.inventoryLabel || '—'}</span>
|
||||
{selected.inventoryInboundAt ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
(审核入库于 {formatDateTime(selected.inventoryInboundAt)})
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="供货来源">
|
||||
{selected.workerId ? (
|
||||
<Space size={6}>
|
||||
<Tag icon={<TeamOutlined />} color="blue">
|
||||
{selected.workerName || `供货打手 #${selected.workerId}`}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
(验收后将自动为该打手发放货款)
|
||||
</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Tag color="default">内部库存现货(无打手关联)</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="对接客服">
|
||||
{formatConsignmentContactSupporter(selected).text}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="匹配判定">
|
||||
<Tag color={describeManualMatchType(selected.matchType).color}>
|
||||
{describeManualMatchType(selected.matchType).label}
|
||||
</Tag>
|
||||
{selected.conflicts.length > 0 ? (
|
||||
<Text style={{ fontSize: 12, color: '#cf1322' }}>
|
||||
{selected.conflicts.join(';')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{requirement.needsPartialConfirm ? (
|
||||
<Checkbox
|
||||
checked={confirmedPartial}
|
||||
onChange={(e) => setConfirmedPartial(e.target.checked)}
|
||||
>
|
||||
允许非精确匹配(订单规格未写全或高配充低配)
|
||||
</Checkbox>
|
||||
) : null}
|
||||
|
||||
{requirement.needsMismatchConfirm ? (
|
||||
<>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="该现货与订单规格不一致,确认要强制匹配?"
|
||||
description="系统/区服/系列/钻级不符通常是发错货;确实要指定请勾选下方确认并填写原因,操作会留痕。"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={confirmedMismatch}
|
||||
onChange={(e) => setConfirmedMismatch(e.target.checked)}
|
||||
>
|
||||
确认规格不一致,仍要手动匹配
|
||||
</Checkbox>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{requirement.needsNote ? (
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
maxLength={200}
|
||||
showCount
|
||||
placeholder="手动匹配原因(必填,会写入工单留痕,便于事后核对)"
|
||||
value={matchNote}
|
||||
onChange={(e) => setMatchNote(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
|
||||
确认后现货立即被占用并进入「待加好友」,双方加好买家好友后点确认加好友进入赠送冷却期。
|
||||
如需退回请使用「撤销寄售履约」。
|
||||
</Paragraph>
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -12,13 +12,25 @@ import { formatDateTime } from '@/utils/date-time'
|
||||
const { Text } = Typography
|
||||
|
||||
function resolveScoreTag(pair: PendingPistolMatchPair) {
|
||||
if (pair.matchType === 'exact') return { color: 'green', label: '精确' }
|
||||
if (pair.quantitySufficient === false) return { color: 'orange', label: '数量不足' }
|
||||
if (pair.matchType === 'exact') {
|
||||
return pair.autoMatchable === false
|
||||
? { color: 'gold', label: '需人工' }
|
||||
: { color: 'green', label: '精确' }
|
||||
}
|
||||
if (pair.matchType === 'conflict') return { color: 'default', label: '不匹配' }
|
||||
if (pair.autoMatchable) return { color: 'blue', label: '可自动' }
|
||||
if (pair.score >= 90) return { color: 'blue', label: '非精确' }
|
||||
if (pair.score >= 70) return { color: 'orange', label: '非精确' }
|
||||
return { color: 'gold', label: '需核对' }
|
||||
}
|
||||
|
||||
function resolveOrderStatusTag(status?: string) {
|
||||
if (status === 'internal_matching') return { color: 'purple', label: '内部匹配中' }
|
||||
if (status === 'unassigned') return { color: 'default', label: '待分配' }
|
||||
return { color: 'blue', label: '待抢单' }
|
||||
}
|
||||
|
||||
export default function ConsignmentPendingMatchModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -106,9 +118,28 @@ export default function ConsignmentPendingMatchModal({
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
录单 {record.workOrderCreatedAt ? formatDateTime(record.workOrderCreatedAt) : '-'}
|
||||
</Text>
|
||||
{Number(record.totalQuantity || 1) > 1 ? (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份
|
||||
<Space size={4} wrap>
|
||||
<Tag
|
||||
color={resolveOrderStatusTag(record.workOrderStatus).color}
|
||||
style={{ marginInlineEnd: 0 }}
|
||||
>
|
||||
{resolveOrderStatusTag(record.workOrderStatus).label}
|
||||
</Tag>
|
||||
{Number(record.totalQuantity || 1) > 1 ? (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
拼单剩余 {record.remainingQuantity ?? 0} / {record.totalQuantity} 份
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{Number(record.requiredQuantity || 0) > 0 ? (
|
||||
<Text
|
||||
type={record.quantitySufficient === false ? 'danger' : 'secondary'}
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{record.sharingEnabled
|
||||
? `拼单还需 ${record.requiredQuantity} 份 / 该库存剩 ${record.availableQuantity ?? '-'} 件`
|
||||
: `需求 ${record.requiredQuantity} 件 / 该库存剩 ${record.availableQuantity ?? '-'} 件`}
|
||||
{record.quantitySufficient === false ? '(整单汇总不足)' : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
@@ -198,7 +229,11 @@ export default function ConsignmentPendingMatchModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={categoryName ? `匹配 · ${categoryName} 待抢单 × 在库现货` : '匹配 · 待抢单 × 在库现货'}
|
||||
title={
|
||||
categoryName
|
||||
? `匹配 · ${categoryName} 待匹配订单 × 在库现货`
|
||||
: '匹配 · 待匹配订单 × 在库现货'
|
||||
}
|
||||
open={open}
|
||||
width="min(1320px, 94vw)"
|
||||
footer={null}
|
||||
@@ -211,19 +246,21 @@ export default function ConsignmentPendingMatchModal({
|
||||
showIcon
|
||||
message={
|
||||
categoryName
|
||||
? `只统计「${categoryName}」分类下的待抢单 × 在库待匹配现货,不直接改单。`
|
||||
: '按寄卖配置匹配全部待抢单 × 在库待匹配现货,不直接改单。'
|
||||
? `只统计「${categoryName}」分类下的待匹配订单 × 在库待匹配现货,不直接改单。`
|
||||
: '按寄卖配置匹配全部待匹配订单 × 在库待匹配现货,不直接改单。'
|
||||
}
|
||||
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);拼单订单按剩余份数展示。"
|
||||
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);内部匹配中、待分配、待抢单的工单都会纳入。数量不足或没有正向规格信号的非精确项会单独标记,自动匹配不会选中。"
|
||||
/>
|
||||
|
||||
<Space wrap size={12}>
|
||||
{categoryName ? <Tag color="blue">分类:{categoryName}</Tag> : null}
|
||||
<Tag>待抢单 {data?.pendingOrderCount ?? 0} 单</Tag>
|
||||
<Tag>待匹配 {data?.pendingOrderCount ?? 0} 单</Tag>
|
||||
<Tag>在库待匹配 {data?.inStockInventoryCount ?? 0} 件</Tag>
|
||||
<Tag color="blue">匹配组合 {data?.pairCount ?? 0} 条</Tag>
|
||||
<Tag color="green">精确 {data?.exactCount ?? 0}</Tag>
|
||||
<Tag color="orange">非精确 {data?.partialCount ?? 0}</Tag>
|
||||
<Tag color="purple">数量不足 {data?.quantityShortCount ?? 0}</Tag>
|
||||
<Tag color="cyan">可自动 {data?.autoMatchableCount ?? 0}</Tag>
|
||||
{includeConflicts ? <Tag>不匹配 {data?.conflictCount ?? 0}</Tag> : null}
|
||||
</Space>
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
updateAdminConsignmentInventory,
|
||||
} from '@/services/admin/consignment'
|
||||
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
|
||||
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
|
||||
import ConsignmentInventoryMatchModal from '@/components/admin/ConsignmentInventoryMatchModal'
|
||||
import type {
|
||||
ConsignmentInventory,
|
||||
ConsignmentInventoryFilterField,
|
||||
@@ -1466,19 +1466,13 @@ export default function ConsignmentInventoryPanel() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 手动匹配订单:从现货出发挑订单(与订单侧共用同一弹窗) */}
|
||||
<ConsignmentManualMatchModal
|
||||
{/* 匹配订单:从现货出发,走新引擎(数量/配置/重新匹配排除与看板同口径) */}
|
||||
<ConsignmentInventoryMatchModal
|
||||
key={manualMatchItem?.id ?? 'none'}
|
||||
open={Boolean(manualMatchItem)}
|
||||
inventoryId={manualMatchItem?.id ?? null}
|
||||
inventoryLabel={manualMatchItem?.itemName}
|
||||
onClose={() => setManualMatchItem(null)}
|
||||
inventory={
|
||||
manualMatchItem
|
||||
? {
|
||||
inventoryId: manualMatchItem.id,
|
||||
label: manualMatchItem.itemName,
|
||||
inboundAt: manualMatchItem.inboundAt || manualMatchItem.createdAt,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onMatched={() => void inventoriesQuery.refetch()}
|
||||
/>
|
||||
|
||||
|
||||
@@ -92,20 +92,17 @@ import {
|
||||
import type { CustomerRefundPreview } from '@/services/admin/worker-platform'
|
||||
import {
|
||||
checkWorkOrderConsignmentMatches,
|
||||
fulfillWorkOrderWithConsignment,
|
||||
matchAdminConsignmentWorkOrder,
|
||||
revokeWorkOrderConsignmentFulfillment,
|
||||
} from '@/services/admin/consignment'
|
||||
import { notifyVoucherConsume } from '@/utils/voucher-consume'
|
||||
import type { ConsignmentFulfillmentInfo, WorkOrderInventoryMatchCheck } from '@/types/consignment'
|
||||
import type { UploadedFile, WorkOrder, WorkOrderShare } from '@/types/worker-platform'
|
||||
import { canManuallyMatchConsignmentOrder } from '@/utils/consignment-manual-match'
|
||||
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
|
||||
import {
|
||||
isConsignmentOrder,
|
||||
isInternalConsignmentOrder,
|
||||
resolveConsignmentScreenshotUrls,
|
||||
} from '@/utils/consignment-display'
|
||||
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
|
||||
import ConsignmentWorkOrderMatchModal from '@/components/admin/ConsignmentWorkOrderMatchModal'
|
||||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||||
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
|
||||
@@ -433,7 +430,6 @@ export default function WorkOrdersPanel() {
|
||||
}>()
|
||||
|
||||
// 寄售库存匹配拦截弹窗状态
|
||||
const [manualMatchOrder, setManualMatchOrder] = useState<WorkOrder | null>(null)
|
||||
const [consignmentMatchOrder, setConsignmentMatchOrder] = useState<WorkOrder | null>(null)
|
||||
const [consignmentInterceptModal, setConsignmentInterceptModal] = useState<{
|
||||
open: boolean
|
||||
@@ -1207,17 +1203,23 @@ export default function WorkOrdersPanel() {
|
||||
if (!consignmentInterceptModal.order) return
|
||||
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: true }))
|
||||
try {
|
||||
const res = await fulfillWorkOrderWithConsignment({
|
||||
const res = await matchAdminConsignmentWorkOrder({
|
||||
workOrderId: consignmentInterceptModal.order.workOrderId,
|
||||
inventoryId,
|
||||
allowPartial,
|
||||
inventoryIds: [inventoryId],
|
||||
allowNonExact: allowPartial,
|
||||
reason: allowPartial ? '内部寄售库存手动匹配(非精确)' : '',
|
||||
})
|
||||
message.success('已占用内部库存,跳过打手资料,已进入待加好友阶段')
|
||||
notifyVoucherConsume(res?.data?.voucherConsume)
|
||||
if (res.data?.matched === false) {
|
||||
message.warning('工单状态或库存已变化,请刷新后重试')
|
||||
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: false }))
|
||||
await refreshAll()
|
||||
return
|
||||
}
|
||||
message.success('已占用内部库存并匹配工单')
|
||||
setConsignmentInterceptModal({ open: false, order: null, matchData: null, submitting: false })
|
||||
await refreshAll()
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '内部履约失败')
|
||||
message.error(err.message || '内部库存匹配失败')
|
||||
setConsignmentInterceptModal((prev) => ({ ...prev, submitting: false }))
|
||||
}
|
||||
}
|
||||
@@ -2066,18 +2068,9 @@ export default function WorkOrdersPanel() {
|
||||
</Button>
|
||||
) : null}
|
||||
{/*
|
||||
手动指定现货必须**常驻**:只在"检测到候选"的提醒弹窗里给入口,
|
||||
订单没有任何系统候选时客服反而进不去——而那正是最需要人工指定的时候。
|
||||
寄卖匹配只保留新引擎入口:旧的 /fulfill 手动匹配不支持数量拆分,
|
||||
还会绕过 profile/重新匹配排除,已从 UI 收敛掉。
|
||||
*/}
|
||||
{canFulfillConsignment && canManuallyMatchConsignmentOrder(row) ? (
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setManualMatchOrder(row)}
|
||||
title="从全部在库现货里人工指定一件(支持规格冲突强制匹配,需填原因)"
|
||||
>
|
||||
手动匹配寄售
|
||||
</Button>
|
||||
) : null}
|
||||
{canFulfillConsignment &&
|
||||
['internal_matching', 'unassigned', 'open'].includes(String(row.status || '')) ? (
|
||||
<Button
|
||||
@@ -4411,10 +4404,10 @@ export default function WorkOrdersPanel() {
|
||||
matchData: null,
|
||||
submitting: false,
|
||||
})
|
||||
setManualMatchOrder(currentOrder)
|
||||
setConsignmentMatchOrder(currentOrder)
|
||||
}}
|
||||
>
|
||||
手动指定现货匹配
|
||||
内部库存匹配
|
||||
</Button>
|
||||
) : null,
|
||||
<Button
|
||||
@@ -4627,23 +4620,6 @@ export default function WorkOrdersPanel() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConsignmentManualMatchModal
|
||||
open={Boolean(manualMatchOrder)}
|
||||
onClose={() => setManualMatchOrder(null)}
|
||||
workOrder={
|
||||
manualMatchOrder
|
||||
? {
|
||||
workOrderId: manualMatchOrder.workOrderId,
|
||||
label: `#${manualMatchOrder.workOrderId} ${manualMatchOrder.productName || ''}`,
|
||||
createdAt: manualMatchOrder.createdAt,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onMatched={() => {
|
||||
void refreshAll()
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 撤单 / 撤销寄售履约弹窗 */}
|
||||
<Modal
|
||||
title={`撤单 / 撤销寄售履约 · ${getDisplayOrderNo(revokeConsignmentModal.order)}`}
|
||||
|
||||
@@ -268,10 +268,12 @@ export function fetchInventoryMatchableOrders(
|
||||
)
|
||||
}
|
||||
|
||||
/** 全局匹配看板:待抢单 × 在库待匹配现货,按细粒度评分排序。 */
|
||||
/** 全局匹配看板:待匹配订单 × 在库待匹配现货,按细粒度评分排序。 */
|
||||
export function fetchPendingPistolMatches(params?: {
|
||||
/** 只统计该寄卖分类;不传 = 全部 */
|
||||
categoryId?: number
|
||||
/** 只统计这一条库存(库存侧「匹配订单」用) */
|
||||
inventoryId?: number
|
||||
keyword?: string
|
||||
includeConflicts?: boolean
|
||||
limit?: number
|
||||
@@ -280,6 +282,7 @@ export function fetchPendingPistolMatches(params?: {
|
||||
'/api/v1/admin/worker-platform/consignment/pending-pistol-matches',
|
||||
{
|
||||
categoryId: params?.categoryId,
|
||||
inventoryId: params?.inventoryId,
|
||||
keyword: params?.keyword?.trim() || undefined,
|
||||
includeConflicts: params?.includeConflicts ? 'true' : undefined,
|
||||
limit: params?.limit,
|
||||
|
||||
@@ -359,6 +359,18 @@ export type PendingPistolMatchPair = {
|
||||
totalQuantity?: number
|
||||
/** 命中的寄卖配置 id;无配置时为 null(旧手枪兜底) */
|
||||
profileId?: number | null
|
||||
/** 本单一次需要匹配的数量(整单=总数量,拼单=剩余份数) */
|
||||
requiredQuantity?: number
|
||||
/** 该条库存的可匹配余量 */
|
||||
availableQuantity?: number
|
||||
/** 该库存余量是否够本单一次匹配;拼单按整单汇总判断 */
|
||||
quantitySufficient?: boolean
|
||||
/** 自动匹配是否真会选中:精确,或配置允许非精确且订单有正向规格信号 */
|
||||
autoMatchable?: boolean
|
||||
/** 是否为拼单工单 */
|
||||
sharingEnabled?: boolean
|
||||
/** 工单当前状态:internal_matching / open / unassigned */
|
||||
workOrderStatus?: string
|
||||
}
|
||||
|
||||
export type PendingPistolMatchResult = {
|
||||
@@ -368,6 +380,8 @@ export type PendingPistolMatchResult = {
|
||||
exactCount: number
|
||||
partialCount: number
|
||||
conflictCount: number
|
||||
quantityShortCount: number
|
||||
autoMatchableCount: number
|
||||
truncated: boolean
|
||||
pairs: PendingPistolMatchPair[]
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
/**
|
||||
* 手动匹配弹窗曾经被插到「编辑寄售物品」弹窗**内部**,导致外层弹窗没打开时它根本没挂载:
|
||||
* 用户点「手动匹配订单」什么都不会发生。这类"点了没反应"的问题在浏览器里才看得出来,
|
||||
* 这里用静态检查把它钉住:组件必须挂在顶层(前面不能有未闭合的 Modal)。
|
||||
* 手动匹配弹窗必须挂在页面顶层,不能嵌在别的 Modal 里:
|
||||
* 外层弹窗没打开时它根本不会挂载,用户点按钮会「没反应」。
|
||||
*/
|
||||
function assertTopLevelMount(filePath: string, componentName: string) {
|
||||
const source = readFileSync(filePath, 'utf8')
|
||||
@@ -23,19 +22,19 @@ function assertTopLevelMount(filePath: string, componentName: string) {
|
||||
)
|
||||
}
|
||||
|
||||
test('库存面板:手动匹配订单弹窗挂在顶层(按钮点了必须能弹出来)', () => {
|
||||
test('库存面板:匹配订单弹窗挂在顶层(按钮点了必须能弹出来)', () => {
|
||||
assertTopLevelMount(
|
||||
fileURLToPath(
|
||||
new URL('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx', import.meta.url),
|
||||
),
|
||||
'ConsignmentManualMatchModal',
|
||||
'ConsignmentInventoryMatchModal',
|
||||
)
|
||||
})
|
||||
|
||||
test('订单面板:手动指定现货弹窗同样挂在顶层', () => {
|
||||
test('订单面板:内部库存匹配弹窗同样挂在顶层', () => {
|
||||
assertTopLevelMount(
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
|
||||
'ConsignmentManualMatchModal',
|
||||
'ConsignmentWorkOrderMatchModal',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -57,8 +56,6 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
// 库存面板:手动匹配订单按钮要与编辑/作废拆开 —— 前者 fulfill(提交打到
|
||||
// /fulfill、/assign-worker,后端要求 consignment.fulfill),后者 manage
|
||||
assert.ok(
|
||||
inventoryPanel.includes("resolveAdminPermission(sessionPermissions, 'consignment.fulfill')"),
|
||||
'库存面板必须解析 consignment.fulfill 权限',
|
||||
@@ -74,56 +71,50 @@ test('手动匹配入口必须按 consignment.fulfill 门控(提交的是履
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
// 拦截弹窗 footer 的入口同样要门控,无 fulfill 权限的人不该看到能直达 403 的按钮
|
||||
const footerButtonIndex = ordersPanel.indexOf('手动指定现货匹配')
|
||||
assert.ok(footerButtonIndex > 0, '找不到「手动指定现货匹配」按钮')
|
||||
const footerButtonIndex = ordersPanel.lastIndexOf('内部库存匹配')
|
||||
assert.ok(footerButtonIndex > 0, '找不到「内部库存匹配」按钮')
|
||||
const beforeFooterButton = ordersPanel.slice(0, footerButtonIndex)
|
||||
assert.ok(
|
||||
beforeFooterButton.lastIndexOf('canFulfillConsignment ?') >
|
||||
beforeFooterButton.lastIndexOf('footer={['),
|
||||
'拦截弹窗 footer 的「手动指定现货匹配」必须按 canFulfillConsignment 门控',
|
||||
'拦截弹窗 footer 的「内部库存匹配」必须按 canFulfillConsignment 门控',
|
||||
)
|
||||
})
|
||||
|
||||
test('手动匹配弹窗:数据过期的 409 要清选择并刷新,走内部现货直接履约', () => {
|
||||
test('库存侧匹配弹窗:必须走新引擎,不再调用旧 /fulfill', () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url),
|
||||
new URL('../src/components/admin/ConsignmentInventoryMatchModal.tsx', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
// 并发占用/规格在打开弹窗后被改 → 清空选择并重新拉列表,不能让运营循环撞同一个错
|
||||
assert.ok(source.includes('consignment_inventory_race_condition'), '要处理并发占用错误码')
|
||||
assert.ok(source.includes('consignment_spec_mismatch'), '要处理规格变化错误码')
|
||||
assert.ok(source.includes('setSelected(null)'), '数据过期时要清空已选')
|
||||
// 统一内部现货直接履约:调用 fulfillWorkOrderWithConsignment,免外派佣金与二次押金
|
||||
assert.ok(source.includes('fetchPendingPistolMatches'), '库存侧必须复用待匹配看板的统一判定')
|
||||
assert.ok(source.includes('inventoryId: Number(inventoryId)'), '必须按当前库存过滤候选工单')
|
||||
assert.ok(
|
||||
source.includes('fulfillWorkOrderWithConsignment'),
|
||||
'手动匹配必须调用内部现货直接履约接口',
|
||||
source.includes('matchAdminConsignmentWorkOrder'),
|
||||
'库存侧落库必须调用新引擎 /consignment/match',
|
||||
)
|
||||
assert.ok(
|
||||
!source.includes('assignWorkOrderWithConsignmentWorker'),
|
||||
'手动匹配不得再调用旧的指派打手接口',
|
||||
)
|
||||
// 凭证核销反馈与关键字搜索:用共享出口 / 透传后端,不内联复制
|
||||
assert.ok(source.includes("from '@/utils/voucher-consume'"), '核销反馈必须用共享出口')
|
||||
assert.ok(source.includes('keyword: keyword.trim() || undefined'), '库存侧关键字要透传后端')
|
||||
assert.ok(!source.includes('fulfillWorkOrderWithConsignment'), '库存侧不得再调用旧 /fulfill 链路')
|
||||
assert.ok(source.includes('quantitySufficient'), '数量不足的工单必须禁用选择')
|
||||
assert.ok(source.includes('允许非精确/冲突强制匹配'), '非精确必须显式勾选')
|
||||
})
|
||||
|
||||
test('发布拦截弹窗:只走内部现货履约,文案停在待加好友而不是冷静期', () => {
|
||||
test('订单面板:拦截弹窗与行内入口都只走新引擎', () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL('../src/pages/admin/panels/WorkOrdersPanel.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('fulfillWorkOrderWithConsignment'), '拦截弹窗必须调用内部现货履约')
|
||||
assert.ok(source.includes('matchAdminConsignmentWorkOrder'), '拦截弹窗必须调用新引擎匹配')
|
||||
assert.ok(
|
||||
!source.includes('fulfillWorkOrderWithConsignment'),
|
||||
'订单面板不得再调用旧 /fulfill 链路',
|
||||
)
|
||||
assert.ok(
|
||||
!source.includes('assignWorkOrderWithConsignmentWorker'),
|
||||
'拦截弹窗不得再调用旧的指派打手接口',
|
||||
'订单面板不得调用旧的指派打手接口',
|
||||
)
|
||||
assert.ok(!source.includes('手动匹配寄售'), '旧「手动匹配寄售」入口必须移除')
|
||||
assert.ok(source.includes('进入待加好友'), '拦截弹窗必须写明工单进入待加好友')
|
||||
assert.ok(!source.includes('自动进冷静期'), '拦截弹窗不得再写自动进冷静期')
|
||||
assert.ok(!source.includes('进入赠送冷静期'), '成功提示不得再写成进入赠送冷静期')
|
||||
assert.ok(source.includes('入库时间:'), '拦截弹窗现货展示必须包含入库时间')
|
||||
})
|
||||
|
||||
test('寄售仓库库存面板:必须包含「通过审核入库时间」列,且详情弹窗正确展示时间', () => {
|
||||
@@ -139,33 +130,4 @@ test('寄售仓库库存面板:必须包含「通过审核入库时间」列
|
||||
'表格必须格式化入库时间',
|
||||
)
|
||||
assert.ok(source.includes('label="通过审核入库时间"'), '详情弹窗必须包含「通过审核入库时间」项')
|
||||
assert.ok(
|
||||
source.includes('inboundAt: manualMatchItem.inboundAt || manualMatchItem.createdAt'),
|
||||
'打开手动匹配弹窗时必须透传现货的入库时间',
|
||||
)
|
||||
})
|
||||
|
||||
test('手动匹配弹窗:候选订单必须展示创建时间,现货展示审核入库时间', () => {
|
||||
const source = readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../src/components/admin/ConsignmentManualMatchModal.tsx', import.meta.url),
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('创建时间 {formatDateTime(order.createdAt)}'),
|
||||
'候选订单卡片必须展示工单创建时间',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('审核入库:${formatDateTime(inventoryInboundAt)}'),
|
||||
'弹窗现货提示栏必须展示审核入库时间',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('审核入库于 {formatDateTime(selected.inventoryInboundAt)}'),
|
||||
'已选现货概要必须展示审核入库时间',
|
||||
)
|
||||
assert.ok(
|
||||
source.includes('创建于 {formatDateTime(selected.workOrderCreatedAt)}'),
|
||||
'已选订单概要必须展示创建时间',
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user