From 5ff0ddb58e54d43bf14f08b7d19cc55d44e9ecad Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Sat, 14 Nov 2026 16:06:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=96=B0=E5=A2=9E=E5=AF=84=E5=8D=96=E5=8D=95?= =?UTF-8?q?=E5=8F=B7=E5=88=97=E5=B9=B6=E4=B8=AD=E6=96=87=E5=8C=96=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consignment-match-engine-service.ts | 7 +- .../consignment-match-log-service.ts | 69 +++++++++- .../admin/ConsignmentMatchLogsModal.tsx | 47 +++++-- .../admin/consignment-match-log-labels.ts | 54 ++++++++ .../src/types/consignment-match-log.ts | 4 + .../consignment-match-log-reasons.test.ts | 125 ++++++++++++++++++ 6 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 apps/frontend/src/components/admin/consignment-match-log-labels.ts create mode 100644 apps/frontend/tests/consignment-match-log-reasons.test.ts diff --git a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts index 195de3e7..7f798722 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-engine-service.ts @@ -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 } diff --git a/apps/backend/src/services/worker-platform/consignment-match-log-service.ts b/apps/backend/src/services/worker-platform/consignment-match-log-service.ts index 80fd8bb4..4fe10733 100644 --- a/apps/backend/src/services/worker-platform/consignment-match-log-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-match-log-service.ts @@ -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): 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() + if (inventoryIds.length === 0) return map + const rows = await query( + ` + 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 = {} 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, diff --git a/apps/frontend/src/components/admin/ConsignmentMatchLogsModal.tsx b/apps/frontend/src/components/admin/ConsignmentMatchLogsModal.tsx index 6d215fe7..5ed9eb08 100644 --- a/apps/frontend/src/components/admin/ConsignmentMatchLogsModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentMatchLogsModal.tsx @@ -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 = { @@ -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 ? ( + + {text} + + ) : ( + text + ) + }, + }, { title: '触发', dataIndex: 'triggerType', @@ -96,16 +120,17 @@ export default function ConsignmentMatchLogsModal({ { title: '原因', dataIndex: 'reasonText', - render: (value: string, record: ConsignmentMatchLogItem) => ( -
- {value || '-'} - {record.reasonCode ? ( - - {record.reasonCode} - - ) : null} -
- ), + render: (value: string, record: ConsignmentMatchLogItem) => { + const label = resolveConsignmentMatchReasonLabel(record.reasonCode, value) + // 英文 reasonCode 只做排查用途,收进悬浮提示,列表里始终显示中文。 + return record.reasonCode ? ( + + {label} + + ) : ( + {label} + ) + }, }, { title: '操作人', diff --git a/apps/frontend/src/components/admin/consignment-match-log-labels.ts b/apps/frontend/src/components/admin/consignment-match-log-labels.ts new file mode 100644 index 00000000..77c73502 --- /dev/null +++ b/apps/frontend/src/components/admin/consignment-match-log-labels.ts @@ -0,0 +1,54 @@ +/** + * 寄卖匹配日志原因码 → 中文文案。 + * + * 后端的 `reason_code` 有两种来源:匹配引擎固定写入的流程原因,以及 + * `extractConsignmentErrorCode` 透传的业务错误码。新增登记点时必须同步补齐, + * `tests/consignment-match-log-reasons.test.ts` 会扫描后端源码校验覆盖率。 + */ +export const CONSIGNMENT_MATCH_REASON_LABELS: Record = { + // 自动 / 重新匹配流程 + 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 +} diff --git a/apps/frontend/src/types/consignment-match-log.ts b/apps/frontend/src/types/consignment-match-log.ts index ca846b63..8b0ab1fc 100644 --- a/apps/frontend/src/types/consignment-match-log.ts +++ b/apps/frontend/src/types/consignment-match-log.ts @@ -21,6 +21,10 @@ export type ConsignmentMatchLogItem = { actorId: string durationMs: number payload: Record + /** 本次日志涉及的寄卖单号(提报单号优先,无则库存号),由后端按 payload 库存 ID 反查。 */ + consignmentNos: string[] + /** 对应库存号,用于悬浮提示。 */ + inventoryNos: string[] createdAt: string } diff --git a/apps/frontend/tests/consignment-match-log-reasons.test.ts b/apps/frontend/tests/consignment-match-log-reasons.test.ts new file mode 100644 index 00000000..9c17b81c --- /dev/null +++ b/apps/frontend/tests/consignment-match-log-reasons.test.ts @@ -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) { + 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() + + 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'), '原因列必须使用中文映射') +})