feat(consignment): 匹配日志新增寄卖单号列并中文化原因
This commit is contained in:
@@ -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'), '原因列必须使用中文映射')
|
||||
})
|
||||
Reference in New Issue
Block a user