feat(admin): streamline appointments and fleet intake
This commit is contained in:
@@ -10,6 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
|
||||
import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
|
||||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||||
import { getCapacityOffer, getCapacityInquiry, listCapacityInquiries } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, offerTime, loadText, type CapacityOfferDetail, type CapacityInquiry, type InquiryResult } from '../capacityOfferTypes'
|
||||
@@ -17,6 +18,16 @@ import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<CapacityOfferDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), inquiryError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1), size = ref(20), inquiryLoading = ref(false), editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), readonly = ref(false)
|
||||
const confirmMode = ref(false)
|
||||
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
function openFulfillment(key: string, row: CapacityInquiry) {
|
||||
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
|
||||
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
|
||||
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
|
||||
editorOpen.value = false
|
||||
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
|
||||
return true
|
||||
}
|
||||
let sequence = 0, inquirySequence = 0, resetting = false
|
||||
const hasCreatePermission = computed(() => (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create')))
|
||||
const canCreate = computed(() => detail.value?.offer.canCreateInquiry && hasCreatePermission.value)
|
||||
@@ -49,8 +60,8 @@ function accept(value: InquiryResult) {
|
||||
for (const row of rows) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
|
||||
}
|
||||
const commands = useCapacityInquiryCommands('capacity-offer-command', accept)
|
||||
async function action(key: string, row: CapacityInquiry) { if (key === 'confirm' || key === 'cancel') await commands.run(row, key); else { editing.value = row; readonly.value = key !== 'edit'; editorOpen.value = true } }
|
||||
function create() { if (!canCreate.value) return; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||
async function action(key: string, row: CapacityInquiry) { if (openFulfillment(key, row)) return; if (key === 'cancel') await commands.run(row, key); else { if (key === 'confirm' && !access.can('platform:fleet:capacity-offer:manage')) return; confirmMode.value = key === 'confirm'; editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true } }
|
||||
function create() { if (!canCreate.value) return; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||
watch(() => [route.name, route.params.offerNo], ([name, offerNo]) => {
|
||||
if (name !== 'overseas-master-data-capacity-offer-detail' || typeof offerNo !== 'string' || !offerNo) return
|
||||
detail.value = null; editorOpen.value = false; void load()
|
||||
@@ -59,17 +70,18 @@ watch([page, size], loadInquiries)
|
||||
onBeforeUnmount(() => { sequence++; inquirySequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车需求池</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记意向</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车接货</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
|
||||
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>交货截止</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记意向:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
|
||||
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记意向不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
|
||||
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
|
||||
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
|
||||
<h2 class="capacity-section-title">分段运力</h2><DataTable :data="detail.offer.segments" :columns="columns" :stripe="false" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable>
|
||||
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的意向' }}</h2>
|
||||
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
|
||||
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
|
||||
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
|
||||
</template></div>
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="load" />
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
|
||||
</DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
|
||||
|
||||
@@ -14,9 +14,9 @@ const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, rout
|
||||
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
|
||||
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
|
||||
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), listCapacityInquiries: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), saveCapacityInquiry: vi.fn(), saveAndConfirmInquiry: vi.fn(), commandCapacityInquiry: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
|
||||
export const offer: CapacityOffer = { offerNo: 'CO-1', tripNo: 'TR-1', status: 'OPEN', recordVersion: 2, tripRecordVersion: 5, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-10T12:00:00Z', cutoffAt: '2026-10-10T10:00:00Z', contactName: '调度', contactPhone: '测试联系信息', remark: null, capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待调度核对', destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
|
||||
export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
|
||||
export const inquiry: CapacityInquiry = { bookingNo: 'FB-1', offerNo: 'CO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 5, salesOwnerUserId: '17', customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'US_TRUCK', deliveryMode: 'SELF_DELIVERY', estimatedArrivalAt: null, expectedPalletCount: null, cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null, progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '确认前还需补充资料', missingFields: ['预计重量'] } }
|
||||
const pageOf = <T,>(records: T[], total = records.length) => ({ records, total, page: 1, size: 20, pages: Math.ceil(total / 20) })
|
||||
const wrappers: VueWrapper[] = []
|
||||
const layout = { template: '<div><slot name="header"/><slot/></div>' }
|
||||
@@ -44,7 +44,7 @@ it('loads only the active tab and sends exactly one query after resetting from a
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
|
||||
await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
||||
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'MINE' })); expect(api.getCapacityOffer).not.toHaveBeenCalled()
|
||||
const query = wrapper.findComponent({ name: 'QueryToolbar' }); query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityInquiries).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'MINE' }))
|
||||
@@ -52,7 +52,7 @@ it('loads only the active tab and sends exactly one query after resetting from a
|
||||
it('ignores an older page response after changing tabs and retains an explicit error state', async () => {
|
||||
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
|
||||
vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
||||
resolve(pageOf([offer], 900)); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('total')).toBe(1)
|
||||
vi.mocked(api.listCapacityInquiries).mockRejectedValue(new Error('读取失败')); wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
@@ -62,7 +62,7 @@ it('ignores an older page response after changing tabs and retains an explicit e
|
||||
it('reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes', async () => {
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
await button(wrapper, '登记意向').trigger('click'); await flushPromises()
|
||||
await button(wrapper, '登记客户货物').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(true)
|
||||
expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -100,7 +100,7 @@ it('retains an uncertain request and route protection after closing and reopenin
|
||||
})
|
||||
it('keeps sales from confirming even if a stale row advertises the action', async () => {
|
||||
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled(); expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -110,27 +110,31 @@ it('loads publishing settings only when explicitly opened, not with the trip pag
|
||||
expect(api.listCapacityOffers).not.toHaveBeenCalled(); await (wrapper as VueWrapper<Record<string, unknown>>).setProps({ modelValue: true }); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith({ scope: 'ALL', tripNo: 'TR-1', page: 1, size: 1 })
|
||||
})
|
||||
it('retries an uncertain dispatch confirmation with the same command and no second confirmation box', async () => {
|
||||
it('retries an uncertain combined save and confirmation with the original payload', async () => {
|
||||
state.manage = true
|
||||
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
|
||||
vi.mocked(api.commandCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry: { ...row, status: 'CONFIRMED' }, replayed: true })
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的意向').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
|
||||
const first = vi.mocked(api.commandCapacityInquiry).mock.calls[0]!
|
||||
await button(wrapper, '重试原操作').trigger('click'); await flushPromises()
|
||||
expect(api.commandCapacityInquiry).toHaveBeenNthCalledWith(2, ...first); expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
|
||||
expect(api.listCapacityInquiries).toHaveBeenCalledOnce()
|
||||
vi.mocked(api.saveAndConfirmInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry: { ...row, status: 'CONFIRMED' }, replayed: true })
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: false, confirmMode: true }); await flushPromises()
|
||||
const dialog = wrapper.findComponent({ name: 'AppDialog' })
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(api.saveAndConfirmInquiry).mock.calls[0]!
|
||||
expect(first[0]).toBe(row.bookingNo)
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveAndConfirmInquiry).toHaveBeenNthCalledWith(2, ...first)
|
||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the create button disabled with a reason after cutoff, while hiding it without write permission', async () => {
|
||||
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(button(wrapper, '登记意向').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('无法登记意向:交货截止已过')
|
||||
expect(button(wrapper, '登记客户货物').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('无法登记客户货物:交货截止已过')
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
|
||||
state.write = false
|
||||
const readonly = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记意向')).toBe(false)
|
||||
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
|
||||
})
|
||||
it('defaults administrators to available offers and requires an explicit history filter', async () => {
|
||||
state.manage = true
|
||||
@@ -142,3 +146,18 @@ it('defaults administrators to available offers and requires an explicit history
|
||||
query.vm.$emit('update:modelValue', {}); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'AVAILABLE' }))
|
||||
})
|
||||
|
||||
it('automatically selects a single destination and preserves unknown quantities when only the customer is known', async () => {
|
||||
vi.mocked(api.saveCapacityInquiry).mockResolvedValue({ offer, inquiry, replayed: false })
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', '19'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveCapacityInquiry).toHaveBeenCalledWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null }) }))
|
||||
})
|
||||
it('requires an explicit choice for multiple destinations', async () => {
|
||||
const many = { ...offer, destinations: [...offer.destinations, { ...offer.destinations[0]!, destinationKey: 'FBA:ACY2', name: 'ACY2', deliveryIndex: 2 }] }
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer: many, inquiry: null, readonly: false }); await flushPromises()
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', '19'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
|
||||
import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
|
||||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||||
import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
|
||||
@@ -20,6 +21,16 @@ const tab = ref<'offers' | 'inquiries'>('offers'), filters = ref<QueryModel>({})
|
||||
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
|
||||
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
|
||||
const confirmMode = ref(false)
|
||||
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
function openFulfillment(key: string, row: CapacityInquiry) {
|
||||
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
|
||||
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
|
||||
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
|
||||
editorOpen.value = false
|
||||
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
|
||||
return true
|
||||
}
|
||||
let sequence = 0, editorSequence = 0
|
||||
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
|
||||
@@ -30,15 +41,22 @@ const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
|
||||
] : [
|
||||
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
|
||||
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的意向', options: [{ value: 'MINE', label: '我的意向' }, { value: 'ALL', label: '全部意向' }] } as QueryField] : []),
|
||||
...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的登记', options: [{ value: 'MINE', label: '我的登记' }, { value: 'ALL', label: '全部登记' }, { value: 'TODO', label: '待我处理' }] } as QueryField] : []),
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'offerNo', label: '发布号 / 车次号', minWidth: 220, slotName: 'identity' },
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 200, fixed: 'left', slotName: 'actions' }, { key: 'offerNo', label: '发布号 / 车次号', minWidth: 220, slotName: 'identity' },
|
||||
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
|
||||
{ key: 'plannedDepartureAt', label: '发车 / 交货截止', minWidth: 195, slotName: 'times' },
|
||||
{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false },
|
||||
{ key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' },
|
||||
...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []),
|
||||
{ key: 'status', label: '接单状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
|
||||
]
|
||||
])
|
||||
const canCreate = computed(() => manage.value || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create'))
|
||||
async function create(row: CapacityOffer) {
|
||||
if (!canCreate.value || !row.canCreateInquiry) return
|
||||
const current = ++editorSequence; error.value = null
|
||||
try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
|
||||
}
|
||||
function switchTab(value: 'offers' | 'inquiries') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
@@ -48,10 +66,10 @@ async function load() {
|
||||
const query: OfferQuery = { scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'AVAILABLE', page: page.value, size: size.value, origin: String(q.origin || '') || undefined, destination: String(q.destination || '') || undefined, departureFrom: String(q.departureFrom || '') || undefined, departureTo: String(q.departureTo || '') || undefined, status: manage.value ? String(q.status || '') || undefined : undefined, canConfirm: q.canConfirm === 'true' ? true : q.canConfirm === 'false' ? false : undefined }
|
||||
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
|
||||
} else {
|
||||
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
|
||||
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'TODO' ? 'TODO' : manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
|
||||
const value = await listCapacityInquiries(query); if (current === sequence) { inquiries.value = value.records; total.value = value.total }
|
||||
}
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车需求池读取失败') }
|
||||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车接货读取失败') }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
|
||||
@@ -63,13 +81,16 @@ function accept(value: InquiryResult) {
|
||||
}
|
||||
const commands = useCapacityInquiryCommands('capacity-pool-command', accept)
|
||||
async function inquiryAction(key: string, row: CapacityInquiry) {
|
||||
if (key === 'confirm' || key === 'cancel') { await commands.run(row, key); return }
|
||||
if (openFulfillment(key, row)) return
|
||||
if (key === 'cancel') { await commands.run(row, key); return }
|
||||
if (key === 'confirm' && !manage.value) return
|
||||
confirmMode.value = key === 'confirm'
|
||||
const current = ++editorSequence; error.value = null
|
||||
if (key === 'edit') {
|
||||
if (key === 'edit' || key === 'confirm') {
|
||||
try { const detail = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = detail.offer }
|
||||
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '意向编辑信息读取失败'); return }
|
||||
} else selectedOffer.value = null
|
||||
editing.value = row; readonly.value = key !== 'edit'; editorOpen.value = true
|
||||
editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true
|
||||
}
|
||||
async function recheckEditor() {
|
||||
if (!editing.value) return
|
||||
@@ -82,33 +103,34 @@ async function recheckEditor() {
|
||||
}
|
||||
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车需求池">
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车接货">
|
||||
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||||
<template #toolbar-start><h1>拼车需求池</h1></template>
|
||||
<template #toolbar-start><h1>拼车接货</h1></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可接运力</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的意向</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可接运力查询" :loading="loading" @search="search" />
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车接货页签"><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
|
||||
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
|
||||
<template #destinations="{ row }"><div v-for="stop in (row as CapacityOffer).destinations" :key="stop.destinationKey">{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}</div></template>
|
||||
<template #times="{ row }"><div>{{ offerTime(row.plannedDepartureAt) }}</div><div>截止 {{ offerTime(row.cutoffAt) }}</div></template>
|
||||
<template #times="{ row }"><div>发车 {{ offerTime(row.plannedDepartureAt) }}</div><div>最晚送仓 {{ offerTime(row.cutoffAt) }}</div></template>
|
||||
<template #loads="{ row }"><span v-if="!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
|
||||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可确认承运' : '暂不可确认' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||||
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
|
||||
</DataTable>
|
||||
<CapacityInquiryTable v-else integrated v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
||||
<template #toolbar-start><h1>{{ applied.scope === 'ALL' ? '全部意向' : '我的意向' }}</h1></template>
|
||||
<template #toolbar-start><h1>{{ applied.scope === 'TODO' ? '待我处理' : applied.scope === 'ALL' ? '全部登记' : '我的登记' }}</h1></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车需求池页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可接运力</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的意向</button></div>
|
||||
<div class="density-status-tabs" role="tablist" aria-label="拼车接货页签"><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
|
||||
</CapacityInquiryTable>
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" @saved="accept" @refresh="recheckEditor" />
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
||||
</ListPageLayout></AdminPage></template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import TripDispatchSelector from '../components/TripDispatchSelector.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -194,8 +195,8 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
|
||||
<template v-if="activeTab === 'overview'">
|
||||
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
|
||||
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><el-input v-model="dispatchNo" placeholder="输入待关联的排车单号" :disabled="!!busy" @keyup.enter="bind" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<p>先分配车辆和司机,关联排车单并匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
|
||||
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><TripDispatchSelector v-model="dispatchNo" :trip-no="detail.trip.tripNo" :disabled="!!busy" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
|
||||
<p>先分配车辆和司机,选择待运排车单后自动匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
|
||||
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
|
||||
</FormSection>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user