Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test
This commit is contained in:
@@ -11,7 +11,7 @@ import { listVehicles, listDrivers, pickupStatusLabels, type VehicleDetail, type
|
||||
import { getTransport, assignTransport, transportOccupancy, cancelTransport, releaseTransport, commandTransport, uploadTransport, downloadTransportEvidence, transportLabels, type TransportDetail } from '../api/transportTasks'
|
||||
import { dispatchActionPermissions as permission } from '../dispatchPresentation'
|
||||
|
||||
const props = defineProps<{ dispatchNo: string }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const props = defineProps<{ dispatchNo: string; pickupApproved?: boolean }>(), emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore(), detail = ref<TransportDetail | null>(null), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
|
||||
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), vehicleId = ref(''), driverId = ref(''), reason = ref('')
|
||||
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
|
||||
@@ -109,13 +109,13 @@ onBeforeRouteLeave(() => !locked.value || window.confirm('运输请求尚待核
|
||||
<el-button :loading="busy" :disabled="candidateBusy || !vehicleId || !driverId" @click="assign">保存车辆司机</el-button>
|
||||
</el-form>
|
||||
<div class="density-actions">
|
||||
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId" @click="command('depart')">确认发车</el-button>
|
||||
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.DEPART)" :disabled="locked || !trip.vehicleId || !trip.driverId || (detail.taskType === 'PICKUP' && !props.pickupApproved)" @click="command('depart')">确认发车</el-button>
|
||||
<el-button v-if="trip.status === 'PLANNED' && access.can(permission.CANCEL)" :disabled="locked" @click="command('cancel')">取消排车</el-button>
|
||||
<el-button v-if="trip.status === 'IN_TRANSIT' && editable" :disabled="locked || (detail.taskType === 'PICKUP' ? !detail.pickups.length || detail.pickups.some(row => row.status !== 'ARRIVED') : !truckEvidence.length)" @click="command('complete')">确认运输完成</el-button>
|
||||
<template v-if="trip.status === 'COMPLETED' && editable"><el-button v-if="!trip.vehicleRelease" :disabled="locked" @click="openRelease('VEHICLE')">结束车辆任务</el-button><el-button v-if="!trip.driverRelease" :disabled="locked" @click="openRelease('DRIVER')">结束司机任务</el-button></template>
|
||||
<el-button :disabled="locked" @click="load">刷新运输进度</el-button>
|
||||
</div>
|
||||
<p v-if="detail.taskType === 'PICKUP'">送仓后由仓库独立清点收货,取货数量保留为运输事实。</p>
|
||||
<p v-if="detail.taskType === 'PICKUP'">{{ props.pickupApproved ? '两级审核已通过,可发车取货。' : '请维护车辆信息并提交调度审核,经调度和理货员审核通过后发车取货。' }}送仓后由仓库独立清点收货,取货数量保留为运输事实。</p>
|
||||
<el-table v-if="detail.taskType === 'PICKUP'" :data="detail.pickups"><el-table-column prop="transferNo" label="转运单号" min-width="190" /><el-table-column label="进度"><template #default="{ row }">{{ pickupStatusLabels[row.status] }}</template></el-table-column><el-table-column prop="packageCount" label="预报箱数" /><el-table-column prop="actualPackages" label="实提箱数" /><el-table-column label="凭证"><template #default="{ row }"><el-button v-for="file in row.evidence" :key="file.fileId" link @click="download(file, row as PickupTask)">{{ file.fileName }}</el-button></template></el-table-column><el-table-column label="操作" width="180"><template #default="{ row }"><template v-if="trip.status === 'IN_TRANSIT' && editable"><el-button v-if="row.status === 'ASSIGNED'" link :disabled="locked" @click="editPickup(row as PickupTask)">确认取货</el-button><el-button v-if="row.status === 'PICKED'" link :disabled="locked" @click="command('arrive', row as PickupTask)">确认送仓</el-button></template></template></el-table-column></el-table>
|
||||
<div v-if="detail.taskType === 'LOCAL_TRUCK' && trip.status === 'IN_TRANSIT' && editable"><label>运输完成凭证 <input type="file" :disabled="locked" @change="upload($event, false)" /></label><p v-for="file in truckEvidence" :key="file.fileId">{{ file.fileName }}</p></div>
|
||||
<div v-if="detail.completionEvidence?.length"><p>已确认的运输完成凭证</p><el-button v-for="file in detail.completionEvidence" :key="file.fileId" link @click="download(file)">{{ file.fileName }}</el-button></div>
|
||||
|
||||
@@ -330,7 +330,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
|
||||
const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, DataTable: table, DeliveryDocumentsDialog: true } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('1/3 已上传')
|
||||
expect(wrapper.text()).toContain('签收文件(POD签收/快递签收) · 已上传')
|
||||
expect(wrapper.text()).not.toContain('签收文件(POD签收/快递签收)')
|
||||
expect(getDeliveryDocuments).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button => button.text().includes('管理资料'))!.trigger('click')
|
||||
const dialog = wrapper.findComponent({ name: 'DeliveryDocumentsDialog' })
|
||||
|
||||
@@ -23,7 +23,6 @@ import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
|
||||
import DeliveryDocumentsDialog from '../components/DeliveryDocumentsDialog.vue'
|
||||
import { deliveryDocumentCategories } from '../api/deliveryDocuments'
|
||||
import { returnSourceKey, returnSourceReference } from '../returnSource'
|
||||
|
||||
const documentsOpen = ref(false)
|
||||
@@ -193,7 +192,7 @@ onMounted(() => { void load(); void loadWarehouses() })
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template #notices><template v-if="error"><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
|
||||
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as DeliveryOrderRow)">详情</el-button><el-button v-if="row.outboundNo && access.can(permission.outboundList)" class="density-action" data-tone="neutral" size="small" @click="openOutbound(row as DeliveryOrderRow)">前往出库</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryStatus) && availableStatuses(row as DeliveryOrderRow).length > 0" size="small" @click="openStatus(row as DeliveryOrderRow)">编辑状态</el-button><el-button class="density-action" data-tone="warning" v-if="canReturn(row as DeliveryOrderRow)" size="small" @click="openReturn(row as DeliveryOrderRow)">标记退货</el-button></div></template><template #documents="{ row }"><div class="delivery-document-summary"><div><strong>{{ row.documentSummary ? `${row.documentSummary.uploadedCategoryCount}/3 已上传` : '资料摘要暂无' }}</strong><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryList)" size="small" @click="openDocuments(row as DeliveryOrderRow)">管理资料</el-button></div><div class="delivery-document-categories"><span v-for="category in deliveryDocumentCategories" :key="category.category" :class="{ uploaded: row.documentSummary?.categories.some((item: { category: string; status: string }) => item.category === category.category && item.status === 'UPLOADED') }">{{ category.label }} · {{ row.documentSummary?.categories.find((item: { category: string }) => item.category === category.category)?.status === 'UPLOADED' ? '已上传' : '未上传' }}</span></div></div></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></div></section></ListPageLayout></AdminPage><AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="statusOpen" :title="'编辑派送状态 · ' + (statusRow?.deliveryNo || '—')" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving || statusLoading" :loading="statusLoading" :confirm-disabled="!statusForm.status || statusConflict || statusLoading || !access.can(permission.deliveryStatus)" @confirm="submitStatus">
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as DeliveryOrderRow)">详情</el-button><el-button v-if="row.outboundNo && access.can(permission.outboundList)" class="density-action" data-tone="neutral" size="small" @click="openOutbound(row as DeliveryOrderRow)">前往出库</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryStatus) && availableStatuses(row as DeliveryOrderRow).length > 0" size="small" @click="openStatus(row as DeliveryOrderRow)">编辑状态</el-button><el-button class="density-action" data-tone="warning" v-if="canReturn(row as DeliveryOrderRow)" size="small" @click="openReturn(row as DeliveryOrderRow)">标记退货</el-button></div></template><template #documents="{ row }"><div class="delivery-document-summary"><div><strong>{{ row.documentSummary ? `${row.documentSummary.uploadedCategoryCount}/3 已上传` : '资料摘要暂无' }}</strong><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryList)" size="small" @click="openDocuments(row as DeliveryOrderRow)">管理资料</el-button></div></div></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></div></section></ListPageLayout></AdminPage><AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="statusOpen" :title="'编辑派送状态 · ' + (statusRow?.deliveryNo || '—')" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving || statusLoading" :loading="statusLoading" :confirm-disabled="!statusForm.status || statusConflict || statusLoading || !access.can(permission.deliveryStatus)" @confirm="submitStatus">
|
||||
<div class="density-dialog-sections">
|
||||
<el-alert title="取消派送请先解除未执行车次关联,再前往关联出库单取消出库;已出库或已发车不能直接取消。" type="warning" :closable="false" show-icon />
|
||||
<section class="density-dialog-section" data-tone="source" aria-label="派送状态依据"><h3 class="density-dialog-section__title">当前资料</h3><table class="density-table"><tbody><tr><th scope="row">当前状态</th><td>{{ statusLabels[String(statusRow?.status)] || '—' }}</td></tr><tr><th scope="row">关联出库</th><td>{{ statusRow?.outboundNo || '—' }}</td></tr></tbody></table></section>
|
||||
@@ -219,4 +218,4 @@ onMounted(() => { void load(); void loadWarehouses() })
|
||||
</div>
|
||||
</AppDialog><DeliveryDocumentsDialog v-if="documentsRow" v-model="documentsOpen" :delivery-no="documentsRow.deliveryNo" :customer-id="documentsRow.customerId" @saved="load()" /></template>
|
||||
|
||||
<style scoped>.delivery-document-summary{display:grid;gap:var(--admin-space-3);padding:var(--admin-space-2) 0}.delivery-document-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.delivery-document-categories{display:flex;flex-wrap:wrap;gap:var(--admin-space-2) var(--admin-space-5);font-size:var(--admin-font-caption);color:var(--workbench-muted)}.delivery-document-categories .uploaded{color:var(--el-color-success)}.delivery-return-editor { table-layout: fixed; }.delivery-return-editor td:not([colspan]) { width: 50%; }.delivery-return-sources { table-layout: fixed; }.delivery-return-select { width: 68px; }.delivery-return-quantity { width: 160px; } @media (max-width: 720px) { .delivery-return-editor, .delivery-return-editor tbody { display: block; }.delivery-return-editor tr { display: grid; grid-template-columns: 1fr; }.delivery-return-editor td:not([colspan]) { width: auto; } }</style>
|
||||
<style scoped>.delivery-document-summary{display:grid;gap:var(--admin-space-3);padding:var(--admin-space-2) 0}.delivery-document-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.delivery-return-editor { table-layout: fixed; }.delivery-return-editor td:not([colspan]) { width: 50%; }.delivery-return-sources { table-layout: fixed; }.delivery-return-select { width: 68px; }.delivery-return-quantity { width: 160px; } @media (max-width: 720px) { .delivery-return-editor, .delivery-return-editor tbody { display: block; }.delivery-return-editor tr { display: grid; grid-template-columns: 1fr; }.delivery-return-editor td:not([colspan]) { width: auto; } }</style>
|
||||
|
||||
@@ -29,7 +29,7 @@ const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const operation = ref<'ROUTE' | undefined>()
|
||||
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
|
||||
const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
|
||||
const visibleActions = computed(() => detailActions.filter(value => canDispatchAction(detail.value?.actions, value, access.can)))
|
||||
const visibleActions = computed(() => detailActions.filter(value => !(detail.value?.taskType === 'PICKUP' && value === 'EDIT') && canDispatchAction(detail.value?.actions, value, access.can)))
|
||||
const vehicleStatus = computed(() => {
|
||||
const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus
|
||||
return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—'
|
||||
@@ -84,8 +84,8 @@ onBeforeUnmount(() => { generation++ })
|
||||
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" @saved="load" />
|
||||
<section v-else-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" @saved="load" />
|
||||
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="(!detail.taskType || detail.taskType === 'DELIVERY') && canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></el-tab-pane>
|
||||
|
||||
@@ -154,11 +154,24 @@ it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispa
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType })]))
|
||||
const wrapper = mount(DispatchesView, options); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'detail', label: '运输操作' })])
|
||||
expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'detail', label: taskType === 'PICKUP' ? '详情' : '运输操作' })])
|
||||
row.vm.$emit('action', 'detail'); await flushPromises()
|
||||
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
|
||||
expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
|
||||
})
|
||||
it('keeps pickup workflow actions but hides route ordering and BOL even when editing is allowed', async () => {
|
||||
const allowed = { allowed: true, blockCode: null, blockReason: null }
|
||||
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
|
||||
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, TransportExecutionPanel: true, AppDialog: { template: '<div><slot /></div>' } } } }); await flushPromises()
|
||||
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
|
||||
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
|
||||
row.vm.$emit('action', 'EDIT'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
|
||||
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reads one BOL on demand and preserves popup failure handling', async () => {
|
||||
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, ty
|
||||
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions, dispatchRejectionReason } from '../dispatchPresentation'
|
||||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||||
import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||||
import { transportLabels, type TransportType } from '../api/transportTasks'
|
||||
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
|
||||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||||
@@ -33,6 +35,7 @@ const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | n
|
||||
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||||
const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP')
|
||||
const transportEditOpen = ref(false), transportEditNo = ref('')
|
||||
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
@@ -84,7 +87,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
const review = section.value
|
||||
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
|
||||
}
|
||||
if (row.taskType && row.taskType !== 'DELIVERY') return [{ key: 'detail', label: '运输操作', priority: 500 }]
|
||||
if (row.taskType === 'LOCAL_TRUCK') return [{ key: 'detail', label: '运输操作', priority: 500 }]
|
||||
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
|
||||
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
|
||||
return [...available.map((value, index) => ({
|
||||
@@ -93,7 +96,7 @@ function rowActions(row: DispatchListRow): RowActionItem[] {
|
||||
ariaLabel: dispatchActionLabels[value],
|
||||
priority: value === primaryAction ? 600 : 400 - index * 10,
|
||||
tone: ['CANCEL', 'MARK_EXCEPTION'].includes(value) ? 'danger' as const : undefined,
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }]
|
||||
})), { key: 'detail', label: '详情', priority: 500 }, ...(row.taskType === 'PICKUP' ? [] : [...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }])]
|
||||
}
|
||||
function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'detail') return openDetail(row)
|
||||
@@ -105,6 +108,7 @@ function handleAction(value: string, row: DispatchListRow) {
|
||||
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
|
||||
const requested = value as DispatchAction
|
||||
if (!canDispatchAction(row.actions, requested, access.can)) return
|
||||
if (row.taskType === 'PICKUP' && requested === 'EDIT') { transportEditNo.value = row.dispatchNo; transportEditOpen.value = true; return }
|
||||
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
|
||||
}
|
||||
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
|
||||
@@ -150,6 +154,7 @@ onBeforeUnmount(() => { sequence++ })
|
||||
</DataTable></div>
|
||||
</ListPageLayout></AdminPage>
|
||||
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
|
||||
<AppDialog v-model="transportEditOpen" title="维护取货车辆信息" width="1000px"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :pickup-approved="rows.find(row => row.dispatchNo === transportEditNo)?.reviewState === 'APPROVED'" @saved="load" /></AppDialog>
|
||||
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
|
||||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||||
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
|
||||
|
||||
@@ -74,21 +74,12 @@ describe('inventory customer-name snapshots', () => {
|
||||
expect(wrapper.text()).not.toContain(balance.id)
|
||||
})
|
||||
|
||||
it('keeps the formal customer and carton list when piece projection fails', async () => {
|
||||
vi.mocked(getInventoryPieceProjection).mockRejectedValueOnce(new Error('projection unavailable'))
|
||||
it('keeps carton totals without the removed SKU and piece columns or their projection request', async () => {
|
||||
const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('华远贸易')
|
||||
expect(wrapper.text()).toContain('可用件数及箱内SKU加载失败')
|
||||
expect(wrapper.text()).not.toContain('库存数据加载失败')
|
||||
})
|
||||
|
||||
it('renders every mixed-carton SKU and real piece count alongside carton totals', async () => {
|
||||
vi.mocked(getInventoryPieceProjection).mockResolvedValueOnce({ availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', groups: [{ customerId, warehouseId: '200', destinationCode: null, anchorSkuCode: 'SKU-1', availablePieceCount: '12', knownAvailablePieceCount: '12', unknownCartonCount: '0', availableCartonCount: '2', availablePalletCount: '1', skuContents: [{ skuCode: 'SKU-1', quantity: '5' }, { skuCode: 'SKU-SECOND', quantity: '7' }], cartons: [] }] })
|
||||
const wrapper = mount(InventoryView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('华远贸易')
|
||||
expect(wrapper.text()).toContain('SKU-1 × 5 件;SKU-SECOND × 7 件')
|
||||
expect(wrapper.text()).not.toContain('可用件数')
|
||||
expect(getInventoryPieceProjection).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[aria-label="可用库存件箱托汇总"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[aria-label="库存汇总"]').text()).toContain('可用库存(箱)10')
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceGroup } from '../api/inventory'
|
||||
import { getInventorySummary, listInventoryBalances, type InventoryDashboardDto } from '../api/inventory'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { toInventoryListItem } from '../inventoryPresentation'
|
||||
@@ -26,8 +26,6 @@ const access = useAccessPreviewStore()
|
||||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||||
const rows = ref<InventoryListItem[]>([])
|
||||
const summary = ref<InventoryDashboardDto | null>(null)
|
||||
const rowPieces = ref<Record<string, InventoryPieceGroup | undefined>>({})
|
||||
const pieceError = ref('')
|
||||
const loading = ref(false)
|
||||
const contextLoading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -53,8 +51,6 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'owner', prop: 'owner', label: '客户', minWidth: 160 },
|
||||
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
|
||||
{ key: 'sku', prop: 'sku', label: '箱内SKU / 可用件数', minWidth: 220, slotName: 'skuContents', singleLine: false, className: 'inventory-sku-contents' },
|
||||
{ key: 'availablePieces', label: '可用件数', minWidth: 130, slotName: 'availablePieces' },
|
||||
{ key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'location', prop: 'location', label: '库位', minWidth: 140, defaultVisible: false },
|
||||
{ key: 'onHand', prop: 'onHand', label: '在库总量', width: 105, align: 'right' },
|
||||
@@ -79,17 +75,13 @@ function query() {
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true; loadError.value = null; rowPieces.value = {}; pieceError.value = ''
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const currentQuery = query()
|
||||
const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(currentQuery), getInventorySummary(currentQuery)])
|
||||
rows.value = pageResult.records.map(toInventoryListItem); total.value = pageResult.total; summary.value = summaryResult
|
||||
try {
|
||||
const pieceResult = await getInventoryPieceProjection(currentQuery)
|
||||
rowPieces.value = Object.fromEntries(pageResult.records.map(dto => [String(dto.id), pieceResult.groups.find(group => String(group.customerId) === String(dto.customerId) && String(group.warehouseId) === String(dto.overseasWarehouseId) && (group.destinationCode ?? '') === (dto.destinationWarehouseCode ?? '') && (group.anchorSkuCode ?? '') === (dto.skuCode ?? ''))]))
|
||||
} catch { pieceError.value = '可用件数及箱内SKU加载失败,请重新查询;箱数列表仍可查看。' }
|
||||
}
|
||||
catch (error) { rows.value = []; total.value = 0; summary.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
|
||||
catch (error) { rows.value = []; total.value = 0; summary.value = null; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -107,20 +99,17 @@ onMounted(() => { void loadWarehouses(); void load() })
|
||||
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" empty-text="暂无库存记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>库存管理</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><p v-if="pieceError" role="alert">{{ pieceError }}</p></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
<template #before-results><section class="inventory-metrics workbench-panel is-flush" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}(箱)</span><strong>{{ metric[1] }}</strong></div></section></template>
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
|
||||
<template #skuContents="{ row }"><span v-if="rowPieces[row.id]?.skuContents.length">{{ rowPieces[row.id]!.skuContents.map(sku => `${sku.skuCode} × ${sku.quantity} 件`).join(';') }}<template v-if="Number(rowPieces[row.id]!.unknownCartonCount) > 0">;另有 {{ rowPieces[row.id]!.unknownCartonCount }} 箱内容未知</template></span><span v-else>记账SKU:{{ row.sku }};箱内件数未记录</span></template>
|
||||
<template #availablePieces="{ row }">{{ rowPieces[row.id]?.availablePieceCount ?? (row.available === 0 ? 0 : '未记录') }}</template>
|
||||
<template #pagination-left>共 {{ total }} 条库存记录</template>
|
||||
</DataTable></div></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-density :deep(.inventory-sku-contents .cell) { white-space: normal; overflow-wrap: anywhere; }
|
||||
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; }
|
||||
.inventory-metric:last-child { border-right: 0; }
|
||||
.inventory-metric span { color: #64748b; font-size: var(--admin-font-caption); }
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
|
||||
])
|
||||
expect(columnKeys('InventoryView.vue')).toEqual([
|
||||
'actions', 'owner', 'warehouse', 'sku', 'availablePieces', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
|
||||
'actions', 'owner', 'warehouse', 'productName', 'location', 'onHand', 'available', 'held', 'looseboard',
|
||||
'inTransit', 'updatedAt',
|
||||
])
|
||||
expect(columnKeys('RelabelOrdersView.vue')).toEqual([
|
||||
|
||||
Reference in New Issue
Block a user