Files
admin-web/src/modules/overseas-master-data/pages/CapacityOffersView.vue
T
2026-10-20 21:33:46 +08:00

166 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
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 CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
import { listLooseboardDemands, listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type LooseboardDemand, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const router = useRouter(), access = useAccessPreviewStore()
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const tab = ref<'offers' | 'inquiries' | 'demands'>(manage.value ? 'inquiries' : 'offers')
const filters = ref<QueryModel>(manage.value ? { scope: 'TODO' } : {}), applied = ref<QueryModel>({ ...filters.value })
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 demands = ref<LooseboardDemand[]>([]), selectedDemand = ref<LooseboardDemand | null>(null)
const demandColumns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 130, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', label: '散板原单', minWidth: 220 }, { key: 'customerId', label: '客户编号', minWidth: 140 }, { key: 'origin', label: '起运地', minWidth: 170 }, { key: 'destination', label: '目的地', minWidth: 170 }, { key: 'productName', label: '货物', minWidth: 160 }, { key: 'estimatedPalletCount', label: '预计实托', width: 110 }, { key: 'packageCount', label: '箱数', width: 100 }]
function matchDemand(row: LooseboardDemand) { selectedDemand.value = row; switchTab('offers') }
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
async function fulfillmentSaved() {
const row = fulfillmentRow.value
fulfillmentOpen.value = false
if (!row) return
const current = ++followupSequence
await load()
try {
const updated = await getCapacityInquiry(row.bookingNo)
if (current === followupSequence) await inquiryAction('view', updated)
} catch (cause) { if (current === followupSequence) error.value = getApiErrorDetails(cause, '操作已完成,下一步进度读取失败,请刷新原登记') }
}
let followupSequence = 0
function openFulfillment(key: string, row: CapacityInquiry) {
if (key === 'business-order') { const target = inquiryOrderTarget(row, access.can); if (target) void router.push(target); return true }
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 }, query: inquirySourceQuery(row) }); return true }
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: inquirySourceQuery(row) }); 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 fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [
{ 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: '全部登记' }, { value: 'TODO', label: '待我处理' }] } as QueryField] : []),
])
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' },
...(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' | 'demands') { 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
try {
const q = applied.value
if (tab.value === 'demands') {
const value = await listLooseboardDemands({ page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined }); if (current === sequence) { demands.value = value.records; total.value = value.total }
} else if (tab.value === 'offers') {
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 === '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, '散板需求读取失败') }
finally { if (current === sequence) loading.value = false }
}
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
function accept(value: InquiryResult) {
if (selectedDemand.value && value.inquiry.businessOrderNo === selectedDemand.value.orderNo) { selectedDemand.value = null; switchTab('inquiries') }
const index = inquiries.value.findIndex(row => row.bookingNo === value.inquiry.bookingNo)
if (index >= 0) inquiries.value[index] = value.inquiry
for (const row of inquiries.value) if (row.offerNo === value.offer.offerNo) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
selectedOffer.value = value.offer
}
const commands = useCapacityInquiryCommands('capacity-pool-command', accept)
async function inquiryAction(key: string, row: CapacityInquiry) {
followupSequence++
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' || 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' && key !== 'confirm'; editorOpen.value = true
}
async function recheckEditor() {
if (!editing.value) return
const current = ++editorSequence
try {
const detail = await getCapacityOffer(editing.value.offerNo)
const checked = detail.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
if (current === editorSequence) { selectedOffer.value = detail.offer; editing.value = checked }
} catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '最新意向核对失败') }
}
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="散板需求">
<div class="density-status-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" :aria-selected="tab === 'demands'" :class="{ 'is-active': tab === 'demands' }" @click="switchTab('demands')">待匹配散板</button><button role="tab" :aria-selected="tab === 'offers'" :class="{ 'is-active': tab === 'offers' }" @click="switchTab('offers')">可拼车次</button><button role="tab" :aria-selected="tab === 'inquiries'" :class="{ 'is-active': tab === 'inquiries' }" @click="switchTab('inquiries')">我的登记</button></div>
<p v-if="selectedDemand && tab === 'offers'">正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。<el-button link @click="selectedDemand = null">取消选择</el-button></p>
<DataTable v-if="tab === 'demands'" appearance="integrated" :stripe="false" preference-key="fleet-looseboard-demands" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="demands" :columns="demandColumns" :loading="loading" :total="total" row-key="orderNo" 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 #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" @click="matchDemand(row as LooseboardDemand)">匹配车次</el-button></div></template>
</DataTable>
<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-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">
<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" 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 #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 ? '可登记,确认后保位' : 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-if="tab === 'inquiries'" 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 === 'TODO' ? '待我处理' : applied.scope === 'ALL' ? '全部登记' : '我的登记' }}</h1></template>
<template #filters>
<div class="integrated-query-group">
<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>
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
</ListPageLayout></AdminPage></template>