Merge origin/release/test and combine transport photo workflows

This commit is contained in:
2026-10-27 17:45:43 +08:00
73 changed files with 1914 additions and 541 deletions
@@ -188,7 +188,7 @@ onMounted(load)
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="containerDialogTitle(editing ? '调整尾程费用' : '新增尾程费用', props)" width="min(920px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<section class="density-dialog-section">
<el-form class="density-form" label-position="left">
<div class="workbench-evidence"><table class="density-table fee-editor-table" aria-label="尾程费用填写"><tbody>
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td></tr>
@@ -1,10 +1,10 @@
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect } from 'element-plus'
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect, ElUpload } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
import { getContainer, getContainerOverview, linkContainerOceanBooking, getContainerClearance, uploadContainerClearance, uploadContainerDocument, completeContainerClearance } from '../api/platformContainerLoading'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, recordTerminalPickup } from '../api/terminalDrayages'
import { listSuppliers } from '@/modules/suppliers'
@@ -59,7 +59,7 @@ function accessibleText(wrapper: { element: Element } | undefined) {
describe('ContainerDetailView empty return', () => {
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }])
const wrapper = await mountDetail()
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
expect(value).toBe('2026-10-23(默认推算)')
@@ -100,7 +100,8 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(recordDirectDelivery).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...row, version: 1, actualAt: '2026-10-16T01:00:00' })
const wrapper = await mountDetail()
expect(getDirectDeliveries).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
@@ -123,7 +124,8 @@ describe('ContainerDetailView empty return', () => {
canAccess.mockImplementation(code => code !== 'platform:firstleg:terminal-drayage:direct-unload:confirm')
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
const wrapper = await mountDetail()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
@@ -206,17 +208,20 @@ describe('ContainerDetailView empty return', () => {
granted.value = true
await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('已确认离仓')
expect(wrapper.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((wrapper.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁')
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
})
it('reloads authoritative departure state once on each newly opened domestic detail', async () => {
const first = await mountDetail('DOMESTIC')
expect(first.text()).toContain('已确认离仓')
expect(first.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((first.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
first.unmount()
const reopened = await mountDetail('DOMESTIC')
expect(reopened.text()).toContain('已确认离仓')
expect(reopened.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((reopened.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
})
@@ -437,3 +442,54 @@ describe('domestic container loading list and booking link', () => {
expect(routerPush).not.toHaveBeenCalled()
})
})
describe('destination document automatic upload', () => {
const uploaded = { id: 'doc-1', fileId: 'file-1', fileName: '资料.pdf', version: 1, completedAt: null }
beforeEach(() => {
canAccess.mockReset().mockReturnValue(true)
routeState.query = {}
vi.mocked(getContainer).mockReset().mockResolvedValue({ ...base, outboundConfirmedAt: '2026-10-20T08:00:00', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS'] } as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockReset().mockResolvedValue(null as never)
vi.mocked(uploadContainerDocument).mockReset().mockResolvedValue(uploaded as never)
vi.mocked(uploadContainerClearance).mockReset().mockResolvedValue(uploaded as never)
vi.mocked(completeContainerClearance).mockReset()
})
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('saves %s on selection without completing clearance', async type => {
const wrapper = await mountDetail()
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
expect(dialog.props('showConfirm')).toBe(false)
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
const select = dialog.findComponent(ElUpload).props('onChange')!
select({ raw: file } as never, [])
select({ raw: file } as never, [])
await flushPromises()
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
expect(upload).toHaveBeenCalledTimes(1)
expect(upload).toHaveBeenCalledWith(...(type === 'DELIVERY_ORDER' ? ['9001', type, file, 0, expect.any(String)] : ['9001', file, 0, expect.any(String)]))
expect(completeContainerClearance).not.toHaveBeenCalled()
expect(dialog.props('modelValue')).toBe(false)
})
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('retains a failed %s upload and retries the same file', async type => {
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
vi.mocked(upload).mockRejectedValueOnce(new Error('网络中断'))
const wrapper = await mountDetail()
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
dialog.findComponent(ElUpload).props('onChange')!({ raw: file } as never, [])
await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.text()).toContain('上传失败')
await dialog.findAll('button').find(button => button.text() === '重试上传')!.trigger('click')
await flushPromises()
expect(upload).toHaveBeenCalledTimes(2)
expect(vi.mocked(upload).mock.calls[1]).toEqual(vi.mocked(upload).mock.calls[0])
expect(dialog.props('modelValue')).toBe(false)
expect(completeContainerClearance).not.toHaveBeenCalled()
})
})
@@ -19,10 +19,8 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
type ContainerOutboundDispatchDetail,
type ContainerOutboundGateItem,
} from '@/modules/outbound-dispatch'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
@@ -244,7 +242,6 @@ async function saveDirect() {
}
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const canViewCurrentLoadingList = computed(() => {
@@ -274,6 +271,7 @@ const pickupCodeSaving = ref(false)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
const clearanceFile = ref<File | null>(null)
const clearanceUploadKey = ref(0)
const clearanceLoading = ref(false)
const clearanceSaving = ref(false)
const clearanceError = ref<ApiErrorDetails | null>(null)
@@ -282,10 +280,8 @@ const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const outboundLoading = ref(false)
const outboundConfirming = ref(false)
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail()
const emptyReturnOpen = ref(false)
@@ -337,32 +333,6 @@ const emptyReturnWarningText = computed(() => {
return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
})
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE'])
const outboundGateRows = computed<AggregatedGateRow[]>(() => {
const grouped = new Map<string, AggregatedGateRow>()
for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) {
for (const item of inbound.gate.items || []) {
if (referenceOnlyGateCodes.has(item.code)) continue
const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name])
const existing = grouped.get(key)
if (existing) {
if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode)
} else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] })
}
}
return [...grouped.values()]
})
const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed))
const outboundGateSummary = computed(() => ({
total: outboundGateRows.value.length,
passed: outboundGateRows.value.filter((row) => row.passed).length,
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
}))
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& hasAction('CONFIRM_OUTBOUND')
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() =>
!outboundDetail.value ? 'UNKNOWN'
: outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
@@ -469,7 +439,7 @@ watch(
async function loadOutboundDispatch() {
if (!containerId.value || !canViewOutboundDispatch.value) return
outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
outboundLoading.value = true; outboundError.value = null
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
finally { outboundLoading.value = false }
@@ -488,37 +458,6 @@ async function refreshAfterCustoms() {
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
}
async function confirmDeparture() {
const aggregate = outboundDetail.value?.aggregate
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
outboundConfirming.value = true
try {
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
} catch { outboundConfirming.value = false; return }
outboundError.value = null
try {
outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
expectedVersion: aggregate.version,
remark: '货柜详情确认国内出仓',
})
carrierRefreshKey.value += 1
ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
try { overview.value = await getContainerOverview(containerId.value) }
catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
} catch (cause) {
const details = getApiErrorDetails(cause, '确认国内出仓失败')
if (details.code === 'A0409') await loadOutboundDispatch()
outboundError.value = details
} finally { outboundConfirming.value = false }
}
function outboundStatusText(status?: string | null) {
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record<string, string>)[status || ''] || status || '—'
}
function truckReferenceText(status?: string | null) {
return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record<string, string>)[status || ''] || status || '未关联'
}
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
@@ -526,9 +465,14 @@ function openDocument(type: string) {
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function selectDocumentFile(file: UploadFile) {
if (documentSaving.value) return
documentFile.value = file.raw || null; documentError.value = null
if (documentFile.value) void saveDocument()
}
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (documentSaving.value || !canManageDestinationDocuments.value) return
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
try {
@@ -601,14 +545,20 @@ async function saveException() {
}
async function openClearance() {
clearanceUploadKey.value += 1
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
finally { clearanceLoading.value = false }
}
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
function selectClearanceFile(file: UploadFile) {
if (clearanceSaving.value || clearanceLoading.value) return
clearanceFile.value = file.raw || null; clearanceError.value = null
if (clearanceFile.value) void uploadClearance()
}
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (clearanceSaving.value || clearanceLoading.value || !canManageDestinationDocuments.value || clearanceDocument.value?.completedAt) return
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
try {
@@ -697,7 +647,7 @@ onMounted(async () => {
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :hide-technical-details="isOverseas" :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense hide-technical-details :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-customs-heading">
@@ -710,26 +660,10 @@ onMounted(async () => {
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-receiving-heading">
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁<InlineHelp title="离仓核验规则"><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></InlineHelp></h2></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
<template v-else-if="outboundDetail">
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
<div class="gate-list">
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
<div class="gate-row__coverage"><span>{{ row.passed ? '覆盖' : '影响' }} {{ row.inboundCodes.length }} 个入库码</span><el-tag v-for="code in row.inboundCodes.slice(0, 3)" :key="code" size="small" effect="plain">{{ code }}</el-tag><span v-if="row.inboundCodes.length > 3">+{{ row.inboundCodes.length - 3 }}</span></div>
</article>
</div>
</template>
</div>
</section>
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
@@ -753,9 +687,9 @@ onMounted(async () => {
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving"><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
+29 -1
View File
@@ -7,7 +7,6 @@
.admin-density .finance-detail-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-3); }
.admin-density .finance-detail-context { min-width: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.admin-density .statement-summary-grid .statement-summary-item--remark { grid-column: span 2; }
.admin-density .settlement-summary.density-detail-overview { padding: 0; border: 0; }
.admin-density .statement-summary-section { padding: var(--admin-space-5); }
.admin-density .account-metrics .is-negative,
.admin-density .density-detail-overview .is-danger,
@@ -15,3 +14,32 @@
.admin-density .density-detail-overview .is-success,
.admin-density-dialog .partial-summary .is-success { color: var(--el-color-success); }
.admin-density-dialog .partial-summary { margin-bottom: var(--admin-space-5); }
/* Outrank AdminPage scoped spacing regardless of stylesheet load order; the toolbar supplies the gap. */
.admin-workbench.receivable-statistics-page.admin-page.is-spacing-compact,
.admin-workbench.finance-settlement-page.admin-page.is-spacing-compact { gap: 0; }
.admin-workbench .finance-metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
@media (max-width: 720px) {
.admin-workbench .finance-metric-grid { grid-template-columns: 1fr; }
}
/* The outer AdminPage owns the screen inset for every finance detail section. */
@media screen {
.admin-density .detail-page-layout.finance-density-detail > :is(.detail-page-layout__header, .detail-page-layout__summary, .detail-page-layout__content, .detail-page-layout__footer),
.admin-density .finance-density-detail > .detail-page-layout__header > .density-detail-header,
.admin-density .finance-density-detail > .detail-page-layout__summary > :is(.density-detail-overview, .statement-summary-section),
.admin-density .finance-density-detail > .detail-page-layout__content > .settlement-detail-content {
padding-inline: 0;
}
}
/* Detail sections share one vertical rhythm instead of stacking section padding. */
.admin-density .finance-density-detail > .detail-page-layout__content,
.admin-density .finance-density-detail .settlement-detail-content {
display: flex;
flex-direction: column;
gap: var(--admin-space-5);
}
.admin-density .finance-density-detail > .detail-page-layout__content .density-form-section {
padding-block: 0;
}
@@ -65,7 +65,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain('v-else-if="!detailOpen"')
expect(page).toContain('返回客户账户')
expect(page).toContain('<DetailPageLayout')
expect(page).toContain('class="density-detail-overview account-metrics"')
expect(page).toContain('class="account-metrics"')
for (const metric of ['实际余额', '冻结金额', '总可用余额', '授信额度', '已用授信', '可用授信']) expect(page).toContain(metric)
expect(page).toContain('class="ledger-query workbench-query"')
expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"')
@@ -83,18 +83,18 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps term editing aligned with the account summary and nearby actions', () => {
for (const marker of ['class="term-section density-form-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
expect(page).toContain('density-detail-overview account-metrics')
expect(page).toContain('account-metrics')
for (const column of ['实际余额(CNY)', '冻结金额(CNY)', '总可用余额(CNY)', '授信额度(CNY)', '已用授信(CNY)', '待审核充值']) expect(page).toContain(column)
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
expect(page).toContain('justify-content: start')
expect(page).toContain('class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface"')
expect(page).toContain('class="density-detail-header"')
expect(page).toContain('padding: var(--admin-space-4) 0')
expect(page).toContain('class="detail-table-scroll workbench-table"')
expect(page).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(page).toContain('class="ledger-identifier"')
expect(page).toContain('class="ledger-document"')
expect(page).toContain('text-overflow: ellipsis')
expect(page).toContain('white-space: nowrap')
expect(page).toContain('overflow-wrap: anywhere')
expect(page).not.toContain('text-overflow: ellipsis')
})
it('keeps account detail and term editing readable across the 1920 workspace', () => {
@@ -186,7 +186,10 @@ describe('FinanceCustomerAccountsView delivery', () => {
await flushPromises()
expect(wrapper.text()).toContain('客户A')
expect(wrapper.text()).toContain('C-001')
const profile = wrapper.find('.customer-profile-section')
expect(profile.text()).toContain('客户名称客户A')
expect(profile.text()).not.toContain('客户编号')
expect(profile.text()).not.toContain('C-001')
expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('13800000000')
const metrics = wrapper.find('.account-metrics')
@@ -194,7 +197,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(metrics.text()).toContain('实际余额¥126580.50 CNY')
expect(metrics.text()).toContain('冻结金额¥8500.00 CNY')
expect(metrics.text()).toContain('总可用余额¥138080.50 CNY')
expect(metrics.text()).toContain('授信额度¥20000.00 2028-09-10 到期')
expect(metrics.text()).toContain('授信额度¥20000.00 CNY2028-09-10 到期')
expect(metrics.text()).toContain('已用授信¥0.00 CNY')
expect(metrics.text()).toContain('可用授信¥20000.00 CNY')
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
import { ArrowLeft, CircleCheck, CreditCard, Download, LockKeyhole, MinusCircle, PlusCircle, Save, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
@@ -100,13 +100,13 @@ const columns: DataTableColumn[] = [
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
{ key: 'actualBalance', label: '实际余额(CNY)', width: 155, slotName: 'actualBalance' },
{ key: 'frozenBalance', label: '冻结金额(CNY)', width: 150, slotName: 'frozenBalance' },
{ key: 'availableBalance', label: '总可用余额(CNY)', width: 155, slotName: 'availableBalance' },
{ key: 'creditLimit', label: '授信额度(CNY)', width: 155, slotName: 'creditLimit' },
{ key: 'actualBalance', label: '实际余额(CNY)', align: 'right', width: 155, slotName: 'actualBalance' },
{ key: 'frozenBalance', label: '冻结金额(CNY)', align: 'right', width: 150, slotName: 'frozenBalance' },
{ key: 'availableBalance', label: '总可用余额(CNY)', align: 'right', width: 155, slotName: 'availableBalance' },
{ key: 'creditLimit', label: '授信额度(CNY)', align: 'right', width: 155, slotName: 'creditLimit' },
{ key: 'creditExpiresOn', label: '授信有效期', width: 140, slotName: 'creditExpiresOn' },
{ key: 'creditUsed', label: '已用授信(CNY)', width: 150, slotName: 'creditUsed' },
{ key: 'pendingRechargeCount', label: '待审核充值', width: 120, slotName: 'pendingRechargeCount' },
{ key: 'creditUsed', label: '已用授信(CNY)', align: 'right', width: 150, slotName: 'creditUsed' },
{ key: 'pendingRechargeCount', label: '待审核充值', align: 'right', width: 120, slotName: 'pendingRechargeCount' },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
]
@@ -123,18 +123,18 @@ const ledgerQueryFields: QueryField[] = [
const ledgerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, align: 'right', slotName: 'amount' },
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, slotName: 'actualBalanceAfter' },
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, slotName: 'frozenBalanceAfter' },
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, slotName: 'availableBalanceAfter' },
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, slotName: 'creditUsedAfter' },
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, align: 'right', slotName: 'actualBalanceAfter' },
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, align: 'right', slotName: 'frozenBalanceAfter' },
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, align: 'right', slotName: 'availableBalanceAfter' },
{ key: 'creditUsedAfter', label: '变动后已用授信', width: 160, align: 'right', slotName: 'creditUsedAfter' },
{ key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
]
const rechargeColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' },
{ key: 'amount', label: '充值金额(CNY)', width: 150, align: 'right', slotName: 'amount' },
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
@@ -516,18 +516,43 @@ onBeforeUnmount(clearVoucherPreview)
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
</DataTable></section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal">
<AdminPage v-else as="div" class="finance-account-detail admin-density admin-workbench is-embedded" spacing="normal">
<section class="surface-panel finance-account-detail__surface workbench-panel is-flush is-page-surface">
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户资金账户余额">
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">实际余额</h2></div>
<div class="workbench-metric-value"><strong :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><LockKeyhole aria-hidden="true" /></span><h2 class="workbench-metric-label">冻结金额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.frozenBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">总可用余额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.availableBalance) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">授信额度</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.creditLimit) }}</strong> <span>CNY</span></div><div class="workbench-note" :class="{ 'credit-expired': selected.creditExpiresOn && !selected.creditActive }">{{ creditExpiryLabel(selected) }}</div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">已用授信</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.creditUsed) }}</strong> <span>CNY</span></div>
</article>
<article class="account-metric workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CreditCard aria-hidden="true" /></span><h2 class="workbench-metric-label">可用授信</h2></div>
<div class="workbench-metric-value"><strong>{{ money(selected.availableCredit) }}</strong> <span>CNY</span></div>
</article>
</section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading || detailLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions account-profile" :column="3"><el-descriptions-item label="客户名称">{{ customerName(selected.customerId, selected.customerName) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
<LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" />
<div v-else class="detail-table-scroll workbench-table"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
<div v-else class="detail-table-scroll workbench-table is-page-surface"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template>
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
@@ -535,8 +560,7 @@ onBeforeUnmount(clearVoucherPreview)
</DataTable></div>
</FormSection>
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
<div class="detail-table-scroll workbench-table is-page-surface"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" appearance="integrated" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #filters><QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" /></template><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template>
@@ -670,27 +694,26 @@ onBeforeUnmount(clearVoucherPreview)
object-fit: contain;
}
.ledger-section {
padding: var(--admin-space-4) 0 0;
.account-metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--admin-space-5);
padding-block: var(--admin-space-3) 0;
}
.recharge-section {
padding: var(--admin-space-4) 0 0;
border-bottom: 1px solid var(--workbench-border);
.account-metrics .workbench-metric-value > .is-negative,
.account-metrics .credit-expired {
color: var(--el-color-danger);
}
.account-profile :deep(.el-descriptions__table) {
table-layout: fixed;
}
.recharge-table {
min-width: 0;
}
.ledger-query {
margin-bottom: var(--admin-space-3);
}
:deep(.ledger-query.query-toolbar) {
padding: 0;
}
.detail-table-scroll {
overflow-x: auto;
}
@@ -702,9 +725,7 @@ onBeforeUnmount(clearVoucherPreview)
.ledger-identifier,
.ledger-document {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
}
@@ -761,12 +782,6 @@ onBeforeUnmount(clearVoucherPreview)
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
border-left: 0;
}
.finance-account-detail__surface {
width: 100%;
}
@@ -795,16 +810,7 @@ onBeforeUnmount(clearVoucherPreview)
gap: 12px;
}
.account-metric,
.account-metric:first-child,
.account-metric:nth-child(3),
.account-metric:nth-child(5) {
padding-left: 0;
}
.account-metric + .account-metric {
border-left: 0;
}
}
@media (max-width: 480px) {
@@ -127,6 +127,26 @@ describe('FinanceCustomerArapView G2 operations', () => {
}
})
it.each([['PAYABLE_BILLED', '已生成应付单'], ['PARTIALLY_PAID', '部分付款'], ['PAID', '已付款']])(
'shows payable progress and prevents editing occupied or paid sources: %s', async (status, label) => {
const row = { ...feeSummary, direction: 'PAYABLE', sourceSystem: 'FINANCE', sourceType: 'MANUAL',
reviewStatus: 'APPROVED', sourceStatus: status, status, statementGenerated: false } as FinanceFeeSourceSummary
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [row], total: 1, pages: 1 })
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:edit'])
await loadRows(wrapper)
const body = wrapper.get('.el-table__body-wrapper')
expect(body.text()).toContain(label)
expect(body.text()).not.toContain('可结算')
expect(body.text()).not.toContain('未生成')
expect(body.findAll('button').map(button => button.text().trim())).toEqual(['详情'])
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, ...row })
await body.get('button').trigger('click')
await flushPromises()
expect(wrapper.get('.arap-metrics').text()).toContain(label)
expect(wrapper.get('.arap-information').text()).toContain('不适用')
},
)
it('shows detail from the list permission but keeps G2 operations independent', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list'])
await loadRows(wrapper)
@@ -253,7 +273,9 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(wrapper.find('.arap-metrics').exists()).toBe(true)
expect(wrapper.findAll('.arap-metric')).toHaveLength(4)
expect(wrapper.find('.arap-information').text()).toContain('单据信息')
expect(wrapper.find('.arap-settlement').text()).toContain('审核与结算')
expect(wrapper.find('.arap-settlement').exists()).toBe(false)
expect(wrapper.find('.arap-information').text()).toContain('已生成对账单')
expect(wrapper.find('.arap-information').text()).toContain('审核原因')
expect(wrapper.find('.operation-log').text()).toContain('操作记录')
expect(wrapper.find('.arap-information').text()).toContain('业务日期')
expect(wrapper.find('.arap-information').text()).not.toContain('T00:00:00')
@@ -262,8 +284,8 @@ describe('FinanceCustomerArapView G2 operations', () => {
it('keeps the fee-source detail readable across the 1920 workspace', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
expect(page).toContain('width: min(100%, 1920px)')
expect(page).toContain('@media (min-width: 1920px)')
expect(page).toContain('grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr)')
expect(page).toContain('<el-descriptions border :column="3" class="arap-detail density-descriptions">')
expect(page).not.toContain('arap-detail-grid')
expect(page).toContain('finance-density-detail')
expect(page).not.toContain('max-width: 520px')
})
@@ -126,7 +126,7 @@ const queryFields = computed<QueryField[]>(() => [
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
},
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '请选择收付方向', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已生成应付单', value: 'PAYABLE_BILLED' }, { label: '部分付款', value: 'PARTIALLY_PAID' }, { label: '已付款', value: 'PAID' }, { label: '资金账户已结清', value: 'SETTLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions },
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
@@ -141,10 +141,17 @@ function closeCustomerSelectOnScroll() {
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
}
// Eight fixed fields precede settlement status; optional source fields also occupy a cell.
const settlementStatusSpan = computed(() => {
const source = detail.value
const precedingFields = 8 + Number(source?.direction === 'PAYABLE') + Number(source?.quantity != null) + Number(Boolean(source?.periodStart))
return 3 - precedingFields % 3
})
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', align: 'right', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
]
@@ -191,7 +198,7 @@ function businessReference(row: Pick<ArapRow, 'inboundCode' | 'containerNo' | 's
}
function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' }
function sourceStatusDisplay(value: unknown) {
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', PAYABLE_BILLED: '已生成应付单', PARTIALLY_PAID: '部分付款', PAID: '已付款', SETTLED: '资金账户已结清', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
return labels[String(value || '').toUpperCase()] || '状态待确认'
}
function formatDateTime(value: unknown) {
@@ -414,17 +421,17 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
<template #generated="{ row }">{{ row.direction === 'PAYABLE' ? '不适用' : row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
</DataTable></section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal">
<AdminPage v-else as="div" class="finance-arap-detail admin-density admin-workbench is-embedded" spacing="normal">
<section class="surface-panel finance-arap-detail__surface workbench-panel is-flush is-page-surface">
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template>
<template v-else-if="detail"><FormSection class="arap-information density-form-section" title="单据信息与审核结算" :bordered="false"><el-descriptions border :column="3" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) || '—' }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单" :span="settlementStatusSpan">{{ detail.direction === 'PAYABLE' ? '不适用' : detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="3">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><div class="workbench-table is-page-surface"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></div></FormSection></template>
</DetailPageLayout>
</section>
</AdminPage>
@@ -522,23 +529,12 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
color: var(--workbench-muted);
}
.arap-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
gap: var(--admin-space-6);
border-bottom: 1px solid var(--workbench-border);
}
.arap-information,
.arap-settlement,
.operation-log {
padding: var(--admin-space-4) 0;
}
.arap-settlement {
padding-left: var(--admin-space-6);
border-left: 1px solid var(--workbench-border);
}
@@ -566,16 +562,6 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
color: var(--workbench-muted);
}
@media (min-width: 1920px) {
.arap-detail-grid {
gap: var(--admin-space-4);
}
.arap-settlement {
padding-left: var(--admin-space-4);
}
}
@media (max-width: 1080px) {
.finance-arap-detail__surface {
@@ -591,16 +577,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
border-left: 0;
}
.arap-detail-grid {
grid-template-columns: 1fr;
gap: 0;
}
.arap-settlement {
padding-left: 0;
border-left: 0;
border-top: 1px solid var(--workbench-border);
}
}
@media (max-width: 720px) {
@@ -108,13 +108,37 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(20)
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(22)
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户确认人')
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('2026-08-05 10:00')
expect(document.querySelector('.density-detail-heading')?.textContent).toContain('REC-501')
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.el-drawer')).toBeNull()
expect(document.querySelector('.statement-detail-page .el-tabs')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('客户A')
expect(wrapper.find('[aria-label="调整记录"]').isVisible()).toBe(true)
expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true)
})
it('collapses advanced criteria without losing values and resets hidden filters', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; appliedFilters: Record<string, unknown> }
const toolbar = () => wrapper.find('#statement-query-filters')
const toggle = () => toolbar().find('button[aria-controls="statement-query-filters"]')
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(3)
await toggle().trigger('click')
expect(toolbar().findAll('.query-toolbar__field')).toHaveLength(9)
vm.filters.containerNo = 'CONT-31'
vm.filters.createdRange = ['2026-08-01', '2026-08-31']
await toggle().trigger('click')
expect(vm.filters.containerNo).toBe('CONT-31')
const reset = toolbar().findAll('button').find((button) => button.text() === '重置')!
await reset.trigger('click')
await flushPromises()
expect(vm.filters.containerNo).toBe('')
expect(vm.filters.createdRange).toEqual([])
expect(vm.appliedFilters.containerNo).toBe('')
})
it('uses the shared four-group summary grid with long remarks spanning two groups', () => {
@@ -163,9 +187,8 @@ describe('FinanceCustomerStatementsView', () => {
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
const { wrapper } = await mountPage()
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' }
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }
await vm.openDetail(statement)
vm.activeDetailTab = 'payments'
await flushPromises()
expect(document.querySelector('[aria-label="客户对账单支付历史"]')?.textContent).toContain('PAY-701')
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus } from '@lucide/vue'
import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -15,6 +15,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -65,7 +66,7 @@ const selectedId = ref('')
const detail = ref<FinanceReconciliationDetail | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const activeDetailTab = ref<'lines' | 'payments'>('lines')
const showAdvancedFilters = ref(false)
const detailCache = new Map<string, FinanceReconciliationDetail>()
const createOpen = ref(false)
const createBusy = ref(false)
@@ -118,6 +119,13 @@ const fields = computed<QueryField[]>(() => [
{ key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
])
const visibleFields = computed(() => showAdvancedFilters.value ? fields.value : fields.value.slice(0, 3))
function resetFilters() {
filters.value = { status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [], settled: '', lastPaymentRange: [], createdRange: [] }
void load()
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
@@ -126,15 +134,15 @@ const columns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'periodStart', label: '结算起始日', width: 130 },
{ key: 'periodEnd', label: '结算结束日', width: 130 },
{ key: 'creditDays', label: '账期(天)', width: 100 },
{ key: 'creditDays', label: '账期(天)', align: 'right', width: 100 },
{ key: 'lineCount', label: '费用明细数', width: 110 },
{ key: 'sourceCount', label: '来源数量', width: 100 },
{ key: 'currency', label: '货币', width: 86 },
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 },
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 },
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
{ key: 'sourceAmount', label: '调整前金额(CNY)', align: 'right', width: 150 },
{ key: 'adjustmentAmount', label: '调整金额(CNY)', align: 'right', width: 145 },
{ key: 'totalAmount', label: '总应收金额(CNY)', align: 'right', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', align: 'right', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', align: 'right', width: 145 },
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
{ key: 'remark', label: '备注', minWidth: 180 },
@@ -157,10 +165,10 @@ const detailLineColumns: DataTableColumn[] = [
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
{ key: 'feeItemName', label: '费用项目', minWidth: 160 },
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' },
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' },
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 },
{ key: 'amount', label: '账单金额(CNY)', width: 150 },
{ key: 'quantity', label: '计费量', align: 'right', width: 110, slotName: 'quantity' },
{ key: 'unitPrice', label: '单价(CNY)', align: 'right', width: 120, slotName: 'unitPrice' },
{ key: 'sourceAmount', label: '来源金额(CNY)', align: 'right', width: 150 },
{ key: 'amount', label: '账单金额(CNY)', align: 'right', width: 150 },
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
@@ -169,7 +177,7 @@ const detailLineColumns: DataTableColumn[] = [
const paymentColumns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
{ key: 'amount', label: '支付金额(CNY)', align: 'right', width: 150 },
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
@@ -612,9 +620,10 @@ watch(detailRouteId, (statementId) => {
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail workbench-panel is-flush is-page-surface" aria-label="客户对账单详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button><h1 class="density-detail-title">{{ detail?.billNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div></div></template>
<template v-if="detail" #summary>
<dl class="density-detail-overview"><div><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div><div><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div></dl>
<FormSection class="statement-summary-section density-form-section" title="对账单概要" aria-label="对账单摘要">
<dl class="density-fields statement-summary-grid">
<div class="statement-summary-item"><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div>
<div class="statement-summary-item"><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div>
<div class="statement-summary-item"><dt>总应收金额</dt><dd>{{ detail.totalAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>已收金额</dt><dd>{{ detail.paidAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div>
@@ -638,12 +647,11 @@ watch(detailRouteId, (statementId) => {
</dl>
</FormSection>
</template>
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="lines"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>账单明细表</span></template>
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
<FormSection class="density-form-section" v-else-if="detail" title="账单明细" :bordered="false">
<div class="detail-table-scroll workbench-table">
<template v-else-if="detail">
<FormSection class="density-form-section" :bordered="false" title="账单明细表" aria-label="账单明细">
<div class="detail-table-scroll workbench-table is-page-surface">
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
<template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
@@ -658,12 +666,9 @@ watch(detailRouteId, (statementId) => {
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
</DataTable>
</div>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><div class="workbench-table"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></div></el-collapse-item></el-collapse>
</FormSection>
</el-tab-pane>
<el-tab-pane name="payments"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>账单支付历史表</span></template>
<FormSection class="density-form-section" v-if="detail" title="支付历史" :bordered="false">
<div class="detail-table-scroll workbench-table">
<FormSection class="density-form-section" :bordered="false" title="账单支付历史表" aria-label="支付历史">
<div class="detail-table-scroll workbench-table is-page-surface">
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
@@ -674,15 +679,27 @@ watch(detailRouteId, (statementId) => {
</DataTable>
</div>
</FormSection>
</el-tab-pane>
</el-tabs>
<FormSection v-if="detail.revisions.length" class="density-form-section" :bordered="false" title="调整记录" aria-label="调整记录">
<div class="workbench-table is-page-surface">
<el-table :data="detail.revisions" size="small" border stripe>
<el-table-column prop="revisionNo" label="修订" width="72" />
<el-table-column label="动作" width="120"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column>
<el-table-column prop="beforeAmount" label="调整前" align="right" width="130" />
<el-table-column prop="afterAmount" label="调整后" align="right" width="130" />
<el-table-column prop="reason" label="原因" min-width="280"><template #default="{ row }"><LineClampText :content="row.reason" :lines="1" /></template></el-table-column>
<el-table-column label="操作人" width="170"><template #default="{ row }"><LineClampText :content="operatorDisplayName(row.operatorName)" /></template></el-table-column>
<el-table-column label="发生时间" width="180"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
</el-table>
</div>
</FormSection>
</template>
</DetailPageLayout>
<ListPageLayout v-else aria-label="客户对账单">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-start><h1>客户对账单</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
<template #filters><QueryToolbar id="statement-query-filters" v-model="filters" ariaLabel="客户对账单筛选" :fields="visibleFields" :available-fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" :search-on-reset="false" @search="load" @reset="resetFilters"><template #actions><el-button :aria-expanded="showAdvancedFilters" aria-controls="statement-query-filters" @click="showAdvancedFilters = !showAdvancedFilters"><SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}</el-button></template></QueryToolbar></template>
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
@@ -751,12 +768,8 @@ watch(detailRouteId, (statementId) => {
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); }
.statement-detail-tabs { min-width: 0; }
.statement-detail-table { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; }
.statement-line-table { min-width: 1840px; }
.statement-payment-table { min-width: 1300px; }
.revision-collapse { margin-top: var(--admin-space-5); }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (min-width: 1920px) {
@@ -765,13 +778,6 @@ watch(detailRouteId, (statementId) => {
padding: var(--admin-space-3) var(--admin-space-5);
}
.detail-table-scroll {
margin-bottom: var(--admin-space-3);
}
.revision-collapse {
margin-top: var(--admin-space-3);
}
}
@media (max-width: 640px) {
@@ -6,11 +6,11 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/Fi
const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
describe('Finance formal settlement desktop presentation', () => {
it('keeps the title and actions in the toolbar and settlement totals before results', () => {
it('keeps the title and actions in the toolbar and settlement totals above the list', () => {
expect(source).toContain('<template #toolbar-start>')
expect(source).toContain('<h1>{{ title }}</h1>')
expect(source).toContain('<template #before-results>')
expect(source).toContain('<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">')
expect(source.indexOf('<section class="finance-metric-grid"')).toBeLessThan(source.indexOf('<ListPageLayout :aria-label="title">'))
expect(source).toContain('<section class="finance-metric-grid" aria-label="结算汇总"')
expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('<div class="settlement-list-actions">')
expect(source).not.toContain('class="settlement-list-toolbar"')
@@ -44,7 +44,7 @@ describe('Finance formal settlement desktop presentation', () => {
it('uses a continuous detail layout with locally scrollable wide tables', () => {
expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel finance-density-detail workbench-panel is-flush is-page-surface"')
expect(source).toContain('class="settlement-detail-content"')
expect(source).toContain('class="detail-table-scroll workbench-table"')
expect(source.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
expect(source).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(source).not.toContain('class="settlement-detail-card"')
})
@@ -5,7 +5,7 @@ import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, CreditCard, Download, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
import { ArrowLeft, CircleCheck, Clock3, CreditCard, Download, HandCoins, Plus, RotateCcw, Wallet, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -142,9 +142,9 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 },
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' },
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
{ key: 'receivableAmount', label: '应收金额', align: 'right', width: 130, slotName: 'money' },
{ key: 'receivedAmount', label: '已收', align: 'right', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
{ key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
@@ -162,9 +162,9 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 },
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' },
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
{ key: 'payableAmount', label: '应付金额', align: 'right', width: 130, slotName: 'money' },
{ key: 'paidAmount', label: '已付', align: 'right', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', align: 'right', width: 120, slotName: 'outstandingMoney' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
@@ -520,7 +520,7 @@ onMounted(() => {
</script>
<template>
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" spacing="normal">
<AdminPage as="div" class="finance-settlement-page admin-density admin-workbench" :spacing="detailMode ? 'normal' : 'compact'">
<OperationErrorToast v-if="createOpen || paymentOpen" :details="error" />
<OperationErrorToast :message="receiptValidationError" />
<OperationErrorToast :details="receiptError" />
@@ -534,17 +534,25 @@ onMounted(() => {
<el-button v-if="detail && canVoid && detail.status === 'VOIDED'" :loading="actionBusy" @click="performAction(detailActionRow(), 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
</div></div></template>
<template v-if="detail" #summary>
<dl class="density-detail-overview settlement-detail-summary" aria-label="结算金额汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</dt><dd>{{ money(detail.amount) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</dt><dd class="is-success">{{ money(detail.paidAmount) }}</dd></div>
<div class="summary-item"><dt>未结余额</dt><dd class="is-danger">{{ money(detail.outstandingAmount) }}</dd></div>
<div class="summary-item"><dt>到期日</dt><dd class="is-date">{{ displayDate(detail.dueDate) }}</dd></div>
</dl>
<section class="finance-metric-grid" aria-label="结算金额汇总">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收金额' : '应付金额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.amount) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '已收金额' : '已付金额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.paidAmount) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">未结余额</h2></div>
<div class="workbench-metric-value"><strong>{{ money(detail.outstandingAmount) }}</strong><span>CNY</span></div>
</article>
</section>
</template>
<LoadErrorState v-if="detailError" class="settlement-detail-state" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading" @retry="loadDetail(String(settlementId || ''))"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" class="settlement-detail-state" :rows="10" animated />
<section v-else-if="detail" class="settlement-detail-content">
<FormSection class="density-form-section" title="基本信息">
<FormSection class="density-form-section" title="基本信息" :bordered="false">
<el-descriptions border :column="4" class="settlement-detail-info density-descriptions">
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
@@ -562,8 +570,8 @@ onMounted(() => {
<el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item>
</el-descriptions>
</FormSection>
<FormSection class="density-form-section" title="费用明细">
<div class="detail-table-scroll workbench-table">
<FormSection class="density-form-section" title="费用明细" :bordered="false">
<div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column>
@@ -575,8 +583,8 @@ onMounted(() => {
</el-table>
</div>
</FormSection>
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'">
<div class="detail-table-scroll workbench-table">
<FormSection class="density-form-section" :title="kind === 'receivable' ? '收款记录' : '付款记录'" :bordered="false">
<div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
@@ -590,7 +598,7 @@ onMounted(() => {
</div>
</FormSection>
<FormSection class="density-form-section" title="修订记录" :bordered="false">
<div class="detail-table-scroll workbench-table">
<div class="detail-table-scroll workbench-table is-page-surface">
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无调整记录">
<el-table-column prop="action" label="动作" min-width="120"><template #default="scope">{{ settlementRevisionActionLabel(scope.row.action) || financeOperationActionLabel(scope.row.action) }}</template></el-table-column>
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
@@ -603,7 +611,22 @@ onMounted(() => {
</FormSection>
</section>
</DetailPageLayout>
<ListPageLayout v-else :aria-label="title">
<template v-else>
<section class="finance-metric-grid" aria-label="结算汇总" v-if="!customerError && !error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.amount) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '已收' : '已付' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.paid) }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">{{ kind === 'receivable' ? '待收' : '待付' }}</h2></div>
<div class="workbench-metric-value"><strong>{{ loading ? '—' : money(totals.outstanding) }}</strong><span>CNY</span></div>
</article>
</section>
<ListPageLayout :aria-label="title">
<section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
@@ -614,13 +637,7 @@ onMounted(() => {
<template #notices><template v-if="customerError || error">
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
</template></template>
<template #before-results>
<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</dt><dd>{{ money(totals.amount) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收' : '已付' }}</dt><dd class="is-success">{{ money(totals.paid) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '待收' : '待付' }}</dt><dd class="is-danger">{{ money(totals.outstanding) }}</dd></div>
</dl>
</template>
<template #toolbar-actions>
@@ -654,6 +671,7 @@ onMounted(() => {
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable></section>
</ListPageLayout>
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="submitCreate">
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="结算单生成设置"><h3 class="density-dialog-section__title">生成设置</h3>
@@ -700,8 +718,8 @@ onMounted(() => {
<tr><th scope="row">当前余额</th><td colspan="3" class="is-danger">{{ money(detail.outstandingAmount) }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
<el-form class="settlement-operation-form density-form" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
<section class="density-dialog-section workbench-evidence" aria-label="收款登记"><h3 class="density-dialog-section__title">收款登记</h3>
<el-form class="settlement-operation-form density-form density-form--outlined" label-position="left" label-width="140px" :disabled="receiptBusy"><table class="density-table density-table--editable settlement-operation-table"><tbody>
<tr><td><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" placeholder="请输入收款金额" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :disabled="receiptBusy" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item></td></tr>
@@ -717,8 +735,7 @@ onMounted(() => {
<style scoped>
.finance-settlement-page { min-width: 0; }
.settlement-summary, .settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
.settlement-summary { gap: var(--admin-space-2) 0; }
.settlement-list-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; }
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: var(--admin-space-3); }
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
.settlement-detail-page { display: flex; width: min(100%, 1920px); margin: 0 auto; min-width: 0; gap: 0; overflow: hidden; padding: 0; }
@@ -754,21 +771,11 @@ onMounted(() => {
padding: 0 var(--admin-space-5) var(--admin-space-4);
}
.detail-table-scroll {
margin-bottom: var(--admin-space-3);
}
}
@media (max-width: 720px) {
.settlement-detail-summary { grid-template-columns: 1fr; }
.settlement-detail-summary { gap: 10px; padding: 12px 16px; }
.settlement-detail-summary .summary-item { padding: 0; border-left: 0; }
.settlement-detail-content { padding: 0 16px 14px; }
.settlement-detail-state { margin: 14px; }
.settlement-summary { align-items: flex-start; flex-direction: column; gap: 6px; }
.settlement-summary .summary-item + .summary-item { margin-left: 0; padding-left: 0; border-left: 0; }
.settlement-list-actions { width: 100%; justify-content: flex-start; }
.summary-item strong { font-size: 21px; }
.settlement-detail-header { align-items: flex-start; flex-direction: column; padding: 16px; }
.settlement-detail-actions { width: 100%; }
.settlement-detail-actions :deep(.el-button) { min-height: 40px; }
@@ -39,21 +39,21 @@ describe('finance presentation governance', () => {
expect(settlement).toContain('class="settlement-detail-info density-descriptions"')
for (const title of ['基本信息', '费用明细', '修订记录']) expect(settlement).toContain('title="' + title + '"')
expect(settlement).toContain(':title="kind === \'receivable\' ? \'收款记录\' : \'付款记录\'"')
expect(settlement.match(/class="detail-table-scroll workbench-table"/g)).toHaveLength(3)
expect(settlement.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(3)
expect(settlement).toContain(':deep(.detail-table-scroll .el-table) { min-width: 920px; }')
expect(settlement).not.toContain('class="detail-section surface-panel"')
expect(settlement.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
expect(settlement.match(/class="settlement-operation-form density-form"/g)).toHaveLength(2)
expect(settlement.match(/class="settlement-operation-form density-form(?: density-form--outlined)?"/g)).toHaveLength(2)
expect(settlement).toContain('density-table density-table--editable settlement-operation-table')
expect(settlement).toContain('.settlement-operation-table td:not([colspan]) { width: 50%; }')
})
it('keeps settlement totals and list actions in the table toolbar', () => {
expect(settlement).toContain('<template #toolbar-start>')
expect(settlement).toContain('class="density-detail-overview settlement-summary" aria-label="结算汇总"')
expect(settlement).toContain('class="finance-metric-grid" aria-label="结算汇总"')
expect(settlement).toContain('<template #toolbar-actions>')
expect(settlement).toContain('class="settlement-list-actions"')
expect(financeDensity).toContain('.settlement-summary.density-detail-overview { padding: 0; border: 0; }')
expect(financeDensity).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
expect(settlement).not.toContain('class="settlement-list-toolbar"')
})
@@ -148,15 +148,13 @@ describe('finance presentation governance', () => {
expect(statements).not.toContain('只读展示当前对账单关联的有效收款记录。')
})
it('uses a shared compact financial summary row for customer accounts', () => {
expect(accounts).toContain('class="density-detail-overview account-metrics"')
it('uses shared compact financial cards for customer accounts', () => {
expect(accounts).toContain('class="account-metrics"')
expect(financeDensity).toContain('.detail-page-layout.finance-density-detail { gap: 0; }')
expect(density).toContain('.density-detail-overview')
expect(accounts).toContain(':deep(.ledger-query.query-toolbar)')
expect(accounts).toContain('padding: 0;')
const summaryStyles = density.match(/\.density-detail-overview \{([^}]*)\}/)?.[1]
expect(summaryStyles).toContain('display: flex;')
expect(summaryStyles).toContain('flex-wrap: wrap;')
expect(accounts).toContain('<template #filters><QueryToolbar v-model="ledgerFilters"')
expect(financeDensity).toContain('padding-block: 0;')
expect(accounts.match(/account-metric workbench-metric workbench-tone is-compact/g)).toHaveLength(6)
const queryStyles = workbench.match(/\.query-toolbar\.workbench-query \{([^}]*)\}/)?.[1]
expect(accounts).toContain('class="ledger-query workbench-query"')
expect(queryStyles).toContain('border-color: var(--workbench-border);')
@@ -168,9 +166,11 @@ describe('finance presentation governance', () => {
it('keeps fee-source details continuous without nested metric and section cards', () => {
expect(arap).toContain('class="density-detail-overview arap-metrics"')
expect(arap).toContain('class="arap-information density-form-section"')
expect(arap).toContain('class="arap-settlement density-form-section"')
expect(arap).toContain('padding-left: var(--admin-space-6);')
expect(arap).toContain('border-left: 1px solid')
expect(arap).toContain('title="单据信息与审核结算"')
expect(arap).toContain('label="审核原因" :span="3"')
expect(arap).not.toContain('arap-detail-grid')
expect(arap).not.toContain('arap-settlement')
expect(arap).not.toContain('border-left: 1px solid')
expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
for (const description of ['费用来源与关联业务摘要', '当前审核和出账状态', '按时间查看审核与结算调整记录']) expect(arap).not.toContain('description="' + description + '"')
})
@@ -9,6 +9,9 @@ describe('receivable statistics page', () => {
for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label)
expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'")
expect(page).toContain('window.print()')
expect(page).toContain('aria-label="统计月份"')
expect(page).toContain('aria-label="应收单位"')
for (const removed of ['<span>统计月份</span>', '<span>应收单位</span>', '#before-results', '应收款明细', '仅统计CNY正式应收结算单']) expect(page).not.toContain(removed)
expect(page).toContain('displayCustomerName(row)')
expect(page).toContain('/^客户\\s*\\d+$/.test(snapshot)')
expect(page).toContain("return masterName || '—'")
@@ -3,7 +3,7 @@ import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue'
import { Download, Printer, RotateCcw, Search } from '@lucide/vue'
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -39,9 +39,9 @@ const customerNameById = computed(() => new Map(customers.value.map((item) => [S
const columns: DataTableColumn[] = [
{ key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' },
{ key: 'currency', label: '币种', width: 100 },
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, slotName: 'totalReceivable' },
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, slotName: 'totalReceived' },
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, slotName: 'totalOutstanding' },
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, align: 'right', slotName: 'totalReceivable' },
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, align: 'right', slotName: 'totalReceived' },
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, align: 'right', slotName: 'totalOutstanding' },
]
async function load() {
@@ -90,22 +90,31 @@ onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="normal">
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="compact">
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else>
<section class="finance-metric-grid" aria-label="CNY应收汇总" v-if="!error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">应收总额</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceivable) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已收合计</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceived) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">余额未收</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalOutstanding) : '—' }}</strong><span>CNY</span></div>
</article>
</section>
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>应收款统计表</h1></template>
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><span>应收单位</span><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-field"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
</section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #before-results><dl class="density-detail-overview statistics-summary" aria-label="CNY应收汇总">
<div><dt>应收总额</dt><dd>CNY {{ money(result?.totalReceivable) }}</dd></div>
<div><dt>已收合计</dt><dd class="is-received">CNY {{ money(result?.totalReceived) }}</dd></div>
<div><dt>余额未收</dt><dd class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</dd></div>
</dl><div class="statistics-report-heading"><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></div></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
@@ -117,20 +126,17 @@ onMounted(() => { void loadCustomers(); void load() })
<style scoped>
.statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); }
.query-field { display: grid; min-width: 220px; gap: var(--admin-space-2); color: var(--workbench-muted); font-size: var(--admin-font-body); }
.query-field { min-width: 220px; }
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
.query-actions, .report-actions { display: flex; gap: var(--admin-space-3); }
.report-actions { margin-left: auto; }
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; }
.statistics-report-heading { display: flex; align-items: center; justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statistics-table h2 { margin: 0; font-size: var(--admin-font-section); }
.statistics-report-heading span { color: var(--workbench-muted); font-size: var(--admin-font-body); }
@media print {
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
.receivable-statistics-page { gap: 10px; padding: 0; color: #000; }
.workbench-table > :deep(.data-table) { border-radius: var(--admin-radius); box-shadow: none; }
.statistics-summary > div, .statistics-table { border-color: #999; box-shadow: none; }
.finance-metric-grid > article, .statistics-table { border-color: #999; box-shadow: none; break-inside: avoid; }
}
</style>
@@ -44,3 +44,5 @@
- 报价、汇率、车辆、车队报价与出车记录导出统一使用 `../downloads/` 的 `useExportTask`,按当前筛选创建 XLSX 任务;按钮要求原业务导出权限和 `platform:download:list`。出车成本可见性由 File 根据账号权限冻结,不接受页面声明。CSV 仅用于车队报价导入模板。
- 拼车接货简化沿 `FleetCapacityOfferModels.Inquiry.progress` 展示缺项、是否保位、负责人及主要动作,`appointmentCoverage`只消费预约侧真实投影,不以承运成功冒充预约。`CapacityInquiryFulfillment.vue`从原登记按需读取本票履约,不授予销售整车读取权限;来货、核量、排入本车分别保持原写权限。`TripPreparationDialog.vue`和`TripDispatchSelector.vue`通过`index.ts`公开给排车/预约使用;前者提交原子`/fleet/trips/prepare`并衔接容量发布,后者只读取本仓合格候选。新版合同见`fleet-preparation-v1.yaml`及拼车唯一主契约。
- 车辆详情与司机详情通过 `ResourcePhotoPanel.vue` 维护常用照片,接口 `api/resourcePhotos.ts`/`fleet-resource-photos-v1.yaml`;车辆沿vehicle:edit、司机沿driver:upload,读取沿各自list。照片版本独立于档案字段版本,替换保留历史且不改变驾驶证审核;排车缺照片时由服务端复制为当趟证据。只在详情加载元数据、点击预览读取图片,不逐行请求。
@@ -1,5 +1,9 @@
# 海外通用资料原型核对矩阵
## 档案照片复用(2026-10-26)
依据用户确认优化重复上传:车辆概览和司机详情增加常用照片维护,沿既有详情分区与620px居中预览;元数据按详情加载,原图点击才读。车辆编辑权限与司机上传权限分别控制替换。排车选档案后自动带入并保存当趟副本;沿用Admin详情、提示及表单规范。自动行为验证与类型检查单独报告,1920×1080、100%截图待人工确认。
## 散板需求只读展示(2026-10-21)
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
@@ -367,3 +371,9 @@ S05补充源码核对:实际自有/外配API均经公共解包检查业务码
| OwnedBusinessOperationsEditor、OutsourcedBusinessOperationsEditor | 自有/外配业务详情的居中运营登记弹框 | 表单补齐公共密度控件宽度;目的仓及预约号允许换行,实际到达时间列以受约束宽度占位,防止长业务编号撑宽 | 定向回归通过;待人工1920×1080、100%截图确认 |
依据本轮可用性反馈与本文件原操作矩阵;金额、日期时区、版本及未知结果重试逻辑均未修改。司机、车辆和运维编辑表单已有多列布局与公共底部确认入口,源码复查未据此扩大修改;真实可用性仍需截图确认。
## 装车资料多图(2026-10-26)
依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。
- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。
@@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('outsourced business HTTP contract', () => {
it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => {
const license: OutsourcedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' }
const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob)
await expect(getOutsourcedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效')
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await expect(getOutsourcedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效')
})
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle.ownership = ownership
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
@@ -44,7 +44,7 @@ export async function saveOutsourcedBusinessOperations(no: string, body: Outsour
}
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
requireSlot(slot)
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
@@ -68,7 +68,7 @@ export async function getOutsourcedBusinessPhotoContent(no: string, photo: Outso
try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效')
return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -12,6 +12,15 @@ beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/owned-business'
const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('owned business HTTP contract', () => {
it('reads an imported PDF only for the driver license category and rejects a mismatched response', async () => {
const license: OwnedBusinessPhoto = { ...photo, slot: 'LOADING_SCENE', category: 'LOADING', contentType: 'application/pdf' }
const blob = new Blob(['%PDF-1.4'], { type: 'application/pdf' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(getOwnedBusinessPhotoContent('DS/1', license)).resolves.toBe(blob)
await expect(getOwnedBusinessPhotoContent('DS/1', { ...license, slot: 'LOADING_LIST' })).rejects.toThrow('格式无效')
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await expect(getOwnedBusinessPhotoContent('DS/1', license)).rejects.toThrow('格式无效')
})
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified non-pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = ownedBusinessFixture(); detail.row.vehicle.ownership = ownership; detail.row.vehicle.vehicleId = null
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
@@ -38,7 +38,7 @@ export async function saveOwnedBusinessOperations(no: string, body: OwnedBusines
return value
}
export async function uploadOwnedBusinessPhoto(no: string, slot: OwnedBusinessPhotoSlot, file: File, key: string) {
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || file.size <= 0) throw new Error('请选择JPG/PNG,司机驾驶证也支持PDF')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OwnedBusinessPhoto>, ApiEnvelope<OwnedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
@@ -60,7 +60,7 @@ export async function getOwnedBusinessPhotoContent(no: string, photo: OwnedBusin
try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: ownedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
if (!(blob instanceof Blob) || !(['image/jpeg','image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料内容为空或格式无效')
return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -0,0 +1,15 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto } from './resourcePhotos'
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
it('preserves long identifiers and versions and submits the correct resource scope', async () => {
const id = '9223372036854775701', fileId = '9223372036854775702', version = '9007199254740993'
const wire = { code: '200', data: { recordVersion: version, photo: { fileId, fileName: 'photo.png', contentType: 'image/png', sizeBytes: '3' } } }
vi.mocked(http.get).mockResolvedValue(wire); vi.mocked(http.post).mockResolvedValue(wire)
expect(await getResourcePhoto('VEHICLE', id)).toMatchObject({ recordVersion: version, photo: { fileId, sizeBytes: 3 } })
await uploadResourcePhoto('VEHICLE', id, version, new File(['png'], 'photo.png', { type: 'image/png' }))
expect(http.post).toHaveBeenCalledWith(`/platform/v1/last-leg/fleet/resource-photos/VEHICLE/${id}`, expect.any(FormData))
expect((vi.mocked(http.post).mock.calls[0]![1] as FormData).get('recordVersion')).toBe(version)
vi.mocked(http.get).mockResolvedValue(new Blob(['{"code":"A0403"}'], { type: 'application/json' }))
await expect(downloadResourcePhoto('VEHICLE', id, fileId)).rejects.toThrow('读取失败')
})
@@ -0,0 +1,22 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ResourcePhotoKind = 'VEHICLE' | 'DRIVER'
export interface ResourcePhoto { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface ResourcePhotoDetail { recordVersion: string; photo: ResourcePhoto | null }
const path = (kind: ResourcePhotoKind, id: string) => `/platform/v1/last-leg/fleet/resource-photos/${kind}/${encodeURIComponent(id)}`
type Wire = { recordVersion: string | number; photo: (Omit<ResourcePhoto, 'sizeBytes'> & { sizeBytes: number | string }) | null }
function normalize(value: Wire): ResourcePhotoDetail {
return { recordVersion: String(value.recordVersion), photo: value.photo ? { ...value.photo, sizeBytes: Number(value.photo.sizeBytes) } : null }
}
export async function getResourcePhoto(kind: ResourcePhotoKind, id: string) {
return normalize(unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(kind, id))))
}
export async function uploadResourcePhoto(kind: ResourcePhotoKind, id: string, version: string, file: File) {
const body = new FormData(); body.append('recordVersion', version); body.append('file', file)
return normalize(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(kind, id), body)))
}
export async function downloadResourcePhoto(kind: ResourcePhotoKind, id: string, fileId: string) {
const blob = await http.get<Blob, Blob>(`${path(kind, id)}/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size || blob.size > 10 * 1024 * 1024) throw new Error('档案照片读取失败,请重新核对')
return blob
}
@@ -24,7 +24,7 @@ describe('OutsourcedBusiness presentation boundaries', () => {
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字'])
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
})
@@ -37,12 +37,15 @@ describe('OutsourcedBusiness presentation boundaries', () => {
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
it('unlocks operations closing when the request ends and still respects the owner guard', async () => {
const done = vi.fn(), guard = vi.fn()
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ busy: false }); close(done)
expect(done).toHaveBeenCalledTimes(1)
await wrapper.setProps({ beforeClose: guard }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})
@@ -19,7 +19,7 @@ function close(done: () => void) {
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">装车资料每类最多5张,支持 JPG、PNG,可逐张替换。此处补充照片不改变装车状态。</InlineHelp></p>
<slot name="state" />
<div class="outsourced-business-photo-grid">
@@ -57,8 +57,21 @@ describe('outsourced business per-slot photo save',()=>{
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(calls.content).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
await wrapper.findAll('button').find(button=>button.text()==='查看资料')!.trigger('click');await flushPromises()
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
})
})
it('renders all saved images and replaces only the chosen file', async () => {
const detail=outsourcedBusinessFixture()
detail.photos=[{...photo,savedAt:'2026-10-26T00:00:00Z'},{...photo,fileId:'222',fileName:'second.png',savedAt:'2026-10-26T00:00:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}})
await flushPromises()
expect(wrapper.text()).toContain('loading.png');expect(wrapper.text()).toContain('second.png')
const input=wrapper.findAll('input[type="file"]')[1]!
Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['new'],'new.png',{type:'image/png'})]})
await input.trigger('change');await flushPromises()
expect(calls.bind).toHaveBeenLastCalledWith(expect.any(String),'LOADING_SCENE',expect.objectContaining({replaceFileId:'222'}))
wrapper.unmount()
})
@@ -16,10 +16,10 @@ const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
const urls = ref<Record<string,string>>({}), reading = ref<string | null>(null)
const uploadable = computed(() => access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
let actor = '', number = '', generation = 0
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
function savedPhotos(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.filter(photo => photo.slot === slot && photo.savedAt) || [] }
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
watch(busy, value => emit('busy', value))
@@ -30,25 +30,26 @@ watch(open, value => {
}, { immediate: true })
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
onBeforeUnmount(() => { generation++; release() })
async function preview(slot: OutsourcedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
async function preview(slot: OutsourcedBusinessPhotoSlot, fileId: string) {
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
reading.value = fileId; error.value = null
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[fileId] = URL.createObjectURL(blob) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (current === generation) reading.value = null }
}
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event, replaceFileId?: string) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return }
busy.value = true; error.value = null
try {
ensureScope()
const photo = await uploadOutsourcedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
const latest = await getOutsourcedBusiness(number); ensureScope()
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
const result = await bindOutsourcedBusinessPhoto(number, slot, { recordVersion: outsourcedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
const result = await bindOutsourcedBusinessPhoto(number, slot, { recordVersion: outsourcedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); ensureScope()
emit('saved', result); ElMessage.success('照片已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
finally { busy.value = false }
@@ -60,10 +61,14 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
<p v-else>暂无已保存照片</p>
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
<a v-if="urls[photo.fileId] && photo.contentType === 'application/pdf'" :href="urls[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<img v-else-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="outsourced-business-photo" />
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看资料</el-button>
<label v-if="uploadable" class="outsourced-business-upload">替换资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
</div>
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="outsourced-business-upload">上传资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event)" /></label>
</template>
</OutsourcedBusinessPhotosDialog>
</template>
@@ -24,7 +24,7 @@ describe('OwnedBusiness presentation boundaries', () => {
const wrapper = mount(OwnedBusinessPhotosDialog, { global, props: { modelValue: true } })
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证', '起始油耗表盘', '结束油耗表盘'])
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['司机驾驶证', '车牌照片', '装车完成', '封车照片', 'BOL司机签字', '起始油耗表盘', '结束油耗表盘'])
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
})
@@ -37,12 +37,15 @@ describe('OwnedBusiness presentation boundaries', () => {
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
it('unlocks operations closing when the request ends and still respects the owner guard', async () => {
const done = vi.fn(), guard = vi.fn()
const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, busy: true } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ busy: false }); close(done)
expect(done).toHaveBeenCalledTimes(1)
await wrapper.setProps({ beforeClose: guard }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
expect(guard).toHaveBeenCalledWith(done); expect(done).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})
@@ -10,13 +10,13 @@ const props = defineProps<{
}>()
const open = defineModel<boolean>({ default: false })
const activeTab = defineModel<'loading' | 'fuel'>('activeTab', { default: 'loading' })
// These are prototype presentation slots; the public file categories are not yet frozen.
// 展示槽位沿用既有引用,标签与排车装车资料一致。
const loadingPhotos = [
{ slot: 'loading-scene', label: '装车现场' },
{ slot: 'loading-list', label: '装车清单' },
{ slot: 'pre-departure-check', label: '出车前检查' },
{ slot: 'driver-license', label: '驾驶证附件' },
{ slot: 'delivery-receipt', label: '签收凭证' },
{ slot: 'loading-scene', label: '司机驾驶证' },
{ slot: 'loading-list', label: '车牌照片' },
{ slot: 'pre-departure-check', label: '装车完成' },
{ slot: 'driver-license', label: '封车照片' },
{ slot: 'delivery-receipt', label: 'BOL司机签字' },
]
const fuelPhotos = [{ slot: 'fuel-start', label: '起始油耗表盘' }, { slot: 'fuel-end', label: '结束油耗表盘' }]
function close(done: () => void) {
@@ -28,7 +28,7 @@ function close(done: () => void) {
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</InlineHelp></p>
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}<InlineHelp title="照片说明">装车资料每类最多5张,油耗每类1张;支持 JPG、PNG,可逐张替换。此处补充照片不改变装车状态。</InlineHelp></p>
<slot name="state" />
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
@@ -50,3 +50,16 @@ describe('owned business per-slot photo save',()=>{
wrapper.unmount()
})
})
it('renders all saved images and replaces only the chosen file', async () => {
const detail=ownedBusinessFixture()
detail.photos=[{...photo,savedAt:'2026-10-26T00:00:00Z'},{...photo,fileId:'222',fileName:'second.png',savedAt:'2026-10-26T00:00:00Z'}]
const wrapper=mount(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail}})
await flushPromises()
expect(wrapper.text()).toContain('loading.png');expect(wrapper.text()).toContain('second.png')
const input=wrapper.findAll('input[type="file"]')[1]!
Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['new'],'new.png',{type:'image/png'})]})
await input.trigger('change');await flushPromises()
expect(calls.bind).toHaveBeenLastCalledWith(expect.any(String),'LOADING_SCENE',expect.objectContaining({replaceFileId:'222'}))
wrapper.unmount()
})
@@ -16,10 +16,10 @@ const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OwnedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const urls = ref<Partial<Record<OwnedBusinessPhotoSlot,string>>>({}), reading = ref<OwnedBusinessPhotoSlot | null>(null)
const urls = ref<Record<string,string>>({}), reading = ref<string | null>(null)
const uploadable = computed(() => access.can(permission.ownedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
let actor = '', number = '', generation = 0
function savedPhoto(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
function savedPhotos(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.filter(photo => photo.slot === slot && photo.savedAt) || [] }
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
watch(busy, value => emit('busy', value))
@@ -30,25 +30,26 @@ watch(open, value => {
}, { immediate: true })
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
onBeforeUnmount(() => { generation++; release() })
async function preview(slot: OwnedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
async function preview(slot: OwnedBusinessPhotoSlot, fileId: string) {
const photo = savedPhotos(slot).find(item => item.fileId === fileId), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
reading.value = fileId; error.value = null
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[fileId] = URL.createObjectURL(blob) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (current === generation) reading.value = null }
}
async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
async function upload(slot: OwnedBusinessPhotoSlot, event: Event, replaceFileId?: string) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
if (!replaceFileId && savedPhotos(slot).length >= (slot.startsWith('FUEL_') ? 1 : 5)) return
if (!(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size) { error.value = { message: '请选择JPG/PNG,司机驾驶证也支持PDF' }; return }
busy.value = true; error.value = null
try {
ensureScope()
const photo = await uploadOwnedBusinessPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
const latest = await getOwnedBusiness(number); ensureScope()
if (!latest.row.actions.upload.allowed) throw new Error(latest.row.actions.upload.blockReason || '当前不允许上传照片')
const result = await bindOwnedBusinessPhoto(number, slot, { recordVersion: ownedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
const result = await bindOwnedBusinessPhoto(number, slot, { recordVersion: ownedBusinessVersion(latest.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) }); ensureScope()
emit('saved', result); ElMessage.success('照片已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
finally { busy.value = false }
@@ -60,10 +61,14 @@ function beforeClose(done: () => void) { if (!busy.value) done() }
<OwnedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="owned-business-photo" />
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
<p v-else>暂无已保存照片</p>
<label v-if="uploadable" class="owned-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy" @change="upload(role.slot, $event)" /></label>
<div v-for="photo in savedPhotos(role.slot)" :key="photo.fileId">
<a v-if="urls[photo.fileId] && photo.contentType === 'application/pdf'" :href="urls[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<img v-else-if="urls[photo.fileId]" :src="urls[photo.fileId]" :alt="role.label" class="owned-business-photo" />
<p>{{ photo.fileName }} · 已保存</p><el-button :loading="reading === photo.fileId" :disabled="Boolean(reading)" @click="preview(role.slot, photo.fileId)">查看资料</el-button>
<label v-if="uploadable" class="owned-business-upload">替换资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event, photo.fileId)" /></label>
</div>
<p v-if="!savedPhotos(role.slot).length">暂无已保存照片</p>
<label v-if="uploadable && savedPhotos(role.slot).length < (role.slot.startsWith('FUEL_') ? 1 : 5)" class="owned-business-upload">上传资料<input type="file" :accept="role.slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :disabled="busy" @change="upload(role.slot, $event)" /></label>
</template>
</OwnedBusinessPhotosDialog>
</template>
@@ -0,0 +1,54 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import ResourcePhotoPanel from './ResourcePhotoPanel.vue'
import * as api from '../api/resourcePhotos'
const security = vi.hoisted(() => ({ writable: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => security.writable }) }))
vi.mock('../api/resourcePhotos', () => ({ getResourcePhoto: vi.fn(), uploadResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn() }))
const photo = { fileId: '81', fileName: 'driver.png', contentType: 'image/png', sizeBytes: 3 }
const options = { props: { kind: 'DRIVER' as const, resourceId: '7' }, global: { directives: { loading: () => undefined }, stubs: {
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /></div>' },
} } }
beforeEach(() => {
vi.resetAllMocks(); security.writable = true
vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '2', photo })
URL.createObjectURL = vi.fn().mockReturnValue('blob:photo'); URL.revokeObjectURL = vi.fn()
})
it('keeps reading independent from upload permission and downloads only on preview', async () => {
security.writable = false
vi.mocked(api.downloadResourcePhoto).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
expect(wrapper.text()).not.toContain('上传 / 替换')
expect(api.downloadResourcePhoto).not.toHaveBeenCalled()
await wrapper.get('button').trigger('click'); await flushPromises()
expect(api.downloadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '81')
wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:photo')
})
it('submits the independent photo version and releases busy state after failure', async () => {
vi.mocked(api.uploadResourcePhoto).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409'))
.mockResolvedValueOnce({ recordVersion: '3', photo })
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
const input = wrapper.get('input[type="file"]'), file = new File(['png'], 'driver.png', { type: 'image/png' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change'); await flushPromises()
expect(api.uploadResourcePhoto).toHaveBeenCalledWith('DRIVER', '7', '2', file)
expect(wrapper.text()).toContain('版本冲突')
await input.trigger('change'); await flushPromises()
expect(api.uploadResourcePhoto).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('does not apply an upload result to a different resource', async () => {
let finish!: (value: api.ResourcePhotoDetail) => void
vi.mocked(api.uploadResourcePhoto).mockImplementation(() => new Promise(resolve => { finish = resolve }))
const wrapper = mount(ResourcePhotoPanel, options); await flushPromises()
const input = wrapper.get('input[type="file"]')
Object.defineProperty(input.element, 'files', { value: [new File(['png'], 'driver.png', { type: 'image/png' })] })
await input.trigger('change')
vi.mocked(api.getResourcePhoto).mockResolvedValue({ recordVersion: '0', photo: null })
await wrapper.setProps({ resourceId: '8' }); await flushPromises()
finish({ recordVersion: '3', photo }); await flushPromises()
expect(wrapper.text()).toContain('尚未上传司机照片')
wrapper.unmount()
})
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getResourcePhoto, uploadResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind, type ResourcePhotoDetail } from '../api/resourcePhotos'
import { overseasMasterDataPermissions as permission } from '../permissions'
const props = defineProps<{ kind: ResourcePhotoKind; resourceId: string }>()
const access = useAccessPreviewStore(), detail = ref<ResourcePhotoDetail | null>(null), loading = ref(false), busy = ref(false)
const error = ref<ApiErrorDetails | null>(null), input = ref<HTMLInputElement | null>(null), previewOpen = ref(false), url = ref('')
const label = computed(() => props.kind === 'VEHICLE' ? '车辆图片' : '司机照片')
const editable = computed(() => access.can(props.kind === 'VEHICLE' ? permission.vehicleEdit : permission.driverUpload))
let generation = 0
function closePreview() { previewOpen.value = false; if (url.value) URL.revokeObjectURL(url.value); url.value = '' }
async function load() {
const current = ++generation; closePreview(); detail.value = null; loading.value = true; error.value = null; busy.value = false
try { const result = await getResourcePhoto(props.kind, props.resourceId); if (current === generation) detail.value = result }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '档案照片加载失败') }
finally { if (current === generation) loading.value = false }
}
async function upload(event: Event) {
const element = event.target as HTMLInputElement, file = element.files?.[0]; element.value = ''
if (!file || !detail.value || busy.value || !editable.value) return
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择非空 JPG 或 PNG,最大 10MiB'); return }
const current = generation; busy.value = true; error.value = null
try {
const saved = await uploadResourcePhoto(props.kind, props.resourceId, detail.value.recordVersion, file)
if (current !== generation) return
detail.value = saved; closePreview(); ElMessage.success(`${label.value}已保存,新排车会自动带入`)
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '照片保存未完成,请重新读取后重试') }
finally { if (current === generation) busy.value = false }
}
async function preview() {
if (!detail.value?.photo || busy.value) return
const current = generation; busy.value = true; error.value = null
try {
const blob = await downloadResourcePhoto(props.kind, props.resourceId, detail.value.photo.fileId)
if (current !== generation) return
closePreview(); url.value = URL.createObjectURL(blob); previewOpen.value = true
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '照片预览失败') }
finally { if (current === generation) busy.value = false }
}
watch(() => [props.kind, props.resourceId], load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview() })
</script>
<template>
<section v-loading="loading" :aria-label="label">
<LoadErrorState v-if="error" title="档案照片操作未完成" :message="error.message" compact @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div class="density-actions">
<el-button v-if="detail?.photo" :disabled="busy" @click="preview">查看{{ label }}</el-button><span v-else-if="detail">尚未上传{{ label }}</span>
<el-button v-if="editable" :disabled="!detail || busy" :loading="busy" @click="input?.click()">上传 / 替换{{ label }}</el-button>
<input ref="input" hidden type="file" accept="image/jpeg,image/png" :aria-label="'上传' + label" @change="upload">
</div>
<p class="density-dialog-note">排车选择此档案后自动带入照片;替换不影响已保存的排车资料。JPG、PNG,最大 10MiB。</p>
<AppDialog :model-value="previewOpen" class="admin-density-dialog" :title="label" width="620px" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview"><img v-if="url" class="resource-photo-preview" :src="url" :alt="label"></AppDialog>
</section>
</template>
<style scoped>.resource-photo-preview { display: block; width: 100%; max-height: 60vh; object-fit: contain; }</style>
@@ -18,3 +18,6 @@ export { normalizeTripDetail } from './api/trips'
export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
export type { PickupCandidate, PickupTask, PickupEvidence, PickupPlan, PickupCommand, PickupConfirmation } from './api/pickups'
export { pickupStatusLabels, downloadPickupEvidence } from './api/pickups'
export { getResourcePhoto, downloadResourcePhoto } from './api/resourcePhotos'
export type { ResourcePhotoKind } from './api/resourcePhotos'
@@ -5,7 +5,7 @@ export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PR
export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] }
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OutsourcedBusinessRow {
dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
@@ -21,16 +21,16 @@ export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: n
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' }
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' },
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' },
]
export function outsourcedBusinessVersion(value: unknown): string {
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
@@ -5,7 +5,7 @@ export type OwnedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEP
export interface OwnedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OwnedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OwnedBusinessOperations { money: OwnedBusinessMoney | null; actualDistanceMiles: string | null; fuelConsumptionLitresPer100Km: string | null; arrivals: OwnedBusinessArrival[] }
export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png' | 'application/pdf'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OwnedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OwnedBusinessRow {
dispatchNo: string; stage: OwnedBusinessStage; plannedDepartureAt: string; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
@@ -22,16 +22,16 @@ export interface OwnedBusinessPage<T> { records: T[]; page: number; size: number
export interface OwnedBusinessList extends OwnedBusinessPage<OwnedBusinessRow> { summary: OwnedBusinessSummary }
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] }
export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null }
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string }
export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
export interface OwnedBusinessCommand { dispatchNo: string; action: OwnedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OwnedBusinessDetail | OwnedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OwnedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OwnedBusinessOperations | null; photos: OwnedBusinessPhoto[] }
export const ownedBusinessStages: Record<OwnedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片', INVALIDATE_RESOURCE_PHOTOS: '车辆或司机资料变更,原凭证失效' }
export const ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' },
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '司机驾驶证', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '车牌照片', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '装车完成', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '封车照片', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: 'BOL司机签字', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' },
]
export function ownedBusinessVersion(value: unknown): string {
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
@@ -153,6 +154,7 @@ onBeforeUnmount(() => { detailActive = false; resetDetailRequests() })
<div v-loading="loading">
<template v-if="detail">
<FormSection class="density-form-section" title="基础信息"><dl class="density-fields"><div v-for="[label, value] in basic" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div><div class="wide-field"><dt>备注</dt><dd>{{ detail.details.remark || '—' }}</dd></div></dl></FormSection>
<FormSection class="density-form-section" title="司机照片"><ResourcePhotoPanel kind="DRIVER" :resource-id="detail.id" /></FormSection>
<FormSection class="density-form-section" title="驾驶资质"><dl class="density-fields"><div v-for="[label, value] in credentials" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl></FormSection>
<FormSection class="density-form-section" title="证照与派车状态">
<div class="driver-document-actions"><StatusBadge :status="driverReviewLabels[detail.reviewStatus]" /><el-button v-if="detail.licenseFileId" @click="showLicense(detail.licenseFileId)">查看驾驶证</el-button><span v-else>尚未上传驾驶证</span><el-button v-if="access.can(permission.driverUpload)" :loading="busy" @click="fileInput?.click()">上传 / 替换驾驶证</el-button><input ref="fileInput" hidden type="file" accept="application/pdf,image/jpeg,image/png" @change="upload" /></div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { onBeforeUnmount, ref, watch } from 'vue'
@@ -19,5 +20,5 @@ function back(){void router.push({name:'overseas-master-data-vehicles'})}
watch(() => route.params.vehicleId, () => { activeTab.value='overview';void load() }, { immediate:true })
onBeforeUnmount(() => { request++ })
</script>
<template><AdminPage class="admin-density admin-workbench" ><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="车辆详情"><template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><el-tabs v-if="detail" v-model="activeTab"><el-tab-pane label="车辆概览" name="overview" /><el-tab-pane label="保养及加油" name="care" /><el-tab-pane label="维修记录" name="REPAIR" /><el-tab-pane label="紧急故障" name="BREAKDOWN" /><el-tab-pane label="应急救援" name="RESCUE" /><el-tab-pane label="异常管理" name="EXCEPTION" /></el-tabs><template v-if="activeTab === 'overview'"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['最大标准托位',detail.maxPalletSpaces??'待确认'],['最大载重(kg)',detail.maxLoadKg??'待确认'],['可用容积(m³)',detail.maxVolumeCbm??'待确认'],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection class="density-form-section" title="派送订单明细"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="deliveries" /></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="changes" /></FormSection></template><div v-if="detail && activeTab === 'care'" :key="detail.id" class="care-grid"><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="MAINTENANCE" /><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="FUEL" /></div><VehicleCareTable v-if="detail && ['REPAIR','BREAKDOWN','RESCUE','EXCEPTION'].includes(activeTab)" :key="`${detail.id}:${activeTab}`" :vehicle-id="detail.id" :plate="detail.plateNumber" :record-type="activeTab as VehicleCareType" /></section></DetailPageLayout></AdminPage></template>
<template><AdminPage class="admin-density admin-workbench" ><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="车辆详情"><template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><el-tabs v-if="detail" v-model="activeTab"><el-tab-pane label="车辆概览" name="overview" /><el-tab-pane label="保养及加油" name="care" /><el-tab-pane label="维修记录" name="REPAIR" /><el-tab-pane label="紧急故障" name="BREAKDOWN" /><el-tab-pane label="应急救援" name="RESCUE" /><el-tab-pane label="异常管理" name="EXCEPTION" /></el-tabs><template v-if="activeTab === 'overview'"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['最大标准托位',detail.maxPalletSpaces??'待确认'],['最大载重(kg)',detail.maxLoadKg??'待确认'],['可用容积(m³)',detail.maxVolumeCbm??'待确认'],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection v-if="detail" class="density-form-section" title="车辆图片"><ResourcePhotoPanel kind="VEHICLE" :resource-id="detail.id" /></FormSection><FormSection class="density-form-section" title="派送订单明细"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="deliveries" /></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><VehicleFulfillmentTable v-if="detail" :vehicle-id="detail.id" kind="changes" /></FormSection></template><div v-if="detail && activeTab === 'care'" :key="detail.id" class="care-grid"><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="MAINTENANCE" /><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="FUEL" /></div><VehicleCareTable v-if="detail && ['REPAIR','BREAKDOWN','RESCUE','EXCEPTION'].includes(activeTab)" :key="`${detail.id}:${activeTab}`" :vehicle-id="detail.id" :plate="detail.plateNumber" :record-type="activeTab as VehicleCareType" /></section></DetailPageLayout></AdminPage></template>
<style scoped>.care-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-6)}.detail-grid .wide-field { grid-column: span 2; }</style>
+1 -1
View File
@@ -58,7 +58,7 @@
## 验证
- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
- 排车调度/理货审核分区仅显示审核、详情;车辆图片与司机照片优先从所选主档自动带入,保存时复制为本趟附件;已有当趟照片保留,主档缺失或临时外配时通过既有本车附件接口补传。驾驶证沿主档引用且接受PDF/JPG/PNG;司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
- 纯文案/纯展示只核对差异;业务逻辑变化选择直接对应测试,不因文档引用某测试就自动执行。
- 转运审核组件/请求逻辑变化:定向选择 `components/WarehouseTransferReviewDialog.test.ts`、`api/warehouseTransfers.test.ts`;入口、权限或状态变化再加 `pages/prototypeContract.test.ts`。
@@ -317,6 +317,7 @@
| 尾程订单 | 出库计划 | `modal-lmo-outbound-plan`,927 行 | 居中弹框,约 760px | 无正式实现 | 明确属于库存离仓,排除,不实现 |
| 入库单 | 列表/筛选 | `page-fba-inbound-order`,491–525 行;`fbioSearch()`、`fbioReset()` | 独立列表页,查询区、批量预约/排车、横向宽表 | `/prototype/fba-returns/inbound-orders` 占位 | 已接真实分页、显式客户、筛选、状态、加载/错误/空态;排车字段后端未提供,保持只读空值 |
| 入库单 | 详情 | `page-fba-inbound-order-detail`,684–691 行;`openFbaInboundOrderDetail()` 8567–8575 行 | 独立详情页;单据信息、仓储信息、预约与排车三组字段,应收费用明细位于正文 | 正式参数路由与 `InboundOrderDetailView.vue` | 已接计划详情和 `sourceOrderId`;接口未提供的预约/排车时间、备注和费用继续显示空值 |
| 入库单 | 确认短少入库 | 用户确认实际短少后的处理要求;Admin规范“简单确认、原因填写” | 详情托盘明细标题区操作,560px居中确认弹框;应收、实收和短少只读,原因必填 | `InboundShortageConfirmation.vue`;仅待差异核对的FBA短少和入库编辑权限可用 | 定向组件/API/页面测试与类型检查通过;1920×1080、100%待人工截图确认 |
| 入库单 | 编辑 | `modal-fbio-edit`,527 行 | 居中弹框,约 880px | 无正式实现 | 待海外区域写接口与状态机冻结后实现 |
| 入库单 | 预约 | `modal-fbio-appointment`,526 行 | 居中弹框,约 720px | 无正式实现 | 待预约/排车接口与幂等契约冻结后实现 |
| 入库单 | 新增 | 页面按钮存在但原型未定义对应可达表单 | 原型未定义 | 无正式实现 | 标记待确认,不自行选弹框/抽屉/独立页 |
@@ -448,3 +449,27 @@
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 |
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
## 灰度界面精简(2026-10-26)
依据:本轮用户截图 1—7 及后续五项确认;Admin 基于 main `952abd56` 的 `hotfix-gray-bugfix`。已有入库短收改动保留。
| 页面 | 操作 | 确认布局与实现 | 验证 |
|---|---|---|---|
| 尾程订单 | 入库计划 | 保留原居中、可全屏弹框;移除顶部说明,托数使用紧凑输入框,说明靠近输入并自然换行;保存生成入库单及预计到仓锁定规则不变 | 模板类型检查通过;1920×1080、100%待人工截图确认 |
| 尾程订单 | 修改节点 | 保留原居中弹框;去掉黄色提示及分区背景,只显示国内报关完成、国外清关完成;读取并原样回传隐藏节点已有值 | 两项编辑、隐藏节点保留、零版本提交回归通过;待人工截图确认 |
| 排车列表 | 调度审核图片 | 保持三类图片横向并排;分区顶部对齐,多图按可用宽度并排;缩略图保持完整比例,长文件名省略且可悬停查看,保留大图预览 | 附件组件19项、审核布局29项通过;待人工截图确认 |
| 清关提柜详情 | DO/目的港清关附件 | 保留上传弹框,选中文件即上传保存;上传中防重复,失败保留文件可重试;目的港清关完成仍是独立业务动作 | 自动上传、防重复、失败重试共4项回归通过;待人工截图确认 |
续修验证:修正与当前开船阶段、服务端到仓投影和默认白色分区不一致的旧测试,新增未开船订单只读回归;补齐尾程费用表单的可编辑表格标记、统一订单状态按钮的共享高度变量。Admin 7个文件265项定向检查全部通过,Vue类型检查通过。PDA 381项全量测试全部通过(失败重试按现行规则使用新键与当前输入);此前 H5 与 App 资源构建通过。自动检查不代替真实服务、PDA真机或人工视觉验收。
## 档案照片自动带入(2026-10-26)
依据用户确认优化重复上传:维护车辆/司机沿原三列照片布局,缺少本趟照片时展示所选主档照片,并显示保存后用于本趟;允许本趟补传覆盖默认来源。驾驶证允许PDF/JPG/PNG。普通派送及到仓取货均由服务端保存档案照片副本,历史排车保持原图。自动行为测试与类型检查单独报告;布局待人工1920×1080、100%截图确认。
## 装车资料多图(2026-10-26)
依据用户PDA截图标注,五类统一为司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字。PDA每类最多5张,可多选追加、逐张更换;Admin排车资料仍沿五行表格展示,每类逐图预览/更换,自有和外配照片弹框展示全部当前图片,油耗保持单张。五类各至少1张与装车确认共同控制自动派送。PDA相关75项、后端47项、Admin照片编辑17项及照片弹框4项通过,定向类型检查通过;扩大到运营布局时另有2项既存关闭行为断言失败,未改动该运营代码。未部署,PDA真机及Admin人工截图待确认。
- 同日双端复核:Admin装车资料补齐多选上传;PDA/Admin保存响应异常时重读服务端当前照片,核对失败保留刷新入口,停止本批后续上传。旧图替换不影响同类其他图,批次已成功部分保留。Admin 6文件32项、PDA 3文件38项及定向类型检查通过;此前2项运营弹框过期断言已按仅busy期间锁定的现行规则修正并通过。后端本轮未改,沿用47项验证结果;未部署、未做真机/人工截图验收。
@@ -3,15 +3,16 @@ import type { DispatchStage } from '../dispatchContracts'
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] as const
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
export const loadingPhotoLabels: Record<LoadingPhotoSlot, string> = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' }
export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string }
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; reviewRound: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string; uploadKey: string; replaceFileId?: string }
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
export async function getLoadingDocuments(no: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
}
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string) {
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string, reviewRound: string) {
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key); form.append('reviewRound', reviewRound)
return unwrapApiResponse(await http.post<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form))
}
export async function recoverLoadingPhoto(no: string, key: string) {
@@ -22,6 +23,6 @@ export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body:
}
export async function readLoadingPhoto(no: string, photo: LoadingPhoto) {
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
if (!(blob instanceof Blob) || !(['image/jpeg', 'image/png'].includes(blob.type) || photo.slot === 'LOADING_SCENE' && blob.type === 'application/pdf') || blob.type !== photo.contentType || !blob.size) throw new Error('资料读取失败,请重新核对')
return blob
}
@@ -1,10 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getInboundPlan, listInboundPlans } from './inboundPlans'
import { confirmInboundShortage, getInboundPlan, listInboundPlans } from './inboundPlans'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
describe('inbound plan overseas API', () => {
beforeEach(() => vi.mocked(http.get).mockReset())
it('confirms the current shortage with customer, version and idempotency key', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { status: 'PARTIAL_INBOUND' } })
const payload = { customerId: '190000000000000001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '短少1箱' }
await confirmInboundShortage('IP/1', payload, 'request-key')
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1/confirm-shortage', payload, { headers: { 'Idempotency-Key': 'request-key' } })
})
it('pages plans with explicit customer scope', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } })
await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' })
@@ -45,6 +45,7 @@ export interface InboundPlanSummaryDto {
}
export interface InboundPlanDetailDto {
version?: number
id: string
planNo: string
customerId: string
@@ -81,6 +82,21 @@ export async function getInboundPlan(planNo: string, customerId: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } }))
}
export interface ConfirmInboundShortagePayload {
customerId: string
version: number
expectedPackageCount: number
receivedPackageCount: number
reason: string
}
export async function confirmInboundShortage(planNo: string, payload: ConfirmInboundShortagePayload, idempotencyKey: string) {
return unwrapApiResponse(await http.post<ApiEnvelope<{ status: string }>, ApiEnvelope<{ status: string }>>(
`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/confirm-shortage`, payload,
{ headers: { 'Idempotency-Key': idempotencyKey } },
))
}
export interface PlatformInventoryMoveRequest {
overseasWarehouseId: string
sourcePalletCode: string
@@ -6,13 +6,13 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import * as api from '../api/dispatches'
import type { DispatchEvidenceFile } from '../dispatchContracts'
import { downloadDriverLicense } from '@/modules/overseas-master-data'
vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn() }))
import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto } from '@/modules/overseas-master-data'
vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn(), getResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn() }))
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '<section v-if="modelValue" role="dialog"><slot /><button @click="$emit(\'update:modelValue\', false)">关闭预览</button></section>' } } } }
const button = (wrapper: ReturnType<typeof mount>, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not count a PDF or failed upload as a %s photo', async kind => {
it.each(['VEHICLE', 'DRIVER_PHOTO'] as const)('does not count a PDF or failed upload as a %s photo', async kind => {
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind, photoCard: true, modelValue: [] } }); await flushPromises()
await selectFile(wrapper, new File(['pdf'], 'document.pdf', { type: 'application/pdf' }))
expect(api.uploadDispatchEvidence).not.toHaveBeenCalled()
@@ -154,3 +154,36 @@ it('rejects mismatched file identity and a blocked PDF popup without fetching co
vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); const open = vi.spyOn(window, 'open').mockReturnValueOnce(null)
const blocked = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(blocked, '预览').trigger('click'); await flushPromises(); expect(blocked.text()).toContain('请允许本站打开PDF预览窗口'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); blocked.unmount(); open.mockRestore()
})
it('accepts a PDF driver license in the photo card without requiring another image', async () => {
vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302', 'application/pdf'))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: [] } })
await flushPromises(); await selectFile(wrapper, new File(['pdf'], 'license.pdf', { type: 'application/pdf' }))
expect(api.uploadDispatchEvidence).toHaveBeenCalledOnce()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']]); wrapper.unmount()
})
it('previews a master photo without submitting its foreign file ID as dispatch evidence', async () => {
vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '1', photo: metadata('81') })
vi.mocked(downloadResourcePhoto).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, modelValue: [], masterResource: { kind: 'VEHICLE', id: '7' } } })
await flushPromises()
expect(wrapper.text()).toContain('已带入档案照片')
expect(api.getDispatchEvidence).not.toHaveBeenCalled()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.emitted('available')?.at(-1)).toEqual([true])
await wrapper.setProps({ modelValue: ['301'] }); await flushPromises()
expect(wrapper.text()).not.toContain('已带入档案照片')
expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
expect(getResourcePhoto).toHaveBeenCalledOnce(); wrapper.unmount()
})
it('ignores late profile photo responses after changing the selected resource', async () => {
let finish!: (value: Awaited<ReturnType<typeof getResourcePhoto>>) => void
vi.mocked(getResourcePhoto).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
.mockResolvedValueOnce({ recordVersion: '0', photo: null })
const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, photoCard: true, modelValue: [], masterResource: { kind: 'VEHICLE', id: '7' } } })
await wrapper.setProps({ masterResource: { kind: 'VEHICLE', id: '8' } }); await flushPromises()
finish({ recordVersion: '1', photo: metadata('81') }); await flushPromises()
expect(downloadResourcePhoto).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('已带入档案照片')
expect(wrapper.emitted('available')?.at(-1)).toEqual([false]); wrapper.unmount()
})
@@ -9,12 +9,13 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches'
import { downloadDriverLicense } from '@/modules/overseas-master-data'
import { downloadDriverLicense, getResourcePhoto, downloadResourcePhoto, type ResourcePhotoKind } from '@/modules/overseas-master-data'
import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts'
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; masterLicense?: { driverId: string; fileId: string } | null }>()
const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; masterResource?: { kind: ResourcePhotoKind; id: string } | null; masterLicense?: { driverId: string; fileId: string } | null }>()
const ids = defineModel<string[]>({ required: true })
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])])
const emit = defineEmits<{ busy: [value: boolean] }>()
const profilePhotoId = ref<string | null>(null)
const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value, ...(profilePhotoId.value ? [profilePhotoId.value] : [])])])
const emit = defineEmits<{ busy: [value: boolean]; available: [value: boolean] }>()
const metadata = ref<Record<string, DispatchEvidenceFile>>({}), error = ref<ApiErrorDetails | null>(null), uploading = ref(false), input = ref<HTMLInputElement | null>(null)
const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false)
const thumbnails = ref<Record<string, string>>({})
@@ -23,8 +24,8 @@ const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('')
const previewBusy = computed(() => previewLoading.value || imageDecoding.value)
const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照'))
const types = ['application/pdf', 'image/jpeg', 'image/png'], maximum = 10 * 1024 * 1024, urls = new Set<string>()
const uploadTypes = computed(() => (props.photoCard || ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind)) ? types.slice(1) : types)
const uploadFormats = computed(() => ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind) ? 'JPG、PNG' : 'PDF、JPG、PNG')
const uploadTypes = computed(() => (props.kind !== 'DRIVER' && (props.photoCard || ['DRIVER_PHOTO', 'EXCEPTION'].includes(props.kind))) ? types.slice(1) : types)
const uploadFormats = computed(() => uploadTypes.value.includes('application/pdf') ? 'PDF、JPG、PNG' : 'JPG、PNG')
let generation = 0, previewGeneration = 0, replacing: string | null = null, pendingPopup: Window | null = null
function valid(value: DispatchEvidenceFile, expected?: string) {
if (!value || !/^[1-9]\d*$/.test(String(value.fileId)) || typeof value.fileId === 'number' && !Number.isSafeInteger(value.fileId) || expected && String(value.fileId) !== expected || !value.fileName?.trim() || !types.includes(value.contentType) || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes <= 0 || value.sizeBytes > maximum) throw new DispatchInputError('附件元数据未确认本车有效文件,请重新读取')
@@ -44,13 +45,30 @@ function imageFailed(event: Event) {
imageFailure.value = '图片无法解码或加载,请重新预览;若仍失败,请核对原附件是否损坏。'
}
async function load() {
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value]
const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...new Set([...(props.referenceIds || []), ...ids.value])]
for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) }
thumbnails.value = {}
closePreview(); metadata.value = {}; error.value = null; masterBlob = null
closePreview(); metadata.value = {}; error.value = null; masterBlob = null; profilePhotoId.value = null; emit('available', false)
if (!no) return
// 未保存且没有本趟照片时展示档案图片;保存由服务端复制,不提交跨归属文件ID。
if (!values.length && props.masterResource) {
const resource = props.masterResource
try {
const source = await getResourcePhoto(resource.kind, resource.id)
if (current !== generation) return
if (source.photo) {
const blob = await downloadResourcePhoto(resource.kind, resource.id, source.photo.fileId)
if (current !== generation) return
const file = valid(source.photo)
if (blob.size !== file.sizeBytes || blob.type !== file.contentType) throw new DispatchInputError('档案照片内容与元数据不一致')
metadata.value[file.fileId] = file; masterBlob = { fileId: file.fileId, blob }
profilePhotoId.value = file.fileId; values.push(file.fileId); emit('available', true)
}
} catch (cause) { if (current === generation) fail(cause, '档案照片读取失败,请重试') }
}
const master = props.kind === 'DRIVER' ? props.masterLicense : null
const results = await Promise.allSettled(values.map(async id => {
if (id === profilePhotoId.value && metadata.value[id]) return metadata.value[id]!
// 未保存的司机主档证照按司机身份读取;本车补传仍走排车文件归属。
if (master && master.fileId === id) {
const blob = await downloadDriverLicense(master.driverId, id)
@@ -89,7 +107,7 @@ async function upload(event: Event) {
}
function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) }
async function readContent(no: string, kind: DispatchEvidenceKind, id: string, file: DispatchEvidenceFile) {
const blob = kind === 'DRIVER' && masterBlob?.fileId === id ? masterBlob.blob : await getDispatchEvidenceContent(no, kind, id)
const blob = masterBlob?.fileId === id ? masterBlob.blob : await getDispatchEvidenceContent(no, kind, id)
const type = blob.type.split(';')[0]?.toLowerCase()
if (type?.includes('json')) {
let body: Partial<{ code: string; message: string; requestId: string; traceId: string }>
@@ -115,7 +133,7 @@ async function preview(id: string) {
} catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } }
finally { if (current === previewGeneration) previewLoading.value = false }
}
watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value, props.masterLicense]), load, { immediate: true })
watch(() => JSON.stringify([props.dispatchNo, props.kind, ids.value, props.referenceIds, props.masterLicense, props.masterResource]), load, { immediate: true })
onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })
</script>
<template>
@@ -125,11 +143,12 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
<div v-if="allIds.length" class="evidence-photo-list">
<div v-for="id in allIds" :key="id" class="evidence-photo-item">
<button type="button" class="evidence-photo-preview" :aria-label="'预览' + label + ':' + (metadata[id]?.fileName || id)" :disabled="!metadata[id] || previewLoading" @click="preview(id)"><img v-if="thumbnails[id]" :src="thumbnails[id]" :alt="label + ':' + metadata[id]?.fileName"><span v-else>{{ metadata[id]?.contentType === 'application/pdf' ? '查看 PDF' : '照片待加载' }}</span></button>
<p class="density-dialog-note evidence-photo-name">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
<p class="density-dialog-note evidence-photo-name" :title="metadata[id]?.fileName">{{ metadata[id]?.fileName || '附件资料待核实' }}</p>
<div v-if="!readonly && ids.includes(id)" class="density-actions evidence-photo-actions"><el-button size="small" :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button size="small" :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></div>
</div>
</div>
<div v-else class="evidence-photo-empty">暂无照片</div>
<p v-if="profilePhotoId" class="density-dialog-note">已带入档案照片,保存后用于本趟排车</p>
<figcaption>{{ label }}<InlineHelp v-if="!readonly" title="附件上传说明"><p>{{ uploadFormats }},单文件最大10MiB;{{ saveHint || '上传后保存车辆信息才完成本车引用。' }}</p></InlineHelp></figcaption>
</figure>
<template v-else>
@@ -153,8 +172,9 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
.evidence-input { display: none; }
.dispatch-evidence--photos { min-width: 0; display: flex; flex-direction: column; }
.evidence-photo-card { margin: 0; text-align: center; flex: 1; display: flex; flex-direction: column; }
.evidence-photo-list { display: grid; gap: var(--admin-space-3); }
.evidence-photo-name { margin: var(--admin-space-3) 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.evidence-photo-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); align-items: start; gap: var(--admin-space-3); }
.evidence-photo-item { min-width: 0; }
.evidence-photo-name { margin: var(--admin-space-2) 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.evidence-photo-actions { justify-content: center; }
.dispatch-evidence--photos > .evidence-upload-button { width: 100%; }
.evidence-photo-preview, .evidence-photo-empty { box-sizing: border-box; width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); color: var(--el-text-color-secondary); }
@@ -10,11 +10,11 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operato
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await importOriginal<typeof api>(), getLoadingDocuments: vi.fn(), uploadLoadingPhoto: vi.fn(), recoverLoadingPhoto: vi.fn(), bindLoadingPhoto: vi.fn(), readLoadingPhoto: vi.fn() }))
const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null }
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
const data = (extra: Partial<api.LoadingDocuments> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', reviewRound: '1', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) }
async function upload(wrapper: ReturnType<typeof setup>) {
const input = wrapper.get('input[aria-label="装车照片 1"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], 'loading.png', { type: 'image/png' })] })
async function upload(wrapper: ReturnType<typeof setup>, files = [new File(['image'], 'loading.png', { type: 'image/png' })]) {
const input = wrapper.get('input[aria-label="上传司机驾驶证"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: files })
await input.trigger('change'); await flushPromises()
}
beforeEach(() => {
@@ -24,20 +24,146 @@ beforeEach(() => {
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '1', photos: [photo] }))
vi.mocked(api.readLoadingPhoto).mockRejectedValue(new Error('preview unavailable'))
})
it('refreshes photos added or replaced by PDA without writing and notifies the parent to reload the stage', async () => {
can.mockReturnValue(false)
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [photo] }))
const wrapper = setup(); await flushPromises()
const photos = api.loadingPhotoSlots.flatMap((slot, category) => Array.from({ length: 5 }, (_, index) => ({
...photo, slot, fileId: `${category + 2}${index}`, fileName: `pda-${category}-${index}.png`,
})))
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '26', stage: 'DELIVERING', complete: true, photos }))
await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises()
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(25)
for (const item of photos) expect(wrapper.text()).not.toContain(item.fileName)
expect(wrapper.text()).not.toContain(photo.fileName)
expect(wrapper.findAll('.loading-document-heading').map(row => row.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}5/5 张`))
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
vi.mocked(api.getLoadingDocuments).mockRejectedValueOnce(new Error('刷新失败'))
await wrapper.get('[aria-label="刷新装车资料"]').trigger('click'); await flushPromises()
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(25)
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => {
const wrapper = setup(); await flushPromises()
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
const table = wrapper.get('table[aria-label="装车照片"]')
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间'])
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['装车照片 1', '装车照片 2', '装车照片 3', '装车照片 4', '装车照片 5'])
const list = wrapper.get('[aria-label="装车照片"]')
expect(wrapper.find('table').exists()).toBe(false)
expect(list.findAll('h3').map(heading => heading.text())).toEqual(Object.values(api.loadingPhotoLabels))
expect(list.findAll('.photo-count').map(count => count.text())).toEqual(Array(5).fill('0/5 张'))
expect(list.findAll('.photo-empty')).toHaveLength(5)
await upload(wrapper)
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String))
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String), '1')
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
expect(vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2].uploadKey).toBe(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![3])
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('图片读取失败')
expect(table.get('tbody tr').text()).toContain('loading.png')
expect(list.get('.loading-photo-row').attributes('aria-label')).toBe('司机驾驶证第1张')
expect(list.text()).not.toContain('loading.png')
wrapper.unmount()
})
it('stops a batch when re-review changes the source during upload instead of rebasing old files', async () => {
const wrapper = setup(); await flushPromises()
vi.mocked(api.uploadLoadingPhoto).mockImplementationOnce(async () => {
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '8', dispatchRecordVersion: '12', reviewRound: '2' }))
return photo
})
await upload(wrapper, [new File(['a'], 'old-driver.png', { type: 'image/png' }), new File(['b'], 'old-driver-2.png', { type: 'image/png' })])
expect(api.bindLoadingPhoto).not.toHaveBeenCalled()
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1)
expect(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![4]).toBe('1')
expect(wrapper.text()).toContain('审核轮次已变化')
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it('shows an imported PDF license as an authenticated document and accepts an exact PDF replacement', async () => {
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:license')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const license = { ...photo, fileName: 'license.pdf', contentType: 'application/pdf' }
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ photos: [license] }))
vi.mocked(api.readLoadingPhoto).mockResolvedValue(new Blob(['%PDF-1.4'], { type: 'application/pdf' }))
const wrapper = setup()
try {
await flushPromises()
expect(wrapper.get('a').attributes('href')).toBe('blob:license')
expect(wrapper.findComponent({ name: 'ElImage' }).exists()).toBe(false)
const input = wrapper.get('input[aria-label="更换司机驾驶证第1张"]')
expect(input.attributes('accept')).toContain('application/pdf')
expect(wrapper.get('input[aria-label="上传车牌照片"]').attributes('accept')).not.toContain('application/pdf')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['%PDF-1.4'], 'new.pdf', { type: 'application/pdf' })] })
await input.trigger('change'); await flushPromises()
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ replaceFileId: '101' }))
} finally { wrapper.unmount(); vi.restoreAllMocks() }
})
it('shows every photo in a category, stops at five and replaces the selected image only', async () => {
const photos = Array.from({ length: 5 }, (_, index) => ({ ...photo, fileId: String(101 + index), fileName: `photo-${index}.png`, savedAt: `2026-10-26T11:49:0${index}.978960362Z` }))
vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '5', photos }))
const replacement = { ...photo, fileId: '999' }
vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(replacement)
vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '6', photos: photos.map(item => item.fileId === '103' ? replacement : item) }))
const wrapper = setup(); await flushPromises()
expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('disabled')).toBeDefined()
const rows = wrapper.findAll('.loading-photo-row')
expect(rows).toHaveLength(5)
rows.forEach((row, index) => {
expect(row.attributes('aria-label')).toBe(`司机驾驶证第${index + 1}张`)
expect(row.get('.photo-time').text()).toMatch(new RegExp(`保存时间:2026-10-26 \\d{2}:49:0${index}$`))
expect(row.findAll('input[type="file"]')).toHaveLength(1)
})
for (const item of photos) expect(wrapper.text()).not.toContain(item.fileName)
const input = rows[2]!.get('input[aria-label="更换司机驾驶证第3张"]')
Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['new'], 'new.png', { type: 'image/png' })] })
await input.trigger('change'); await flushPromises()
expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '5', fileId: '999', replaceFileId: '103' }))
expect(wrapper.findAll('.loading-photo-row')).toHaveLength(5)
expect(wrapper.findAll('.loading-photo-row')[0]!.get('.photo-time').text()).toMatch(/:49:00$/)
expect(wrapper.findAll('.loading-photo-row')[2]!.get('.photo-time').text()).toBe('保存时间:—')
expect(wrapper.findAll('.loading-photo-row')[4]!.get('.photo-time').text()).toMatch(/:49:04$/)
wrapper.unmount()
})
it('saves a multiple selection with fresh versions and notifies the parent once', async () => {
let latest = data(), id = 200
vi.mocked(api.getLoadingDocuments).mockImplementation(async () => latest)
vi.mocked(api.uploadLoadingPhoto).mockImplementation(async () => ({ ...photo, fileId: String(++id) }))
vi.mocked(api.bindLoadingPhoto).mockImplementation(async (_no, _slot, body) => {
latest = data({ recordVersion: String(Number(body.recordVersion) + 1), photos: [...latest.photos, { ...photo, fileId: body.fileId }] }); return latest
})
const wrapper = setup(); await flushPromises()
expect(wrapper.get('input[aria-label="上传司机驾驶证"]').attributes('multiple')).toBeDefined()
await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' })))
expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(3)
expect(vi.mocked(api.bindLoadingPhoto).mock.calls.map(call => call[2].recordVersion)).toEqual(['0', '1', '2'])
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).toContain('3/5 张')
await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' })))
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(3)
wrapper.unmount()
})
it('stops a batch after an unknown binding result and displays the server snapshot', async () => {
let latest = data(), id = 200, binds = 0
vi.mocked(api.getLoadingDocuments).mockImplementation(async () => latest)
vi.mocked(api.uploadLoadingPhoto).mockImplementation(async () => ({ ...photo, fileId: String(++id) }))
vi.mocked(api.bindLoadingPhoto).mockImplementation(async (_no, _slot, body) => {
latest = data({ recordVersion: String(++binds), photos: [...latest.photos, { ...photo, fileId: body.fileId }] })
if (binds === 2) throw new Error('保存响应超时')
return latest
})
const wrapper = setup(); await flushPromises()
await upload(wrapper, Array.from({ length: 3 }, () => new File(['image'], 'photo.png', { type: 'image/png' })))
expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(2); expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('2/5 张'); expect(wrapper.text()).toContain('已读取最新资料')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('requires a successful refresh after both binding and reconciliation fail', async () => {
vi.mocked(api.getLoadingDocuments).mockResolvedValueOnce(data()).mockResolvedValueOnce(data()).mockRejectedValueOnce(new Error('读取失败')).mockResolvedValue(data())
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('保存超时'))
const wrapper = setup(); await flushPromises(); await upload(wrapper)
expect(wrapper.text()).toContain('保存状态尚未核实')
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
expect(wrapper.find('input[aria-label="上传司机驾驶证"]').exists()).toBe(true)
expect(api.bindLoadingPhoto).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('unlocks after binding failure and accepts a new upload with a fresh key', async () => {
vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown'))
const wrapper = setup(); await flushPromises(); await upload(wrapper)
@@ -1,24 +1,31 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
import { useAuthStore } from '@/core/auth/auth'
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 { overseasWarehousePermissions as permission } from '../permissions'
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
import { bindLoadingPhoto, getLoadingDocuments, loadingPhotoSlots, loadingPhotoLabels, readLoadingPhoto, uploadLoadingPhoto, type LoadingDocuments, type LoadingPhotoSlot } from '../api/dispatchLoadingDocuments'
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: [] }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const snapshot = ref<LoadingDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const needsRefresh = ref(false)
const images = ref<Record<string, string>>({})
const actor = auth.user?.id || '', number = props.dispatchNo
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true && !needsRefresh.value)
let active = true
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
function accept(value: LoadingDocuments) { ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致'); snapshot.value = value }
function accept(value: LoadingDocuments) {
ensureScope(); if (value.dispatchNo !== number) throw new Error('装车资料与排车单不一致')
const current = new Set(value.photos.map(photo => photo.fileId))
for (const [id, url] of Object.entries(images.value)) if (!current.has(id)) { URL.revokeObjectURL(url); delete images.value[id] }
snapshot.value = value; needsRefresh.value = false
}
async function previews() {
const photos = snapshot.value?.photos || []
await Promise.allSettled(photos.map(async photo => {
@@ -29,58 +36,115 @@ async function previews() {
async function load() {
if (busy.value) return
busy.value = true; error.value = null
try { accept(await getLoadingDocuments(number)); await previews() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '装车资料读取失败') }
try { accept(await getLoadingDocuments(number)); await previews(); return true }
catch (cause) { if (active) { needsRefresh.value = true; error.value = getApiErrorDetails(cause, '装车资料读取失败') } }
finally { if (active) busy.value = false }
}
async function upload(slot: LoadingPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || !editable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
async function refresh() { if (await load()) emit('saved') }
function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filter(photo => photo.slot === slot) || [] }
function savedTime(value?: string | null) {
return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: string) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
if (!files.length || busy.value || !editable.value) return
const reviewRound = snapshot.value!.reviewRound
const capacity = replaceFileId ? 1 : 5 - photosFor(slot).length
if (files.length > capacity) { ElMessage.warning(`本次最多可选择${capacity}张图片`); return }
if (files.some(file => !(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG,司机驾驶证也支持PDF'); return }
busy.value = true; error.value = null
let saved = 0, reconciled = false, bindingFailed = false, roundChanged = false
try {
ensureScope()
const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope()
const latest = await getLoadingDocuments(number); ensureScope(); snapshot.value = latest
if (!latest.editable) throw new Error('当前状态不允许修改照片')
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
const result = await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
await previews()
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
for (const file of files) {
const uploadKey = crypto.randomUUID()
const photo = await uploadLoadingPhoto(number, slot, file, uploadKey, reviewRound); ensureScope()
const latest = await getLoadingDocuments(number); accept(latest)
if (latest.reviewRound !== reviewRound) { reconciled = true; roundChanged = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') }
if (!latest.editable) throw new Error('当前状态不允许修改照片')
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
try {
accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) }))
} catch (cause) {
// 保存可能已经提交;先核对当前事实,不重放请求或继续后续图片。
bindingFailed = true
needsRefresh.value = true
try {
const current = await getLoadingDocuments(number); accept(current)
reconciled = current.recordVersion !== latest.recordVersion || current.dispatchRecordVersion !== latest.dispatchRecordVersion || current.stage !== latest.stage
await previews()
} catch { /* 保留原错误与刷新入口。 */ }
throw cause
}
saved++; await previews()
}
ElMessage.success(`已保存${saved}张照片`)
} catch (cause) {
if (active) error.value = getApiErrorDetails(cause, roundChanged ? '审核轮次已变化,请核对新车辆、司机后重新选择资料' : bindingFailed
? needsRefresh.value ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续'
: '图片上传失败,已保存照片保留,请重新选择图片')
}
finally { busy.value = false }
if (active && (saved || reconciled)) emit('saved')
}
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; Object.values(images.value).forEach(url => URL.revokeObjectURL(url)) })
defineExpose({ busy })
</script>
<template>
<div v-loading="busy" class="density-dialog-sections">
<p v-if="snapshot?.stage === 'LOADING'">可边装车边上传 5 张装车照片;装车确认完成且照片齐全后自动进入派送中。</p>
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
<div v-loading="busy" class="loading-documents">
<p>可边装车边上传五类装车资料,每类至少 1 张、最多 5 张;装车确认完成且五类齐全后自动进入派送中。</p>
<p>司机驾驶证、车牌照片在理货审核通过后从本趟资料自动带入,可补充或更换;驾驶证支持 PDF。</p>
<div><el-button :loading="busy" :disabled="busy" aria-label="刷新装车资料" @click="refresh">刷新</el-button></div>
<p v-if="needsRefresh">保存状态尚未核实,请先重新读取资料后再上传。</p>
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
<tr v-for="(slot, index) in loadingPhotoSlots" :key="slot">
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
<td>装车照片 {{ index + 1 }}</td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
</tr>
</tbody></table>
<div class="loading-document-list" aria-label="装车照片">
<section v-for="slot in loadingPhotoSlots" :key="slot" class="loading-document-category" :aria-label="loadingPhotoLabels[slot]">
<header class="loading-document-heading">
<h3>{{ loadingPhotoLabels[slot] }}</h3>
<span class="photo-count">{{ photosFor(slot).length }}/5 张</span>
<label v-if="editable" class="photo-upload category-upload" :class="{ 'is-disabled': busy || photosFor(slot).length >= 5 }">
{{ photosFor(slot).length >= 5 ? '已满 5 张' : '上传资料' }}
<input type="file" multiple :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`上传${loadingPhotoLabels[slot]}`" :disabled="busy || photosFor(slot).length >= 5" @change="upload(slot, $event)" />
</label>
</header>
<ol v-if="photosFor(slot).length" class="loading-photo-list">
<li v-for="(photo, index) in photosFor(slot)" :key="photo.fileId" class="loading-photo-row" :aria-label="`${loadingPhotoLabels[slot]}第${index + 1}张`">
<span class="photo-index">{{ index + 1 }}</span>
<div class="photo-preview">
<a v-if="images[photo.fileId] && photo.contentType === 'application/pdf'" :href="images[photo.fileId]" target="_blank" rel="noopener noreferrer">查看驾驶证 PDF</a>
<el-image v-else-if="images[photo.fileId]" :src="images[photo.fileId]" :alt="`${loadingPhotoLabels[slot]}第${index + 1}张`" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
</div>
<span class="photo-time">保存时间:{{ savedTime(photo.savedAt) }}</span>
<label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">
更换
<input type="file" :accept="slot === 'LOADING_SCENE' ? 'image/jpeg,image/png,application/pdf' : 'image/jpeg,image/png'" :aria-label="`更换${loadingPhotoLabels[slot]}第${index + 1}张`" :disabled="busy" @change="upload(slot, $event, photo.fileId)" />
</label>
</li>
</ol>
<p v-else class="photo-empty">未上传</p>
</section>
</div>
</div>
</template>
<style scoped>
.loading-documents-table { table-layout: fixed; }
.loading-documents-table td { overflow-wrap: anywhere; }
.photo-actions { width: 110px; }
.photo-category { width: 120px; }
.photo-preview { width: 140px; }
.photo-time { width: 190px; }
.loading-photo-image { display: block; width: 100px; height: 64px; }
.loading-documents { display: flex; flex-direction: column; gap: 12px; }
.loading-documents > p { margin: 0; }
.loading-document-list { border: 1px solid var(--cc-border, var(--el-border-color)); }
.loading-document-category + .loading-document-category { border-top: 1px solid var(--cc-border, var(--el-border-color)); }
.loading-document-heading { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); }
.loading-document-heading h3 { margin: 0; font-size: 14px; }
.photo-count, .photo-time, .photo-index, .photo-empty { color: var(--el-text-color-secondary); }
.category-upload { margin-left: auto; }
.loading-photo-list { display: flex; flex-direction: column; margin: 0; padding: 0 12px; list-style: none; }
.loading-photo-row { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 8px 0; }
.loading-photo-row + .loading-photo-row { border-top: 1px solid var(--el-border-color-lighter); }
.photo-index { width: 20px; text-align: center; }
.photo-preview { display: flex; align-items: center; flex: 0 0 160px; min-height: 96px; }
.loading-photo-image { display: block; width: 160px; height: 96px; }
.photo-time { font-variant-numeric: tabular-nums; }
.photo-empty { margin: 0; padding: 12px; }
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
@@ -3,9 +3,10 @@ import { ApiResponseError } from '@/shared/api/http'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import * as api from '../api/dispatches'
import { command, detail, loadingPreview } from '../dispatchTestFixtures'
import type { CommandResult, DispatchAction, DispatchDetail } from '../dispatchContracts'
import type { CommandResult, DispatchAction, DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
@@ -102,6 +103,14 @@ it('refuses a route that drops an authoritative destination', async () => {
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
})
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('requires saved vehicle and driver identities for %s review submission', async taskType => {
vi.mocked(transportApi.getTransport).mockResolvedValue({ dispatchNo: 'PC/1', pickups: [] } as unknown as Awaited<ReturnType<typeof transportApi.getTransport>>)
const { wrapper, state } = await open('SUBMIT_REVIEW', true, { taskType })
await state.submit()
expect(api.submitDispatchReview).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('车辆信息尚未维护完整')
wrapper.unmount()
})
it('reports A0409 and immediately releases the form without a forced readback', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
@@ -292,3 +301,45 @@ it('saves a cleared unavailable master vehicle as an empty draft resource', asyn
expect(api.submitDispatchReview).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not silently discard %s files when the master resource is missing', async kind => {
const { wrapper, state } = await open('EDIT')
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
editor.driver.mode = 'MASTER'; editor.driver.driverId = null
await flushPromises()
const card = wrapper.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === kind)!
expect(card.get('.evidence-upload-button').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).not.toContain('请先在“排车信息”')
// A pending reference must also be guarded at save time, rather than being converted to null.
card.vm.$emit('update:modelValue', ['301'])
await state.submit(); await flushPromises()
expect(api.updateDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(card.props('modelValue')).toEqual(['301'])
expect(wrapper.text()).toContain(kind === 'VEHICLE' ? '请先选择车辆' : '请先选择司机')
wrapper.unmount()
})
it.each(['MASTER', 'EXTERNAL'] as const)('keeps all three %s photo references after saving, reopening and saving again', async mode => {
const { wrapper, state } = await open('EDIT')
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
editor.vehicle.mode = mode; editor.vehicle.vehicleId = mode === 'MASTER' ? '11' : null
editor.driver.mode = mode; editor.driver.driverId = mode === 'MASTER' ? '12' : null
const expected = { VEHICLE: ['301'], DRIVER_PHOTO: ['302'], DRIVER: ['303'] }
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) card.vm.$emit('update:modelValue', expected[card.props('kind') as keyof typeof expected])
let saved: DispatchDetail | undefined
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => {
saved = detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: JSON.parse(JSON.stringify({ vehicle: body.vehicle, driver: body.driver, maintenance: body.maintenance })) })
vi.mocked(api.getDispatch).mockImplementation(async () => JSON.parse(JSON.stringify(saved)))
return command('EDIT', body.idempotencyKey)
})
await state.submit(); await flushPromises()
expect(wrapper.emitted('saved')).toHaveLength(1)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) expect(card.props('modelValue')).toEqual(expected[card.props('kind') as keyof typeof expected])
await state.submit()
expect(api.updateDispatch).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toMatchObject({ vehicle: { evidenceFileIds: ['301'] }, driver: { evidence: { photoFileIds: ['302'], fileIds: ['303'] } } })
wrapper.unmount()
})
@@ -41,6 +41,8 @@ const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; pa
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
const missingVehicle = computed(() => vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId)
const missingDriver = computed(() => driver.value.mode === 'MASTER' && !driver.value.driverId)
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
// 新选司机的主档证照先按司机读取,已保存的冻结证照仍按排车读取。
const masterLicenseReference = computed(() => driver.value.mode === 'MASTER' ? driver.value.evidence?.masterLicenseFileId : null)
@@ -134,7 +136,10 @@ function payload(): object {
case 'MARK_EXCEPTION':
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
case 'EDIT': return { ...common, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'EDIT':
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车辆图片;当前图片尚未保存')
if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存')
return { ...common, vehicle: missingVehicle.value ? null : vehicle.value, driver: missingDriver.value ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
@@ -148,7 +153,7 @@ function payload(): object {
return { recordVersion: requireVersion(preview.value.recordVersion), idempotencyKey: common.idempotencyKey, manifestHash: preview.value.manifestHash, remark: form.remark || null } satisfies LoadingComplete
}
case 'SUBMIT_REVIEW':
if (!detail.value.vehicleInfo.vehicle?.vehicleId || !detail.value.vehicleInfo.driver?.driverId) {
if (['PICKUP', 'LOCAL_TRUCK'].includes(detail.value.taskType || '') && (!detail.value.vehicleInfo.vehicle?.vehicleId || !detail.value.vehicleInfo.driver?.driverId)) {
throw new DispatchInputError('车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核')
}
return { ...common, remark: null }
@@ -263,8 +268,8 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<template #fees><DispatchVehicleMaintenanceFees :dispatch-no="currentNo" :deliveries="detail.documents.deliveries" /></template>
<template #attachments>
<div class="workflow-photo-grid density-dialog-sections" aria-label="车辆司机照片">
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
<DispatchEvidenceFiles photo-card v-model="vehicle.evidenceFileIds" :dispatch-no="currentNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicle.mode === 'MASTER' && vehicle.vehicleId ? { kind: 'VEHICLE', id: vehicle.vehicleId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.VEHICLE = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="currentNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driver.mode === 'MASTER' && driver.driverId ? { kind: 'DRIVER', id: driver.driverId } : null" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER_PHOTO = $event" />
<DispatchEvidenceFiles photo-card v-if="driver.evidence" v-model="driver.evidence.fileIds" :dispatch-no="currentNo" kind="DRIVER" label="驾驶证照片" :reference-ids="masterLicenseReference ? [masterLicenseReference] : []" :master-license="unsavedMasterLicense" :disabled="frozen || loading || uploadingEvidence || !allowed" @busy="evidenceBusy.DRIVER = $event" />
</div>
</template>
@@ -279,7 +284,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<tr v-else><th scope="row">起运地</th><td>{{ detail.dispatchInfo.route?.split(' → ')[0] || '—' }}</td><th scope="row">目的地</th><td colspan="3">{{ detail.dispatchInfo.route?.split(' → ').slice(1).join(' → ') || '—' }}</td></tr>
<tr><th scope="row">计划发车</th><td>{{ reviewTime(detail.dispatchInfo.plannedDepartureAt) }}</td><th scope="row">预计送达</th><td>{{ reviewTime(detail.dispatchInfo.estimatedArrivalAt) }}</td><th scope="row">货量</th><td>{{ detail.counts.palletCount }} 托 / {{ detail.counts.packageCount }} 箱</td></tr>
</tbody></table>
<el-alert v-if="!detail.vehicleInfo.vehicle?.vehicleId || !detail.vehicleInfo.driver?.driverId" title="车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核。" type="warning" :closable="false" show-icon />
<el-alert v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType || '') && (!detail.vehicleInfo.vehicle?.vehicleId || !detail.vehicleInfo.driver?.driverId)" title="车辆信息尚未维护完整,请先点击“维护车辆”并保存车辆、司机后再提交审核。" type="warning" :closable="false" show-icon />
</section>
</template>
<template v-else-if="currentAction === 'DISPATCH_REVIEW' || currentAction === 'LOADING_REVIEW'">
@@ -328,7 +333,7 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
</template>
<style scoped>
.workflow-order-actions { width: 160px; white-space: nowrap; }
.workflow-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
.workflow-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; gap: var(--admin-space-4); }
.workflow-review-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); }
.workflow-review-actions > .el-button { margin-left: 0; }
</style>
@@ -0,0 +1,57 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
import InboundShortageConfirmation from './InboundShortageConfirmation.vue'
const access = vi.hoisted(() => ({ can: vi.fn(() => true) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => access }))
vi.mock('../api/inboundPlans', () => ({ confirmInboundShortage: vi.fn() }))
const dialog = { name: 'AppDialog', props: ['modelValue', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<div v-if="modelValue"><slot /><button data-confirm :disabled="confirmDisabled || confirmLoading" @click="$emit(\'confirm\')">确认并按实收入库</button></div>' }
function detail(overrides: Partial<InboundPlanDetailDto> = {}): InboundPlanDetailDto {
return { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4,
orderType: 'FIRST_LEG', status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20,
lineCount: 1, palletCount: 2, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0,
lines: [{ id: '3', orderId: '2', fbaWarehouseCode: 'ONT8', requiredPackageCount: 21, plannedPalletCount: 2 }],
pallets: [], allocations: [], ...overrides }
}
function render(value = detail()) {
return mount(InboundShortageConfirmation, { props: { detail: value }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialog } } })
}
describe('入库短少确认', () => {
beforeEach(() => { access.can.mockReturnValue(true); vi.mocked(confirmInboundShortage).mockReset().mockResolvedValue({ status: 'PARTIAL_INBOUND' }) })
it('requires a reason and submits actual counts without overwriting the expected count', async () => {
const wrapper = render()
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('21')
expect(wrapper.text()).toContain('20')
expect(wrapper.get('[data-confirm]').attributes('disabled')).toBeDefined()
await wrapper.get('textarea').setValue('现场清点短少1箱')
await wrapper.get('[data-confirm]').trigger('click')
await flushPromises()
expect(confirmInboundShortage).toHaveBeenCalledWith('IP-1', { customerId: '9007199254741001', version: 4, expectedPackageCount: 21, receivedPackageCount: 20, reason: '现场清点短少1箱' }, expect.any(String))
expect(wrapper.emitted('refresh')).toHaveLength(1)
expect(wrapper.find('textarea').exists()).toBe(false)
wrapper.unmount()
})
it('preserves the reason on failure and uses fresh facts and a new key for retry', async () => {
vi.mocked(confirmInboundShortage).mockRejectedValueOnce(new Error('记录已变化'))
const wrapper = render()
await wrapper.find('button').trigger('click'); await wrapper.get('textarea').setValue('短少1箱')
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('短少1箱')
await wrapper.setProps({ detail: detail({ version: 5 }) })
await wrapper.get('[data-confirm]').trigger('click'); await flushPromises()
const calls = vi.mocked(confirmInboundShortage).mock.calls
expect(calls[1]?.[1].version).toBe(5)
expect(calls[1]?.[2]).not.toBe(calls[0]?.[2])
wrapper.unmount()
})
it('hides the action without edit permission', () => {
access.can.mockReturnValue(false)
const wrapper = render(); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
})
it.each([{ status: 'PARTIAL_INBOUND' }, { pickedPackageCount: 21 }, { pickedPackageCount: 22 }, { pickedPackageCount: 0 }, { version: undefined }, { orderType: 'ONE_CLICK_DROPSHIP' }])('rejects ineligible facts %o', (overrides) => {
const wrapper = render(detail(overrides)); expect(wrapper.find('button').exists()).toBe(false); wrapper.unmount()
})
})
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { confirmInboundShortage, type InboundPlanDetailDto } from '../api/inboundPlans'
import { overseasWarehousePermissions } from '../permissions'
const props = defineProps<{ detail: InboundPlanDetailDto }>()
const emit = defineEmits<{ refresh: [] }>()
const access = useAccessPreviewStore()
const open = ref(false)
const saving = ref(false)
const reason = ref('')
const error = ref<ApiErrorDetails | null>(null)
const expected = computed(() => props.detail.expectedPackageCount)
const received = computed(() => props.detail.pickedPackageCount)
const shortage = computed(() => (expected.value ?? 0) - (received.value ?? 0))
const eligible = computed(() => access.can(overseasWarehousePermissions.inboundOrderEdit)
&& props.detail.status === 'PENDING_RECONCILIATION' && props.detail.version != null
&& props.detail.orderType !== 'ONE_CLICK_DROPSHIP' && props.detail.lines.length > 0
&& props.detail.lines.every(line => !!line.fbaWarehouseCode)
&& expected.value != null && received.value != null && received.value > 0 && shortage.value > 0)
function show() {
if (!eligible.value || saving.value) return
reason.value = ''; error.value = null; open.value = true
}
async function confirm() {
if (!open.value || saving.value || !eligible.value || !reason.value.trim()
|| props.detail.version == null || expected.value == null || received.value == null) return
saving.value = true; error.value = null
try {
await confirmInboundShortage(props.detail.planNo, {
customerId: props.detail.customerId, version: props.detail.version,
expectedPackageCount: expected.value, receivedPackageCount: received.value, reason: reason.value.trim(),
}, crypto.randomUUID())
ElMessage.success(`已确认短少,${received.value} 箱实收已入库`)
open.value = false
emit('refresh')
} catch (cause) {
error.value = getApiErrorDetails(cause, '短少确认失败,请核对最新收货记录后重试')
emit('refresh')
} finally { saving.value = false }
}
</script>
<template>
<el-button v-if="eligible" class="density-action" size="small" :disabled="saving" @click="show">确认短少入库</el-button>
<AppDialog v-model="open" class="admin-density-dialog" title="确认短少入库" width="560px"
confirm-text="确认并按实收入库" :confirm-loading="saving" :confirm-disabled="!eligible || !reason.trim()" @confirm="confirm">
<div class="density-dialog-sections">
<table class="density-table"><tbody>
<tr><th scope="row">入库单号</th><td>{{ detail.planNo }}</td></tr>
<tr><th scope="row">应收箱数</th><td>{{ expected }}</td></tr>
<tr><th scope="row">实收入库箱数</th><td>{{ received }}</td></tr>
<tr><th scope="row">确认短少箱数</th><td>{{ shortage }}</td></tr>
</tbody></table>
<el-form class="density-form" label-position="left" label-width="100px" :disabled="saving">
<el-form-item label="确认原因" required><el-input v-model="reason" aria-label="短少确认原因" type="textarea" :rows="2" maxlength="200" /></el-form-item>
</el-form>
<LoadErrorState v-if="error" compact title="短少确认未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</div>
</AppDialog>
</template>
@@ -0,0 +1,122 @@
import { mount, flushPromises } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import TransportExecutionPanel from './TransportExecutionPanel.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import { assignTransport, getTransport, type TransportDetail } from '../api/transportTasks'
import { getDispatchEvidence, getDispatchEvidenceContent } from '../api/dispatches'
import { getDriver, getResourcePhoto, downloadResourcePhoto, downloadDriverLicense } from '@/modules/overseas-master-data'
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), getDriver: vi.fn(), getResourcePhoto: vi.fn(), downloadResourcePhoto: vi.fn(), downloadDriverLicense: vi.fn(), pickupStatusLabels: { ASSIGNED: '待取货' } }))
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn(), assignTransport: vi.fn(), transportOccupancy: vi.fn(), cancelTransport: vi.fn(), releaseTransport: vi.fn(), commandTransport: vi.fn(), uploadTransport: vi.fn(), downloadTransportEvidence: vi.fn(), transportLabels: { PICKUP: '到仓取货' } }))
vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
const facts = (): TransportDetail => ({ dispatchNo: 'PC1', taskType: 'PICKUP', dispatchRecordVersion: 9, execution: { trip: { status: 'PLANNED', recordVersion: 4, vehicleId: '1', vehiclePlate: 'CA-1', driverId: '2', driverName: '司机甲' } }, pickups: [{ customerId: 'C1', transferNo: 'WT1', packageCount: 5, status: 'ASSIGNED', evidence: [] }], completionEvidence: [] }) as unknown as TransportDetail
const photo = (fileId: string) => ({ fileId, fileName: `${fileId}.jpg`, contentType: 'image/jpeg', sizeBytes: 5 })
const blob = () => new Blob(['photo'], { type: 'image/jpeg' })
const mountPanel = () => mount(TransportExecutionPanel, { props: { dispatchNo: 'PC1', editResources: true }, global: { plugins: [ElementPlus] } })
const saveButton = (wrapper: ReturnType<typeof mountPanel>) => wrapper.findAll('button').find(button => button.text() === '保存车辆司机')!
beforeEach(() => {
vi.resetAllMocks()
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:resource-photo')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
vi.mocked(getTransport).mockImplementation(async () => facts())
vi.mocked(getDriver).mockResolvedValue({ id: '2', licenseFileId: '33' } as Awaited<ReturnType<typeof getDriver>>)
vi.mocked(getResourcePhoto).mockImplementation(async kind => ({ recordVersion: '1', photo: photo(kind === 'VEHICLE' ? '91' : '92') }))
vi.mocked(downloadResourcePhoto).mockImplementation(async () => blob())
vi.mocked(downloadDriverLicense).mockImplementation(async () => blob())
vi.mocked(getDispatchEvidence).mockImplementation(async (_no, _kind, id) => photo(id))
vi.mocked(getDispatchEvidenceContent).mockImplementation(async () => blob())
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('keeps automatically loaded photos available after refreshing the open form', async () => {
const wrapper = mountPanel()
try {
await flushPromises()
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
expect(getResourcePhoto).toHaveBeenCalledTimes(2)
await wrapper.get('[aria-label="刷新运输任务"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('.evidence-photo-preview')).toHaveLength(3)
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
vi.mocked(assignTransport).mockResolvedValue(facts())
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({ reviewPhotos: { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: [] } }))
} finally { wrapper.unmount() }
})
it('loads local truck vehicle and driver archives while retaining the trip license upload requirement', async () => {
const truck = facts()
truck.taskType = 'LOCAL_TRUCK'
truck.pickups = []
truck.execution.trip.stops = []
vi.mocked(getTransport).mockResolvedValue(truck)
vi.mocked(assignTransport).mockResolvedValue(truck)
const wrapper = mountPanel()
try {
await flushPromises()
expect(getResourcePhoto).toHaveBeenCalledWith('VEHICLE', '1')
expect(getResourcePhoto).toHaveBeenCalledWith('DRIVER', '2')
expect(saveButton(wrapper).attributes('disabled')).toBeDefined()
const cards = wrapper.findAllComponents(DispatchEvidenceFiles)
expect(cards[2]!.props('masterLicense')).toBeNull()
cards[2]!.vm.$emit('update:modelValue', ['103'])
await flushPromises()
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(assignTransport).toHaveBeenCalledWith('PC1', expect.objectContaining({
dispatchRecordVersion: 9, masterLicenseFileId: null,
reviewPhotos: { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: ['103'] },
}))
} finally { wrapper.unmount() }
})
it('resubmits saved snapshots when reopening maintenance even if archive photos change', async () => {
const saved = facts()
saved.execution.trip.recordVersion = 5
saved.dispatchRecordVersion = 10
saved.resources = { vehicle: { vehicleId: '1', evidenceFileIds: ['101'] }, driver: { driverId: '2', evidence: { photoFileIds: ['102'], fileIds: ['33'], masterLicenseFileId: '33' } } } as TransportDetail['resources']
vi.mocked(assignTransport).mockResolvedValue(saved)
const wrapper = mountPanel()
try {
await flushPromises()
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(assignTransport).toHaveBeenCalledTimes(1)
vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '2', photo: photo('999') })
await wrapper.findAll('button').find(button => button.text() === '维护车辆信息')!.trigger('click')
await flushPromises()
const reopened = wrapper.findAllComponents(DispatchEvidenceFiles)
expect(reopened[0]!.props('modelValue')).toEqual(['101'])
expect(reopened[1]!.props('modelValue')).toEqual(['102'])
expect(getResourcePhoto).toHaveBeenCalledTimes(2)
expect(getDispatchEvidence).toHaveBeenCalledWith('PC1', 'VEHICLE', '101')
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(assignTransport).toHaveBeenLastCalledWith('PC1', expect.objectContaining({ recordVersion: 5, dispatchRecordVersion: 10, reviewPhotos: { vehicleFileIds: ['101'], driverPhotoFileIds: ['102'], licenseFileIds: [] } }))
} finally { wrapper.unmount() }
})
it.each([0, 1])('requires a new photo when resource selector %s changes to an archive without a photo', async index => {
const wrapper = mountPanel()
try {
await flushPromises()
expect(saveButton(wrapper).attributes('disabled')).toBeUndefined()
vi.mocked(getResourcePhoto).mockResolvedValue({ recordVersion: '0', photo: null })
wrapper.findAllComponents({ name: 'ElSelect' })[index]!.vm.$emit('update:modelValue', '4')
await flushPromises()
expect(saveButton(wrapper).attributes('disabled')).toBeDefined()
expect(wrapper.findAllComponents(DispatchEvidenceFiles)[index]!.props('modelValue')).toEqual([])
expect(getResourcePhoto).toHaveBeenLastCalledWith(index === 0 ? 'VEHICLE' : 'DRIVER', '4')
await saveButton(wrapper).trigger('click')
expect(assignTransport).not.toHaveBeenCalled()
} finally { wrapper.unmount() }
})
@@ -23,9 +23,10 @@ const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([]), ve
const occupancy = ref<Record<string, boolean>>({}), candidateBusy = ref(false)
const vehiclePhotos = ref<string[]>([]), driverPhotos = ref<string[]>([]), licensePhotos = ref<string[]>([])
const masterLicense = ref<{ driverId: string; fileId: string } | null>(null), licenseLoading = ref(false)
const masterPhotoAvailable = reactive({ vehicle: false, driver: false })
const photoBusy = reactive({ vehicle: false, driver: false, license: false })
const activeMasterLicense = computed(() => detail.value?.taskType === 'PICKUP' && !licensePhotos.value.length ? masterLicense.value : null)
const photosComplete = computed(() => !['PICKUP', 'LOCAL_TRUCK'].includes(detail.value?.taskType || '') || !!(vehiclePhotos.value.length && driverPhotos.value.length && (licensePhotos.value.length || activeMasterLicense.value)))
const photosComplete = computed(() => !['PICKUP', 'LOCAL_TRUCK'].includes(detail.value?.taskType || '') || !!((vehiclePhotos.value.length || masterPhotoAvailable.vehicle) && (driverPhotos.value.length || masterPhotoAvailable.driver) && (licensePhotos.value.length || activeMasterLicense.value)))
const selected = ref<PickupTask | null>(null), pickupOpen = ref(false), actual = ref<number>(), evidence = ref<PickupEvidence[]>([])
const truckPickupEvidence = ref<PickupEvidence[]>([]), truckDeliveryEvidence = ref<PickupEvidence[]>([])
const truckPreviewUrls = ref<Record<string, string>>({})
@@ -74,7 +75,10 @@ function restoreVehiclePhotos() {
const saved = detail.value?.resources?.vehicle
vehiclePhotos.value = saved?.vehicleId === vehicleId.value ? [...saved.evidenceFileIds] : []
}
watch(vehicleId, restoreVehiclePhotos, { flush: 'sync' })
// 同一资源刷新沿用照片组件的可用状态;切换资源时立即清空,等待新照片确认。
watch(vehicleId, () => { masterPhotoAvailable.vehicle = false }, { flush: 'sync' })
watch(driverId, () => { masterPhotoAvailable.driver = false }, { flush: 'sync' })
watch(() => [vehicleId.value, resourceFormOpen.value, detail.value] as const, restoreVehiclePhotos, { flush: 'sync' })
/** 切换司机后重读其主档驾驶证,迟到响应不得覆盖新司机或新单据。 */
watch(() => [driverId.value, resourceFormOpen.value, detail.value] as const, async ([id, open], _, onCleanup) => {
let current = true
@@ -214,8 +218,8 @@ onBeforeUnmount(clearTruckPreviewUrls)
<el-form-item label="车辆"><el-select v-model="vehicleId" filterable placeholder="选择车辆" @visible-change="(value: boolean) => value && !vehicles.length && candidates()"><el-option v-if="vehicleId && !vehicles.some(row => row.id === vehicleId)" :value="vehicleId" :label="trip.vehiclePlate || '当前车辆'" /><el-option v-for="row in vehicles" :key="row.id" :value="row.id" :disabled="occupancy[`V:${row.id}`] !== true" :label="`${row.plateNumber} · ${row.maxPalletSpaces}托位`" /></el-select></el-form-item>
<el-form-item label="司机"><el-select v-model="driverId" filterable placeholder="选择司机" @visible-change="(value: boolean) => value && !drivers.length && candidates()"><el-option v-if="driverId && !drivers.some(row => row.id === driverId)" :value="driverId" :label="`${trip.driverName || '当前司机'} · ${trip.driverPhone || ''}`" /><el-option v-for="row in drivers" :key="row.id" :value="row.id" :disabled="occupancy[`D:${row.id}`] !== true || !row.qualification.canDispatch" :label="`${row.details.name} · ${row.details.phone}`" /></el-select></el-form-item>
<section v-if="['PICKUP', 'LOCAL_TRUCK'].includes(detail.taskType)" class="transport-review-photos" aria-label="调度审核照片">
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :disabled="locked" @busy="photoBusy.vehicle = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :disabled="locked" @busy="photoBusy.driver = $event" />
<DispatchEvidenceFiles photo-card v-model="vehiclePhotos" :dispatch-no="dispatchNo" kind="VEHICLE" label="车辆图片" :master-resource="vehicleId ? { kind: 'VEHICLE', id: vehicleId } : null" @available="masterPhotoAvailable.vehicle = $event" :disabled="locked" @busy="photoBusy.vehicle = $event" />
<DispatchEvidenceFiles photo-card v-model="driverPhotos" :dispatch-no="dispatchNo" kind="DRIVER_PHOTO" label="司机照片" :master-resource="driverId ? { kind: 'DRIVER', id: driverId } : null" @available="masterPhotoAvailable.driver = $event" :disabled="locked" @busy="photoBusy.driver = $event" />
<DispatchEvidenceFiles photo-card v-model="licensePhotos" :dispatch-no="dispatchNo" kind="DRIVER" label="驾驶证照片" :master-license="activeMasterLicense" :reference-ids="activeMasterLicense ? [activeMasterLicense.fileId] : []" :disabled="locked" @busy="photoBusy.license = $event" />
<p class="transport-photo-hint workbench-meta">请补齐车辆图片、司机照片和驾驶证照片;保存后供调度员和理货员审核。</p>
</section>
@@ -129,6 +129,7 @@ describe('last-mile order presentation adapter', () => {
['SIGNED', '已签收'],
['PICKING', '拣货中'],
['PARTIAL_INBOUND', '部分入库'],
['PENDING_RECONCILIATION', '待差异核对'],
['COMPLETED', '全部入库'],
])('maps inbound plan status %s to %s', (status, expected) => {
expect(inboundPlanStatusLabel(status)).toBe(expected)
@@ -46,6 +46,7 @@ const inboundPlanStatusLabels: Record<string, string> = {
PDA_PENDING: '待入库',
SIGNED: '已签收',
PICKING: '拣货中',
PENDING_RECONCILIATION: '待差异核对',
PARTIAL_INBOUND: '部分入库',
COMPLETED: '全部入库',
}
@@ -0,0 +1,25 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { getInboundPlan } from '../api/inboundPlans'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'IP-1' }, query: { customerId: '9007199254741001' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { name: 'InboundShortageConfirmation', props: ['detail'], emits: ['refresh'], template: '<button data-shortage @click="$emit(\'refresh\')">确认短少入库</button>' } }))
it('provides the scoped plan facts and reloads after shortage confirmation', async () => {
const detail = { id: '1', planNo: 'IP-1', customerId: '9007199254741001', sourceOrderId: '2', version: 4,
status: 'PENDING_RECONCILIATION', expectedPackageCount: 21, pickedPackageCount: 20,
lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] }
vi.mocked(getInboundPlan).mockResolvedValue(detail)
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true, BusinessDocumentFees: true } } })
await flushPromises()
expect(wrapper.findComponent({ name: 'InboundShortageConfirmation' }).props('detail')).toEqual(detail)
expect(wrapper.text()).toContain('待差异核对')
await wrapper.get('[data-shortage]').trigger('click'); await flushPromises()
expect(getInboundPlan).toHaveBeenCalledTimes(2)
expect(getInboundPlan).toHaveBeenLastCalledWith('IP-1', '9007199254741001')
wrapper.unmount()
})
@@ -8,6 +8,7 @@ const route = vi.hoisted(() => ({ params: { orderId: 'PLAN-1' }, query: { custom
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/inboundPlans', () => ({ getInboundPlan: vi.fn(), moveInboundPallet: vi.fn() }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('../components/InboundShortageConfirmation.vue', () => ({ default: { template: '<span />' } }))
vi.mock('@/modules/overseas-business', () => ({ BusinessDocumentFees: {
name: 'BusinessDocumentFees', props: ['businessType', 'businessNo', 'customerId', 'businessId'], template: '<section>本单费用</section>',
} }))
@@ -22,7 +23,7 @@ describe('InboundOrderDetailView fees', () => {
})
it('uses the loaded inbound plan identity without substituting its source order', async () => {
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } })
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true, InboundShortageConfirmation: true } } })
await flushPromises()
expect(getInboundPlan).toHaveBeenCalledWith('PLAN-1', '9007199254741001')
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).props()).toEqual({
@@ -34,7 +35,7 @@ describe('InboundOrderDetailView fees', () => {
it('does not expose fee operations when the customer scope is absent', async () => {
route.query = {}
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true } } })
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: true, AppDialog: true, InboundShortageConfirmation: true } } })
await flushPromises()
expect(getInboundPlan).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BusinessDocumentFees' }).exists()).toBe(false)
@@ -15,6 +15,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import InboundShortageConfirmation from '../components/InboundShortageConfirmation.vue'
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
const route = useRoute(); const router = useRouter()
@@ -98,7 +99,7 @@ onMounted(load)
<template><OverseasDetailShell workbench class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><InboundShortageConfirmation v-if="detail" :detail="detail" @refresh="load" /><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
<section v-if="detail" class="detail-section fee-section" aria-label="入库单费用"><BusinessDocumentFees business-type="OVERSEAS_INBOUND" :business-no="detail.planNo" :customer-id="detail.customerId" :business-id="detail.id" /></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections">
@@ -42,7 +42,7 @@ const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' },
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' },
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] },
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }, { label: '待差异核对', value: 'PENDING_RECONCILIATION' }] },
{ key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' },
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' },
{ key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }, { label: '多批预约', value: 'MULTIPLE' }] },
@@ -157,10 +157,10 @@ async function mountView() {
return wrapper
}
async function openPending() {
async function openAfterDeparture() {
const wrapper = await mountView()
const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船前')
await pendingTab!.trigger('click')
const departedTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '开船后')
await departedTab!.trigger('click')
await flushPromises()
return wrapper
}
@@ -308,9 +308,13 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ orderType: 'FIRST_LEG', sailingStage: stage, detained: false }))
expect(toolbar.props('availableFields')?.some((field) => field.key === 'orderType')).toBe(false)
await button(wrapper, '导出订单')!.trigger('click')
await flushPromises()
expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0])
if (stage === 'BEFORE_DEPARTURE') {
expect(button(wrapper, '导出订单')).toBeUndefined()
} else {
await button(wrapper, '导出订单')!.trigger('click')
await flushPromises()
expect(submitExport).toHaveBeenLastCalledWith('LAST_MILE_ORDER_LIST', '尾程订单', vi.mocked(listLastMileOrders).mock.lastCall![0])
}
}
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '全部')!.trigger('click')
await flushPromises()
@@ -359,6 +363,18 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(button(wrapper, '高级筛选')).toBeUndefined()
})
it.each([null, 'ESTIMATED_DEPARTURE'])('keeps a first-leg order without actual departure read-only: %s', async containerNode => {
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'before-1', customerId, orderNo: 'LM-BEFORE', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG', containerNode }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(['查看'])
await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]])
expect((wrapper.vm as unknown as { selectedRows: object[] }).selectedRows).toEqual([expect.objectContaining({ orderTypeCode: 'FIRST_LEG', containerNode: containerNode || '—' })])
expect(wrapper.find('.data-table__business-actions').exists()).toBe(false)
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(transferLastMileOrders).not.toHaveBeenCalled()
})
it('keeps the original carton count and adds only the signed difference in the list', async () => {
canAccess.mockReturnValue(false)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
@@ -381,7 +397,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库', '修改节点'], false],
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE' }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
@@ -507,7 +523,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
])('only shows a new hold after inbound completion for %s', async (currentStatus, visible) => {
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false,
id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: false,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
@@ -520,7 +536,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps release hold available for an already detained order', async () => {
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', detained: true,
id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: true,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
@@ -533,7 +549,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('removes generation and hold actions after an inbound plan has been submitted', async () => {
canAccess.mockReturnValue(true)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', detained: false,
id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', containerNode: 'ACTUAL_DEPARTURE', detained: false,
}], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
@@ -546,8 +562,8 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true)
})
it('keeps the original pending order table and does not render a view switch or container table', async () => {
const wrapper = await openPending()
it('keeps after-departure order actions in the existing table without a container view switch', async () => {
const wrapper = await openAfterDeparture()
expect(wrapper.text()).toContain('尾程单号')
expect(wrapper.text()).toContain('海外仓')
expect(wrapper.text()).toContain('转仓')
@@ -556,7 +572,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).not.toContain('按订单')
expect(wrapper.text()).not.toContain('待分配整柜')
expect(wrapper.text()).not.toContain('柜子工作台')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'BEFORE_DEPARTURE' }))
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, currentStatus: undefined, orderType: 'FIRST_LEG', sailingStage: 'AFTER_DEPARTURE' }))
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
@@ -613,7 +629,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
@@ -633,7 +649,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
it('shows cabinet-frozen facts but never sends arrival time or per-order warehouse overrides', async () => {
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
@@ -655,7 +671,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
it('blocks a held order from generating its inbound plan', async () => {
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow({
detained: true,
orderTypeCode: 'ONE_CLICK_DROPSHIP',
@@ -676,7 +692,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
[{ ...arrivalTime, estimatedWarehouseArrivalAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02' }, '2026-11-02'],
])('reads the latest cabinet arrival on opening and permits planning with %s', async (arrival, expectedText) => {
vi.mocked(listContainerArrivalTimes).mockResolvedValue([arrival])
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openArrivalPlan(wrapper, arrival.estimatedWarehouseArrivalAt ? selectedRow() : selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' }))
expect(listContainerArrivalTimes).toHaveBeenCalledWith([bookingId])
@@ -697,7 +713,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
{ arrivals: [{ ...arrivalTime, version: -1 }] },
])('blocks manual planning when the authoritative cabinet arrival is missing or inconsistent: $arrivals', async ({ arrivals }) => {
vi.mocked(listContainerArrivalTimes).mockResolvedValue(arrivals)
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openArrivalPlan(wrapper)
const confirm = [...document.body.querySelectorAll('button')]
@@ -708,7 +724,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('shows arrival read failures and does not reuse the list timestamp', async () => {
vi.mocked(listContainerArrivalTimes).mockRejectedValue(new ApiResponseError('到仓来源暂不可读', 'A0503', 503))
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openArrivalPlan(wrapper)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.text()).toContain('到仓来源暂不可读')
@@ -721,7 +737,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps confirmation blocked during arrival loading and ignores an older opening of the same order', async () => {
let resolveOld!: (value: typeof arrivalTime[]) => void
vi.mocked(listContainerArrivalTimes).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openArrivalPlan(wrapper)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.props('confirmDisabled')).toBe(true)
@@ -750,7 +766,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
}],
}],
} as never)
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'ONE_CLICK_DROPSHIP', pallets: 2 })])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
@@ -768,7 +784,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
it('opens a cabinet selector without requiring order selection and loads server summaries only then', async () => {
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper)
expect(listContainerPlanProgress).toHaveBeenCalledTimes(1)
expect(listContainerPlanProgress).toHaveBeenCalledWith({ page: 1, size: 50, containerNo: undefined })
@@ -781,7 +797,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
it('selects one cabinet and sends one long-id convert-container request without using current-page row counts', async () => {
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
@@ -807,7 +823,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError(
'柜级生成失败', code, 200, undefined, `request-${code}`, `trace-${code}`,
))
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const confirm = [...document.body.querySelectorAll('button')].find((item) => item.textContent?.includes('确认生成')) as HTMLButtonElement
@@ -826,7 +842,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
if (id === bookingId) resolveFirst = resolve
else resolveSecond = resolve
}) as never)
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper)
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
const containerSelect = selects[selects.length - 1]
@@ -848,7 +864,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
vi.mocked(convertLastMileContainerToInboundPlans)
.mockRejectedValueOnce(new Error('network uncertain'))
.mockResolvedValueOnce([{ planNo: 'LIP-RETRY', pallets: [{ id: 'PALLET-R', palletNo: 'PLT-RETRY' }] }] as never)
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper)
await chooseContainer(wrapper)
const findConfirm = () => [...document.body.querySelectorAll('button')]
@@ -931,7 +947,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
.mockResolvedValueOnce(resourceCommittedPreview)
.mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined })
vi.mocked(convertLastMileContainerToInboundPlans).mockResolvedValue(originalResourcePlans as never)
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper); await chooseContainer(wrapper)
cabinetDialog(wrapper).vm.$emit('update:modelValue', false); await flushPromises()
await openContainerDialog(wrapper); await chooseContainer(wrapper)
@@ -955,7 +971,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps ordinary zero-pending previews without resourceRetry disabled in both UI and handler', async () => {
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, resourceRetry: undefined })
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
expect(cabinetDialog(wrapper).props('confirmText')).toBe('确认生成')
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
@@ -964,7 +980,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('uses the visible missing-container label in the preview tooltip without exposing the booking ID', async () => {
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...preview, containerNo: null } as never)
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
const identity = document.body.querySelector('.container-preview__identity')!
expect(identity.textContent).toBe('未录入货柜号')
expect(identity.getAttribute('title')).toBe('未录入货柜号')
@@ -973,7 +989,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps server readiness blocks effective even for a resource retry', async () => {
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({ ...resourceCommittedPreview, ready: false, blockedOrderCount: 1 })
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
@@ -982,7 +998,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('guards resource retry at the handler when the caller lacks create permission', async () => {
canAccess.mockImplementation(code => code !== 'platform:overseas-warehouse:inbound-order:create')
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview)
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
const view = wrapper.vm as unknown as { openContainerConversion(): void }
view.openContainerConversion(); await flushPromises(); await chooseContainer(wrapper)
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
@@ -995,7 +1011,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
let resolveOptions!: (value: Awaited<ReturnType<typeof listContainerPlanProgress>>) => void
vi.mocked(getContainerInboundPlanPreview).mockReturnValue(new Promise(resolve => { resolvePreview = resolve }))
vi.mocked(listContainerPlanProgress).mockReturnValue(new Promise(resolve => { resolveOptions = resolve }))
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(true)
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
expect(convertLastMileContainerToInboundPlans).not.toHaveBeenCalled()
@@ -1011,7 +1027,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
const success = vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() } as never))
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(resourceCommittedPreview)
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new ApiResponseError('资源确认失败', 'S0503', 503))
const wrapper = await openPending(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
const wrapper = await openAfterDeparture(); await openContainerDialog(wrapper); await chooseContainer(wrapper)
cabinetDialog(wrapper).vm.$emit('confirm'); await flushPromises()
expect(cabinetDialog(wrapper).props('modelValue')).toBe(true)
expect(cabinetDialog(wrapper).props('confirmDisabled')).toBe(false)
@@ -1233,7 +1249,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow(), selectedRow({ id: '2092873463568596995', customerId: '2092618717861048322' })])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
@@ -1257,7 +1273,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
})
it('clears all selected warehouse facts when the FBA candidate is cleared', async () => {
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
@@ -1273,7 +1289,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('renders an explicit empty state when there are no active FBA candidates', async () => {
vi.mocked(listAllPlatformWarehouses).mockResolvedValueOnce([])
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
@@ -1285,7 +1301,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new ApiResponseError(
'FBA candidate failed', 'A0503', 200, undefined, 'request-fba', 'trace-fba',
))
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click')
await flushPromises()
@@ -1355,21 +1371,22 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
expect(updateLastMileOrderHold).toHaveBeenCalledWith(row.id, { held: !detained, chargeable: false, reason: '原因', expectedVersion: 0 }, expect.stringContaining('last-mile-hold-'))
})
it('renders all eight node times and submits the complete set with zero version', async () => {
it('edits only customs nodes while preserving hidden node facts and zero version', async () => {
vi.mocked(getLastMileOrderManualNodes).mockResolvedValue({ orderId: '2092873463568596994', manualOverrideEnabled: true, nodes: [{ nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' }], version: 0 })
const wrapper = await mountView()
const row = selectedRow()
await (wrapper.vm as unknown as { openManualNodes(row: object): Promise<void> }).openManualNodes(row)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('修改节点 ·'))!
expect(dialog.findAll('.manual-nodes-editor tbody tr')).toHaveLength(8)
expect(dialog.findAll('.manual-nodes-editor tbody tr').map(row => row.find('th').text())).toEqual(['国内报关完成', '国外清关完成'])
const dates = dialog.findAllComponents({ name: 'ElDatePicker' })
expect(dates).toHaveLength(8)
expect(dates).toHaveLength(2)
dates[0]!.vm.$emit('update:modelValue', '2026-10-20T08:00:00')
await dialog.get('button[aria-label="展开全屏"]').trigger('click')
await dialog.findAll('button').find(item => item.text() === '保存')!.trigger('click'); await flushPromises()
expect(updateLastMileOrderManualNodes).toHaveBeenCalledWith(row.id, 0, [
{ nodeCode: 'DOMESTIC_CUSTOMS_COMPLETED', nodeAt: '2026-10-20T08:00:00' },
{ nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: null },
{ nodeCode: 'ESTIMATED_DEPARTURE', nodeAt: null }, { nodeCode: 'ACTUAL_DEPARTURE', nodeAt: '2026-10-19T08:00:00' },
{ nodeCode: 'ESTIMATED_ARRIVAL', nodeAt: null }, { nodeCode: 'OVERSEAS_CUSTOMS_COMPLETED', nodeAt: null },
{ nodeCode: 'ACTUAL_ARRIVAL_WAITING_UNLOAD', nodeAt: null }, { nodeCode: 'PICKUP_APPOINTED', nodeAt: null }, { nodeCode: 'UNLOADED', nodeAt: null },
], expect.stringContaining('last-mile-manual-nodes-'))
@@ -1381,7 +1398,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
groups: Array.from({ length: 13 }, (_, index) => ({ ...group, destinationWarehouseCode: 'FBA-' + index, orderCount: 1, pendingOrderCount: 1, pendingPalletCount: 1, sourcePalletCount: 1, supplementPalletCount: 0,
orders: [{ ...group.orders[0]!, orderId: 'order-' + index, businessOrderNo: '业务单-' + index, sourcePalletCount: 1, targetPalletCount: 1, supplementPalletCount: 0 }] })),
})
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await openContainerDialog(wrapper); await chooseContainer(wrapper)
const dialog = cabinetDialog(wrapper)
expect(dialog.findAll('[aria-label="目的地分组明细"]')).toHaveLength(13)
@@ -1394,7 +1411,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps transfer drafts locked during submission and editable after rejection', async () => {
let reject!: (reason: unknown) => void
vi.mocked(transferLastMileOrders).mockReturnValueOnce(new Promise((_, no) => { reject = no }))
const wrapper = await openPending()
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '转仓')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('转仓 ·'))!
@@ -143,6 +143,7 @@ const manualNodeDefinitions: Array<{ code: ManualNodeCode; label: string }> = [
{ code: 'PICKUP_APPOINTED', label: '预约提柜' },
{ code: 'UNLOADED', label: '卸柜' },
]
const visibleManualNodeDefinitions = manualNodeDefinitions.filter(({ code }) => ['DOMESTIC_CUSTOMS_COMPLETED', 'OVERSEAS_CUSTOMS_COMPLETED'].includes(code))
const manualNodeValues = reactive<Record<ManualNodeCode, string | null>>(Object.fromEntries(manualNodeDefinitions.map(({ code }) => [code, null])) as Record<ManualNodeCode, string | null>)
const transferOpen = ref(false)
const transferWarehouseLoading = ref(false)
@@ -1330,7 +1331,6 @@ onMounted(() => { void load() })
</AppDialog>
<AppDialog class="inbound-plan-dialog admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(1160px, calc(100vw - 48px))" top="6vh" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading || inboundPlanArrivalLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<div class="inbound-plan-content">
<el-alert title="保存后生成入库单;首个入库计划生成后,整柜预计到仓时间不可更改。" type="info" :closable="false" show-icon />
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon />
<el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
@@ -1342,7 +1342,7 @@ onMounted(() => { void load() })
</section>
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3>
<table class="density-table density-table--editable last-mile-command-table"><tbody>
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="托数" required><div class="inbound-plan-pallet-field"><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></div></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="是否外配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
@@ -1360,8 +1360,7 @@ onMounted(() => { void load() })
</tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="manualNodesOpen" :title="`修改节点 · ${actionTarget?.orderNo || '—'}`" width="min(700px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="人工节点登记"><h3 class="density-dialog-section__title">人工节点</h3><el-form class="density-form" :disabled="actionLoading"><table class="density-table density-table--editable manual-nodes-editor"><thead><tr><th>节点</th><th>节点时间</th></tr></thead><tbody><tr v-for="node in manualNodeDefinitions" :key="node.code"><th scope="row">{{ node.label }}</th><td><el-date-picker v-model="manualNodeValues[node.code]" :aria-label="`${node.label}时间`" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></td></tr></tbody></table></el-form></section>
<section class="density-dialog-section" aria-label="人工节点登记"><h3 class="density-dialog-section__title">人工节点</h3><el-form class="density-form" :disabled="actionLoading"><table class="density-table density-table--editable manual-nodes-editor"><thead><tr><th>节点</th><th>节点时间</th></tr></thead><tbody><tr v-for="node in visibleManualNodeDefinitions" :key="node.code"><th scope="row">{{ node.label }}</th><td><el-date-picker v-model="manualNodeValues[node.code]" :aria-label="`${node.label}时间`" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></td></tr></tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="reworkOpen" :title="`换标退回重做 · ${reworkForm.relabelNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
<section class="density-dialog-section" data-tone="work" aria-label="换标重做登记"><h3 class="density-dialog-section__title">退回重做</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="reworkSaving"><table class="density-table density-table--editable"><tbody>
@@ -1382,7 +1381,9 @@ onMounted(() => { void load() })
.inbound-plan-arrival { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.inbound-plan-form :deep(.el-form-item__content) { min-width: 0; }
.inbound-plan-form :deep(.el-select) { width: 100%; }
.inbound-plan-pallet-note { flex-basis: 100%; margin-top: var(--admin-space-2); }
.inbound-plan-pallet-field { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-2) var(--admin-space-3); min-width: 0; }
.inbound-plan-pallet-field :deep(.el-input-number) { width: 140px; flex: 0 0 140px; }
.inbound-plan-pallet-note { margin: 0; line-height: 1.5; }
.manual-nodes-editor th { width: 180px; }
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
.last-mile-toolbar { min-width: 0; }
@@ -220,7 +220,7 @@ onMounted(initialize)
</template>
<style scoped>
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }
.admin-workbench.pallet-page.admin-page.is-spacing-compact { gap: 0; }
.pallet-editor-table { table-layout: fixed; }
.pallet-editor-table td:not([colspan]) { width: 50%; }
@@ -68,7 +68,7 @@ describe('original container schedule', () => {
wrapper.unmount()
})
it('missing original ownership blocks initialization and late read cannot populate another container', async () => {
const first = setup({ ...container, currentPortScheduleId: '', oceanBookingId: '' }); await first.vm.begin(); expect(getPortSchedule).not.toHaveBeenCalled(); expect(first.wrapper.text()).toContain('不能维护船期'); first.wrapper.unmount()
const first = setup({ ...container, currentPortScheduleId: '', oceanBookingId: '' }); await first.vm.begin(); expect(getPortSchedule).not.toHaveBeenCalled(); expect(first.wrapper.text()).not.toContain('不能维护船期'); expect(first.wrapper.find('button').attributes('disabled')).toBeDefined(); first.wrapper.unmount()
let resolve!: (value: ReturnType<typeof result>) => void
vi.mocked(getPortSchedule).mockReturnValue(new Promise(done => { resolve = done }))
const { wrapper, vm } = setup(); const pending = vm.begin()
@@ -105,8 +105,8 @@ onBeforeUnmount(() => { generation++ })
<template>
<div class="schedule-actions admin-workbench">
<el-button v-if="canRead" :disabled="!!prerequisite" @click="begin">船期维护与历史</el-button>
<span v-if="prerequisite">{{ prerequisite }}</span>
<span v-else-if="!canRead">当前账号无船期读取权限。</span>
<span v-if="prerequisite && container.oceanBookingId && container.customerId">{{ prerequisite }}</span>
<span v-if="!prerequisite && !canRead">当前账号无船期读取权限。</span>
</div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="dialogTitle" fullscreenable :width="container.currentPortScheduleId ? '1000px' : '1120px'" :show-confirm="canWrite" :confirm-disabled="loading || !!prerequisite || (!!container.currentPortScheduleId && !schedule)" :confirm-loading="busy" confirm-text="保存船期" @confirm="save">
<div v-loading="loading" class="density-dialog-sections">
@@ -38,6 +38,7 @@ function summary(raw?: Partial<SailingCalendarEntry['volumeSummary']> | null) {
overCapacity: Boolean(raw?.overCapacity),
planningCapacityCbm: raw?.planningCapacityCbm == null ? null : number(raw.planningCapacityCbm),
bookedPlanningCapacityCbm: raw?.bookedPlanningCapacityCbm == null ? null : number(raw.bookedPlanningCapacityCbm),
availableBookedCapacityCbm: raw?.availableBookedCapacityCbm == null ? null : number(raw.availableBookedCapacityCbm),
catchableReceivedCbm: raw?.catchableReceivedCbm == null ? null : number(raw.catchableReceivedCbm),
lateReceivedCbm: raw?.lateReceivedCbm == null ? null : number(raw.lateReceivedCbm),
snapshotAt: raw?.snapshotAt ?? null,
@@ -19,13 +19,20 @@ vi.mock('../api/platformSailingCalendar', () => ({
listSailingChannels: vi.fn().mockResolvedValue([]), listSailingTemplates: vi.fn().mockResolvedValue([]),
previewSailingTemplateGeneration: vi.fn(), updateSailingTemplate: vi.fn(), updateSailing: vi.fn(),
}))
const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false }
const volume = { planningCapacityCbm: 60, bookedPlanningCapacityCbm: 0, availableBookedCapacityCbm: 0, catchableReceivedCbm: 12, lateReceivedCbm: 0, snapshotAt: null, actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false }
const calendar: SailingCalendarResponse = {
nextRefreshAt: '2026-10-01T00:00:00',
month: '2026-09', timezone: 'Asia/Shanghai', summary: { ...volume, sailingCount: 1 },
filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] },
records: [{ id: 'sailing-1', sailingNo: 'SAIL-TEST', warehouseId: 'warehouse-1', warehouseName: '测试仓', channelCode: 'CH-TEST', channelName: '测试渠道', vesselName: 'TEST VESSEL', voyageNo: 'TEST-01', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: '2026-09-30T10:00:00', cutoffAt: '2026-09-29T18:00:00', status: 'OPEN', volumeSummary: volume, version: 1 }],
}
let wrapper: VueWrapper | undefined
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: Error) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
return { promise, resolve, reject }
}
async function mountView() {
wrapper = mount(SailingCalendarView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -33,7 +40,7 @@ async function mountView() {
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date(2026, 8, 30, 12))
vi.setSystemTime(new Date('2026-09-30T12:00:00+08:00'))
vi.clearAllMocks()
vi.mocked(api.listSailingTemplates).mockResolvedValue([])
vi.mocked(api.listSailingChannels).mockResolvedValue([])
@@ -43,6 +50,141 @@ beforeEach(() => {
afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers(); vi.restoreAllMocks() })
describe('SailingCalendarView integrated workspace', () => {
it.each(['success', 'failure'] as const)('ignores a stale calendar %s after the next month has loaded', async (outcome) => {
const view = await mountView()
const previous = deferred<SailingCalendarResponse>()
vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise)
const keyword = view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]')
await keyword.trigger('keyup', { key: 'Enter' })
const october = { ...calendar, month: '2026-10', records: [{ ...calendar.records[0], id: 'october', sailingNo: 'SL-OCTOBER', etdAt: '2026-10-01T10:00:00' }] }
vi.mocked(listSailingCalendar).mockResolvedValueOnce(october)
await view.get('button[aria-label="下个月"]').trigger('click')
await flushPromises()
if (outcome === 'success') previous.resolve(calendar)
else previous.reject(new Error('旧月份请求失败'))
await flushPromises()
expect(view.get('.detail-header h2').text()).toBe('2026-10-01')
expect(view.get('.detail-panel').text()).toContain('SL-OCTOBER')
expect(view.find('.load-error-state').exists()).toBe(false)
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
})
it('keeps the latest calendar request loading when an earlier request finishes first', async () => {
const view = await mountView()
const previous = deferred<SailingCalendarResponse>()
const current = deferred<SailingCalendarResponse>()
vi.mocked(listSailingCalendar).mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise)
await view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]').trigger('keyup', { key: 'Enter' })
await view.get('button[aria-label="下个月"]').trigger('click')
previous.resolve(calendar)
await flushPromises()
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('true')
current.resolve({ ...calendar, month: '2026-10', records: [] })
await flushPromises()
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
})
it('keeps the copied sailing selected when its date is later in the current month', async () => {
vi.setSystemTime(new Date('2026-09-20T12:00:00+08:00'))
const original = { ...calendar.records[0], etdAt: '2026-09-21T10:00:00', cutoffAt: '2026-09-20T18:00:00' }
const copied = { ...original, id: 'copied', sailingNo: 'SL-COPIED', etdAt: '2026-09-28T10:00:00', cutoffAt: '2026-09-27T18:00:00' }
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original] })
const view = await mountView()
await view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '21')!.trigger('click')
await view.get('.sailing-card').trigger('click')
vi.mocked(api.copySailing).mockResolvedValueOnce({ sailing: copied, histories: [] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, records: [original, copied] })
await clickAction(view, '复制下一班')
expect(view.get('.detail-header h2').text()).toBe('2026-09-28')
expect(view.get('.sailing-card.active').text()).toContain('SL-COPIED')
expect(view.get('.detail-actions').text()).toContain('订柜建议')
await view.findAll('button').find(button => button.text() === '今天')!.trigger('click')
await flushPromises()
expect(view.get('.detail-header h2').text()).toBe('2026-09-20')
})
it('uses the Shanghai calendar date at the UTC day boundary', async () => {
vi.setSystemTime(new Date('2026-09-29T16:00:00Z'))
const view = await mountView()
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-09' }))
expect(view.get('.detail-header h2').text()).toBe('2026-09-30')
expect(view.get('.calendar-note').text()).toContain('仅今天及以后')
})
it('refreshes at cutoff so the current cargo reference can roll forward', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T12:00:01' }] })
const view = await mountView()
await view.get('.sailing-card').trigger('click')
const emptyVolume = { ...volume, actualReceivedCbm: 0, catchableReceivedCbm: 0, recommendedContainerCount: 0, pendingContainerCount: 0 }
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, summary: { ...emptyVolume, sailingCount: 1 }, records: [{ ...calendar.records[0], volumeSummary: emptyVolume }] })
await vi.advanceTimersByTimeAsync(1002)
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
expect(view.get('.volume-line').text()).toContain('建议 0 柜')
expect(view.find('.detail-actions').exists()).toBe(false)
})
it('refreshes across midnight and month end and removes expired selection', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00'))
const view = await mountView()
await view.get('.sailing-card').trigger('click')
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, month: '2026-10', records: [], summary: { ...calendar.summary, sailingCount: 0 } })
await vi.advanceTimersByTimeAsync(1001)
await flushPromises()
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
expect(view.get('.detail-header h2').text()).toBe('2026-10-01')
expect(view.find('.sailing-card').exists()).toBe(false)
expect(view.find('.detail-actions').exists()).toBe(false)
expect(view.get('.calendar-note').text()).toContain('共 0 个船期')
const calls = vi.mocked(listSailingCalendar).mock.calls.length
view.unmount(); wrapper = undefined
await vi.advanceTimersByTimeAsync(86_400_000)
expect(listSailingCalendar).toHaveBeenCalledTimes(calls)
})
it('refreshes the next month at the server boundary even when the earlier sailing is hidden', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], cutoffAt: '2026-09-30T13:00:00', etdAt: '2026-09-30T18:00:00' }] })
const view = await mountView()
const nextMonth = { ...calendar, month: '2026-10', nextRefreshAt: '2026-09-30T13:00:00.001', records: [{ ...calendar.records[0], id: 'sailing-2', etdAt: '2026-10-02T10:00:00', cutoffAt: '2026-10-01T18:00:00' }] }
vi.mocked(listSailingCalendar).mockResolvedValue(nextMonth)
await view.get('button[aria-label="下个月"]').trigger('click')
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
vi.mocked(listSailingCalendar).mockResolvedValue({ ...nextMonth, nextRefreshAt: '2026-10-01T00:00:00' })
await vi.advanceTimersByTimeAsync(3_600_002)
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(3)
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
})
it('backs off after a refresh failure instead of repeatedly requesting an expired boundary', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' })
await mountView()
vi.mocked(listSailingCalendar).mockRejectedValueOnce(new Error('网络错误'))
await vi.advanceTimersByTimeAsync(1002)
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(59_000)
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1001)
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(3)
})
it('reloads today and clears actions when the selected sailing is no longer returned', async () => {
const view = await mountView()
await view.get('.sailing-card').trigger('click')
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], summary: { ...calendar.summary, sailingCount: 0 } })
await view.findAll('button').find(button => button.text() === '今天')!.trigger('click')
await flushPromises()
expect(listSailingCalendar).toHaveBeenCalledTimes(2)
expect(view.find('.detail-actions').exists()).toBe(false)
})
it('keeps one create entry and defaults the sailing to the selected calendar date', async () => {
const view = await mountView()
expect(view.find('.detail-header button').exists()).toBe(false)
@@ -129,6 +271,44 @@ async function editWeeklyTemplate() {
}
describe('SailingCalendarView dialog workflows', () => {
it.each([
['货量明细', false], ['货量明细', true], ['变更历史', false], ['变更历史', true], ['订柜建议', false], ['订柜建议', true],
] as const)('keeps the latest %s response after reopening; switch sailing: %s', async (label, switchSailing) => {
const nextSailing = { ...calendar.records[0], id: 'sailing-2', sailingNo: 'SAIL-NEXT' }
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [calendar.records[0], nextSailing] })
let finishOld!: () => void
if (label === '货量明细') {
const previous = deferred<Awaited<ReturnType<typeof api.getSailingVolumeDetail>>>()
const result = (code: string): Awaited<ReturnType<typeof api.getSailingVolumeDetail>> => ({ summary: volume, assignments: [{ id: code, inboundCode: code, volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] })
vi.mocked(api.getSailingVolumeDetail).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
finishOld = () => previous.resolve(result('OLD-ONLY'))
} else if (label === '变更历史') {
const previous = deferred<Awaited<ReturnType<typeof api.getSailing>>>()
const result = (reason: string): Awaited<ReturnType<typeof api.getSailing>> => ({ sailing: nextSailing, histories: [{ id: reason, action: 'UPDATE', reason, createdAt: '2026-09-30T12:00:00' }] })
vi.mocked(api.getSailing).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
finishOld = () => previous.resolve(result('OLD-ONLY'))
} else {
const previous = deferred<Awaited<ReturnType<typeof api.getSailingBookingAdvice>>>()
const result = (code: string) => ({ actualReceivedCbm: 12, catchableTotalCbm: 12, planningCapacityCbm: 68, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, scenarios: [{ containerTypeCode: code, containerTypeName: code, capacityCbm: 68, recommendedContainerCount: 1, pendingContainerCount: 1 }], calculatedAt: '2026-09-30T12:00:00', catchableReceivedCbm: 12, lateReceivedCbm: 0 })
vi.mocked(api.getSailingBookingAdvice).mockReturnValueOnce(previous.promise).mockResolvedValueOnce(result('NEW-ONLY'))
finishOld = () => previous.resolve(result('OLD-ONLY'))
}
const view = await mountView()
await view.findAll('.sailing-card')[0].trigger('click')
await clickAction(view, label)
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith(label === '货量明细' ? '货量明细' : label === '变更历史' ? '船期变更历史' : '订柜建议'))!
dialog.vm.$emit('update:modelValue', false)
await flushPromises()
if (switchSailing) await view.findAll('.sailing-card')[1].trigger('click')
await clickAction(view, label)
expect(document.body.textContent).toContain('NEW-ONLY')
finishOld()
await flushPromises()
expect(dialog.props('title')).toContain(switchSailing ? 'SAIL-NEXT' : 'SAIL-TEST')
expect(document.body.textContent).toContain('NEW-ONLY')
expect(document.body.textContent).not.toContain('OLD-ONLY')
})
it('updates the cutoff result for presets and custom days while preserving a manually chosen time', async () => {
const view = await mountView()
await clickAction(view, '新增单个船次')
@@ -194,6 +374,49 @@ describe('SailingCalendarView dialog workflows', () => {
}))
})
it.each(['cutoff', 'midnight'] as const)('preserves the editing target, changes and version across a %s refresh', async (boundary) => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
if (boundary === 'midnight') vi.setSystemTime(new Date('2026-09-30T23:59:59+08:00'))
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: boundary === 'midnight' ? '2026-10-01T00:00:00' : '2026-09-30T12:00:01.001' })
const view = await mountView()
await view.get('.sailing-card').trigger('click')
await clickAction(view, '编辑')
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))!
await dialog.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-09-28T16:30:00')
vi.mocked(listSailingCalendar).mockResolvedValue({ ...calendar, records: [], nextRefreshAt: '2026-10-02T00:00:00' })
await vi.advanceTimersByTimeAsync(1002)
await flushPromises()
expect(view.find('.detail-actions').exists()).toBe(false)
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST')
document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button').forEach(button => {
if (button.textContent?.trim() === '保存') button.click()
})
await flushPromises()
expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({
expectedVersion: 1, cutoffAt: '2026-09-28T16:30:00',
}))
expect(api.createSailing).not.toHaveBeenCalled()
expect(dialog.props('modelValue')).toBe(false)
})
it('keeps the edited form when the server rejects a save after automatic refresh', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.mocked(listSailingCalendar).mockResolvedValueOnce({ ...calendar, nextRefreshAt: '2026-09-30T12:00:01.001' })
const view = await mountView()
await view.get('.sailing-card').trigger('click')
await clickAction(view, '编辑')
const dialog = view.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.props('title').startsWith('编辑船期'))!
await vi.advanceTimersByTimeAsync(1002)
await flushPromises()
vi.mocked(api.updateSailing).mockRejectedValueOnce(new Error('版本冲突'))
dialog.vm.$emit('confirm')
await flushPromises()
expect(api.updateSailing).toHaveBeenCalledExactlyOnceWith('sailing-1', expect.objectContaining({ expectedVersion: 1 }))
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.props('title')).toBe('编辑船期 · SAIL-TEST')
})
it('loads volume, history and booking advice as read-only data for the business-number title', async () => {
vi.mocked(api.getSailingVolumeDetail).mockResolvedValue({ summary: volume, assignments: [{ id: 'internal-assignment', inboundCode: 'RK-TEST-01', volumeCbm: 12, status: 'RECEIVED', assignmentSource: 'MANUAL' }] })
vi.mocked(api.getSailing).mockResolvedValue({ sailing: calendar.records[0], histories: [{ id: 'history-internal', action: 'UPDATE', reason: '调整截止时间', createdAt: '2026-09-29T10:00:00' }] })
File diff suppressed because one or more lines are too long
+2
View File
@@ -13,6 +13,7 @@ export interface SailingVolumeSummary {
overCapacity: boolean
planningCapacityCbm?: number | null
bookedPlanningCapacityCbm?: number | null
availableBookedCapacityCbm?: number | null
catchableReceivedCbm?: number | null
lateReceivedCbm?: number | null
snapshotAt?: string | null
@@ -63,6 +64,7 @@ export interface SailingCalendarSummary extends SailingVolumeSummary {
export interface SailingCalendarResponse {
month: string
timezone: string
nextRefreshAt: string
records: SailingCalendarEntry[]
summary: SailingCalendarSummary
filterOptions: SailingCalendarFilterOptions
@@ -3161,7 +3161,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-query-panel { min-width: 0; }
.orders-query-toolbar { border: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); }
.unified-orders-page .orders-status-toolbar :deep(.density-status-tabs > button) { min-height: 32px; padding-right: 4px; padding-left: 4px; }
.unified-orders-page .orders-status-toolbar :deep(.density-status-tabs > button) { min-height: var(--admin-control-height); padding-right: 4px; padding-left: 4px; }
.unified-orders-page .detail-tabs > button { height: 46px; min-height: 46px; padding-right: 20px; padding-left: 20px; }
.table-card { overflow: hidden; border-radius: var(--admin-radius, 4px); box-shadow: none; }
.coload-error { margin: var(--admin-space-3, 8px) var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border: 1px solid var(--el-color-danger-light-5); border-radius: var(--admin-radius, 4px); background: var(--el-color-danger-light-9); color: var(--el-color-danger); font-size: var(--admin-font-caption, 12px); }
@@ -488,7 +488,7 @@ onMounted(() => {
<style scoped>
.pallet-page { display: grid; min-width: 0; }
.pallet-page.admin-page.is-spacing-compact { gap: var(--admin-space-3); }
.admin-workbench.pallet-page.admin-page.is-spacing-compact { gap: 0; }
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.table-panel { min-width: 0; }
.context-empty { padding: var(--admin-space-6, 16px); color: var(--workbench-muted); text-align: center; }
+2
View File
@@ -453,6 +453,8 @@ function setPage(value: number) {
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button + .el-button),
.data-table > .data-table__toolbar > .data-table__toolbar-actions > :is(.data-table__business-actions, .data-table__utility-actions) > :deep(:where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > :where(div, span):not(:where(.el-overlay, .el-dialog, .el-drawer, .el-popper, .data-table)) > .el-button + .el-button) { margin-left: 0; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--cc-list-pagination-font-size); }
/* Element Plus already supplies the table bottom border. */
.data-table > .el-table + .data-table__pagination { border-top: 0; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
+1 -1
View File
@@ -20,7 +20,7 @@ withDefaults(defineProps<{
</template>
<style scoped>
.form-section { min-width: 0; padding: 20px 0; }
.form-section { min-width: 0; padding: 20px 0; border-bottom: 0; }
.form-section.is-bordered { border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.form-section:first-child { padding-top: 0; }
.form-section:last-child { padding-bottom: 0; border-bottom: 0; }
+113 -5
View File
@@ -221,6 +221,110 @@ const cssRoot = (css: string) => {
}
describe('approved Admin density ownership', () => {
it('draws only one separator between an Element table and pagination', () => {
const style = parse(read('shared/components/DataTable.vue')).descriptor.styles[0]!
const compiled = compileStyle({ source: style.content, filename: 'DataTable.vue', id: 'data-v-border-test', scoped: true })
expect(compiled.errors).toHaveLength(0)
const selectors: string[] = []
compiled.rawResult!.root.walkRules(rule => {
if (rule.selector.includes('.el-table + .data-table__pagination')) {
selectors.push(rule.selector)
expect(rule.nodes.some(node => node.type === 'decl' && node.prop === 'border-top' && node.value === '0')).toBe(true)
}
})
expect(selectors).toHaveLength(1)
const host = document.createElement('div')
host.innerHTML = `<div class="data-table"><div class="el-table"><div class="el-table__inner-wrapper"></div></div><footer id="standard" class="data-table__pagination" data-v-border-test></footer></div>
<div class="data-table"><section class="custom-results"></section><footer id="custom" class="data-table__pagination" data-v-border-test></footer></div>`
expect(host.querySelector('#standard')!.matches(selectors[0]!)).toBe(true)
expect(host.querySelector('#custom')!.matches(selectors[0]!)).toBe(false)
expect(style.content).not.toMatch(/inner-wrapper::before\s*\{[^}]*display:\s*none/)
})
it('uses the shared compact metric cards for finance statistics and settlement totals', () => {
expect(read('modules/finance/pages/FinanceReceivableStatisticsView.vue')).toContain('spacing="compact"')
expect(read('modules/finance/pages/FinanceFormalSettlementPage.vue')).toContain(':spacing="detailMode ? \'normal\' : \'compact\'"')
const financeStyles = read('modules/finance/financeDensity.css')
expect(financeStyles).toContain('.admin-workbench.receivable-statistics-page.admin-page.is-spacing-compact,')
expect(financeStyles).toContain('.admin-workbench.finance-settlement-page.admin-page.is-spacing-compact { gap: 0; }')
expect(financeStyles).not.toContain('.data-table__toolbar')
const baseStyle = parse(read('shared/components/AdminPage.vue')).descriptor.styles[0]!
const compiled = compileStyle({ source: baseStyle.content, filename: 'AdminPage.vue', id: 'data-v-spacing-check', scoped: true })
expect(compiled.errors).toHaveLength(0)
const baseSelector = compiled.code.match(/([^{}]+)\{\s*gap: 12px;/)![1]!.trim()
const specificity = (selector: string) => (selector.match(/\.[\w-]+|\[[^\]]+\]/g) ?? []).length
for (const page of ['receivable-statistics-page', 'finance-settlement-page']) {
const selector = `.admin-workbench.${page}.admin-page.is-spacing-compact`
expect(specificity(selector), 'must override scoped AdminPage even when it loads later').toBeGreaterThan(specificity(baseSelector))
}
for (const file of ['FinanceReceivableStatisticsView', 'FinanceFormalSettlementPage']) {
const content = read(`modules/finance/pages/${file}.vue`)
const summary = content.match(/<section class="finance-metric-grid"[^>]*v-loading="loading"[\s\S]*?<\/section>/)![0]
expect(content.indexOf(summary), file).toBeLessThan(content.indexOf('<template #filters>'))
const template = parse(content).descriptor.template!.ast!
function visit(node: { tag?: string; loc?: { source: string }; children?: unknown[] }, insideTable = false) {
if (node.tag === 'section' && node.loc?.source === summary) expect(insideTable, file).toBe(false)
for (const child of node.children ?? []) visit(child as typeof node, insideTable || node.tag === 'DataTable')
}
visit(template)
expect(summary.match(/workbench-metric workbench-tone is-compact/g), file).toHaveLength(3)
expect(summary).toContain('v-loading="loading" :aria-busy="loading"')
expect(summary).not.toContain('density-detail-overview')
for (const tone of ['blue', 'green', 'purple']) expect(summary).toContain(`data-tone="${tone}"`)
}
})
it('keeps finance detail tables on one continuous surface', () => {
const statements = read('modules/finance/pages/FinanceCustomerStatementsView.vue')
const arap = read('modules/finance/pages/FinanceCustomerArapView.vue')
expect(statements).not.toContain('<el-tabs')
expect(statements).toContain('title="账单明细表"')
expect(statements).toContain('title="账单支付历史表"')
expect(statements.match(/class="detail-table-scroll workbench-table is-page-surface"/g)).toHaveLength(2)
expect(statements).not.toContain('title="账单明细"')
expect(statements).not.toContain('title="支付历史"')
expect(arap).toContain('<div class="workbench-table is-page-surface"><DataTable :data="operationLogRows"')
const css = read('modules/finance/financeDensity.css')
expect(css).toContain('.finance-density-detail > .detail-page-layout__content .density-form-section')
expect(css).toContain('padding-block: 0;')
const accounts = read('modules/finance/pages/FinanceCustomerAccountsView.vue')
expect(accounts).not.toContain(':deep(.detail-page-layout__content)')
})
it('keeps finance detail insets on the outer page without stripping card or cell padding', () => {
for (const [file, className] of [['FinanceCustomerAccountsView', 'finance-account-detail'], ['FinanceCustomerArapView', 'finance-arap-detail']]) {
expect(read(`modules/finance/pages/${file}.vue`)).toContain(`class="${className} admin-density admin-workbench is-embedded"`)
}
const css = read('modules/finance/financeDensity.css')
const rule = css.slice(css.indexOf('/* The outer AdminPage owns'))
expect(rule).toContain('@media screen')
expect(rule).toContain('padding-inline: 0;')
expect(rule).toContain('.detail-page-layout__content > .settlement-detail-content')
expect(rule).toContain(':is(.density-detail-overview, .statement-summary-section)')
for (const protectedClass of ['workbench-metric', 'el-descriptions__cell', 'query-toolbar', 'el-table']) expect(rule).not.toContain(protectedClass)
const accountStyles = parse(read('modules/finance/pages/FinanceCustomerAccountsView.vue')).descriptor.styles[0]!.content
expect(accountStyles.match(/\.account-metrics \{([^}]+)\}/)![1]).not.toMatch(/padding:|padding-inline:|padding-left:|padding-right:/)
})
it('keeps account detail filters in the shared table layout and long identifiers readable', () => {
const content = read('modules/finance/pages/FinanceCustomerAccountsView.vue')
const header = content.split('<template #header>')[1]!.split('</template>')[0]!
expect(header).toContain('@click="exportLedger"')
expect(header).toContain('v-if="canExport"')
expect(header).toContain(':loading="ledgerExportBusy"')
const ledgerSection = content.split('class="ledger-section density-form-section"')[1]!.split('</FormSection>')[0]!
expect(ledgerSection).not.toContain('#toolbar-actions')
expect(ledgerSection).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(ledgerSection).toContain('class="ledger-table" appearance="integrated"')
const rechargeSection = content.split('class="recharge-section density-form-section"')[1]!.split('</FormSection>')[0]!
expect(rechargeSection).toContain('class="detail-table-scroll workbench-table is-page-surface"')
expect(content.match(/account-metric workbench-metric workbench-tone is-compact/g)).toHaveLength(6)
expect(content).toContain('creditExpiryLabel(selected)')
expect(content).toContain("'is-negative': Number(selected.actualBalance) < 0")
expect(content).toContain('<template #filters><QueryToolbar v-model="ledgerFilters"')
expect(content).not.toContain(':deep(.ledger-query.query-toolbar)')
expect(content).toContain('class="density-descriptions account-profile"')
const styles = parse(content).descriptor.styles.map(style => style.content).join('\n')
expect(styles).not.toContain('text-overflow: ellipsis')
expect(styles).toContain('overflow-wrap: anywhere')
})
it('keeps pallet overview spacing and metric appearance owned by shared primitives', () => {
for (const file of ['warehouse-resources/pallets/pages/PalletsView.vue', 'overseas-warehouse/pages/PalletsView.vue']) {
const page = parse(read(`modules/${file}`)).descriptor
@@ -850,7 +954,8 @@ describe('approved Admin density ownership', () => {
}
expect(truckDetail).toContain('density-detail-layout')
expect(truckDetail).toContain('density-descriptions')
expect(domestic).toContain('density-detail-overview')
expect(domestic).toContain('<DetailPageLayout')
expect(domestic).toContain('order-info-grid density-fields')
expect(read('modules/overseas-warehouse/pages/LastMileOrderDetailView.vue')).toContain('density-detail-overview')
})
@@ -1191,7 +1296,9 @@ describe('approved Admin density ownership', () => {
rule.walkDecls(decl => {
expect(decl.prop, file).not.toMatch(/^--el-button-/)
if (desktop && decl.prop === 'font-size') expect(decl.value, `${file}: ${rule.selector}`).not.toMatch(/^(10|11|12|13|14|16)px$/)
if (desktop && /^(height|min-height)$/.test(decl.prop)) expect(decl.value, `${file}: ${rule.selector}`).not.toMatch(/^(28|32|36|38|64)px$/)
// Decorative artwork is not a form control or data row.
const decorativeBrand = file === 'ocean-bookings/pages/OceanBookingDetailView.vue' && rule.selector === '.booking-brand'
if (desktop && !decorativeBrand && /^(height|min-height)$/.test(decl.prop)) expect(decl.value, `${file}: ${rule.selector}`).not.toMatch(/^(28|32|36|38|64)px$/)
})
})
})
@@ -1448,7 +1555,8 @@ describe('approved Admin density ownership', () => {
expect(source).toContain('label-position="left" label-width="120px" :disabled="createLocked"')
expect(source).toContain('density-table density-table--editable outbound-editor-table')
expect(source).toContain('data-tone="source"')
expect(source).toContain('data-tone="work"')
expect(source).toContain('class="density-dialog-section" aria-label="出库模式"')
expect(source).toContain('class="density-dialog-section" aria-label="本次出库备注"')
expect(source).toContain(':confirm-loading="saving || inventoryLoading"')
expect(source).toContain('aria-label="全选本页订单"')
expect(source).toContain('class="return-list-card density-dialog-section"')
@@ -1590,9 +1698,9 @@ describe('approved Admin density ownership', () => {
const fields = read('modules/overseas-warehouse/components/FbaAppointmentFields.vue')
expect(list).toContain('<FbaAppointmentFields continuous')
expect(list.match(/fullscreenable/g)).toHaveLength(2)
expect(list).toContain('data-tone="summary"')
expect(list).toContain('class="density-dialog-section" aria-label="预约范围核对"')
expect(list).toContain('data-tone="source"')
expect(list).toContain('data-tone="work"')
expect(list).toContain('class="density-dialog-section" aria-label="目的仓登记"')
expect(list).not.toContain('v-model="planEditForm.version"')
expect(fields).toContain('continuous?: boolean')
expect(fields).toContain('v-if="continuous"')