补全工单流转记录中文事件映射
- 补齐内部寄售、电子凭证核销、自动发布等 30+ 事件文案 - 新增扫描测试:后端写入的 eventType 必须在前端有中文映射 - 覆盖生产库历史事件类型,避免工单详情露英文 key
This commit is contained in:
@@ -61,6 +61,42 @@ function formatFenAmount(value: number) {
|
||||
return `${(value / 100).toFixed(2)} 元`
|
||||
}
|
||||
|
||||
/**
|
||||
* 电子凭证核销计划事件:发布 / 指派 / 寄卖履约三条链路共用一套 payload。
|
||||
* status=queued 已安排、failed 安排失败、skipped 无需核销或跳过。
|
||||
*/
|
||||
function formatVoucherScheduleEvent(event: WorkOrderEvent, actionLabel: string): EventNodeStyle {
|
||||
const status = readPayload(event, 'status')
|
||||
const errorMessage = readPayload(event, 'errorMessage')
|
||||
const dueAt = readPayload(event, 'dueAt')
|
||||
const voucherCount = readPayloadAmount(event, 'voucherCount')
|
||||
|
||||
if (status === 'queued') {
|
||||
const details = [
|
||||
dueAt ? `计划核销时间:${dayjs(dueAt).format('MM-DD HH:mm')}` : '',
|
||||
voucherCount > 0 ? `待核销 ${voucherCount} 张` : '',
|
||||
].filter(Boolean)
|
||||
return {
|
||||
title: `${actionLabel}时已安排电子凭证自动核销`,
|
||||
description: details.length > 0 ? details.join(';') : undefined,
|
||||
color: 'geekblue',
|
||||
}
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return {
|
||||
title: `${actionLabel}时电子凭证自动核销安排失败`,
|
||||
description: errorMessage || undefined,
|
||||
color: 'orange',
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: `${actionLabel}时电子凭证核销已跳过`,
|
||||
description:
|
||||
voucherCount > 0 ? `未核销 ${voucherCount} 张` : '该订单没有需要自动核销的电子凭证',
|
||||
color: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
/** 事件涉及的打手名(管理端接口会附带打手摘要;打手端退回 #id) */
|
||||
function resolveEventWorkerLabel(event: WorkOrderEvent): string {
|
||||
if (event.worker?.displayName) return event.worker.displayName
|
||||
@@ -203,6 +239,91 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
||||
color: 'orange',
|
||||
}
|
||||
|
||||
// —— 内部寄售 / 寄卖履约节点 ——
|
||||
case 'consignment_waiting_friend_add':
|
||||
return {
|
||||
title: '内部寄售已占库,等待客服加好友',
|
||||
description: '订单停在「待加好友」,客服确认后再开始赠送冷却',
|
||||
color: 'gold',
|
||||
}
|
||||
case 'consignment_gift_countdown_started':
|
||||
case 'consignment_share_gift_countdown_started': {
|
||||
const cooldownHours = readPayloadAmount(event, 'cooldownHours')
|
||||
const cooldownMinutes = readPayloadAmount(event, 'cooldownMinutes')
|
||||
const duration =
|
||||
cooldownMinutes > 0
|
||||
? `${cooldownMinutes} 分钟`
|
||||
: cooldownHours > 0
|
||||
? `${cooldownHours} 小时`
|
||||
: ''
|
||||
return {
|
||||
title:
|
||||
type === 'consignment_share_gift_countdown_started'
|
||||
? '拼单寄售已确认加好友,赠送冷却开始'
|
||||
: '内部寄售已确认加好友,赠送冷却开始',
|
||||
description: duration ? `冷却 ${duration},结束后使用库存账号赠送并验收` : undefined,
|
||||
color: 'gold',
|
||||
}
|
||||
}
|
||||
case 'consignment_manual_matched':
|
||||
case 'consignment_auto_matched': {
|
||||
const inventoryNo = readPayload(event, 'inventoryNo')
|
||||
return {
|
||||
title: `${type === 'consignment_manual_matched' ? '手动' : '自动'}匹配寄卖库存${
|
||||
inventoryNo ? `(${inventoryNo})` : ''
|
||||
}`,
|
||||
description: '按内部现货履约,不在工单上指派接单打手',
|
||||
color: 'green',
|
||||
}
|
||||
}
|
||||
case 'consignment_sharing_matched':
|
||||
case 'consignment_manual_sharing_matched': {
|
||||
const inventoryNo = readPayload(event, 'inventoryNo')
|
||||
return {
|
||||
title: `拼单寄卖库存匹配${inventoryNo ? `(${inventoryNo})` : ''}`,
|
||||
description:
|
||||
type === 'consignment_manual_sharing_matched'
|
||||
? '手动匹配拼单寄卖库存'
|
||||
: '自动匹配拼单寄卖库存',
|
||||
color: 'green',
|
||||
}
|
||||
}
|
||||
case 'consignment_fulfilled': {
|
||||
const inventoryNo = readPayload(event, 'inventoryNo')
|
||||
return {
|
||||
title: '寄卖库存已履约,订单停在待加好友',
|
||||
description: inventoryNo ? `库存号:${inventoryNo}` : undefined,
|
||||
color: 'green',
|
||||
}
|
||||
}
|
||||
case 'consignment_worker_assigned': {
|
||||
const workerLabel = resolveEventWorkerLabel(event) || readPayload(event, 'workerName')
|
||||
const inventoryNo = readPayload(event, 'inventoryNo')
|
||||
return {
|
||||
title: `寄卖库存已指派履约打手${workerLabel ? `(${workerLabel})` : ''}`,
|
||||
description: inventoryNo ? `库存号:${inventoryNo}` : undefined,
|
||||
color: 'green',
|
||||
}
|
||||
}
|
||||
case 'consignment_revoked':
|
||||
return {
|
||||
title: note ? `撤销寄卖履约(${note})` : '撤销寄卖履约',
|
||||
color: 'red',
|
||||
}
|
||||
case 'consignment_internal_matching_entered':
|
||||
case 'consignment_internal_matching_reentered':
|
||||
case 'consignment_internal_matching_expired':
|
||||
case 'consignment_internal_matching_migrated':
|
||||
return {
|
||||
title:
|
||||
type === 'consignment_internal_matching_expired'
|
||||
? '内部匹配等待超时(历史流程)'
|
||||
: type === 'consignment_internal_matching_migrated'
|
||||
? '内部匹配等待记录已迁移(历史流程)'
|
||||
: '进入内部匹配等待(历史流程)',
|
||||
color: type === 'consignment_internal_matching_expired' ? 'orange' : 'default',
|
||||
}
|
||||
|
||||
// —— 验收节点 ——
|
||||
case 'acceptance_submitted': {
|
||||
const imageCount = readPayload(event, 'imageCount')
|
||||
@@ -232,6 +353,14 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
||||
description: readPayload(event, 'evidenceDueAt') ? '可在 24 小时内补充验收图片' : undefined,
|
||||
color: 'purple',
|
||||
}
|
||||
case 'acceptance_reminded':
|
||||
return {
|
||||
title: '打手提醒客服尽快验收',
|
||||
description: readPayload(event, 'reminderCount')
|
||||
? `已提醒 ${readPayload(event, 'reminderCount')} 次`
|
||||
: undefined,
|
||||
color: 'orange',
|
||||
}
|
||||
case 'acceptance_evidence_supplemented': {
|
||||
const addedImageCount = readPayload(event, 'addedImageCount')
|
||||
return {
|
||||
@@ -329,6 +458,25 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
||||
}
|
||||
|
||||
// —— 其他 ——
|
||||
case 'auto_publish_succeeded':
|
||||
return { title: '订单自动发布成功', color: 'geekblue' }
|
||||
case 'admin_note_updated':
|
||||
return {
|
||||
title: note ? `更新了后台备注:${note}` : '更新了后台备注',
|
||||
color: 'default',
|
||||
}
|
||||
case 'customer_refund_cancelled':
|
||||
return {
|
||||
title: '客户退款已取消',
|
||||
description: note || undefined,
|
||||
color: 'default',
|
||||
}
|
||||
case 'status_repaired':
|
||||
return { title: '系统修复了订单状态', color: 'orange' }
|
||||
case 'problem_cancel_restored':
|
||||
return { title: '问题单取消后已恢复', color: 'orange' }
|
||||
case 'cancelled_by_channel_switch':
|
||||
return { title: '因切换履约渠道取消订单', color: 'red' }
|
||||
case 'payment_amount_updated': {
|
||||
const previousAmountFen = readPayloadAmount(event, 'previousAmountFen')
|
||||
const amountFen = readPayloadAmount(event, 'amountFen')
|
||||
@@ -509,6 +657,30 @@ export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
|
||||
return { title: '扣减了待解冻押金', color: 'default' }
|
||||
case 'voucher_backfilled':
|
||||
return { title: '回填了电子凭证信息', color: 'default' }
|
||||
case 'douyin_voucher_backfilled':
|
||||
return { title: '回填了抖音电子凭证信息', color: 'default' }
|
||||
case 'publish_voucher_consumed': {
|
||||
const consumedCount = readPayloadAmount(event, 'consumedCount')
|
||||
const voucherCodes = event.payload?.voucherCodes
|
||||
const count = Array.isArray(voucherCodes) ? voucherCodes.length : consumedCount
|
||||
return {
|
||||
title: '订单发布时已核销电子凭证',
|
||||
description: count > 0 ? `已核销 ${count} 张` : undefined,
|
||||
color: 'green',
|
||||
}
|
||||
}
|
||||
case 'publish_voucher_consume_scheduled':
|
||||
case 'publish_voucher_consume_schedule_skipped':
|
||||
case 'publish_voucher_consume_schedule_failed':
|
||||
return formatVoucherScheduleEvent(event, '订单发布')
|
||||
case 'assign_voucher_consume_scheduled':
|
||||
case 'assign_voucher_consume_schedule_skipped':
|
||||
case 'assign_voucher_consume_schedule_failed':
|
||||
return formatVoucherScheduleEvent(event, '指派')
|
||||
case 'consignment_fulfill_voucher_consume_scheduled':
|
||||
case 'consignment_fulfill_voucher_consume_schedule_skipped':
|
||||
case 'consignment_fulfill_voucher_consume_schedule_failed':
|
||||
return formatVoucherScheduleEvent(event, '寄卖履约')
|
||||
case 'assign_voucher_consume_failed':
|
||||
return { title: '电子凭证核销失败,本次操作未生效', color: 'red' }
|
||||
case 'publish_voucher_consume_failed':
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import test from 'node:test'
|
||||
|
||||
import ts from 'typescript'
|
||||
|
||||
/** 这些调用最终都会写入 work_order_events,事件类型必须在前端有中文映射。 */
|
||||
const WORK_ORDER_EVENT_CALLS = new Set([
|
||||
'createWorkOrderEvent',
|
||||
'createWorkOrderEventWithClient',
|
||||
'parkWorkOrderWaitingFriendAddWithClient',
|
||||
'startWorkOrderGiftCountdownWithClient',
|
||||
])
|
||||
|
||||
/**
|
||||
* 生产库仍存在、但当前代码已经不再写入的历史事件类型。
|
||||
* 前端必须继续映射,否则历史工单详情会露英文 key。
|
||||
*/
|
||||
const PRODUCTION_ONLY_EVENT_TYPES = [
|
||||
'auto_publish_succeeded',
|
||||
'status_repaired',
|
||||
'problem_cancel_restored',
|
||||
'consignment_internal_matching_entered',
|
||||
'consignment_internal_matching_reentered',
|
||||
'consignment_internal_matching_expired',
|
||||
'consignment_internal_matching_migrated',
|
||||
]
|
||||
|
||||
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)) {
|
||||
// 只取分支结果,不把条件里的 'queued' / 'failed' 当成事件类型。
|
||||
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
|
||||
}
|
||||
if (ts.isTemplateExpression(expr) && expr.templateSpans.length === 0) {
|
||||
result.add(expr.head.text)
|
||||
}
|
||||
}
|
||||
|
||||
function collectEventTypesFromFile(filePath: string, result: Set<string>) {
|
||||
const source = readFileSync(filePath, 'utf8')
|
||||
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true)
|
||||
|
||||
function visit(node: ts.Node) {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = node.expression.getText(sourceFile).split('.').pop() || ''
|
||||
if (WORK_ORDER_EVENT_CALLS.has(callee)) {
|
||||
const objectArg = node.arguments.find((arg): arg is ts.ObjectLiteralExpression =>
|
||||
ts.isObjectLiteralExpression(arg),
|
||||
)
|
||||
if (objectArg) {
|
||||
for (const property of objectArg.properties) {
|
||||
if (
|
||||
ts.isPropertyAssignment(property) &&
|
||||
property.name.getText(sourceFile) === 'eventType'
|
||||
) {
|
||||
collectStringLiterals(property.initializer, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
}
|
||||
|
||||
function walkBackendSources(dir: string, result: Set<string>) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const filePath = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walkBackendSources(filePath, result)
|
||||
continue
|
||||
}
|
||||
if (!entry.isFile() || !filePath.endsWith('.ts') || filePath.endsWith('.test.ts')) continue
|
||||
collectEventTypesFromFile(filePath, result)
|
||||
}
|
||||
}
|
||||
|
||||
test('工单事件时间线:后端写入的每个 eventType 都必须有中文映射', () => {
|
||||
const backendRoot = fileURLToPath(new URL('../../backend/src', import.meta.url))
|
||||
const timelinePath = fileURLToPath(
|
||||
new URL('../src/components/WorkOrderEventTimeline.tsx', import.meta.url),
|
||||
)
|
||||
|
||||
const eventTypes = new Set<string>()
|
||||
for (const type of PRODUCTION_ONLY_EVENT_TYPES) eventTypes.add(type)
|
||||
walkBackendSources(backendRoot, eventTypes)
|
||||
|
||||
const timelineSource = readFileSync(timelinePath, 'utf8')
|
||||
const mappedTypes = new Set(
|
||||
[...timelineSource.matchAll(/case '([a-z_]+)'/g)].map((match) => match[1]!),
|
||||
)
|
||||
|
||||
const missing = [...eventTypes].filter((type) => !mappedTypes.has(type)).sort()
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`以下工单事件缺少中文映射,会在流转记录里直接显示英文 key:${missing.join(', ')}`,
|
||||
)
|
||||
})
|
||||
|
||||
test('工单事件时间线:截图中的内部寄售与凭证事件必须命中中文映射', () => {
|
||||
const timelineSource = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/WorkOrderEventTimeline.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
for (const eventType of [
|
||||
'consignment_gift_countdown_started',
|
||||
'publish_voucher_consume_schedule_skipped',
|
||||
'consignment_manual_matched',
|
||||
]) {
|
||||
assert.ok(timelineSource.includes(`case '${eventType}'`), `${eventType} 必须有中文映射`)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user