fix(admin): unblock tailleg inbound planning
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
|
||||
| 页面/能力 | Method + Path | Params / Body(当前 adapter) | 权限与承载 |
|
||||
|---|---|---|---|
|
||||
| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 |
|
||||
| 尾程列表/详情 | `GET /platform/v1/last-leg/orders`;`GET /orders/{id}` | list query:`page,size,orderNo,orderType,warehouse,inboundCode,sourceOrderNo,containerNo,currentStatus,transferWarehouseCode,abnormal,detained,createdFrom,createdTo`;平台跨客户,不传 `customerId`;货主只消费响应 `customerName`,空值显示 `—` | `platform:overseas-warehouse:last-mile-order:list`;真实分页、空态、错误及 request/trace 元数据;详情只读展示离仓关联事实 |
|
||||
| 尾程转目的仓 | `PUT /platform/v1/last-leg/orders/destination` | body:`orderIds:string[],warehouseId,warehouseCode,warehouseName,remark?` | `...:last-mile-order:edit`;失败保留弹框,不提示成功 |
|
||||
| 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string`,不传客户 | `...:inbound-order:create`;不接补托/PDA |
|
||||
| 尾程转入仓计划 | `POST /platform/v1/last-leg/inbound-plans/convert` | body:`orderId:string,customerId:string`,客户作用域精确取自所选尾程行;缺失时前端失败关闭 | `...:inbound-order:create`;A0404/A0409 保留 code/requestId/traceId;不接补托/PDA |
|
||||
| 入库列表 | `GET /platform/v1/last-leg/inbound-plans` | query:`page,size,planNo,inboundCode,status,warehouse,fbaWarehouseCode,appointmentStatus,dispatchStatus,createdFrom,createdTo`;平台跨客户 | `...:inbound-order:list`;`lineId/planId/orderId/customerId` 均为字符串 |
|
||||
| 入库详情 | `GET /platform/v1/last-leg/inbound-plans/{planNo}` | query:显式 `customerId`;path 编码 | 同 list;缺客户范围时页面明确报错;`sourceOrderId` 字符串化 |
|
||||
| FBA 预约新增 | `POST /platform/v1/last-leg/inbound-plans/fba-appointments/batch` | body:`customerId,lineIds:string[],appointmentNo,appointmentAt,expectedLoadingAt,appointmentStatus,appointmentRemark?` | `...:inbound-order:fba-appointment`;只允许同客户、同 FBA 仓勾选 |
|
||||
|
||||
@@ -8,8 +8,9 @@ describe('last-mile order overseas API', () => {
|
||||
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
|
||||
|
||||
it('loads all platform orders without a customer scope', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
|
||||
await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [], page: 2, size: 20, total: 0, pages: 0 })
|
||||
const record = { id: '190000000000000099', customerId: '190000000000000001', customerName: '华远贸易', orderNo: 'LMO-260827-0001' }
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [record], page: '2', size: '20', total: '1', pages: '1' } })
|
||||
await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [record], page: 2, size: 20, total: 1, pages: 1 })
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
|
||||
params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' },
|
||||
})
|
||||
@@ -47,10 +48,11 @@ describe('last-mile order overseas API', () => {
|
||||
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] }))
|
||||
})
|
||||
|
||||
it('uses the inbound-plan conversion endpoint without customerId', async () => {
|
||||
it('uses the inbound-plan conversion endpoint with the exact selected customer scope', async () => {
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
|
||||
await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099' })
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', { orderId: '190000000000000099' })
|
||||
expect(JSON.stringify(vi.mocked(overseasHttp.post).mock.calls[0]?.[1])).not.toContain('customerId')
|
||||
await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099', customerId: '190000000000000001' })
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', {
|
||||
orderId: '190000000000000099', customerId: '190000000000000001',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,6 +150,7 @@ export interface TransferDestinationPayload {
|
||||
|
||||
export interface ConvertInboundPlanPayload {
|
||||
orderId: string
|
||||
customerId: string
|
||||
}
|
||||
|
||||
export interface LastMileOrderDetailDto {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import OrderReferenceText from './OrderReferenceText.vue'
|
||||
|
||||
describe('OrderReferenceText', () => {
|
||||
it.each([
|
||||
['desktop-1920', '170px'],
|
||||
['narrow', '88px'],
|
||||
])('keeps the complete server order number in %s layouts', (_viewport, width) => {
|
||||
const orderNo = 'LEGACY-LAST-MILE-ORDER-202608270000000001'
|
||||
const wrapper = mount({
|
||||
components: { OrderReferenceText },
|
||||
template: `<div style="width:${width}"><OrderReferenceText value="${orderNo}" /></div>`,
|
||||
})
|
||||
|
||||
const reference = wrapper.get('.order-reference-text')
|
||||
expect(reference.text()).toBe(orderNo)
|
||||
expect(reference.attributes('title')).toBe(orderNo)
|
||||
})
|
||||
|
||||
it('renders the new short number in full and does not guess from another identifier', () => {
|
||||
const wrapper = mount(OrderReferenceText, { props: { value: 'LMO-260827-0001' } })
|
||||
expect(wrapper.text()).toBe('LMO-260827-0001')
|
||||
expect(wrapper.attributes('title')).toBe('LMO-260827-0001')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value?: string | null
|
||||
emptyText?: string
|
||||
}>(), { value: null, emptyText: '—' })
|
||||
|
||||
const displayValue = computed(() => {
|
||||
const value = String(props.value ?? '')
|
||||
return value || props.emptyText
|
||||
})
|
||||
|
||||
const fullTitle = computed(() => displayValue.value === props.emptyText ? undefined : displayValue.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="order-reference-text" :title="fullTitle">{{ displayValue }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-reference-text {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,7 @@ defineEmits<{ back: [] }>()
|
||||
<el-button :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回</el-button>
|
||||
<div class="overseas-detail-title">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ referenceLabel }} <strong>{{ referenceValue }}</strong></p>
|
||||
<p>{{ referenceLabel }} <strong><slot name="reference">{{ referenceValue }}</slot></strong></p>
|
||||
</div>
|
||||
<span class="overseas-detail-status" :class="{ 'is-live': !pending }" role="status">{{ statusText }}</span>
|
||||
</header>
|
||||
@@ -37,8 +37,8 @@ defineEmits<{ back: [] }>()
|
||||
.overseas-detail-head { display: flex; min-height: 44px; align-items: center; gap: 12px; }
|
||||
.overseas-detail-title { min-width: 0; flex: 1; }
|
||||
.overseas-detail-title h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
|
||||
.overseas-detail-title p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.overseas-detail-title strong { color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.overseas-detail-title p { display: flex; min-width: 0; align-items: baseline; gap: 4px; margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.overseas-detail-title strong { min-width: 0; max-width: 100%; color: var(--cc-text); }
|
||||
.overseas-detail-status { display: inline-flex; min-height: 26px; align-items: center; padding: 3px 10px; border: 1px solid #f3d19e; border-radius: 999px; background: #fdf6ec; color: #b88230; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||
.overseas-detail-status.is-live { border-color: var(--el-color-success-light-7); background: var(--el-color-success-light-9); color: var(--el-color-success-dark-2); }
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import OrderReferenceText from './OrderReferenceText.vue'
|
||||
|
||||
type VehicleSpec = { value: string; label: string; capacity: number; maxWeightLb: number }
|
||||
interface LoadingContainer { id: string; capacity: number; maxWeightLb: number; vehicleLabel: string; palletIds: string[] }
|
||||
@@ -211,7 +212,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
<header><div><h3>出库单列表 <span>({{ orderGroups.length }}单)</span></h3><div class="order-type-tabs"><button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">{{ option.label }}</button></div></div></header>
|
||||
<div v-loading="loading" class="order-table-wrap">
|
||||
<el-table :data="orderGroups" border height="520" highlight-current-row row-key="key" @current-change="activeOrderKey = $event?.key || ''">
|
||||
<el-table-column label="订单号" min-width="150"><template #default="{ row }">{{ text(row.first.orderNo) }}</template></el-table-column>
|
||||
<el-table-column label="订单号" min-width="170"><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template></el-table-column>
|
||||
<el-table-column label="订单类型" width="100"><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column>
|
||||
<el-table-column label="重量(lb)" width="100" align="right"><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column>
|
||||
<el-table-column label="托盘数量" width="90" align="right"><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column>
|
||||
@@ -225,7 +226,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
</section>
|
||||
|
||||
<section class="detail-pane" aria-label="入仓单托盘和箱号详情">
|
||||
<header><h3>托盘明细 <span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span></h3><span>订单号:{{ text(activePallets[0]?.orderNo) }}</span></header>
|
||||
<header><h3>托盘明细 <span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span></h3><span class="detail-order-reference">订单号:<OrderReferenceText :value="text(activePallets[0]?.orderNo)" /></span></header>
|
||||
<div class="pallet-list">
|
||||
<div v-for="item in activePallets" :key="palletId(item)" class="pallet-row" :class="{ assigned: containerFor(palletId(item)) }" draggable="true" @dragstart="dragStart($event, item)">
|
||||
<GripVertical :size="16" class="drag-handle" />
|
||||
@@ -246,7 +247,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
<div v-for="(container, index) in containers" :key="container.id" class="loading-container" :class="{ 'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1 }" @dragover.prevent @drop.prevent="dropInto($event, container.id)">
|
||||
<div class="container-head"><strong>{{ container.vehicleLabel }}</strong><span>托数 <b>{{ container.palletIds.length }}</b>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span><el-button text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button></div>
|
||||
<div class="container-body">
|
||||
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(palletSnapshot(id)?.palletNo || id) }}</strong>{{ text(palletSnapshot(id)?.orderNo) }}</span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div>
|
||||
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(palletSnapshot(id)?.palletNo || id) }}</strong><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div>
|
||||
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,6 +277,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
.dispatch-workbench { display: grid; grid-template-columns: minmax(550px, 1.5fr) minmax(330px, .85fr) minmax(390px, 1fr); gap: 16px; margin-top: 16px; }
|
||||
.order-pane, .detail-pane, .container-pane { min-width: 0; }
|
||||
.order-pane header, .detail-pane header, .container-pane header { display: flex; min-height: 74px; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.detail-order-reference { display: flex; min-width: 0; max-width: 240px; align-items: baseline; gap: 3px; }
|
||||
h3, p { margin: 0; } h3 { font-size: 16px; } h3 span, header > span, p { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
|
||||
.order-type-tabs { display: flex; gap: 6px; margin-top: 10px; overflow-x: auto; }
|
||||
.order-type-tabs button { flex: 0 0 auto; padding: 5px 12px; border-radius: 16px; font-size: 12px; }
|
||||
@@ -297,7 +299,7 @@ h3, p { margin: 0; } h3 { font-size: 16px; } h3 span, header > span, p { color:
|
||||
.container-head span { color: var(--cc-muted); font-size: 12px; }.container-head b { color: var(--el-color-primary); }
|
||||
.container-body { min-height: 76px; padding: 8px; }.drop-hint { display: grid; min-height: 60px; place-items: center; color: var(--cc-muted); font-size: 12px; }
|
||||
.loaded-pallet { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--cc-border); }
|
||||
.loaded-pallet > span { display: flex; min-width: 0; flex-direction: column; color: var(--cc-muted); font-size: 12px; }.loaded-pallet strong { color: var(--cc-text); font-size: 13px; }
|
||||
.loaded-pallet > span { display: flex; min-width: 0; flex: 1; flex-direction: column; color: var(--cc-muted); font-size: 12px; }.loaded-pallet strong { color: var(--cc-text); font-size: 13px; }
|
||||
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 16px -20px -20px; padding: 14px 20px; border-top: 1px solid var(--cc-border); background: #fafbfc; }
|
||||
.dialog-footer > span { color: var(--cc-muted); font-size: 12px; }
|
||||
.vehicle-options { display: grid; gap: 14px; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toLastMileOrderListItem } from './lastMilePresentation'
|
||||
import { formatOverseasDateTime, toLastMileOrderListItem } from './lastMilePresentation'
|
||||
|
||||
describe('last-mile order presentation adapter', () => {
|
||||
it('maps backend codes, associations and long ids without inventing data', () => {
|
||||
@@ -14,6 +14,28 @@ describe('last-mile order presentation adapter', () => {
|
||||
expect(row.rejectedAt).toBe('—')
|
||||
})
|
||||
|
||||
it('shows the backend customerName and never guesses the owner from customerId', () => {
|
||||
expect(toLastMileOrderListItem({
|
||||
id: '1', customerId: '190000000000000001', customerName: '华远贸易', orderNo: 'LMO-260827-0001',
|
||||
}).owner).toBe('华远贸易')
|
||||
expect(toLastMileOrderListItem({
|
||||
id: '2', customerId: '190000000000000002', customerName: null, orderNo: 'LMO-260827-0002',
|
||||
}).owner).toBe('—')
|
||||
})
|
||||
|
||||
it('preserves real metrics and distinguishes zero from unknown fields and times', () => {
|
||||
const estimatedArrivalAt = '2026-08-27T12:30:00Z'
|
||||
const row = toLastMileOrderListItem({
|
||||
id: '3', customerId: '190000000000000003', orderNo: 'LMO-260827-0003',
|
||||
palletCount: 3, packageCount: 0, volumeCbm: 12.75, productQuantity: null, volumeWeightKg: 88.6,
|
||||
estimatedArrivalAt, actualArrivalAt: null, estimatedWarehouseAt: null,
|
||||
})
|
||||
expect(row).toMatchObject({
|
||||
pallets: 3, cartons: 0, volume: 12.75, quantity: '—', volumeWeight: 88.6,
|
||||
portEta: formatOverseasDateTime(estimatedArrivalAt), portAta: '—', warehouseEta: '—',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['RELABEL_APPLICATION', '换标下单'],
|
||||
['WAREHOUSE_TRANSFER', '仓储转运'],
|
||||
|
||||
@@ -27,8 +27,8 @@ function text(value: unknown) {
|
||||
return normalized || '—'
|
||||
}
|
||||
|
||||
function count(value: number | null | undefined) {
|
||||
return Number.isFinite(value) ? Number(value) : 0
|
||||
function metric(value: number | null | undefined): number | '—' {
|
||||
return value != null && Number.isFinite(value) ? Number(value) : '—'
|
||||
}
|
||||
|
||||
function yesNo(value: boolean | null | undefined) {
|
||||
@@ -69,11 +69,11 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
containerStatus: text(dto.containerStatus),
|
||||
abnormal: yesNo(dto.abnormal),
|
||||
held: yesNo(dto.detained),
|
||||
pallets: count(dto.palletCount),
|
||||
cartons: count(dto.packageCount),
|
||||
volume: count(dto.volumeCbm),
|
||||
quantity: count(dto.productQuantity),
|
||||
volumeWeight: count(dto.volumeWeightKg),
|
||||
pallets: metric(dto.palletCount),
|
||||
cartons: metric(dto.packageCount),
|
||||
volume: metric(dto.volumeCbm),
|
||||
quantity: metric(dto.productQuantity),
|
||||
volumeWeight: metric(dto.volumeWeightKg),
|
||||
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
|
||||
portAta: formatOverseasDateTime(dto.actualArrivalAt),
|
||||
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type CustomerVisitRow,
|
||||
} from '../api/customerVisits'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<CustomerVisitRow[]>([])
|
||||
@@ -188,7 +189,7 @@ onMounted(load)
|
||||
<div class="inbound-options">
|
||||
<label v-for="option in detail.inboundOrders" :key="option.inboundOrderId" class="inbound-option" :class="{ selected: form.selectedInboundOrderId === option.inboundOrderId }">
|
||||
<el-radio v-model="form.selectedInboundOrderId" :value="option.inboundOrderId"><strong>{{ option.inboundCode }}</strong></el-radio>
|
||||
<span>尾程单:{{ option.lastMileOrderNo }}</span><span>目的仓:{{ option.destinationWarehouseCode || option.destinationWarehouseName || '—' }}</span><span>箱数:{{ option.packageCount }}</span>
|
||||
<span class="visit-order-reference">尾程单:<OrderReferenceText :value="option.lastMileOrderNo" /></span><span>目的仓:{{ option.destinationWarehouseCode || option.destinationWarehouseName || '—' }}</span><span>箱数:{{ option.packageCount }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -216,6 +217,7 @@ onMounted(load)
|
||||
.visit-order-summary div { display: grid; gap: 5px; } .visit-order-summary span { color: var(--cc-text-secondary); } .visit-order-summary strong { color: var(--cc-text-primary); }
|
||||
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; width: 100%; }
|
||||
.inbound-option { display: grid; gap: 6px; padding: 12px; border: 1px solid var(--cc-border-color); border-radius: 8px; cursor: pointer; color: var(--cc-text-secondary); }
|
||||
.visit-order-reference { display: flex; min-width: 0; align-items: baseline; gap: 3px; }
|
||||
.inbound-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.file-picker { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; color: var(--el-color-primary); border: 1px solid var(--el-color-primary); border-radius: 6px; cursor: pointer; }
|
||||
.file-picker input { display: none; } .file-picker.disabled { opacity: .6; cursor: not-allowed; } .file-tip { margin-left: 12px; color: var(--cc-text-secondary); }
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
|
||||
const rows = ref<ExceptionOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -73,7 +74,7 @@ onMounted(load)
|
||||
<template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" 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-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
|
||||
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo">尾程 {{ row.lastMileOrderNo }}</span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
|
||||
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo" class="related-order-reference">尾程 <OrderReferenceText :value="String(row.lastMileOrderNo)" /></span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
@@ -87,13 +88,13 @@ onMounted(load)
|
||||
<el-form-item label="箱数"><el-input-number v-model="form.packageCount" :min="1" :max="relation?.availablePackages || 999999" /></el-form-item>
|
||||
<el-form-item label="SKU(可自由填写)"><el-input v-model="form.skuCode" maxlength="128" /></el-form-item>
|
||||
</div><el-form-item label="异常原因" required><el-input v-model="form.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
|
||||
<el-alert v-if="relation" :title="`已关联:${relation.lastMileOrderNo || '无尾程单'} / ${relation.inboundCode || '无入仓单'},客户 ${relation.customerName || relation.customerId}`" type="success" :closable="false" />
|
||||
<el-alert v-if="relation" type="success" :closable="false"><template #title><span class="relation-alert-title">已关联:<OrderReferenceText :value="relation.lastMileOrderNo || '无尾程单'" /> / {{ relation.inboundCode || '无入仓单' }},客户 {{ relation.customerName || relation.customerId }}</span></template></el-alert>
|
||||
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.related-orders { display: grid; gap: 2px; font-size: 12px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
|
||||
.related-orders { display: grid; gap: 2px; font-size: 12px; }.related-order-reference,.relation-alert-title { display: flex; min-width: 0; align-items: baseline; gap: 3px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
|
||||
@media (max-width: 720px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createLastMileOrderFee, getLastMileOrder, type LastMileOrderDetailDto }
|
||||
import { formatOverseasDateTime, lastMileOrderTypeLabel, lastMileStatusLabel } 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 }
|
||||
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
|
||||
@@ -150,6 +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>
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { convertLastMileOrderToInboundPlan, listLastMileOrders } from '../api/lastMileOrders'
|
||||
import LastMileOrdersView from './LastMileOrdersView.vue'
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('../api/lastMileOrders', () => ({
|
||||
listLastMileOrders: vi.fn(),
|
||||
convertLastMileOrderToInboundPlan: vi.fn(),
|
||||
transferLastMileOrders: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size }, clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
function selectedRow(customerId = '2092618717861048321') {
|
||||
return { id: '2092873463568596994', customerId, orderNo: 'LMO-260827-0001' }
|
||||
}
|
||||
|
||||
async function mountView() {
|
||||
const wrapper = mount(LastMileOrdersView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function selectAndConvert(wrapper: Awaited<ReturnType<typeof mountView>>, row = selectedRow()) {
|
||||
wrapper.findComponent(DataTable).vm.$emit('selection-change', [row])
|
||||
await wrapper.vm.$nextTick()
|
||||
const button = wrapper.findAll('button').find((item) => item.text().includes('生成入库计划'))
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('LastMileOrdersView inbound-plan conversion', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', memoryStorage())
|
||||
vi.mocked(listLastMileOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('submits the selected long customer id without coercing it to a number', async () => {
|
||||
const wrapper = await mountView()
|
||||
await selectAndConvert(wrapper)
|
||||
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({
|
||||
orderId: '2092873463568596994', customerId: '2092618717861048321',
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes the list and displays INBOUND_PLANNED after a successful conversion', async () => {
|
||||
const base = {
|
||||
id: '2092873463568596994', customerId: '2092618717861048321', customerName: '华远贸易',
|
||||
orderNo: 'LMO-260827-0001', orderType: 'FIRST_LEG',
|
||||
}
|
||||
vi.mocked(listLastMileOrders)
|
||||
.mockResolvedValueOnce({ records: [{ ...base, currentStatus: 'PENDING_ASSIGNMENT' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
.mockResolvedValueOnce({ records: [{ ...base, currentStatus: 'INBOUND_PLANNED', inboundOrderNo: 'IP-260827-0001' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = await mountView()
|
||||
expect(wrapper.text()).toContain('待分配')
|
||||
await selectAndConvert(wrapper)
|
||||
expect(listLastMileOrders).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('已生成入库计划')
|
||||
expect(wrapper.text()).toContain('IP-260827-0001')
|
||||
})
|
||||
|
||||
it('fails closed when the selected row has no customer scope', async () => {
|
||||
const wrapper = await mountView()
|
||||
await selectAndConvert(wrapper, selectedRow(''))
|
||||
expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('所选尾程订单缺少客户标识')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['A0404', 'Inbound order not found'],
|
||||
['A0409', 'Inbound order version conflict'],
|
||||
])('keeps %s request metadata visible after conversion failure', async (code, message) => {
|
||||
vi.mocked(convertLastMileOrderToInboundPlan).mockRejectedValueOnce(new ApiResponseError(
|
||||
message, code, 200, undefined, `request-${code}`, `trace-${code}`,
|
||||
))
|
||||
const wrapper = await mountView()
|
||||
await selectAndConvert(wrapper)
|
||||
expect(wrapper.text()).toContain(code)
|
||||
expect(wrapper.text()).toContain(`request-${code}`)
|
||||
expect(wrapper.text()).toContain(`trace-${code}`)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { convertLastMileOrderToInboundPlan, listLastMileOrders, transferLastMileOrders } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { toLastMileOrderListItem } from '../lastMilePresentation'
|
||||
@@ -60,7 +61,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
|
||||
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
|
||||
{ key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 },
|
||||
{ key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
|
||||
@@ -153,9 +154,15 @@ async function submitTransfer() {
|
||||
}
|
||||
async function convertInboundPlan() {
|
||||
if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
|
||||
const selected = selectedRows.value[0]
|
||||
const customerId = typeof selected.customerId === 'string' ? selected.customerId.trim() : ''
|
||||
if (!customerId) {
|
||||
actionError.value = { message: '所选尾程订单缺少客户标识,无法安全生成入库计划' }
|
||||
return
|
||||
}
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await convertLastMileOrderToInboundPlan({ orderId: selectedRows.value[0].id })
|
||||
await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId })
|
||||
selectedRows.value = []; ElMessage.success('入库计划生成成功'); await load()
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
@@ -185,9 +192,13 @@ onMounted(() => { void load() })
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
</template>
|
||||
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
|
||||
<template #details><ApiErrorMeta :details="actionError" /></template>
|
||||
</LoadErrorState>
|
||||
<section v-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></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 #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { getInboundPlan } from '../api/inboundPlans'
|
||||
import { listInventoryLedger } from '../api/inventory'
|
||||
import { getLastMileOrder } from '../api/lastMileOrders'
|
||||
import InboundOrderDetailView from './InboundOrderDetailView.vue'
|
||||
import InventoryTransactionsView from './InventoryTransactionsView.vue'
|
||||
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
|
||||
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn() }))
|
||||
vi.mock('../api/inventory', () => ({ listInventoryLedger: vi.fn() }))
|
||||
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
|
||||
@@ -22,7 +26,9 @@ describe('overseas warehouse mounted real API pages', () => {
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ONT8', destinationWarehouseName: '洛杉矶仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
|
||||
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', destinationWarehouseCode: 'LAX', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
|
||||
})
|
||||
it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') })
|
||||
it('mounts inventory ledger as read-only facts', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') })
|
||||
it('renders the backend owner and complete short order number in last-mile detail', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001') })
|
||||
})
|
||||
|
||||
@@ -19,11 +19,11 @@ export interface LastMileOrderListItem {
|
||||
containerStatus: string
|
||||
abnormal: string
|
||||
held: string
|
||||
pallets: number
|
||||
cartons: number
|
||||
volume: number
|
||||
quantity: number
|
||||
volumeWeight: number
|
||||
pallets: number | '—'
|
||||
cartons: number | '—'
|
||||
volume: number | '—'
|
||||
quantity: number | '—'
|
||||
volumeWeight: number | '—'
|
||||
portEta: string
|
||||
portAta: string
|
||||
warehouseEta: string
|
||||
|
||||
Reference in New Issue
Block a user