Release/test #11
@@ -87,6 +87,7 @@ export interface LastMileOrderSummaryDto {
|
||||
deliveryOrderNo?: string | null
|
||||
outboundOrderNo?: string | null
|
||||
relabelOrderNo?: string | null
|
||||
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; createdAt: string }>
|
||||
relabelStatus?: string | null
|
||||
palletMergeFlag?: string | null
|
||||
/** Optional grouping facts returned by newer last-leg services. */
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WarehouseTransferReviewDialog from './WarehouseTransferReviewDialog.vue'
|
||||
import { getWarehouseTransfer, reviewWarehouseTransfer } from '../api/warehouseTransfers'
|
||||
|
||||
vi.mock('../api/warehouseTransfers', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../api/warehouseTransfers')>(),
|
||||
getWarehouseTransfer: vi.fn(),
|
||||
reviewWarehouseTransfer: vi.fn(),
|
||||
}))
|
||||
|
||||
const AppDialogStub = defineComponent({
|
||||
name: 'AppDialog',
|
||||
props: { modelValue: Boolean },
|
||||
emits: ['confirm', 'update:modelValue'],
|
||||
setup(props, { slots }) {
|
||||
return () => props.modelValue ? h('section', { class: 'dialog' }, slots.default?.()) : null
|
||||
},
|
||||
})
|
||||
|
||||
const DataTableStub = defineComponent({
|
||||
name: 'DataTable',
|
||||
props: { data: { type: Array, default: () => [] } },
|
||||
setup(props) {
|
||||
return () => h('div', { class: 'data-table' }, JSON.stringify(props.data))
|
||||
},
|
||||
})
|
||||
|
||||
function detail(transferNo: string) {
|
||||
return {
|
||||
id: `id-${transferNo}`,
|
||||
transferNo,
|
||||
packageCount: 2,
|
||||
needRelabel: false,
|
||||
status: 'PENDING_REVIEW',
|
||||
items: [{ id: 'item-1', skuCode: 'SKU-1', productNameZh: '测试货物', packageCount: 2 }],
|
||||
cartons: [],
|
||||
pallets: [],
|
||||
relabelBindings: [],
|
||||
}
|
||||
}
|
||||
|
||||
function mountDialog(props: { modelValue: boolean; customerId: string; customerName: string; transferNo: string }) {
|
||||
return mount(WarehouseTransferReviewDialog, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
AppDialog: AppDialogStub,
|
||||
DataTable: DataTableStub,
|
||||
LoadErrorState: true,
|
||||
ApiErrorMeta: true,
|
||||
ElForm: { template: '<form><slot /></form>' },
|
||||
ElFormItem: { template: '<label><slot /></label>' },
|
||||
ElRadioGroup: { template: '<div><slot /></div>' },
|
||||
ElRadioButton: { template: '<button type="button"><slot /></button>' },
|
||||
ElInput: { template: '<textarea />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('WarehouseTransferReviewDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getWarehouseTransfer).mockReset()
|
||||
vi.mocked(reviewWarehouseTransfer).mockReset()
|
||||
})
|
||||
|
||||
it('loads and renders review details when first mounted already open', async () => {
|
||||
vi.mocked(getWarehouseTransfer).mockResolvedValue(detail('WT-1'))
|
||||
|
||||
const wrapper = mountDialog({ modelValue: true, customerId: '20001', customerName: '测试客户', transferNo: 'WT-1' })
|
||||
await flushPromises()
|
||||
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledTimes(1)
|
||||
expect(getWarehouseTransfer).toHaveBeenCalledWith('20001', 'WT-1')
|
||||
expect(wrapper.text()).toContain('WT-1')
|
||||
expect(wrapper.text()).toContain('测试货物')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reloads details when an open dialog switches to another transfer', async () => {
|
||||
vi.mocked(getWarehouseTransfer)
|
||||
.mockResolvedValueOnce(detail('WT-1'))
|
||||
.mockResolvedValueOnce(detail('WT-2'))
|
||||
|
||||
const wrapper = mountDialog({ modelValue: true, customerId: '20001', customerName: '测试客户', transferNo: 'WT-1' })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ customerId: '20002', transferNo: 'WT-2' })
|
||||
await flushPromises()
|
||||
|
||||
expect(getWarehouseTransfer).toHaveBeenLastCalledWith('20002', 'WT-2')
|
||||
expect(wrapper.text()).toContain('WT-2')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,16 @@ async function submit() {
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '审核提交失败,请重试').message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
watch(() => props.modelValue, (value) => { if (value) { form.decision = 'APPROVE'; form.remark = ''; void load() } })
|
||||
watch(
|
||||
() => [props.modelValue, props.customerId, props.transferNo] as const,
|
||||
([value]) => {
|
||||
if (!value) return
|
||||
form.decision = 'APPROVE'
|
||||
form.remark = ''
|
||||
void load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -2,6 +2,19 @@ import { describe, expect, it } from 'vitest'
|
||||
import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
|
||||
|
||||
describe('last-mile order presentation adapter', () => {
|
||||
it('keeps completed and processing relabel applications visible together', () => {
|
||||
const row = toLastMileOrderListItem({
|
||||
id: '1', customerId: 'customer-1', orderNo: 'RT-001', currentStatus: 'INBOUND_COMPLETED',
|
||||
relabelApplications: [
|
||||
{ relabelNo: 'RL-FIRST', status: 'COMPLETED', quantity: 10, createdAt: '2026-09-21T10:00:00' },
|
||||
{ relabelNo: 'RL-SECOND', status: 'PROCESSING', quantity: 5, createdAt: '2026-09-21T11:00:00' },
|
||||
],
|
||||
})
|
||||
expect(row.relabelApplications?.map(item => [item.relabelNo, item.statusLabel, item.quantity])).toEqual([
|
||||
['RL-FIRST', '已完成', 10], ['RL-SECOND', '处理中', 5],
|
||||
])
|
||||
})
|
||||
|
||||
it('formats overseas timestamps with zero-padded seconds', () => {
|
||||
expect(formatOverseasDateTime('2026-08-27T16:19:46')).toBe('2026-08-27 16:19:46')
|
||||
expect(formatOverseasDateTime('2026-08-07T06:09:04')).toBe('2026-08-07 06:09:04')
|
||||
|
||||
@@ -96,7 +96,12 @@ export function packageDifferenceText(value: number | '—' | null | undefined)
|
||||
return value > 0 ? `+${value}` : String(value)
|
||||
}
|
||||
|
||||
// 认领后显示处理中,可出库节点统一显示换标完成。
|
||||
export function relabelApplicationStatusLabel(value: unknown) {
|
||||
const code = String(value ?? '').trim()
|
||||
return ({ PENDING_REVIEW: '待审核', PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '待出库', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } as Record<string, string>)[code] || text(code)
|
||||
}
|
||||
|
||||
// 兼容原有单张换标单的汇总展示;子单使用完整业务状态。
|
||||
export function relabelBusinessStatusLabel(value: unknown) {
|
||||
const code = String(value ?? '').trim()
|
||||
if (code === 'PROCESSING') return '处理中'
|
||||
@@ -185,6 +190,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
outboundOrderNo: text(dto.outboundOrderNo),
|
||||
dispatchOrderNo: text(dto.deliveryOrderNo),
|
||||
relabelOrderNo: text(dto.relabelOrderNo),
|
||||
relabelApplications: (dto.relabelApplications || []).map(application => ({ ...application, statusLabel: relabelApplicationStatusLabel(application.status) })),
|
||||
relabelStatusCode: String(dto.relabelStatus ?? '').trim(),
|
||||
relabelStatus: relabelBusinessStatusLabel(dto.relabelStatus),
|
||||
consolidated: palletMergeLabel(dto.palletMergeFlag),
|
||||
|
||||
@@ -87,4 +87,30 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
expect(overview.text()).toContain('客户预报总箱数10 箱')
|
||||
expect(overview.text()).not.toContain('实收总箱数')
|
||||
})
|
||||
|
||||
it('renders both return relabel documents with independent states and quantities', async () => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({
|
||||
order: {
|
||||
id: 'return-id', customerId: 'customer-1', orderNo: 'RT-001', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED',
|
||||
relabelOrderNo: 'RL-SECOND', relabelStatus: 'PROCESSING',
|
||||
relabelApplications: [
|
||||
{ relabelNo: 'RL-FIRST', status: 'COMPLETED', quantity: 10, createdAt: '2026-09-21T10:00:00' },
|
||||
{ relabelNo: 'RL-SECOND', status: 'PROCESSING', quantity: 5, createdAt: '2026-09-21T11:00:00' },
|
||||
],
|
||||
},
|
||||
cargo: [], fees: [], statusHistory: [], operationHistory: [],
|
||||
})
|
||||
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.get('#tab-plan').trigger('click')
|
||||
await flushPromises()
|
||||
const rows = wrapper.findAll('.related-panel .el-table__body tbody tr')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].text()).toContain('RL-FIRST')
|
||||
expect(rows[0].text()).toContain('已完成')
|
||||
expect(rows[0].text()).toContain('10')
|
||||
expect(rows[1].text()).toContain('RL-SECOND')
|
||||
expect(rows[1].text()).toContain('处理中')
|
||||
expect(rows[1].text()).toContain('5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,14 +13,14 @@ 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, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
|
||||
import { relabelApplicationStatusLabel, formatOverseasDateTime, lastMileOrderReference, lastMileOrderTypeLabel, lastMileStatusLabel, packageDifferenceText } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
|
||||
interface DetailField { label: string; value: string | number; wide?: boolean }
|
||||
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
|
||||
interface DocumentRow { type: string; number: string; createdAt: string; quote: string }
|
||||
interface DocumentRow { status?: string; quantity?: number; type: string; number: string; createdAt: string; quote: string }
|
||||
interface FeeRow { id: string; item: string; currency: string; amount: string; status: string; description: string }
|
||||
interface LogRow { id: string; occurredAt: string; node: string; operator: string; result: string; sortAt: string }
|
||||
|
||||
@@ -117,14 +117,15 @@ const planFields = computed<DetailField[]>(() => {
|
||||
{ label: '合托批次', value: text(order?.palletMergeFlag) },
|
||||
{ label: '计划托数', value: Number(order?.palletCount || 0) },
|
||||
{ label: '入库单号', value: text(order?.inboundOrderNo) },
|
||||
{ label: '换标状态', value: lastMileStatusLabel(order?.relabelStatus) },
|
||||
{ label: '换标子单', value: order?.relabelApplications?.length ? `${order.relabelApplications.length} 张(详见关联单据)` : lastMileStatusLabel(order?.relabelStatus) },
|
||||
]
|
||||
})
|
||||
const documents = computed<DocumentRow[]>(() => {
|
||||
const order = detail.value?.order
|
||||
if (!order) return []
|
||||
return [['入库单', order.inboundOrderNo], ['出库单(只读关联)', order.outboundOrderNo], ['派送单(只读关联)', order.deliveryOrderNo], ['换标单', order.relabelOrderNo]]
|
||||
.filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' }))
|
||||
return [['入库单', order.inboundOrderNo], ['出库单(只读关联)', order.outboundOrderNo], ['派送单(只读关联)', order.deliveryOrderNo], ['换标单', order.relabelApplications?.length ? undefined : order.relabelOrderNo]]
|
||||
.filter((item) => item[1]).map(([type, number]) => ({ type: String(type), number: String(number), createdAt: '—', quote: '—' } as DocumentRow))
|
||||
.concat((order.relabelApplications || []).map(application => ({ type: '换标单', number: application.relabelNo, createdAt: formatOverseasDateTime(application.createdAt), quote: '—', status: relabelApplicationStatusLabel(application.status), quantity: application.quantity })))
|
||||
})
|
||||
const cargo = computed<CargoRow[]>(() => (detail.value?.cargo || []).map((item) => ({
|
||||
id: String(item.id), sku: text(item.skuCode), productName: text(item.productNameZh), pallets: Number(item.palletCount || 0),
|
||||
@@ -152,6 +153,7 @@ const cargoColumns: DataTableColumn[] = [
|
||||
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 120, align: 'right' },
|
||||
]
|
||||
const documentColumns: DataTableColumn[] = [
|
||||
{ key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(件)', minWidth: 130 },
|
||||
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 },
|
||||
]
|
||||
|
||||
@@ -234,8 +234,8 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
|
||||
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
|
||||
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
|
||||
{ key: 'relabelOrderNo', prop: 'relabelOrderNo', label: '换标单号', minWidth: 150 },
|
||||
{ key: 'relabelStatus', prop: 'relabelStatus', label: '换标状态', width: 110 },
|
||||
{ key: 'relabelOrderNo', slotName: 'relabelOrderNo', label: '换标单号', minWidth: 220 },
|
||||
{ key: 'relabelStatus', slotName: 'relabelStatus', label: '换标子状态', minWidth: 180 },
|
||||
{ key: 'status', prop: 'status', label: '当前状态', width: 110 },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
|
||||
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(仅自己)', minWidth: 160 },
|
||||
@@ -942,6 +942,8 @@ onMounted(() => { void load() })
|
||||
</section>
|
||||
<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.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || hasSelectedRejectedReturn() || hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></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 #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></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>
|
||||
@@ -1082,6 +1084,7 @@ onMounted(() => { void load() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
|
||||
.last-mile-toolbar { padding: var(--admin-space-5) var(--admin-space-5) 0; }
|
||||
.row-actions { white-space: nowrap; }
|
||||
.command-form { margin-top: var(--admin-space-5); }
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface LastMileOrderListItem {
|
||||
/** Read-only association fact; this module does not implement overseas delivery. */
|
||||
dispatchOrderNo: string
|
||||
relabelOrderNo: string
|
||||
relabelApplications?: Array<{ relabelNo: string; status: string; statusLabel: string; quantity: number; createdAt: string }>
|
||||
relabelStatusCode: string
|
||||
relabelStatus: string
|
||||
consolidated: string
|
||||
|
||||
Reference in New Issue
Block a user