fix(admin): preserve tail-leg references

This commit is contained in:
2026-09-01 11:03:45 +08:00
parent 0cf4a93f9c
commit 4711bcc4e6
10 changed files with 80 additions and 16 deletions
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OrderReferenceText from './OrderReferenceText.vue'
describe('OrderReferenceText', () => {
@@ -23,4 +23,15 @@ describe('OrderReferenceText', () => {
expect(wrapper.text()).toBe('LMO-260827-0001')
expect(wrapper.attributes('title')).toBe('LMO-260827-0001')
})
it('copies the exact server short number without frontend formatting', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })
const wrapper = mount(OrderReferenceText, { props: { value: 'LMO-9A7', copyable: true } })
await wrapper.get('el-button').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('LMO-9A7')
expect(wrapper.attributes('title')).toBe('LMO-9A7')
vi.unstubAllGlobals()
})
})
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import CopyButton from '@/shared/components/CopyButton.vue'
const props = withDefaults(defineProps<{
value?: string | null
emptyText?: string
}>(), { value: null, emptyText: '—' })
copyable?: boolean
copyLabel?: string
}>(), { value: null, emptyText: '—', copyable: false, copyLabel: '尾程单号' })
const displayValue = computed(() => {
const value = String(props.value ?? '')
@@ -15,16 +18,23 @@ const fullTitle = computed(() => displayValue.value === props.emptyText ? undefi
</script>
<template>
<span class="order-reference-text" :title="fullTitle">{{ displayValue }}</span>
<span class="order-reference-text" :title="fullTitle"><span class="order-reference-text__value">{{ displayValue }}</span><CopyButton v-if="copyable && fullTitle" class="order-reference-text__copy" :value="displayValue" :label="copyLabel" button-text="" /></span>
</template>
<style scoped>
.order-reference-text {
display: inline-block;
display: inline-flex;
min-width: 0;
align-items: center;
gap: 4px;
max-width: 100%;
vertical-align: bottom;
}
.order-reference-text__value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
.order-reference-text__copy { flex: 0 0 auto; margin: 0; padding-inline: 6px; }
</style>
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, toLastMileOrderListItem } from './lastMilePresentation'
import { formatOverseasDateTime, lastMileOrderReference, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => {
it('maps backend codes, associations and long ids without inventing data', () => {
@@ -23,6 +23,14 @@ describe('last-mile order presentation adapter', () => {
}).owner).toBe('—')
})
it('preserves the exact server LMO short number without padding, prefixing or deriving it from id', () => {
const serverOrderNo = 'LMO-9A7'
const row = toLastMileOrderListItem({ id: '2092873463568596994', customerId: '100', orderNo: serverOrderNo })
expect(row.orderNo).toBe(serverOrderNo)
expect(lastMileOrderReference(serverOrderNo)).toBe(serverOrderNo)
expect(row.orderNo).not.toContain(row.id)
})
it('preserves real metrics and distinguishes zero from unknown fields and times', () => {
const estimatedArrivalAt = '2026-08-27T12:30:00Z'
const row = toLastMileOrderListItem({
@@ -45,6 +45,11 @@ export function lastMileOrderTypeLabel(value: unknown) {
return orderTypeLabels[code] || text(code)
}
export function lastMileOrderReference(value: unknown, emptyLabel = '—') {
const serverValue = value == null ? '' : String(value)
return serverValue || emptyLabel
}
export function formatOverseasDateTime(value: unknown) {
const raw = String(value ?? '').trim()
if (!raw) return '—'
@@ -57,7 +62,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
id: String(dto.id),
customerId: String(dto.customerId),
sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(),
orderNo: text(dto.orderNo),
orderNo: lastMileOrderReference(dto.orderNo),
warning: dto.abnormal ? '异常' : '—',
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)),
rejectedAt: '—',
@@ -11,7 +11,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto } from '../api/lastMileOrders'
import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
import { formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.vue'
@@ -39,7 +39,7 @@ function text(value: unknown) { return String(value ?? '').trim() || '—' }
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
const referenceValue = computed(() => text(detail.value?.order.orderNo || '订单编码待补充'))
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
@@ -151,7 +151,7 @@ onMounted(load)
<template>
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
<template #reference><OrderReferenceText :value="referenceValue" /></template>
<template #reference><OrderReferenceText :value="referenceValue" copyable /></template>
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
@@ -406,7 +406,7 @@ onMounted(() => { void load() })
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" /></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
@@ -128,6 +128,16 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).not.toContain('海外派送')
})
it('renders and copies the server-provided tail-leg short number in list and detail', () => {
const list = source('LastMileOrdersView.vue')
const detail = source('LastMileOrderDetailView.vue')
expect(list).toContain('<OrderReferenceText :value="String(row.orderNo || \'\')" copyable />')
expect(detail).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(detail).toContain('<OrderReferenceText :value="referenceValue" copyable />')
expect(list).not.toMatch(/padStart|LMO-\$\{|slice\([^)]*orderNo/)
expect(detail).not.toMatch(/padStart|LMO-\$\{|slice\([^)]*orderNo/)
})
it('connects the tail-leg detail to the real overseas API and keeps missing contracts pending', () => {
const tailLeg = source('LastMileOrderDetailView.vue')
expect(tailLeg).toContain('getLastMileOrder')
@@ -137,6 +147,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(tailLeg).toContain('label="费用明细"')
expect(tailLeg).toContain('label="操作记录"')
expect(tailLeg).toContain('title="新增费用"')
expect(tailLeg).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(tailLeg).toContain('<OrderReferenceText :value="referenceValue" copyable />')
const inbound = source('InboundOrderDetailView.vue')
expect(inbound).toContain('getInboundPlan')
expect(inbound).toContain('sourceOrderId')
@@ -69,4 +69,16 @@ describe('truck appointment API', () => {
expect(form.get('file')).toBe(file)
expect(form.get('attachmentType')).toBe('DISPATCH_ORDER')
})
it('never forwards a legacy booking document type into truck attachments', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { id: '1', fileId: '2', fileName: 'legacy.pdf' } })
await referenceTruckAppointmentAttachment('77', { fileId: '2', fileName: 'legacy.pdf', attachmentType: 'BILL_OF_LADING' as never })
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/truck-appointments/77/attachments', { fileId: '2', fileName: 'legacy.pdf' })
const file = new File(['legacy'], 'legacy.pdf', { type: 'application/pdf' })
await uploadTruckAppointmentAttachment('77', file, 'BILL_OF_LADING' as never)
const form = vi.mocked(http.post).mock.calls.at(-1)?.[1] as FormData
expect(form.get('file')).toBe(file)
expect(form.has('attachmentType')).toBe(false)
})
})
+5 -3
View File
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, normalizeTruckAppointmentCurrency } from '../types'
import { TRUCK_APPOINTMENT_SAVE_FIELDS, isTruckAttachmentType, normalizeTruckAppointmentCurrency } from '../types'
import type {
SaveTruckAppointmentRequest,
TruckAppointmentPage,
@@ -77,14 +77,16 @@ export async function listTruckAppointmentAttachments(id: string | number) {
}
export async function referenceTruckAppointmentAttachment(id: string | number, payload: { fileId: string; attachmentType?: TruckAttachmentType | null; fileName?: string | null; fileHash?: string | null }) {
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, payload)
const { attachmentType, ...rest } = payload
const body = { ...rest, ...(isTruckAttachmentType(attachmentType) ? { attachmentType } : {}) }
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments`, body)
return unwrapApiResponse(response)
}
export async function uploadTruckAppointmentAttachment(id: string | number, file: File, attachmentType?: TruckAttachmentType | null) {
const form = new FormData()
form.append('file', file)
if (attachmentType) form.append('attachmentType', attachmentType)
if (isTruckAttachmentType(attachmentType)) form.append('attachmentType', attachmentType)
const response = await http.post<ApiEnvelope<TruckAttachmentReference>, ApiEnvelope<TruckAttachmentReference>>(`${basePath}/${id}/attachments/upload`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
return unwrapApiResponse(response)
}
+5 -1
View File
@@ -52,7 +52,11 @@ export function truckAppointmentActionLabel(action: string | null | undefined) {
return truckAppointmentActionLabels[action] || '状态更新'
}
export type TruckAttachmentType = 'DISPATCH_ORDER' | 'DRIVER_DOCUMENT' | 'VEHICLE_DOCUMENT' | 'OTHER'
export const TRUCK_ATTACHMENT_TYPES = ['DISPATCH_ORDER', 'DRIVER_DOCUMENT', 'VEHICLE_DOCUMENT', 'OTHER'] as const
export type TruckAttachmentType = typeof TRUCK_ATTACHMENT_TYPES[number]
export function isTruckAttachmentType(value: unknown): value is TruckAttachmentType {
return TRUCK_ATTACHMENT_TYPES.includes(value as TruckAttachmentType)
}
export const TRUCK_APPOINTMENT_CURRENCIES = ['RMB', 'USD'] as const
export type TruckAppointmentCurrency = typeof TRUCK_APPOINTMENT_CURRENCIES[number]
export function normalizeTruckAppointmentCurrency(value: unknown): TruckAppointmentCurrency {