feat(fleet): publish capacity from a single guided dialog

This commit is contained in:
2026-10-09 15:31:57 +08:00
parent f405452c37
commit 3a917324f1
5 changed files with 177 additions and 22 deletions
@@ -11,19 +11,21 @@ import type { CapacityOffer } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { getCapacityOffer, listCapacityOffers } from '../api/capacityOffers'
import { getTrip, getTripCapacityPlan } from '../api/trips'
import { isWorkspaceRoute, workspaceRouteId } from '@/core/workspace-tabs/routeWorkspace'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/trips', () => ({ getTrip: vi.fn(), getTripCapacityPlan: vi.fn(), getTripLoading: vi.fn(), confirmTripCapacityPlan: vi.fn(), confirmTripLoading: vi.fn(), updateTripPlan: vi.fn() }))
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), getCapacityOffer: vi.fn(), getCapacityInquiry: vi.fn(), listCapacityInquiries: vi.fn(), listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn(), commandCapacityInquiry: vi.fn() }))
const offer: CapacityOffer = { offerNo: 'CO-CLOSE', tripNo: 'TR-CLOSE', status: 'OPEN', recordVersion: 1, tripRecordVersion: 2, warehouseId: '9', warehouseName: '测试仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: null }], segments: [], capacityKnown: false, canCreateInquiry: true, canConfirm: false, blockReason: '待核对', actions: { canEdit: true, canPublish: false, canPause: true, canResume: false, canClose: true } }
const wrappers: VueWrapper[] = []
beforeEach(() => { vi.clearAllMocks(); vi.mocked(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }); vi.mocked(getCapacityOffer).mockResolvedValue({ offer, inquiries: { records: [], total: 0, page: 1, size: 20, pages: 0 } }) })
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getTrip).mockResolvedValue({ trip: { tripNo: offer.tripNo, recordVersion: 2, plannedDepartureAt: offer.plannedDepartureAt, stops: [{ name: '起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } } as Awaited<ReturnType<typeof getTrip>>); vi.mocked(getTripCapacityPlan).mockResolvedValue({ tripNo: offer.tripNo, recordVersion: 2, cargo: [], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }); vi.mocked(listCapacityOffers).mockResolvedValue({ records: [offer], total: 1, page: 1, size: 1, pages: 1 }); vi.mocked(getCapacityOffer).mockResolvedValue({ offer, inquiries: { records: [], total: 0, page: 1, size: 20, pages: 0 } }) })
afterEach(async () => { ElMessageBox.close(); wrappers.splice(0).forEach(wrapper => wrapper.unmount()); await flushPromises(); document.body.innerHTML = ''; vi.restoreAllMocks() })
function mountDialog(kind: 'inquiry' | 'settings') {
const pinia = createPinia()
const wrapper = kind === 'inquiry'
? mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [pinia, ElementPlus] } })
: mount(CapacityOfferSettingsDialog, { attachTo: document.body, props: { modelValue: false, trip: { tripNo: offer.tripNo, recordVersion: 2 } as FleetTrip }, global: { plugins: [pinia, ElementPlus] } })
: mount(CapacityOfferSettingsDialog, { attachTo: document.body, props: { modelValue: false, trip: { tripNo: offer.tripNo, recordVersion: 2 } as FleetTrip }, global: { plugins: [pinia, ElementPlus, createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div/>' } }] })] } })
wrappers.push(wrapper)
return { wrapper, pinia }
}
@@ -38,6 +40,7 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
const { wrapper, pinia } = mountDialog(kind)
if (kind === 'settings') await wrapper.setProps({ modelValue: true })
await flushPromises()
if (kind === 'settings') await wrapper.get('details summary').trigger('click')
const input = wrapper.find<HTMLInputElement>(kind === 'inquiry' ? 'input[placeholder="选填"]' : 'input[placeholder="填写公开给销售的联系人(选填)"]')
expect(input.exists()).toBe(true)
await input.setValue('UI-未保存检查'); await flushPromises()
@@ -0,0 +1,73 @@
import { shallowMount, flushPromises } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import Dialog from './CapacityOfferSettingsDialog.vue'
import * as offers from '../api/capacityOffers'
import * as trips from '../api/trips'
import type { FleetTrip } from '../tripTypes'
const state = vi.hoisted(() => ({ plan: vi.fn(), push: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('../api/capacityOffers', () => ({ listCapacityOffers: vi.fn(), saveCapacityOffer: vi.fn(), commandCapacityOffer: vi.fn() }))
vi.mock('../api/trips', () => ({ getTrip: vi.fn(), getTripLoading: vi.fn(), updateTripPlan: vi.fn(), getTripCapacityPlan: vi.fn(), confirmTripCapacityPlan: vi.fn(), confirmTripLoading: vi.fn() }))
const trip = { tripNo: 'TR-1', recordVersion: 5, plannedDepartureAt: '2099-01-01T12:00:00Z', stops: [{ name: '起运仓' }, { name: 'ACY2', destinationKey: 'FBA:ACY2' }] } as FleetTrip
const draft = { offerNo: 'FO-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 1, tripRecordVersion: 6, actions: { canEdit: true } }
const wrappers: ReturnType<typeof shallowMount>[] = []
function mount(realPlanner = false) {
const wrapper = shallowMount(Dialog, { props: { modelValue: true, trip }, global: { plugins: [createPinia()], stubs: {
AppDialog: { name: 'AppDialog', props: ['confirmText'], emits: ['confirm'], template: '<div><slot/><button @click="$emit(\'confirm\')">{{confirmText}}</button></div>' },
TripLoadingPanel: realPlanner ? false : defineComponent({ name: 'TripLoadingPanel', setup(_, { expose }) { expose({ save: state.plan }); return {} }, template: '<div/>' }),
DataTable: { name: 'DataTable', props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' },
ElInputNumber: { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input/>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
ElButton: { template: '<button><slot/></button>' }, ElDatePicker: true, ElInput: true, LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(trips.getTrip).mockResolvedValue({ trip } as Awaited<ReturnType<typeof trips.getTrip>>)
vi.mocked(offers.listCapacityOffers).mockResolvedValue({ records: [], total: 0, page: 1, size: 1, pages: 0 })
state.plan.mockResolvedValue({ tripNo: 'TR-1', recordVersion: 6 })
vi.mocked(offers.saveCapacityOffer).mockResolvedValue({ offer: draft, replayed: false } as Awaited<ReturnType<typeof offers.saveCapacityOffer>>)
vi.mocked(offers.commandCapacityOffer).mockResolvedValue({ offer: { ...draft, status: 'OPEN', recordVersion: 2 }, replayed: false } as Awaited<ReturnType<typeof offers.commandCapacityOffer>>)
})
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('confirms capacity, saves metadata and publishes from one click using returned versions', async () => {
const wrapper = mount(); await flushPromises()
expect(wrapper.findComponent({ name: 'ElDatePicker' }).attributes('modelvalue')).toBe(trip.plannedDepartureAt)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(state.plan).toHaveBeenCalledOnce()
expect(offers.saveCapacityOffer).toHaveBeenCalledWith(null, expect.objectContaining({ tripRecordVersion: 6, cutoffAt: trip.plannedDepartureAt }))
expect(offers.commandCapacityOffer).toHaveBeenCalledWith('FO-1', 'publish', expect.objectContaining({ tripRecordVersion: 6, offerRecordVersion: 1 }))
expect(wrapper.text()).toContain('查看需求池发布')
})
it('does not publish if capacity validation is incomplete', async () => {
state.plan.mockResolvedValue(undefined)
const wrapper = mount(); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(offers.saveCapacityOffer).not.toHaveBeenCalled(); expect(offers.commandCapacityOffer).not.toHaveBeenCalled()
})
it('retries only the uncertain publish command with the original key', async () => {
vi.mocked(offers.commandCapacityOffer).mockRejectedValueOnce(new Error('network'))
const wrapper = mount(); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(offers.commandCapacityOffer).mock.calls[0]!
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmText')).toBe('重试原请求')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(offers.commandCapacityOffer).toHaveBeenNthCalledWith(2, ...first)
expect(state.plan).toHaveBeenCalledOnce(); expect(offers.saveCapacityOffer).toHaveBeenCalledOnce()
})
it('submits measurements from the embedded panel before saving and publishing', async () => {
const basis = { tripNo: 'TR-1', recordVersion: 5, cargo: [{ dispatchNo: 'DP-1', customerId: '1', dispatchItemId: '2', warehousePalletId: '3', palletNo: 'PLT-1', packages: 1, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' }], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }
vi.mocked(trips.getTripCapacityPlan).mockResolvedValue(basis)
vi.mocked(trips.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 6, planValid: true })
const wrapper = mount(true); await flushPromises()
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 2); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(trips.confirmTripCapacityPlan).toHaveBeenCalledWith('TR-1', expect.objectContaining({ recordVersion: 5, pallets: [{ warehousePalletId: '3', palletSpaces: 1, weightKg: 100, volumeCbm: 2, deliveryIndex: 1 }] }))
expect(offers.commandCapacityOffer).toHaveBeenCalledOnce()
})
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import TripLoadingPanel from './TripLoadingPanel.vue'
import { getTrip, getTripLoading, updateTripPlan } from '../api/trips'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -12,51 +15,121 @@ import type { FleetTrip } from '../tripTypes'
import { useFleetDraftGuard } from '../useFleetDraftGuard'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: FleetTrip }>()
const emit = defineEmits<{ saved: [version: number] }>()
const emit = defineEmits<{ saved: [version: number]; tripUpdated: [trip: FleetTrip] }>()
const access = useAccessPreviewStore(), offer = ref<CapacityOffer | null>(null), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const loaded = ref(false)
const loaded = ref(false), publishAfterSave = ref(false)
const router = useRouter()
const currentTrip = ref<FleetTrip>(props.trip)
const planner = ref<InstanceType<typeof TripLoadingPanel> | null>(null)
const planState = ref('')
let planInitialized = false
const canPlan = computed(() => access.can('platform:fleet:trip:edit'))
const form = reactive({ cutoffAt: '', contactName: '', contactPhone: '', remark: '' })
const pending = ref<{ action: 'save'; offerNo: string | null; body: OfferWrite } | { action: 'publish' | 'pause' | 'resume' | 'close'; offerNo: string; body: OfferCommand } | null>(null)
let generation = 0
const writable = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const guard = useFleetDraftGuard('capacity-offer-settings', open, form, busy, computed(() => !!pending.value))
const guard = useFleetDraftGuard('capacity-offer-settings', open, reactive({ form, planState }), busy, computed(() => !!pending.value))
async function load() {
if (!writable.value || !open.value || pending.value) return
const current = ++generation; loading.value = true; error.value = null; loaded.value = false
try { const value = await listCapacityOffers({ scope: 'ALL', tripNo: props.trip.tripNo, page: 1, size: 1 }); if (current !== generation) return; offer.value = value.records[0] || null; loaded.value = true; Object.assign(form, { cutoffAt: offer.value?.cutoffAt || '', contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' }); await nextTick(); guard.capture() }
try {
const [value, detail] = await Promise.all([listCapacityOffers({ scope: 'ALL', tripNo: props.trip.tripNo, page: 1, size: 1 }), getTrip(props.trip.tripNo)])
if (current !== generation) return
currentTrip.value = detail.trip
offer.value = value.records[0] || null
planInitialized = false; planState.value = ''
Object.assign(form, { cutoffAt: offer.value?.cutoffAt || detail.trip.plannedDepartureAt, contactName: offer.value?.contactName || '', contactPhone: offer.value?.contactPhone || '', remark: offer.value?.remark || '' })
loaded.value = true; await nextTick(); guard.capture()
}
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '发布设置读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(open, value => { generation++; if (value) { if (pending.value) return; void load() } })
watch(open, value => { generation++; if (value) { if (pending.value) return; loaded.value = false; void load() } }, { immediate: true })
async function execute() {
if (!pending.value || busy.value || !writable.value) return
const current = generation; busy.value = true; error.value = null
try { const p = pending.value; const result = p.action === 'save' ? await saveCapacityOffer(p.offerNo, p.body) : await commandCapacityOffer(p.offerNo, p.action, p.body); if (current !== generation) return; offer.value = result.offer; pending.value = null; emit('saved', result.offer.tripRecordVersion); guard.capture(); ElMessage.success('发布设置已更新') }
try {
while (pending.value) {
const p: NonNullable<typeof pending.value> = pending.value
const result: Awaited<ReturnType<typeof saveCapacityOffer>> = p.action === 'save' ? await saveCapacityOffer(p.offerNo, p.body) : await commandCapacityOffer(p.offerNo, p.action, p.body)
if (current !== generation) return
offer.value = result.offer
currentTrip.value = { ...currentTrip.value, recordVersion: result.offer.tripRecordVersion }
emit('saved', result.offer.tripRecordVersion)
pending.value = null
if (p.action === 'save' && publishAfterSave.value) {
pending.value = { action: result.offer.status === 'DRAFT' ? 'publish' : 'resume', offerNo: result.offer.offerNo,
body: { tripRecordVersion: result.offer.tripRecordVersion, offerRecordVersion: result.offer.recordVersion, idempotencyKey: crypto.randomUUID() } }
}
}
guard.capture()
if (publishAfterSave.value) { publishAfterSave.value = false; ElMessage.success('拼车余位已发布,可在需求池登记意向') }
else ElMessage.success('发布设置已更新')
}
catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '发布操作未完成'); if (cause instanceof ApiResponseError) pending.value = null } }
finally { if (current === generation) busy.value = false }
}
async function matchExistingStops() {
if (busy.value || !canPlan.value || pending.value) return
busy.value = true; error.value = null
try {
const cargo = await getTripLoading(currentTrip.value.tripNo)
const stops = currentTrip.value.stops
const next = stops.length === 2 && !stops[1]?.destinationKey && stops[1]?.name.startsWith('待确定卸货点') ? [stops[0]!] : [...stops]
for (const item of cargo.cargo) {
if (!item.destinationKey) throw new Error('排车货物目的地未确认,请先完善排车单')
if (!next.some(stop => stop.destinationKey === item.destinationKey)) next.push({ name: item.destinationLabel || '卸货站', destinationKey: item.destinationKey })
}
const result = await updateTripPlan(currentTrip.value.tripNo, { recordVersion: cargo.recordVersion, plannedDepartureAt: currentTrip.value.plannedDepartureAt, expectedDriverEndAt: currentTrip.value.expectedDriverEndAt, stops: next, remark: currentTrip.value.remark, reason: '发布前按货物匹配卸货站' })
currentTrip.value = result.trip; emit('tripUpdated', result.trip); emit('saved', result.trip.recordVersion)
if (offer.value) offer.value = { ...offer.value, tripRecordVersion: result.trip.recordVersion }
} catch (cause) { error.value = getApiErrorDetails(cause, '卸货站匹配未完成') }
finally { busy.value = false }
}
function planChanged(value: string) {
planState.value = value
if (!planInitialized) { planInitialized = true; void nextTick(() => guard.capture()) }
}
async function save() {
if (busy.value) return
if (pending.value) { await execute(); return }
if (loading.value || !loaded.value || !writable.value || offer.value && !offer.value.actions.canEdit) return
if (!form.cutoffAt) { ElMessage.warning('请填写交货截止时间'); return }
pending.value = { action: 'save', offerNo: offer.value?.offerNo || null, body: { tripNo: props.trip.tripNo, tripRecordVersion: offer.value?.tripRecordVersion ?? props.trip.recordVersion, offerRecordVersion: offer.value?.recordVersion ?? null, cutoffAt: form.cutoffAt, contactName: form.contactName.trim() || null, contactPhone: form.contactPhone.trim() || null, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() } }
if (new Date(form.cutoffAt).getTime() <= Date.now() || new Date(form.cutoffAt).getTime() > new Date(currentTrip.value.plannedDepartureAt).getTime()) { ElMessage.warning('交货截止须晚于现在且不晚于计划发车'); return }
busy.value = true
let plan
try { plan = await planner.value?.save() } finally { busy.value = false }
if (!plan) return
currentTrip.value = { ...currentTrip.value, recordVersion: plan.recordVersion }
emit('saved', plan.recordVersion)
publishAfterSave.value = true
pending.value = { action: 'save', offerNo: offer.value?.offerNo || null, body: { tripNo: currentTrip.value.tripNo, tripRecordVersion: currentTrip.value.recordVersion, offerRecordVersion: offer.value?.recordVersion ?? null, cutoffAt: form.cutoffAt, contactName: form.contactName.trim() || null, contactPhone: form.contactPhone.trim() || null, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() } }
await execute()
}
async function command(action: 'publish' | 'pause' | 'resume' | 'close') {
if (!offer.value || loading.value || !loaded.value || busy.value || pending.value || !writable.value) return
if (guard.dirty.value) { ElMessage.warning('请先保存发布信息,再执行发布状态操作'); return }
if (guard.dirty.value) { ElMessage.warning('有未保存修改,请先确认发布或关闭后重新打开再操作'); return }
publishAfterSave.value = false
const labels = { publish: '发布运力', pause: '暂停接单', resume: '恢复接单', close: '关闭发布' }
try { await ElMessageBox.confirm(`${labels[action]} ${offer.value.offerNo}?已确认预订及客户订单不会被取消。`, labels[action], { customClass: 'admin-density-confirm' }) } catch { return }
pending.value = { action, offerNo: offer.value.offerNo, body: { tripRecordVersion: offer.value.tripRecordVersion, offerRecordVersion: offer.value.recordVersion, idempotencyKey: crypto.randomUUID() } }; await execute()
}
onBeforeUnmount(() => { generation++ })
</script>
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布设置" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '保存发布信息'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
<template><AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" title="发布拼车余位" width="1080px" :show-confirm="writable && (!offer || offer.actions.canEdit || !!pending)" :confirm-text="pending ? '重试原请求' : '确认发布'" :confirm-loading="busy" :confirm-disabled="loading || !loaded" @confirm="save">
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason && offer.status !== 'DRAFT'">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
<template v-if="loaded && open && (!offer || offer.actions.canEdit)">
<h3>本车货物与余位</h3>
<p>卸货站已按货物匹配。核对下方数据,确认发布时一并保存容量;多个目的地的先后顺序请在车次计划中确认。</p>
<el-button v-if="canPlan && planner?.destinationIssue" :disabled="busy || !!pending" @click="matchExistingStops">按货物补齐卸货站</el-button>
<TripLoadingPanel ref="planner" :trip="currentTrip" :editable="canPlan && !busy && !pending" planning embedded @changed="planChanged" />
</template>
<p v-if="!canPlan">已有有效容量可直接发布;需要修改容量时请联系有车次编辑权限的调度。</p>
<el-button v-if="offer?.status === 'OPEN'" :disabled="busy || !!pending" type="primary" link @click="open = false; router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: offer.offerNo } })">查看需求池发布</el-button>
<el-form v-loading="loading" label-position="top" class="density-form offer-settings-form" :disabled="busy || loading || !!pending || !writable || !!offer && !offer.actions.canEdit">
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /></el-form-item><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item>
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /><span>默认与计划发车时间一致,可提前截止。</span></el-form-item><details class="offer-settings-wide"><summary>联系人与交货说明(选填)</summary><div class="offer-settings-form"><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item></div></details>
</el-form>
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPublish" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('publish')">发布运力</el-button><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canResume" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="primary" @click="command('resume')">重新核对并恢复</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
</AppDialog></template>
<style scoped>.offer-settings-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.offer-settings-wide{grid-column:1/-1}.offer-settings-form :deep(.el-date-editor){width:100%}</style>
@@ -8,11 +8,13 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getTripLoading, confirmTripLoading, getTripCapacityPlan, confirmTripCapacityPlan } from '../api/trips'
import type { FleetTrip, TripLoading, TripCapacityPlanning, TripMeasurement } from '../tripTypes'
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean }>()
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning] }>()
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean; embedded?: boolean }>()
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning]; changed: [value: string] }>()
type PalletRow = { warehousePalletId: string; palletNo: string; destinations: string; destinationKeys: string[]; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }
const info = ref<TripLoading | TripCapacityPlanning | null>(null), rows = ref<PalletRow[]>([]), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let sequence = 0
const baseline = ref('')
watch(rows, value => emit('changed', JSON.stringify(value)), { deep: true })
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实物托盘', minWidth: 150 },
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 160 }, { key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 175 },
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 190 }, { key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 190 },
@@ -45,6 +47,7 @@ function apply(value: TripLoading | TripCapacityPlanning) {
palletSpaces: saved?.palletSpaces ?? 1, weightKg: saved?.weightKg ?? null, volumeCbm: saved?.volumeCbm ?? null, deliveryIndex: saved && saved.deliveryIndex > 0 && props.trip.stops[saved.deliveryIndex]?.destinationKey === cargo.destinationKey ? saved.deliveryIndex : (index > 0 ? index : null) })
}
rows.value = [...pallets.values()]
baseline.value = JSON.stringify(rows.value)
}
async function load() {
const current = ++sequence; loading.value = true; error.value = null
@@ -53,7 +56,9 @@ async function load() {
finally { if (current === sequence) loading.value = false }
}
async function save() {
if (busy.value || loading.value || !props.editable || !info.value) return
if (busy.value || loading.value || !info.value) return
if (props.planning && 'planValid' in info.value && info.value.planValid && baseline.value === JSON.stringify(rows.value)) return info.value
if (!props.editable) { ElMessage.warning('计划容量需重新核对,请由有车次编辑权限的调度操作'); return }
if (destinationIssue.value) { ElMessage.warning(destinationIssue.value); return }
if (rows.value.some(row => !matchingStops(row).some(stop => stop.index === row.deliveryIndex))) { ElMessage.warning('请选择与货物目的地匹配的卸货站'); return }
if (!rows.value.length || rows.value.some(row => row.weightKg == null || row.volumeCbm == null || row.deliveryIndex == null)) { ElMessage.warning(props.planning ? '请填写每托预计重量、体积和卸货站' : '请填写每托实测重量、体积和卸货站'); return }
@@ -65,7 +70,7 @@ async function save() {
const body = { recordVersion: snapshot.recordVersion, manifestHash: snapshot.manifestHash, pallets, reason: props.planning ? '核对本车自有货计划载荷' : '核对实物托盘及实际装载' }
if (props.planning) {
const result = await confirmTripCapacityPlan(props.trip.tripNo, body)
if (current === sequence) { apply(result); emit('planned', result); ElMessage.success('计划容量已核对') }
if (current === sequence) { apply(result); emit('planned', result); if (!props.embedded) ElMessage.success('计划容量已核对'); return result }
} else {
const result = await confirmTripLoading(props.trip.tripNo, body)
if (current === sequence) { apply(result); emit('saved', result); ElMessage.success('实际装载已确认') }
@@ -74,6 +79,7 @@ async function save() {
finally { busy.value = false }
}
watch(() => [props.trip.tripNo, props.trip.recordVersion, props.planning], () => { if (info.value?.tripNo !== props.trip.tripNo || info.value.recordVersion !== props.trip.recordVersion || !!props.planning !== ('capacityPlan' in info.value)) void load() }, { immediate: true })
defineExpose({ save, destinationIssue })
onBeforeUnmount(() => { sequence++ })
</script>
<template>
@@ -89,8 +95,8 @@ onBeforeUnmount(() => { sequence++ })
<template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template>
<template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template>
<template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template>
<template #stop="{ row }"><el-select v-model="row.deliveryIndex" :disabled="!editable || busy || loading || !matchingStops(row as PalletRow).length" placeholder="请先匹配货物目的地"><el-option v-for="stop in matchingStops(row as PalletRow)" :key="stop.index" :value="stop.index" :label="stop.name" /></el-select></template>
<template #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="loading || !!info?.blockReason || !!destinationIssue || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
<template #stop="{ row }"><span v-if="matchingStops(row as PalletRow).length === 1">{{ matchingStops(row as PalletRow)[0]?.name }}</span><el-select v-else v-model="row.deliveryIndex" :disabled="!editable || busy || loading || !matchingStops(row as PalletRow).length" placeholder="请先匹配货物目的地"><el-option v-for="stop in matchingStops(row as PalletRow)" :key="stop.index" :value="stop.index" :label="stop.name" /></el-select></template>
<template v-if="!embedded" #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="loading || !!info?.blockReason || !!destinationIssue || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
</DataTable>
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
</div>
@@ -184,7 +184,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
</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-trips' })">返回车次列表</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布设置</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</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-trips' })">返回车次列表</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
<div v-loading="loading"><template v-if="detail">
@@ -218,7 +218,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
</template>
<FormSection v-if="activeTab === 'history'" class="density-form-section" title="操作历史"><DataTable v-model:page="historyPage" :page-size="20" :data="history" :total="historyTotal" :loading="historyLoading" :columns="historyColumns" row-key="recordVersion"><template #time="{ row }">{{ time(row.occurredAt) }}</template><template #action="{ row }">{{ actionLabels[row.action] || '更新记录' }}</template></DataTable></FormSection>
<TripPlanDialog v-model="editorOpen" :trip="detail.trip" @saved="accept" />
<CapacityOfferSettingsDialog v-model="offerSettingsOpen" :trip="detail.trip" @saved="detail.trip.recordVersion = $event" />
<CapacityOfferSettingsDialog v-model="offerSettingsOpen" :trip="detail.trip" @trip-updated="detail.trip = $event" @saved="detail.trip.recordVersion = $event" />
</template></div>
</DetailPageLayout></AdminPage>
</template>