diff --git a/apps/frontend/src/components/WorkOrderEventTimeline.tsx b/apps/frontend/src/components/WorkOrderEventTimeline.tsx index c84e4ed0..5ffb46e6 100644 --- a/apps/frontend/src/components/WorkOrderEventTimeline.tsx +++ b/apps/frontend/src/components/WorkOrderEventTimeline.tsx @@ -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': diff --git a/apps/frontend/tests/work-order-event-labels.test.ts b/apps/frontend/tests/work-order-event-labels.test.ts new file mode 100644 index 00000000..9a271a20 --- /dev/null +++ b/apps/frontend/tests/work-order-event-labels.test.ts @@ -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) { + 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) { + 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) { + 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() + 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} 必须有中文映射`) + } +})