diff --git a/src/modules/overseas-warehouse/api/dispatches.ts b/src/modules/overseas-warehouse/api/dispatches.ts index 98b64059..0f434334 100644 --- a/src/modules/overseas-warehouse/api/dispatches.ts +++ b/src/modules/overseas-warehouse/api/dispatches.ts @@ -177,6 +177,7 @@ export interface DispatchPdaItemDto { destinationWarehouseCode?: string | null destinationWarehouseName?: string | null fbaWarehouseCode?: string | null + warehouseRouteLabel?: string | null scheduledPackages: number pickedPackages: number positions: DispatchPdaPositionDto[] diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index f56f36b6..1f338835 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { overseasHttp } from '@/shared/api/overseasHttp' -import { convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, setLastMileOrderDetained, setLastMileOrderPalletMergeRequired, transferLastMileOrders } from './lastMileOrders' +import { convertLastMileOrderToInboundPlan, downloadLastMileOrders, getLastMileOrder, listLastMileOrders, setLastMileOrderDetained, setLastMileOrderPalletMergeBatch, setLastMileOrderPalletMergeRequired, transferLastMileOrders } from './lastMileOrders' vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) @@ -47,6 +47,38 @@ describe('last-mile order overseas API', () => { expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] })) }) + it('returns the exported CSV blob without reading data twice', async () => { + const blob = new Blob(['orders'], { type: 'text/csv' }) + vi.mocked(overseasHttp.get).mockResolvedValue(blob) + + await expect(downloadLastMileOrders({ orderNo: 'LM-1' })).resolves.toBe(blob) + expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', { + params: { orderNo: 'LM-1' }, responseType: 'blob', + }) + }) + + it('exports all authorized orders without pagination or empty filters', async () => { + const blob = new Blob(['orders'], { type: 'text/csv' }) + vi.mocked(overseasHttp.get).mockResolvedValue(blob) + + await downloadLastMileOrders({ page: 1, size: 10, orderNo: '', createdFrom: undefined }) + + expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', { + params: {}, responseType: 'blob', + }) + }) + + it('rejects a JSON business error instead of saving it as CSV', async () => { + const blob = new Blob([JSON.stringify({ + code: 'S0500', message: '系统内部错误', data: null, requestId: 'req-1', traceId: 'trace-1', + })], { type: 'application/json' }) + vi.mocked(overseasHttp.get).mockResolvedValue(blob) + + await expect(downloadLastMileOrders({})).rejects.toMatchObject({ + code: 'S0500', message: '系统内部错误', requestId: 'req-1', traceId: 'trace-1', + }) + }) + it('uses the order hold endpoint with the required operation remark', async () => { vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: null }) await setLastMileOrderDetained('190000000000000099', { detained: true, remark: '等待质检确认' }) @@ -63,6 +95,14 @@ describe('last-mile order overseas API', () => { }) }) + it('marks selected orders as one visible pallet-merge group', async () => { + vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: null }) + await setLastMileOrderPalletMergeBatch(['11', '12'], true) + expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/pallet-merge-required/batch', { + orderIds: ['11', '12'], palletMergeRequired: true, + }) + }) + it('uses the inbound-plan conversion endpoint without customerId', async () => { vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} }) await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099' }) diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 0f181d14..6da9efbb 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -1,5 +1,5 @@ import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' export interface OverseasPageResult { records: T[] @@ -74,7 +74,7 @@ export interface LastMileOrderSummaryDto { palletMergeFlag?: string | null /** Optional grouping facts returned by newer last-leg services. */ palletMergeBatchId?: string | null - palletMergeOrderNos?: string[] | null + palletMergeInboundNos?: string[] | null currentStatus?: string | null privateRemark?: string | null customerRemark?: string | null @@ -188,8 +188,26 @@ export async function getLastMileOrder(orderId: string) { } export async function downloadLastMileOrders(query: Partial) { - const response = await overseasHttp.get('/platform/v1/last-leg/orders/export', { params: query, responseType: 'blob' }) - return response.data + const { page: _page, size: _size, ...filters } = query + const params = Object.fromEntries( + Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && value !== ''), + ) + const blob = await overseasHttp.get('/platform/v1/last-leg/orders/export', { params, responseType: 'blob' }) + if (!blob.type.toLowerCase().includes('json')) return blob + let error: Partial> + try { + error = JSON.parse(await blob.text()) as Partial> + } catch { + throw new ApiResponseError('导出接口返回了非 CSV 内容', 'INVALID_EXPORT_RESPONSE') + } + throw new ApiResponseError( + error.message || '尾程订单导出失败', + error.code || 'INVALID_EXPORT_RESPONSE', + undefined, + error.data, + error.requestId, + error.traceId, + ) } export async function createLastMileOrderFee(orderId: string, payload: CreateLastMileFeePayload) { @@ -226,3 +244,10 @@ export async function setLastMileOrderPalletMergeRequired(orderId: string, paylo ) return unwrapApiResponse(response) } + +export async function setLastMileOrderPalletMergeBatch(orderIds: string[], palletMergeRequired: boolean) { + const response = await overseasHttp.put, ApiEnvelope>( + '/platform/v1/last-leg/orders/pallet-merge-required/batch', { orderIds, palletMergeRequired }, + ) + return unwrapApiResponse(response) +} diff --git a/src/modules/overseas-warehouse/lastMilePresentation.test.ts b/src/modules/overseas-warehouse/lastMilePresentation.test.ts index f8aa73e5..3e951cc3 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.test.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.test.ts @@ -33,12 +33,12 @@ describe('last-mile order presentation adapter', () => { expect(row.orderType).toBe(expected) }) - it('shows the actual related order numbers returned after pallet merge', () => { + it('shows related inbound numbers instead of the shared tail-leg order number', () => { const row = toLastMileOrderListItem({ id: '11', customerId: '100', orderNo: 'LMO-1', palletMergeFlag: 'MERGED', - palletMergeBatchId: 'PMG-1', palletMergeOrderNos: ['LMO-2', 'LMO-3'], + palletMergeBatchId: 'PMG-1', palletMergeInboundNos: ['NYEXX2608280001', 'NYEXX2608280002'], }) - expect(row.palletMergeRelation).toBe('LMO-2、LMO-3') + expect(row.palletMergeRelation).toBe('NYEXX2608280001、NYEXX2608280002') }) }) diff --git a/src/modules/overseas-warehouse/lastMilePresentation.ts b/src/modules/overseas-warehouse/lastMilePresentation.ts index 591158f8..0f05e6f2 100644 --- a/src/modules/overseas-warehouse/lastMilePresentation.ts +++ b/src/modules/overseas-warehouse/lastMilePresentation.ts @@ -99,8 +99,8 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO relabelStatus: lastMileStatusLabel(dto.relabelStatus), consolidated: palletMergeLabel(dto.palletMergeFlag), palletMergeFlag: String(dto.palletMergeFlag ?? '').trim(), - palletMergeRelation: Array.isArray(dto.palletMergeOrderNos) && dto.palletMergeOrderNos.length - ? dto.palletMergeOrderNos.map((value) => text(value)).join('、') + palletMergeRelation: Array.isArray(dto.palletMergeInboundNos) && dto.palletMergeInboundNos.length + ? dto.palletMergeInboundNos.map((value) => text(value)).join('、') : dto.palletMergeBatchId ? `批次 ${text(dto.palletMergeBatchId)}` : '—', status: lastMileStatusLabel(dto.currentStatus), internalRemark: text(dto.privateRemark), diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue index b0cf39b6..ac67429d 100644 --- a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue @@ -187,7 +187,7 @@ onMounted(load)
任务号:{{ pdaTask.taskNo }} 状态:{{ dispatchStatus(pdaTask.status) }} 仓库:{{ text(pdaTask.warehouseId) }}
- +
拣货明细:{{ pickForm.itemId || '未选择' }}拣货并扣减库存
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 203f9e6c..61b1b5fa 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -12,12 +12,12 @@ 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 { convertLastMileOrderToInboundPlan, listLastMileOrders, setLastMileOrderDetained, transferLastMileOrders, downloadLastMileOrders } from '../api/lastMileOrders' +import { convertLastMileOrderToInboundPlan, listLastMileOrders, setLastMileOrderDetained, setLastMileOrderPalletMergeBatch, transferLastMileOrders, downloadLastMileOrders } from '../api/lastMileOrders' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasWarehousePermissions as permission } from '../permissions' import { toLastMileOrderListItem } from '../lastMilePresentation' import type { LastMileOrderListItem } from '../types' -import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets' +import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import { saveCsv } from '@/modules/overseas-master-data/downloadCsv' const rows = ref([]) @@ -29,16 +29,17 @@ const page = ref(1) const pageSize = ref(10) const total = ref(0) const selectedRows = ref([]) +const cancellableMergeCount = computed(() => selectedRows.value.filter((row) => row.palletMergeFlag === 'REQUIRED').length) 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, page: undefined, size: undefined, 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 { ElMessage.error('尾程订单导出失败,请重试') } finally { exportLoading.value = 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(null) const transferOpen = ref(false) const detainedOpen = ref(false) const detainedTarget = ref(null) const detainedRemark = ref('') const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' }) -const warehouses = ref([]) +const warehouses = ref([]) const statusTab = ref<'全部' | '待分配'>('全部') const filters = ref({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] }) const fields = computed(() => [ @@ -69,7 +70,8 @@ const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left' }, { key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 }, - { key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联订单', minWidth: 220, hideable: false }, + { key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false }, + { key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联入仓单', minWidth: 220, hideable: false }, { key: 'warning', prop: 'warning', label: '预警', width: 90 }, { key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 }, { key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 }, @@ -166,6 +168,32 @@ async function submitDetained() { } catch (error) { actionError.value = getApiErrorDetails(error, target.detained ? '解除暂扣失败,请重试' : '暂扣失败,请重试') } finally { actionLoading.value = false } } +async function batchPalletMerge() { + const targets = selectedRows.value.filter((row) => row.palletMergeFlag !== 'MERGED') + if (!targets.length) { ElMessage.warning('请选择至少 1 条未完成合托的尾程订单'); return } + try { + await ElMessageBox.confirm(`确定为选中的 ${targets.length} 条尾程订单标记合托吗?PDA 扫描对应箱唛时将显示合托提示。`, '批量合托标记', { type: 'warning', confirmButtonText: '确认标记', cancelButtonText: '返回' }) + } catch { return } + actionLoading.value = true; actionError.value = null + try { + await setLastMileOrderPalletMergeBatch(targets.map((row) => row.id), true) + selectedRows.value = []; ElMessage.success(`已标记 ${targets.length} 条订单合托`); await load() + } catch (error) { actionError.value = getApiErrorDetails(error, '批量合托标记失败,请重试') } + finally { actionLoading.value = false } +} +async function cancelBatchPalletMerge() { + const targets = selectedRows.value.filter((row) => row.palletMergeFlag === 'REQUIRED') + if (!targets.length) { ElMessage.warning('请选择至少 1 条待合托订单'); return } + try { + await ElMessageBox.confirm(`确定取消选中的 ${targets.length} 条订单合托标记吗?`, '取消合托标记', { type: 'warning', confirmButtonText: '确认取消', cancelButtonText: '返回' }) + } catch { return } + actionLoading.value = true; actionError.value = null + try { + await setLastMileOrderPalletMergeBatch(targets.map((row) => row.id), false) + selectedRows.value = []; ElMessage.success(`已取消 ${targets.length} 条订单合托标记`); await load() + } catch (error) { actionError.value = getApiErrorDetails(error, '取消合托标记失败,请重试') } + finally { actionLoading.value = false } +} async function submitTransfer() { if (!transferForm.warehouseId.trim() || !transferForm.warehouseCode.trim() || !transferForm.warehouseName.trim()) { ElMessage.warning('请填写目的仓 ID、代码和名称') @@ -194,7 +222,13 @@ function openDetail(source: object) { } onMounted(() => { void load(); void loadWarehouses() }) -async function loadWarehouses() { try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch { warehouses.value = [] } } +async function loadWarehouses() { + try { + warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' }) + } catch { + warehouses.value = [] + } +}
- +
- + diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts index 161cf81a..a76ba889 100644 --- a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts +++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts @@ -305,8 +305,17 @@ describe('overseas warehouse first-batch prototype contract', () => { expect(detail).not.toContain('ElMessage.success') }) + it('shows the transfer route label on PDA pallet picking rows', () => { + const detail = source('DispatchDetailView.vue') + expect(detail).toContain('label="转仓提示"') + expect(detail).toContain('row.warehouseRouteLabel') + expect(detail).toContain('type="warning"') + }) + it('preserves last-mile status tabs and read-only outbound association columns without links', () => { const value = source('LastMileOrdersView.vue') + expect(value).toContain("listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })") + expect(value).toContain('请选择头程 FBA 目的仓') expect(value).toContain('aria-label="尾程订单状态"') expect(value).toContain('全部') expect(value).toContain('待分配') @@ -327,7 +336,7 @@ describe('overseas warehouse first-batch prototype contract', () => { it('keeps every prototype list column in its frozen order', () => { expect(columnKeys('LastMileOrdersView.vue')).toEqual([ - 'actions', 'selection', 'orderNo', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse', + 'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse', 'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets', 'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',