feat(lastleg): expose commercial source and booking references

This commit is contained in:
2026-10-09 16:11:59 +08:00
parent 3a917324f1
commit 00a186b37c
13 changed files with 134 additions and 14 deletions
@@ -1,5 +1,13 @@
# 海外仓原型核对矩阵
### 尾程商业来源展示
| 页面 | 操作 | 依据 | 承载与结论 | 验收 |
|---|---|---|---|---|
| 尾程列表 | 来源展示、筛选、导出 | 本轮用户截图1及已确认方案 | 保留横向列表与原操作;增加业务来源、商业单号、预订号;订单类型改称履约类型 | 待人工1920×1080、100%截图确认 |
| 尾程详情 | 查看关联单据 | 本轮用户截图2及已确认方案 | 保留独立详情、基本信息页签;增加紧凑关联单据区 | 待人工截图确认 |
| 本土散板/卡车列表 | 来源商业单跳转 | 本轮已确认方案、现有承运安排入口 | 有列表权限才跳转既有列表,携带商业单号筛选;池来源保留1080px承运安排弹框 | 自动检查与人工确认分别报告 |
### F33 已有库存转运履约接入(2026-10-04)
| 页面 | 操作 | 依据 | 承载方式与本轮结论 | 验收 |
@@ -27,6 +27,9 @@ export interface LastMileOrderQuery {
orderNo?: string
relabelNo?: string
orderType?: string
businessType?: string
businessOrderNo?: string
externalBookingNo?: string
sailingStage?: 'BEFORE_DEPARTURE' | 'AFTER_DEPARTURE'
inboundCode?: string
lxCode?: string
@@ -42,7 +45,15 @@ export interface LastMileOrderQuery {
createdTo?: string
}
export interface LastMileBusinessSource {
businessType: string
label: string
businessOrderNo: string | null
externalBookingNo: string | null
}
export interface LastMileOrderSummaryDto {
businessSource?: LastMileBusinessSource | null
id: string
customerId: string
sourceContainerBookingId?: string | null
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { businessOrderLink } from './businessOrderLink'
import { toLastMileOrderListItem } from './lastMilePresentation'
describe('tail order business source', () => {
it('keeps physical fulfillment type and uses only formal business references', () => {
const row = toLastMileOrderListItem({ id: '1', customerId: 'A', orderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', customerRemark: '外接预订 FB-WRONG', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } })
expect(row).toMatchObject({ orderTypeCode: 'WAREHOUSE_TRANSFER', orderType: '仓储转运', businessSource: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' })
const unlinked = toLastMileOrderListItem({ id: '2', customerId: 'A', orderNo: 'WT-2', customerRemark: '外接预订 FB-WRONG' })
expect(unlinked.businessOrderNo).toBe('—')
expect(unlinked.externalBookingNo).toBe('—')
})
it('links only known business numbers with the target list permission', () => {
expect(businessOrderLink('LOCAL_LOOSEBOARD', 'LB-1', p => p.endsWith('looseboard:list'))).toEqual({ name: 'overseas-business-looseboards', query: { orderNo: 'LB-1' } })
expect(businessOrderLink('US_TRUCK', 'TRUCK-1', p => p.endsWith('truck:list'))).toEqual({ name: 'overseas-business-trucks', query: { orderNo: 'TRUCK-1' } })
expect(businessOrderLink('LOCAL_LOOSEBOARD', 'LB-1', () => false)).toBeUndefined()
expect(businessOrderLink('LOCAL_LOOSEBOARD', null, () => true)).toBeUndefined()
expect(businessOrderLink('WAREHOUSE_TRANSFER', 'WT-1', () => true)).toBeUndefined()
})
})
@@ -0,0 +1,12 @@
import { overseasBusinessPermissions } from '@/modules/overseas-business'
export function businessOrderLink(type: string | undefined, orderNo: string | null | undefined, can: (permission: string) => boolean) {
if (!orderNo || orderNo === '—') return undefined
if (type === 'LOCAL_LOOSEBOARD' && can(overseasBusinessPermissions.looseboardList)) {
return { name: 'overseas-business-looseboards', query: { orderNo } }
}
if (type === 'US_TRUCK' && can(overseasBusinessPermissions.truckList)) {
return { name: 'overseas-business-trucks', query: { orderNo } }
}
return undefined
}
@@ -153,6 +153,10 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(),
orderNo: lastMileOrderReference(dto.orderNo),
sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''),
businessType: dto.businessSource?.businessType || '',
businessSource: text(dto.businessSource?.label),
businessOrderNo: text(dto.businessSource?.businessOrderNo),
externalBookingNo: text(dto.businessSource?.externalBookingNo),
orderTypeCode: String(dto.orderType ?? '').trim(),
currentStatusCode: String(dto.currentStatus ?? '').trim(),
warning: dto.abnormal ? '异常' : '—',
@@ -33,6 +33,18 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
})
})
it('shows business references without changing the transfer workflow or requiring business-list permission', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'A', orderNo: 'WT-1', sourceOrderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
const source = wrapper.get('[aria-label="商业来源与关联单据"]')
expect(source.text()).toContain('本土散板 · 拼车需求池')
expect(source.text()).toContain('LB-1')
expect(source.text()).toContain('FB-1')
expect(source.find('button').exists()).toBe(false)
expect(wrapper.get('[aria-label="基本信息"]').text()).toContain('履约类型仓储转运')
expect(getWarehouseTransfer).not.toHaveBeenCalled()
})
it('shows one business heading, a semantic status and summary without losing basic fields', async () => {
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -16,6 +16,7 @@ import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto }
import { relabelApplicationStatusLabel, relabelBusinessStatusLabel, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { businessOrderLink } from '../businessOrderLink'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import WarehouseTransferFulfillmentPanel from '../components/WarehouseTransferFulfillmentPanel.vue'
@@ -58,7 +59,7 @@ const referenceValue = computed(() => lastMileOrderReference(detail.value?.order
const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order
const fields: DetailField[] = [
{ label: '订单类型', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
{ label: '履约类型', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
{ label: '入仓码', value: text(order?.inboundCode) },
{ label: '货主', value: text(order?.customerName) },
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
@@ -94,6 +95,7 @@ const containerFields = computed<DetailField[]>(() => {
{ label: '货柜节点', value: containerNodeLabel(order?.containerNode) },
]
})
const commercialLink = computed(() => businessOrderLink(detail.value?.order.businessSource?.businessType, detail.value?.order.businessSource?.businessOrderNo, access.can))
const basicFields = computed(() => [...orderFields.value, ...containerFields.value])
const overviewFields = computed(() => {
const order = detail.value?.order
@@ -211,6 +213,15 @@ onMounted(load)
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
<el-tabs v-model="activeTab" class="detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="basic"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>基本信息</span></template>
<section aria-label="商业来源与关联单据">
<h2 class="source-heading">关联单据</h2>
<dl class="detail-fields density-fields">
<div class="wide"><dt>业务来源</dt><dd>{{ text(detail?.order.businessSource?.label) }}</dd></div>
<div class="wide"><dt>关联商业单号</dt><dd><el-button v-if="commercialLink" link type="primary" @click="router.push(commercialLink)">{{ detail?.order.businessSource?.businessOrderNo }}</el-button><span v-else>{{ text(detail?.order.businessSource?.businessOrderNo) }}</span></dd></div>
<div class="wide"><dt>外接预订号</dt><dd>{{ text(detail?.order.businessSource?.externalBookingNo) }}</dd></div>
<div class="wide"><dt>来源单号</dt><dd>{{ text(detail?.order.sourceOrderNo) }}</dd></div>
</dl>
</section>
<dl class="detail-fields density-fields" aria-label="基本信息">
<div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field), 'package-difference--nonzero': isNonzeroDifference(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div>
</dl>
@@ -241,6 +252,7 @@ onMounted(load)
</template>
<style scoped>
.source-heading { margin: 0; padding: var(--admin-space-3); font-size: var(--admin-font-section); font-weight: 600; }
.detail-fields > div.wide { grid-column: span 2; }
.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
@@ -186,6 +186,19 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('shows commercial source alongside fulfillment type and sends source filters to the server', async () => {
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: '1', customerId: 'A', orderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const table = wrapper.findComponent(DataTable)
expect(table.props('data')[0]).toMatchObject({ businessSource: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1', orderType: '仓储转运', orderTypeCode: 'WAREHOUSE_TRANSFER' })
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('search', { ...toolbar.props('modelValue'), businessType: 'LOCAL_LOOSEBOARD', businessOrderNo: ' LB-1 ', externalBookingNo: ' FB-1 ' })
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: 'LOCAL_LOOSEBOARD', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' }))
toolbar.vm.$emit('reset'); await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: undefined, businessOrderNo: undefined, externalBookingNo: undefined }))
})
it('supports keyboard status switching and keeps selection and queries in sync', async () => {
const wrapper = await mountView()
const tabs = wrapper.findAll('[role="tab"]')
@@ -240,7 +253,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(toolbar.props('fields').map((field) => field.key)).toEqual(['orderNo', 'inboundCode', 'warehouse'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('fields')).toHaveLength(13)
expect(toolbar.props('fields')).toHaveLength(16)
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'), containerNo: ' CONT-15 ', abnormal: 'false',
createdAt: ['2026-09-01', '2026-09-30'],
@@ -497,7 +510,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
expect(availableFields.filter((field) => ['orderType', 'status', 'abnormal', 'detained'].includes(field.key)))
.toEqual(expect.arrayContaining([
expect.objectContaining({ key: 'orderType', placeholder: '请选择订单类型' }),
expect.objectContaining({ key: 'orderType', placeholder: '请选择履约类型' }),
expect.objectContaining({ key: 'status', placeholder: '请选择当前状态' }),
expect.objectContaining({ key: 'abnormal', placeholder: '请选择是否异常' }),
expect.objectContaining({ key: 'detained', placeholder: '请选择是否暂扣' }),
@@ -17,6 +17,7 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import { businessOrderLink } from '../businessOrderLink'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
@@ -153,13 +154,20 @@ let containerOptionRequest = 0
const statusTab = ref<'全部' | '开船前' | '开船后' | '柜子'>('全部')
const statusTabs = ['全部', '开船前', '开船后'] as const
const showAdvancedFilters = ref(false)
const filters = ref<QueryModel>({ orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const filters = ref<QueryModel>({ businessType: '', businessOrderNo: '', externalBookingNo: '', orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
{ key: 'businessType', type: 'select', label: '业务来源', width: 180, placeholder: '请选择业务来源', options: [
{ label: '本土散板(全部来源)', value: 'LOCAL_LOOSEBOARD' }, { label: '本土卡车', value: 'US_TRUCK' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '头程订单', value: 'FIRST_LEG' },
{ label: '换标下单', value: 'RELABEL_APPLICATION' }, { label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
] },
{ key: 'businessOrderNo', type: 'input', label: '关联商业单号', width: 210, placeholder: '请输入商业单号' },
{ key: 'externalBookingNo', type: 'input', label: '外接预订号', width: 210, placeholder: '请输入外接预订号' },
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '请选择订单类型', options: [
{ key: 'orderType', type: 'select', label: '履约类型', width: 170, placeholder: '请选择履约类型', options: [
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
@@ -246,10 +254,13 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 330, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'businessSource', prop: 'businessSource', label: '业务来源', minWidth: 220 },
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '关联商业单号', minWidth: 230, slotName: 'businessOrderNo' },
{ key: 'externalBookingNo', prop: 'externalBookingNo', label: '外接预订号', minWidth: 220 },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
{ key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联入仓单', minWidth: 220, hideable: false },
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
{ key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 },
{ key: 'orderType', prop: 'orderType', label: '履约类型', minWidth: 110 },
{ key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ key: 'lxCode', prop: 'lxCode', label: 'LX代码', minWidth: 150 },
@@ -340,6 +351,9 @@ function orderQuery(): LastMileOrderQuery {
return {
page: page.value,
size: pageSize.value,
businessType: String(filters.value.businessType || '').trim() || undefined,
businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined,
externalBookingNo: String(filters.value.externalBookingNo || '').trim() || undefined,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
orderType: statusTab.value === '全部' ? String(filters.value.orderType || '').trim() || undefined : 'FIRST_LEG',
@@ -1097,6 +1111,7 @@ onMounted(() => { void load() })
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }} · {{ application.quantity }} 件</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #businessOrderNo="{ row }"><el-button v-if="businessOrderLink(row.businessType, row.businessOrderNo, access.can)" link type="primary" @click="router.push(businessOrderLink(row.businessType, row.businessOrderNo, access.can)!)">{{ row.businessOrderNo }}</el-button><span v-else>{{ row.businessOrderNo }}</span></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
@@ -397,7 +397,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('keeps every prototype list column in its frozen order', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
'actions', 'selection', 'orderNo', 'businessSource', 'businessOrderNo', 'externalBookingNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
+4
View File
@@ -4,6 +4,10 @@
* identifiers, timestamps, money precision, or mutation request contracts.
*/
export interface LastMileOrderListItem {
businessType: string
businessSource: string
businessOrderNo: string
externalBookingNo: string
id: string
customerId: string
sourceContainerBookingId: string