feat: complete phase 5 overseas fulfillment admin

This commit is contained in:
2026-09-01 14:23:53 +08:00
parent 96467b9468
commit e42d765f5c
15 changed files with 372 additions and 29 deletions
@@ -1,13 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet } from './dispatches'
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
describe('dispatch overseas API', () => {
beforeEach(() => {
vi.mocked(overseasHttp.get).mockReset()
vi.mocked(overseasHttp.post).mockReset()
vi.mocked(overseasHttp.put).mockReset()
vi.mocked(overseasHttp.delete).mockReset()
})
@@ -68,6 +69,23 @@ describe('dispatch overseas API', () => {
expect(overseasHttp.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
})
it('edits and cancels only by dispatchNo without customer scope or status mutation', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await updateDispatch('PC/1', { vehiclePlate: 'CA-100', route: 'LAX → ONT8' })
await cancelDispatch('PC/1')
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100', route: 'LAX → ONT8' })
expect(overseasHttp.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() }))
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('transfers to delivery only by dispatchNo without customerId', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', deliveryNo: 'DEL-1' } })
const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', currency: 'USD', amount: 10 }] }
await transferDispatchToDelivery('PC/1', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
})
it('loads drafts and preserves long container ids in create and delete paths', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 68, palletIds: [] } })
@@ -87,6 +87,19 @@ export interface DispatchSummaryDto {
items?: DispatchItemDto[]
}
export interface UpdateDispatchPayload {
vehiclePlate?: string | null
driverName?: string | null
driverPhone?: string | null
carrierName?: string | null
route?: string | null
departAt?: string | null
fbaAppointmentNo?: string | null
fbaAppointmentStatus?: string | null
vehicleAppointmentStatus?: string | null
estimatedArrivalAt?: string | null
}
export interface DispatchItemDto {
id: string
planPalletId: string
@@ -120,6 +133,20 @@ export async function getDispatch(dispatchNo: string) {
return unwrapApiResponse(response)
}
export async function updateDispatch(dispatchNo: string, payload: UpdateDispatchPayload) {
const response = await overseasHttp.put<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, payload,
)
return unwrapApiResponse(response)
}
export async function cancelDispatch(dispatchNo: string) {
const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
)
return unwrapApiResponse(response)
}
export async function listDispatchCandidates(query: DispatchCandidateQuery) {
const response = await overseasHttp.get<ApiEnvelope<DispatchPalletCandidateDto[]>, ApiEnvelope<DispatchPalletCandidateDto[]>>(
'/platform/v1/last-leg/dispatches/candidates',
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { cancelOutbound, createOneClickDropshipOutbound, getOutbound, listOutbounds } from './outbounds'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
describe('outbound overseas API', () => {
beforeEach(() => { vi.mocked(overseasHttp.get).mockReset(); vi.mocked(overseasHttp.post).mockReset() })
it('lists outbound orders with supported filters', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listOutbounds({ page: 1, size: 20, documentNo: 'OUT-1', warehouseId: 'WH-1', status: 'PICKED' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders', { params: { page: 1, size: 20, documentNo: 'OUT-1', warehouseId: 'WH-1', status: 'PICKED' } })
})
it('loads detail and cancels only by outboundNo without customerId', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { outbound: { id: '1', outboundNo: 'OUT/1', status: 'PICKED' }, delivery: null } })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: null })
await getOutbound('OUT/1')
await cancelOutbound('OUT/1', '运营取消')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/OUT%2F1')
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/OUT%2F1/cancel', { reason: '运营取消' })
})
it('keeps customerId explicit only for one-click dropship creation', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '1', outboundNo: 'OUT-1' } })
const payload = { recipient: 'John / 555 / LA', items: [{ inventoryPositionId: '99', packages: 2, sku: 'SKU-1' }], idempotencyKey: 'dropship-1' }
await createOneClickDropshipOutbound('customer-1', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId: 'customer-1' } })
})
})
@@ -1,18 +1,24 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
import type { DeliveryOrderRow } from './deliveryOrders'
export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; createdAt?: string | null }
export interface OutboundDetail { outbound: OutboundRow; delivery?: DeliveryOrderRow | null }
export interface OutboundQuery { page: number; size: number; documentNo?: string; warehouseId?: string; status?: string }
export async function listOutbounds(query: OutboundQuery) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<OutboundRow>>, ApiEnvelope<OverseasPagePayload<OutboundRow>>>('/platform/v1/last-leg/outbound-orders', { params: query })
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getOutbound(outboundNo: string) {
const response = await overseasHttp.get<ApiEnvelope<OutboundDetail>, ApiEnvelope<OutboundDetail>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`)
return unwrapApiResponse(response)
}
export async function createOneClickDropshipOutbound(customerId: string, payload: { recipient: string; items: Array<{ inventoryPositionId: string; packages: number; sku?: string }>; remark?: string; idempotencyKey: string }) {
const response = await overseasHttp.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId } })
return unwrapApiResponse(response)
}
export async function cancelOutbound(outboundNo: string, reason: string, customerId?: string) {
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason }, { params: customerId ? { customerId } : undefined })
export async function cancelOutbound(outboundNo: string, reason: string) {
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason })
return unwrapApiResponse(response)
}
@@ -4,10 +4,12 @@ import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listDispatches } from '../api/dispatches'
import { cancelDispatch, listDispatches, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DispatchesView from './DispatchesView.vue'
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn() }))
const canPermission = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
class ResizeObserverStub implements ResizeObserver {
observe() {}
@@ -44,6 +46,10 @@ describe('DispatchesView', () => {
}],
page: 1, size: 20, total: 1, pages: 1,
})
vi.mocked(updateDispatch).mockReset()
vi.mocked(cancelDispatch).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset()
canPermission.mockReset().mockReturnValue(true)
})
it('loads immediately and sends only supported page filters', async () => {
@@ -81,4 +87,21 @@ describe('DispatchesView', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('请确认海外尾程排车接口已发布')
})
it('gates edit, cancel and transfer with independent permissions and service statuses', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('permission.dispatchEdit')
expect(source).toContain('permission.dispatchCancel')
expect(source).toContain('permission.dispatchTransferDelivery')
expect(source).toContain("function isPendingDeparture(row: DispatchListItem) { return row.statusCode === 'PENDING_DEPARTURE' }")
expect(source).toContain('access.can(permission.dispatchEdit) && isPendingDeparture(row)')
expect(source).toContain('access.can(permission.dispatchTransferDelivery) && isPendingDeparture(row)')
expect(source).toContain('普通编辑不会修改排车状态')
expect(source).not.toContain('departDispatch')
canPermission.mockReturnValue(false)
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('转派送')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Truck } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { Eye, MoreHorizontal, Pencil, Truck, XCircle } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -11,12 +11,16 @@ 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 StatusBadge from '@/shared/components/StatusBadge.vue'
import { listDispatches, transferDispatchToDelivery, type DispatchSummaryDto } from '../api/dispatches'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, listDispatches, transferDispatchToDelivery, updateDispatch, type DispatchSummaryDto } from '../api/dispatches'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
const router = useRouter()
const access = useAccessPreviewStore()
const rows = ref<DispatchListItem[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -25,6 +29,10 @@ const pageSize = ref(10)
const total = ref(0)
const filters = ref<QueryModel>({ dispatchNo: '', vehiclePlate: '', status: '', fbaAppointmentNo: '' })
const transferOpen = ref(false); const transferRow = ref<DispatchListItem | null>(null); const transferSaving = ref(false)
const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false); const editRow = ref<DispatchListItem | null>(null); const editSaving = ref(false)
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
const editForm = reactive({ vehiclePlate: '', driverName: '', driverPhone: '', carrierName: '', route: '', departAt: '', fbaAppointmentNo: '', fbaAppointmentStatus: '', vehicleAppointmentStatus: '', estimatedArrivalAt: '' })
const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, remark: '' })
const deliveryMethodOptions = [
@@ -66,7 +74,7 @@ const fields = computed<QueryField[]>(() => [
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 240, fixed: 'left', slotName: 'actions' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
@@ -123,6 +131,7 @@ function toListItem(dto: DispatchSummaryDto): DispatchListItem {
vehicleAppointment: text(dto.vehicleAppointmentStatus),
estimatedArrivalAt: formatOverseasDateTime(dto.estimatedArrivalAt),
status: statusLabel(dto.status),
statusCode: String(dto.status || ''),
palletCount: number(dto.palletCount),
packageCount: number(dto.packageCount),
createdAt: formatOverseasDateTime(dto.createdAt),
@@ -149,6 +158,8 @@ async function load(model: QueryModel = filters.value) {
status: String(filters.value.status || '').trim() || undefined,
fbaAppointmentNo: String(filters.value.fbaAppointmentNo || '').trim() || undefined,
})
dispatchSnapshots.clear()
result.records.forEach((item) => dispatchSnapshots.set(String(item.dispatchNo), item))
rows.value = result.records.map(toListItem)
total.value = result.total
} catch (error) {
@@ -163,9 +174,17 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(row: DispatchListItem) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
function openTransfer(row: DispatchListItem) { transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, remark: '' }); transferOpen.value = true }
function isPendingDeparture(row: DispatchListItem) { return row.statusCode === 'PENDING_DEPARTURE' }
function canEdit(row: DispatchListItem) { return access.can(permission.dispatchEdit) && isPendingDeparture(row) }
function canCancel(row: DispatchListItem) { return access.can(permission.dispatchCancel) && row.statusCode === 'PENDING_DEPARTURE' }
function canTransfer(row: DispatchListItem) { return access.can(permission.dispatchTransferDelivery) && isPendingDeparture(row) }
function openEdit(row: DispatchListItem) { if (!canEdit(row)) return; const source = dispatchSnapshots.get(row.dispatchNo); editRow.value = row; Object.assign(editForm, { vehiclePlate: source?.vehiclePlate || '', driverName: source?.driverName || '', driverPhone: source?.driverPhone || '', carrierName: source?.carrierName || '', route: source?.route || '', departAt: source?.departAt || '', fbaAppointmentNo: source?.fbaAppointmentNo || '', fbaAppointmentStatus: source?.fbaAppointmentStatus || '', vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '', estimatedArrivalAt: source?.estimatedArrivalAt || '' }); mutationError.value = null; editOpen.value = true }
function openTransfer(row: DispatchListItem) { if (!canTransfer(row)) return; transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, remark: '' }); mutationError.value = null; transferOpen.value = true }
async function submitEdit() { if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return; editSaving.value = true; mutationError.value = null; try { await updateDispatch(editRow.value.dispatchNo, { vehiclePlate: editForm.vehiclePlate.trim() || null, driverName: editForm.driverName.trim() || null, driverPhone: editForm.driverPhone.trim() || null, carrierName: editForm.carrierName.trim() || null, route: editForm.route.trim() || null, departAt: editForm.departAt || null, fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null, fbaAppointmentStatus: editForm.fbaAppointmentStatus || null, vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null, estimatedArrivalAt: editForm.estimatedArrivalAt || null }); editOpen.value = false; ElMessage.success('排车信息已保存'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试') } finally { editSaving.value = false } }
async function cancel(row: DispatchListItem) { if (!canCancel(row)) return; try { await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }) } catch { return } mutationError.value = null; try { await cancelDispatch(row.dispatchNo); ElMessage.success('排车单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试') } }
function handleMore(command: string, row: DispatchListItem) { if (command === 'edit') openEdit(row); else if (command === 'cancel') void cancel(row) }
async function submitTransfer() {
if (!transferRow.value) return
if (!transferRow.value || !canTransfer(transferRow.value)) return
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) { ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型'); return }
if (transferForm.deliveryFee == null) { ElMessage.warning('请填写派送费用'); return }
if (needsLoadingFee.value && transferForm.loadingFee == null) { ElMessage.warning('当前装车类型需要填写装车费'); return }
@@ -173,7 +192,7 @@ async function submitTransfer() {
const feeInputs = [['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'], ['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'], ['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用']] as const
const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, remark]) => ({ feeType, currency: 'USD', amount: Number(amount), remark }))
transferSaving.value = true
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单') } catch (error) { ElMessage.error(getApiErrorDetails(error, '转派送失败').message) } finally { transferSaving.value = false }
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试') } finally { transferSaving.value = false }
}
onMounted(load)
@@ -181,13 +200,15 @@ onMounted(load)
<template>
<AdminPage><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<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-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button v-if="canTransfer(row as DispatchListItem)" size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button><el-dropdown v-if="canEdit(row as DispatchListItem) || canCancel(row as DispatchListItem)" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"><el-button size="small" plain><MoreHorizontal :size="15" />更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canEdit(row as DispatchListItem)" command="edit"><Pencil :size="15" />编辑</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided><XCircle :size="15" />取消</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable></section>
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态。`" type="info" :closable="false" /><el-form label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form></AppDialog>
</template>
<style scoped>.transfer-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.transfer-grid :deep(.el-select),.transfer-grid :deep(.el-date-editor),.transfer-grid :deep(.el-input-number){width:100%}h3{margin:4px 0 12px}@media(max-width:900px){.transfer-grid{grid-template-columns:1fr}}</style>
<style scoped>.transfer-grid,.edit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.transfer-grid :deep(.el-select),.transfer-grid :deep(.el-date-editor),.transfer-grid :deep(.el-input-number),.edit-grid :deep(.el-select),.edit-grid :deep(.el-date-editor){width:100%}h3{margin:4px 0 12px}@media(max-width:900px){.transfer-grid,.edit-grid{grid-template-columns:1fr}}</style>
@@ -0,0 +1,39 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOutbound } from '../api/outbounds'
import OutboundDetailView from './OutboundDetailView.vue'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { outboundNo: 'OUT/1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/outbounds', () => ({ getOutbound: vi.fn(), cancelOutbound: vi.fn() }))
describe('OutboundDetailView', () => {
beforeEach(() => {
canPermission.mockReset().mockReturnValue(false)
vi.mocked(getOutbound).mockReset().mockResolvedValue({
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
delivery: { id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
})
})
it('loads by encoded business outbound number and remains read-only without cancel permission', async () => {
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getOutbound).toHaveBeenCalledWith('OUT/1')
expect(wrapper.text()).toContain('OUT/1')
expect(wrapper.text()).toContain('PC-1')
expect(wrapper.text()).toContain('DEL-1')
expect(wrapper.text()).not.toContain('取消出库单')
wrapper.unmount()
})
it('shows cancellation only for PICKED with the independent permission', async () => {
canPermission.mockImplementation((code: string) => code === 'platform:overseas-warehouse:outbound:cancel')
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('取消出库单')
wrapper.unmount()
})
})
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ArrowLeft, XCircle } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { cancelOutbound, getOutbound, type OutboundDetail } from '../api/outbounds'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
const outboundNo = computed(() => String(route.params.outboundNo || '').trim())
const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const statusLabels: Record<string, string> = { WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.status === 'PICKED')
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } }
function back() { void router.push({ name: 'overseas-outbounds' }) }
watch(outboundNo, () => { void load() }, { immediate: true })
</script>
<template>
<AdminPage><DetailPageLayout aria-label="出库单详情">
<template #header><div class="detail-heading"><el-button aria-label="返回出库单列表" @click="back"><ArrowLeft :size="16" />返回</el-button><div class="detail-title"><div><h1>出库单详情</h1><p>出库单号 {{ outboundNo }}</p></div><StatusBadge v-if="detail" :status="statusLabel(detail.outbound.status)" /></div><el-button v-if="canCancel" type="danger" plain :loading="cancelling" @click="cancel"><XCircle :size="16" />取消出库单</el-button></div></template>
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="出库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ show(detail.outbound.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section></template>
</DetailPageLayout></AdminPage>
</template>
<style scoped>.detail-heading{display:flex;min-width:0;align-items:center;gap:14px}.detail-title{display:flex;min-width:0;flex:1;align-items:center;justify-content:space-between;gap:12px}.detail-title h1{margin:0;font-size:20px}.detail-title p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.detail-section{padding:16px}.detail-section h2{margin:0 0 14px;font-size:16px}.detail-section+.detail-section{margin-top:16px}@media(max-width:1200px){.detail-section :deep(.el-descriptions__body .el-descriptions__table){table-layout:auto}}@media(max-width:720px){.detail-heading,.detail-title{align-items:flex-start;flex-direction:column}}</style>
@@ -0,0 +1,53 @@
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listOutbounds } from '../api/outbounds'
import OutboundsView from './OutboundsView.vue'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => true))
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn(), cancelOutbound: vi.fn(), createOneClickDropshipOutbound: vi.fn() }))
vi.mock('../api/inventory', () => ({ listInventoryBalances: vi.fn(), listInventoryPositions: vi.fn() }))
class ResizeObserverStub { observe() {} unobserve() {} disconnect() {} }
describe('OutboundsView permissions and navigation', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
vi.mocked(listOutbounds).mockReset().mockResolvedValue({ records: [{ id: '1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 1, packageCount: 2 }], page: 1, size: 20, total: 1, pages: 1 })
canPermission.mockReset().mockReturnValue(true)
routerPush.mockReset()
})
it('uses list access for detail and independent create/cancel permissions', async () => {
canPermission.mockImplementation((code: string) => code.endsWith(':list'))
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('详情')
expect(wrapper.text()).not.toContain('一件代发出库')
expect(wrapper.text()).not.toContain('取消')
expect(OutboundsView.__file).toContain('OutboundsView.vue')
wrapper.unmount()
})
it('shows one-click creation and cancellation only with their exact permissions', async () => {
canPermission.mockImplementation((code: string) => ['platform:overseas-warehouse:outbound:create', 'platform:overseas-warehouse:outbound:cancel'].includes(code))
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('一件代发出库')
expect(wrapper.text()).toContain('取消')
wrapper.unmount()
})
it('requires an explicit customer selection before choosing customer inventory', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
expect(source).toContain('v-model="selectedCustomerId"')
expect(source).toContain('availablePositionOptions')
expect(source).toContain('createOneClickDropshipOutbound(selectedCustomerId.value')
})
})
@@ -1,41 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Plus, XCircle } from '@lucide/vue'
import { computed, onMounted, ref } from 'vue'
import { Eye, Plus, XCircle } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { cancelOutbound, createOneClickDropshipOutbound, listOutbounds, type OutboundRow } from '../api/outbounds'
import { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ documentNo: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false)
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const router = useRouter(); const access = useAccessPreviewStore()
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ documentNo: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false)
const customerOptions = computed(() => {
const customers = new Map<string, string>()
positionOptions.value.forEach(({ balance }) => customers.set(balance.customerId, balance.customerName?.trim() || '客户名称待补充'))
return [...customers].map(([value, label]) => ({ value, label }))
})
const availablePositionOptions = computed(() => positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value))
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号' }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓' }, { key: 'status', type: 'select', label: '状态', options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }]
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
async function openCreate() { createOpen.value = true; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
async function submit() { const selected = positionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selected.balance.customerId, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } }
async function cancel(row: OutboundRow) { let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '取消出库单失败').message) } }
async function openCreate() { createOpen.value = true; selectedCustomerId.value = ''; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
function selectCustomer() { selectedPositionId.value = ''; quantity.value = undefined }
async function submit() { const selected = availablePositionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!selectedCustomerId.value || !recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请选择客户,填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } }
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.status === 'PICKED' }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
</script>
<template>
<AdminPage><ListPageLayout aria-label="出库单">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load" /></template>
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @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-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></template>
<template #actions="{ row }"><el-button v-if="row.status === 'PICKED'" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" />取消</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></template>
<template #actions="{ row }"><el-button size="small" type="primary" plain @click="openDetail(row as OutboundRow)"><Eye :size="15" />详情</el-button><el-button v-if="canCancel(row as OutboundRow)" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" />取消</el-button></template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog>
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="客户" required><el-select v-model="selectedCustomerId" :loading="inventoryLoading" filterable placeholder="请选择库存所属客户" @change="selectCustomer"><el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" :disabled="!selectedCustomerId" filterable placeholder="请先选择客户,再选择有可用库存的托盘"><el-option v-for="item in availablePositionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog>
</template>
@@ -0,0 +1,54 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
function source(name: string) { return readFileSync(resolve(process.cwd(), `src/modules/overseas-warehouse/pages/${name}`), 'utf8') }
function expectOperationColumnFirst(name: string) {
const value = source(name)
if (!value.includes("key: 'actions', label: '操作'")) return
expect(value).toMatch(/const columns:[\s\S]*?= \[\s*\{ key: 'actions', label: '操作'/)
}
describe('phase-five outbound, delivery, exception and visit behavior', () => {
it('keeps outbound detail and cancellation scoped only by outboundNo', () => {
const list = source('OutboundsView.vue')
const detail = source('OutboundDetailView.vue')
expect(list).toContain("params: { outboundNo: row.outboundNo }")
expect(list).toContain('access.can(permission.outboundCreate)')
expect(list).toContain('access.can(permission.outboundCancel)')
expect(detail).toContain('getOutbound(outboundNo.value)')
expect(detail).toContain('cancelOutbound(outboundNo.value, reason)')
expect(detail).not.toContain('customerId')
expect(list).toContain('createOneClickDropshipOutbound(selectedCustomerId.value')
expectOperationColumnFirst('OutboundsView.vue')
})
it('keeps delivery return and status operations on real APIs with independent permissions', () => {
const value = source('DeliveryOrdersView.vue')
expect(value).toContain('permission.deliveryStatus')
expect(value).toContain('permission.deliveryReturn')
expect(value).toContain('updateDeliveryOrderStatus')
expect(value).toContain('markDeliveryReturn')
expect(value).toContain('listDeliveryReturnInboundOptions')
expectOperationColumnFirst('DeliveryOrdersView.vue')
})
it('keeps exception creation and relation resolution on real APIs', () => {
const value = source('ExceptionOrdersView.vue')
expect(value).toContain('permission.exceptionOrderCreate')
expect(value).toContain('resolveExceptionRelation')
expect(value).toContain('createExceptionOrder')
expect(value).toContain('getApiErrorDetails')
expectOperationColumnFirst('ExceptionOrdersView.vue')
})
it('keeps customer visit updates and attachments behind edit permission', () => {
const value = source('CustomerVisitsView.vue')
expect(value).toContain('permission.customerVisitEdit')
expect(value).toContain('saveCustomerVisit')
expect(value).toContain('uploadCustomerVisitAttachment')
expect(value).toContain('getApiErrorDetails')
expectOperationColumnFirst('CustomerVisitsView.vue')
})
})
@@ -8,8 +8,12 @@ export const overseasWarehousePermissions = {
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
inboundOrderSmartDispatch: 'platform:overseas-warehouse:inbound-order:smart-dispatch',
dispatchList: 'platform:overseas-warehouse:dispatch:list',
dispatchEdit: 'platform:overseas-warehouse:dispatch:edit',
dispatchCancel: 'platform:overseas-warehouse:dispatch:cancel',
dispatchTransferDelivery: 'platform:overseas-warehouse:dispatch:transfer-delivery',
outboundList: 'platform:overseas-warehouse:outbound:list',
outboundCreate: 'platform:overseas-warehouse:outbound:create',
outboundCancel: 'platform:overseas-warehouse:outbound:cancel',
deliveryList: 'platform:overseas-warehouse:delivery:list',
exceptionOrderList: 'platform:overseas-warehouse:exception-order:list',
exceptionOrderCreate: 'platform:overseas-warehouse:exception-order:create',
@@ -12,6 +12,7 @@ describe('overseas warehouse routes', () => {
['overseas-warehouse/dispatches', permission.dispatchList],
['overseas-warehouse/dispatches/:dispatchNo', permission.dispatchList],
['overseas-warehouse/outbounds', permission.outboundList],
['overseas-warehouse/outbounds/:outboundNo', permission.outboundList],
['overseas-warehouse/delivery-orders', permission.deliveryList],
['overseas-warehouse/delivery-orders/:deliveryNo', permission.deliveryList],
['overseas-warehouse/exception-orders', permission.exceptionOrderList],
@@ -28,7 +29,7 @@ describe('overseas warehouse routes', () => {
it('keeps all details as formal parameterized pages', () => {
const details = overseasWarehouseRoutes.filter((route) => route.path.includes('/:'))
expect(details).toHaveLength(7)
expect(details).toHaveLength(8)
expect(details.every((route) => route.meta.showPageHeading === false)).toBe(true)
})
@@ -46,6 +47,8 @@ describe('overseas warehouse routes', () => {
permission.productCreate, permission.productEdit,
permission.exceptionOrderCreate, permission.deliveryReturn, permission.deliveryStatus,
permission.customerVisitEdit,
]).size).toBe(12)
permission.dispatchEdit, permission.dispatchCancel, permission.dispatchTransferDelivery,
permission.outboundCancel, permission.outboundCreate,
]).size).toBe(17)
})
})
+6
View File
@@ -44,6 +44,12 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/OutboundsView.vue'),
meta: { title: '出库单', description: '查询海外仓出库单并创建一件代发出库', permission: permission.outboundList },
},
{
path: 'overseas-warehouse/outbounds/:outboundNo',
name: 'overseas-outbound-detail',
component: () => import('./pages/OutboundDetailView.vue'),
meta: { title: '出库单详情', description: '查看海外仓出库单与关联派送信息', permission: permission.outboundList, showPageHeading: false },
},
{
path: 'overseas-warehouse/delivery-orders',
name: 'overseas-delivery-orders',
+1
View File
@@ -87,6 +87,7 @@ export interface DispatchListItem {
vehicleAppointment: string
estimatedArrivalAt: string
status: string
statusCode: string
palletCount: number
packageCount: number
createdAt: string