feat(admin): 排车默认发布散板需求并直接确认装车

This commit is contained in:
2026-10-21 18:51:10 +08:00
parent 7c43fcacc4
commit a4cb6c3757
18 changed files with 216 additions and 36 deletions
@@ -0,0 +1,40 @@
import { shallowMount, flushPromises } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import View from './DispatchCapacityDemandsView.vue'
import * as api from '../api/dispatchCapacityDemands'
vi.mock('../api/dispatchCapacityDemands', () => ({ listDispatchCapacityDemands: vi.fn() }))
const row: api.DispatchCapacityDemand = { dispatchNo: 'PC-TEST', warehouseName: '测试仓', route: '测试仓 → ONT8', plannedDepartureAt: null, vehiclePalletSpaces: 30, scheduledPalletCount: 18, remainingPalletSpaces: null, remainingWeightKg: null, remainingVolumeCbm: null, capacityKnown: false, capacityReason: '占用标准托位、重量及体积待确认' }
const result = { records: [row], page: 1, size: 20, total: 1, pages: 1 }
const layout = { template: '<div><slot/></div>' }
function mount() { return shallowMount(View, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, DataTable: { name: 'DataTable', props: ['data', 'columns', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' }, QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search'], template: '<div/>' }, LoadErrorState: { name: 'LoadErrorState', emits: ['retry'], template: '<div/>' } } } }) }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.listDispatchCapacityDemands).mockResolvedValue(result) })
it('uses the dispatch source and never presents registration, publication or legacy trip navigation', async () => {
const wrapper = mount(); await flushPromises()
expect(api.listDispatchCapacityDemands).toHaveBeenCalledExactlyOnceWith({ page: 1, size: 20, origin: undefined, destination: undefined })
const table = wrapper.getComponent({ name: 'DataTable' })
expect(table.props('data')).toEqual([row])
const columns = table.props('columns') as { key: string; formatter?: (value: api.DispatchCapacityDemand) => string }[]
expect(columns.some(c => c.key === 'actions')).toBe(false)
expect(columns.find(c => c.key === 'remainingPalletSpaces')?.formatter?.(row)).toBe('待确认')
expect(columns.find(c => c.key === 'remainingPalletSpaces')?.formatter?.({ ...row, capacityKnown: true, remainingPalletSpaces: 12 })).toBe('12')
expect(wrapper.findAll('button')).toHaveLength(0)
wrapper.unmount()
})
it('applies warehouse and destination queries and resets pagination only once', async () => {
const wrapper=mount(); await flushPromises()
wrapper.getComponent({name:'DataTable'}).vm.$emit('update:page',2); await flushPromises()
const query=wrapper.getComponent({name:'QueryToolbar'})
query.vm.$emit('update:modelValue',{origin:'洛杉矶',destination:'ONT8'}); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listDispatchCapacityDemands).toHaveBeenCalledTimes(3)
expect(api.listDispatchCapacityDemands).toHaveBeenLastCalledWith({page:1,size:20,origin:'洛杉矶',destination:'ONT8'})
wrapper.unmount()
})
it('preserves the query and retries an explicit read error', async () => {
vi.mocked(api.listDispatchCapacityDemands).mockRejectedValueOnce(new Error('offline'))
const wrapper=mount(); await flushPromises()
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(false)
wrapper.getComponent({name:'LoadErrorState'}).vm.$emit('retry'); await flushPromises()
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true)
expect(api.listDispatchCapacityDemands).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
@@ -0,0 +1,48 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { offerTime } from '../capacityOfferTypes'
import { listDispatchCapacityDemands, type DispatchCapacityDemand } from '../api/dispatchCapacityDemands'
const rows = ref<DispatchCapacityDemand[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ origin: '', destination: '' }), applied = ref({ ...filters.value })
const fields: QueryField[] = [{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 190 }, { key: 'destination', label: '目的地', type: 'input', placeholder: '目的地名称', width: 190 }]
const amount = (value: number | null | undefined) => value == null ? '—' : String(value)
const columns: DataTableColumn[] = [
{ key: 'dispatchNo', label: '排车单号', minWidth: 200 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
{ key: 'route', label: '路线', minWidth: 240, formatter: (row: DispatchCapacityDemand) => row.route || '—' },
{ key: 'plannedDepartureAt', label: '计划发车', minWidth: 180, formatter: (row: DispatchCapacityDemand) => offerTime(row.plannedDepartureAt) },
{ key: 'vehiclePalletSpaces', label: '车辆容量(托位)', width: 145, align: 'right', formatter: (row: DispatchCapacityDemand) => amount(row.vehiclePalletSpaces) },
{ key: 'scheduledPalletCount', label: '已排实托数', width: 120, align: 'right', formatter: (row: DispatchCapacityDemand) => amount(row.scheduledPalletCount) },
{ key: 'remainingPalletSpaces', label: '剩余标准托位', width: 140, align: 'right', formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? amount(row.remainingPalletSpaces) : '待确认' },
{ key: 'remainingWeightKg', label: '剩余载重(kg)', width: 145, align: 'right', formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? amount(row.remainingWeightKg) : '待确认' },
{ key: 'remainingVolumeCbm', label: '剩余容积(m³)', width: 145, align: 'right', formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? amount(row.remainingVolumeCbm) : '待确认' },
{ key: 'capacityReason', label: '余位状态', minWidth: 220, formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? '余位已确认' : row.capacityReason || '余位待确认' },
]
let sequence = 0
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
const value = await listDispatchCapacityDemands({ page: page.value, size: size.value, origin: String(applied.value.origin || '') || undefined, destination: String(applied.value.destination || '') || undefined })
if (current === sequence) { rows.value = value.records; total.value = value.total }
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '散板需求加载失败') } }
finally { if (current === sequence) loading.value = false }
}
function search() { applied.value = { ...filters.value }; if (page.value === 1) void load(); else page.value = 1 }
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求"><div class="workbench-table is-page-surface">
<DataTable appearance="integrated" preference-key="dispatch-capacity-demands" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="dispatchNo" empty-text="暂无已发布的散板需求">
<template #toolbar-start><h1>散板需求</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="散板需求查询" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="散板需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #pagination-left>共 {{ total }} 条散板需求</template>
</DataTable>
</div></ListPageLayout></AdminPage></template>