Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeTripDetail, type TripDetail, type PickupTask, type PickupCandidate, type PickupEvidence, type PickupPlan, type TripAssignment, type TripResourceRelease } from '@/modules/overseas-master-data'
|
||||
|
||||
export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
|
||||
export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
|
||||
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[] }
|
||||
export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
|
||||
export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
|
||||
type WireDetail = Omit<TransportDetail, 'execution'> & { execution: Parameters<typeof normalizeTripDetail>[0] }
|
||||
const base = '/platform/v1/last-leg/dispatches/transport-tasks'
|
||||
const path = (number: string) => `${base}/${encodeURIComponent(number)}`
|
||||
async function get<T>(url: string, params?: object) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
|
||||
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
|
||||
const normalize = (value: WireDetail): TransportDetail => ({ ...value, execution: normalizeTripDetail(value.execution), pickups: value.pickups.map(row => ({ ...row, packageCount: Number(row.packageCount), actualPackages: row.actualPackages == null ? null : Number(row.actualPackages) })) })
|
||||
export const getTransport = (number: string) => get<WireDetail>(path(number)).then(normalize)
|
||||
export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: number | string }>(`${base}/pickup-candidates`, { page: 1, size: 100 })
|
||||
export const truckCandidates = () => get<TruckCandidate[]>(`${base}/truck-candidates`)
|
||||
export const createTransportPickup = (body: PickupPlan) => post<WireDetail>(`${base}/pickup`, body).then(normalize)
|
||||
export const createTransportTruck = (body: TruckPlan) => post<WireDetail>(`${base}/truck`, body).then(normalize)
|
||||
export const assignTransport = (number: string, body: TripAssignment) => post<WireDetail>(`${path(number)}/assign`, body).then(normalize)
|
||||
export const transportOccupancy = (number: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]) => resourceIds.length ? post<Array<{ resourceId: string; occupiedTripNo: string | null; available: boolean }>>(`${path(number)}/resource-occupancy`, { kind, resourceIds }) : Promise.resolve([])
|
||||
export const cancelTransport = (number: string, body: object) => post(`${path(number)}/cancel`, body)
|
||||
export const releaseTransport = (number: string, body: TripResourceRelease) => post(`${path(number)}/release`, body)
|
||||
export const commandTransport = (number: string, suffix: string, body: object) => post(`${path(number)}/${suffix}`, body)
|
||||
export async function uploadTransport(number: string, suffix: string, file: File) { const body = new FormData(); body.append('file', file); return post<PickupEvidence>(`${path(number)}/${suffix}/files`, body) }
|
||||
export const downloadTransportEvidence = (number: string, fileId: string, row?: PickupTask): Promise<Blob> => http.get<Blob, Blob>(`${path(number)}/${row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'}/files/${encodeURIComponent(fileId)}`, { responseType: 'blob' })
|
||||
@@ -1,28 +0,0 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TransferQuantitySummary from './TransferQuantitySummary.vue'
|
||||
import { getTransferQuantityTotals } from '../api/transferQuantityFacts'
|
||||
vi.mock('../api/transferQuantityFacts', () => ({ getTransferQuantityTotals: vi.fn() }))
|
||||
|
||||
describe('转运完整筛选数量', () => {
|
||||
it('keeps unknown pieces explicit and renders overlapping pallet facts after paging', async () => {
|
||||
vi.mocked(getTransferQuantityTotals).mockResolvedValue({ orderCount: '2', declaredPieceCount: null, knownDeclaredPieceCount: '16', unknownDeclaredOrderCount: '1', receivedPieceCount: '20', knownReceivedPieceCount: '20', unknownReceivedOrderCount: '0', declaredCartonCount: '4', receivedCartonCount: '4', receivedPalletCount: '3', availableCartonCount: '2', shippedCartonCount: '2', remainingPalletCount: '2', shippedPalletCount: '2' })
|
||||
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1, orderType: 'WAREHOUSE_TRANSFER' } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('未记录(已知 16 件,1 票未知)')
|
||||
expect(wrapper.text()).toContain('20 件 / 4 箱 / 3 托')
|
||||
expect(wrapper.text()).toContain('剩余 2 托')
|
||||
expect(wrapper.text()).toContain('2 箱 / 2 托(托盘可重叠)')
|
||||
await wrapper.setProps({ query: { page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('全筛选 2 票')
|
||||
expect(getTransferQuantityTotals).toHaveBeenLastCalledWith({ page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' })
|
||||
})
|
||||
it('shows a recoverable error instead of zero totals', async () => {
|
||||
vi.mocked(getTransferQuantityTotals).mockRejectedValue(new Error('offline'))
|
||||
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1 } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('加载失败')
|
||||
expect(wrapper.text()).not.toContain('全筛选 0')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { getTransferQuantityTotals, type TransferQuantityTotals } from '../api/transferQuantityFacts'
|
||||
import type { LastMileOrderQuery } from '../api/lastMileOrders'
|
||||
const props = defineProps<{ query: LastMileOrderQuery }>()
|
||||
const summary = ref<TransferQuantityTotals>()
|
||||
const error = ref('')
|
||||
let sequence = 0
|
||||
watch(() => JSON.stringify(props.query), async () => {
|
||||
const current = ++sequence
|
||||
summary.value = undefined; error.value = ''
|
||||
try { const value = await getTransferQuantityTotals(props.query); if (current === sequence) summary.value = value }
|
||||
catch { if (current === sequence) error.value = '转运全筛选数量加载失败,请重新查询' }
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="summary" role="status" aria-label="仓储转运全筛选汇总">
|
||||
<strong>仓储转运 · 全筛选 {{ summary.orderCount }} 票</strong> ·
|
||||
预报 {{ summary.declaredPieceCount ?? `未记录(已知 ${summary.knownDeclaredPieceCount} 件,${summary.unknownDeclaredOrderCount} 票未知)` }}{{ summary.declaredPieceCount == null ? '' : ' 件' }} / {{ summary.declaredCartonCount }} 箱 ·
|
||||
历史实收 {{ summary.receivedPieceCount ?? `未记录(已知 ${summary.knownReceivedPieceCount} 件,${summary.unknownReceivedOrderCount} 票未知)` }}{{ summary.receivedPieceCount == null ? '' : ' 件' }} / {{ summary.receivedCartonCount }} 箱 / {{ summary.receivedPalletCount }} 托 ·
|
||||
当前已清点 {{ summary.availableCartonCount }} 箱 · 剩余 {{ summary.remainingPalletCount }} 托 · 已出库 {{ summary.shippedCartonCount }} 箱 / {{ summary.shippedPalletCount }} 托(托盘可重叠)
|
||||
</div>
|
||||
<p v-else-if="error" role="alert">{{ error }}</p>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { listVehicles, listDrivers, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
|
||||
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
|
||||
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<TransportDetail | null>(null), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), vehicleId = ref(''), driverId = ref(''), reason = ref('')
|
||||
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
|
||||
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref(1), evidence = ref<PickupEvidence[]>([]), truckEvidence = ref<PickupEvidence[]>([])
|
||||
const releaseOpen = ref(false), releaseKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), endLocation = ref(''), actualEnd = ref('')
|
||||
const pending = ref<{ suffix: string; body: object; kind?: 'cancel' | 'release' } | null>(null)
|
||||
const trip = computed(() => detail.value?.execution.trip)
|
||||
const editable = computed(() => access.can(permission.EDIT))
|
||||
const locked = computed(() => busy.value || !!pending.value)
|
||||
async function load() {
|
||||
error.value = null
|
||||
try { detail.value = await getTransport(props.dispatchNo); vehicleId.value = trip.value?.vehicleId || ''; driverId.value = trip.value?.driverId || '' }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '运输任务加载失败') }
|
||||
}
|
||||
watch(() => props.dispatchNo, () => { detail.value = null; pending.value = null; void load() }, { immediate: true })
|
||||
/** 资源候选与占用分开校验;保存由后端锁定复验。 */
|
||||
async function candidates() {
|
||||
if (!trip.value || candidateBusy.value) return
|
||||
candidateBusy.value = true; error.value = null
|
||||
try {
|
||||
const [v, d] = await Promise.all([listVehicles({ page: 1, size: 100, region: 'OVERSEAS', status: 'IN_SERVICE' }), listDrivers({ page: 1, size: 100, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: trip.value.expectedDriverEndAt })])
|
||||
const [vo, dr] = await Promise.all([transportOccupancy(props.dispatchNo, 'VEHICLE', v.records.map(row => row.id)), transportOccupancy(props.dispatchNo, 'DRIVER', d.records.map(row => row.id))])
|
||||
occupancy.value = Object.fromEntries([...vo.map(row => [`V:${row.resourceId}`, row.available]), ...dr.map(row => [`D:${row.resourceId}`, row.available])]); vehicles.value = v.records; drivers.value = d.records
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '资源候选加载失败') }
|
||||
finally { candidateBusy.value = false }
|
||||
}
|
||||
async function assign() {
|
||||
if (!trip.value || locked.value || !vehicleId.value || !driverId.value) return
|
||||
busy.value = true; error.value = null
|
||||
try { detail.value = await assignTransport(props.dispatchNo, { recordVersion: trip.value.recordVersion, vehicleId: vehicleId.value, driverId: driverId.value, reason: reason.value.trim() || '分配运输车辆及司机' }); emit('saved'); ElMessage.success('车辆司机已分配') }
|
||||
catch (cause) { await load(); error.value = getApiErrorDetails(cause, '分配未完成') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
/** 每次业务命令保留原版本与幂等键,结果未知只重试原请求。 */
|
||||
async function execute() {
|
||||
if (!pending.value || busy.value) return
|
||||
const request = pending.value; busy.value = true; error.value = null
|
||||
try {
|
||||
if (request.kind === 'cancel') await cancelTransport(props.dispatchNo, request.body)
|
||||
else if (request.kind === 'release') await releaseTransport(props.dispatchNo, request.body as Parameters<typeof releaseTransport>[1])
|
||||
else await commandTransport(props.dispatchNo, request.suffix, request.body)
|
||||
pending.value = null; pickupOpen.value = false; releaseOpen.value = false; await load(); emit('saved'); ElMessage.success('运输操作已保存')
|
||||
} catch (cause) { if (cause instanceof ApiResponseError) { pending.value = null; await load() }; error.value = getApiErrorDetails(cause, '运输操作未完成') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function command(action: 'depart' | 'complete' | 'arrive' | 'cancel', row?: PickupTask) {
|
||||
if (!trip.value || locked.value) return
|
||||
const message = { depart: '确认车辆司机已出发执行运输任务?', complete: '确认运输已实际完成?', arrive: '确认货物已实际送达仓库?确认后仓库可开始收货。', cancel: '确认取消未发车排车并释放车辆、司机和原单安排?' }[action]
|
||||
try { await ElMessageBox.confirm(message, '确认运输操作', { customClass: 'admin-density-confirm' }) } catch { return }
|
||||
const prefix = detail.value?.taskType === 'PICKUP' ? 'pickup' : 'truck'
|
||||
const suffix = row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/${action}` : `${prefix}/${action}`
|
||||
pending.value = { suffix, kind: action === 'cancel' ? 'cancel' : undefined, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || message, ...(action === 'complete' && prefix === 'truck' ? { evidenceFileIds: truckEvidence.value.map(file => file.fileId) } : {}) } }
|
||||
await execute()
|
||||
}
|
||||
function editPickup(row: PickupTask) { selected.value = row; actual.value = Number(row.packageCount); evidence.value = []; pickupOpen.value = true }
|
||||
async function download(file: PickupEvidence, row?: PickupTask) {
|
||||
try { const blob = await downloadTransportEvidence(props.dispatchNo, file.fileId, row); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = file.fileName; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '凭证下载失败') }
|
||||
}
|
||||
async function upload(event: Event, pickup: boolean) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; if (!file || locked.value) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const row = selected.value, suffix = pickup && row ? `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}` : 'truck'
|
||||
const result = await uploadTransport(props.dispatchNo, suffix, file); (pickup ? evidence.value : truckEvidence.value).push(result)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '凭证上传失败') }
|
||||
finally { busy.value = false; input.value = '' }
|
||||
}
|
||||
async function picked() {
|
||||
if (!selected.value || !trip.value || !Number.isInteger(actual.value) || actual.value <= 0 || !evidence.value.length || locked.value) return
|
||||
const row = selected.value
|
||||
pending.value = { suffix: `pickup/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}/picked`, body: { recordVersion: trip.value.recordVersion, idempotencyKey: crypto.randomUUID(), actualPackages: actual.value, evidenceFileIds: evidence.value.map(file => file.fileId), remark: reason.value.trim() } }; await execute()
|
||||
}
|
||||
function openRelease(kind: 'VEHICLE' | 'DRIVER') { releaseKind.value = kind; endLocation.value = ''; actualEnd.value = ''; releaseOpen.value = true }
|
||||
async function release() {
|
||||
if (!trip.value || !actualEnd.value || !endLocation.value.trim() || locked.value) return
|
||||
pending.value = { suffix: 'release', kind: 'release', body: { recordVersion: trip.value.recordVersion, resourceKind: releaseKind.value, actualEndAt: actualEnd.value, endLocation: endLocation.value.trim(), vehicleStatus: releaseKind.value === 'VEHICLE' ? 'IN_SERVICE' : null, idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() || '确认资源实际任务结束' } }; await execute()
|
||||
}
|
||||
onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核对,离开会丢失原请求。确认离开?'))
|
||||
</script>
|
||||
<template>
|
||||
<section class="detail-section" aria-label="运输任务执行">
|
||||
<LoadErrorState v-if="error" title="运输操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-button v-if="pending" :loading="busy" @click="execute">按原请求重试</el-button>
|
||||
<template v-if="detail && trip">
|
||||
<h2>{{ transportLabels[detail.taskType] }} · {{ { PLANNED: '待发车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }[trip.status] }}</h2>
|
||||
<p v-if="detail.orderNo">本土卡车原单:{{ detail.orderNo }} · 客户:{{ detail.customerId }}</p>
|
||||
<p>调度仓库:{{ trip.warehouseName }} · 车辆:{{ trip.vehiclePlate || '待分配' }} · 司机:{{ trip.driverName || '待分配' }} {{ trip.driverPhone }}</p>
|
||||
<p v-for="(stop, index) in trip.stops" :key="index">{{ index + 1 }}. {{ stop.name }} {{ stop.address }}</p>
|
||||
<el-input v-if="editable" v-model="reason" :disabled="locked" placeholder="本次操作说明" />
|
||||
<el-form v-if="trip.status === 'PLANNED' && editable" label-position="top" :disabled="locked" class="density-form">
|
||||
<el-button :loading="candidateBusy" @click="candidates">加载车辆司机候选</el-button>
|
||||
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位${occupancy[`V:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
|
||||
<el-form-item label="司机"><el-select v-model="driverId" filterable><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}${occupancy[`D:${row.id}`] ? '' : ' · 已占用'}`" /></el-select></el-form-item>
|
||||
<el-button :loading="busy" :disabled="candidateBusy || !vehicleId || !driverId" @click="assign">保存车辆司机</el-button>
|
||||
</el-form>
|
||||
<div class="density-actions">
|
||||
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
|
||||
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.CANCEL)" :disabled="locked" @click="command('cancel')">取消排车</el-button>
|
||||
<el-button v-if="trip.status === 'IN_TRANSIT' && editable" :disabled="locked || (detail.taskType === 'PICKUP' ? !detail.pickups.length || detail.pickups.some(row => row.status !== 'ARRIVED') : !truckEvidence.length)" @click="command('complete')">确认运输完成</el-button>
|
||||
<template v-if="trip.status === 'COMPLETED' && editable"><el-button v-if="!trip.vehicleRelease" :disabled="locked" @click="openRelease('VEHICLE')">结束车辆任务</el-button><el-button v-if="!trip.driverRelease" :disabled="locked" @click="openRelease('DRIVER')">结束司机任务</el-button></template>
|
||||
<el-button :disabled="locked" @click="load">刷新运输进度</el-button>
|
||||
</div>
|
||||
<p v-if="detail.taskType === 'PICKUP'">送仓后由仓库独立清点收货,取货数量保留为运输事实。</p>
|
||||
<el-table v-if="detail.taskType === 'PICKUP'" :data="detail.pickups"><el-table-column prop="transferNo" label="转运单号" min-width="190" /><el-table-column label="进度"><template #default="{ row }">{{ pickupStatusLabels[row.status] }}</template></el-table-column><el-table-column prop="packageCount" label="预报箱数" /><el-table-column prop="actualPackages" label="实提箱数" /><el-table-column label="凭证"><template #default="{ row }"><el-button v-for="file in row.evidence" :key="file.fileId" link @click="download(file, row as PickupTask)">{{ file.fileName }}</el-button></template></el-table-column><el-table-column label="操作" width="180"><template #default="{ row }"><template v-if="trip.status === 'IN_TRANSIT' && editable"><el-button v-if="row.status === 'ASSIGNED'" link :disabled="locked" @click="editPickup(row as PickupTask)">确认取货</el-button><el-button v-if="row.status === 'PICKED'" link :disabled="locked" @click="command('arrive', row as PickupTask)">确认送仓</el-button></template></template></el-table-column></el-table>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>运输完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
|
||||
<div v-if="detail.completionEvidence?.length"><p>已确认的运输完成凭证</p><el-button v-for="file in detail.completionEvidence" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button></div>
|
||||
</template>
|
||||
<AppDialog class="admin-density-dialog" v-model="pickupOpen" title="确认原单取货" width="650px" :show-close="!locked" :close-on-click-modal="!locked" :close-on-press-escape="!locked"><p>{{ selected?.transferNo }}</p><el-form label-position="top" :disabled="locked"><el-form-item label="实提箱数" required><el-input-number v-model="actual" :min="1" :precision="0" /></el-form-item><el-form-item label="取货凭证" required><input type="file" :disabled="locked" @change="upload($event, true)" /></el-form-item><p v-for="file in evidence" :key="file.fileId">{{ file.fileName }}</p></el-form><template #footer><el-button type="primary" :disabled="locked || !evidence.length" @click="picked">确认实提</el-button></template></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="releaseOpen" :title="releaseKind === 'VEHICLE' ? '结束车辆任务' : '结束司机任务'" width="650px" :show-close="!locked" :close-on-click-modal="!locked"><el-form label-position="top" :disabled="locked"><el-form-item label="实际任务结束时间" required><el-date-picker v-model="actualEnd" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点" required><el-input v-model="endLocation" /></el-form-item></el-form><template #footer><el-button type="primary" :disabled="locked || !actualEnd || !endLocation.trim()" @click="release">确认任务结束并释放占用</el-button></template></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import type { PickupCandidate, PickupPlan } from '@/modules/overseas-master-data'
|
||||
import { pickupCandidates, truckCandidates, createTransportPickup, createTransportTruck, type TruckCandidate, type TruckPlan, type TransportType } from '../api/transportTasks'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; type: TransportType }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
|
||||
const router = useRouter(), pickups = ref<PickupCandidate[]>([]), trucks = ref<TruckCandidate[]>([]), warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const selected = ref<PickupCandidate[]>([]), truck = ref(''), warehouse = ref(''), departure = ref(''), end = ref('')
|
||||
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<{ type: TransportType; body: PickupPlan | TruckPlan } | null>(null)
|
||||
let sequence = 0
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try {
|
||||
if (props.type === 'PICKUP') { const value = await pickupCandidates(); if (current === sequence) pickups.value = value.records }
|
||||
else { const [orders, directory] = await Promise.all([truckCandidates(), listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' })]); if (current === sequence) { trucks.value = orders; warehouses.value = directory.records } }
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '运输需求加载失败') }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
watch(() => props.modelValue, value => { if (value) { selected.value = []; truck.value = ''; warehouse.value = ''; pending.value = null; void load() } else sequence++ })
|
||||
/** 结果未知时保留相同原请求,禁止重复选择来源另建单。 */
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!pending.value) {
|
||||
if (!departure.value || !end.value || Date.parse(end.value) <= Date.parse(departure.value)) { ElMessage.warning('请填写有效的计划发车和预计任务结束时间'); return }
|
||||
const common = { plannedDepartureAt: departure.value, expectedDriverEndAt: end.value, idempotencyKey: crypto.randomUUID() }
|
||||
if (props.type === 'PICKUP') {
|
||||
if (!selected.value.length || new Set(selected.value.map(row => row.warehouseId)).size !== 1) { ElMessage.warning('请选择送往同一个仓库的取货单'); return }
|
||||
pending.value = { type: 'PICKUP', body: { ...common, sources: selected.value.map(({ customerId, transferNo }) => ({ customerId, transferNo })) } }
|
||||
} else {
|
||||
const order = trucks.value.find(row => `${row.customerId}:${row.orderNo}` === truck.value)
|
||||
if (!order || !warehouse.value) { ElMessage.warning('请选择本土卡车原单和调度仓库'); return }
|
||||
pending.value = { type: 'LOCAL_TRUCK', body: { ...common, customerId: order.customerId, orderNo: order.orderNo, warehouseId: warehouse.value } }
|
||||
}
|
||||
}
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
const request = pending.value
|
||||
const detail = request.type === 'PICKUP' ? await createTransportPickup(request.body as PickupPlan) : await createTransportTruck(request.body as TruckPlan)
|
||||
pending.value = null; emit('created'); emit('update:modelValue', false); ElMessage.success(`已生成排车单 ${detail.dispatchNo}`)
|
||||
await router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: detail.dispatchNo } })
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '安排排车未完成'); if (cause instanceof ApiResponseError) pending.value = null }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog" :model-value="modelValue" :title="type === 'PICKUP' ? '安排到仓取货' : '安排本土卡车'" width="1100px" :close-on-click-modal="!busy && !pending" :close-on-press-escape="!busy && !pending" :show-close="!busy && !pending" @update:model-value="emit('update:modelValue', $event)">
|
||||
<LoadErrorState v-if="error" title="排车安排未完成" :message="error.message" @retry="pending ? save() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form label-position="top" class="density-form" :disabled="busy || !!pending" v-loading="loading">
|
||||
<template v-if="type === 'PICKUP'">
|
||||
<p>审核通过的仓储转运上门取货单自动进入候选;同车货物必须送往同一个海外仓。</p>
|
||||
<DataTable :data="pickups" row-key="transferNo" :columns="[{ key: 'selection', type: 'selection', width: 45 }, { key: 'transferNo', prop: 'transferNo', label: '转运单号', minWidth: 190 }, { key: 'warehouseName', prop: 'warehouseName', label: '送达仓库', minWidth: 140 }, { key: 'pickupAddress', prop: 'pickupAddress', label: '取货地址', minWidth: 240 }, { key: 'packageCount', prop: 'packageCount', label: '预报箱数', width: 100 }]" :pagination="false" @selection-change="selected = $event as PickupCandidate[]" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="本土卡车原单" required><el-select v-model="truck" filterable><el-option v-for="row in trucks" :key="`${row.customerId}:${row.orderNo}`" :value="`${row.customerId}:${row.orderNo}`" :label="`${row.orderNo} · ${row.origin} → ${row.destination} · ${row.palletCount}托`" /></el-select></el-form-item>
|
||||
<el-form-item label="调度仓库" required><el-select v-model="warehouse" filterable><el-option v-for="row in warehouses" :key="String(row.id)" :value="String(row.id)" :label="row.warehouseName" /></el-select></el-form-item>
|
||||
</template>
|
||||
<el-form-item label="计划发车时间" required><el-date-picker v-model="departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
<el-form-item label="预计司机任务结束时间" required><el-date-picker v-model="end" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button v-if="!pending" :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button><el-button type="primary" :loading="busy" @click="save">{{ pending ? '按原请求重试' : '生成排车单' }}</el-button></template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -24,13 +24,13 @@ export interface DispatchBatchAllocation { planPalletId: string; batchKey: strin
|
||||
export interface DispatchQuery { page: number; size: number; dispatchNo?: string; vehiclePlate?: string; warehouseId?: string; customerId?: string; fbaAppointmentNo?: string; status?: DispatchStatus | 'ALL'; stage?: DispatchStage; beforeLoadingSection?: BeforeLoadingSection; reviewStatus?: ReviewStatus }
|
||||
export interface ReviewCounts { notSubmitted: number; pending: number; approved: number; rejected: number }
|
||||
export interface DispatchStatistics { matchedTotal: number; activeTotal: number; cancelledTotal: number; stageCounts: Record<DispatchStage, number>; beforeLoadingReviewCounts: { vehicleInfoTotal: number; dispatchReview: ReviewCounts; loadingReview: ReviewCounts } }
|
||||
export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
|
||||
export interface DispatchListRow { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
|
||||
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
|
||||
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
|
||||
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
|
||||
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
|
||||
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
|
||||
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
|
||||
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
|
||||
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
|
||||
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
|
||||
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
|
||||
|
||||
@@ -41,7 +41,10 @@ async function retry(state: VueWrapper) { await state.get('.load-error-state__ac
|
||||
|
||||
describe('native cargo discard detail and approval', () => {
|
||||
it('preserves all readonly facts, full item values and history through fullscreen', async () => {
|
||||
const { dialog } = await open()
|
||||
const { wrapper, dialog } = await open()
|
||||
expect(api.statistics).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('全筛选')
|
||||
expect(wrapper.text()).not.toContain('统计未加载')
|
||||
expect(dialog.props('title')).toBe('废弃单详情 · CD-001'); expect(api.detail).toHaveBeenCalledExactlyOnceWith('CD-001', '9007199254740994')
|
||||
const facts = dialog.get('[aria-label="废弃单基础资料"]')
|
||||
for (const value of ['CD-001', 'OUT-1', '洛杉矶仓', 'INBOUND-1', '待审核', '废弃原因与备注']) expect(facts.text()).toContain(value)
|
||||
|
||||
@@ -13,18 +13,17 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, getCargoDiscardStatistics, type CargoDiscardStatistics, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
|
||||
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
|
||||
const statistics = ref<CargoDiscardStatistics | null>(null)
|
||||
/** 数量事实缺失时明确提示,不用零代替未记录。 */
|
||||
function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
|
||||
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'requestedPackages', label: '申请箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
|
||||
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null); const statisticsError = ref<ApiErrorDetails | null>(null)
|
||||
const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null); const reviewError = ref<ApiErrorDetails | null>(null)
|
||||
const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref<CargoDiscardSummary | null>(null)
|
||||
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, ApiErrorDetails | null>>({})
|
||||
@@ -43,17 +42,20 @@ async function loadPhoto(fileId: string) {
|
||||
watch(detailOpen, value => { if (!value) { detailGeneration++; clearPhotos() } })
|
||||
onBeforeUnmount(() => { disposed = true; loadGeneration++; detailGeneration++; clearPhotos() })
|
||||
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
|
||||
/** 分页与全筛选统计独立保留诊断,过期响应不得覆盖新的查询。 */
|
||||
/** 过期响应不得覆盖新的查询。 */
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null; statisticsError.value = null; statistics.value = null
|
||||
const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null
|
||||
const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }
|
||||
const [list, stat] = await Promise.allSettled([listCargoDiscards(query), getCargoDiscardStatistics(query)])
|
||||
if (disposed || generation !== loadGeneration) return
|
||||
if (list.status === 'fulfilled') { rows.value = list.value.records; total.value = list.value.total }
|
||||
else { rows.value = []; total.value = 0; listError.value = getApiErrorDetails(list.reason, '废弃单列表加载失败') }
|
||||
if (stat.status === 'fulfilled') statistics.value = stat.value
|
||||
else statisticsError.value = getApiErrorDetails(stat.reason, '废弃统计加载失败')
|
||||
loading.value = false
|
||||
try {
|
||||
const result = await listCargoDiscards(query)
|
||||
if (disposed || generation !== loadGeneration) return
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (error) {
|
||||
if (disposed || generation !== loadGeneration) return
|
||||
rows.value = []; total.value = 0; listError.value = getApiErrorDetails(error, '废弃单列表加载失败')
|
||||
} finally {
|
||||
if (!disposed && generation === loadGeneration) loading.value = false
|
||||
}
|
||||
}
|
||||
async function loadCustomers() {
|
||||
customerError.value = null
|
||||
@@ -78,7 +80,7 @@ async function audit(row: CargoDiscardSummary) {
|
||||
onMounted(() => { void loadCustomers(); void load() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="statisticsError" title="废弃统计加载失败" :message="statisticsError.message" @retry="load()"><template #details><ApiErrorMeta :details="statisticsError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template><template #pagination-left><span v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</span><span v-else>统计未加载</span></template></DataTable></div></section></ListPageLayout></AdminPage>
|
||||
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template></DataTable></div></section></ListPageLayout></AdminPage>
|
||||
<AppDialog v-model="detailOpen" class="admin-density-dialog admin-workbench is-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable>
|
||||
<p v-if="detailLoading" role="status">正在读取废弃单…</p>
|
||||
<LoadErrorState v-else-if="detailError" title="废弃单详情加载失败" :message="detailError.message" @retry="readDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
|
||||
@@ -16,9 +16,16 @@ vi.mock('@/modules/overseas-master-data', async () => ({
|
||||
vehicleStatusLabels: (await vi.importActual<typeof import('../../overseas-master-data/presentation')>('../../overseas-master-data/presentation')).vehicleStatusLabels,
|
||||
inquiryReturnTarget: (value: Record<string, string>) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null,
|
||||
}))
|
||||
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } }
|
||||
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true, TransportExecutionPanel: true } } }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
||||
describe('whole-dispatch four-tab detail', () => {
|
||||
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('hosts %s execution in the existing dispatch page', async taskType => {
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail(), taskType })
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(wrapper.get('transport-execution-panel-stub').attributes('dispatchno')).toBe('PC/1')
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
|
||||
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it('returns to the original appointment date and view from the dispatch detail', async () => {
|
||||
query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week'
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getDispatch } from '../api/dispatches'
|
||||
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||
import { stageLabel } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels, displayDispatchValue as text } from '../dispatchPresentation'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
@@ -82,8 +83,9 @@ onBeforeUnmount(() => { generation++ })
|
||||
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" @saved="load" />
|
||||
<section v-else-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" readonly /><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" readonly /></el-tab-pane>
|
||||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></el-tab-pane>
|
||||
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
|
||||
|
||||
@@ -18,7 +18,7 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })
|
||||
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ TransportRouteDialog: { template: '<div />' }, routeLocation: (label: string) => ({ label }) }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchBolPdf: vi.fn() }))
|
||||
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true } } }
|
||||
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true } } }
|
||||
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
|
||||
it('offers recovery of the original pending command independently of the filtered list rows', async () => {
|
||||
const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
|
||||
@@ -31,7 +31,7 @@ it('offers recovery of the original pending command independently of the filtere
|
||||
it('shows exactly five phases and uses server stage counts instead of current page length', async () => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
|
||||
expect(wrapper.get('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
|
||||
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
|
||||
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
|
||||
await tabs[3]!.trigger('click'); await flushPromises()
|
||||
@@ -67,6 +67,15 @@ it('navigates by the business number without arranging a separate trip', async (
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'detail'); await flushPromises()
|
||||
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } }); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
|
||||
})
|
||||
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispatch detail and omits warehouse commands', async taskType => {
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'detail', label: '运输操作' })])
|
||||
row.vm.$emit('action', 'detail'); await flushPromises()
|
||||
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
|
||||
expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
|
||||
})
|
||||
it('reads one BOL on demand and preserves popup failure handling', async () => {
|
||||
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatche
|
||||
import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, type DispatchAction, type DispatchListRow, type DispatchStage, type DispatchStatistics, type DispatchStatus, type ReviewStatus } from '../dispatchContracts'
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions } from '../dispatchPresentation'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
|
||||
import { transportLabels, type TransportType } from '../api/transportTasks'
|
||||
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
@@ -30,6 +32,8 @@ const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.us
|
||||
const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | null>(null)
|
||||
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP')
|
||||
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
@@ -48,6 +52,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
])
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
|
||||
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
@@ -75,6 +80,7 @@ function switchSection(value: BeforeLoadingSection) { section.value = value; mod
|
||||
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
||||
const rowActionOrder: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
|
||||
function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
if (row.taskType && row.taskType !== 'DELIVERY') return [{ key: 'detail', label: '运输操作', priority: 500 }]
|
||||
return [{ key: 'detail', label: '详情', priority: 500 }, ...rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can)).map((value, index) => ({ key: value, label: dispatchActionLabels[value], priority: 400 - index * 10, tone: value === 'CANCEL' ? 'danger' as const : undefined })), ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
|
||||
}
|
||||
function handleAction(value: string, row: DispatchListRow) {
|
||||
@@ -118,15 +124,17 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}<el-button link :disabled="recovery.busy.value" @click="recoverPending">{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}</el-button></p>
|
||||
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div></template>
|
||||
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
|
||||
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
|
||||
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
|
||||
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
|
||||
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
|
||||
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
|
||||
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
|
||||
</DataTable></div>
|
||||
</ListPageLayout></AdminPage>
|
||||
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
|
||||
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
|
||||
|
||||
@@ -89,7 +89,8 @@ describe('inventory customer-name snapshots', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('华远贸易')
|
||||
expect(wrapper.text()).toContain('SKU-1 × 5 件;SKU-SECOND × 7 件')
|
||||
expect(wrapper.text()).toContain('可用 12 件 / 2 箱 / 1 托')
|
||||
expect(wrapper.find('[aria-label="可用库存件箱托汇总"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[aria-label="库存汇总"]').text()).toContain('可用库存(箱)10')
|
||||
})
|
||||
|
||||
it('combines the available references and then shows an explicit empty business reference without exposing the inventory id', async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceProjection, type InventoryPieceGroup } from '../api/inventory'
|
||||
import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceGroup } from '../api/inventory'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { toInventoryListItem } from '../inventoryPresentation'
|
||||
@@ -26,7 +26,6 @@ const access = useAccessPreviewStore()
|
||||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||||
const rows = ref<InventoryListItem[]>([])
|
||||
const summary = ref<InventoryDashboardDto | null>(null)
|
||||
const pieces = ref<InventoryPieceProjection | null>(null)
|
||||
const rowPieces = ref<Record<string, InventoryPieceGroup | undefined>>({})
|
||||
const pieceError = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -80,7 +79,7 @@ function query() {
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true; loadError.value = null; pieces.value = null; rowPieces.value = {}; pieceError.value = ''
|
||||
loading.value = true; loadError.value = null; rowPieces.value = {}; pieceError.value = ''
|
||||
try {
|
||||
const currentQuery = query()
|
||||
const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(currentQuery), getInventorySummary(currentQuery)])
|
||||
@@ -88,10 +87,9 @@ async function load(model: QueryModel = filters.value) {
|
||||
try {
|
||||
const pieceResult = await getInventoryPieceProjection(currentQuery)
|
||||
rowPieces.value = Object.fromEntries(pageResult.records.map(dto => [String(dto.id), pieceResult.groups.find(group => String(group.customerId) === String(dto.customerId) && String(group.warehouseId) === String(dto.overseasWarehouseId) && (group.destinationCode ?? '') === (dto.destinationWarehouseCode ?? '') && (group.anchorSkuCode ?? '') === (dto.skuCode ?? ''))]))
|
||||
pieces.value = pieceResult
|
||||
} catch { pieceError.value = '可用件数及箱内SKU加载失败,请重新查询;箱数列表仍可查看。' }
|
||||
}
|
||||
catch (error) { rows.value = []; total.value = 0; summary.value = null; pieces.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
|
||||
catch (error) { rows.value = []; total.value = 0; summary.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -110,7 +108,7 @@ onMounted(() => { void loadWarehouses(); void load() })
|
||||
<template #toolbar-start><h1>库存管理</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><p v-if="pieceError" role="alert">{{ pieceError }}</p></template>
|
||||
<template #before-results><section class="inventory-metrics workbench-panel is-flush" 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><p v-if="pieces" role="status" aria-label="可用库存件箱托汇总">全部筛选结果 · 可用 {{ pieces.availablePieceCount ?? `未知(已知 ${pieces.knownAvailablePieceCount} 件,${pieces.unknownCartonCount} 箱件数未记录)` }}{{ pieces.availablePieceCount == null ? '' : ' 件' }} / {{ pieces.availableCartonCount }} 箱 / {{ pieces.availablePalletCount }} 托</p></template>
|
||||
<template #before-results><section class="inventory-metrics workbench-panel is-flush" 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) && 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>
|
||||
|
||||
@@ -211,7 +211,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
vi.mocked(getReturnOrderStatistics).mockRejectedValueOnce(new Error('统计暂不可用'))
|
||||
table.vm.$emit('update:page', 3)
|
||||
await flushPromises()
|
||||
expect(summary().exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="退货全筛选汇总"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('尾程订单加载失败')
|
||||
expect(table.props('data')).toEqual([])
|
||||
})
|
||||
|
||||
@@ -46,23 +46,16 @@ enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('RelabelOrdersView', () => {
|
||||
it('uses full-filter totals instead of the visible page and preserves unknown boxes', async () => {
|
||||
it('hides filter totals and preserves row quantities and unknown boxes', async () => {
|
||||
vi.mocked(listRelabelOrders).mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-PIECE', skuCodes: 'A', totalPackageCount: 100, status: 'PROCESSING' }], page: 1, size: 10, total: 45, pages: 5 })
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 45, skuCount: 12, knownLabelPieceCount: 300, unknownPieceOrders: 1, knownCartonCount: 8, unknownCartonOrders: 2, orders: { 'RL-PIECE': { labelPieceCount: 100, cartonCount: null, skuCount: 1, duplicateCarton: false, quantityUnit: 'PIECE', sourcePalletCount: null, targetPalletCount: null } } })
|
||||
const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const summary = wrapper.get('[aria-label="全部筛选结果数量汇总"]')
|
||||
expect(summary.text()).toContain('45 票')
|
||||
expect(summary.text()).toContain('300 件')
|
||||
expect(summary.text()).toContain('8 箱')
|
||||
expect(wrapper.findAll('tbody tr').some(row => row.text().includes('RL-PIECE') && row.text().includes('未提供') && row.text().includes('100'))).toBe(true)
|
||||
})
|
||||
it('does not display all-unknown carton quantities as zero boxes', async () => {
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 1, knownLabelPieceCount: 100, unknownPieceOrders: 0, knownCartonCount: 0, unknownCartonOrders: 1, orders: {} })
|
||||
const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).toContain('箱数未提供(1票)')
|
||||
expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).not.toContain('0 箱')
|
||||
expect(wrapper.find('[aria-label="全部筛选结果数量汇总"]').exists()).toBe(false)
|
||||
const cells = wrapper.findAll('tbody tr').find(row => row.text().includes('RL-PIECE'))!.findAll('td')
|
||||
expect(cells[5]!.text()).toBe('—')
|
||||
expect(cells[5]!.text()).toBe(cells[8]!.text())
|
||||
expect(cells[6]!.text()).toBe('100')
|
||||
})
|
||||
it('shows every SKU and quantity in the review dialog while keeping one box', async () => {
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 2, knownLabelPieceCount: 15, unknownPieceOrders: 0, knownCartonCount: 1, unknownCartonOrders: 0, orders: { 'RL-MULTI': { labelPieceCount: 15, cartonCount: 1, skuCount: 2, duplicateCarton: false, quantityUnit: 'CARTON', sourcePalletCount: null, targetPalletCount: null } } })
|
||||
|
||||
@@ -38,9 +38,7 @@ const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const quantities = ref<RelabelQuantityTotals | null>(null)
|
||||
const quantityError = ref('')
|
||||
function dimension(value: number | string | null | undefined) { return value == null ? '未提供' : String(value) }
|
||||
function knownDimension(value: number | string, unknown: number | string, unit: string) { return Number(value) === 0 && Number(unknown) > 0 ? `${unit === '箱' ? '箱数' : unit === '托' ? '托盘数' : '贴标件数'}未提供(${unknown}票)` : `已知 ${value} ${unit}${Number(unknown) > 0 ? `(${unknown}票未提供)` : ''}` }
|
||||
function dimension(value: number | string | null | undefined) { return value == null ? '—' : String(value) }
|
||||
const filters = ref<QueryModel>({ relabelNo: '', sku: '', status: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 },
|
||||
@@ -69,7 +67,7 @@ const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const submitting = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const editSourceStatus = ref('')
|
||||
const auditLabelPieces = ref('未提供')
|
||||
const auditLabelPieces = ref('—')
|
||||
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
const createItems = ref<CreateRelabelItemPayload[]>([])
|
||||
const createOperation = ref<CreateRelabelItemPayload['operationType']>('CARTON_LABEL')
|
||||
@@ -254,12 +252,11 @@ async function load(model: QueryModel = filters.value) {
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const query = { page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }
|
||||
quantities.value = null; quantityError.value = ''
|
||||
quantities.value = null
|
||||
const result = await listRelabelOrders(query)
|
||||
const measured = await getRelabelQuantities(query).catch(() => null)
|
||||
if (current !== loadGeneration) return
|
||||
quantities.value = measured
|
||||
if (!measured) quantityError.value = '数量汇总加载失败,请重试;未将当前页作为全部合计'
|
||||
rows.value = result.records.map((item) => ({
|
||||
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
|
||||
productName: item.productNameZh?.trim() || '—', cartons: dimension(quantities.value?.orders[item.relabelNo]?.cartonCount), labelPieces: dimension(quantities.value?.orders[item.relabelNo]?.labelPieceCount), skuCount: dimension(quantities.value?.orders[item.relabelNo]?.skuCount), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
|
||||
@@ -281,7 +278,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="换标单列表">
|
||||
|
||||
|
||||
<section class="quantity-summary workbench-panel is-compact" aria-label="全部筛选结果数量汇总"><template v-if="quantities"><strong>全部筛选结果</strong><span>{{ quantities.orderCount }} 票</span><span>{{ quantities.skuCount }} 种 SKU</span><span>{{ knownDimension(quantities.knownLabelPieceCount, quantities.unknownPieceOrders, '件') }}</span><span>{{ knownDimension(quantities.knownCartonCount, quantities.unknownCartonOrders, '箱') }}</span><span>原托盘:{{ knownDimension(quantities.knownSourcePalletCount ?? 0, quantities.unknownSourcePalletOrders ?? quantities.orderCount, '托') }}</span><span>本轮目标托盘:{{ knownDimension(quantities.knownTargetPalletCount ?? 0, quantities.unknownTargetPalletOrders ?? quantities.orderCount, '托') }}</span></template><span v-else-if="quantityError" role="alert">{{ quantityError }}</span></section>
|
||||
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @selection-change="selectedRows = $event" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>换标单</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
@@ -327,7 +323,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quantity-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 16px; border: 1px solid var(--workbench-border); border-radius: 8px; margin-bottom: 12px; }
|
||||
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number), .el-form :deep(.el-date-editor) { width: 100%; }
|
||||
.relabel-editor-table { table-layout: fixed; }
|
||||
|
||||
Reference in New Issue
Block a user