diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue index 4afed29c..afa7446e 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue @@ -188,7 +188,7 @@ onMounted(load)
-
+
diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index 6970882d..a7653528 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -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 }).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 }).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() + }) +}) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 989caa49..a375411f 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -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(null) const clearanceFile = ref(null) +const clearanceUploadKey = ref(0) const clearanceLoading = ref(false) const clearanceSaving = ref(false) const clearanceError = ref(null) @@ -282,10 +280,8 @@ const exceptionReason = ref('') const exceptionSaving = ref(false) const exceptionError = ref(null) const outboundLoading = ref(false) -const outboundConfirming = ref(false) const outboundDetail = ref(null) const outboundError = ref(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(() => { - const grouped = new Map() - 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)[status || ''] || status || '—' -} -function truckReferenceText(status?: string | null) { - return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record)[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 () => {

船名航次信息

船公司
{{ detail.shippingCompanyName || '—' }}
船名 / 航次
{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}
预计开船
{{ formatTime(detail.estimatedSailingAt) }}
预计到港
{{ formatTime(detail.estimatedPortArrivalAt) }}
-
+
@@ -710,26 +660,10 @@ onMounted(async () => {

装柜作业与国内出仓

-
-

国内出仓门禁

按整柜核验全部入库绑定,仅确认一次离仓。

{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}确认离仓
- -
- -
-

提柜、卸柜与还空

提柜安排与执行直送卸货清单确认还柜
+

提柜、卸柜与还空

提柜安排与执行确认还柜
码头拖车状态
{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}
码头车队
{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
预计到仓
{{ expectedArrivalText }}
实际到仓
{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}
卸柜完成
{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}
实际还柜
{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}
@@ -753,9 +687,9 @@ onMounted(async () => { -
选择文件
+
选择文件
- + diff --git a/src/modules/finance/financeDensity.css b/src/modules/finance/financeDensity.css index 3e7b4049..1b606a86 100644 --- a/src/modules/finance/financeDensity.css +++ b/src/modules/finance/financeDensity.css @@ -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; +} diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts index daa356e5..123aa5d4 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts @@ -65,7 +65,7 @@ describe('FinanceCustomerAccountsView delivery', () => { expect(page).toContain('v-else-if="!detailOpen"') expect(page).toContain('返回客户账户') expect(page).toContain(' { 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) diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue index 6033a7ee..5216714b 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue @@ -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)
-
{{ customerName(form.customerId) || editing.customerName || '—' }}