feat(consignment): 匹配日志新增寄卖单号列并中文化原因
This commit is contained in:
@@ -512,7 +512,12 @@ export async function tryAutoMatchBeforeHall(
|
|||||||
reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`,
|
reasonText: `没有库存能一次满足订单数量 ${requiredQuantity}`,
|
||||||
candidateCount: candidates.length,
|
candidateCount: candidates.length,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
payload: { phase: 'before_hall', requiredQuantity },
|
// 记录候选库存 ID,日志弹窗才能反查出试过哪些寄卖单号。
|
||||||
|
payload: {
|
||||||
|
phase: 'before_hall',
|
||||||
|
requiredQuantity,
|
||||||
|
inventoryIds: candidates.map(({ inventory }) => Number(inventory.id)),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return false
|
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) {
|
function mapLog(row: ConsignmentMatchLogRow) {
|
||||||
let payload: Record<string, unknown> = {}
|
let payload: Record<string, unknown> = {}
|
||||||
if (row.payload_json && typeof row.payload_json === 'object') {
|
if (row.payload_json && typeof row.payload_json === 'object') {
|
||||||
@@ -124,6 +169,10 @@ function mapLog(row: ConsignmentMatchLogRow) {
|
|||||||
actorId: row.actor_id || '',
|
actorId: row.actor_id || '',
|
||||||
durationMs: Number(row.duration_ms || 0),
|
durationMs: Number(row.duration_ms || 0),
|
||||||
payload,
|
payload,
|
||||||
|
/** 本次日志涉及的寄卖单号(提报单号优先,无则库存号),由 payload 中的库存 ID 反查。 */
|
||||||
|
consignmentNos: [] as string[],
|
||||||
|
/** 对应库存号,前端用于悬浮提示。 */
|
||||||
|
inventoryNos: [] as string[],
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,8 +244,26 @@ export async function listConsignmentMatchLogs(
|
|||||||
listValues,
|
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 {
|
return {
|
||||||
items: rows.rows.map(mapLog),
|
items,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ReloadOutlined } from '@ant-design/icons'
|
import { ReloadOutlined } from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { useState } from 'react'
|
||||||
|
|
||||||
import PercentTable from '@/components/common/PercentTable'
|
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 type { ConsignmentMatchLogItem } from '@/types/consignment-match-log'
|
||||||
import { formatDateTime } from '@/utils/date-time'
|
import { formatDateTime } from '@/utils/date-time'
|
||||||
|
|
||||||
|
import { resolveConsignmentMatchReasonLabel } from './consignment-match-log-labels'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
const TRIGGER_LABELS: Record<string, string> = {
|
const TRIGGER_LABELS: Record<string, string> = {
|
||||||
@@ -68,6 +70,28 @@ export default function ConsignmentMatchLogsModal({
|
|||||||
render: (_: string, record: ConsignmentMatchLogItem) =>
|
render: (_: string, record: ConsignmentMatchLogItem) =>
|
||||||
record.workOrderNo || (record.workOrderId ? `#${record.workOrderId}` : '-'),
|
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: '触发',
|
title: '触发',
|
||||||
dataIndex: 'triggerType',
|
dataIndex: 'triggerType',
|
||||||
@@ -96,16 +120,17 @@ export default function ConsignmentMatchLogsModal({
|
|||||||
{
|
{
|
||||||
title: '原因',
|
title: '原因',
|
||||||
dataIndex: 'reasonText',
|
dataIndex: 'reasonText',
|
||||||
render: (value: string, record: ConsignmentMatchLogItem) => (
|
render: (value: string, record: ConsignmentMatchLogItem) => {
|
||||||
<div style={{ display: 'grid', gap: 2 }}>
|
const label = resolveConsignmentMatchReasonLabel(record.reasonCode, value)
|
||||||
<Text>{value || '-'}</Text>
|
// 英文 reasonCode 只做排查用途,收进悬浮提示,列表里始终显示中文。
|
||||||
{record.reasonCode ? (
|
return record.reasonCode ? (
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Tooltip title={record.reasonCode}>
|
||||||
{record.reasonCode}
|
<Text style={{ cursor: 'help' }}>{label}</Text>
|
||||||
</Text>
|
</Tooltip>
|
||||||
) : null}
|
) : (
|
||||||
</div>
|
<Text>{label}</Text>
|
||||||
),
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作人',
|
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
|
actorId: string
|
||||||
durationMs: number
|
durationMs: number
|
||||||
payload: Record<string, unknown>
|
payload: Record<string, unknown>
|
||||||
|
/** 本次日志涉及的寄卖单号(提报单号优先,无则库存号),由后端按 payload 库存 ID 反查。 */
|
||||||
|
consignmentNos: string[]
|
||||||
|
/** 对应库存号,用于悬浮提示。 */
|
||||||
|
inventoryNos: string[]
|
||||||
createdAt: 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