fix(admin): preserve tail-leg references
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user