This commit is contained in:
2026-10-08 17:08:03 +08:00
parent cc37a42298
commit 7b0df1db37
44 changed files with 574 additions and 405 deletions
+2
View File
@@ -48,3 +48,5 @@
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
- 尾程订单、库存余额与进销存记录导出统一使用 `../downloads/` 的 `useExportTask` 创建 XLSX 任务;保留当前筛选和业务导出权限,并要求 `platform:download:list`。Lastleg 内部 Provider 契约为 `lastleg-exports-v1.yaml`,业务页面不再请求同步 CSV。
@@ -1,27 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadInventoryBalances, downloadInventoryLedger, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
describe('overseas inventory API', () => {
beforeEach(() => { vi.clearAllMocks() })
it('returns the binary response directly for inventory exports', async () => {
const balances = new Blob(['customerId\\r\\n10001\\r\\n'], { type: 'text/csv' })
const ledger = new Blob(['occurredAt\\r\\n'], { type: 'text/csv' })
vi.mocked(http.get).mockResolvedValueOnce(balances).mockResolvedValueOnce(ledger)
await expect(downloadInventoryBalances({ skuCode: 'SKU-1' })).resolves.toBe(balances)
await expect(downloadInventoryLedger({ inboundCode: 'IN-1' })).resolves.toBe(ledger)
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/export', {
params: { skuCode: 'SKU-1' }, responseType: 'blob',
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger/export', {
params: { inboundCode: 'IN-1' }, responseType: 'blob',
})
})
it('keeps skuCode and nullable location filters on real balance queries', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } })
@@ -117,9 +117,6 @@ export async function listInventoryBalances(query: InventoryBalanceQuery) {
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function downloadInventoryBalances(query: Partial<InventoryBalanceQuery>) {
return http.get<Blob, Blob>('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' })
}
export async function getInventorySummary(query: InventoryBalanceQuery) {
const response = await http.get<ApiEnvelope<InventoryDashboardDto>, ApiEnvelope<InventoryDashboardDto>>(
@@ -165,7 +162,3 @@ export async function listInventoryLedger(query: InventoryLedgerQuery) {
)
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function downloadInventoryLedger(query: Partial<InventoryLedgerQuery>) {
return http.get<Blob, Blob>('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' })
}
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { reviewCargoDiscard } from './lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
@@ -60,37 +60,8 @@ describe('last-mile order overseas API', () => {
expect(http.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(http.get).mockResolvedValue(blob)
await expect(downloadLastMileOrders({ orderNo: 'LM-1' })).resolves.toBe(blob)
expect(http.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(http.get).mockResolvedValue(blob)
await downloadLastMileOrders({ page: 1, size: 10, orderNo: '', createdFrom: undefined })
expect(http.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(http.get).mockResolvedValue(blob)
await expect(downloadLastMileOrders({})).rejects.toMatchObject({
code: 'S0500', message: '系统内部错误', requestId: 'req-1', traceId: 'trace-1',
})
})
it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
@@ -1,4 +1,4 @@
import { http, ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { InboundPlanDetailDto } from './inboundPlans'
@@ -394,28 +394,6 @@ export async function reviewDirectRelabelOrder(orderId: string, customerId: stri
return unwrapApiResponse(response)
}
export async function downloadLastMileOrders(query: Partial<LastMileOrderQuery>) {
const { page: _page, size: _size, ...filters } = query
const params = Object.fromEntries(
Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && value !== ''),
)
const blob = await http.get<Blob, Blob>('/platform/v1/last-leg/orders/export', { params, responseType: 'blob' })
if (!blob.type.toLowerCase().includes('json')) return blob
let error: Partial<ApiEnvelope<unknown>>
try {
error = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
} 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) {
const response = await http.post<ApiEnvelope<LastMileFeeDto>, ApiEnvelope<LastMileFeeDto>>(
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { ArrowLeft, Download } from '@lucide/vue'
@@ -8,9 +9,9 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import InboundCodeRelationshipDialog from '@/shared/components/InboundCodeRelationshipDialog.vue'
import { saveCsv } from '@/shared/downloadCsv'
import { downloadInventoryLedger, listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime, inventoryBusinessTypeLabel } from '../lastMilePresentation'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { InventoryTransactionListItem } from '../types'
import { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime, inventoryBusinessTypeLabel } from '../lastMilePresentation'; import { overseasWarehousePermissions as permission } from '../permissions'; import type { InventoryTransactionListItem } from '../types'
const submitExport = useExportTask()
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore(); const rows = ref<InventoryTransactionListItem[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const exportLoading = ref(false)
const relationshipDialogOpen = ref(false); const relationshipDialogInboundCode = ref('')
@@ -39,7 +40,7 @@ function text(value: unknown) { return String(value ?? '').trim() || '—' }
function openRelationship(row: InventoryTransactionListItem) { if (row.inboundCode === '—') return; relationshipDialogInboundCode.value = row.inboundCode; relationshipDialogOpen.value = true }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ page: page.value, size: pageSize.value, customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: text(item.customerName), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: inventoryBusinessTypeLabel(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundCode: text(item.inboundCode), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: '—', remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }; function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; await submitExport('OVERSEAS_INVENTORY_LEDGER', '进销存记录', { customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="进销存记录列表">
@@ -51,7 +52,7 @@ async function exportLedger() { exportLoading.value = true; try { const range =
<template #notices><template v-if="loadError"><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else><p class="read-only-boundary">本页面仅用于查询库存流水,不提供出库单跳转或库存变更操作。</p></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<InboundCodeRelationshipDialog dense v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:var(--admin-space-3) 2px 0;color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:1.5}</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { BookOpen, Download } from '@lucide/vue'
@@ -11,14 +12,14 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { downloadInventoryBalances, getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { saveCsv } from '@/shared/downloadCsv'
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
import { ElMessage } from 'element-plus'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { toInventoryListItem } from '../inventoryPresentation'
import { overseasWarehousePermissions } from '../permissions'
import type { InventoryListItem } from '../types'
const submitExport = useExportTask()
const router = useRouter()
const access = useAccessPreviewStore()
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
@@ -82,7 +83,7 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as InventoryListItem; void router.push({ name: 'overseas-inventory-detail', params: { inventoryId: row.id }, query: { overseasWarehouseId: row.warehouseId, inboundOrderNo: row.inboundOrderNo, inboundCode: row.inboundCode, skuCode: row.sku === '—' ? undefined : row.sku.split('、')[0] } }) }
async function exportBalances() { exportLoading.value = true; try { const q = query(); const { page: _page, size: _size, ...params } = q; saveCsv(await downloadInventoryBalances(params) as Blob, '库存余额.csv') } catch { ElMessage.error('库存导出失败,请重试') } finally { exportLoading.value = false } }
async function exportBalances() { exportLoading.value = true; try { const q = query(); const { page: _page, size: _size, ...params } = q; await submitExport('OVERSEAS_INVENTORY_BALANCE', '库存余额', params) } catch { ElMessage.error('库存导出失败,请重试') } finally { exportLoading.value = false } }
onMounted(() => { void loadWarehouses(); void load() })
</script>
@@ -97,7 +98,7 @@ onMounted(() => { void loadWarehouses(); void load() })
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport)" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
<template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></section>
@@ -1,3 +1,5 @@
const { submitExport } = vi.hoisted(() => ({ submitExport: vi.fn().mockResolvedValue(undefined) }))
vi.mock('@/modules/downloads', () => ({ useExportTask: () => submitExport }))
import ElementPlus, { ElMessageBox, ElMessage } from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
@@ -12,7 +14,6 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
import {
changeContainerReceivingWarehouse,
createManualInboundPlan,
downloadLastMileOrders,
convertLastMileContainerToInboundPlans,
getContainerInboundPlanPreview,
getContainerPlanProgress,
@@ -36,7 +37,6 @@ vi.mock('../api/lastMileOrders', () => ({
changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
downloadLastMileOrders: vi.fn(),
reviewCargoDiscard: vi.fn(),
}))
@@ -226,7 +226,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(false)
await button(wrapper, '导出订单')!.trigger('click')
await flushPromises()
expect(downloadLastMileOrders).toHaveBeenLastCalledWith(vi.mocked(listLastMileOrders).mock.lastCall![0])
expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0])
}
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '全部')!.trigger('click')
await flushPromises()
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizontal } from '@lucide/vue'
@@ -15,7 +16,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 RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders'
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
@@ -24,9 +25,8 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { lastMileStatusLabel, packageDifferenceText, toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
import { saveCsv } from '@/shared/downloadCsv'
const rows = ref<LastMileOrderListItem[]>([])
const submitExport = useExportTask()
const access = useAccessPreviewStore()
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -71,7 +71,7 @@ async function submitReturnPackingPlan() {
}
const actionLoading = ref(false)
const exportLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { saveCsv(await downloadLastMileOrders(orderQuery()) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = false } }
async function exportOrders() { exportLoading.value = true; try { await submitExport('LAST_MILE_ORDER_LIST', '尾程订单', orderQuery()) } 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)
@@ -1093,7 +1093,7 @@ onMounted(() => { void load() })
</div>
</section></template>
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }} · {{ application.quantity }} 件</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>