优化
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import OwnedBusinessDetailLayout from '../components/OwnedBusinessDetailLayout.vue'
|
||||
import OwnedBusinessOperationsEditor from '../components/OwnedBusinessOperationsEditor.vue'
|
||||
import OwnedBusinessPhotosEditor from '../components/OwnedBusinessPhotosEditor.vue'
|
||||
import { getOwnedBusiness, getOwnedBusinessPhotoContent, listOwnedBusinessHistory } from '../api/ownedBusiness'
|
||||
import { ownedBusinessActionLabels, ownedBusinessStages, ownedBusinessText as text, ownedBusinessPhotoRoles, type OwnedBusinessDetail, type OwnedBusinessHistory, type OwnedBusinessPhoto } from '../ownedBusinessTypes'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const dispatchNo = computed(() => typeof route.params.dispatchNo === 'string' ? route.params.dispatchNo : '')
|
||||
const detail = ref<OwnedBusinessDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), activeTab = ref<'task'|'route'|'history'>('task')
|
||||
const editorOpen = ref(false), photosOpen = ref(false), busy = ref(false)
|
||||
const history = ref<OwnedBusinessHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null), previewError = ref<ApiErrorDetails | null>(null)
|
||||
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
|
||||
const previewUrls = new Set<string>()
|
||||
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
|
||||
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
|
||||
const taskFields = computed(() => {
|
||||
const row = detail.value?.row
|
||||
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['实际里程(mile)', row?.operations.actualDistanceMiles ?? '未登记'], ['油耗(L/100km)', row?.operations.fuelConsumptionLitresPer100Km ?? '未登记']]
|
||||
})
|
||||
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
||||
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
|
||||
function photoLabel(photo: OwnedBusinessPhoto) { return ownedBusinessPhotoRoles.find(role => role.slot === photo.slot)?.label || '照片' }
|
||||
async function load() {
|
||||
if (!active) return
|
||||
const request = ++generation, no = dispatchNo.value; loading.value = true; error.value = null
|
||||
try { if (!no || !access.can(permission.ownedBusinessList)) throw new Error('缺少排车业务号或查看权限'); const value = await getOwnedBusiness(no); if (active && request === generation) { if (value.row.dispatchNo !== no) throw new Error('当前运营资料与排车业务号不一致'); detail.value = value; if (activeTab.value === 'history') void loadHistory() } }
|
||||
catch (cause) { if (active && request === generation) { detail.value = null; error.value = getApiErrorDetails(cause, '自有业务详情读取失败') } }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
async function loadHistory() {
|
||||
if (!active || activeTab.value !== 'history' || !detail.value) return
|
||||
const request = ++historyGeneration, no = dispatchNo.value; historyLoading.value = true; historyError.value = null
|
||||
try { const value = await listOwnedBusinessHistory(no, historyPage.value, historySize.value); if (active && request === historyGeneration && no === dispatchNo.value) { history.value = value.records; historyTotal.value = value.total } }
|
||||
catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '操作记录读取失败') }
|
||||
finally { if (request === historyGeneration) historyLoading.value = false }
|
||||
}
|
||||
async function preview(photo: OwnedBusinessPhoto, revision: string | null) {
|
||||
const no = dispatchNo.value, request = generation; previewError.value = null
|
||||
if (revision == null) { previewError.value = { message: '历史照片缺少运营资料版本,请重新读取' }; return }
|
||||
const windowRef = window.open('', '_blank')
|
||||
if (!windowRef) { previewError.value = { message: '请允许本站打开图片预览窗口' }; return }
|
||||
try { const blob = await getOwnedBusinessPhotoContent(no, photo, revision); if (!active || request !== generation || no !== dispatchNo.value) { windowRef.close(); return }; const url = URL.createObjectURL(blob); previewUrls.add(url); windowRef.location.href = url }
|
||||
catch (cause) { windowRef.close(); previewError.value = getApiErrorDetails(cause, '历史照片读取失败') }
|
||||
}
|
||||
function saved(value: OwnedBusinessDetail) { if (value.row.dispatchNo !== dispatchNo.value) return; detail.value = value; if (activeTab.value === 'history') void loadHistory() }
|
||||
watch([activeTab,historyPage,historySize], () => { if (active) void loadHistory() })
|
||||
watch(dispatchNo, () => { generation++; historyGeneration++; detail.value = null; history.value = []; historyTotal.value = 0; activeTab.value = 'task'; editorOpen.value = false; photosOpen.value = false; if (active) void load() })
|
||||
onMounted(load)
|
||||
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
|
||||
onDeactivated(() => { active = false; generation++; historyGeneration++; loading.value = false; historyLoading.value = false; editorOpen.value = false; photosOpen.value = false })
|
||||
onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previewUrls.forEach(url => URL.revokeObjectURL(url)) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OwnedBusinessDetailLayout v-model:active-tab="activeTab" :dispatch-no="detail?.row.dispatchNo || dispatchNo" :status-text="detail ? ownedBusinessStages[detail.row.stage] : ''" :loading="loading" @back="router.push('/overseas-master-data/owned-business')">
|
||||
<template #actions><el-button v-if="detail && access.can(permission.ownedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.ownedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
|
||||
<template #state><LoadErrorState v-if="error" title="自有业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
|
||||
<template #task><dl v-if="detail" class="density-fields owned-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
|
||||
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="owned-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable><section class="owned-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
|
||||
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ ownedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><p>里程 {{ row.operations.actualDistanceMiles ?? '未登记' }} mile · 油耗 {{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }} L/100km</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></template>
|
||||
</OwnedBusinessDetailLayout>
|
||||
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
|
||||
<OwnedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.owned-business-fields { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); padding: var(--admin-space-4); }
|
||||
.owned-business-fields > div { min-width: 0; overflow-wrap: anywhere; }
|
||||
.owned-business-route { padding: var(--admin-space-4); border-bottom: 1px solid var(--admin-table-border); }
|
||||
.owned-business-route h3 { font-size: inherit; margin: 0 0 var(--admin-space-3); }
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import QueryToolbar, { type QueryField, type QueryModel, type QueryOption } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OwnedBusinessListLayout from '../components/OwnedBusinessListLayout.vue'
|
||||
import OwnedBusinessOperationsEditor from '../components/OwnedBusinessOperationsEditor.vue'
|
||||
import OwnedBusinessPhotosEditor from '../components/OwnedBusinessPhotosEditor.vue'
|
||||
import { getOwnedBusiness, listOwnedBusiness } from '../api/ownedBusiness'
|
||||
import { ownedBusinessStages, ownedBusinessText as text, type OwnedBusinessDetail, type OwnedBusinessList, type OwnedBusinessQuery, type OwnedBusinessRow, type OwnedBusinessStage } from '../ownedBusinessTypes'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const result = ref<OwnedBusinessList | null>(null), page = ref(1), size = ref(20), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ plannedRange: [], keyword: '', stage: '', warehouseId: '' })
|
||||
const warehouseOptions = ref<QueryOption[]>([]), warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<OwnedBusinessDetail | null>(null), editorOpen = ref(false), photosOpen = ref(false), opening = ref(false), mutating = ref(false)
|
||||
let applied: Omit<OwnedBusinessQuery,'page'|'size'> = {}, generation = 0, openGeneration = 0, active = true, initialActivation = true
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'plannedRange', type: 'daterange', label: '计划发车日期', width: 270, props: { valueFormat: 'YYYY-MM-DD', 'aria-label': '计划发车日期范围' } },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 230, placeholder: '排车单号 / 车牌 / 目的仓', props: { maxlength: 128 } },
|
||||
{ key: 'stage', type: 'select', label: '阶段', width: 140, options: Object.entries(ownedBusinessStages).map(([value,label]) => ({ value,label })) },
|
||||
{ key: 'warehouseId', type: 'select', label: '提货仓库', width: 180, options: warehouseOptions.value, props: { disabled: !access.can('platform:warehouse:list') } },
|
||||
])
|
||||
const summary = computed(() => [
|
||||
['排车数', result.value?.summary.dispatchCount], ['装车完成', result.value?.summary.afterLoadingCount], ['派送中', result.value?.summary.deliveringCount], ['派送完成', result.value?.summary.deliveredCount],
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
|
||||
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
|
||||
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
|
||||
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
|
||||
{ key: 'money', label: '整车总金额 / 单价', minWidth: 170, slotName: 'money' },
|
||||
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
|
||||
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
|
||||
]
|
||||
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
||||
async function load() {
|
||||
if (!active) return
|
||||
const request = ++generation
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
if (!access.can(permission.ownedBusinessList)) throw new Error('没有自有业务查看权限')
|
||||
const value = await listOwnedBusiness({ ...applied, page: page.value, size: size.value })
|
||||
if (active && request === generation) result.value = value
|
||||
} catch (cause) { if (active && request === generation) { result.value = null; error.value = getApiErrorDetails(cause, '自有业务读取失败') } }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
function search() {
|
||||
try {
|
||||
const range = filters.value.plannedRange
|
||||
const dates = Array.isArray(range) ? range : []
|
||||
if (dates.length && (dates.length !== 2 || !dayjs(String(dates[0])).isValid() || !dayjs(String(dates[1])).isValid() || dayjs(String(dates[1])).isBefore(dayjs(String(dates[0]))))) throw new Error('请选择有效的计划发车日期范围')
|
||||
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, stage: (filters.value.stage || undefined) as OwnedBusinessStage | undefined, warehouseId: String(filters.value.warehouseId || '') || undefined,
|
||||
plannedDepartureFrom: dates.length ? dayjs(String(dates[0])).startOf('day').toISOString() : undefined, plannedDepartureTo: dates.length ? dayjs(String(dates[1])).add(1,'day').startOf('day').toISOString() : undefined }
|
||||
if (page.value !== 1) page.value = 1; else void load()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '筛选条件无效') }
|
||||
}
|
||||
async function loadWarehouses() {
|
||||
if (!access.can('platform:warehouse:list')) return
|
||||
try { const values = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }); warehouseOptions.value = values.map(value => ({ value: String(value.id), label: value.warehouseName })) }
|
||||
catch (cause) { warehouseError.value = getApiErrorDetails(cause, '仓库目录读取失败,可继续使用其他筛选') }
|
||||
}
|
||||
async function openAction(row: OwnedBusinessRow, kind: 'operations' | 'photos') {
|
||||
if (opening.value || mutating.value) return
|
||||
const code = kind === 'operations' ? permission.ownedBusinessEdit : permission.ownedBusinessList
|
||||
if (!access.can(code) || kind === 'operations' && !row.actions.edit.allowed) return
|
||||
const request = ++openGeneration; opening.value = true; error.value = null
|
||||
try { const value = await getOwnedBusiness(row.dispatchNo); if (!active || request !== openGeneration) return; detail.value = value; if (kind === 'operations') editorOpen.value = true; else photosOpen.value = true }
|
||||
catch (cause) { if (request === openGeneration) error.value = getApiErrorDetails(cause, '当前运营资料读取失败') }
|
||||
finally { if (request === openGeneration) opening.value = false }
|
||||
}
|
||||
function saved(value: OwnedBusinessDetail) { detail.value = value; void load() }
|
||||
watch([page,size], () => { if (active) void load() })
|
||||
onMounted(() => { void load(); void loadWarehouses() })
|
||||
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
|
||||
onDeactivated(() => { active = false; generation++; openGeneration++; loading.value = false; opening.value = false; editorOpen.value = false; photosOpen.value = false })
|
||||
onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OwnedBusinessListLayout>
|
||||
<template #statistics><section class="owned-business-statistics" aria-label="完整筛选范围自有业务统计" :aria-busy="loading"><div v-for="item in summary" :key="String(item[0])"><span>{{ item[0] }}</span><strong>{{ item[1] ?? '—' }}</strong></div></section></template>
|
||||
<DataTable appearance="integrated" :stripe="false" v-model:page="page" v-model:page-size="size" :data="result?.records || []" :columns="columns" :total="result?.total || 0" :loading="loading" row-key="dispatchNo" preference-key="owned-business-table" empty-text="当前筛选范围暂无已装车的自有车辆排车单">
|
||||
<template #toolbar-start><h1>自有业务</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自有业务筛选" preference-key="owned-business-query" :loading="loading" @search="search" /><small>日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</small></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="自有业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
|
||||
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
|
||||
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
|
||||
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
|
||||
<template #fuel="{ row }">{{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }}</template>
|
||||
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
|
||||
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ ownedBusinessStages[row.stage as OwnedBusinessStage] }}</template>
|
||||
</DataTable>
|
||||
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
||||
<OwnedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
||||
</OwnedBusinessListLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.owned-business-statistics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); background: white; border: 1px solid var(--admin-table-border); }
|
||||
.owned-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--admin-table-border); }
|
||||
.owned-business-statistics > div:last-child { border-right: 0; }
|
||||
.owned-business-statistics strong { display: block; margin-top: var(--admin-space-2); font-size: var(--admin-font-title); }
|
||||
.owned-business-destination + .owned-business-destination { margin-top: var(--admin-space-3); border-top: 1px solid var(--admin-table-border); padding-top: var(--admin-space-3); }
|
||||
.owned-business-destination small { display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import dayjs from 'dayjs'
|
||||
import OwnedBusinessView from './OwnedBusinessView.vue'
|
||||
import OwnedBusinessDetailView from './OwnedBusinessDetailView.vue'
|
||||
import { ownedBusinessFixture } from '../components/OwnedBusinessFixtures'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const calls = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), history: vi.fn(), content: vi.fn(), warehouses: vi.fn(), push: vi.fn(), allowed: new Set<string>() }))
|
||||
vi.mock('../api/ownedBusiness', () => ({ listOwnedBusiness: calls.list, getOwnedBusiness: calls.detail, listOwnedBusinessHistory: calls.history, getOwnedBusinessPhotoContent: calls.content }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: calls.warehouses }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => calls.allowed.has(code) }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: calls.push }), useRoute: () => ({ params: { dispatchNo: 'DS-OWN-1' } }) }))
|
||||
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
|
||||
const Button = defineComponent({ props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' })
|
||||
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
|
||||
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
|
||||
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
|
||||
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } })
|
||||
const Editor = defineComponent({ props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
|
||||
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OwnedBusinessOperationsEditor: Editor, OwnedBusinessPhotosEditor: Editor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); calls.allowed = new Set(Object.values(permission)); calls.allowed.add('platform:warehouse:list')
|
||||
const detail = ownedBusinessFixture()
|
||||
calls.list.mockResolvedValue({ records:[detail.row],page:1,size:20,total:41,summary:{dispatchCount:41,afterLoadingCount:30,deliveringCount:10,deliveredCount:1} })
|
||||
calls.detail.mockResolvedValue(detail); calls.history.mockResolvedValue({records:[],page:1,size:20,total:0}); calls.warehouses.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('owned business views', () => {
|
||||
it('uses one list request for all statistics and never hydrates each row', async () => {
|
||||
const wrapper = mount(OwnedBusinessView,{global}); await flushPromises()
|
||||
expect(calls.list).toHaveBeenCalledTimes(1); expect(calls.detail).not.toHaveBeenCalled(); expect(calls.content).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[aria-label="完整筛选范围自有业务统计"]').text()).toContain('排车数41')
|
||||
expect(wrapper.text()).not.toContain('今日排车')
|
||||
expect(wrapper.findComponent(Table).props('columns')[0].key).toBe('actions')
|
||||
expect(wrapper.findComponent(Table).props('total')).toBe(41)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('applies planned date half-open boundaries only on query and keeps all stage counts from the response', async () => {
|
||||
const wrapper=mount(OwnedBusinessView,{global});await flushPromises();const query=wrapper.findComponent(Query)
|
||||
query.vm.$emit('update:modelValue',{plannedRange:['2026-10-20','2026-10-21'],keyword:' FBA-1 ',stage:'AFTER_LOADING',warehouseId:'1'});await wrapper.vm.$nextTick()
|
||||
expect(calls.list).toHaveBeenCalledTimes(1)
|
||||
query.vm.$emit('search');await flushPromises()
|
||||
expect(calls.list).toHaveBeenLastCalledWith({page:1,size:20,keyword:'FBA-1',stage:'AFTER_LOADING',warehouseId:'1',plannedDepartureFrom:dayjs('2026-10-20').startOf('day').toISOString(),plannedDepartureTo:dayjs('2026-10-21').add(1,'day').startOf('day').toISOString()})
|
||||
expect(wrapper.find('[aria-label="完整筛选范围自有业务统计"]').text()).toContain('装车完成30')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens the operations modal on demand with auxiliary zero version instead of the dispatch version', async () => {
|
||||
const wrapper=mount(OwnedBusinessView,{global});await flushPromises()
|
||||
await wrapper.findAll('button').find(button=>button.text()==='登记运营信息')!.trigger('click');await flushPromises()
|
||||
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OWN-1')
|
||||
const editor=wrapper.findAllComponents(Editor).find(child=>child.props('modelValue'))!
|
||||
expect(editor.props('detail').row.recordVersion).toBe('0');expect(editor.props('detail').dispatch.recordVersion).toBe('8')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps a list-only user read-only while retaining detail and authorized photo reads', async () => {
|
||||
calls.allowed=new Set([permission.ownedBusinessList])
|
||||
const wrapper=mount(OwnedBusinessView,{global});await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('登记运营信息');expect(wrapper.text()).toContain('查看照片');expect(calls.warehouses).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('loads aggregated history only on its tab, preserving null operations on dispatch events', async () => {
|
||||
calls.history.mockResolvedValue({records:[{eventSource:'DISPATCH',recordVersion:null,dispatchRecordVersion:'8',action:'LOADING_COMPLETE',operatorName:null,occurredAt:'2026-10-20T09:00:00Z',reason:null,operations:null,photos:[]},{eventSource:'OPERATIONS',recordVersion:'1',dispatchRecordVersion:null,action:'SAVE_OPERATIONS',operatorName:'登记员',occurredAt:'2026-10-20T10:00:00Z',reason:null,operations:ownedBusinessFixture().row.operations,photos:[]}],page:1,size:20,total:2})
|
||||
const wrapper=mount(OwnedBusinessDetailView,{global});await flushPromises()
|
||||
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.history).not.toHaveBeenCalled()
|
||||
wrapper.findComponent(Tabs).vm.$emit('update:modelValue','history');await flushPromises()
|
||||
expect(calls.history).toHaveBeenCalledExactlyOnceWith('DS-OWN-1',1,20)
|
||||
expect(wrapper.text()).toContain('原排车');expect(wrapper.text()).toContain('运营登记')
|
||||
const table=wrapper.findComponent(Table), records=table.props('data')
|
||||
expect(records[0].eventKey).not.toBe(records[1].eventKey);expect(records[0].operations).toBeNull()
|
||||
table.vm.$emit('update:page',2);await flushPromises();expect(calls.history).toHaveBeenLastCalledWith('DS-OWN-1',2,20)
|
||||
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.content).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows authoritative large money text and unit price without rounding or recomputing from two cargo rows', async () => {
|
||||
const detail=ownedBusinessFixture();detail.row.operations.money={currency:'USD',amount:'9999999999999999.99'};detail.row.unitPrice='9999999999999999.99'
|
||||
calls.detail.mockResolvedValue(detail)
|
||||
const wrapper=mount(OwnedBusinessDetailView,{global});await flushPromises()
|
||||
expect(wrapper.text()).toContain('USD 9999999999999999.99');expect(wrapper.text()).not.toContain('10000000000000000')
|
||||
expect(wrapper.text()).not.toContain('4999999999999999')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user