优化
This commit is contained in:
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
|
||||
export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
|
||||
export interface CargoDiscardSummary { skuCodes?: string[]; skuCount?: number; requestedPackages?: number; actualPackages?: number | null; id: string; customerId?: string | null; discardNo: string; outboundNo?: string | null; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdByName?: string | null; createdAt?: string | null; reviewedAt?: string | null }
|
||||
export interface CargoDiscardItem { actualCartonMarks?: string[]; palletNo?: string | null; quantityUnit?: string | null; actualDiscardPackages?: number | null; pdaStatus?: string | null; cartonMarks?: string[]; id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
|
||||
export interface CargoDiscardStatistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
|
||||
export async function getCargoDiscardStatistics(query: CargoDiscardQuery) {
|
||||
|
||||
@@ -134,7 +134,8 @@ it('retains edited values and the original validation response when the action i
|
||||
expect(state.form.remark).toBe('用户未保存的本轮备注')
|
||||
expect(state.detail.recordVersion).toBe(0)
|
||||
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
||||
expect(wrapper.text()).toContain('A0400')
|
||||
expect(wrapper.text()).not.toContain('A0400')
|
||||
expect(wrapper.text()).not.toContain('validation-request')
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await state.retryError()
|
||||
@@ -150,7 +151,7 @@ it('allows another click after rejection even when a later read would fail', asy
|
||||
await state.submit(); await flushPromises()
|
||||
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'reject-request' })]])
|
||||
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).not.toContain('A0409')
|
||||
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
||||
expect(state.form.remark).toBe('保留本次备注')
|
||||
await state.submit()
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { getTransport } from '../api/transportTasks'
|
||||
@@ -254,7 +253,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert v-if="cargoAction" :title="cargoEntryMessage" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理,请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="error && !cargoAction" title="排车操作未完成" :message="error.message" :loading="loading || busy || uploadingEvidence" retry-text="刷新当前资料" @retry="retryError"></LoadErrorState>
|
||||
<el-alert v-if="detail && !allowed && !pending" :title="detail?.actions[currentAction]?.blockReason || '当前操作未获得允许,请核对权限与作业状态'" type="warning" :closable="false" show-icon />
|
||||
<el-form v-if="detail && !cargoAction" class="density-form density-dialog-sections" label-position="left" label-width="130px" :disabled="frozen || loading || uploadingEvidence || !allowed">
|
||||
<section v-if="currentOperation === 'ROUTE' && route" class="density-dialog-section" data-tone="source" aria-label="本车卸货顺序"><h3 class="density-dialog-section__title">卸货顺序</h3>
|
||||
@@ -323,7 +322,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
|
||||
<el-form-item v-if="currentAction === 'CANCEL' && currentOperation !== 'ROUTE'" label="取消原因" label-position="top" required>
|
||||
<el-input v-model="form.reason" aria-label="取消原因" type="textarea" :rows="4" placeholder="请填写取消原因" />
|
||||
</el-form-item>
|
||||
<section v-else-if="currentOperation === 'ROUTE' || ['RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="currentAction === 'DISPATCH_REVIEW' ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || currentAction === 'RETURN_TO_REVIEW' ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="currentOperation === 'ROUTE' || ['RETURN_TO_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW'].includes(currentAction)" class="density-dialog-section" :data-tone="isReview ? undefined : 'work'" aria-label="操作原因与意见"><h3 class="density-dialog-section__title">{{ isReview ? '审核意见' : '操作说明' }}</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item :label="isReview && form.decision === 'REJECTED' ? '驳回原因(必填)' : currentOperation === 'ROUTE' || currentAction === 'RETURN_TO_REVIEW' ? '原因(必填)' : '审核意见(选填)'" :error="isReview && form.decision === 'REJECTED' && !form.reason.trim() ? '请填写驳回原因' : ''"><el-input v-model="form.reason" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" :placeholder="isReview ? '驳回时必须填写原因,审核通过时选填' : ''" /></el-form-item></td></tr></tbody></table></section>
|
||||
<section v-else-if="!['MARK_EXCEPTION', 'EDIT', 'SUBMIT_REVIEW'].includes(currentAction)" class="density-dialog-section" data-tone="work" aria-label="本次操作备注"><h3 class="density-dialog-section__title">本次备注</h3><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="备注"><el-input v-model="form.remark" aria-label="本次操作备注" :disabled="frozen || loading || uploadingEvidence || !allowed" type="textarea" :rows="3" /></el-form-item></td></tr></tbody></table></section>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import TransportExecutionPanel from './TransportExecutionPanel.vue'
|
||||
import { assignTransport, commandTransport, downloadTransportEvidence, getTransport, uploadTransport, type TransportDetail } from '../api/transportTasks'
|
||||
import { getDriver } from '@/modules/overseas-master-data'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
|
||||
const can = vi.hoisted(() => vi.fn((_code: string) => true))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
|
||||
@@ -150,7 +151,7 @@ it('requires three categories and sends photos with both current versions for re
|
||||
it('restores saved photos and retains uploads after a failed save', async () => {
|
||||
const value = facts(); value.dispatchRecordVersion = 9
|
||||
value.resources = { vehicle: { vehicleId: '1', evidenceFileIds: ['21'] }, driver: { driverId: '2', evidence: { photoFileIds: ['22'], fileIds: ['23'] } } } as TransportDetail['resources']
|
||||
vi.mocked(getTransport).mockResolvedValue(value); vi.mocked(assignTransport).mockRejectedValue(new Error('保存失败'))
|
||||
vi.mocked(getTransport).mockResolvedValue(value); vi.mocked(assignTransport).mockRejectedValue(new ApiResponseError('司机预计任务结束时间须晚于当前时间', 'A0400', 400, undefined, 'save-request', 'save-trace'))
|
||||
const wrapper = mount(TransportExecutionPanel, { ...photoOptions, props: { dispatchNo: 'PC1', editResources: true } }); await flushPromises()
|
||||
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
|
||||
expect(cards.map(card => card.props('modelValue'))).toEqual([['21'], ['22'], ['23']])
|
||||
@@ -158,6 +159,8 @@ it('restores saved photos and retains uploads after a failed save', async () =>
|
||||
cards[0]!.vm.$emit('update:modelValue', ['24']); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!.trigger('click'); await flushPromises()
|
||||
expect(cards[0]!.props('modelValue')).toEqual(['24'])
|
||||
expect(wrapper.text()).toContain('司机预计任务结束时间须晚于当前时间')
|
||||
for (const diagnostic of ['A0400', 'save-request', 'save-trace']) expect(wrapper.text()).not.toContain(diagnostic)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount()
|
||||
})
|
||||
it('clears old photos when selecting another vehicle or driver and ignores late license responses', async () => {
|
||||
|
||||
@@ -7,8 +7,6 @@ import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { Truck, Package, RefreshCw, Trash2 } from '@lucide/vue'
|
||||
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, getDriver, pickupStatusLabels, type VehicleDetail, type DriverDetail, type PickupTask, type PickupEvidence } from '@/modules/overseas-master-data'
|
||||
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
@@ -195,7 +193,7 @@ onBeforeUnmount(clearTruckPreviewUrls)
|
||||
</script>
|
||||
<template>
|
||||
<section class="transport-execution admin-density admin-workbench" aria-label="运输任务执行">
|
||||
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="error" :title="error.message" type="error" :closable="false" show-icon />
|
||||
|
||||
<template v-if="detail && trip">
|
||||
<header class="transport-toolbar">
|
||||
|
||||
@@ -43,6 +43,20 @@ function errorState(wrapper: Pick<VueWrapper, 'findAllComponents'>, title: strin
|
||||
async function retry(state: VueWrapper) { await state.get('.load-error-state__actions button').trigger('click'); await flushPromises() }
|
||||
|
||||
describe('native cargo discard detail and approval', () => {
|
||||
it('rejects a pending application with a required trimmed reason and customer scope', async () => {
|
||||
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValueOnce({ value: ' 箱唛不符 ', action: 'confirm' })
|
||||
const wrapper = await page()
|
||||
await row(wrapper).findAll('button').find(item => item.text() === '驳回')!.trigger('click'); await flushPromises()
|
||||
expect(prompt).toHaveBeenCalled()
|
||||
expect(api.review).toHaveBeenCalledWith('CD-001', 'REJECT', '箱唛不符', '9007199254740994')
|
||||
prompt.mockRestore()
|
||||
})
|
||||
it('formats applicant names and seconds and uses the same reviewed status as the client', async () => {
|
||||
api.list.mockResolvedValueOnce({ records: [summary({ createdBy: '123456', createdByName: '客户姓名', createdAt: '2026-11-02T18:42:27.511', status: 'APPROVED' })], total: 1 })
|
||||
const wrapper = await page()
|
||||
expect(row(wrapper).text()).toContain('客户姓名'); expect(row(wrapper).text()).toContain('2026-11-02 18:42:27')
|
||||
expect(row(wrapper).text()).not.toContain('.511'); expect(row(wrapper).text()).not.toContain('待 PDA')
|
||||
})
|
||||
it('binds fees to the discard application instead of its inventory or outbound order, and unmounts them on close', async () => {
|
||||
const { dialog } = await open()
|
||||
expect(dialog.getComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
|
||||
|
||||
@@ -19,11 +19,13 @@ import { BusinessDocumentFees } from '@/modules/overseas-business'
|
||||
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: '' })
|
||||
/** 时间统一显示到秒。 */
|
||||
function dateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 19) : '—' }
|
||||
/** 数量事实缺失时明确提示,不用零代替未记录。 */
|
||||
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: 'status', label: '状态', width: 150, slotName: 'status', afterColumn: 'discardNo' }, { 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 }]
|
||||
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'status', label: '状态', width: 150, slotName: 'status', afterColumn: 'discardNo' }, { 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: 'createdByName', label: '申请人', width: 110, formatter: (row: CargoDiscardSummary) => row.createdByName || '—' }, { key: 'createdAt', label: '创建时间', minWidth: 170, formatter: (row: CargoDiscardSummary) => dateTime(row.createdAt) }]
|
||||
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核', PDA_COMPLETED: '待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
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)
|
||||
@@ -78,10 +80,23 @@ async function audit(row: CargoDiscardSummary) {
|
||||
catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '审核失败') }
|
||||
finally { reviewing.value = '' }
|
||||
}
|
||||
/** 驳回须填写原因,成功后刷新当前列表和详情。 */
|
||||
async function reject(row: CargoDiscardSummary) {
|
||||
if (reviewing.value || row.status !== 'PENDING_REVIEW' || !access.can(permission.discardApprove)) return
|
||||
reviewing.value = row.discardNo; reviewError.value = null
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回废弃单', { inputType: 'textarea', inputValidator: (value: string) => !!value?.trim() || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消', customClass: 'admin-density-confirm admin-workbench is-dialog' })
|
||||
if (!access.can(permission.discardApprove)) return
|
||||
await reviewCargoDiscard(row.discardNo, 'REJECT', value.trim(), row.customerId || undefined)
|
||||
ElMessage.success('已驳回'); await load()
|
||||
if (detailOpen.value && detailTarget.value?.discardNo === row.discardNo) await readDetail()
|
||||
} catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '驳回失败') }
|
||||
finally { reviewing.value = '' }
|
||||
}
|
||||
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="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" :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>
|
||||
<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" :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><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" class="density-action" size="small" type="danger" :disabled="!!reviewing" @click="reject(row as CargoDiscardSummary)">驳回</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>
|
||||
@@ -111,6 +126,7 @@ onMounted(() => { void loadCustomers(); void load() })
|
||||
<LoadErrorState v-else-if="photoErrors[photo.fileId]" title="废弃照片加载失败" :message="photoErrors[photo.fileId]?.message" @retry="loadPhoto(photo.fileId)"><template #details><ApiErrorMeta :details="photoErrors[photo.fileId]!" /></template></LoadErrorState>
|
||||
<el-button v-else @click="loadPhoto(photo.fileId)">加载照片</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small>
|
||||
</div></div><p v-else>暂无已提交的废弃现场照片</p>
|
||||
<div v-if="detail.summary.status === 'PENDING_REVIEW' && access.can(permission.discardApprove)"><el-button type="danger" :disabled="!!reviewing" @click="reject(detail.summary)">驳回</el-button></div>
|
||||
<h3>状态流转</h3><table v-if="detail.histories.length" class="density-table" aria-label="废弃状态流转"><tbody><tr v-for="(item, index) in detail.histories" :key="index"><td>{{ item.createdAt || '—' }}</td><td>{{ statusLabels[item.toStatus || ''] || item.toStatus || '未记录' }}</td><td>{{ item.remark || '—' }}</td></tr></tbody></table><p v-else>暂无状态流转记录</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
@@ -202,29 +202,28 @@ it('shows five working phases and uses server stage counts instead of current pa
|
||||
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
|
||||
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' }))
|
||||
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ALL' }))
|
||||
await tabs[3]!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
|
||||
})
|
||||
it.each([0, 1, 2])('preserves section %s and its query after clearing the active dispatch filter', async index => {
|
||||
it.each([0, 1, 2])('queries all documents after clearing scope from section %s', async index => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const sectionValues = ['VEHICLE_INFO', 'DISPATCH_REVIEW', 'LOADING_REVIEW']
|
||||
const sectionSelector = '[aria-label="装车前审核分区"]'
|
||||
await wrapper.get(sectionSelector).findAll('button')[index]!.trigger('click'); await flushPromises()
|
||||
const toolbar = wrapper.getComponent(QueryToolbar)
|
||||
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
|
||||
toolbar.findAllComponents(ElSelect).find(select => select.props('placeholder') === '单据范围')!.vm.$emit('update:modelValue', '')
|
||||
await flushPromises()
|
||||
expect(wrapper.get(sectionSelector).findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
|
||||
expect(wrapper.get(sectionSelector).findAll('button')[index]!.attributes('aria-selected')).toBe('true')
|
||||
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[index] }))
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ALL' }))
|
||||
const nextIndex = (index + 1) % 3
|
||||
await wrapper.get(sectionSelector).findAll('button')[nextIndex]!.trigger('click'); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
|
||||
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.get(sectionSelector).findAll('button')).toHaveLength(3)
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ALL' }))
|
||||
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.find(sectionSelector).exists()).toBe(false)
|
||||
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
|
||||
@@ -279,7 +278,7 @@ it('shows trip progress independently of master vehicle status and leaves cancel
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page(records))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const toolbar = wrapper.getComponent(QueryToolbar)
|
||||
expect(toolbar.props('fields').map(field => field.key)).toEqual(['dispatchNo', 'vehiclePlate', 'vehicleStatus'])
|
||||
expect(toolbar.props('fields').map(field => field.key)).toEqual(['dispatchNo', 'vehiclePlate', 'vehicleStatus', 'status'])
|
||||
expect(toolbar.props('fields').find(field => field.key === 'vehicleStatus')?.options?.map(option => option.label)).toEqual(['待发车', '配送中', '已送达'])
|
||||
expect(wrapper.getComponent(DataTable).props('columns').some(column => column.key === 'vehicleStatus' && column.label === '车辆状态')).toBe(true)
|
||||
expect(wrapper.findAllComponents(ElTag).map(tag => tag.text())).toEqual(['待发车', '待发车', '配送中', '已送达'])
|
||||
@@ -287,23 +286,21 @@ it('shows trip progress independently of master vehicle status and leaves cancel
|
||||
expect(wrapper.text()).not.toContain('维修中')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each(['CANCELLED', 'ALL'])('retains advanced %s lookup and resets its hidden value', async status => {
|
||||
it.each(['CANCELLED', 'ALL'])('keeps %s lookup visible and resets to all documents', async status => {
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const toolbar = wrapper.getComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('search', { status: 'ACTIVE', vehicleStatus: 'WAIT_DEPARTURE' }); await flushPromises()
|
||||
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
|
||||
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单据范围')!
|
||||
select.vm.$emit('update:modelValue', status); select.vm.$emit('change', status); await flushPromises()
|
||||
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
|
||||
expect(toolbar.props('modelValue').vehicleStatus).toBe('')
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status }))
|
||||
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
|
||||
await toolbar.findAll('button').find(button => button.text().startsWith('收起筛选'))!.trigger('click')
|
||||
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(false)
|
||||
expect(toolbar.text()).toContain(status === 'CANCELLED' ? '高级筛选 · 已取消' : '高级筛选 · 全部单据')
|
||||
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(true)
|
||||
expect(toolbar.text()).not.toContain('高级筛选')
|
||||
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ status: 'ACTIVE', vehicleStatus: '' })
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ status: '', vehicleStatus: '' })
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, status: 'ALL' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('returns to active dispatches when selecting vehicle progress from cancelled lookup', async () => {
|
||||
@@ -330,7 +327,7 @@ it.each(['dispatchReview', 'loadingReview'] as const)('shows %s rejection in the
|
||||
row.reviews[reviewKey] = { ...row.reviews[reviewKey], status: 'REJECTED', reason: '车辆证照需补齐' }
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([row]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'VEHICLE_INFO' }))
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ALL' }))
|
||||
expect(wrapper.text()).toContain(`${reviewKey === 'dispatchReview' ? '调度审核' : '理货员审核'}退回:车辆证照需补齐`)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -424,8 +421,8 @@ it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %
|
||||
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'EDIT' }))
|
||||
if (event === 'rejected') {
|
||||
expect(wrapper.text()).toContain('本轮审核已经提交')
|
||||
expect(wrapper.text()).toContain('A0409')
|
||||
expect(wrapper.text()).toContain('list-reject-request')
|
||||
expect(wrapper.text()).not.toContain('A0409')
|
||||
expect(wrapper.text()).not.toContain('list-reject-request')
|
||||
}
|
||||
expect(wrapper.text()).not.toContain('结果尚待核对')
|
||||
wrapper.unmount()
|
||||
@@ -481,14 +478,14 @@ it('opens the exception form from the dispatching row and hides it without permi
|
||||
denied.unmount()
|
||||
})
|
||||
|
||||
it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_REJECTED']] as const)('returns a rejected %s to vehicle information',async(action,index,reviewState)=>{
|
||||
it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED','DELIVERY'],['LOADING_REVIEW',2,'LOADING_REJECTED','DELIVERY'],['DISPATCH_REVIEW',1,'DISPATCH_REJECTED','LOCAL_TRUCK'],['LOADING_REVIEW',2,'LOADING_REJECTED','LOCAL_TRUCK']] as const)('returns a rejected %s to vehicle information (%s)',async(action,index,reviewState,taskType)=>{
|
||||
const allowed={allowed:true,blockCode:null,blockReason:null}
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({actions:{[action]:allowed}})]))
|
||||
vi.mocked(listDispatches).mockResolvedValue(page([listRow({taskType,actions:{[action]:allowed}})]))
|
||||
const wrapper=mount(DispatchesView,options);await flushPromises()
|
||||
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click');await flushPromises()
|
||||
const row=wrapper.findAllComponents(RowActions).find(entry=>entry.props('moreLabel')==='更多操作 · PC/1')!
|
||||
row.vm.$emit('action',action);await flushPromises()
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({reviewState,stage:'BEFORE_LOADING'}))
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(detail({taskType,reviewState,stage:'BEFORE_LOADING'}))
|
||||
wrapper.getComponent({name:'DispatchWorkflowDialog'}).vm.$emit('saved');await flushPromises()
|
||||
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({page:1,stage:'BEFORE_LOADING',beforeLoadingSection:'VEHICLE_INFO'}))
|
||||
expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
|
||||
|
||||
@@ -48,8 +48,7 @@ const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
|
||||
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' })
|
||||
const showAdvancedFilters = ref(false)
|
||||
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', vehicleStatus: '', status: '' })
|
||||
const vehicleStatuses = [
|
||||
{ value: 'WAIT_DEPARTURE', label: '待发车', type: 'warning' },
|
||||
{ value: 'DELIVERING', label: '配送中', type: 'primary' },
|
||||
@@ -63,14 +62,14 @@ const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
|
||||
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
|
||||
const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }]
|
||||
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && (model.value.status || 'ACTIVE') === 'ACTIVE')
|
||||
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && (!model.value.status || model.value.status === 'ACTIVE'))
|
||||
const availableFields = computed<QueryField[]>(() => [
|
||||
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
|
||||
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
|
||||
{ key: 'vehicleStatus', label: '车辆状态', placeholder: '车辆状态', type: 'select', width: 140, options: [...vehicleStatuses], props: { 'aria-label': '车辆状态', onChange: (value: unknown) => { if (value) model.value.status = 'ACTIVE' } } },
|
||||
{ key: 'status', label: '单据范围', placeholder: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }], props: { 'aria-label': '单据范围', onChange: (value: unknown) => { if (value && value !== 'ACTIVE') model.value.vehicleStatus = '' } } },
|
||||
{ key: 'status', label: '单据范围', placeholder: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }], props: { 'aria-label': '单据范围', onChange: (value: unknown) => { if (value && value !== 'ACTIVE') model.value.vehicleStatus = '' } } },
|
||||
])
|
||||
const fields = computed(() => availableFields.value.filter(field => showAdvancedFilters.value || field.key !== 'status'))
|
||||
const fields = availableFields
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: rows.value.some(row => row.taskType === 'PICKUP') ? 330 : rows.value.some(canDownloadBol) ? 290 : 220, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
|
||||
@@ -95,17 +94,17 @@ async function load() {
|
||||
const current = ++sequence
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ACTIVE') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
|
||||
...(showSections.value ? { beforeLoadingSection: section.value } : {}) })
|
||||
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ALL') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
|
||||
...(showSections.value && model.value.status === 'ACTIVE' ? { beforeLoadingSection: section.value } : {}) })
|
||||
if (current !== sequence) return
|
||||
rows.value = result.records; total.value = result.total; statistics.value = result.statistics
|
||||
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search(value: QueryModel) {
|
||||
model.value = { ...value, status: value.status || 'ACTIVE' }
|
||||
model.value = { ...value, status: value.status || '' }
|
||||
const selected = vehicleStatuses.find(item => item.value === model.value.vehicleStatus)
|
||||
if (model.value.status !== 'ACTIVE') model.value.vehicleStatus = ''
|
||||
if (model.value.status !== 'ACTIVE') { model.value.vehicleStatus = ''; section.value = 'VEHICLE_INFO' }
|
||||
else if (selected && vehicleStatusForStage(stage.value)?.value !== selected.value) {
|
||||
// 车辆运输进度与阶段联动,查询仍由服务端按阶段分页,避免交叉条件恒为空。
|
||||
stage.value = selected.value === 'WAIT_DEPARTURE' ? 'BEFORE_LOADING' : selected.value
|
||||
@@ -113,13 +112,13 @@ function search(value: QueryModel) {
|
||||
}
|
||||
page.value = 1; void load()
|
||||
}
|
||||
function resetSearch() { search({ dispatchNo: '', vehiclePlate: '', vehicleStatus: '', status: 'ACTIVE' }) }
|
||||
function resetSearch() { search({ dispatchNo: '', vehiclePlate: '', vehicleStatus: '', status: '' }) }
|
||||
function switchStage(value: DispatchStage) {
|
||||
stage.value = value
|
||||
if (model.value.vehicleStatus) model.value.vehicleStatus = vehicleStatusForStage(value)?.value || ''
|
||||
section.value = 'VEHICLE_INFO'; page.value = 1; void load()
|
||||
}
|
||||
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
|
||||
function switchSection(value: BeforeLoadingSection) { model.value.status = 'ACTIVE'; section.value = value; page.value = 1; void load() }
|
||||
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
||||
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'MARK_EXCEPTION', 'CANCEL']
|
||||
function canManageDocuments(row: DispatchListRow) { return (row.taskType || 'DELIVERY') === 'DELIVERY' && row.status === 'ACTIVE' && ['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(row.stage) }
|
||||
@@ -229,11 +228,11 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外仓排车列表">
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<el-alert v-if="rejectionError" :title="rejectionError.message" type="error" :closable="false" show-icon />
|
||||
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :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)" @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) || access.can(dispatchActionPermissions.ADD_CARGO)" 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 id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="dispatch-query-filters" @click="showAdvancedFilters = !showAdvancedFilters">{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}{{ model.status === 'CANCELLED' ? ' · 已取消' : model.status === 'ALL' ? ' · 全部单据' : '' }}</el-button></template></QueryToolbar></div></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 id="dispatch-query-filters" v-model="model" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="resetSearch"></QueryToolbar></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)" :max-visible="row.taskType === 'PICKUP' || canDownloadBol(row as DispatchListRow) ? 3 : 2" :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>
|
||||
|
||||
@@ -145,6 +145,33 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('allows selecting and submitting manual storage rent', async () => {
|
||||
const { dialog } = await openFeeEditor()
|
||||
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
|
||||
expect(select.findAllComponents({ name: 'ElOption' }).some(option => option.props('value') === 'FEE_TYPE_077')).toBe(true)
|
||||
select.vm.$emit('update:modelValue', 'FEE_TYPE_077')
|
||||
dialog.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 80)
|
||||
await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ feeItemCode: 'FEE_TYPE_077', amount: 80 }))
|
||||
})
|
||||
|
||||
it('creates a return order fee without charging quantity or unit inputs', async () => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'RT-1', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
|
||||
const { dialog } = await openFeeEditor()
|
||||
expect(dialog.text()).not.toContain('计费数量')
|
||||
expect(dialog.text()).not.toContain('计费单位')
|
||||
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'FEE_TYPE_071')
|
||||
dialog.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 80)
|
||||
await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(createLastMileOrderFee).toHaveBeenCalledOnce()
|
||||
const payload = vi.mocked(createLastMileOrderFee).mock.calls[0]![1]
|
||||
expect(payload.amount).toBe(80)
|
||||
expect(payload).not.toHaveProperty('chargeableQuantity')
|
||||
expect(payload).not.toHaveProperty('quantityUnit')
|
||||
expect(payload).not.toHaveProperty('unitPrice')
|
||||
})
|
||||
it('shows business references without changing the transfer workflow or requiring business-list permission', async () => {
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'A', orderNo: 'WT-1', sourceOrderNo: 'WT-1', orderType: 'WAREHOUSE_TRANSFER', businessSource: { businessType: 'LOCAL_LOOSEBOARD', label: '本土散板 · 拼车需求池', businessOrderNo: 'LB-1', externalBookingNo: 'FB-1' } }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
|
||||
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises()
|
||||
@@ -284,13 +311,13 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
await dialog.findAll('button').find(button => button.text() === '保存费用')!.trigger('click'); await flushPromises()
|
||||
expect(createLastMileOrderFee).not.toHaveBeenCalled()
|
||||
})
|
||||
it('uses the live catalog, excludes automatic rent and only loads suppliers for payables', async () => {
|
||||
it('uses the live catalog including rent and only loads suppliers for payables', async () => {
|
||||
const { dialog } = await openFeeEditor()
|
||||
expect(listOverseasFeeItems).toHaveBeenCalledWith('RECEIVABLE')
|
||||
expect(listSuppliers).not.toHaveBeenCalled()
|
||||
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
|
||||
expect(select.props('allowCreate')).toBe(false)
|
||||
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071'])
|
||||
expect(select.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).toEqual(['FEE_TYPE_071', 'FEE_TYPE_077'])
|
||||
await fillFee(dialog, 'PAYABLE')
|
||||
expect(listOverseasFeeItems).toHaveBeenLastCalledWith('PAYABLE')
|
||||
expect(listSuppliers).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -272,7 +272,7 @@ async function loadFeeItems() {
|
||||
feeForm.itemCode = ''; feeForm.itemName = ''
|
||||
try {
|
||||
const items = await listOverseasFeeItems(direction)
|
||||
if (request === feeItemsRequest) feeItemOptions.value = items.filter(item => item.feeCode !== 'FEE_TYPE_077')
|
||||
if (request === feeItemsRequest) feeItemOptions.value = items
|
||||
} catch (error) {
|
||||
if (request === feeItemsRequest) feeItemsError.value = getApiErrorDetails(error, '费用项目加载失败').message
|
||||
} finally { if (request === feeItemsRequest) feeItemsLoading.value = false }
|
||||
@@ -324,7 +324,7 @@ async function saveFee() {
|
||||
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
|
||||
saving.value = true; mutationError.value = null
|
||||
try {
|
||||
await createLastMileOrderFee(orderId.value, { feeItemCode: selectedItem.feeCode, feeItemName: selectedItem.feeTypeName, feeRemark: feeForm.feeRemark.trim() || undefined, direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: feeForm.direction === 'PAYABLE' ? 'ON_ACCOUNT' : (feeForm.settlementMode || undefined), chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined })
|
||||
await createLastMileOrderFee(orderId.value, { feeItemCode: selectedItem.feeCode, feeItemName: selectedItem.feeTypeName, feeRemark: feeForm.feeRemark.trim() || undefined, direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: feeForm.direction === 'PAYABLE' ? 'ON_ACCOUNT' : (feeForm.settlementMode || undefined), ...(!isReturnOrder.value ? { chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined } : {}) })
|
||||
feeDialogOpen.value = false; ElMessage.success('费用已提交审核'); await load()
|
||||
await Promise.all([feePanel.value?.refresh(), payableFeePanel.value?.refresh()]); activeTab.value = 'fees'
|
||||
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用新增失败,请重试') }
|
||||
@@ -443,7 +443,7 @@ onMounted(load)
|
||||
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable :loading="suppliersLoading" :disabled="suppliersLoading || !!suppliersError" placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td></tr>
|
||||
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
|
||||
<tr v-if="!isReturnOrder"><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="费用说明" :required="duplicateFees.length > 0"><el-input v-model="feeForm.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item></td></tr>
|
||||
<tr v-if="duplicateFees.length"><td colspan="2" role="status">此单已有相同费用,确认本次为新增作业后可继续添加。<div v-for="row in duplicateFees" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</div></td></tr>
|
||||
<tr v-if="existingFeesError"><td colspan="2">{{ existingFeesError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></td></tr>
|
||||
|
||||
@@ -1436,6 +1436,24 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
|
||||
expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-'))
|
||||
})
|
||||
it.each(['RELABEL_APPLICATION', 'WAREHOUSE_TRANSFER', 'LOCAL_LOOSEBOARD', 'RETURN_RELABEL', 'CARGO_DISCARD'])('does not offer manual transport nodes for %s', async orderType => {
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'business-tail', customerId, orderNo: 'BUSINESS-1', orderType, currentStatus: 'PENDING_ASSIGNMENT' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
const wrapper = await mountView()
|
||||
expect(wrapper.get('.el-table__body-wrapper .row-actions').text()).not.toContain('修改节点')
|
||||
await (wrapper.vm as unknown as { openManualNodes(row: object): Promise<void> }).openManualNodes(selectedRow({ orderTypeCode: orderType }))
|
||||
expect(getLastMileOrderManualNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
it('provides the same return receipt statuses as the list labels and submits their exact codes', async () => {
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const status = (toolbar.props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>).find(field => field.key === 'status')!
|
||||
for (const [value, label] of [['PENDING_REVIEW', '待审核'], ['PENDING_RECEIPT', '待签收入库'], ['PARTIALLY_RECEIVED', '部分入库'], ['INBOUND_COMPLETED', '全部入库']]) {
|
||||
expect(status.options).toContainEqual({ value, label })
|
||||
toolbar.vm.$emit('search', { ...toolbar.props('modelValue'), status: value })
|
||||
await flushPromises()
|
||||
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: value }))
|
||||
}
|
||||
})
|
||||
it('edits only customs nodes while preserving hidden node facts and zero version', async () => {
|
||||
vi.mocked(getLastMileOrderManualNodes).mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [{ nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }], version: 0 })
|
||||
const wrapper = await mountView()
|
||||
|
||||
@@ -220,12 +220,16 @@ const allFields: QueryField[] = [
|
||||
{ key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
|
||||
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
|
||||
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '请选择当前状态', options: [
|
||||
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||
{ label: '待签收入库', value: 'PENDING_RECEIPT' },
|
||||
{ label: '部分入库', value: 'PARTIALLY_RECEIVED' },
|
||||
{ label: '已驳回', value: 'REJECTED' },
|
||||
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
|
||||
{ label: '待换标', value: 'WAIT_RELABEL' },
|
||||
{ label: '待排车', value: 'READY_FOR_DISPATCH' },
|
||||
{ label: '待出库', value: 'WAIT_OUTBOUND' },
|
||||
{ label: '待入库', value: 'PENDING_INBOUND' },
|
||||
{ label: '入库完成', value: 'INBOUND_COMPLETED' },
|
||||
{ label: '全部入库', value: 'INBOUND_COMPLETED' },
|
||||
{ label: '待派送', value: 'PENDING_DELIVERY' },
|
||||
{ label: '派送中', value: 'DELIVERING' },
|
||||
{ label: '派送完成', value: 'DELIVERED' },
|
||||
@@ -763,8 +767,14 @@ async function submitHold() {
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '暂扣状态保存失败,请重试') }
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
const manualNodeExcludedOrderTypes = new Set(['RELABEL_APPLICATION', 'WAREHOUSE_TRANSFER', 'LOCAL_LOOSEBOARD', 'RETURN_RELABEL', 'CARGO_DISCARD'])
|
||||
/** 独立仓内业务不提供头程运输节点修改入口。 */
|
||||
function canEditManualNodes(row: LastMileOrderListItem) {
|
||||
return !manualNodeExcludedOrderTypes.has(row.orderTypeCode)
|
||||
}
|
||||
async function openManualNodes(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
if (!canEditManualNodes(row)) return
|
||||
actionTarget.value = row
|
||||
manualNodesOpen.value = true
|
||||
manualNodesLoading.value = true
|
||||
@@ -1078,7 +1088,7 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
|
||||
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
|
||||
{ key: 'inbound-plan', label: '入库', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated },
|
||||
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
|
||||
{ key: 'manual-nodes', label: '修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
|
||||
{ key: 'manual-nodes', label: '修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) && canEditManualNodes(row) },
|
||||
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
|
||||
{ key: 'rework-relabel', label: '换标退回重做', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && canReworkRelabel(row) },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user