feat: add tail order controls and terminal drayage UI
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
@@ -84,12 +84,29 @@ describe('last-mile order overseas API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the inbound-plan conversion endpoint with the exact selected customer scope', async () => {
|
||||
it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => {
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
|
||||
await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099', customerId: '190000000000000001' })
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', {
|
||||
orderId: '190000000000000099', customerId: '190000000000000001',
|
||||
})
|
||||
const payload = { orderId: '190000000000000099', customerId: '190000000000000001', expectedOrderVersion: 4,
|
||||
plannedWarehouseId: '193000000000200003', plannedWarehouseCode: 'US-LAX-01', plannedWarehouseName: '洛杉矶仓',
|
||||
plannedArrivalDate: '2026-09-08', inboundCode: 'NYEXX2609060063', palletCount: 8, palletType: 'STANDARD' as const,
|
||||
assigned: false, mergeRequired: false, urgent: true, remark: '加急' }
|
||||
await createManualInboundPlan(payload, 'plan-key-1')
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/manual', payload,
|
||||
{ headers: { 'Idempotency-Key': 'plan-key-1' } })
|
||||
})
|
||||
|
||||
it('uses versioned idempotent order hold and complete manual-node snapshot endpoints', async () => {
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: {} })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { nodes: [] } })
|
||||
await updateLastMileOrderHold('190000000000000099', { held: true, chargeable: true, reason: '海关查验', expectedVersion: 4 }, 'hold-key-1')
|
||||
expect(overseasHttp.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/hold',
|
||||
{ held: true, chargeable: true, reason: '海关查验', expectedVersion: 4 }, { headers: { 'Idempotency-Key': 'hold-key-1' } })
|
||||
await getLastMileOrderManualNodes('190000000000000099')
|
||||
expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes')
|
||||
const nodes = [{ nodeCode: 'ESTIMATED_ARRIVAL' as const, nodeAt: '2026-09-10T10:00:00' }]
|
||||
await updateLastMileOrderManualNodes('190000000000000099', 5, nodes, 'node-key-1')
|
||||
expect(overseasHttp.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes',
|
||||
{ expectedVersion: 5, nodes }, { headers: { 'Idempotency-Key': 'node-key-1' } })
|
||||
})
|
||||
|
||||
it('uses one atomic container request without looping individual conversions', async () => {
|
||||
|
||||
@@ -61,6 +61,11 @@ export interface LastMileOrderSummaryDto {
|
||||
containerStatus?: string | null
|
||||
abnormal?: boolean | null
|
||||
detained?: boolean | null
|
||||
holdChargeable?: boolean | null
|
||||
holdReason?: string | null
|
||||
manualOverrideEnabled?: boolean | null
|
||||
nodeSource?: 'CONTAINER' | 'MANUAL' | null
|
||||
version?: number | null
|
||||
palletCount?: number | null
|
||||
packageCount?: number | null
|
||||
volumeCbm?: number | null
|
||||
@@ -155,11 +160,6 @@ export interface TransferDestinationPayload {
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface ConvertInboundPlanPayload {
|
||||
orderId: string
|
||||
customerId: string
|
||||
}
|
||||
|
||||
export interface ContainerPlanProgressQuery {
|
||||
page: number
|
||||
size: number
|
||||
@@ -210,6 +210,60 @@ export interface ConvertContainerInboundPlanPayload {
|
||||
sourceContainerBookingId: string
|
||||
}
|
||||
|
||||
export type ManualNodeCode =
|
||||
| 'DOMESTIC_CUSTOMS_COMPLETED'
|
||||
| 'ESTIMATED_DEPARTURE'
|
||||
| 'ACTUAL_DEPARTURE'
|
||||
| 'ESTIMATED_ARRIVAL'
|
||||
| 'OVERSEAS_CUSTOMS_COMPLETED'
|
||||
| 'ACTUAL_ARRIVAL_WAITING_UNLOAD'
|
||||
| 'PICKUP_APPOINTED'
|
||||
| 'UNLOADED'
|
||||
|
||||
export interface ManualInboundPlanPayload {
|
||||
customerId: string
|
||||
orderId: string
|
||||
expectedOrderVersion: number
|
||||
plannedWarehouseId: string
|
||||
plannedWarehouseCode: string
|
||||
plannedWarehouseName: string
|
||||
plannedArrivalDate: string
|
||||
inboundCode: string
|
||||
palletCount: number
|
||||
palletType: 'STANDARD' | 'WOOD' | 'PAPER'
|
||||
assigned: boolean
|
||||
mergeRequired: boolean
|
||||
urgent: boolean
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface HoldLastMileOrderPayload {
|
||||
held: boolean
|
||||
chargeable: boolean
|
||||
reason: string
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
export interface HoldLastMileOrderResponse {
|
||||
orderId: string
|
||||
held: boolean
|
||||
chargeable: boolean
|
||||
reason?: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface ManualNodeValue {
|
||||
nodeCode: ManualNodeCode
|
||||
nodeAt: string | null
|
||||
}
|
||||
|
||||
export interface ManualNodesResponse {
|
||||
orderId: string
|
||||
manualOverrideEnabled: boolean
|
||||
nodes: Array<ManualNodeValue & { source: 'MANUAL' }>
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface ChangeContainerReceivingWarehousePayload {
|
||||
overseasWarehouseId: string
|
||||
expectedVersion: number
|
||||
@@ -218,6 +272,7 @@ export interface ChangeContainerReceivingWarehousePayload {
|
||||
|
||||
export interface LastMileOrderDetailDto {
|
||||
order: LastMileOrderDto
|
||||
manualNodes?: ManualNodesResponse['nodes']
|
||||
cargo: LastMileCargoDto[]
|
||||
statusHistory: LastMileStatusHistoryDto[]
|
||||
fees: LastMileFeeDto[]
|
||||
@@ -276,13 +331,6 @@ export async function transferLastMileOrders(payload: TransferDestinationPayload
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundPlanPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
'/platform/v1/last-leg/inbound-plans/convert', payload,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listContainerPlanProgress(query: ContainerPlanProgressQuery): Promise<OverseasPageResult<ContainerPlanProgressSummaryDto>> {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>, ApiEnvelope<OverseasPagePayload<ContainerPlanProgressSummaryDto>>>(
|
||||
'/platform/v1/last-leg/orders/container-summaries',
|
||||
@@ -298,6 +346,43 @@ export async function getContainerPlanProgress(sourceContainerBookingId: string)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createManualInboundPlan(payload: ManualInboundPlanPayload, idempotencyKey: string) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(
|
||||
'/platform/v1/last-leg/inbound-plans/manual', payload,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload,
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getLastMileOrderManualNodes(orderId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ManualNodesResponse>, ApiEnvelope<ManualNodesResponse>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updateLastMileOrderManualNodes(
|
||||
orderId: string,
|
||||
expectedVersion: number,
|
||||
nodes: ManualNodeValue[],
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<ManualNodesResponse>, ApiEnvelope<ManualNodesResponse>>(
|
||||
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes`,
|
||||
{ expectedVersion, nodes },
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function changeContainerReceivingWarehouse(
|
||||
sourceContainerBookingId: string,
|
||||
payload: ChangeContainerReceivingWarehousePayload,
|
||||
|
||||
@@ -113,6 +113,11 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
containerStatus: text(dto.containerStatus),
|
||||
abnormal: yesNo(dto.abnormal),
|
||||
held: yesNo(dto.detained),
|
||||
detained: Boolean(dto.detained),
|
||||
holdChargeable: Boolean(dto.holdChargeable),
|
||||
holdReason: String(dto.holdReason ?? '').trim(),
|
||||
manualNodeOverride: Boolean(dto.manualOverrideEnabled),
|
||||
version: Number(dto.version ?? 0),
|
||||
pallets: metric(dto.palletCount),
|
||||
cartons: metric(dto.packageCount),
|
||||
volume: metric(dto.volumeCbm),
|
||||
|
||||
@@ -8,7 +8,7 @@ const component = (name: string) => readFileSync(resolve(process.cwd(), 'src/mod
|
||||
describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
it('keeps action columns first and wide enough for every visible action', () => {
|
||||
const widths: Record<string, number> = {
|
||||
'LastMileOrdersView.vue': 110,
|
||||
'LastMileOrdersView.vue': 178,
|
||||
'InboundOrdersView.vue': 260,
|
||||
'DispatchesView.vue': 270,
|
||||
'OutboundsView.vue': 180,
|
||||
|
||||
@@ -11,12 +11,15 @@ import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import {
|
||||
changeContainerReceivingWarehouse,
|
||||
createManualInboundPlan,
|
||||
convertLastMileContainerToInboundPlans,
|
||||
convertLastMileOrderToInboundPlan,
|
||||
getContainerPlanProgress,
|
||||
listContainerPlanProgress,
|
||||
listLastMileOrders,
|
||||
transferLastMileOrders,
|
||||
getLastMileOrderManualNodes,
|
||||
updateLastMileOrderHold,
|
||||
updateLastMileOrderManualNodes,
|
||||
} from '../api/lastMileOrders'
|
||||
import LastMileOrdersView from './LastMileOrdersView.vue'
|
||||
|
||||
@@ -26,7 +29,8 @@ vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/lastMileOrders', () => ({
|
||||
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
|
||||
changeContainerReceivingWarehouse: vi.fn(),
|
||||
convertLastMileOrderToInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), transferLastMileOrders: vi.fn(),
|
||||
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
|
||||
downloadLastMileOrders: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -121,7 +125,10 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
summary,
|
||||
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
|
||||
})
|
||||
vi.mocked(convertLastMileOrderToInboundPlan).mockReset().mockResolvedValue({})
|
||||
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(getLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: false, nodes: [], version: 0 })
|
||||
vi.mocked(updateLastMileOrderHold).mockReset().mockResolvedValue({ orderId: '2092873463568596994', held: true, chargeable: true, version: 1 })
|
||||
vi.mocked(updateLastMileOrderManualNodes).mockReset().mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [], version: 1 })
|
||||
vi.mocked(convertLastMileContainerToInboundPlans).mockReset().mockResolvedValue([{ planNo: 'LIP-1' }, { planNo: 'LIP-2' }] as never)
|
||||
vi.mocked(changeContainerReceivingWarehouse).mockReset().mockResolvedValue({ ...summary, overseasWarehouseId: '9007199254740996', overseasWarehouseCode: 'LAX02', overseasWarehouseName: '洛杉矶二号接柜仓', assignmentVersion: 8 })
|
||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([fbaWarehouse] as never)
|
||||
@@ -180,13 +187,15 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(wrapper.text()).not.toContain('尾程订单加载失败')
|
||||
})
|
||||
|
||||
it('keeps single-order conversion isolated with long order and customer ids', async () => {
|
||||
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow()])
|
||||
await button(wrapper, '生成入库计划')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledTimes(1)
|
||||
expect(convertLastMileOrderToInboundPlan).toHaveBeenCalledWith({ orderId: '2092873463568596994', customerId })
|
||||
expect(document.body.textContent).toContain('计划入库仓')
|
||||
expect(document.body.textContent).toContain('预计到仓日期')
|
||||
expect(document.body.textContent).toContain('托盘类型')
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
|
||||
expect(listContainerPlanProgress).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -211,7 +220,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
await flushPromises()
|
||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledTimes(1)
|
||||
expect(convertLastMileContainerToInboundPlans).toHaveBeenCalledWith({ sourceContainerBookingId: bookingId })
|
||||
expect(convertLastMileOrderToInboundPlan).not.toHaveBeenCalled()
|
||||
expect(createManualInboundPlan).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('已按柜生成 2 个独立入库计划')
|
||||
expect(listLastMileOrders).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
@@ -435,7 +444,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(source).toContain('.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
|
||||
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }')
|
||||
expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }')
|
||||
expect(source).toContain("{ key: 'actions', label: '操作', width: 110, fixed: 'left'")
|
||||
expect(source).toContain("{ key: 'actions', label: '操作', width: 178, fixed: 'left'")
|
||||
})
|
||||
|
||||
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
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 { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, convertLastMileOrderToInboundPlan, downloadLastMileOrders, getContainerPlanProgress, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto } from '../api/lastMileOrders'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
@@ -40,6 +40,27 @@ const actionLoading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const actionTarget = ref<LastMileOrderListItem | null>(null)
|
||||
const inboundPlanOpen = ref(false)
|
||||
const inboundPlanWarehouseLoading = ref(false)
|
||||
const inboundPlanWarehouses = ref<TransferWarehouseOption[]>([])
|
||||
const inboundPlanForm = reactive({ warehouseId: '', plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'STANDARD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
const holdOpen = ref(false)
|
||||
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
|
||||
const manualNodesOpen = ref(false)
|
||||
const manualNodesLoading = ref(false)
|
||||
const manualNodesVersion = ref(0)
|
||||
const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [
|
||||
{ code: 'DOMESTIC_CUSTOMS_COMPLETED', label: '国内报关完成' },
|
||||
{ code: 'ESTIMATED_DEPARTURE', label: '预计开船' },
|
||||
{ code: 'ACTUAL_DEPARTURE', label: '实际开船' },
|
||||
{ code: 'ESTIMATED_ARRIVAL', label: '预计到港' },
|
||||
{ code: 'OVERSEAS_CUSTOMS_COMPLETED', label: '国外清关完成' },
|
||||
{ code: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', label: '实际到港等待卸船' },
|
||||
{ code: 'PICKUP_APPOINTED', label: '预约提柜' },
|
||||
{ code: 'UNLOADED', label: '卸柜' },
|
||||
]
|
||||
const manualNodeValues = reactive<Record<ManualNodeCode, string | null>>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record<ManualNodeCode, string | null>)
|
||||
const transferOpen = ref(false)
|
||||
const transferWarehouseLoading = ref(false)
|
||||
const transferWarehouseError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -136,7 +157,7 @@ function newReceivingWarehouseIdempotencyKey() {
|
||||
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 178, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
|
||||
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
|
||||
@@ -412,26 +433,94 @@ async function submitTransfer() {
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
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: '所选尾程订单缺少客户标识,无法安全生成入库计划' }
|
||||
function idempotencyKey(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
async function loadInboundPlanWarehouses() {
|
||||
inboundPlanWarehouseLoading.value = true
|
||||
try {
|
||||
inboundPlanWarehouses.value = (await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' })).map(toTransferWarehouseOption)
|
||||
const targetWarehouse = inboundPlanWarehouses.value.find((item) => actionTarget.value?.warehouse.includes(item.warehouseName))
|
||||
if (targetWarehouse) inboundPlanForm.warehouseId = targetWarehouse.warehouseId
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '入库仓候选加载失败,请重试')
|
||||
} finally {
|
||||
inboundPlanWarehouseLoading.value = false
|
||||
}
|
||||
}
|
||||
function openInboundPlan(source?: object) {
|
||||
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
|
||||
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
|
||||
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
|
||||
actionTarget.value = target
|
||||
Object.assign(inboundPlanForm, { warehouseId: '', plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: typeof target.pallets === 'number' && target.pallets > 0 ? target.pallets : 1, palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
|
||||
inboundPlanWarehouses.value = []
|
||||
actionError.value = null
|
||||
inboundPlanOpen.value = true
|
||||
void loadInboundPlanWarehouses()
|
||||
}
|
||||
async function submitInboundPlan() {
|
||||
const row = actionTarget.value
|
||||
const warehouse = inboundPlanWarehouses.value.find((item) => item.warehouseId === inboundPlanForm.warehouseId)
|
||||
if (!row || !warehouse || !inboundPlanForm.plannedArrivalDate || !inboundPlanForm.inboundCode.trim() || inboundPlanForm.palletCount < 1) {
|
||||
ElMessage.warning('请完整填写计划入库仓、预计到仓日期、入仓码和托数')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
actionError.value = null
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await convertLastMileOrderToInboundPlan({ orderId: String(selected.id), customerId })
|
||||
selectedRows.value = []
|
||||
ElMessage.success('入库计划生成成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
await createManualInboundPlan({
|
||||
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
|
||||
plannedWarehouseId: warehouse.warehouseId, plannedWarehouseCode: warehouse.warehouseCode,
|
||||
plannedWarehouseName: warehouse.warehouseName, plannedArrivalDate: inboundPlanForm.plannedArrivalDate,
|
||||
inboundCode: inboundPlanForm.inboundCode.trim(), palletCount: inboundPlanForm.palletCount,
|
||||
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
|
||||
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
|
||||
remark: inboundPlanForm.remark.trim() || undefined,
|
||||
}, idempotencyKey('manual-inbound-plan'))
|
||||
inboundPlanOpen.value = false; selectedRows.value = []; ElMessage.success('入库计划已生成'); await load()
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
function openHold(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
actionTarget.value = row
|
||||
Object.assign(holdForm, { held: !row.detained, chargeable: row.detained ? false : true, reason: '' })
|
||||
actionError.value = null
|
||||
holdOpen.value = true
|
||||
}
|
||||
async function submitHold() {
|
||||
const row = actionTarget.value
|
||||
if (!row || !holdForm.reason.trim()) { ElMessage.warning('请输入变更原因'); return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await updateLastMileOrderHold(row.id, { held: holdForm.held, chargeable: holdForm.chargeable, reason: holdForm.reason.trim(), expectedVersion: row.version }, idempotencyKey('last-mile-hold'))
|
||||
holdOpen.value = false; ElMessage.success(holdForm.held ? '订单已暂扣' : '订单已解除暂扣'); await load()
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
async function openManualNodes(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
actionTarget.value = row
|
||||
manualNodesOpen.value = true
|
||||
manualNodesLoading.value = true
|
||||
actionError.value = null
|
||||
manualNodeDefinitions.forEach(({ code }) => { manualNodeValues[code] = null })
|
||||
try {
|
||||
const result = await getLastMileOrderManualNodes(row.id)
|
||||
manualNodesVersion.value = result.version
|
||||
result.nodes.forEach((node) => { manualNodeValues[node.nodeCode] = node.nodeAt })
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点加载失败,请重试') }
|
||||
finally { manualNodesLoading.value = false }
|
||||
}
|
||||
async function submitManualNodes() {
|
||||
const row = actionTarget.value
|
||||
if (!row || !manualNodeDefinitions.some(({ code }) => Boolean(manualNodeValues[code]))) { ElMessage.warning('请至少填写一个节点时间'); return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await updateLastMileOrderManualNodes(row.id, manualNodesVersion.value, manualNodeDefinitions.map(({ code }) => ({ nodeCode: code, nodeAt: manualNodeValues[code] })), idempotencyKey('last-mile-manual-nodes'))
|
||||
manualNodesOpen.value = false; ElMessage.success('人工节点已保存,该订单不再继承柜级节点'); await load()
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '人工节点保存失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
async function loadContainerOptions(containerNo = '') {
|
||||
const request = ++containerOptionRequest
|
||||
@@ -552,8 +641,8 @@ onMounted(() => { void load() })
|
||||
</div>
|
||||
</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 || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
|
||||
<template #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 || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-dropdown v-if="access.can(permission.lastMileOrderTransfer) || access.can(permission.inboundOrderCreate)" trigger="click"><el-button size="small" plain>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" @click="openInboundPlan(row)">填写入库计划</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></section>
|
||||
@@ -626,10 +715,37 @@ onMounted(() => { void load() })
|
||||
</LoadErrorState>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :loading="inboundPlanWarehouseLoading" :confirm-loading="actionLoading" @confirm="submitInboundPlan">
|
||||
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
|
||||
<el-form class="command-form" label-position="top">
|
||||
<div class="command-form__grid"><el-form-item label="计划入库仓" required><el-select v-model="inboundPlanForm.warehouseId" filterable placeholder="请选择海外仓"><el-option v-for="warehouse in inboundPlanWarehouses" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" /></el-select></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码" required><el-input v-model="inboundPlanForm.inboundCode" maxlength="128" placeholder="请输入入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="1" :max="999" controls-position="right" placeholder="请输入托数" /></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
|
||||
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="holdOpen" :title="holdForm.held ? '暂扣订单' : '解除暂扣'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
|
||||
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
|
||||
<el-form class="command-form" label-position="top"><div class="command-form__grid"><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></div><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="manualNodesOpen" title="人工修改节点" width="min(700px, calc(100vw - 28px))" confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
|
||||
<div class="manual-node-order"><span>尾程单号</span><strong>{{ actionTarget?.orderNo || '—' }}</strong></div>
|
||||
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
|
||||
<div class="manual-node-table"><div class="manual-node-table__head"><span>节点</span><span>节点时间</span></div><div v-for="node in manualNodeDefinitions" :key="node.code" class="manual-node-table__row"><span>{{ node.label }}</span><el-date-picker v-model="manualNodeValues[node.code]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></div></div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.last-mile-toolbar { padding: 14px 16px 0; }
|
||||
.row-actions { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.command-form { margin-top: 16px; }
|
||||
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
|
||||
.command-form :deep(.el-select), .command-form :deep(.el-date-editor), .command-form :deep(.el-input-number) { width: 100%; }
|
||||
.manual-node-order { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); }
|
||||
.manual-node-table { max-height: 420px; margin-top: 14px; overflow: auto; border: 1px solid var(--cc-border); border-radius: 6px; }
|
||||
.manual-node-table__head, .manual-node-table__row { display: grid; grid-template-columns: 42% 58%; align-items: center; gap: 12px; padding: 10px 14px; }
|
||||
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: 13px; font-weight: 600; }
|
||||
.manual-node-table__row { border-top: 1px solid var(--cc-border); }
|
||||
.manual-node-table__row :deep(.el-date-editor) { width: 100%; }
|
||||
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
|
||||
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
|
||||
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
|
||||
|
||||
@@ -21,6 +21,11 @@ export interface LastMileOrderListItem {
|
||||
containerStatus: string
|
||||
abnormal: string
|
||||
held: string
|
||||
detained: boolean
|
||||
holdChargeable: boolean
|
||||
holdReason: string
|
||||
manualNodeOverride: boolean
|
||||
version: number
|
||||
pallets: number | '—'
|
||||
cartons: number | '—'
|
||||
volume: number | '—'
|
||||
|
||||
Reference in New Issue
Block a user