feat(consignment): 匹配日志新增寄卖单号列并中文化原因

This commit is contained in:
2026-11-14 16:06:59 +08:00
parent e2863286c3
commit 5ff0ddb58e
6 changed files with 293 additions and 13 deletions
@@ -512,7 +512,12 @@ export async function tryAutoMatchBeforeHall(
reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`,
candidateCount: candidates.length,
durationMs: Date.now() - startedAt,
payload: { phase: 'before_hall', requiredQuantity },
// 记录候选库存 ID,日志弹窗才能反查出试过哪些寄卖单号。
payload: {
phase: 'before_hall',
requiredQuantity,
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
},
})
return false
}
@@ -92,6 +92,51 @@ export async function recordConsignmentMatchLog(
}
}
type ConsignmentInventoryNoRow = {
id: number | string
inventory_no: string
source_submission_no: string | null
}
type ConsignmentNoEntry = { inventoryNo: string; consignmentNo: string }
/** 日志写入时只记库存 ID,展示时再反查单号,历史日志也能补齐。 */
function resolvePayloadInventoryIds(payload: Record<string, unknown>): number[] {
const ids: number[] = []
const push = (value: unknown) => {
const id = Number(value)
if (Number.isInteger(id) && id > 0 && !ids.includes(id)) ids.push(id)
}
push(payload.inventoryId)
const list = payload.inventoryIds
if (Array.isArray(list)) list.forEach(push)
return ids
}
/** 寄卖单号口径与前端一致:来源提报单号优先,客服手工入库的现货回退库存号。 */
async function loadConsignmentNoMap(inventoryIds: number[]) {
const map = new Map<number, ConsignmentNoEntry>()
if (inventoryIds.length === 0) return map
const rows = await query<ConsignmentInventoryNoRow>(
`
SELECT i.id, i.inventory_no, cs.submission_no AS source_submission_no
FROM consignment_inventories i
LEFT JOIN consignment_submissions cs ON cs.id = i.source_submission_id
WHERE i.id = ANY($1::bigint[])
`,
[inventoryIds],
).catch(() => null)
for (const row of rows?.rows || []) {
const inventoryNo = String(row.inventory_no || '').trim()
const sourceSubmissionNo = String(row.source_submission_no || '').trim()
map.set(Number(row.id), {
inventoryNo,
consignmentNo: sourceSubmissionNo || inventoryNo,
})
}
return map
}
function mapLog(row: ConsignmentMatchLogRow) {
let payload: Record<string, unknown> = {}
if (row.payload_json && typeof row.payload_json === 'object') {
@@ -124,6 +169,10 @@ function mapLog(row: ConsignmentMatchLogRow) {
actorId: row.actor_id || '',
durationMs: Number(row.duration_ms || 0),
payload,
/** 本次日志涉及的寄卖单号(提报单号优先,无则库存号),由 payload 中的库存 ID 反查。 */
consignmentNos: [] as string[],
/** 对应库存号,前端用于悬浮提示。 */
inventoryNos: [] as string[],
createdAt: row.created_at,
}
}
@@ -195,8 +244,26 @@ export async function listConsignmentMatchLogs(
listValues,
)
const items = rows.rows.map(mapLog)
const inventoryIds = [
...new Set(items.flatMap((item) => resolvePayloadInventoryIds(item.payload))),
]
const consignmentNoMap = await loadConsignmentNoMap(inventoryIds)
for (const item of items) {
for (const inventoryId of resolvePayloadInventoryIds(item.payload)) {
const entry = consignmentNoMap.get(inventoryId)
if (!entry) continue
if (entry.consignmentNo && !item.consignmentNos.includes(entry.consignmentNo)) {
item.consignmentNos.push(entry.consignmentNo)
}
if (entry.inventoryNo && !item.inventoryNos.includes(entry.inventoryNo)) {
item.inventoryNos.push(entry.inventoryNo)
}
}
}
return {
items: rows.rows.map(mapLog),
items,
total,
page,
pageSize,
@@ -1,6 +1,6 @@
import { ReloadOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Input, Modal, Select, Space, Tag, Typography } from 'antd'
import { Input, Modal, Select, Space, Tag, Tooltip, Typography } from 'antd'
import { useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
@@ -8,6 +8,8 @@ import { fetchAdminConsignmentMatchLogs } from '@/services/admin/consignment'
import type { ConsignmentMatchLogItem } from '@/types/consignment-match-log'
import { formatDateTime } from '@/utils/date-time'
import { resolveConsignmentMatchReasonLabel } from './consignment-match-log-labels'
const { Text } = Typography
const TRIGGER_LABELS: Record<string, string> = {
@@ -68,6 +70,28 @@ export default function ConsignmentMatchLogsModal({
render: (_: string, record: ConsignmentMatchLogItem) =>
record.workOrderNo || (record.workOrderId ? `#${record.workOrderId}` : '-'),
},
{
title: '寄卖单号',
dataIndex: 'consignmentNos',
width: 160,
render: (_: string[], record: ConsignmentMatchLogItem) => {
const consignmentNos = Array.isArray(record.consignmentNos)
? record.consignmentNos.filter(Boolean)
: []
if (consignmentNos.length === 0) return '-'
const text = consignmentNos.join('、')
const inventoryNos = Array.isArray(record.inventoryNos)
? record.inventoryNos.filter(Boolean)
: []
return inventoryNos.length > 0 ? (
<Tooltip title={`库存号:${inventoryNos.join('、')}`}>
<span>{text}</span>
</Tooltip>
) : (
text
)
},
},
{
title: '触发',
dataIndex: 'triggerType',
@@ -96,16 +120,17 @@ export default function ConsignmentMatchLogsModal({
{
title: '原因',
dataIndex: 'reasonText',
render: (value: string, record: ConsignmentMatchLogItem) => (
<div style={{ display: 'grid', gap: 2 }}>
<Text>{value || '-'}</Text>
{record.reasonCode ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{record.reasonCode}
</Text>
) : null}
</div>
),
render: (value: string, record: ConsignmentMatchLogItem) => {
const label = resolveConsignmentMatchReasonLabel(record.reasonCode, value)
// 英文 reasonCode 只做排查用途,收进悬浮提示,列表里始终显示中文。
return record.reasonCode ? (
<Tooltip title={record.reasonCode}>
<Text style={{ cursor: 'help' }}>{label}</Text>
</Tooltip>
) : (
<Text>{label}</Text>
)
},
},
{
title: '操作人',
@@ -0,0 +1,54 @@
/**
* 寄卖匹配日志原因码 → 中文文案。
*
* 后端的 `reason_code` 有两种来源:匹配引擎固定写入的流程原因,以及
* `extractConsignmentErrorCode` 透传的业务错误码。新增登记点时必须同步补齐,
* `tests/consignment-match-log-reasons.test.ts` 会扫描后端源码校验覆盖率。
*/
export const CONSIGNMENT_MATCH_REASON_LABELS: Record<string, string> = {
// 自动 / 重新匹配流程
before_effective_from: '早于匹配生效时间',
no_candidate: '无可用寄卖库存',
quantity_insufficient: '库存数量不足',
auto_matched_before_hall: '发布前已自动匹配',
apply_not_applied: '匹配未生效(库存已变化)',
exception: '匹配异常',
rematch_started: '已重新发起匹配',
// 拼单
auto_sharing_matched_before_hall: '发布前已自动拼单匹配',
sharing_quantity_insufficient: '拼单份数不足',
sharing_not_applied: '拼单匹配未生效',
// 手动匹配
manual_matched: '手动匹配成功',
manual_not_applied: '手动匹配未生效',
// 业务校验错误码
work_order_not_found: '工单不存在',
work_order_not_matchable: '工单状态不允许匹配',
consignment_inventory_required: '未选择可用寄卖库存',
consignment_spec_mismatch: '所选库存与订单规格不一致',
consignment_match_reason_required: '非精确匹配未填写原因',
consignment_sharing_quantity_insufficient: '所选库存不够一次拼满',
consignment_sharing_not_applied: '工单状态或库存已变化',
consignment_multiple_inventory_not_allowed: '整单匹配只能选一条库存',
consignment_inventory_quantity_insufficient: '库存剩余数量不足',
consignment_inventory_not_found: '寄卖库存不存在',
consignment_inventory_unavailable: '寄卖库存不可用',
consignment_inventory_race_condition: '库存被并发占用',
consignment_deposit_insufficient: '押金不足',
work_order_conflict: '工单状态冲突',
}
/**
* 原因列展示文案:优先用后端带的详细说明,其次用中文映射,未知码兜底原文
* (英文码只在前端未覆盖时出现,正常情况全部命中映射)。
*/
export function resolveConsignmentMatchReasonLabel(
reasonCode: string,
reasonText?: string,
): string {
const text = String(reasonText || '').trim()
if (text) return text
const code = String(reasonCode || '').trim()
if (!code) return '-'
return CONSIGNMENT_MATCH_REASON_LABELS[code] || code
}
@@ -21,6 +21,10 @@ export type ConsignmentMatchLogItem = {
actorId: string
durationMs: number
payload: Record<string, unknown>
/** 本次日志涉及的寄卖单号(提报单号优先,无则库存号),由后端按 payload 库存 ID 反查。 */
consignmentNos: string[]
/** 对应库存号,用于悬浮提示。 */
inventoryNos: string[]
createdAt: string
}
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import ts from 'typescript'
import {
CONSIGNMENT_MATCH_REASON_LABELS,
resolveConsignmentMatchReasonLabel,
} from '../src/components/admin/consignment-match-log-labels.ts'
const ENGINE_PATH = fileURLToPath(
new URL(
'../../backend/src/services/worker-platform/consignment-match-engine-service.ts',
import.meta.url,
),
)
const MODAL_PATH = fileURLToPath(
new URL('../src/components/admin/ConsignmentMatchLogsModal.tsx', import.meta.url),
)
function collectStringLiterals(expr: ts.Expression, result: Set<string>) {
if (ts.isStringLiteralLike(expr)) {
result.add(expr.text)
return
}
if (ts.isParenthesizedExpression(expr)) {
collectStringLiterals(expr.expression, result)
return
}
if (ts.isConditionalExpression(expr)) {
// 只取分支结果,不把条件里的字符串当成原因码。
collectStringLiterals(expr.whenTrue, result)
collectStringLiterals(expr.whenFalse, result)
return
}
if (
ts.isBinaryExpression(expr) &&
[
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.QuestionQuestionToken,
ts.SyntaxKind.PlusToken,
].includes(expr.operatorToken.kind)
) {
collectStringLiterals(expr.left, result)
collectStringLiterals(expr.right, result)
return
}
}
/** 匹配日志的 reasonCode 只有两种来源:固定流程原因 + createHttpError 的 errorCode。 */
function collectEngineReasonCodes() {
const source = readFileSync(ENGINE_PATH, 'utf8')
const sourceFile = ts.createSourceFile(ENGINE_PATH, source, ts.ScriptTarget.Latest, true)
const result = new Set<string>()
function visit(node: ts.Node) {
if (ts.isCallExpression(node)) {
const callee = node.expression.getText(sourceFile).split('.').pop() || ''
if (callee === 'recordConsignmentMatchLog') {
const objectArg = node.arguments.find((arg): arg is ts.ObjectLiteralExpression =>
ts.isObjectLiteralExpression(arg),
)
for (const property of objectArg?.properties || []) {
if (
ts.isPropertyAssignment(property) &&
property.name.getText(sourceFile) === 'reasonCode'
) {
collectStringLiterals(property.initializer, result)
}
}
}
}
if (ts.isPropertyAssignment(node) && node.name.getText(sourceFile) === 'errorCode') {
collectStringLiterals(node.initializer, result)
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return result
}
test('寄卖匹配日志:后端写入的每个原因码都必须有中文映射', () => {
const reasonCodes = collectEngineReasonCodes()
assert.ok(reasonCodes.size > 0, '没有扫描到任何原因码,测试逻辑可能失效')
const missing = [...reasonCodes].filter((code) => !CONSIGNMENT_MATCH_REASON_LABELS[code]).sort()
assert.deepEqual(
missing,
[],
`以下原因码缺少中文映射,会在匹配日志里直接显示英文:${missing.join(', ')}`,
)
})
test('寄卖匹配日志:原因列优先显示中文,未知码兜底原文', () => {
assert.equal(resolveConsignmentMatchReasonLabel('manual_matched', ''), '手动匹配成功')
assert.equal(resolveConsignmentMatchReasonLabel('manual_matched', undefined), '手动匹配成功')
assert.equal(
resolveConsignmentMatchReasonLabel(
'consignment_spec_mismatch',
'所选库存与订单规格不一致:INV1',
),
'所选库存与订单规格不一致:INV1',
)
assert.equal(resolveConsignmentMatchReasonLabel('future_unknown_code', ''), 'future_unknown_code')
assert.equal(resolveConsignmentMatchReasonLabel('', ''), '-')
})
test('寄卖匹配日志弹窗:工单右侧新增寄卖单号列', () => {
const modalSource = readFileSync(MODAL_PATH, 'utf8')
assert.ok(modalSource.includes("title: '寄卖单号'"), '必须新增「寄卖单号」列')
assert.ok(
modalSource.includes("dataIndex: 'consignmentNos'"),
'「寄卖单号」列必须读取后端返回的 consignmentNos',
)
assert.ok(
modalSource.indexOf("title: '寄卖单号'") > modalSource.indexOf("title: '工单'"),
'「寄卖单号」列必须在「工单」列右侧',
)
assert.ok(modalSource.includes('resolveConsignmentMatchReasonLabel'), '原因列必须使用中文映射')
})