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
@@ -67,6 +67,14 @@ describe('overseas business mounted pages', () => {
}) })
it('opens the associated commercial order using a server-side order number filter', async () => {
routing.route.query = { orderNo: 'LB-LINKED' }
const wrapper = mount(TransportOrdersView, { props: { domain: 'looseboard' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listLocalLooseboardOrders).toHaveBeenCalledWith(expect.objectContaining({ orderNo: 'LB-LINKED' }))
expect(wrapper.find('input[placeholder="请输入商业单号"]').element).toHaveProperty('value', 'LB-LINKED')
})
it('opens pool arrangements from the list without another request or exposing edit actions', async () => { it('opens pool arrangements from the list without another request or exposing edit actions', async () => {
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement, status: 'WAITING_INBOUND' }], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement, status: 'WAITING_INBOUND' }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises() const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
@@ -162,7 +170,7 @@ describe('overseas business mounted pages', () => {
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() }) it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() }) it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); wrapper.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'PORT_PICKUP'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系人'))!.find('input').setValue('联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系方式'))!.find('input').setValue('13800000000'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-31'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() }) it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); wrapper.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'PORT_PICKUP'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系人'))!.find('input').setValue('联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系方式'))!.find('input').setValue('13800000000'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-31'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() }) it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) }) it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() }) it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() }) it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Plus } from '@lucide/vue' import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -19,7 +19,7 @@ import { overseasBusinessPermissions } from '../permissions'
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types' import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>() const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const router = useRouter(); const access = useAccessPreviewStore() const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车') const isLooseboard = computed(() => props.domain === 'looseboard'); const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车')
const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query') const queryPreferenceKey = computed(() => isLooseboard.value ? 'overseas-business-looseboard-query' : 'overseas-business-truck-query')
const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate) const createPermission = computed(() => isLooseboard.value ? overseasBusinessPermissions.looseboardCreate : overseasBusinessPermissions.truckCreate)
@@ -28,12 +28,13 @@ const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const l
const page = ref(1); const pageSize = ref(20); const total = ref(0) const page = ref(1); const pageSize = ref(20); const total = ref(0)
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null) const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true } function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value }) const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' } const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels } const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '商业单号', placeholder: '请输入商业单号', width: 230 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 }, { key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
]) ])
@@ -64,10 +65,10 @@ async function load() {
rows.value = []; total.value = 0; loadError.value = null; loading.value = true rows.value = []; total.value = 0; loadError.value = null; loading.value = true
try { try {
if (isLooseboard.value) { if (isLooseboard.value) {
const result = await listLocalLooseboardOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalLooseboardStatus | '' } satisfies LocalLooseboardQuery) const result = await listLocalLooseboardOrders({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalLooseboardStatus | '' } satisfies LocalLooseboardQuery)
rows.value = result.records; total.value = result.total rows.value = result.records; total.value = result.total
} else { } else {
const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery) const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery)
rows.value = result.records; total.value = result.total rows.value = result.records; total.value = result.total
} }
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单加载失败,请稍后重试`) } finally { loading.value = false } } catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单加载失败,请稍后重试`) } finally { loading.value = false }
@@ -76,7 +77,7 @@ function search(model: QueryModel) { applied.value = { ...model }; if (page.valu
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) } function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') } function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return !row.fleetArrangement && (isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW') }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) } function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() }) watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route.query.orderNo], () => { page.value = 1; applied.value = { orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
</script> </script>
<template> <template>
@@ -1,5 +1,13 @@
# 海外仓原型核对矩阵 # 海外仓原型核对矩阵
### 尾程商业来源展示
| 页面 | 操作 | 依据 | 承载与结论 | 验收 |
|---|---|---|---|---|
| 尾程列表 | 来源展示、筛选、导出 | 本轮用户截图1及已确认方案 | 保留横向列表与原操作;增加业务来源、商业单号、预订号;订单类型改称履约类型 | 待人工1920×1080、100%截图确认 |
| 尾程详情 | 查看关联单据 | 本轮用户截图2及已确认方案 | 保留独立详情、基本信息页签;增加紧凑关联单据区 | 待人工截图确认 |
| 本土散板/卡车列表 | 来源商业单跳转 | 本轮已确认方案、现有承运安排入口 | 有列表权限才跳转既有列表,携带商业单号筛选;池来源保留1080px承运安排弹框 | 自动检查与人工确认分别报告 |
### F33 已有库存转运履约接入(2026-10-04) ### F33 已有库存转运履约接入(2026-10-04)
| 页面 | 操作 | 依据 | 承载方式与本轮结论 | 验收 | | 页面 | 操作 | 依据 | 承载方式与本轮结论 | 验收 |
@@ -27,6 +27,9 @@ export interface LastMileOrderQuery {
orderNo?: string orderNo?: string
relabelNo?: string relabelNo?: string
orderType?: string orderType?: string
businessType?: string
businessOrderNo?: string
externalBookingNo?: string
sailingStage?: 'BEFORE_DEPARTURE' | 'AFTER_DEPARTURE' sailingStage?: 'BEFORE_DEPARTURE' | 'AFTER_DEPARTURE'
inboundCode?: string inboundCode?: string
lxCode?: string lxCode?: string
@@ -42,7 +45,15 @@ export interface LastMileOrderQuery {
createdTo?: string createdTo?: string
} }
export interface LastMileBusinessSource {
businessType: string
label: string
businessOrderNo: string | null
externalBookingNo: string | null
}
export interface LastMileOrderSummaryDto { export interface LastMileOrderSummaryDto {
businessSource?: LastMileBusinessSource | null
id: string id: string
customerId: string customerId: string
sourceContainerBookingId?: string | null 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(), sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(),
orderNo: lastMileOrderReference(dto.orderNo), orderNo: lastMileOrderReference(dto.orderNo),
sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''), 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(), orderTypeCode: String(dto.orderType ?? '').trim(),
currentStatusCode: String(dto.currentStatus ?? '').trim(), currentStatusCode: String(dto.currentStatus ?? '').trim(),
warning: dto.abnormal ? '异常' : '—', 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 () => { it('shows one business heading, a semantic status and summary without losing basic fields', async () => {
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
@@ -16,6 +16,7 @@ import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto }
import { relabelApplicationStatusLabel, relabelBusinessStatusLabel, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation' import { relabelApplicationStatusLabel, relabelBusinessStatusLabel, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue' import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { businessOrderLink } from '../businessOrderLink'
import OrderReferenceText from '../components/OrderReferenceText.vue' import OrderReferenceText from '../components/OrderReferenceText.vue'
import WarehouseTransferFulfillmentPanel from '../components/WarehouseTransferFulfillmentPanel.vue' import WarehouseTransferFulfillmentPanel from '../components/WarehouseTransferFulfillmentPanel.vue'
@@ -58,7 +59,7 @@ const referenceValue = computed(() => lastMileOrderReference(detail.value?.order
const orderFields = computed<DetailField[]>(() => { const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order const order = detail.value?.order
const fields: DetailField[] = [ 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?.inboundCode) },
{ label: '货主', value: text(order?.customerName) }, { label: '货主', value: text(order?.customerName) },
{ label: '海外仓', value: text(order?.overseasWarehouseName) }, { label: '海外仓', value: text(order?.overseasWarehouseName) },
@@ -94,6 +95,7 @@ const containerFields = computed<DetailField[]>(() => {
{ label: '货柜节点', value: containerNodeLabel(order?.containerNode) }, { 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 basicFields = computed(() => [...orderFields.value, ...containerFields.value])
const overviewFields = computed(() => { const overviewFields = computed(() => {
const order = detail.value?.order const order = detail.value?.order
@@ -211,6 +213,15 @@ onMounted(load)
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading"> <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-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> <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="基本信息"> <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> <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> </dl>
@@ -241,6 +252,7 @@ onMounted(load)
</template> </template>
<style scoped> <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-fields > div.wide { grid-column: span 2; }
.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; } .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', () => { 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 () => { it('supports keyboard status switching and keeps selection and queries in sync', async () => {
const wrapper = await mountView() const wrapper = await mountView()
const tabs = wrapper.findAll('[role="tab"]') 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']) expect(toolbar.props('fields').map((field) => field.key)).toEqual(['orderNo', 'inboundCode', 'warehouse'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
await button(wrapper, '高级筛选')!.trigger('click') await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('fields')).toHaveLength(13) expect(toolbar.props('fields')).toHaveLength(16)
toolbar.vm.$emit('update:modelValue', { toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'), containerNo: ' CONT-15 ', abnormal: 'false', ...toolbar.props('modelValue'), containerNo: ' CONT-15 ', abnormal: 'false',
createdAt: ['2026-09-01', '2026-09-30'], 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))) expect(availableFields.filter((field) => ['orderType', 'status', 'abnormal', 'detained'].includes(field.key)))
.toEqual(expect.arrayContaining([ .toEqual(expect.arrayContaining([
expect.objectContaining({ key: 'orderType', placeholder: '请选择订单类型' }), expect.objectContaining({ key: 'orderType', placeholder: '请选择履约类型' }),
expect.objectContaining({ key: 'status', placeholder: '请选择当前状态' }), expect.objectContaining({ key: 'status', placeholder: '请选择当前状态' }),
expect.objectContaining({ key: 'abnormal', placeholder: '请选择是否异常' }), expect.objectContaining({ key: 'abnormal', placeholder: '请选择是否异常' }),
expect.objectContaining({ key: 'detained', 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 QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.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 { 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 OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders' import { reviewCargoDiscard } from '../api/lastMileOrders'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue' import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
@@ -153,13 +154,20 @@ let containerOptionRequest = 0
const statusTab = ref<'全部' | '开船前' | '开船后' | '柜子'>('全部') const statusTab = ref<'全部' | '开船前' | '开船后' | '柜子'>('全部')
const statusTabs = ['全部', '开船前', '开船后'] as const const statusTabs = ['全部', '开船前', '开船后'] as const
const showAdvancedFilters = ref(false) 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[] = [ 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: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' }, { key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' }, { key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' }, { 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: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' }, { label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' }, { 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: 'actions', label: '操作', width: 330, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' }, { 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: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
{ key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联入仓单', minWidth: 220, hideable: false }, { key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联入仓单', minWidth: 220, hideable: false },
{ key: 'warning', prop: 'warning', label: '预警', width: 90 }, { 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: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
{ key: 'lxCode', prop: 'lxCode', label: 'LX代码', minWidth: 150 }, { key: 'lxCode', prop: 'lxCode', label: 'LX代码', minWidth: 150 },
@@ -340,6 +351,9 @@ function orderQuery(): LastMileOrderQuery {
return { return {
page: page.value, page: page.value,
size: pageSize.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, orderNo: String(filters.value.orderNo || '').trim() || undefined,
relabelNo: String(filters.value.relabelNo || '').trim() || undefined, relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
orderType: statusTab.value === '全部' ? String(filters.value.orderType || '').trim() || undefined : 'FIRST_LEG', 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 #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 #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 #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 #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 #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> <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', () => { it('keeps every prototype list column in its frozen order', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([ 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', 'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo', 'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status', 'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
+4
View File
@@ -4,6 +4,10 @@
* identifiers, timestamps, money precision, or mutation request contracts. * identifiers, timestamps, money precision, or mutation request contracts.
*/ */
export interface LastMileOrderListItem { export interface LastMileOrderListItem {
businessType: string
businessSource: string
businessOrderNo: string
externalBookingNo: string
id: string id: string
customerId: string customerId: string
sourceContainerBookingId: string sourceContainerBookingId: string